美文网首页
Android中的协程建议

Android中的协程建议

作者: JackieZhu | 来源:发表于2023-07-18 22:02 被阅读0次

    本章介绍在应用开发实践中使用协程时,让应用更具可扩展性和可测试性带的积极影响。

    注意: 这些实践可广泛用于多数应用。但,事例仅供参数,应对整体需求时视情况调整满足具体要求。

    注入调度程序

    在创建新协程或调用withContext时,请勿对Dispatchers进行硬编码。

    // DO inject Dispatchers
    class NewsRepository(
        private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
    ) {
        suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
    }
    
    // DO NOT hardcode Dispatchers
    class NewsRepository {
        // DO NOT use Dispatchers.Default directly, inject it instead
        suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
    }
    

    这种依赖注入模式可以降低测试难度,因为可以使用[测试高度程序](#在测试中注入 TestDispatcher)替换单元测试和插桩测试中的这些高度程序,以提高测试的确定性。

    挂起函数应该能够安全地从主线调用

    挂起函数应该是主线程安全的,这意味着,可以安全地人主线程调用挂起函数。如果某个类在协程中执行长期运行的阻塞操作,那么该类负责使用withContext将执行操作移出主线程。这适用于应用中的所有类,无论其属于架构的哪个部分都不例外。

    class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {
    
        // As this operation is manually retrieving the news from the server
        // using a blocking HttpURLConnection, it needs to move the execution
        // to an IO dispatcher to make it main-safe
        suspend fun fetchLatestNews(): List<Article> {
            withContext(ioDispatcher) { /* ... implementation ... */ }
        }
    }
    
    // This use case fetches the latest news and the associated author.
    class GetLatestNewsWithAuthorsUseCase(
        private val newsRepository: NewsRepository,
        private val authorsRepository: AuthorsRepository
    ) {
        // This method doesn't need to worry about moving the execution of the
        // coroutine to a different thread as newsRepository is main-safe.
        // The work done in the coroutine is lightweight as it only creates
        // a list and add elements to it
        suspend operator fun invoke(): List<ArticleWithAuthor> {
            val news = newsRepository.fetchLatestNews()
    
            val response: List<ArticleWithAuthor> = mutableEmptyList()
            for (article in news) {
                val author = authorsRepository.getAuthor(article.author)
                response.add(ArticleWithAuthor(article, author))
            }
            return Result.Success(response)
        }
    }
    

    此模式可以提高应用的可扩展性,因为调用挂起函数的类无需担心使用哪个Dispatcher来处理哪种类型的工作。该责任将由执行相关工作的类承担。

    ViewModel应创建协程

    ViewModel类应首选创建协程,而不是公开挂起函数来执行业务逻辑。如只需要发出一个值,而不使用数据流公开状态,ViewModel 中的挂起函数就会非常有用。

    // DO create coroutines in the ViewModel
    class LatestNewsViewModel(
        private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
    ) : ViewModel() {
    
        private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
        val uiState: StateFlow<LatestNewsUiState> = _uiState
    
        fun loadNews() {
            viewModelScope.launch {
                val latestNewsWithAuthors = getLatestNewsWithAuthors()
                _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
            }
        }
    }
    
    // Prefer observable state rather than suspend functions from the ViewModel
    class LatestNewsViewModel(
        private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
    ) : ViewModel() {
        // DO NOT do this. News would probably need to be refreshed as well.
        // Instead of exposing a single value with a suspend function, news should
        // be exposed using a stream of data as in the code snippet above.
        suspend fun loadNews() = getLatestNewsWithAuthors()
    }
    

    视图层不应直接触发任何协程来执行业务 逻辑,而将这项工作委托给ViewModel。这样一来,业务逻辑就会变得更易于测试,因为可以对ViewModle对象进行单元测试,而不必使用测试视图所需的插桩测试。

    此外,如果工作是在ViewModelScope中启动,你的协程将在配置更改后自动保留。如果你改用lifecycleScope创建协程,则必须手动处理该操作。如果协程的存在时间需要比ViewModel的作用域长,请查看在业务层和数据层中创建协程部分。

    不要公开可变类型

    最好向其他类公开不可变类型。这样一来,对可变类型的所有更改会集中在一个类中,便于在出现问题时进行调试。

    // DO expose immutable types
    class LatestNewsViewModel : ViewModel() {
    
        private val _uiState = MutableStateFlow(LatestNewsUiState.Loading)
        val uiState: StateFlow<LatestNewsUiState> = _uiState
    
        /* ... */
    }
    
    class LatestNewsViewModel : ViewModel() {
    
        // DO NOT expose mutable types
        val uiState = MutableStateFlow(LatestNewsUiState.Loading)
    
        /* ... */
    }
    

    数据层和业务层应公开挂起函数和数据流

    数据层和业务层中的类通常会代开函数以执行一次性调用,或接收数据随时间变化的通知。这些层中的类应该针对一次调用公开挂起函数,并公开数据流以接收关于数据更改的通知

    // Classes in the data and business layer expose
    // either suspend functions or Flows
    class ExampleRepository {
        suspend fun makeNetworkRequest() { /* ... */ }
    
        fun getExamples(): Flow<Example> { /* ... */ }
    }
    

    采用该最佳实践后,调用方(通常是演示层)能够控制这些层中发生的工作执行和生命周期,并在需要时取消相应工作。

    在业务层和数据层创建协程

    对于数据层或业务层中因不同原因而需要创建协程的类,它们可以选择不同的选项。

    如果仅当用户查看当前屏幕时,要在这些协程中完成的工作才具有相关性,则应遵循调用方的生命周期。

    在大多数情况下,调用方是ViewModel,当用户离开屏幕并且ViewModel被清除时,调用将被取消。在这种情况下,应使用coroutineScopesupervisorScope

    class GetAllBooksAndAuthorsUseCase(
        private val booksRepository: BooksRepository,
        private val authorsRepository: AuthorsRepository,
    ) {
        suspend fun getBookAndAuthors(): BookAndAuthors {
            // In parallel, fetch books and authors and return when both requests
            // complete and the data is ready
            return coroutineScope {
                val books = async { booksRepository.getAllBooks() }
                val authors = async { authorsRepository.getAllAuthors() }
                BookAndAuthors(books.await(), authors.await())
            }
        }
    }
    

    如果只要应用处于打开状态,要完成的工作就具有相关性,并且些工作不限于特定界面,那么此工作的存在时间应该比调用方的生命周期要更长。对于这种情况,可以使用外部的CoroutineScope

    class ArticlesRepository(
        private val articlesDataSource: ArticlesDataSource,
        private val externalScope: CoroutineScope,
    ) {
        // As we want to complete bookmarking the article even if the user moves
        // away from the screen, the work is done creating a new coroutine
        // from an external scope
        suspend fun bookmarkArticle(article: Article) {
            externalScope.launch { articlesDataSource.bookmarkArticle(article) }
                .join() // Wait for the coroutine to complete
        }
    }
    

    externalScope应由存在时间比当前屏幕理长的类进行创建和管理,并且可以由Application类或者作用域限定为导航图的ViewModel进行管理。

    在测试中注入 TestDispatcher

    应在测试内的类的中注入TestDispatcher的实例。kotlinx-coroutines=test库中有两种可用的实现:

    • StandardTestDispatcher:使用调度器将已在其上启动的协程加入队列,并在测试线程不繁忙时执行这些协程。还可使用advanceUntilIdel等方法挂起测试线程,以允许其他加入队列的协程运行。
    • UnconfinedTestDispatcher:以阻塞的方式立刻执行新的协程。这样做通常可以更轻松地编写测试,但无法较好地控制测试期间协程的执行方式。

    如需了解更多详情,请参阅各个调度器程序的实现文档。

    如需测试协程,请使用runTest协程构建器。runTest使用TextCoroutineScheduler跳过测试中的延迟过程,并允许控制虚拟时间。也还可以根据需要使用此调度器创建其他测试调度程序。

    class ArticlesRepositoryTest {
    
        @Test
        fun testBookmarkArticle() = runTest {
            // Pass the testScheduler provided by runTest's coroutine scope to
            // the test dispatcher
            val testDispatcher = UnconfinedTestDispatcher(testScheduler)
    
            val articlesDataSource = FakeArticlesDataSource()
            val repository = ArticlesRepository(
                articlesDataSource,
                testDispatcher
            )
            val article = Article()
            repository.bookmarkArticle(article)
            assertThat(articlesDataSource.isBookmarked(article)).isTrue()
        }
    }
    

    所有TextDispatchers都共用同一个调度器。这样可以在单个测试线程上运行所有协程代码,从而使测试具有确定性。runTest会等待同一调度上的所有协程或测试协程的所有子协程完成运行后再返回。

    避免使用GlobalScope

    这类似于注入调度程序最佳做法。通过使用GlobalScope,将对类使用CoroutineScope 进行硬编码会带来以下问题:

    • 提高硬编码值。这将可能同时对Dispatcher进行硬编码。
    • 让测试变得非常困难,因为代码是在非受控的作用域内执行的,这将无法控制其执行。
    • 无法设置一个通用的CoroutineContext来执行对内置作用域本身的所有协程。

    可以考虑针对存在时间需要比当前作用域更长的工作注入一个CoroutineScope。

    // DO inject an external scope instead of using GlobalScope.
    // GlobalScope can be used indirectly. Here as a default parameter makes sense.
    class ArticlesRepository(
        private val articlesDataSource: ArticlesDataSource,
        private val externalScope: CoroutineScope = GlobalScope,
        private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
    ) {
        // As we want to complete bookmarking the article even if the user moves
        // away from the screen, the work is done creating a new coroutine
        // from an external scope
        suspend fun bookmarkArticle(article: Article) {
            externalScope.launch(defaultDispatcher) {
                articlesDataSource.bookmarkArticle(article)
            }
                .join() // Wait for the coroutine to complete
        }
    }
    
    // DO NOT use GlobalScope directly
    class ArticlesRepository(
        private val articlesDataSource: ArticlesDataSource,
    ) {
        // As we want to complete bookmarking the article even if the user moves away
        // from the screen, the work is done creating a new coroutine with GlobalScope
        suspend fun bookmarkArticle(article: Article) {
            GlobalScope.launch {
                articlesDataSource.bookmarkArticle(article)
            }
                .join() // Wait for the coroutine to complete
        }
    }
    

    将协程设为可取消

    协程取消属于协作操作,也就是说,在协程的Job被取消后,相应协程在挂起或检查是否存在取消操作之前不会被取消。如果在协程中执行阻塞操作,请确保相应协程是可取消的。

    例如,从磁盘读取多个文件,先检查协程是否已取消,然后再开始读取每个文件。如需检查是否取消了订阅,一种方法是调用ensureActive函数。

    someScope.launch {
        for(file in files) {
            ensureActive() // Check for cancellation
            readFile(file)
        }
    }
    

    kotlinx.coroutines 中的所有挂起函数(例如 withContextdelay)都是可取消的。如果您的协程调用这些函数,您无需执行任何其他操作。

    如需详细了解协程取消,请参阅“协程取消”这篇博文

    留意异常

    示处理协程中抛出异常可能会导致应用崩溃。如果可能会发生异常,请在使用viewModelScopelifecycleScope创建的任何协程主体中捕获相应异常。

    class LoginViewModel(
        private val loginRepository: LoginRepository
    ) : ViewModel() {
    
        fun login(username: String, token: String) {
            viewModelScope.launch {
                try {
                    loginRepository.login(username, token)
                    // Notify view user logged in successfully
                } catch (exception: IOException) {
                    // Notify view login attempt failed
                }
            }
        }
    }
    

    注意 :如需启用协程取消,请勿使用 CancellationException 类型的异常(切勿捕获这些异常,或在发生以下情况时一律重新抛出异常)被捕获)。 最好捕获特定的异常类型(如 IOException),而不是捕获通用类型(如 ExceptionThrowable)。

    相关文章

      网友评论

          本文标题:Android中的协程建议

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