美文网首页Android技术知识
从 Android 官方 App 中学到了什么?

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

作者: 搬砖小老弟 | 来源:发表于2022-05-19 13:53 被阅读0次

    转载地址:https://juejin.cn/post/7099245635269820453

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

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

    目标&要求

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

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

    架构概述

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

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

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

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

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

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

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

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

    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 依赖以下数据源:

    数据同步

    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 中。

    构建 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 官方相关的资料:

    相关文章

      网友评论

        本文标题:从 Android 官方 App 中学到了什么?

        本文链接:https://www.haomeiwen.com/subject/orjtprtx.html