Android开发者

Android开发者 查看完整档案

北京编辑  |  填写毕业院校谷歌中国  |  开发者社区 编辑 developer.android.google.cn/ 编辑
编辑

Android 最新开发技术更新,包括 Kotlin、Android Studio、Jetpack 和 Android 最新系统技术特性分享。

个人动态

Android开发者 发布了文章 · 11月28日

使用 Jetpack Security 在 Android 上进行数据加密

作者 / Jon Markoff, Staff Developer Advocate, Android Security

您是否尝试过对应用中的数据进行加密?作为开发者,您想要保护数据安全,并确保数据掌握在其合理使用者的手中。但是,大多数 Android 开发者没有专门的安全团队来帮助他们正确地加密应用数据。就算通过网络来搜索如何加密数据,您得到的答案也可能已经过时好几年了,找到的示例也难以保证准确性。

Jetpack Security (JetSec) 加密库为 Files 和 SharedPreferences 对象的加密操作提供了抽象支持。该库使用了安全且运用广泛的 密码学原语 (cryptographic primitives),强化了 AndroidKeyStore 的使用。使用 EncryptedFile 和 EncryptedSharedPreferences 可以让您在本地保护可能包含敏感数据、API 密钥、OAuth 令牌和其他类型机密信息的文件。

从 5.0 开始,Android 会默认 对用户数据分区的内容进行加密,那您为什么还需要加密应用中的数据呢?这是因为在某些场合中,您可能需要额外的保护。如果您的应用使用 共享存储 (shared storage),则应该对数据进行加密。如果您的应用处理敏感信息,包括但不限于个人身份可识别信息 (Personally Identifiable Information, PII)、健康记录、财务信息或企业数据,那么您的应用应该对其主目录中的数据进行加密。如果可能,我们建议您将此类信息与生物验证操作绑定,以提供额外的保护。

Jetpack Security 基于 Tink,而 Tink 是 Google 的一个开源并支持跨平台的安全项目。如果您需要常规加密、混合加密或类似的安全措施,那么 Tink 可能适用于您的项目。Jetpack Security 的数据结构与 Tink 完全兼容。

密钥生成

在开始加密数据之前,首先要了解您的加密密钥是如何被保护的。Jetpack Security 使用一个主密钥 (master key) 对所有的子密钥 (subkey) 进行加密,子密钥则被用于每个加密操作。JetSec 在 MasterKeys 类中提供了建议的默认主密钥。这个类使用基础的 AES256-GCM 密钥,该密钥在 AndroidKeyStore 中生成并存储。AndroidKeyStore 是一个在 TEE 或 StrongBox 中存储加密密钥的容器,这使得其内容很难被提取。子密钥则存储在可配置的 SharedPreferences 对象中。

我们在 Jetpack Security 中主要使用 AES256_GCM_SPEC 规范,在一般的用例中很推荐使用该规范。AES256-GCM 是对称的,并且在现代设备上运算的速度通常很快。

val keyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)

对于配置更多样或处理非常敏感数据的应用,我们建议您构建自己的 KeyGenParameterSpec,选择适合您需求的选项。针对设备被 root 或遭到篡改的情况,带有 BiometricPrompt 生物验证步骤的限时密钥可以提供更高级别的保护。

重要选项:

  • userAuthenticationRequired()userAuthenticationValiditySeconds() 可以用来创建限时密钥。限时密钥需要通过 BiometricPrompt 获得授权,才能对对称密钥进行加密和解密。
  • unlockedDeviceRequired() 可以设置一个标志,用于确保在设备未解锁时不会发生密钥访问。该开关值在 Android 9 及更高版本上可用。
  • 使用 setIsStrongBoxBacked(),即可在更强大的独立芯片上运行加密操作。这会对性能带来轻微的影响,但更加安全。此功能在运行 Android 9 或更高版本的某些设备上可用。

注意: 如果您的应用需要在后台加密数据,则不应使用限时密钥或要求设备处于解锁状态,因为如果没有用户在场,您的操作将无法完成。

// Custom Advanced Master Key
val advancedSpec = KeyGenParameterSpec.Builder(
    "master_key",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).apply {
    setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    setKeySize(256)
    setUserAuthenticationRequired(true)
    setUserAuthenticationValidityDurationSeconds(15) // must be larger than 0
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        setUnlockedDeviceRequired(true)
        setIsStrongBoxBacked(true)
    }
}.build()

val advancedKeyAlias = MasterKeys.getOrCreate(advancedSpec)

解锁限时密钥

如果您的密钥是使用以下选项创建的,则必须使用 BiometricPrompt 对设备进行授权:

  • userAuthenticationRequired 值为 true
  • userAuthenticationValiditySeconds > 0

在用户进行验证后,将基于有效秒数字段中给出时长解锁密钥。AndroidKeystore 没有用于查询密钥设置的 API,因此您的应用必须自己记录这些设置。您应该在展示授权界面的 Activity 的 onCreate() 方法中构建 BiometricPrompt 实例,以引导用户进行授权操作。

用来解锁限时密钥的 BiometricPrompt 代码:

// Activity.onCreate

val promptInfo = PromptInfo.Builder()
    .setTitle("Unlock?")
    .setDescription("Would you like to unlock this key?")
    .setDeviceCredentialAllowed(true)
    .build()

val biometricPrompt = BiometricPrompt(
    this, // Activity
    ContextCompat.getMainExecutor(this),
    authenticationCallback
)

private val authenticationCallback = object : AuthenticationCallback() {
        override fun onAuthenticationSucceeded(
            result: AuthenticationResult
        ) {
            super.onAuthenticationSucceeded(result)
            // Unlocked -- do work here.
        }
        override fun onAuthenticationError(
            errorCode: Int, errString: CharSequence
        ) {
            super.onAuthenticationError(errorCode, errString)
            // Handle error.
        }
    }

To use:
biometricPrompt.authenticate(promptInfo)

加密文件

Jetpack Security 包含一个 EncryptedFile 类,它解决了加密文件数据的问题。与 File 相似,EncryptedFile 提供一个 FileInputStream 对象用于读取,一个 FileOutputStream 对象用于写入。我们使用遵循 OAE2 定义的 Streaming AHEAD 对文件进行加密。数据被分为多个区块,并使用 AES256-GCM 进行加密,使得外界无法对其进行重组。

val secretFile = File(filesDir, "super_secret")
val encryptedFile = EncryptedFile.Builder(
    secretFile,
    applicationContext,
    advancedKeyAlias,
    FileEncryptionScheme.AES256_GCM_HKDF_4KB)
    .setKeysetAlias("file_key") // optional
    .setKeysetPrefName("secret_shared_prefs") // optional
    .build()

encryptedFile.openFileOutput().use { outputStream ->
    // Write data to your encrypted file
}

encryptedFile.openFileInput().use { inputStream ->
    // Read data from your encrypted file

加密 SharedPreferences

如果您的应用需要保存键值对 (例如 API 密钥),JetSec 提供了 EncryptedSharedPreferences 类,该类使用的是您所熟知的 SharedPreferences 接口。

键和值均会被加密。键使用能提供确定性密文的 AES256-SIV-CMAC 进行加密;值则使用 AES256-GCM 进行加密,并绑定到加密的键。该方案允许对机要数据进行安全加密,同时仍然便于查询。

EncryptedSharedPreferences.create(
    "my_secret_prefs",
    advancedKeyAlias,
    applicationContext,
    PrefKeyEncryptionScheme.AES256_SIV,
    PrefValueEncryptionScheme.AES256_GCM
).edit {
    // Update secret values
}

更多资源

FileLocker 是我们准备的一个示例应用,您可以在 Android Security GitHub 示例页面上找到它。这个应用很好地展示了应该如何使用 Jetpack Security 进行文件加密。

祝大家加密愉快!

查看原文

赞 1 收藏 0 评论 0

Android开发者 发布了文章 · 11月28日

协程 Flow 最佳实践 | 基于 Android 开发者峰会应用

本文介绍了我们在开发 2019 Android 开发者峰会 (ADS) 应用时总结整理的 Flow 最佳实践 (应用源码已开源),我们将和大家共同探讨应用中的每个层级将如何处理数据流。

ADS 应用的架构遵守 Android 官方的 推荐架构指南,我们在其中引入了 Domain 层 (用以囊括各种 UseCases 类) 来帮助分离焦点,进而保持代码的精简、复用性、可测试性。

2019 ADS 应用的架构

2019 ADS 应用的架构

更多关于应用架构指南的分层设计 (Data 层、Domain 层、UI 层),请参考 示例应用 | Plaid 2.0 重构

如同许多 Android 应用一样,ADS 应用从网络或缓存懒加载数据。我们发现,这种场景非常适合 Flow。挂起函数 (suspend functions) 更适合于一次性操作。为了使用协程,我们将重构分为两次 commit 提交: 第一次 迁移了一次性操作,第二次 将其迁移至数据流。

在本文中,您将看到我们把应用从 "在所有层级使用 LiveData",重构为 "只在 View 和 ViewModel 间使用 LiveData 进行通讯,并在应用的底层和 UserCase 层架构中使用协程"。

优先使用 Flow 来暴露数据流 (而不是 Channel)

您有两种方法在协程中处理数据流: 一种是 Flow API,另一种是 Channel API。Channels 是一种同步原语,而 Flows 是为数据流模型所设计的: 它是订阅数据流的工厂。不过我们可以使用 Channels 来支持 Flows,这一点我们稍后再说。

相较于 Channel,Flow 更灵活,并提供了更明确的约束和更多操作符。

由于末端操作符 (terminal operator) 会触发数据流的执行,同时会根据生产者一侧流操作来决定是成功完成操作还是抛出异常,因此 Flows 会自动地关闭数据流,您基本不会在生产者一侧泄漏资源;而一旦 Channel 没有正确关闭,生产者可能不会清理大型资源,因此 Channels 更容易造成资源泄漏。

应用数据层负责提供数据,通常是从数据库中读取,或从网络获取数据,例如,示例 是一个数据源接口,它提供了一个用户事件数据流:

interface UserEventDataSource {
  fun getObservableUserEvent(userId: String): Flow<UserEventResult>
}

如何将 Flow 应用在您的 Android 应用架构中

1. UseCase 层和 Repository 层

介于 View/ViewModel 和数据源之间的层 (在我们的例子中是 UseCase 和 Repository) 通常需要合并来自多个查询的数据,或在 ViewModel 层使用之前转化数据。就像 Kotlin sequences 一样,Flow 支持大量操作符来转换数据。目前已经有 大量的可用的操作符,同时您也可以创建您自己的转换器 (比如,使用 transform 操作符)。不过 Flow 在许多的操作符中暴露了 suspend lambda 表达式,因此在大多数情况下没有必要通过自定义转换来完成复杂任务,可以直接在 Flow 中调用挂起函数。

在 ADS 应用中,我们想将 UserEventResult 和 Repository 层中的会话数据进行绑定。我们利用 map 操作符来将一个 suspend lambda 表达式应用在从数据源接收到的每一个 Flow 的值上:

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
class DefaultSessionAndUserEventRepository(
    private val userEventDataSource: UserEventDataSource,
    private val sessionRepository: SessionRepository
) : SessionAndUserEventRepository {

    override fun getObservableUserEvent(
        userId: String?,
        eventId: SessionId
    ): Flow<Result<LoadUserSessionUseCaseResult>> {
        // 处理 userId

        // 监听用户事件,并将其与 Session 数据进行合并
        return userEventDataSource.getObservableUserEvent(userId, eventId).map { userEventResult ->
            val event = sessionRepository.getSession(eventId)

           // 将 Session 和用户数据进行合并,并传递结果
            val userSession = UserSession(
                event,
                userEventResult.userEvent ?: createDefaultUserEvent(event)
            )
            Result.Success(LoadUserSessionUseCaseResult(userSession))
        }
    }
}

2. ViewModel

在利用 LiveData 执行 UI ↔ ViewModel 通信时,ViewModel 层应该利用末端操作符来消费来自数据层的数据流 (比如: collectfirst 或者是 toList) 。

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
// 真实代码的简化版
class SessionDetailViewModel(
    private val loadUserSessionUseCase: LoadUserSessionUseCase,
    ...
): ViewModel() {

    private fun listenForUserSessionChanges(sessionId: SessionId) {
        viewModelScope.launch {
            loadUserSessionUseCase(sessionId).collect { loadResult ->
            }
        }
    }
}

完整代码可以参考 这里.

如果您需要将 Flow 转化为 LiveData,则可以使用 AndroidX lifecycle library 提供的 Flow.asLiveData() 扩展函数 (extension function)。这个扩展函数非常便于使用,因为它共享了 Flow 的底层订阅,同时根据观察者的生命周期管理订阅。此外,LiveData 可以为后续添加的观察者提供最新的数据,其订阅在配置发生变更的时候依旧能够生效。下面利用一段简单的代码来演示如何使用这个扩展函数:

class SimplifiedSessionDetailViewModel(
  private val loadUserSessionUseCase: LoadUserSessionUseCase,
  ...
): ViewModel() {
  val sessions = loadUserSessionUseCase(sessionId).asLiveData()
}

特别说明: 这段代码不是 ADS 应用的,它只是用来演示如何使用 Flow.asLiveData()。

具体实现时,该在何时使用 BroadcastChannel 或者 Flow

回到数据源的实现,要怎样去实现之前暴露的 getObservableUserEvent 函数?我们考虑了两种实现: flow 构造器,或 BroadcastChannel 接口,这两种实现应用于不同的场景。

1. 什么时候使用 Flow ?

Flow 是一种 "冷流"(Cold Stream)。"冷流" 是一种数据源,该类数据源的生产者会在每个监听者开始消费事件的时候执行,从而在每个订阅上创建新的数据流。一旦消费者停止监听或者生产者的阻塞结束,数据流将会被自动关闭。

Flow 非常适合需要开始/停止数据的产生来匹配观察者的场景。

您可以利用 flow 构造器来发送有限个/无限个元素。

val oneElementFlow: Flow<Int> = flow {
  // 生产者代码开始执行,流被打开
  emit(1)
 // 生产者代码结束,流将被关闭
}
val unlimitedElementFlow: Flow<Int> = flow {
 // 生产者代码开始执行,流被打开
  while(true) {
    // 执行计算
    emit(result)
    delay(100)
  }
 // 生产者代码结束,流将被关闭
}

Flow 通过协程取消功能提供自动清理功能,因此倾向于执行一些重型任务。请注意,这里提到的取消是有条件的,一个永不挂起的 Flow 是永不会被取消的: 在我们的例子中,由于 delay 是一个挂起函数,用于检查取消状态,当订阅者停止监听时,Flow 将会停止并清理资源。

2. 什么时候使用 BroadcastChannel

Channel 是一个用于协程间通信的并发原语。BroadcastChannel 基于 Channel,并加入了多播功能。

可能在这样一些场景里,您可能会考虑在数据源层中使用 BroadcastChannel:

如果生产者和消费者的生命周期不同或者彼此完全独立运行时,请使用 BroadcastChannel。

如果您希望生产者有独立的生命周期,同时向任何存在的监听者发送当前数据的时候,BroadcastChannel API 非常适合这种场景。在这种情况下,当新的监听者开始消费事件时,生产者不需要每次都被执行。

您依然可以向调用者提供 Flow,它们不需要知道具体的实现。您可以使用 BroadcastChannel.asFlow() 这个扩展函数来将一个 BroadcastChannel 作为一个 Flow 使用。

不过,关闭这个特殊的 Flow 不会取消订阅。当使用 BroadcastChannel 的时候,您必须自己管理生命周期。BroadcastChannel 无法感知到当前是否还存在监听者,除非关闭或取消 BroadcastChannel,否则将会一直持有资源。请确保在不需要 BroadcastChannel 的时候将其关闭。同时请注意关闭后的 BroadcastChannel 无法再次被使用,如果需要,您需要重新创建实例。

接下来,我们将分享如何使用 BroadcastChannel API 的示例。

3. 特别说明

部分 Flow 和 Channel API 仍处于实验阶段,很可能会发生变动。在一些情况下,您可能会正在使用 Channel,不过在未来可能会建议您使用 Flow。具体来讲,StateFlow 和 Flow 的 share operator 方案可能在未来会减少 Channel 的使用。

将数据流中基于回调的 API 转化为协程

包含 Room 在内的很多库已经支持将协程用于数据流操作。对于那些还不支持的库,您可以将任何基于回调的 API 转换为协程。

1. Flow 的实现

如果您想将一个基于回调的流 API 转换为使用 Flow,您可以使用 channelFlow 函数 (当然也可以使用 callbackFlow,它们都基于相同的实现)。channelFlow 将会创建一个 Flow 的实例,该实例中的元素将传递给一个 Channel。这样可以允许我们在不同的上下文或并发中提供元素。

以下示例中,我们想要把从回调中拿到的元素发送到 Flow 中:

  1. 利用 channelFlow 构造器创建一个可以把回调注册到第三方库的流;
  2. 将从回调接收到的所有数据传递给 Flow;
  3. 当订阅者停止监听,我们利用挂 起函数 "awaitClose" 来解除 API 的订阅。
/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
override fun getObservableUserEvent(userId: String, eventId: SessionId): Flow<UserEventResult> {
     // 1) 利用 channelFlow 创建一个 Flow
    return channelFlow<UserEventResult> {

        val eventDocument = firestore.collection(USERS_COLLECTION)
            .document(userId)
            .collection(EVENTS_COLLECTION)
            .document(eventId)
    
       // 1) 将回调注册到 API 上
        val subscription = eventDocument.addSnapshotListener { snapshot, _ ->
            val userEvent = if (snapshot.exists()) {
                parseUserEvent(snapshot)
            } else { null }
            
            // 2) 将数据发送到 Flow
            channel.offer(UserEventResult(userEvent))
        }
        // 3) 请不要关闭数据流,在消费者关闭或者 API 调用 onCompleted/onError 函数之前,请保证数据流
       // 一直处于打开状态。
        // 当数据流关闭后,请取消第三方库的订阅。
        awaitClose { subscription.remove() }
    }
}

详细代码可以参考 这里

2. BroadcastChannel 实现

对于使用 Firestore 跟踪用户身份认证的数据流,我们使用了 BroadcastChannel API,因为我们希望注册一个有独立生命周期的 Authentication 监听者,同时也希望能向所有正在监听的对象广播当前的结果。

转化回调 API 为 BroadcastChannel 相比转化为 Flow 要略复杂一点。您可以创建一个类,并设置将实例化后的 BroadcastChannel 作为变量保存。在初始化期间,注册回调,像以前一样将元素发送到 BroadcastChannel:

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
class FirebaseAuthStateUserDataSource(...) : AuthStateUserDataSource {

    private val channel = ConflatedBroadcastChannel<Result<AuthenticatedUserInfo>>()

    private val listener: ((FirebaseAuth) -> Unit) = { auth ->
       // 数据处理逻辑
       
       // 将当前的用户 (数据) 发送给消费者
        if (!channel.isClosedForSend) {
            channel.offer(Success(FirebaseUserInfo(auth.currentUser)))
        } else {
            unregisterListener()
        }
    }

    @Synchronized
    override fun getBasicUserInfo(): Flow<Result<AuthenticatedUserInfo>> {
        if (!isListening) {
            firebase.addAuthStateListener(listener)
            isListening = true
        }
        return channel.asFlow()
    }
}

详细代码可以参考 这里

测试小建议

为了测试 Flow 转换 (就像我们在 UseCase 和 Repository 层中所做的那样),您可以利用 flow 构造器返回一个假数据,例如:

/* Copyright 2019 Google LLC.
   SPDX-License-Identifier: Apache-2.0 */
object FakeUserEventDataSource : UserEventDataSource {
  override fun getObservableUserEvents(userId: String) = flow {
    emit(UserEventsResult(userEvents))
  }
}
class DefaultSessionAndUserEventRepositoryTest {
  @Test
  fun observableUserEvents_areMappedCorrectly() = runBlockingTest {
   // 准备一个 repo
    val userEvents = repository
          .getObservableUserEvents("user", true).first()
   // 对接收到的用户事件进行断言
  }
}

为了成功完成测试,一个比较好的做法是使用 take 操作符来从 Flow 中获取一些数据,使用 toList 作为末端操作符来从数组中获取结果。示例如下:

class AnotherStreamDataSourceImplTest {
  @Test
  fun `Test happy path`() = runBlockingTest {
   //准备好 subject
    val result = subject.flow.take(1).toList()
   // 断言结果和预期的一致
  }
}

take 操作符非常适合在获取到数据后关闭 Flow。在测试完毕后不关闭 Flow 或 BroadcastChannel 将会导致内存泄漏以及测试结果不一致。

注意: 如果在数据源的实现是通过 BroadcastChannel 完成的,那么上面的代码还不够。您需要自己管理数据源的生命周期,并确保 BroadcastChannel 在测试开始之前已经启动,同时需要在测试结束后将其关闭,否则将会导致内存泄漏。你可以 在这里获取更多信息

协程测试的最佳实践在这里依然适用。如果您在测试代码中创建新的协程,则可能想要在测试线程中执行它来确保测试获得执行。

您也可以通过视频回顾 2019 Android 开发者峰会演讲 —— 在 Android 上测试协程 获取更多相关信息。

总结

  • 因为 Flow 所提供的更加明确的约束和各种操作符,我们更建议向消费者暴露 Flow 而不是 Channel;
  • 使用 Flow 时,生产者会在每次有新的监听者时被执行,同时数据流的生命周期将会被自动处理;
  • 使用 BroadcastChannel 时,您可以共享生产者,但需要自己管理它的生命周期;
  • 请考虑将基于回调的 API 转化为协程,以便在您的应用中更好、更惯用地集成 API;
  • 使用 taketoList 操作符可以简化 Flow 的相关代码测试。

2019 ADS 应用在 GitHub 开源,请点击 这里 在 GitHub 上查看更详细的代码实现。

查看原文

赞 1 收藏 0 评论 0

Android开发者 发布了文章 · 11月28日

在 Android 开发中使用协程 | 代码实战

本文是介绍 Android 协程系列中的第三部分,这篇文章通过发送一次性请求来介绍如何使用协程处理在实际编码过程中遇到的问题。在阅读本文之前,建议您先阅读本系列的前两篇文章,关于在 Android 开发中使用协程的 背景介绍上手指南

使用协程解决实际编码问题

前两篇文章主要是介绍了如何使用协程来简化代码,在 Android 上保证主线程安全,避免任务泄漏。以此为背景,我们认为使用协程是在处理后台任务和简化 Android 回调代码的绝佳方案。

目前为止,我们主要集中在介绍协程是什么,以及如何管理它们,本文我们将介绍如何使用协程来完成一些实际任务。协程同函数一样,是在编程语言特性中的一个常用特性,您可以使用它来实现任何可以通过函数和对象能实现的功能。但是,在实际编程中,始终存在两种类型的任务非常适合使用协程来解决:

  1. 一次性请求 (one shot requests) 是那种调用一下就请求一下,请求获取到结果后就结束执行;
  2. 流式请求 (streaming request) 在发出请求后,还一直监听它的变化并返回给调用方,在拿到第一个结果之后它们也不会结束。

协程对于处理这些任务是一个绝佳的解决方案。在这篇文章中,我们将会深入介绍一次性请求,并探索如何在 Android 中使用协程实现它们。

一次性请求

一次性请求会调用一次就请求一次,获取到结果后就结束执行。这个模式同调用常规函数很像 —— 调用一次,执行,然后返回。正因为同函数调用相似,所以相对于流式请求它更容易理解。

一次性请求会调用一次就请求一次,获取到结果后就结束执行。

举例来说,您可以把它类比为浏览器加载页面。当您点击了这篇文章的链接后,浏览器向服务器发送了网络请求,然后进行页面加载。一旦页面数据传输到浏览器后,浏览器就有了所有需要的数据,然后停止同后端服务的对话。如果服务器后来又修改了这篇文章的内容,新的更改是不会显示在浏览器中的,除非您主动刷新了浏览器页面。

尽管这样的方式缺少了流式请求那样的实时推送特性,但是它还是非常有用的。在 Android 的应用中您可以用这种方式解决很多问题,比如对数据的查询、存储或更新,它还很适用于处理列表排序问题。

问题: 展示一个有序列表

我们通过一个展示有序列表的例子来探索一下如何构建一次性请求。为了让例子更具体一些,我们来构建一个用于商店员工使用的库存应用,使用它能够根据上次进货的时间来查找相应商品,并能够以升序和降序的方式排列。因为这个仓库中存储的商品很多,所以对它们进行排序要花费将近 1 秒钟,因此我们需要使用协程来避免阻塞主线程。

在应用中,所有的数据都会存储到 Room 数据库中。由于不涉及到网络请求,因此我们不需要进行网络请求,从而专注于一次性请求这样的编程模式。由于无需进行网络请求,这个例子会很简单,尽管如此它仍然展示了该使用怎样的模式来实现一次性请求。

为了使用协程来实现此需求,您需要在协程中引入 ViewModel、Repository 和 Dao。让我们逐个进行介绍,看看如何把它们同协程整合在一起。

class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
   private val _sortedProducts = MutableLiveData<List<ProductListing>>()
   val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
 
   /**
    * 当用户点击相应排序按钮后,UI 进行调用
    */
   fun onSortAscending() = sortPricesBy(ascending = true)
   fun onSortDescending() = sortPricesBy(ascending = false)
 
   private fun sortPricesBy(ascending: Boolean) {
       viewModelScope.launch {
      // suspend 和 resume 使得这个数据库请求是主线程安全的,所以 ViewModel 不需要关心线程安全问题
           _sortedProducts.value =
                   productsRepository.loadSortedProducts(ascending)
       }
   }
}

ProductsViewModel 负责从 UI 层接受事件,然后向 repository 请求更新的数据。它使用 LiveData 来存储当前排序的列表数据,以供 UI 进行展示。当出现某个新事件时,sortProductsBy 会启动一个新的协程对列表进行排序,当排序完成后更新 LiveData。在这种架构下,通常都是使用 ViewModel 启动协程,因为这样做的话可以在 onCleared 中取消所启动的协程。当用户离开此界面后,这些任务就没必要继续进行了。

\*如果您之前没有用过 LiveData,您可以看看这篇由 @CeruleanOtter 写的文章,它介绍了 LiveData 是如何为 UI 保存数据的 —— ViewModels: A Simple Example。

这是在 Android 上使用协程的通用模式。由于 Android framework 不会主动调用挂起函数,所以您需要配合使用协程来响应 UI 事件。最简单的方法就是来一个事件就启动一个新的协程,最适合处理这种情况的地方就是 ViewModel 了。

在 ViewModel 中启动协程是很通用的模式。

ViewModel 实际上使用了 ProductsRepository 来获取数据,示例代码如下:

class ProductsRepository(val productsDao: ProductsDao) {

  /**
       这是一个普通的挂起函数,也就是说调用方必须在一个协程中。repository 并不负责启动或者停止协程,因为它并不负责对协程生命周期的掌控。
       这可能会在 Dispatchers.Main 中调用,同样它也是主线程安全的,因为 Room 会为我们保证主线程安全。
    */
   suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
       return if (ascending) {
           productsDao.loadProductsByDateStockedAscending()
       } else {
           productsDao.loadProductsByDateStockedDescending()
       }
   }
}

ProductsRepository 提供了一个合理的同商品数据进行交互的接口,此应用中,所有内容都存储在本地 Room 数据库中,它为 @Dao 提供了针对不同排序具有不同功能的两个接口。

repository 是 Android 架构组件中的一个可选部分,如果您在应用中已经集成了它或者其他的相似功能的模块,那么它应该更偏向于使用挂起函数。因为 repository 并没有生命周期,它仅仅是一个对象,所以它不能处理资源的清理工作,所以默认情况下,repository 中启动的所有协程都有可能出现泄漏。

使用挂起函数除了避免泄漏之外,在不同的上下文中也可以重复使用 repository,任何知道如何创建协程的都可以调用 loadSortedProducts,例如 WorkManager 所调度管理的后台任务就可以直接调用它。

repository 应该使用挂起函数来保证主线程安全。

注意: 当用户离开界面后,有些在后台中处理数据保存的操作可能还要继续工作,这种情况下脱离了应用生命周期来运行是没有意义的,所以大部分情况下 viewModelScope 都是一个好的选择。

再来看看 ProductsDao,示例代码如下:

@Dao
interface ProductsDao {

   // 因为这个方法被标记为了 suspend,Room 将会在保证主线程安全的前提下使用自己的调度器来运行这个查询
   @Query("select * from ProductListing ORDER BY dateStocked ASC")
   suspend fun loadProductsByDateStockedAscending(): List<ProductListing>
   // 因为这个方法被标记为了 suspend,Room 将会在保证主线程安全的前提下使用自己的调度器来运行这个查询
   @Query("select * from ProductListing ORDER BY dateStocked DESC")
   suspend fun loadProductsByDateStockedDescending(): List<ProductListing>
}

ProductsDao 是一个 Room @Dao,它对外提供了两个挂起函数,因为这些函数都增加了 suspend 修饰,所以 Room 会保证它们是主线程安全的,这也意味着您可以直接在 Dispatchers.Main 中调用它们。

\*如果您没有在 Room 中使用过协程,您可以先看看这篇由 @FMuntenescu 写的文章: Room 🔗 Coroutines

不过要注意的是,调用它的协程将会在主线程上执行。所以,如果您要对执行结果做一些比较耗时的操作,比如对列表内容进行转换,您要确保这个操作不会阻塞主线程。

注意: Room 使用了自己的调度器在后台线程上进行查询操作。您不应该再使用 withContext(Dispatchers.IO) 来调用 Room 的 suspend 查询,这只会让您的代码变复杂,也会拖慢查询速度。

Room 的挂起函数是主线程安全的,并运行于自定义的调度器中。

一次性请求模式

这是在 Android 架构组件中使用协程进行一次性请求的完整模式,我们将协程添加到了 ViewModel、Repository 和 Room 中,每一层都有着不同的责任分工。

  1. ViewModel 在主线程上启动了协程,一旦有结果后就结束执行;
  2. Repository 提供了保证主线程安全的挂起函数;
  3. 数据库和网络层提供了保证主线程安全的挂起函数。

ViewModel 负责启动协程,并保证用户离开了相应界面时它们就会被取消。它本身并不会做一些耗时的操作,而是依赖别的层级来做。一旦有了结果,就使用 LiveData 将数据发送到 UI 层。因为 ViewModel 并不做一些耗时操作,所以它是在主线程启动协程的,以便能够更快地响应用户事件。

Repository 提供了挂起函数用来访问数据,它通常不会启动一些生命周期比较长的协程,因为它们一旦启动了便无法取消。无论何时 Repository 想要做一些耗时操作,比如对列表内容进行转换,都应该使用 withContext 来提供主线程安全的接口。

数据层 (网络或数据库) 总是会提供挂起函数,使用 Kotlin 协程的时候要保证这些挂起函数是主线程安全的,Room 和 Retrofit 都遵循了这一点。

在一次性请求中,数据层只提供挂起函数,调用方如果想要获取最新的值,只能再次进行调用,这就像浏览器中的刷新按钮一样。

花点时间让您了解一次性请求的模式是值得,它在 Android 协程中是比较通用的模式,您会一直用到它。

第一个 bug 出现了

在经过测试后,您部署到了生产环境,运行了几周都感觉良好,直到您收到了一个很奇怪的 bug 报告:

标题: 🐞 — 排序错误!

错误报告: 当我非常快速地点击排序按钮时,排序的结果偶尔是错的,这还不是每次都能复现的🙃。

您研究了一下,不禁问自己哪里出错了?这个逻辑很简单:

  1. 开始执行用户请求的排序操作;
  2. 在 Room 调度器中开始进行排序;
  3. 展示排序结果。

您觉得这个 bug 不存在准备关闭它,因为解决方案很简单,"不要那么快地点击按钮",但是您还是很担心,觉得还是哪个地方出了问题。于是在代码中加入一些日志,并跑了一堆测试用例后,您终于知道问题出在什么地方了!

看起来应用内展示的排序结果并不是真正的 "排序结果",而是上一次完成排序的结果。当用户快速点击按钮时,就会同时触发多个排序操作,这些操作可能以任意顺序结束。

当启动一个新的协程来响应 UI 事件时,要去考虑一下用户若在上一个任务未完成之前又开始了新的任务,会有什么样的后果。

这其实是一个并发导致的问题,它和是否使用了协程其实没有什么关系。如果您使用回调、Rx 或者是 ExecutorService,还是可能会遇到这样的 bug。

有非常多方案能够解决这个问题,既可以在 ViewModel 中解决,又可以在 Repository 中解决。我们来看看怎么才能让一次性请求按照我们所期望的顺序返回结果。

最佳解决方案: 禁用按钮

核心问题出在我们做了两次排序,要修复的话我们可以只让它排序一次。最简单的解决方法就是禁用按钮,不让它发出新的事件就可以了。

这看起来很简单,而且确实是个好办法。实现起来的代码也很简单,还容易测试,只要它能在 UI 中体现出来这个按钮的状态,就完全可以解决问题。

要禁用按钮,只需要告诉 UI 在 sortPricesBy 中是否有正在处理的排序请求,示例代码如下:

// 方案 0: 当有任何排序正在执行时,禁用排序按钮

class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
   private val _sortedProducts = MutableLiveData<List<ProductListing>>()
   val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts

   private val _sortButtonsEnabled = MutableLiveData<Boolean>()
   val sortButtonsEnabled: LiveData<Boolean> = _sortButtonsEnabled

   init {
       _sortButtonsEnabled.value = true
   }

   /**
       当用户点击排序按钮时,调用
    */
   fun onSortAscending() = sortPricesBy(ascending = true)
   fun onSortDescending() = sortPricesBy(ascending = false)

   private fun sortPricesBy(ascending: Boolean) {
       viewModelScope.launch {
          // 只要有排序在进行,禁用按钮
           _sortButtonsEnabled.value = false
           try {
               _sortedProducts.value =
                       productsRepository.loadSortedProducts(ascending)
           } finally {
              // 排序结束后,启用按钮
               _sortButtonsEnabled.value = true
           }
       }
   }
}

使用 sortPricesBy 中的 _sortButtonsEnabled 在排序时禁用按钮

好了,这看起来还行,只需要在调用 repository 时在 sortPricesBy 内部禁用按钮就好了。

大部分情况下,这都是最佳解决方案,但是如果我们想在保持按钮可用的前提下解决 bug 呢?这样的话有一点困难,在本文剩余的部分看看该怎么做。

注意: 这段代码展示了从主线程启动的巨大优势,点击之后按钮立刻变得不可点了。但如果您换用了其他的调度程序,当出现某个手速很快的用户在运行速度较慢的手机上操作时,还是可能出现发送多次点击事件的情况。

并发模式

下面几个章节我们探讨一些比较高级的话题,如果您才刚刚接触协程,可以不去理解这一部分,使用禁用按钮这一方案就是解决大部分类似问题的最佳方案。

在剩余部分我们将探索在不禁用按钮的前提下,确保一次性请求能够正常运行。我们可以通过控制何时让协程运行 (或者不运行) 来避免刚刚出现的并发问题。

有三个基本的模式可以让我们确保在同一时间只会有一次请求进行:

  1. 在启动更多协程之前取消之前的任务
  2. 下一个任务排队等待前一个任务执行完成;
  3. 如果有一个任务正在执行,返回该任务,而不是启动一个新的任务。

当介绍完这三个方案后,您可能会发现它们的实现都挺复杂的。为了专注于设计模式而不是实现细节,我创建了一个 gist 来提供这三个模式的实现作为可重用抽象 。

方案 1: 取消之前的任务

在排序这种情况下,获取新的事件后就意味着可以取消上一个排序任务了。毕竟用户通过这样的行为已经表明了他们不想要上次的排序结果了,继续进行上一次排序操作没什么意义了。

要取消上一个请求,我们首先要以某种方式追踪它。在 gist 中的 cancelPreviousThenRun 函数就做到了这个。

来看看如何使用它修复这个 bug:

// 方案 1: 取消之前的任务
 
// 对于排序和过滤的情况,新请求进来,取消上一个,这样的方案是很适合的。

class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
   var controlledRunner = ControlledRunner<List<ProductListing>>()

   suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
      // 在开启新的排序之前,先取消上一个排序任务
       return controlledRunner.cancelPreviousThenRun {
           if (ascending) {
               productsDao.loadProductsByDateStockedAscending()
           } else {
               productsDao.loadProductsByDateStockedDescending()
           }
       }
   }
}

使用 cancelPreviousThenRun 来确保同一时间只有一个排序任务在进行

看一下 gist 中 cancelPreviousThenRun 中的 代码实现,您可以学习到如何追踪正在工作的任务。

// see the complete implementation at
// 在 https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7 中查看完整实现
suspend fun cancelPreviousThenRun(block: suspend () -> T): T {
   // 如果这是一个 activeTask,取消它,因为它的结果已经不需要了
   activeTask?.cancelAndJoin()

   // ...

简而言之,它会通过成员变量 activeTask 来保持对当前排序的追踪。无论何时开始一个新的排序,都立即对当前 activeTask 中的所有任务执行 cancelAndJoin 操作。这样会在开启一次新的排序之前就会把正在进行中的排序任务给取消掉。

使用类似于 ControlledRunner<T> 这样的抽象实现来对逻辑进行封装是比较好的方法,比直接混杂并发与应用逻辑要好很多。

选择使用抽象来封装代码逻辑,避免混杂并发和应用逻辑代码。

注意: 这个模式不适合在全局单例中使用,因为不相关的调用方是不应该相互取消。

方案 2: 让下一个任务排队等待

这里有一个对并发问题总是有效的解决方案。

让任务去排队等待依次执行,这样同一时间就只会有一个任务会被处理。就像在商场里进行排队,请求将会按照它们排队的顺序来依次处理。

对于这种特定的排序问题,其实选择方案 1 比使用本方案要更好一些,但还是值得介绍一下这个方法,因为它总是能够有效的解决并发问题。

// 方案 2: 使用互斥锁
// 注意: 这个方法对于排序或者是过滤来说并不是一个很好的解决方案,但是它对于解决网络请求引起的并发问题非常适合。

class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
   val singleRunner = SingleRunner()

   suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
      // 开始新的任务之前,等待之前的排序任务完成
       return singleRunner.afterPrevious {
           if (ascending) {
               productsDao.loadProductsByDateStockedAscending()
           } else {
               productsDao.loadProductsByDateStockedDescending()
           }
       }
   }
}

无论何时进行一次新的排序, 都使用一个 SingleRunner 实例来确保同时只会有一个排序任务在进行。

它使用了 Mutex,可以把它理解为一张单程票 (或是锁),协程在必须要获取锁才能进入代码块。如果一个协程在运行时,另一个协程尝试进入该代码块就必须挂起自己,直到所有的持有 Mutex 的协程完成任务,并释放 Mutex 后才能进入。

Mutex 保证同时只会有一个协程运行,并且会按照启动的顺序依次结束。

方案 3: 复用前一个任务

第三种可以考虑的方案是复用前一个任务,也就是说新的请求可以重复使用之前存在的任务,比如前一个任务已经完成了一半进来了一个新的请求,那么这个请求直接重用这个已经完成了一半的任务,就省事很多。

但其实这种方法对于排序来说并没有多大意义,但是如果是一个网络数据请求的话,就很适用了。

对于我们的库存应用来说,用户需要一种方式来从服务器获取最新的商品库存数据。我们提供了一个刷新按钮这样的简单操作来让用户点击一次就可以发起一次新的网络请求。

当请求正在进行时,禁用按钮就可以简单地解决问题。但是如果我们不想这样,或者说不能这样,我们就可以选择这种方法复用已经存在的请求。

查看下面的来自 gist 的使用了 joinPreviousOrRun 的示例代码:

class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
   var controlledRunner = ControlledRunner<List<ProductListing>>()

   suspend fun fetchProductsFromBackend(): List<ProductListing> {
      // 如果已经有一个正在运行的请求,那么就返回它。如果没有的话,开启一个新的请求。
       return controlledRunner.joinPreviousOrRun {
           val result = productsApi.getProducts()
           productsDao.insertAll(result)
           result
       }
   }
}

上面的代码行为同 cancelPreviousAndRun 相反,它会直接使用之前的请求而放弃新的请求,而 cancelPreviousAndRun 则会放弃之前的请求而创建一个新的请求。如果已经存在了正在运行的请求,它会等待这个请求执行完成,并将结果直接返回。只有不存在正在运行的请求时才会创建新的请求来执行代码块。

您可以在 joinPreviousOrRun 开始时看到它是如何工作的,如果 activeTask 中存在任何正在工作的任务,就直接返回它。

// 在 https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7#file-concurrencyhelpers-kt-L124 中查看完整实现

suspend fun joinPreviousOrRun(block: suspend () -> T): T {
   // 如果存在 activeTask,直接返回它的结果,并不会执行代码块
    activeTask?.let {
        return it.await()
    }
    // ...

这个模式很适合那种通过 id 来查询商品数据的请求。您可以使用 map 来建立 id 到 Deferred 的映射关系,然后使用相同的逻辑来追踪同一个产品之前的请求数据。

直接复用之前的任务可以有效避免重复的网络请求。

下一步

在这篇文章中,我们探讨了如何使用 Kotlin 协程来实现一次性请求。我们实现了如何在 ViewModel 中启动协程,然后在 Repository 和 Room Dao 中提供公开的 suspend function,这样形成了一个完整的编程范式。

对于大部分任务来说,在 Android 上使用 Kotlin 协程按照上面这些方法就已经足够了。这些方法就像上面所说的排序一样可以应用在很多场景中,您也可以使用这些方法来解决查询、保存、更新网络数据等问题。

然后我们探讨了一下可能出现 bug 的地方,并给出了解决方案。最简单 (往往也是最好的) 的方案就是从 UI 上直接更改,排序运行时直接禁用按钮。

最后,我们探讨了一些高级并发模式,并介绍了如何在 Kotlin 协程中实现它们。虽然 这些代码 有点复杂,但是为一些高级协程方面的话题做了很好的介绍。

在下一篇文章中,我们将会研究一下流式请求,并探索如何使用 liveData 构造器,感兴趣的读者请继续关注我们的更新。

查看原文

赞 0 收藏 0 评论 0

Android开发者 发布了文章 · 11月27日

Kotlin Vocabulary | 枚举和 R8 编译器

学习或使用一门新的编程语言时,了解这门语言所提供的功能,以及了解这些功能是否有相关联的开销,都是十分重要的环节。

这方面的问题在 Kotlin 中显得更加有趣,因为 Kotlin 最终会编译为 Java 字节码,但是它却提供了 Java 所没有的功能。那么 Kotlin 是怎么做到的呢?这些功能有没有额外开销?如果有,我们能做些什么来优化它吗?

接下来的内容与 Kotlin 中枚举 (enums) 和 when 语句 (java 中的 switch 语句) 有关。我会讨论一些和 when 语句相关的潜在开销,以及 Android R8 编译器是如何优化您的应用并减少这些开销的。

编译器

首先,我们讲一讲 D8 和 R8。

事实上,有三个编译器参与了 Android 应用中 Kotlin 代码的编译。

1. Kotlin 编译器

Kotlin 编译器将会首先运行,它会把您写的代码转换为 Java 字节码。虽然听起来很棒,但可惜的是 Android 设备上并不运行 Java 字节码,而是被称为 DEX 的 Dalvik 可执行文件。Dalvik 是 Android 最初所使用的运行时。而 Android 现在的运行时,则是从 Android 5.0 Lollipop 开始使用的 ART (Android Runtime),不过 ART 依然在运行 DEX 代码 (如果替换后的运行时无法运行原有的可执行文件的话,就毫无兼容性可言了)。

2. D8

D8 是整个链条中的第二个编译器,它把 Java 字节码转换为 DEX 代码。到了这一步,您已经有了能够运行在 Android 中的代码。不过,您也可以选择继续使用第三个编译器 —— R8。

3. R8 (可选,但推荐使用)

R8 以前是用来优化和缩减应用体积的,它基本上就是 ProGuard 的一个替代方案。R8 不是默认开启的,如果您希望使用它 (例如您想要这里讨论到的那些优化时),就需要启用它。在模块的 build.gradle 里添加 minifyEnabled = true ,就可以强制打开 R8 。它将在所有其他编译工作后执行,来保证您获得的是一个缩减和优化过的应用。

 android {
    buildTypes {
        release {
            minifyEnabled true
 
            proguardFiles getDefaultProguardFile(
                ‘proguard-android-optimize.txt’),
                ‘proguard-rules.pro’
        }
    }
}

枚举

现在,让我们讨论一下枚举。

无论在 Java 还是 Kotlin 中,枚举的功能和消耗本质上都是一样的。有趣的地方在于引入了 R8 之后,我们能对其中的一些开销做些什么。

枚举本身不包含任何隐藏开销。使用 Kotlin 时,也仅仅是将其转换为 Java 编程语言中的枚举而已,并没有多大开销。(我们曾经提到避免使用枚举,但那是很多年前的事了,而且运行时也与今日不同。所以现在使用枚举没什么问题。)

但当您配合枚举使用 when 语句时,就会引入额外的开销。

首先,我们来看一个枚举的示例:

enum class BlendMode {
    OPAQUE,
    TRANSPARENT,
    FADE,
    ADD
}

这个枚举中包含四个值。这些值是什么无关紧要,这里仅作为示例。

枚举 + when

接下来,我们使用一个 when 语句来转换这个枚举:

fun blend(b: BlendMode) {
    when (b) {
        BlendMode.OPAQUE -> src()
        BlendMode.TRANSPARENT -> srcOver()
        BlendMode.FADE -> srcOver()
        BlendMode.ADD -> add()
    }
}

对应枚举的每一个值,我们都去调用另一个方法。

如果您去看这段代码编译成的 Java 字节码 (您可以通过 Android Studio 的查看字节码功能直接看到 (Tools -> Kotlin -> Show Kotlin Bytecode),然后点击 "Decompile" 按钮),就会看到下面这样的代码:

public static void blend(@NotNull BlendMode b) {
    switch (BlendingKt$WhenMappings.
            $EnumSwitchMapping$0[b.ordinal()]) {
        case 1: {
            src();
            break;
        }
        // ...
    }
}

这段代码中没有对枚举直接使用 switch 语句,而是调用了一个数组。这个数组是从哪来的呢?

而且这个数组存储在一个被生成的类文件中。这个类文件是从哪来的?

这里究竟发生了什么呢?

自动生成的枚举映射

事实上,为了实现二进制兼容,我们不能简单地依靠枚举的序数值进行转换,因为这样的代码十分脆弱。假设您的一个库中包含了一个枚举,而您改变了这个枚举中值的顺序,您就可能破坏了某个人的应用。虽然这些代码除了顺序,看起来完全相同,但就是这种顺序的不同导致了对其它代码的影响。

所以取而代之的是,编译器将序数值与另一个值做映射,这样一来,无论您对这些枚举做什么修改,基于这个库的代码都能正常运行。

当然,这就意味着只要像这样使用枚举,就会额外生成其它内容。在本例中,就会生成很多代码。

生成的代码就像下面这样:

public final class BlendingKt$WhenMappings {
    public static final int[] $EnumSwitchMapping$0 =
            new int[BlendMode.values().length];

    static {
        $EnumSwitchMapping$0[BlendMode.OPAQUE.ordinal()] = 1;
        $EnumSwitchMapping$0[BlendMode.TRANSPARENT.ordinal()] = 2;
        $EnumSwitchMapping$0[BlendMode.FADE.ordinal()] = 3;
        $EnumSwitchMapping$0[BlendMode.ADD.ordinal()] = 4;
    }
}

这段代码中生成了一个 BlendingKt$WhenMappings 类。这个类里面有一个存储映射信息的数组: $EnumSwitchMapping$0,接下来则是一些执行映射操作的静态代码。

示例中是只有一个 when 语句时的情况。但如果我们写了更多的 when 语句,每个 when 语句就会生成一个对应的数组,即使这些 when 语句都在使用同一个枚举也一样。

虽然所有这些开销没什么大不了的,但是却也意味着,在您不知情的时候,会生成一个类,而且其中还包含了一些数组,这些都会让类加载和实例化消耗更多的时间。

幸运的是,我们可以做一些事情来减少开销: 这就是 R8 发挥作用的时候了。

使用 R8 来解决问题

R8 是一个有趣的优化器,它能 "看" 到与应用相关的所有内容。由于 R8 可以 "看" 到无论是您自己写的还是您依赖的库中的所有代码,它便可以根据这些信息决定做哪些优化。比如,它能避免枚举映射造成的开销: 它不需要那些映射信息,因为它知道这些代码只会以既定的方式使用这些枚举,所以它可以直接调用序数值。

下面是 R8 优化过的代码反编译后的样子:

public static void blend(@NotNull BlendMode b) {
    switch (b.ordinal()) {
        case 0: {
            src();
            break;
        }
        // ...
    }
}

这样就避免了生成类和映射数组,而且只创建了您所需的最佳代码。

探索 R8 与 Kotlin,然后用 Kotlin 写出更好的应用吧。

更多信息

更多 R8 相关信息,请查看以下资源:

查看原文

赞 1 收藏 0 评论 0

Android开发者 发布了文章 · 11月27日

别让 USB 传输速度影响 Android 开发效率

概览

迭代速度是构建高质量 Android 应用的关键要素。处理的速度越快,应用程序的体验就越顺畅。这篇文章将和大家聊聊如何优化部署时间。

部署 101

在 Android Studio 3.5 版本中,我们在多个方面改进了迭代速度。开发者可以使用 Apply Changes: 根据代码结构,它可以替换更改的代码,并且重启当前 Activity; 如果代码和 Activity 生命周期无关,则会调用 "Apply Code Changes" 来仅替换代码而不会重启正在运行的 Activity。

△ Apply Changes 带来了两种全新的应用程序部署方式

△ Apply Changes 带来了两种全新的应用程序部署方式

基于 Apply Changes,Android Studio 3.5 新增了 "Delta Push",在下次调用 Run 的时候,他只会把 APK 中修改的部分推送到设备上。应用程序会通过 sendfile(2) 命令将新旧资源发送给 Package Manager 并且在设备上进行重构,以此来达到 "零拷贝" 安装。

这些机制组合起来减少了安装时间,不过要想得到较好的优化效果,开发者还需要注意设备数据线传输的速度和稳定性。

为什么 USB 传输速度很重要

无论 "Delta Push" 是否被启用,总会有数据传输到设备上。如果用的是模拟器,数据会通过 TCP/IP 进行传输,但是如果用的是真机则会用到 USB,USB 传输速度越快,等待时间越短,程序迭代速度也就越快。

好消息是 USB 传输速度现在越来越快了: 1996 年时,USB 仅能够提供 12 Mb/s 的传输速度,到了 2019 年 8 月发布 USB 4.0 版本的时候,已经可以达到几个 Gb/s 的速度了。

△ 从 1996 到 2019,USB 传输速率从 1.5Mbps 提高到了 40GBps

△ 从 1996 到 2019,USB 传输速率从 1.5Mbps 提高到了 40GBps

如果不去深究,USB 其实是一个较为简单的技术。只要两台设备所支持的 USB 版本相同,用 USB 连接线相连就可以用了,客户端层不用作出任何修改,用起来易如反掌。

对开发者来说,拥有高速的 USB 传输至关重要。传输速度达到一定程度以后,USB 传输速度和程序迭代速度就会关联起来,手机存储的写入速度可能无法达到 USB 3.2 的 20Gb/s,不过还是比 USB 2.0 要快,所以如果您安装调试一个 50M 大小的 APK,USB 3.0 协议的传输速度会比使用 USB 2.0 协议快 25%,一天下来能节省下的时间应该不少:

△ 安装一个 50 MB 的应用,USB 3.0 比 USB 2.0 快 30%

△ 安装一个 50 MB 的应用,USB 3.0 比 USB 2.0 快 30%

但坏消息是,USB 3.0 的设备常常以 USB 2.0 的速度在传输数据。有两种方式会导致这种情况,要么线出现问题,要么集线器出现问题。

避免降低 USB 连接的传输速度

有三个基本原则可以避免 USB 降速的问题:

  • 找蓝色接口
  • 了解 USB-C
  • 选择合适的 USB 集线器

找蓝色接口

如果您用的是 Type-A 或者 Type-B 接口,那就简单了。USB 3.0 标准里建议通过颜色来区分专门连接到 USB 3.X 硬件的接口。如果您看到下面图片中的接口,那么说明您接对接口了。

USB Type-A:

△ 左侧是 USB Type-A 母口,右侧是 USB Type-A 公口

△ 左侧是 USB Type-A 母口,右侧是 USB Type-A 公口

USB Type-B:

△ 左侧是 USB Type-B 母口,右侧是 USB Type-B 公口

△ 左侧是 USB Type-B 母口,右侧是 USB Type-B 公口

了解 USB-C

USB-C 接口和 USB 3.1 几乎同时发布。很多人以为他们之间有联系,但是其实他们毫不相干。

USB 2.0 数据线中有四根线缆。其中两根用于供电,另外两根用于数据传输。

△ 一条 USB 2.0 数据线中含四根线缆

△ 一条 USB 2.0 数据线中含四根线缆

USB 3.X 数据线完全向下兼容。它含有两条线缆用于 USB-2 信号传输,还有两组双绞线用于 USB-3 的上行和下行数据。

△ USB 3.0+ 数据线含有 8 根线缆

△ USB 3.0+ 数据线含有 8 根线缆

从传输数据角度看,USB Type-C 只是一种新的接口形态,并且它不会探测线缆连接的数量,换句话说就是,它并不关心另一端的传输协议是高速的 USB 3.X 协议还是相对低速的 USB 2.0 协议,加之其造价低廉,导致很多数据线用的虽然是 Type-C 接口,但数据传输还是走 USB 2.0 的协议。

△ Nexus 6P 标配的 Type-A 转 C 数据线

△ Nexus 6P 标配的 Type-A 转 C 数据线

上图所示是 Nexus 6P 手机标配的数据线,这台手机是使用 USB 2.0 协议,并通过 USB Type-C 接口连接,这些数据线中只有 4 根线缆。

如果您用的是这样的数据线接入开发环境的话,很有可能您在用低速连接 USB-3 兼容设备。原因是在开发过程中,如果您一直在使用 Nexus 6P 的这条数据线连接其他不同的手机 (部分手机的 Type-C 接口可能会使用 USB 3.0 协议,而这条数据线最高只能有 2.0 协议的速度),那您的整体开发体验和开发效率可能会降低不少。

所以当使用 USB Type-C 接口的时候,确保上面有 SS (SuperSpeed) 的标志。

△ USB Super-Speed 标志

△ USB Super-Speed 标志

对于手机接口使用哪个协议,可以查看设备的相关参数。即使是近期发布的手机也有可能使用 2.0 低速接口,比如 Pixel 3a 和 Pixel 3a XL。

选择合适的 USB 集线器

最后一个要避免的错误操作就是避免使用较差质量的 USB 集线器产品 —— 它的效果和用错线是一样的。USB 集线器可以把您所有的设备都串联到一起 (从键盘、鼠标、到开发设备)。但是很多集线器产品都是用于一些轻量级低速设备,比如键盘、鼠标和耳机,这些设备仅需要 USB 2.0连接。

确认一下您的 USB 集线器是否支持 USB 3.0,看一下它的接口是不是蓝色的,如果接口只有 Type-C,可以看一下说明书。

实用工具

如果您仍有疑虑,可以通过下面命令看一下您的 USB 设备和宿主机之间的速度。

  • Mac 系统: system_profiler SPUSBDataType
  • Linux 系统: lsusb -vvv
  • Windows 系统: USBView.exe
查看原文

赞 0 收藏 0 评论 0

Android开发者 发布了文章 · 11月26日

是时候迁移至 AndroidX 了!

长久以来,我们致力于做到三件事: 实践指南、减少模板代码和简化任务流程,我们希望帮助开发者们集中精力专注在真正需要考虑的逻辑中去。Jetpack 为此而生,它所包含的库、工具和指南,可以帮助您更轻松地编写高质量的应用。

Jetpack 和 AndroidX 有什么关系呢? Jetpack 中所有库都使用 AndroidX 作为包名,我们把 AndroidX 作为一个开发、测试和发布 Jetpack 库的开源工程。

在 2018 年的 I/O 大会上我们宣布了把 Support Library 重构至 AndroidX 命名空间的计划。在 Support Library 28,我们完成了重构,并且发布了 AndroidX 1.0。为了能够享受 Jetpack 所带来的便利,您需要将旧的 Support Library 迁移至 AndroidX。

为什么有必要迁移至 AndroidX

您可能会想: 既然 AndroidX 只是 Support Library 28 的重构,那为什么要迁移呢? 关于这个问题,我们有下面几个理由:

  1. Support Library 已经完成了它的历史使命,28 将会是它的最后发布版。我们接下来将不会继续在 Support Library 中修复 bug 或发布新功能;
  2. 更好的包管理: 独立版本、独立命名以及更高频率的更新。以上优点,AndroidX 开箱既得;
  3. 目前已经有许多我们耳熟能详的工具库已经迁移至 AndroidX,例如 Google Play 服务、Firebase、Butterknife、Mockito 2、SQL Delight,我们后面会提到如何迁移它们的依赖;
  4. 我们正在努力推广 AndroidX 命名空间,未来所有新推出的组件库,例如 Jetpack Compose 和 CameraX,都将成为 AndroidX 的一员。

如何迁移至 AndroidX

前期准备

在开始迁移之前,为了使接下来的工作可以更加顺畅平滑,我们希望您可以做到以下几点:

  • 首先,备份整个工程。大部分开发者都在使用 代码版本控制系统,但由于迁移工作牵扯到大量的文件修改,建议您备份整个工程;
  • 其次,我们希望您尽量减少同时进行的功能开发;
  • 最后还是建议您: 在单独的分支进行迁移工作。

开始迁移

在整个迁移步骤中,我们将着重于解决错误,使您的应用编译并通过所有测试。

下面是迁移工作的流程示意图,虽然步骤不少,但是本文会对其中的每一步都做出说明:

第一步: 将 Support Library 升级至 28

首先,我们希望您把当前的 Support Library 依赖升级至版本 28。如果您从早期版本的 Support Library 进行迁移,可能会在需要修改命名空间的同时遭遇 API 不兼容的问题; 而 Support Library 28 的 API 与 AndroidX 之间只有命名空间上的不同。所以我们建议,先尝试将 Support Library 升级至版本 28,处理过所有 API 变更,并且确保编译通过后,再进行下一步,这样所做的修改是最少的。

第二步: 开启 Jetifier

接下来需要做的是开启 Jetifier。Jetifier 可以帮助您迁移第三方依赖库的依赖至 AndroidX。正如字面意思所说,Jetifier 会修改这些第三方依赖库的代码,从而使其与使用 AndroidX 的工程兼容。不过 Jetifier 不会修改您的源码和自动生成的代码,因此不用担心它会造成额外的不良影响。

开启 Jetifier 十分的简单,您只需要在 gradle.properties 文件中加入 "android.useAndroidX = true" 和 "android.enableJetifier = true" 即可。"useAndroidX" 设置用于开启 AndroidX 库的自动导入,当您自动补全或导入依赖库时,会自动导入 AndroidX 库。

第三步: 检查第三方库版本的兼容性

当您开启 Jetifier 之后,就要着手升级第三方依赖库到兼容的版本。在您真的开始迁移之前,最好把所有依赖升级到最新。

为什么要这么做? 其实我们自己就在这方面 "栽过跟头",我们有一个演示应用: Plaid,它依赖了图片加载库 Glide,我们本来打算使用 Plaid 来演示如何迁移应用至 AndroidX,但当我们在没有检查 Glide 依赖库版本就开始迁移时,我们遭遇了一堆编译错误。检查后才发现,当时依赖的那个版本的 Glide 无法兼容 AndroidX。

而当我们把 Glide 和其他依赖库版本都升级后,再做迁移工作,就没有再出现相同的错误。所以,建议在开始迁移前,先检查和升级应用的第三方依赖,新版本的第三方库可能已经兼容 AndroidX。由于Jetifier 不会帮您迁移自动生成代码的依赖库,所以您还是需要自己检查这类依赖是否兼容 AndroidX。

如果跳过了前面两步,您可能会遇到一些问题:

  • 如果您当前使用的第三方库不兼容 AndroidX,您将会看到它依然在尝试拉取旧版本的 Support Library;
  • 而如果您的工程被部分迁移,可能还会遇到类型重复的错误,这是因为工程正在尝试从 Support Library 和 AndroidX 拉取相同的代码。

第四步: 将 Support 库依赖转换为 AndroidX

这一步开始前,您应该完成了前面三个步骤: 升级 Support Library 到 28 版; 开启 Jetifier; 升级和检查第三方依赖库。确定这些都没问题后,我们终于可以开始真正的迁移工作了。这一步有以下三个方法供您参考:

  1. 使用 Android studio 自动迁移工具

我们在 Android 3.2 稳定版中加入了 "Migrate to AndroidX" 选项,方便大家迁移。您可以在 "Refactor" 菜单中找到 "Migrate to AndroidX" 选项:

这个按钮的功能,就是迁移源码中的依赖到 AndroidX,理想情况下,它会帮您完成绝大部分工作。

  1. 使用自动迁移脚本

我们也意识到有些团队使用的不是 Android Studio,而且也会有一些应用的结构过于复杂,使我们的工具无法生效。

所以还有两种选择,其中之一便是使用 bash 脚本中的 grep 和 sed 命令。在介绍如何使用脚本进行迁移之前,我们要特别感谢 Dan Lew 为我们提供了这个工具。

您可以通过短链接: goo.gle/androidx-migration-script 去到脚本源码的 GitHub 页面,在那里您也可以找到更多的社区贡献内容。

脚本的工作原理并不复杂,如下所示,您需要手动做的是配置好类型映射表 "androidx-class-mapping.csv" 和工程路径地址,而脚本中真正有效的部分,就只是 grep 命令后跟着一个 sed 命令来替换工程中导入的包名:

由于脚本的处理十分简单粗暴,所以可能会在某些情况下造成一些错误。使用这种方式一定要自己心里有数。

  1. 人工迁移

另一个选择,是人工进行迁移工作。在 迁移到 AndroidX 中,您能看到前文提到过的 Support Library 与 AndroidX 的类型映射关系表。如下图,有了这个映射关系表,您就可以根据具体情况进行替换:

这一步做完之后,只要您重新编译工程,并且修复那些迁移工作中损坏的测试,就可以获得一个基于 AndroidX 的工程。可喜可贺!

可能遇到的问题

当然,真实的情况往往不会那么一帆风顺。下面我们收集了一些迁移过程中常见的问题,希望能帮到您。

常见的需要手动处理的情况

以下图为例,我们看到这里依赖的仍然是 Support Library,其中 drawerLayout 和 recyclerview 的版本是用一组变量设置的:

遇到这种情况时,自动迁移不会理会您之前的变量配置,它会直接把这些库替换成一个确定的 AndroidX 版,如果您仍然想要使用变量管理这些库的版本号,就需要手动把 AndroidX 的依赖库版本改为使用变量设置。

自动迁移工具也不会修改您的混淆文件和构建脚本。如果这些文件中包含相关的包名,您需要手动去把它们改好。

冲突处理

我们前面有提到,一定要在一个新的分支中处理迁移工作,关于这点还有一些和大家分享的内容。

由于迁移工作会修改大量的文件,所以我们建议减缓或停止手头的开发工作。虽然要求整个开发团队停工听起来十分离谱,但是这样确实可以大大减少可能产生的合并冲突。

退而求其次的话,如果条件允许,最好能安排一些人手在一个单独的分支上专注于迁移的工作。与此同时,也要向团队中的其他成员预警即将到来的合并冲突。

在迁移依赖时,要专注于错误的修改,以编译成功和通过所有测试为首要目标。不要在迁移的同时进行重构或者引入新的功能。

检查自动迁移工具导入的库版本

当您运行完自动迁移功能后,您可能会发现新的依赖库中既有稳定版,又有 Alpha 版。这其实取决于我们最新发布的版本。您需要手动修改这些依赖库的版本,以满足自己工程的特定需要。

文档资源

我们总结了一些与本文相关的文档放在最后,来方便您回顾和查找。

AndroidX 概览 包括: AndroidX 总览、迁移指南以及 Support Library 到 AndroidX 库稳定版和 Alpha 版的映射关系表。如果您想要使用脚本处理迁移,这里也提供映射关系表的 CSV 文件。

我们有一篇文章介绍 Kotlin & Jetpack 实践技巧: 把 "格子衫" 改造得更时尚,描述了示例工程 Plaid 迁移至 AndroidX 的过程。在这篇文章中,我们说明了迁移的步骤,遇到的问题和对应的解决方案。

我们还提供了 问题追踪页,您可以在这个页面看到我们正在解决的问题,也可以通过左上角的按钮建立新的问题给我们。

祝大家都能顺畅地迁移至 AndroidX!

您也可以通过下面视频回顾 2019 Android 开发者峰会演讲 —— 是时候迁移至 AndroidX 了!

https://www.bilibili.com/vide...

查看原文

赞 0 收藏 0 评论 0

Android开发者 发布了文章 · 11月26日

在 Android 开发中使用协程 | 上手指南

本文是介绍 Android 协程系列中的第二部分,这篇文章主要会介绍如何使用协程来处理任务,并且能在任务开始执行后保持对它的追踪。

保持对协程的追踪

本系列文章的第一篇,我们探讨了协程适合用来解决哪些问题。这里再简单回顾一下,协程适合解决以下两个常见的编程问题:

  1. 处理耗时任务 (Long running tasks),这种任务常常会阻塞住主线程;
  2. 保证主线程安全 (Main-safety) ,即确保安全地从主线程调用任何 suspend 函数。

协程通过在常规函数之上增加 suspend 和 resume 两个操作来解决上述问题。当某个特定的线程上的所有协程被 suspend 后,该线程便可腾出资源去处理其他任务。

协程自身并不能够追踪正在处理的任务,但是有成百上千个协程并对它们同时执行挂起操作并没有太大问题。协程是轻量级的,但处理的任务却不一定是轻量的,比如读取文件或者发送网络请求。

使用代码来手动追踪上千个协程是非常困难的,您可以尝试对所有协程进行跟踪,手动确保它们都完成了或者都被取消了,那么代码会臃肿且易出错。如果代码不是很完美,就会失去对协程的追踪,也就是所谓 "work leak" 的情况。

任务泄漏 (work leak) 是指某个协程丢失无法追踪,它类似于内存泄漏,但比它更加糟糕,这样丢失的协程可以恢复自己,从而占用内存、CPU、磁盘资源,甚至会发起一个网络请求,而这也意味着它所占用的这些资源都无法得到重用。

泄漏协程会浪费内存、CPU、磁盘资源,甚至发送一个无用的网络请求。

为了能够避免协程泄漏,Kotlin 引入了 结构化并发 (structured concurrency) 机制,它是一系列编程语言特性和实践指南的结合,遵循它能帮助您追踪到所有运行于协程中的任务。

在 Android 平台上,我们可以使用结构化并发来做到以下三件事:

  1. 取消任务 —— 当某项任务不再需要时取消它;
  2. 追踪任务 —— 当任务正在执行时,追踪它;
  3. 发出错误信号 —— 当协程失败时,发出错误信号表明有错误发生。

接下来我们对以上几点一一进行探讨,看看结构化并发是如何帮助能够追踪所有协程,而不会导致泄漏出现的。

借助 scope 来取消任务

在 Kotlin 中,定义协程必须指定其 CoroutineScope 。CoroutineScope 可以对协程进行追踪,即使协程被挂起也是如此。同 第一篇文章 中讲到的调度程序 (Dispatcher) 不同,CoroutineScope 并不运行协程,它只是确保您不会失去对协程的追踪。

为了确保所有的协程都会被追踪,Kotlin 不允许在没有使用 CoroutineScope 的情况下启动新的协程。CoroutineScope 可被看作是一个具有超能力的 ExecutorService 的轻量级版本。它能启动新的协程,同时这个协程还具备我们在第一部分所说的 suspend 和 resume 的优势。

CoroutineScope 会跟踪所有协程,同样它还可以取消由它所启动的所有协程。这在 Android 开发中非常有用,比如它能够在用户离开界面时停止执行协程。

CoroutineScope 会跟踪所有协程,并且可以取消由它所启动的所有协程。

启动新的协程

需要特别注意的是,您不能随便就在某个地方调用 suspend 函数,suspend 和 resume 机制要求您从常规函数中切换到协程。

有两种方式能够启动协程,它们分别适用于不同的场景:

  1. launch 构建器适合执行 "一劳永逸" 的工作,意思就是说它可以启动新协程而不将结果返回给调用方;
  2. async 构建器可启动新协程并允许您使用一个名为 await 的挂起函数返回 result。

通常,您应使用 launch 从常规函数中启动新协程。因为常规函数无法调用 await (记住,它无法直接调用 suspend 函数),所以将 async 作为协程的主要启动方法没有多大意义。稍后我们会讨论应该如何使用 async。

您应该改为使用 coroutine scope 调用 launch 方法来启动协程。

scope.launch {
    // 这段代码在作用域里启动了一个新协程
   // 它可以调用挂起函数
   fetchDocs()
}

您可以将 launch 看作是将代码从常规函数送往协程世界的桥梁。在 launch 函数体内,您可以调用 suspend 函数并能够像我们 上一篇 介绍的那样保证主线程安全。

Launch 是将代码从常规函数送往协程世界的桥梁。

注意: launch 和 async 之间的很大差异是它们对异常的处理方式不同。async 期望最终是通过调用 await 来获取结果 (或者异常),所以默认情况下它不会抛出异常。这意味着如果使用 async 启动新的协程,它会静默地将异常丢弃。

由于 launchasync 仅能够在 CouroutineScope 中使用,所以任何您所创建的协程都会被该 scope 追踪。Kotlin 禁止您创建不能够被追踪的协程,从而避免协程泄漏。

在 ViewModel 中启动协程

既然 CoroutineScope 会追踪由它启动的所有协程,而 launch 会创建一个新的协程,那么您应该在什么地方调用 launch 并将其放在 scope 中呢? 又该在什么时候取消在 scope 中启动的所有协程呢?

在 Android 平台上,您可以将 CoroutineScope 实现与用户界面相关联。这样可让您避免泄漏内存或者对不再与用户相关的 Activities 或 Fragments 执行额外的工作。当用户通过导航离开某界面时,与该界面相关的 CoroutineScope 可以取消掉所有不需要的任务。

结构化并发能够保证当某个作用域被取消后,它内部所创建的所有协程也都被取消。

当将协程同 Android 架构组件 (Android Architecture Components) 集成起来时,您往往会需要在 ViewModel 中启动协程。因为大部分的任务都是在这里开始进行处理的,所以在这个地方启动是一个很合理的做法,您也不用担心旋转屏幕方向会终止您所创建的协程。

从生命周期感知型组件 (AndroidX Lifecycle) 的 2.1.0 版本开始 (发布于 2019 年 9 月),我们通过添加扩展属性 ViewModel.viewModelScope 在 ViewModel 中加入了协程的支持。

推荐您阅读 Android 开发者文档 "将 Kotlin 协程与架构组件一起使用" 了解更多。

看看如下示例:

class MyViewModel(): ViewModel() {
    fun userNeedsDocs() {
       // 在 ViewModel 中启动新的协程
        viewModelScope.launch {
            fetchDocs()
        }
    }
}

当 viewModelScope 被清除 (当 onCleared() 回调被调用时) 之后,它将自动取消它所启动的所有协程。这是一个标准做法,如果一个用户在尚未获取到数据时就关闭了应用,这时让请求继续完成就纯粹是在浪费电量。

为了提高安全性,CoroutineScope 会进行自行传播。也就是说,如果某个协程启动了另一个新的协程,它们都会在同一个 scope 中终止运行。这意味着,即使当某个您所依赖的代码库从您创建的 viewModelScope 中启动某个协程,您也有方法将其取消。

注意: 协程被挂起时,系统会以抛出 CancellationException 的方式 协作取消 协程。捕获顶级异常 (如Throwable) 的异常处理程序将捕获此异常。如果您做异常处理时消费了这个异常,或从未进行 suspend 操作,那么协程将会徘徊于半取消 (semi-canceled) 状态下。

所以,当您需要将一个协程同 ViewModel 的生命周期保持一致时,使用 viewModelScope 来从常规函数切换到协程中。然后,viewModelScope 会自动为您取消协程,因此在这里哪怕是写了死循环也是完全不会产生泄漏。如下示例:

fun runForever() {
    // 在 ViewModel 中启动新的协程
    viewModelScope.launch {
        // 当 ViewModel 被清除后,下列代码也会被取消
        while(true) {
            delay(1_000)
           // 每过 1 秒做点什么
        }
    }
}

通过使用 viewModelScope,可以确保所有的任务,包含死循环在内,都可以在不需要的时候被取消掉。

任务追踪

使用协程来处理任务对于很多代码来说真的很方便。启动协程,进行网络请求,将结果写入数据库,一切都很自然流畅。

但有时候,可能会遇到稍微复杂点的问题,例如您需要在一个协程中同时处理两个网络请求,这种情况下需要启动更多协程。

想要创建多个协程,可以在 suspend function 中使用名为 coroutineScopesupervisorScope 这样的构造器来启动多个协程。但是这个 API 说实话,有点令人困惑。coroutineScope 构造器和 CoroutineScope 这两个的区别只是一个字符之差,但它们却是完全不同的东西。

另外,如果随意启动新协程,可能会导致潜在的任务泄漏 (work leak)。调用方可能感知不到启用了新的协程,也就意味着无法对其进行追踪。

为了解决这个问题,结构化并发发挥了作用,它保证了当 suspend 函数返回时,就意味着它所处理的任务也都已完成。

结构化并发保证了当 suspend 函数返回时,它所处理任务也都已完成。

示例使用 coroutineScope 来获取两个文档内容:

suspend fun fetchTwoDocs() {
    coroutineScope {
        launch { fetchDoc(1) }
        async { fetchDoc(2) }
    }
}

在这个示例中,同时从网络中获取两个文档数据,第一个是通过 launch 这样 "一劳永逸" 的方式启动协程,这意味着它不会返回任何结果给调用方。

第二个是通过 async 的方式获取文档,所以是会有返回值返回的。不过上面示例有一点奇怪,因为通常来讲两个文档的获取都应该使用 async,但这里我仅仅是想举例来说明可以根据需要来选择使用 launch 还是 async,或者是对两者进行混用。

coroutineScope 和 supervisorScope 可以让您安全地从 suspend 函数中启动协程。

但是请注意,这段代码不会显式地等待所创建的两个协程完成任务后才返回,当 fetchTwoDocs 返回时,协程还正在运行中。

所以,为了做到结构化并发并避免泄漏的情况发生,我们想做到在诸如 fetchTwoDocs 这样的 suspend 函数返回时,它们所做的所有任务也都能结束。换个说法就是,fetchTwoDocs 返回之前,它所启动的所有协程也都能完成任务。

Kotlin 确保使用 coroutineScope 构造器不会让 fetchTwoDocs 发生泄漏,coroutinScope 会先将自身挂起,等待它内部启动的所有协程完成,然后再返回。因此,只有在 coroutineScope 构建器中启动的所有协程完成任务之后,fetchTwoDocs 函数才会返回。

处理一堆任务

既然我们已经做到了追踪一两个协程,那么来个刺激的,追踪一千个协程来试试!

先看看下面这个动画:

这个动画展示了 coroutineScope 是如何追踪一千个协程的

这个动画展示了 coroutineScope 是如何追踪一千个协程的。

这个动画向我们展示了如何同时发出一千个网络请求。当然,在真实的 Android 开发中最好别这么做,太浪费资源了。

这段代码中,我们在 coroutineScope 构造器中使用 launch 启动了一千个协程,您可以看到这一切是如何联系到一起的。由于我们使用的是 suspend 函数,因此代码一定使用了 CoroutineScope 创建了协程。我们目前对这个 CoroutineScope 一无所知,它可能是viewModelScope 或者是其他地方定义的某个 CoroutineScope,但不管怎样,coroutineScope 构造器都会使用它作为其创建新的 scope 的父级。

然后,在 coroutineScope 代码块内,launch 将会在新的 scope 中启动协程,随着协程的启动完成,scope 会对其进行追踪。最后,一旦所有在 coroutineScope 内启动的协程都完成后,loadLots 方法就可以轻松地返回了。

注意: scope 和协程之间的父子关系是使用 Job 对象进行创建的。但是您不需要深入去了解,只要知道这一点就可以了。

coroutineScope 和 supervisorScope 将会等待所有的子协程都完成。

以上的重点是,使用 coroutineScope 和 supervisorScope 可以从任何 suspend function 来安全地启动协程。即使是启动一个新的协程,也不会出现泄漏,因为在新的协程完成之前,调用方始终处于挂起状态。

更厉害的是,coroutineScope 将会创建一个子 scope,所以一旦父 scope 被取消,它会将取消的消息传递给所有新的协程。如果调用方是 viewModelScope,这一千个协程在用户离开界面后都会自动被取消掉,非常整洁高效。

在继续探讨报错 (error) 相关的问题之前,有必要花点时间来讨论一下 supervisorScope 和 coroutineScope,它们的主要区别是当出现任何一个子 scope 失败的情况,coroutineScope 将会被取消。如果一个网络请求失败了,所有其他的请求都将被立即取消,这种需求选择 coroutineScope。相反,如果您希望即使一个请求失败了其他的请求也要继续,则可以使用 supervisorScope,当一个协程失败了,supervisorScope 是不会取消剩余子协程的。

协程失败时发出报错信号

在协程中,报错信号是通过抛出异常来发出的,就像我们平常写的函数一样。来自 suspend 函数的异常将通过 resume 重新抛给调用方来处理。跟常规函数一样,您不仅可以使用 try/catch 这样的方式来处理错误,还可以构建抽象来按照您喜欢的方式进行错误处理。

但是,在某些情况下,协程还是有可能会弄丢获取到的错误的。

val unrelatedScope = MainScope()
// 丢失错误的例子
suspend fun lostError() {
   // 未使用结构化并发的 async
    unrelatedScope.async {
        throw InAsyncNoOneCanHearYou("except")
    }
}

注意: 上述代码声明了一个无关联协程作用域,它将不会按照结构化并发的方式启动新的协程。还记得我在一开始说的结构化并发是一系列编程语言特性和实践指南的集合,在 suspend 函数中引入无关联协程作用域违背了结构化并发规则。

在这段代码中错误将会丢失,因为 async 假设您最终会调用 await 并且会重新抛出异常,然而您并没有去调用 await,所以异常就永远在那等着被调用,那么这个错误就永远不会得到处理。

结构化并发保证当一个协程出错时,它的调用方或作用域会被通知到。

如果您按照结构化并发的规范去编写上述代码,错误就会被正确地抛给调用方处理。

suspend fun foundError() {
    coroutineScope {
        async { 
            throw StructuredConcurrencyWill("throw")
        }
    }
}

coroutineScope 不仅会等到所有子任务都完成才会结束,当它们出错时它也会得到通知。如果一个通过 coroutineScope 创建的协程抛出了异常,coroutineScope 会将其抛给调用方。因为我们用的是coroutineScope 而不是 supervisorScope,所以当抛出异常时,它会立刻取消所有的子任务。

使用结构化并发

在这篇文章中,我介绍了结构化并发,并展示了如何让我们的代码配合 Android 中的 ViewModel 来避免出现任务泄漏。

同样,我还帮助您更深入去理解和使用 suspend 函数,通过确保它们在函数返回之前完成任务,或者是通过暴露异常来确保它们正确发出错误信号。

如果我们使用了不符合结构化并发的代码,将会很容易出现协程泄漏,即调用方不知如何追踪任务的情况。这种情况下,任务是无法取消的,同样也不能保证异常会被重新抛出来。这样会使得我们的代码很难理解,并可能会导致一些难以追踪的 bug 出现。

您可以通过引入一个新的不相关的 CoroutineScope (注意是大写的 C),或者是使用 GlobalScope 创建的全局作用域,但是这种方式的代码不符合结构化并发要求的方式。

但是当出现需要协程比调用方的生命周期更长的情况时,就可能需要考虑非结构化并发的编码方式了,只是这种情况比较罕见。因此,使用结构化编程来追踪非结构化的协程,并进行错误处理和任务取消,将是非常不错的做法。

如果您之前一直未按照结构化并发的方法编码,一开始确实一段时间去适应。这种结构确实保证与 suspend 函数交互更安全,使用起来更简单。在编码过程中,尽可能多地使用结构化并发,这样让代码更易于维护和理解。

在本文的开始列举了结构化并发为我们解决的三个问题:

  1. 取消任务 —— 当某项任务不再需要时取消它;
  2. 追踪任务 —— 当任务正在执行时,追踪它;
  3. 发出错误信号 —— 当协程失败时,发出错误信号表明有错误发生。

实现这种结构化并发,会为我们的代码提供一些保障:

  1. 作用域取消 时,它内部所有的协程也会被取消
  2. suspend 函数返回 时,意味着它的所有任务都已完成
  3. 协程报错 时,它所在的作用域或调用方会收到报错通知

总结来说,结构化并发让我们的代码更安全,更容易理解,还避免了出现任务泄漏的情况。

下一步

本篇文章,我们探讨了如何在 Android 的 ViewModel 中启动协程,以及如何在代码中运用结构化并发,来让我们的代码更易于维护和理解。

在下一篇文章中,我们将探讨如何在实际编码过程中使用协程,感兴趣的读者请继续关注我们的更新。

查看原文

赞 0 收藏 0 评论 0

Android开发者 发布了文章 · 11月26日

网易云音乐的 Kotlin 乐章 | Android 开发者故事

https://www.bilibili.com/vide...

"音乐是灵魂之间的美好交流,是带着情绪的艺术品。网易云音乐要做的,就是帮助用户发现和分享好音乐,用音乐连接用户和音乐人,让用户去感受音乐人想表达的情绪,让更多的人用音乐取暖、发光、获得力量。"

—— 郭元,网易云音乐产品经理

网易云音乐是网易旗下一款专注于发现和分享的音乐产品,依托专业音乐人、DJ、好友推荐及社区功能,为用户打造全新的音乐生活。目前,网易云音乐用户数已超过 8 亿,曲库数超 4,000 万 (近期更新数据),入驻原创音乐人超 20 万,是中国最活跃的音乐社区和中国最大的原创音乐平台。

△ 用网易云音乐和好友分享音乐

△ 用网易云音乐和好友分享音乐

Android 客户端开发团队 2019 年 8 月引入 Kotlin 之后,很快就发现学习 Kotlin 是一件比较轻松的事情: 刚开始的一段时间,经常可以在团队成员的周报中看到对 Kotlin 特性的讨论,团队中也不时会有同事撰写 Kotlin 的学习总结文章并发出来分享。大家基本都可以很快上手开发。而且通过 Kotlin 官方网站、Github 上 Kotlin 项目中的文档,以及 Android Studio 提供的将 Kotlin 代码反编译为 Java 代码的功能,都可以帮助团队成员们更加深入地了解 Kotlin 语言。

"Kotlin 作为 Android 开发的新语言新技术,集成了很多其它语言中的先进设计思想。与 Java 代码兼容和互相调用的特性,也极大地减少了我们在内部推广 Kotlin 的阻力。"

—— 贾斌,网易云音乐资深 Android 开发工程师

目前在网易云音乐的 Android 工程中,Kotlin 文件比例大约占 23%,而且新增的功能大部分都是使用 Kotlin 进行编写。团队也同时引入了 KTX 和协程等库来提高开发效率,让工程师更专注于功能本身的实现。

使用 Kotlin 带来的第一个直观好处是简洁。团队自己有做过统计:

"相比 Java 平均每个文件有 138 行代码,Kotlin 平均每个文件只有 82 行代码,代码量平均减少了 40%,使用 Kotlin 要更加简洁、高效。"

—— 展智泉,网易云音乐高级 Android 开发工程师, 网易云音乐 Kotlin 布道者

Kotlin 协程提供了非常方便的线程切换功能,而且通过引入结构化并发机制,使得对异步任务的追踪和取消变得非常简单。协程还可以与 ViewModel 结合使用,利用 viewModelScope,可以让异步任务和 ViewModel 的生命周期保持一致,有助于防止泄漏。

△ 颇受欢迎的 Kotlin 协程

△ 颇受欢迎的 Kotlin 协程

团队统计到的线上崩溃中出现的 NPE 几乎全都发生在 Java 代码中,在 Kotlin 代码中出现的则非常少。

更简洁、更高效、更稳定、更轻松,这就是网易云音乐的 Kotlin 乐章。用户们在这个音乐平台上找到惊喜,而开发者则在新技术的推动下找到了持续探索的新方向。

您的作品中有多少用到了 Kotlin 呢?欢迎在下方评论区和大家分享。

查看原文

赞 0 收藏 0 评论 0

Android开发者 发布了文章 · 11月25日

Kotlin Vocabulary | 类型别名 typealias

作者 / David Winer, Kotlin 产品经理

有时候一些可读性差、不够明确或者名字太长的类型声明会干扰代码的 "自我表达"。这种情况下,可以使用 Kotlin 特别针对这个问题提供的特性: Typealias (本文下称 "类型别名")。类型别名可以使您在不增加新类型的情况下,为现有类或函数类型提供替代名称。

类型别名的使用

使用类型别名为函数类型命名:

typealias TeardownLogic = () -> Unit
fun onCancel(teardown : TeardownLogic){ }
private typealias OnDoggoClick = (dog: Pet.GoodDoggo) -> Unit
val onClick: OnDoggoClick

不过要注意这种用法会隐藏传入参数,使可读性变差:

typealias TeardownLogic = () -> Unit
typealias TeardownLogic = (exception: Exception) -> Unit
fun onCancel(teardown : TeardownLogic){
      // 无法轻易知晓可以从 TeardownLogic 得到什么信息
}

类型别名有助于缩短较长的泛型类名:

typealias Doggos = List<Pet.GoodDoggo>
fun train(dogs: Doggos){ ... }

使用类型别名时,需要思考是否有必要这么做: 在这里使用类型别名真的会让您的代码意义更明确、可读性更好吗?

思考一下,使用类型别名是否使您的代码变得更易懂

如果您正使用的某个类名称很长,您可以使用类型别名来缩短它:

typealias AVD = AnimatedVectorDrawable

在此示例中,使用 导入别名 (import alias) 会更加合适:

import android.graphics.drawable.AnimatedVectorDrawable as AVD

更适用的场景是: 如果在代码中出现了来自不同包的相同类名,可以使用导入别名来消除这样的歧义:

import io.plaidapp.R as appR
import io.plaidapp.about.R

由于类型别名需要在类的外部声明,所以使用时您需要考虑约束它们的可见性。

在多平台工程中使用类型别名

在使用 Kotlin 开发 多平台工程 时,您可以在公共代码 (common code) 中写一个接口,并在相应的平台代码中实现这个接口。Kotlin 提供了 "实际声明" (actual declarations) 和 "预期声明" (expected declarations) 的机制来简化这种操作。在公共代码中声明的接口为预期声明,使用 expect 关键字;在相应的平台代码中的扩展为实际声明,使用 actual 关键字。如果平台代码中已经实现了公共代码中的某个接口,并且所有期望方法的签名一致时,您可以使用类型别名将实际声明的类型名称映射到期望类型上:

expect annotation class Test
actual typealias Test = org.junit.Test

工作原理

类型别名不会引入新的类型。例如,反编译 train 和 play 方法后,可以看到传入参数仅使用了 List 类型:

// Kotlin
typealias Doggos = List<Pet.GoodDoggo> 
fun train(dogs: Doggos) { ... }
fun play(dogs: Doggos) { ... }
// 反编译后 Java 代码
public static final void train(@NotNull List dogs) { … }
public static final void play(@NotNull List dogs) { … }

类型别名不会引入新的类型

因此,您不应该依赖类型别名做编译类型检查,而应该使用一个不同的类型或者内联类。例如,下面的方法中,需要传入一个长整型参数:

fun play(dogId: Long)

为长整型取一个别名,并不能防止您传入一个错的 id:

typealias DogId = Long
fun pet(dogId: DogId) { … }
fun usage() {
    val cat = Cat(1L)
    pet(cat.catId) // compiles
}

类型别名为现有类型提供一个更短或更具意义的名称。但如果您要追求更高的安全性,则创建一个新的类型会比较合适。

查看原文

赞 0 收藏 0 评论 0

Android开发者 发布了文章 · 11月25日

在 Android 11 及更高版本系统中处理可空性

作者 / David Winer, Kotlin 产品经理

在去年 5 月的 I/O 开发者大会上,我们正式宣布 Kotlin 优先 (Kotlin First) 的这一重要理念,Kotlin 将成为 Android 开发者的首选语言。目前,在排名前 1,000 位的 Android 应用中,已有超过 60% 正在使用 Kotlin 进行开发。为什么 Kotlin 受到这么多开发者的喜爱呢?这里就不得不提 Kotlin 在可空性方面的优势了。Kotlin 将可空性直接融合到了类型系统中,这意味着开发者在声明一个参数时,需要提前说明该参数能否接纳 null 值。本文将带您了解 Android 11 SDK 引入了哪些变更,以便在 API 中显示更多的可空性信息。此外,我们还将介绍一些实用方法与技巧,帮助您做好准备,顺利应对 Kotlin 中的可空性问题。

浅谈 Kotlin 中的可空性

使用 Kotlin 编写代码时,您可以使用 问号操作符 来指明可空性:

KOTLIN

var x: Int = 1
x = null // compilation error

var y: Int? = 1
y = null // okay

Kotlin 的这个特性能够让您的代码更安全。即使您随后调用一个方法或试图访问 x 等非空变量属性,也不会面临空指针异常的风险。许多开发者向我们反馈表示,自从有了该特性后,他们便可以把更多精力放在代码设计上并为用户打造质量更高的应用。

可空性对 Java 编程语言有什么作用?

如果我使用的 (Android) API 不是用 Kotlin 编写的,该怎么办?不用担心,Kotlin 编译器能够识别 Java 代码的注释,从而判定方法返回的结果是否为可空值,例如:

JAVA

public @Nullable String getCurrentName() {
   return currentName;
}

添加 @Nullable 注释后,当您在 Kotlin 文件中使用 getCurrentName 的结果时,您必须先进行 null 值检查,才能对其解析引用,否则会触发 Android Studio 报错,而且 Kotlin 编译器也会在构建时抛出错误。@NonNull 注释则恰好与之相反,如果一个方法标有 @NonNull 注释,Kotlin 编译器会将该方法返回的结果视为非空类型,并禁止您在之后的代码中把该结果设置为 null。

此外,Kotlin 编译器还可以识别另外两个类似的注释: @RecentlyNullable 和 @RecentlyNonNull。这两个注释与 @Nullable 和 @NonNull 完全相同,唯一的区别在于它们会生成警告而非错误*。

\* 鉴于 Kotlin 注释处理的相关规则,目前在少数情况下,编译器仅对 @Nullable 引用报错,而对 @RecentlyNullable 引用不报错。
更多详情: https://youtrack.jetbrains.com/issue/KT-36867

Android 11 中的可空性

我们在发布 Android 11 开发者预览版 的时候,邀请开发者们试用了最新的 Android 11 SDK。我们升级了 SDK 中的部分注释,将 @RecentlyNullable 和 @RecentlyNonNull 分别更改为 @Nullable 和 @NonNull (可空性违规行为从触发警告变为导致错误)。此外,我们也继续为 SDK 中不包含可空性信息的方法增加 @RecentlyNullable 和 @RecentlyNonNull 注释。

下一步

如果您正在使用 Kotlin 编写代码,当您从 Android 10 升级至 Android 11 SDK 后,您可能会遇到一些新的编译器警告,另外,之前的警告也可能会以错误形式出现。这是我们特意引入的变更,也是 Kotlin 编译器的特性之一。这些警告会提醒您当前代码可能会导致应用崩溃 (如果您编写的不是 Kotlin 代码,您可能会完全忽略这个风险)。您可以通过在代码中添加 空值检查 来解决这些警告或报错。

我们将继续遵循以下原则为 Android SDK 添加注释: 先在较低版本 (例如 Android 10) 中采用 @RecentlyNullable 和 @RecentlyNonNull,然后再在新版本 (例如 Android 11) 中将其升级为 @Nullable 和 @NonNull。此举的目的是为了给您预留至少一个版本发布周期的时间更新 Kotlin 代码,确保其质量更高、更加健壮。

\* Java 是 Oracle 和/或其附属公司的注册商标。
查看原文

赞 1 收藏 0 评论 0

认证与成就

  • 获得 48 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2019-08-15
个人主页被 3.1k 人浏览