はじめに

WINTICKET アプリチームの横井(@_yoko_com)です。

2023 年 1 月に、約 2 年間かけて遂行された WINTICKET の Swift で開発された iOS アプリを Flutter へリプレースするプロジェクトが無事に完了しました。その裏側で無事故を達成したローカルデータのマイグレーションのお話を今回執筆させて頂きます。

マイグレーションを解説するためには、元々実装されているマイグレーション機構についての前提情報が必要となるので以下の二章構成とします。

  • 【第 1 章】 WINTICKET の Flutter リプレースにおけるマイグレーション
  • 【第 2 章】 可用性と整合性を考慮したマイグレーション機構の設計

また、リプレースをした背景や戦略・プロジェクト体制などは当記事では言及しないため、そちらに関しての詳細は以下の記事をご覧ください。

【第 1 章】 WINTICKET の Flutter リプレースにおけるマイグレーション

通常、マイグレーションとは使用している DB の刷新などにより、旧 DB のデータを新 DB でも今まで通り正常に扱うためにデータを移行することなどをイメージされます。アプリをリプレースする際にも、このマイグレーションはとても重要になってきます。

今回 WINTICKET アプリは、 Swift で実装された iOS アプリを Flutter で実装し直しました。 そのため、ここでのマイグレーションとは、Swift で扱っていた端末に保存しているローカルデータを、Flutter でも扱えるようにデータを移行することを指しています。

そして、この内部的なシステムの刷新はユーザーにとっては一切無関係のお話です。 なので、ユーザー視点でリプレースによるアプリの更新を考えると、「いつも通りアプリをただ更新した」という事実で済まさなければなりません。

そのためには適切なマイグレーションによって、シームレスにアプリを更新させ、ユーザー体験を損なわず暗黙的にリプレースを完遂する必要があります。

WINTICKET でのマイグレーション要件

前提として、Swift から Flutter へのアプリ更新の際に、初回起動のタイミングのみでマイグレーションを実行します。

マイグレーションの対象

iOS アプリには、アプリに保存する UserDefaults と、iCloud に紐付く KeyChain という二種類のデータストレージがあります。

WINTICKET アプリではこの 2 つを使用しているためこれらをマイグレーションの対象とします。

それぞれの役割・機能は以下の通りです。

UserDefaults KeyChain
主な用途 簡易的なユーザー設定やキャッシュなどの保持 暗号化されるためセキュアな情報の保持
データの生存期間 アプリをアンインストールするまで iCloud に紐づくため、アプリをアンインストールしても消えない
具体例 マイページにてユーザー設定の各フラグ ON/OFF の状態。ショッピング系アプリにてカートに入れた商品データなど ユーザー ID、パスワード、アクセストークン、クレジットカード情報など

ユーザー体験を損なってしまう具体例

どちらもマイグレーションに失敗すると、以下のような問題点が発生します。

  • UserDefaults
    • アプリを更新したら、マイページで設定していたオプションがリセットされていた
    • アプリを更新したら、カートに入れていた未注文の商品データが消えていた
  • KeyChain
    • アプリを更新したら、再ログインを求められた
    • アプリを更新したら、クレジットカード情報の入力が消えていた

どちらもユーザー体験を損なってしまいますが、特に後者の KeyChain はユーザーの機密情報が保存されており影響度が大きいと捉えられます。 今まで普通に使えていたのにも関わらず、アプリを更新したら急にログインを求められてしまうと、面倒な体験と認知されてサービスの離反に繋がる可能性が大きいからです。

考慮すべき要件

今回以下の要件が求められていました。

  • リプレースの前に公募ユーザーのみでベータテストを行う
  • リプレースの際に段階リリースをする
  • Flutter を Swift にいつでも戻すことが出来る
    • リプレース中に致命的な問題があった場合にロールバックを可能にするため
    • また、ロールバック以後も同様に再度 Swift から Flutter へ更新することが出来る

つまり、Swift と Flutter を相互に何度も行き来ができ、その際にどちらのアプリでも書き込んだ値を相互で引き継げる設計にしている必要があります。

ロールバックを考慮したリプレースのパターン

図のように、ロールバックする際にはリプレース時と同じくアプリのメジャーバージョンを 1 つ繰り上げて更新させます。この図では既存の iOS アプリを v2 としています。

まとめると、「Swift から Flutter への更新」はリプレース、「Flutter から Swift への更新」はロールバックとなります。

Swift から Flutter への更新

ここからは実装の具体についてです。

ここで実現したいことは、「Swift で書き込んだデータを Flutter でも読み込めること」です。

具体的には以下の 2 つを行えば、Flutter で読み込むことが可能となります。

  • Flutter で扱う key 名に一致させる
  • Flutter で扱える型に加工する

UserDefaults と KeyChain で実装や注意点が異なるため分けて説明していきます。

UserDefaults

Flutter では、shared_preferencesというプラグインを介することで扱えます。

注意点

この shared_preferences の内部実装を見てみると、key の prefix にflutter.が付与されていることが分かります。

この prefix は key の get と save 両方のタイミングで付与されます。

https://github.com/flutter/packages/blob/main/packages/shared_preferences/shared_preferences/lib/shared_preferences.dart#L17

static const String _prefix = 'flutter.';

これにより、Swift で書き込んでいた key を shared_preferences で同じ命名の key を参照しようとしても、異なる命名となるため失敗します。

  • Swift
    • isFirstOpenというアプリ初回起動を表す bool 値を保存した
  • Flutter
    • shared_preferences を使用して、isFirstOpenを参照しようとしても、内部実装によりflutter.isFirstOpenの命名で参照が実行されて失敗する

この内部実装に関しては、Flutter 公式の issue で言及されている通り、今後撤廃される予定があるかは未定です。

参考: https://github.com/flutter/flutter/issues/52544

対策

前述した issue の中でコメントがある通り、native_shared_preferencesというプラグインを用いることで、prefixを無視して参照可能です。 また、このプラグインはネイティブアプリのデータを引き継ぎするタイミングのみでの使用を推奨されています。それ以外は Flutter 公式提供の shared_preferences
を使用するのが好ましいです。

実装

実際の実装を説明していきます。

また、WINTICKET の Swift アプリはプリミティブ型以外のモデルを、UTF-8 のバイトコードとして保存しているのでその前提で記載します。

プリミティブ型

Swift で保存したデータがプリミティブ型であれば簡単です。

プリミティブ型とは、ざっくりですが Int, String, Boolなどの基礎の型のことです。

例として、先ほどのisFirstOpenという bool 値を対象にします。

final isFirstOpenAtSwift = nativeSharedPreferences.get('isFirstOpen');
await sharedPreferences.setBool('isFirstOpen', isFirstOpenAtSwift);

nativeSharedPreferences を介して Swift で書き込んだisFirstOpenを読み込み、Flutter で今後も読み込めるように sharedPreferences を介して書き込みをしています。 これだけで Flutter でも読み込めるようになります。

プリミティブ型以外

例として、Userという name と age のフィールドを保持する構造体を対象にします。

struct User: Codable {
  name: String
  age: Int
}

この場合、nativeSheredPreferences.get()で取得した中身を確認すると、以下のような UTF-8 のバイトコードの形となっています。

[91, 121, 34, 118, 90, 108, 88, 101, 34, 58, 34, 50, 54, 55, 53, 50, 48, 50, 50, 48, 50, 49, 48, 34, 125, 44, 91, 123, 34, 112, 97, 116, 116, 101, 114, 110, 34, 58, 48, 44, 34, 116, 105, 99, 131, 101, 125, 7]

なので、dart:convertutf8.decodeをしてあげることで JSON の文字列として識別可能です。

final userAtSwift = nativeSharedPreferences.get('user');
if (userAtSwift is List<int>) {
  final userJson = utf8.decode(userAtSwift as List<int>)
}

この時の、userJsonの中身は以下のような JSON です。

{"name": "yokoi", "age": 25}

あとはプリミティブ型の時と同様に、sharedPreferences にて書き込みを行う流れです。

KeyChain

Flutter では、flutter_secure_storageというプラグインを介することで扱えます。

引き継ぎ方法

昨年自分が執筆した zenn の記事ですが、内容としてはこちらと全く同じです。

Flutter にて、既存ネイティブ iOS からのセキュアデータ引き継ぎ方法

記事に記載されている通り、accountNameに開発している iOS アプリの Bundle Identifier を指定することで、Swift で書き込んだ KeyChain を Flutter でもそのまま読み込み可能となります。

const secureStorage = FlutterSecureStorage(
  iOptions: IOSOptions(
    accountName: 'アプリのBundleIdentifier',
  ),
);
実装

KeyChain に関して、実装としてのマイグレーション対応は本来必要ありません。 前述した引き継ぎ方法のみで特に key 名を修正することなく、そのまま Flutter 側で参照可能となるからです。

ただ、WINTICKET では要件の都合上、 KeyChain の各キーの命名にflutter.を付与するマイグレーションを実装しています。ここに関しては、「Flutter から Swift への更新」の項目で詳しく記述します。

Flutter から Swift への更新

ここで実現したいことは、「Flutter で書き込んだデータを Swift でも読み込めること」です。

実現するための案としては、以下の 2 つが考えられます。

  • 「Swift から Flutter への更新」の際と同じく、更新先の framework(ここでは Swift 側)でマイグレーションを実装する
  • Flutter 側でデータを書き込む際には、Flutter 用と Swift 用として冗長書き込みを行う
    • こうすることで、Swift へ更新した際も Flutter 側で書き込んだ Swift 用のデータがそのまま Swift 側で読み込み可能となる

モチベーションとして、Flutter から Swift への更新はあくまでリプレースに問題があった際にロールバックの選択肢を用意するためです。

なので、基本的には発生しないフローであるため、Swift 側でも同じようにマイグレーションを実装するような工数が大きい対応は避け、後者の案を採用しました。

冗長書き込みの実態

UserDefaults と KeyChain に関して、Swift と Flutter で別物として管理します。

以下の表のように、Flutter のデータの key にはそれぞれ prefix としてflutter.をつけるルールを設けました。 UserDefaults に関しては、前述した通り shared_preferences のデフォルト設定でそのまま要件が満たされます。

UserDefaults KeyChain
Swift XXX YYY
Flutter flutter.XXX flutter.YYY

このように冗長構成を組むことで Flutter から Swift へ更新しても、Flutter 側で書き込んだデータが Swift 側でも反映されます。

また、Swift で使用するデータへの冗長書き込みの実装はリプレースが落ち着き、アプリが安定したタイミングで削除する想定です。

まとめ

「考慮すべき要件」の項目で以下を示していました。

Swift と Flutter を相互に何度も行き来ができ、その際にどちらのアプリでも書き込んだ値を相互で引き継げる設計にしている必要があります。

それに対して、

  • Swift から Flutter への更新時は
    • UserDefaults と KeyChain のマイグレーション
  • Flutter から Swift  への更新時は
    • 何もしない
    • Flutter 側で UserDefaults と KeyChain を 冗長書き込み

により Swift と Flutter で相互にデータ引き継ぎを実現可能にしました。

ロールバックを考慮したリプレースの具体実装

【第 2 章】 可用性と整合性を考慮したマイグレーション機構の設計

マイグレーションは第 1 章で紹介したリプレース以外にも、アプリを運用していく中で必要になるタイミングがあります。 例として、何かの都合でデータの型・命名・構造を変更しなければならない時がそのタイミングとして挙げられます。

その際に、可用性を考慮したマイグレーションを実行しないと、「アプリが永遠に起動出来ない」「あるデータを読み込みする際に必ず落ちてしまう」などのユーザー体験に不利益が生じます。 厄介な点は、「アプリが永遠に起動出来ない」問題はアプリを運用する上で最も致命的な問題であることです。

また、可用性だけではなく整合性も考慮しなければなりません。マイグレーションは 1 つだけではなく複数個ある前提です。 例えば、「このアプリのバージョンではこのマイグレーションを実行させる必要がある」というものが複数あることです。 この場合にどのアプリのバージョンからでも、ユーザーが更新してマイグレーションを実行した際に必ず成功させたいはずです。 もし失敗すると、異常なマイグレーションによりローカルデータの不整合が生じます。その結果、「異常なローカルデータが反映されている」などの問題が発生します。

この章では、具体的に可用性と整合性をどう考慮したのか、そのためにどう機構を設計したのかについて解説します。

全体像

まず初めに全体像を見てもらうとイメージが湧きやすいです。

マイグレーション機構の全体像

PerVersionMigratorというマイグレーション全体をハンドリングするコンポーネントがあります。 その中で、マイグレーション単位である
MigrationVersionItemを順次実行していきます。 エラーハンドリング(try-catch)はそれぞれのコンポーネントごとに構えています。

各コンポーネントの実装

MigrationVersionItem

マイグレーションを行いたいアプリのバージョン(version)と処理内容(asyncMigration)を含めたMigrationVersionItemという Class を定義しています。

typedef AsyncMigration = Future<void> Function(MigrationStorage storage);

class MigrationVersionItem {
  MigrationVersionItem({
    required this.version,
    required this.asyncMigration,
  });

  final String version;

  final AsyncMigration asyncMigration;
}

class MigrationStorage {
  MigrationStorage(
    this.sharedPreferences,
    this.secureStorage,
    this.nativeSharedPreferences,
  );

  final SharedPreferences sharedPreferences;

  final FlutterSecureStorage secureStorage;

  final NativeSharedPreferences nativeSharedPreferences;
}

asyncMigrationのコールバックの引数としてMigrationStorageというデータソースの集合体を渡します。

具体的にこの Class を使用した実装方法は以下です。

final migrationVersionItem_2_8_0 = MigrationVersionItem(
  version: '2.8.0',
  asyncMigration: _migrate_2_8_0,
);

Future<void> _migrate_2_8_0(MigrationStorage storage) async {
  try {
    // マイグレーションしたいことをここに実装する

    // 例として第一章で記載していたサンプルを置いています
    final isFirstOpenAtSwift = storage.nativeSharedPreferences.get('isFirstOpen');
    await sharedPreferences.setBool('isFirstOpen', isFirstOpenAtSwift);
  } catch (e, s) {
    // saasのモニタリングツールへログ送信
    logger(e, s);

    // 失敗時のハンドリング
  }

この例では、アプリのバージョンが v2.8.0 以降であればこの_migrate_2_8_0のマイグレーションを実行します。

PerVersionMigrator

List に格納された複数のMigrationVersionItem内のマイグレーションを順次行うPerVersionMigratorという Class を実装しています。 この List 内部のMigrationVersionItemはバージョンごとで昇順に並んでいます。

class PerVersionMigrator {
  PerVersionMigrator(
    this._sharedPreferences,
    this._secureStorage,
    this._nativeSharedPreferences,
    this._packageInfo,
  );

  final Future<SharedPreferences> _sharedPreferences;
  final Future<FlutterSecureStorage> _secureStorage;
  final Future<NativeSharedPreferences> _nativeSharedPreferences;
  final Future<PackageInfo> _packageInfo;

  Future<void> migrate(List<MigrationVersionItem> migrationVersionItems) async {
    // [migrationVersionItems]がverごとに昇順となっているかチェック
    assert(() {
      for (var i = 0; i < migrationVersionItems.length; i++) {
        if (i + 1 >= migrationVersionItems.length) {
          break;
        }
        if (migrationVersionItems[i + 1].version <
            migrationVersionItems[i].version) {
          return false;
        }
      }

      return true;
    }());

    try {
      final currentVersion = (await _packageInfo).version;
      final preferences = await _sharedPreferences;
      final secureStorage = await _secureStorage;
      final nativeSharedPreferences = await _nativeSharedPreferences;

      bool migrated = false;
      // 「最後にマイグレーションしたver」 を取得
      // "lastMigratedVersion"というkeyで保持しています
      final lastMigratedVersion = await _getLastMigratedVersion(preferences);

      for (final item in migrationVersionItems) {
        // 「最後にマイグレーションしたverよりも大きいver」 かつ
        // 「そのverが現在のアプリver以下」 のマイグレーションを順次実行
        if (lastMigratedVersion < item.version &&
            item.version <= currentVersion) {
          await item.asyncMigration(
            MigrationStorage(
              preferences,
              secureStorage,
              nativeSharedPreferences,
            ),
          );
          migrated = true;
        }
      }

      // マイグレーションを行った場合、「最後にマイグレーションしたver」 を現在のアプリverで上書き
      if (migrated) {
        await _saveLastMigratedVersion(preferences, currentVersion);
      }
    } catch (e, s) {
      // saasのモニタリングツールへログ送信
      logger(e, s);

      // 全データ削除(整合性担保)
      await _clearAll();
    }
  }

※全実装を掲示すると長くなるため、本質のコードのみを抜粋しています。version 比較の箇所も本来は extension 実装がされていたりします。

PerVersionMigrator.migrate(migrationVersionItems)

を main メソッドで実行させます。
migrationVersionItemsは以下のように定義しています。

final List<MigrationVersionItem> migrationVersionItems = [
  migrationVersionItem_2_8_0,
  migrationVersionItem_3_3_0,
  migrationVersionItem_3_18_0,
];
一度のみのマイグレーション実行

lastMigratedVersionという最後にマイグレーションした時のアプリのバージョンを保持しています。 各 MigrationVersionItem の version と lastMigratedVersion を順次比較し、version が大きい要素のみを実行していきます。

全てのマイグレーションが成功した時のみ、lastMigratedVersion を現在のアプリのバージョンで更新します。

これにより、一度のみのマイグレーション実行を実現しています。

整合性の担保

lastMigratedVersionという最後にマイグレーションした時のアプリのバージョンを比較して実行すると前述しました。

ただ、このlastMigratedVersionが null となるケースが以下の 2 つが考えられ、それぞれで行うべきことが異なります。

  • アプリを新規インストールした
    • この場合は、そもそもローカルデータがまだ何も無いためマイグレーションを実行する必要はない
  • lastMigratedVersionが実装される前からアプリを使っているユーザーが、この機構を実装後のバージョンにアプデした
    • この場合は、どのバージョンからのアプデかの判断か不可能なため、全てのマイグレーションを実行する必要がある

この 2 つのケースを両方救わなければ、異常なマイグレーションの実行によりローカルデータの不整合が生じます。

解決策として、どちらのパターンでも一律で全てのマイグレーションを実行させます。 というのも、
lastMigratedVersionが null の場合に、どちらのパターンであるかが断定出来ません。 そのため「全てのマイグレーション実行が必要」となる後者のパターンがある以上、後者のパターンを軸として対策することで、どのアプリのバージョンからの実行でも整合性を担保することを実現しています。

失敗時のハンドリング

マイグレーションに失敗すると、可用性と整合性を損ねてしまうと前述しました。 失敗要因としては、「DB の読み込み・書き込みに失敗」「実装にバグがある」などが挙げられます。

なので、徹底的な対策が必要となります。

先ほどPerVersionMigratorがマイグレーションの全体、MigrationVersionItemがその中の単位と説明しました。

この各コンポーネントごとで失敗時のハンドリング責務が異なります。

MigrationVersionItem

処理内容によるため、具体のハンドリングは都度検討するルールにしています。

実際に考えられるハンドリング内容は以下の通りです。

  • 書き込み失敗した場合に、そのままデータを残して良いか消すべきか
  • 複数の値をマイグレーションする場合に、1つでも失敗したら全て失敗とみなすか成功とみなすか

PerVersionMigrator

ここでの処理失敗パターンは以下が考えられます。

  • 各 MigrationVersionItem にて、エラーハンドリングが行えていない箇所(バグ)
    • 徹底的な try-catch が組み込まれているが可能性はある
  • SharedPreferences の getInstance などのストレージへのアクセスエラー(主にユーザーの端末起因)
    • こちらも同様に基本的には発生しない前提
可用性の担保

PerVersionMigratorのスコープ内で万が一失敗した場合は、正常にアプリが働かなくなる可能性が高いため可用性を最優先し、データ(SecureStorage, SharedPreferences)を全て削除します。 ユーザー体験の観点ではローカル上で設定したデータを削除するのは良くないため、部分的なロールバックを取り入れたいのですが、整合性の担保が非常に困難なため可用性を優先する判断を下しました。 加えて、処理失敗パターンに挙げた通り、このスコープでの失敗は例外中の例外であるためという理由もあります。

失敗時のモニタリング

失敗時のハンドリングの冒頭で失敗したことを検知するために、必ず saas のモニタリングツールにエラーログを送信するように実装しています。

まとめ

以上の機能たちによって、どのアプリのバージョンからのマイグレーションでもローカルデータの整合性を担保し、万が一致命的な問題が発生した場合でもアプリの可用性を損ねることない機構を実現しました。

最後に

2 年間に渡る大規模かつ高品質・高水準の Flutter リプレースプロジェクトにおいて、実際のアプリ更新時にユーザー体験を損ねないために徹底的な対策を練りました。当記事ではまだまだ紹介出来ていないほど、マイグレーションでは考慮したことが多くありました。その努力の甲斐もあり、ロールバックの出番なく無事故でマイグレーションを完遂出来たため、シームレスなリプレースに大きく貢献出来たなと自分自身とても嬉しい気持ちでいっぱいです。

引き続きユーザー体験を最重視した開発に取り組んでいきます。最後まで読んで頂きありがとうございました。