我从 Android 官方 App 中学到了什么

最近 Android 官方开源了一个新的 App:Now in Android[1] ,这个 App 主要展示了其他 App 可能没有的一些最佳实践、架构设计、以及完整的线上 App (后面会发布到 Google Play 商店中)解决方案,其次是帮助开发者及时了解到自己感兴趣的 Android 开发领域。现在已经在 GitHub[2] 中开源。

通过这篇文章你可以了解到 Now in Android 的应用架构:分层、关键类以及他们之间的交互。

目标&要求

App 的架构目标有以下几点:

  • 尽可能遵循 官方架构指南[3] 。

  • 易于开发人员理解,没有什么太实验性的特性。

  • 支持多个开发人员在同一个代码库上工作。

  • 在开发人员的机器上和使用持续集成 (CI) 促进本地和仪器测试。

  • 最小化构建时间。

架构概述

App 目前包括 Data layer[4] 和 UI layer[5] ,Domain layer[6] 正在开发中。

 

Diagram showing overall app architecture 

该架构遵循单向数据流[7]的响应式编程方式。Data Layer 位于底层,主要包括:

  • UI Layer 需对 Data Layer 的变化做出反应。

  • 事件应向下流动。

  • 数据/状态应向上流动。

数据流是采用 Kotlin Flows 来实现的。

示例:在 For you 页面展示新闻信息

App 首次运行的时候,会尝试从云端加载新闻列表(选择 staging 或 release 构建变体时,debug 构建将使用本地数据)。加载后,这些内容会根据用户选择的兴趣显示给用户。

下图详细展示了事件以及数据是流转的。

Diagram showing how news resources are displayed on the For You screen

下面是每一步的详细过程。Code 列中的内容是对应的代码,可以下载项目后在 Android Studio 查看。

 

步骤描述Code
1App 启动的时候,WorkManager[8] 的同步任务会把所有的 Repository 添加到任务队列中。SyncInitializer.create
2初始状态会设置为 Loading,这样会在 UI 页面上展示一个旋转的动画。ForYouFeedState.Loading
3WorkManager 开始执行 OfflineFirstNewsRepository 中的同步任务,开始同步远程的数据源。SyncWorker.doWork
4OfflineFirstNewsRepository 开始调用 RetrofitNiaNetwork  开始使用 Retrofit[9] 进行真正的网络请求。OfflineFirstNewsRepository.syncWith
5RetrofitNiaNetwork 调用云端接口。RetrofitNiaNetwork.getNewsResources
6RetrofitNiaNetwork 接收到远程服务器返回的数据。RetrofitNiaNetwork.getNewsResources
7OfflineFirstNewsRepository 通过 NewsResourceDao 将远程数据更新(增删改查)到本地的 Room[10] 数据库中。OfflineFirstNewsRepository.syncWith
8当 NewsResourceDao 中的数据发生变化的时候,其会被更新到新闻的数据流(Flow[11])中。NewsResourceDao.getNewsResourcesStream
9OfflineFirstNewsRepository 扮演数据流中的 中间操作符[12], 将 PopulatedNewsResource (数据层内部数据库的一个实体类) 转换成公开的 NewsResource 实体类供其他层使用。OfflineFirstNewsRepository.getNewsResourcesStream
10当 ForYouViewModel 接收到 Success 成功, ForYouScreen 会使用新的 State 来渲染页面。页面将会展示最新的新闻内容。ForYouFeedState.Success

Data Layer

数据层包含 App 数据以及业务逻辑,会优先提供本地离线数据,它是 App 中所有数据的唯一信源。

每个 Repository 中都有自己的实体类(model/entity)。如,TopicsRepository 包含 Topic  实体类,  NewsRepository 包含 NewsResource 实体类。

Repository 是其他层的公共的 API,提供了访问 App 数据的唯一途径。Repository 通常提供一种或多种数据读取和写入的方法。

读取数据

数据通过数据流提供。这意味着 Repository 的调用者都必须准备好对数据的变化做出响应。数据不会作为快照(例如 getModel )提供,因为无法确保它在使用时仍然有效。

Repository 以本地存储数据作为单一信源,因此从实例读取时不会出现错误。但是,当尝试将本地存储中的数据与云端数据进行合并时,可能会发生错误。有关错误的更多信息,请查看下面的数据同步部分。

示例:读取作者信息

可以用过订阅 AuthorsRepository::getAuthorsStream 发出的流来获得 List<Authors> 信息。每当作者列表更改时(例如,添加新作者时),更新后的 List<Author> 的内容都会发送到数据流中。如下:

class OfflineFirstTopicsRepository @Inject constructor(  
    private val topicDao: TopicDao,  
    private val network: NiANetwork,  
    private val niaPreferences: NiaPreferences,  
) : TopicsRepository {  

 // 监听 Room 数据的变化,当数据发生变化的时候,调用者就会收到对应的数据
    override fun getTopicsStream(): Flow<List<Topic>> = topicDao.getTopicEntitiesStream().map {  
        it.map(TopicEntity::asExternalModel)  
    }

 // ...
}

写入数据

为了写入数据,Repository 库提供了 suspend 函数。由调用者来确保它们在合适的 scope 中被执行。

示例:关注 Topic

调用 TopicsRepository.setFollowedTopicId 将用户想要关注的 topic id 传入即可。

在 OfflineFirstTopicsRepository 中定义:

interface TopicsRepository : Syncable {

    suspend fun setFollowedTopicIds(followedTopicIds: Set<String>)
    
}

在 ForYouViewModel 中定义:

class ForYouViewModel @Inject constructor(
    private val topicsRepository: TopicsRepository,
    // ...
) : ViewModel() {
    // ...

    fun saveFollowedInterests() {
        // ...
        viewModelScope.launch {
            topicsRepository.setFollowedTopicIds(inProgressTopicSelection)
            // ...
        }
    }
}

数据源(Data Sources)

Repository 可能依赖于一个或多个 DataSource。例如,OfflineFirstTopicsRepository 依赖以下数据源:

名称使用目的
TopicsDaoRoom/SQLite[13]持久化和 Topics 相关的关系型数据。
NiaPreferencesProto DataStore[14]持久化和用户相关的非结构化偏好数据,主要是用户感兴趣的 Topics 内容。这里使用的是 .proto 文件。
NiANetworkRetrofit[15]云端以 JSON 形式提供对应的 Topics 数据。

数据同步

Repository 的职责之一就是整合本地数据与云端数据。一旦从云端返回数据就会立即将其写入本地数据中。更新后的数据将会从本地数据(Room)中发送到相关的数据流中,调用者便可以监听到对应的变化。

这种方法可确保应用程序的读取和写入关注点是分开的,不会相互干扰。

 

在数据同步过程中出现错误的情况下,应采用对应的回退策略。App 中是经由 SyncWorker 代理给 WorkManager  的。SyncWorker 是 Synchronizer 的实现类。

可以通过 OfflineFirstNewsRepository.syncWith 来查看数据同步的示例,如下:

class OfflineFirstNewsRepository @Inject constructor(
    private val newsResourceDao: NewsResourceDao,
    private val episodeDao: EpisodeDao,
    private val authorDao: AuthorDao,
    private val topicDao: TopicDao,
    private val network: NiANetwork,
) : NewsRepository {

    override suspend fun syncWith(synchronizer: Synchronizer) =
        synchronizer.changeListSync(
            versionReader = ChangeListVersions::newsResourceVersion,
            changeListFetcher = { currentVersion ->
                network.getNewsResourceChangeList(after = currentVersion)
            },
            versionUpdater = { latestVersion ->
                copy(newsResourceVersion = latestVersion)
            },
            modelDeleter = newsResourceDao::deleteNewsResources,
            modelUpdater = { changedIds ->
                val networkNewsResources = network.getNewsResources(ids = changedIds)
                topicDao.insertOrIgnoreTopics(
                    topicEntities = networkNewsResources
                        .map(NetworkNewsResource::topicEntityShells)
                        .flatten()
                        .distinctBy(TopicEntity::id)
                )
                // ...
            }
        )
}

UI Layer

UI Layer 包含:

  • Jetpack Compose 中的 UI  元素。

  • Android ViewMode 。

ViewModel 从 Repository 接收数据流并将其转换为 UI State。UI 元素根据 UI State 进行渲染,并为用户提供了与 App 交互的方式。这些交互作为事件(UI Event)传递到对应的 ViewModel 中。

构建 UI State

UI State 一般是通过接口和 data class 来组装的密封类。State 对象只能通过数据流的转换发出。这种方法可确保:

  • UI State 始终代表底层应用程序数据 - App 中的单一信源。

  • UI 元素处理所有可能的 UI State 。

示例:For You 页面的新闻列表

For You 页面的新闻列表数据源是 ForYouFeedState ,他是一个 sealed interface 类,包含 Loading 和 Success 两种状态:

  • Loading 表示数据正在加载。

  • Success 表示数据加载成功。Success 状态包含新闻资源列表。

sealed interface ForYouFeedState {
    object Loading : ForYouFeedState
    data class Success(val feed: List<SaveableNewsResource>) : ForYouFeedState
}

ForYouScreen 中会处理 feedState 的这两种状态,如下:

private fun LazyListScope.Feed(
    feedState: ForYouFeedState,
    //...
) {
    when (feedState) {
        ForYouFeedState.Loading -> {
            // show loading
        }
        is ForYouFeedState.Success -> {
            // show feed
        }
    }
}

将数据流转换为 UI State

ViewModel 从一个或者多个 Repository 中接收数据流当做冷流[16] 。将他们一起 组合[17] 成单一的 UI State。然后使用 stateIn[18] 将冷流转换成热流。转换的状态流使 UI 元素可以读取到数据流中最后的状态。

示例: 展示已关注的话题及作者

InterestsViewModel 暴露 StateFlow<FollowingUiState> 类型的 uiState 。通过组合 4 个数据流来创建热流:

  • 作者列表

  • 已关注的作者 ID 列表

  • Topics 列表

  • 已关注 Topics 列表的 IDs

将 Author 转换为 FollowableAuthorFollowableAuthor 是对 Author 的包装类, 添加了当前用户是否已经关注了作者。对 Topic 也做了相同转换。如下:

    val uiState: StateFlow<InterestsUiState> = combine(
        authorsRepository.getAuthorsStream(),
        authorsRepository.getFollowedAuthorIdsStream(),
        topicsRepository.getTopicsStream(),
        topicsRepository.getFollowedTopicIdsStream(),
    ) { availableAuthors, followedAuthorIdsState, availableTopics, followedTopicIdsState ->

        InterestsUiState.Interests(
            // 将 Author 转换为 FollowableAuthor,FollowableAuthor 是对 Author 的包装类,
            // 添加了当前用户是否已经关注了作者
            authors = availableAuthors
                .map { author ->
                    FollowableAuthor(
                        author = author,
                        isFollowed = author.id in followedAuthorIdsState
                    )
                }
                .sortedBy { it.author.name },
            // 将 Topic 转换为 FollowableTopic,同 Author
            topics = availableTopics
                .map { topic ->
                    FollowableTopic(
                        topic = topic,
                        isFollowed = topic.id in followedTopicIdsState
                    )
                }
                .sortedBy { it.topic.name }
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
        )

两个新的列表创建了新的 FollowingUiState.Interests UiState 暴露给 UI 层。

处理用户交互

用户对 UI 元素的操作通过常规的函数调用传递给 ViewModel ,这些方法作为 lambda 表达式传递给 UI 元素。

示例:关注话题

InterestsScreen 通过 followTopic lambda 表达式传递事件,然后会调用到 InterestsViewModel.followTopic 函数。当用户点击关注话题的时候,函数将会被调用。然后 ViewModel 就会通过通知 TopicsRepository 处理对应的用户操作。

如下在  InterestsRoute 中关联 InterestsScreen 与 InterestsViewModel :

@Composable  
fun InterestsRoute(  
    modifier: Modifier = Modifier,  
    navigateToAuthor: (String) -> Unit,  
    navigateToTopic: (String) -> Unit,  
    viewModel: InterestsViewModel = hiltViewModel()  
) {  
    val uiState by viewModel.uiState.collectAsState()  
    val tabState by viewModel.tabState.collectAsState()  
  
    InterestsScreen(  
        uiState = uiState,  
        tabState = tabState,  
        followTopic = viewModel::followTopic,  
     // ...
    )  
}

@Composable  
fun InterestsScreen(  
    uiState: InterestsUiState,  
    tabState: InterestsTabState,  
    followTopic: (String, Boolean) -> Unit,  
    // ...
) {
 //...
}

扩展阅读

本文主要是根据 Now in Android 中的 Architecture Learning Journey[19] 整理而成,感兴趣的可以进一步阅读原文。除此之外,还可以进一步学习 Android 官方相关的资料:

  • Guide to app architecture[20]

  • Jetpack Compose[21]

关于架构指南部分,我之前也整理了部分对应的解读部分,大家可以移步查看

这篇文章也算是架构指南的实战篇,后续仍会更新相关主题内容,欢迎关注。

参考资料

[1]

Now in Android: https://android-developers.googleblog.com/2022/05/now-in-android-sample-app-alpha.html

[2]

GitHub: https://github.com/android/nowinandroid

[3]

官方架构指南: https://developer.android.com/jetpack/guide

[4]

Data layer: https://developer.android.com/jetpack/guide/data-layer

[5]

UI layer: https://developer.android.com/jetpack/guide/ui-layer

[6]

Domain layer: https://developer.android.com/jetpack/guide/domain-layer

[7]

单向数据流: https://developer.android.com/jetpack/guide/ui-layer#udf

[8]

WorkManager: https://developer.android.com/topic/libraries/architecture/workmanager

[9]

Retrofit: https://square.github.io/retrofit/

[10]

Room: https://developer.android.com/training/data-storage/room

[11]

Flow: https://developer.android.com/kotlin/flow

[12]

中间操作符: https://developer.android.com/kotlin/flow#modify

[13]

Room/SQLite: https://developer.android.com/training/data-storage/room

[14]

Proto DataStore: https://developer.android.com/topic/libraries/architecture/datastore

[15]

Retrofit: https://github.com/square/retrofit

[16]

流: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/index.html

[17]

组合: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/combine.html

[18]

stateIn: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/state-in.html

[19]

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

[20]

Guide to app architecture: https://developer.android.com/topic/architecture

[21]

Jetpack Compose: https://developer.android.com/jetpack/compose

转自:我从 Android 官方 App 中学到了什么?

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值