阅读视图

发现新文章,点击刷新页面。

用 AI 辅助读书

最近一年闲下来,我重新挖掘了读书的乐趣,尤其是读小说。

读小说真的需要时间和心境,因为进入心流状态更慢。如果长时间无法进入状态,很容易就读不下去;但一旦读进去了,比玩游戏(互动形式)或看影视剧(多媒体形式)更让人沉浸和回味。你可以对精彩处反复斟酌体会其中的情感,也更容易停下来脑补作者在情节上的留白。阅读节奏完全由自己控制,可快可慢。鉴于制作成本,小说的多样性远超其它媒介,提供的选择就更为宽泛。

我最近尝试使用 AI 来提升我的阅读体验。首先发现的是 AI 非常适合荐书。我使用的主要是 Gemini ,免费的版本就足够了。我可以先列举一些我很喜欢的书,让它帮我推荐更多。在初选的名单中,再通过对话了解书的特色。为了避免自己总是阅读类似的书,也会让 AI 推荐一些我之前没有尝试过的类型。当然,小说本身还是人创作的,通过推荐作者比推荐书本身更有效率。

这两个月我想读点太空歌剧类的小说,但老一点的名著基本都看过了,所以转向近十年的新作。另一方面的原因是大多数科幻小说本身就有时效性,这些年人类现实中的科技发展很快,文学家的幻想很容易随着时间和现实脱节。

但我很快就发现,想读新一点科幻小说最大的问题是中文版的翻译速度完全跟不上。AI 推荐的书 90% 都没有中译版。即使把时间放宽一点,十年前的长篇,往往也只翻译了开头。这很好理解:如果出版了第一本销量不如意,可想而知后续会更不理想。这在经济上是绝对理性的行为,可对粉丝来说颇有点难受。

题外话,桌游领域也有点类似。桌面游戏通常也是由单个人设计,受者也非大众。即使设计者想好了出一个系列,若前作卖得不够好,扩展包也就难以发行。我最喜欢的桌游设计师 Thomas Lehmann 解释过 Res Arcana 的第三个扩展 Res Arcana Duo 为什么作为一个(看似简化过的)独立游戏发行而不延续扩展包的形式:必须想点办法扩大这个系列的玩家群,否则扩展包的销量只会越来越少。作为中文用户,我对 Res Arcana Duo 至今没能出中文版还是有点伤心的。希望今年的新作 Dark Pact (2026) 黑暗契约可以出中文版。看完介绍,我对这个纯粹的卡牌构筑游戏颇感兴趣。


我是 Old Man's War 系列的忠实粉丝,很喜欢 John Scalzi 。他的书读起来一点也不累,那种书中遍处可见的程式员式的冷幽默颇对我胃口。我前段时间在京东上买了一本互惠帝国系列的第一本《崩塌的帝国》。收到书时是一个暖日的下午五点,晚上十点就合上了书页,中间除了正常吃了个晚饭,别的时间都在读书。读这本书的另一个动机是我想多看看关于太空旅行的不同设定(以给设计我那个关于太空航行的游戏提供灵感)。读完了这本书后,除了很满意书里的科幻设定外,还很期待后续的故事发展。

可惜这套三部曲的后两本一直都没有翻译成中文版。

我觉得我近些年的英文阅读水平提升了不少,要不尝试一下直接读英文吧。试了一下,离享受读书还是颇有距离。阅读小说需要一个流畅的体验过程,无法顺势进入心流,阅读就变成了一个苦差事。文学类作品和技术类文章差异很大,能顺利阅读技术类英文,不等于读小说也没问题。我想还需要更多的阅读练习,而学习必然辛苦,这不是我目前想要的。

隔了两天,我尝试了另一个方法,这是我发现 AI 能提供给我的另一项重要帮助:翻译阅读。

我觉得,技术类文章和文学类创作最大的不同是:前者追求用精炼准确表达知识,后者需要在描述作者构思的情节之外传达情感。理解一本小说,需要基于对小说中人物和故事的理解;正如翻译一本技术书,你得理解其中的技术原理。这也是为何机器的逐句翻译无法做到准确的原因。大语言模型应该能改善翻译,但我一开始尝试的还是直接的 google translate 和装在本地 ollama 中的 translategemma 本地模型,对小说直译。

用不同方法,经过几个章节的体验,我发现最适合我的是让机器完全对译,不做任何针对中文语境的加工,并以中英对照的形式一段段话展示供我阅读。我主要还是针对中文阅读,虽然语言感觉有点蹩脚,但因为我知道信息原本是英文的,而我又有相当的英文语法知识,所以大脑很快就能适应,在阅读过程中自动转换为合适的中文理解。由于是机械直译,反而不会缺失信息。当觉得句子难以理解时,迅速跳转到英文原文处,通常就明了了。读到精彩的对话,往往回味一下英文原句更有感触。

有些句子颇难理解。这时可以打开一个 Gemini 对话,提供它足够多的上下文,然后贴上原文,Gemini 可以解释得非常清楚。毕竟这是 10 年前的小说了,我估计小说的原文本身(甚至第一卷的中译本)就是大模型的训练材料。比如这次我就学到一个知识:在英文语境中,皇帝会自称 We/Our 而不是 I/My ,用来指代个人和背后皇权的双重身份,这和中文背景下,皇帝自称“朕”颇有共通之处。第一次读英文直译时,我会对翻译器输出的“我们”有所疑惑,但随即和 AI 讨论就学到了这个。第一卷的中译本中,译者恰如其分的选择了“朕”来翻译 We ,google translate 这种直译显然是做不到的,但 Gemini 有了上下文就能选择这种译法。我很怀疑它受了训练语料的影响(被中译版的文本训练过)。

我用这个方法读完了第二本《the consuming fire》,大约花了 2-3 倍第一本的时间,阅读速度的下降是很明显的,但可以接受。我觉得稍加训练就可以改善到完全不影响阅读心流的状态。然后我读了第三本《The Last Emperox》,居然和读第一本一样的时长。但我觉得倒不是我快速适应了这些新的阅读方法,而是这个系列三本书的故事结构其实是类似的,读到了后面,跟上书的节奏越来越容易了。阅读长篇小说的过程有点像是在在脑子里逐步搭建作者构建的世界,然后一点点填上细节,最艰难的部分在最前面,后面就是顺理成章的活。

即使情节上有点雷同,我还是很喜欢这套三部曲。


这两天在补《The Expanse》小说的最后三卷,不需要等美剧了 :)

祝大家马年新春快乐! -- 肘子的 Swift 周报 #123

issue123.webp

祝大家马年新春快乐!

今年的中国农历年是丙午年,是一个60年一遇的“赤马年”。

在干支纪年中,天干“丙”与地支“午”五行皆属火,双火叠加,形成了极为罕见的“火马”格局。因火色为赤,故此马年又称“红马年”。这匹“红马”承载着最纯粹的阳刚之气与蓬勃活力,预示着接下来的一年将充满奔放的能量与昂扬的进取心。

在这个 60 年一遇的吉庆节点,我在此祝各位读者新的一年:身体健康(CPU 满血),事业驰骋(性能优化),万事顺遂(无 Bug 运行),马到成功(编译通过)! 🎉

本期内容 | 前一期内容 | 全部周报列表

🚀 《肘子的 Swift 周报》

每周为你精选最值得关注的 Swift、SwiftUI 技术动态

近期推荐

Swift 并发进阶阅读路线图 (Swift Concurrency from Zero to Hero | Reading List)

暂不说 AI 目前还很难像专家一样处理 Swift 6 并发相关的复杂代码,即便工具能力突飞猛进,作为使用者的你也不该只当旁观者:系统、全面地理解 Swift 并发的概念、用法与历史脉络,才能从源头构建更安全、更强健的应用。Alex Ozun 在本文中按难度划分关卡,整理了一份“从入门到进阶”的 Swift Concurrency 阅读路线图:每一关都配套了公开且免费的参考资料,难度也会逐级递增,希望你在完成这条主线之后,能从 Zero 走到 Hero。


15 步打造现代 iOS 项目 (A Modern iOS Project Setup in 15 Steps)

对于不少开发者而言,新项目的开始往往伴随着重复而琐碎的配置工作。Ertem Biyik 在这条串贴中用 15 个步骤梳理了一套现代 iOS 项目的工程化基线:以 Tuist 为核心管理依赖与生成,通过 xcconfig 统一环境配置与构建参数,借助 Makefile 简化初始化流程,并在 GitHub Actions 中完成 lint 与构建校验;同时,他还建议使用 AGENTS.md 统一 AI 上下文,并配合 sosumi.ai 获取更利于 LLM 阅读的 Apple 文档。


Micelio:后 Git 时代的代码托管工具 (Micelio: Growing Software Like Nature Grows Forests)

AI 深度参与开发流程后,许多传统工具开始显露出“水土不服”。Pedro Piñera 以“菌丝网络(mycelium)”为隐喻,指出随着 AI agent 参与开发的规模与频率不断提升,Git 这种只记录 what changed、却难以保留 why it changed 的历史模型,正在变得不够充分。本文是他对“后 Git 时代”的一次系统性思考,也是对“agent-first 协作方式”的公开实验。配套的开源 git forge Micelio 项目尝试以 session 为核心单元捕获上下文与推理,将代码托管的重心从“存 diff”转向“存决策”。项目仍处于早期阶段,但其问题意识与工程方向,都值得持续关注。


Swift 写时复制 (Copy-on-Write in Swift - How It Works and Why It Optimizes Memory)

写时复制(COW)是 Swift 的核心机制之一,也是值类型在保证语义一致性的同时获得良好性能的关键。Sagar Unagar 结合集合类型的内部实现,解释了 Swift 如何通过“结构体外壳 + 引用存储”的方式实现读共享、写分离:只有在发生写入且检测到非唯一引用时,才会借助 isKnownUniquelyReferenced 触发真正的拷贝。文章同时展示了自定义类型实现 COW 的基本模式。


Metal Shader 入门 (Taking First Steps into Metal Shaders)

对于大多数 SwiftUI 开发者而言,Metal 往往显得遥远而复杂。Letizia Granata 通过本文带你从 0 开始接触 Shader:从理解 GPU 与 CPU 的分工,到在 Xcode 中添加 .metal 文件,再到编写并在 SwiftUI 中应用一个最简单的 shader 效果。Letizia 没有深入渲染管线或图形学细节,而是聚焦“如何真正跑起来”,帮助读者跨过入门门槛。


为 Landmarks 构建 Vapor 后端 (Setting Up a Backend Server for Our Landmarks App)

Landmarks 是 Apple 在 SwiftUI 教程中提供的经典示例应用,但由于它使用本地 JSON 数据,与真实项目的客户端—服务器架构仍存在差距。Kyle Browning 使用 Vapor 为 Landmarks 构建了一个简单的后端服务,将静态示例升级为通过 API 获取数据的完整结构。文章围绕模型定义、路由配置、数据响应以及与 SwiftUI 前端的对接展开,重点不在复杂业务逻辑,而在于示范如何把一个“教学示例”扩展为更贴近真实世界的项目。


Spotlight 后台索引机制解析 (In the Background: Spotlight Indexing)

系统更新后的几天里机器发热、卡顿,很多人都知道这与 Spotlight 的重新索引有关,但它在后台如何扫描文件、何时触发重建索引、又如何调度系统资源,却鲜少被系统性梳理。Howard Oakley 基于日志与实测数据,对 Spotlight 索引的触发机制、进程行为以及对 I/O 与性能的影响进行了细致分析,提供了一次关于 macOS 内部运行机制的深入技术观察。

工具

ScreenStateKit:并发时代的 MVVM 演进

如果你欣赏 TCA 的单向数据流与可预测状态管理,却又不想在现有 MVVM 项目中彻底重构架构,那么 ScreenStateKit 状态管理库也许是一个折中方案。作者 Anthony Tran 将传统 ViewModel 拆分为两个职责清晰的部分:一个运行在 @MainActor 上、只负责持有 UI 状态的 ScreenState,以及一个以 Swift actor 实现、专门处理业务逻辑与异步任务的 ScreenActionStore,所有状态变更都通过强类型 Action 作为唯一入口,从而在保持 MVVM 结构直观性的同时,引入类似 TCA 的单向数据流与编译期并发安全。配合内建的加载与错误状态管理、重复请求防护(ActionLocker)、父子状态传播与分页支持。Anthony 亦撰写了一篇详尽的设计说明文章,对其架构理念与实践细节进行了系统阐述。


GitHub 子目录下载工具

如果你曾为了下载 GitHub 上的某个示例目录而被迫 git clone 整个仓库,这款 GitHub Downloader 显然正是为此而生。它是 Stewart Lynch 开发的一款原生 macOS 应用,允许你粘贴任意 GitHub 仓库或子目录 URL(包括 tree/branch/path 形式),自动识别默认分支,递归下载所选目录内容,并完整保留其原始层级结构。


SimTag:为模拟器标注分支上下文

在并行开发成为常态的今天,面对多个外观几乎相同的 iOS Simulator,开发者很容易迷失:到底哪个分支正在运行?是否调试了错误的构建版本?Aryaman Sharda 开发的 SimTag 会在每个 Simulator 窗口上叠加一个轻量标识,显示当前构建对应的 git 分支与提交信息。对于习惯使用 worktrees、进行 PR 审查或结合 AI 协作开发的团队来说,这是一个能够显著减少认知摩擦、避免“调错分支”的实用小工具。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

1.Flutter 环境配置 & Shell 基础知识笔记

Flutter 环境配置 & Shell 基础知识笔记


一、Flutter 环境变量配置(实践总结)

需要配置哪些环境变量?

环境变量 是否必须 作用
PATH ✅ 必须 让终端能找到 flutterdart 命令
PUB_HOSTED_URL 🇨🇳 国内必须 Dart 包的下载镜像(不配会很慢或下载失败)
FLUTTER_STORAGE_BASE_URL 🇨🇳 国内必须 Flutter SDK 更新的下载镜像

为什么要配置镜像?

Flutter 默认从 Google 服务器下载资源,国内无法直接访问。配置中国镜像后,所有下载都走国内服务器,速度快且稳定。

常用的中国镜像:

镜像 地址
Flutter 社区镜像 https://pub.flutter-io.cn / https://storage.flutter-io.cn
清华大学镜像 https://mirrors.tuna.tsinghua.edu.cn/dart-pub / https://mirrors.tuna.tsinghua.edu.cn/flutter

我的具体配置

Flutter SDK 安装路径:/Users/hongliangchang/development/flutter

~/.zshrc 末尾添加的内容:

# Flutter 中国镜像(解决国内无法访问 Google 服务器的问题)
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

# Flutter PATH(让终端能直接使用 flutter 命令)
export PATH="$HOME/development/flutter/bin:$PATH"

# Dart SDK PATH(让终端能直接使用 dart 命令)
export PATH="$HOME/development/flutter/bin/cache/dart-sdk/bin:$PATH"

配置完成后

# 1. 让配置生效
source ~/.zshrc

# 2. 验证 flutter 是否可用
flutter --version

# 3. 检查环境是否完整(会列出缺少的依赖)
flutter doctor

踩坑记录

  1. 配置写错文件:macOS 用的是 zsh,环境变量要写在 ~/.zshrc,不是 ~/.bash_profile
  2. Windows 换行符问题:如果 Flutter SDK 是从 Windows 拷贝过来的,脚本文件会带 \r 换行符,macOS 无法执行,需要在 macOS 上重新下载解压

二、为什么要配置环境变量?

核心原因:让系统知道去哪里找程序。

当你在终端输入 flutter --version 时,系统不会搜遍整个电脑找 flutter,它只会去 PATH 环境变量列出的目录 里找。

# 查看当前 PATH 里有哪些目录
echo $PATH

不配置会怎样?

# ❌ 不配置 PATH,每次必须写完整路径
/Users/hongliangchang/development/flutter/bin/flutter --version

# ✅ 配置了 PATH,直接输名字
flutter --version

通俗比喻:好比手机通讯录存了一个人的号码(配置 PATH),以后打电话搜名字就行。不存的话,每次都得手动输完整手机号码(完整路径)。

PATH 之外的环境变量

环境变量不只是 PATH,还能存各种配置信息:

环境变量 作用
PATH 告诉系统去哪些目录找程序
PUB_HOSTED_URL 告诉 Flutter 从哪个镜像下载 Dart 包(中国镜像加速)
FLUTTER_STORAGE_BASE_URL 告诉 Flutter 从哪个镜像下载 SDK(中国镜像加速)

三、配置文件的区别

不同 Shell 读取不同的配置文件,这是环境变量不生效的常见原因:

Shell 配置文件
bash ~/.bash_profile~/.bashrc
zsh ~/.zshrc~/.zprofile

⚠️ 如果你的 Mac 用的是 zsh,环境变量写在 ~/.bash_profile 里是不生效的,必须写在 ~/.zshrc 里。

配置完后让其生效:

source ~/.zshrc

四、什么是 Shell?

Shell 就是你打开「终端」后,帮你执行命令的程序。可以理解为一个「翻译官」,把你输入的命令翻译给操作系统执行。

常见的 Shell 有 sh、bash、zsh、fish 等,它们功能类似但各有增强。


五、Bash 和 Zsh 是什么?

名称 全称 含义
sh Bourne Shell 最古老的 Shell,以作者 Stephen Bourne 命名
bash Bourne Again Shell sh 的增强版,"重生的 Bourne Shell"(双关语 born again = 重生)
zsh Z Shell bash 的增强版,名字来自普林斯顿助教邵中(Zhong Shao)的用户名

继承关系

sh(祖宗)
 └── bash(儿子,增强版)
      └── zsh(孙子,更强大)

六、macOS 默认用哪个 Shell?

  • macOS Catalina(10.15)之前:默认 bash
  • macOS Catalina(10.15)及之后:默认 zsh

查看当前 Shell:

echo $SHELL
# /bin/zsh → 用的 zsh
# /bin/bash → 用的 bash

为什么苹果要从 bash 换成 zsh?

bash 新版本改用了 GPLv3 许可证,苹果不愿接受。

GPLv3 的核心要求:如果你在产品中使用了 GPLv3 的软件,用户修改了这个软件后,你必须允许用户把修改版装回设备运行

这和苹果的封闭生态冲突——macOS/iOS 的系统文件都有代码签名,不允许用户随意替换。

通俗比喻:苹果卖你一辆车,车里装了一台 GPLv3 的发动机。GPLv3 说车主可以自己改造发动机并装回去,但苹果不愿意让你动它的车。所以苹果换了一台 MIT 许可的发动机(zsh),没有任何限制。

最终苹果的做法:

  • 系统自带的 bash 停留在 3.2 版本(2007 年的,最后一个 GPLv2 版本)
  • 默认 Shell 改为 zsh(MIT 许可证,没有"传染性"要求)

七、Oh-My-Zsh 是什么?

Oh-My-Zsh = zsh 的「插件和主题管理器」,它不改变 zsh 核心功能,而是让体验更好。

zsh = 引擎(自带 Tab 补全等核心功能)
oh-my-zsh = 改装套件(主题 + 插件)
功能 提供者
Tab 补全命令/路径 zsh 自带
Tab 补全时方向键选择 zsh 自带
终端主题/配色 oh-my-zsh
Git 分支显示在命令行 oh-my-zsh 主题
命令别名(如 gst = git status oh-my-zsh 的 git 插件
根据历史记录灰色提示 oh-my-zsh 的 autosuggestions 插件

八、Zsh 命名趣事

zsh 的作者是 Paul Falstad,1990 年在普林斯顿大学读书时开发。当时有个助教叫邵中(Zhong Shao),他的登录用户名是 zsh,Paul 觉得这名字结尾是 sh,很像一个 Shell 的名字,就直接拿来用了。

邵中本人和 zsh 的开发没有任何关系,他后来成为了耶鲁大学计算机科学系教授,研究编程语言和编译器。

春节提审高峰来袭!App Store 审核时长显著延长。

背景

春节将至,回乡的路依然开始堵车。对应AppStore来讲也是全民消费与线上活动进入高峰期。 昨天依然有海量 iOS 开发者集中提交新 App 与版本更新,直接导致App Store 审核队列拥堵、等待时长大幅拉长

最常用的海外账号Buff都受到严重的影响:

4d2fac676632afa6329bebbdb19a7080.jpg

一、当前审核现状

  • 常规时段:约90% 应用在 24 小时内完成审核
  • 春节高峰:审核周期普遍拉长至3–7 个工作日,复杂应用、游戏、含内购 / 支付功能的应用更久
  • 核心原因:集中提审量暴增、审核人力有限、假期排班调整

二、为什么春节会 “堵审核”

  1. 节日效应:开发者扎堆上线春节活动、红包、促销版本,提交量短期翻倍
  2. 全球时差:苹果审核团队按欧美假期排班,春节期间人力收紧
  3. 审核更严:节日流量大,苹果对合规、安全、支付、诱导分享审查更严格
  4. 排队机制:先提交先处理,晚提交只能持续排队

三、开发者应对建议(实用可落地)

  1. 错峰提交:尽量在节后1–2 周完成提审,避开春节拥堵高峰

  2. 精简更新:非紧急功能延后上线,只发必更版本

  3. 提前自检:先过一遍隐私政策、权限说明、内购协议、元数据,减少被拒重提

  4. 用好加急通道

    • 适用:线上严重崩溃、重大安全漏洞、时效性极强的官方活动
    • 入口:App Store Connect → 联系我们 → 申请加急审核
    • 注意:次数有限,非真紧急慎用
  5. 预留缓冲:春节上线计划按最长 7 天审核倒排工期

四、提醒

春节期间,需安排好值班人员,常规巡检App运行情况。同时,更需要关注开发者苹果邮箱,遭遇AppStore竞品的“敌袭”,错过最佳抢救时间。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

适配Swift 6 Sendable:用AALock优雅解决线程安全与不可变引用难题

Swift 6 带来的 Sendable 协议是并发安全领域的重要升级,它强制要求跨线程传递的类型具备明确的线程安全语义。但在实际开发中,我们常会陷入一个两难境地:既要满足 Sendable 对不可变引用(let)的要求,又要保证非线程安全对象的并发访问安全。本文将介绍我封装的 AALock 工具库,它既能完美适配 Swift 6 Sendable 检查,又能以极简的方式实现线程安全,让你的代码在 Swift 6 并发模型下既合规又优雅。

本组件的设计思路参考了 iOS 18 原生 mutex 锁的设计理念,通过封装适配层实现了低版本 iOS 系统的兼容使用,既保留了原生高性能特性,又解决了不同系统版本下线程安全锁的适配问题。

一、Swift 6 Sendable 的核心痛点

1. Sendable 对“不可变”的强约束

Sendable 协议的核心要求之一是:符合 Sendable 的类型,其属性应优先使用 let(不可变)修饰。如果类型中存在 var 修饰的引用类型属性(比如 var dict: [String: Any]),编译器会直接判定该类型不满足 Sendable,导致无法安全地跨 actor/线程传递。

但现实场景中,我们不可能所有数据都做成不可变——业务逻辑必然需要修改数组、字典、自定义对象等,直接用 let 修饰非线程安全对象,又会带来并发访问的线程安全问题。

2. 传统解决方案的弊端

为了兼顾 Sendable 和线程安全,传统做法通常有两种,但都有明显缺陷:

  • 方案1:用 var 修饰属性 + 手动加锁。直接违反 Sendable 对不可变引用的要求,编译器报错,无法通过检查;
  • 方案2:封装成不可变容器 + 拷贝修改。每次修改都生成新对象,性能开销大,且代码冗余,违背“最小修改成本”原则。

二、AALock 的核心设计思路

AALock 的核心目标是:让非线程安全对象通过 let 修饰仍能安全修改,同时满足 Sendable 检查。其设计围绕两个核心封装展开:

1. 核心思想:“不可变容器 + 内部可变 + 自动加锁”

  • let 修饰 AALock 包装后的对象(满足 Sendable 对不可变引用的要求);
  • 容器内部维护需要修改的非线程安全对象,通过锁(不公平锁/读写锁)保证修改的线程安全;
  • 对外暴露极简的闭包式 API,自动处理加锁/解锁,避免手动操作的漏解锁风险。

2. 核心组件

组件 适用场景 核心优势
AAUnfairLock 通用互斥场景 基于系统 os_unfair_lock,性能优于 NSLock,无递归重入
AARWLock 读多写少场景 读写分离,读操作并发执行,写操作互斥,性能远超普通互斥锁
AALockedValue 通用线程安全封装 基于 AAUnfairLock,包装任意类型,闭包式操作,自动加解锁
AARWLockedValue 读多写少的高性能场景 基于 AARWLock,读写锁分离,最大化读操作并发性能

三、AALock 如何适配 Sendable?

1. 关键特性:let 修饰仍可安全修改

通过 AALockedValue/AARWLockedValue 包装后,我们可以用 let 修饰属性(满足 Sendable),同时通过闭包修改内部数据(线程安全):

// 符合 Sendable 的自定义类型
struct SafeData: Sendable {
    // let 修饰,满足 Sendable 不可变要求
    let lockedDict = AALockedValue(value: [String: String]())
    let rwLockedArray = AARWLockedValue(value: [Int]())
}

// 跨线程传递(满足 Sendable 检查)
let safeData = SafeData()
DispatchQueue.global().async {
    // 写操作:自动加锁,线程安全
    safeData.lockedDict.withLock { dict in
        dict["key"] = "value"
    }
    
    // 读操作:自动加锁,线程安全
    let value = safeData.lockedDict.withLock { dict in
        dict["key"]
    }
    print("读取值:\(value ?? "nil")")
}

2. 底层适配 Sendable 协议

AALock 核心组件均遵循 Sendable 协议,确保包装后的对象可安全跨线程传递:

// AALockedValue 核心定义(简化版)
public final class AALockedValue<Value>: @unchecked Sendable {
    private let lock: AAUnfairLock
    private var _value: Value
    
    public init(value: Value, lock: AAUnfairLock = AAUnfairLock()) {
        self._value = value
        self.lock = lock
    }
    
    // 闭包式操作,自动加解锁
    public func withLock<T>(_ body: (inout Value) -> T) -> T {
        lock.lock {
            body(&_value)
        }
    }
    
    // 便捷取值(自动加锁)
    public var value: Value {
        withLock { $0 }
    }
}

关键设计点:

  • final class 避免继承带来的线程安全风险;
  • 内部 _valuevar 修饰(仅内部可变),对外暴露 let 容器;
  • 所有操作通过闭包封装,确保锁的范围精准,避免手动解锁遗漏;
  • 遵循 Sendable 协议,可直接跨 actor/线程传递。

四、AALock 核心用法示例

1. 基础用法:普通互斥锁(AAUnfairLock)

let lock = AAUnfairLock()
var dict = [String: String]()

// 闭包式加解锁(推荐)
lock.lock {
    dict["name"] = "AALock"
    dict["version"] = "1.0.0"
}

// 手动加解锁(兼容场景)
lock.lock()
let name = dict["name"]
lock.unlock()

2. 高性能场景:读写锁(AARWLock)

读多写少场景下,读写锁性能远超普通互斥锁:

let rwLock = AARWLock()
let rwLockedArray = AARWLockedValue(value: [Int]())

// 写锁:互斥操作,修改数据
rwLockedArray.withWriteLock { array in
    array.append(contentsOf: [1,2,3,4,5])
}

// 读锁:并发读取,性能最优
DispatchQueue.concurrentPerform(iterations: 10) { _ in
    let count = rwLockedArray.withReadLock { array in
        array.count
    }
    print("数组长度:\(count)")
}

3. 完整 Sendable 适配示例

// 自定义 Sendable 类型
class BusinessManager: Sendable {
    // let 修饰,满足 Sendable
    private let userCache = AALockedValue(value: [String: User]())
    private let statisticData = AARWLockedValue(value: [String: Int]())
    
    // 新增用户(写操作)
    func addUser(_ user: User, id: String) {
        userCache.withLock { cache in
            cache[id] = user
        }
    }
    
    // 获取用户(读操作)
    func getUser(id: String) -> User? {
        userCache.withLock { cache in
            cache[id]
        }
    }
    
    // 统计数据(读多写少)
    func incrementStatistic(key: String) {
        statisticData.withWriteLock { data in
            data[key, default: 0] += 1
        }
    }
    
    func getStatistic(key: String) -> Int {
        statisticData.withReadLock { data in
            data[key] ?? 0
        }
    }
}

// 跨 Actor 传递(Swift 6 并发模型)
actor UserActor {
    func handleManager(_ manager: BusinessManager) {
        let count = manager.getStatistic(key: "login")
        print("登录次数:\(count)")
    }
}

// 调用示例
let manager = BusinessManager()
let actor = UserActor()
Task {
    await actor.handleManager(manager) // 无 Sendable 警告
}

五、AALock 的核心优势

1. 完美适配 Swift 6 Sendable

  • let 修饰包装后的对象,满足 Sendable 对不可变引用的要求;
  • 所有核心组件遵循 Sendable,无编译器警告,直接通过 Swift 6 严格检查。

2. 极致的性能

  • 基于 os_unfair_lock 实现 AAUnfairLock,性能远超 NSLock/pthread_mutex_t
  • 读写锁 AARWLock 针对读多写少场景做优化,读操作并发执行,性能提升数倍。

3. 极简的 API 设计

  • 闭包式加解锁,避免手动 lock()/unlock() 导致的漏解锁、死锁问题;
  • 支持任意类型的包装(基础类型、集合、自定义对象),无侵入式修改。

4. 零学习成本

  • API 语义清晰(withLock/withReadLock/withWriteLock),一看就会;
  • 无需修改原有业务逻辑,仅需包装非线程安全对象即可。

六、总结与推广

Swift 6 的 Sendable 协议是未来并发编程的标配,而线程安全是跨线程开发的基础要求。AALock 既解决了 Sendable 对不可变引用的强约束,又通过极简的 API 实现了线程安全,让开发者无需在“合规”和“易用”之间妥协。

适用场景

  • Swift 6 项目中需要满足 Sendable 检查的跨线程类型;
  • 读多写少的高性能并发场景(如缓存、统计数据);
  • 任意需要线程安全的非线程安全对象(数组、字典、自定义 struct/class)。

接入建议

  1. AALock 集成到项目中(支持 CocoaPods/Carthage/Swift Package Manager);
  2. 将原有 var 修饰的非线程安全属性,替换为 let 修饰的 AALockedValue/AARWLockedValue
  3. 通过 withLock/withReadLock/withWriteLock 操作内部数据,无需手动加锁。

AALock 让 Swift 6 并发编程更简单、更安全、更合规,如果你也在适配 Swift 6 Sendable,或者需要优雅解决线程安全问题,不妨试试这个封装——它会成为你 Swift 6 并发开发的“瑞士军刀”。

项目地址:GitHub - AALock
欢迎 Star、Fork、PR,一起完善 Swift 6 并发安全生态!

Swift 全面深入指南

本文从底层原理、横向对比、纵向深度、性能优化、难点问题、高难度原理六大维度,对 Swift 语言进行全面、细致、深入的梳理。


第一部分:Swift 基础与底层原理


1. 值类型 vs 引用类型

1.1 核心区别

维度 值类型 (Value Type) 引用类型 (Reference Type)
代表 struct, enum, tuple class, closure
存储 栈(小对象)/ 堆(大对象或含引用)
赋值语义 拷贝(Copy-on-Write 优化) 共享引用
线程安全 天然线程安全(独立副本) 需要同步机制
引用计数 有 ARC
继承 不支持(enum/struct) 支持(class)
deinit 不支持 支持
Identity 无 === 操作 有 === 操作

1.2 底层内存布局

struct 布局:

  • struct 的成员按声明顺序存储(有对齐填充)
  • 小 struct(通常 ≤ 3 个 word,即 24 字节 on 64-bit)直接在栈上分配
  • 大 struct 或含引用类型成员时,可能会被编译器优化到堆上(间接存储)
  • 作为协议的 existential container 时,超过 3 word 会触发堆分配

class 布局:

  • 堆上分配,包含:
    • isa 指针(8 字节):指向类的元数据(metadata),用于动态派发
    • 引用计数(8 字节):strong count + unowned count + weak count(打包在一个 64-bit InlineRefCounts 中)
    • 实例变量:按声明顺序排列,有对齐
  • 总 overhead 至少 16 字节(isa + refcount),加上 malloc 的 16 字节对齐 overhead

1.3 Copy-on-Write (COW) 深入

标准库 COW 实现(Array/Dictionary/Set/String):

  • 内部持有一个引用类型的 buffer(如 _ArrayBuffer
  • 赋值时只复制 buffer 的引用(引用计数 +1),O(1)
  • 写入前检查 isKnownUniquelyReferenced(&buffer)
    • 如果引用计数 == 1,直接修改(无拷贝)
    • 如果引用计数 > 1,先深拷贝 buffer,再修改
  • 注意:自定义 struct 不会自动获得 COW,需要手动实现

自定义 COW 模式:

final class Storage<T> {
    var value: T
    init(_ value: T) { self.value = value }
}
struct COWWrapper<T> {
    private var storage: Storage<T>
    init(_ value: T) { storage = Storage(value) }
    var value: T {
        get { storage.value }
        set {
            if !isKnownUniquelyReferenced(&storage) {
                storage = Storage(newValue)
            } else {
                storage.value = newValue
            }
        }
    }
}

1.4 struct 中包含引用类型的代价

  • struct 拷贝时,内部引用类型成员的引用计数也要 +1
  • 如果 struct 有 N 个引用类型成员,一次拷贝就有 N 次 retain
  • 这就是为什么大量包含引用类型的 struct 拷贝比纯 class 更慢

2. 内存管理 — ARC 深入

2.1 ARC 的本质

  • ARC 是编译器在编译期自动插入 retain/release 调用
  • 不是 GC(垃圾回收),没有 stop-the-world
  • 引用计数操作是原子的(使用 atomic_fetch_add 等),保证线程安全
  • 每次 retain/release 有 CPU 开销(原子操作 + 内存屏障)

2.2 引用计数的存储结构(Swift 5+)

InlineRefCounts (8 bytes):
┌─────────────────────────────────────────────────┐
│ strong RC (32 bit) │ unowned RC (31 bit) │ flags │
└─────────────────────────────────────────────────┘
  • strong count:强引用计数。变为 0 时触发 deinit,释放实例内存(如果无 unowned/weak 引用)
  • unowned count:unowned 引用计数 + 1(自身占 1)。变为 0 时释放 side table / 对象内存
  • weak count:存储在 side table 中。当有 weak 引用时,对象会创建 side table
  • Side Table:当需要 weak 引用或引用计数溢出时,从 InlineRefCounts 切换到指向 side table 的指针

2.3 strong / weak / unowned 深入对比

维度 strong weak unowned
引用计数 +1 strong RC 不增加 strong RC,增加 weak RC(side table) +1 unowned RC
解引用速度 最快(直接访问) 较慢(需要检查 side table) 快(直接访问,但有运行时检查)
置 nil 不会 对象释放后自动置 nil 不会(对象释放后访问触发 fatal error)
Optional 不要求 必须 Optional 不要求 Optional
内存释放时机 strong RC = 0 时 deinit 不影响释放 不影响 deinit,但影响内存回收
适用场景 默认所有权 delegate、可能为 nil 的反向引用 生命周期确定不短于自身的引用

unowned 的危险与底层:

  • unowned 引用在对象 deinit 后,内存不会立即释放(因为 unowned count > 0)
  • 访问已 deinit 的 unowned 引用会触发 runtime trap(不是野指针,是确定性崩溃)
  • unowned(unsafe) 可以跳过检查,行为类似 C 的悬垂指针,性能最高但最危险

weak 的底层机制:

  • weak 引用不直接指向对象,而是通过 side table 间接引用
  • 对象 deinit 时,runtime 遍历 side table 将所有 weak 引用置 nil
  • 这就是为什么 weak 必须是 Optional —— 因为可能被置 nil
  • weak 的读取需要加锁(原子操作),有性能开销

2.4 循环引用的三种场景与解决

场景一:两个对象互相持有

class A { var b: B? }
class B { var a: A? }  // 循环引用!
// 解决:B 中用 weak var a: A?

场景二:闭包捕获 self

class ViewController {
    var handler: (() -> Void)?
    func setup() {
        handler = { self.doSomething() }  // self 持有 handler,handler 捕获 self
    }
}
// 解决:handler = { [weak self] in self?.doSomething() }

场景三:嵌套闭包中的 capture list

handler = { [weak self] in
    guard let self = self else { return }
    // 这里 self 是 strong 的局部变量,闭包执行期间不会释放
    someAsyncCall {
        self.doSomething()  // 安全,因为外层已经 guard 了
    }
}

2.5 Autorelease Pool 在 Swift 中的角色

  • Swift 原生对象不使用 autorelease(ARC 直接管理)
  • 但与 ObjC 交互时(调用返回 ObjC 对象的方法),仍可能进入 autorelease pool
  • autoreleasepool { } 在 Swift 中仍然可用,用于循环中大量创建临时 ObjC 对象时控制内存峰值

3. 协议 (Protocol) 底层原理

3.1 协议的两种使用方式

作为泛型约束(Static Dispatch):

func process<T: MyProtocol>(_ value: T) { value.doSomething() }
  • 编译期确定类型,静态派发
  • 编译器为每个具体类型生成特化版本(monomorphization / specialization)
  • 性能最优,等同于直接调用

作为存在类型(Dynamic Dispatch):

func process(_ value: MyProtocol) { value.doSomething() }
// Swift 5.6+ 显式写法:func process(_ value: any MyProtocol)
  • 运行时通过 Existential Container 动态派发
  • 有性能开销

3.2 Existential Container 详细结构

Existential Container (5 words = 40 bytes on 64-bit):
┌──────────────────────────────────────────┐
│  Value Buffer (3 words = 24 bytes)       │  ← 存储值或指向堆的指针
│  Metadata Pointer (1 word = 8 bytes)     │  ← 指向类型元数据
│  PWT Pointer (1 word = 8 bytes)          │  ← Protocol Witness Table 指针
└──────────────────────────────────────────┘

Value Buffer 策略:

  • 值 ≤ 24 字节:inline 存储,直接放在 buffer 中(无堆分配)
  • 值 > 24 字节:buffer 中存指向堆分配内存的指针
  • 这就是为什么小 struct 遵循协议时没有额外堆分配

Protocol Witness Table (PWT):

  • 每个「类型 + 协议」组合有一张 PWT
  • PWT 是一个函数指针数组,每个协议方法对应一个条目
  • 调用协议方法时:从 existential container 取出 PWT → 查表 → 间接调用
  • 类似于 C++ 的 vtable,但针对的是协议而非类继承

Value Witness Table (VWT):

  • 每个类型有一张 VWT,描述该类型的内存操作
  • 包含:size、alignment、copy、move、destroy 等函数指针
  • 存在类型赋值/拷贝时,通过 VWT 执行正确的内存操作

3.3 协议组合与多协议 existential

func process(_ value: ProtocolA & ProtocolB) { ... }
  • existential container 会包含多个 PWT 指针(每个协议一个)
  • 容器大小 = 24(buffer)+ 8(metadata)+ 8 × N(N 个协议的 PWT)

3.4 Class-Only Protocol 的优化

protocol MyDelegate: AnyObject { ... }
  • 编译器知道遵循者一定是 class(引用类型)
  • existential container 退化为:1 个 word 的引用 + metadata + PWT
  • 不需要 24 字节的 value buffer
  • 可以使用 weak/unowned 修饰

3.5 协议扩展 vs 协议要求方法的派发差异

protocol Greetable {
    func greet()  // 协议要求:PWT 动态派发
}
extension Greetable {
    func greet() { print("Hello") }     // 默认实现
    func farewell() { print("Bye") }    // 扩展方法:静态派发!
}
struct Person: Greetable {
    func greet() { print("Hi, I'm a person") }
    func farewell() { print("See you") }
}

let p: Greetable = Person()
p.greet()     // "Hi, I'm a person" —— 动态派发,走 PWT
p.farewell()  // "Bye" —— 静态派发!走协议扩展的默认实现

关键区别:

  • 协议要求中声明的方法 → 在 PWT 中有条目 → 动态派发 → 能被遵循者重写
  • 仅在协议扩展中定义的方法 → PWT 中无条目 → 静态派发 → 根据编译期类型决定

4. 泛型底层原理

4.1 泛型的实现方式

Swift 泛型采用类型擦除 + 运行时传递元数据的策略(不同于 C++ 的完全模板实例化):

  • 编译器生成一份泛型函数的代码(不是每个类型一份)
  • 运行时通过隐藏参数传递 type metadatawitness table
  • 但在开启优化(-O)时,编译器会进行泛型特化(specialization),为常用类型生成直接调用的版本

4.2 泛型特化 (Specialization)

func swap<T>(_ a: inout T, _ b: inout T) { ... }
// 编译器优化后可能生成:
// swap_Int(...)  ← 针对 Int 的特化版本
// swap_String(...)  ← 针对 String 的特化版本
// swap_generic(...)  ← 通用版本(需要 metadata)

特化条件:

  • 编译器能看到泛型函数的实现(同一模块 或 @inlinable
  • 能确定具体类型
  • 优化级别 -O 或 -Osize

跨模块特化:

  • 默认不能跨模块特化(泛型函数实现不可见)
  • @inlinable 将函数体暴露给其他模块,允许跨模块特化
  • @frozen 将 struct 布局暴露给其他模块

4.3 Type Erasure(类型擦除)模式

问题: 带 associatedtype 的协议不能直接作为存在类型

protocol Iterator {
    associatedtype Element
    func next() -> Element?
}
// let iter: Iterator  ← 编译错误(Swift 5.6 以前)
// let iter: any Iterator  ← Swift 5.7+ 部分支持

经典手动类型擦除:

struct AnyIterator<Element>: IteratorProtocol {
    private let _next: () -> Element?
    init<I: IteratorProtocol>(_ iterator: I) where I.Element == Element {
        var iter = iterator
        _next = { iter.next() }
    }
    func next() -> Element? { _next() }
}

原理: 用闭包捕获具体类型实例,对外暴露统一的泛型接口,擦除了具体类型信息。

4.4 some vs any(Swift 5.7+)

维度 some Protocol (Opaque Type) any Protocol (Existential Type)
底层 编译期确定的固定类型(对调用者隐藏) 运行时动态类型(existential container)
派发 静态派发 动态派发(PWT)
性能 高(无间接开销) 低(堆分配 + 间接调用)
类型一致性 同一函数返回的 some P 保证是同一类型 不保证
适用 返回值、属性 参数、集合元素

5. 方法派发 (Method Dispatch) 全面解析

5.1 四种派发方式

派发方式 速度 机制 适用场景
内联 (Inline) 最快 编译器将函数体直接插入调用点 小函数、@inline(__always)
静态派发 (Static/Direct) 编译期确定函数地址,直接 call struct 方法、final 方法、private 方法
虚表派发 (V-Table) 通过类的虚函数表间接调用 class 的非 final 方法
消息派发 (Message) ObjC runtime 的 objc_msgSend @objc dynamic 方法

5.2 Swift class 的虚函数表

Class Metadata:
┌──────────────────────┐
│  isa (指向 metaclass)  │
│  superclass pointer   │
│  cache (ObjC 兼容)     │
│  data (ObjC 兼容)      │
│  ...                  │
│  V-Table:             │
│    [0] → method1()    │
│    [1] → method2()    │
│    [2] → method3()    │
│    ...                │
└──────────────────────┘
  • 子类的 vtable 包含父类的所有方法条目 + 自己新增的
  • override 时,子类 vtable 中对应位置替换为子类的函数指针
  • 调用时:metadata → vtable[index] → 间接跳转

5.3 各场景的派发方式总结

声明位置 修饰符 派发方式
struct 方法 静态
enum 方法 静态
class 方法 虚表 (V-Table)
class 方法 final 静态
class 方法 private 静态(隐式 final)
class 方法 @objc dynamic 消息 (objc_msgSend)
protocol 要求方法 泛型约束 <T: P> 静态(特化后)/ Witness Table
protocol 要求方法 存在类型 any P PWT 动态派发
protocol 扩展方法 静态
extension of class 静态(不在 vtable 中!)

重要陷阱:class 的 extension 中定义的方法是静态派发!

class Base {
    func inVTable() { print("Base") }  // vtable
}
extension Base {
    func notInVTable() { print("Base ext") }  // 静态派发!
}
class Sub: Base {
    override func inVTable() { print("Sub") }  // OK
    // override func notInVTable() { }  // 编译错误!不能 override
}
let obj: Base = Sub()
obj.inVTable()      // "Sub" —— 动态派发
obj.notInVTable()   // "Base ext" —— 静态派发

5.4 @objc 与 dynamic 的区别

修饰符 作用 派发方式
@objc 将方法暴露给 ObjC runtime 仍然是 vtable(Swift 侧)
dynamic 使用 ObjC 消息派发 objc_msgSend
@objc dynamic 暴露给 ObjC 且使用消息派发 objc_msgSend(可被 KVO/method swizzling)

6. 闭包 (Closure) 底层原理

6.1 闭包的内存结构

闭包在 Swift 中是一个引用类型,底层结构:

Closure = 函数指针 + 上下文 (Context)
┌─────────────────────────┐
│  Function Pointer       │  → 指向闭包体的代码
│  Context (Capture List)  │  → 堆上分配的捕获变量
└─────────────────────────┘
  • 如果闭包不捕获任何变量,退化为普通函数指针(无堆分配)
  • 捕获变量时,编译器创建堆上的 context 对象,存储捕获的变量

6.2 捕获语义

默认捕获:引用捕获(变量)

var x = 10
let closure = { print(x) }
x = 20
closure()  // 输出 20 —— 捕获的是变量本身(引用)

底层:编译器将 x 从栈上提升到堆上的一个 Box 中,闭包和外部代码共享同一个 Box。

capture list 捕获:值捕获

var x = 10
let closure = { [x] in print(x) }
x = 20
closure()  // 输出 10 —— 捕获的是值的拷贝

6.3 逃逸闭包 vs 非逃逸闭包

维度 @escaping 非逃逸(默认)
生命周期 超出函数作用域 函数返回前执行完毕
堆分配 必须堆分配 context 编译器可能优化到栈上
捕获 self 需要显式 self. 不需要
性能 有堆分配开销 可能零开销

withoutActuallyEscaping 允许将非逃逸闭包临时当作逃逸闭包使用(高级场景)。

6.4 @autoclosure

  • 表达式自动包装为闭包,延迟求值
  • 常用于 assert?? 等需要短路求值的场景
  • 底层就是一个无参闭包 () -> T
func logIfTrue(_ condition: @autoclosure () -> Bool) {
    if condition() { print("True") }
}
logIfTrue(2 > 1)  // 2 > 1 被自动包装为 { 2 > 1 }

7. 枚举 (Enum) 底层原理

7.1 简单枚举的内存布局

enum Direction { case north, south, east, west }
// sizeof = 1 字节(只需要区分 4 个 case,1 字节足够 256 个)
  • 底层就是一个整数 tag(鉴别器/discriminator)
  • case 数量 ≤ 256 → 1 字节;≤ 65536 → 2 字节;依此类推

7.2 关联值枚举的内存布局

enum Result {
    case success(Int)    // payload: 8 字节
    case failure(String) // payload: 16 字节
}
// sizeof = max(payload) + tag = 16 + 1 = 17,对齐到 8 → 24 字节
  • 采用 tagged union 策略
  • 大小 = max(所有 case 的 payload) + tag 的大小(可能利用 spare bits 优化)

7.3 Optional 的底层:枚举的极致优化

// Optional<T> 就是:
enum Optional<Wrapped> {
    case none
    case some(Wrapped)
}

指针类型的 Optional 优化:

  • Optional<AnyObject> 只占 8 字节(和非 Optional 一样!)
  • 因为指针不可能为 0x0,所以 none 用全零表示,some 用有效指针值
  • 这叫 spare bit optimization —— 利用值中不可能出现的 bit pattern 作为 tag
  • 同理 Optional<Bool> = 1 字节(Bool 只有 0/1,用 2 表示 none)

7.4 indirect enum

indirect enum Tree {
    case leaf(Int)
    case node(Tree, Tree)
}
  • 没有 indirect 时,Tree 大小会无限递归(编译错误)
  • indirect 让关联值通过堆上的 Box 间接引用
  • 底层类似于 case node(Box<Tree>, Box<Tree>),Box 是引用类型

8. Struct vs Class 的性能深入对比

8.1 分配与释放

操作 struct (栈) class (堆)
分配 移动栈指针(1条指令) malloc 系统调用(涉及锁、空闲链表搜索)
释放 移动栈指针 free + 引用计数归零检查
速度比 ~1ns ~25-100ns

8.2 引用计数开销

  • class 每次传递都要 retain(原子操作 ~5ns)
  • 多线程下原子操作可能导致 cache line bouncing
  • struct 无引用计数,但含引用类型成员时有间接引用计数开销

8.3 缓存友好性

  • struct 的数组 [MyStruct]:连续内存,cache 友好
  • class 的数组 [MyClass]:数组存的是指针,实际对象分散在堆上,cache miss 率高
  • 这在遍历大数据集时差距巨大

9. String 底层原理

9.1 Small String Optimization (SSO)

  • Swift String 占 16 字节(2 个 word)
  • 短字符串(≤ 15 字节 UTF-8)直接 inline 存储在这 16 字节中,无堆分配
  • 长字符串在堆上分配 buffer,16 字节中存 buffer 指针 + 长度 + flags
  • 判断标志位在最高位

9.2 String 的字符模型

  • Swift 的 Character扩展字形簇 (Extended Grapheme Cluster)
  • 一个 Character 可能对应多个 Unicode 标量(如 emoji 👨‍👩‍👧‍👦 = 7 个标量)
  • 因此 String.count 是 O(n) 复杂度(需要遍历确定字形簇边界)
  • String.Index 不是整数,是不透明的偏移量,因为字符宽度不固定

9.3 String 的多种视图

视图 元素 场景
string.utf8 UTF8.CodeUnit (UInt8) 网络传输、C 交互
string.utf16 UTF16.CodeUnit (UInt16) NSString 兼容
string.unicodeScalars Unicode.Scalar Unicode 处理
string (默认) Character 用户可见字符

9.4 String 与 NSString 的桥接

  • Swift String 和 NSString 可以零成本桥接(toll-free bridging 的 Swift 版本)
  • 但底层编码不同:Swift 用 UTF-8,NSString 用 UTF-16
  • 桥接时可能触发转码,有性能开销
  • String 被传给 ObjC API 时可能创建临时 NSString(autorelease 对象)

10. 属性 (Property) 的底层机制

10.1 存储属性 vs 计算属性

类型 内存 本质
存储属性 占实例内存 实际的内存字段
计算属性 不占内存 getter/setter 方法
lazy 属性 Optional 存储 首次访问时初始化

10.2 属性观察器 (willSet/didSet) 底层

var name: String {
    willSet { print("将变为 \(newValue)") }
    didSet { print("已从 \(oldValue) 变为 \(name)") }
}

编译器展开为:

var _name: String
var name: String {
    get { _name }
    set {
        let oldValue = _name
        // willSet(newValue)
        _name = newValue
        // didSet(oldValue)
    }
}

注意:init 中赋值不会触发 willSet/didSet。

10.3 Property Wrapper 底层

@propertyWrapper struct Clamped {
    var wrappedValue: Int { ... }
    var projectedValue: Clamped { self }
}
struct Config {
    @Clamped var volume: Int
}

编译器展开为:

struct Config {
    private var _volume: Clamped
    var volume: Int {
        get { _volume.wrappedValue }
        set { _volume.wrappedValue = newValue }
    }
    var $volume: Clamped { _volume.projectedValue }
}

10.4 KeyPath 底层

  • KeyPath 是一个类型安全的属性引用,底层是一个对象
  • 编译器生成一系列 offset/accessor 信息
  • 支持组合(\Base.a.b.c)、运行时读写
  • WritableKeyPathReferenceWritableKeyPath 等继承层级
  • KeyPath 的读取性能接近直接属性访问(编译器优化后)

11. 并发 (Concurrency) — Swift Concurrency

11.1 Actor 模型

actor BankAccount {
    var balance: Double = 0
    func deposit(_ amount: Double) { balance += amount }
}

底层原理:

  • Actor 内部有一个串行执行器 (Serial Executor)
  • 所有对 actor 的方法调用被排列在执行器的队列中
  • 保证同一时刻只有一个任务在执行 actor 的代码
  • 跨 actor 调用需要 await(可能涉及线程切换)

Actor 隔离 (Isolation):

  • Actor 的属性和方法默认是 isolated 的
  • 外部访问需要 await(异步访问)
  • nonisolated 标记的方法可以不需要 await(不能访问 mutable 状态)

11.2 Structured Concurrency

async let result1 = fetchData1()
async let result2 = fetchData2()
let combined = await (result1, result2)
  • 子任务的生命周期绑定到父作用域
  • 父任务取消时,子任务自动取消
  • 子任务完成前,父作用域不会退出

11.3 Task 与 TaskGroup

Task:

  • Task { } 创建非结构化的顶级任务
  • Task.detached { } 创建完全独立的任务(不继承 actor context)
  • Task 持有对结果的引用,可以 await task.value

TaskGroup:

await withTaskGroup(of: Int.self) { group in
    for i in 0..<10 {
        group.addTask { await compute(i) }
    }
    for await result in group { ... }
}

11.4 Sendable 协议

  • 标记类型可以安全跨并发域传递
  • 值类型自动满足 Sendable(如果所有成员都是 Sendable)
  • class 需要满足:final + 所有属性 immutable (let) + Sendable
  • @Sendable 标记闭包,禁止捕获可变状态
  • 编译器在严格并发检查模式下会检查 Sendable 合规性

11.5 async/await 底层 —— Continuation

func fetchData() async -> Data { ... }
  • 编译器将 async 函数转换为状态机
  • 每个 await 点是一个 suspension point(挂起点)
  • 函数被分割为多个 continuation(延续)
  • 挂起时不阻塞线程,线程可以执行其他任务
  • 恢复时通过 continuation 跳回正确的执行点
  • 这就是 协程 (Coroutine) 的实现

与 GCD 的区别:

维度 GCD Swift Concurrency
线程模型 每个 block 可能在不同线程 协程,挂起不占线程
线程爆炸 容易创建过多线程 协作式线程池(线程数 ≤ CPU 核心数)
结构化 Task 层级结构,自动取消传播
安全性 手动保证 Actor 隔离,Sendable 检查

12. 类型系统高级特性

12.1 Phantom Type(幻影类型)

enum Kilometers {}
enum Miles {}
struct Distance<Unit> {
    let value: Double
}
// Distance<Kilometers> 和 Distance<Miles> 是不同类型
// Unit 从未被使用为值,只在类型层面区分 → 零开销

12.2 Result Builder

@resultBuilder struct ArrayBuilder {
    static func buildBlock(_ components: Int...) -> [Int] {
        components
    }
}
  • 编译器将 DSL 块内的语句转换为 buildBlock/buildOptional/buildEither 等方法调用
  • SwiftUI 的 @ViewBuilder 就是 result builder

12.3 Metatype(元类型)

let type: Int.Type = Int.self  // Int 的元类型
let obj = type.init(42)        // 用元类型创建实例
  • .self 获取类型本身的值
  • .Type 是类型的元类型
  • type(of: instance) 获取运行时动态类型
  • 对于 class,type(of:) 返回的可能是子类类型

第二部分:第三方常用库原理


1. Alamofire

1.1 架构分层

Request → SessionManager → URLSession → URLSessionTask
   ↑          ↑                ↑
Encoding   ServerTrust     Interceptor

1.2 核心原理

  • 基于 URLSession 封装,使用 URLSessionDelegate 统一管理回调
  • 请求拦截器 (RequestInterceptor)adapt 修改请求(如添加 token),retry 处理重试
  • 响应序列化:通过 ResponseSerializer 协议将 Data 转为目标类型
  • 请求链:请求排队 → 适配 → 发送 → 验证 → 序列化 → 回调
  • 证书锁定 (Certificate Pinning):通过 ServerTrustManager 实现,防中间人攻击

1.3 重要设计模式

  • Builder 模式:链式调用 .validate().responseDecodable(of:)
  • 命令模式Request 封装了一次完整请求的所有信息
  • 策略模式ParameterEncoding 协议的不同实现(URL/JSON/Custom)

2. Kingfisher

2.1 核心架构

KingfisherManager
  ├── ImageDownloader(网络下载)
  └── ImageCache
        ├── MemoryCache(NSCache)
        └── DiskCache(FileManager)

2.2 缓存策略

  • 内存缓存:基于 NSCache,系统内存紧张时自动清理
  • 磁盘缓存:文件名 = URL 的 MD5 哈希,支持过期时间和大小限制
  • 查找顺序:内存 → 磁盘 → 网络下载
  • 缓存键:默认为 URL 字符串,可自定义 CacheKeyFilter

2.3 图片处理管线

  • Processor:下载后/缓存前进行图片处理(裁剪、模糊、圆角等)
  • 处理后的图片以 原始key + processor标识 为 key 缓存
  • 支持渐进式 JPEG 加载、GIF 动画、SVG

2.4 性能优化细节

  • 下载使用 URLSession,支持 HTTP/2 多路复用
  • 图片解码在后台线程,避免阻塞主线程
  • 支持下载优先级和取消(cell 复用时取消旧请求)
  • ImagePrefetcher 预加载机制

3. SnapKit

3.1 底层原理

  • 本质是对 Auto Layout 的 NSLayoutConstraint 的 DSL 封装
  • 链式调用:每个方法返回 ConstraintMaker/ConstraintDescription
  • make.top.equalTo(view).offset(10) 最终等价于创建一个 NSLayoutConstraint
  • 约束更新snp.updateConstraints 找到已有约束修改 constant,比重新创建高效
  • 约束引用:可以保存约束引用后续修改 constraint.update(offset: 20)

3.2 与原生 API 的性能对比

  • SnapKit 在约束创建阶段有少量 wrapper 开销(可忽略)
  • 约束解算性能完全等同于原生 Auto Layout(最终都走同一个 Cassowary 算法引擎)
  • 主要价值是可读性和维护性

4. RxSwift / Combine 响应式框架

4.1 核心概念对比

概念 RxSwift Combine
数据流 Observable Publisher
消费者 Observer Subscriber
取消 Disposable / DisposeBag AnyCancellable
背压 无原生支持 Demand 机制
调度器 Scheduler Scheduler
Subject PublishSubject/BehaviorSubject PassthroughSubject/CurrentValueSubject

4.2 RxSwift 底层原理

  • Observable 是一个持有 subscribe 闭包的结构
  • subscribe 时创建 Sink(桥梁),连接 Observable 和 Observer
  • 操作符(map/filter 等)创建新的 Observable,形成链式管道
  • DisposeBag 在 deinit 时调用所有 Disposable 的 dispose,断开链条

4.3 Combine 底层原理

  • 基于 Publisher-Subscriber 协议
  • 背压 (Backpressure):Subscriber 通过 Demand 控制接收速率
  • Publisher 是值类型(struct),Subscriber 是引用类型(class)
  • sink/assign 等返回 AnyCancellable,释放即取消订阅

5. SwiftUI 数据流原理

5.1 属性包装器对比

Property Wrapper 所有权 触发刷新 适用场景
@State View 拥有 值变化时 View 内部简单状态
@Binding 不拥有(引用) 值变化时 父子 View 双向绑定
@ObservedObject 不拥有 objectWillChange 时 外部注入的 ObservableObject
@StateObject View 拥有 objectWillChange 时 View 创建的 ObservableObject
@EnvironmentObject 不拥有 objectWillChange 时 跨层级的 ObservableObject
@Environment 不拥有 值变化时 系统环境值

5.2 View 的 diff 更新机制

  • SwiftUI View 是值类型 struct,每次状态变化创建新的 View 值
  • SwiftUI 通过 attribute graph 追踪依赖关系
  • 只有依赖的数据发生变化的 View 才会被重新 body 求值
  • body 返回的新旧 View tree 做 structural diff,只更新差异部分

第三部分:开发难点与解决方案


1. 内存泄漏排查

1.1 常见泄漏场景

场景 根因 解决
闭包捕获 self 循环引用 [weak self] / [unowned self]
delegate 强引用 循环引用 delegate 用 weak 声明
Timer 持有 target Timer → self → Timer Timer.scheduledTimer(withTimeInterval:repeats:block:) + [weak self]
NotificationCenter addObserver iOS 8 以下需手动 remove block-based API + [weak self]
DispatchWorkItem 捕获 闭包内持有 self 取消 workItem 或 [weak self]
WKWebView 与 JS 交互 WKScriptMessageHandler 被 WKUserContentController 强持有 使用中间代理对象弱引用 self

1.2 排查工具链

  1. Xcode Memory Graph Debugger:可视化对象引用关系,直接定位循环引用
  2. Instruments - Leaks:运行时检测内存泄漏
  3. Instruments - Allocations:查看对象生命周期和内存分配
  4. deinit 打印:在 deinit 中加 print 确认对象释放
  5. MLeaksFinder(第三方):自动检测 ViewController 泄漏

1.3 难点:隐蔽的循环引用

// 难以发现的泄漏:闭包嵌套
class ViewModel {
    var onUpdate: (() -> Void)?
    func start() {
        NetworkManager.shared.request { [weak self] data in
            self?.onUpdate = {
                // 这里隐式捕获了 self(strong),因为 onUpdate 是 self 的属性
                // 而 self?.onUpdate = ... 外层已经是 weak self
                // 但 inner closure 没有 weak!
                self?.process(data)  // 如果这里 self 已经 unwrap 为 strong...
            }
        }
    }
}

2. 多线程数据竞争

2.1 经典问题

var array = [Int]()
DispatchQueue.concurrentPerform(iterations: 1000) { i in
    array.append(i)  // 崩溃!Array 非线程安全
}

2.2 解决方案对比

方案 优点 缺点
Serial DispatchQueue 简单直观 完全串行,性能差
Concurrent Queue + Barrier 读并发,写独占 代码稍复杂
NSLock / pthread_mutex 最轻量 需要手动 lock/unlock
os_unfair_lock 最快的互斥锁 不支持递归
Actor (Swift 5.5+) 编译器保证安全 异步调用
@Atomic property wrapper 属性级别保护 单次操作安全,复合操作不安全

2.3 Barrier 读写锁模式

class ThreadSafeArray<T> {
    private var array = [T]()
    private let queue = DispatchQueue(label: "safe", attributes: .concurrent)

    func read<R>(_ block: ([T]) -> R) -> R {
        queue.sync { block(array) }  // 并发读
    }
    func write(_ block: @escaping (inout [T]) -> Void) {
        queue.async(flags: .barrier) { block(&self.array) }  // 独占写
    }
}

3. 大量数据的列表性能

3.1 问题

  • 大量 cell 导致滚动卡顿
  • 图片加载闪烁
  • 内存暴涨

3.2 解决方案

问题 解决方案
cell 创建开销 复用机制 dequeueReusableCell
图片解码卡主线程 异步解码 + 缓存解码后的 bitmap
复杂 cell 布局 预计算 cell 高度,缓存布局结果
透明度 / 离屏渲染 避免 cornerRadius + masksToBounds,用 CAShapeLayer 或预渲染圆角图
大量图片内存 Kingfisher/SDWebImage 的缩略图 + downsampling
Diff 更新 DiffableDataSource / IGListKit / 手动 diff 只更新变化的 cell

4. 启动优化

4.1 启动阶段拆解

冷启动:
  1. 内核创建进程
  2. dyld 加载 → 动态库绑定 → rebase/bind
  3. +load / __attribute__((constructor))
  4. Runtime 初始化(ObjC class 注册、category attach)
  5. main() 函数
  6. AppDelegate → UIWindow → 首屏渲染

4.2 优化手段

阶段 优化方式
dyld 减少动态库数量(合并为 1 个);使用静态库
+load 移到 +initialize 或懒加载
二进制 二进制重排(Profile-Guided Optimization),减少 Page Fault
main 后 延迟非必要初始化,首屏数据预加载/缓存
渲染 简化首屏 UI,避免首屏大量 Auto Layout

5. 崩溃治理

5.1 常见崩溃类型

崩溃 原因 Swift 中的表现
EXC_BAD_ACCESS 野指针 / 访问已释放内存 极少(ARC + 值类型),除非 unowned(unsafe) 或 Unsafe 指针
EXC_BREAKPOINT trap 指令 fatalError、force unwrap nil、数组越界
SIGABRT abort() 断言失败、unrecognized selector(ObjC 交互)
OOM 内存超限 无 crash log(Jetsam),需要 MetricKit

5.2 Swift 特有崩溃

  • Force unwrap nillet x: Int = optional! —— 最常见
  • Array index out of range:下标越界
  • Unowned reference after dealloc:访问已释放的 unowned 对象
  • Exhaustive switch:enum 新增 case 但 switch 未覆盖(@unknown default)

第四部分:性能优化深入细节


1. 编译器优化

1.1 关键编译选项

选项 含义 效果
-Onone 无优化(Debug) 保留所有调试信息
-O 标准优化(Release) 内联、泛型特化、死代码消除
-Osize 优化体积 减少内联,优先选择小代码
-Ounchecked 移除安全检查 数组越界、溢出检查被移除,危险但最快
WMO (Whole Module Optimization) 全模块优化 跨文件内联/特化/去虚拟化

1.2 WMO 的重要性

  • 非 WMO 模式下,每个文件单独编译,看不到其他文件的实现
  • WMO 允许编译器将非 public/非 open 的 class 方法去虚拟化(直接调用)
  • internal 方法从 vtable 派发降级为静态派发
  • 自动推断 final(如果子类在整个模块中不存在)

1.3 帮助编译器优化的编码技巧

技巧 原因
final 修饰不需要继承的 class 静态派发
private / fileprivate 编译器可推断 final,静态派发
用 struct 而非 class 无引用计数,栈分配
避免过大的协议 existential 减少堆分配
@inlinable 暴露关键路径 跨模块内联优化
@frozen 标记稳定的 struct/enum 允许编译器直接操作内存布局
减少不必要的 Optional 减少分支和 unwrap 开销

2. 内存优化

2.1 减少堆分配

场景 优化
小对象 用 struct 替代 class
协议类型 用泛型约束替代 existential
闭包 非逃逸闭包(编译器可栈分配)
String 短字符串利用 SSO
数组 Array.reserveCapacity(_:) 预分配,避免多次扩容拷贝

2.2 减少引用计数操作

  • 减少 class 实例的传递次数
  • struct 中减少引用类型成员数量
  • let 代替 var(编译器可以省略某些 retain/release)
  • 考虑 Unmanaged<T> 手动管理引用计数(高性能场景)

2.3 内存对齐与布局优化

// 不好:padding 浪费
struct Bad {
    let a: Bool    // 1 byte + 7 padding
    let b: Int64   // 8 bytes
    let c: Bool    // 1 byte + 7 padding
}  // 总共 24 bytes

// 好:重排成员减少 padding
struct Good {
    let b: Int64   // 8 bytes
    let a: Bool    // 1 byte
    let c: Bool    // 1 byte + 6 padding
}  // 总共 16 bytes

Swift 编译器不会自动重排 struct 成员(为了保持 ABI 兼容),需要手动优化。


3. 集合操作优化

3.1 Lazy Collection

// 非 lazy:创建 3 个中间数组
let result = array.filter { $0 > 0 }.map { $0 * 2 }.prefix(5)

// lazy:单次遍历,按需计算,无中间数组
let result = array.lazy.filter { $0 > 0 }.map { $0 * 2 }.prefix(5)
  • lazy 将操作转为惰性求值
  • 只遍历一次,遇到满足条件的前 5 个就停止
  • 适合大数组 + 链式操作 + 只取部分结果

3.2 Dictionary 性能

  • Dictionary 使用开放寻址 + 线性探测哈希表
  • 负载因子超过 75% 自动扩容(容量翻倍 + rehash)
  • Dictionary.reserveCapacity(_:) 可预分配
  • 自定义 Hashable 时注意 hash 分布均匀性
  • Dictionary(grouping:by:) 比手动 for 循环分组更高效

3.3 ContiguousArray vs Array

  • Array 需要兼容 NSArray 桥接,有额外判断开销
  • ContiguousArray 保证连续内存存储,不支持 NSArray 桥接
  • 存储非 class、非 @objc 类型时两者等效
  • 存储 class 类型且确定不需要 ObjC 桥接时,ContiguousArray 更快

4. 字符串性能

4.1 避免频繁拼接

// 差:每次 += 可能触发拷贝和堆分配
var s = ""
for i in 0..<1000 { s += "\(i)" }

// 好:预分配
var s = ""
s.reserveCapacity(4000)
for i in 0..<1000 { s += "\(i)" }

// 更好:用数组 join
let s = (0..<1000).map(String.init).joined()

4.2 子串 Substring

  • Substring 与原 String 共享底层 buffer(COW)
  • 长期持有 Substring 会阻止原 String buffer 释放
  • 短期使用 Substring,长期存储时转为 String(substring)

5. 减少动态派发

5.1 性能对比数据

派发方式 相对开销
内联 0(最快)
静态派发 1x
vtable 派发 ~1.1x - 1.5x(间接跳转 + 可能的 cache miss)
PWT 派发 ~1.5x - 2x(多一次间接寻址)
objc_msgSend ~3x - 5x(查找 IMP 缓存)

5.2 优化方法

  1. struct > class(天然静态派发)
  2. final class / final method(静态派发)
  3. private / fileprivate method(隐式 final)
  4. 泛型约束 <T: P> > 存在类型 any P(可特化为静态派发)
  5. WMO 开启(自动去虚拟化)

第五部分:八股文中的横向对比


1. 值类型 vs 引用类型(深度对比)

对比维度 值类型 引用类型
拷贝语义 深拷贝(COW 优化后延迟拷贝) 浅拷贝(共享引用)
身份判断 无法判断「同一个」(只有值相等) === 判断同一实例
多态 协议实现 + 泛型 继承 + 协议
线程安全 天然安全 需同步
析构 无 deinit 有 deinit
内存位置 栈/内联(优先)
引用计数 有(ARC)
适用场景 数据模型、算法、并发安全 共享状态、标识语义、继承层级

选择原则: 默认用 struct,只在需要共享状态、继承、deinit、identity 时用 class。


2. struct vs class vs enum vs actor

特性 struct class enum actor
类型 引用 引用
继承 不支持 支持 不支持 不支持
协议遵循 支持 支持 支持 支持
deinit
可变性 mutating 自由修改 mutating 隔离保护
线程安全 拷贝安全 需手动 拷贝安全 编译器保证
引用计数
内存 栈优先 栈优先

3. let vs var(底层差异)

维度 let var
可变性 不可变 可变
编译器优化 更多(常量折叠、省略 retain/release) 较少
线程安全 安全(不可变) 不安全
引用类型 引用不可变(属性仍可变) 引用可变

4. map vs flatMap vs compactMap

方法 签名 作用
map (T) -> U 1:1 转换
flatMap (T) -> [U] 1:N 转换后展平
compactMap (T) -> U? 1:1 转换,自动过滤 nil
let a = [[1,2],[3,4]]
a.map { $0 }        // [[1,2],[3,4]]
a.flatMap { $0 }    // [1,2,3,4]

let b = ["1","a","3"]
b.compactMap { Int($0) }  // [1, 3]

Optional 上的 flatMap:

let x: Int? = 5
x.flatMap { $0 > 3 ? $0 : nil }  // Optional(5)
x.map { $0 > 3 ? $0 : nil }      // Optional(Optional(5)) → Int??

5. GCD vs Operation vs Swift Concurrency

维度 GCD Operation Swift Concurrency
抽象层级 低(C API) 中(ObjC 对象) 高(语言级别)
取消 手动检查 isCancelled 属性 结构化自动传播
依赖管理 手动 dispatch_group/barrier addDependency async let / TaskGroup
线程控制 QoS + target queue maxConcurrentOperationCount 协作式线程池
线程爆炸 容易 容易 不会(线程数 ≤ 核心数)
错误处理 无内建 无内建 throws + try await
安全保证 Actor + Sendable

6. weak vs unowned vs unowned(unsafe)

维度 weak unowned unowned(unsafe)
类型 Optional 非 Optional 非 Optional
对象释放后 自动 nil trap 崩溃 野指针(UB)
性能开销 Side table + 原子操作 较少 零额外开销
安全性 最安全 安全(确定性崩溃) 最危险
适用场景 delegate、不确定生命周期 确定不会先于 self 释放 极致性能,生命周期绝对保证

7. Any vs AnyObject vs any Protocol vs some Protocol

类型 含义 底层
Any 任意类型(值/引用) existential container (32 bytes)
AnyObject 任意引用类型 单指针 (8 bytes)
any Protocol 任意遵循 P 的类型 existential container
some Protocol 某个特定的遵循 P 的类型(编译期确定) 无 container,直接值

8. 访问控制对比

级别 可见范围 编译器优化影响
open 任何模块可继承和 override 不能优化派发
public 任何模块可访问,不可继承 override 不能优化派发(外部可能做协议遵循等)
internal 同一模块(默认) WMO 下可推断 final
fileprivate 同一文件 可推断 final
private 同一声明作用域 隐式 final,静态派发

9. 闭包 vs 函数 vs 方法

维度 全局函数 实例方法 闭包
类型 (Args) -> Return (Self) -> (Args) -> Return(柯里化) (Args) -> Return
捕获 隐式捕获 self 显式/隐式捕获环境
堆分配 无(作为闭包传递时有) 有(逃逸时)

10. throws vs Result vs Optional

方式 适用场景 性能 链式处理
throws 同步错误处理 正常路径零开销(Swift 使用 error return) do-catch
Result<T, E> 异步回调 / 存储结果 enum 开销(极小) map/flatMap
Optional<T> 值可能不存在 最小 map/flatMap/??

第六部分:高难度深底层原理


1. Swift Runtime 与 Metadata 系统

1.1 类型元数据 (Type Metadata)

每个 Swift 类型在运行时都有一个元数据 (Metadata) 记录:

Struct Metadata:
┌─────────────────────────┐
│ Kind (标识类型种类)        │  ← struct/class/enum/optional/tuple...
│ Type Descriptor          │  → 指向类型描述符(名称、字段、泛型参数等)
│ Value Witness Table Ptr  │  → VWT(size/alignment/copy/destroy 等操作)
└─────────────────────────┘

Class Metadata (ISA):
┌─────────────────────────┐
│ Kind                     │
│ SuperClass Pointer       │  → 父类元数据
│ Cache / Data (ObjC兼容)   │
│ Flags                    │
│ Instance Size            │
│ Instance Alignment       │
│ Type Descriptor          │
│ V-Table entries...       │  → 虚函数表
└─────────────────────────┘

1.2 泛型 Metadata 的懒创建

  • 泛型类型如 Array<Int> 的 metadata 是运行时按需创建
  • 首次使用 Array<Int> 时,runtime 用模板 + Int.self 的 metadata 组合生成
  • 生成后缓存在全局表中(线程安全的 concurrent hash map)
  • 这就是为什么泛型类型的首次使用可能比后续使用略慢

1.3 Mirror 反射的底层

let mirror = Mirror(reflecting: someInstance)
for child in mirror.children { ... }
  • Mirror 通过 Type Descriptor 中的字段描述信息获取属性名和偏移量
  • 通过 Value Witness Table 中的操作函数读取字段值
  • 属于「有限反射」—— 只能读取,不能修改(不像 Java/ObjC 的完全运行时反射)
  • Release 模式下如果类型信息被 strip,反射能力会受限

2. SIL (Swift Intermediate Language)

2.1 编译流程

Swift Source → AST → SIL (raw) → SIL (canonical) → SIL (optimized) → LLVM IR → Machine Code
                ↑         ↑              ↑                 ↑
            解析/类型检查  SILGen     强制诊断/优化      LLVM 优化

2.2 SIL 的作用

  • 类型检查之后、LLVM 之前的中间表示
  • 比 LLVM IR 更高级,保留了 Swift 的类型信息
  • 用于:
    • ARC 优化:合并/消除冗余的 retain/release
    • 泛型特化:生成具体类型的特化版本
    • 去虚拟化:将 vtable 调用转为直接调用
    • 内联:将小函数体直接插入调用点
    • 诊断:检测未初始化变量、排他性访问违规等

2.3 查看 SIL

swiftc -emit-sil file.swift  # 优化前的 SIL
swiftc -emit-sil -O file.swift  # 优化后的 SIL

SIL 中可以直接看到 retain/release 的插入位置、dispatch 方式、内联决策等。


3. 排他性访问 (Exclusivity Enforcement)

3.1 原则

Swift 保证同一时刻不能同时存在对同一变量的读访问和写访问(Law of Exclusivity)。

3.2 静态检查

var x = 1
swap(&x, &x)  // 编译错误!同时对 x 进行两个写访问

3.3 动态检查

var array = [1, 2, 3]
// 运行时可能崩溃:对 array 同时读 (subscript) 和写 (modifyElement)
extension Array {
    mutating func modifyFirst(using: (inout Element) -> Void) {
        using(&self[0])  // self 正在被修改,又通过 subscript 修改
    }
}

3.4 底层实现

  • 编译器在变量的访问开始/结束时插入 begin_access / end_access 标记
  • 栈上变量:编译器静态证明(大部分情况)
  • 堆上变量/全局变量:运行时维护访问记录栈,检测冲突
  • Debug 模式检查更严格,Release 中部分检查被优化掉

4. 内存安全与 Unsafe API

4.1 Swift 的安全保证

  • 变量使用前必须初始化
  • 数组下标自动检查越界
  • 整数溢出自动检测(Debug 模式)
  • Optional 强制解包检查
  • 排他性访问检查

4.2 Unsafe 指针体系

类型 含义 等价 C 类型
UnsafePointer<T> 只读指针 const T*
UnsafeMutablePointer<T> 可变指针 T*
UnsafeRawPointer 无类型只读指针 const void*
UnsafeMutableRawPointer 无类型可变指针 void*
UnsafeBufferPointer<T> 只读指针 + 长度 const T* + size_t
UnsafeMutableBufferPointer<T> 可变指针 + 长度 T* + size_t
OpaquePointer 不透明指针 C 的 opaque struct pointer
Unmanaged<T> 手动管理引用计数的引用 CFTypeRef

4.3 使用场景

  • C 库交互(Core Audio, Metal, 网络底层等)
  • 高性能数据处理(避免 ARC / 边界检查开销)
  • 内存映射文件操作

4.4 常见陷阱

// 危险:指针悬垂
var ptr: UnsafeMutablePointer<Int>?
do {
    var x = 42
    ptr = UnsafeMutablePointer(&x)
}
ptr?.pointee  // 未定义行为!x 已超出作用域

// 正确:使用 withUnsafe 系列方法
withUnsafePointer(to: &x) { ptr in
    // ptr 仅在此闭包内有效
}

5. ABI 稳定性 (Swift 5+)

5.1 什么是 ABI 稳定

  • ABI (Application Binary Interface):二进制层面的接口约定
  • 包括:函数调用约定、类型内存布局、name mangling、元数据格式、runtime 接口
  • Swift 5 之后 ABI 稳定 → Swift runtime 嵌入 OS → App 不再需要内嵌 Swift runtime → 包体积减小

5.2 Library Evolution

  • @frozen:向编译器承诺 struct/enum 的布局不会变化
    • 编译器可以直接根据偏移量访问成员(更快)
    • 不加 @frozen 时,编译器通过间接方式访问(支持未来布局变化)
  • @inlinable:向编译器暴露函数体,允许跨模块内联
  • 这些是标准库和系统框架使用的属性

5.3 Module Stability

  • Swift 5.1+ 模块稳定:.swiftinterface 文件替代 .swiftmodule
  • 不同编译器版本编译的模块可以互相兼容

6. 类型转换的底层机制

6.1 as / as? / as! 的区别

操作 检查时机 失败行为 底层
as 编译期 编译错误 无运行时开销(类型已知)
as? 运行时 返回 nil metadata 比较
as! 运行时 trap 崩溃 metadata 比较 + 强制

6.2 is 检查的底层

if value is MyClass { ... }
  • 值类型:编译期确定(静态检查)
  • 引用类型:运行时检查 isa 指针链(遍历继承链)
  • 协议类型:检查 type metadata 中的 protocol conformance 表

6.3 Protocol Conformance 查找

  • Swift runtime 维护一个全局的 Protocol Conformance Table
  • 表项格式:(TypeDescriptor, ProtocolDescriptor) → WitnessTable
  • as? SomeProtocol 时,runtime 在表中查找当前类型是否遵循该协议
  • 查找结果会被缓存

7. Swift 与 Objective-C 互操作底层

7.1 桥接机制

Swift 类型 ObjC 类型 桥接方式
String NSString 按需转换(UTF-8 ↔ UTF-16)
Array NSArray 包装/拆包
Dictionary NSDictionary 包装/拆包
Int/Double NSNumber 装箱/拆箱
struct 不可桥接 需要手动封装为 class

7.2 @objc 的代价

  • 标记为 @objc 的方法会生成 ObjC 兼容的调用入口
  • Swift class 继承 NSObject 时,会注册到 ObjC runtime
  • ObjC 方法调用走 objc_msgSend,比 Swift vtable 慢 3-5 倍
  • 每个 @objc 方法增加约 100 字节的二进制体积

7.3 Dynamic Member Lookup

@dynamicMemberLookup
struct JSON {
    subscript(dynamicMember member: String) -> JSON { ... }
}
let value = json.user.name  // 编译器转换为 subscript 调用
  • 编译期将 .member 语法转为 subscript 调用
  • 不涉及 ObjC runtime,纯 Swift 实现
  • 用于 DSL、动态语言桥接等

8. Move Semantics 与 Ownership(Swift 5.9+)

8.1 consuming / borrowing 参数

func process(_ value: consuming MyStruct) {
    // value 的所有权被转移到此函数,调用方不能再使用
}
func inspect(_ value: borrowing MyStruct) {
    // 只读借用,不拷贝,不转移所有权
}

8.2 ~Copyable(不可拷贝类型)

struct FileHandle: ~Copyable {
    let fd: Int32
    deinit { close(fd) }  // struct 有了 deinit!
}
  • 不可拷贝类型保证唯一所有权
  • 赋值 = 移动(原变量失效)
  • 可以为 struct 添加 deinit(资源清理)
  • 类似 Rust 的 ownership 模型
  • 消除不必要的引用计数开销

8.3 意义

  • 零成本抽象的资源管理(RAII)
  • 编译器保证资源不会被重复释放或遗忘释放
  • 为 Swift 引入更精细的内存控制能力

9. Result Type 与 Error Handling 底层

9.1 throws 的底层实现

Swift 的 throws 不使用异常表(不同于 C++/Java):

// 函数签名实际上是:
func foo() throws -> Int
// 底层等价于:
func foo() -> (Int, Error?)
  • 通过隐藏的返回值寄存器传递 Error
  • 正常路径零开销(no error → 直接返回结果)
  • 错误路径有 Error 对象创建的开销
  • 这就是为什么 try 的正常路径性能很好

9.2 typed throws (Swift 5.9+)

func parse() throws(ParseError) -> AST { ... }
  • 限定了错误类型,避免 existential Error 的开销
  • 调用方可以直接 catch 具体类型,无需 as? 转换

10. @dynamicCallable 与语言扩展能力

@dynamicCallable
struct PythonObject {
    func dynamicallyCall(withArguments args: [Any]) -> PythonObject { ... }
    func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, Any>) -> PythonObject { ... }
}
let result = pythonObj(1, 2, name: "test")  // 编译器转为 dynamicallyCall
  • 编译器将函数调用语法重写为 dynamicallyCall 方法调用
  • 用于与 Python/Ruby 等动态语言桥接
  • TensorFlow for Swift 等项目大量使用

11. Opaque Return Type 与 Reverse Generics

11.1 some 的底层

func makeShape() -> some Shape {
    Circle()
}
  • 编译器知道返回类型是 Circle,但对调用方隐藏
  • 不使用 existential container,直接返回 Circle 的值
  • 零额外开销(等同于直接返回 Circle)
  • 但保持了 API 的抽象性(future-proof)

11.2 与 existential 的根本差异

// some:编译期确定类型,运行时无开销
func a() -> some Collection { [1,2,3] }
// a() 和 a() 保证是同一类型(Int Array)

// any:运行时动态类型,有 container 开销
func b() -> any Collection { Bool.random() ? [1] : Set([1]) }
// b() 每次可能不同类型

12. Memory Layout 工具

MemoryLayout<Int>.size        // 8(实际占用字节)
MemoryLayout<Int>.stride      // 8(数组中相邻元素的间距)
MemoryLayout<Int>.alignment   // 8(对齐要求)

MemoryLayout<Bool>.size       // 1
MemoryLayout<Bool>.stride     // 1
MemoryLayout<Bool>.alignment  // 1

MemoryLayout<Optional<Int>>.size    // 9(8 + 1 tag)
MemoryLayout<Optional<Int>>.stride  // 16(对齐到 8 的倍数)

MemoryLayout<String>.size     // 16(SSO 结构)
MemoryLayout<String>.stride   // 16

这些在需要与 C 交互、手动管理内存、优化内存布局时非常关键。


附录:高频面试题速查

# 问题 核心关键词
1 struct 和 class 的区别? 值/引用、栈/堆、COW、ARC、继承
2 Swift 的方法派发有几种? 静态、vtable、PWT、objc_msgSend
3 ARC 和 GC 的区别? 编译期插入 vs 运行时扫描、确定性 vs 非确定性、无停顿 vs STW
4 weak 和 unowned 的区别? Optional/非Optional、side table、释放后行为
5 什么是 Existential Container? 5 words、value buffer、metadata、PWT
6 什么是 COW? isKnownUniquelyReferenced、延迟拷贝
7 泛型约束和存在类型的区别? 静态/动态派发、特化、性能差异
8 闭包是值类型还是引用类型? 引用类型、函数指针+context、堆分配
9 Swift 的 String 为什么不能用 Int 下标? 变长 UTF-8、扩展字形簇、O(n) 遍历
10 Optional 底层是什么? 枚举 .none/.some、spare bit 优化
11 some 和 any 的区别? opaque type vs existential、静态/动态、性能
12 Actor 怎么保证线程安全? 串行执行器、isolation、await
13 async/await 底层原理? 协程、状态机、continuation、不阻塞线程
14 throws 的性能开销? 正常路径零开销、隐藏返回寄存器
15 @frozen 和 @inlinable 的作用? ABI 稳定、跨模块优化、库演进
16 什么是 WMO? 全模块优化、去虚拟化、跨文件内联
17 ~Copyable 是什么? 不可拷贝类型、唯一所有权、move semantics
18 协议扩展方法为什么不能多态? PWT 无条目、静态派发
19 class extension 的方法能 override 吗? 不能、不在 vtable 中、静态派发
20 排他性访问是什么? Law of Exclusivity、begin/end_access、读写冲突检测

Swift 并发编程深度解析:从 async/await 到智能调度

深入理解 Swift 5.5+ 的现代并发模型,掌握如何编写安全高效的多线程代码


引言:为什么需要新的并发模型?

在传统 iOS/macOS 开发中,我们使用 GCD(Grand Central Dispatch)或 OperationQueue 来处理并发任务。然而,这些技术存在一些痛点:

  1. 回调地狱:多层嵌套的回调难以阅读和维护
  2. 手动内存管理:容易忘记 weak self 导致内存泄漏
  3. 线程爆炸:过度创建线程消耗系统资源
  4. 数据竞争:共享状态需要手动加锁,容易出错

Swift 5.5 引入的 async/await 和结构化并发解决了这些问题,提供了更安全、更简洁的并发编程方式,iOS 13以上是支持的。


第一部分:async/await 基础语法

1.1 异步函数声明

// 传统回调方式
func fetchUser(completion: @escaping (Result<User, Error>) -> Void)

// 异步函数方式
func fetchUser() async throws -> User

1.2 异步函数调用

// 使用 await 调用异步函数
do {
    let user = try await fetchUser()
    print("用户: \(user.name)")
} catch {
    print("错误: \(error)")
}

第二部分:async let 与结构化并发

2.1 并发启动多个任务

// 同时启动多个异步任务
func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser()          // 立即开始
    async let orders = fetchOrders()      // 立即开始
    async let messages = fetchMessages()  // 立即开始
    
    // 等待所有任务完成
    return try await Dashboard(
        user: user,
        orders: orders,
        messages: messages
    )
}

2.2 与顺序执行的对比

// 并发执行(总耗时 ≈ 最慢的任务)
async let a = taskA()  // 0-1秒
async let b = taskB()  // 0-2秒
let results = await (a, b)  // 总耗时: 2秒

// 顺序执行(总耗时 = 所有任务时间之和)
let a = await taskA()  // 0-1秒
let b = await taskB()  // 1-3秒(等A完成后才开始)
// 总耗时: 3秒

2.3 重要概念澄清

Q: async let user = fetchUser() 立即返回什么? A: 它不立即返回数据,而是返回一个异步任务句柄。实际数据在 await 时获取。

Q: 多个 async let 相当于 GCD 的异步任务吗? A: 相似但有重要区别。async let 是结构化并发的一部分,任务生命周期自动管理,支持取消和错误传播。


第三部分:数据安全与线程调度

3.1 数据竞争的解决方案

方案一:使用 Actor(银行柜台模型)

actor UserCache {
    private var storage: [String: User] = [:]
    
    func getUser(id: String) -> User? {
        return storage[id]
    }
    
    func setUser(_ user: User, for id: String) {
        storage[id] = user
    }
}

// 使用时自动序列化访问
let cache = UserCache()
let user = await cache.getUser(id: "123")  // 自动排队等待

原理:编译器强制同一时间只有一个任务能访问 Actor 内部状态,通过消息传递模型确保安全。

方案二:使用值语义(发复印件模型)

struct UserProfile {
    let user: User
    var settings: Settings
    // 结构体是值类型,复制安全
}

func processProfile(profile: UserProfile) async {
    // 每个任务获取独立的副本
    async let task1 = {
        var copy = profile
        copy.settings.theme = .dark
        return copy
    }()
    
    async let task2 = {
        var copy = profile
        copy.settings.fontSize = 16
        return copy
    }()
    
    let results = await (task1, task2)  // 独立修改,互不影响
}

原理:通过复制而非共享,从根本上消除数据竞争的可能性。

3.2 智能线程调度

Q: async let 任务在哪个线程执行? A: Swift 并发运行时智能决定,基于以下因素:

  1. 当前线程负载 - 太忙就调度到其他线程
  2. 任务类型 - I/O密集型 vs CPU密集型
  3. 优先级 - 高优先级任务可能更快执行
  4. 硬件资源 - CPU核心数、当前负载
  5. 执行器约束 - 如 @MainActor 强制主线程

智能调度的具体表现:

@MainActor
func updateUIWithData() async {
    // 从主线程调用,但会自动优化
    async let data = fetchHeavyData()  // 运行时:这个会阻塞 → 调度到后台线程
    
    let processed = await process(data)  // 可能在后台线程继续处理
    
    // 更新UI时自动回到主线程
    self.label.text = processed.title
}

3.3 什么时候需要显式控制线程?

// 1. UI操作必须主线程
@MainActor
func updateUI() {
    // 编译时确保在主线程
}

// 2. CPU密集型长时间计算
func processImage(_ image: UIImage) async -> UIImage {
    // 明确指定在独立线程执行
    return await Task.detached {
        return image.applyFilters()  // 耗时的图像处理
    }.value
}

// 3. 不应该干预的案例
// ❌ 不要这样:破坏了智能调度
Task {
    DispatchQueue.global().async {
        await someAsyncWork()
    }
}

// ✅ 应该这样:信任运行时
Task {
    await someAsyncWork()  // 让系统决定最佳执行方式
}

第四部分:实际应用模式

4.1 网络请求组合

class UserService {
    func loadFullProfile(userId: String) async throws -> FullProfile {
        // 并发获取所有数据
        async let userInfo = fetchUserInfo(userId)
        async let posts = fetchUserPosts(userId)
        async let friends = fetchUserFriends(userId)
        async let preferences = fetchUserPreferences(userId)
        
        // 等待所有结果
        return try await FullProfile(
            info: userInfo,
            posts: posts,
            friends: friends,
            preferences: preferences
        )
    }
    
    // 对比传统回调方式
    func loadFullProfileOld(userId: String, 
                           completion: @escaping (Result<FullProfile, Error>) -> Void) {
        fetchUserInfo(userId) { result1 in
            switch result1 {
            case .success(let userInfo):
                self.fetchUserPosts(userId) { result2 in
                    switch result2 {
                    case .success(let posts):
                        // 更多嵌套...
                    case .failure(let error):
                        completion(.failure(error))
                    }
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

4.2 限制并发数量

func downloadMultipleFiles(urls: [URL], maxConcurrent: Int = 4) async throws -> [Data] {
    // 使用 TaskGroup 控制并发数
    return try await withThrowingTaskGroup(of: Data.self) { group in
        var results: [Data] = []
        results.reserveCapacity(urls.count)
        
        // 分批处理,限制并发数
        for index in urls.indices {
            if group.taskCount >= maxConcurrent {
                // 等待一个任务完成再添加新的
                if let result = try await group.next() {
                    results.append(result)
                }
            }
            
            group.addTask {
                return try await downloadFile(from: urls[index])
            }
        }
        
        // 收集剩余结果
        for try await result in group {
            results.append(result)
        }
        
        return results
    }
}

第五部分:与系统框架的集成

5.1 iOS 13+ 的系统 API 更新

// iOS 13+ 提供了异步版本的 openURL
func openSettings() async -> Bool {
    guard let url = URL(string: UIApplication.openSettingsURLString) else {
        return false
    }
    
    return await UIApplication.shared.open(url)
}

// 使用示例
Task {
    let success = await openSettings()
    print("设置应用打开\(success ? "成功" : "失败")")
}

// 为什么使用Task?
// ❌ 错误:不能在同步函数中直接使用 await
func buttonTapped() {
    let success = await openSettings()  // 编译错误!
    print("结果: \(success)")
}

// ✅ 正确:需要 Task 包装
func buttonTapped() {
    Task {  // 创建异步执行环境
        let success = await openSettings()
        print("结果: \(success)")
    }
}

5.2 适配旧版本系统

// 为 iOS 13+ 提供兼容方案
func openURL(_ url: URL) async -> Bool {
    if #available(iOS 13.0, *) {
        return await UIApplication.shared.open(url)
    } else {
        // 使用 continuation 桥接到 async/await
        return await withCheckedContinuation { continuation in
            UIApplication.shared.open(url) { success in
                continuation.resume(returning: success)
            }
        }
    }
}

第六部分:最佳实践总结

6.1 代码组织原则

  1. 优先使用 async/await 替代回调
  2. 合理使用 async let 进行并发,但注意数量控制
  3. 使用 Actor 保护共享状态,避免手动锁
  4. 尽量使用值类型,减少共享可变状态

6.2 架构设计建议

// 推荐的层次结构:
// UI层 (@MainActor) - 处理用户交互和界面更新
// 业务层 (混合) - 协调数据流,处理业务逻辑
// 数据层 (async/await) - 网络请求、数据库操作
// 工具层 (值类型) - 纯函数计算、数据处理

@MainActor
class ViewController: UIViewController {
    private let viewModel: UserViewModel
    
    func loadData() async {
        await viewModel.loadUserData()
        updateUI()
    }
}

actor UserViewModel {
    private let repository: UserRepository
    
    func loadUserData() async {
        let user = await repository.fetchUser()
        // 处理业务逻辑
    }
}

class UserRepository {
    func fetchUser() async throws -> User {
        // 数据层操作
        return try await apiClient.fetchUser()
    }
}

第七部分:内部原理机制

Swift的async/await基于协程实现: 技术关系:

// 1个线程上可以运行多个协程
Thread A: [协程1运行] → [协程2运行] → [协程1恢复] → [协程3运行]
                ↑           ↑           ↑           ↑
           遇到await挂起 遇到await挂起 结果返回恢复 遇到await挂起

// 协程在挂起时释放线程,让其他协程使用

// 传统线程 vs 协程

// 线程:操作系统调度,上下文切换成本高
Thread 1: [运行] → [阻塞等待I/O] → [运行]
Thread 2: [等待] → [运行] → [等待]

// 协程:用户态调度,轻量级
协程 A: [运行] → [挂起] → [运行]
协程 B:     [运行] → [挂起]
// 在同一线程上交替执行,没有线程切换开销

结合实际代码说明:

// 规则1:一个协程必须在一个线程上运行
// 规则2:协程只能在特定点挂起(await处)
// 规则3:挂起的协程不占用线程

// 示例:
func fetchMultipleResources() async {
    // 开始:在主线程运行(如果从@MainActor调用)
    
    let data1 = await fetchData()  // 挂起点1
    // 挂起:释放主线程,其他协程可用
    
    // 恢复:可能在任意线程(不一定是主线程)
    process(data1)  // 在某个后台线程执行
    
    let data2 = await fetchData()  // 挂起点2
    // 再次挂起...
    
    // 最后如果需要更新UI,要确保在主线程
    await MainActor.run {
        updateUI(data1, data2)
    }
}

结语

Swift 的现代并发模型代表了并发编程的范式转变:

  1. 从手动调度到智能调度 - 信任运行时做出最优决策
  2. 从回调地狱到线性代码 - 使用 async/await 简化异步流程
  3. 从容易出错到内存安全 - 通过 Actor 和值语义避免数据竞争
  4. 从复杂管理到结构化 - 自动处理任务生命周期和取消

虽然学习曲线比 GCD 更陡峭,但一旦掌握,你将能编写出更安全、更简洁、更高效的并发代码。


进一步学习资源:

日常锻炼的一些记录

我大概是去年 4 月左右开始跑步的。离上次的记录已过了半年

最近坚持的还不错,每周可以保证至少 3 次跑步。现在的心肺能力明显好了很多。去年刚跑步时,每跑 5-10 分钟就需要步行几分钟缓缓,不然心率很容易超过 140 (我给自己定的心率上限)。现在差不多可以保持心率在 140 以下连续跑完 4 km 了。大约花费 30 分钟。这差不多是 8km/h 的平均速度,前半程会更快一点,后半程为了保持心率需要降一点速度。

我觉得另一方面的原因是在冬季,跑起来不那么热。现在会挑选中午有太阳的时候跑,晒晒太阳更舒适。跑完后也没特别累的感觉,只是在最后 10 分钟有一点点难受,希望快点结束。但每次还是坚持跑满 30 分钟。


我家附近 500 米处开了家抱石馆。在两个月前,我带可可去了一次,她莫名其妙的喜欢上了抱石。去了两次后就让我给她办张月卡。我说,次卡每次 95 ,月卡 750 ,一个月要去 8 次以上才划算。她说没问题,几乎天天晚上让我带她去。虽然有一半的动力是去岩馆撸那只胖猫,但看得出来是真的喜欢。我之前也带云豆出去攀岩,从小到大爬过上百次,谈不上讨厌,但始终爱不起来,我也没逼他。后来可可长大了一点,去过两次明显没有兴趣,我干脆就不带她去了。这次莫名其妙的爱上抱石,我是没想到的。

一开始,她只能爬 v0/v1 的线路。但进步非常快,她有从小练起的舞蹈基本功打底,身体的柔韧性特别好,尤其在爬平衡线上特别有优势。在岩馆中超过很多大人(新手)也颇为得意。在第二张月卡时,几乎可以完成所有的 V2 线路,并勉强可以挑战 V3 了。毕竟身高臂展上有劣势,一些成人可以顺利完成的 V2 线路,她需要多做几个动作,无形中提高了难度。

我跟着她也办了月卡,但不会每天爬,有时就是看着教一下,但也比过去勤快了许多。水平也跟着上升。现在可以爬一些 V4-V5 的线路了,而上次在这个水平还是小孩没出生前,体重在 75kg 以下的时候。

现在体重保持在 83kg-84kg 之间,已经很久没有降低了。比半年前再减了大约 2 kg ,比开始跑步前最重 93kg 时几乎减了 10 kg 。考虑到力量(肌肉?)也有所增长,还算满意。身边很多人都说我前两年日益见长的肚子又消失了。希望未来一年可以把体重降到 80kg 以下。

体能的上升对爬高墙的帮助特别明显。去年时,我去岩馆爬高墙,差不多 3-4 条线后就需要躺下休息。现在可以爬满两个小时。最近开始恢复爬先锋(比顶绳更消耗体力,我已经有 10 年没爬过了),发现自己又可以比较轻松的完成 5.10c/d 左右的先锋线路了。去年野外去了多次英西,一次阳朔,一次六盘水。野外先锋还没怎么爬,明年应该可以逐步恢复。

另,痛风未再来过。但尿酸水平并未降低,也没有更高。

还有一个身体的小问题值得注意:有次在去阳朔的车上和同车的岩友聊天。我说我的指关节常年疼痛,是不是大部分攀岩者都是这样。他们的水平都比我高一大截,说并不是这样,这种现象只在部分超高水平的岩友中听过,并建议我保护好指关节,减少抱石中那些指力线路。

我回头和 gemini 讨论了一下,建议是差不多的。另外可以做一些反向的力量训练,我买了一根套在指头上外撑的橡皮筋每日练习。也正是这个原因,我现在没有跟着可可一起每天抱石,并在刻意减少了需要做 Crimps 的线路。目前恢复的还不错,至少日常不爬的时候关节不疼了。

可可还拉了一个同班的小女孩一起抱石,我意外的发现她爸爸的爱好是跑马拉松。我请教了他许多长跑的问题,他说下次带我跑一次 8km 再加到 10km 。据说他从高中开始长跑,一直停留在每次 5km 的量,直到有人带着跑才越过这个坎。虽然他真的很爱长跑,但说每次跑马拉松,跑到最后也是非常难受的,全靠意志力坚持下来。


虽然云豆对和我攀岩兴趣不大,却意外的愿意和我一起跑步。部分原因是他意识到自己体重有点超标了。目前是六年级的寒假,身高 1.74m ,体重最重时有 77kg 。我说你还是跟我跑步吧,我能减下来,你也可以。

寒假第一次跟我跑了 4 km 累得不行,后来我便随着他减到 3km 一次。毕竟是小孩,慢慢的就适应了。和他一起跑步,也帮我把速度提了起来。他嫌我跑得太慢(一开始我跑 4km 需要 35 分钟),父子俩跑了几次后便在半小时之内了。这跑步的兴趣也来得莫名其妙,最近一周就跑了 5 次。(体重还真减了一些,75kg)

今天跑完我告诫他,切忌一时热情,锻炼身体是个长期的过程,贵在坚持。每次跑到最后,总会有点难受的,需要一些意志力说服自己坚持下来。有个伴当然最好,可以相互督促。养成习惯后,日后住校,也能有自驱力。

ps. 教育子女真的是个长期的活。我琢磨着儿子愿意跟我跑步还有一部分原因是最近两个月每晚带着妹妹攀岩有点懈怠了他,或许是有点吃醋:过去我总是陪他比妹妹多一点的。而妹妹似乎不愿意跑步…… 结果,我也被动的增加了颇多的运动量,何尝不是件好事。

iOS开发有什么好用的图片浏览器?

年更博主终于推出新版本,JXPhotoBrowser v4.0 全面重构焕新!

JXPhotoBrowser 是一个轻量级、可定制的 iOS 图片/视频浏览器,实现 iOS 系统相册的交互体验。支持缩放、拖拽关闭、自定义转场动画等特性,架构清晰,易于集成和扩展。同时支持 UIKitSwiftUI 两种调用方式(SwiftUI 通过桥接层集成,详见 Demo-SwiftUI 示例工程)。

首页列表 图片浏览 下拉关闭
homepage.png browsing.png pull_down.png

核心设计

  • 零数据模型依赖:框架不定义任何数据模型,业务方完全使用自己的数据结构,通过 delegate 配置 Cell 内容。
  • 图片加载完全开放:框架不内置图片加载逻辑,业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。
  • 极简 Cell 协议JXPhotoBrowserCellProtocol 仅包含 browsertransitionImageView 两个属性,将浏览器与具体 Cell 实现解耦,既可以直接使用内置的 JXZoomImageCell,也可以实现完全自定义的 Cell。
  • 协议驱动的数据与 UI 解耦JXPhotoBrowserDelegate 只关心数量、Cell 与转场,不强制统一的数据模型。

功能特性

  • 多模式浏览:支持水平(Horizontal)和垂直(Vertical)两个方向的滚动浏览。
  • 无限循环:支持无限循环滚动(Looping),无缝切换首尾图片。
  • 手势交互
    • 双击缩放:仿系统相册支持双击切换缩放模式。
    • 捏合缩放:支持双指捏合随意缩放(1.0x - 3.0x)。
    • 拖拽关闭:支持下滑手势(Pan)交互式关闭,伴随图片缩小和背景渐变效果。
  • 转场动画
    • Fade:经典的渐隐渐现效果。
    • Zoom:类似微信/系统相册的缩放转场效果,无缝衔接列表与大图。
    • None:无动画直接显示。
  • 浏览体验优化:基于 UICollectionView 复用机制,内存占用低,滑动流畅。
  • 自定义 Cell 支持:内置图片 JXZoomImageCell,也支持通过协议与注册机制接入完全自定义的 Cell(如视频播放 Cell)。
  • Overlay 组件机制:支持按需装载附加 UI 组件(如页码指示器、关闭按钮等),默认不装载任何组件,零开销。内置 JXPageIndicatorOverlay 页码指示器。

核心架构

  • JXPhotoBrowserViewController:核心控制器,继承自 UIViewController。内部维护一个 UICollectionView 用于展示图片页面,负责处理全局配置(如滚动方向、循环模式)和手势交互(如下滑关闭)。
  • JXZoomImageCell:可缩放图片展示单元,继承自 UICollectionViewCell 并实现 JXPhotoBrowserCellProtocol。内部使用 UIScrollView 实现缩放,负责单击、双击等交互。通过 imageView 属性供业务方设置图片。
  • JXImageCell:轻量级图片展示 Cell,不支持缩放手势,适用于 Banner 等嵌入式场景。内置可选的加载指示器(默认不启用),支持样式定制。
  • JXPhotoBrowserCellProtocol:极简 Cell 协议,仅需 browser(弱引用浏览器)和 transitionImageView(转场视图)两个属性即可接入浏览器,另提供 photoBrowserDismissInteractionDidChange 可选方法响应下拉关闭交互,不强制依赖特定基类。
  • JXPhotoBrowserDelegate:代理协议,负责提供总数、Cell 实例、生命周期回调(willDisplay/didEndDisplaying)以及转场动画所需的缩略图视图等,不强制要求统一的数据模型。
  • JXPhotoBrowserOverlay:附加视图组件协议,定义了 setupreloadDatadidChangedPageIndex 三个方法,用于页码指示器、关闭按钮等附加 UI 的统一接入。
  • JXPageIndicatorOverlay:内置页码指示器组件,基于 UIPageControl,支持自定义位置和样式,通过 addOverlay 按需装载。

依赖

  • 框架本身依赖:UIKit(核心),无任何第三方依赖
  • 图片加载:框架不内置图片加载逻辑,业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。
  • 示例工程:
    • Demo-UIKit:UIKit 示例,使用 CocoaPods 集成,依赖 Kingfisher 加载图片,演示完整功能(图片浏览、视频播放、Banner 轮播等)。
    • Demo-SwiftUI:SwiftUI 示例,使用 SPM 集成,演示如何通过桥接层在 SwiftUI 中使用 JXPhotoBrowser(媒体网格、设置面板、图片浏览)。
    • Demo-Carthage:UIKit 示例,使用 Carthage 集成。首次使用需在 Demo-Carthage 目录下执行 carthage update --use-xcframeworks --platform iOS 构建框架。

隐私清单(Privacy Manifest)

本框架已包含 PrivacyInfo.xcprivacy 隐私清单文件,符合 Apple 自 2024 年春季起对第三方 SDK 的隐私清单要求。

JXPhotoBrowser 不追踪用户、不收集任何数据、不使用任何 Required Reason API,隐私清单中所有字段均为空声明。通过 CocoaPods、SPM 或 Carthage 集成时,隐私清单会自动包含在框架中,无需额外配置。

系统要求

  • iOS 12.0+
  • Swift 5.4+

安装

CocoaPods

在你的 Podfile 中添加:

pod 'JXPhotoBrowser', '~> 4.0.1'

注意:Xcode 15 起默认开启了 User Script SandboxingENABLE_USER_SCRIPT_SANDBOXING=YES),该沙盒机制会阻止 CocoaPods 的 Run Script 阶段(如 [CP] Copy Pods Resources[CP] Embed Pods Frameworks 等)访问沙盒外的文件,导致编译失败。需要在编译 Target 的 Build Settings 中将 ENABLE_USER_SCRIPT_SANDBOXING 设置为 NO

Target → Build Settings → Build Options → User Script Sandboxing → No

Swift Package Manager

在 Xcode 中:

  1. 选择 File > Add Package Dependencies...
  2. 输入仓库地址:https://github.com/JiongXing/PhotoBrowser
  3. 选择版本规则后点击 Add Package

或在 Package.swift 中添加依赖:

dependencies: [
    .package(url: "https://github.com/JiongXing/PhotoBrowser", from: "4.0.1")
]

Carthage

在你的 Cartfile 中添加:

github "JiongXing/PhotoBrowser"

然后运行:

carthage update --use-xcframeworks --platform iOS

构建完成后,将 Carthage/Build/JXPhotoBrowser.xcframework 拖入 Xcode 工程的 Frameworks, Libraries, and Embedded Content 中,并设置为 Embed & Sign

手动安装

Sources 目录下的所有文件拖入你的工程中。

快速开始

基础用法

import JXPhotoBrowser

// 1. 创建浏览器实例
let browser = JXPhotoBrowserViewController()
browser.delegate = self
browser.initialIndex = indexPath.item // 设置初始索引

// 2. 配置选项(可选)
browser.scrollDirection = .horizontal // 滚动方向
browser.transitionType = .zoom        // 转场动画类型
browser.isLoopingEnabled = true       // 是否开启无限循环

// 3. 展示
browser.present(from: self)

实现 Delegate

遵守 JXPhotoBrowserDelegate 协议,提供数据和转场支持:

import Kingfisher // 示例使用 Kingfisher,可替换为任意图片加载库

extension ViewController: JXPhotoBrowserDelegate {
    // 1. 返回图片总数
    func numberOfItems(in browser: JXPhotoBrowserViewController) -> Int {
        return items.count
    }
    
    // 2. 提供用于展示的 Cell
    func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell {
        let cell = browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell
        return cell
    }
    
    // 3. 当 Cell 将要显示时加载图片
    func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {
        guard let photoCell = cell as? JXZoomImageCell else { return }
        let item = items[index]
        
        // 使用 Kingfisher 加载图片(可替换为 SDWebImage 或其他库)
        let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: item.thumbnailURL.absoluteString)
        photoCell.imageView.kf.setImage(with: item.originalURL, placeholder: placeholder) { [weak photoCell] _ in
            photoCell?.setNeedsLayout()
        }
    }
    
    // 4. (可选) Cell 结束显示时清理资源(如取消加载、停止播放等)
    func photoBrowser(_ browser: JXPhotoBrowserViewController, didEndDisplaying cell: JXPhotoBrowserAnyCell, at index: Int) {
        // 可用于取消图片加载、停止视频播放等
    }
    
    // 5. (可选) 支持 Zoom 转场:提供列表中的缩略图视图
    func photoBrowser(_ browser: JXPhotoBrowserViewController, thumbnailViewAt index: Int) -> UIView? {
        let indexPath = IndexPath(item: index, section: 0)
        guard let cell = collectionView.cellForItem(at: indexPath) as? MyCell else { return nil }
        return cell.imageView
    }
    
    // 6. (可选) 控制缩略图显隐,避免 Zoom 转场时视觉重叠
    func photoBrowser(_ browser: JXPhotoBrowserViewController, setThumbnailHidden hidden: Bool, at index: Int) {
        let indexPath = IndexPath(item: index, section: 0)
        if let cell = collectionView.cellForItem(at: indexPath) as? MyCell {
            cell.imageView.isHidden = hidden
        }
    }
    
    // 7. (可选) 自定义 Cell 尺寸,默认使用浏览器全屏尺寸
    func photoBrowser(_ browser: JXPhotoBrowserViewController, sizeForItemAt index: Int) -> CGSize? {
        return nil // 返回 nil 使用默认尺寸
    }
}

在 SwiftUI 中使用

JXPhotoBrowser 是基于 UIKit 的框架,在 SwiftUI 项目中可通过桥接方式集成。Demo-SwiftUI 示例工程演示了完整的集成方案。

核心思路

  1. 网格和设置面板使用纯 SwiftUI 实现(LazyVGridPickerAsyncImage 等)
  2. 全屏图片浏览器通过桥接层调用 JXPhotoBrowserViewController
  3. 创建一个 Presenter 类实现 JXPhotoBrowserDelegate,获取当前 UIViewController 后调用 browser.present(from:)

桥接层示例

import JXPhotoBrowser

/// 封装 JXPhotoBrowserViewController 的创建、配置和呈现
final class PhotoBrowserPresenter: JXPhotoBrowserDelegate {
    private let items: [MyMediaItem]

    func present(initialIndex: Int) {
        guard let viewController = topViewController() else { return }

        let browser = JXPhotoBrowserViewController()
        browser.delegate = self
        browser.initialIndex = initialIndex
        browser.transitionType = .fade
        browser.addOverlay(JXPageIndicatorOverlay())
        browser.present(from: viewController)
    }

    func numberOfItems(in browser: JXPhotoBrowserViewController) -> Int {
        items.count
    }

    func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell {
        browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell
    }

    func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {
        guard let photoCell = cell as? JXZoomImageCell else { return }
        // 加载图片到 photoCell.imageView ...
    }
}

在 SwiftUI View 中调用

struct ContentView: View {
    // 持有 presenter(JXPhotoBrowserViewController.delegate 为 weak,需要外部强引用)
    @State private var presenter: PhotoBrowserPresenter?

    var body: some View {
        LazyVGrid(columns: columns) {
            ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
                AsyncImage(url: item.thumbnailURL)
                    .onTapGesture {
                        let p = PhotoBrowserPresenter(items: items)
                        presenter = p
                        p.present(initialIndex: index)
                    }
            }
        }
    }
}

注意JXPhotoBrowserViewControllerdelegateweak 引用,必须在 SwiftUI 侧用 @State 持有 Presenter 实例,否则它会在创建后立即被释放。

关于 Zoom 转场

Demo-SwiftUI 示例工程未演示 Zoom 转场动画,默认使用 Fade 转场。

原因:Zoom 转场依赖 thumbnailViewAt delegate 方法返回列表中缩略图的 UIView 引用,框架通过该引用计算动画起止位置并构建临时动画视图。而 SwiftUI 的 AsyncImage 等原生视图无法直接提供底层 UIView 引用。

如需自行实现:可将缩略图从 AsyncImage 替换为 UIViewRepresentable 包裹的 UIImageView,从而获取真实的 UIView 引用,再通过 thumbnailViewAtsetThumbnailHidden 两个 delegate 方法提供给框架即可。具体的 Zoom 转场接入方式可参考 Demo-UIKit 示例工程。

JXImageCell 加载指示器

JXImageCell 内置了一个 UIActivityIndicatorView 加载指示器,默认不启用。适用于 Banner 等嵌入式场景下展示图片加载状态。

启用加载指示器

let cell = browser.dequeueReusableCell(withReuseIdentifier: JXImageCell.reuseIdentifier, for: indexPath) as! JXImageCell

// 启用加载指示器
cell.isLoadingIndicatorEnabled = true
cell.startLoading()

// 图片加载完成后停止
cell.imageView.kf.setImage(with: imageURL) { [weak cell] _ in
    cell?.stopLoading()
}

自定义样式

通过 loadingIndicator 属性可直接定制指示器的外观:

cell.loadingIndicator.style = .large       // 指示器尺寸
cell.loadingIndicator.color = .systemBlue  // 指示器颜色

自定义 Cell

框架支持两种方式创建自定义 Cell:

方式一:继承 JXZoomImageCell(推荐)

继承 JXZoomImageCell 可自动获得缩放、转场、手势等功能。以 Demo 中的 VideoPlayerCell 为例,它继承 JXZoomImageCell 并添加了视频播放能力:

class VideoPlayerCell: JXZoomImageCell {
    static let videoReuseIdentifier = "VideoPlayerCell"
    
    private var player: AVPlayer?
    private var playerLayer: AVPlayerLayer?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // 自定义初始化:添加 loading 指示器等
    }
    
    /// 配置视频资源
    func configure(videoURL: URL, coverImage: UIImage? = nil) {
        imageView.image = coverImage
        // 创建播放器并开始播放...
    }
    
    /// 重写单击手势:暂停视频或关闭浏览器
    override func handleSingleTap(_ gesture: UITapGestureRecognizer) {
        if isPlaying {
            pauseVideo()
        } else {
            browser?.dismissSelf()
        }
    }
}

方式二:实现协议(完全自定义)

直接实现 JXPhotoBrowserCellProtocol 协议,获得完全的自由度:

class StandaloneCell: UICollectionViewCell, JXPhotoBrowserCellProtocol {
    static let reuseIdentifier = "StandaloneCell"
    
    // 必须实现:弱引用浏览器(避免循环引用)
    weak var browser: JXPhotoBrowserViewController?
    
    // 可选实现:用于 Zoom 转场动画,返回 nil 则使用 Fade 动画
    var transitionImageView: UIImageView? { imageView }
    
    let imageView = UIImageView()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // 自定义初始化
    }
    
    // 可选实现:下拉关闭交互状态变化时调用
    // isInteracting 为 true 表示用户正在下拉(图片缩小跟随手指),false 表示交互结束(回弹恢复)
    // 适用于在拖拽关闭过程中暂停视频、隐藏附加 UI 等场景
    func photoBrowserDismissInteractionDidChange(isInteracting: Bool) {
        // 例如:下拉时暂停视频播放
    }
}

注册和使用自定义 Cell

let browser = JXPhotoBrowserViewController()

// 注册自定义 Cell(必须在设置 delegate 之前)
browser.register(VideoPlayerCell.self, forReuseIdentifier: VideoPlayerCell.videoReuseIdentifier)

browser.delegate = self
browser.present(from: self)

// 在 delegate 中使用
func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell {
    let cell = browser.dequeueReusableCell(withReuseIdentifier: VideoPlayerCell.videoReuseIdentifier, for: indexPath) as! VideoPlayerCell
    cell.configure(videoURL: url, coverImage: thumbnail)
    return cell
}

Overlay 组件

框架提供了通用的 Overlay 组件机制,用于在浏览器上层叠加附加 UI(如页码指示器、关闭按钮、标题栏等)。默认不装载任何 Overlay,业务方按需装载

使用内置页码指示器

框架内置了 JXPageIndicatorOverlay(基于 UIPageControl),一行代码即可装载:

let browser = JXPhotoBrowserViewController()
browser.addOverlay(JXPageIndicatorOverlay())

支持自定义位置和样式:

let indicator = JXPageIndicatorOverlay()
indicator.position = .bottom(padding: 20)  // 位置:底部距离 20pt(也支持 .top)
indicator.hidesForSinglePage = true         // 仅一页时自动隐藏
indicator.pageControl.currentPageIndicatorTintColor = .white
indicator.pageControl.pageIndicatorTintColor = .lightGray
browser.addOverlay(indicator)

自定义 Overlay

实现 JXPhotoBrowserOverlay 协议即可创建自定义组件:

class CloseButtonOverlay: UIView, JXPhotoBrowserOverlay {
    
    func setup(with browser: JXPhotoBrowserViewController) {
        // 在此完成布局(如添加约束)
    }
    
    func reloadData(numberOfItems: Int, pageIndex: Int) {
        // 数据或布局变化时更新
    }
    
    func didChangedPageIndex(_ index: Int) {
        // 页码变化时更新
    }
}

// 装载
browser.addOverlay(CloseButtonOverlay())

多个 Overlay 可同时装载,互不干扰:

browser.addOverlay(JXPageIndicatorOverlay())
browser.addOverlay(CloseButtonOverlay())

保存图片/视频到相册

框架本身不内置保存功能,业务方可自行实现。Demo 中演示了通过长按手势弹出 ActionSheet 保存媒体到系统相册的完整流程。

前提:需要在 Info.plist 中配置 NSPhotoLibraryAddUsageDescription(写入相册权限描述)。

核心步骤

  1. 添加长按手势:在自定义 Cell 中添加 UILongPressGestureRecognizer
  2. 弹出 ActionSheet:通过 browser 属性获取浏览器控制器来 present。
  3. 请求权限并保存:使用 PHPhotoLibrary 请求权限,下载后写入相册。

示例:在自定义 Cell 中长按保存

以 Demo 中的 VideoPlayerCell 为例,继承 JXZoomImageCell 后添加长按保存能力:

import Photos

class VideoPlayerCell: JXZoomImageCell {
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // 添加长按手势
        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
        scrollView.addGestureRecognizer(longPress)
    }
    
    @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
        guard gesture.state == .began else { return }
        
        let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
        alert.addAction(UIAlertAction(title: "保存视频", style: .default) { [weak self] _ in
            self?.saveVideoToAlbum()
        })
        alert.addAction(UIAlertAction(title: "取消", style: .cancel))
        
        // 通过 browser 属性获取浏览器控制器来 present
        browser?.present(alert, animated: true)
    }
    
    private func saveVideoToAlbum() {
        guard let url = videoURL else { return }
        
        // 1. 请求相册写入权限
        PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
            guard status == .authorized || status == .limited else { return }
            
            // 2. 下载视频(远程 URL 需先下载到本地)
            URLSession.shared.downloadTask(with: url) { tempURL, _, _ in
                guard let tempURL else { return }
                
                // 3. 写入相册
                PHPhotoLibrary.shared().performChanges({
                    PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: tempURL)
                }) { success, error in
                    // 处理结果...
                }
            }.resume()
        }
    }
}

保存图片的流程类似,将下载部分替换为图片写入即可:

// 下载图片数据
URLSession.shared.dataTask(with: imageURL) { data, _, _ in
    guard let data, let image = UIImage(data: data) else { return }
    
    PHPhotoLibrary.shared().performChanges({
        PHAssetChangeRequest.creationRequestForAsset(from: image)
    }) { success, error in
        // 处理结果...
    }
}.resume()

常见问题 (FAQ)

Q: Zoom 转场动画时图片尺寸不对或有闪烁现象?

A: 这通常是因为打开浏览器时,目标 Cell 的 imageView 还没有设置图片,导致其 bounds 为 zero。

解决方案:在 willDisplay 代理方法中,确保同步设置占位图。例如使用 Kingfisher 时:

func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {
    guard let photoCell = cell as? JXZoomImageCell else { return }
    
    // 同步从缓存取出缩略图作为占位图
    let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: thumbnailURL.absoluteString)
    photoCell.imageView.kf.setImage(with: imageURL, placeholder: placeholder) { [weak photoCell] _ in
        photoCell?.setNeedsLayout()
    }
}

这样可以确保转场动画开始时,Cell 已经有正确尺寸的图片,动画效果更加流畅。

项目开源地址

github.com/JiongXing/P…

读《控糖革命》

你是否经常在午饭后感到困倦、脑子转不动?是否明明吃了很多甜食,却依然觉得“细胞在挨饿”?

我就有这样的困扰。而且我爸爸,奶奶都有糖尿病、高血压,加上我有高尿酸,所以我一直有在关注血糖相关的知识。

最近读完了一本深度改变我饮食观的书——《控糖革命》。作者杰西·安佐佩斯(Jessie Inchauspé)通过科学的角度揭示了一个核心真相:比起计算卡路里,控制“血糖峰值”才是维持健康、保持身材和延缓衰老的关键。

以下是我整理的本书精华,带你重新认识身体里的“糖”。

一、 溯源:植物是如何“造糖”的?

在进入控糖技巧前,我们先看大自然的魔法。植物通过光合作用产生葡萄糖,并根据需要将其转化为三种形态:

  1. 淀粉:葡萄糖的储存形态。
  2. 纤维:虽然人类无法消化,但它是肠道的守护者,能极大缓冲糖分的吸收。
  3. 果糖:比葡萄糖甜2.3倍,是植物吸引动物吃下果实,从而散播种子的诱饵。

正是这些形态的不同,决定了食物进入人体后不同的“命运”。

二、 血糖峰值:身体隐形的“杀手”

人体摄入糖分后,血糖会升高再降下,形成一个“波峰”。这个峰值越高,对身体的伤害就越大。

当血糖剧烈波动时,身体会陷入以下困境:

  • 氧化应激:产生大量自由基,攻击细胞,诱发心脏病、二型糖尿病及认知下降。
  • 糖化反应:糖分与蛋白质结合产生AGEs(糖化终产物),这是皮肤松弛、长皱纹、暗沉发黄的元凶。果糖的糖化速度是葡萄糖的 10 倍。
  • 线粒体“罢工”:细胞忙于处理过载的葡萄糖,无法有效转化能量,导致你出现“晕碳”和疲劳感。

三、脂肪的秘密:为什么果糖更容易胖?

人体处理葡萄糖的过程如下:

  • 肝脏转化:葡萄糖在经过肝脏时会转化为糖原,肝脏以此形态储存一部分葡萄糖
  • 肌肉储存:我们的肌肉也可以储存糖原形态的葡萄糖
  • 转化为脂肪:如果在肝脏和肌肉储存完糖原后,体内还有更多的葡萄糖,就需要把它转化成脂肪,储存在肝脏或肌肉中

但果糖更加霸道:它无法转化为糖原储存,唯一的去处就是直接转化成脂肪。这就是为什么甜食(含果糖)比单纯的面食(只含葡萄糖)更容易让人发胖的原因。

此外,高频率的血糖峰值会导致胰岛素抵抗。只有在胰岛素水平较低时,身体才能有效燃烧脂肪。

四、 9个实操技巧,平滑你的血糖曲线

控制血糖不代表要戒绝一切,而是要讲究“策略”,书中介绍了许多控糖技巧,我整理如下:

  1. 调整饮食顺序(核心技巧):按照 纤维(蔬菜)→ 蛋白质/脂肪 → 淀粉/糖的顺序进食。纤维像在小肠铺了一层滤网,能有效减缓糖分的吸收。
  2. 餐前先吃点蔬菜:作为开胃菜,提前建立纤维屏障。
  3. 停止死磕卡路里:100 卡路里的果糖和 100 卡路里的蛋白质对身体的代谢影响完全不同。
  4. 打造“控糖早餐”:早餐要有蛋白质和纤维,拒绝高碳水和果汁(打碎的水果失去了纤维阻挡)。
  5. 警惕代糖:阿斯巴甜、麦芽糖醇等会误导胰岛素分泌;如果非要用代糖,建议选择赤藓糖醇、罗汉果甜苷或甜叶菊。
  6. 餐后吃甜点,而非单独吃:有正餐垫底,糖分吸收会更慢。
  7. 餐前喝点醋:醋酸能暂时抑制淀粉酶活性,减缓转化速度。推荐用油醋汁代替酸奶酱。
  8. 餐后动一动:哪怕只是散步,也能帮助肌肉消耗掉多余的葡萄糖。
  9. 给甜食找个“伴”:吃甜食时,搭配点坚果(蛋白质)或蔬菜(纤维),能平滑血糖曲线。

五、结语

《控糖革命》带给我们的最大启发是:健康的身体,不在于极端的节食,而在于对代谢规律的尊重。

当你学会通过调整进食顺序、利用纤维和醋等简单工具来抚平血糖波动,你会发现:精力变好了,皮肤亮了,甚至连身材也自然而然地轻盈了。

从下一餐开始,先吃那盘蔬菜吧!

三方支付真的香吗?日本iOS、Google三方支付调研报告

你以为的“三方支付”的样子,和苹果谷歌落地“三方支付”的样子,堪比网友见面、梦境与现实。

  • 抽成方面:即使你使用三方支付,苹果谷歌依然要抽成,只是换了个名字叫“商店服务费”,抽成并没有便宜多少。
  • 财务对账:苹果谷歌为了审计你的三方支付的抽成,你需要每月和苹果谷歌对账、打款。
  • 必须接入官方内购系统:即使使用三方支付,依然必须接入官方内购系统作为可选项,与三方支付并列显示。
  • 劝退弹窗:用户使用三方支付时,会被苹果谷歌弹窗警告,“你即将离开安全环境”、“苹果将不再负责该交易的安全、退款及支持”等。

下面是详细介绍。

一、抽成费率

苹果和谷歌又将“三方支付”分为“应用内三方支付”、“网页外链支付”。顾名思义,“应用内三方支付”就是在应用内使用三方支付(例如接入PayPal SDK),“网页外链支付”就是跳出应用,打开外链支付。二者抽成比例是不一样的。

苹果

在日本,苹果将原来的“佣金”拆分成了 “商店服务费” 和 固定5%的“支付处理费”。如果使用三方支付,则不用出 5%“支付处理费”,但“商店服务费”还是得出。苹果:我聪明吧。

方案 苹果收取的商店服务费 苹果收的支付服务费 苹果抽成合计
官方内购 21% (小型开发者或订阅 10%) 5% 15% ~ 26%
App内三方支付 21% (小型开发者或订阅 10%) 0 10% ~ 21%
网页外链支付 15% (小型开发者或订阅 10%) 0 10% ~ 15%

信源:Changes to iOS in Japan

谷歌

场景 Google 收取的费率 谷歌抽成合计
官方内购 30% (小型开发者或订阅 15%) 15% ~ 30%
App内三方支付 26% (小型开发者或订阅 11%) 11% ~ 26%
网页外链支付 20% (小型开发者或订阅 10%) 10% ~ 20%

App内三方支付,4%优惠,信源:自选结算系统优惠4%Understanding user choice billing on Google Play(文档里列出了JP)

网页外链支付,10%优惠,信源:Enrolling in the external offers program

费率小结:
三方支付,支付通道(PayPal、Stripe等)收取的通道费一般在3%左右,所以三方支付的综合成本,应该在上面再加上3%。加完后,应用内三方支付和官方内购差别极小,毫无优势。只有“网页外链支付”在抽成方面占优势,但“网页外链支付”体验很差,用户可能更倾向选官方内购,导致“网页外链支付”实际使用率低,达不到降低抽成的效果。

二、申请开通

使用三方支付(App内三方支付、网页外链支付)均需向苹果和谷歌提交申请,并签署新的协议条款。

向苹果提交申请

1、签署最新商业条款

账号持有者(Account Holder)登录 Apple Developer 官网。 在协议(Agreements)页面,找到并签署针对日本地区的最新补充协议(如 Alternative Terms Addendum for Apps in Japan)。这代表你接受苹果的新版佣金结构及月度申报制度。

2、提交在线申请表单

(1)申请入口: 访问苹果官方的权限申请表单(需登录)

(2)选择授权类型:

  • 外链支付:勾选 StoreKit External Purchase Link Entitlement (Japan)。
  • 第三方支付:勾选 Alternative Payment Processor Entitlement (Japan)。

(3)填写 App 详细信息:

  • Bundle ID:必须是已经上架或准备在日本商店分发的 App ID。
  • 支付网站域名:如果是申请外链支付,必须提供你计划链接到的顶级域名(URL 必须是 HTTPS 且归属于开发者)。
  • PSP 信息:如果是第三方内购,需要填写你合作的支付服务商名称(如 Stripe, PayPay 等)。

向谷歌提交申请

申请“第三方应用内支付”(Google Play External Payments Declaration Form)

1、主体要求: 必须是以“企业/组织”名义注册的账号(个人开发者目前很难申请通过)
2、目标市场: App 必须在日本市场分发,且该功能仅对日本用户生效。
3、技术准备: App 必须集成 Play Billing Library 8.2 或更高版本。即使申请通过,不调用新版本API没办法实现。
4、谷歌官方的帮助文档页面,找到“declaration form”入口进行意向申请 (提交后,谷歌会审核开发者身份,然后后台开放配置入口)
5、如果意向申请通过,Google Play Console -> 在左侧菜单中找到 设置 (Settings) -> 外部支付计划,这个页面可以提交计划使用的外部支付网址URL,然后供谷歌审核
6、提供详细信息:

开发者账号ID:Google Play Console后台可查看的开发者账号ID
企业官方名称:必须与你申请开发者账号时提交的企业名称一致
企业注册地址:请使用注册公司所在国家/地区的官方语言输入地址
应用包名:填写要申请应用的包名,可以一次申请多个应用,但每个应用都需要符合日本分发要求。
账单寄送地址:谷歌在电子发票上显示的地址。用于财务对账和开票。
账单接收邮箱地址:谷歌会根据上报的金额按月向这个邮箱发送服务费账单
联系人邮箱:政策审核、技术问题、合规通知的接收邮箱
用户申诉的地址:一般可以是客服链接或者处理交易纠纷的邮箱地址

三、每月对账:上报三方支付流水

因为苹果和谷歌需要对三方支付抽成,所以需要按照平台要求,每月和苹果谷歌对账,提交三方支付流水。如果被发现瞒报、漏报,苹果和谷歌会采取极严厉的惩罚,包括:追缴欠款及利息;终止该权益的使用权限; 封禁开发者账号。

向苹果提交交易报告

采用 API 实时上报 + 每月财务对账” 的模式。 注意,即使做了API实时上报,也必须做每月App Store Connect的对账进行二次确认。

1、技术侧实时上报

整体流程:客户端生成 Token (StoreKit 侧) => 业务服务端 => 苹果服务器

(1)App 调用 ExternalPurchase.present() (针对外链) 或 ExternalPurchase.purchase() (针对三方支付)

(2)如果用户在系统弹窗中点击了“继续”,StoreKit 会生成一个加密的 ExternalPurchaseToken(字符串格式)。这个 Token 包含了当前用户、当前 App 以及这次点击行为的唯一标识,它是苹果后续对账的唯一凭证,每月财务对账的csv文件里也需要包含该字段。

(3)客户端将token发送给业务服务端。业务服务器需要将这个 Token 与该用户的订单/会话进行关联。如果是外链支付,可能需要暂存这个 Token,等待用户在网页端完成支付

(4)服务器收到三方支付回调(确认钱已到账)后,必须立即(或在 24 小时内)通过 External Purchase Server API 调用 Send External Purchase Report 接口。

上报内容:你需要把从客户端拿到的 Token,连同实际的交易金额(Amount)、货币类型(Currency)以及交易时间戳一起发给苹果服务器。

退款上报:如果用户后来在三方支付端发起了退款,你的服务器也需要通过 API 向苹果上报这笔退款,否则苹果依然会扣你这笔订单的佣金。

2、每月财务对账与支付

(1)在每个日历月结束后的 15天内,你需要通过 App Store Connect 提交一份详细的交易报告。申报通常是上传一个 .csv 格式的模板文件

申报填写字段: 
App Apple ID,纯数字,您 App 的唯一 ID 
Transaction Date,日期格式,交易发生的具体日期 
PSP Name,手动输入文本,您使用的支付服务商全称 
Purchase Token,字符串,技术端生成的唯一追踪标识符
Sales Amount,数字,用户实际支付的金额(需扣除交易税) 

(2)即使无交易也需汇报:如果该月没有产生任何三方支付流水,您依然需要提交一份“零交易汇报(Zero Transaction Report)”

(3)提交入口:App Store Connect - “Payments and Financial Reports” (付款和财务报告) 模块 - “External Purchases” (外部购买) 选项卡

(4)上传或确认数据:系统通常会根据您通过 API 上报的数据自动预填部分信息。您需要核对并上传最终的 CSV 格式报告,确保其与您的财务记录一致。

(5)苹果会根据你申报的销售额扣除佣金,然后向你发送电子发票。你需要按照发票金额,在规定时间内通过银行转账等方式向苹果支付这笔费用。

(6)注意:为了防止偷税漏税,苹果在协议中保留了强力审计权:苹果有权雇佣第三方审计机构检查你的财务账簿。如果被发现瞒报、漏报,苹果会采取极严厉的惩罚,包括:追缴欠款及利息;终止该权益的使用权限;封禁开发者账号。

向谷歌提交交易报告

采用 "API 实时上报 + 开发者后台汇总确认" 的模式。谷歌不用每月手动上报,采用全自动上报模式,但需要核对漏报进行补报。

1、技术侧实时上报
与苹果相比,谷歌的流程更强调实时性 (24小时内) 和交易类型的精细化。

整体流程:客户端生成 Token (externalTransactionToken) => 业务服务端 => 谷歌服务器

(1)当用户在 App 内选择三方支付或点击外链,并在 Google Play 弹出的系统底页(Disclosure Sheet)点击“确认”后,Billing Library 会向你的 App 返回一个 externalTransactionToken。App 必须将此 Token 连同订单信息传给你的业务后端。它是这笔交易唯一的身份凭证。

(2)每当三方支付成功后,你必须在 24 小时内 通过服务端调用 externaltransactions API内容:上报 Token、交易金额、货币、时间戳、税收地址(日本区对税收合规要求严格)以及交易类型。

你的服务器需要使用一个拥有 Reply to reviews 或 Manage orders 权限的 Google Cloud 服务账号 (Service Account)。

业务服务端调用上报接口
接口地址: POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/externalTransactions?externalTransactionId={ID}
URL参数:externalTransactionId:由你定义的唯一订单号(建议使用你数据库里的自增 ID 或 UUID)。

请求body参数:
{
 "externalTransactionToken": "从客户端传来的Token",
 "transactionTime": "2025-12-30T10:00:00Z",
 "oneTimeTransaction": {
  "fullPrice": {
   "amountMicros": "1000000", // 代表 1.00 货币单位(百万分之一单位)
   "currency": "JPY"
  }
 },
 // 或者如果是订阅
 // "recurringTransaction": { ... },
 "userTaxAddress": {
  "regionCode": "JP" // 对日本区对账极其重要
 }
}

(3)异常处理:如果在 API 上报过程中出现了由于网络等原因导致的漏报,你需要在次月 5 个工作日内 通过 API 补报(补报之前的漏单)

(4)下载报告核对:你可以从 Google Play Console 的 创收 > 备选的结算系统 中导出谷歌生成的报告,将其与你自己的数据库进行核对。如果金额对不上,通常是因为你漏报了某些 API 请求。

  参考资料:
创建/上报交易 (Create Transaction) developers.google.com/android-pub…
注:在该页面左侧菜单可以看到 get 和 refund(退款)接口

备选结算系统(Alternative Billing)集成指南
developer.android.com/google/play…
此页面包含了“报告外部交易”的技术步骤说明。

2、 账单确认与支付
生成账单:谷歌会根据你 API 上报的数据,在次月生成汇总账单。
支付方式:不像苹果通常需要你主动汇款,谷歌通常会从你账户绑定的结算方式(信用卡或付款资料)直接扣除这部分佣金,或者发送正式账单让你在规定时间内支付。

四、严苛的“劝退式”交互

为了保护自己的生态,两大巨头在用户体验上设置了重重障碍:

内购强制接入: 苹果和谷歌均要求,开发者不能仅提供第三方支付,必须同时接入官方内购作为选项,且官方支付按钮的醒目程度必须不低于第三方支付。

劝退的风险弹窗: 用户在点击第三方支付链接时,系统会弹出充满“警告色”的通知(如:“你即将离开安全环境”、“苹果将不再负责该交易的安全、退款及支持”等),这极大地增加了用户的跳失率。

五、总结

1、并没有消失的“平台税”

先看抽成方面。

通过应用内三方支付SDK方式接入,体验较好,但抽成比例和官方支付差别很小,可以省约 1%~2%只有通过外链跳出App支付这种方式接入,省的比较多,可以省7%左右,但这种方式体验较差,再加上平台强制“风险警告弹窗”,即使接入了这种支付方式,最终又有多少比例的用户会选择这种支付方式呢?所以,这个7%可能需要打个大折扣。

总结起来就是,接入三方支付并不一定会“省钱”。

2、从运营成本方面看

一旦采用第三方支付,开发者需要承担原本由平台处理的大量行政工作:

每月结算申报: 开发者必须每月手动向苹果/谷歌申报通过第三方渠道产生的流水,并根据申报单向平台转账支付佣金

自理客服与退款: 所有的退款申请、订阅管理和支付争议,平台一概不管,开发者必须建立自己的客服团队来处理这些琐事。

接受审计风险: 平台保留对开发者财务记录进行审计的权利。如果瞒报、(人员或技术疏忽导致的)漏报三方支付流水,可能面临权利被限制、下架或封号等风险。

3、过审可能变得困难

接入三方支付,会增加包体提审被拒的风险。

作为开发者,提审时需要额外在审核备注里说明接入了三方支付,并提供支付流程截图或视频

作为审核人员,也会额外“关照”接入了三方支付的应用,检查是否符合相关审核要求。

包体层面,接入三方支付,势必会在包体里加入支付判断、支付切换,或者嵌入三方支付SDK 等高风险行为。机器审核有可能误判为切支付、隐藏功能,增加过审难度。

4、可能失去官方推荐资格

虽然苹果和谷歌官方层面没有明确表明,接入三方支付的应用不会被推荐。但从平台的角度出发,加入三方支付,很大程度上会影响被平台推荐的可能性。

苹果
官方口径: 只要符合 Guideline 3.1.1(a) 且已获得 Entitlement 授权,应用在法律上具备推荐资格。

实际:苹果的推荐位是由其编辑团队(App Store Editors)人工筛选的。他们的考核标准中,“用户体验的一致性”权重极高。三方支付必须弹出一个“离开 App”的系统警告框。对于编辑来说,这种“打断感”被视为用户体验的瑕疵。编辑团队通常倾向于推荐那些能给平台带来完整生态价值(包括 IAP 闭环)的应用。

谷歌
官方口径: 参与 User Choice Billing (UCB) 计划的应用,其推荐资格不受限制。

实际: 算法决定了你是否能进入备选池,人工决定了你是否被推荐。谷歌的自动化推荐算法(如“猜你喜欢”)基于转化率和评分。如果三方支付导致支付流程变长、跳出率增加,你的算法推荐位会自然下降。针对三方支付的退款请求,务必在 App 内提供显著的客服入口,避免用户在商店留下“无法退款,骗钱”的差评,这是算法降权的头号原因。

结语

看完上面的内容,你还会觉得“三方支付真香”吗?

而且,后续如果其它国家或地区吵着要开三方支付,大概率也会遵循上面日本的范式。

放弃幻想吧,宝子们!

理想与现实的落差.png

SwiftUI View 继承扩展:别再执着于 UIKit 的“子承父业”啦!

做 iOS 开发的,谁没在 UIKit 里享受过“继承的快乐”?比如写个 BaseViewController,把导航栏样式、加载动画、空白页统一封装好,后面所有页面直接 : BaseViewController,一顿操作猛如虎,不用重复写代码——主打一个“父债子还”(不是),“父功子享”才对!

可等咱们兴冲冲转到 SwiftUI,想依葫芦画瓢写个 BaseView,再让 HomeView: BaseView 时,Xcode 直接给你泼一盆冷水:“兄弟,你怕不是喝多了?View 是协议,不是类,不能继承!”

那一刻,多少开发者的内心是崩溃的:“SwiftUI 你玩我呢?UIKit 能行的事,你凭啥不行?我就想省点劲,有错吗?”

别急别急,今天就用唠嗑的方式,扒一扒 SwiftUI 为啥“反骨”不支持 View 继承,以及它到底藏了啥“骚操作”,能比 UIKit 的继承更省心(偶尔也更闹心)。

先吐槽:UIKit 的继承有多香,SwiftUI 的“拒绝”就有多离谱?

咱们先回味下 UIKit 的“继承爽点”:

  • 「一脉相承」:BaseVC 写好导航栏隐藏、返回按钮自定义,所有子类自动继承,不用重复写一行代码;
  • 「按需修改」:子类想改个导航栏颜色?重写个方法就行,不影响其他子类,主打一个“个性化不破坏全局”;
  • 「新人友好」:新人接手项目,只要懂 BaseVC 的封装,所有页面的基础逻辑一目了然,不用到处找重复代码。

反观 SwiftUI,一上来就断了“继承”这条路——核心原因很简单(虽然听着有点绕):SwiftUI 的 View 是“协议”,不是“类” ,而 Swift 里的协议,本身就不支持“继承”(只能遵循);再加上 SwiftUI 里的 View 载体都是 Struct(值类型),值类型也不能继承(只有类是引用类型,能继承)。

苹果爸爸的心思其实很歪:“我就是要逼你们放弃‘继承依赖’,值类型+协议的组合,线程安全又轻量,不香吗?” 香是香,但刚开始确实浑身不自在,就像习惯了用筷子吃饭,突然让你用叉子,怎么都觉得别扭。

重点来了:SwiftUI 没有继承,怎么实现“复用+扩展”?

别慌,SwiftUI 虽然堵死了“继承”这一条路,但开了 N 条“后门”,每一条都比继承更灵活(就是得适应适应),咱们一条条唠,结合吐槽讲明白。

方案1:协议扩展 —— 给所有 View 发“通用福利”(最省心)

UIKit 里 BaseVC 的“全局统一样式”,在 SwiftUI 里用「协议扩展」就能实现,相当于给所有遵循 View 协议的“打工人”,统一发福利,不用一个个单独给。

举个栗子:咱们想让所有按钮都有统一的圆角、背景色,不用每个按钮都写 .cornerRadius(8).background(Color.blue),直接给 View 写个协议扩展:

// 自定义协议(可选,也可以直接扩展 View)
protocol CommonButtonStyle: View {}

// 给协议写扩展,实现统一样式(相当于 BaseVC 的统一配置)
extension CommonButtonStyle {
    func commonButton() -> some View {
        self
            .cornerRadius(8) // 统一圆角
            .background(Color.blue) // 统一背景色
            .foregroundColor(.white) // 统一文字色
            .padding(.horizontal, 16) // 统一水平内边距
            .padding(.vertical, 8)
    }
}

// 让所有 View 都能“领取”这个福利(遵循协议)
extension View: CommonButtonStyle {}

// 使用时,一句话搞定,比继承还简单!
Button("我是统一样式按钮") {
    print("点击啦")
}
.commonButton() // 直接调用扩展方法

吐槽点:这种方式确实香,但是!只能加“通用样式/通用方法”,不能加“个性化状态”——比如你想让某个子类按钮有个专属的加载动画,光靠协议扩展就不够了,得搭配其他方案。

优点:零耦合、全局可用,改一处,所有用到的地方都同步改,比 UIKit 继承还省心(不用维护 BaseView 子类)。

方案2:组合封装 —— 把“重复 View”做成“乐高零件”(最常用)

UIKit 里,我们继承 BaseVC 是为了复用“导航栏、空白页”这些重复组件;而 SwiftUI 里,更推荐“组合优于继承”——把重复的 View 抽成一个独立的 Struct,用到的时候直接“拼”上去,就像搭乐高,想要哪个零件就放哪个,不用继承整个“底座”。

举个栗子:APP 所有页面都有统一的“标题栏”(左边返回按钮,中间标题),UIKit 里我们会在 BaseVC 里写好标题栏;SwiftUI 里,直接把标题栏做成一个独立 View:

// 封装通用标题栏(相当于 BaseVC 里的标题栏逻辑)
struct CommonNavigationBar: View {
    let title: String // 可配置标题(个性化参数)
    let onBack: () -> Void // 可配置返回事件(个性化回调)
    
    var body: some View {
        HStack {
            // 返回按钮
            Button(action: onBack) {
                Image(systemName: "chevron.left")
                    .foregroundColor(.black)
            }
            Spacer()
            // 标题
            Text(title)
                .font(.title2)
                .fontWeight(.bold)
            Spacer()
            // 占位(和返回按钮对称,美观)
            Color.clear.frame(width: 24)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
    }
}

// 页面使用:直接组合,不用继承,想改就改
struct HomeView: View {
    var body: some View {
        VStack {
            // 组合标题栏,传入个性化参数
            CommonNavigationBar(title: "首页") {
                print("返回上一页")
            }
            Spacer()
            Text("首页内容")
            Spacer()
        }
    }
}

struct MineView: View {
    var body: some View {
        VStack {
            // 同一个标题栏,换个标题和回调,就是自己的样式
            CommonNavigationBar(title: "我的") {
                print("返回首页")
            }
            Spacer()
            Text("我的内容")
            Spacer()
        }
    }
}

吐槽点:这种方式比继承更灵活,但如果重复组件太多(比如标题栏、加载框、空白页、错误页),每个页面都要手动“拼”,确实有点繁琐——不过总比重复写代码强,而且可以自由组合,不想用某个零件就直接删掉,比继承的“捆绑销售”舒服多了。

优点:高度解耦,每个组件都是独立的,修改一个组件不会影响其他组件;可定制性强,传入不同参数就能实现不同效果,比 UIKit 继承的“重写方法”更简单。

方案3:Modifier 修饰器 —— 给 View 贴“个性化标签”(最灵活)

如果说协议扩展是“全局统一福利”,组合封装是“乐高零件”,那 Modifier 就是“个性化贴纸”——可以给任意 View 贴不同的贴纸,实现不同的样式/功能,而且可以叠加使用,比继承的“重写”灵活一百倍。

其实 SwiftUI 自带的 .cornerRadius().background() 都是 Modifier,我们也可以自定义 Modifier,实现自己的“扩展逻辑”,相当于给 View 加“专属技能”。

举个栗子:我们想给某些 View 加一个“加载中遮罩”,UIKit 里可能要在 BaseVC 里写个 showLoading() 方法,子类调用;SwiftUI 里,自定义一个 Modifier 就行:

// 自定义 Modifier:加载中遮罩
struct LoadingModifier: ViewModifier {
    let isLoading: Bool // 控制是否显示(个性化参数)
    
    func body(content: Content) -> some View {
        content
            .overlay {
                if isLoading {
                    // 遮罩+加载动画
                    ZStack {
                        Color.black.opacity(0.3)
                            .ignoresSafeArea()
                        ProgressView("加载中...")
                            .foregroundColor(.white)
                            .padding()
                            .background(Color.black.opacity(0.5))
                            .cornerRadius(8)
                    }
                }
            }
    }
}

// 扩展 View,让所有 View 都能使用这个 Modifier
extension View {
    func loading(isLoading: Bool) -> some View {
        self.modifier(LoadingModifier(isLoading: isLoading))
    }
}

// 使用时,任意 View 都能加加载遮罩,不用继承!
struct DetailView: View {
    @State private var isLoading = true
    
    var body: some View {
        Text("详情页内容")
            .loading(isLoading: isLoading) // 直接贴“加载贴纸”
            .onAppear {
                // 模拟加载完成
                DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                    isLoading = false
                }
            }
    }
}

吐槽点:Modifier 确实灵活,但写多了容易乱,比如一个 View 叠加了五六个 Modifier,可读性就变差了——不过比起 UIKit 里继承层层嵌套、重写方法混乱的问题,这点乱真不算啥。

优点:可叠加、可复用、可定制,任意 View 都能使用,不用受继承关系限制;而且 Modifier 是“无侵入”的,不会改变 View 本身的结构,比继承更安全。

方案4:@ViewBuilder —— 封装“可变内容”的组合(进阶骚操作)

有时候,我们想封装一个“容器 View”,里面的内容是可变的(比如 BaseVC 里的 contentView),这时候就可以用 @ViewBuilder,相当于给“乐高底座”留了个“自定义凹槽”,想放什么内容就放什么内容,比继承更灵活。

举个栗子:封装一个“带标题栏+底部按钮”的容器 View,中间内容由子类(页面)自定义:

// 封装容器 View,用 @ViewBuilder 接收可变内容
struct ContainerView<Content: View>: View {
    let title: String
    let bottomButtonTitle: String
    let onBottomButtonClick: () -> Void
    // 用 @ViewBuilder 接收自定义内容
    @ViewBuilder let content: () -> Content
    
    var body: some View {
        VStack {
            // 通用标题栏
            CommonNavigationBar(title: title) {
                print("返回")
            }
            // 自定义内容(页面自己的内容)
            content()
                .flexibleFrame(maxWidth: .infinity, maxHeight: .infinity)
            // 通用底部按钮
            Button(action: onBottomButtonClick) {
                Text(bottomButtonTitle)
                    .commonButton() // 复用之前的协议扩展
            }
            .padding(.bottom, 16)
        }
    }
}

// 页面使用:传入自定义内容,不用继承
struct EditView: View {
    var body: some View {
        ContainerView(
            title: "编辑页面",
            bottomButtonTitle: "保存",
            onBottomButtonClick: {
                print("保存成功")
            }
        ) {
            // 自定义内容,想放什么就放什么
            VStack(spacing: 20) {
                TextField("请输入内容", text: .constant(""))
                    .padding()
                    .border(Color.gray)
                Text("编辑页面的自定义内容")
            }
            .padding()
        }
    }
}

吐槽点:这个方案稍微有点进阶,刚开始写的时候容易搞混 @ViewBuilder 的用法,比如忘记加 () -> Content,Xcode 报错能让你怀疑人生——但一旦学会,封装复杂容器 View 简直爽到飞起,比 UIKit 里继承 BaseVC 再重写 contentView 简单多了。

最后总结:别再执念于继承了,SwiftUI 的“套路”更香!

其实 SwiftUI 不是“反继承”,而是它的设计思路和 UIKit 完全不同:UIKit 是“面向类的继承”,主打一个“一脉相承”;SwiftUI 是“面向协议的组合”,主打一个“灵活拼接”。

用一句话吐槽总结:

UIKit 里的继承,就像“继承家产”,好处是省心,但容易被“家产”绑定,想改点东西还要顾及祖宗规矩;SwiftUI 里的扩展,就像“搭乐高”,虽然每个零件都要自己拼,但想怎么搭就怎么搭,拆了重拼也不心疼,灵活到飞起!

最后给大家一个小建议:刚从 UIKit 转到 SwiftUI 时,别总想着“怎么继承”,而是多想想“怎么组合、怎么封装”——用协议扩展做全局统一,用组合封装做重复组件,用 Modifier 做个性化扩展,用 @ViewBuilder 做灵活容器,慢慢你就会发现,SwiftUI 的扩展方式,比 UIKit 的继承香多了!

Meta Audience Network + AdMob 双平台集成完整指南

一、架构选择:聚合平台(Mediation)

同时接入多个广告网络,强烈推荐使用广告聚合(Mediation)平台,而不是自己手动管理切换逻辑。

1.1 主流聚合平台对比

聚合平台 说明 推荐度
Google AdMob Mediation AdMob 官方内置聚合,支持 Meta AN 作为第三方适配器 ⭐⭐⭐⭐⭐
AppLovin MAX 独立聚合平台,支持广泛广告网络,实时竞价能力强 ⭐⭐⭐⭐⭐
ironSource LevelPlay 游戏领域主流,已与 Unity Ads 合并 ⭐⭐⭐⭐
Mintegral / TopOn / TradPlus 国内出海常用,支持国内外主流网络 ⭐⭐⭐⭐

本指南重点讲解最主流的两种方案:

  1. Google AdMob Mediation(以 AdMob 为主,Meta AN 做竞价补充)
  2. AppLovin MAX(独立聚合,AdMob + Meta AN 并行竞价)

二、方案一:Google AdMob Mediation + Meta Audience Network(推荐)

这是最直接的方案——AdMob 作为聚合主体,Meta AN 通过 Bidding(实时竞价) 参与竞争。好的,已经获取到所有关键信息。下面为您撰写完整详细的集成指南。


Meta Audience Network + AdMob 双平台集成完整指南(iOS/Swift)


一、架构选择:为什么需要聚合(Mediation)?

手动管理两个广告 SDK 的加载、展示、降级逻辑非常繁琐且容易出错。使用 广告聚合平台 可以:

  • ✅ 自动让多个广告网络实时竞价(Bidding),最大化收益
  • ✅ 统一 API 调用,不需要为每个广告网络写单独代码
  • ✅ 自动处理 No Fill 降级(A 网络没广告则自动切换到 B)
  • ✅ 统一管理隐私合规

1.1 主流聚合平台对比

聚合平台 特点 适合场景 推荐度
Google AdMob Mediation AdMob 官方内置,Meta AN 做竞价适配器 已使用 AdMob 的项目,最简单 ⭐⭐⭐⭐⭐
AppLovin MAX 独立聚合,两者并行竞价,公正透明 追求最高 eCPM 的游戏类应用 ⭐⭐⭐⭐⭐
ironSource LevelPlay 与 Unity 合并,游戏领域强势 Unity 游戏或已使用 ironSource ⭐⭐⭐⭐
TopOn / TradPlus 国内出海常用,支持国内外主流网络 出海应用同时接国内外广告 ⭐⭐⭐⭐

💡 本指南重点讲解最主流的方案:Google AdMob Mediation + Meta AN(方案一)AppLovin MAX(方案二)


二、方案一:Google AdMob Mediation + Meta Audience Network

核心思路: AdMob 作为主聚合,Meta AN 通过 Bidding 适配器参与实时竞价竞争

2.1 版本要求

条件 最低版本
iOS Deployment Target 13.0
Google Mobile Ads SDK 12.0.0+(推荐最新)
Meta Audience Network SDK 6.21.0
Meta Adapter 6.21.0.0
Xcode 最新版本

⚠️ Meta AN 自 2021 年起 只支持 Bidding(实时竞价),不再支持 Waterfall

2.2 CocoaPods 安装

platform :ios, '13.0'

target 'YourApp' do
  use_frameworks!

  # ① Google Mobile Ads SDK(AdMob 主体)
  pod 'Google-Mobile-Ads-SDK'

  # ② Meta Audience Network Mediation Adapter(自动包含 FBAudienceNetwork SDK)
  pod 'GoogleMobileAdsMediationFacebook'
end
pod install --repo-update

只需要添加 GoogleMobileAdsMediationFacebook,它会自动拉取 FBAudienceNetwork SDK,不需要额外单独引入。

2.3 Info.plist 配置

<!-- ① AdMob App ID(必须) -->
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy</string>

<!-- ② App Tracking Transparency 权限说明(iOS 14.5+ 必须) -->
<key>NSUserTrackingUsageDescription</key>
<string>此标识符将用于向您投放个性化广告</string>

<!-- ③ SKAdNetwork 标识符(AdMob + Meta 都需要) -->
<key>SKAdNetworkItems</key>
<array>
  <!-- Google -->
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>cstr6suwn9.skadnetwork</string>
  </dict>
  <!-- Meta -->
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>v9wttpbfk9.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>n38lu8286q.skadnetwork</string>
  </dict>
  <!-- ... 完整列表参见 Google 和 Meta 官方文档 -->
</array>

2.4 AppDelegate 初始化

import UIKit
import GoogleMobileAds
import FBAudienceNetwork
import AppTrackingTransparency

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        // ⏱ 延迟请求 ATT 权限(建议在首页 viewDidAppear 中调用更好)
        // 但必须在广告请求之前完成
        
        return true
    }
}

2.5 ATT 权限请求 + SDK 初始化(推荐写法)

import UIKit
import GoogleMobileAds
import FBAudienceNetwork
import AppTrackingTransparency

class MainViewController: UIViewController {

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        requestATTThenInitializeAds()
    }

    private func requestATTThenInitializeAds() {
        if #available(iOS 14.5, *) {
            // ① 先请求 ATT 权限
            ATTrackingManager.requestTrackingAuthorization { [weak self] status in
                DispatchQueue.main.async {
                    // ② 根据结果设置 Meta ATE 标志
                    // 注意:SDK 6.15.0+ 在 iOS 17+ 会自动读取 ATT 状态
                    // 但 iOS 14.5 ~ 16.x 仍需要手动设置
                    switch status {
                    case .authorized:
                        FBAdSettings.setAdvertiserTrackingEnabled(true)
                    case .denied, .restricted:
                        FBAdSettings.setAdvertiserTrackingEnabled(false)
                    case .notDetermined:
                        FBAdSettings.setAdvertiserTrackingEnabled(false)
                    @unknown default:
                        break
                    }

                    // ③ ATT 完成后再初始化 Google Mobile Ads SDK
                    self?.initializeGoogleAds()
                }
            }
        } else {
            // iOS 14.5 以下直接初始化
            initializeGoogleAds()
        }
    }

    private func initializeGoogleAds() {
        // Google Mobile Ads SDK 初始化(会同时初始化所有 Mediation Adapter)
        GADMobileAds.sharedInstance().start { status in
            print("✅ AdMob SDK 初始化完成")

            // 打印各 Adapter 状态
            let adapterStatuses = status.adapterStatusesByClassName
            for (adapter, status) in adapterStatuses {
                print("  Adapter: \(adapter), State: \(status.state.rawValue), Desc: \(status.description)")
            }
        }
    }
}

⚠️ 关键顺序:ATT 权限 → 设置 Meta ATE → 初始化 GADMobileAds

Google AdMob Mediation 初始化时会自动初始化 Meta AN SDK 适配器,不需要单独调用 FBAudienceNetworkAds.initialize()

2.6 Banner 广告(通过 AdMob 聚合)

import UIKit
import GoogleMobileAds

class BannerViewController: UIViewController, GADBannerViewDelegate {

    private var bannerView: GADBannerView!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupBanner()
    }

    private func setupBanner() {
        // 使用 AdMob 的 Ad Unit ID(在 AdMob 后台配置了 Meta Mediation 的广告单元)
        bannerView = GADBannerView(adSize: GADAdSizeBanner) // 320×50
        bannerView.adUnitID = "ca-app-pub-xxxxx/yyyyy" // ⬅️ AdMob Ad Unit ID
        bannerView.rootViewController = self
        bannerView.delegate = self
        bannerView.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(bannerView)
        NSLayoutConstraint.activate([
            bannerView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            bannerView.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])

        bannerView.load(GADRequest())
    }

    // MARK: - GADBannerViewDelegate

    func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
        print("✅ Banner 加载成功")
        // 可通过 bannerView.responseInfo 查看是哪个网络填充的
        if let adNetworkClassName = bannerView.responseInfo?.loadedAdNetworkResponseInfo?.adNetworkClassName {
            print("  填充来源: \(adNetworkClassName)")
            // 如果是 Meta 填充,会显示 GADMediationAdapterFacebook
        }
    }

    func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
        print("❌ Banner 加载失败: \(error.localizedDescription)")
    }

    func bannerViewDidRecordImpression(_ bannerView: GADBannerView) {
        print("👁️ Banner 曝光")
    }

    func bannerViewDidRecordClick(_ bannerView: GADBannerView) {
        print("👆 Banner 点击")
    }
}

2.7 Interstitial 插屏广告(通过 AdMob 聚合)

import UIKit
import GoogleMobileAds

class InterstitialViewController: UIViewController, GADFullScreenContentDelegate {

    private var interstitialAd: GADInterstitialAd?

    override func viewDidLoad() {
        super.viewDidLoad()
        loadInterstitialAd()
    }

    /// 提前加载插屏广告
    func loadInterstitialAd() {
        GADInterstitialAd.load(
            withAdUnitID: "ca-app-pub-xxxxx/yyyyy", // ⬅️ AdMob Ad Unit ID
            request: GADRequest()
        ) { [weak self] ad, error in
            if let error = error {
                print("❌ 插屏广告加载失败: \(error.localizedDescription)")
                return
            }
            print("✅ 插屏广告加载成功")
            self?.interstitialAd = ad
            self?.interstitialAd?.fullScreenContentDelegate = self

            // 查看填充来源
            if let adNetwork = ad?.responseInfo.loadedAdNetworkResponseInfo?.adNetworkClassName {
                print("  填充来源: \(adNetwork)")
            }
        }
    }

    /// 在合适时机展示
    func showInterstitialAd() {
        if let ad = interstitialAd {
            ad.present(fromRootViewController: self)
        } else {
            print("⚠️ 广告尚未就绪")
        }
    }

    // MARK: - GADFullScreenContentDelegate

    func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
        print("❌ 展示失败: \(error.localizedDescription)")
        loadInterstitialAd() // 重新加载
    }

    func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
        print("✅ 插屏广告已关闭")
        loadInterstitialAd() // ⭐ 关闭后预加载下一个
    }

    func adDidRecordImpression(_ ad: GADFullScreenPresentingAd) {
        print("👁️ 插屏广告曝光")
    }
}

2.9 AdMob 后台配置 Meta AN Mediation(关键步骤)

在 AdMob 后台完成以下配置,才能让 Meta AN 参与竞价:

步骤 1:Meta 后台创建广告位
  1. 登录 Meta Business Suite → Monetization Manager
  2. 创建 Property → 选择 iOS 平台
  3. Mediation Platform 选择 "AdMob"
  4. 为每种格式(Banner / Interstitial / Rewarded)创建 Placement
  5. 记录每个 Placement ID(格式如 123456789_987654321
步骤 2:AdMob 后台添加 Meta 竞价
  1. 登录 AdMob Console
  2. 导航到 Mediation → Mediation Groups
  3. 创建或编辑一个 Mediation Group
  4. Bidding 区域,点击 Add Ad Sources → Meta Audience Network
  5. 输入 Meta 的 Placement ID
  6. 保存

💡 AdMob 会自动与 Meta 进行实时竞价(Bidding),不需要设置 eCPM 手动排序

步骤 3:配置 app-ads.txt

在您的开发者网站根目录添加 app-ads.txt 文件,包含 AdMob 和 Meta 的授权行:

# Google AdMob
google.com, pub-xxxxxxxxxxxxxxxx, DIRECT, f08c47fec0942fa0

# Meta Audience Network
facebook.com, xxxxxxxxxxxxxxxxx, RESELLER, c3e20eee3f780d68

三、方案二:AppLovin MAX 聚合(独立聚合平台)

核心思路: MAX 作为独立聚合,AdMob 和 Meta AN 同为竞价参与者,更加公平透明好的,已经获取到了所有需要的信息。以下是完整的后续内容:


3.1 CocoaPods 安装

platform :ios, '13.0'

target 'YourApp' do
  use_frameworks!
  inhibit_all_warnings!

  # ① AppLovin MAX SDK(聚合主体)
  pod 'AppLovinSDK'

  # ② Google AdMob 适配器(自动包含 Google Mobile Ads SDK)
  pod 'AppLovinMediationGoogleAdapter'

  # ③ Meta Audience Network 适配器(自动包含 FBAudienceNetwork SDK)
  pod 'AppLovinMediationFacebookAdapter'
end
pod install --repo-update

💡 只需安装适配器 Pod,它们会自动拉取对应的广告网络 SDK

3.2 Info.plist 配置

<!-- ① AppLovin SDK Key -->
<key>AppLovinSdkKey</key>
<string>YOUR_APPLOVIN_SDK_KEY</string>

<!-- ② AdMob App ID(Google Adapter 需要) -->
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy</string>

<!-- ③ ATT 权限描述 -->
<key>NSUserTrackingUsageDescription</key>
<string>此标识符将用于向您投放个性化广告</string>

<!-- ④ SKAdNetwork 标识符(AppLovin + Google + Meta 都需要) -->
<key>SKAdNetworkItems</key>
<array>
  <!-- AppLovin -->
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>ludvb6z3bs.skadnetwork</string>
  </dict>
  <!-- Google -->
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>cstr6suwn9.skadnetwork</string>
  </dict>
  <!-- Meta -->
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>v9wttpbfk9.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>n38lu8286q.skadnetwork</string>
  </dict>
  <!-- ... 完整列表从各平台文档获取 -->
</array>

3.3 SDK 初始化

import UIKit
import AppLovinSDK
import FBAudienceNetwork
import AppTrackingTransparency

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        // ① 请求 ATT 权限(延迟到首页更好,此处简化演示)
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.requestATTAndInitialize()
        }

        return true
    }

    private func requestATTAndInitialize() {
        if #available(iOS 14.5, *) {
            ATTrackingManager.requestTrackingAuthorization { [weak self] status in
                DispatchQueue.main.async {
                    // ② 设置 Meta ATE 标志
                    switch status {
                    case .authorized:
                        FBAdSettings.setAdvertiserTrackingEnabled(true)
                    default:
                        FBAdSettings.setAdvertiserTrackingEnabled(false)
                    }

                    // ③ 初始化 AppLovin MAX SDK
                    self?.initializeMAX()
                }
            }
        } else {
            initializeMAX()
        }
    }

    private func initializeMAX() {
        // SDK Key 可在 AppLovin Dashboard → Account → General → Keys 找到
        let initConfig = ALSdkInitializationConfiguration(sdkKey: "YOUR_SDK_KEY") { builder in
            builder.mediationProvider = ALMediationProviderMAX

            // (可选)如果需要测试特定广告单元
            // builder.testDeviceAdvertisingIdentifiers = ["YOUR_IDFA"]
        }

        ALSdk.shared().initialize(with: initConfig) { sdkConfig in
            print("✅ AppLovin MAX SDK 初始化完成")
            // 此时可以开始加载广告
        }
    }
}

3.4 Banner 广告

import UIKit
import AppLovinSDK

class MAXBannerViewController: UIViewController, MAAdViewAdDelegate {

    private var adView: MAAdView!

    override func viewDidLoad() {
        super.viewDidLoad()
        createBannerAd()
    }

    private func createBannerAd() {
        // Ad Unit ID 在 AppLovin Dashboard → MAX → Ad Units 创建
        adView = MAAdView(adUnitIdentifier: "YOUR_AD_UNIT_ID")
        adView.delegate = self

        // Banner 尺寸:iPhone 50pt / iPad 90pt
        let height: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 90 : 50
        let width: CGFloat = UIScreen.main.bounds.width

        adView.frame = CGRect(
            x: 0,
            y: view.bounds.height - height - view.safeAreaInsets.bottom,
            width: width,
            height: height
        )
        adView.backgroundColor = .clear

        view.addSubview(adView)

        // 加载广告(Banner 默认自动刷新)
        adView.loadAd()
    }

    // MARK: - MAAdViewAdDelegate

    func didLoad(_ ad: MAAd) {
        print("✅ Banner 加载成功, 来源: \(ad.networkName)")
        // ad.networkName 会显示 "Google Bidding and Google AdMob" 或 "Meta Audience Network"
    }

    func didFailToLoadAd(forAdUnitIdentifier adUnitIdentifier: String, withError error: MAError) {
        print("❌ Banner 加载失败: \(error.message)")
    }

    func didClick(_ ad: MAAd) {
        print("👆 Banner 点击")
    }

    func didFail(toDisplay ad: MAAd, withError error: MAError) {
        print("❌ Banner 展示失败")
    }

    func didExpand(_ ad: MAAd) {
        print("📐 Banner 展开")
    }

    func didCollapse(_ ad: MAAd) {
        print("📐 Banner 折叠")
    }

    deinit {
        adView.delegate = nil
        adView.removeFromSuperview()
    }
}

3.5 Interstitial 插屏广告

import UIKit
import AppLovinSDK

class MAXInterstitialViewController: UIViewController, MAAdDelegate {

    private var interstitialAd: MAInterstitialAd!
    private var retryAttempt = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        createInterstitialAd()
    }

    private func createInterstitialAd() {
        interstitialAd = MAInterstitialAd(adUnitIdentifier: "YOUR_AD_UNIT_ID")
        interstitialAd.delegate = self
        interstitialAd.load()
    }

    /// 在合适时机展示
    func showInterstitialAd() {
        if interstitialAd.isReady {
            interstitialAd.show()
        } else {
            print("⚠️ 插屏广告尚未就绪")
        }
    }

    // MARK: - MAAdDelegate

    func didLoad(_ ad: MAAd) {
        print("✅ 插屏加载成功, 来源: \(ad.networkName)")
        retryAttempt = 0
    }

    func didFailToLoadAd(forAdUnitIdentifier adUnitIdentifier: String, withError error: MAError) {
        print("❌ 插屏加载失败: \(error.message)")

        // ⭐ 指数退避重试(最大 64 秒)
        retryAttempt += 1
        let delaySec = pow(2.0, min(6.0, Double(retryAttempt)))
        DispatchQueue.main.asyncAfter(deadline: .now() + delaySec) { [weak self] in
            self?.interstitialAd.load()
        }
    }

    func didDisplay(_ ad: MAAd) {
        print("📺 插屏已展示")
    }

    func didHide(_ ad: MAAd) {
        print("✅ 插屏已关闭")
        // ⭐ 关闭后预加载下一个
        interstitialAd.load()
    }

    func didClick(_ ad: MAAd) {
        print("👆 插屏被点击")
    }

    func didFail(toDisplay ad: MAAd, withError error: MAError) {
        print("❌ 插屏展示失败")
        interstitialAd.load()
    }
}

3.6 Rewarded 激励视频广告

import UIKit
import AppLovinSDK

class MAXRewardedViewController: UIViewController, MARewardedAdDelegate {

    private var rewardedAd: MARewardedAd!
    private var retryAttempt = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        createRewardedAd()
    }

    private func createRewardedAd() {
        rewardedAd = MARewardedAd.shared(withAdUnitIdentifier: "YOUR_AD_UNIT_ID")
        rewardedAd.delegate = self
        rewardedAd.load()
    }

    /// 用户主动触发观看
    @IBAction func watchAdTapped(_ sender: UIButton) {
        if rewardedAd.isReady {
            rewardedAd.show()
        } else {
            print("⚠️ 激励视频尚未就绪")
        }
    }

    // MARK: - MAAdDelegate

    func didLoad(_ ad: MAAd) {
        print("✅ 激励视频加载成功, 来源: \(ad.networkName)")
        retryAttempt = 0
    }

    func didFailToLoadAd(forAdUnitIdentifier adUnitIdentifier: String, withError error: MAError) {
        print("❌ 激励视频加载失败: \(error.message)")

        retryAttempt += 1
        let delaySec = pow(2.0, min(6.0, Double(retryAttempt)))
        DispatchQueue.main.asyncAfter(deadline: .now() + delaySec) { [weak self] in
            self?.rewardedAd.load()
        }
    }

    func didDisplay(_ ad: MAAd) {
        print("📺 激励视频已展示")
    }

    func didHide(_ ad: MAAd) {
        print("✅ 激励视频已关闭")
        rewardedAd.load() // ⭐ 预加载下一个
    }

    func didClick(_ ad: MAAd) {
        print("👆 激励视频被点击")
    }

    func didFail(toDisplay ad: MAAd, withError error: MAError) {
        print("❌ 激励视频展示失败")
        rewardedAd.load()
    }

    // MARK: - MARewardedAdDelegate

    /// ⭐ 用户观看完成,发放奖励
    func didRewardUser(for ad: MAAd, with reward: MAReward) {
        print("🎉 用户获得奖励: \(reward.amount) \(reward.label)")
        grantReward(amount: reward.amount, currency: reward.label)
    }

    private func grantReward(amount: Int, currency: String) {
        // 发放奖励逻辑
        print("发放 \(amount) \(currency)")
    }
}

3.7 AppLovin MAX 后台配置

AppLovin Dashboard 中完成以下配置:

添加 AdMob 和 Meta AN
  1. MAX → Manage → Ad Units → 创建 Ad Unit
  2. 选择格式(Banner / Interstitial / Rewarded)
  3. Bidding 区域启用:
    • Google Bidding and Google AdMob → 填入 AdMob 的 Ad Unit ID
    • Meta Audience Network → 填入 Meta 的 Placement ID
  4. 保存

两个网络都通过 实时竞价(Bidding) 参与,MAX 会自动选择出价最高的网络展示广告


四、两种方案对比

特性 方案一:AdMob Mediation 方案二:AppLovin MAX
聚合主体 Google AdMob AppLovin MAX
竞价公平性 AdMob 自家广告可能有优势 更公平透明,所有网络平等竞争
接入复杂度 ⭐ 简单(已用 AdMob 的项目) ⭐⭐ 中等(需额外注册 AppLovin)
支持网络数量 约 20+ 约 25+
收益报告 AdMob 后台 AppLovin Dashboard(更详细)
A/B 测试 有限 内置强大 A/B 测试
广告质量审核 Google Ad Review MAX Ad Review
费用 免费 免费
推荐场景 已深度使用 AdMob 新项目或追求最高收益

五、方案三:手动管理(不推荐但可行)

如果你有特殊原因不想使用聚合平台,可以手动管理两个 SDK 的降级逻辑:

5.1 安装两个 SDK

platform :ios, '13.0'

target 'YourApp' do
  use_frameworks!

  pod 'Google-Mobile-Ads-SDK'   # AdMob
  pod 'FBAudienceNetwork'        # Meta AN
end

5.2 手动广告管理器

import Foundation
import GoogleMobileAds
import FBAudienceNetwork

/// 广告管理器 - 手动聚合(降级逻辑)
/// ⚠️ 不推荐:仅作学习参考,生产环境请用聚合平台
class ManualAdManager: NSObject {

    static let shared = ManualAdManager()

    // MARK: - 配置

    private let admobInterstitialUnitID = "ca-app-pub-xxxxx/yyyyy"
    private let metaInterstitialPlacementID = "123456789_987654321"

    private let admobRewardedUnitID = "ca-app-pub-xxxxx/zzzzz"
    private let metaRewardedPlacementID = "123456789_111111111"

    // MARK: - 广告实例

    private var admobInterstitial: GADInterstitialAd?
    private var metaInterstitial: FBInterstitialAd?

    private var admobRewarded: GADRewardedAd?
    private var metaRewarded: FBRewardedVideoAd?

    // MARK: - 状态追踪

    private var isAdMobInterstitialReady = false
    private var isMetaInterstitialReady = false
    private var isAdMobRewardedReady = false
    private var isMetaRewardedReady = false

    // MARK: - 回调

    var onRewardEarned: ((_ amount: Int, _ type: String) -> Void)?
    var onInterstitialDismissed: (() -> Void)?

    private override init() {
        super.init()
    }

    // MARK: - ==================== 插屏广告 ====================

    /// 同时请求两个网络,谁先 ready 谁展示
    func loadInterstitial() {
        isAdMobInterstitialReady = false
        isMetaInterstitialReady = false

        loadAdMobInterstitial()
        loadMetaInterstitial()
    }

    // —— AdMob 插屏 ——

    private func loadAdMobInterstitial() {
        GADInterstitialAd.load(
            withAdUnitID: admobInterstitialUnitID,
            request: GADRequest()
        ) { [weak self] ad, error in
            guard let self = self else { return }
            if let error = error {
                print("❌ AdMob 插屏加载失败: \(error.localizedDescription)")
                return
            }
            print("✅ AdMob 插屏加载成功")
            self.admobInterstitial = ad
            self.admobInterstitial?.fullScreenContentDelegate = self
            self.isAdMobInterstitialReady = true
        }
    }

    // —— Meta 插屏 ——

    private func loadMetaInterstitial() {
        metaInterstitial = FBInterstitialAd(placementID: metaInterstitialPlacementID)
        metaInterstitial?.delegate = self
        metaInterstitial?.load()
    }

    /// 展示插屏:优先 AdMob → 降级 Meta → 两者都无则放弃
    func showInterstitial(from viewController: UIViewController) -> Bool {
        if isAdMobInterstitialReady, let ad = admobInterstitial {
            print("📺 展示 AdMob 插屏")
            ad.present(fromRootViewController: viewController)
            return true
        } else if isMetaInterstitialReady, let ad = metaInterstitial, ad.isAdValid {
            print("📺 展示 Meta 插屏")
            ad.show(fromRootViewController: viewController)
            return true
        } else {
            print("⚠️ 无可用插屏广告")
            return false
        }
    }

    // MARK: - ==================== 激励视频 ====================

    func loadRewarded() {
        isAdMobRewardedReady = false
        isMetaRewardedReady = false

        loadAdMobRewarded()
        loadMetaRewarded()
    }

    // —— AdMob 激励 ——

    private func loadAdMobRewarded() {
        GADRewardedAd.load(
            withAdUnitID: admobRewardedUnitID,
            request: GADRequest()
        ) { [weak self] ad, error in
            guard let self = self else { return }
            if let error = error {
                print("❌ AdMob 激励加载失败: \(error.localizedDescription)")
                return
            }
            print("✅ AdMob 激励加载成功")
            self.admobRewarded = ad
            self.admobRewarded?.fullScreenContentDelegate = self
            self.isAdMobRewardedReady = true
        }
    }

    // —— Meta 激励 ——

    private func loadMetaRewarded() {
        metaRewarded = FBRewardedVideoAd(placementID: metaRewardedPlacementID)
        metaRewarded?.delegate = self
        metaRewarded?.load()
    }

    /// 展示激励视频:优先 AdMob → 降级 Meta
    func showRewarded(from viewController: UIViewController) -> Bool {
        if isAdMobRewardedReady, let ad = admobRewarded {
            print("📺 展示 AdMob 激励视频")
            ad.present(fromRootViewController: viewController) { [weak self] in
                let reward = ad.adReward
                print("🎉 AdMob 奖励: \(reward.amount) \(reward.type)")
                self?.onRewardEarned?(reward.amount.intValue, reward.type)
            }
            return true
        } else if isMetaRewardedReady, let ad = metaRewarded, ad.isAdValid {
            print("📺 展示 Meta 激励视频")
            ad.show(fromRootViewController: viewController)
            return true
        } else {
            print("⚠️ 无可用激励视频")
            return false
        }
    }

    /// 检查是否有广告就绪
    var isInterstitialReady: Bool {
        return isAdMobInterstitialReady || isMetaInterstitialReady
    }

    var isRewardedReady: Bool {
        return isAdMobRewardedReady || isMetaRewardedReady
    }
}

// MARK: - ==================== AdMob Delegate ====================

extension ManualAdManager: GADFullScreenContentDelegate {

    func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
        print("✅ AdMob 全屏广告已关闭")
        isAdMobInterstitialReady = false
        isAdMobRewardedReady = false
        onInterstitialDismissed?()
        // 预加载下一个
        loadInterstitial()
        loadRewarded()
    }

    func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
        print("❌ AdMob 展示失败: \(error.localizedDescription)")
    }
}

// MARK: - ==================== Meta Interstitial Delegate ====================

extension ManualAdManager: FBInterstitialAdDelegate {

    func interstitialAdDidLoad(_ interstitialAd: FBInterstitialAd) {
        print("✅ Meta 插屏加载成功")
        isMetaInterstitialReady = true
    }

    func interstitialAd(_ interstitialAd: FBInterstitialAd, didFailWithError error: Error) {
        print("❌ Meta 插屏加载失败: \(error.localizedDescription)")
        isMetaInterstitialReady = false
    }

    func interstitialAdDidClose(_ interstitialAd: FBInterstitialAd) {
        print("✅ Meta 插屏已关闭")
        isMetaInterstitialReady = false
        onInterstitialDismissed?()
        loadInterstitial()
    }

    func interstitialAdDidClick(_ interstitialAd: FBInterstitialAd) {
        print("👆 Meta 插屏被点击")
    }

    func interstitialAdWillLogImpression(_ interstitialAd: FBInterstitialAd) {
        print("👁️ Meta 插屏曝光")
    }
}

// MARK: - ==================== Meta Rewarded Delegate ====================

extension ManualAdManager: FBRewardedVideoAdDelegate {

    func rewardedVideoAdDidLoad(_ rewardedVideoAd: FBRewardedVideoAd) {
        print("✅ Meta 激励加载成功")
        isMetaRewardedReady = true
    }

    func rewardedVideoAd(_ rewardedVideoAd: FBRewardedVideoAd, didFailWithError error: Error) {
        print("❌ Meta 激励加载失败: \(error.localizedDescription)")
        isMetaRewardedReady = false
    }

    func rewardedVideoAdDidClose(_ rewardedVideoAd: FBRewardedVideoAd) {
        print("✅ Meta 激励视频已关闭")
        isMetaRewardedReady = false
        loadRewarded()
    }

    func rewardedVideoAdVideoComplete(_ rewardedVideoAd: FBRewardedVideoAd) {
        print("🎉 Meta 激励视频观看完成")
        // Meta 不像 AdMob 那样返回具体奖励信息,需要自行定义
        onRewardEarned?(1, "coin")
    }

    func rewardedVideoAdDidClick(_ rewardedVideoAd: FBRewardedVideoAd) {
        print("👆 Meta 激励视频被点击")
    }
}

5.3 手动方案的使用方式

class GameViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // 预加载广告
        ManualAdManager.shared.loadInterstitial()
        ManualAdManager.shared.loadRewarded()

        // 设置奖励回调
        ManualAdManager.shared.onRewardEarned = { amount, type in
            print("🎉 发放奖励: \(amount) \(type)")
            // 更新用户余额等
        }
    }

    /// 关卡结束后展示插屏
    func onLevelComplete() {
        _ = ManualAdManager.shared.showInterstitial(from: self)
    }

    /// 用户主动观看激励视频
    @IBAction func watchAdForReward(_ sender: UIButton) {
        let shown = ManualAdManager.shared.showRewarded(from: self)
        if !shown {
            // 提示用户稍后再试
            showAlert(message: "广告暂不可用,请稍后再试")
        }
    }
}

⚠️ 手动方案的缺点:

  • 无法实现真正的实时竞价(Bidding),只是简单的优先级降级
  • 需要自己维护两套 Delegate
  • 无法动态调整优先级和 eCPM 排序
  • 合规(GDPR/CCPA)需要分别处理
  • 新增广告网络时需要大量改代码

六、隐私合规处理(三种方案通用)

6.1 Google UMP(User Messaging Platform)- GDPR 合规

import UIKit
import UserMessagingPlatform

class ConsentManager {

    static let shared = ConsentManager()

    /// 在 SDK 初始化之前调用
    func requestConsentIfNeeded(from viewController: UIViewController, completion: @escaping () -> Void) {

        // ① 创建请求参数
        let parameters = UMPRequestParameters()

        // 调试时使用(正式发布移除)
        #if DEBUG
        let debugSettings = UMPDebugSettings()
        debugSettings.testDeviceIdentifiers = ["YOUR_TEST_DEVICE_HASHED_ID"]
        debugSettings.geography = .EEA // 模拟欧洲用户
        parameters.debugSettings = debugSettings
        #endif

        // ② 请求更新同意信息
        UMPConsentInformation.sharedInstance.requestConsentInfoUpdate(with: parameters) { error in
            if let error = error {
                print("❌ 同意信息更新失败: \(error.localizedDescription)")
                completion()
                return
            }

            // ③ 如果需要,展示同意表单
            UMPConsentForm.loadAndPresentIfRequired(from: viewController) { formError in
                if let formError = formError {
                    print("❌ 同意表单展示失败: \(formError.localizedDescription)")
                }

                // ④ 检查是否可以请求广告
                if UMPConsentInformation.sharedInstance.canRequestAds {
                    print("✅ 用户已授权,可以请求广告")
                }

                completion()
            }
        }
    }

    /// 检查是否可以请求个性化广告
    var canRequestAds: Bool {
        return UMPConsentInformation.sharedInstance.canRequestAds
    }
}

6.2 Meta 隐私合规设置

import FBAudienceNetwork

class MetaPrivacyHelper {

    /// 设置 GDPR 数据处理选项(欧洲用户)
    static func setGDPRConsent(granted: Bool) {
        // Meta 不在 IAB GVL 中,需要使用 Additional Consent
        // 如果用户未同意,应当限制数据使用
        if !granted {
            // 限制数据处理
            FBAdSettings.setDataProcessingOptions(["LDU"], country: 0, state: 0)
        } else {
            // 不限制
            FBAdSettings.setDataProcessingOptions([])
        }
    }

    /// 设置 CCPA 数据处理选项(加州用户)
    static func setCCPAOptOut(optedOut: Bool) {
        if optedOut {
            // 用户选择退出数据售卖
            FBAdSettings.setDataProcessingOptions(["LDU"], country: 1, state: 1000)
        } else {
            FBAdSettings.setDataProcessingOptions([])
        }
    }

    /// 设置 iOS 14+ 广告追踪状态
    static func setAdvertiserTracking(enabled: Bool) {
        FBAdSettings.setAdvertiserTrackingEnabled(enabled)
    }
}

6.3 完整的初始化流程(合规 → ATT → 广告 SDK)

import UIKit
import GoogleMobileAds
import FBAudienceNetwork
import AppTrackingTransparency
import UserMessagingPlatform

class AppStartupManager {

    static let shared = AppStartupManager()
    
    private var isAdsInitialized = false

    /// 完整的广告初始化流程:GDPR → ATT → Meta ATE → SDK 初始化
    func startAdInitialization(from viewController: UIViewController) {
        
        // ==================== 第 1 步:GDPR 同意 ====================
        print("📋 Step 1: 请求 GDPR 同意...")
        
        ConsentManager.shared.requestConsentIfNeeded(from: viewController) { [weak self] in
            guard let self = self else { return }
            
            // ==================== 第 2 步:ATT 权限 ====================
            print("📋 Step 2: 请求 ATT 权限...")
            
            self.requestATTPermission { trackingAuthorized in
                
                // ==================== 第 3 步:配置 Meta 隐私 ====================
                print("📋 Step 3: 配置 Meta 隐私设置...")
                
                FBAdSettings.setAdvertiserTrackingEnabled(trackingAuthorized)
                
                // 如果 GDPR 同意信息可用,配置 Meta 数据处理选项
                if ConsentManager.shared.canRequestAds {
                    FBAdSettings.setDataProcessingOptions([])
                } else {
                    FBAdSettings.setDataProcessingOptions(["LDU"], country: 0, state: 0)
                }
                
                // ==================== 第 4 步:初始化广告 SDK ====================
                print("📋 Step 4: 初始化广告 SDK...")
                
                self.initializeAdSDK()
            }
        }
    }
    
    private func requestATTPermission(completion: @escaping (Bool) -> Void) {
        if #available(iOS 14.5, *) {
            ATTrackingManager.requestTrackingAuthorization { status in
                DispatchQueue.main.async {
                    let authorized = (status == .authorized)
                    print("  ATT 状态: \(status.rawValue), 已授权: \(authorized)")
                    completion(authorized)
                }
            }
        } else {
            // iOS 14.5 以下默认可追踪
            completion(true)
        }
    }
    
    private func initializeAdSDK() {
        guard !isAdsInitialized else { return }
        isAdsInitialized = true
        
        // ====== 方案一:使用 AdMob Mediation ======
        GADMobileAds.sharedInstance().start { status in
            print("✅ AdMob SDK 初始化完成")
            
            for (adapter, adapterStatus) in status.adapterStatusesByClassName {
                print("  [\(adapter)] state=\(adapterStatus.state.rawValue), \(adapterStatus.description)")
            }
            
            // 初始化完成,发送通知让各页面开始加载广告
            NotificationCenter.default.post(name: .adsSDKInitialized, object: nil)
        }
        
        // ====== 方案二(替代):使用 AppLovin MAX ======
        /*
        let initConfig = ALSdkInitializationConfiguration(sdkKey: "YOUR_SDK_KEY") { builder in
            builder.mediationProvider = ALMediationProviderMAX
        }
        ALSdk.shared().initialize(with: initConfig) { sdkConfig in
            print("✅ AppLovin MAX SDK 初始化完成")
            NotificationCenter.default.post(name: .adsSDKInitialized, object: nil)
        }
        */
    }
}

// MARK: - 自定义通知名

extension Notification.Name {
    static let adsSDKInitialized = Notification.Name("adsSDKInitialized")
}

6.4 在 AppDelegate / SceneDelegate 中调用

// SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    var window: UIWindow?
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }
        
        let window = UIWindow(windowScene: windowScene)
        let rootVC = MainViewController()
        window.rootViewController = UINavigationController(rootViewController: rootVC)
        window.makeKeyAndVisible()
        self.window = window
    }
}

// MainViewController.swift
class MainViewController: UIViewController {
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // ⭐ 在主页面显示后启动广告初始化流程
        // 这样 GDPR 弹窗和 ATT 弹窗能正常展示
        AppStartupManager.shared.startAdInitialization(from: self)
        
        // 监听 SDK 初始化完成
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(onAdsReady),
            name: .adsSDKInitialized,
            object: nil
        )
    }
    
    @objc private func onAdsReady() {
        print("🚀 广告 SDK 已就绪,开始加载广告")
        // 在这里加载各种广告
    }
}

七、测试和调试

7.1 AdMob 测试广告 ID

在开发阶段,使用 Google 提供的官方测试 ID,不要使用真实广告 ID 测试(会被封号):

struct TestAdUnitIDs {
    // Google 官方测试 ID(安全使用,不会触发违规)
    static let admobBanner           = "ca-app-pub-3940256099942544/2934735716"
    static let admobInterstitial     = "ca-app-pub-3940256099942544/4411468910"
    static let admobRewarded         = "ca-app-pub-3940256099942544/1712485313"
    static let admobRewardedInterstitial = "ca-app-pub-3940256099942544/6978759866"
    static let admobNative           = "ca-app-pub-3940256099942544/3986624511"
    static let admobAppOpen          = "ca-app-pub-3940256099942544/5575463023"
}

7.2 Meta AN 测试模式

#if DEBUG
// 添加测试设备(设备 IDFA 的哈希值,在控制台日志中查找)
FBAdSettings.addTestDevice("YOUR_DEVICE_HASH")

// 或者启用模拟器测试模式
FBAdSettings.addTestDevice(FBAdSettings.testDeviceHash())

// 设置测试广告类型(可选)
// FBAdSettings.setLogLevel(.log)
#endif

7.3 AppLovin MAX 调试工具

#if DEBUG
// 显示 MAX Mediation Debugger(可视化调试面板)
// 显示所有适配器状态、广告加载记录等
ALSdk.shared().showMediationDebugger()
#endif

💡 MAX Mediation Debugger 非常强大,可以一目了然看到:

  • 各适配器是否正确初始化
  • 各网络的竞价情况
  • 广告加载成功/失败详情

7.4 广告来源追踪(通用)

/// 统一的广告事件追踪器
class AdEventTracker {
    
    /// 记录广告展示来源
    static func trackImpression(
        adFormat: String,       // "banner" / "interstitial" / "rewarded"
        networkName: String,    // "AdMob" / "Meta" / "Google Bidding"
        revenue: Double? = nil, // 收益(如可用)
        adUnitID: String
    ) {
        print("""
        📊 广告曝光
          格式: \(adFormat)
          来源: \(networkName)
          收益: \(revenue.map { String(format: "%.6f", $0) } ?? "N/A")
          广告单元: \(adUnitID)
        """)
        
        // 发送到你的分析平台(Firebase / Amplitude / 自建等)
        // Analytics.logEvent("ad_impression", parameters: [...])
    }
    
    // —— AdMob 获取收益信息 ——
    static func trackAdMobRevenue(ad: GADFullScreenPresentingAd, adFormat: String) {
        // AdMob 收益追踪需要通过 paidEventHandler
        // 在加载成功后设置:
        // ad.paidEventHandler = { value in
        //     let revenue = value.value.doubleValue / 1_000_000 // 微单位转换
        //     trackImpression(adFormat: adFormat, networkName: "AdMob", revenue: revenue, adUnitID: "xxx")
        // }
    }
    
    // —— MAX 获取收益信息 ——
    static func trackMAXRevenue(ad: MAAd, adFormat: String) {
        let revenue = ad.revenue // MAX 直接提供收益值
        let networkName = ad.networkName
        trackImpression(
            adFormat: adFormat,
            networkName: networkName,
            revenue: revenue,
            adUnitID: ad.adUnitIdentifier
        )
    }
}

7.5 常见问题排查清单

问题 可能原因 解决方案
Meta AN 始终 No Fill 未通过 Meta 审核 / Placement ID 错误 确认 App 已在 Meta Business 审核通过
AdMob Adapter 未初始化 GADApplicationIdentifier 未配置 检查 Info.plist
ATT 弹窗不出现 viewDidLoad 中调用太早 改到 viewDidAppear 中调用
收益极低 仅一个网络参与竞争 接入更多网络(Bidding 竞争越多收益越高)
测试时展示真实广告 未添加测试设备 使用测试 ID 或添加测试设备
崩溃:GADApplicationIdentifier AdMob App ID 格式错误 格式应为 ca-app-pub-xxxx~yyyy
Meta SDK 初始化失败 iOS Deployment Target < 13.0 升级最低版本到 13.0
MAX Debugger 显示红色 适配器版本不兼容 更新所有 Pod 到最新版本

八、收益优化最佳实践

8.1 广告展示策略

/// 广告频次控制器
class AdFrequencyManager {
    
    static let shared = AdFrequencyManager()
    
    // 配置
    private let interstitialMinInterval: TimeInterval = 60      // 插屏最少间隔 60 秒
    private let maxInterstitialsPerSession = 10                  // 每次会话最多 10 个插屏
    private let rewardedCooldown: TimeInterval = 30              // 激励视频冷却 30 秒
    
    // 状态
    private var lastInterstitialTime: Date?
    private var sessionInterstitialCount = 0
    private var lastRewardedTime: Date?
    
    /// 检查是否可以展示插屏
    func canShowInterstitial() -> Bool {
        // 检查频率限制
        if let lastTime = lastInterstitialTime {
            let elapsed = Date().timeIntervalSince(lastTime)
            if elapsed < interstitialMinInterval {
                print("⏳ 插屏冷却中,还需 \(Int(interstitialMinInterval - elapsed)) 秒")
                return false
            }
        }
        
        // 检查会话上限
        if sessionInterstitialCount >= maxInterstitialsPerSession {
            print("🚫 已达到本次会话插屏上限")
            return false
        }
        
        return true
    }
    
    /// 记录插屏已展示
    func recordInterstitialShown() {
        lastInterstitialTime = Date()
        sessionInterstitialCount += 1
    }
    
    /// 检查是否可以展示激励视频
    func canShowRewarded() -> Bool {
        if let lastTime = lastRewardedTime {
            let elapsed = Date().timeIntervalSince(lastTime)
            if elapsed < rewardedCooldown {
                return false
            }
        }
        return true
    }
    
    /// 记录激励视频已展示
    func recordRewardedShown() {
        lastRewardedTime = Date()
    }
    
    /// 重置会话计数(App 启动或从后台恢复时调用)
    func resetSession() {
        sessionInterstitialCount = 0
    }
}

8.2 收益优化清单

优化项 说明 预期效果
接入 3+ 个 Bidding 网络 竞争越多出价越高 eCPM 提升 20~50%
使用实时竞价 优于传统 Waterfall eCPM 提升 10~30%
合理控制频次 避免用户疲劳和政策违规 长期收益稳定
预加载广告 关闭后立即预加载下一个 填充率接近 100%
ATT 优化弹窗文案 提高授权率 → 个性化广告收益更高 eCPM 提升 15~30%
Banner 自适应尺寸 使用 Adaptive Banner 替代固定尺寸 eCPM 提升 10~20%
定期更新 SDK 各网络持续优化竞价算法 持续收益改善

九、项目文件结构建议

YourApp/
├── Podfile
├── Info.plist
├── AppDelegate.swift
├── SceneDelegate.swift
│
├── Ads/
│   ├── Core/
│   │   ├── AppStartupManager.swift          // 完整初始化流程(GDPR→ATT→SDK)
│   │   ├── ConsentManager.swift             // GDPR / UMP 同意管理
│   │   ├── MetaPrivacyHelper.swift          // Meta 隐私合规
│   │   ├── AdFrequencyManager.swift         // 广告频次控制
│   │   └── AdEventTracker.swift             // 收益/事件追踪
│   │
│   ├── AdMobMediation/                      // 方案一:AdMob Mediation
│   │   ├── AdMobBannerManager.swift
│   │   ├── AdMobInterstitialManager.swift
│   │   └── AdMobRewardedManager.swift
│   │
│   ├── MAXMediation/                        // 方案二:AppLovin MAX
│   │   ├── MAXBannerManager.swift
│   │   ├── MAXInterstitialManager.swift
│   │   └── MAXRewardedManager.swift
│   │
│   └── Manual/                              // 方案三(不推荐)
│       └── ManualAdManager.swift
│
├── Config/
│   ├── AdConfig.swift                       // 广告 ID 配置(开发/生产)
│   └── TestAdUnitIDs.swift                  // 测试广告 ID
│
├── Views/
│   └── ...
└── ViewControllers/
    └── ...

9.1 广告配置文件(开发/生产切换)

// AdConfig.swift
import Foundation

struct AdConfig {
    
    // MARK: - 环境切换
    
    #if DEBUG
    static let isTestMode = true
    #else
    static let isTestMode = false
    #endif
    
    // MARK: - AdMob 配置
    
    struct AdMob {
        static var bannerID: String {
            isTestMode
                ? "ca-app-pub-3940256099942544/2934735716"       // 测试
                : "ca-app-pub-YOUR_REAL_PUB_ID/BANNER_ID"       // 生产
        }
        
        static var interstitialID: String {
            isTestMode
                ? "ca-app-pub-3940256099942544/4411468910"
                : "ca-app-pub-YOUR_REAL_PUB_ID/INTERSTITIAL_ID"
        }
        
        static var rewardedID: String {
            isTestMode
                ? "ca-app-pub-3940256099942544/1712485313"
                : "ca-app-pub-YOUR_REAL_PUB_ID/REWARDED_ID"
        }
    }
    
    // MARK: - Meta AN 配置
    
    struct Meta {
        static var bannerPlacementID: String {
            isTestMode
                ? "IMG_16_9_APP_INSTALL#YOUR_PLACEMENT_ID"       // 测试
                : "YOUR_REAL_PLACEMENT_ID"                        // 生产
        }
        
        static var interstitialPlacementID: String {
            isTestMode
                ? "IMG_16_9_APP_INSTALL#YOUR_PLACEMENT_ID"
                : "YOUR_REAL_PLACEMENT_ID"
        }
        
        static var rewardedPlacementID: String {
            isTestMode
                ? "VID_HD_16_9_46S_APP_INSTALL#YOUR_PLACEMENT_ID"
                : "YOUR_REAL_PLACEMENT_ID"
        }
    }
    
    // MARK: - AppLovin MAX 配置
    
    struct MAX {
        static let sdkKey = "YOUR_APPLOVIN_SDK_KEY"
        
        // MAX Ad Unit ID(在 AppLovin Dashboard 创建)
        static let bannerAdUnitID       = "YOUR_MAX_BANNER_UNIT"
        static let interstitialAdUnitID = "YOUR_MAX_INTERSTITIAL_UNIT"
        static let rewardedAdUnitID     = "YOUR_MAX_REWARDED_UNIT"
    }
}

十、SwiftUI 集成(额外补充)

如果你的项目使用 SwiftUI,以下是适配方式:

10.1 AdMob Banner 的 SwiftUI 封装

import SwiftUI
import GoogleMobileAds

struct AdMobBannerView: UIViewRepresentable {
    
    let adUnitID: String
    
    func makeUIView(context: Context) -> GADBannerView {
        let bannerView = GADBannerView(adSize: GADAdSizeBanner)
        bannerView.adUnitID = adUnitID
        bannerView.delegate = context.coordinator
        
        // 延迟获取 rootViewController(SwiftUI 环境需要这样做)
        DispatchQueue.main.async {
            if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
               let rootVC = windowScene.windows.first?.rootViewController {
                bannerView.rootViewController = rootVC
                bannerView.load(GADRequest())
            }
        }
        
        return bannerView
    }
    
    func updateUIView(_ uiView: GADBannerView, context: Context) {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    class Coordinator: NSObject, GADBannerViewDelegate {
        func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
            print("✅ [SwiftUI] Banner 加载成功")
        }
        
        func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
            print("❌ [SwiftUI] Banner 加载失败: \(error.localizedDescription)")
        }
    }
}

10.2 在 SwiftUI View 中使用

import SwiftUI

struct GameView: View {
    
    @StateObject private var adViewModel = AdViewModel()
    
    var body: some View {
        VStack {
            // 游戏内容
            Text("Your Game Content")
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            
            // 底部 Banner 广告
            AdMobBannerView(adUnitID: AdConfig.AdMob.bannerID)
                .frame(height: 50)
        }
        .onAppear {
            adViewModel.loadInterstitial()
            adViewModel.loadRewarded()
        }
    }
}

// MARK: - 广告 ViewModel

class AdViewModel: ObservableObject {
    
    @Published var isInterstitialReady = false
    @Published var isRewardedReady = false
    
    private var interstitialAd: GADInterstitialAd?
    private var rewardedAd: GADRewardedAd?
    
    func loadInterstitial() {
        GADInterstitialAd.load(
            withAdUnitID: AdConfig.AdMob.interstitialID,
            request: GADRequest()
        ) { [weak self] ad, error in
            if let ad = ad {
                self?.interstitialAd = ad
                self?.isInterstitialReady = true
            }
        }
    }
    
    func showInterstitial() {
        guard isInterstitialReady,
              let ad = interstitialAd,
              let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let rootVC = windowScene.windows.first?.rootViewController else {
            return
        }
        
        ad.present(fromRootViewController: rootVC)
        isInterstitialReady = false
        
        // 展示后重新加载
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
            self?.loadInterstitial()
        }
    }
    
    func loadRewarded() {
        GADRewardedAd.load(
            withAdUnitID: AdConfig.AdMob.rewardedID,
            request: GADRequest()
        ) { [weak self] ad, error in
            if let ad = ad {
                self?.rewardedAd = ad
                self?.isRewardedReady = true
            }
        }
    }
    
    func showRewarded(onReward: @escaping (Int, String) -> Void) {
        guard isRewardedReady,
              let ad = rewardedAd,
              let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let rootVC = windowScene.windows.first?.rootViewController else {
            return
        }
        
        ad.present(fromRootViewController: rootVC) {
            let reward = ad.adReward
            onReward(reward.amount.intValue, reward.type)
        }
        
        isRewardedReady = false
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
            self?.loadRewarded()
        }
    }
}

10.3 AppLovin MAX 的 SwiftUI 封装

import SwiftUI
import AppLovinSDK

struct MAXBannerSwiftUIView: UIViewRepresentable {
    
    let adUnitID: String
    
    func makeUIView(context: Context) -> MAAdView {
        let adView = MAAdView(adUnitIdentifier: adUnitID)
        adView.delegate = context.coordinator
        adView.backgroundColor = .clear
        adView.loadAd()
        return adView
    }
    
    func updateUIView(_ uiView: MAAdView, context: Context) {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    class Coordinator: NSObject, MAAdViewAdDelegate {
        func didLoad(_ ad: MAAd) {
            print("✅ [SwiftUI] MAX Banner 加载成功, 来源: \(ad.networkName)")
        }
        
        func didFailToLoadAd(forAdUnitIdentifier adUnitIdentifier: String, withError error: MAError) {
            print("❌ [SwiftUI] MAX Banner 加载失败: \(error.message)")
        }
        
        func didClick(_ ad: MAAd) {}
        func didFail(toDisplay ad: MAAd, withError error: MAError) {}
        func didExpand(_ ad: MAAd) {}
        func didCollapse(_ ad: MAAd) {}
    }
}

// 使用方式
struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello World")
                .frame(maxHeight: .infinity)
            
            MAXBannerSwiftUIView(adUnitID: AdConfig.MAX.bannerAdUnitID)
                .frame(height: 50)
        }
    }
}

十一、完整的 Podfile 汇总

根据你选择的方案,使用对应的 Podfile:

方案一:AdMob Mediation(推荐快速上手)

platform :ios, '13.0'

target 'YourApp' do
  use_frameworks!

  # AdMob SDK(聚合主体)
  pod 'Google-Mobile-Ads-SDK', '~> 12.0'

  # Meta Audience Network Mediation 适配器
  pod 'GoogleMobileAdsMediationFacebook'

  # GDPR 合规
  pod 'GoogleUserMessagingPlatform'

  # (可选)更多网络
  # pod 'GoogleMobileAdsMediationAppLovin'
  # pod 'GoogleMobileAdsMediationUnity'
end

方案二:AppLovin MAX(推荐追求高收益)

platform :ios, '13.0'

target 'YourApp' do
  use_frameworks!
  inhibit_all_warnings!

  # AppLovin MAX SDK(聚合主体)
  pod 'AppLovinSDK'

  # AdMob 适配器
  pod 'AppLovinMediationGoogleAdapter'

  # Meta AN 适配器
  pod 'AppLovinMediationFacebookAdapter'

  # (可选)更多网络 - 接入越多竞争越激烈收益越高
  # pod 'AppLovinMediationUnityAdsAdapter'
  # pod 'AppLovinMediationMintegralAdapter'
  # pod 'AppLovinMediationVungleAdapter'
  # pod 'AppLovinMediationIronSourceAdapter'
  # pod 'AppLovinMediationByteDanceAdapter'     # Pangle / TikTok
  # pod 'AppLovinMediationChartboostAdapter'
end

方案三:手动管理(不推荐)

platform :ios, '13.0'

target 'YourApp' do
  use_frameworks!

  pod 'Google-Mobile-Ads-SDK', '~> 12.0'
  pod 'FBAudienceNetwork'
  pod 'GoogleUserMessagingPlatform'
end

十二、总结与推荐

最终推荐

场景 推荐方案 理由
新项目 / 追求最高收益 ⭐ AppLovin MAX 公平竞价、更多网络、详细报告
已有 AdMob 基础 / 快速接入 ⭐ AdMob Mediation 改动最小,生态成熟
学习了解原理 手动管理 仅作学习参考

关键要点回顾

  1. 一定要使用聚合平台,不要手动管理多个 SDK
  2. 优先使用 Bidding(实时竞价) 而非 Waterfall(瀑布流)
  3. 接入 3 个以上竞价网络,竞争越多收益越高
  4. 隐私合规三步走:GDPR 同意 → ATT 授权 → 各 SDK 设置
  5. 使用测试 ID 开发,上线前切换为生产 ID
  6. 预加载策略:广告关闭后立即预加载下一个
  7. 频次控制:避免过度展示导致用户流失或政策违规
  8. 定期更新 SDK:各广告网络持续优化,新版本通常带来更高收益

预期收益参考(仅供参考,受地区/品类/用户质量影响极大)

广告格式 美国市场 eCPM 参考 中国/亚洲市场 eCPM 参考
Banner 0.5 0.5 ~ 3.0 0.1 0.1 ~ 1.0
Interstitial 5.0 5.0 ~ 20.0 1.0 1.0 ~ 8.0
Rewarded Video 10.0 10.0 ~ 40.0 3.0 3.0 ~ 15.0
MREC 1.0 1.0 ~ 5.0 0.3 0.3 ~ 2.0
App Open 5.0 5.0 ~ 15.0 1.0 1.0 ~ 6.0

⚠️ 以上数据仅为行业大致参考范围。实际 eCPM 受以下因素影响极大:

  • 用户地区(T1 国家如美/英/澳/加远高于其他地区)
  • App 品类(金融、教育类 > 工具类 > 游戏休闲类)
  • 用户质量(高留存用户 eCPM 更高)
  • 接入网络数量(3+ 个 Bidding 网络可提升 20~50%)
  • ATT 授权率(授权用户 eCPM 可比未授权高 30~80%)

十三、App Store 审核注意事项

13.1 App 隐私标签(Privacy Nutrition Labels)

上架 App Store 时,需要在 App Store Connect 中如实填写隐私标签。接入广告 SDK 后,通常需要声明以下数据收集:

数据类型 是否收集 用途 是否关联用户
设备标识符 (IDFA) 第三方广告、分析 是(如用户授权 ATT)
粗略位置 第三方广告
使用数据(产品交互) 第三方广告、分析
诊断数据 分析
广告数据 第三方广告

💡 各 SDK 的隐私声明文档:

13.2 审核常见被拒原因及解决

被拒原因 描述 解决方案
Guideline 5.1.1 ATT 弹窗描述不清或存在误导 使用清晰、诚实的 NSUserTrackingUsageDescription 文案
Guideline 5.1.2 隐私标签与实际不符 根据所有接入 SDK 如实更新隐私标签
Guideline 2.3.2 广告遮挡 UI 或影响功能 确保 Banner 不遮挡按钮;插屏在合理时机展示
Guideline 3.1.1 激励视频绕过内购 激励视频只能奖励消耗型道具,不能替代订阅/永久解锁
Guideline 4.0 广告内容不当 启用 AdMob 或 MAX 的广告质量审核功能

13.3 ATT 弹窗最佳实践

// ❌ 不好的描述
"We need your permission to track you."

// ✅ 好的描述(清晰说明对用户的好处)
"此标识符将用于为您提供更相关的广告体验。您的数据不会用于其他目的。"

// ✅ 英文版
"This identifier will be used to deliver personalized ads to you. Your data will not be used for any other purpose."

提高 ATT 授权率的技巧:

/// 在弹出系统 ATT 弹窗之前,先展示一个自定义的预弹窗说明
class ATTPrePromptView: UIViewController {
    
    func showPrePrompt(from viewController: UIViewController, completion: @escaping () -> Void) {
        let alert = UIAlertController(
            title: "支持我们继续免费提供服务 🙏",
            message: """
            我们通过展示广告来维持应用免费。
            
            接下来系统会询问您是否允许追踪。
            如果您同意,我们能为您展示更相关的广告,
            同时帮助我们获得更好的收入来改进应用。
            
            您的选择不会影响广告数量。
            """,
            preferredStyle: .alert
        )
        
        alert.addAction(UIAlertAction(title: "好的,继续", style: .default) { _ in
            completion()
        })
        
        alert.addAction(UIAlertAction(title: "暂时跳过", style: .cancel) { _ in
            completion()
        })
        
        viewController.present(alert, animated: true)
    }
}

💡 自定义预弹窗可将 ATT 授权率从 ~20% 提升到 ~40%+,直接影响广告收益。


十四、上线前的检查清单 ✅

### 📋 上线前广告集成检查清单

#### 基础配置
- [ ] Info.plist 中配置了 GADApplicationIdentifier(AdMob App ID)
- [ ] Info.plist 中配置了 NSUserTrackingUsageDescription
- [ ] Info.plist 中添加了所有必需的 SKAdNetworkItems
- [ ] AppLovinSdkKey 已配置(如使用 MAX)

#### SDK 初始化
- [ ] GDPR 同意流程在 SDK 初始化之前执行
- [ ] ATT 权限请求在 SDK 初始化之前执行
- [ ] Meta ATE 标志根据 ATT 结果正确设置
- [ ] 广告 SDK 初始化在 completionHandler 中确认成功

#### 广告实现
- [ ] 所有测试 ID 已替换为生产 ID
- [ ] 测试设备代码已移除或被 #if DEBUG 包裹
- [ ] 插屏广告有频次控制
- [ ] 广告关闭后有预加载逻辑
- [ ] 加载失败有指数退避重试
- [ ] 激励视频奖励逻辑在 didRewardUser 回调中处理

#### 隐私合规
- [ ] App Store Connect 隐私标签已更新
- [ ] GDPR 同意弹窗在欧洲地区正确显示
- [ ] CCPA 合规处理(如面向美国用户)
- [ ] Meta 数据处理选项根据用户同意状态设置

#### 后台配置
- [ ] AdMob 后台已创建所有 Ad Unit
- [ ] Meta AN 后台已创建所有 Placement
- [ ] AppLovin Dashboard 已配置所有 Ad Unit(如使用 MAX)
- [ ] Mediation 组配置正确,Bidding 已启用

#### 测试验证
- [ ] 三种广告格式(Banner/Interstitial/Rewarded)均能正常展示
- [ ] 在模拟器和真机上均测试通过
- [ ] 多次打开/关闭广告无崩溃
- [ ] 网络断开时不崩溃,恢复后能重新加载
- [ ] 内存泄漏检查通过(Instruments - Leaks)

#### 收益追踪
- [ ] 广告展示事件正确上报到分析平台
- [ ] 收益数据可在 AdMob / AppLovin 后台查看
- [ ] 不同网络的填充率和 eCPM 可分别追踪

十五、参考链接汇总

资源 链接
AdMob iOS 官方文档 developers.google.com/admob/ios/q…
AdMob Mediation 文档 developers.google.com/admob/ios/m…
Meta AN iOS 文档 developers.facebook.com/docs/audien…
AppLovin MAX iOS 文档 support.axon.ai/en/max/ios/…
MAX Mediated Networks support.axon.ai/en/max/ios/…
MAX Banner 文档 support.axon.ai/en/max/ios/…
MAX Interstitial 文档 support.axon.ai/en/max/ios/…
MAX Rewarded 文档 support.axon.ai/en/max/ios/…
SKAdNetwork 配置 support.axon.ai/en/max/ios/…
AppLovin MAX SDK GitHub github.com/AppLovin/Ap…
Google UMP SDK developers.google.com/admob/ump/i…

以上就是在 iOS 应用中同时集成 Google AdMobMeta Audience Network 的完整指南。总结核心建议:

  1. 优先选择聚合方案(AdMob Mediation 或 AppLovin MAX),避免手动管理
  2. 隐私合规必须放在最优先级——GDPR → ATT → SDK 初始化
  3. 接入 3+ 个 Bidding 网络是提升收益的最有效手段
  4. 使用测试 ID 开发,上线前严格按照检查清单逐项确认

Xcode 迈入 Agent 时代 -- 肘子的 Swift 周报 #122

issue122.webp

Xcode 迈入 Agent 时代

尽管在 Xcode 26 的最初版本中,苹果就已经加入了一定的 AI 辅助编程能力,但当时的体验更像是把 ChatGPT 生硬地嵌入到 IDE 中:功能存在,却彼此割裂。与当时风头正盛的 Cursor 相比,它更像是两个时代的产物。随着 Claude Code 等 AI CLI 工具逐渐成熟,Xcode 更显得步伐迟缓,甚至让不少开发者开始怀疑:在 AI 时代,它是否还能胜任“主力 IDE”的角色。

26.3 版本的到来,几乎没有任何预热,却用实际行动回应了这些质疑。通过集成 Claude Code / Codex,苹果给出的答案很直接:只要策略得当,Xcode 依然是苹果生态中极具潜力的开发环境。这一次,Xcode 并没有简单地塞进一个 CLI 工具面板,而是引入了一套原生的 Xcode Tools(MCP),并配合 Swift 6、SwiftUI、SwiftData 等官方技术文档,形成了高度一致、贴合最新实践的整体体验。即便对于已经熟练使用 CLI + XcodeBuildMCP + 各类 Skills 的开发者而言,这套原生方案依然具备很强的竞争力——尤其是几乎为零的配置成本,这对绝大多数开发者来说意义重大。

更值得注意的是,这次提供的 Xcode Tools 并不只是服务于 Xcode 本身,它们同样可以作为标准 MCP,为其他 AI 工具提供能力支持。这种开放姿态,并不完全符合外界对苹果一贯风格的印象。

当然,站在今天这个时间点,我们还不能断言 Xcode 已经重新回到了第一阵营。但可以肯定的是,26.3 释放了一个非常明确的信号:苹果愿意与主流工具和服务协作,去打造真正符合时代的开发体验。也正因为如此,我对下一阶段的 Siri 抱有更高的期待——很可能在 iOS 27 中,苹果会在现有 Intent 体系之外,为系统和应用提供更多标准化接口,让 AI 更自然地融入整个生态。

Xcode + Agent 只是起点。

Apple + Agent,才是更值得关注的未来。

本期内容 | 前一期内容 | 全部周报列表

🚀 《肘子的 Swift 周报》

每周为你精选最值得关注的 Swift、SwiftUI 技术动态

原创

Xcode 26.3 + Claude Agent:模型替换、MCP、Skill 与自适应配置

Xcode 26.3 版本中苹果直接提供了对 Claude Code/Codex 的支持。自此,开发者终于可以在 Xcode 中方便的使用原生 AI Agent 了。 这两天我针对新版本进行了一系列尝试,包括如何使用最新模型、配置 MCPs/Skill/Command、以及编写自适应的 CLAUDE.md。本文以 Claude Code 为例,分享一些文档之外的技巧。

近期推荐

macOS 录屏软件开发实录:从像素抓取到元数据重现

视频正在取代文字成为更受欢迎的表达方式,而好工具是创作的加速器。macOS 录屏软件 ScreenSage Pro 的开发者 Sintone 深度复盘了如何基于 ScreenCaptureKit 和 Metal 实现“录完即剪完”。从解决 SCK -3821 诡异报错,到由 ObservableObject 迁移至 @Observable 优化时间线性能,本文毫无保留地分享了从像素抓取到高性能合成的全过程。


哪种方式判断字符串是否在白名单里最快:Set、Array、Enum、Dictionary 还是 switch?

在 Swift 里,判断一个字符串是否属于某个键集合,可以写成 Set.containsArray.contains、RawRepresentable enum 的 init?(rawValue:)switch 多分支,甚至用 Dictionary 来做映射。看起来差别不大,但真要放进性能敏感路径,结果可能并不完全符合直觉。Helge Heß 做了一次简单的基准测试:Set.contains 毫无悬念地领先,其次是 enum(rawValue:)Dictionary(两者非常接近);而很多人下意识会高估的 switch,反而排在 enum 之后,Array.contains 则垫底收场。作为一个小实验,这个结果或许正好可以拿来校准一下我们对 Swift 性能的直觉。


从一次性付费到 Freemium (Migrating an iOS app from Paid up Front to Freemium)

付费下载和免费 + 应用内购买是两种截然不同的商业模式,随着应用发展,开发者可能需要在两者之间转换。Donny Wals 在本文中分享了他将 Practical Core Data 应用从 $4.99 付费下载转为 freemium 的完整经历。文章不仅涵盖了 StoreKit 2 的技术实现细节(购买流程、状态管理、家庭共享),更有价值的是他对商业决策的深入思考:付费门槛虽然能筛选出认真的用户,但也阻挡了大量潜在用户体验产品价值的机会。对于教育类或工具类独立应用,freemium 可能是用户增长和收入之间更好的平衡点。


iOS 应用中的按需资源 (On-demand resources in iOS app)

应用体积一直是开发者需要关注的问题,尤其是在应用包含大量图片、音频或其他资源时。尽管苹果很早就在 iOS 中提供了 On-Demand Resources(ODR)来应对这一挑战,但这一功能的存在感并不强,常被开发者忽略。在本文中,Majid Jabrayilov 系统性地回顾了 ODR 的工作机制与使用方式,包括资源分组、标签管理、下载生命周期,以及与系统缓存策略之间的协作关系。

虽然苹果在推广 Background Assets 作为更现代的方案,但 ODR 在需要即时响应的按需下载、细粒度资源控制等场景下仍有其独特价值。


Observation 四个常见陷阱 (Objectively Better, Observably Trickier)

在全面拥抱 Observation 框架时,开发者需要警惕其工作机制与 Combine 的 @Published 并不相同,简单替换往往会引入隐蔽的问题。Danny Bolella 总结了迁移过程中四个常见陷阱:@State 持有引用类型时的非惰性初始化、嵌套 @Observable 对象导致的更新丢失、数组元素绑定方式的变化,以及与其他属性包装器产生的冲突。文章通过清晰的代码示例逐一给出解决方案,并反复强调一个核心原则:只有视图当前正在访问(调用 getter)的属性发生变化时,才会触发更新。理解并顺应这种“惰性观察”的思维方式,是正确使用 Observation 框架的关键。


在 macOS 应用中实现 Open Recent 菜单 (Add an Open Recent Menu to a SwiftUI app)

“Open Recent” 是 macOS 应用的标准功能,但对于 SwiftUI 开发者来说,正确实现这个功能并不直观。在本文中,Mark Szymczyk 通过一个简洁的示例,展示了如何利用 NSDocumentController 为应用接入系统级的最近文件管理能力:自动维护列表、更新菜单,以及与文档生命周期的无缝协作。对于文档型或工具类应用,这是一个低成本、却能明显提升“原生感”的细节优化。

工具

Radioform:一个原生、开源的 macOS 音频均衡器

macOS 一直缺少系统级的音频均衡器,由 Matthew Porteous 开发的 Radioform 填补了这个空白。该项目采用 SwiftUI 菜单栏 App + Swift Host + CoreAudio HAL Driver + C++ DSP 的分层架构,把 UI 与实时音频处理彻底解耦。DSP 部分实现了 10 段参数 EQ、参数平滑、限幅与实时安全控制;工程上也有完整 CI、签名公证与 DMG 发布流程。不是“能跑就行”的 Demo,而是接近可长期维护的生产级音频工程样板。


CircuitPro:macOS 原生的 PCB 设计工具

这是一个 macOS 原生应用较少涉足的领域:PCB 设计。CircuitPro 是一款面向 macOS 的 PCB EDA 工具,目标是把原理图、布局与元件库流程做成更符合 Apple 平台习惯的体验。(项目仍处于早期开发阶段)

项目里最吸引我的是自研的 CanvasKit。它更像一个面向 EDA 场景的 2D 交互引擎,而不只是普通画布组件:上层是声明式 CanvasView,中层是状态中枢 CanvasController,底层是输入路由、渲染树与工具系统。更关键的是,吸附、输入处理、连线引擎都被做成了协议化插拔点,让原理图和布局共享同一基础设施,同时保留各自的路由规则。

即便你对 PCB 设计本身不感兴趣,CircuitPro 也很值得关注,尤其是它在 SwiftUI + AppKit 融合架构上的工程实践。

求贤

了解二次元的 iOS 工程师

本公司是二次元文生图头部企业(总部新加坡),招聘岗位为大陆全职 remote。求职者需要了解二次元文化,懂得二次元用语(黑话)。

岗位职责 (Responsibilities):

  • 我们正在寻找一位经验丰富的 iOS 工程师(中高级),负责主导我们 iOS 应用的开发与优化工作。

  • 理想的候选人应具备深厚的 Swift 技术功底,出色的测试与团队协作能力,并拥有现代 iOS 架构及工具链的实战经验。

任职要求 (Requirements):

  • 3 年以上 iOS 开发经验,主要使用 Swift,同时具备一定的 Objective-C 代码维护能力。

  • 至少 1 年的 SwiftUI 和 SPM (Swift Package Manager) 实战经验,熟悉其生态系统及最佳实践。

  • 熟悉 iOS 15+ 新特性,能够针对不同的 iOS 版本和设备屏幕尺寸进行适配及性能优化。

  • 掌握单元测试和 UI 自动化测试 (XCTest, XCUITest),有能力编写可维护的代码,以确保项目的稳定性和可扩展性。

  • 精通 Git 工作流(Git Flow, 主干开发/Trunk-Based Development),并具备基本的代码审查 (Code Review) 技能。

  • 理解基础的 iOS 应用模块化设计、多种单页面架构模式以及性能优化方法,并具备在项目中落地的能力。

加分项/优先考虑 (We will give priority to who):

  • 拥有跨平台开发经验(满足以下任意一项即可):

  • 6 个月以上的任意前端技术栈经验 (TypeScript/JavaScript, React, React Native)。

  • 6 个月以上使用 Kotlin 及相关框架的 Android 开发经验。

  • 6 个月以上的任意后端开发框架经验。

  • 拥有至少 6 个月的 iOS 基础设施工具或框架搭建经验,包括代码质量提升(Linting, 静态分析, CI/CD)、效率优化(模块化,Gradle 组件化*)、以及性能调优(启动速度、帧率、离线模式、多线程)。

  • 拥有 1 年以上 SDK 开发经验,包括通用库开发,如图片加载库 (SDWebImage, Kingfisher)、富文本编辑器、网络层或持久化层 (SQLite, Realm, Core Data)。

  • 具备 UI/UX 相关经验

  • 熟悉 Apple 人机交互指南 (HIG),能够在理解跨平台设计差异的同时,实现符合 Apple 设计标准的 UI。

  • 拥有扎实的动画和交互动效开发经验,熟悉 Core Animation, UIKit Dynamics 等。

  • 深色模式 (Dark Mode) 及主题切换功能的开发经验。

  • 具备极强的审美感知力,拥有绘画、摄影或设计相关的技能或爱好(附带作品集者优先)。

  • 拥有完整的 App 生命周期经验:曾独立开发、发布并维护过支持多国/多语言的 iOS 应用。

  • 积极参与技术社区,例如:

  • 具有主动学习和分享的心态,有进行技术演讲的经验。

  • 有技术写作经验(博客、文章)。

  • 有开源项目贡献经历。

  • 有使用 AI 编程工具的经验,如 Claude, ChatGPT, GitHub Copilot, Cursor 或 Windsurf。

  • 具备流利的英语沟通能力或持有日语 N2 证书。

联系人

xx2bab@gmail.com

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

Xcode 迈入 Agent 时代 - 肘子的 Swift 周报 #122

Xcode 26.3 版本的到来,几乎没有任何预热,却用实际行动回应了这些质疑。通过集成 Claude Code / Codex,苹果给出的答案很直接:只要策略得当,Xcode 依然是苹果生态中极具潜力的开发环境。这一次,Xcode 并没有简单地塞进一个 CLI 工具面板,而是引入了一套原生的 Xcode Tools(MCP),并配合 Swift 6、SwiftUI、SwiftData 等官方技术文档,形成了高度一致、贴合最新实践的整体体验。即便对于已经熟练使用 CLI + XcodeBuildMCP + 各类 Skills 的开发者而言,这套原生方案依然具备很强的竞争力——尤其是几乎为零的配置成本,这对绝大多数开发者来说意义重大。

SKAdNetwork 6.0 深度实战:多窗口转化值(Conversion Value)建模与数据分层架构

摘要:随着 iOS 隐私政策的持续演进,SKAdNetwork (SKAN) 6.0 已成为移动营销衡量的新标准。本文将深入探讨 SKAN 6.0 的核心机制,重点解析如何针对三个转化窗口进行科学的转化值(CV)建模,并构建适配分层数据(Hierarchical Data)的归因架构,帮助高级 iOS 开发者与 AdTech 专家在隐私保护时代重构数据增长引擎。


一、 SKAN 6.0:从“黑盒”到“多维度透明”

SKAdNetwork 6.0(随 iOS 17.4+ 发布)在 4.0 的基础上进一步深化了隐私与效果的平衡。相比早期版本,SKAN 6.0 的核心进步在于通过多窗口回传(Multiple Postbacks)分层源标识符(Hierarchical Source IDs),提供了更长的生命周期观测能力和更灵活的数据粒度。

核心变化点:

  1. 三段式转化窗口
    • Window 1 (P1): 0-2 天,支持精细化(Fine-grained, 0-63)或粗略化(Coarse-grained)CV。
    • Window 2 (P2): 3-7 天,仅支持粗略化 CV。
    • Window 3 (P3): 8-35 天,仅支持粗略化 CV。
  2. 分层源标识符(Source ID):从 2 位扩展到 4 位,根据人群匿名度(Crowd Anonymity)阶梯式释放数据。
  3. 广告主域名(Advertising Domain):增强了网页到 App 归因的安全性与透明度。

二、 多窗口转化值(CV)建模策略

在 SKAN 6.0 中,CV 建模不再是单一维度的映射,而是一场关于“时间”与“价值”的博弈。

2.1 Window 1 (P1):精细化建模(0-63)

P1 决定了初始出价模型的准确性。建议采用“收入+行为”混合模型:

  • Bits 0-3 (Value 0-15):代表收入区间(e.g., 0,0, 0.99-$4.99, ...)。
  • Bits 4-5 (Value 16-63):代表关键转化行为(e.g., 完成新手引导、加入购物车、订阅尝试)。

2.2 Window 2 & 3 (P2/P3):粗略化建模(Low/Medium/High)

由于仅支持三个档位,建模应侧重于长期留存LTV 预测

  • Low: 用户仅启动过 App(维持活跃)。
  • Medium: 用户完成了中层转化(e.g., 累计在线时长 > 10min 或 完成 3 次关卡)。
  • High: 高价值行为(e.g., 再次复购或触发深度互动)。

2.3 锁窗机制(LockWindow)的应用

开发者可以通过 lockWindow() 提前锁定当前的转化窗口,以缩短数据回传的延迟。 实战建议:当用户触发了预期的最高价值行为(如首充)后立即锁窗,以最快速度将数据反馈给投放渠道。


三、 适配分层数据(Hierarchical Data)的架构设计

SKAN 6.0 的数据产出取决于“人群匿名度”。这种不确定性要求服务端架构具备极强的鲁棒性。

3.1 数据分层接收流程

  1. 捕获原始回传:服务端需能够处理不同粒度的 JSON。
  2. 映射解析层:根据 source-identifier 的位数(2/3/4位)决定关联的广告层级(Campaign vs Ad Group vs Creative)。
  3. 延迟修正模型:利用 Apple 定义的时间随机延迟(Window 1: 24-48h; Window 2/3: 24-144h)进行数据对齐。

3.2 代码示例:更新转化值与锁定窗口(Swift)

import StoreKit

func updateSKANConversion(revenue: Double, isDeepConversion: Bool) {
    let cvValue = calculateFineGrainedCV(revenue) // 自定义映射逻辑
    let coarseValue: SKAdNetwork.CoarseConversionValue = revenue > 10 ? .high : .medium
    
    if #available(iOS 16.1, *) {
        SKAdNetwork.updatePostbackConversionValue(cvValue, coarseValue: coarseValue) { error in
            if let error = error {
                print("SKAN Update Failed: \(error.localizedDescription)")
            }
        }
        
        // 如果是关键高价值行为,锁定窗口以加速回传
        if isDeepConversion {
            SKAdNetwork.updatePostbackConversionValue(cvValue, coarseValue: coarseValue, lockWindow: true) { error in
                // 处理回调
            }
        }
    }
}

四、 总结与最佳实践

  1. 组合建模:利用 P1 优化 CPI/tROAS,利用 P2/P3 观测用户留存。
  2. 阈值监控:实时监控 postback 中的数据粒度,若频繁出现低位 Source ID,说明样本量不足以触发隐私阈值,需调整投放预算集中度。
  3. 混合归因:将 SKAN 数据与自建的概率性归因(Probabilistic Attribution)进行交叉校验,构建更完整的用户画像。

老司机 iOS 周报 #364 | 2026-02-09

ios-weekly

在各位读者们的陪伴下老司机技术又度过了一年春秋,这一年大模型的发展出乎意料的快,我们也添加了不少相关的实践与经验,拥抱大模型享受红利也是大势所趋。下一期我们的相见就在年后 3 月初了,老司机的编辑们给大家拜年了,新春快乐!

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

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

新闻

Xcode 26.3 unlocks the power of agentic coding

Xcode 26.3 的 RC 版本已发布,大模型编程终于以一个较高的原生支持完成度来到我们身边,推荐与下方 Exploring AI Driven Coding 一文一同观看。

即将生效的 SDK 最低要求

2026 年 4 月 28 日 开始要求必须 Xcode 26 提交 App 的新版本,大家可以早做准备制定升级计划。

新手推荐

🐕 Intercepting SwiftUI Sheet Dismissal

@极速男孩:文章介绍了拦截 SwiftUI Sheet 下拉关闭的巧妙方案:利用 Presentation Detents 设置一个极小高度作为“拦截阈值”。当用户下拉至此,程序会捕捉状态并强制回弹,从而触发确认弹框。该方法不依赖复杂底层手势,实现简单且复用性强。

文章

🌟 🐕 Exploring AI Driven Coding: Using Xcode 26.3 MCP Tools in Cursor, Claude Code and Codex

@zhangferry:Xcode 26.3 带来一项重要更新:官方通过 xcrun mcpbridge 桥接工具,向外部 MCP 客户端开放了 20 个原生工具接口。这一举措相较苹果以往的产品策略,显得尤为开放。其核心交互机制为:外部 MCP 客户端(如 Cursor、Claude Code)<-> mcpbridge 桥接工具 <-> Xcode(基于 XPC 通信)。

该功能依托 Xcode 内部运行的 MCP 服务实现,第三方智能代理(Agent)可通过这一链路调用 Xcode 的 MCP 能力,所以该功能无法脱离 Xcode 独立运行。在开放的工具中,除基础的文件读取类工具外,以下几款实用工具值得关注:

  • BuildProject:构建工程,可快速验证工程代码的可编译性;
  • GetBuildLog:获取构建日志,助力优化构建流程,也可用于排查构建环节的潜在问题;
  • XcodeListNavigatorIssues:提取 Xcode 导航栏中展示的各类 issue 或 error 信息,便于快速定位代码问题;
  • RenderPreview:将 SwiftUI 预览界面渲染为图片文件,直观预览 UI 效果;
  • ExecuteSnippet:实时执行代码片段,可快速验证小段代码的执行逻辑与结果。

🐕 The Magic Behind UUID() in Swift, How Your App Generates Truly Unique Identifiers

@Barney:本文介绍 Swift 中 UUID () 的原理与特性,默认生成 Version 4 随机 UUID。它基于 122 位加密安全随机数,从硬件、系统等多源收集熵值,经 CSPRNG 处理,按 RFC 4122 格式化,唯一性几乎绝对。还提及 Version 1(时间 + MAC 地址)和 Version 7(时间 + 随机),并说明 UUID 高效且无碰撞顾虑。

🐕 Swift Modules and Code/Assets Duplication

@Smallfly:这篇文章针对 Swift 模块化开发中的代码与资源重复问题,提供了简洁高效的解决方案。核心内容包括:

  • 模块化痛点:使用 Swift Package 拆分模块后,静态链接导致代码在主应用与扩展(如 Action Extension)中重复;资源文件(图片、本地化字符串等)会生成独立 bundle 并被复制到每个依赖 target,增加包体积。
  • 代码去重:创建动态框架聚合所有模块,通过 Package.swift 配置动态库目标,让主应用与扩展依赖该框架,避免代码重复链接。
  • 资源去重:利用 Run Script 脚本将模块资源 bundle 移动到动态框架内,删除主应用与扩展中的重复 bundle;结合 Bundle.module 的查找逻辑,确保资源访问路径正确。

文章通过具体代码示例与目录结构对比,展示了优化前后的效果,为 Swift 开发者解决模块化带来的包体积问题提供了可落地的实践方案。

Attach to Multiple Processes

@ChengzhiHuang:调试工作通常只涉及单个主应用进程。但随着 App Extension、XPC 服务以及更复杂的 macOS 应用架构变得普遍,我们偶尔需要同时关注多个进程。然而 Xcode 在这方面的支持却不尽如人意:它能自动 attach 到你构建的 XPC 服务,但对于非 XPC 的子进程或多个同名进程实例,就显得力不从心,需要手动逐一操作,效率低下。对此作者提供了一个脚本查找所有指定名称的进程,并让 Xcode 的调试器一次性全部 attach 到它们上面。适合有特定需求的同学阅读。

🐎 How to Avoid Double Updates When Filtering SwiftUI TextField Input

@阿权:文章为文本输入组件的文字 filter 提供了通用的解决方案。本文核心解决 SwiftUI TextField 过滤输入时的 “双重更新” 问题,核心思路封过滤 / 转换逻辑,以解耦上下游逻辑。具体思路如下:

  1. 内部缓冲区隔离,引入 “内部缓冲区(internalText)” 与 “外部绑定(text)” 分离的设计:
    1. 用户输入路径:键盘输入 → 内部缓冲区 → 过滤 / 转换 → 同步到外部绑定(仅有效值);
    2. 外部更新路径:外部绑定修改 → 过滤 / 转换 → 同步到内部缓冲区(确保 UI 一致);
  2. 注意事项:
    1. 光标位置:编程修改文本可能导致光标重置,复杂场景需适配;
    2. 转换规则:需保证确定性和幂等性,避免同步循环。

🐎 Understanding Spring Animations in SwiftUI

@含笑饮砒霜:这篇文章讲解 SwiftUI 中的弹簧动画,说明其模拟物理运动、适配用户直接交互的特性,优于 easeInOut 等动画;介绍了该动画的默认用法、响应速度和阻尼系数两个可调参数,withAnimation 和.animation (_:value:) 两种触发方式,以及手势适配的.interactiveSpring () 修饰符,并结合实操案例展示应用,同时明确其适用于用户触发的操作,加载类自动界面变化则更适合线性 / 缓入缓出动画,强调其能提升应用的交互体验。

🐎 InlineArray in Swift - Memory Efficient Fixed-Size Arrays

@JonyFang: 本文介绍了 Swift 6.2 新增的 InlineArray 类型,一种固定大小、值类型的内联数组。与标准 Array 的堆分配不同,InlineArray 将元素直接存储在值内部,消除了堆分配、引用计数和指针间接访问的开销。核心要点:

  • 声明与约束:大小是类型的一部分(如 InlineArray<Int, 4>),编译期即确定容量,必须提供恰好该数量的元素,不可 append 或 remove,提供强编译期保证。
  • 性能优势:元素连续内联存储,无需指针追踪,CPU 缓存命中率更高,适合数学运算、几何类型、小型缓冲区及框架内部结构等场景。
  • 实际应用:文章以 3D 向量为例,将 var values: [Float] 替换为 InlineArray<Float, 3>,在语义更清晰的同时实现零堆分配。支持标准 for-in 迭代,也可通过 Array(inline) 转换为普通数组。
  • 适用边界:适合小型固定集合、性能关键路径和框架内部实现;不适合需要 append()、filter()、map() 等动态操作的场景。文章强调 InlineArray 是专用工具而非通用集合,日常应用代码仍推荐标准 Array。

🐎 Non-Sendable First Design

@DylanYang:作者向我们讲述了借助 NonisolatedNonsendingByDefault ,Non-Sendable 类型目前成为了非常适合作为首选的类型,它更简单、没有额外的语法负担,也更通用,没有太多限制不像 Actor。当然 Non-Sendable 也有一些缺点,比如配合 Task 的场景等。开发者可以根据实际需求来做出适合的选择。

工具

steve

@EyreFree:steve 是基于 macOS 无障碍 API 开发的命令行工具,主打 Mac 应用的自动化操控,适用于自动化测试与 AI 代理控制场景。它支持通过命令完成应用的启动、聚焦、退出等基础管理,还能发现和定位应用界面元素,实现点击、输入、快捷键触发等交互操作,亦可对窗口、菜单栏进行操控,以及截取应用或指定元素的截图。工具默认输出结构化文本,也支持 JSON 格式,提供断言、等待等可靠性辅助功能,能通过 stderr 反馈错误。对 Mac 端应用自动化操作有需要的同学可以试试。

代码

🐎 三国霸业 / 伏魔记重制版源码

@Crazy:一个多平台游戏项目集合,包含几个移植版本的游戏,其中包括在电子词典上非常经典的《三国霸业》与《伏魔记》,尤其是《伏魔记》最后的师尊 boss 更是令人记忆犹新。该项目将多个经典的游戏进行和移植,并且将源码也提供了出来,大家可以在回味童年的时候去学习下小游戏的开发也是一种非常不同的感觉。

内推

重新开始更新「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)

APP原生与H5互调Bridge技术原理及基础使用

API使用

js调用原生插件功能

调用命名为'11'的插件里的一个定时器api:jsCallTimer

带回调结果带参数的调用方式:

YN.callNative('11',"jsCallTimer",'我是传到原生端的参数',function (value) {
      if (a == 1){
        document.getElementById("progress1").innerText = value
      }else{
        document.getElementById("progress2").innerText = value
      }
    },function (error) {
      alert(error)
    })

不带回调结果带参数的调用方式:

YN.callNative('11',"jsCallTimer",'我是传到原生端的参数')

不带回调结果不带参数的调用方式:

YN.callNative('11',"jsCallTimer")

原生调用js插件功能

调用命名为'asynObj'的插件里的一个定时器api:startTimer

带回调结果带参数的调用方式:

[dwebview callHandler:@"asynObj" action:@"startTimer" arguments:@"我是传到js端的参数" completionHandler:^(CallbackStatus status, id  _Nonnull value, NSString * _Nonnull callId, BOOL complete) {
        [sender setTitle:[NSString stringWithFormat:@"%@-%@",value,callId] forState:0];
    }];

不带回调结果的调用方式:

[dwebview callHandler:@"asynObj" action:@"startTimer" arguments:@"我是传到js端的参数" completionHandler:nil];

一些全局约定

  • js调原生和原生调js的参数传递必须是json字符串格式。

  • api调用,底层逻辑必须使用命名空间方式即:namespace.apixxx的形式。

  • 还有很多规范和约定,后续补充。

js call native

关键技术点

原生Android端向浏览器注入供js调用的对象‘_anbridge’,对象里实现‘call()’方法,并且方法需要加上@JavascriptInterface注解,代码示例:

WebSettings webSettings = wv.getSettings();
webSettings.setJavaScriptEnabled(true);
wv.addJavascriptInterface(new JsApp(),"_anbridge");
class JsApp{
  public JsApp(){}
  @JavascriptInterface
  public void call(Object obj){

  }
}

原生iOS端

向浏览器配置对象里注入‘window._ynwk=true;’这段js代码,并且设置注入时机为开始加载时即:injectionTime=WKUserScriptInjectionTimeAtDocumentStart,代码实现:

///初始化注入js标记
    WKUserScript *script = [[WKUserScript alloc] initWithSource:@"window._ynwk=true;"
                                                  injectionTime:WKUserScriptInjectionTimeAtDocumentStart
                                               forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:script];

实现js端换起原生通信的关键是实现wk的h5输入框拦截回调方法- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler
当js端执行代码‘prompt()’时原生端就会自动调起该方法

在上面实现的基础上,js端判断window._anbridge为true则为与Android通信,执行代码:_anbridge.call(api, arg),如果判断window._ynwk为true则为与iOS端通信,执行代码:prompt('_ynbridge=' + api, arg),js端代码实现:

var natiValue = '';
if (window._anbridge)
   natiValue = _anbridge.call(api, arg);//调用android对象的call()
else if (window._ynwk)
   natiValue = prompt('_ynbridge=' + api, arg);

原生端、js端提供的api都要通过命名空间的方式管理,如:api_1在‘namespace1’这个命名空间下的类里面,则js端调用api_1书写形式为‘namespace1.api_1’。

原生端和js端提供的功能都以插件的方式提供,插件(除基础插件)都继承自一个基础插件类,插件结果回调都是走异步回传值方式,同步方式也可以但暂没实现。

iOS端逻辑步骤

基础插件对象是处理js通讯和插件扩展的必要条件,wk浏览器初始化好后将基础插件类注册进插件集合,然后读取配置文件里可用的其他插件,将每个插件类注册进插件集合,代码实现:

//注册基础插件
    [self addJavascriptObject:self.ynPlugin namespace:baseNameSpace];
    //注册已有插件
    NSString* plistPath = [[NSBundle mainBundle] pathForResource:@"applyPlugPlist" ofType:@"plist"];
    NSArray *modules = [NSArray arrayWithContentsOfFile:plistPath];
    for (NSDictionary *obj in modules) {
        Class class = NSClassFromString(obj[@"plug"]);
        if (class != nil && ![class isKindOfClass:[NSNull class]]) {
            [self addJavascriptObject:[[class alloc] init] namespace:obj[@"namespace"]];
        }
    }
  1. js端的第一个信号来自wk的h5输入框拦截回调方法,参数prompt里携带js端要调用的api名字,参数值为字符串:_ynbridge=namespace1.api_1,_ynbridge=为YNBridge框架调用的标记,如果不是以这个标记开头则不做任何处理,只弹出正常的系统弹框。

  2. 通过api名,去插件集合里找有没有注册对应的插件对象,如果没有找到或找到了但插件下没有对应api则将错误结果返回js端

  3. js调起的api,参数由defaultText携带。defaultText是json字符串,需要转换为json对象来解析出数据,参数值示例:{"data":null,"callId":"callId0"} data:真实参数值。 callId:api调用事件id或叫回传值队列id,当次api调用js需要回传值时此参数不为空,如果为空则表示当次api调用js端不需要结果回调

  4. -(BOOL)exec:(YNJsCallInfo*)arg 此方法是插件接收数据的入口,这是个工厂方法子类必须实现,解析和组装好js过来的api和参数后用反射的方式执行对应插件的exec:方法,该方法同步方式返回个bool值,表示调用成功或失败,如果失败则将失败结果返回给js,代码实现:

BOOL(*action)(id,SEL,id) = (BOOL(*)(id,SEL,id))objc_msgSend;
    BOOL ret=action(JavascriptInterfaceObject,sel,info);
    if (ret) {
        return YES;
    }
    return [self nativeCallBackWithCode:ret ? OK : ERROR value:ret ? @"OK" : error complete:YES callId:info.callId];
  1. exec:方法的形参是YNJsCallInfo对象,该对象携带的参数:
    action:api名,或叫动作标识字符串,各业务通过该字段判断该执行什么功能,如果插件内没有处理该api则返回调用失败的错误值false反之返回true。
    callId:api调用事件id或叫回传值队列id,当给js回传值时需要带上该值返回去。
    data:js给过来的参数值。
    callBack:block变量,结果回调入口,回传值时需要指定四个参数status、value、callId、complete,参数用处后面讲解。

  2. 功能实现完成后需要调用YNJsCallInfo对象的callBack回调方法,方法参数:
    status:结果状态值,此值为一个枚举类型,OK表示成功ERROR表示失败。
    value:结果值,该值最后在调用js回传值api时会转换为json字符串格式。
    callId:api调用事件id或叫回传值队列id。
    complete:bool值,当次api任务是否全部执行完毕,处理需要保活服务的长连接状态,false执行完毕,true服务需要继续保持。

  3. api调用完毕,需要给js回传值时,调用wk的- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler方法 执行这段js代码:window.nativeCallBack('%@',%ld,%@,%d),nativeCallBack()是js端接收原生端回传值的方法,接收四个参数,即为YNJsCallInfo对象的callBack回调参数。

  4. 原生功能通过插件的形式实现,要新增一个插件只需要: 第一步新建一个继承'YNPlugin'基础插件类的对象,然后在对象里实现方法-(BOOL)exec:(YNJsCallInfo*)arg; 第二步在YNBridgePlugPlist.plist文件里添加以下形式的代码

<dict>
        <key>namespace</key>
        <string>命名空间</string>
        <key>plug</key>nativeCallBack
        <string>插件类名</string>
</dict>

然后将命名空间名和相应的api名告诉js端即可

js端

调起一个原生插件时,执行YN对象里面的callNative: function (service,action,actionArgs,successCallback,failCallback)方法,方法参数:
service:原生api对应的命名空间名。
action:api名。
actionArgs:需要给原生端的参数。
successCallback:成功的回调。
failCallback:失败的回调。 比如我要调起原生端11命名空间下的jsCallTimer这个api,让原生端执行一个定时器功能,代码实现:

YN.callNative('11',"jsCallTimer",undefined,function (value) {
      if (a == 1){
        document.getElementById("progress1").innerText = value
      }else{
        document.getElementById("progress2").innerText = value
      }
    },function (error) {
      alert(error)
    })
  1. 执行YN.call()方法,实现调起原生和结果回调队列的维护,如果注入过安卓js对象‘window._anbridge’则执行_anbridge.call(api, arg)调起安卓端,如果注入过‘window._ynwk’值为true则执行prompt('_ynbridge=' + api, arg)调起iOS端,如果需要有回传值,则arg对象将给callId字段赋一个唯一值,并且在window.nativeCallBackIds缓存集合里新增callId值,值即为回调函数。

  2. 所有插件调用的前提基础是js端和原生端都已正常初始化,并且通讯已建立,即deviceReady已为ture,deviceReady的询问会在js入口函数里执行,即通过YN.call()方法,执行一个原生YNBase.init的api,如果结果返回为OK则为deviceReady成功

  3. 原生端插件执行结果回调通过‘nativeCallBack = function (callId,status,args,complete)’方法接收值,方法内部通过callId在window.nativeCallBackIds对象里找到回调方法然后执行,将args值由json字符串转json对象后传入,判断complete字段,为true则执行:delete window.nativeCallBackIds[callId]代码,将该服务回调移除队列。

native call js

js端

  1. 实现思路和设计方式同js call native,即只是其一个反向过程,实现基础依然是需要实现和注册基础插件类,各子插件继承基础插件,结果回调都是通过异步回传值,所以细节不做重复阐述。
  2. 在入口函数执行基础插件和各插件对象的注册,注册完成后可以调用原生YNBase.jsinit这个api告诉原生端,代码实现:
YN.register('asynObj',new YNPlugin());
   YN.register('YNPlugin1',new YNPlugin1());
  //告诉原生js初始化了,调原生初始化api(在js初始化前原生就要求执行的js方法可在jsinit方法里开始执行了)
  if (deviceReady){
    YN.call('YNBase.jsinit');
  }

register()方法内部实现同原生注册插件的形式,将插件和对应的命名空间添加进window.nativeNamespaceInterfaces集合。

  1. 接收原生端第一个信号由nativeCallJs = function(callId,service,action,actionArgs)方法接收,参数:
    callId:api调用事件id或叫回传值队列id。
    service:js api对应的命名空间名。
    action:api名。
    actionArgs:原生端的参数。 方法内部实现同原生插件调用,也是找到插件并执行插件方法exec(action,args,responseCallback)。

  2. 插件回传值结果和api调用结果通过调用原生的YNBase.returnValue这个api实现,即执行YN.call('YNBase.returnValue', value); value是参数对象,包含data、callId、complete、status四个字段,含义和用途同原生回调那里。

iOS端

  1. 调起一个js端的插件功能,执行wk对象的方法-(void)callHandler:(NSString*)server action:(NSString *)action arguments:(id)args completionHandler:(JSCallback)completionHandler;该方法逻辑同js call native时调用的YN.call()方法,通过维护一个callid服务队列来处理结果回传。

  2. 组装好参数后浏览器执行window.nativeCallJs('%@','%@','%@',%@)这个js代码即可调起js,代码示例:

[self evaluateJavaScript:[NSString stringWithFormat:@"window.nativeCallJs('%@','%@','%@',%@)",info.callId,info.service,info.action,[JSBUtil objToJsonString:info.args]]];

接收插件结果回传值在基础插件里监听returnValue这个api的执行,逻辑处理同js端nativeCallBack()方法。也是如果complete字段值为true时将该服务对象从队列里移除

❌