Loading

重新学习Android —— (二)应用架构指南

本篇文章的说明

本篇文章汇聚了Android官方文档中的部分内容并且加入了一些自己的看法,由于本人英语水平有限,很多地方可能不太准确,有问题您可以指出,也可以直接参考官方文档。

关于本篇中出现的代码,如果存在您还不了解的部分,比如coroutineWorkManagerRoom数据库LiveData等,请不要较真,关键是要理解Google所推荐的Android应用开发架构,以及这套架构带来的好处。

开始啦~

组件的限制

若干年前我学习Android时,我们大部分时间是同四大组件交互——Activity、Service、Content Provider和Broadcast Receiver,再有就是Fragment。

要澄清的概念就是:当本篇文章的后面提及“组件”,说的就是这些东西。

我们开发时,对组件的依赖很强,因为在传统的Android开发中,组件就是全部了,我们从组件中bindView,我们从组件中控制业务逻辑,我们从组件中发起请求,我们从组件中保存数据,我们从组件中更新UI,所有的操作都在组件中完成,但一个基本的事实是,组件随时可能被操作系统销毁,这是因为移动设备上的资源是很有限的。

这种情况大家应该都遇到过,比如我应用的某个Activity被用户放到后台运行了,等用户再进去时发现Activity被重启了,用户之前操作的数据都丢失了。

所以我们不应该在组件的内存中保留数据和状态,并且组件间不应该相互依赖。

分离关注点(separation of concerns)

这个名词在Android的官方文档中随处可见,还有就是单一可信数据源。

分离关注点的含义很简单,即不将所有代码编写在组件里,组件只干处理界面和操作系统交互的逻辑,组件应尽量简洁。这有点类似一些后端MVC架构中的Controller只应包含最简单的代码。

通过数据模型驱动界面

数据模型是独立于组件的模型,用来保存应用数据。它们与界面和应用组件的生命周期没有关联,它们只会在操作系统决定从内存中移除应用进程时被销毁。

推荐的应用架构

基于上面的原则,每个应用至少有两层

  1. 界面层(UI Layer):在屏幕上显示应用数据
  2. 数据层(Data Layer):包含应用的业务逻辑并公开应用数据

可以在界面层和数据层之间添加可选的“网域层”(Domain Layer),它的作用是进一步简化和复用界面层和数据层之间的交互。

界面层

界面层所扮演的角色就是在屏幕上展示app数据,并且与用户交互。不管数据是因为用户交互还是外部输入(网络请求响应)而发生改变,界面都要更新以响应这些改变。简而言之,界面层就是从数据层读取应用数据并进行可视化展示的层。

界面层包含:

  1. 用于展示数据,接收输入的视图元素(View或Jetpack Compose)
  2. 用于保存数据、向外界提供数据及处理逻辑的状态容器(如ViewModel类)

一个基本的例子

后面会通过这个例子来对界面层的架构进行说明,在这个例子中,app允许用户做这些事:

  1. 展示可以阅读的文章
  2. 根据分类阅读文章
  3. 登录并mark文章
  4. 如果符合条件,访问一些高级功能

界面层架构

因为数据层扮演着持有、管理、提供访问app数据的角色,所以界面层必须做这些事:

  1. 使用app数据并将其转换成可以被轻松渲染的数据
  2. 为了向用户展示这些可以轻松被渲染的数据,需要将其显示到界面元素上
  3. 对用户在那些界面元素上的输入事件进行响应,并根据需要在UI数据中反应它们的效果
  4. 必要时就重复前三步

下面就是如何通过这些步骤实现界面层,包括如下任务和概念:

  1. 如何定义UI状态
  2. 使用单向数据流(UDF)作为生成和管理UI状态的方法
  3. 如何根据UDF原则通过可观察数据类型暴露UI
  4. 如何实现使用可观察UI状态的UI

UI状态(UI State)的定义

参考刚刚的例子,UI列出了一系列文章,这些app呈现给用户的文章信息就是UI状态。简而言之,UI是UI状态的可视化表现。

UI由UI元素和UI状态组成,你可以理解为UI定义了如何展示数据,而UI状态才是具体要展示的数据,任何UI状态中的变化都会被立即反映给UI。

所以根据刚刚例子的需求,我们可以定义如下两个UI状态

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,
    ...

不可变性

上面定义的UI状态中的属性都使用了val,这说明这些对象都是不可变(Immutable)对象。

这带来一个好处就是,你不能直接从UI中更改UI状态,除非UI自己就是自己状态的提供源。如果我们可以在UI中改变UI状态,比如我们从Activity中修改一篇文章的bookmark,那么这个数据就会与数据层中的数据不一致,从而引发一些潜在的bug。不可变数据类可以很好的阻止这些问题发生。

使用单向数据流管理状态

前面的示例建立的UI状态是一个不可变快照。无论如何,随着时间的推移,程序中的数据会发生变化,也就是说那些状态会被更改掉。

使用一个中间层去处理这些,定义每个事件的逻辑,转换必要的数据以创建UI是有好处的。当然,你也可以将这些逻辑定义在UI本身中,但很快,你就会发现UI做了远超它名字所暗示的工作:它是数据的拥有者、生产者、转换者等等...这会影响程序的可测试性,因为UI代码紧紧的耦合了起来,并且没有一个明确的边界。

除非UI很简单,否则UI应该只使用和显示UI状态。

State Holders

承担生产UI状态,并包含该任务必要的逻辑的责任的类称为state holders。它们的大小是很灵活的,取决于它们管理的相应的UI元素的范围,可以从一个底部Bar到全屏。

对于全屏级别的UI状态管理,一个典型的实现就是Jetpack中的ViewModel类。对于我们的新闻示例,使用一个NewsViewModel类作为生产UI状态的State Holder。

有很多方法可以对UI和状态生产者之间的依赖关系进行建模。UI和它的ViewModel之间的交互可以大体上理解为事件输入和随后的状态输出,所以它们之间的联系可以用下图表示:

UI的事件向ViewModel上流(flow up),而状态会下流(flow down),这种模式称为单向数据流(UDF)。这个模式暗示:

  1. ViewModel持有和暴露UI需要使用的UI状态,UI状态就是ViewModel转换的Application Data
  2. UI将用户事件通知给ViewModel
  3. ViewModel处理用户行为,更新状态
  4. 更新后的状态回流给UI渲染
  5. 对于任何一个导致状态改变的事件都重复上述步骤

上面的新闻App例子中,屏幕中包含一系列新闻项目,每一个新闻项目都有titledescriptionsourceauthor namepublication date和它是否被用户mark

一个用户请求mark一个新闻就是一个导致状态改变的事件,下图显示了整个的处理流程。

逻辑的类型

  1. 业务逻辑是如何处理数据更改,比如刚刚的mark新闻操作。业务逻辑通常放在“网域层”或“数据层”,从不放在UI层
  2. UI行为逻辑是如何在屏幕上显示状态改变,比如刚刚新闻的mark状态被改变后将右侧的书签图标修改为实心。

UI行为逻辑经常会卷入UI类型,比如Context,它们需要在UI中,不是在ViewModel中。如果程序规模增大,你想将它们抽取到独立的类中来加强可测试性和分离关注点,你可以创建一个简单的类来作为State Holder,它们遵循UI的生命周期,所以让它们依赖Android SDK API也没问题,而ViewModel这种不遵循UI生命周期的,就不可以依赖Android SDK API,它具有更长的生命周期。

为什么使用UDF

UDF将状态改变、状态转换和状态被使用的地方分开,这使UI仅仅做它名字所暗示的工作:监视状态改变并显示信息,将这些改变传递给ViewModel来传递用户的意图。

UDF带来如下好处:

  1. 数据一致性:UI只有单一数据源
  2. 可测试性:状态源是单独的,并且可以独立于UI进行测试
  3. 可维护性:状态的变化遵循一种定义良好的模式

暴露UI状态

因为你使用了UDF模型来管理和生产状态,那么随着时间的推移,程序中的状态会不断更替。所以,你应该将UI状态暴露在一个可观察的数据持有者中,比如Live DataState Flow,这可以让UI不用主动拉取ViewModel中的UI状态就可以响应更改。

下面的代码大致理解一下就好,不要纠结于代码是怎么写的

可以将一个可变状态转换为不可变状态进行暴露

class NewsViewModel(...) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}

ViewModel可以暴露内部改变状态的方法,发布更新给UI使用

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                _uiState.update {
                    it.copy(newsItems = newsItems)
                }
            } catch (ioe: IOException) {
                // Handle the error and notify the notify the UI when appropriate.
                _uiState.update {
                    val messages = getMessagesFromThrowable(ioe)
                    it.copy(userMessages = messages)
                 }
            }
        }
    }
}

fetchArticles拉取指定分类下的文章,无论拉取是否成功,用户都会得到响应。当成功时,ui状态的newsItems(新闻条目)被更新,而当失败时,ui状态的userMessage被更新,用户接收到一条错误消息。

额外考虑

  1. 一个UI状态对象应该处理相互关联的状态,这能进一步减少不一致状态并使代码更加便于理解。
    data class NewsUiState(
      val isSignedIn: Boolean = false,
      val isPremium: Boolean = false,
      val newsItems: List<NewsItemUiState> = listOf()
    )
    
    val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
    
  2. 暂时无法理解,不写在这了

使用UI状态

还是那句话,这里不要纠结于代码是怎么写的,看懂逻辑就行,看懂里面的思想就行。

显示正在处理操作

一个展示加载状态简单的方法是在UIState中添加一个boolean字段:

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

然后UI中会根据观察这个状态值来设置进度条的显示与否。

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state
                // of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}

显示错误信息

同样的,显示错误信息可以在UiState中提供一个其他字段

data class Message(val id: Long, val message: String)

data class NewsUiState(
    val userMessages: List<Message> = listOf(),
    ...
)

然后UI中再去观察这个状态并做出响应即可。

线程和并发

任何ViewModel中的操作都应该是主线程安全的(main safe)——即能在主线程中安全的调用,这是因为“网域层”和“数据层”负责将工作转移到其他线程。

如果ViewModel中进行耗时操作,那么它也有责任移动那些逻辑到后台线程。

数据层

界面层包含界面相关的状态和UI逻辑,而数据层包括app的数据和业务逻辑。业务逻辑是你app的价值所在,它是由真实世界的业务规则组成的,它决定你的应用数据应该如何创建、保存和修改。

这样的关注点分离允许数据层被多个屏幕所使用,在app的多个部分共享信息,并且和UI分离的业务逻辑会对测试更加友好。

数据层架构

数据层包括若干Repository(仓库)对象,每个Repository对象又持有零到多个DataSource(数据源)。你可以针对你App所处理的不同类型的数据来创建Repository类。比如,MoviesRepository类用于标识电影数据相关的类,PaymentsRepository类用表示支付数据相关的类。

仓库类的职责:

  1. 向app剩余部分暴露数据
  2. 集中更改数据
  3. 解决多个数据源之间的冲突
  4. 从程序的其它部分提取数据来源
  5. 包含业务逻辑

每一个Data Source类应该只负责处理一个数据来源,这个数据来源可以是文件、网络、本地数据库。Data Source类是应用和数据操作的系统之间的桥梁。

应用体系结构中的其他层永远不应该直接访问Data Source,数据层的入口点应该总是Repository类。

数据层暴露的数据应该是不可变的(Immutable),不会被其他类所篡改,该层也就不会陷入数据的不一致状态。而且不可变对象总是线程安全的。

遵循依赖注入的最佳实践,Data Source应作为一个依赖放在Repository的构造函数中:

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }

暴露APIs

数据层的类通常暴露执行Create、Read、Update和Delete的一次(one-shot)执行的函数和数据在一段时间内发生改变的通知。数据层应该暴露以下用例:

  1. 一次操作(one-shot operations):如果使用Kotlin的话,数据层应暴露suspend方法;如果使用Java,应该暴露一个提供通知操作结果的回调的函数。
  2. 数据在一段时间内发生改变的通知:在Kotlin中,数据层应该暴露flows,在Java中,应该暴露一个可以发射(emit)新数据的回调。
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

数据层命名规范

Repository应该使用数据类型 + Repository来命名,比如NewsRepositoryMoviesRepository

DataSource应该使用数据类型 + 数据源类型 + DataSource来命名,通常数据源类型使用更通用的RemoteLocal,比如NewsRemoteDataSourceNewsLocalDataSource,因为这样你可以更改实际的实现。如果必要,也可以使用具体的源类型,比如NewsNetworkDataSourceNewsDiskDataSource

不要在DataSource的命名中出现具体的实现细节,比如UserSharedPreferencesDataSource,因为Repository不应该知道数据是如何被DataSource所保存的,如果遵循这条规则,DataSource可以随时更改其实现。

多级Repositories

当业务需求更加复杂时,Repository可能会依赖其它的Repository,这可能是因为所需要的数据来自多个数据源的聚合,或者责任需要封装在另一个存储库中。

比如一个处理用户认证数据的Repository,UserRepository,可能需要依赖于LoginRepositoryRegistrationRepository来实现需求。

真实源

重要的一点是,每一个Repository都定义了一个单一真实来源,真是来源总是包含一致、正确、最新的数据。事实上,数据层暴露的数据总是直接来自真实源的。

为了提供脱机优先的支持,一个本地数据源——比如一个数据库——是被推荐的真实源

线程

调用DataSource和Repository应该是主线程安全的,这些类有责任在处理长时间任务时将它们的逻辑移动到合适的线程。

注意很多数据源已经提供了主线程安全的APIs(比如suspend方法调用),如RoomRetrofit。你的Repository可以在这些API可用时利用这些优势。

生命周期(Lifecycle)

只要从垃圾回收器的根可以访问到,数据层类的实例就会驻留在内存中,它们通常被你的app中的其它对象所引用。

如果类的职责对整个应用程序至关重要,你可以将该类的实例作用域设置成Application,这会让这个实例跟随应用程序的生命周期。同样,如果只是应用程序的特定部分需要重用这些类的实例,那么你就把这些实例的作用域设置成特定部分。举个例子,你可以将保存注册信息的RegistrationRepository绑定到RegistrationActivity

Represent business models

数据层中你想要暴露的数据可能是你从不同数据源中获得到的信息的一个子集。理想情况下,数据源们应该只返回程序需要的信息,但事实却并不总是这样。

举个例子,新闻程序的API服务器不仅返回了文章信息,还有编辑历史,用户评论和一些元数据:

data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)

app不需要如此多的信息,因为它只显示文章内容和一些它作者的基本信息。分割这些模型类并让你的Repository只暴露程序中的其他层实际需要的数据是一个好的实践。你可以按如下方式分割:

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

分割模型类有如下好处:

  1. 通过只留需要的数据节省内存
  2. 它能够改造外部数据类型以适应你app中的数据类型
  3. 提供了更好的关注点分离

你也可以在程序架构的任何部分定义分割模型类,比如在DataSource类中和ViewModel类中。至少当数据源接收到的数据与程序中剩余部分所需要的数据类型不匹配时,推荐你都要创建一个新的模型类。

数据操作的类型

数据层可以处理不同关键程度的操作类型:UI相关、app相关和业务逻辑相关操作

UI相关操作

UI相关操作与特定的屏幕相关联,当用户离开这些屏幕,操作就会被取消,比如显示从数据库中获取的数据。

UI相关操作由界面层触发,并且跟随调用者的生命周期,比如ViewModel的生命周期。

App相关操作

App相关操作与应用程序相关,当应用程序关闭或者进程被杀掉,这些操作就被取消,一个例子是收集网络请求的结果以便一会儿使用。

这些操作通常跟随Application或数据层的生命周期。

业务相关操作

业务相关操作不可以被取消,它们会在进程死亡时幸存下来,一个例子是完成用户希望上传到它们资料页上的照片。

推荐使用WorkManager来完成业务相关操作。

暴露错误

与Repository和DataSource的交互可以成功,也可以在失败时抛出一个异常。UI层在调用数据层时处理异常。

数据层可以理解并处理不同种类的错误并通过自定义异常来暴露它们,比如UserNotAuthenticatedException

注意,另一种模型是使用Result类。数据层返回一个Result<T>而不是T,这告诉其他层可能会有错误。这在一些没有合适的异常处理的响应式编程API中是必要的,比如LiveData

常见任务

下面是一个如何使用和建模数据层来完成app中常见的具体任务。还是使用那个新闻app的例子。

创建网络请求

这是Android应用最常见的任务了,新闻app需要展示给用户从网络上拉取的最新新闻,所以app需要一个DataSource类来管理网络操作:NewsRemoteDataSource。为了将信息暴露给应用程序的其它部分,我们创建一个NewsRepository来处理新闻数据上的操作。

需求是当用户打开屏幕时最新的新闻总会被更新,所以这是一个UI相关操作。

创建数据源

DataSource需要暴露一个方法来返回最新的新闻,还需要提供一个主线程安全的方式来从网络上获取最新新闻。因此,它需要依赖CoroutineDispatcherExecutor来运行任务。

class NewsRemoteDataSource(
  private val newsApi: NewsApi,
  private val ioDispatcher: CoroutineDispatcher
) {
    /**
     * Fetches the latest news from the network and returns the result.
     * This executes on an IO-optimized thread pool, the function is main-safe.
     */
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        // Move the execution to an IO-optimized thread since the ApiService
        // doesn't support coroutines and makes synchronous requests.
        withContext(ioDispatcher) {
            newsApi.fetchLatestNews()
        }
    }
}

// Makes news-related network synchronous requests.
interface NewsApi {
    fun fetchLatestNews(): List<ArticleHeadline>
}

NewsApi接口隐藏了网络API客户端实现的细节,这使得这个接口到底是使用Retrofit还是HttpURLConnection来发起的网络请求变得不重要。依赖于接口编程让你应用中的API实现变得可更换。

除此之外,依赖于接口编程还让你的应用更加可测试。你可以在测试中随时注入虚拟的数据源实现

创建Repository

因为在该任务中,repository类并没有多余的逻辑,所以NewsRepository扮演了网络数据源的代理。

// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource
) {
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        newsRemoteDataSource.fetchLatestNews()
}

实现内存数据缓存

假设新闻App引入了新的需求,当用户打开屏幕,如果之前已经做过请求了,那么被缓存的数据会先行展示给用户,然后app会创建一个网络请求来拉取最新新闻。

新需求使得app打开时应用必须在内存中保存最新的消息,这是一个app相关的操作。

缓存网络请求的结果

简单起见,NewsRepository使用一个可变变量来缓存最新新闻,为了提供在不同线程进行读写的保护,使用了一个Mutex互斥量。

class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}

创建比screen存活时间更长的操作

如果用户在网络请求过程中离开屏幕,那么网络请求会被取消,结果也不会被缓存。NewsRepository不应该使用调用者的CoroutineScope来执行这个逻辑,而是使用一个跟随它自己生命周期的CoroutineScope拉取最新新闻应该是一个app相关的操作

遵循依赖注入的最佳实践,NewsRepository应该接收一个CoroutineScope的参数,而不是自己创建一个CoroutineScope

class NewsRepository(
    ...,
    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
    private val externalScope: CoroutineScope
) { ... }

Because NewsRepository is ready to perform app-oriented operations with the external CoroutineScope, it must perform the call to the data source and save its result with a new coroutine started by that scope:

class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource,
    private val externalScope: CoroutineScope
) {
    /* ... */

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        return if (refresh) {
            externalScope.async {
                newsRemoteDataSource.fetchLatestNews().also { networkResult ->
                    // Thread-safe write to latestNews.
                    latestNewsMutex.withLock {
                        latestNews = networkResult
                    }
                }
            }.await()
        } else {
            return latestNewsMutex.withLock { this.latestNews }
        } 
    }
}

async is used to start the coroutine in the external scope. await is called on the new coroutine to suspend until the network request comes back and the result is saved to the cache. If by that time the user is still on the screen, then they will see the latest news; if the user moves away from the screen, await is canceled but the logic inside async continues to execute.

从磁盘保存和检索数据

假设你要存储类似用户mark的新闻或用户首选项,这种类型的数据需要在进程死亡后仍然存在,并且即使用户没有连接到网络也可以访问。此时,你需要通过如下方式将它们保存到磁盘上:

  1. 对于需要增删改查,参照完整性的大型数据集,将它们存储在Room数据库中。在新闻App案例中,新闻和作者都将被存储在数据库中
  2. 对于只需要检索和设置(不需要部分查询和更改)的数据,使用DataStore。在新闻App案例中,用户首选项保存在DataStore中。DataStore非常适合存储用户首选项这种键值对数据。
  3. 对于像JSON这样的数据块,使用文件存储。

Room作为数据源

因为每个数据源只负责与一个特定类型数据的来源进行工作,所以一个Room数据源可能需要接受一个DAO(数据访问对象)或数据库实例本身作为参数。比如NewsLocalDataSource可能需要一个NewsDao实例作为参数。

在某些情况下,如果不需要额外的逻辑,你可以直接将DAO注入到Repository中,因为Dao是一个你可以轻松在测试中替换的接口。

使用WorkManager调度任务

假设又来了一个新需求,只要设备在充电状态并且已经连接到不计量网络,app就要定时的从网络自动拉取最新的新闻。这是一个业务相关的操作。这一需求使得用户打开app时,即使没有连接,也能看到最近的新闻。

WorkManager使得异步任务的调度和约束管理非常简单。推荐使用这个库来做持久性任务。要执行上面的任务,需要新建一个Worker类:FetchLatestNewsWorker。并将NewsRepository作为它的依赖项来获取最新的新闻并缓存到磁盘。

class RefreshLatestNewsWorker(
    private val newsRepository: NewsRepository,
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = try {
        newsRepository.refreshLatestNews()
        Result.success()
    } catch (error: Throwable) {
        Result.failure()
    }
}

在这个例子中,所有与新闻相关的操作必须从NewsRepository被调用,所以我们需要新建一个NewsTasksDataSource并作为它的依赖项传入

private const val REFRESH_RATE_HOURS = 4L
private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"
private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"

class NewsTasksDataSource(
    private val workManager: WorkManager
) {
    fun fetchNewsPeriodically() {
        val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(
            REFRESH_RATE_HOURS, TimeUnit.HOURS
        ).setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)
                .setRequiresCharging(true)
                .build()
        )
            .addTag(TAG_FETCH_LATEST_NEWS)

        workManager.enqueueUniquePeriodicWork(
            FETCH_LATEST_NEWS_TASK,
            ExistingPeriodicWorkPolicy.KEEP,
            fetchNewsRequest.build()
        )
    }

    fun cancelFetchingNewsPeriodically() {
        workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)
    }
}

测试

依赖注入是帮助你测试app的最佳实践。类依赖接口同外界资源交流,当你进行单元测试时,你可以注入依赖的虚假版本以使单元测试具有确定性和可靠性。

单元测试是对应用中最小功能单元的测试,它不应该引入其他模块,比如我们想测试NewsRepository的逻辑是否正常,它就不应该引入网络和数据库,如果引入了,那么测试失败时,我们还需要猜测错误到底是不是NewsRepository中的逻辑问题引出的。依赖注入让我们可以向NewsRepository中传入一个虚假版本的DataSource,它不进行网络和数据库操作,它只返回特定的虚拟数据。

网域层

网域层中封装复杂的业务逻辑或经常被复用的简单逻辑。仅在应用规模已经需要它时才添加。

网域层详情,稍后会更新

管理组件之间的依赖关系

  1. 依赖注入 :类通过定义其依赖让框架自动注入,而非类主动构造
  2. 服务定位器 :提供一个注册表,类可以从中获取其依赖项而不主动构造

官方推荐使用依赖注入模式并使用Hilt库。

常见的最佳做法

  1. 不要将数据存储在应用组件中
  2. 减少对Android类的依赖
    保证只有应用组件类依赖Android SDK API。
  3. 在应用的各个模块之间设定明确定义的职责界限
  4. 尽量少公开每个模块中的代码
  5. 考虑如何使应用每个部分可独立测试
  6. 保留尽可能多的相关数据和最新数据

参考

posted @ 2021-12-31 17:46  yudoge  阅读(664)  评论(0编辑  收藏  举报