- 投稿日:2021-06-21T18:18:49+09:00
Android個人開発者におすすめするアプリコンテスト(賞金総額はUS$1,000,000)
Apps UP 2021 の概要 背景 ファーウェイは世界各地のエンジニアと共にデジタルトランスフォーメーションを推進し、HMS キットを用いて、世界の消費者に次世代のIoT 体験を提供します。 合計賞金が100万ドル HMS開発者が400万人以上 HMSアプリ数が134,000以上 国と地域が170個以上 地区 大会はアジア地区、中国地区、ヨーロッパ地区、中東・アフリカ地区、南アメリカ地区に分かれてで行われます。 賞金 賞金総額が100 万ドルであり、各地区の賞金総額が20 万ドルです。また、アジア地区では次の賞が設けられます。 受賞者は賞金以外に次のインセンティブも受けられます。 スケジュール 期間 内容 2021 年6 月10 日~2021 年8 月20 日(18:00 UTC+8) 申し込みと作品提出 2021 年8 月21 日~2021 年9 月9 日(UTC+8) 予選 2021 年9 月10 日~2021 年9 月23 日(UTC+8) 公開レビュー 2021 年10 月 地区決勝戦 アジア地区審査員 パートナー 大会ルール説明 1.2021 年6 月10 日~2021 年8 月20 日(18:00 UTC+8) エントリー作品はHMS キットを導入し、オフィシャルサイトに提出しなければなりません。 (1) HUAWEI ID(所持しない場合は新規登録)でファーウェイ開発者にサインインし、“Sign up”をクリックします。 チームリーダーは“New team”をクリックし、チーム情報を入力します。チームメンバーは“Join team”をクリックし、チームを選びます。 (2) HMS キットを導入した作品を作成します。 (3) 大会詳細ページで“Submit work”をクリックし、アプリ名、ID、概要を入力し、APK とその他のドキュメント をアップロードします。 2.2021 年8 月21 日~2021 年9 月9 日(UTC+8) 審査員が全作品を採点し、公開レビューと地区決勝戦向けに、各地区から20 個の作品を選びます。 3.2021 年9 月10 日~2021 年9 月23 日(UTC+8) 入選作品はAppGallery とオフィシャルサイトでプロモーションされます。また、投票結果に基づき、公開レビュー点数が決定されます。 4.2021 年10 月 審査員が入選した20 個の作品に点数をつけます。最終結果は審査員点数と公開レビュー点数で決定されます。 エントリー資格 HUAWEI ID が必要です。 18 歳以上であることです。 ファーウェイおよび関連会社の社員と親族は参加不可です。 一人での参加もチームでの参加も可能です。ただし、チームメンバーは同じ地区でなければなりません。 一つのチームにしか参加できません。 チーム人数は3 人までです。 チームリーダーはチームメンバーの追加、削除、作品の提出等の管理を担当します。 チームメンバーはチームの参加と離脱ができます。 点数はチームのものとします。チームメンバーがチームを離脱しても、点数は離脱したチームメンバーのものではなく、チームのものとします。 次のいずれかが起きた場合、エントリー資格が取り消されます。 盗作と判断される 作品にサードパーティーのソースコードが使われ、かつ使用の承諾をもらっていない、あるいは法的トラブルを招く ファーウェイまはた第三者の権利を侵害したと判断される HUAWEI Developer Service Agreement、Agreement on Use of Huawei APIs、AppGallery Review Guidelinesに違反する 応募作品要項 HMS キットを一つ以上導入し、かつHMS 端末で正常に動作しなければなりません。 サードパーティーのゲームエンジン、ミドルウェア、オープンソース、ライブラリをしてもかまいません。ただし、使用許諾をもらわなければなりません。 HUAWEI Developer Service Agreementとその他の規定(HUAWEI Developer Merchant Service Agreement、HUAWEI Partner Paid Service Agreement、AppGallery Connect とHMS Core の使用規定を含む)に同意しなければなりません。 AppGallery にリリースする作品はリリースポリシーに同意しなければなりません。 ※作品はZIP またはRAR に圧縮すること。圧縮ファイルは200MB 以内であること。作品にAPKs と紹介ドキュメントが含まれること。 採点方法 予選採点方法 合計点数=審判員採点(80 点)+AppGallery ボーナス(10 点)+HMS キット導入ボーナス(10 点) 【審査員採点】 デザイン・テクノロジー・ユーザー体験(20 点) 独創性(30 点) 社会的価値(30 点) 【AppGallery ボーナス】 AppGallery にリリースした場合は10 点加点されます。 【HMS キット導入ボーナス】 10 点満点 次のHMS キットは一つずつ4 点とします。 IAP、Ads Kit、Map Kit、Push Kit、Location Kit、ML Kit、AR Engine、Nearby Service、Wireless Kit、hQUIC Kit、 Network Kit、CG Kit、Scene Kit、Wear Engine、DeviceVirtualization Engine、Cast Engine それ以外のHMS キットは一つずつ2 点とします。 決勝戦採点方法 合計点数=審判員採点(80 点)+公開レビュー(10 点)+HMS キット導入ボーナス(10 点) 【審査員採点】 デザイン・テクノロジー・ユーザー体験(20 点) 独創性(30 点) 社会的価値(30 点) 【公開レビュー】 10×(作品投票数/最も投票数が高い作品の投票数) 【HMS キット導入ボーナス】 10 点満点 次のHMS キットは一つずつ4 点とします。 IAP、Ads Kit、Map Kit、Push Kit、Location Kit、ML Kit、AR Engine、Nearby Service、Wireless Kit、hQUIC Kit、 Network Kit、CG Kit、Scene Kit、Wear Engine、DeviceVirtualization Engine、Cast Engine それ以外のHMS キットは一つずつ2 点とします。 参考資料 Apps UP 2021:https://developer.huawei.com/consumer/en/activity/digixActivity/digixdetail/101618451100197545 日本語資料:https://drive.google.com/file/d/1skkaigP-MgvqsxU1Yzs-0IERDOUSiGBt/view?usp=sharing
- 投稿日:2021-06-21T16:27:34+09:00
EMUIバージョンの取得方法
EMUIはファーウェイがAndroidをベースにカスタマイズしたものです。プログラムでは次の方法でEMUIのバージョンを取得できます。 public class EmuiInfo { public static EmuiInfo getEMUI() { EmuiInfo.Builder builder = new EmuiInfo.Builder(); try { Class<?> classType = Class.forName("android.os.SystemProperties"); Method getMethod = classType.getDeclaredMethod("get", new Class<?>[]{String.class}); String buildVersion = (String) getMethod.invoke(classType, new Object[]{"ro.build.version.emui"}); int startIndex = buildVersion.indexOf("_"); if (startIndex != 1) { String emuiVersion = buildVersion.substring(startIndex + 1); String[] versionArray = emuiVersion.split("\\."); builder.major(versionArray[0]); builder.minor(versionArray[1]); builder.patch(versionArray[2]); } } catch (Exception e) { e.printStackTrace(); return null; } return builder.build(); } private String major = ""; private String minor = ""; private String patch = ""; private EmuiInfo(Builder builder) { this.major = builder.major; this.minor = builder.minor; this.patch = builder.patch; } public String getMajor() { return this.major; } public String getMinor() { return this.minor; } public String getPatch() { return this.patch; } public String toString() { return this.major + "." + this.minor + "." + this.patch; } private static class Builder { private String major = ""; private String minor = ""; private String patch = ""; public Builder() { } public Builder major(String major) { this.major = major; return this; } public Builder minor(String minor) { this.minor = minor; return this; } public Builder patch(String patch) { this.patch = patch; return this; } public EmuiInfo build() { return new EmuiInfo(this); } } }
- 投稿日:2021-06-21T16:17:17+09:00
【Kotlin研修13日目】暗黙的インテントを利用したカメラ機能の実装
カメラ機能の実装 カメラ機能を実装する方法は、以下の2通り。 暗黙的インテントによるOS標準の「カメラ」アプリの利用 android.hardware.camera2APIによるカメラ機能の作成 暗黙的インテントを利用したカメラ機能の実装 参考: 研修11日目 暗黙的インテントを用いてカメラ機能を実装する手順は、以下の通り。 マニフェストファイルに端末のストレージを利用するためのパーミッションを付与 撮影した画像ファイル名が一意となるよう、SimpleDateFormatを用いて日時フォーマッタを作成 ストレージに格納する画像ファイル名・ファイル形式を指定 ContentResolverを用いてデータの格納先URIを作成 暗黙的インテントを用いてOS標準の「カメラ」アプリを起動 遷移元アクティビティに戻った際に実行される処理を記述 マニフェストファイルへのパーミッションの記述 参考: 研修12日目 アプリケーションが端末のストレージを利用できるよう、マニフェストファイル(=AndroidManifest.xml)に<uses-permission>タグを追記する。 なお、ユーザに対してパーミッションダイアログの表示が必要なのは、 アプリケーションに付与されたパーミッションを用いて外部(=インターネット)との通信が行われる場合に限定されるため、端末内部でデータのやり取りを行う場合はパーミッションダイアログの表示は不要である。 サンプルコード AndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest ...> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> ... </manifest> 日時フォーマッタの作成 ストレージにファイルを格納する場合、ファイル名が同名となり自動で上書きされないよう、一意となるファイル名にする必要がある。 ファイル名を一意とするためには、タイムスタンプを利用する。 タイムスタンプの利用にあたって、日時フォーマッタであるSimpleDateFormatオブジェクト、現在時刻を保持するDateオブジェクトを利用する。 ここで、Dateオブジェクトをタイムスタンプとして利用する場合、 タイムスタンプはString型であるため、 DateFormatクラスのformat()メソッドを用いてDateオブジェクトをString型に変換する必要がある。 SimpleDateFormat アルファベットを用いたユーザ定義の日時フォーマッタを生成するクラス。 Date ミリ秒単位で特定の「瞬間」を表すクラス。 定義 // 日時フォーマットを指定したSimpleDateFormatオブジェクトの生成 SimpleDateFormat(pattern: String) // パラメータ // pattern: 日時の形式 // Date型 -> String型 への変換 // <- SimpleDateFormatクラスはDateFormatクラスを継承しているため、 // SimpleDateFormatクラスからも利用可能 DateFormat.format(date: Date): String // パラメータ // date: 日時を表すDateオブジェクト 日時形式の主な表現方法 参考: SimpleDateFormat 文字 内容 例 yyyy 年 2021 MM 月 06 dd 日 21 EEE 曜日 Mon HH 時 10 mm 分 56 ss 秒 48 SSS ミリ秒 978 サンプルコード MainActivity.kt // 日時形式を指定したSimpleDateFormatオブジェクト val dateFormat = SimpleDateFormat("yyyyMMddHHmmss") // 現在の時刻を保持するDateオブジェクト val now = Date() // Date型 -> String型 への変換 val nowStr = dateFormat.format(now) // タイムスタンプを利用したファイル名の指定 val fileName = "CameraIntentPhoto_${nowStr}.jpg" ContentValueオブジェクトの生成・定義 ContentValueオブジェクトを生成し、やり取りするデータのファイル名と、データの種類(=MIMEタイプ)を定義する。 ContentValue ContentResolverが生成するURIに含まれる、コンテンツプロバイダが取り扱うデータ情報をマップ構造で保持するクラス。 ContentResolver コンテンツプロバイダに渡す、データの格納先URI(=Uriオブジェクト)とデータ情報(=ContentValueオブジェクト)が含まれるURIを生成するクラス。 コンテンツプロバイダ(ContentProvider) 参考: コンテンツプロバイダ 端末のストレージや複数の外部アプリケーションと直接データのやり取りを行う、アプリケーションの構成要素。 出典: コンテンツプロバイダと他のコンポーネントとの関係 コンテンツプロバイダは、ContentResolverオブジェクトによって作成されたURIを基に、指定されたストレージに対して指定されたデータをやり取りする。 端末のストレージへのアクセス手順を図式化すると、以下のようになる。 出典: ストレージとのやり取りの流れ 定義 ContentValues.put( key: String!, value: <T>! ): Unit // パラメータ // key: データの種類を表すキー // value: 値 データの種類を表すContentValuesのキー キー名 内容 MediaStore.Images.Media.TITLE 画像ファイル名 MediaStore.Images.Media.MIME_TYPE ファイルの種類(=MIMEタイプ) サンプルコード MainActivity.kt // タイムスタンプを利用したファイル名の指定 val fileName = "CameraIntentPhoto_${nowStr}.jpg" // ContentValuesオブジェクト val values = ContentValues() // ContentValuesオブジェクトに格納するファイル名 values.put(MediaStore.Images.Media.TITLE, fileName) // ContentValuesオブジェクトに格納するMIMEタイプ(=ファイルの種類) values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") Uriオブジェクトの生成 ContentResolverクラスのinsert()メソッドを用いて、 「データの格納先URI」(=Uriオブジェクト)と、 格納する「データ情報」(=ContentValuesオブジェクト)を保持するUriオブジェクトを生成する。 なお、ContentResolverがコンテンツプロバイダに対して渡すURIはリレーショナルデータベースのデータ構造をとっており、 URIに含まれるデータの格納先URI毎にテーブルが存在し、 テーブル毎に取り扱うデータ情報が含まれる。 上記サンプルコードの場合のテーブルのデータ構造は、以下の通り。 キー 値 MediaStore.Images.Media.TITLE ${filename} MediaStore.Images.Media.MIME_TYPE "image/jpeg" 定義 ContentResolver.insert( url: Uri, values: ContentValues? ): Uri? // パラメータ // url: 「データの格納先」を表すUriオブジェクト // values: 「データ情報」を表すContentValuesオブジェクト サンプルコード MainActivity.kt // ContentValuesオブジェクト(=データ情報) val values = ContentValues() // ContentValuesオブジェクトに格納するファイル名 values.put(MediaStore.Images.Media.TITLE, fileName) // ContentValuesオブジェクトに格納するMIMEタイプ(=ファイルの種類) values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") // データの格納先Uriとデータ情報(=ContentValuesオブジェクト)を保持するURI _imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) 暗黙的インテントの定義 参考: 研修11日目 インテントが行うアクションがアプリケーションそのものを表す場合、URIの指定は不要となる。 また、「カメラ」アプリを利用し、撮影した画像データをストレージに保存する場合、 インテントのExtraデータに、ContentResolverを用いて作成したURIを追加する。 ただし、その場合のExtraデータのキー(=name)は、値が「データの出力先」であることを表すMediaStore.EXTRA_OUTPUTとする。 定義 // 暗黙的インテントを利用したIntentオブジェクトの生成 Intent(action: String!) // パラメータ // action: インテントが実行するアクション // データの出力先URIを指定するExtraデータの追加 Intent.putExtra( name: String!, value: Parcelable? ): Intent // name: 格納するデータの名称 // value: 格納するデータ サンプルコード MainActivity.kt // 「カメラ」アプリを起動するインテント val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) // 画像の出力先をURIで指定 intent.putExtra(MediaStore.EXTRA_OUTPUT, _imageUri) 遷移先での処理後に遷移元へ戻る画面遷移の実装 参考: 研修3日目 遷移先アクティビティでの処理終了時に、自動的に遷移元アクティビティに戻る場合、 ComponentActivityクラスのstartActivityForResult()メソッドを用いてインテントを開始する。 定義 ComponentActivity.startActivityForResult( intent Intent!, requestCode: Int ): Unit // パラメータ // intent: 遷移先アクティビティを表すIntentオブジェクト // requestCode: onActivityResult()メソッド(後述)と // 一対一で対応させるリクエストコード サンプルコード MainActivity.kt // 「カメラ」アプリを起動するインテント val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) // 遷移先での処理後に遷移元へ戻る画面遷移の実行 startActivityForResult(intent, 200) 画面遷移後に遷移元で呼び出される処理の記述 ComponentActivityクラスのstartActivityForResult()メソッドを用いてインテントを開始した場合、遷移先アクティビティでの処理終了後、 遷移元アクティビティでComponentActivityクラスのonActivityResult()メソッドが呼び出される。 また、遷移元アクティビティで撮影した画像データを表示する場合は、 あらかじめImageViewを配置しておき、ImageViewに対して画像データが保存されている格納先ストレージのURIを指定する。 定義 // startActivityForResult()メソッドの終了時に呼び出される処理 @CallSuper ComponentActivity.onActivityResult( requestCode: Int, resultCode: Int, @Nullable data: Intent? ): Unit // パラメータ // requestCode: startActivityForResult()メソッドと // 一対一で対応させるリクエストコード // resultCode: 処理結果を表すActivityクラス定数 // URIを指定してImageViewに画像を表示 ImageView.setImageURI(uri: Uri?): Unit // uri: 画像データのURI 処理結果を表すActivityクラス定数 定数名 内容 RESULT_OK 正常終了 RESULT_CANCELED キャンセル サンプルコード MainActivity.kt // データの格納先URIとデータ情報(=ContentValuesオブジェクト)を保持するURI _imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) // startActivityForResult()による遷移先アクティビティでの処理終了後、 // 遷移元アクティビティで呼び出される処理(=コールバック処理) public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) // リクエストコードが一致し、かつ正常に処理が終了していた場合の処理 if(requestCode == 200 && resultCode == AppCompatActivity.RESULT_OK) { // 「カメラ」アプリによって変換されたBitmapオブジェクト(=画像) // <- ストレージを利用しない場合はサムネイル画像しか取得できないため、 // 解像度の低い画像となる // val bitmap = data?.getParcelableExtra<Bitmap>("data") // 画像を表示するImageView val ivCamera = findViewById<ImageView>(R.id.ivCamera) // Bitmapデータを指定してImageViewに画像を反映 // ivCamera.setImageBitmap(bitmap) // URIを指定してImageViewに画像を反映 ivCamera.setImageURI(_imageUri) } }
- 投稿日:2021-06-21T13:43:14+09:00
DaggerのAssisted Injectを楽にするクラスと拡張関数を作った
はじめに DaggerのAssisted Inject、便利ですよね。 私はDagger-Hiltで利用していますが、ViewModelにコンストラクタInjectionできるので重宝しています。 しかし、ViewModelで利用する場合、FactoryとそれをProvideする(companion objectの)関数を書く必要がありますが、ボイラプレートがあると感じたので、クラスと拡張関数を用いて実装を楽にしてみました。 Assisted Injectの対象引数が一つの場合にしか使えませんが、data class化するなど一つにまとめれば、実質複数の値を渡せるので、汎用性はあるのではないかと思います。 確認環境 確認した環境は以下の通りです。 Dagger-Hilt : 2.35.1 Kotlin : 1.5.10 AGP : 4.2.1 ViewModel向けの便利クラス 関数を共通interfaceとして切り出すことで、無駄をなくします。 使う側 先にメリットが分かるほうが良いと思うので、まずは利用側から。 Before(普通の書き方) SampleViewModel class SampleViewModel @AssistedInject constructor( @Assisted private val input: SampleData, ) : ViewModel() { @AssistedFactory interface Factory { fun create(input: SampleData): SampleViewModel } companion object { @Suppress("UNCHECKED_CAST") fun provideFactory(factory: Factory, input: SampleData): ViewModelProvider.Factory = object : ViewModelProvider.Factory { override fun <T : ViewModel?> create(modelClass: Class<T>): T { return factory.create(input) as T } } } } After(作ったクラスの適用) SampleViewModel class SampleViewModel @AssistedInject constructor( @Assisted private val input: SampleData, ) : ViewModel() { @AssistedFactory interface Factory : ViewModelAssistedFactory<SampleViewModel, SampleData> companion object : ViewModelFactoryProvider<Factory, SampleData> } ViewModelは、interfaceとcompanion objectを作りますが、継承してジェネリクスを定義するだけで良くしました。 楽ですね!(小並感) ViewModelAssistedFactoryとViewModelFactoryProviderが今回作ったクラスです。 作ったクラス 上を実現している作成したクラスです。 ViewModelAssistedFactory interface ViewModelAssistedFactory<VM : ViewModel, Parameter> { fun create(parameter: Parameter): VM } ViewModelFactoryProvider interface ViewModelFactoryProvider<Factory, Parameter> where Factory : ViewModelAssistedFactory<*, Parameter> { @Suppress("UNCHECKED_CAST") fun provideFactory(factory: Factory, parameter: Parameter): ViewModelProvider.Factory = object : ViewModelProvider.Factory { override fun <T : ViewModel?> create(modelClass: Class<T>): T { return factory.create(parameter) as T } } } interfaceとジェネリクスを使って共通化しただけなので、特筆すべきところはありません。 ViewModelFactoryProviderは型が長いのでwhereを使っていますが、単一継承なので:でも書けます。 蛇足 ViewModelとParameterがあればFactoryが作れるんだから、Factoryの定義も省略できるんじゃないか?と思いました。 結論としては出来なかったので、蛇足です。 ViewModelAssistedFactoryに@AssistedFactoryを追加して @AssistedFactory interface ViewModelAssistedFactory<VM : ViewModel, Parameter> { fun create(parameter: Parameter): VM } ViewModelFactoryProviderはFactoryではなくViewModelを型パラメータに interface ViewModelFactoryProvider<VM : ViewModel, Parameter> { @Suppress("UNCHECKED_CAST") fun provideFactory(factory: ViewModelAssistedFactory<VM, Parameter>, parameter: Parameter): ViewModelProvider.Factory = object : ViewModelProvider.Factory { override fun <T : ViewModel?> create(modelClass: Class<T>): T { return factory.create(parameter) as T } } } そうすれば、ViewModelの実装はcompanion objectだけで良くなるのでは?と思いました。 使う側 class SampleViewModel @AssistedInject constructor( @Assisted private val input: SampleData, ) : ViewModel() { // これが無くせるのでは? // @AssistedFactory // interface Factory : ViewModelAssistedFactory<SampleViewModel, SampleData> companion object : ViewModelFactoryProvider<Factory, SampleData> } が、コンパイルエラーになりました。 Invalid return type: VM. An assisted factory's abstract method must return a type with an @AssistedInject-annotated constructor. public abstract VM create(Parameter parameter); 恐らく、型パラメータの解決とAnnotationProcessing実行の関係で解決できなくなったのではないかと思われます。 なので、interfaceでの個別のFactory定義とcompanion objectの記述は両方必要そうです。 Fragment向けの拡張関数 FragmentのNavigation SafeArgsが持つパラメータをViewModelにAssisted Injectすることを例にします。 が、こちらはViewModel向けのものほど楽にはなりませんでした(ハードルを下げる) 使う側 Before(普通の書き方) SampleFragment.kt @AndroidEntryPoint class SampleFragment : Fragment(R.layout.fragment_sample) { @Inject lateinit var viewModelFactory: SampleViewModel.Factory private val navArgs: SampleViewModel by navArgs() private val viewModel: SampleViewModel by viewModels { SampleViewModel.provideFactory(viewModelFactory, navArgs.data) } } After(作ったクラスの適用) SampleFragment.kt @AndroidEntryPoint class SampleFragment : Fragment(R.layout.fragment_sample) { @Inject lateinit var viewModelFactory: SampleViewModel.Factory private val navArgs: SampleViewModel by navArgs() private val viewModel by assistedViewModels(SampleViewModel) { viewModelFactory to navArgs.data } } 書く量はほとんど同じですが、assistedViewModelsの方がViewModelを入れるだけでfactoryとparameterを強制できるので、多少は(?)良いのかもしれません。 とはいえViewModelのcompanion object関数がprovideFactoryしかない場合が多い気がするので、あんまり変わらないかもですね・・・。 作った拡張関数 FragmentExt.kt inline fun <reified VM : ViewModel, Param, Factory : ViewModelAssistedFactory<VM, Param>> Fragment.assistedViewModels( provider: ViewModelFactoryProvider<Factory, Param>, crossinline factoryParameterProducer: () -> Pair<Factory, Param> ): Lazy<VM> { return viewModels { val (factory, param) = factoryParameterProducer() provider.provideFactory(factory, param) } } 蛇足 なぜPairになったの?みたいな流れを記載します。 最初はこう作ろうとしていました。 拡張関数 inline fun <reified VM : ViewModel, Param, Factory : ViewModelAssistedFactory<VM, Param>> Fragment.assistedViewModels( provider: ViewModelFactoryProvider<Factory, Param>, factory: Factory, param: Param ): Lazy<VM> { return viewModels { provider.provideFactory(factory, param) } } 使う側 @AndroidEntryPoint class SampleFragment : Fragment(R.layout.fragment_sample) { @Inject lateinit var viewModelFactory: SampleViewModel.Factory private val navArgs: SampleViewModel by navArgs() private val viewModel by assistedViewModels(SampleViewModel, viewModelFactory, navArgs.data) } コンパイルは通りますが、実行時にエラーになります。 Caused by: kotlin.UninitializedPropertyAccessException: lateinit property viewModelFactory has not been initialized 原因は、viewModelFactoryが初期化される前に参照されていることです。 本家のViewModelsの実装を見てみます。 public inline fun <reified VM : ViewModel> Fragment.viewModels( noinline ownerProducer: () -> ViewModelStoreOwner = { this }, noinline factoryProducer: (() -> Factory)? = null ): Lazy<VM> = createViewModelLazy(VM::class, { ownerProducer().viewModelStore }, factoryProducer) 今回渡すのはfactoryProducerですが、高階関数になっています。 以下のように、使う場合の違いから見ても分かりますね。 // 本家:高階関数なので{}で記述 private val viewModel: SampleViewModel by viewModels { SampleViewModel.provideFactory(viewModelFactory, navArgs.data) } // 試したもの:通常引数なので()内に記述 private val viewModel by assistedViewModels(SampleViewModel, viewModelFactory, navArgs.data) よって、このassistedViewModelsではFragmentのインスタンス生成時に引数を参照していることになります。 @InjectのviewModelFactoryの初期化タイミングを調べると、HiltのページからFragmentの場合はonAttachであることが分かります。 なお、第三引数paramのために参照しているnavArgsもThis property can be accessed only after the Fragment's constructor.とあるので、こちらもこのままではエラーになります。 ということは、@Inject対象のもの(=viewModelFactory)とparamのために参照しているnavArgsを遅延初期化する必要がある = 高階関数で渡せば良さそうです。 inline fun <reified VM : ViewModel, Param, Factory : ViewModelAssistedFactory<VM, Param>> Fragment.assistedViewModels( factory: ProvideViewModelFactory<Factory, Param>, crossinline factoryProducer: () -> Factory, crossinline paramProducer: () -> Param ): Lazy<VM> { return viewModels { factory.provideFactory(factoryProducer(), paramProducer()) } } こうすると使う側は以下のようになり、エラーは起きません。 private val viewModel: SampleViewModel by assistedViewModels(SampleViewModel, { viewModelFactory }, { navArgs.data }) しかし、{},{}という記述方法が気持ちよくなかったので、Pairでまとめてみて、今回作成した形に落ち着きました。 最後に 今回はAssisted Injectを楽にするクラス・拡張関数を作ってみました。 Fragmentの拡張関数の方は導入しても大きな効果は少ないかもしれませんが、ViewModelで使う2つのクラスについてはロジックのボイラープレートも無くせるので有用ではないかと思います。 ViewModel向けの方は、ジェネリクスを増やしていけばAssistedFactoryの引数が2つ、3つと増えても対応できます。 assistedViewModelsはTripleを使えば2つの引数まではいけますが、通常のviewModelsを使ったほうが良いかもしれませんね。
- 投稿日:2021-06-21T09:45:15+09:00
iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(3/3 ライブラリ使い方編)
iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(1/3 考え方編) iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(2/3 実装方針編) iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(3/3 ライブラリ使い方編) ← いまここ 前置き 前回、前々回とモバイルアプリにおける状態管理の一つの考え方と、コードに落とし込むための実装方針、そしてなぜこのライブラリを作成したのかについてお話してきました。 今回は純粋に作成したStoreFlowableライブラリでどういった機能を提供しているかと、その使い方について紹介したいと思います。 Dart版も同等の機能を提供していますが、まだドキュメントやサンプルコード、テストなどが準備できていないので本記事ではKotlin版とSwift版を元に紹介します。 サンプルコード それぞれのリポジトリにはサンプルコードが含まれています。Kotlin版 / Swift版 下記の使い方と合わせて見ていただくと良いかと思います。 導入 Kotlin版はMavenCentralで配信しているので、Gradleの依存に以下を追加して下さい。 Kotlin dependencies { implementation("com.kazakago.storeflowable:storeflowable:x.x.x") } Swift版はSwift Package Managerでの導入が可能です。 Swift dependencies: [ .package(url: "https://github.com/KazaKago/StoreFlowable.swift.git", from: "x.x.x"), ], 基本的な使い方 1. FlowableDataStateManagerを継承したシングルトンクラスを作成する まず、FlowableDataStateManager<KEY>継承クラスをシングルトンクラスとして作成して下さい。 このクラスが前回の記事でも解説している、状態を通知できる仕組みを内包しています。 Kotlin object UserStateManager : FlowableDataStateManager<UserId>() Swift class UserStateManager: FlowableDataStateManager<UserId> { static let shared = UserStateManager() private override init() {} } 2. StoreFlowableFactory<KEY, DATA>を実装したクラスを作る まず、リモートからデータを取得するApiクラスとローカルキャッシュへのデータ入出力Cacheクラスを用意してください。 ここでは便宜上UserApiクラスとUserCacheクラスとします。 次にStoreFlowableFactory<KEY, DATA>の実装クラスを作ります。 このクラスがデータごとに処理が変わる、共通化部分としてまとめることが出来ない部分を記述したクラスとなります。 以下に例を示します。 Kotlin // StoreFlowableFactoryを実装したクラスを作成してください。 // 同一のデータが複数存在する場合はジェネリクスのKEYにその区別となる型を指定して下さい。データが一つの場合はUnitでOKです。 // DATAジェネリクスには扱うデータの型を指定して下さい。 class UserFlowableFactory(userId: UserId) : StoreFlowableFactory<UserId, UserData> { private val userApi = UserApi() private val userCache = UserCache() // データが複数存在する場合はその区別となるデータを渡して下さい。 override val key: UserId = userId // 作成したFlowableStateManagerのシングルトンインスタンスを指定して下さい。 override val flowableDataStateManager: FlowableDataStateManager<UserId> = UserStateManager // ローカルキャッシュからの取得処理を実装して下さい override suspend fun loadDataFromCache(): UserData? { return userCache.load(key) } // ローカルキャッシュへの保存処理を実装して下さい override suspend fun saveDataToCache(data: UserData?) { userCache.save(key, data) } // リモートからの取得処理を実装して下さい override suspend fun fetchDataFromOrigin(): FetchingResult<UserData> { val data = userApi.fetch(key) return FetchingResult(data = data) } // キャッシュが有効かどうかを判断する処理を実装して下さい。キャッシュが期限切れする必要がなければ常にfalseを返してしまってもOKです。 override suspend fun needRefresh(cachedData: UserData): Boolean { return cachedData.isExpired() } } Swift // StoreFlowableFactoryを実装したクラスを作成してください。 // 同一のデータが複数存在する場合はassosiatedTypeのKEYにその区別となる型を指定して下さい。データが一つの場合はUnitHashを指定して下さい。 // DATA assosiatedTypeには扱うデータの型を指定して下さい。 struct UserFlowableFactory : StoreFlowableFactory { typealias KEY = UserId typealias DATA = UserData private let userApi = UserApi() private let userCache = UserCache() init(userId: UserId) { key = userId } // データが複数存在する場合はその区別となるデータを渡して下さい。 let key: UserId // 作成したFlowableStateManagerのシングルトンインスタンスを指定して下さい。 let flowableDataStateManager: FlowableDataStateManager<UserId> = UserStateManager.shared // ローカルキャッシュからの取得処理を実装して下さい func loadDataFromCache() -> AnyPublisher<UserData?, Never> { userCache.load(userId: key) } // ローカルキャッシュへの保存処理を実装して下さい func saveDataToCache(newData: UserData?) -> AnyPublisher<Void, Never> { userCache.save(userId: key, data: newData) } // リモートからの取得処理を実装して下さい func fetchDataFromOrigin() -> AnyPublisher<UserData, Error> { userApi.fetch(userId: key).map { data in FetchingResult(data: data) }.eraseToAnyPublisher() } // キャッシュが有効かどうかを判断する処理を実装して下さい。キャッシュが期限切れする必要がなければ常にfalseを返してしまってもOKです。 func needRefresh(cachedData: UserData) -> AnyPublisher<Bool, Never> { cachedData.isExpired() } } <KEY>の利用するシーンとしては、例えばGET /users/{user_id}/reposのようなREST APIがある場合などに、UserIdごとにキャッシュを保持しておきたいケースなどに使用して下さい。 このような場合分けが不要な場合は<KEY>にKotlin版ではUnitを指定して下さい。SwiftではUnitHashというstructを作成してあるのでそちらを指定して下さい。 3. Repositoryクラスを作成する ここまででStoreFlowableを利用するための準備は整っているので、Repositoryパターンを体現したクラスを作成します。 2.で作成したクラスのインスタンスに生えているcreate()メソッドからStoreFlowableクラスを作成できます。 このクラスが本ライブラリの本体となり、データの監視や入出力を司るメソッドが生えています。 Kotlin class UserRepository { fun followUserData(userId: UserId): Flow<State<UserData>> { val userFlowable: StoreFlowable<UserId, UserData> = UserFlowableFactory(userId).create() return userFlowable.publish() } suspend fun updateUserData(userData: UserData) { val userFlowable: StoreFlowable<UserId, UserData> = UserFlowableFactory(userData.userId).create() userFlowable.update(userData) } } Swift struct UserRepository { func followUserData(userId: UserId) -> AnyPublisher<State<UserData>, Never> { let userFlowable: AnyStoreFlowable<UserId, UserData> = UserFlowableFactory(userId: userId).create() return userFlowable.publish() } func updateUserData(userData: UserData) -> AnyPublisher<Void, Never> { let userFlowable: AnyStoreFlowable<UserId, UserData> = UserFlowableFactory(userId: userId).create() return userFlowable.update(newData: userData) } } データの入出力を行う際には必ずこのStoreFlowableを経由して行って下さい。 データの実体を直接書き換えてしまうと変更通知が行われません。 データの監視を行いたい場合はpublish()を用いて下さい。 このメソッドを通すことで考え方編で当初目指していたRepositoryクラスのインターフェースを提供することが出来ます。 4. 作成したRepositoryクラスを利用する 作成したリポジトリクラスを利用するには利用側(ActivityやViewController、ViewModelなど)で監視するメソッドを実行します。 Kotlin版であればFlowによる通知なのでcollect {}, Swift版であればCombineによる通知なので sink {}を利用してデータ監視を開始できます。 また、返却されるStateクラスやStateContentクラスはdoActionメソッドで状態の分岐が可能です。 データの状態(停止状態・取得中状態・エラー状態)とデータの有無(存在する・存在しない)の組み合わせの最大6パターンの分岐を表示上網羅することができれば、データが将来的にいかなる状態になってもカバーできます。 Kotlin private fun subscribe(userId: UserId) = viewModelScope.launch { userRepository.followUserData(userId).collect { it.doAction( onFixed = { ... // 停止状態 }, onLoading = { ... // 取得中状態 }, onError = { exception -> ... // エラー状態 } ) it.content.doAction( onExist = { userData -> ... // データが存在する }, onNotExist = { ... // データが存在しない } ) } } Swift private func subscribe(userId: UserId) { userRepository.followUserData(userId: userId) .receive(on: DispatchQueue.main) .sink { state in state.doAction( onFixed: { ... // 停止状態 }, onLoading: { ... // 取得中状態 }, onError: { error in ... // エラー状態 } ) state.content.doAction( onExist: { userData in ... // データが存在する }, onNotExist: { ... // データが存在しない } ) } .store(in: &cancellableSet) } ここまでが基本的な使い方となります。 適切に使えば表示の不整合を解消しつつ、リモートとキャッシュの抽象化が実現できているはずです。 その他の機能 State<T>が不要な一度きりのデータ取得を行いたい場合 ここまでデータを監視して変化を受け取れることを前提にお話してきましたが、常にデータ監視が適切とは限りません。 その瞬間のデータが一度だけ必要で継続的な監視を必要としない場合はgetData()あるいはrequiredData()メソッドを使って下さい。 Kotlin interface StoreFlowable<KEY, DATA> { suspend fun getData(from: GettingFrom = GettingFrom.Both): DATA? suspend fun requireData(from: GettingFrom = GettingFrom.Both): DATA } Swift public extension StoreFlowable { func getData(from: GettingFrom = .both) -> AnyPublisher<DATA?, Never> func requireData(from: GettingFrom = .both) -> AnyPublisher<DATA, Error> } requiredData()は有効なキャッシュが存在せず、リモートからのデータ取得にも失敗した場合は例外を投げます。 getData()では例外の代わりにnull, nilを返します。 引数のGettingFromはどこからデータを取得するかを指定します。 デフォルトは両方からよしなに取得する.Bothですが、キャッシュからのみ取得する.Cacheと、リモートからのみ取得する.Originを指定することも出来ます。 enum class GettingFrom { Both, Origin, Cache, } 画面上でデータの取得から表示を行う場合は基本的にはpublish()によるデータ監視の仕組みを利用して下さい。 requiredData(), getData()についてはデータ監視と相性の悪い場合のみ使用して下さい。 データを強制的に更新する 通常の使い方ではキャッシュが無効にならない限りはリモートから新しいデータは取得しませんが、要件によっては監視を開始するタイミング(画面を開いたときなど)でデータの更新を強制的に行いたい場面もあると思います。 その場合はpublish()メソッドのforceRefresh引数にtrueを指定して下さい。 Kotlin interface StoreFlowable<KEY, DATA> { fun publish(forceRefresh: Boolean = false): Flow<State<<DATA>> } Swift public extension StoreFlowable { func publish(forceRefresh: Bool = false) -> AnyPublisher<State<DATA>, Never> } また、監視開始のタイミングではなく任意のタイミングでデータの更新をしたい場合はrefresh()を用いることも可能です。 引っ張って更新の機能などを提供する場合に利用して下さい。 Kotlin interface StoreFlowable<KEY, DATA> { suspend fun refresh(clearCacheWhenFetchFails: Boolean = true, continueWhenError: Boolean = true) } Swift public extension StoreFlowable { func refresh(clearCacheWhenFetchFails: Bool = true, continueWhenError: Bool = true) -> AnyPublisher<Void, Never> } clearCacheWhenFetchFailsはリモートからのデータ取得失敗時にローカルキャッシュを消去するか、continueWhenErrorはすでにエラー状態のときにデータ取得を継続するかどうかを変更できます。 いずれもtrueがデフォルトの振る舞いとなります。 キャッシュデータが有効か検証する 現時点で保持するデータが有効か検証したい場合はvalidate()が利用できます。 キャッシュが無効であればリモートからの再取得処理が実行されます。キャッシュが有効であれば何もしません。 Kotlin interface StoreFlowable<KEY, DATA> { suspend fun validate() } Swift public protocol StoreFlowable { func validate() -> AnyPublisher<Void, Never> } キャッシュデータを更新する なんらかの処理の都合でキャッシュデータを更新する必要がある場合はupdate()メソッドが使用できます。 null, nilを指定することでキャッシュデータの削除も可能です。 このメソッドからキャッシュを更新することで、データの変更通知が発火しすべてのデータ監視者にデータの変更が反映されます。 Kotlin interface StoreFlowable<KEY, DATA> { suspend fun update(newData: DATA?) } Swift public protocol StoreFlowable { func update(newData: DATA?) -> AnyPublisher<Void, Never> } State<T> 関連オペレーター State<T>に内包されるデータをストリーム内で触りたい場合に便利なオペレータをいくつか用意しています。 Flow<State<T>>、AnyPublisher<State<T>, Never>の変換 Flow<State<T>>、AnyPublisher<State<T>, Never>のストリーム内でデータを別のデータに置き換えたい場合にはmapContent()という関数を利用できます。 Kotlin val flowState: Flow<State<Int>> = ... val flowMergedState: Flow<State<String>> = flowState1.mapContent { value -> value.toString() } Swift let statePublisher: AnyPublisher<State<Int>, Never> = .. let mergedStatePublisher: AnyPublisher<State<String>, Never> = statePublisher1.mapContent { value in String(value) } Flow<State<T>>、AnyPublisher<State<T>, Never>の統合 複数のFlow<State<T>>>、AnyPublisher<State<Int>, Never>を統合したい場合は、Kotlinの場合はcombineState()、Swiftの場合はzipState()が利用できます。 Kotlin val flowState1: Flow<State<Int>> = ... val flowState2: Flow<State<Int>> = ... val flowMergedState: Flow<State<Int>> = flowState1.combineState(flowState2) { value1, value2 -> value1 + value2 } Swift let statePublisher1: AnyPublisher<State<Int>, Never> = .. let statePublisher2: AnyPublisher<State<Int>, Never> = .. let mergedStatePublisher: AnyPublisher<State<Int>, Never> = statePublisher1.zipState(statePublisher2) { value1, value2 in value1 + value2 } 片方のステータスがLoadingやErrorだった場合は全体がLoadingやErrorとして扱われるのでご注意下さい。 状態の優先度は Error > Loading > Fixed となります。 ページネーションサポート APIから取得したリモートのデータとローカルキャッシュのデータをうまくやりくりしないといけない一般的なユースケースの一つとしてページネーションがあります。 下記のようにリストの最下部に達したときに追加をAPIから読み込んで繋ぎ合わせるような仕組みです。 これに関してもRepositoryよりも外側でキャッシュを意識せずに利用するための追加クラスを提供しています。 1. PaginatingStoreFlowableFactoryを実装する この機能を使うにはStoreFlowableFactoryの代わりにPaginatingStoreFlowableFactoryを実装したクラスを作成して下さい。 基本的には同じですがsaveAdditionalDataToCache()とfetchAdditionalDataFromOrigin()を追加で実装する必要があるという部分が違いとしてあります。以下に実装例を示します。 Kotlin class UserListFlowableFactory : PaginatingStoreFlowableFactory<Unit, List<UserData>> { private val userListApi = UserListApi() private val userListCache = UserListCache() override val key: Unit = Unit override val flowableDataStateManager: FlowableDataStateManager<Unit> = UserListStateManager override suspend fun loadDataFromCache(): List<UserData>? { return userListCache.load() } override suspend fun saveDataToCache(newData: List<UserData>?) { userListCache.save(newData) } // 追加読み込みを行ったデータをキャッシュデータとどう繋ぎ合わせるかを定義して下さい override suspend fun saveAdditionalDataToCache(cachedData: List<UserData>?, newData: List<UserData>) { val mergedData = (cachedData ?: emptyList()) + newData userListCache.save(mergedData) } override suspend fun fetchDataFromOrigin(): FetchingResult<List<UserData>> { val fetchedData = userListApi.fetch(1) return FetchingResult(data = fetchedData, noMoreAdditionalData = fetchedData.isEmpty()) } // 追加読み込みを行う際のリモートからのデータの取得処理を記載して下さい。 // これ以上追加のデータが存在しないことがわかっている場合は戻り値のnoMoreAdditionalDataにtrueを指定して下さい override suspend fun fetchAdditionalDataFromOrigin(cachedData: List<GithubOrg>?): FetchingResult<List<GithubOrg>> { val page = (cachedData?.size ?: 0) / 10 + 1 val fetchedData = userListApi.fetch(page) return FetchingResult(data = fetchedData, noMoreAdditionalData = fetchedData.isEmpty()) } override suspend fun needRefresh(cachedData: List<UserData>): Boolean { return cachedData.last().isExpired() } } Swift struct UserListFlowableFactory : PaginatingStoreFlowableFactory { typealias KEY = UnitHash typealias DATA = [UserData] private let userListApi = UserListApi() private let userListCache = UserListCache() let key: UnitHash = UnitHash() let flowableDataStateManager: FlowableDataStateManager<UnitHash> = UserListStateManager.shared func loadDataFromCache() -> AnyPublisher<[UserData]?, Never> { userListCache.load() } func saveDataToCache(newData: [UserData]?) -> AnyPublisher<Void, Never> { userListCache.save(data: newData) } // 追加読み込みを行ったデータをキャッシュデータとどう繋ぎ合わせるかを定義して下さい // これ以上追加のデータが存在しないことがわかっている場合は戻り値のnoMoreAdditionalDataにtrueを指定して下さい func saveAdditionalDataToCache(cachedData: [UserData]?, newData: [UserData]) -> AnyPublisher<Void, Never> { let mergedData = (cachedData ?? []) + newData return userListCache.save(data: mergedData).map { data in FetchingResult(data: data, noMoreAdditionalData: data.isEmpty) }.eraseToAnyPublisher() } func fetchDataFromOrigin() -> AnyPublisher<FetchingResult<[UserData]>, Error> { userListApi.fetch(page: 1) } // 追加読み込みを行う際のリモートからのデータの取得処理を記載して下さい。 func fetchAdditionalDataFromOrigin(cachedData: [UserData]?) -> AnyPublisher<FetchingResult<[UserData]>, Error> { let page = ((cachedData?.count ?? 0) / 10 + 1) return userListApi.fetch(page: page).map { data in FetchingResult(data: data, noMoreAdditionalData: data.isEmpty) }.eraseToAnyPublisher() } func needRefresh(cachedData: [UserData]) -> AnyPublisher<Bool, Never> { cachedData.last.isExpired() } } このFactoryクラスから、create()で本体となるPaginatingStoreFlowableクラスを作成することができます。 2. requestAdditionalData()で追加読み込みを行う PaginatingStoreFlowableも通常のStoreFlowableクラスと使い方にほとんど変わりはありませんが、追加読み込みを行うためのメソッドが追加されています。 Kotlin interface PaginatingStoreFlowable<KEY, DATA> { suspend fun requestAdditionalData(continueWhenError: Boolean = true) } Swift public extension PaginatingStoreFlowable { func requestAdditionalData(continueWhenError: Bool = true) -> AnyPublisher<Void, Never> } このメソッドを呼ぶことで自動的にデータがつなぎ合わされた状態で通知されます。 すでに読込中の状態で連続でこのメソッドが呼ばれても、前回解説したとおり多重にAPIがリクエストされてしまうことはありません。 ゆえに画面側で読込中かどうかを判定して、メソッドを呼ぶかどうかを分岐させる処理は不要です。 繋ぎ合わされたデータは毎回まとめて通知されるため、実際に画面上にリスト表示する際にはRecyclerViewやUITableViewなどに対応する差分更新機能(DiffUtilやUITableViewDiffableDataSource)などを用いて描画更新してあげて下さい。 一連の記事のまとめ ここまで様々な視点から長々と解説とライブラリの紹介をしてきました。 もちろん今回紹介したライブラリを使って頂いても構いませんが、最も大事なのは状態管理に対する考え方であり、アプリ内におけるデータの不整合が出づらく、ローカルキャッシュに振り回されない仕組みが用意できていることです。 それが達成されてさえいればどのような仕組みのでも構わないと思います。 データ取得処理のインターフェースをなるべく早く安定させて、変わらないようにしておくこともとても大事です。 その際に技術的詳細を隠蔽したり、取得先を抽象化することを意識してみてください。 一年後〜数年後に、きっと状態管理が少しだけ楽になっているはずです!
- 投稿日:2021-06-21T09:44:49+09:00
iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(2/3 実装方針編)
iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(1/3 考え方編) iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(2/3 実装方針編) ← いまここ iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(3/3 ライブラリ使い方編) 前置き 前回「iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(1/3 考え方編)」という記事を書きました。 今回はその考え方をどうやって実際のコードに落とし込んでいくのかを整理しながら紹介するとともに、実際にSwift/Kotlin/Dartそれぞれ向けにリファレンスライブラリを作成したので合わせて紹介します。 記事内のコードはKotlinとSwiftの2つで記載します。 作成したリファレンスライブラリの紹介 前回・今回の記事の思想を元に実装したライブラリをKotlin版、Swift版、Dart版にわけて作成しました。 いずれもApache License 2.0にて提供しています。 上記ライブラリ群の提供する機能やインターフェースはほとんど同一です。 このことからも記事でお話する内容は特定の言語やフレームワークに依存するものではないことがわかるかと思います。 リファレンスとはいえ、Kotlin版については私が担当する数十万人が利用するAndroidアプリで実際に運用しているライブラリでもあります。 最初に申し上げておくと弊社プロダクトに必要な要件向けにライブラリまで落とし込んでみた一例であり、あらゆるシーンに対応するものではありません。 しかしながら、API通信とアプリ内キャッシングを行う一般的なアプリのユースケースでは十分だと考えています。 細かい思想や実装方針はいいからライブラリの使い方と使い勝手が知りたいんだと言う方は3/3 ライブラリ使い方編記事を御覧ください。 複雑さの軽減に必要な要素 前回の記事にてデータの取得先の抽象化や通知の仕組みが状態管理の複雑さの軽減につながるという話をしました。 ここで整理した内容を再度書き出してみます。 データの状態を表現できる構造が存在すること データに変更があった場合は教えてくれる仕組みを用意する(Observerパターンの概念) 使う側からキャッシュかAPIかなどどこから取ってきているかを意識させず整合性の取れた値を返す(Repositoryパターンの概念) 出来る限り早く値を返却する 使う側でデータを変数などで保持せず、取得先を常に1箇所に絞る(Single Source of Truthの概念) 最終的なインターフェース データ取得のための最終的な出力としては以下のような継続的に変更を受け取れる形がよさそうだという話も前回しました。 今回はこの形を吐き出すコードを実装を目指します。 Kotlin+Coroutines interface MyRepository { fun followUser(): Flow<State<User>> } Swift+Combine protocol MyRepository { func followUser() -> AnyPublisher<State<User>, Never> } ※今回はState自体がErrorの情報を持ちうるため、AnyPublisherの第2ジェネリクスはNeverを指定しています。 1. データの「状態」を扱う まずは整理した要素の「1. データの状態を表現できる構造が存在すること」をコードで表現してみます。 これは前回の記事に記載した通りですが、データの有無とは別にデータの状態を取り扱えるようにしておいたほうが良いです。 具体的には「停止状態(Fixed)・取得中状態(Loading)・エラー状態(Error)」の3つあればモバイルアプリの要件としては十分だと思います。 以下のようにデータの状態とデータの実体を組み合わせたState<T>というデータの箱を考えてみます。 Kotlin sealed interface State<out T> { val content: StateContent<T> data class Fixed<out T>(override val content: StateContent<T>) : State<T> data class Loading<out T>(override val content: StateContent<T>) : State<T> data class Error<out T>(override val content: StateContent<T>, val exception: Exception) : State<T> } sealed interface StateContent<out T> { data class Exist<out T>(val rawContent: T) : StateContent<T> class NotExist<out T> : StateContent<T> } Swift enum State<T> { case fixed(stateContent: StateContent<T>) case loading(stateContent: StateContent<T>) case error(stateContent: StateContent<T>, error: Error) } enum StateContent<T> { case exist(rawContent: T) case notExist } この実装例でデータの有無を?(Null Safety)ではなくsealed classやenumで表現しているのはわざとで、データが無いケースが存在するというのを強く意識してUIを実装してほしいがためです。 作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版) 2. Observerパターンの構築 次に「2. データに変更があった場合は教えてくれる仕組みを用意する」について実装を考えます。 データを保持して変化したときに通知できる仕組み、いわゆるObserverパターンを構築します。 KotlinではCoroutinesに含まれるStateFlowを、SwiftではCombineに含まれるCurrentValueSubjectを用いることでこの仕組みは簡単に実現できます。 RxJava,RxSwiftではBehaviorSubjectと呼ばれているものがこれに相当します。 Kotlin val observableData = MutableStateFlow<String>("initial_data") observableData.collect { data -> println("Data is changed: $data") } observableData.emit("new_data") Swift let observableData = CurrentValueSubject<String, Never>("initial_data") observableData.sink { data in print("Data is changed: \(data)") } observableData.send("new_data") 色々必要なコードは省いていますが、Swift・Kotlinともにこのようなコードで通知を記述するとデータを購読したタイミングで"initial_data"が通知され、emit,sendした時点で"new_data"を購読者に伝えることが出来ます。 通知の仕組みとデータの保存処理の分離 データを通知する仕組み自体は比較的簡単ですが、今回実装したライブラリのコードではデータの実体をこのような仕組みでは保持していません。 これはデータの実体の保持の仕組みをStateFlowやCurrentValueSubjectの振る舞いにロックインさせてしまうことを避けるためです。 StateFlowやCurrentValueSubjectに直接データをもたせるのは直感的ですが、これではアプリのキル時にデータが揮発してしまいます。 ではこの通知の仕組みを維持したままデータを永続化したい場合はどうすればよいでしょう? すでにAndroidのSharedPreferencesやiOSのCoreDataを使っていた場合は?これから使いたい場合は? それぞれのアプリの性質や歴史によってデータをどういった形式でどこに保存するかは様々です。 ゆえに今回は汎用的な用途を前提どこに保存するかは問わずに、データの変更通知の仕組みと分けて考えてみます。 データの実体と状態を別に管理する そこで着目するのが先の項目で話題に上げた「データの状態」です。 データの状態は一時的なものでアプリキル後まで永続化する必要は基本的にはありません。いかなる場合に揮発しても構わないはずです。 なのでこの要素を監視対象としてライブラリ内に組み込みStateFlowやCurrentValueSubjectにセットします。 データの実体はどこに保存してもよく、状態の変化の通知に合わせてついでに取ってきて一緒に返すくらいの考え方です。 これを満たすために、まず以下のように状態だけを取り扱う箱(DataState)を用意します。 エラー状態におけるのエラーの内容については基本的には揮発しても良いことが大半なので状態の一部として取り扱ってしまってよいでしょう。 Kotlin sealed interface DataState { class Fixed : DataState // 停止状態 class Loading : DataState // 取得中状態 class Error(val exception: Exception) : DataState // エラー状態 } Swift public enum DataState { case fixed // 停止状態 case loading // 取得中状態 case error(rawError: Error) // エラー状態 } これを内包するStateFlowやCurrentValueSubjectをシングルトンクラスで維持しつつ、通知される状態の流れをmap関数を使って変化させることで状態の変化に応じてデータを渡すことが出来るようになります。 作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版) また、以下のコードはDataStateを通知のトリガーとして機能させつつキャッシュデータの実体と統合して前述したState<T>として返却する一例です。(※解説用に実際のコードより簡略化しています) Kotlin val dataStateObserver = MutableStateFlow<DataState>(DataState.Fixed()) // 状態を監視できるObserver fun observeData(): Flow<State<RawData>> { return dataStateObserver .map { dataState -> val data = loadCacheData() // 状態が変化した際に対象のデータの実体をキャッシュから取得する convertToState(data, dataState) // 最終的な出力形式である`State<T>`の形式に加工する } } Swift let dataStateObserver = CurrentValueSubject<DataState, Never>(.fixed()) // 状態を監視できるObserver func observeData() -> AnyPublisher<State<RawData>, Never> { dataStateObserver .map { dataState in let data = loadCacheData() // 状態が変化した際に対象のデータの実体をキャッシュから取得する return convertToState(data, dataState) // 最終的な出力形式である`State<T>`の形式に加工する } .eraseToAnyPublisher() } このようなアプローチを取ることで、データの保存先や保存形式に左右されずにデータ通知の仕組みを構築することが可能です。 作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版) この手法を取る上で一つ注意すべきなのは、状態が変化したときしか通知されないためデータの実体を直接書き換えてしまうと通知されません。 データの更新を行う際は実体と状態を必ずセットで更新して上げる必要があります。 これらは手動で行おうとすると大変ですが、この2つの処理をセットにして共通化した仕組みを用意して必ずそこから更新処理を行うように徹底することでこの問題に対処することは可能です。作成したライブラリでもそのような仕組みを用意しています。 3&4, データ取得先の抽象化 次に考えてみるのは「3. 使う側からキャッシュかAPIかなどどこから取ってきているかを意識させず整合性の取れた値を返す」と「4. 出来る限り早く値を返却する」についてです。 これについては実際にコードに落とし込むにあたりどういった処理が必要なのか簡単にフローチャートを書いてみます。 上記の手順を踏んでデータを返すことで、ローカルキャッシュとリモートからのデータの取得を抽象化したデータを取り出すことが可能です。 さらにデータを要求者に返す仕組みを前述したObserverパターンと組み合わせることで、要求時のデータのみならず将来的な更新や状態の変化まで監視することが出来ます。 これを限界まで簡略化した上で愚直にコードで表現してみるとこんな感じかと思います。 データの入出力や状態の入出力については外部へ切り出して抽象化処理の共通部分のみ表現しています。 Kotlin fun process() { val currentState = loadState() // データの状態を取り出す if (currentState is DataState.Loading) return // 状態がLoadingなら何もしない val cacheData = loadDataFromCache() // キャッシュデータの実体を取り出す if (!needRefresh(cacheData)) return // キャッシュが有効なら何もしない saveState(DataState.Loading()) // データの状態をLoadingに変える (データが通知される) val response = fetchDataFromOrigin() // リモートから最新データを取得する if (response.isSuccess) { // データの取得に成功 saveDataToCache(response.data) // 取得したデータをキャッシュへ保存する saveState(DataState.Fixed()) //データの状態をFixedに変える (データが通知される) } else { // データの取得に失敗 saveState(DataState.Error()) //データの状態をErrorに変える (データが通知される) } } Swift func process() { let currentState = loadState() // データの状態を取り出す if case .loading = currentState { return } // 状態がLoadingなら何もしない let cacheData = loadDataFromCache() // キャッシュデータの実体を取り出す if !needRefresh(cacheData) { return } // キャッシュが有効なら何もしない saveState(.loading) // データの状態をLoadingに変える (データが通知される) let response = fetchDataFromOrigin() // リモートから最新データを取得する if response.isSuccess { // データの取得に成功 saveDataToCache(response.data) // 取得したデータをキャッシュへ保存する saveState(.fixed) //データの状態をFixedに変える (データが通知される) } else { // データの取得に失敗 saveState(.error) //データの状態をErrorに変える (データが通知される) } } 作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版) 実際には非同期処理やパラメータによる制御処理を多数入れているので上記で示したコードとはかなり異なります。 5. Single Source of Truthを守る 最後に「5. 使う側でデータを変数などで保持せず、取得先を常に1箇所に絞る」について考えてみます。 とはいえ、これに関してはライブラリ等でどうにかすることは出来ないので利用者に頑張ってルールを守ってもらうしかありません。 今回紹介している仕組みは前回の記事からお話している通り、擬似的なSingle Source of Truthを実現していますが あくまでこの仕組みを通した場合に限定されるので、仕組みを介さずにデータを取得・更新した場合やこの仕組みの外側でデータを保持してしまった場合はこの前提は崩れてしまいます。 この仕組みを採用する以上は、「4. 出来る限り早く値を返却する」に基づくため、この仕組みの外側でデータをメンバー変数などで保持する必要はなく、常にこの仕組みを経由して取得・更新を行うことが十分可能な作りになっているはずです。 共通化出来ない部分を切り分ける ここまでデータの保存先や保存形式に影響を受けない部分の共通化処理を考えてきました。 ここからは前回の記事でもお話しましたが、共通化出来ない部分についてもう一度書き出してみます。 データの状態を保持する機構 キャッシュからの取得処理 キャッシュへの保存処理 API等のプライマリデータからの取得処理 キャッシュが有効か否かの判断処理(時間、個数、etc..) これらに関してはデータによって処理が変わる部分なので、それぞれの実装時に処理を記述を変えられるように枠組みだけを提供してみます。 具体的には以下のようなインターフェースの提供を検討します。(※解説用に実際のコードより簡略化しています) Kotlin interface StoreFlowableFactory<DATA> { fun loadDataFromCache(): DATA? // ローカルキャッシュからの取得処理 fun saveDataToCache(newData: DATA?) // ローカルキャッシュへの保存処理 suspend fun fetchDataFromOrigin(): DATA // リモートデータからの取得処理 fun needRefresh(cachedData: DATA): Boolean // ローカルキャッシュが有効かの判定処理 } Swift protocol StoreFlowableFactory { associatedtype DATA func loadDataFromCache() -> DATA? // ローカルキャッシュからの取得処理 func saveDataToCache(newData: DATA?) // ローカルキャッシュへの保存処理 func fetchDataFromOrigin() -> AnyPublisher<DATA, Error> // リモートデータからの取得処理 func needRefresh(cachedData: DATA) -> Bool // ローカルキャッシュが有効かの判定処理 } 上記のインターフェースに準じた実装をデータごとに用意してあげることで保存先や保存形式を実装した部分に任せつつ、その他の抽象化のための共通処理をまとめることが出来ます。 作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版) また、これに加えて前述したデータの状態のみを取り扱う箱DataStateを通知の仕組みに乗せたシングルトンクラスも必要になります。 こちらについても共通化して隠蔽してしまうと柔軟性が損なわれるので今回のライブラリでは切り出していますが場合によっては共通化してしまっても良いかもしれません。 Kotlin abstract class FlowableDataStateManager { val dataState = MutableStateFlow<DataState>(DataState.Fixed()) } Swift open class FlowableDataStateManager { let dataState = CurrentValueSubject<DataState, Never>(.fixed()) } これらを継承して作成したクラスをシングルトンで保持する部分は隠蔽せず、実装側に任せます。 作成したライブラリの該当コードを次のとおりです。(Kotlin版 / Swift版) 複雑さを軽減する5つの要素をまとめる ここまでで、以下の5つの要素を実装に落とし込むための実装のパーツを紹介してきました。 今回はデータ通知の仕組みにKotlin Coroutines FlowやCombine Frameworkを使って表現しましたが、RxJavaやRxSwift、Stream APIやReactiveSwiftなど他の近しい技術を使っても実現できるはずです。 また、データの取得先を抽象化する処理についても解説したフローチャートのような分岐処理を記述することはさほど難しくはないと思います。 これらのパーツを組み合わせつつ、パラメータによる細かい調整を可能にしつつ共通化出来る部分を整えたのが今回紹介したStoreFlowableとなります。 ライブラリ内では様々なパターンを考慮して抽象化している部分が多々ありますが、特定のプロダクトに特化すればもっと簡易な仕組みを自作するだけでも十分機能すると思います。 次回記事に続く 今回の記事では考え方をコードに表現するにあたっての実装のキモとなる部分を作成したライブラリを元に紹介しました。 次回はライブラリの具体的な使い方について解説してみます。 次回記事: iOS/Androidアプリにおける状態管理の複雑さにリポジトリパターンを拡張して立ち向かう(3/3 ライブラリ使い方編)