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

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

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

目标&要求

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

  • 尽可能遵循 官方架构指南
  • 易于开发人员理解,没有什么太实验性的特性。
  • 支持多个开发人员在同一个代码库上工作。
  • 在开发人员的机器上和使用持续集成 (CI) 促进本地和仪器测试。
  • 最小化构建时间。

架构概述

App 目前包括 Data layerUI layerDomain layer 正在开发中。

Diagram showing overall app architecture

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

  • UI Layer 需对 Data Layer 的变化做出反应。
  • 事件应向下流动。
  • 数据/状态应向上流动。

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

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

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

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

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

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

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

Data Layer

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

Diagram showing the data layer architecture

每个 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持久化和 Topics 相关的关系型数据。
NiaPreferencesProto DataStore持久化和用户相关的非结构化偏好数据,主要是用户感兴趣的 Topics 内容。这里使用的是 .proto 文件。
NiANetworkRetrofit云端以 JSON 形式提供对应的 Topics 数据。

数据同步

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

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

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

可以通过 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 包含:

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

Diagram showing the UI layer architecture

构建 UI State

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

  • UI State 始终代表底层应用程序数据 - App 中的单一信源。
  • UI 元素处理所有可能的 UI State

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

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

  • 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 中接收数据流当做冷 。将他们一起 组合 成单一的 UI State。然后使用 stateIn 将冷流转换成热流。转换的状态流使 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 中关联 InterestsScreenInterestsViewModel

@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 整理而得,感兴趣的可以进一步阅读原文。除此之外,还可以进一步学习 Android 官方相关的资料:

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

更多内容会第一时间发布在微信公众号中,欢迎大家关注:

扫码_搜索联合传播样式-标准色版.png