Android 并发考古:newThread 与 RxJava 共存,以及 GlobalScope 的三宗罪

几代异步方案并存不是问题,没有所有者才是问题

前言

打开一个活了五年的 Android 工程,经常能在同一个文件里看到 Handler、TimerTask、线程池、RxJava、协程。第一反应通常是"这代码太脏了",然后想找个时间统一技术栈。

但脏不是问题的本质。真正让人吃亏的,是这些方案各自管各自的取消、各自管各自的异常 :Handler 用 removeCallbacks,线程池用 shutdownNow,RxJava 用 Disposable,协程用 Job.cancel。只要漏掉一条,就会出现"服务已经停了,任务还在跑"的状态。这跟用了几代技术无关,跟"谁负责收尾"有关。

我维护的这套测绘采集工程里,一个 400 行的后台同步 Service 同时躺着五代异步方案。这篇分两半:前半讲共存本身------它是怎么来的、代价是什么、不重写怎么治理;后半讲其中危险的一处,GlobalScope。两部分共用一条主线:几代方案共存完全没问题,问题是资源的所有者必须唯一。

一、一个文件里的"五世同堂"

1.1 现场

这个工程里有一个后台同步服务(弱网环境下上传测量数据),单文件 400 行,里面同时出现了:

代际 出现形式 作用
第一代 Thread / Handler Handler(Looper.getMainLooper()) 主线程消息、弹窗
第二代 Executor + TimerTask Executors.newScheduledThreadPool(1) + scheduleWithFixedDelay 60 秒轮询
第三代 回调式网络库 Retrofit Call.enqueue(Callback) 网络请求
第四代 RxJava 2 Observable + Disposable(散落在各业务模块) 数据流订阅
第五代 协程 GlobalScope.launch(Dispatchers.IO) + async/await 批量上传编排
kotlin 复制代码
// 同一个 Service 里的片段(示意,已脱敏)
class SyncService : Service() {

    private var selectService = Executors.newScheduledThreadPool(1)   // 第二代
    private val timerTask = object : TimerTask() {                    // 第二代
        override fun run() {
            if (isNetworkConnected()) selectResult()
            if (System.currentTimeMillis() - startTime >= 4 * 3600 * 1000) {
                stopRecord()
            }
        }
    }

    fun start() {
        selectService?.scheduleWithFixedDelay(timerTask, 1000, 60_000, TimeUnit.MILLISECONDS)
    }

    private fun upload(list: List<Item>) {
        job = GlobalScope.launch(Dispatchers.IO) {                    // 第五代
            val online = async { isNetOnline() }
            if (online.await()) {
                for (item in list) {
                    val r = async { httpManager.upload(item) }        // 混用第三代回调
                    if (r.await()) { /* ... */ }
                }
            }
            withContext(Dispatchers.Main) {
                showSuccessDialog()                                   // 第一代语义
            }
        }
    }
}

这不是段子,是大多数活过五年的 Android 工程的真实现状。

1.2 为什么会变成这样

每一代技术的引入都有当时的合理性:

时期 引入原因 遗留原因
早期 Thread + Handler 只有这个能用 能跑,没人敢动
AsyncTask 官方推荐(当年) 已被废弃,但老代码还在
Executor / TimerTask 需要定时、需要线程复用 定时器逻辑稳定,改了要重测
RxJava 需要复杂的数据流编排、线程切换优雅 学习曲线陡,新人不会改只会抄
协程 官方主推,结构化并发 只有新代码用,老代码没人迁移

关键洞察:遗留代码的迁移成本,几乎总是由"业务价值"来排优先级。异步框架改造没有直接业务价值,所以永远排最后。

这句话解释了为什么"统一技术栈"这个目标在真实项目里几乎不可能达成。它不是一个技术决策,而是一个排期决策;而排期永远轮不到它。接受这一点之后,治理思路才会从"重写"转向"共存 + 约束"。

二、共存的四个代价

先把"代价"说清楚。四代方案真正的差别,其实不在 API 长什么样,而在谁负责结束:

代际 谁负责结束 漏掉的后果
Thread 自己跑完,或手动 interrupt 线程无法停止,持有外部引用
Handler removeCallbacks 消息还在队列里,持有 target
Executor shutdown / shutdownNow 线程常驻,任务继续跑
RxJava Disposable.dispose() / clear() 订阅泄漏,回调继续打
协程 父 scope 的取消传播 取决于这个 scope 有没有 owner

这张表就是后面所有问题的索引:每一种方案都需要一个"所有者"来触发结束,而混用时所有者有五个。

2.1 代价一:取消语义不统一,"停止服务"停不干净

Service 停止时需要取消所有异步任务,五种技术有五种取消方式:

kotlin 复制代码
override fun onDestroy() {
    super.onDestroy()
    timerTask.cancel()               // TimerTask:cancel
    selectService?.shutdownNow()     // 线程池:shutdownNow
    job?.cancel()                    // 协程 Job:cancel
    compositeDisposable.clear()      // RxJava:clear
    handler.removeCallbacksAndMessages(null)   // Handler:移除消息
    // 还有 Retrofit 的 Call:call.cancel()
}

漏掉任何一个,都会留下"服务已死但任务还在跑"的状态。 而这类问题的表现是:

  • 用户退出后还在上传,流量偷偷跑了;
  • Service 被杀后任务写数据库,抛 IllegalStateException;
  • 定时任务持有 Activity 引用(虽然用的是 Application Context,但回调里有 UI 操作)。

第一条只是费流量,第二条和第三条才是真麻烦:它们发生在别人的生命周期里。组件已经销毁,任务还在往里写,结果要么是崩溃,要么是写进一个没人再读的地方。

2.2 代价二:异常处理路径各不相同

技术 未捕获异常去哪了
Thread UncaughtExceptionHandler
AsyncTask onCancelled / 直接崩
线程池 submit 被吞进 Future,不 get() 就永远看不到
线程池 execute UncaughtExceptionHandler
RxJava RxJavaPlugins.setErrorHandler,否则崩溃
协程 launch CoroutineExceptionHandler,否则走全局 handler
协程 async 封装进 Deferred,不 await 就静默丢失

这意味着:"为什么这个后台任务失败了却没日志",要按技术栈逐一套答案。 排查一个人要同时知道七种异常路径,这本身就是成本。

注意表里有两行是"静默"的:submit 的 Future 和 async 的 Deferred。它们的设计假设是"调用方一定会来取结果",但在业务代码里,submit 之后忘了 get、async 之后忘了 await,是极常见的事。一旦忘了,异常就存在对象里,随着对象被 GC 一起消失。

2.3 代价三:线程切换的心智负担

同一个业务流程里,可能经历:

复制代码
RxJava subscribeOn(IO) → observeOn(mainThread)
   → 回调进 Retrofit Callback(IO 线程)
       → 手动切 Handler 到主线程
           → 主线程里 launch 协程
               → withContext(IO)

每一跳都是"读代码时要在脑子里跑一遍线程模型"。而线程切换的地方,恰恰是最容易出并发 Bug 的地方。

更麻烦的是,这段代码里没有任何一处是"错"的。每一跳都符合它所在技术的用法,但连起来之后,没有人能一眼说出"这段代码最终在哪个线程上更新 UI"。

2.4 代价四:新人只会抄最新看到的那一种

结果是同一个模块内,A 文件用 RxJava、B 文件用协程、C 文件用 Thread。代码风格不一致时,Code Review 也失去了统一标准------你没法说"这里应该用协程",因为隔壁文件就是 RxJava,而且跑得很好。

风格分裂的深层影响是:团队失去了判断"什么是好代码"的共同基准。这比多几行样板代码严重得多。

三、不重写,先让现状可控

完全重写不现实(风险与收益不成正比)。可操作的策略是"止血 → 隔离 → 渐进替换"三步。

3.1 第一步:给每一代技术定"禁止新增"的边界

技术 状态
AsyncTask 禁止新增,且已废弃(API 30+),列入清理计划
new Thread() 禁止新增,一律用协程
Handler 仅允许用于"主线程消息",不允许 postDelayed 做定时轮询
Executors 仅允许在"非 Android 生命周期组件"中使用(如长驻服务)
RxJava 冻结:老代码可维护,新代码一律协程
协程 唯一推荐

这条规则要写进 Code Review 清单,并用 CI 做粗粒度检查(比如禁止 new Thread( 出现在 src/main)。

"冻结"这个词很重要。它不是"禁止",而是"不再新增,存量保留"。前者会引发无谓的争论,后者是一个可执行的状态描述。

3.2 第二步:统一"取消"入口

给每个持有异步任务的组件定义一个统一的销毁契约:

kotlin 复制代码
interface DisposableScope {
    fun register(disposable: Disposable)     // RxJava
    fun register(job: Job)                   // 协程
    fun register(future: Future<*>)          // 线程池
    fun disposeAll()
}

或者更实际的:在组件(Activity / Fragment / 自定义生命周期类)里维护一个统一的清理列表:

kotlin 复制代码
class TaskBag {
    private val jobs = mutableListOf<Job>()
    private val disposables = CompositeDisposable()
    private val futures = mutableListOf<Future<*>>()

    fun add(job: Job) = jobs.add(job)
    fun add(d: Disposable) = disposables.add(d)
    fun add(f: Future<*>) = futures.add(f)

    fun clear() {
        jobs.forEach { it.cancel() }
        jobs.clear()
        disposables.clear()
        futures.forEach { it.cancel(true) }
        futures.clear()
    }
}

收益 :onDestroy 里只需要调一次 taskBag.clear(),不会漏。

这个模式的价值不在代码本身,而在它把"取消"从一个分散的约定 变成了一个集中的入口。以前是"每个人记得取消自己创建的东西",现在是"所有东西都必须登记,销毁时统一清"。前者依赖自觉,后者依赖编译器和 review。

3.3 第三步:边界隔离------老代码保持原样,新代码用新方案

不要在一个文件里混用两套。规则:

改造一个文件时,把它整体迁移到协程;不改造的文件,保持原技术栈不动。

避免"文件内一半 RxJava 一半协程"这种最难维护的状态。因为一旦混用,你就同时需要两套取消逻辑、两套异常处理、两套线程模型------而且它们还在同一个函数里交替出现。

3.4 一个可执行的迁移优先级

按"风险低 + 收益高"排序:

优先级 目标 理由
P0 所有 new Thread() + runOnUiThread 改动机械、风险低、收益(可取消 + 异常可控)明显
P0 所有 GlobalScope 泄漏风险最高(后文详述)
P1 所有 postDelayed 轮询 换成 lifecycleScope + delay 循环,可读性提升大
P1 纯 IO 型 RxJava(单次请求) 可直接替换为 suspend 函数
P2 复杂 RxJava 数据流(组合、防抖、切换) 迁移风险高,建议最后做,或保留
P3 稳定运行多年的 Executor 定时任务 不动,除非有明确 Bug

两个 P0 的共同点:它们的改动是机械的,不需要重新设计业务 。new Thread 换成 scope.launch,GlobalScope 换成有 owner 的 scope,改完行为不变、生命周期变清楚。这种改动最适合先做,因为它能快速建立团队对"迁移是安全的"的信心。

四、最危险的一处:GlobalScope

4.1 现场:一个 Service 里的三处

kotlin 复制代码
// 后台同步服务(示意,已脱敏)
class SyncService : Service() {

    private var job: Job? = null

    private fun uploadAll(list: List<Item>) {
        job = GlobalScope.launch(Dispatchers.IO) {          // ①
            val online = async { isNetOnline() }
            if (online.await()) {
                for (item in list) {
                    val r = async { httpManager.upload(item) }
                    if (r.await()) {
                        withContext(Dispatchers.Main) { updateProgress() }
                    }
                }
            }
        }
    }

    private fun dealFile(path: String) {
        GlobalScope.launch(Dispatchers.IO) {                 // ②
            val result = async { updateDatabase(path) }
            if (result.await()) {
                withContext(Dispatchers.Main) { showSuccessDialog() }
            }
        }
    }

    private fun refresh(filePath: String, taskId: String) {
        GlobalScope.launch(Dispatchers.IO) {                 // ③
            updateItem(filePath, taskId)
        }
    }
}

三处 GlobalScope.launch,其中只有第一处保存了 Job 引用------而这个 job 从来没有被 cancel() 过 (搜索全类,job 只有赋值,没有取消)。

这个细节很典型:写代码的人其实"知道要保存 Job",但保存之后没有下一步。保存 Job 只是获得了取消的能力,不等于取消了。

4.2 GlobalScope 是什么

kotlin 复制代码
public object GlobalScope : CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext        // 空的:没有 Job,没有 Dispatcher,没有异常处理器
}

三个特征决定了它的危险性:

  1. 没有 Job → 无法整体取消,只能逐个 Job.cancel();
  2. 生命周期 = 整个应用进程 → 启动它的组件销毁了,协程还在跑;
  3. 没有 CoroutineExceptionHandler → 异常走全局 UncaughtExceptionHandler(如果全局 handler 又被自定义实现覆盖且没有委托,异常就此蒸发)。

官方注释里写得很直白:

Application code usually should use an application-defined CoroutineScope. Using async or launch on the instance of GlobalScope is highly discouraged.

往机制里再走一步:CoroutineScope.launch 在创建协程时会用 newCoroutineContext(context) 计算上下文,其中如果找不到 Job,就新建一个独立的 Job()。所以每一次 GlobalScope.launch 都是一个没有父节点的根协程------它不参与任何树,取消不会传播给它,它失败也不会通知任何人。

结构化并发只有三条规则:父取消则子取消、子失败则父取消(SupervisorJob 例外)、父等待所有子结束。GlobalScope 把三条全绕过了。

五、三宗罪

5.1 第一宗:生命周期失控(泄漏)

Service.onDestroy() 执行了,但 GlobalScope 启动的协程:

  • 继续持有 this@SyncService(隐式外部引用);
  • 继续执行网络请求、数据库写入;
  • 继续 withContext(Dispatchers.Main) 尝试更新 UI(此时 Activity 可能已经销毁)。

泄漏链:

复制代码
GlobalScope 协程(进程级)
   → 持有 SyncService 实例
       → 持有 Context / 数据库连接 / 监听器
           → 全部无法释放

每次启动服务跑一轮,就多一份引用。反复启停,内存缓慢上涨,最终 OOM------而且 LeakCanary 未必报得出来:它的检测逻辑针对的是"已销毁但被引用的组件",而这里协程还在运行、引用看起来"有主",很容易被当成正常的后台任务放过。

5.2 第二宗:无法取消(资源浪费 + 状态错乱)

job?.cancel() 从来没被调用。后果:

场景 表现
用户停止同步 上传继续跑,流量继续消耗
用户退出登录 上传继续用旧账号凭证,服务端返回 401
服务重启 上一轮的协程和新一轮并发写同一批数据,数据错乱
进程即将被杀 协程在后台写数据库,写一半进程没了

尤其是"并发写同一批数据"------这属于数据一致性问题,比崩溃更难查。因为两次写入单独看都是成功的,只是最终结果不对。

即便调用了 cancel(),也要知道:协程的取消是协作式的。

kotlin 复制代码
GlobalScope.launch(Dispatchers.IO) {
    for (item in list) {
        upload(item)          // 如果是阻塞式调用,cancel 不会中断它
    }
}

必须主动检查:

kotlin 复制代码
for (item in list) {
    ensureActive()            // 取消时抛 CancellationException
    upload(item)
}

或者用 isActive 判断。

ensureActive() 只是"检查并抛异常",它不会中断正在执行的 upload(item)。如果 upload 是一个阻塞的 socket 写,取消信号要等到这一帧写完才有机会生效。真正要及时中断,得让阻塞调用本身可中断------关闭 socket、设置读超时,或者用 withTimeout 配合可取消的底层 API。

5.3 第三宗:异常静默

kotlin 复制代码
GlobalScope.launch(Dispatchers.IO) {
    updateItem(filePath, taskId)      // 这里抛异常会怎样?
}

launch 里的未捕获异常会交给 CoroutineExceptionHandler。GlobalScope 没设置,所以走全局 → 如果全局 handler 被覆盖且没委托,异常彻底消失。

加一个 handler 至少能看见:

kotlin 复制代码
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e ->
    Log.e(TAG, "coroutine failed", e)
    CrashReport.record(e)
})

注意这里用的是 SupervisorJob() 而不是 Job():

  • Job():一个子协程失败 → 整个 scope 全部取消;
  • SupervisorJob():子协程失败互不影响,适合"多个独立任务"的场景。

还有一处比 launch 更静默:async。async 的异常不会交给 CoroutineExceptionHandler,而是存进 Deferred,只有 await() 时才会抛出来。上面那段代码里 async { httpManager.upload(item) } 如果失败了,而某条分支提前 return,这个异常就永远躺在 Deferred 里。

六、正确的写法

6.1 场景一:Service 里

Service 本身不是 CoroutineScope,需要自己建一个,并在 onDestroy 里取消:

kotlin 复制代码
class SyncService : Service() {

    // 自建作用域:SupervisorJob(任务互不影响)+ IO 调度 + 异常处理
    private val scope = CoroutineScope(
        SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e ->
            Log.e(TAG, "sync failed", e)
            CrashReport.record(e)
        }
    )

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        scope.launch { uploadAll() }
        return START_STICKY
    }

    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()          // 一次性取消所有子协程,连带取消嵌套的 async
    }
}

scope.cancel() 会取消所有子协程(结构化并发),比逐个 job.cancel() 可靠得多。区别在于:前者取消的是"树",你不需要知道树上有几个节点;后者要求你把每一个节点都记下来。

6.2 场景二:Activity / Fragment

不要自建 scope,用现成的:

kotlin 复制代码
// Activity
lifecycleScope.launch { /* 随 Activity 销毁自动取消 */ }

// Fragment(推荐用 viewLifecycleOwner,随 View 销毁取消)
viewLifecycleOwner.lifecycleScope.launch { /* ... */ }

Fragment 这里有个常见错误:用 lifecycleScope 而不是 viewLifecycleOwner.lifecycleScope。前者跟 Fragment 同寿,后者跟 View 同寿。在 replace 场景下,Fragment 还在回退栈里,View 已经销毁了,此时用 lifecycleScope 启动的协程仍然会去碰 binding,于是 NullPointerException 或者更隐蔽的"更新到了已经不在屏幕上的 View"。

6.3 场景三:ViewModel

kotlin 复制代码
viewModelScope.launch {
    repository.load()
}

viewModelScope 在 ViewModel.onCleared() 时自动取消。

6.4 场景四:普通类

让类自己实现 LifecycleOwner,然后用 lifecycleScope:

kotlin 复制代码
class MyWorkMode : LifecycleOwner { /* ... */ }

// 使用
myWorkMode.lifecycleScope.launch {
    doWork()
}

或者实现 CoroutineScope 并手动管理(记得 close()):

kotlin 复制代码
class Repository : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.IO) {
    fun load() { launch { /* ... */ } }

    fun close() { cancel() }        // 必须有人调用
}

第二种写法有个陷阱:委托出来的 close() 没人调用,就等于 GlobalScope 。它的安全性完全取决于"是否有人记得调 close",而这正是 GlobalScope 的问题。所以更推荐第一种,或者干脆由外部注入 scope。

6.5 场景五:需要"比页面活得更久"的任务

这是唯一需要考虑 GlobalScope 语义的场景(比如"用户退出页面后仍要完成的上传")。但仍然不该用 GlobalScope:

  • Android 上用 WorkManager(可约束网络、可重试、进程被杀后仍会执行);
  • 或者自建一个应用级 scope,并明确它的生命周期:
kotlin 复制代码
object AppScope : CoroutineScope {
    override val coroutineContext =
        SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e ->
            Log.e("AppScope", "error", e)
        }
}

AppScope 和 GlobalScope 的差别只有一点:它有一个明确的、写在代码里的 owner,而且这个 owner 的生命周期是"应用进程",与任务的需求匹配 。GlobalScope 的生命周期也是进程,但它是隐式的------没人能回答"这个协程该活多久",因为它压根没有声明。

七、怎么把 GlobalScope 找干净

7.1 搜索与清理

复制代码
GlobalScope

每一个命中都要能回答三个问题:谁取消它?异常去哪了?它持有谁? 答不上来就改。

这三个问题对应三宗罪,也对应三种修法:加 scope、加 handler、缩小持有范围。

7.2 Detekt / Lint 规则

yaml 复制代码
# detekt.yml
ForbiddenMethodCall:
  active: true
  methods:
    - reason: "使用 lifecycleScope / viewModelScope / 自建 scope 替代 GlobalScope"
      value: "kotlinx.coroutines.GlobalScope.launch"
    - reason: "同上"
      value: "kotlinx.coroutines.GlobalScope.async"

7.3 运行时监控

给所有自建 scope 加一个统一的命名与注册,Debug 包里能看到"当前存活的协程":

kotlin 复制代码
// 用 CoroutineName 标记
CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("SyncService"))

再配合 -Dkotlinx.coroutines.debug 开启调试模式,堆栈里会带上协程名,排查泄漏时能直接看到"哪个 scope 还在跑"。

八、五种写法的对比

写法 生命周期 可取消 异常处理 结论
GlobalScope.launch 进程级 需手动逐个取消 全局 handler 禁用
CoroutineScope(SupervisorJob() + ...) 手动管理 scope.cancel() 一次性 可配 handler 适用非生命周期组件
lifecycleScope 随 Lifecycle 自动 可配 首选(Activity/Fragment/自定义 LifecycleOwner)
viewModelScope 随 ViewModel 自动 可配 ViewModel 内首选
WorkManager 系统托管 系统管理 系统重试 需要"比页面活得久"时用

选择顺序可以记成一句话:能用现成的就用现成的,不能用现成的就自己建一个并写清谁取消;两样都做不到,才轮到 GlobalScope------而这时候正确的答案是 WorkManager。

结论

  1. 多代异步方案共存是常态,不是耻辱------耻辱的是没有治理规则。
  2. 四代方案的真正差别不是 API,而是"谁负责结束" :Thread 靠自己跑完、Handler 靠队列清理、Executor 靠 shutdown、RxJava 靠 dispose、协程靠 scope 传播。
  3. 四个真实代价:取消语义不统一、异常路径各异、线程切换心智负担、代码风格分裂。
  4. 治理三步 :禁止新增 → 统一取消入口(TaskBag)→ 按文件整体迁移,不在一个文件里混两套。
  5. 迁移优先级 :先 new Thread 和 GlobalScope(改动机械、收益明确),复杂 RxJava 数据流留到最后。
  6. GlobalScope 的三宗罪:生命周期失控(泄漏)、无法整体取消、异常静默------本质是"没有 owner"。
  7. 取消是协作式的 :耗时循环里要 ensureActive(),阻塞调用要靠关闭资源或超时来响应取消。
  8. 自建 scope 用三件套 :SupervisorJob() + Dispatcher + CoroutineExceptionHandler;Activity/Fragment 用 lifecycleScope,ViewModel 用 viewModelScope,"要活久一点"用 WorkManager。
  9. CI 加规则禁止 GlobalScope,每个搜索结果都要回答"谁取消它、异常去哪了、它持有谁"。

这套东西并不高级,甚至有点笨:承认老代码不会消失,然后给每一代技术划一条边界。但工程里真正有效的治理,往往就是这种笨办法。如果你也在维护一个"五世同堂"的工程,欢迎在评论区聊聊你们是怎么给这些方案划边界的------尤其是那些最后决定"不动"的部分,理由通常比改法更有参考价值。

关键词标签 :#Android #Kotlin #协程 #GlobalScope #RxJava #内存泄漏 #并发编程

相关推荐
骑着蜗牛撵大象3271 小时前
大模型 SDK 编译就崩?用 CMakeLists 把 httplib、JSON 与 OpenSSL 装配成可复用的 ChatServer
android·java·c++·http·json·cmake·大模型sdk
海水冷却10 小时前
Android 群组聊天怎么实现:基于 ZIM 的完整落地指南(附可运行 Demo)
android
三少爷的鞋13 小时前
现代 Android 架构指南:不要再主动设计 Callback 接口
android
TimeFine19 小时前
AI 编码工作流的设计
android·ai编程
骑着蜗牛撵大象32719 小时前
C++20 协程 × 屏障:手写一个 co_await 版 Barrier,让等待不再钉死线程
android·c++20·协程·并发编程·co_await·屏障同步
山东孙燕姿21 小时前
设备台账重构:从一张表到三层模型
android·java·网络·低代码·重构·rxjava
蒸鱼Yuzheng1 天前
移动端 CPU 调度分析:线程亲和性、大小核迁移与卡顿
android·perfetto·性能分析·线程优化·cpu调度
booboosui1 天前
安卓唤醒词:12 维 MFCC 加 DTW,阈值 1.8
android
传奇开心果1 天前
Compose Multiplatform 共享边界实战:架构契约、互操作同步与可回退设计
android·kotlin