📖

Now in Android分析 - 単方向データフロー

に公開

Now in AndroidはGoogle公式のAndroidコードサンプルです。

このサンプルは、理想的なアーキテクチャとアプリ開発のガイドラインを示すことを目的としているため、コードを分析するだけで最新の技術や開発ガイドラインを確認できると思います。

主な機能はグーグルで提供するAndroidやKotlin関連ニュースを配信することです。

Now in Androidのアーキテクチャ概要

Now in Androidプロジェクトでは、どのようにアーキテクチャを設計しているのかが具体的に説明されています。

https://github.com/android/nowinandroid/blob/main/docs/ArchitectureLearningJourney.md

もちろんグーグル公式サンプルのため公式文書にあるガイドラインに従っています。

https://developer.android.com/topic/architecture#app_composition

つまり、こうしたガイドラインに沿ったアーキテクチャを用いてNow in Androidアプリが作られていると言えます。

しかし、すべてのガイドラインを完璧に守っているわけではありません。

Now in Androidは、Clean Architectureを厳密な意味で完全に採用しているプロジェクトではありません。

その理由はこちらにあります。

簡単に説明すると、Clean Architectureではドメイン層(下の図ではEntities)にはビジネスロジックのみを定義する必要があります。

しかし、Now in Androidではドメイン層がデータ層に依存しているため、両者が完全に分離されているわけではありません。
実際にコードを読みながら、本当にそのような依存関係になっているのか、またなぜそのような構成にしているのかを今後確認していく予定です。

単方向データフロー(UDF)

Now in Androidは単方向データフローという方法を利用してデータを管理・処理しています。

たとえば、次のようなUIを表示するためのUiStateを持っています。

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

このUiStateはその状態を維持しながら単方向でデータを処理する必要があります。

ここで単方向というのは、データがData LayerからUI Layerへ一方向に流れ、UI Layerでのユーザー操作はイベントとしてData Layerへ伝達されることを指しています。

Now in AndroidのUI Layer

Now in AndroidでUI Layerは次の2つの要素で構成されています。

  • UI Elements
    • UIを作成
    • ユーザーとの相互作用を行う
  • ViewModel
    • UIに渡すデータを保持・提供
    • ユーザーの操作に対しその結果データをDataLayerに渡す

News Screen

Now in Androidでは次のようなパターンでUDFを実装しています。

これを一つずつどのように実現しているのかを実際のコードを見ながら確認します。

1. WorkManagerでRepositoryを実行

アプリを起動すると、Sync.initialize関数を呼び出し、すべてのリポジトリの同期処理をWorkManagerのキューにenqueueしてバックグラウンドで実行します。

object Sync {
    // This method is initializes sync, the process that keeps the app's data current.
    // It is called from the app module's Application.onCreate() and should be only done once.
    fun initialize(context: Context) {
        WorkManager.getInstance(context).apply {
            // Run sync on app startup and ensure only one sync worker runs at any time
            enqueueUniqueWork(
                SYNC_WORK_NAME,
                ExistingWorkPolicy.KEEP,
                SyncWorker.startUpSyncWork(),
            )
        }
    }
}

(このことから、syncモジュールはWorkManagerを利用して各リポジトリの同期処理を非同期に実行する役割を担っていると考えられます。)

2. NewsFeedで使うデータロード

この後、ForYouScreenというUIが表示されます。

ここで使うデータは、ForYouViewModel内のuserNewsResourceRepository.observeAllForFollowedTopics()を通じて取得し、feedStateにデータをセットします。

val feedState: StateFlow<NewsFeedUiState> =
        userNewsResourceRepository.observeAllForFollowedTopics()
            .map(NewsFeedUiState::Success)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = NewsFeedUiState.Loading,
            )

ちなみに、ForYouScreen、ForYouViewModelはfeature moduleの中にあります。
そのためfeature moduleはNow in AndroidのUI Layerを構成する部分だと考えられます。

3. UserData取得

userNewsResourceRepository.observeAllForFollowedTopics()を使うためにはuserDataが必要なのでUserDataRepositoryにあるuserDataを取得する必要があります。

override fun observeAllForFollowedTopics(): Flow<List<UserNewsResource>> =
    userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged()
        .flatMapLatest { followedTopics ->
            when {
                followedTopics.isEmpty() -> flowOf(emptyList())
                else -> observeAll(NewsResourceQuery(filterTopicIds = followedTopics))
            }
        }

この処理で利用するuserDataは、OfflineFirstUserDataRepository内のNiaPreferencesDataSourceから取得されます。

NiaPreferencesDataSourceの中を見るとuserDataはPreferencesでデータを保存・取得する役割を持っています。

internal class OfflineFirstUserDataRepository @Inject constructor(
    private val niaPreferencesDataSource: NiaPreferencesDataSource,
    private val analyticsHelper: AnalyticsHelper,
) : UserDataRepository {

    override val userData: Flow<UserData> =
        niaPreferencesDataSource.userData
    ~~~~~~

RepositoryとDataSourceはcore配下のデータ系モジュールに配置されているため、これらのモジュールがビジネスロジックやデータアクセスを担う役割を持っていると考えられます。

4. データSync

SyncWorker.doWork()内のnewsRepository.sync()からNewsデータを取得します。

override suspend fun doWork(): Result = withContext(ioDispatcher) {
    traceAsync("Sync", 0) {
        analyticsHelper.logSyncStarted()
    
        syncSubscriber.subscribe()
    
        // First sync the repositories in parallel
        val syncedSuccessfully = awaitAll(
            async { topicRepository.sync() },
            async { newsRepository.sync() },
        ).all { it }
    
        analyticsHelper.logSyncFinished(syncedSuccessfully)
    
        if (syncedSuccessfully) {
            searchContentsRepository.populateFtsData()
            Result.success()
        } else {
            Result.retry()
        }
    }
}

5. OfflineFirstNewsRepositoryからデータセットアップ

doWork()メソッドによりOfflineFirstNewsRepository.syncWithメソッドが呼び出されることが分かります。
syncWithメソッドはSynchronizer.changeListSyncメソッドを呼び出しています。

 override suspend fun syncWith(synchronizer: Synchronizer): Boolean {
        var isFirstSync = false
        return synchronizer.changeListSync(
        ~~~

Synchronizer.changeListSyncの内部を見るとmodelUpdaterを利用しデータをアップデートする動作をしていることが確認できます。

suspend fun Synchronizer.changeListSync(
    versionReader: (ChangeListVersions) -> Int,
    changeListFetcher: suspend (Int) -> List<NetworkChangeList>,
    versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions,
    modelDeleter: suspend (List<String>) -> Unit,
    modelUpdater: suspend (List<String>) -> Unit,
) = suspendRunCatching {
    ~~~
    // Using the change list, pull down and save the changes (akin to a git pull)
    modelUpdater(updated.map(NetworkChangeList::id))
    ~~~
}.isSuccess

6 & 7 getNewsResourcesメソッドを利用しNewsデータを取得

次のメソッドを利用しREST API通信でNewsデータを取得します。

val networkNewsResources = network.getNewsResources(ids = chunkedIds)

8 & 9. 取得したNewsデータをLocalに保存

newResourceDaoを利用し取得したNewsデータをLocalに保存します。

changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds ->
    val networkNewsResources = network.getNewsResources(ids = chunkedIds)
    
    // Order of invocation matters to satisfy id and foreign key constraints!
    
    topicDao.insertOrIgnoreTopics(
        topicEntities = networkNewsResources
            .map(NetworkNewsResource::topicEntityShells)
            .flatten()
            .distinctBy(TopicEntity::id),
    )
    newsResourceDao.upsertNewsResources(
        newsResourceEntities = networkNewsResources.map(
            NetworkNewsResource::asEntity,
        ),
    )
    newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
        newsResourceTopicCrossReferences = networkNewsResources
            .map(NetworkNewsResource::topicCrossReferences)
            .distinct()
            .flatten(),
    )
}

10. 保存されたデートを読み込み

UDFの実現のためREST APIから取得したデータではなく直前Dao Objectを利用し保存したデータを取得します。

    override fun getNewsResources(
        query: NewsResourceQuery,
    ): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
        useFilterTopicIds = query.filterTopicIds != null,
        filterTopicIds = query.filterTopicIds ?: emptySet(),
        useFilterNewsIds = query.filterNewsIds != null,
        filterNewsIds = query.filterNewsIds ?: emptySet(),
    ).map { it.map(PopulatedNewsResource::asExternalModel) }

11. UserDataに合わせて表示するNewsを調整

Userの設定に合わせてNewsデータをフィルタします。

CompositeUserNewsResourceRepository.observeAllメソッドを利用しUser設定に合わせたNews情報の取得することができます。

12. 最終データをセットアップ

ForYouViewModel.feedStateから最終データを取得します。

ForYouViewModel.feedStateはflow Objectのためデータが変わると自動にUI側に反映されます。

val feedState: StateFlow<NewsFeedUiState> =
    userNewsResourceRepository.observeAllForFollowedTopics()
        .map(NewsFeedUiState::Success)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = NewsFeedUiState.Loading,
        )

まとめ

上記の流れで以下の動作を確認しました。

また、UDFによりデータは1つの流れ取得から表示までされることが確認できました。

今までのプロジェクトではこのようなUDFを意識せず、APIから取得したデータをLocalに保存した後、直接ViewModelに渡すケースが多かったと認識しています。

今後はこのようなアーキテクチャを意識しながら設計や開発を行いたいと思うきっかけになりました。

Discussion