every Tech Blog

株式会社エブリーのTech Blogです。

`sealed` Class を使った実践 Flutter アプリのリファクタリング

エブリーで小売業界向き合いの開発を行っている @kosukeohmura です。私は普段はバックエンドの開発を行っていますが、数ヶ月ほど前から Flutter アプリの開発にも従事しております。エブリーではここ数年いくつかの Flutter アプリを開発/運用しています。

今回は Flutter アプリに機能追加する中で、sealed Class を使ってリファクタリングを経て機能追加してみたので、その例を紹介します。


sealed Class とは

Dart における sealed Class は そのクラス自体のインスタンスを生成できない点で abstract Class と似ています。abstract Class と異なる点として、Exhaustiveness checking (意訳: 網羅性チェック) により、switch 文やパターンマッチングでサブクラスを網羅的に扱えることがあります。これにより、例えば switch 文でケースの書き漏れが存在するとコンパイルエラーとなり気づくことができます。

enum を使うことでも網羅性チェックを行えますが、列挙子それぞれにはフィールドやメソッドを持たせることはできません。一方 abstract Class ではフィールドやメソッドを持つことができますが、網羅性チェックは行えません。sealed Class にはすべてのサブタイプは同じファイルに定義するという制約がありますが、それを受け入れることで網羅性チェックが可能となり、結果的に abstract Class と網羅性チェックそれぞれの恩恵を受けられると理解しています。


ディープリンクの制御を sealed Class を利用してリファクタリングしてみる

アプリのディープリンク経由での起動のハンドリングを実装する際、URL パスごとに処理や遷移先画面が異なり、またそれぞれ特定のパスパラメータやクエリパラメータを期待するかと思います。sealed Class の特徴を利用することで、URL パスごとに異なるパラメータをコード上に表現しながら、安全かつ見通しよくロジックを記述できると思い、実際にリファクタリングを行いました。

リファクタリング前のコード

以下はディープリンクに応じ、いくつかの条件を満たす場合に画面遷移を行うコードです。

// app.dart

// リンクを受信した場合に呼ばれ、それを処理する関数
void openDeepLink(Uri uri) {
  final hostName = uri.host;
  if (hostName.isEmpty) return;
  switch (hostName) {
    case 'news':
      if (uri.pathSegments.length == 1) {
        final newsId = int.parse(uri.pathSegments[0]);
        // newsId を使って特定のお知らせの画面へ遷移
      }
      break;
    case 'carts':
      // カート画面へ遷移
      break;
    default:
      // 未知のリンク
      break;
  }
}

このコードには以下の問題があります:

  • 責務が混在している
    URL に応じたバリデーション、値の取り出し、画面遷移の処理がすべて app.dart というアプリ全体の設定を書くファイル内の openDeepLink 関数に含まれています。単体テストは困難で、色々なパターンの URL を実際に踏むなどし動作確認をするしかなく、非効率です。

  • URL 構造の表現の欠落
    それぞれの URL パスがどういう意味のパラメータの存在を期待しているか(いないか)が、コード上に明確に現れていません。'news' のケースでは myapp://news/:id といった形式を期待しますが、uri.pathSegments.length といったプリミティブな要素を扱うため、仕様と処理の一致が読み取りにくくなっています。

これらの課題を解決するため、リファクタリングをしてみます。


リファクタリングの内容

DeepLink という sealed Class を定義し、URL が特定の条件を満たしているかを確認し、値を取り出せるようにする責務を対象の関数から分離しました。結果下記のようになりました:

// deep_link.dart

sealed class DeepLink {
  final Uri uri;

  DeepLink._(this.uri);

  factory DeepLink.fromUri(Uri uri) {
    switch (uri.host) {
      case 'carts':
        return CartDetailDeepLink.fromUri(uri);
      case 'news':
        return NewsDetailDeepLink.fromUri(uri);
    }
    throw FormatException('Unknown URI pattern. URI: $uri');
  }
}

class NewsDetailDeepLink extends DeepLink {
  final int newsId;

  NewsDetailDeepLink(Uri uri, this.newsId) : super._(uri);

  factory NewsDetailDeepLink.fromUri(Uri uri) {
    if (uri.pathSegments.length != 1) {
      throw FormatException('Invalid NewsDetailDeepLink URI: $uri');
    }
    return NewsDetailDeepLink(uri, int.parse(uri.pathSegments[0]));
  }
}

class CartDetailDeepLink extends DeepLink {
  CartDetailDeepLink(Uri uri) : super._(uri);

  factory CartDetailDeepLink.fromUri(Uri uri) {
    if (uri.pathSegments.isNotEmpty) {
      throw FormatException('Invalid CartDetailDeepLink URI: $uri');
    }
    return CartDetailDeepLink(uri);
  }
}

使用例

リファクタリング元の関数は以下のように書き換えました。

// app.dart

void openDeepLink(Uri uri) {
  final DeepLink deepLink;
  try {
    deepLink = DeepLink.fromUri(uri);
  } catch (e) {
    // エラーハンドリング
  }
  switch (deepLink) {
    case NewsDetailDeepLink(:final newsId):
      // `:final newsId` の記述でオブジェクトのプロパティをローカル変数として利用
      // newsId を使って特定のお知らせの画面へ遷移
      break;
    case CartDetailDeepLink:
      // カート画面へ遷移
      break;
  }
}

責務を分離することにより得られた恩恵

  • URL 構造のコードでの表現
    どの URL パスがどういう意味のパラメータの存在を期待しているか(いないか)、をコード上に明確に表現できるようになりました。

  • 見通しの良さ、テスト可能性の向上
    URL の解析と画面遷移のロジックを分離することで、コードの見通しが良くなりました。URL の解析ロジックを独立させたことで、以下のように単体テストを簡単に記述できます。なおテスト対象が単純になると、テストコードの記述を AI 任せにするのも楽になります。

group('DeepLink.fromUri', () {
  test('should return NewsDetailDeepLink for valid news URI', () {
    final uri = Uri.parse('example://news/123');
    final deepLink = DeepLink.fromUri(uri);

    expect(deepLink, isA<NewsDetailDeepLink>());
    final newsDeepLink = deepLink as NewsDetailDeepLink;
    expect(newsDeepLink.newsId, 123);
  });
});

sealed Class により得られる恩恵

今回責務の分離に加え、sealed Class の使用による恩恵も受けられるようになりました。

  • 網羅性チェック
    switch 文で default ケースを記述する必要がなくなりました。かつ、case の漏れが生じた場合にはコンパイル時にエラーとなるため、漏れを防げます。

  • コードの一貫性
    サブクラスを同じファイル内に記述する制約が生まれることで、関連するコードが一箇所にまとまり、メンテナンス性が向上します。


終わりに

sealed Class を使って、実際にコードをリファクタリングした例をご紹介しました。
まだまだ未熟な Flutter エンジニアですが、Flutter / Dart の開発業務の中で知見を深めまたご紹介できればと思います。お読みいただきありがとうございました。