Android 架构指南:suspend 如何响应取消?别再给 Repository 加 `cancel()`

前言

之前写过一篇: Android 架构指南之 Data 层不要再暴露 start/stop 了:用 Flow 接管生命周期

那篇文章讨论的是一个比较常见的问题:

scss 复制代码
repository.start()
repository.stop()

为什么一个 Data 层对象的生命周期,需要由调用方显式管理?

如果 Repository 提供的是持续变化的数据,Flow 本身就可以承担这件事情:

markdown 复制代码
repository.observeUser()
    .collect { ... }

调用方的 coroutine 被取消,collect 被取消,Flow 上游自然也就结束了。

这其实已经解决了一大类生命周期问题。

但还有一种情况,Flow 并不是最合适的表达方式。

比如:

kotlin 复制代码
suspend fun init()

这个操作可能不是简单地创建几个对象。

比如:

  • 初始化比较重的资源
  • 创建并持有一些需要主动释放的对象
  • 初始化底层组件或连接
  • 完成一些耗时的初始化工作

这些资源初始化完成之后,页面可能还会继续使用。

但当页面关闭、ViewModel 被销毁时,这些资源通常也需要及时释放。

于是生命周期变成了:

csharp 复制代码
init
 ↓
初始化资源
 ↓
页面继续使用
 ↓
页面关闭
 ↓
coroutine 被取消
 ↓
释放资源

问题来了:

如果 init() 本身是一个 suspend 操作,它应该怎么感知到调用方的生命周期结束,并在取消时释放这些资源?

很多时候,我们第一反应还是:

kotlin 复制代码
suspend fun init()

fun cancel()

然后:

scss 复制代码
viewModelScope.launch {
    repository.init()
}

// 页面关闭
repository.cancel()

但如果 init() 本身就在:

csharp 复制代码
viewModelScope.launch {
    repository.init()
}

里面运行,那么这个 cancel() 真的还需要存在吗?

其实不需要。

不需要手动取消,这就是 Structured Concurrency 的优势。

我们来看,一个负责初始化重资源的 suspend 操作,如何响应调用方的 cancellation,并在页面生命周期结束时完成资源释放。


一、先看一个最简单的例子

很多 Android 项目的 Repository,会设计成这样:

kotlin 复制代码
interface UserRepository {
    suspend fun init()
    fun cancel()
}

调用方:

scss 复制代码
viewModelScope.launch {
    repository.init()
}

// 页面销毁
repository.cancel()

看起来没什么问题。

但仔细想想:

为什么调用方需要知道 Repository 什么时候该取消?

如果 Repository 本身运行在 viewModelScope 中,那么 ViewModel 销毁的时候,viewModelScope 本来就会自动取消。

既然如此,Repository 为什么还需要额外提供一个 cancel()?

这里其实已经暴露出了一个问题:

我们正在用一个额外的 API,手动管理本来就属于 coroutine 的生命周期。

这篇文章从一个很小的例子开始,看看能不能把这件事情做得更自然。


二、先把问题说清楚:初始化完成,不代表资源生命周期结束

假设我们有一个 Repository:

kotlin 复制代码
class MyRepository {

    suspend fun init() {
        setup()
    }

    private fun setup() {
        println("setup")
    }
}

ViewModel:

kotlin 复制代码
class MyViewModel : ViewModel() {

    fun initRepository() {
        viewModelScope.launch {
            repository.init()
        }
    }
}

这里没有任何问题。

init() 执行:

scss 复制代码
ViewModel
    ↓
viewModelScope
    ↓
launch
    ↓
repository.init()
    ↓
setup()
    ↓
init() 完成

但是这里马上出现一个问题。

如果 init() 很快就执行完了:

csharp 复制代码
viewModelScope.launch {
    repository.init()
}

这个 coroutine 里的任务也就结束了。

之后页面关闭:

markdown 复制代码
ViewModel 被销毁
      ↓
viewModelScope.cancel()

这时候已经没有什么 Repository 的 coroutine 可以取消了。

这本身并不是问题。

因为一个已经完成的操作,本来就不需要再响应取消。

真正的问题是另一种情况:

初始化阶段结束了,但初始化出来的资源生命周期还没有结束。

例如:

csharp 复制代码
init
 ↓
初始化重资源
 ↓
资源初始化完成
 ↓
页面继续使用
 ↓
页面关闭
 ↓
释放资源

这时候:

kotlin 复制代码
suspend fun init()

如果执行完马上返回,那么 Repository 就失去了一个非常重要的东西:

资源生命周期。

我们真正需要解决的不是:

"初始化怎么做?"

而是:

"初始化完成之后,如何让这个资源的生命周期继续跟随调用方?"


三、能不能让 init() 一直等到取消?

可以。

Kotlin 协程提供了一个非常适合这个场景的函数:

scss 复制代码
awaitCancellation()

例如:

kotlin 复制代码
suspend fun init() {
    setup()
    awaitCancellation()
}

它的意思不是:

"阻塞线程,等着取消。"

而是:

"挂起当前协程,一直等到它被取消。"

这是一个非常重要的区别。

这里的 awaitCancellation() 可以理解成:

lua 复制代码
初始化完成
     ↓
资源已经准备好
     ↓
但是资源生命周期还没有结束
     ↓
当前 coroutine 继续保持
     ↓
直到调用方取消

所以:

kotlin 复制代码
suspend fun init() {
    setup()
    awaitCancellation()
}

表达的并不是:

初始化永远不会结束。

而是:

初始化已经结束,但这个生命周期型操作还没有结束。


四、awaitCancellation() 会不会阻塞线程?

不会。

例如:

kotlin 复制代码
suspend fun init() {
    setup()
    awaitCancellation()
}

执行到:

scss 复制代码
awaitCancellation()

之后:

scss 复制代码
Coroutine
    │
    ├── setup()
    │
    ↓
awaitCancellation()
    │
    │
    │ 协程挂起
    │
    ↓
等待 Cancellation

它不会一直占用当前线程。

这和:

bash 复制代码
Thread.sleep(...)

完全不是一回事。

Thread.sleep():

复制代码
线程
 ↓
被占用
 ↓
等待
 ↓
继续执行

而 awaitCancellation():

复制代码
协程
 ↓
挂起
 ↓
线程可以去干其他事情
 ↓
收到取消
 ↓
进入取消流程

所以它不会把主线程卡住。

这一点非常重要。

因为我们想要的是:

保持 coroutine 的生命周期,而不是占住一个线程。


五、现在问题就变得有意思了

我们把 Repository 改成:

kotlin 复制代码
class MyRepository {

    suspend fun init() {
        setup()
        awaitCancellation()
    }

    private fun setup() {
        println("setup")
    }
}

ViewModel:

kotlin 复制代码
fun initRepository() {
    viewModelScope.launch {
        repository.init()
    }
}

现在整个生命周期就变成了:

scss 复制代码
ViewModel
    │
    ↓
viewModelScope
    │
    ↓
launch
    │
    ↓
repository.init()
    │
    ├── setup()
    │
    └── awaitCancellation()
             │
             │
             │ 等待
             │
             ↓
        页面继续使用
             │
             ↓
        ViewModel 销毁
             │
             ↓
        viewModelScope.cancel()
             │
             ↓
        init() 被取消

这里其实发生了一件非常重要的事情:

我们没有手动取消 Repository。

没有:

scss 复制代码
repository.cancel()

也没有:

scss 复制代码
repository.stop()

甚至 Repository 根本不需要知道:

"页面什么时候关闭?"

因为:

csharp 复制代码
repository.init()

本身就是 viewModelScope 中 coroutine 的一部分。

当父 coroutine 被取消时,子 coroutine 会自然地跟着取消。

这就是 Structured Concurrency 的优势。

可以把它理解成:

lua 复制代码
父 coroutine
    │
    ├── 子 coroutine A
    │
    ├── 子 coroutine B
    │
    └── repository.init()

父协程取消:

markdown 复制代码
Parent cancel
     ↓
Child cancel
     ↓
cleanup

所以:

取消不是 Repository 额外提供的一种能力,而是 coroutine 生命周期自然产生的结果。

这也是 Structured Concurrency 和传统手动生命周期管理最大的区别之一。

以前我们可能习惯:

scss 复制代码
init()

// 页面关闭
cancel()

需要自己记住什么时候 cancel()。

现在则变成:

csharp 复制代码
viewModelScope.launch {
    repository.init()
}

谁创建 coroutine,谁就拥有这个 coroutine 的生命周期。

页面销毁:

scss 复制代码
ViewModel
    ↓
viewModelScope.cancel()
    ↓
repository.init() 自动收到取消

不需要手动取消。

当然,这里还有一个问题:

init() 为什么没有在 setup() 完成之后立即返回?

因为我们的场景并不是:

复制代码
初始化
 ↓
完成
 ↓
结束

而是:

复制代码
初始化资源
 ↓
资源初始化完成
 ↓
页面继续使用
 ↓
页面关闭
 ↓
资源释放

所以这里才需要:

scss 复制代码
awaitCancellation()

它负责的不是"取消"。

取消由 Structured Concurrency 负责传播。

awaitCancellation() 只是让这个生命周期型的 suspend 操作继续存在,直到父 coroutine 被取消。

这两个概念一定要分清:

scss 复制代码
Structured Concurrency
        ↓
负责生命周期和取消传播

awaitCancellation()
        ↓
让 init() 保持到取消发生

两者结合起来,才形成完整的资源生命周期管理。


六、取消是怎么进入 Repository 的?

现在我们已经知道:

csharp 复制代码
viewModelScope.launch {
    repository.init()
}

这里不需要:

scss 复制代码
repository.cancel()

因为:

csharp 复制代码
viewModelScope
    ↓
repository.init()

本身就建立了父子关系。

当 ViewModel 被销毁:

scss 复制代码
viewModelScope.cancel()
        ↓
child coroutine 被取消
        ↓
repository.init()
        ↓
CancellationException

那么 Repository 怎么处理这个取消?

很简单:

kotlin 复制代码
class MyRepository {

    suspend fun init() {
        try {
            setup()
            awaitCancellation()
        } catch (e: CancellationException) {
            cleanup()
            throw e
        }
    }

    private fun setup() {
        println("setup")
    }

    private fun cleanup() {
        println("cleanup")
    }
}

整个过程:

scss 复制代码
viewModelScope.cancel()
        ↓
init() 收到 cancellation
        ↓
CancellationException
        ↓
cleanup()
        ↓
throw e
        ↓
取消继续向上传播

这里最重要的一点就是:

Repository 不需要主动发起取消。

它只需要:

响应调用方已经发生的取消。

这就是 Structured Concurrency 带来的一个非常大的好处:

调用方负责拥有生命周期,子任务负责响应生命周期。

因此,我们不需要再设计一套额外的:

scss 复制代码
start()
stop()

init()
cancel()

来同步两个生命周期。

生命周期已经通过 coroutine 的父子关系连接起来了。


七、为什么一定要 throw e?

这里有一个很容易踩的坑。

不要这样:

scss 复制代码
catch (e: CancellationException) {
    cleanup()
}

因为这样实际上把 CancellationException 吃掉了。

正确做法:

scss 复制代码
catch (e: CancellationException) {
    cleanup()
    throw e
}

原因很简单:

取消不是普通业务异常。

它是协程生命周期的一部分。

取消发生之后,Repository 可以做清理,但不应该阻止取消继续向上传播。

所以可以把它理解成:

markdown 复制代码
CancellationException
        │
        ├── Repository 做 cleanup
        │
        └── 继续向上抛

而不是:

markdown 复制代码
CancellationException
        │
        ↓
Repository 吞掉
        │
        ↓
取消状态被隐藏

所以:

scss 复制代码
catch (e: CancellationException) {
    cleanup()
    throw e
}

这三个动作其实分别对应三个职责:

复制代码
收到取消
   ↓
释放资源
   ↓
继续传播取消

八、还有一个细节:cleanup 如果需要挂起怎么办?

假设:

kotlin 复制代码
suspend fun cleanup() {
    // some suspend work
}

这时候:

scss 复制代码
catch (e: CancellationException) {
    cleanup()
    throw e
}

可能存在问题。

因为当前 coroutine 已经处于取消状态。

如果 cleanup() 内部还有需要挂起的操作,那么它也可能立即响应取消。

例如:

scss 复制代码
catch (e: CancellationException) {
    cleanup()
    throw e
}

如果 cleanup() 内部:

kotlin 复制代码
suspend fun cleanup() {
    someSuspendFunction()
}

这个挂起操作也可能因为当前 coroutine 已经取消而无法正常完成。

如果这个 cleanup 必须保证执行完成,可以使用:

scss 复制代码
catch (e: CancellationException) {
    withContext(NonCancellable) {
        cleanup()
    }

    throw e
}

意思就是:

当前协程已经被取消,但这段必要的清理工作必须执行完。

然后再:

arduino 复制代码
throw e

继续把取消传播出去。

不过不要滥用 NonCancellable。

它适合的是:

  • 必须释放的资源
  • 必须执行的 unregister
  • 必须完成的状态恢复
  • 必须保证一致性的清理操作

而不是拿来让一个已经取消的任务继续干一大堆工作。

NonCancellable 应该是一个非常小的保护区域。


九、为什么这一切都成立?Structured Concurrency

看到这里,其实已经可以看出:

真正重要的并不是:

scss 复制代码
awaitCancellation()

而是它背后的生命周期关系。

我们来看一个错误的写法:

kotlin 复制代码
class MyRepository {

    fun init() {
        CoroutineScope(Dispatchers.IO).launch {
            doSomething()
        }
    }
}

然后:

csharp 复制代码
viewModelScope.launch {
    repository.init()
}

表面上看起来生命周期绑定了。

实际上没有。

因为:

scss 复制代码
viewModelScope
    │
    └── repository.init()
             │
             └── CoroutineScope(IO).launch
                        │
                        └── doSomething()

Repository 又创建了一个新的 CoroutineScope。

这个新的 Job 并不是 viewModelScope 的 child。

所以:

lua 复制代码
ViewModel 销毁
      ↓
viewModelScope.cancel()
      ↓
ViewModel 相关 coroutine 被取消
      ↓
Repository 自己创建的 coroutine
      ↓
还在运行

这就是生命周期脱节。

真正理想的结构是:

scss 复制代码
ViewModel
    │
    ↓
viewModelScope
    │
    ↓
launch
    │
    ↓
Repository.init()
    │
    ├── setup()
    │
    └── awaitCancellation()

也就是说:

Repository 使用调用方提供的 coroutine 上下文,而不是自己偷偷创建一个新的生命周期。

这就是 Structured Concurrency 的核心思想之一:

父协程负责子协程的生命周期。

父取消:

markdown 复制代码
Parent cancel
     ↓
Child cancel
     ↓
Child cleanup

所以 Repository 不需要自己维护:

ini 复制代码
private val scope = CoroutineScope(...)

也不需要维护:

csharp 复制代码
private var job: Job? = null

更不需要额外暴露:

kotlin 复制代码
fun cancel()

如果这个操作本来就属于调用方的生命周期,那么让 coroutine 结构表达这种关系,通常更加自然。


十、这也解释了为什么 Repo 不需要 cancel()

如果 Repository API 是:

kotlin 复制代码
interface Repository {

    suspend fun init()
}

调用方:

csharp 复制代码
viewModelScope.launch {
    repository.init()
}

那么:

csharp 复制代码
ViewModel
    │
    └── viewModelScope
          │
          └── repository.init()

Repository 根本不需要知道:

"你什么时候页面关闭?"

它只需要遵守协程的取消规则。

页面生命周期:

markdown 复制代码
ViewModel destroyed
        ↓
viewModelScope.cancel()

自然就会传递到:

csharp 复制代码
repository.init()

这就是一个非常漂亮的职责分离。

调用方决定:

这个操作属于谁的生命周期?

Repository 决定:

资源初始化、使用以及释放应该怎么做?

两者通过 coroutine 的 cancellation 自然连接起来。


十一、调用方只负责生命周期

调用方:

csharp 复制代码
viewModelScope.launch {
    repository.init()
}

负责的是:

这个操作属于谁的生命周期?

Repository:

kotlin 复制代码
suspend fun init() {
    ...
}

负责的是:

我要怎么初始化和管理这些资源?

而不是:

scss 复制代码
repository.init()
repository.cancel()

让调用方同时管理:

  • 启动
  • 生命周期
  • 取消
  • 清理

这样职责就混在一起了。

更自然的结构是:

markdown 复制代码
调用方
    ↓
决定生命周期
    ↓
coroutine

Repository
    ↓
执行初始化
    ↓
持有资源
    ↓
响应 cancellation
    ↓
释放资源

所以:

调用方不需要管理 Repository 的取消,它只需要管理自己的 coroutine 生命周期。


十二、一个完整的例子

Repository:

kotlin 复制代码
class MyRepository {

    suspend fun init() {
        try {
            log("init start")
            setup()
            log("setup finished")

            awaitCancellation()

        } catch (e: CancellationException) {
            log("received cancellation")
            cleanup()
            throw e
        }
    }

    private fun setup() {
        log("setup")
    }

    private fun cleanup() {
        log("cleanup")
    }

    private fun log(message: String) {
        println(
            "[${Thread.currentThread().name}] $message"
        )
    }
}

ViewModel:

kotlin 复制代码
class MyViewModel : ViewModel() {

    fun initRepository() {
        viewModelScope.launch {
            repository.init()
        }
    }
}

运行过程:

scss 复制代码
[main] init start

[main] setup

[main] setup finished

                    ↓
              awaitCancellation()
                    ↓
                协程挂起
                    ↓
                页面继续使用
                    ↓
                页面关闭
                    ↓
            viewModelScope.cancel()
                    ↓

[main] received cancellation

[main] cleanup

这里最重要的是:

awaitCancellation() 并没有占用线程。

它只是让这个 coroutine 保持生命周期。

而真正让 init() 在页面关闭时结束的,是:

scss 复制代码
viewModelScope.cancel()

以及 Structured Concurrency 带来的取消传播:

csharp 复制代码
viewModelScope
      ↓
child coroutine
      ↓
repository.init()
      ↓
CancellationException

所以:

Repository 没有主动取消自己,也没有被调用方手动取消。

它只是响应父 coroutine 的 cancellation。


十三、这个模式并不是让所有 Repo 都这么写

这里需要特别强调:

scss 复制代码
awaitCancellation()

不是一个"Repository 标准模板"。

如果你的 Repository 方法只是一次性的:

kotlin 复制代码
suspend fun loadUser(): User

那么完全不需要:

scss 复制代码
awaitCancellation()

正常:

ini 复制代码
viewModelScope.launch {
    val user = repository.loadUser()
}

就够了。

因为:

scss 复制代码
loadUser()
    ↓
完成
    ↓
coroutine 结束

这就是正确的生命周期。

awaitCancellation() 适合的是:

初始化完成之后,资源本身还需要继续存在,并且应该随着调用方 coroutine 的生命周期结束而释放。

例如:

  • 初始化比较重的资源
  • 创建并持有需要主动释放的对象
  • 初始化底层组件
  • 建立需要主动关闭的连接
  • 持有某些必须在生命周期结束时释放的资源

这时候才有必要考虑:

scss 复制代码
awaitCancellation()

所以不要看到 suspend 就条件反射地加:

scss 复制代码
awaitCancellation()

它解决的是一个非常具体的问题:

资源初始化完成了,但资源生命周期还没有结束。


十四、如果本身就是持续的数据,Flow 依然更自然

这里也需要和上一篇文章联系起来。

如果 Repository 本身提供的是持续变化的数据:

kotlin 复制代码
fun observeUser(): Flow<User>

那么通常不需要自己:

scss 复制代码
awaitCancellation()

调用方:

sql 复制代码
viewModelScope.launch {
    repository.observeUser().collect { user ->
        // update UI
    }
}

当 ViewModel 销毁:

scss 复制代码
viewModelScope.cancel()
        ↓
collect cancelled
        ↓
Flow 上游取消
        ↓
Repository 停止工作

这其实也是 Structured Concurrency。

所以:

Flow 更适合表达持续的数据流;而 awaitCancellation() 更适合表达一个资源初始化完成之后,仍然需要继续持有资源的生命周期型 suspend 操作。

两者解决的问题并不完全一样。

可以简单理解成:

scss 复制代码
持续的数据
    ↓
   Flow
    ↓
collect 生命周期


资源型操作
    ↓
 suspend
    ↓
awaitCancellation()
    ↓
coroutine 生命周期

所以上一篇文章讲:

Data 层不要再暴露 start/stop,用 Flow 接管生命周期。

这一篇则继续往下讨论:

如果这个场景不是 Flow,而是一个生命周期型 suspend 操作,它又应该如何响应取消?

答案还是同一个:

让 coroutine 自己管理生命周期,而不是额外设计一套 start/stop 或 init/cancel。


十五、回到那个最初的问题

为什么 Repository 要不要提供:

scss 复制代码
cancel()

真正的问题并不是:

"有没有必要提供一个 cancel 方法?"

而是:

这个 Repository 的 coroutine 到底属于谁?

如果它属于:

复制代码
ViewModel
   ↓
viewModelScope

那么:

scss 复制代码
repository.cancel()

通常就是多余的。

因为调用方已经拥有生命周期。

正确的设计是:

csharp 复制代码
viewModelScope.launch {
    repository.init()
}

Repository:

kotlin 复制代码
suspend fun init() {
    try {
        setup()
        awaitCancellation()
    } catch (e: CancellationException) {
        cleanup()
        throw e
    }
}

最终形成:

scss 复制代码
              ViewModel
                 │
                 ↓
           viewModelScope
                 │
                 ↓
               launch
                 │
                 ↓
             Repository
                 │
                 ↓
               init()
                 │
                 ↓
           初始化重资源
                 │
                 ↓
       awaitCancellation()
                 │
                 │
           页面继续使用
                 │
                 ↓
       页面关闭 / VM 清除
                 │
                 ↓
       viewModelScope.cancel()
                 │
                 ↓
        CancellationException
                 │
                 ↓
              cleanup()

没有:

scss 复制代码
repository.cancel()

没有:

复制代码
GlobalScope

没有:

scss 复制代码
CoroutineScope(Dispatchers.IO)

也没有额外的生命周期同步代码。

生命周期已经存在于 coroutine 的父子关系里。


最后

上一篇文章讲的是:

不要让调用方通过 start/stop 管理 Data 层的生命周期,让 Flow 接管它。

这一次,我们继续往下走了一步。

当一个操作本身适合用 suspend 表达时,也不应该重新退回到:

scss 复制代码
init()
cancel()

而应该让 coroutine 自己携带生命周期。

调用方决定:

csharp 复制代码
viewModelScope.launch {
    repository.init()
}

Repository 决定:

kotlin 复制代码
suspend fun init() {
    try {
        setup()
        awaitCancellation()
    } catch (e: CancellationException) {
        cleanup()
        throw e
    }
}

于是:

lua 复制代码
谁创建 coroutine
        ↓
谁拥有生命周期
        ↓
父协程取消
        ↓
子协程自动取消
        ↓
Repository 响应 cancellation
        ↓
释放自己持有的资源

这里最值得记住的,其实不是:

scss 复制代码
awaitCancellation()

而是:

不需要手动取消,这就是 Structured Concurrency 的优势。

awaitCancellation() 只是技术手段。

它解决的是:

初始化完成了,但资源生命周期还没有结束。

而 Structured Concurrency 解决的是更大的问题:

这个资源到底属于谁的生命周期?

如果它属于 viewModelScope,那么就让它成为 viewModelScope 的一部分。

页面销毁:

scss 复制代码
ViewModel
    ↓
viewModelScope.cancel()
    ↓
child coroutine 自动取消
    ↓
Repository 响应 CancellationException
    ↓
cleanup()

整个过程没有:

scss 复制代码
repository.cancel()

没有:

scss 复制代码
repository.stop()

也不需要调用方额外记住:

"页面销毁的时候,我是不是还漏掉了一个资源释放?"

因为生命周期已经进入了 coroutine 的结构。

以前我们习惯:

scss 复制代码
init()
...
cancel()

现在则是:

lua 复制代码
创建 coroutine
    ↓
资源属于这个 coroutine
    ↓
父协程结束
    ↓
子协程自动取消
    ↓
资源清理

取消不是一个额外的业务 API,而是 coroutine 生命周期自然产生的结果。

这就是 Structured Concurrency 真正漂亮的地方。

好的架构,有时候不是增加一个 cancel()。

而是:

让这个 cancel() 根本不需要存在。

相关推荐
迪飞特科技2 小时前
【无标题】
android·人工智能·本地化大模型
FlightYe2 小时前
视界原理之2D视频(二):视频文件里有什么
android·linux·网络·c++·ffmpeg·音视频·aac
传奇开心果编程2 小时前
【Flutter入门练中学】第8课:动画与过渡
android·学习·flutter·ui·ios
传奇开心果编程5 小时前
【Flutter入门练中学】第11课:状态管理进阶与声明式路由
android·学习·flutter·ui·ios
JasonSJX5 小时前
四端自建播放器怎么落地:Android、iOS、tvOS、Tizen 的 DRM 播放 SDK 技术盘点
android·ios·音视频·视频防录屏·加密保护课程·直播安全
驰骋工作流5 小时前
工作流引擎四大流程模块功能点统计:769 项能力清单梳理低代码工作流引擎表单
android·低代码·rxjava
恋猫de小郭5 小时前
Flutter 多窗口重要优化合并,多窗口性能和实用性大幅提升
android·前端·flutter
AirDroid_cn5 小时前
实时定位孩子手机超便捷!iPhone家长同步收提醒,省心又踏实
android
hai_android6 小时前
LruCache 图片浏览器内存缓存
android·java·kotlin