Skip to content

老司机 iOS 周报 #369 | 2026-04-27

Latest

Choose a tag to compare

@ChengzhiHuang ChengzhiHuang released this 26 Apr 16:22
· 1 commit to master since this release

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

文章

🌟 🐢 Under the hood: Android 17’s lock-free MessageQueue

@Crazy:Android MessageQueue 是从 Android 的核心框架,从 API1 就已经存在了,这次 Android 针对它进行了重构,是非常大的优化。

原始 MessageQueue 存在的问题: Android 的 MessageQueue 在过去的二十多年里靠着一把 monitor 同步锁保护,虽然没有大的问题,但是在多核多优先级场景下,这把锁会引发多线程争用同一把锁,并且进一步引发高优先级 UI 线程被低优先级后台线程间接拖慢。

新 MessageQueue 设计核心 DeliQueue: 新的 DeliQueue 采用 lock-free 数据结构的设计方式来解决上面的问题,简单用一句话来描述就是 “可以无锁写入的多生产者线程与独占排序和结构整理能力的单消费者 Looper 线程。” 核心方式就是利用原子操作来替代锁,下面我们把 lock-free 拆解一下,不涉及很多的源码。

  1. 插入序号: 利用 mNextInsertSeqValue/mNextFrontInsertSeqValue 两个 volatile 变量来进行插入排序, 主要判断在 enqueueMessageUnchecked 方法的第一行,就是判断 when 是否为 0 来保证不管来自多少个生产者线程的任何两个消息都有一个全序:先比 when,再比 insertSeq。最小堆就靠这个 key 排序。
long seq = when != 0 ? ((long) sNextInsertSeq.getAndAdd(this, 1L) + 1L) : ((long) sNextFrontInsertSeq.getAndAdd(this, -1L) - 1L);
  1. 唤醒判断: 利用 mWaitState 这个 volatile 变量实现 64 位状态机,然后加上 CAS 版本号操作实现整体唤醒判断.
if (WaitState.isCounter(waitState)) {
    // 情况 A:looper 已醒
} else if (msg.when >= WaitState.getTSMillis(waitState)) {
    // 情况 B:新消息不比当前 deadline 更早,我们不需要唤醒
} else if (msg.isAsynchronous()) {
    // 情况 C:新消息更早,且是 async(绕过消息屏障),需要唤醒
} else {
    // 情况 D:我们需要看消息屏障状态,决定是否需要唤醒
    if (blockedByBarrier) {
        newWaitState = WaitState.incrementDeadline(waitState);
        checkBarrier = barrier;
        needWake = false;
    } else {
        newWaitState = WaitState.initCounter();
        checkBarrier = null;
        needWake = true;
    }
}
  1. native 指针保护: 利用 mMptrRefCountValue 这个 volatile 变量实现对 native epoll 的句柄控制。native epoll 句柄,必须有人持有时不能 free,无人持有时要立即 free,Google 仅用一个 long 就这两件事。
private static final long MPTR_TEARDOWN_MASK = 1L << 63;

// 生产者增引用(incrementMptrRefs)
while (true) {
    final long oldVal = mMptrRefCountValue;
    if ((oldVal & MPTR_TEARDOWN_MASK) != 0) {
        return false;  // 已 teardown,拒绝新引用
    }
    if (sMptrRefCount.compareAndSet(this, oldVal, oldVal + 1L)) {
        return true;
    }
}

// 生产者减引用(decrementMptrRefs)
long oldVal = (long) sMptrRefCount.getAndAdd(this, -1L);
if (oldVal - 1 == MPTR_TEARDOWN_MASK) {
    LockSupport.unpark(mLooperThread);  // 我是最后一个活引用,且 looper 在等
}

// 与 wake 的协作
private void concurrentWake() {
    if (incrementMptrRefs()) {
        try { nativeWake(mPtr); }
        finally { decrementMptrRefs(); }
    }
}
  1. 单消息存在性: Tombstone CAS(墓碑 CAS),用一次 CAS 翻一个"已删除"标志位,而不是用锁去操作数据结构本身,保证消息的逻辑消失(真正能让消息消失的只有 looper 线程操作 min-heap),同时保证不会触发多线程修改同一个列表的情况。
// 代码位置 MessageStack,所有线程都可以调用
public int moveMatchingToFreelist(Message.MessageCompare compare, Handler h, int what, Object object, Runnable r, long when) {
    Message current = (Message) sTop.getAcquire(this);
    Message prev = null;
    Message firstRemoved = null;
    int numRemoved = 0;

    while (current != null) {
        if (messageMatches(current, compare, h, what, object, r, when)
                && current.markRemoved()) {
            if (firstRemoved == null) {
                firstRemoved = current;
            }
            current.clearReferenceFields();
            // nextFree links each to-be-removed message to the one processed before.
            current.nextFree = prev;
            prev = current;
            numRemoved++;
        }
        current = current.next;
    }

    if (firstRemoved != null) {
        Message freelist;
        do {
            freelist = mFreelistHeadValue;
            firstRemoved.nextFree = freelist;
        // prev points to the last to-be-removed message that was processed.
        } while (!sFreelistHead.compareAndSet(this, freelist, prev));
    }

        return numRemoved;
}

// looper 准备派发的消息已被并发删除
if (found != null && !peek) {
    if (!found.markRemoved()) {
        continue;  // 别人已经把它标记为删除,重新找下一条
    }
    mStack.remove(found);
}

// looper 线程
MessageStack.poplooper 线程):
if (!m.markRemoved()) {
    return null;  // 别人已经标记了,我让出
}
  1. 生产者/消费者职责切分: 任何线程都可以进行 pushMessage、markRemoved、freelist 和 nativeWake 等操作,但是只有 looper 线程可以进行 min-heap、nativePollOnce 等操作,将整体职责全部分开,昂贵的结构维护工作集中到单线程中完成。

最后我们总结一下新的设计的整体流程: 拿插入序号(原子 getAndAdd,1 条 CPU 指令) -> 写消息字段(不用同步控制) -> mStack.pushMessage(Treiber stack CAS push。失败重试,平均 1–2 次 CAS) -> 唤醒决策循环(读 mWaitState,CAS 写新状态) -> 可能调 concurrentWake。完成整体 pushMessage 操作,全程没有 synchronized 关键字,最坏情况也只有几次 CAS retry,最快路径 0 次内核调用,大大减轻了系统负担。

整篇文章其实不止写了 lock-free 数据结构的设计,其余还有很多,比如 Treiber stack、比如如何利用双链表机制是让 Looper 在线程内高效地把某个节点从 stack 链中摘掉。还有 Google 如何利用 Perfetto 和 PerfettoSQL 进行大量的 trace 分析,确认问题以及修复问题后的验证。可以说这篇文章中的每一部分都可以拿出来单独写一篇比较好的操作指南针,也可以看出 Google 在针对 MessageQueue 的修改上是有多么的慎重,以及在这种多线程上的恐怖控制力,可以说这是一篇值得所有人反复阅读的文章。

🐕 SQLite: Vacuuming the WALs

@ChengzhiHuang: sqlite 是常见的端用存储,一般也都会辅以开启 Write-Ahead Logging(WAL) 模式提升性能。对于一些低存储用户,我们还会辅以开启 incremental_vacuum 定期整理 .-wal 文件进一步减少磁盘占用(注:直接使用 vacuum 是不被推荐的,但是如果数据库本身已经存在,则必须先执行一次完整的 vacuum 才能开启 incremental_vacuum,因此最好是新建的时候默认打开)。本文对 incremental_vacuum 进行了进一步的细分,研究了配置不同的阈值(每次清理的页数)下,整体数据库的表现。大家可以参考自己数据库的实际情况选择不同阈值分批 incremental_vacuum 。

同时提醒大家记得在 incremental_vacuum 完成后再手动进行 checkpoint 才能有效减少磁盘占用,不然只是缩小了 free pages 的数量。

🐕 A Small SwiftUI Warning and a Long Journey to Understand It

@阿权:本文作者在开启 Swift InferSendableFromCaptures(SE-0418)特性后,遇到 SwiftUI 导航修饰器传递视图构造器函数引用的 Actor 隔离警告的问题。根本原因是:警告只是 Swift 5 迁移模式下的一个产物,升级到 Swift 6 后并不是问题。怎么理解呢?

  1. Swift 5 迁移模式的限制:当我们启用一个“未来特性”标志(如 InferSendableFromCaptures)来提前测试 Swift 6 的行为时,它可能会暴露一些问题,但由于底层的检查模型仍是旧版的,所以会产生一些在最终模型中并不存在的“过渡性警告”。
  2. 理解编译模式的差异:在 Swift 6 模式下,编译器能全盘理解上下文并得出正确结论,所以代码直接通过。而在 Swift 5 模式下,它只看到了部分信息,从而发出了多余的警告。

如何去一步步找到问题的根因也是文章的重点,通过作者的探索也能给到我们一些开发实践的建议:

  1. 优先升级语言版本:如果项目条件允许,尽早将 Swift 语言版本升级到 Swift 6。这能让你获得最准确、最一致的并发检查体验。
  2. 深入理解并发模型:花时间去理解 Swift 并发模型的核心概念,如 Actor 隔离和上下文继承。这能让你在处理更复杂的并发问题时游刃有余。
  3. 审慎看待过渡期警告:当你使用 -enable-upcoming-feature 等标志在旧语言模式下测试新特性时,要意识到看到的警告可能带有“过渡性”特征,需要结合最终的语言模型来理解其真正含义。
  4. 不满足于“能用”:在开发中,遇到一个修复方案时,多追问一句“为什么”,“为什么这样能解决问题”。这能帮助你真正理解问题本质,避免被表象解释所误导。

🐕 Lazy Properties in Swift - Why They Don't Always Work in SwiftUI

@Barney:这篇文章系统梳理了 Swift 里 lazy 属性的行为边界,重点不是语法本身,而是它在 SwiftUI 里的常见误用。作者先回顾了 lazy 适合解决的几类问题:延迟昂贵初始化、缓存只需计算一次的结果,以及依赖 self 的初始化;随后指出一个很容易踩的坑:SwiftUI 的 View 是值类型且会频繁重建,而 lazy 首次访问时需要发生写入,这使它既不适合作为稳定缓存,也无法直接放进 body 所依赖的视图属性里。文章给出的实践建议也很明确:在 SwiftUI 中优先用 @State@StateObject 或对象持有者管理生命周期,把 lazy 留给 class、service、formatter 或计算代价较高的缓存对象。对经常在 SwiftUI 中做性能优化的同学很有参考价值。

🐎 A Reusable Spotlight Onboarding Component in SwiftUI

@DylanYang:作者基于 SwiftUI 的 PreferenceKey 与锚点系统,实现了一款可复用的引导组件,无需依赖 UIKit 即可完成视图高亮、圆角镂空遮罩、自适应提示卡片展示与多步骤平滑动画切换。该组件适配导航栈、滚动视图、安全区、弹窗等各类场景,通过 tutorialSpotlight modifier 和 tutorialSpotlightSource modifier 即可快速接入,还支持自定义高亮内边距、圆角、背景点击关闭等配置,能便捷搭建完整的界面引导流程。感兴趣的开发同学可以阅读下具体的实现过程。

🐎 SwiftPM: 2x faster resolves, 3x smaller disk footprint

@david-clang:SwiftPM 长期受限于 Git 全量克隆导致的解析缓慢与磁盘占用过大。受此影响的 Ordo One 公司提交了优化提案 (PR #9870),通过引入源码归档下载路径实现大幅优化。该方案能保持 Public API 不变,且无需开发者修改 Package.swift 。其核心流程如下:

  1. 执行 git ls-remote --tags —— 发现可用版本(无新增 API,与现有机制一致)。
  2. 从 CDN 获取 Package.swift —— 检查工具版本(Tools Version)的兼容性。
  3. 从 GitHub CDN 直接下载 ZIP 压缩包 —— 提取源码,完全绕过 Git 克隆过程。

降级机制 :

  • 子模块 (Submodules):降级为浅克隆。
  • 流程异常 (如下载失败):无缝回退至旧版全量克隆机制(git clone --mirror)。

基准测试与性能收益:

  • 测试方法:选取包含 swift-composable-architecture (TCA)、SwiftLint 等不同规模(9 至 67 个依赖项)的知名开源项目。分别对比新旧方案在冷解析(清空 .build 与全局缓存,模拟 CI 环境)和热解析(保留全局共享缓存,模拟本地开发)下的耗时与磁盘增量。
  • 解析提速:冷解析场景下速度最高提升约 2.1 倍;热解析场景下速度最高提升达 3.8 倍
  • 空间优化:因彻底免除本地 Git 历史数据的存储,.build/ 目录的磁盘占用平均锐减 3 倍(例如,某重度依赖项目的体积由 1.8GB 缩减至约 600MB)。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)