祝大家马年新春快乐! - 肘子的 Swift 周报 #123
在这个 60 年一遇的吉庆节点,我在此祝各位读者新的一年:身体健康(CPU 满血),事业驰骋(性能优化),万事顺遂(无 Bug 运行),马到成功(编译通过)! 🎉
在这个 60 年一遇的吉庆节点,我在此祝各位读者新的一年:身体健康(CPU 满血),事业驰骋(性能优化),万事顺遂(无 Bug 运行),马到成功(编译通过)! 🎉
| 环境变量 | 是否必须 | 作用 |
|---|---|---|
PATH |
✅ 必须 | 让终端能找到 flutter 和 dart 命令 |
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
~/.zshrc,不是 ~/.bash_profile
\r 换行符,macOS 无法执行,需要在 macOS 上重新下载解压核心原因:让系统知道去哪里找程序。
当你在终端输入 flutter --version 时,系统不会搜遍整个电脑找 flutter,它只会去 PATH 环境变量列出的目录 里找。
# 查看当前 PATH 里有哪些目录
echo $PATH
# ❌ 不配置 PATH,每次必须写完整路径
/Users/hongliangchang/development/flutter/bin/flutter --version
# ✅ 配置了 PATH,直接输名字
flutter --version
通俗比喻:好比手机通讯录存了一个人的号码(配置 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 有 sh、bash、zsh、fish 等,它们功能类似但各有增强。
| 名称 | 全称 | 含义 |
|---|---|---|
| sh | Bourne Shell | 最古老的 Shell,以作者 Stephen Bourne 命名 |
| bash | Bourne Again Shell | sh 的增强版,"重生的 Bourne Shell"(双关语 born again = 重生) |
| zsh | Z Shell | bash 的增强版,名字来自普林斯顿助教邵中(Zhong Shao)的用户名 |
sh(祖宗)
└── bash(儿子,增强版)
└── zsh(孙子,更强大)
查看当前 Shell:
echo $SHELL
# /bin/zsh → 用的 zsh
# /bin/bash → 用的 bash
bash 新版本改用了 GPLv3 许可证,苹果不愿接受。
GPLv3 的核心要求:如果你在产品中使用了 GPLv3 的软件,用户修改了这个软件后,你必须允许用户把修改版装回设备运行。
这和苹果的封闭生态冲突——macOS/iOS 的系统文件都有代码签名,不允许用户随意替换。
通俗比喻:苹果卖你一辆车,车里装了一台 GPLv3 的发动机。GPLv3 说车主可以自己改造发动机并装回去,但苹果不愿意让你动它的车。所以苹果换了一台 MIT 许可的发动机(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 的作者是 Paul Falstad,1990 年在普林斯顿大学读书时开发。当时有个助教叫邵中(Zhong Shao),他的登录用户名是 zsh,Paul 觉得这名字结尾是 sh,很像一个 Shell 的名字,就直接拿来用了。
邵中本人和 zsh 的开发没有任何关系,他后来成为了耶鲁大学计算机科学系教授,研究编程语言和编译器。
春节将至,回乡的路依然开始堵车。对应AppStore来讲也是全民消费与线上活动进入高峰期。 昨天依然有海量 iOS 开发者集中提交新 App 与版本更新,直接导致App Store 审核队列拥堵、等待时长大幅拉长。
最常用的海外账号Buff都受到严重的影响:
错峰提交:尽量在节后1–2 周完成提审,避开春节拥堵高峰
精简更新:非紧急功能延后上线,只发必更版本
提前自检:先过一遍隐私政策、权限说明、内购协议、元数据,减少被拒重提
用好加急通道
预留缓冲:春节上线计划按最长 7 天审核倒排工期
春节期间,需安排好值班人员,常规巡检App运行情况。同时,更需要关注开发者苹果邮箱,遭遇AppStore竞品的“敌袭”,错过最佳抢救时间。
遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!
# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。
Swift 6 带来的 Sendable 协议是并发安全领域的重要升级,它强制要求跨线程传递的类型具备明确的线程安全语义。但在实际开发中,我们常会陷入一个两难境地:既要满足 Sendable 对不可变引用(let)的要求,又要保证非线程安全对象的并发访问安全。本文将介绍我封装的 AALock 工具库,它既能完美适配 Swift 6 Sendable 检查,又能以极简的方式实现线程安全,让你的代码在 Swift 6 并发模型下既合规又优雅。
本组件的设计思路参考了 iOS 18 原生 mutex 锁的设计理念,通过封装适配层实现了低版本 iOS 系统的兼容使用,既保留了原生高性能特性,又解决了不同系统版本下线程安全锁的适配问题。
Sendable 协议的核心要求之一是:符合 Sendable 的类型,其属性应优先使用 let(不可变)修饰。如果类型中存在 var 修饰的引用类型属性(比如 var dict: [String: Any]),编译器会直接判定该类型不满足 Sendable,导致无法安全地跨 actor/线程传递。
但现实场景中,我们不可能所有数据都做成不可变——业务逻辑必然需要修改数组、字典、自定义对象等,直接用 let 修饰非线程安全对象,又会带来并发访问的线程安全问题。
为了兼顾 Sendable 和线程安全,传统做法通常有两种,但都有明显缺陷:
var 修饰属性 + 手动加锁。直接违反 Sendable 对不可变引用的要求,编译器报错,无法通过检查;AALock 的核心目标是:让非线程安全对象通过 let 修饰仍能安全修改,同时满足 Sendable 检查。其设计围绕两个核心封装展开:
let 修饰 AALock 包装后的对象(满足 Sendable 对不可变引用的要求);| 组件 | 适用场景 | 核心优势 |
|---|---|---|
AAUnfairLock |
通用互斥场景 | 基于系统 os_unfair_lock,性能优于 NSLock,无递归重入 |
AARWLock |
读多写少场景 | 读写分离,读操作并发执行,写操作互斥,性能远超普通互斥锁 |
AALockedValue |
通用线程安全封装 | 基于 AAUnfairLock,包装任意类型,闭包式操作,自动加解锁 |
AARWLockedValue |
读多写少的高性能场景 | 基于 AARWLock,读写锁分离,最大化读操作并发性能 |
通过 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")")
}
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 避免继承带来的线程安全风险;_value 用 var 修饰(仅内部可变),对外暴露 let 容器;Sendable 协议,可直接跨 actor/线程传递。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()
读多写少场景下,读写锁性能远超普通互斥锁:
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)")
}
// 自定义 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 警告
}
let 修饰包装后的对象,满足 Sendable 对不可变引用的要求;Sendable,无编译器警告,直接通过 Swift 6 严格检查。os_unfair_lock 实现 AAUnfairLock,性能远超 NSLock/pthread_mutex_t;AARWLock 针对读多写少场景做优化,读操作并发执行,性能提升数倍。lock()/unlock() 导致的漏解锁、死锁问题;withLock/withReadLock/withWriteLock),一看就会;Swift 6 的 Sendable 协议是未来并发编程的标配,而线程安全是跨线程开发的基础要求。AALock 既解决了 Sendable 对不可变引用的强约束,又通过极简的 API 实现了线程安全,让开发者无需在“合规”和“易用”之间妥协。
AALock 集成到项目中(支持 CocoaPods/Carthage/Swift Package Manager);var 修饰的非线程安全属性,替换为 let 修饰的 AALockedValue/AARWLockedValue;withLock/withReadLock/withWriteLock 操作内部数据,无需手动加锁。AALock 让 Swift 6 并发编程更简单、更安全、更合规,如果你也在适配 Swift 6 Sendable,或者需要优雅解决线程安全问题,不妨试试这个封装——它会成为你 Swift 6 并发开发的“瑞士军刀”。
项目地址:GitHub - AALock
欢迎 Star、Fork、PR,一起完善 Swift 6 并发安全生态!
本文从底层原理、横向对比、纵向深度、性能优化、难点问题、高难度原理六大维度,对 Swift 语言进行全面、细致、深入的梳理。
| 维度 | 值类型 (Value Type) | 引用类型 (Reference Type) |
|---|---|---|
| 代表 | struct, enum, tuple | class, closure |
| 存储 | 栈(小对象)/ 堆(大对象或含引用) | 堆 |
| 赋值语义 | 拷贝(Copy-on-Write 优化) | 共享引用 |
| 线程安全 | 天然线程安全(独立副本) | 需要同步机制 |
| 引用计数 | 无 | 有 ARC |
| 继承 | 不支持(enum/struct) | 支持(class) |
| deinit | 不支持 | 支持 |
| Identity | 无 === 操作 | 有 === 操作 |
struct 布局:
class 布局:
标准库 COW 实现(Array/Dictionary/Set/String):
_ArrayBuffer)isKnownUniquelyReferenced(&buffer)
自定义 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
}
}
}
}
retain/release 调用atomic_fetch_add 等),保证线程安全InlineRefCounts (8 bytes):
┌─────────────────────────────────────────────────┐
│ strong RC (32 bit) │ unowned RC (31 bit) │ flags │
└─────────────────────────────────────────────────┘
| 维度 | 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(unsafe) 可以跳过检查,行为类似 C 的悬垂指针,性能最高但最危险weak 的底层机制:
场景一:两个对象互相持有
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 了
}
}
autoreleasepool { } 在 Swift 中仍然可用,用于循环中大量创建临时 ObjC 对象时控制内存峰值作为泛型约束(Static Dispatch):
func process<T: MyProtocol>(_ value: T) { value.doSomething() }
作为存在类型(Dynamic Dispatch):
func process(_ value: MyProtocol) { value.doSomething() }
// Swift 5.6+ 显式写法:func process(_ value: any MyProtocol)
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 策略:
Protocol Witness Table (PWT):
Value Witness Table (VWT):
func process(_ value: ProtocolA & ProtocolB) { ... }
protocol MyDelegate: AnyObject { ... }
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" —— 静态派发!走协议扩展的默认实现
关键区别:
Swift 泛型采用类型擦除 + 运行时传递元数据的策略(不同于 C++ 的完全模板实例化):
func swap<T>(_ a: inout T, _ b: inout T) { ... }
// 编译器优化后可能生成:
// swap_Int(...) ← 针对 Int 的特化版本
// swap_String(...) ← 针对 String 的特化版本
// swap_generic(...) ← 通用版本(需要 metadata)
特化条件:
@inlinable)跨模块特化:
@inlinable 将函数体暴露给其他模块,允许跨模块特化@frozen 将 struct 布局暴露给其他模块问题: 带 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() }
}
原理: 用闭包捕获具体类型实例,对外暴露统一的泛型接口,擦除了具体类型信息。
| 维度 |
some Protocol (Opaque Type) |
any Protocol (Existential Type) |
|---|---|---|
| 底层 | 编译期确定的固定类型(对调用者隐藏) | 运行时动态类型(existential container) |
| 派发 | 静态派发 | 动态派发(PWT) |
| 性能 | 高(无间接开销) | 低(堆分配 + 间接调用) |
| 类型一致性 | 同一函数返回的 some P 保证是同一类型 | 不保证 |
| 适用 | 返回值、属性 | 参数、集合元素 |
| 派发方式 | 速度 | 机制 | 适用场景 |
|---|---|---|---|
| 内联 (Inline) | 最快 | 编译器将函数体直接插入调用点 | 小函数、@inline(__always)
|
| 静态派发 (Static/Direct) | 快 | 编译期确定函数地址,直接 call | struct 方法、final 方法、private 方法 |
| 虚表派发 (V-Table) | 中 | 通过类的虚函数表间接调用 | class 的非 final 方法 |
| 消息派发 (Message) | 慢 | ObjC runtime 的 objc_msgSend | @objc dynamic 方法 |
Class Metadata:
┌──────────────────────┐
│ isa (指向 metaclass) │
│ superclass pointer │
│ cache (ObjC 兼容) │
│ data (ObjC 兼容) │
│ ... │
│ V-Table: │
│ [0] → method1() │
│ [1] → method2() │
│ [2] → method3() │
│ ... │
└──────────────────────┘
| 声明位置 | 修饰符 | 派发方式 |
|---|---|---|
| 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" —— 静态派发
| 修饰符 | 作用 | 派发方式 |
|---|---|---|
@objc |
将方法暴露给 ObjC runtime | 仍然是 vtable(Swift 侧) |
dynamic |
使用 ObjC 消息派发 | objc_msgSend |
@objc dynamic |
暴露给 ObjC 且使用消息派发 | objc_msgSend(可被 KVO/method swizzling) |
闭包在 Swift 中是一个引用类型,底层结构:
Closure = 函数指针 + 上下文 (Context)
┌─────────────────────────┐
│ Function Pointer │ → 指向闭包体的代码
│ Context (Capture List) │ → 堆上分配的捕获变量
└─────────────────────────┘
默认捕获:引用捕获(变量)
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 —— 捕获的是值的拷贝
| 维度 | @escaping |
非逃逸(默认) |
|---|---|---|
| 生命周期 | 超出函数作用域 | 函数返回前执行完毕 |
| 堆分配 | 必须堆分配 context | 编译器可能优化到栈上 |
| 捕获 self | 需要显式 self.
|
不需要 |
| 性能 | 有堆分配开销 | 可能零开销 |
withoutActuallyEscaping: 允许将非逃逸闭包临时当作逃逸闭包使用(高级场景)。
assert、?? 等需要短路求值的场景() -> T
func logIfTrue(_ condition: @autoclosure () -> Bool) {
if condition() { print("True") }
}
logIfTrue(2 > 1) // 2 > 1 被自动包装为 { 2 > 1 }
enum Direction { case north, south, east, west }
// sizeof = 1 字节(只需要区分 4 个 case,1 字节足够 256 个)
enum Result {
case success(Int) // payload: 8 字节
case failure(String) // payload: 16 字节
}
// sizeof = max(payload) + tag = 16 + 1 = 17,对齐到 8 → 24 字节
// Optional<T> 就是:
enum Optional<Wrapped> {
case none
case some(Wrapped)
}
指针类型的 Optional 优化:
Optional<AnyObject> 只占 8 字节(和非 Optional 一样!)0x0,所以 none 用全零表示,some 用有效指针值Optional<Bool> = 1 字节(Bool 只有 0/1,用 2 表示 none)indirect enum Tree {
case leaf(Int)
case node(Tree, Tree)
}
indirect 时,Tree 大小会无限递归(编译错误)indirect 让关联值通过堆上的 Box 间接引用case node(Box<Tree>, Box<Tree>),Box 是引用类型| 操作 | struct (栈) | class (堆) |
|---|---|---|
| 分配 | 移动栈指针(1条指令) | malloc 系统调用(涉及锁、空闲链表搜索) |
| 释放 | 移动栈指针 | free + 引用计数归零检查 |
| 速度比 | ~1ns | ~25-100ns |
[MyStruct]:连续内存,cache 友好[MyClass]:数组存的是指针,实际对象分散在堆上,cache miss 率高Character 是扩展字形簇 (Extended Grapheme Cluster)
Character 可能对应多个 Unicode 标量(如 emoji 👨👩👧👦 = 7 个标量)String.count 是 O(n) 复杂度(需要遍历确定字形簇边界)String.Index 不是整数,是不透明的偏移量,因为字符宽度不固定| 视图 | 元素 | 场景 |
|---|---|---|
string.utf8 |
UTF8.CodeUnit (UInt8) | 网络传输、C 交互 |
string.utf16 |
UTF16.CodeUnit (UInt16) | NSString 兼容 |
string.unicodeScalars |
Unicode.Scalar | Unicode 处理 |
string (默认) |
Character | 用户可见字符 |
String 被传给 ObjC API 时可能创建临时 NSString(autorelease 对象)| 类型 | 内存 | 本质 |
|---|---|---|
| 存储属性 | 占实例内存 | 实际的内存字段 |
| 计算属性 | 不占内存 | getter/setter 方法 |
| lazy 属性 | Optional 存储 | 首次访问时初始化 |
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。
@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 }
}
\Base.a.b.c)、运行时读写WritableKeyPath、ReferenceWritableKeyPath 等继承层级actor BankAccount {
var balance: Double = 0
func deposit(_ amount: Double) { balance += amount }
}
底层原理:
await(可能涉及线程切换)Actor 隔离 (Isolation):
await(异步访问)nonisolated 标记的方法可以不需要 await(不能访问 mutable 状态)async let result1 = fetchData1()
async let result2 = fetchData2()
let combined = await (result1, result2)
Task:
Task { } 创建非结构化的顶级任务Task.detached { } 创建完全独立的任务(不继承 actor context)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 { ... }
}
@Sendable 标记闭包,禁止捕获可变状态func fetchData() async -> Data { ... }
await 点是一个 suspension point(挂起点)
与 GCD 的区别:
| 维度 | GCD | Swift Concurrency |
|---|---|---|
| 线程模型 | 每个 block 可能在不同线程 | 协程,挂起不占线程 |
| 线程爆炸 | 容易创建过多线程 | 协作式线程池(线程数 ≤ CPU 核心数) |
| 结构化 | 无 | Task 层级结构,自动取消传播 |
| 安全性 | 手动保证 | Actor 隔离,Sendable 检查 |
enum Kilometers {}
enum Miles {}
struct Distance<Unit> {
let value: Double
}
// Distance<Kilometers> 和 Distance<Miles> 是不同类型
// Unit 从未被使用为值,只在类型层面区分 → 零开销
@resultBuilder struct ArrayBuilder {
static func buildBlock(_ components: Int...) -> [Int] {
components
}
}
buildBlock/buildOptional/buildEither 等方法调用@ViewBuilder 就是 result builderlet type: Int.Type = Int.self // Int 的元类型
let obj = type.init(42) // 用元类型创建实例
.self 获取类型本身的值.Type 是类型的元类型type(of: instance) 获取运行时动态类型type(of:) 返回的可能是子类类型Request → SessionManager → URLSession → URLSessionTask
↑ ↑ ↑
Encoding ServerTrust Interceptor
URLSession 封装,使用 URLSessionDelegate 统一管理回调adapt 修改请求(如添加 token),retry 处理重试ResponseSerializer 协议将 Data 转为目标类型ServerTrustManager 实现,防中间人攻击.validate().responseDecodable(of:)
Request 封装了一次完整请求的所有信息ParameterEncoding 协议的不同实现(URL/JSON/Custom)KingfisherManager
├── ImageDownloader(网络下载)
└── ImageCache
├── MemoryCache(NSCache)
└── DiskCache(FileManager)
NSCache,系统内存紧张时自动清理CacheKeyFilter
原始key + processor标识 为 key 缓存URLSession,支持 HTTP/2 多路复用ImagePrefetcher 预加载机制NSLayoutConstraint 的 DSL 封装ConstraintMaker/ConstraintDescription
make.top.equalTo(view).offset(10) 最终等价于创建一个 NSLayoutConstraint
snp.updateConstraints 找到已有约束修改 constant,比重新创建高效constraint.update(offset: 20)
| 概念 | RxSwift | Combine |
|---|---|---|
| 数据流 | Observable | Publisher |
| 消费者 | Observer | Subscriber |
| 取消 | Disposable / DisposeBag | AnyCancellable |
| 背压 | 无原生支持 | Demand 机制 |
| 调度器 | Scheduler | Scheduler |
| Subject | PublishSubject/BehaviorSubject | PassthroughSubject/CurrentValueSubject |
Observable 是一个持有 subscribe 闭包的结构subscribe 时创建 Sink(桥梁),连接 Observable 和 ObserverDisposeBag 在 deinit 时调用所有 Disposable 的 dispose,断开链条Demand 控制接收速率sink/assign 等返回 AnyCancellable,释放即取消订阅| Property Wrapper | 所有权 | 触发刷新 | 适用场景 |
|---|---|---|---|
@State |
View 拥有 | 值变化时 | View 内部简单状态 |
@Binding |
不拥有(引用) | 值变化时 | 父子 View 双向绑定 |
@ObservedObject |
不拥有 | objectWillChange 时 | 外部注入的 ObservableObject |
@StateObject |
View 拥有 | objectWillChange 时 | View 创建的 ObservableObject |
@EnvironmentObject |
不拥有 | objectWillChange 时 | 跨层级的 ObservableObject |
@Environment |
不拥有 | 值变化时 | 系统环境值 |
| 场景 | 根因 | 解决 |
|---|---|---|
| 闭包捕获 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 |
// 难以发现的泄漏:闭包嵌套
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...
}
}
}
}
var array = [Int]()
DispatchQueue.concurrentPerform(iterations: 1000) { i in
array.append(i) // 崩溃!Array 非线程安全
}
| 方案 | 优点 | 缺点 |
|---|---|---|
| Serial DispatchQueue | 简单直观 | 完全串行,性能差 |
| Concurrent Queue + Barrier | 读并发,写独占 | 代码稍复杂 |
| NSLock / pthread_mutex | 最轻量 | 需要手动 lock/unlock |
| os_unfair_lock | 最快的互斥锁 | 不支持递归 |
| Actor (Swift 5.5+) | 编译器保证安全 | 异步调用 |
| @Atomic property wrapper | 属性级别保护 | 单次操作安全,复合操作不安全 |
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) } // 独占写
}
}
| 问题 | 解决方案 |
|---|---|
| cell 创建开销 | 复用机制 dequeueReusableCell
|
| 图片解码卡主线程 | 异步解码 + 缓存解码后的 bitmap |
| 复杂 cell 布局 | 预计算 cell 高度,缓存布局结果 |
| 透明度 / 离屏渲染 | 避免 cornerRadius + masksToBounds,用 CAShapeLayer 或预渲染圆角图 |
| 大量图片内存 | Kingfisher/SDWebImage 的缩略图 + downsampling |
| Diff 更新 | DiffableDataSource / IGListKit / 手动 diff 只更新变化的 cell |
冷启动:
1. 内核创建进程
2. dyld 加载 → 动态库绑定 → rebase/bind
3. +load / __attribute__((constructor))
4. Runtime 初始化(ObjC class 注册、category attach)
5. main() 函数
6. AppDelegate → UIWindow → 首屏渲染
| 阶段 | 优化方式 |
|---|---|
| dyld | 减少动态库数量(合并为 1 个);使用静态库 |
| +load | 移到 +initialize 或懒加载 |
| 二进制 | 二进制重排(Profile-Guided Optimization),减少 Page Fault |
| main 后 | 延迟非必要初始化,首屏数据预加载/缓存 |
| 渲染 | 简化首屏 UI,避免首屏大量 Auto Layout |
| 崩溃 | 原因 | 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 |
let x: Int = optional! —— 最常见| 选项 | 含义 | 效果 |
|---|---|---|
-Onone |
无优化(Debug) | 保留所有调试信息 |
-O |
标准优化(Release) | 内联、泛型特化、死代码消除 |
-Osize |
优化体积 | 减少内联,优先选择小代码 |
-Ounchecked |
移除安全检查 | 数组越界、溢出检查被移除,危险但最快 |
| WMO (Whole Module Optimization) | 全模块优化 | 跨文件内联/特化/去虚拟化 |
internal 方法从 vtable 派发降级为静态派发final(如果子类在整个模块中不存在)| 技巧 | 原因 |
|---|---|
用 final 修饰不需要继承的 class |
静态派发 |
用 private / fileprivate
|
编译器可推断 final,静态派发 |
| 用 struct 而非 class | 无引用计数,栈分配 |
| 避免过大的协议 existential | 减少堆分配 |
用 @inlinable 暴露关键路径 |
跨模块内联优化 |
用 @frozen 标记稳定的 struct/enum |
允许编译器直接操作内存布局 |
| 减少不必要的 Optional | 减少分支和 unwrap 开销 |
| 场景 | 优化 |
|---|---|
| 小对象 | 用 struct 替代 class |
| 协议类型 | 用泛型约束替代 existential |
| 闭包 | 非逃逸闭包(编译器可栈分配) |
| String | 短字符串利用 SSO |
| 数组 |
Array.reserveCapacity(_:) 预分配,避免多次扩容拷贝 |
let 代替 var(编译器可以省略某些 retain/release)Unmanaged<T> 手动管理引用计数(高性能场景)// 不好: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 兼容),需要手动优化。
// 非 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 将操作转为惰性求值
Dictionary 使用开放寻址 + 线性探测哈希表Dictionary.reserveCapacity(_:) 可预分配Dictionary(grouping:by:) 比手动 for 循环分组更高效Array 需要兼容 NSArray 桥接,有额外判断开销ContiguousArray 保证连续内存存储,不支持 NSArray 桥接ContiguousArray 更快// 差:每次 += 可能触发拷贝和堆分配
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()
Substring 与原 String 共享底层 buffer(COW)Substring 会阻止原 String buffer 释放Substring,长期存储时转为 String(substring)
| 派发方式 | 相对开销 |
|---|---|
| 内联 | 0(最快) |
| 静态派发 | 1x |
| vtable 派发 | ~1.1x - 1.5x(间接跳转 + 可能的 cache miss) |
| PWT 派发 | ~1.5x - 2x(多一次间接寻址) |
| objc_msgSend | ~3x - 5x(查找 IMP 缓存) |
<T: P> > 存在类型 any P(可特化为静态派发)| 对比维度 | 值类型 | 引用类型 |
|---|---|---|
| 拷贝语义 | 深拷贝(COW 优化后延迟拷贝) | 浅拷贝(共享引用) |
| 身份判断 | 无法判断「同一个」(只有值相等) |
=== 判断同一实例 |
| 多态 | 协议实现 + 泛型 | 继承 + 协议 |
| 线程安全 | 天然安全 | 需同步 |
| 析构 | 无 deinit | 有 deinit |
| 内存位置 | 栈/内联(优先) | 堆 |
| 引用计数 | 无 | 有(ARC) |
| 适用场景 | 数据模型、算法、并发安全 | 共享状态、标识语义、继承层级 |
选择原则: 默认用 struct,只在需要共享状态、继承、deinit、identity 时用 class。
| 特性 | struct | class | enum | actor |
|---|---|---|---|---|
| 类型 | 值 | 引用 | 值 | 引用 |
| 继承 | 不支持 | 支持 | 不支持 | 不支持 |
| 协议遵循 | 支持 | 支持 | 支持 | 支持 |
| deinit | 无 | 有 | 无 | 有 |
| 可变性 | mutating | 自由修改 | mutating | 隔离保护 |
| 线程安全 | 拷贝安全 | 需手动 | 拷贝安全 | 编译器保证 |
| 引用计数 | 无 | 有 | 无 | 有 |
| 内存 | 栈优先 | 堆 | 栈优先 | 堆 |
| 维度 | let |
var |
|---|---|---|
| 可变性 | 不可变 | 可变 |
| 编译器优化 | 更多(常量折叠、省略 retain/release) | 较少 |
| 线程安全 | 安全(不可变) | 不安全 |
| 引用类型 | 引用不可变(属性仍可变) | 引用可变 |
| 方法 | 签名 | 作用 |
|---|---|---|
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??
| 维度 | GCD | Operation | Swift Concurrency |
|---|---|---|---|
| 抽象层级 | 低(C API) | 中(ObjC 对象) | 高(语言级别) |
| 取消 | 手动检查 |
isCancelled 属性 |
结构化自动传播 |
| 依赖管理 | 手动 dispatch_group/barrier | addDependency |
async let / TaskGroup |
| 线程控制 | QoS + target queue | maxConcurrentOperationCount |
协作式线程池 |
| 线程爆炸 | 容易 | 容易 | 不会(线程数 ≤ 核心数) |
| 错误处理 | 无内建 | 无内建 | throws + try await |
| 安全保证 | 无 | 无 | Actor + Sendable |
| 维度 | weak | unowned | unowned(unsafe) |
|---|---|---|---|
| 类型 | Optional | 非 Optional | 非 Optional |
| 对象释放后 | 自动 nil | trap 崩溃 | 野指针(UB) |
| 性能开销 | Side table + 原子操作 | 较少 | 零额外开销 |
| 安全性 | 最安全 | 安全(确定性崩溃) | 最危险 |
| 适用场景 | delegate、不确定生命周期 | 确定不会先于 self 释放 | 极致性能,生命周期绝对保证 |
| 类型 | 含义 | 底层 |
|---|---|---|
Any |
任意类型(值/引用) | existential container (32 bytes) |
AnyObject |
任意引用类型 | 单指针 (8 bytes) |
any Protocol |
任意遵循 P 的类型 | existential container |
some Protocol |
某个特定的遵循 P 的类型(编译期确定) | 无 container,直接值 |
| 级别 | 可见范围 | 编译器优化影响 |
|---|---|---|
open |
任何模块可继承和 override | 不能优化派发 |
public |
任何模块可访问,不可继承 override | 不能优化派发(外部可能做协议遵循等) |
internal |
同一模块(默认) | WMO 下可推断 final |
fileprivate |
同一文件 | 可推断 final |
private |
同一声明作用域 | 隐式 final,静态派发 |
| 维度 | 全局函数 | 实例方法 | 闭包 |
|---|---|---|---|
| 类型 | (Args) -> Return |
(Self) -> (Args) -> Return(柯里化) |
(Args) -> Return |
| 捕获 | 无 | 隐式捕获 self | 显式/隐式捕获环境 |
| 堆分配 | 无 | 无(作为闭包传递时有) | 有(逃逸时) |
| 方式 | 适用场景 | 性能 | 链式处理 |
|---|---|---|---|
throws |
同步错误处理 | 正常路径零开销(Swift 使用 error return) | do-catch |
Result<T, E> |
异步回调 / 存储结果 | enum 开销(极小) | map/flatMap |
Optional<T> |
值可能不存在 | 最小 | map/flatMap/?? |
每个 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... │ → 虚函数表
└─────────────────────────┘
Array<Int> 的 metadata 是运行时按需创建的Array<Int> 时,runtime 用模板 + Int.self 的 metadata 组合生成let mirror = Mirror(reflecting: someInstance)
for child in mirror.children { ... }
Swift Source → AST → SIL (raw) → SIL (canonical) → SIL (optimized) → LLVM IR → Machine Code
↑ ↑ ↑ ↑
解析/类型检查 SILGen 强制诊断/优化 LLVM 优化
swiftc -emit-sil file.swift # 优化前的 SIL
swiftc -emit-sil -O file.swift # 优化后的 SIL
SIL 中可以直接看到 retain/release 的插入位置、dispatch 方式、内联决策等。
Swift 保证同一时刻不能同时存在对同一变量的读访问和写访问(Law of Exclusivity)。
var x = 1
swap(&x, &x) // 编译错误!同时对 x 进行两个写访问
var array = [1, 2, 3]
// 运行时可能崩溃:对 array 同时读 (subscript) 和写 (modifyElement)
extension Array {
mutating func modifyFirst(using: (inout Element) -> Void) {
using(&self[0]) // self 正在被修改,又通过 subscript 修改
}
}
begin_access / end_access 标记| 类型 | 含义 | 等价 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 |
// 危险:指针悬垂
var ptr: UnsafeMutablePointer<Int>?
do {
var x = 42
ptr = UnsafeMutablePointer(&x)
}
ptr?.pointee // 未定义行为!x 已超出作用域
// 正确:使用 withUnsafe 系列方法
withUnsafePointer(to: &x) { ptr in
// ptr 仅在此闭包内有效
}
@frozen:向编译器承诺 struct/enum 的布局不会变化
@frozen 时,编译器通过间接方式访问(支持未来布局变化)@inlinable:向编译器暴露函数体,允许跨模块内联.swiftinterface 文件替代 .swiftmodule
| 操作 | 检查时机 | 失败行为 | 底层 |
|---|---|---|---|
as |
编译期 | 编译错误 | 无运行时开销(类型已知) |
as? |
运行时 | 返回 nil | metadata 比较 |
as! |
运行时 | trap 崩溃 | metadata 比较 + 强制 |
if value is MyClass { ... }
(TypeDescriptor, ProtocolDescriptor) → WitnessTable
as? SomeProtocol 时,runtime 在表中查找当前类型是否遵循该协议| Swift 类型 | ObjC 类型 | 桥接方式 |
|---|---|---|
| String | NSString | 按需转换(UTF-8 ↔ UTF-16) |
| Array | NSArray | 包装/拆包 |
| Dictionary | NSDictionary | 包装/拆包 |
| Int/Double | NSNumber | 装箱/拆箱 |
| struct | 不可桥接 | 需要手动封装为 class |
@objc 的方法会生成 ObjC 兼容的调用入口objc_msgSend,比 Swift vtable 慢 3-5 倍@objc 方法增加约 100 字节的二进制体积@dynamicMemberLookup
struct JSON {
subscript(dynamicMember member: String) -> JSON { ... }
}
let value = json.user.name // 编译器转换为 subscript 调用
.member 语法转为 subscript 调用func process(_ value: consuming MyStruct) {
// value 的所有权被转移到此函数,调用方不能再使用
}
func inspect(_ value: borrowing MyStruct) {
// 只读借用,不拷贝,不转移所有权
}
struct FileHandle: ~Copyable {
let fd: Int32
deinit { close(fd) } // struct 有了 deinit!
}
deinit(资源清理)Swift 的 throws 不使用异常表(不同于 C++/Java):
// 函数签名实际上是:
func foo() throws -> Int
// 底层等价于:
func foo() -> (Int, Error?)
try 的正常路径性能很好func parse() throws(ParseError) -> AST { ... }
as? 转换@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 方法调用func makeShape() -> some Shape {
Circle()
}
Circle,但对调用方隐藏// some:编译期确定类型,运行时无开销
func a() -> some Collection { [1,2,3] }
// a() 和 a() 保证是同一类型(Int Array)
// any:运行时动态类型,有 container 开销
func b() -> any Collection { Bool.random() ? [1] : Set([1]) }
// b() 每次可能不同类型
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 5.5+ 的现代并发模型,掌握如何编写安全高效的多线程代码
在传统 iOS/macOS 开发中,我们使用 GCD(Grand Central Dispatch)或 OperationQueue 来处理并发任务。然而,这些技术存在一些痛点:
Swift 5.5 引入的 async/await 和结构化并发解决了这些问题,提供了更安全、更简洁的并发编程方式,iOS 13以上是支持的。
// 传统回调方式
func fetchUser(completion: @escaping (Result<User, Error>) -> Void)
// 异步函数方式
func fetchUser() async throws -> User
// 使用 await 调用异步函数
do {
let user = try await fetchUser()
print("用户: \(user.name)")
} catch {
print("错误: \(error)")
}
// 同时启动多个异步任务
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
)
}
// 并发执行(总耗时 ≈ 最慢的任务)
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秒
Q: async let user = fetchUser() 立即返回什么?
A: 它不立即返回数据,而是返回一个异步任务句柄。实际数据在 await 时获取。
Q: 多个 async let 相当于 GCD 的异步任务吗?
A: 相似但有重要区别。async let 是结构化并发的一部分,任务生命周期自动管理,支持取消和错误传播。
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) // 独立修改,互不影响
}
原理:通过复制而非共享,从根本上消除数据竞争的可能性。
Q: async let 任务在哪个线程执行?
A: Swift 并发运行时智能决定,基于以下因素:
@MainActor 强制主线程智能调度的具体表现:
@MainActor
func updateUIWithData() async {
// 从主线程调用,但会自动优化
async let data = fetchHeavyData() // 运行时:这个会阻塞 → 调度到后台线程
let processed = await process(data) // 可能在后台线程继续处理
// 更新UI时自动回到主线程
self.label.text = processed.title
}
// 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() // 让系统决定最佳执行方式
}
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))
}
}
}
}
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
}
}
// 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)")
}
}
// 为 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)
}
}
}
}
// 推荐的层次结构:
// 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 的现代并发模型代表了并发编程的范式转变:
虽然学习曲线比 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. 教育子女真的是个长期的活。我琢磨着儿子愿意跟我跑步还有一部分原因是最近两个月每晚带着妹妹攀岩有点懈怠了他,或许是有点吃醋:过去我总是陪他比妹妹多一点的。而妹妹似乎不愿意跑步…… 结果,我也被动的增加了颇多的运动量,何尝不是件好事。
年更博主终于推出新版本,JXPhotoBrowser v4.0 全面重构焕新!
JXPhotoBrowser 是一个轻量级、可定制的 iOS 图片/视频浏览器,实现 iOS 系统相册的交互体验。支持缩放、拖拽关闭、自定义转场动画等特性,架构清晰,易于集成和扩展。同时支持 UIKit 和 SwiftUI 两种调用方式(SwiftUI 通过桥接层集成,详见 Demo-SwiftUI 示例工程)。
| 首页列表 | 图片浏览 | 下拉关闭 |
|---|---|---|
JXPhotoBrowserCellProtocol 仅包含 browser 和 transitionImageView 两个属性,将浏览器与具体 Cell 实现解耦,既可以直接使用内置的 JXZoomImageCell,也可以实现完全自定义的 Cell。JXPhotoBrowserDelegate 只关心数量、Cell 与转场,不强制统一的数据模型。UICollectionView 复用机制,内存占用低,滑动流畅。JXZoomImageCell,也支持通过协议与注册机制接入完全自定义的 Cell(如视频播放 Cell)。JXPageIndicatorOverlay 页码指示器。UIViewController。内部维护一个 UICollectionView 用于展示图片页面,负责处理全局配置(如滚动方向、循环模式)和手势交互(如下滑关闭)。UICollectionViewCell 并实现 JXPhotoBrowserCellProtocol。内部使用 UIScrollView 实现缩放,负责单击、双击等交互。通过 imageView 属性供业务方设置图片。browser(弱引用浏览器)和 transitionImageView(转场视图)两个属性即可接入浏览器,另提供 photoBrowserDismissInteractionDidChange 可选方法响应下拉关闭交互,不强制依赖特定基类。willDisplay/didEndDisplaying)以及转场动画所需的缩略图视图等,不强制要求统一的数据模型。setup、reloadData、didChangedPageIndex 三个方法,用于页码指示器、关闭按钮等附加 UI 的统一接入。UIPageControl,支持自定义位置和样式,通过 addOverlay 按需装载。UIKit(核心),无任何第三方依赖。Kingfisher 加载图片,演示完整功能(图片浏览、视频播放、Banner 轮播等)。Demo-Carthage 目录下执行 carthage update --use-xcframeworks --platform iOS 构建框架。本框架已包含 PrivacyInfo.xcprivacy 隐私清单文件,符合 Apple 自 2024 年春季起对第三方 SDK 的隐私清单要求。
JXPhotoBrowser 不追踪用户、不收集任何数据、不使用任何 Required Reason API,隐私清单中所有字段均为空声明。通过 CocoaPods、SPM 或 Carthage 集成时,隐私清单会自动包含在框架中,无需额外配置。
在你的 Podfile 中添加:
pod 'JXPhotoBrowser', '~> 4.0.1'
注意:Xcode 15 起默认开启了 User Script Sandboxing(
ENABLE_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
在 Xcode 中:
https://github.com/JiongXing/PhotoBrowser
或在 Package.swift 中添加依赖:
dependencies: [
.package(url: "https://github.com/JiongXing/PhotoBrowser", from: "4.0.1")
]
在你的 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)
遵守 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 使用默认尺寸
}
}
JXPhotoBrowser 是基于 UIKit 的框架,在 SwiftUI 项目中可通过桥接方式集成。Demo-SwiftUI 示例工程演示了完整的集成方案。
LazyVGrid、Picker、AsyncImage 等)JXPhotoBrowserViewController
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 ...
}
}
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)
}
}
}
}
}
注意:
JXPhotoBrowserViewController的delegate是weak引用,必须在 SwiftUI 侧用@State持有 Presenter 实例,否则它会在创建后立即被释放。
Demo-SwiftUI 示例工程未演示 Zoom 转场动画,默认使用 Fade 转场。
原因:Zoom 转场依赖 thumbnailViewAt delegate 方法返回列表中缩略图的 UIView 引用,框架通过该引用计算动画起止位置并构建临时动画视图。而 SwiftUI 的 AsyncImage 等原生视图无法直接提供底层 UIView 引用。
如需自行实现:可将缩略图从 AsyncImage 替换为 UIViewRepresentable 包裹的 UIImageView,从而获取真实的 UIView 引用,再通过 thumbnailViewAt 和 setThumbnailHidden 两个 delegate 方法提供给框架即可。具体的 Zoom 转场接入方式可参考 Demo-UIKit 示例工程。
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:
继承 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) {
// 例如:下拉时暂停视频播放
}
}
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 组件机制,用于在浏览器上层叠加附加 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)
实现 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(写入相册权限描述)。
UILongPressGestureRecognizer。browser 属性获取浏览器控制器来 present。PHPhotoLibrary 请求权限,下载后写入相册。以 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()
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 已经有正确尺寸的图片,动画效果更加流畅。

你是否经常在午饭后感到困倦、脑子转不动?是否明明吃了很多甜食,却依然觉得“细胞在挨饿”?
我就有这样的困扰。而且我爸爸,奶奶都有糖尿病、高血压,加上我有高尿酸,所以我一直有在关注血糖相关的知识。
最近读完了一本深度改变我饮食观的书——《控糖革命》。作者杰西·安佐佩斯(Jessie Inchauspé)通过科学的角度揭示了一个核心真相:比起计算卡路里,控制“血糖峰值”才是维持健康、保持身材和延缓衰老的关键。
以下是我整理的本书精华,带你重新认识身体里的“糖”。
在进入控糖技巧前,我们先看大自然的魔法。植物通过光合作用产生葡萄糖,并根据需要将其转化为三种形态:
正是这些形态的不同,决定了食物进入人体后不同的“命运”。
人体摄入糖分后,血糖会升高再降下,形成一个“波峰”。这个峰值越高,对身体的伤害就越大。
当血糖剧烈波动时,身体会陷入以下困境:
人体处理葡萄糖的过程如下:
但果糖更加霸道:它无法转化为糖原储存,唯一的去处就是直接转化成脂肪。这就是为什么甜食(含果糖)比单纯的面食(只含葡萄糖)更容易让人发胖的原因。
此外,高频率的血糖峰值会导致胰岛素抵抗。只有在胰岛素水平较低时,身体才能有效燃烧脂肪。
控制血糖不代表要戒绝一切,而是要讲究“策略”,书中介绍了许多控糖技巧,我整理如下:
《控糖革命》带给我们的最大启发是:健康的身体,不在于极端的节食,而在于对代谢规律的尊重。
当你学会通过调整进食顺序、利用纤维和醋等简单工具来抚平血糖波动,你会发现:精力变好了,皮肤亮了,甚至连身材也自然而然地轻盈了。
从下一餐开始,先吃那盘蔬菜吧!
你以为的“三方支付”的样子,和苹果谷歌落地“三方支付”的样子,堪比网友见面、梦境与现实。
下面是详细介绍。
苹果和谷歌又将“三方支付”分为“应用内三方支付”、“网页外链支付”。顾名思义,“应用内三方支付”就是在应用内使用三方支付(例如接入PayPal SDK),“网页外链支付”就是跳出应用,打开外链支付。二者抽成比例是不一样的。
苹果
在日本,苹果将原来的“佣金”拆分成了 “商店服务费” 和 固定5%的“支付处理费”。如果使用三方支付,则不用出 5%“支付处理费”,但“商店服务费”还是得出。苹果:我聪明吧。
| 方案 | 苹果收取的商店服务费 | 苹果收的支付服务费 | 苹果抽成合计 |
|---|---|---|---|
| 官方内购 | 21% (小型开发者或订阅 10%) | 5% | 15% ~ 26% |
| App内三方支付 | 21% (小型开发者或订阅 10%) | 0 | 10% ~ 21% |
| 网页外链支付 | 15% (小型开发者或订阅 10%) | 0 | 10% ~ 15% |
谷歌
| 场景 | 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)选择授权类型:
(3)填写 App 详细信息:
申请“第三方应用内支付”(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 上报的数据,在次月生成汇总账单。
支付方式:不像苹果通常需要你主动汇款,谷歌通常会从你账户绑定的结算方式(信用卡或付款资料) 中直接扣除这部分佣金,或者发送正式账单让你在规定时间内支付。
为了保护自己的生态,两大巨头在用户体验上设置了重重障碍:
内购强制接入: 苹果和谷歌均要求,开发者不能仅提供第三方支付,必须同时接入官方内购作为选项,且官方支付按钮的醒目程度必须不低于第三方支付。
劝退的风险弹窗: 用户在点击第三方支付链接时,系统会弹出充满“警告色”的通知(如:“你即将离开安全环境”、“苹果将不再负责该交易的安全、退款及支持”等),这极大地增加了用户的跳失率。
先看抽成方面。
通过应用内三方支付SDK方式接入,体验较好,但抽成比例和官方支付差别很小,可以省约 1%~2% ;只有通过外链跳出App支付这种方式接入,省的比较多,可以省7%左右,但这种方式体验较差,再加上平台强制“风险警告弹窗”,即使接入了这种支付方式,最终又有多少比例的用户会选择这种支付方式呢?所以,这个7%可能需要打个大折扣。
总结起来就是,接入三方支付并不一定会“省钱”。
一旦采用第三方支付,开发者需要承担原本由平台处理的大量行政工作:
每月结算申报: 开发者必须每月手动向苹果/谷歌申报通过第三方渠道产生的流水,并根据申报单向平台转账支付佣金。
自理客服与退款: 所有的退款申请、订阅管理和支付争议,平台一概不管,开发者必须建立自己的客服团队来处理这些琐事。
接受审计风险: 平台保留对开发者财务记录进行审计的权利。如果瞒报、(人员或技术疏忽导致的)漏报三方支付流水,可能面临权利被限制、下架或封号等风险。
接入三方支付,会增加包体提审被拒的风险。
作为开发者,提审时需要额外在审核备注里说明接入了三方支付,并提供支付流程截图或视频。
作为审核人员,也会额外“关照”接入了三方支付的应用,检查是否符合相关审核要求。
包体层面,接入三方支付,势必会在包体里加入支付判断、支付切换,或者嵌入三方支付SDK 等高风险行为。机器审核有可能误判为切支付、隐藏功能,增加过审难度。
虽然苹果和谷歌官方层面没有明确表明,接入三方支付的应用不会被推荐。但从平台的角度出发,加入三方支付,很大程度上会影响被平台推荐的可能性。
苹果
官方口径: 只要符合 Guideline 3.1.1(a) 且已获得 Entitlement 授权,应用在法律上具备推荐资格。
实际:苹果的推荐位是由其编辑团队(App Store Editors)人工筛选的。他们的考核标准中,“用户体验的一致性”权重极高。三方支付必须弹出一个“离开 App”的系统警告框。对于编辑来说,这种“打断感”被视为用户体验的瑕疵。编辑团队通常倾向于推荐那些能给平台带来完整生态价值(包括 IAP 闭环)的应用。
谷歌
官方口径: 参与 User Choice Billing (UCB) 计划的应用,其推荐资格不受限制。
实际: 算法决定了你是否能进入备选池,人工决定了你是否被推荐。谷歌的自动化推荐算法(如“猜你喜欢”)基于转化率和评分。如果三方支付导致支付流程变长、跳出率增加,你的算法推荐位会自然下降。针对三方支付的退款请求,务必在 App 内提供显著的客服入口,避免用户在商店留下“无法退款,骗钱”的差评,这是算法降权的头号原因。
结语
看完上面的内容,你还会觉得“三方支付真香”吗?
而且,后续如果其它国家或地区吵着要开三方支付,大概率也会遵循上面日本的范式。
放弃幻想吧,宝子们!
做 iOS 开发的,谁没在 UIKit 里享受过“继承的快乐”?比如写个 BaseViewController,把导航栏样式、加载动画、空白页统一封装好,后面所有页面直接 : BaseViewController,一顿操作猛如虎,不用重复写代码——主打一个“父债子还”(不是),“父功子享”才对!
可等咱们兴冲冲转到 SwiftUI,想依葫芦画瓢写个 BaseView,再让 HomeView: BaseView 时,Xcode 直接给你泼一盆冷水:“兄弟,你怕不是喝多了?View 是协议,不是类,不能继承!”
那一刻,多少开发者的内心是崩溃的:“SwiftUI 你玩我呢?UIKit 能行的事,你凭啥不行?我就想省点劲,有错吗?”
别急别急,今天就用唠嗑的方式,扒一扒 SwiftUI 为啥“反骨”不支持 View 继承,以及它到底藏了啥“骚操作”,能比 UIKit 的继承更省心(偶尔也更闹心)。
咱们先回味下 UIKit 的“继承爽点”:
反观 SwiftUI,一上来就断了“继承”这条路——核心原因很简单(虽然听着有点绕):SwiftUI 的 View 是“协议”,不是“类” ,而 Swift 里的协议,本身就不支持“继承”(只能遵循);再加上 SwiftUI 里的 View 载体都是 Struct(值类型),值类型也不能继承(只有类是引用类型,能继承)。
苹果爸爸的心思其实很歪:“我就是要逼你们放弃‘继承依赖’,值类型+协议的组合,线程安全又轻量,不香吗?” 香是香,但刚开始确实浑身不自在,就像习惯了用筷子吃饭,突然让你用叉子,怎么都觉得别扭。
别慌,SwiftUI 虽然堵死了“继承”这一条路,但开了 N 条“后门”,每一条都比继承更灵活(就是得适应适应),咱们一条条唠,结合吐槽讲明白。
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 子类)。
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 继承的“重写方法”更简单。
如果说协议扩展是“全局统一福利”,组合封装是“乐高零件”,那 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 本身的结构,比继承更安全。
有时候,我们想封装一个“容器 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 不是“反继承”,而是它的设计思路和 UIKit 完全不同:UIKit 是“面向类的继承”,主打一个“一脉相承”;SwiftUI 是“面向协议的组合”,主打一个“灵活拼接”。
用一句话吐槽总结:
UIKit 里的继承,就像“继承家产”,好处是省心,但容易被“家产”绑定,想改点东西还要顾及祖宗规矩;SwiftUI 里的扩展,就像“搭乐高”,虽然每个零件都要自己拼,但想怎么搭就怎么搭,拆了重拼也不心疼,灵活到飞起!
最后给大家一个小建议:刚从 UIKit 转到 SwiftUI 时,别总想着“怎么继承”,而是多想想“怎么组合、怎么封装”——用协议扩展做全局统一,用组合封装做重复组件,用 Modifier 做个性化扩展,用 @ViewBuilder 做灵活容器,慢慢你就会发现,SwiftUI 的扩展方式,比 UIKit 的继承香多了!
同时接入多个广告网络,强烈推荐使用广告聚合(Mediation)平台,而不是自己手动管理切换逻辑。
| 聚合平台 | 说明 | 推荐度 |
|---|---|---|
| Google AdMob Mediation | AdMob 官方内置聚合,支持 Meta AN 作为第三方适配器 | ⭐⭐⭐⭐⭐ |
| AppLovin MAX | 独立聚合平台,支持广泛广告网络,实时竞价能力强 | ⭐⭐⭐⭐⭐ |
| ironSource LevelPlay | 游戏领域主流,已与 Unity Ads 合并 | ⭐⭐⭐⭐ |
| Mintegral / TopOn / TradPlus | 国内出海常用,支持国内外主流网络 | ⭐⭐⭐⭐ |
本指南重点讲解最主流的两种方案:
- Google AdMob Mediation(以 AdMob 为主,Meta AN 做竞价补充)
- AppLovin MAX(独立聚合,AdMob + Meta AN 并行竞价)
这是最直接的方案——AdMob 作为聚合主体,Meta AN 通过 Bidding(实时竞价) 参与竞争。好的,已经获取到所有关键信息。下面为您撰写完整详细的集成指南。
手动管理两个广告 SDK 的加载、展示、降级逻辑非常繁琐且容易出错。使用 广告聚合平台 可以:
| 聚合平台 | 特点 | 适合场景 | 推荐度 |
|---|---|---|---|
| Google AdMob Mediation | AdMob 官方内置,Meta AN 做竞价适配器 | 已使用 AdMob 的项目,最简单 | ⭐⭐⭐⭐⭐ |
| AppLovin MAX | 独立聚合,两者并行竞价,公正透明 | 追求最高 eCPM 的游戏类应用 | ⭐⭐⭐⭐⭐ |
| ironSource LevelPlay | 与 Unity 合并,游戏领域强势 | Unity 游戏或已使用 ironSource | ⭐⭐⭐⭐ |
| TopOn / TradPlus | 国内出海常用,支持国内外主流网络 | 出海应用同时接国内外广告 | ⭐⭐⭐⭐ |
💡 本指南重点讲解最主流的方案:Google AdMob Mediation + Meta AN(方案一) 和 AppLovin MAX(方案二)
核心思路: AdMob 作为主聚合,Meta AN 通过 Bidding 适配器参与实时竞价竞争
| 条件 | 最低版本 |
|---|---|
| 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
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,它会自动拉取FBAudienceNetworkSDK,不需要额外单独引入。
<!-- ① 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>
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
}
}
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()
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 点击")
}
}
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("👁️ 插屏广告曝光")
}
}
在 AdMob 后台完成以下配置,才能让 Meta AN 参与竞价:
123456789_987654321)💡 AdMob 会自动与 Meta 进行实时竞价(Bidding),不需要设置 eCPM 手动排序
在您的开发者网站根目录添加 app-ads.txt 文件,包含 AdMob 和 Meta 的授权行:
# Google AdMob
google.com, pub-xxxxxxxxxxxxxxxx, DIRECT, f08c47fec0942fa0
# Meta Audience Network
facebook.com, xxxxxxxxxxxxxxxxx, RESELLER, c3e20eee3f780d68
核心思路: MAX 作为独立聚合,AdMob 和 Meta AN 同为竞价参与者,更加公平透明好的,已经获取到了所有需要的信息。以下是完整的后续内容:
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
<!-- ① 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>
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 初始化完成")
// 此时可以开始加载广告
}
}
}
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()
}
}
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()
}
}
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)")
}
}
在 AppLovin Dashboard 中完成以下配置:
两个网络都通过 实时竞价(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 的降级逻辑:
platform :ios, '13.0'
target 'YourApp' do
use_frameworks!
pod 'Google-Mobile-Ads-SDK' # AdMob
pod 'FBAudienceNetwork' # Meta AN
end
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 激励视频被点击")
}
}
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)需要分别处理
- 新增广告网络时需要大量改代码
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
}
}
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)
}
}
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")
}
// 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 已就绪,开始加载广告")
// 在这里加载各种广告
}
}
在开发阶段,使用 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"
}
#if DEBUG
// 添加测试设备(设备 IDFA 的哈希值,在控制台日志中查找)
FBAdSettings.addTestDevice("YOUR_DEVICE_HASH")
// 或者启用模拟器测试模式
FBAdSettings.addTestDevice(FBAdSettings.testDeviceHash())
// 设置测试广告类型(可选)
// FBAdSettings.setLogLevel(.log)
#endif
#if DEBUG
// 显示 MAX Mediation Debugger(可视化调试面板)
// 显示所有适配器状态、广告加载记录等
ALSdk.shared().showMediationDebugger()
#endif
💡 MAX Mediation Debugger 非常强大,可以一目了然看到:
- 各适配器是否正确初始化
- 各网络的竞价情况
- 广告加载成功/失败详情
/// 统一的广告事件追踪器
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
)
}
}
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 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 到最新版本 |
/// 广告频次控制器
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
}
}
| 优化项 | 说明 | 预期效果 |
|---|---|---|
| 接入 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/
└── ...
// 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,以下是适配方式:
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)")
}
}
}
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()
}
}
}
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:
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
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 | 改动最小,生态成熟 |
| 学习了解原理 | 手动管理 | 仅作学习参考 |
| 广告格式 | 美国市场 eCPM 参考 | 中国/亚洲市场 eCPM 参考 |
|---|---|---|
| Banner | 3.0 | 1.0 |
| Interstitial | 20.0 | 8.0 |
| Rewarded Video | 40.0 | 15.0 |
| MREC | 5.0 | 2.0 |
| App Open | 15.0 | 6.0 |
⚠️ 以上数据仅为行业大致参考范围。实际 eCPM 受以下因素影响极大:
- 用户地区(T1 国家如美/英/澳/加远高于其他地区)
- App 品类(金融、教育类 > 工具类 > 游戏休闲类)
- 用户质量(高留存用户 eCPM 更高)
- 接入网络数量(3+ 个 Bidding 网络可提升 20~50%)
- ATT 授权率(授权用户 eCPM 可比未授权高 30~80%)
上架 App Store 时,需要在 App Store Connect 中如实填写隐私标签。接入广告 SDK 后,通常需要声明以下数据收集:
| 数据类型 | 是否收集 | 用途 | 是否关联用户 |
|---|---|---|---|
| 设备标识符 (IDFA) | ✅ | 第三方广告、分析 | 是(如用户授权 ATT) |
| 粗略位置 | ✅ | 第三方广告 | 否 |
| 使用数据(产品交互) | ✅ | 第三方广告、分析 | 否 |
| 诊断数据 | ✅ | 分析 | 否 |
| 广告数据 | ✅ | 第三方广告 | 是 |
💡 各 SDK 的隐私声明文档:
| 被拒原因 | 描述 | 解决方案 |
|---|---|---|
| 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 的广告质量审核功能 |
// ❌ 不好的描述
"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 AdMob 和 Meta Audience Network 的完整指南。总结核心建议:
尽管在 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 技术动态
- 📮 立即订阅 | weekly.fatbobman.com 获取完整内容
- 👥 加入社区 | Discord 与 2000+ 开发者交流
- 📚 深度教程 | fatbobman.com 探索 200+ 原创文章
Xcode 26.3 版本中苹果直接提供了对 Claude Code/Codex 的支持。自此,开发者终于可以在 Xcode 中方便的使用原生 AI Agent 了。 这两天我针对新版本进行了一系列尝试,包括如何使用最新模型、配置 MCPs/Skill/Command、以及编写自适应的 CLAUDE.md。本文以 Claude Code 为例,分享一些文档之外的技巧。
视频正在取代文字成为更受欢迎的表达方式,而好工具是创作的加速器。macOS 录屏软件 ScreenSage Pro 的开发者 Sintone 深度复盘了如何基于 ScreenCaptureKit 和 Metal 实现“录完即剪完”。从解决 SCK -3821 诡异报错,到由 ObservableObject 迁移至 @Observable 优化时间线性能,本文毫无保留地分享了从像素抓取到高性能合成的全过程。
在 Swift 里,判断一个字符串是否属于某个键集合,可以写成 Set.contains、Array.contains、RawRepresentable enum 的 init?(rawValue:)、switch 多分支,甚至用 Dictionary 来做映射。看起来差别不大,但真要放进性能敏感路径,结果可能并不完全符合直觉。Helge Heß 做了一次简单的基准测试:Set.contains 毫无悬念地领先,其次是 enum(rawValue:)和 Dictionary(两者非常接近);而很多人下意识会高估的 switch,反而排在 enum 之后,Array.contains 则垫底收场。作为一个小实验,这个结果或许正好可以拿来校准一下我们对 Swift 性能的直觉。
付费下载和免费 + 应用内购买是两种截然不同的商业模式,随着应用发展,开发者可能需要在两者之间转换。Donny Wals 在本文中分享了他将 Practical Core Data 应用从 $4.99 付费下载转为 freemium 的完整经历。文章不仅涵盖了 StoreKit 2 的技术实现细节(购买流程、状态管理、家庭共享),更有价值的是他对商业决策的深入思考:付费门槛虽然能筛选出认真的用户,但也阻挡了大量潜在用户体验产品价值的机会。对于教育类或工具类独立应用,freemium 可能是用户增长和收入之间更好的平衡点。
应用体积一直是开发者需要关注的问题,尤其是在应用包含大量图片、音频或其他资源时。尽管苹果很早就在 iOS 中提供了 On-Demand Resources(ODR)来应对这一挑战,但这一功能的存在感并不强,常被开发者忽略。在本文中,Majid Jabrayilov 系统性地回顾了 ODR 的工作机制与使用方式,包括资源分组、标签管理、下载生命周期,以及与系统缓存策略之间的协作关系。
虽然苹果在推广 Background Assets 作为更现代的方案,但 ODR 在需要即时响应的按需下载、细粒度资源控制等场景下仍有其独特价值。
在全面拥抱 Observation 框架时,开发者需要警惕其工作机制与 Combine 的 @Published 并不相同,简单替换往往会引入隐蔽的问题。Danny Bolella 总结了迁移过程中四个常见陷阱:@State 持有引用类型时的非惰性初始化、嵌套 @Observable 对象导致的更新丢失、数组元素绑定方式的变化,以及与其他属性包装器产生的冲突。文章通过清晰的代码示例逐一给出解决方案,并反复强调一个核心原则:只有视图当前正在访问(调用 getter)的属性发生变化时,才会触发更新。理解并顺应这种“惰性观察”的思维方式,是正确使用 Observation 框架的关键。
“Open Recent” 是 macOS 应用的标准功能,但对于 SwiftUI 开发者来说,正确实现这个功能并不直观。在本文中,Mark Szymczyk 通过一个简洁的示例,展示了如何利用 NSDocumentController 为应用接入系统级的最近文件管理能力:自动维护列表、更新菜单,以及与文档生命周期的无缝协作。对于文档型或工具类应用,这是一个低成本、却能明显提升“原生感”的细节优化。
macOS 一直缺少系统级的音频均衡器,由 Matthew Porteous 开发的 Radioform 填补了这个空白。该项目采用 SwiftUI 菜单栏 App + Swift Host + CoreAudio HAL Driver + C++ DSP 的分层架构,把 UI 与实时音频处理彻底解耦。DSP 部分实现了 10 段参数 EQ、参数平滑、限幅与实时安全控制;工程上也有完整 CI、签名公证与 DMG 发布流程。不是“能跑就行”的 Demo,而是接近可长期维护的生产级音频工程样板。
这是一个 macOS 原生应用较少涉足的领域:PCB 设计。CircuitPro 是一款面向 macOS 的 PCB EDA 工具,目标是把原理图、布局与元件库流程做成更符合 Apple 平台习惯的体验。(项目仍处于早期开发阶段)
项目里最吸引我的是自研的 CanvasKit。它更像一个面向 EDA 场景的 2D 交互引擎,而不只是普通画布组件:上层是声明式 CanvasView,中层是状态中枢 CanvasController,底层是输入路由、渲染树与工具系统。更关键的是,吸附、输入处理、连线引擎都被做成了协议化插拔点,让原理图和布局共享同一基础设施,同时保留各自的路由规则。
即便你对 PCB 设计本身不感兴趣,CircuitPro 也很值得关注,尤其是它在 SwiftUI + AppKit 融合架构上的工程实践。
本公司是二次元文生图头部企业(总部新加坡),招聘岗位为大陆全职 remote。求职者需要了解二次元文化,懂得二次元用语(黑话)。
我们正在寻找一位经验丰富的 iOS 工程师(中高级),负责主导我们 iOS 应用的开发与优化工作。
理想的候选人应具备深厚的 Swift 技术功底,出色的测试与团队协作能力,并拥有现代 iOS 架构及工具链的实战经验。
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 应用模块化设计、多种单页面架构模式以及性能优化方法,并具备在项目中落地的能力。
拥有跨平台开发经验(满足以下任意一项即可):
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 证书。
如果本期周报对你有帮助,请:
🚀 拓展 Swift 视野
- 📮 邮件订阅 | weekly.fatbobman.com 获取独家技术洞察
- 👥 开发者社区 | Discord 实时交流开发经验
- 📚 原创教程 | fatbobman.com 学习 Swift/SwiftUI 最佳实践
Xcode 26.3 版本的到来,几乎没有任何预热,却用实际行动回应了这些质疑。通过集成 Claude Code / Codex,苹果给出的答案很直接:只要策略得当,Xcode 依然是苹果生态中极具潜力的开发环境。这一次,Xcode 并没有简单地塞进一个 CLI 工具面板,而是引入了一套原生的 Xcode Tools(MCP),并配合 Swift 6、SwiftUI、SwiftData 等官方技术文档,形成了高度一致、贴合最新实践的整体体验。即便对于已经熟练使用 CLI + XcodeBuildMCP + 各类 Skills 的开发者而言,这套原生方案依然具备很强的竞争力——尤其是几乎为零的配置成本,这对绝大多数开发者来说意义重大。
摘要:随着 iOS 隐私政策的持续演进,SKAdNetwork (SKAN) 6.0 已成为移动营销衡量的新标准。本文将深入探讨 SKAN 6.0 的核心机制,重点解析如何针对三个转化窗口进行科学的转化值(CV)建模,并构建适配分层数据(Hierarchical Data)的归因架构,帮助高级 iOS 开发者与 AdTech 专家在隐私保护时代重构数据增长引擎。
SKAdNetwork 6.0(随 iOS 17.4+ 发布)在 4.0 的基础上进一步深化了隐私与效果的平衡。相比早期版本,SKAN 6.0 的核心进步在于通过多窗口回传(Multiple Postbacks)和分层源标识符(Hierarchical Source IDs),提供了更长的生命周期观测能力和更灵活的数据粒度。
在 SKAN 6.0 中,CV 建模不再是单一维度的映射,而是一场关于“时间”与“价值”的博弈。
P1 决定了初始出价模型的准确性。建议采用“收入+行为”混合模型:
由于仅支持三个档位,建模应侧重于长期留存与LTV 预测:
开发者可以通过 lockWindow() 提前锁定当前的转化窗口,以缩短数据回传的延迟。
实战建议:当用户触发了预期的最高价值行为(如首充)后立即锁窗,以最快速度将数据反馈给投放渠道。
SKAN 6.0 的数据产出取决于“人群匿名度”。这种不确定性要求服务端架构具备极强的鲁棒性。
source-identifier 的位数(2/3/4位)决定关联的广告层级(Campaign vs Ad Group vs Creative)。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
// 处理回调
}
}
}
}
postback 中的数据粒度,若频繁出现低位 Source ID,说明样本量不足以触发隐私阈值,需调整投放预算集中度。在各位读者们的陪伴下老司机技术又度过了一年春秋,这一年大模型的发展出乎意料的快,我们也添加了不少相关的实践与经验,拥抱大模型享受红利也是大势所趋。下一期我们的相见就在年后 3 月初了,老司机的编辑们给大家拜年了,新春快乐!
老司机 iOS 周报,只为你呈现有价值的信息。
你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。
Xcode 26.3 的 RC 版本已发布,大模型编程终于以一个较高的原生支持完成度来到我们身边,推荐与下方 Exploring AI Driven Coding 一文一同观看。
2026 年 4 月 28 日 开始要求必须 Xcode 26 提交 App 的新版本,大家可以早做准备制定升级计划。
@极速男孩:文章介绍了拦截 SwiftUI Sheet 下拉关闭的巧妙方案:利用 Presentation Detents 设置一个极小高度作为“拦截阈值”。当用户下拉至此,程序会捕捉状态并强制回弹,从而触发确认弹框。该方法不依赖复杂底层手势,实现简单且复用性强。
@zhangferry:Xcode 26.3 带来一项重要更新:官方通过 xcrun mcpbridge 桥接工具,向外部 MCP 客户端开放了 20 个原生工具接口。这一举措相较苹果以往的产品策略,显得尤为开放。其核心交互机制为:外部 MCP 客户端(如 Cursor、Claude Code)<-> mcpbridge 桥接工具 <-> Xcode(基于 XPC 通信)。
该功能依托 Xcode 内部运行的 MCP 服务实现,第三方智能代理(Agent)可通过这一链路调用 Xcode 的 MCP 能力,所以该功能无法脱离 Xcode 独立运行。在开放的工具中,除基础的文件读取类工具外,以下几款实用工具值得关注:
@Barney:本文介绍 Swift 中 UUID () 的原理与特性,默认生成 Version 4 随机 UUID。它基于 122 位加密安全随机数,从硬件、系统等多源收集熵值,经 CSPRNG 处理,按 RFC 4122 格式化,唯一性几乎绝对。还提及 Version 1(时间 + MAC 地址)和 Version 7(时间 + 随机),并说明 UUID 高效且无碰撞顾虑。
@Smallfly:这篇文章针对 Swift 模块化开发中的代码与资源重复问题,提供了简洁高效的解决方案。核心内容包括:
Package.swift 配置动态库目标,让主应用与扩展依赖该框架,避免代码重复链接。Bundle.module 的查找逻辑,确保资源访问路径正确。文章通过具体代码示例与目录结构对比,展示了优化前后的效果,为 Swift 开发者解决模块化带来的包体积问题提供了可落地的实践方案。
@ChengzhiHuang:调试工作通常只涉及单个主应用进程。但随着 App Extension、XPC 服务以及更复杂的 macOS 应用架构变得普遍,我们偶尔需要同时关注多个进程。然而 Xcode 在这方面的支持却不尽如人意:它能自动 attach 到你构建的 XPC 服务,但对于非 XPC 的子进程或多个同名进程实例,就显得力不从心,需要手动逐一操作,效率低下。对此作者提供了一个脚本查找所有指定名称的进程,并让 Xcode 的调试器一次性全部 attach 到它们上面。适合有特定需求的同学阅读。
@阿权:文章为文本输入组件的文字 filter 提供了通用的解决方案。本文核心解决 SwiftUI TextField 过滤输入时的 “双重更新” 问题,核心思路封过滤 / 转换逻辑,以解耦上下游逻辑。具体思路如下:
@含笑饮砒霜:这篇文章讲解 SwiftUI 中的弹簧动画,说明其模拟物理运动、适配用户直接交互的特性,优于 easeInOut 等动画;介绍了该动画的默认用法、响应速度和阻尼系数两个可调参数,withAnimation 和.animation (_:value:) 两种触发方式,以及手势适配的.interactiveSpring () 修饰符,并结合实操案例展示应用,同时明确其适用于用户触发的操作,加载类自动界面变化则更适合线性 / 缓入缓出动画,强调其能提升应用的交互体验。
@JonyFang: 本文介绍了 Swift 6.2 新增的 InlineArray 类型,一种固定大小、值类型的内联数组。与标准 Array 的堆分配不同,InlineArray 将元素直接存储在值内部,消除了堆分配、引用计数和指针间接访问的开销。核心要点:
@DylanYang:作者向我们讲述了借助 NonisolatedNonsendingByDefault ,Non-Sendable 类型目前成为了非常适合作为首选的类型,它更简单、没有额外的语法负担,也更通用,没有太多限制不像 Actor。当然 Non-Sendable 也有一些缺点,比如配合 Task 的场景等。开发者可以根据实际需求来做出适合的选择。
@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)
调用命名为'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")
调用命名为'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的形式。
还有很多规范和约定,后续补充。
原生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端提供的功能都以插件的方式提供,插件(除基础插件)都继承自一个基础插件类,插件结果回调都是走异步回传值方式,同步方式也可以但暂没实现。
基础插件对象是处理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"]];
}
}
js端的第一个信号来自wk的h5输入框拦截回调方法,参数prompt里携带js端要调用的api名字,参数值为字符串:_ynbridge=namespace1.api_1,_ynbridge=为YNBridge框架调用的标记,如果不是以这个标记开头则不做任何处理,只弹出正常的系统弹框。
通过api名,去插件集合里找有没有注册对应的插件对象,如果没有找到或找到了但插件下没有对应api则将错误结果返回js端
js调起的api,参数由defaultText携带。defaultText是json字符串,需要转换为json对象来解析出数据,参数值示例:{"data":null,"callId":"callId0"} data:真实参数值。 callId:api调用事件id或叫回传值队列id,当次api调用js需要回传值时此参数不为空,如果为空则表示当次api调用js端不需要结果回调
-(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];
exec:方法的形参是YNJsCallInfo对象,该对象携带的参数:
action:api名,或叫动作标识字符串,各业务通过该字段判断该执行什么功能,如果插件内没有处理该api则返回调用失败的错误值false反之返回true。
callId:api调用事件id或叫回传值队列id,当给js回传值时需要带上该值返回去。
data:js给过来的参数值。
callBack:block变量,结果回调入口,回传值时需要指定四个参数status、value、callId、complete,参数用处后面讲解。
功能实现完成后需要调用YNJsCallInfo对象的callBack回调方法,方法参数:
status:结果状态值,此值为一个枚举类型,OK表示成功ERROR表示失败。
value:结果值,该值最后在调用js回传值api时会转换为json字符串格式。
callId:api调用事件id或叫回传值队列id。
complete:bool值,当次api任务是否全部执行完毕,处理需要保活服务的长连接状态,false执行完毕,true服务需要继续保持。
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回调参数。
原生功能通过插件的形式实现,要新增一个插件只需要: 第一步新建一个继承'YNPlugin'基础插件类的对象,然后在对象里实现方法-(BOOL)exec:(YNJsCallInfo*)arg; 第二步在YNBridgePlugPlist.plist文件里添加以下形式的代码
<dict>
<key>namespace</key>
<string>命名空间</string>
<key>plug</key>nativeCallBack
<string>插件类名</string>
</dict>
然后将命名空间名和相应的api名告诉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)
})
执行YN.call()方法,实现调起原生和结果回调队列的维护,如果注入过安卓js对象‘window._anbridge’则执行_anbridge.call(api, arg)调起安卓端,如果注入过‘window._ynwk’值为true则执行prompt('_ynbridge=' + api, arg)调起iOS端,如果需要有回传值,则arg对象将给callId字段赋一个唯一值,并且在window.nativeCallBackIds缓存集合里新增callId值,值即为回调函数。
所有插件调用的前提基础是js端和原生端都已正常初始化,并且通讯已建立,即deviceReady已为ture,deviceReady的询问会在js入口函数里执行,即通过YN.call()方法,执行一个原生YNBase.init的api,如果结果返回为OK则为deviceReady成功
原生端插件执行结果回调通过‘nativeCallBack = function (callId,status,args,complete)’方法接收值,方法内部通过callId在window.nativeCallBackIds对象里找到回调方法然后执行,将args值由json字符串转json对象后传入,判断complete字段,为true则执行:delete window.nativeCallBackIds[callId]代码,将该服务回调移除队列。
YN.register('asynObj',new YNPlugin());
YN.register('YNPlugin1',new YNPlugin1());
//告诉原生js初始化了,调原生初始化api(在js初始化前原生就要求执行的js方法可在jsinit方法里开始执行了)
if (deviceReady){
YN.call('YNBase.jsinit');
}
register()方法内部实现同原生注册插件的形式,将插件和对应的命名空间添加进window.nativeNamespaceInterfaces集合。
接收原生端第一个信号由nativeCallJs = function(callId,service,action,actionArgs)方法接收,参数:
callId:api调用事件id或叫回传值队列id。
service:js api对应的命名空间名。
action:api名。
actionArgs:原生端的参数。 方法内部实现同原生插件调用,也是找到插件并执行插件方法exec(action,args,responseCallback)。
插件回传值结果和api调用结果通过调用原生的YNBase.returnValue这个api实现,即执行YN.call('YNBase.returnValue', value); value是参数对象,包含data、callId、complete、status四个字段,含义和用途同原生回调那里。
调起一个js端的插件功能,执行wk对象的方法-(void)callHandler:(NSString*)server action:(NSString *)action arguments:(id)args completionHandler:(JSCallback)completionHandler;该方法逻辑同js call native时调用的YN.call()方法,通过维护一个callid服务队列来处理结果回传。
组装好参数后浏览器执行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时将该服务对象从队列里移除
摘要: Swift 6 引入了严格的并发检查机制,旨在消除数据竞争,提升多线程编程的安全性与可维护性。本文将深入探讨 @Sendable 协议的本质与应用场景,以及 Actor 隔离模型如何成为构建并发安全代码的基石。我们将通过代码示例和架构图,剖析这些新特性如何帮助 iOS 开发者避免常见的并发陷阱,并提供平滑迁移到 Swift 6 并发模型的实践指导。
在现代移动应用开发中,并发编程无处不在,从 UI 响应、网络请求到数据处理,合理利用多核处理器能显著提升用户体验。然而,并发也带来了诸多挑战,如数据竞争(Data Race)、死锁(Deadlock)和优先级反转(Priority Inversion),这些问题往往难以调试,导致应用崩溃或行为异常。
Swift 社区长期致力于解决这些问题。从 Swift 5.5 引入的 async/await 结构化并发,到 Swift 6 升级为默认启用的严格并发检查 (Strict Concurrency Checking),都体现了 Swift 在保证性能的同时,极大提升并发安全性的决心。
本文将聚焦 Swift 6 核心的两个概念:@Sendable 协议和 Actor 隔离模型。它们共同构筑了 Swift 安全并发的基石。
@Sendable:类型安全传递的契约@Sendable 的核心作用@Sendable 是 Swift 6 中引入的一个标记协议 (Marker Protocol),它声明了一个类型或函数是可以在并发上下文之间安全传递的。这里的“安全传递”意味着该类型的值在从一个并发域(如 Task 或 Actor)发送到另一个并发域时,不会引发数据竞争。
具体来说,满足 @Sendable 要求的类型必须满足以下条件之一:
struct 或 enum,它们默认是可复制的,每个并发域都有其独立的副本,因此是 Sendable 的。class 的所有存储属性都是 let 常量,且自身是 final 的,它也是 Sendable 的。Sendable 的容器类型:如 Array<Element> 或 Dictionary<Key, Value>,只要其 Element 或 Key/Value 遵循 Sendable,自身也遵循 Sendable。async 且标记为 @Sendable。@Sendable?考虑以下经典的竞态条件场景:
class Counter {
var value = 0
func increment() {
value += 1
}
}
let counter = Counter()
// ❌ 潜在的数据竞争
Task {
for _ in 0..<1000 {
counter.increment()
}
}
Task {
for _ in 0..<1000 {
counter.increment()
}
}
在 Swift 6 严格并发模式下,编译器会立刻对 counter 这个非 Sendable 的引用类型在多个 Task 中被共享和修改的情况发出警告甚至错误。
@Sendable 的设计哲学:不是通过运行时锁或信号量来强制同步,而是通过编译时检查,确保只有那些本质上安全共享的数据类型才能跨并发边界传递从而在源头上预防数据竞争。
@Sendable 闭包与函数函数和闭包也可以是 @Sendable 的。一个 @Sendable 的闭包意味着它捕获的所有值都必须是 @Sendable 的,或者它没有捕获任何可变状态。
// Sendable 闭包示例
func processData(@Sendable _ handler: @escaping ([Int]) async -> Void) {
Task {
let data = [1, 2, 3] // 假设数据是 Sendable 的
await handler(data)
}
}
processData { numbers in
// numbers 是一个 Sendable 类型 ([Int]),安全
print("Processing numbers: \(numbers)")
}
Actor 是 Swift 并发模型中一种强大的隔离机制 (Isolation Mechanism)。它将数据和操作封装在一个独立的并发执行单元中,确保:
当外部任务需要与 Actor 交互时,必须通过 await 关键字异步调用其方法。这强制了所有对 Actor 状态的访问都经过 Actor 的“信箱”,确保了消息的顺序性。
actor BankAccount {
private var balance: Double
init(initialBalance: Double) {
self.balance = initialBalance
}
func deposit(amount: Double) {
balance += amount
print("Deposited \(amount). New balance: \(balance)")
}
func withdraw(amount: Double) {
if balance >= amount {
balance -= amount
print("Withdrew \(amount). New balance: \(balance)")
} else {
print("Insufficient funds to withdraw \(amount). Current balance: \(balance)")
}
}
func getBalance() -> Double {
return balance
}
}
// 使用 Actor
let account = BankAccount(initialBalance: 1000)
Task {
await account.deposit(amount: 200)
}
Task {
await account.withdraw(amount: 150)
}
Task {
let currentBalance = await account.getBalance()
print("Final balance: \(currentBalance)")
}
在上述例子中,即使 deposit 和 withdraw 被并发调用,Actor 机制也能保证它们按顺序执行,避免了 balance 的数据竞争。
为了更好地理解 Actor 的工作原理,我们可以用一个 Mermaid 流程图来表示:
graph TD
A[外部并发任务 A] -->|异步调用 withdraw(150)| ActorQueue(Actor 消息队列)
B[外部并发任务 B] -->|异步调用 deposit(200)| ActorQueue
C[外部并发任务 C] -->|异步调用 getBalance()| ActorQueue
ActorQueue -->|按顺序执行| ActorCore(BankAccount Actor 核心)
ActorCore -->|修改 balance| ActorState[Actor 内部状态 (balance)]
ActorCore --> D{返回结果给 Task C}
解释:
getBalance()),它会通过 await 机制将结果传递回调用者。Swift UI 和 UIKit 这样的框架,其 UI 更新操作必须在主线程上执行。Swift 引入了 MainActor 这个全局 Actor 来解决这个问题。
任何标记为 @MainActor 的函数、属性或类,都保证其操作在主线程上执行。
@MainActor
class UIUpdater {
var message: String = "" {
didSet {
// 这个属性的修改和 didSet 都会在主线程上执行
print("UI Updated: \(message)")
}
}
func updateMessage(with text: String) {
// 这个方法也会在主线程上执行
self.message = text
}
}
let updater = UIUpdater()
func fetchData() async {
let result = await performNetworkRequest() // 假设这是一个耗时操作
// 异步切换到 MainActor,确保 UI 更新安全
await MainActor.run {
updater.updateMessage(with: "Data loaded: \(result)")
}
}
Task {
await fetchData()
}
在 Swift 6 严格并发模式下,如果一个非 @MainActor 的异步函数尝试直接修改 @MainActor 隔离的属性或调用其方法,编译器会发出警告或错误,强制你使用 await MainActor.run { ... } 进行安全的线程切换。
Swift 6 默认开启严格并发检查,这意味着过去一些“看似无害”的并发代码现在会被编译器捕获。这无疑会增加短期内的编译错误,但从长远来看,它极大地提升了代码的质量和可靠性。
迁移建议:
@Sendable 或 Actor 隔离的编译错误时,不要盲目添加 nonisolated 或 @unchecked Sendable。深入理解编译器报错的意图,思考如何重构代码以满足并发安全。nonisolated 和 @unchecked Sendable:这两个是逃逸舱口,只在明确知道其行为,并能保证外部同步的情况下使用,否则会破坏 Swift 的并发安全性保证。Swift 6 的严格并发检查是 Swift 语言发展的一个里程碑,它通过 @Sendable 和 Actor 隔离,为开发者提供了前所未有的编译时并发安全保证。虽然迁移过程可能需要投入一定精力,但最终会收获更健壮、更易于维护的并发代码。作为资深 iOS 开发者,掌握并应用这些新特性,是构建高性能、高质量应用的必经之路。
参考资料:
涵盖底层原理、第三方库、疑难杂症、性能优化、横向纵向对比,面试+实战全方位覆盖
Flutter 架构自上而下分为三层:
| 层级 | 组成 | 语言 | 职责 |
|---|---|---|---|
| Framework 层 | Widgets、Material/Cupertino、Rendering、Animation、Painting、Gestures、Foundation | Dart | 提供上层 API,开发者直接使用 |
| Engine 层 | Skia(渲染引擎)、Dart VM、Text Layout(LibTxt)、Platform Channels | C/C++ | 底层渲染、文字排版、Dart 运行时 |
| Embedder 层 | 平台相关代码(Android/iOS/Web/Desktop) | Java/Kotlin/ObjC/Swift/JS | 平台嵌入、表面创建、线程设置、事件循环 |
@immutable 的,所有字段都是 finalcreateElement() 创建对应的 ElementruntimeType 和 key 时可以复用 Elementmount():Element 首次插入树中update(Widget newWidget):Widget 重建时更新 Elementunmount():从树中移除deactivate():临时移除(GlobalKey 可重新激活)activate():重新激活performLayout() 计算大小和位置paint() 进行绘制RenderBox:2D 盒模型布局(最常用)RenderSliver:滚动布局模型RenderView:渲染树根节点setState() 触发
↓
Widget 重建(调用 build 方法)→ 新的 Widget Tree
↓
Element 进行 Diff(canUpdate 判断)
↓
canUpdate = true → 更新 Element,调用 RenderObject.updateRenderObject()
canUpdate = false → 销毁旧 Element/RenderObject,创建新的
↓
标记需要重新布局/绘制的 RenderObject
↓
下一帧执行布局和绘制
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
runtimeType 和 key
Key
├── LocalKey(局部 Key,在同一父节点下唯一)
│ ├── ValueKey<T> ← 用值比较(如 ID)
│ ├── ObjectKey ← 用对象引用比较
│ └── UniqueKey ← 每次都唯一(不可复用)
└── GlobalKey(全局 Key,整棵树中唯一)
└── GlobalObjectKey
| Key 类型 | 适用场景 | 原理 |
|---|---|---|
| ValueKey | 列表项有唯一业务 ID 时 | 用 value 的 == 运算符比较 |
| ObjectKey | 组合多个字段作为标识时 | 用 identical() 比较对象引用 |
| UniqueKey | 强制每次重建时 | 每个实例都是唯一的 |
| GlobalKey | 跨组件访问 State、跨树移动 Widget | 通过全局注册表维护 Element 引用 |
createState() → 创建 State 对象(仅一次)
↓
initState() → 初始化状态(仅一次),可访问 context
↓
didChangeDependencies() → 依赖变化时调用(首次 initState 之后也调用)
↓
build() → 构建 Widget 树(多次调用)
↓
didUpdateWidget() → 父组件重建导致 Widget 配置变化时
↓
setState() → 手动触发重建
↓
deactivate() → 从树中移除时(可能重新插入)
↓
dispose() → 永久移除时,释放资源(仅一次)
| 方法 | 调用次数 | 能否调用 setState | 典型用途 |
|---|---|---|---|
createState |
1 次 | 不能 | 创建 State 实例 |
initState |
1 次 | 不能(但赋值 OK) | 初始化控制器、订阅流 |
didChangeDependencies |
多次 | 可以 | 响应 InheritedWidget 变化 |
build |
多次 | 不能 | 返回 Widget 树 |
didUpdateWidget |
多次 | 可以 | 对比新旧 Widget,更新状态 |
reassemble |
多次(仅 debug) | 可以 | hot reload 时调用 |
deactivate |
可能多次 | 不能 | 临时清理 |
dispose |
1 次 | 不能 | 取消订阅、释放控制器 |
initState() 之后自动调用一次InheritedWidget 发生变化时Theme.of(context)、MediaQuery.of(context)、Provider.of(context) 的数据发生变化dependOnInheritedWidgetOfExactType 注册了依赖关系才会触发Vsync 信号到来
↓
① Animate 阶段:执行 Ticker 回调(动画)
↓
② Build 阶段:执行被标记 dirty 的 Element 的 build 方法
↓
③ Layout 阶段:遍历需要重新布局的 RenderObject,执行 performLayout()
↓
④ Compositing Bits 阶段:更新合成层标记
↓
⑤ Paint 阶段:遍历需要重绘的 RenderObject,执行 paint()
↓
⑥ Compositing 阶段:将 Layer Tree 组合成场景
↓
⑦ Semantics 阶段:生成无障碍语义树
↓
⑧ Finalize 阶段:将场景提交给 GPU
| 阶段 | 枚举值 | 说明 |
|---|---|---|
| idle | SchedulerPhase.idle |
空闲,等待下一帧 |
| transientCallbacks | SchedulerPhase.transientCallbacks |
动画回调(Ticker) |
| midFrameMicrotasks | SchedulerPhase.midFrameMicrotasks |
动画后的微任务 |
| persistentCallbacks | SchedulerPhase.persistentCallbacks |
build/layout/paint |
| postFrameCallbacks | SchedulerPhase.postFrameCallbacks |
帧后回调 |
父 RenderObject
│ 传递 BoxConstraints(minW, maxW, minH, maxH)
↓
子 RenderObject
│ 根据约束计算 Size
↑ 返回 Size(width, height)
│
父 RenderObject 确定子的 Offset
sizedByParent == trueconstraints.isTight(紧约束)parentUsesSize == falseDart 是单线程模型
main() 函数执行
↓
进入事件循环 Event Loop
↓
┌─────────────────────────────┐
│ 检查 MicroTask Queue │ ← 优先级高
│ (全部执行完才处理 Event) │
├─────────────────────────────┤
│ 检查 Event Queue │ ← I/O、Timer、点击等
│ (取一个事件处理) │
└─────────────────────────────┘
↓ 循环
| 特性 | MicroTask | Event |
|---|---|---|
| 优先级 | 高 | 低 |
| 来源 |
scheduleMicrotask()、Future.microtask()、Completer |
Timer、I/O、手势事件、Future()、Future.delayed()
|
| 执行时机 | 在当前 Event 处理完之后、下一个 Event 之前 | 按顺序从队列取出 |
| 风险 | 过多会阻塞 UI(卡帧) | 正常调度 |
Future 是对异步操作结果的封装async 函数总是返回 Future
await 暂停当前异步函数执行,但不阻塞线程
await 本质上是注册一个回调到 Future 的 then 链上Future() 构造函数将任务放入 Event Queue
Future.microtask() 将任务放入 MicroTask Queue
Future.value() 如果值已就绪,回调仍然异步执行(下一个 microtask)compute() 函数是对 Isolate 的高层封装Isolate.run(),更简洁| 模式 | 全称 | 场景 | 特点 |
|---|---|---|---|
| JIT | Just-In-Time | Debug/开发 | 支持 Hot Reload、增量编译、反射 |
| AOT | Ahead-Of-Time | Release/生产 | 预编译为机器码,启动快、性能高 |
| Kernel Snapshot | - | 测试/CI | 编译为中间表示 |
String name 不能为 nullString? name
late 关键字:延迟初始化,使用前必须赋值,否则运行时报错required 关键字:命名参数必须传值?.(安全调用)、??(空值合并)、!(强制非空)if (x != null) 后 x 自动提升为非空类型mixin 是代码复用机制,区别于继承with 关键字混入on 限制只能混入特定类的子类dependOnInheritedWidgetOfExactType<T>() 注册依赖didChangeDependencies()
_dependents 集合,保存所有依赖它的 ElementupdateShouldNotify() 方法决定是否通知依赖者setState(() { /* 修改状态 */ })
↓
_element!.markNeedsBuild() → 将 Element 标记为 dirty
↓
SchedulerBinding.instance.scheduleFrame() → 请求新帧
↓
下一帧时 BuildOwner.buildScope()
↓
遍历 dirty Elements,调用 element.rebuild()
↓
调用 State.build() 获取新 Widget
↓
Element.updateChild() 进行 Diff 更新
ChangeNotifier 维护一个 _listeners 列表notifyListeners() 遍历列表调用所有监听器ValueNotifier<T> 继承自 ChangeNotifier,当 value 变化时自动 notifyListeners()
_count 跟踪,支持在遍历时添加/移除监听器平台原始事件(PointerEvent)
↓
GestureBinding.handlePointerEvent()
↓
HitTest(命中测试):从根节点向叶子节点遍历
↓
生成 HitTestResult(命中路径)
↓
按命中路径分发 PointerEvent 给各 RenderObject
↓
GestureRecognizer 加入竞技场(GestureArena)
↓
竞技场裁决(Arena Resolution)→ 只有一个胜出
RawGestureDetector、GestureRecognizer.resolve()、Listener 绕过竞技场hitTest()
hitTestSelf() 和 hitTestChildren()
HitTestBehavior:
deferToChild:只有子节点命中时才命中(默认)opaque:自身命中(即使子节点没命中)translucent:自身也命中,但不阻止后续命中测试| Channel 类型 | 编解码 | 通信模式 | 典型用途 |
|---|---|---|---|
| BasicMessageChannel | 标准消息编解码器 | 双向消息传递 | 简单数据传递(字符串、JSON) |
| MethodChannel | StandardMethodCodec | 方法调用(请求-响应) | 调用原生方法并获取返回值 |
| EventChannel | StandardMethodCodec | 单向事件流(原生→Flutter) | 传感器数据、电池状态等持续性事件 |
| 编解码器 | 支持类型 | 适用场景 |
|---|---|---|
| StringCodec | String | 纯文本 |
| JSONMessageCodec | JSON 兼容类型 | JSON 数据 |
| BinaryCodec | ByteData | 二进制数据 |
| StandardMessageCodec | null, bool, int, double, String, List, Map, Uint8List | 默认,最常用 |
Flutter (Dart) Platform (Native)
│ │
│ MethodChannel.invokeMethod() │
├────────────────────────────────────→│
│ BinaryMessenger │
│ (BinaryCodec编码) │
│ │ MethodCallHandler 处理
│←────────────────────────────────────┤
│ 返回 Result │
│ (BinaryCodec解码) │
BinaryMessenger 传输 ByteData
dart:ffi 包使用Navigator.push() / Navigator.pop()
Navigator.pushNamed() / onGenerateRoute
Overlay + OverlayEntry 实现,每个页面是一个 OverlayEntryRouter、RouteInformationParser、RouterDelegate
RouteInformationProvider:提供路由信息(URL)RouteInformationParser:解析路由信息为应用状态RouterDelegate:根据状态构建 Navigator 的页面栈push 返回 Future<T?>,pop 传回结果arguments 传参onGenerateRoute 中解析 RouteSettings 获取参数Completer<T> 管理,pop 时 complete| 组件 | 作用 |
|---|---|
| Animation | 动画值的抽象,持有当前值和状态 |
| AnimationController | 控制动画的播放、暂停、反向,产生 0.0~1.0 的线性值 |
| Tween | 将 0.0~1.0 映射到任意范围(如颜色、大小) |
| Curve | 定义动画的速度曲线(如 easeIn、bounceOut) |
| AnimatedBuilder | 监听动画值变化,触发重建 |
| Ticker | 与 Vsync 同步的时钟,驱动 AnimationController |
| 特性 | 隐式动画(AnimatedXxx) | 显式动画(XxxTransition) |
|---|---|---|
| 复杂度 | 低 | 高 |
| 控制力 | 低(只需改属性值) | 高(完全控制播放) |
| 实现 | 内部自动管理 Controller | 手动创建 Controller |
| 典型组件 | AnimatedContainer、AnimatedOpacity | FadeTransition、RotationTransition |
| 适用场景 | 简单属性变化 | 复杂动画、组合动画、循环动画 |
TickerProviderStateMixin:为 State 提供 TickerTickerMode 可以禁用 Ticker 节省资源SingleTickerProviderStateMixin 只能创建一个 AnimationControllerTickerProviderStateMixin
tag 的 Hero Widget 会执行飞行动画Viewport:可视窗口,持有 ViewportOffset(滚动偏移)Sliver:可滚动的条状区域SliverConstraints
| 特性 | BoxConstraints | SliverConstraints |
|---|---|---|
| 约束维度 | 宽度 + 高度 | 主轴剩余空间 + 交叉轴大小 |
| 布局结果 | Size | SliverGeometry |
| 适用场景 | 普通布局 | 滚动列表 |
| 包含信息 | min/maxWidth, min/maxHeight | scrollOffset, remainingPaintExtent, overlap 等 |
| 字段 | 含义 |
|---|---|
scrollExtent |
沿主轴方向的总长度 |
paintExtent |
可绘制的长度 |
layoutExtent |
占用的布局空间 |
maxPaintExtent |
最大可绘制长度 |
hitTestExtent |
可命中测试的长度 |
hasVisualOverflow |
是否有视觉溢出 |
CustomScrollView:使用 Sliver 协议的自定义滚动视图NestedScrollView:处理嵌套滚动(如 TabBar + TabBarView + ListView)_NestedScrollCoordinator 协调内外滚动BuildContext 实际上就是 Element
abstract class Element implements BuildContextTheme.of(context))context.findRenderObject())context.findAncestorWidgetOfExactType<T>())context.findAncestorStateOfType<T>())initState 中 context 已可用,但某些操作需要放在 addPostFrameCallback 中Navigator.of(context) 的 context 必须在 Navigator 之下Scaffold.of(context) 的 context 必须在 Scaffold 之下mounted
Image Widget
↓
ImageProvider.resolve()
↓
检查 ImageCache(内存缓存)
↓ 未命中
ImageProvider.load()
↓
ImageStreamCompleter
↓
解码(codec)→ ui.Image
↓
放入 ImageCache
↓
通知 ImageStream 监听器
↓
Image Widget 获取帧数据并绘制
ImageProvider 的实例(需正确实现 == 和 hashCode)PaintingBinding.instance.imageCache 配置Localizations Widget 和 LocalizationsDelegate
GlobalMaterialLocalizations.delegate:Material 组件文本GlobalWidgetsLocalizations.delegate:文字方向GlobalCupertinoLocalizations.delegate:Cupertino 组件文本LocalizationsDelegate<T>,重写 load() 方法InheritedWidget 的封装ChangeNotifierProvider 内部创建 InheritedProvider
ChangeNotifier.addListener() → Element 标记 dirty → 重建| 类 | 作用 |
|---|---|
Provider<T> |
最基础的 Provider,提供值但不监听变化 |
ChangeNotifierProvider<T> |
监听 ChangeNotifier 并自动 rebuild |
FutureProvider<T> |
提供 Future 的值 |
StreamProvider<T> |
提供 Stream 的值 |
MultiProvider |
嵌套多个 Provider 的语法糖 |
ProxyProvider |
依赖其他 Provider 的值来创建 |
Consumer<T> |
精确控制重建范围 |
Selector<T, S> |
选择特定属性监听,减少重建 |
| 方式 | 监听变化 | 使用场景 |
|---|---|---|
context.watch<T>() |
是 | build 方法中,需要响应变化 |
context.read<T>() |
否 | 事件回调中,只读取一次 |
context.select<T, R>() |
是(部分) | 只监听特定属性 |
Provider.of<T>(context) |
默认是 | 等价于 watch |
Provider.of<T>(context, listen: false) |
否 | 等价于 read |
ChangeNotifierProvider 默认在 dispose 时调用 ChangeNotifier.dispose()
ChangeNotifierProvider.value() 不会自动 dispose(因为不拥有生命周期).value() 构造时需要手动管理生命周期UI 发出 Event → Bloc 处理 → 产生新 State → UI 根据 State 重建
| 概念 | 说明 |
|---|---|
| Event | 用户操作或系统事件,输入 |
| State | UI 状态,输出 |
| Bloc | 业务逻辑容器,Event → State 的转换器 |
| Cubit | 简化版 Bloc,直接通过方法调用 emit State(没有 Event) |
Stream 处理 Event 和 StateStreamController 传入mapEventToState(旧版)或 on<Event>()(新版)处理事件emit() 发出,本质是向 State Stream 中添加值BlocProvider 底层也是基于 InheritedWidget + Provider 实现BlocBuilder 内部使用 BlocListener + buildWhen 来控制重建| 特性 | Bloc | Cubit |
|---|---|---|
| 输入方式 | Event 类 | 方法调用 |
| 可追溯性 | 高(Event 可序列化) | 低 |
| 复杂度 | 高 | 低 |
| 测试性 | 优秀(可 mock Event) | 良好 |
| 适用场景 | 复杂业务逻辑、需要 Event Transform | 简单状态管理 |
| 调试 | BlocObserver 可监控所有事件 | 同样支持 |
| 模块 | 功能 |
|---|---|
| 状态管理 |
GetBuilder(简单)、Obx(响应式) |
| 路由管理 |
Get.to()、Get.toNamed() 无需 context |
| 依赖注入 |
Get.put()、Get.lazyPut()、Get.find()
|
| 工具类 | Snackbar、Dialog、BottomSheet 无需 context |
.obs 将值包装成 RxT(如 RxInt、RxString)Obx 内部创建 RxNotifier,通过 Stream 监听变化ProviderContainer 管理状态,而非 Widget Tree| 类型 | 用途 |
|---|---|
Provider |
只读值 |
StateProvider |
简单可变状态 |
StateNotifierProvider |
复杂状态逻辑 |
FutureProvider |
异步计算 |
StreamProvider |
流数据 |
NotifierProvider |
2.0 新式状态管理 |
AsyncNotifierProvider |
2.0 异步状态管理 |
| 特性 | Provider | Riverpod |
|---|---|---|
| 依赖 BuildContext | 是 | 否 |
| 编译时安全 | 否(运行时异常) | 是 |
| 多同类型 Provider | 困难 | 通过 family 支持 |
| 测试性 | 中等 | 优秀 |
| 生命周期 | 跟随 Widget | 独立管理 |
| 学习曲线 | 低 | 中等 |
Request → Interceptors(onRequest) → HttpClientAdapter → Response → Interceptors(onResponse)
dart:io 的 HttpClient(可替换为其他 Adapter)请求发出
↓
Interceptor1.onRequest → Interceptor2.onRequest → ... → InterceptorN.onRequest
↓
实际网络请求(HttpClientAdapter)
↓
InterceptorN.onResponse → ... → Interceptor2.onResponse → Interceptor1.onResponse
↓
返回结果
| 特性 | 说明 |
|---|---|
| 拦截器 | 请求/响应/错误拦截 |
| FormData | 文件上传 |
| 取消请求 | CancelToken |
| 超时控制 | connectTimeout/receiveTimeout/sendTimeout |
| 转换器 | Transformer(JSON 解析可在 Isolate 中进行) |
| 适配器 | HttpClientAdapter(可替换底层实现) |
GoRouterState 管理路由状态| 特性 | 说明 |
|---|---|
| 声明式路由 | 通过配置定义路由表 |
| Deep Link | 自动处理 URL 解析 |
| 路由重定向 |
redirect 回调 |
| ShellRoute | 保持底部导航栏等布局 |
| 类型安全路由 | 通过 code generation 实现 |
| Web 友好 | URL 自动同步 |
build_runner 的代码生成==、hashCode、toString、copyWith
@JsonSerializable() 标记类build_runner 生成 _$XxxFromJson 和 _$XxxToJson 方法请求图片 URL
↓
检查内存缓存(ImageCache)
↓ 未命中
检查磁盘缓存(flutter_cache_manager)
↓ 未命中
网络下载
↓
存入磁盘缓存
↓
解码并存入内存缓存
↓
显示
useState、useEffect、useMemoized、useAnimationController 等症状:包含大量数据的 ListView 滚动时帧率下降
根因分析:
ListView(children: [...]) 一次构建所有子项解决方案:
ListView.builder 按需构建(Lazy Construction)const 构造器减少不必要的重建AutomaticKeepAliveClientMixin 保持状态(谨慎使用,会增加内存)RepaintBoundary 隔离重绘区域CachedNetworkImage 并指定合理的 cacheWidth/cacheHeight
Scrollbar + physics: const ClampingScrollPhysics() 优化滚动感症状:列表项高度不固定,滚动到中间后返回顶部时发生跳动
根因分析:
SliverList 默认使用 estimatedMaxScrollOffset 估算解决方案:
itemExtent 指定固定高度(最优)prototypeItem 提供原型项ScrollController + IndexedScrollController)scrollable_positioned_list 等第三方库症状:PageView 内嵌 ListView,上下滑动和左右滑动冲突
根因分析:
解决方案:
physics: ClampingScrollPhysics() 或 NeverScrollableScrollPhysics()
NestedScrollView + SliverOverlapAbsorber/SliverOverlapInjector
CustomScrollView 统一管理 SliverScrollPhysics 在边界时转发滚动事件给外层NotificationListener<ScrollNotification> 手动协调解决方案:
NestedScrollView 是标准方案body 中的 ListView 使用 SliverOverlapInjector
headerSliverBuilder 中使用 SliverOverlapAbsorber
floatHeaderSlivers 控制头部是否浮动解决方案:
Scaffold 的 resizeToAvoidBottomInset: true(默认开启)SingleChildScrollView 包裹表单MediaQuery.of(context).viewInsets.bottom 获取键盘高度Scrollable.ensureVisible() 滚动到输入框位置解决方案:
resizeToAvoidBottomInset: false,手动处理布局AnimatedPadding 添加键盘高度的底部间距MediaQuery.of(context).viewInsets.bottom 动态调整位置根因分析:
AnimationController 未在 dispose() 中释放StreamSubscription 未取消ScrollController、TextEditingController 未 disposeGlobalKey 使用不当解决方案:
dispose() 中调用 .dispose()
dispose() 中 .cancel()
dispose() 中 .cancel()
mounted 状态flutter_leak 包自动检测解决方案:
ResizeImage 或 cacheWidth/cacheHeight 降低解码尺寸imageCache.clear() 清理缓存Image.memory 时注意 Uint8List 的释放根因分析:
解决方案:
解决方案:
BasicMessageChannel + BinaryCodec 传输二进制数据解决方案:
context.select() 避免不必要的重建解决方案:
ProxyProvider 处理依赖ref.watch() 自动追踪依赖BlocListener 监听一个 Bloc 的变化来触发另一个根因分析:
解决方案:
FlutterEngineCache)FlutterEngineGroup 共享引擎(Flutter 2.0+)FlutterFragment/FlutterViewController 而非 FlutterActivity
根因分析:
VirtualDisplay 模式(Android):额外的纹理拷贝HybridComposition 模式(Android):线程同步开销解决方案:
Hybrid Composition(性能更好,但有线程同步问题)根因分析:
TextPainter 的 strutStyle 和 textHeightBehavior 差异解决方案:
StrutStyle 统一行高TextHeightBehavior 控制首行和末行的行高行为height 属性精确控制行高比例解决方案:
fontTools 子集化字体(只包含用到的字符)根因分析:
解决方案(有限制):
解决方案:
Directionality Widget 或 Localizations
TextDirection.rtl
start/end 代替 left/right(EdgeInsetsDirectional)Positioned.directional 代替 Positioned
flutter run --dart-define=FORCE_RTL=true
解决方案:
MediaQuery.of(context).devicePixelRatio 获取像素密度LayoutBuilder 根据可用空间自适应FittedBox、AspectRatio 比例适配ScreenUtil 等比缩放flutter_screenutil 第三方库辅助适配核心原则:减少不必要的 rebuild
const Widget 在编译期创建实例,运行时不重新创建canUpdate 比较时,const 实例是同一个对象,直接跳过 updateChildSelector<T, S> 只监听 T 的某个属性 SConsumer 将 rebuild 范围限制在 Consumer 的 builder 内Selector 的 shouldRebuild:自定义比较逻辑BlocBuilder 的 buildWhen:控制何时重建shouldRebuild / operator ==
CustomMultiChildLayout 或 CustomPaint 处理复杂布局RepaintBoundary
sizedByParent 等手段触发IntrinsicHeight / IntrinsicWidth 会触发两次布局(一次计算 intrinsic,一次正式布局)LayoutBuilder
saveLayer 会创建离屏缓冲区(OffscreenBuffer)Opacity(< 1.0 时)、ShaderMask、ColorFilter、Clip.antiAliasWithSaveLayer
AnimatedOpacity 代替 Opacity,使用 FadeTransition
| ClipBehavior | 性能 | 质量 |
|---|---|---|
Clip.none |
最好 | 无裁剪 |
Clip.hardEdge |
好 | 锯齿 |
Clip.antiAlias |
中 | 抗锯齿 |
Clip.antiAliasWithSaveLayer |
差(触发 saveLayer) | 最好 |
Clip.hardEdge 或 Clip.antiAlias 即可Clip.none
cacheWidth / cacheHeight:告诉解码器以较小尺寸解码ImageProvider(会重复触发加载)precacheImage() 预加载ResizeImage 包装 Providerflutter run --cache-sksl)| 策略 | 效果 | 实现方式 |
|---|---|---|
| 降低解码分辨率 | 显著 |
cacheWidth / cacheHeight
|
| 调整缓存大小 | 中等 |
imageCache.maximumSize / maximumSizeBytes
|
| 及时清理缓存 | 中等 |
imageCache.clear() / evict()
|
| 使用占位图 | 间接 |
placeholder / FadeInImage
|
| 列表离屏回收 | 显著 | ListView.builder 的自动回收机制 |
ListView.builder:自动回收离屏 Widget 和 ElementaddAutomaticKeepAlives: false:禁止保持状态,释放离屏资源addRepaintBoundaries: false:在确定不需要时禁用(每项都有 RepaintBoundary 也有开销)findChildIndexCallback 优化长列表 Key 查找| 泄漏模式 | 原因 | 修复 |
|---|---|---|
| Controller 未释放 | dispose 未调用 controller.dispose() | 在 dispose 中释放 |
| Stream 未取消 | StreamSubscription 未 cancel | 在 dispose 中 cancel |
| Timer 未取消 | Timer 回调持有 State 引用 | 在 dispose 中 cancel |
| 闭包引用 | 匿名函数持有 context/state | 使用弱引用或检查 mounted |
| GlobalKey 滥用 | GlobalKey 持有 Element 引用 | 减少使用,及时释放 |
| Static 变量持有 | 静态变量引用了 Widget/State | 避免在 static 中存储 UI 相关对象 |
原生初始化 Flutter 引擎初始化
┌──────────┐ ┌─────────────────────────────┐ ┌──────────────┐
│ App Start │ →→→ │ Engine Init + Dart VM Init │ →→→ │ First Frame │
│ (Native) │ │ + Framework Init │ │ Rendered │
└──────────┘ └─────────────────────────────┘ └──────────────┘
| 阶段 | 优化措施 |
|---|---|
| 原生阶段 | 使用 FlutterSplashScreen,减少原生初始化逻辑 |
| 引擎初始化 | 预热引擎(FlutterEngineCache)、FlutterEngineGroup
|
| Dart 初始化 | 延迟非必要初始化、懒加载服务 |
| 首帧渲染 | 简化首屏 UI、减少首屏网络请求、使用骨架屏 |
| AOT 编译 | 确保 Release 模式使用 AOT |
| Tree Shaking | 移除未使用代码和资源 |
| 延迟加载 |
deferred as 延迟导入库 |
deferred-components(基于 Play Feature Delivery)| 组成部分 | 占比 | 说明 |
|---|---|---|
| Dart AOT 代码 | ~30% | 编译后的机器码 |
| Flutter Engine | ~40% | libflutter.so / Flutter.framework |
| 资源文件 | ~20% | 图片、字体、音频等 |
| 原生代码 | ~10% | 第三方 SDK、Channel 实现 |
| 措施 | 效果 |
|---|---|
--split-debug-info |
分离调试信息,减少 ~30% |
--obfuscate |
代码混淆,略微减少 |
| 移除未使用资源 | 手动或使用工具检测 |
| 压缩图片 | WebP 格式、TinyPNG |
| 字体子集化 | 减少中文字体体积 |
--tree-shake-icons |
移除未使用的 Material Icons |
deferred-components |
延迟加载非核心模块 |
| 移除未使用的插件 | pubspec.yaml 清理 |
| 策略 | 说明 |
|---|---|
使用 itemExtent
|
跳过子项布局计算,直接使用固定高度 |
使用 prototypeItem
|
用原型项推导高度 |
findChildIndexCallback |
优化长列表的 Key 查找复杂度 |
addAutomaticKeepAlives: false |
减少内存占用 |
缩小 cacheExtent
|
减少预渲染范围(默认 250 逻辑像素) |
const WidgetOpacity、ClipPath 等高开销 WidgetRepaintBoundary 隔离cacheWidth/cacheHeight
CachedNetworkImage 避免重复加载AnimatedBuilder / XXXTransition 而非在 setState 中直接更新AnimatedBuilder 的 child 参数:不受动画影响的子树只构建一次RepaintBoundary 隔离动画区域Transform 而非改变 Widget 的实际属性Transform 只影响绘制阶段,不触发布局| 策略 | 说明 |
|---|---|
| 请求缓存 | Dio Interceptor 实现 HTTP 缓存 |
| 请求合并 | 相同 URL 的并发请求合并为一个 |
| 请求取消 | 页面退出时取消未完成请求(CancelToken) |
| 连接复用 | HTTP/2 多路复用 |
| 数据压缩 | 开启 gzip 响应 |
| 分页加载 | 避免一次加载全部数据 |
compute() 在 Isolate 中解析Transformer 可配置在后台线程处理json_serializable 代码生成而非手写RepaintBoundary 区域Debug Paint:可视化布局边界和 Padding| 工具/标志 | 用途 |
|---|---|
debugProfileBuildsEnabled |
跟踪 build 调用 |
debugProfileLayoutsEnabled |
跟踪 layout 调用 |
debugProfilePaintsEnabled |
跟踪 paint 调用 |
debugPrintRebuildDirtyWidgets |
打印 dirty Widget |
debugRepaintRainbowEnabled |
彩虹色显示重绘区域 |
debugPrintLayouts |
打印布局过程 |
| 维度 | setState | InheritedWidget | Provider | Bloc | GetX | Riverpod |
|---|---|---|---|---|---|---|
| 学习成本 | 极低 | 中 | 低 | 中高 | 低 | 中 |
| 代码量 | 少 | 多 | 中 | 多 | 少 | 中 |
| 可测试性 | 差 | 差 | 中 | 优秀 | 差 | 优秀 |
| 可维护性 | 差(项目大时) | 中 | 中 | 优秀 | 差 | 优秀 |
| 性能 | 低(全量重建) | 高 | 高 | 高 | 高 | 高 |
| 依赖 context | 是 | 是 | 是 | 是 | 否 | 否 |
| 编译安全 | - | 否 | 否 | 是 | 否 | 是 |
| 适合项目规模 | 小型 | 中型 | 中型 | 大型 | 小中型 | 大型 |
| 社区活跃度 | - | - | 高 | 高 | 高 | 高 |
| 响应式模式 | 手动 | 手动 | 自动 | 自动 | 自动 | 自动 |
| DevTools 支持 | - | - | 有 | 优秀 | 有限 | 有 |
| 原理 | Element dirty | InheritedElement | InheritedWidget封装 | Stream | GetxController+Rx | ProviderContainer |
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 原型 / Demo | setState / GetX | 最快出结果 |
| 中型项目 | Provider | 简单够用,社区支持好 |
| 大型企业项目 | Bloc / Riverpod | 可测试性强,架构清晰 |
| 需要脱离 Widget 树 | Riverpod / GetX | 不依赖 BuildContext |
| 团队不熟悉 Flutter | Provider | 最容易上手 |
| 重视可追溯性 | Bloc | Event 日志、Time Travel |
| 方法 | 调用时机 | 调用次数 | 可否 setState | 有 oldWidget | 典型操作 |
|---|---|---|---|---|---|
createState |
Widget 创建时 | 1 | 否 | 否 | 创建 State |
initState |
State 初始化 | 1 | 否(可赋值) | 否 | 初始化变量、订阅 |
didChangeDependencies |
依赖变化 | ≥1 | 可以 | 否 | 读取 InheritedWidget |
build |
每次重建 | 多次 | 否 | 否 | 返回 Widget 树 |
didUpdateWidget |
父 Widget 重建 | 多次 | 可以 | 是 | 对比新旧配置 |
reassemble |
Hot Reload | 多次(Debug only) | 可以 | 否 | 调试 |
deactivate |
从树移除 | 可能多次 | 否 | 否 | 清理临时状态 |
dispose |
永久移除 | 1 | 否 | 否 | 释放资源 |
| 状态 | 含义 | iOS 对应 | Android 对应 |
|---|---|---|---|
resumed |
前台可见可交互 | viewDidAppear | onResume |
inactive |
前台可见不可交互 | viewWillDisappear | onPause(部分) |
paused |
后台不可见 | 进入后台 | onStop |
detached |
分离(即将销毁) | 应用终止 | onDestroy |
hidden |
Flutter 3.13+ 新增 | 过渡态 | 过渡态 |
| 特性 | didChangeDependencies | didUpdateWidget |
|---|---|---|
| 触发条件 | InheritedWidget 变化 | 父 Widget rebuild |
| 参数 | 无 | covariant oldWidget |
| 首次调用 | initState 之后调用一次 | 首次不调用 |
| 典型用途 | 获取 Theme/MediaQuery/Provider | 对比新旧 Widget 属性 |
| 发生频率 | 较低 | 较高 |
| 维度 | BasicMessageChannel | MethodChannel | EventChannel |
|---|---|---|---|
| 通信方向 | 双向 | 双向(请求-响应) | 单向(Native → Flutter) |
| 通信模式 | 消息传递 | 方法调用 | 事件流 |
| 返回值 | 消息回复 | Future<T?> | Stream |
| 编解码 | MessageCodec | MethodCodec | MethodCodec |
| 适用场景 | 简单数据传递 | 调用原生功能 | 持续性事件监听 |
| 典型用例 | 传递配置、简单消息 | 获取电量、打开相机 | 传感器数据、位置更新、网络状态 |
| 原生端 API | setMessageHandler | setMethodCallHandler | EventChannel.StreamHandler |
| 调用方式 | send(message) | invokeMethod(method, args) | receiveBroadcastStream() |
| 维度 | Platform Channel | Dart FFI |
|---|---|---|
| 通信方式 | 异步消息传递 | 直接函数调用 |
| 性能 | 中(序列化开销) | 高(无序列化) |
| 支持同步 | 否 | 是 |
| 支持的语言 | Java/Kotlin/ObjC/Swift | C/C++ |
| 复杂度 | 低 | 高 |
| 线程模型 | 主线程间通信 | 可在任意 Isolate 调用 |
| 适用场景 | 一般原生交互 | 高频调用、大数据、音视频 |
| Widget | 布局方向 | 超出处理 | 子项数量 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| Row | 水平 | 溢出警告 | 少量 | 高 | 水平排列 |
| Column | 垂直 | 溢出警告 | 少量 | 高 | 垂直排列 |
| Stack | 层叠 | 可溢出 | 少量 | 高 | 重叠布局 |
| Wrap | 自动换行 | 换行 | 中等 | 中 | 标签流 |
| Flow | 自定义 | 自定义 | 大量 | 高(自定义布局) | 复杂流式布局 |
| ListView | 单轴滚动 | 滚动 | 大量 | 高(懒加载) | 长列表 |
| GridView | 二维网格 | 滚动 | 大量 | 高(懒加载) | 网格布局 |
| CustomScrollView | 自定义 | 滚动 | 大量 | 高 | 混合滚动 |
| Widget | flex 默认值 | fit 默认值 | 行为 |
|---|---|---|---|
| Flexible | 1 | FlexFit.loose | 子 Widget 可以小于分配空间 |
| Expanded | 1 | FlexFit.tight | 子 Widget 必须填满分配空间 |
| Spacer | 1 | FlexFit.tight | 纯空白占位 |
关系:Expanded = Flexible(fit: FlexFit.tight),Spacer = Expanded(child: SizedBox.shrink())
| Widget | 功能 | 约束行为 | 性能 |
|---|---|---|---|
| SizedBox | 指定固定大小 | 传递紧约束 | 最高 |
| Container | 多功能容器 | 取决于属性组合 | 中(功能多) |
| ConstrainedBox | 添加额外约束 | 合并约束 | 高 |
| LimitedBox | 在无限约束时限制大小 | 仅在无界时生效 | 高 |
| UnconstrainedBox | 去除父约束 | 让子 Widget 自由布局 | 高 |
| FractionallySizedBox | 按比例设置大小 | 按父空间百分比 | 高 |
| 维度 | Future | Stream |
|---|---|---|
| 值的数量 | 单个值 | 多个值(序列) |
| 完成时机 | 产生值后完成 | 可持续发出值 |
| 订阅方式 | then / await | listen / await for |
| 错误处理 | catchError / try-catch | onError / handleError |
| 取消 | 不可取消 | StreamSubscription.cancel() |
| 典型场景 | 网络请求、文件读写 | WebSocket、传感器、事件流 |
| 维度 | 单订阅 Stream | 广播 Stream |
|---|---|---|
| 监听者数量 | 仅 1 个 | 多个 |
| 数据缓存 | 未监听时缓存 | 未监听时丢弃 |
| 创建方式 | StreamController() | StreamController.broadcast() |
| 适用场景 | 文件读取、HTTP 响应 | 事件总线、UI 事件 |
| 维度 | compute() | Isolate.spawn() | Isolate.run() |
|---|---|---|---|
| API 级别 | 高 | 低 | 中 |
| 返回值 | Future | 无(需 SendPort) | Future |
| 通信方式 | 封装好 | 手动 SendPort/ReceivePort | 封装好 |
| 多次通信 | 不支持 | 支持 | 不支持 |
| 适用场景 | 简单单次计算 | 复杂长期任务 | 简单单次计算(推荐) |
| 版本 | 所有版本 | 所有版本 | Dart 2.19+ |
| 维度 | Navigator 1.0 | Navigator 2.0 |
|---|---|---|
| 编程范式 | 命令式 | 声明式 |
| API 复杂度 | 低 | 高 |
| URL 同步 | 需手动 | 自动 |
| Deep Link | 不完善 | 完善 |
| Web 友好 | 差 | 好 |
| 路由栈控制 | 受限 | 完全控制 |
| 适用场景 | 移动端简单导航 | Web、深度链接、复杂导航 |
| 维度 | go_router | auto_route | beamer | GetX Router |
|---|---|---|---|---|
| 基于 | Navigator 2.0 | Navigator 2.0 | Navigator 2.0 | 自定义 |
| 代码生成 | 可选 | 是 | 否 | 否 |
| 类型安全 | 可选 | 是 | 部分 | 否 |
| 嵌套路由 | ShellRoute | 支持 | BeamLocation | 支持 |
| 守卫 | redirect | AutoRouteGuard | BeamGuard | 中间件 |
| 官方维护 | 是 | 社区 | 社区 | 社区 |
| 学习成本 | 中 | 中高 | 高 | 低 |
| 维度 | 隐式动画 | 显式动画 | 物理动画 | Rive/Lottie |
|---|---|---|---|---|
| 复杂度 | 低 | 中 | 中高 | 低(但需设计工具) |
| 控制力 | 低 | 高 | 中 | 低 |
| 性能 | 好 | 好 | 好 | 取决于复杂度 |
| 典型用途 | 属性过渡 | 自定义动画 | 弹性/惯性效果 | 复杂矢量动画 |
| 代码量 | 少 | 多 | 中 | 少 |
| 适合场景 | 简单过渡 | 精确控制 | 自然效果 | 品牌动画 |
| 维度 | AnimatedBuilder | AnimatedWidget |
|---|---|---|
| 使用方式 | 通过 builder 回调 | 继承后重写 build |
| child 优化 | 支持(child 参数不重建) | 不直接支持 |
| 复用性 | 高(不需要创建新类) | 需要为每种动画创建类 |
| 适用场景 | 简单动画、一次性使用 | 可复用的动画 Widget |
| 维度 | Tween | CurveTween | TweenSequence |
|---|---|---|---|
| 功能 | 线性映射 begin→end | 添加曲线 | 多段动画序列 |
| 输入 | Animation | Animation | Animation |
| 输出 | Animation | Animation | Animation |
| 用法 | tween.animate(controller) | CurveTween(curve: ...) | 定义多段 TweenSequenceItem |
| 维度 | Flutter | React Native | Native |
|---|---|---|---|
| 语言 | Dart | JavaScript | Swift/Kotlin |
| 渲染方式 | 自绘引擎(Skia/Impeller) | 原生控件桥接 | 原生控件 |
| 性能 | 接近原生 | 低于原生(桥接开销) | 原生 |
| UI 一致性 | 跨平台完全一致 | 平台差异 | 仅单平台 |
| 热重载 | 支持 | 支持 | Xcode Preview |
| 生态 | 增长中 | 成熟 | 最成熟 |
| 包大小 | 较大(含引擎) | 中等 | 最小 |
| 调试体验 | DevTools | Chrome DevTools | Xcode/AS |
| 适合场景 | UI 密集型、跨端一致 | 已有 RN 团队 | 极致性能/平台特性 |
| 维度 | Web | Mobile | Desktop |
|---|---|---|---|
| 渲染后端 | CanvasKit / HTML | Skia / Impeller | Skia / Impeller |
| 性能 | 中(取决于浏览器) | 高 | 高 |
| 包大小 | CanvasKit ~2MB | 取决于代码 | 取决于代码 |
| SEO | 差(CanvasKit)/ 中(HTML) | 不适用 | 不适用 |
| 成熟度 | 中等 | 成熟 | 中等 |
| 特殊考虑 | 字体加载、URL 路由 | 平台权限 | 窗口管理 |
| 维度 | Debug | Profile | Release |
|---|---|---|---|
| 编译方式 | JIT | AOT | AOT |
| 热重载 | 支持 | 不支持 | 不支持 |
| 性能 | 低 | 接近 Release | 最高 |
| 包大小 | 大 | 中 | 最小 |
| 断言 | 启用 | 禁用 | 禁用 |
| DevTools | 全功能 | 性能分析 | 不可用 |
| Observatory | 可用 | 可用 | 不可用 |
| 用途 | 开发调试 | 性能分析 | 发布上线 |
| 维度 | ListView | GridView | CustomScrollView | SingleChildScrollView |
|---|---|---|---|---|
| 布局方式 | 线性列表 | 网格 | 自定义 Sliver 组合 | 单个子 Widget 滚动 |
| 懒加载 | .builder 支持 | .builder 支持 | 取决于 Sliver 类型 | 不支持 |
| 性能(大量子项) | 高(builder) | 高(builder) | 高 | 差(全量渲染) |
| 灵活性 | 中 | 中 | 最高 | 低 |
| 适用场景 | 普通列表 | 图片墙 | 混合滚动布局 | 内容少但需滚动 |
| Physics | 效果 | 平台 |
|---|---|---|
BouncingScrollPhysics |
iOS 弹性效果 | iOS 默认 |
ClampingScrollPhysics |
Android 边缘效果 | Android 默认 |
NeverScrollableScrollPhysics |
禁止滚动 | 嵌套时使用 |
AlwaysScrollableScrollPhysics |
总是可滚动 | 下拉刷新 |
PageScrollPhysics |
翻页效果 | PageView |
FixedExtentScrollPhysics |
对齐到固定高度项 | ListWheelScrollView |
| Key 类型 | 唯一性范围 | 比较方式 | 内存开销 | 适用场景 |
|---|---|---|---|---|
ValueKey<T> |
同级 | value 的 == | 低 | 列表项有唯一 ID |
ObjectKey |
同级 | identical() | 低 | 用对象作为标识 |
UniqueKey |
同级 | 每个实例唯一 | 低 | 强制重建 |
GlobalKey |
全局 | 同一实例 | 高(全局注册) | 跨组件访问 State |
PageStorageKey |
存储范围 | value 的 == | 中 | 保存滚动位置 |
| 方案 | 数据类型 | 性能 | 容量 | 适用场景 |
|---|---|---|---|---|
SharedPreferences |
K-V(基本类型) | 高 | 小 | 配置项、简单设置 |
sqflite |
结构化数据 | 高 | 大 | 复杂查询、关系数据 |
hive |
K-V / 对象 | 极高 | 大 | NoSQL、高性能 |
drift(moor) |
结构化数据 | 高 | 大 | 类型安全 ORM |
isar |
对象数据库 | 极高 | 大 | 全文搜索、高性能 |
| 文件存储 | 任意 | 中 | 大 | 日志、缓存 |
secure_storage |
K-V(加密) | 中 | 小 | 敏感数据(Token) |
| 方式 | 作用 | 返回值 | 性能影响 |
|---|---|---|---|
context.dependOnInheritedWidgetOfExactType<T>() |
获取+注册依赖 | T? | 会触发 didChangeDependencies |
context.getInheritedWidgetOfExactType<T>() |
仅获取,不注册依赖 | T? | 无重建影响 |
context.findAncestorWidgetOfExactType<T>() |
向上查找 Widget | T? | O(n) 遍历 |
context.findAncestorStateOfType<T>() |
向上查找 State | T? | O(n) 遍历 |
context.findRenderObject() |
获取 RenderObject | RenderObject? | 直接获取 |
context.findAncestorRenderObjectOfExactType<T>() |
向上查找 RenderObject | T? | O(n) 遍历 |
| 错误类型 | 触发场景 | 处理方式 |
|---|---|---|
| Dart 异常 | 代码逻辑错误 | try-catch |
| Widget 构建异常 | build 方法中抛出 |
ErrorWidget.builder 自定义 |
| Framework 异常 | 布局溢出、约束冲突 | FlutterError.onError |
| 异步异常 | 未捕获的 Future 错误 | runZonedGuarded |
| Platform 异常 | 原生代码异常 | PlatformDispatcher.onError |
| Isolate 异常 | 计算 Isolate 中的错误 | Isolate.errors / compute catch |
void main() {
// 1. Flutter Framework 错误
FlutterError.onError = (details) {
// 上报
};
// 2. 平台错误
PlatformDispatcher.instance.onError = (error, stack) {
// 上报
return true;
};
// 3. Zone 内异步错误
runZonedGuarded(() {
runApp(MyApp());
}, (error, stack) {
// 上报
});
}
| 维度 | 单元测试 | Widget 测试 | 集成测试 |
|---|---|---|---|
| 速度 | 最快 | 快 | 慢 |
| 信心 | 低 | 中 | 高 |
| 依赖 | 无 | 部分 | 完整 App |
| 环境 | Dart VM | 模拟 Framework | 真机/模拟器 |
| 测试对象 | 函数、类 | Widget、交互 | 完整用户流程 |
| 工具 | test | flutter_test | integration_test |
| Mock | mockito | mockito + pump | - |
| 维护成本 | 低 | 中 | 高 |
| 维度 | Skia | Impeller |
|---|---|---|
| 类型 | 通用 2D 渲染 | Flutter 专用渲染 |
| Shader 编译 | 运行时编译(卡顿) | 预编译(无卡顿) |
| API 后端 | OpenGL / Vulkan / Metal | Metal / Vulkan |
| 性能一致性 | 首次卡顿后流畅 | 始终流畅 |
| 成熟度 | 非常成熟 | 发展中 |
| iOS 状态 | 已弃用 | 默认启用(3.16+) |
| Android 状态 | 默认 | 实验中(可选启用) |
| 文字渲染 | 成熟 | 持续改进 |
| 约束类型 | 条件 | 含义 | 例子 |
|---|---|---|---|
| 紧约束 (Tight) | minW==maxW && minH==maxH | 大小完全确定 | SizedBox(w:100, h:100) |
| 松约束 (Loose) | minW==0 && minH==0 | 只有上限 | Center 传给子节点 |
| 有界约束 (Bounded) | maxW < ∞ && maxH < ∞ | 有限空间 | 普通容器 |
| 无界约束 (Unbounded) | maxW == ∞ 或 maxH == ∞ | 无限空间 | ListView 主轴方向 |
| 问题 | 原因 | 解决 |
|---|---|---|
| "RenderFlex overflowed" | 子项总大小超过约束 | Flexible/Expanded/滚动 |
| "unbounded height" | 在无界约束中使用需要有界的 Widget | 给定明确高度/用 Expanded |
| "A RenderFlex overflowed by X pixels" | Row/Column 子项过多 | 使用 Wrap、ListView |
| 子 Widget 撑满父容器 | 紧约束传递 | 用 Center/Align 包裹 |
| 产物 | 说明 | 位置 |
|---|---|---|
libflutter.so |
Flutter Engine | lib/armeabi-v7a & arm64-v8a |
libapp.so |
Dart AOT 代码 | lib/armeabi-v7a & arm64-v8a |
flutter_assets/ |
资源文件 | assets/ |
isolate_snapshot_data |
Isolate 快照 | Debug 模式 |
vm_snapshot_data |
VM 快照 | Debug 模式 |
| 产物 | 说明 |
|---|---|
App.framework |
Dart AOT 代码 |
Flutter.framework |
Flutter Engine |
flutter_assets/ |
资源文件 |
| 维度 | extends(继承) | implements(实现) | with(混入) |
|---|---|---|---|
| 关系 | is-a | can-do | has-ability |
| 数量 | 单继承 | 多实现 | 多混入 |
| 方法实现 | 继承父类实现 | 必须全部实现 | 获得 mixin 实现 |
| 构造函数 | 继承 | 不继承 | mixin 不能有构造函数 |
| 字段 | 继承 | 需要重新声明 | 获得 mixin 字段 |
| 适用场景 | 核心继承关系 | 接口协议 | 横向能力扩展 |
| 概念 | 说明 | 示例 |
|---|---|---|
typedef |
函数类型别名 | typedef VoidCallback = void Function(); |
Function |
通用函数类型 |
Function? callback;(不推荐,无类型) |
ValueChanged<T> |
接收一个值的回调 |
ValueChanged<String> = void Function(String)
|
ValueGetter<T> |
无参返回值 |
ValueGetter<int> = int Function()
|
ValueSetter<T> |
接收一个值无返回 |
ValueSetter<int> = void Function(int)
|
VoidCallback |
无参无返回 | void Function() |
| 关键字 | 赋值次数 | 初始化时机 | 作用域 | 典型用途 |
|---|---|---|---|---|
final |
一次 | 运行时 | 实例 | 运行时确定的不可变值 |
const |
一次 | 编译时 | 实例/类 | 编译时确定的常量 |
late |
延迟一次 | 首次访问时 | 实例 | 延迟初始化、不可空但无法立即初始化 |
static |
多次 | 首次访问时 | 类 | 类级别共享变量 |
static final |
一次 | 首次访问时 | 类 | 类级别常量(运行时) |
static const |
一次 | 编译时 | 类 | 类级别常量(编译时) |
| 集合 | 有序 | 唯一 | 索引访问 | 查找复杂度 | 适用场景 |
|---|---|---|---|---|---|
List<T> |
是 | 否 | O(1) | O(n) | 有序数据 |
Set<T> |
否(LinkedHashSet 有序) | 是 | 不支持 | O(1) | 去重 |
Map<K,V> |
否(LinkedHashMap 有序) | Key 唯一 | O(1) | O(1) | 键值对 |
Queue<T> |
是 | 否 | 不支持 | O(n) | 队列操作 |
SplayTreeSet<T> |
排序 | 是 | 不支持 | O(log n) | 有序集合 |
SplayTreeMap<K,V> |
排序 | Key 唯一 | O(log n) | O(log n) | 有序映射 |
| Sliver | 功能 | 对应普通 Widget |
|---|---|---|
SliverList |
列表 | ListView |
SliverGrid |
网格 | GridView |
SliverFixedExtentList |
固定高度列表 | ListView(itemExtent) |
SliverAppBar |
可折叠 AppBar | AppBar |
SliverToBoxAdapter |
包装普通 Widget | - |
SliverFillRemaining |
填充剩余空间 | - |
SliverPersistentHeader |
吸顶/固定头部 | - |
SliverPadding |
内边距 | Padding |
SliverOpacity |
透明度 | Opacity |
SliverAnimatedList |
动画列表 | AnimatedList |
| Runner | 职责 | 阻塞影响 |
|---|---|---|
| UI Runner | Dart 代码执行、Widget build、Layout | 界面卡顿 |
| GPU Runner(Raster) | 图层合成、GPU 指令提交 | 渲染延迟 |
| IO Runner | 图片解码、文件读写 | 资源加载慢 |
| Platform Runner | 平台消息处理、插件交互 | 原生交互延迟 |
| 概念 | 内存共享 | 通信方式 | 用途 |
|---|---|---|---|
| 线程(Runner) | 共享 | 直接访问 | 引擎内部 |
| Isolate | 不共享 | SendPort/ReceivePort | Dart 并行计算 |
| Zone | 同一 Isolate | 直接 | 错误处理、异步追踪 |
| 格式 | 全称 | 大小 | 适用渠道 |
|---|---|---|---|
| APK | Android Package | 较大(含所有架构) | 直接安装 |
| AAB | Android App Bundle | 较小(按需分发) | Google Play |
| Split APK | 按架构/语言分包 | 最小 | 需要工具分发 |
| 格式 | 用途 |
|---|---|
| .ipa | 发布到 App Store / TestFlight |
| .app | 模拟器运行 |
| .xcarchive | Xcode 归档 |
| 版本 | 重要特性 |
|---|---|
| Flutter 3.0 | 稳定支持 macOS/Linux、Material 3、Casual Games Toolkit |
| Flutter 3.3 | 文字处理改进、SelectionArea、触控板手势 |
| Flutter 3.7 | Material 3 完善、iOS 发布检查、Impeller preview |
| Flutter 3.10 | Impeller iOS 默认、SLSA 合规、无缝 Web 集成 |
| Flutter 3.13 | Impeller 改进、AppLifecycleListener、2D Fragment Shaders |
| Flutter 3.16 | Material 3 默认、Impeller iOS 完全启用、Gemini API |
| Flutter 3.19 | Impeller Android preview、滚动优化、Windows ARM64 |
| Flutter 3.22 | Wasm 稳定、Impeller Android 改进 |
| Flutter 3.24 | Flutter GPU API preview、Impeller Android 更稳定 |
本文档力求全面、深入、细致地覆盖 Flutter 面试和实战开发中的各个知识点。建议结合实际项目经验理解,理论+实践相结合才能真正融会贯通。