Navigation Componentの暗黙的DeepLinkの処理を追う

背景/概要

最近FragmentベースのNavigation Componentの導入を再検討しています。

数年前はXMLNavGraphを作成し、actionで画面遷移を定義するのが一般的でしたが、バージョン 2.4.0からナビゲーションルートKotlin DSLによる動的なグラフ作成が導入されました。
また、ナビゲーションルートでの画面遷移は暗黙的DeepLinkによるものになります。

この時のFragmentのスタック管理方法が気になったため調査します。
※現時点の安定バージョンはバージョン 2.7.7

コードリーディング

ナビゲーションルートでの画面遷移でもNavController#navigateを呼び出すのは変わりません。
引数にrouteをとるメソッドを利用します。

    public fun navigate(route: String, builder: NavOptionsBuilder.() -> Unit) {
        navigate(route, navOptions(builder))
    }

    public fun navigate(
        route: String,
        navOptions: NavOptions? = null,
        navigatorExtras: Navigator.Extras? = null
    ) {
        navigate(
            NavDeepLinkRequest.Builder.fromUri(createRoute(route).toUri()).build(), navOptions,
            navigatorExtras
        )
    }

引用:NavController.kt

2つ目のnavigateメソッドでさらにnavigateメソッドを呼び出していますが、引数にNavDeepLinkRequestを渡しているのがわかります。
つまりは暗黙的DeepLinkが利用されています。

これはバージョン 2.7.7のリリースノートでも言及されています。

ID によるナビゲーションとは異なり、ルートによるナビゲーションは暗黙的ディープリンクと同じルールに従います。

ちなみに明示的DeepLinkを作成する場合は、NavDeepLinkBuilderを利用します。 参考:

デスティネーションへのディープリンクを作成する  |  Android Developers

またcreateRoute(route)では"android-app://androidx.navigation/$route"という文字列を返し、それをDeepLink用のUriに変換しています。

次に引数にNavDeepLinkRequestをとるnavigateメソッドを見てみます。

    @MainThread
    public open fun navigate(
        request: NavDeepLinkRequest,
        navOptions: NavOptions?,
        navigatorExtras: Navigator.Extras?
    ) {
        requireNotNull(_graph) {
            "Cannot navigate to $request. Navigation graph has not been set for " +
                "NavController $this."
        }
        val deepLinkMatch = _graph!!.matchDeepLink(request)
        if (deepLinkMatch != null) {
            val destination = deepLinkMatch.destination
            val args = destination.addInDefaultArgs(deepLinkMatch.matchingArgs) ?: Bundle()
            val node = deepLinkMatch.destination
            val intent = Intent().apply {
                setDataAndType(request.uri, request.mimeType)
                action = request.action
            }
            args.putParcelable(KEY_DEEP_LINK_INTENT, intent)
            navigate(node, args, navOptions, navigatorExtras)
        } else {
            throw IllegalArgumentException(
                "Navigation destination that matches request $request cannot be found in the " +
                    "navigation graph $_graph"
            )
        }
    }

引用:NavController.kt

まずNavGraph#matchDeepLinkrequestに対応するDeepLinkを探索します。
そこで見つからない場合はIllegalArgumentExceptionがスローされます。

見つかった場合はDeepLink用の引数やIntentを作成し、さらにprivatenavigateメソッドを呼び出します。

    @MainThread
    private fun navigate(
        node: NavDestination,
        args: Bundle?,
        navOptions: NavOptions?,
        navigatorExtras: Navigator.Extras?
    ) {
        navigatorState.values.forEach { state ->
            state.isNavigating = true
        }
        var popped = false
        var launchSingleTop = false
        var navigated = false
        if (navOptions != null) {
            when {
                navOptions.popUpToRoute != null ->
                    popped = popBackStackInternal(
                        navOptions.popUpToRoute!!,
                        navOptions.isPopUpToInclusive(),
                        navOptions.shouldPopUpToSaveState()
                    )
                navOptions.popUpToId != -1 ->
                    popped = popBackStackInternal(
                        navOptions.popUpToId,
                        navOptions.isPopUpToInclusive(),
                        navOptions.shouldPopUpToSaveState()
                    )
            }
        }
        val finalArgs = node.addInDefaultArgs(args)
        // Now determine what new destinations we need to add to the back stack
        if (navOptions?.shouldRestoreState() == true && backStackMap.containsKey(node.id)) {
            navigated = restoreStateInternal(node.id, finalArgs, navOptions, navigatorExtras)
        } else {
            launchSingleTop = navOptions?.shouldLaunchSingleTop() == true &&
                launchSingleTopInternal(node, args)

            if (!launchSingleTop) {
                // Not a single top operation, so we're looking to add the node to the back stack
                val backStackEntry = NavBackStackEntry.create(
                    context, node, finalArgs, hostLifecycleState, viewModel
                )
                val navigator = _navigatorProvider.getNavigator<Navigator<NavDestination>>(
                    node.navigatorName
                )
                navigator.navigateInternal(listOf(backStackEntry), navOptions, navigatorExtras) {
                    navigated = true
                    addEntryToBackStack(node, finalArgs, it)
                }
            }
        }
        updateOnBackPressedCallbackEnabled()
        navigatorState.values.forEach { state ->
            state.isNavigating = false
        }
        if (popped || navigated || launchSingleTop) {
            dispatchOnDestinationChanged()
        } else {
            updateBackStackLifecycle()
        }
    }

引用:NavController.kt

privatenavigateメソッドではようやくバックスタックの更新をします。

はじめにnavigatorStateで保持しているNavigatorの状態を更新しています。
カスタムのNavigatorを使っていなければ、navigatorStateNavGraphNavigatorActivityNavigatorDialogFragmentNavigatorFragmentNavigatorを持っています。

次にnavOptionsの内容を処理しています。

  • popUpToRoute
  • popUpToId
  • shouldRestoreState()
  • shouldLaunchSingleTop()

launchSingleTop == falseの場合は、新しいバックスタックを作成し、navigator.navigateInternal(...)を呼び出しています。

navigator.navigateInternal(...)でバックスタックを引数にとるnavigateメソッドを呼び出します。

    private fun Navigator<out NavDestination>.navigateInternal(
        entries: List<NavBackStackEntry>,
        navOptions: NavOptions?,
        navigatorExtras: Navigator.Extras?,
        handler: (backStackEntry: NavBackStackEntry) -> Unit = {}
    ) {
        addToBackStackHandler = handler
        navigate(entries, navOptions, navigatorExtras)
        addToBackStackHandler = null
    }

引用:NavController.kt

バックスタックを引数にとるnavigateメソッドはopenになっていて、FragmentNavigatorなどでoverrideされています。
ちなみにActivityNavigatorだけはoverrideしていません。

    public open fun navigate(
        entries: List<NavBackStackEntry>,
        navOptions: NavOptions?,
        navigatorExtras: Extras?
    ) {
        entries.asSequence().map { backStackEntry ->
            val destination = backStackEntry.destination as? D ?: return@map null
            val navigatedToDestination = navigate(
                destination, backStackEntry.arguments, navOptions, navigatorExtras
            )
            when (navigatedToDestination) {
                null -> null
                destination -> backStackEntry
                else -> {
                    state.createBackStackEntry(
                        navigatedToDestination,
                        navigatedToDestination.addInDefaultArgs(backStackEntry.arguments)
                    )
                }
            }
        }.filterNotNull().forEach { backStackEntry ->
            state.push(backStackEntry)
        }
    }

引用:NavController.kt

overrideされたNavigator#navigateの処理はまた別の記事で調査します。

まとめ

  • ルートによるナビゲーションは暗黙的ディープリンクと同じルール
  • 具体的なナビゲーション処理はNavigatorを実装しているFragmentNavigatorなどに移譲されている

AIフレンドリーなGitHubリポジトリ内のドキュメントにするための調査

背景/概要

生成AIの活用がコード生成からドキュメンテーションに関する活用にも広がってきた。 一例としてCopilot knowledge basesがある。

Copilot以外にも似たサービスが増えているので、それらでも活用しやすいドキュメントを書くための要点を調査する。

なお、GitHubリポジトリと連携可能なサービスを調査対象にする。

ドキュメントを活用するサービス

Copilot knowledge bases

Managing Copilot knowledge bases

  • Markdownドキュメントからナレッジベースが作られる
  • ナレッジベースはCopilot Chatのコンテキストとして使われる
  • いくつかのリポジトリを選択してナレッジベースを作れる
  • ナレッジベースに含めたいパスを指定できる
  • ナレッジベースを削除することもできる

proguard-rules.proとconsumer-rules.pro

背景/概要

Androidのライブラリモジュールを作成するとconsumer-rules.proproguard-rules.proも作成されますが、その役割を「難読化」という表面的なことしか知らずにいました。

このままではプロのAndroidエンジニアとは名乗れない気がしたので、ちゃんと調べようと思います。

本記事はその調査ログです。

アプリ難読化

「proguard」でググると、1件目には「アプリの圧縮、難読化、最適化」というAndroid Developersの記事が出てきました。

まずはそれを読み、難読化の概要を知りました。

難読化に関する要点

  • アプリサイズを小さくするアプローチの1つが難読化。
  • 難読化によりクラスとメンバーの名前を短くし、DEXファイルのサイズを小さくする。
  • Android Studioで新しいプロジェクトを作成する場合、難読化はデフォルトでは有効にならない。

▼サンプルコード:リリースビルド時に難読化を有効にする

// build.gradle.kts
android {
    buildTypes {
        getByName("release") {
            // リリースビルド時だけ、コードの圧縮、難読化、最適化を有効にする。
            isMinifyEnabled = true

            // AGPによって実行されるリソースの圧縮を有効にする。
            isShrinkResources = true

            // AGP同梱のデフォルトProGuardルールファイルを読み込む。
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    ...
}

proguard-rules.pro

R8の構成ファイルの章にはproguard-rules.proの説明がありました。

モジュール内のProGuardルールを記載するファイルなのは分かりましたが、保持するコードのカスタマイズの章で紹介された-keepルール以外の詳しい説明はなく、ProGuardマニュアルがリンクされていました。

consumer-rules.pro

consumer-rules.proproguard-rules.proと同じくProGuardルールを記載するファイルだと思いますが、「アプリの圧縮、難読化、最適化」には記載がありませんでした。

改めて「consumer-rules.pro」でググると「Android ライブラリの作成」 がヒットしました。 そしてライブラリ モジュールの開発に関する考慮事項の章に詳細がありました。

要点

  • consumerProguardFilesを利用することでAARファイルにProGuard構成ファイルを埋める
  • AARを生成しないマルチモジュールビルドにライブラリモジュールが含まれている場合は、ライブラリを使用するアプリモジュールでのみコード圧縮を実行する

つまりは、AARを生成しないマルチモジュールビルドにライブラリモジュールのconsumer-rules.proは不要そうです。

▼サンプルコード:AARファイルにProGuard構成ファイルを埋め込む

// build.gradle.kts
android {
    defaultConfig {
        consumerProguardFiles("consumer-rules.pro")
    }
    ...
}

proguard-android-optimize.txt vs proguard-android.text

どちらもAGP同梱のデフォルトProGuardルールファイルです。

proguard-android-optimize.txtの文頭のコメントを見ると、「最適化したくない場合は、この設定ファイルの代わりにproguard-android.txtを使い」という記載があるので、最適化の有無が異なるようです。

R8の構成ファイルの章ではproguard-android-optimize.txtの使用がおすすめされていますが、使用されているルールが悪いと指摘しているものもあるので、積極的には利用したくありません。(参考

まとめ

  • proguard-rules.proはモジュール内のProGuardルールを記載するファイル。
  • consumerProguardFiles("consumer-rules.pro")でAARファイルにProGuard構成ファイルを埋め込める。
  • proguard-android-optimize.txtはアプリの最適化ができるが、危険性もありそう。

Androidで安全にブラーをかける

背景

ブラー効果の流行

最近はアプリでブラー効果をよく見ます。

YouTube Music アプリでのブラー効果

ブラー効果を使う理由は、次のようなものだと思います。

  • 背景をぼかすことで、その上の文字や画像に注目させたい
  • 没入感を高める(奥行きを出す)

また注目の対象が画像の場合、それにブラーをかけたものを背景にするのをよく見ます。

アプリでブラーをかける懸念

一般的に画像処理は重いものです。
そのためブラー処理をアプリで実装すると以下ような懸念があります。

  • 端末のメモリ不足
  • パフォーマンス低下

古い端末だとメモリ不足で処理ができないケースがあります。
サイズが大きい画像の処理ではそれだけメモリが必要になりますが、メモリ不足になるとアプリがクラッシュします。

またメモリ不足にならなくても、処理に時間がかかります。
それがメインスレッドで処理された場合はアプリが固まり、ユーザにストレスを与えてしまいます。

安全にブラーをかける方法を考えよう

Androidの端末はピンキリなので、上記の懸念に向き合う必要があります。

ただ大変なので、アプリでブラー処理をしないアプローチもあります。(後述)
でも安全なブラー処理ができるなら、UIが良くなり、アプリの競争力向上につながるはずです。

ブラーをかける方法

Compose

ライブラリ

Android API

  • Modifier.blur()Android 12 以上)

AndroidView

ライブラリ

Android API

パフォーマンス計測

Jetpack MacrobenchmarkFrameTimingMetric を計測する。

テストコード: BlurBenchmark.kt

  • frameDurationCpuMs
    • フレームの生成にかかった時間
    • フレーム間の時間は60fps → 16.67ms、90fps → 11.12msなので、これより短くなければフレーム落ちする。
  • frameOverrunMs (API 29以上)
    • 正の値 → どれくらいの時間フレームの生成が遅れたか
    • 負の値 → どれくらいの時間フレームの生成が早かったか

計測結果

テスト環境

  • Pixel 7
    • Android 13
    • RAM 8 GB
    • 解像度 2400 × 1080
    • 最大 90 fps
    • 私物の実機
  • Galaxy J5
    • Android 6.0.1
    • RAM 2 GB
    • 解像度 1280 × 720
    • 最大 60 fps
    • 私物の実機

計測結果の見方

  • frameDurationCpuMsのP95の列で降順ソート
  • frameDurationCpuMs
    • 16.67ms以上なら赤色
    • 11.11ms以上なら黄色
  • frameOverrunMs
    • 負の値なら青色

Pixel 7 の計測結果

計測結果

Galaxy J5 の計測結果

Galaxy J5 の計測結果

比較検討

全体的にAndroidViewが早いので、フルComposeでなければAndroidViewを選択するのが良さそうです。

Composeが良ければ、glide-transformations を使うのが、 Modifier.blur() より良さそうです。

さらに選択肢を精査するためには、以下の観点で比較します。

  • メンテナンスのしやすさ
  • パフォーマンス

メンテナンスのしやすさ

メンテナンスしづらくなる要素には以下のようなものがあります。

  • ライブラリがメンテナンスされていない
  • ライブラリが古いAPIや非推奨APIを利用している

それぞれ比較してみたのが以下の表です。

メンテナンス観点での比較

ここであげたライブラリは全て非推奨のRenderScriptを利用しています。
そのため将来的にRenderScriptから移行が必要になった時に、ライブラリの更新を待つか、自前で実装したものに置き換える必要が出てきます。

パフォーマンス

非推奨のRenderScriptを利用しない方法は以下に絞られました。

  • AndroidView | renderscript-intrinsics-replacement-toolkit
  • AndroidView | RenderEffect(Android 12以上)
  • Compose | Modifier.blur()(Android 12以上)

Modifier.blur() はフレーム落ちが多いので、あまり使いたくありません。

AndroidViewの2つの方法は、どちらも同等のパフォーマンスです。
ちなみに公式ドキュメントには以下の記載がありました。

Android 12(API レベル 31)以降を対象としている場合は、Toolkit.blur() ではなく RenderEffect クラスを使用することをおすすめします。

https://developer.android.com/guide/topics/renderscript/migrate?hl=ja

どの方法を使うか

ここまでの比較で、以下の方法が良いと思いました。

  • Android 12 以上
    • AndroidView + RenderEffect で自前実装
  • Android 12 未満
    • AndroidView + renderscript-intrinsics-replacement-toolkit で自前実装

パフォーマンスと安全性を上げる

TODO

参考

プロダクトで安全にDataStore移行する

DataStoreの紹介

DataStoreはローカルのデータストレージのライブラリです。SharedPreferenceの代替ソリューションとしても紹介されています。

DataStoreの特徴として、以下のようなものがあります。

  • Preferences DataStoreProto DataStore の2種類がある。
  • Kotlin Coroutines と Flowをベースに実装されている。

それぞれ詳しくみていきます。

Preferences DataStore と Proto DataStore

DataStoreにはPreferences DataStoreProto DataStoreの2種類があります。

特徴的な違いは「データ格納方法」と「タイプセーフかどうか」の2点です。

Preferences DataStore Proto DataStore
データ格納方法 Key-Valueでデータ格納 Protocol Buffersを利用して型付きオブジェクトを格納
タイプセーフかどうか タイプセーフではない タイプセーフである

Preferences DataStoreの実装イメージ

ユーザの年齢を記録しておくアプリを作るとします。 記録した年齢をDataStoreに読み書きする場合は、以下のような実装になります。

data class UserPreferences(
    val age: Int
)

class UserPreferencesRepository(
    // ① DataStoreのインスタンス作成
    private val dataStore: DataStore<Preferences>
) {

    private object PreferencesKeys {
        // ② キーの定義
        val AGE: Preferences.Key<Int> = intPreferencesKey("age")
    }

    // ③ データの取得
    val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
        .map { preferences ->
            UserPreferences(
                age = preferences[PreferencesKeys.AGE] ?: -1
            )
        }

    // ④ データの更新
    suspend fun updateAge(age: Int) {
        dataStore.edit { preferences ->
            preferences[PreferencesKeys.AGE] = age
        }
    }
}

① DataStoreのインスタンス作成

DataStore 自体はインタフェースです。 データ読み取る時の data、書き込むときのupdateData() だけが公開されています。

そして、ここでは大事なことが2点あります。

  • dataFlowになっていること
  • updateData()がsuspend関数になっていること

dataFlowになっているので、利用側はこのFlowを購読することでPreferences変更を購読することができます。 これによってPreferencesに記録したデータをSingle Source of Truthとして扱いやすくなります。

またupdateData()がsuspend関数になっているということは、利用側はCoroutinesのスコープから利用する必要があります。

public interface DataStore<T> {

    public val data: Flow<T>

    public suspend fun updateData(transform: suspend (t: T) -> T): T
}

DataStore.kt - Android Code Searchから抜粋

また、DataStore<T>ジェネリクスを使っているのがわかります。 Preferences DataStore を利用するはPreferencesを使います。

Preferences自体は抽象クラスになっています。

ここで大事なことは以下の点です。

  • コンストラクタが internal になっていること
  • 内部クラスを2つ持っていること
  • Mapに似たメソッドを持っていること
public abstract class Preferences internal constructor() {

    public class Key<T> internal constructor(public val name: String) {
        ...
    }

    public class Pair<T> internal constructor(internal val key: Key<T>, internal val value: T)

    public abstract operator fun <T> contains(key: Key<T>): Boolean

    public abstract operator fun <T> get(key: Key<T>): T?

    public abstract fun asMap(): Map<Key<*>, Any>

    public fun toMutablePreferences(): MutablePreferences {
        return MutablePreferences(asMap().toMutableMap(), startFrozen = false)
    }

    public fun toPreferences(): Preferences {
        return MutablePreferences(asMap().toMutableMap(), startFrozen = true)
    }
}

Preferences.kt - Android Code Searchから抜粋

そもそもPreferencesが抽象クラスというのもありますが、Preferences() とやってインスタンスを作ることはできません。 ちなみにPreferencesを継承しているMutablePreferencesもコンストラクタがinternalになっています。

DataStore<Preferences>インスタンスを作るにはPreferenceDataStoreFactory#createを利用します。

引数が4つあります。

  • corruptionHandler → ファイルを読み込んでシリアライズする時の例外をキャッチした時に実行される。この引数に渡すReplaceFileCorruptionHandlerでは、その例外が起きた時に新しく書き込むためのデータを指定する。
  • migrationsマイグレーションの指定。
  • scope → IO処理と変換処理を行うコルーチンスコープ。デフォルト引数はCoroutineScope(Dispatchers.IO + SupervisorJob())
  • produceFile → DataStoreが動作するためのファイルを返す関数。
public object PreferenceDataStoreFactory {

    @JvmOverloads
    public fun create(
        corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
        migrations: List<DataMigration<Preferences>> = listOf(),
        scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
        produceFile: () -> File
    ): DataStore<Preferences> {
        val delegate = DataStoreFactory.create(
            serializer = PreferencesSerializer,
            corruptionHandler = corruptionHandler,
            migrations = migrations,
            scope = scope
        ) {
            val file = produceFile()
            check(file.extension == PreferencesSerializer.fileExtension) { ... }
            file
        }
        return PreferenceDataStore(delegate)
    }
}

create(...)のブロック内では、引数をさらにDataStoreFactory.create(...)に渡しているのがわかります。

PreferenceDataStoreFactory#createの引数と比べるとserializerが増えています。

  • serializerDataStore<T>で使われるT型のSerializer。T型はImmutableでなければならない。PreferencesSerializer固定。

create(...)のブロック内ではSingleProcessDataStoreインスタンスを作成しています。

public object DataStoreFactory {
    ...
    @JvmOverloads // Generate constructors for default params for java users.
    public fun <T> create(
        serializer: Serializer<T>,
        corruptionHandler: ReplaceFileCorruptionHandler<T>? = null,
        migrations: List<DataMigration<T>> = listOf(),
        scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
        produceFile: () -> File
    ): DataStore<T> =
        SingleProcessDataStore(
            produceFile = produceFile,
            serializer = serializer,
            corruptionHandler = corruptionHandler ?: NoOpCorruptionHandler(),
            initTasksList = listOf(DataMigrationInitializer.getInitializer(migrations)),
            scope = scope
        )
}

DataStoreFactory.kt - Android Code Searchから抜粋

SingleProcessDataStoreDataStore<T>の実装クラスです。つまりはDataStoreを利用している時はこのクラスが実体になっています。

/**
 * Single process implementation of DataStore. This is NOT multi-process safe.
 */
internal class SingleProcessDataStore<T>(
    private val produceFile: () -> File,
    private val serializer: Serializer<T>,
    initTasksList: List<suspend (api: InitializerApi<T>) -> Unit> = emptyList(),
    private val corruptionHandler: CorruptionHandler<T> = NoOpCorruptionHandler<T>(),
    private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
) : DataStore<T> {
    ...
}

SingleProcessDataStore.kt - Android Code Searchから抜粋

内部の処理については後述しますが、ここで重要なのは最初のコメント文に書かれています。

Single process implementation of DataStore. This is NOT multi-process safe.

ここでいう"process"は、ActivityServiceが動いているプロセスを指しています。 デフォルトでは同じアプリのActivityなどは同じプロセスで動きますが、独自のプロセスを設定することもできます。 つまりは、もし異なるプロセスからDataStoreを利用するのは安全ではないということが書かれています。

参考:プロセスとスレッドの概要 | Android Developers

ここまででDataStoreのインスタンスが作成されるまでの流れを追うことができました。

② キーの定義

Preference DataStoreはKey-Valueでデータ格納するので、そのためのキーを定義する必要があります。

キーは型はPreferences.Key<T>になります。(Preferencesの内部クラス)

ちなみにPreferences.Key<T>のコンストラクタはinternalになっています。 つまりは利用する側はPreferences.Key<String>(...)のようにインスタンスを作ることができません。

そこで、キーの定義をするためにはintPreferencesKey(...)というような関数が用意されています。

サンプルコードではval AGE: Preferences.Key<Int> = intPreferencesKey("age")というコードでキーを定義しています。 これはInt型の値を格納するためのキーを定義していますが、他の型も定義可能です。

  • Int型 → intPreferencesKey
  • Double型 → doublePreferencesKey
  • String型 → stringPreferencesKey
  • Boolean型 → booleanPreferencesKey
  • Float型 → floatPreferencesKey
  • Long型 → longPreferencesKey
  • Set<String>型 → stringSetPreferencesKey

ちなみにSharedPreferenceではDouble型を読み書きするインタフェースはありませんでした。

③ データの取得

DataStore<T>#dataからデータを取得します。dataの戻り値はFlow<T>になっていて、DataStore<Preferences>を利用している場合はPreferencesが流れてきます。 そして流れてきたPreferencesからキーを使って値を取り出します。

ここでのポイントは以下です。

  • キーに対応する値だけ流れてくるのではなく、Preferencesが流れてくる。

サンプルコードではdataStore.data.map { ... }のブロック内でPreferencesKeys.AGEを使って値を取り出していますが、他のキーの値が変わった場合は変更後のPreferencesが流れてくるので、PreferencesKeys.AGEに対応する値に変更がなくてもそのブロック内の処理は動いています。

次に、DataStore<T>#dataにデータが放出されるまでの流れについてです。

前述の通り、DataStore<T>の実体はSingleProcessDataStore<T>です。

internal class SingleProcessDataStore<T>(
    ...
) : DataStore<T> {

    override val data: Flow<T> = flow {

        val currentDownStreamFlowState = downstreamFlow.value

        if (currentDownStreamFlowState !is Data) {
            // We need to send a read request because we don't have data yet.
            actor.offer(Message.Read(currentDownStreamFlowState))
        }

        emitAll(
            downstreamFlow.dropWhile {
                if (currentDownStreamFlowState is Data<T> ||
                    currentDownStreamFlowState is Final<T>
                ) {
                    // We don't need to drop any Data or Final values.
                    false
                } else {
                    // we need to drop the last seen state since it was either an exception or
                    // wasn't yet initialized. Since we sent a message to actor, we *will* see a
                    // new value.
                    it === currentDownStreamFlowState
                }
            }.map {
                when (it) {
                    is ReadException<T> -> throw it.readException
                    is Final<T> -> throw it.finalException
                    is Data<T> -> it.value
                    is UnInitialized -> error(
                        "This is a bug in DataStore. Please file a bug at: " +
                            "https://issuetracker.google.com/issues/new?" +
                            "component=907884&template=1466542"
                    )
                }
            }
        )
    }

    ...

    @Suppress("UNCHECKED_CAST")
    private val downstreamFlow = MutableStateFlow(UnInitialized as State<T>)

    ...
}

SingleProcessDataStore.kt - Android Code Searchから抜粋

まず、現在のdownStreamFlowの状態がチェックされています。 downStreamFlowSingleProcessDataStoreのメンバになっていて、MutableStateFlow<State<T>>(...)で初期化されています。

State<T>SingleProcessDataStore.ktにファイルプライベートで定義されています。 状態は4つあります。

  • UnInitialized → 初期化前の状態
  • Data<T> → ディスクから読み込みに成功した状態。現在のディスクの値を保持している。
  • ReadException<T> → ディスクからの読み込みに失敗した状態。
  • Final<T> → データの読み書きができなくなった状態。

なお、downStreamFlowの初期値はState.UnInitializedになっています。

private sealed class State<T>

private object UnInitialized : State<Any>()

private class Data<T>(val value: T, val hashCode: Int) : State<T>() {
    fun checkHashCode() {
        check(value.hashCode() == hashCode) {
            "Data in DataStore was mutated but DataStore is only compatible with Immutable types."
        }
    }
}

private class ReadException<T>(val readException: Throwable) : State<T>()

private class Final<T>(val finalException: Throwable) : State<T>()

SingleProcessDataStore.kt - Android Code Searchから抜粋

そしてdownStreamFlowの状態がData<T>ではない時は、actor.offer(Message.Read(currentDownStreamFlowState))という呼出しをしています。

actorSingleProcessDataStoreのメンバになっていて、実体はSimpleActorであることがわかります。

SimpleActorの内部の役割は以下です。(メッセージのキューを管理しているだけなので、内部実装についての詳細は特に述べない)

  • 引数のscopeに与えたCoroutineScopeが持っているJobが完了したらonCompleteをコールバックする。
  • 上記と同じタイミングでメッセージキューに残っているものがあれば、全てonUndeliveredElementでコールバックする。
  • offer(...)でメッセージをキューに追加する。
  • メッセージのキューが消費されるたびにconsumeMessageをコールバックする。

そして、SingleProcessDataStoreSimpleActorのコールバック時に実行されている処理は以下の通りです。

  • onCompleteのコールバック時 → downstreamの状態をFinal<T>にする。activeFilesからfile絶対パスを削除する。
  • onUndeliveredElementのコールバック時 → Message.Update型のメッセージが保持している遅延処理ackを完了させる。
  • consumeMessageのコールバック時 → handleRead(...)またはhandleUpdate(...)を呼び出す。
    private val actor = SimpleActor<Message<T>>(
        scope = scope,
        onComplete = {
            it?.let {
                downstreamFlow.value = Final(it)
            }

            synchronized(activeFilesLock) {
                activeFiles.remove(file.absolutePath)
            }
        },
        onUndeliveredElement = { msg, ex ->
            if (msg is Message.Update) {
                msg.ack.completeExceptionally(
                    ex ?: CancellationException(
                        "DataStore scope was cancelled before updateData could complete"
                    )
                )
            }
        }
    ) { msg ->
        when (msg) {
            is Message.Read -> {
                handleRead(msg)
            }
            is Message.Update -> {
                handleUpdate(msg)
            }
        }
    }

つまりは、actor.offer(Message.Read(currentDownStreamFlowState))を呼び出して、そのメッセージが処理されるとhandleRead(...)が呼ばれることになります。

handleRead()では改めてdownstreamの状態がチェックされています。そしてそれぞれの状態ごとに処理が分岐しています。

  • Data → 何もしない
  • ReadException → メッセージがキューに追加された時点から状態が変わっていなければreadAndInitOrPropagateFailure()を呼び出す。
  • UnInitializedreadAndInitOrPropagateFailure()を呼び出す
  • Final → 例外を投げる
    private suspend fun handleRead(read: Message.Read<T>) {
        when (val currentState = downstreamFlow.value) {
            is Data -> {
                // We already have data so just return...
            }
            is ReadException -> {
                if (currentState === read.lastState) {
                    readAndInitOrPropagateFailure()
                }

                // Someone else beat us but also failed. The collector has already
                // been signalled so we don't need to do anything.
            }
            UnInitialized -> {
                readAndInitOrPropagateFailure()
            }
            is Final -> error("Can't read in final state.") // won't happen
        }
    }

SingleProcessDataStore.kt - Android Code Searchから抜粋

readAndInitOrPropagateFailure()ではさらにreadAndInit()を呼び出しています。もしそこで例外が起きたら、キャッチしてdownstreamの状態をReadExceptionに更新しています。

    private suspend fun readAndInitOrPropagateFailure() {
        try {
            readAndInit()
        } catch (throwable: Throwable) {
            downstreamFlow.value = ReadException(throwable)
        }
    }

readAndInit()ではdownstreamFlowの状態がUnInitializedReadExceptionの初期化ができていない状態かどうかチェックされています。

そのチェックが通ると、readDataOrHandleCorruption()が呼ばれて初期データの読み込みがされます。

private suspend fun readAndInit() {
        // This should only be called if we don't already have cached data.
        check(downstreamFlow.value == UnInitialized || downstreamFlow.value is ReadException)

        val updateLock = Mutex()
        var initData = readDataOrHandleCorruption()

        var initializationComplete: Boolean = false

        // TODO(b/151635324): Consider using Context Element to throw an error on re-entrance.
        val api = object : InitializerApi<T> {
            override suspend fun updateData(transform: suspend (t: T) -> T): T {
                return updateLock.withLock() {
                    if (initializationComplete) {
                        throw IllegalStateException(
                            "InitializerApi.updateData should not be " +
                                "called after initialization is complete."
                        )
                    }

                    val newData = transform(initData)
                    if (newData != initData) {
                        writeData(newData)
                        initData = newData
                    }

                    initData
                }
            }
        }

        initTasks?.forEach { it(api) }
        initTasks = null // Init tasks have run successfully, we don't need them anymore.
        updateLock.withLock {
            initializationComplete = true
        }

        downstreamFlow.value = Data(initData, initData.hashCode())
    }

SingleProcessDataStore.kt - Android Code Searchから抜粋

readDataOrHandleCorruption()ではreadData()が呼ばれています。

    private suspend fun readDataOrHandleCorruption(): T {
        try {
            return readData()
        } catch (ex: CorruptionException) {

            val newData: T = corruptionHandler.handleCorruption(ex)

            try {
                writeData(newData)
            } catch (writeEx: IOException) {
                // If we fail to write the handled data, add the new exception as a suppressed
                // exception.
                ex.addSuppressed(writeEx)
                throw ex
            }

            // If we reach this point, we've successfully replaced the data on disk with newData.
            return newData
        }
    }

SingleProcessDataStore.kt - Android Code Searchから抜粋

readData()ではFileInputStreamが使われて、Preferencesのデータが書き込まれているファイルを読み込みしています。

serializer.readFrom() が呼ばれていますが、Preferences DataStoreではserializerPreferencesSerializerになっています。

    private suspend fun readData(): T {
        try {
            FileInputStream(file).use { stream ->
                return serializer.readFrom(stream)
            }
        } catch (ex: FileNotFoundException) {
            if (file.exists()) {
                throw ex
            }
            return serializer.defaultValue
        }
    }

serializer.readFrom()の内部では、まずPreferencesMapCompat.readFrom(...)が呼ばれています。

internal object PreferencesSerializer : Serializer<Preferences> {
    ...

    @Throws(IOException::class, CorruptionException::class)
    override suspend fun readFrom(input: InputStream): Preferences {
        val preferencesProto = PreferencesMapCompat.readFrom(input)

        val mutablePreferences = mutablePreferencesOf()

        preferencesProto.preferencesMap.forEach { (name, value) ->
            addProtoEntryToPreferences(name, value, mutablePreferences)
        }

        return mutablePreferences.toPreferences()
    }

    ...

    private fun addProtoEntryToPreferences(
        name: String,
        value: Value,
        mutablePreferences: MutablePreferences
    ) {
        return when (value.valueCase) {
            Value.ValueCase.BOOLEAN ->
                mutablePreferences[booleanPreferencesKey(name)] =
                    value.boolean
            ...
        }
    }
}

PreferencesSerializer.kt - Android Code Searchから抜粋

PreferencesMapCompat.readFrom(...)の内部ではPreferencesProto.PreferenceMap.parseFrom(...)が呼ばれています。

class PreferencesMapCompat {
    companion object {
        fun readFrom(input: InputStream): PreferencesProto.PreferenceMap {
            return try {
                PreferencesProto.PreferenceMap.parseFrom(input)
            } catch (ipbe: InvalidProtocolBufferException) {
                throw CorruptionException("Unable to parse preferences proto.", ipbe)
            }
        }
    }

PreferencesMapCompat.kt - Android Code Searchから抜粋

PreferencesProto.PreferenceMap.parseFrom(...)の内部実装をさらに読み進めても、protobufのファイルをパースする処理が続いてあまり述べることがないのでここでは言及しませんが、ここで重要なのはPreferencesProto.PreferenceMapインスタンスを作っていることです。なお、フィールドにpreferences_というMapも保持しています。

    public static final class PreferenceMap extends GeneratedMessageLite<PreferencesProto.PreferenceMap, PreferencesProto.PreferenceMap.Builder> implements PreferencesProto.PreferenceMapOrBuilder {
        public static final int PREFERENCES_FIELD_NUMBER = 1;
        private MapFieldLite<String, PreferencesProto.Value> preferences_ = MapFieldLite.emptyMapField();
        ...


        private PreferenceMap() {
        }

PreferencesSerializer#readFrom(...)の処理に戻ると、preferencesProto.preferencesMap.forEach {...の処理があります。

preferencesProto.preferencesMapの内部処理を見ると、PreferencesProto.PreferenceMapのフィールドで保持していたpreferences_を返却しているのがわかります。

そして、値を取り出してaddProtoEntryToPreferences(...)mutablePreferencesにデータを追加していっています。

そうしてSingleProcessDataStore#readDataの処理にserializer.readFrom(...)の戻り値が来たら、readDataOrHandleCorruption()の呼び出し元のreadAndInit()まで戻ることができます。

readAndInit()内でinitDataが作られた後は、val api = object : InitializerApi<T> {という処理があります。次の行ではinitTasks?.forEach { it(api) }という処理もあり、initTasksマイグレーションの処理のリストなので、apiマイグレーション時に生じる書き込み処理を実装しているのがわかります。

マイグレーションも完了したら、downstreamの状態をDataにしてreadAndInit()の処理は終わっています。ここまで完了するとhandleRead()の呼び出し元まで戻ることができます。

さて、SingleProcessDataStore#dataのFlowビルダーのブロックまで戻ってきました。

次にemitAll(downstreamFlow.dropWhile {...の処理があります。 これはdownstreamの状態がDataまたはFinalになるまで待機して、今の状態から変わるたびにデータを放出します。なおPreferencesが放出されるのは状態がDataになった時だけです。

そしてPreferencesが放出されたら、ようやくDataStore<T>#dataの呼び出し元UserPreferencesRepositoryにデータが流れてきます。

そうしたら② キーの定義で定義したkeyを使ってPreferencesからデータを取り出しています。そしてサンプルコードではUserPreferencesというクラスでラップして扱いやすくしています。

④ データの更新

DataStore<T>のインタフェースにはupdateData()というメソッドがありますが、Preferencesを扱う場合はDataStore<Preferences>#editという拡張関数が用意されています。

この拡張関数を利用しますが、内部的にはDataStore<T>#updateData()を呼び出しています。

PreferenceDataStoreFactoryDataStoreを生成しているので、実体はPreferenceDataStoreです。DataStore<T>.dataを呼び出した時はSingleProcessDataStoreに完全にdelegateしていましたが、updateData()はオーバーライドしています。 ここでは引数のtransformを使ってPreferencesの値を書き換えています。最終的にはdelegate#updateDataを呼び出しています。

internal class PreferenceDataStore(private val delegate: DataStore<Preferences>) :
    DataStore<Preferences> by delegate {
    override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences):
        Preferences {
            return delegate.updateData {
                val transformed = transform(it)
                ...
                (transformed as MutablePreferences).freeze()
                transformed
            }
        }
}

PreferenceDataStoreFactory.kt - Android Code Searchから抜粋

delegate先のSingleProcessDataStore#updateDataを見ると、actor.offer(...)を呼び出してメッセージのキューにMessage.Updateを追加しているのがわかります。

そしてactorで追加したMessage.Updateが処理されるとhandleUpdate(...)が呼び出されます。

ここで気をつけたいのはack.await()があることで、ここでMessage.Updateに渡しているack(Deferred)が完了するまで待機しています。

internal class SingleProcessDataStore<T>(
    ...
    override suspend fun updateData(transform: suspend (t: T) -> T): T {
        val ack = CompletableDeferred<T>()
        val currentDownStreamFlowState = downstreamFlow.value

        val updateMsg =
            Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)

        actor.offer(updateMsg)

        return ack.await()
    }
    ...
}

handleUpdate()の内部ではまず、downstreamの状態をチェックしています。

状態がDataの場合はtransformAndWrite(...)が呼ばれています。

    private suspend fun handleUpdate(update: Message.Update<T>) {
        ...
        update.ack.completeWith(
            runCatching {

                when (val currentState = downstreamFlow.value) {
                    is Data -> {
                        ...
                        transformAndWrite(update.transform, update.callerContext)
                    }
                    is ReadException, is UnInitialized -> {
                        if (currentState === update.lastState) {
                            ...
                            readAndInitOrPropagateAndThrowFailure()

                            ...
                            transformAndWrite(update.transform, update.callerContext)
                        } else {
                            ...
                            throw (currentState as ReadException).readException
                        }
                    }

                    is Final -> throw currentState.finalException // won't happen
                }
            }
        )
    }

SingleProcessDataStore.kt - Android Code Searchから抜粋

transformAndWrite(...)では現在のデータと新しいデータの比較がされています。同じデータだった場合は現在のデータcurDataを返しますが、異なるデータだった場合はwriteData(...)が呼ばれています。

    private suspend fun transformAndWrite(
        transform: suspend (t: T) -> T,
        callerContext: CoroutineContext
    ): T {
        // value is not null or an exception because we must have the value set by now so this cast
        // is safe.
        val curDataAndHash = downstreamFlow.value as Data<T>
        curDataAndHash.checkHashCode()

        val curData = curDataAndHash.value
        val newData = withContext(callerContext) { transform(curData) }

        // Check that curData has not changed...
        curDataAndHash.checkHashCode()

        return if (curData == newData) {
            curData
        } else {
            writeData(newData)
            downstreamFlow.value = Data(newData, newData.hashCode())
            newData
        }
    }

SingleProcessDataStore.kt - Android Code Searchから抜粋

writeData(...)では新しいデータをファイルに書き込んでいます。

まずfile.createParentDirectories()が呼ばれて、ファイルの親ディレクトリが作られます。

その後はFileOutputStreamを使用してファイルの書き込みがされています。.use { ...の中ではserializer.writeTo(...)が呼ばれていて、serializerPreferencesSerializerなのでそこでPreferences用のファイルの書き込みがされています。またこのserializer.writeTo(...)はsuspned関数になっていて、書き込み完了するまで中断しているのがわかります。

    internal suspend fun writeData(newData: T) {
        file.createParentDirectories()

        val scratchFile = File(file.absolutePath + SCRATCH_SUFFIX)
        try {
            FileOutputStream(scratchFile).use { stream ->
                serializer.writeTo(newData, UncloseableOutputStream(stream))
                stream.fd.sync()
                // TODO(b/151635324): fsync the directory, otherwise a badly timed crash could
                //  result in reverting to a previous state.
            }

            if (!scratchFile.renameTo(file)) {
                throw IOException(
                    ...
                )
            }
        } catch (ex: IOException) {
            if (scratchFile.exists()) {
                scratchFile.delete() // Swallow failure to delete
            }
            throw ex
        }
    }

SingleProcessDataStore.kt - Android Code Searchから抜粋

書き込みの処理が終わってtransformAndWrite()に処理が戻るとdownstreamの状態を新しいデータで更新しています。

handleUpdateの処理に戻って、downstreamの状態がReadExceptionUnInitializedの時の処理を見ると、まずreadAndInitOrPropagateAndThrowFailure()が呼ばれています。

downstreamの状態がReadExceptionUnInitializedだとDataStoreの初期化ができていないので、readAndInitOrPropagateAndThrowFailure()ではreadAndInit()が呼ばれています。そこで初期が完了するとtransformAndWrite(...)が呼ばれて新しいデータの書き込みがされます。

    private suspend fun readAndInitOrPropagateAndThrowFailure() {
        try {
            readAndInit()
        } catch (throwable: Throwable) {
            downstreamFlow.value = ReadException(throwable)
            throw throwable
        }
    }

handleUpdateの処理に戻って、downstreamの状態がFinalの時の処理を見ると、例外が投げられています。

これで値を更新する一連の処理が完了しました。

Preferences Storeをプロダクトへ導入する

導入自体はとっても簡単です。

gradleに依存関係を追加して、DataStoreインスタンスを作って使い始めることができます。

    dependencies {
        implementation("androidx.datastore:datastore-preferences:1.0.0")
    }
private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES_NAME,
)

アプリ アーキテクチャとの関係

最近のアプリではアプリ アーキテクチャ ガイドを手本にしたアーキテクチャが多いと思います。またガイドの中でも述べられているようにDataStoreはアーキテクチャにおけるDataSourceの役割を果たします。

https://developer.android.com/topic/architecture/data-layer?hl=ja#architecture

したがって、DataStoreへのアクセスは、UIレイヤーから直接アクセスするのではなく、Repositoryを経由してアクセスするパターンが多くなると思います。

DataStoreのインスタンスの管理

プロダクトのアーキテクチャに合わせてDataStoreインスタンスを持ち方を考える必要があります。

サンプルコードではRepositoryのコンストラクタにDataStoreを渡していますが、注意するべき点があります。

  • ファイル( xxx.preferences_pb のようなデータを書き込む)に対して、DataStoreのインスタンスが1つだけになっていること(PreferenceDataStoreFactory#createのコメントにもあり)

これは1つのファイルを複数から操作するとデータの整合性が取れない場合があるからだと思われます。

class UserPreferencesRepository(
    private val dataStore: DataStore<Preferences>
) {

1つのファイルに対してDataStoreのインスタンスも1つにする方法としては、HiltでDataStoreインスタンスをDIする方法が紹介されています。

https://medium.com/androiddevelopers/datastore-and-dependency-injection-ea32b95704e3

ただ、個人的にはRepositoryに直接DataStoreをInjectしない方が良いと考えています。理由は以下です。

  • Repositoryのテストを書くときにinstrumentationテストのセットアップが必要になる
  • DataStoreにテストデータをセットするのが手間になる

Repositoryのロジックをテストしたいだけなのに、都度DataStoreのセットアップが必要になるのは少々手間なので、RepositoryにはFakeのクラスを差し込めるようにした方が良いと思います。

そのため、DataStoreにアクセスするためのInterfaceを作り、そのinterfaceを実装しているクラスにDataStoreインスタンスを持たせ、そのクラスをシングルトンで管理する方法にすると良いと思います。

キーの管理

DataStoreとSharedPreferenceのどちらにも言えることですが、keyの定義の重複を避ける必要があります。そのため、DataStoreの導入と併せてキーの重複をチェックできる仕組みを入れておくのが良いと思います。

例えば、sealedクラスでkeyを定義しておいて、重複がないかの単体テストを追加しておく方法があります。

class UserPreferencesDataStoreImpl @Inject constructor(
    ...
) : UserPreferencesDataStore {

  sealed class UserPreferencesKeys<T>(
    val preferencesKey: Preferences.Key<T>
  ) {

    object Age : UserPreferencesKeys<Int>(intPreferencesKey("age"))
  }

ここまでできればDataStoreを使い始める準備が出来ました。

参考URL

ブログ

  1. https://android-developers.googleblog.com/2022/03/jetpack-datastore-wrap-up.html
  2. https://medium.com/androiddevelopers/introduction-to-jetpack-datastore-3dc8d74139e7
  3. https://medium.com/androiddevelopers/all-about-preferences-datastore-cc7995679334
  4. https://medium.com/androiddevelopers/all-about-proto-datastore-1b1af6cd2879
  5. https://medium.com/androiddevelopers/datastore-and-dependency-injection-ea32b95704e3
  6. https://medium.com/androiddevelopers/datastore-and-kotlin-serialization-8b25bf0be66c
  7. https://medium.com/androiddevelopers/datastore-and-synchronous-work-576f3869ec4c
  8. https://medium.com/androiddevelopers/datastore-and-data-migration-fdca806eb1aa
  9. https://medium.com/androiddevelopers/datastore-and-testing-edf7ae8df3d8

CodeLab

  1. https://developer.android.com/codelabs/android-preferences-datastore#0

ドキュメント

  1. https://developer.android.com/guide/topics/data?hl=ja
  2. https://developer.android.com/training/data-storage/shared-preferences

【RxJava学習ログ】①基本的な5つの要素

はじめに

この記事について

この記事はRxJavaの基本的な5つの要素の学習ログです。

  • Observable
  • Operators
  • Single
  • Subject
  • Scheduler

学習のモチベーション

  • RxJavaに触れずにAndroidアプリ開発をやってきましたが、業務でRxJavaのコードを読んだり書いたりする場面が出てきたので、 RxJavaを一度学習しておきたい。
  • リアクティブプログラミングを知る。

記事中のコードについて

RxKotlinを用いたコードを載せています。

implementation("io.reactivex.rxjava3:rxkotlin:3.0.1")

ReativeXとRxJava

ReactiveXは非同期でイベント駆動型のプログラムを構成するライブラリ

RxJavaを学び始めると、まずReativeXの存在を知ります。
ReativeXについてReactiveX - Introでは以下のように説明されています。

ReactiveX is a library for composing asynchronous and event-based programs by using observable sequences.

ここで以下の2点がわかりました。

  • ReactiveXにはObservable(観測可能な)な処理の流れがあること
  • ReactiveXは非同期でイベント駆動型のプログラムを構成するライブラリであること

ちなみに「event-based programs」の部分を「event-driven programming(イベント駆動プログラミング)」と解釈しました。
イベント駆動プログラミングについて深掘りはしませんが、普段のアプリ開発でやっているsetOnClickListenerなどがまさにイベント駆動プログラミングであるとわかりました。

また、リアクティブプログラミングについても触れられています。
これについても深掘りはしませんが、「データのストリームがあり、そこにデータが放出されたのを契機に処理したい内容を宣言的に記述するプログラミング」というように解釈しました。

RxJavaはReactiveXをJavaで実装したもの

ReactiveXは様々な言語で実装されていて、JavaScript、Swift、Scalaなどの言語で実装されています。
その中にJavaで実装されたものがあり、それがRxJavaと呼ばれています。
ちなみに、他の言語についてはReactiveX - Languagesで確認できます。
(言語が異なってもReactiveXの考え方は同じなので、別の言語で実装することになってもReactiveXの知識を活かすことができる)

Observable

参考:ReactiveX - Observable

Observableは最も基本的なクラス

Observableはデータをストリームに放出します。
簡単な例として、以下のコードで1~3の数字を放出するObservableを作っています。
いずれも放出する値は同じですが、Observableの作り方は何パターンかあります。

// Observable.just(...)を使う場合
val observable: Observable<Int> = Observable.just(1,2,3)

// Observable.create(...)を使う場合
val observable: Observable<Int> = Observable.create { emitter ->
    emitter.onNext(1)
    emitter.onNext(2)
    emitter.onNext(3)
    emitter.onComplete()
}

// RxKotlinの拡張関数Iterable<T>.toObservable()を使う場合
val observable: Observable<Int> = listOf(1, 2, 3).toObservable()

EmitterのonNextでデータを放出する

Observable.create(...)を使う場合、emitter.onNext(...)という呼び出しでデータを放出しています。
emitterEmitterのインタフェースを実装していて、以下の3つのメソッドを持っています。
(正確にはObservableEmitterを実装していて他のメソッドもありますが、基本的な呼び出しである3つのメソッドを挙げます。)

  • onNext → Observableがデータを放出するためのメソッド
  • onComplete → データの放出が終わったら呼び出すメソッド
  • onError → エラーがあった時に呼び出すメソッド

ちなみに、Observable.create { emitter ->というラムダ式で書けるのはSAM変換によるものです。

Observable#subscribeで放出されたデータを受け取る

以下のコードではデータが放出された時のコールバックだけ追加していますが、エラー時のコールバックやExceptionを指定するオーバーロードされたsubscribeメソッドもあります。

observable.subscribe { value ->
    println(value)
}

Operator

参考:ReactiveX - Operators

OperatorはObservableが放出したデータに操作を加える

Operatorをコードで追加する場合は、Observableに続けて追記していきます。
以下のコードではfilter(...)オペレータを使って、Observableに放出されたデータを偶数かどうかでフィルタしています。

val observable: Observable<Int> = listOf(1, 2, 3).toObservable()

observable
    .filter { value ->
        value % 2 == 0 // 偶数のみ通す
    }
    .subscribe { value ->
        println(value) // 偶数のみ出力
    }

Operatorは続けて追記することができます。
Operatorはfilter(...)のいくつもあり初めから覚えようとするのは覚えるのは困難ですが、ReactiveX - Operatorsでどんなオペレータがあるのか見ることができます。

f:id:go_takahana:20211123173757p:plain

Single

参考:ReactiveX - Single

Singleは値を一つだけ放出する

Observableと似たものにSingleがあります。
Observableは一連の値を放出しますが、Singleは常に1つだけ値を放出するかエラーを返すかします。
以下のコードでは1を放出するSingleを作成しています。

val single: Single<Int> = Single.just(1)
val single: Single<Int> = Single.create { emitter ->
    emitter.onSuccess(1)
}

SingleEmitterのonSuccessでデータを放出する

データを放出する場合はonSuccess、エラーが起きた場合はonErrorを呼び出します。
onErrorが呼び出されると、subscribeも終了します。

Single#subscribeで放出されたデータを受け取る

以下のコードではデータが放出された時のコールバックを追加しています。
またmap(...)オペレータを使っています。

single
    .map { 
        value -> value * 10 // 値を10倍する
    }
    .subscribe { value ->
        println(value)
    }

Subject

参考:ReactiveX - Subject

SubjectをObservableとしてもObserverとしても使う

SubjectはObservableとObserverを継承しています。
そのため以下のコードのように、Subjectをsubscribeすることもできるし、observerとして設定することもできます。
以下のコードを実行した場合、observableが放出した1~3の値が、observable.subscribe(subject)と書いたことによりsubjectに伝播して、subject.subscribe {...}で出力されます。

val subject = PublishSubject.create<Int>()

subject.subscribe { value ->
    println(value)
}

val observable: Observable<Int> = listOf(1, 2, 3).toObservable()

observable
    .subscribe(subject)

出力結果

1
2
3

Subjectの実装クラスを使う

Subject自体はabstractクラスです。
そのためSubjectを継承したクラスを作り、具体的な処理を記述する必要があります。
RxJavaではいくつかのSubjectを継承したクラスがあり、ユースケースに合わせて使用することができます。

それらのSubjectの説明は以下の記事が参考になりました。

Rxで知っておくと便利なSubjectたち

Scheduler

参考:ReactiveX - Scheduler

observeOn(...)でオペレータの処理をするスレッドを切り替える

以下のコードはobserveOn(Schedulers.computation())でオペレータの処理をするスレッドを切り替えています。
observeOn(...)より下に記述したオペレータの処理をするスレッドを指定することになるので、filter(...)subscribe(...)のスレッドが切り替わっています。

subscribeOn(...)でObservableが動作するスレッドを指定する

以下のコードはsubscribeOn(Schedulers.io())でObservableが動作するスレッドを指定しています。

適切なスレッドを選択する

以下のコードではスレッドをよく考えずに指定していますが、本来は用途を考えて適切なスレッドを選択する必要があります。
どのスレッドが適切なのかは以下の記事などを参考にしてください。

非同期や並列処理にも役立つRxJavaの使い方

val observable: Observable<Int> = Observable.create { emitter ->
    println(Thread.currentThread().name) // RxCachedThreadScheduler - Schedulers.io()のスレッド 
    emitter.onNext(1)
    emitter.onNext(2)
    emitter.onNext(3)
    emitter.onComplete()
}
observable
    .subscribeOn(Schedulers.io())
    .observeOn(Schedulers.computation())
    .filter { value ->
        println(Thread.currentThread().name) // RxComputationThreadPool - Schedulers.computation()のスレッド
        value % 2 == 0
    }
    .subscribe { value ->
        println(Thread.currentThread().name) // RxComputationThreadPool - Schedulers.computation()のスレッド
        println(value)
    }

さいごに

RxJavaの基本的な5つの要素を学習しましたが、実務で扱うには足りない知識がいくつもあります。
FlowableやCompletable、AndroidでRxJavaで扱うのに便利なRxAndroidなどたくさんありますが、引き続き勉強していこうと思います。

読んだドキュメント

レイアウトパラメータってStyleに書いていいの?

はじめに

StyleはViewの外観を指定する属性(attribute)の集まりです。
そのため、Viewの属性の共通化したい時に使っていると思います。

ただ最近「レイアウトパラメータってStyleに書いていいのかなぁ」と疑問に思うことがありました。

※ レイアウトパラメータと言っているのは、ViewのXMLに記述する android:layout_... から始まる属性のことです

<style name="Widget.AppTheme.Button.Green">
    <item name="android:layout_width">wrap_content</item>  // 書いていいの?
    <item name="android:layout_height">wrap_content</item> // 書いていいの?
    <item name="backgroundTint">@color/background_tint_green</item>
    <item name="android:textColor">@android:color/white</item>
</style>

もちろん書くのは問題ないし、同じレイアウトパラメータを書く手間も減ります。
でもこの記事を読んでから疑問に思うようになりました。

medium.com

気になった点

A style is a collection of view attribute values. https://medium.com/androiddevelopers/android-styling-themes-vs-styles-ebe05f917578

日本語訳すると「StyleはViewの属性の集まり」なので、これまでのStyleの説明と何ら変わらない気がします。
しかし view attribute valuesViewのXML attributesに定義されているものを指しているのかなと思いました。

そのように考えるとレイアウトパラメータの android:layout_widthandroid:layout_heightViewのXML attributesには定義されていないので、Styleに書く対象ではないと考えることもできます。

ちなみに android:layout_widthandroid:layout_heightViewGroup.LayoutParamsのXML attributesに定義されています。
そのため android:layout_widthandroid:layout_height はViewの属性というより、ViewGroup.LayoutParamsの属性といった方がより正確かもしれません。

どうするべきか

StyleにLayoutParamsを書くことは問題ありません。
ただ意識すべきことが2点あると思います。

  1. ViewのXMLに書いた android:layout_... の値をチェックしてLayoutParamsを決定するのは親のレイアウトであること(参考:レイアウト | Android Developers
  2. ViewGroup.LayoutParamsを継承するレイアウトごとの属性があること

1.について

ViewはLayoutParamsを持っていますが、どんなLayoutParamsがセットされるかは親のレイアウトによって変わります。
レイアウトパラメータの説明画像を見てもわかるように、親のレイアウトが LinearLayout なら LinearLayout.LayoutParams がセットされるし、親のレイアウトが ConstraintLayout なら ConstraintLayout.LayoutParams がセットされます。

2.について

例えば FrameLayout.LayoutParams には android:layout_gravity の属性がありますが、 RelativeLayout.LayoutParams にはありません。

方針案

主に「2. ViewGroup.LayoutParamsを継承するレイアウトごとの属性があること」によって、レイアウトパラメータをStyleに書くべきではないと思っています。
例えば android:layout_gravityFrameLayout 内では有効な属性ですが、 RelativeLayout 内では無効な属性になってしまいます。
また、言い換えるとStyleにレイアウトパラメータを書くということは、使用できる親のレイアウトを制限していると言えるかもしれません。
そのため、複数の種類のレイアウトで使う想定があるなら、レイアウトパラメータをStyleに書くべきではないと思っています。
(一方で、レイアウトの種類ごとにStyleを作るという方針もありかもしれません)

まとめ

  • android:layout_... から始まるレイアウトパラメータは、親のレイアウトによって有効な属性が違う
  • 複数の種類のレイアウトで一つのStyleを使うなら、レイアウトパラメータをStyleに書かない方が良さそう