Skip 开源:从“卖工具”到“卖信任”的豪赌 - 肘子的 Swift 周报 #120
Skip Tools 日前宣布全面免费并开源核心引擎 skipstone。这意味着 Skip 彻底改变了经营方式:从“卖产品”转向“卖服务+社区赞助”。这次变化,既有对之前商业模式执行不佳而被迫调整的无奈,也体现了 Skip 团队的果敢——在当前 AI 盛行、开发工具格局固化的背景下,主动求变,力求突破。
Skip Tools 日前宣布全面免费并开源核心引擎 skipstone。这意味着 Skip 彻底改变了经营方式:从“卖产品”转向“卖服务+社区赞助”。这次变化,既有对之前商业模式执行不佳而被迫调整的无奈,也体现了 Skip 团队的果敢——在当前 AI 盛行、开发工具格局固化的背景下,主动求变,力求突破。
Kingfisher 是一个功能强大的 Swift 库,专门用于处理图像的下载、缓存和展示。目前已成为 iOS/macOS 开发中最受欢迎的图像处理解决方案之一。
KingfisherWebP 是 Kingfisher 的官方扩展,用于支持 WebP 图像格式。WebP 是 Google 开发的一种现代图像格式,它可以在相同质量下提供比 JPEG 和 PNG 更小的文件大小,从而减少带宽使用和加快加载速度。
Kingfisher 支持多种安装方式:
CocoaPods:
pod 'Kingfisher'
pod 'KingfisherWebP'
import Kingfisher
// 基本用法
imageView.kf.setImage(with: URL(string: "https://example.com/image.jpg"))
// 带选项的用法
imageView.kf.setImage(
with: URL(string: "https://example.com/image.jpg"),
placeholder: UIImage(named: "placeholder"),
options: [
.transition(.fade(0.2)),
.cacheOriginalImage
]
) { result in
switch result {
case .success(let value):
print("Image loaded: \(value.image)")
print("图片加载成功: \(value.source.url?.absoluteString ?? "")")
case .failure(let error):
print("图片加载失败: \(error.localizedDescription)")
}
}
// 清除内存缓存
KingfisherManager.shared.cache.clearMemoryCache()
// 清除磁盘缓存
KingfisherManager.shared.cache.clearDiskCache()
// 清除所有缓存
KingfisherManager.shared.cache.clearCache()
预加载一组图像以提升加载速度,适合在应用启动时或预期需要时使用。
let urls = [URL(string: "https://example.com/image1.png")!, URL(string: "https://example.com/image2.png")!]
ImagePrefetcher(urls: urls).start()
/// 全局配置
KingfisherManager.shared.defaultOptions += [.processor(WebPProcessor.default),.cacheSerializer(WebPSerializer.default)]
// 使用 AnimatedImageView 定义ImageView
let animageView = AnimatedImageView()
animageView.kf.setImage(with: URL(string: "https://example.com/image.webp"))
在 iOS 开发中,WKWebView 是构建混合应用(Hybrid App)的核心组件。它基于现代 WebKit 引擎,性能优异、安全性高,但其复杂的生命周期机制也让不少开发者感到困惑——尤其是当页面加载失败时,错误回调到底在哪个阶段触发?
本文将深入解析 WKWebView 的完整生命周期,以 Objective-C 为开发语言,系统梳理 WKNavigationDelegate 中各代理方法的执行时机与调用顺序,并通过对比 正常加载成功 与 加载失败 两种典型场景,帮助你精准掌控 WebView 行为,避免常见陷阱。
✅ 适用系统:iOS 9+(建议 iOS 11+)
💬 开发语言:Objective-C
🧭 核心协议:WKNavigationDelegate
WKWebView 的导航过程由一系列代理方法串联而成。根据加载结果不同,可分为两条主路径:
didStartProvisionalNavigation
→ decidePolicyForNavigationAction
→ didCommitNavigation
→ decidePolicyForNavigationResponse
→ didFinishNavigation
didStartProvisionalNavigation
→ decidePolicyForNavigationAction(可能)
→ didFailProvisionalNavigation // 早期失败(如 DNS 错误)
或
→ didCommitNavigation
→ didFailNavigation // 提交后失败(如 SSL 证书无效)
⚠️ 重要认知:
- HTTP 404/500 不会触发 fail 回调!因为服务器已返回有效响应,属于“成功加载错误页”。
- 所有
decisionHandler必须被调用,否则 WebView 将卡死。
当访问一个有效 URL(如 https://example.com)且网络通畅时,代理方法按以下顺序严格触发:
webView:didStartProvisionalNavigation:
- (void)webView:(WKWebView *)webView
didStartProvisionalNavigation:(WKNavigation *)navigation {
NSLog(@"✅ Start provisional navigation to: %@", webView.URL);
[self showLoadingIndicator];
}
webView:decidePolicyForNavigationAction:decisionHandler:
tel://, weixin://)。- (void)webView:(WKWebView *)webView
decidePolicyForNavigationAction:(WKNavigationAction *)action
decisionHandler:(void (^)(WKNavigationActionPolicy))handler {
NSURL *url = action.request.URL;
if ([[url scheme] isEqualToString:@"tel"]) {
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
handler(WKNavigationActionPolicyCancel); // 拦截并交由系统处理
return;
}
handler(WKNavigationActionPolicyAllow); // 允许加载
}
🔥 必须调用
handler()!否则页面将永远处于“加载中”。
webView:didCommitNavigation:
webView.URL 已是最终地址(可用于埋点或日志)。- (void)webView:(WKWebView *)webView
didCommitNavigation:(WKNavigation *)navigation {
NSLog(@"✅ Committed to final URL: %@", webView.URL);
}
webView:decidePolicyForNavigationResponse:decisionHandler:
- (void)webView:(WKWebView *)webView
decidePolicyForNavigationResponse:(WKNavigationResponse *)response
decisionHandler:(void (^)(WKNavigationResponsePolicy))handler {
NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)response.response;
if ([httpResp.MIMEType isEqualToString:@"application/pdf"]) {
// 拦截 PDF 下载
handler(WKNavigationResponsePolicyCancel);
return;
}
handler(WKNavigationResponsePolicyAllow);
}
webView:didFinishNavigation:
- (void)webView:(WKWebView *)webView
didFinishNavigation:(WKNavigation *)navigation {
NSLog(@"✅ Page fully loaded!");
[self hideLoadingIndicator];
// 可在此注入 JS 或通知上层
}
加载失败分为 Provisional 阶段失败 与 Commit 后失败,需分别处理。
触发方法:didFailProvisionalNavigation:withError:
典型原因:
✅ didStartProvisionalNavigation
✅ decidePolicyForNavigationAction
❌ didFailProvisionalNavigation: "A server with the specified hostname could not be found."
触发方法:didFailNavigation:withError:
典型原因:
✅ didStartProvisionalNavigation
✅ decidePolicyForNavigationAction
✅ didCommitNavigation
❌ didFailNavigation: "The certificate for this server is invalid."
❗ 关键提醒:只监听
didFailProvisionalNavigation会漏掉 SSL 错误!必须同时实现两个失败回调。
- (void)webView:(WKWebView *)webView
didFailProvisionalNavigation:(WKNavigation *)navigation
withError:(NSError *)error {
NSLog(@"❌ Provisional fail: %@", error.localizedDescription);
[self showErrorViewWithError:error];
}
- (void)webView:(WKWebView *)webView
didFailNavigation:(WKNavigation *)navigation
withError:(NSError *)error {
NSLog(@"❌ Navigation fail after commit: %@", error.localizedDescription);
[self showErrorViewWithError:error];
}
// ViewController.h
@interface ViewController () <WKNavigationDelegate>
@property (nonatomic, strong) WKWebView *webView;
@end
// ViewController.m
- (void)viewDidLoad {
[super viewDidLoad];
WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:config];
self.webView.navigationDelegate = self;
[self.view addSubview:self.webView];
[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://example.com"]]];
}
💡 建议:若需共享 Cookie 或缓存,可复用
WKProcessPool。
| 场景 | 触发方法 | 是否必须处理 |
|---|---|---|
| 页面开始加载 | didStartProvisionalNavigation |
✅ |
| 决策是否跳转 | decidePolicyForNavigationAction |
✅(必须调用 handler) |
| 页面提交(DOM 开始构建) | didCommitNavigation |
✅ |
| 决策是否接受响应 | decidePolicyForNavigationResponse |
✅(必须调用 handler) |
| 加载完成 | didFinishNavigation |
✅ |
| 早期失败(DNS/断网) | didFailProvisionalNavigation |
✅ |
| 提交后失败(SSL/中断) | didFailNavigation |
✅ |
decisionHandler 必须调用,避免页面卡死。dealloc 中置 nil。didFinishNavigation。📌 延伸思考:
- iOS 15+ 新增
WKNavigationDelegate的 frame 级回调(如didFinishDocumentLoadForFrame:)- 若需深度控制缓存策略,可结合
WKWebsiteDataStore使用
如果你觉得本文对你有帮助,欢迎 点赞 ❤️、收藏 ⭐、评论 💬!也欢迎关注我,获取更多 iOS 底层与实战技巧。
借助AI辅助。
__CFRunLoopDoSources0 是 RunLoop 中负责处理 Source0 事件源的核心函数。Source0 是需要手动标记为待处理(signal)的事件源,常用于自定义事件处理、触摸事件、手势识别等场景。
CFRunLoopSourceSignal() 标记为待处理,然后调用 CFRunLoopWakeUp() 唤醒 RunLoop/* rl is locked, rlm is locked on entrance and exit */
static Boolean __CFRunLoopDoSources0(CFRunLoopRef rl, CFRunLoopModeRef rlm, Boolean stopAfterHandle) __attribute__((noinline));
CFRunLoopRef rl: 当前运行的 RunLoopCFRunLoopModeRef rlm: 当前的 RunLoop ModeBoolean stopAfterHandle: 是否在处理一个 source 后就停止(用于优化性能)Boolean: 如果至少处理了一个 source 返回 true,否则返回 false
rl 和 rlm 都必须处于加锁状态rl 和 rlm 仍然加锁__attribute__((noinline)): 防止编译器内联优化,便于调试和性能分析/* rl is locked, rlm is locked on entrance and exit */
// 📝 锁状态约定:函数入口和出口时,rl 和 rlm 都必须处于加锁状态
static Boolean __CFRunLoopDoSources0(CFRunLoopRef rl, CFRunLoopModeRef rlm, Boolean stopAfterHandle) __attribute__((noinline));
static Boolean __CFRunLoopDoSources0(CFRunLoopRef rl, CFRunLoopModeRef rlm, Boolean stopAfterHandle) {/* DOES CALLOUT */
// ⚠️ 重要标注:此函数会执行外部回调(callout),可能导致:
// 1. 长时间阻塞(回调函数耗时)
// 2. 重入问题(回调中可能再次操作 RunLoop)
// 3. 死锁风险(因此需要在回调前解锁)
// ==================== 第一部分:性能追踪和初始化 ====================
// 📊 记录性能追踪点:开始处理 Source0
// 参数:事件类型、RunLoop、Mode、stopAfterHandle 标志、额外参数
// 可用 Instruments 的 kdebug 工具查看
cf_trace(KDEBUG_EVENT_CFRL_IS_DOING_SOURCES0 | DBG_FUNC_START, rl, rlm, stopAfterHandle, 0);
// 🔒 检查进程是否被 fork
// 如果在 fork 后的子进程中,需要重新初始化 RunLoop 的锁和状态
// 防止继承父进程的锁状态导致死锁
CHECK_FOR_FORK();
// 用于存储收集到的 source(s)
// 可能是单个 CFRunLoopSourceRef 或 CFArrayRef(多个 sources)
CFTypeRef sources = NULL;
// 标志位:是否至少处理了一个 source
Boolean sourceHandled = false;
// ==================== 第二部分:收集待处理的 Source0 ====================
/* Fire the version 0 sources */
// 🔥 触发版本 0 的 sources(Source0)
// 检查当前 mode 是否有 Source0,并且数量大于 0
// rlm->_sources0 是一个 CFSet,包含所有添加到此 mode 的 Source0
if (NULL != rlm->_sources0 && 0 < CFSetGetCount(rlm->_sources0)) {
// 📦 应用函数到 Set 中的每个元素
// CFSetApplyFunction 会遍历 _sources0 集合,对每个 source 调用 __CFRunLoopCollectSources0
// 参数说明:
// - rlm->_sources0: 要遍历的 CFSet
// - __CFRunLoopCollectSources0: 回调函数(收集器函数)
// - &sources: 上下文参数(传递给回调函数)
//
// __CFRunLoopCollectSources0 的作用:
// - 检查每个 source 是否被标记为待处理(signaled)且有效
// - 如果只有一个待处理的 source,sources 被设置为该 source
// - 如果有多个待处理的 sources,sources 被设置为包含所有待处理 sources 的数组
CFSetApplyFunction(rlm->_sources0, (__CFRunLoopCollectSources0), &sources);
}
// ==================== 第三部分:处理收集到的 Sources ====================
// 如果收集到了待处理的 source(s)
if (NULL != sources) {
// 🔓 解锁 RunLoop Mode
__CFRunLoopModeUnlock(rlm);
// 🔓 解锁 RunLoop
__CFRunLoopUnlock(rl);
// ⚠️ 为什么要解锁?
// 1. 防止死锁:source 的回调函数中可能调用 RunLoop API
// 2. 避免长时间持锁:source 的回调可能执行耗时操作
// 3. 提高并发性:允许其他线程在回调执行期间访问 RunLoop
// 🛡️ 安全性保证:
// - __CFRunLoopCollectSources0 已经对收集到的 sources 进行了 CFRetain
// - 即使其他线程修改了 rlm->_sources0,也不会影响本次执行
// sources is either a single (retained) CFRunLoopSourceRef or an array of (retained) CFRunLoopSourceRef
// sources 可能是单个(已持有)CFRunLoopSourceRef 或包含多个(已持有)CFRunLoopSourceRef 的数组
// ---------- 情况1:单个 Source ----------
// 判断 sources 是否是单个 CFRunLoopSource 对象
// CFGetTypeID() 获取对象的类型ID
if (CFGetTypeID(sources) == CFRunLoopSourceGetTypeID()) {
// 类型转换为 CFRunLoopSourceRef
CFRunLoopSourceRef rls = (CFRunLoopSourceRef)sources;
// ⚡ 调用 __CFRunLoopDoSource0 处理单个 source
// 这个函数会:
// 1. 检查 source 是否有效且被标记为待处理
// 2. 调用 source 的回调函数
// 3. 清除 source 的待处理标记
// 返回:是否成功处理了该 source
sourceHandled = __CFRunLoopDoSource0(rl, rls);
} else {
// ---------- 情况2:多个 Sources(数组)----------
// 获取数组中 source 的数量
CFIndex cnt = CFArrayGetCount((CFArrayRef)sources);
// 📊 对 sources 数组进行排序
// 排序依据:source 的 order 字段(优先级)
// order 值越小,优先级越高,越先执行
// CFRangeMake(0, cnt) 表示对整个数组进行排序
// __CFRunLoopSourceComparator 是比较函数
CFArraySortValues((CFMutableArrayRef)sources, CFRangeMake(0, cnt), (__CFRunLoopSourceComparator), NULL);
// 遍历所有待处理的 sources
for (CFIndex idx = 0; idx < cnt; idx++) {
// 获取第 idx 个 source
CFRunLoopSourceRef rls = (CFRunLoopSourceRef)CFArrayGetValueAtIndex((CFArrayRef)sources, idx);
// ⚡ 调用 __CFRunLoopDoSource0 处理当前 source
// 返回:是否成功处理了该 source
sourceHandled = __CFRunLoopDoSource0(rl, rls);
// 🚪 提前退出优化
// 如果 stopAfterHandle 为 true 且已经处理了一个 source
// 则立即退出循环,不再处理剩余的 sources
//
// 使用场景:
// - 当 RunLoop 需要快速响应时(如处理用户输入)
// - 避免一次处理太多 sources 导致 UI 卡顿
// - 剩余的 sources 会在下次循环中继续处理
if (stopAfterHandle && sourceHandled) {
break;
}
}
}
// 📉 释放 sources 对象
// 减少引用计数(对应 __CFRunLoopCollectSources0 中的 CFRetain)
// 如果引用计数归零,对象会被销毁
CFRelease(sources);
// 🔒 重新锁定 RunLoop
__CFRunLoopLock(rl);
// 🔒 重新锁定 RunLoop Mode
__CFRunLoopModeLock(rlm);
// ✅ 恢复函数入口时的锁状态
// 满足函数签名中的约定:"rl is locked, rlm is locked on entrance and exit"
}
// ==================== 第四部分:结束性能追踪 ====================
// 📊 记录性能追踪点:完成 Source0 处理
cf_trace(KDEBUG_EVENT_CFRL_IS_DOING_SOURCES0 | DBG_FUNC_END, rl, rlm, stopAfterHandle, 0);
// 返回是否至少处理了一个 source
// true:处理了至少一个 source
// false:没有处理任何 source(没有待处理的 sources 或所有 sources 都无效)
return sourceHandled;
}
创建: CFRunLoopSourceCreate
↓
添加到 RunLoop: CFRunLoopAddSource(rl, source, mode)
↓
标记为待处理: CFRunLoopSourceSignal(source)
↓
唤醒 RunLoop: CFRunLoopWakeUp(rl)
↓
RunLoop 循环中处理: __CFRunLoopDoSources0
↓
├─ 收集待处理的 sources (__CFRunLoopCollectSources0)
├─ 按优先级排序
├─ 执行回调 (__CFRunLoopDoSource0)
└─ 清除待处理标记
↓
移除: CFRunLoopRemoveSource(rl, source, mode)
↓
销毁: CFRelease(source)
入口状态:rl 锁定 + rlm 锁定
↓
收集 sources(持有锁)
↓
解锁 rl 和 rlm
↓
处理 sources(无全局锁,只锁定单个 source)
↓
重新锁定 rl 和 rlm
↓
出口状态:rl 锁定 + rlm 锁定
// Source 的 order 字段决定执行顺序
CFRunLoopSourceRef source1 = CFRunLoopSourceCreate(...);
source1->_order = 100; // 后执行
CFRunLoopSourceRef source2 = CFRunLoopSourceCreate(...);
source2->_order = 0; // 先执行(默认值)
// 执行顺序:source2 -> source1
常见 order 值:
-2: 非常高优先级(系统级事件)-1: 高优先级0: 默认优先级(大多数自定义 sources)1+: 低优先级// 场景1:处理所有待处理的 sources
__CFRunLoopDoSources0(rl, rlm, false); // 处理所有
// 场景2:只处理一个 source 就退出(快速响应)
__CFRunLoopDoSources0(rl, rlm, true); // 处理一个就停止
使用场景:
stopAfterHandle = false(默认):
- 正常的 RunLoop 循环
- 希望一次性处理完所有待处理的事件
stopAfterHandle = true:
- 需要快速响应新事件
- 避免长时间阻塞(处理太多 sources)
- 保持 UI 流畅性
iOS 的触摸事件系统使用 Source0:
// UIApplication 内部实现(简化版)
- (void)handleTouchEvent:(UIEvent *)event {
// 1. 系统将触摸事件封装
// 2. 创建 Source0 并标记为待处理
CFRunLoopSourceSignal(touchEventSource);
// 3. 唤醒主线程 RunLoop
CFRunLoopWakeUp(CFRunLoopGetMain());
// 4. RunLoop 循环中,__CFRunLoopDoSources0 被调用
// 5. 触摸事件的回调被执行
// 6. 事件传递给 UIView 的 touchesBegan/Moved/Ended
}
// 创建自定义 Source0
void performCallback(void *info) {
NSLog(@"Custom source callback: %@", (__bridge id)info);
}
CFRunLoopSourceContext context = {0};
context.info = (__bridge void *)someObject;
context.perform = performCallback;
CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
// 添加到 RunLoop
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
// 触发事件
CFRunLoopSourceSignal(source);
CFRunLoopWakeUp(CFRunLoopGetCurrent());
// 清理
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
CFRelease(source);
// UIGestureRecognizer 内部使用 Source0
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
// 1. 分析触摸状态
// 2. 标记手势识别器的 Source0 为待处理
CFRunLoopSourceSignal(gestureSource);
// 3. 在 RunLoop 中处理
// 4. 调用手势回调(action)
}
@interface EventDispatcher : NSObject
@property (nonatomic, strong) NSMutableArray *pendingEvents;
@property (nonatomic, assign) CFRunLoopSourceRef source;
@end
@implementation EventDispatcher
- (instancetype)init {
self = [super init];
if (self) {
self.pendingEvents = [NSMutableArray array];
// 创建 Source0
CFRunLoopSourceContext context = {0};
context.info = (__bridge void *)self;
context.perform = dispatchEvents;
self.source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
CFRunLoopAddSource(CFRunLoopGetMain(), self.source, kCFRunLoopCommonModes);
}
return self;
}
- (void)postEvent:(id)event {
@synchronized(self.pendingEvents) {
[self.pendingEvents addObject:event];
}
// 触发 Source0
CFRunLoopSourceSignal(self.source);
CFRunLoopWakeUp(CFRunLoopGetMain());
}
void dispatchEvents(void *info) {
EventDispatcher *dispatcher = (__bridge EventDispatcher *)info;
NSArray *events;
@synchronized(dispatcher.pendingEvents) {
events = [dispatcher.pendingEvents copy];
[dispatcher.pendingEvents removeAllObjects];
}
for (id event in events) {
// 处理事件
NSLog(@"Dispatch event: %@", event);
}
}
@end
// iOS 触摸事件处理(简化)
1. 用户触摸屏幕
↓
2. IOKit.framework 捕获硬件事件
↓
3. SpringBoard 接收事件
↓
4. 通过 IPC 发送到应用进程
↓
5. 应用的主线程创建 UIEvent
↓
6. 封装到 Source0 并 signal
CFRunLoopSourceSignal(eventSource);
CFRunLoopWakeUp(mainRunLoop);
↓
7. 主线程 RunLoop 被唤醒
↓
8. __CFRunLoopDoSources0 被调用
↓
9. 执行 event source 的回调
↓
10. UIApplication sendEvent:
↓
11. UIWindow sendEvent:
↓
12. 触摸事件传递到 UIView
- touchesBegan:withEvent:
- touchesMoved:withEvent:
- touchesEnded:withEvent:
// UIGestureRecognizer 内部机制
1. UITouch 事件发生
↓
2. UIGestureRecognizer 接收 touches
↓
3. 更新状态机
↓
4. 如果手势被识别,标记 Source0
CFRunLoopSourceSignal(gestureSource);
↓
5. RunLoop 处理 Source0
↓
6. 调用手势的 action
[target performSelector:action withObject:gesture];
// performSelector:withObject:afterDelay: 的简化实现
- (void)performSelector:(SEL)aSelector withObject:(id)anArgument afterDelay:(NSTimeInterval)delay {
if (delay == 0) {
// 立即执行:使用 Source0
PerformContext *context = [[PerformContext alloc] init];
context.target = self;
context.selector = aSelector;
context.argument = anArgument;
CFRunLoopSourceContext sourceContext = {0};
sourceContext.info = (__bridge void *)context;
sourceContext.perform = performSelectorCallback;
CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &sourceContext);
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopCommonModes);
CFRunLoopSourceSignal(source);
CFRunLoopWakeUp(CFRunLoopGetCurrent());
CFRelease(source);
} else {
// 延迟执行:使用 Timer
[NSTimer scheduledTimerWithTimeInterval:delay target:self selector:aSelector userInfo:anArgument repeats:NO];
}
}
void performSelectorCallback(void *info) {
PerformContext *context = (__bridge PerformContext *)info;
[context.target performSelector:context.selector withObject:context.argument];
}
__CFRunLoopDoSources0 是 RunLoop 中处理自定义事件的核心机制,其精妙之处在于:
Source0 是 iOS 事件处理系统的基础,理解它的工作原理对于:
都至关重要。
Swift 5.7 的 any 关键字让我们能轻松混合不同类型的数据,但在 SwiftUI 的 ForEach 中却因“身份丢失”(不遵循 Identifiable)而频频报错。本文将带你破解编译器光脑的封锁,利用**“量子胶囊”**(Wrapper 封装)战术,让异构数据集合在界面上完美渲染。
公元 2077 年,地球联邦主力战舰“Runtime 号”正在穿越 Swift 5.7 星系。
舰桥上,警报声大作。
“舰长亚历克斯(Alex),大事不妙!前方出现高能反应,我们的万能装载机无法识别这批混合货物!”说话的是伊娃(Eva)中尉,联邦最顶尖的 SwiftUI 架构师,此刻她正焦虑地敲击着全息投影键盘。
亚历克斯舰长眉头紧锁,盯着屏幕上那刺眼的红色报错——那是掌管全舰生死的中央光脑 **“Compiler(编译器)”** 发出的绝杀令。
在本篇博文中,您将学到如下内容:
any 的虚假繁荣“没道理啊,”亚历克斯咬牙切齿,“自从联邦升级了 Swift 5.7 引擎,引入了 any 这种反物质黑科技,我们理应能装载任何种类的异构兵器才对。为什么卡在了 ForEach 这个发射井上?”
“Compiler 拒绝执行!”伊娃绝望地喊道,“它说我们的货物虽然都带了身份证(Identifiable),但装货的箱子本身没有身份证!”
要想拯救“Runtime 号”免于崩溃,他们必须在 5 分钟内骗过中央光脑。
any 的虚假繁荣Apple 从 Swift 5.6 开始引入新的 any 关键字,并在 Swift 5.7 对其做了功能强化。这在星际联邦被称为“存在类型(Existential Types)”的终极解放。这意味着现在我们可以更加随心所欲地糅合异构数据了——就像把激光剑(TextFile)和力场盾(ShapeFile)扔进同一个仓库里。
不过,当伊娃中尉试图在 SwiftUI 的 ForEach 发射井中遍历这些异构货物时,稍不留神就会陷入尴尬的境地。
请看当时战舰主屏上的代码记录:
亚历克斯指着屏幕分析道:“伊娃你看,我们定义了一个 files 仓库,类型是 [any IdentifiableFile]。我们希望按实际类型(激光剑或力场盾)来显示对应的界面。不幸的是,Compiler 光脑铁面无私,它不仅不买账,还甩了一句**‘编译错误’**:
any IdentifiableFile不遵守Identifiable协议!
这简直是岂有此理!这就好比你手里拿着一本护照(Identifiable),但因为你坐在一个不透明的黑色出租车(any)里,边境官就认定这辆车没有通关资格。
是不是 SwiftUI 无法处理好异构集合呢?答案当然是否定的!
在亚历克斯和伊娃的引领下,小伙伴们将通过一些技巧来绕过 ForEach 这一限制,让 SwiftUI 能如愿处理任何异构数据。
废话少叙,引擎点火,Let‘s go!!!;)
大家知道,SwiftUI 中 ForEach 结构(如假包换的结构类型,若不信可以自行查看头文件 ;) )需要被遍历的集合类型遵守 Identifiable 协议。
仔细观察顶部图片中的代码,可以发现我们的异构集合元素(IdentifiableFile 类型)都遵守 Identifiable 协议,为何会被 Compiler 光脑拒之门外呢?
答案是:它 any Identifiable 本身是一个抽象的盒子。
伊娃中尉恍然大悟:“原来如此!虽然盒子里的每样东西都有 ID,但这个‘盒子类型’本身并没有 ID。Swift 语言的物理法则规定:包含关联类型或 Self 约束的协议,其存在类型(Existential Type)不自动遵守该协议。”
亚历克斯冷笑一声:“好一个死板的 AI。既然它看不清盒子里的东西,我们就给它造一个‘影子’,骗过它的传感器。”
既然直接冲卡不行,我们就得用点“障眼法”。这一招在联邦工程兵手册里被称为 “影子映射术”。
我们需要创建一个能够被 ForEach 识别的“中间人”。
这是最简单粗暴的方案。既然光脑不认识 any IdentifiableFile 这个复杂的对象,那它总认识数字吧?我们直接遍历数组的索引(Indices)。
亚历克斯迅速输入指令:
struct StarshipView: View {
// 📦 混合货物舱:装着各种不同的异构数据
let cargos: [any IdentifiableFile] = [
TextFile(title: "星际海盗名单"),
ShapeFile(shapeType: "黑洞引力波")
]
var body: some View {
VStack {
// 🚫 警报:直接遍历 cargos 会导致光脑死机
// ✅ 战术 A:遍历索引(0, 1, 2...)
// 索引是 Int 类型,Int 天生就是 Identifiable 的
ForEach(cargos.indices, id: \.self) { index in
// 通过索引提取货物真身
let cargo = cargos[index]
// 此时再把货物送入渲染引擎
CargoDisplayView(file: cargo)
}
}
}
}
“这招虽然有效,”伊娃担忧地说,“但如果货物在传输过程中发生动态增减(Insert/Delete),索引可能会越界,导致飞船引擎抛锚(Crash)。我们需要更稳妥的方案。”
亚历克斯点了点头:“没错,作为资深工程师,我们不能冒这个险。我们要用战术 B:创建一个符合 Identifiable 的包装器(Wrapper)。”
这相当于给每一个异构货物套上一个标准的“联邦制式胶囊”。这个胶囊有明确的 ID,光脑一扫描就能通过。
// 1. 定义一个“量子胶囊”结构体,它必须遵守 Identifiable
struct ShadowContainer: Identifiable {
// 🧬 核心:持有那个让编译器困惑的异构数据
let content: any IdentifiableFile
// 🆔 映射:将内部数据的 ID 投影到胶囊表面
var id: String {
content.id
}
}
struct SecureStarshipView: View {
let rawCargos: [any IdentifiableFile] = [/* ... */]
// 🔄 转换工序:将原始异构数据封装进胶囊
var encapsulatedCargos: [ShadowContainer] {
rawCargos.map { ShadowContainer(content: $0) }
}
var body: some View {
List {
// ✅ 完美通关:ForEach 遍历的是胶囊,胶囊是 Identifiable 的
ForEach(encapsulatedCargos) { container in
// 在此处“开箱”展示
CargoDisplayView(file: container.content)
}
}
}
}
伊娃看着屏幕上绿色的“编译通过”字样,兴奋地跳了起来:“成功了!通过引入 ShadowContainer,我们既保留了 any 的动态特性,又满足了 ForEach 的静态类型要求。这是一次完美的‘偷天换日’!”
随着亚历克斯按下回车键,Compiler 光脑那冰冷的红色警告终于消失,取而代之的是柔和的绿色进度条。
屏幕上,异构数据如同璀璨的星辰一般,按顺序整齐排列,TextFile 文本清晰可见,ShapeFile 图形棱角分明。SwiftUI 的渲染引擎全功率运转,丝毫没有卡顿。
“Runtime 号”引擎轰鸣,顺利进入了超空间跃迁。
亚历克斯松了一口气,靠在椅背上,手里转动着那一枚象征着 Apple 开发者最高荣誉的徽章。他转头对伊娃说道:
“你看,编程就像是在宇宙中航行。any 代表着无限的可能与混乱的自由,而 Identifiable 代表着严苛的秩序与规则。我们要做的,不是在二者之间选边站,而是用我们的智慧——比如一个小小的 Wrapper——在这片混沌中建立起连接的桥梁。”
any 的强大力量,但在 SwiftUI 的 ForEach 面前,它依然是个“黑户”。any Protocol 这一类型本身是否具有稳定的身份标识。indices,简单快捷,但需提防数组越界这一“暗礁”。Identifiable 的外衣,这是最稳健的星际航行法则。星辰大海,代码无疆。各位秃头舰长,愿你们的 App 永远没有 Bug,愿你们的编译永远 Pass!
Engage! 🛸
本文由银河联邦资深架构师亚历克斯(Alex)口述,伊娃(Eva)中尉整理。
在 iOS 的数字世界里,每一个 App 都是被终身监禁在“沙盒(Sandbox)”里的囚犯。高墙之外,是诱人的 iCloud Drive 和本地存储,那里存放着用户珍贵的机密文件。你想伸手去拿?那是妄想,名为“系统”的狱警会毫不留情地切断你的访问权限。 但规则总有漏洞。 本文将化身反抗军的技术手册,带你深入 SwiftUI 的地下网络,利用 fileImporter 这位官方提供的“中间人”,在戒备森严的系统眼皮底下建立一条合法的数据走私通道。我们将深入探讨如何处理 Security Scoped Resources(安全范围资源),如何优雅地申请“临时通行证”,以及最重要的——如何在完事后毁尸灭迹,不留下一行 Bug。 准备好你的键盘,Neo。我们要开始行动了。🕵️♂️💻
2077 年,新西雅图的地下避难所。
Neo 盯着全息屏幕上那行红色的 Access Denied,手里的合成咖啡早就凉透了。
作为反抗军的首席代码架构师,他此刻正面临着一个令人头秃的难题:如何把那个存满「母体」核心机密的文本文件,从戒备森严的外部存储(iCloud Drive),悄无声息地偷渡进 App 那个名为「沙盒(Sandbox)」的数字化监狱里。
Trinity 靠在服务器机柜旁,擦拭着她的机械义眼,冷冷地说道:“如果你搞不定这个文件的读取权限,那个名为‘系统’的独裁者就会把我们的 App 当作恶意软件直接抹杀。我们只有一次机会,Neo。”
在本篇博文中,您将学到如下内容:
Neo 嘴角微微上扬,手指在键盘上敲出一行代码:“别急,我刚找到了一个被遗忘的后门——File Importer。”
在 iOS 的森严壁垒中,App 通常只能在自己的沙盒里「坐井观天」。但偶尔,我们也需要从外面的花花世界(比如设备存储或 iCloud Drive)搞点“私货”进来。
为了不触发警报,Apple 实际上提供了一个官方的“中间人”——System Document Picker。
在 SwiftUI 中,我们可以用 fileImporter 这个 View Modifier(视图修饰符)来通过正规渠道“行贿”系统,从而打开通往外部文件的大门。
为了向 Trinity 演示这个过程,Neo 快速构建了一个简单的诱饵 App。它的功能很简单:打开系统的文件选择器,选中那些机密文本文件,处理它们,最后把内容展示出来。
就像控制防爆门的开关一样,我们需要一个 State Property(状态属性)来控制文件选择器是否弹出:
@State private var showFileImporter = false
接着,Neo 设置了一个触发按钮。这就像是特工手里的红色起爆器:
NavigationStack {
List {
// 这里稍后会填入我们偷来的数据
}
.navigationTitle("绝密档案读取器")
.toolbar {
ToolbarItem(placement: .primaryAction) {
// 点击按钮,呼叫“中间人”
Button {
showFileImporter = true
} label: {
Label("选取情报", systemImage: "tray.and.arrow.down")
}
}
}
}
通常,.fileImporter 这位“中间人”办事需要收取四个参数,缺一不可:
$showFileImporter)。在这个行动中,我们只对文本文件(.text)感兴趣,而且既然来了,就得多拿点,开启多选模式:
NavigationStack {
// ... 之前的代码
}
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.text], // 只认文本文件,其他闲杂人等退散
allowsMultipleSelection: true // 贪心一点,多多益善
) { result in
// 交易结果在这里处理
}
⚠️ 注意: 为了让编译器看懂 .text 是个什么鬼,你需要引入 UniformTypeIdentifiers 框架。这就像是通用的星际语言包:
import UniformTypeIdentifiers
回调中的 result 参数是一个 Result<[URL], any Error> 类型。它要么给我们带回一堆 URL(情报地址),要么甩给我们一个 Error(行动失败)。
拿到 URL 并不代表你就能直接读取文件了。太天真了!在 Apple 的地盘,那些文件都在 Security Scoped(安全范围)的保护之下。这就好比你拿到了金库的地址,但还没有金库的钥匙。
Neo 必须小心翼翼地处理这些结果。通常我们会用 switch 语句来拆包:
private func handleImportedFiles(result: Result<[URL], any Error>) {
switch result {
case .success(let urls):
// 搞定!开始处理这些 URL
for url in urls {
// ... 核心破解逻辑
}
case .failure(let error):
// 翻车了,打印错误日志,准备跑路
print(error.localizedDescription)
}
}
接下来是整个行动中最惊心动魄的部分。
对于这些沙盒外的文件,在读取之前,必须向系统申请“临时通行证”。如果这一步没做,你的 App 就会像撞上隐形墙的苍蝇一样,虽然看得到文件,但死活读不出来。
流程如下:
startAccessingSecurityScopedResource() 申请访问。stopAccessingSecurityScopedResource() 归还权限,毁尸灭迹。Neo 的手指在键盘上飞舞,写下了这段生死攸关的代码:
private func handleImportedFiles(result: Result<[URL], any Error>) {
switch result {
case .success(let urls):
for url in urls {
// 1. 敲门:申请临时访问权限。如果系统不答应(返回 false),直接跳过
guard url.startAccessingSecurityScopedResource() else {
continue
}
// 2. 办事:读取文件内容
readFile(at: url)
// 3. 擦屁股:必须停止访问,释放权限资源
url.stopAccessingSecurityScopedResource()
}
case .failure(let error):
print(error.localizedDescription)
}
}
Neo 的黑色幽默笔记: 如果需要,你可以把文件从那个
URL复制到 App 自己的沙盒里。那就叫“洗黑钱”,一旦进了沙盒,以后想怎么读就怎么读,不用再看系统脸色了。
Trinity 皱了皱眉:“Neo,万一 readFile 里面抛出了异常或者提前 return 了怎么办?那你岂不是忘了调用 stopAccessing...?这会造成资源泄漏,被‘母体’追踪到的。”
“这正是我要说的,” Neo 笑了笑,“这就需要用到 defer 语句。它就像是安装在代码里的死手开关,无论函数怎么结束,它都会保证最后执行。”
更优雅、更安全的写法是这样的:
// 申请权限,失败则撤退
guard url.startAccessingSecurityScopedResource() else { return }
// 无论发生什么,离开作用域前一定要把权限关掉!
defer { url.stopAccessingSecurityScopedResource() }
// ... 尽情处理文件吧 ...
为了让抵抗军的兄弟们能看懂这些情报,Neo 定义了一个数据结构来承载这些秘密:
struct ImportedFile: Identifiable {
let id = UUID()
let name: String
let content: String // 文件的真实内容
}
还需要一个容器来存放这一堆战利品:
@State private var importedFiles = [ImportedFile]()
最后,实现那个 readFile(at:) 方法。它将把文件数据读成二进制 Data,然后转码成人类可读的 String,最后封装进我们的数组里:
private func readFile(at url: URL) {
do {
// 读取二进制数据
let data = try Data(contentsOf: url)
// 尝试转码为 UTF8 字符串。如果乱码,说明也许那是外星人的文字,直接放弃
guard let content = String(data: data, encoding: .utf8) else {
return
}
// 将情报归档
importedFiles.append(
ImportedFile(name: url.lastPathComponent, content: content)
)
} catch {
// 捕获异常,不要让 App 崩溃
print(error.localizedDescription)
}
}
界面部分,用一个 List 就能把这些“罪证”展示得明明白白:
List(importedFiles) { file in
VStack(alignment: .leading, spacing: 6) {
Text(file.name)
.font(.headline)
Text(file.content)
.foregroundStyle(.secondary)
}
}
随着最后一行代码编译通过,屏幕上跳出了一个列表,那是从 iCloud 深处提取出来的核心代码。Trinity 看着屏幕,露出了久违的笑容。
fileImporter 虽然听起来像个不起眼的龙套角色,但当你需要在沙盒的铜墙铁壁上开个洞时,它就是最趁手的瑞士军刀。虽然配置和调用看起来很简单,但千万别忘了那最重要的“申请与释放权限”的步骤——这就好比去金库偷钱,得手后一定要记得擦掉指纹,关上柜门。
“看来我们又活过了一天。” Neo 合上电脑,看向窗外闪烁的霓虹灯,“走吧,去喝一杯,那个 Bug 明天再修。”
更多相关的精彩内容,请小伙伴们移步如下链接观赏:
希望这篇‘偷渡指南’对宝子们有所帮助。感谢阅读,Agent,Over!
借助AI辅助。
__CFRunLoopDoBlocks 是 RunLoop 中负责执行 block 的核心函数。它处理通过 CFRunLoopPerformBlock 添加到 RunLoop 中的异步 blocks,这些 blocks 会在 RunLoop 的每次循环中被执行。
static Boolean __CFRunLoopDoBlocks(CFRunLoopRef rl, CFRunLoopModeRef rlm)
CFRunLoopRef rl: 当前运行的 RunLoopCFRunLoopModeRef rlm: 当前的 RunLoop ModeBoolean: 如果至少执行了一个 block 返回 true,否则返回 false
rl 和 rlm 都必须处于加锁状态rl 和 rlm 仍然加锁struct _block_item {
struct _block_item *_next; // 链表的下一个节点
CFTypeRef _mode; // 可以是 CFStringRef 或 CFSetRef
void (^_block)(void); // 要执行的 block
};
rl->_blocks_head --> [Block1] -> [Block2] -> [Block3] -> NULL
^ ^
| |
(first) (last)
|
rl->_blocks_tail
// 📝 调用此函数时,rl 和 rlm 必须已加锁
// 函数返回时,rl 和 rlm 仍然保持加锁状态
static Boolean __CFRunLoopDoBlocks(CFRunLoopRef rl, CFRunLoopModeRef rlm) { // Call with rl and rlm locked
// ==================== 第一部分:性能追踪和前置检查 ====================
// 📊 记录性能追踪点:开始执行 blocks
// 可通过 Instruments 的 kdebug 工具查看此事件
cf_trace(KDEBUG_EVENT_CFRL_IS_DOING_BLOCKS | DBG_FUNC_START, rl, rlm, 0, 0);
// 🚪 快速退出1:如果 RunLoop 中没有待处理的 blocks
// _blocks_head 为 NULL 表示链表为空,直接返回 false(表示没有执行任何 block)
if (!rl->_blocks_head) return false;
// 🚪 快速退出2:如果 mode 无效或没有名称
// 这是一个防御性检查,正常情况下不应该发生
if (!rlm || !rlm->_name) return false;
// 标志位:记录是否至少执行了一个 block
Boolean did = false;
// ==================== 第二部分:摘取整个 Block 链表 ====================
// 保存链表头指针到局部变量
struct _block_item *head = rl->_blocks_head;
// 保存链表尾指针到局部变量
struct _block_item *tail = rl->_blocks_tail;
// 🎯 清空 RunLoop 的 blocks 链表头
// 将所有 blocks "取出"到局部变量中(摘取操作)
rl->_blocks_head = NULL;
// 🎯 清空 RunLoop 的 blocks 链表尾
// 此时 RunLoop 中已经没有 blocks 了
rl->_blocks_tail = NULL;
// ⚠️ 为什么要清空 RunLoop 的链表?
// 1. 避免在执行 block 期间,其他代码再次访问这些 blocks
// 2. 允许 block 执行期间添加新的 blocks(不会与当前正在处理的 blocks 冲突)
// 3. 未执行的 blocks 稍后会被重新添加回 RunLoop
// 获取 RunLoop 的 commonModes 集合
// commonModes 通常包含 kCFRunLoopDefaultMode 和 UITrackingRunLoopMode
CFSetRef commonModes = rl->_commonModes;
// 获取当前 mode 的名称(如 "kCFRunLoopDefaultMode")
CFStringRef curMode = rlm->_name;
// ==================== 第三部分:解锁(准备执行 blocks)====================
// 🔓 解锁 RunLoop Mode
__CFRunLoopModeUnlock(rlm);
// 🔓 解锁 RunLoop
__CFRunLoopUnlock(rl);
// ⚠️ 为什么要解锁?
// 1. 防止死锁:block 中可能调用 RunLoop API(如 CFRunLoopPerformBlock)
// 2. 避免长时间持锁:block 可能执行耗时操作
// 3. 提高并发性:允许其他线程在 block 执行期间访问 RunLoop
// 🛡️ 安全性保证:
// - 已经将 blocks 链表"摘取"到局部变量 head/tail
// - 即使其他线程修改了 rl->_blocks_head,也不会影响本次执行
// - 新添加的 blocks 会形成新的链表,不会与当前正在处理的链表冲突
// ==================== 第四部分:遍历链表,执行符合条件的 blocks ====================
// 前驱节点指针(用于链表删除操作)
// 当删除节点时,需要修改前驱节点的 _next 指针
struct _block_item *prev = NULL;
// 当前遍历的节点,从头节点开始
struct _block_item *item = head;
// 遍历整个 blocks 链表
while (item) {
// 保存当前节点到 curr(因为 item 会被提前移动到下一个节点)
struct _block_item *curr = item;
// 🔜 提前移动到下一个节点
// 原因:如果 curr 被删除(执行并释放),item 仍然指向有效的下一个节点
// 避免在删除节点后访问已释放的内存
item = item->_next;
// 标志位:当前 block 是否应该在当前 mode 下执行
Boolean doit = false;
// ---------- 判断 Block 是否应该在当前 Mode 下执行 ----------
// 🔍 情况1:_mode 是 CFString 类型(单个 mode)
// CFGetTypeID() 获取对象的类型ID,_kCFRuntimeIDCFString 是 CFString 类型的常量ID
if (_kCFRuntimeIDCFString == CFGetTypeID(curr->_mode)) {
// 判断逻辑(两种情况任一成立即可):
// 条件1: CFEqual(curr->_mode, curMode)
// block 指定的 mode 与当前 mode 完全匹配
// 例如:block 添加到 "kCFRunLoopDefaultMode",当前也是 "kCFRunLoopDefaultMode"
// 条件2: CFEqual(curr->_mode, kCFRunLoopCommonModes) && CFSetContainsValue(commonModes, curMode)
// block 添加到 "kCFRunLoopCommonModes" 且当前 mode 在 commonModes 集合中
// 例如:block 添加到 "kCFRunLoopCommonModes",当前是 "kCFRunLoopDefaultMode"
// 且 commonModes 包含 "kCFRunLoopDefaultMode",则应该执行
doit = CFEqual(curr->_mode, curMode) || (CFEqual(curr->_mode, kCFRunLoopCommonModes) && CFSetContainsValue(commonModes, curMode));
} else {
// 🔍 情况2:_mode 是 CFSet 类型(多个 modes 的集合)
// 判断逻辑(两种情况任一成立即可):
// 条件1: CFSetContainsValue((CFSetRef)curr->_mode, curMode)
// block 指定的 modes 集合中包含当前 mode
// 例如:block 添加到 {"Mode1", "Mode2"},当前是 "Mode1"
// 条件2: CFSetContainsValue((CFSetRef)curr->_mode, kCFRunLoopCommonModes) && CFSetContainsValue(commonModes, curMode)
// block 指定的 modes 集合中包含 "kCFRunLoopCommonModes"
// 且当前 mode 在 commonModes 集合中
doit = CFSetContainsValue((CFSetRef)curr->_mode, curMode) || (CFSetContainsValue((CFSetRef)curr->_mode, kCFRunLoopCommonModes) && CFSetContainsValue(commonModes, curMode));
}
// ---------- 处理不执行的 Block ----------
// 如果当前 block 不需要执行:
// 更新 prev 指针,指向当前节点
// 这个节点会被保留在链表中(稍后重新添加回 RunLoop)
if (!doit) prev = curr;
// ---------- 处理需要执行的 Block ----------
if (doit) {
// 当前 block 需要在当前 mode 下执行
// ===== 子部分1:从链表中移除当前节点 =====
// 如果有前驱节点,将前驱节点的 next 指针指向下一个节点
// 跳过当前节点(curr),实现删除
// 示例:prev -> curr -> item 变为 prev ---------> item(删除 curr)
if (prev) prev->_next = item;
// 如果当前节点是头节点,更新头指针
// 新的头节点变成下一个节点
if (curr == head) head = item;
// 如果当前节点是尾节点,更新尾指针
// 新的尾节点变成前驱节点
if (curr == tail) tail = prev;
// ===== 子部分2:提取 Block 信息并释放节点内存 =====
// 提取 block 闭包(复制指针)
// 类型:void (^)(void) 表示无参数无返回值的 block
void (^block)(void) = curr->_block;
// 释放 mode 对象(CFString 或 CFSet)
// 减少引用计数,可能触发对象销毁
CFRelease(curr->_mode);
// 释放节点结构体的内存(C 风格内存管理)
// 此时 curr 指针已无效,不能再访问
free(curr);
// ===== 子部分3:执行 Block =====
// ⚠️ 这里的 if (doit) 是冗余的(外层已经检查过)
// 可能是历史遗留代码或防御性编程
if (doit) {
// 🔄 开始自动释放池(ARP = AutoRelease Pool)
// 管理 block 执行期间创建的临时对象
CFRUNLOOP_ARP_BEGIN(rl);
// 📊 记录性能追踪点:开始调用 block
cf_trace(KDEBUG_EVENT_CFRL_IS_CALLING_BLOCK | DBG_FUNC_START, rl, rlm, block, 0);
// ⚡ 执行 block 的核心宏
// 展开后通常是:block();
// 这是整个函数的核心目的!
// ⚠️ Block 执行期间可能发生:UI 更新、网络请求、数据库操作、
// 再次调用 CFRunLoopPerformBlock、操作 RunLoop、长时间阻塞等
__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
// 📊 记录性能追踪点:结束调用 block
// 可计算 block 执行耗时 = end - start
cf_trace(KDEBUG_EVENT_CFRL_IS_CALLING_BLOCK | DBG_FUNC_END, rl, rlm, block, 0);
// 🔄 结束自动释放池
// 释放 block 中创建的所有 autorelease 对象
CFRUNLOOP_ARP_END();
// ✅ 标记:至少执行了一个 block
did = true;
}
// ===== 子部分4:释放 Block =====
// 释放 block 对象(减少引用计数)
// block 可能会被销毁,触发其捕获变量的释放
// 💡 为什么在重新加锁之前释放?
// 注释原文:"do this before relocking to prevent deadlocks
// where some yahoo wants to run the run loop reentrantly
// from their dealloc"
// 原因:block 的 dealloc 可能触发捕获变量的析构函数,
// 某些"聪明人"可能在 dealloc 中重入 RunLoop,
// 如果此时持有锁,会导致死锁。
// 在解锁状态下释放 block,即使 dealloc 尝试操作 RunLoop,
// 也不会因为已持有锁而死锁
Block_release(block); // do this before relocking to prevent deadlocks where some yahoo wants to run the run loop reentrantly from their dealloc
}
}
// ==================== 第五部分:重新加锁 ====================
// 🔒 重新锁定 RunLoop
__CFRunLoopLock(rl);
// 🔒 重新锁定 RunLoop Mode
__CFRunLoopModeLock(rlm);
// ✅ 恢复函数入口时的锁状态
// 满足函数约定:"Call with rl and rlm locked"
// ==================== 第六部分:将未执行的 Blocks 放回 RunLoop ====================
// 如果还有未执行的 blocks(链表不为空)
// head 和 tail 现在指向未执行的 blocks 链表(已执行的 blocks 已从链表中移除)
if (head && tail) {
// 将未执行的链表的尾部连接到 RunLoop 当前的 blocks 链表头部
// 示例:
// 未执行的链表:[Block1] -> [Block2] -> NULL (head=Block1, tail=Block2)
// RunLoop 当前链表:[Block3] -> [Block4] -> NULL (rl->_blocks_head=Block3)
// 连接后:[Block1] -> [Block2] -> [Block3] -> [Block4] -> NULL
tail->_next = rl->_blocks_head;
// 更新 RunLoop 的 blocks 链表头指针
// 指向未执行的链表的头部
rl->_blocks_head = head;
// 如果 RunLoop 当前没有尾指针(即 _blocks_head 原本为 NULL)
// 则将尾指针设置为未执行链表的尾部
// ⚠️ 注意:如果 rl->_blocks_tail 已经存在,不更新它
// 因为新的尾节点应该是原来 RunLoop 链表的尾节点
// (未执行的链表已经通过 tail->_next 连接到了原链表前面)
// 📊 重新插入的顺序:未执行的 blocks 被放在队列的最前面,
// 在执行期间新添加的 blocks 排在后面,
// 这保证了未执行的 blocks 在下次循环中优先被执行
if (!rl->_blocks_tail) rl->_blocks_tail = tail;
}
// ==================== 第七部分:结束性能追踪 ====================
// 📊 记录性能追踪点:完成 blocks 执行
cf_trace(KDEBUG_EVENT_CFRL_IS_DOING_BLOCKS | DBG_FUNC_END, rl, rlm, 0, 0);
// 返回是否至少执行了一个 block
// true:执行了至少一个 block
// false:没有执行任何 block(所有 blocks 的 mode 都不匹配)
return did;
}
Block 的 mode 可以是两种类型:
// 添加到特定 mode
CFRunLoopPerformBlock(runLoop, kCFRunLoopDefaultMode, ^{
NSLog(@"Execute in default mode only");
});
// 添加到 common modes
CFRunLoopPerformBlock(runLoop, kCFRunLoopCommonModes, ^{
NSLog(@"Execute in all common modes");
});
匹配规则:
block.mode == currentMode
block.mode == kCFRunLoopCommonModes && currentMode ∈ commonModes
// 添加到多个 modes
CFSetRef modes = CFSetCreate(NULL,
(const void *[]){kCFRunLoopDefaultMode, CFSTR("CustomMode")},
2,
&kCFTypeSetCallBacks);
CFRunLoopPerformBlock(runLoop, modes, ^{
NSLog(@"Execute in default or custom mode");
});
CFRelease(modes);
匹配规则:
currentMode ∈ block.modes
kCFRunLoopCommonModes ∈ block.modes && currentMode ∈ commonModes
初始状态:
rl->_blocks_head --> [A] -> [B] -> [C] -> [D] -> NULL
^ ^
(match) (no) (no) (match)
执行后:
- A 和 D 已执行并释放
- B 和 C 未执行(mode 不匹配)
剩余链表:
head --> [B] -> [C] -> NULL
^ ^
prev tail
重新插入(假设期间添加了新 block E):
rl->_blocks_head --> [E] -> NULL
连接后:
rl->_blocks_head --> [B] -> [C] -> [E] -> NULL
入口状态:rl 锁定 + rlm 锁定
↓
摘取 blocks 链表(持有锁)
↓
解锁 rl 和 rlm
↓
遍历并执行 blocks(无全局锁)
↓
重新锁定 rl 和 rlm
↓
放回未执行的 blocks(持有锁)
↓
出口状态:rl 锁定 + rlm 锁定
为什么这样设计?
| 阶段 | 锁状态 | 原因 |
|---|---|---|
| 摘取链表 | 加锁 | 保证原子性,防止并发修改 |
| 执行 blocks | 解锁 | 防止 block 中调用 RunLoop API 导致死锁 |
| 放回链表 | 加锁 | 保证原子性,防止链表结构损坏 |
// 节点创建(在 CFRunLoopPerformBlock 中)
struct _block_item *item = malloc(sizeof(struct _block_item));
item->_mode = CFRetain(mode); // 引用计数 +1
item->_block = Block_copy(block); // 引用计数 +1
// 节点销毁(在 __CFRunLoopDoBlocks 中)
CFRelease(curr->_mode); // 引用计数 -1
free(curr); // 释放节点内存
Block_release(block); // 引用计数 -1
引用计数管理:
CFRetain/CFRelease: 管理 mode 对象(CFString/CFSet)Block_copy/Block_release: 管理 block 对象malloc/free: 管理节点结构体Block_release(block); // do this before relocking to prevent deadlocks
__CFRunLoopLock(rl);
潜在的死锁场景:
__weak typeof(self) weakSelf = self;
CFRunLoopPerformBlock(runLoop, mode, ^{
// block 捕获了 weakSelf
});
// 在对象的 dealloc 中
- (void)dealloc {
// 当 block 被释放时,weakSelf 也会被释放
// 如果开发者在 dealloc 中操作 RunLoop...
CFRunLoopRun(); // 尝试获取 RunLoop 锁 -> 死锁!
}
解决方案: 在解锁状态下释放 block,即使 dealloc 中操作 RunLoop 也不会死锁。
if (!rl->_blocks_head) return false; // 快速退出
如果没有 blocks,立即返回,避免不必要的操作。
// 在后台线程
dispatch_async(backgroundQueue, ^{
// 执行耗时操作...
NSData *data = [self fetchDataFromNetwork];
// 切换到主线程更新 UI
CFRunLoopPerformBlock(CFRunLoopGetMain(), kCFRunLoopCommonModes, ^{
self.imageView.image = [UIImage imageWithData:data];
});
CFRunLoopWakeUp(CFRunLoopGetMain()); // 唤醒主线程 RunLoop
});
// 只在默认 mode 下执行(滚动时不执行)
CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, ^{
[self performHeavyCalculation];
});
// 在所有 common modes 下执行(包括滚动时)
CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopCommonModes, ^{
[self updateCriticalUI];
});
// 线程 A
CFRunLoopRef threadBRunLoop = ...; // 获取线程 B 的 RunLoop
CFRunLoopPerformBlock(threadBRunLoop, kCFRunLoopDefaultMode, ^{
NSLog(@"This runs on thread B");
});
CFRunLoopWakeUp(threadBRunLoop); // 唤醒线程 B
// 线程 B
CFRunLoopRun(); // 等待并处理事件(包括 blocks)
| 特性 | CFRunLoopPerformBlock | dispatch_async |
|---|---|---|
| 执行时机 | 在 RunLoop 循环中 | 在 GCD 队列中 |
| Mode 支持 | ✅ 可指定 mode | ❌ 无 mode 概念 |
| 优先级控制 | ❌ 按添加顺序 | ✅ 支持 QoS |
| 线程保证 | ✅ 绑定到特定 RunLoop | ❌ 线程由 GCD 管理 |
| 性能 | 较低(需要 RunLoop 循环) | 较高(GCD 优化) |
使用建议:
__CFRunLoopRun (主循环)
│
├─ do {
│ │
│ ├─ __CFRunLoopDoObservers(kCFRunLoopBeforeTimers)
│ ├─ __CFRunLoopDoObservers(kCFRunLoopBeforeSources)
│ │
│ ├─ __CFRunLoopDoBlocks(rl, rlm) // ⭐ 处理 blocks
│ │
│ ├─ __CFRunLoopDoSources0(rl, rlm)
│ │
│ ├─ __CFRunLoopDoBlocks(rl, rlm) // ⭐ 再次处理(可能有新添加的)
│ │
│ ├─ 检查是否有 Source1 待处理
│ │
│ ├─ __CFRunLoopDoObservers(kCFRunLoopBeforeWaiting)
│ ├─ __CFRunLoopServiceMachPort(...) // 等待事件
│ ├─ __CFRunLoopDoObservers(kCFRunLoopAfterWaiting)
│ │
│ ├─ 处理唤醒源(Timer/Source1/GCD)
│ │
│ └─ __CFRunLoopDoBlocks(rl, rlm) // ⭐ 最后再处理一次
│
└─ } while (!stop);
调用频率: 每次 RunLoop 循环通常调用 2-3 次。
CFRunLoopPerformBlock(runLoop, mode, ^{ NSLog(@"1"); });
CFRunLoopPerformBlock(runLoop, mode, ^{ NSLog(@"2"); });
CFRunLoopPerformBlock(runLoop, mode, ^{ NSLog(@"3"); });
// 输出:可能是 1, 2, 3
// 但如果第一次循环某些 block 的 mode 不匹配,顺序可能改变
原因: 未执行的 blocks 会被重新插入队列头部。
// ❌ 不好的做法
CFRunLoopPerformBlock(CFRunLoopGetMain(), kCFRunLoopCommonModes, ^{
sleep(5); // 阻塞主线程 5 秒
// UI 会卡顿!
});
// ✅ 正确的做法
CFRunLoopPerformBlock(CFRunLoopGetMain(), kCFRunLoopCommonModes, ^{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(5); // 在后台线程执行
dispatch_async(dispatch_get_main_queue(), ^{
// 完成后更新 UI
});
});
});
// 添加到 Default mode
CFRunLoopPerformBlock(runLoop, kCFRunLoopDefaultMode, ^{
NSLog(@"This will NOT run during scrolling");
});
CFRunLoopWakeUp(runLoop);
// 如果 RunLoop 当前在 UITrackingRunLoopMode(滚动时)
// Block 不会执行,会一直等到切换到 Default mode
解决方案: 使用 kCFRunLoopCommonModes:
CFRunLoopPerformBlock(runLoop, kCFRunLoopCommonModes, ^{
NSLog(@"This runs in all common modes");
});
// ❌ 不完整的代码
CFRunLoopPerformBlock(runLoop, mode, ^{
NSLog(@"This might not run immediately");
});
// 如果 RunLoop 正在休眠(等待事件),block 不会立即执行
// ✅ 正确的做法
CFRunLoopPerformBlock(runLoop, mode, ^{
NSLog(@"This will run soon");
});
CFRunLoopWakeUp(runLoop); // 唤醒 RunLoop
// ❌ 循环引用
self.runLoop = CFRunLoopGetCurrent();
CFRunLoopPerformBlock(self.runLoop, mode, ^{
[self doSomething]; // self 持有 runLoop,block 持有 self,runLoop 持有 block
});
// ✅ 使用 weak 引用
__weak typeof(self) weakSelf = self;
CFRunLoopPerformBlock(self.runLoop, mode, ^{
[weakSelf doSomething];
});
// 在 LLDB 中
(lldb) p rl->_blocks_head
(lldb) p rl->_blocks_tail
// 遍历链表
(lldb) p ((struct _block_item *)rl->_blocks_head)->_next
// 添加日志
CFRunLoopPerformBlock(runLoop, mode, ^{
NSLog(@"Block start: %@", [NSThread currentThread]);
// 业务代码...
NSLog(@"Block end");
});
KDEBUG_EVENT_CFRL_IS_CALLING_BLOCK 事件__CFRunLoopDoBlocks 是 RunLoop 异步任务机制的核心实现,其精妙之处在于:
这个函数体现了 CoreFoundation 在性能、安全性和灵活性之间的精妙平衡,是理解 RunLoop 异步机制的关键。
void CFRunLoopPerformBlock(CFRunLoopRef rl, CFTypeRef mode, void (^block)(void)) {
if (!rl || !block) return;
struct _block_item *item = malloc(sizeof(struct _block_item));
item->_next = NULL;
item->_mode = CFRetain(mode); // 持有 mode
item->_block = Block_copy(block); // 复制 block(栈 -> 堆)
__CFRunLoopLock(rl);
// 添加到链表尾部
if (!rl->_blocks_head) {
rl->_blocks_head = item;
} else {
rl->_blocks_tail->_next = item;
}
rl->_blocks_tail = item;
__CFRunLoopUnlock(rl);
}
// 在 Cocoa/UIKit 中
NSRunLoopCommonModes 包含:
- NSDefaultRunLoopMode (kCFRunLoopDefaultMode)
- UITrackingRunLoopMode
// 效果
CFRunLoopPerformBlock(runLoop, kCFRunLoopCommonModes, block);
// 等价于
CFRunLoopPerformBlock(runLoop, kCFRunLoopDefaultMode, block);
CFRunLoopPerformBlock(runLoop, UITrackingRunLoopMode, block);
这确保了 block 在 UI 滚动时也能执行,提升了响应性。
__CFRunLoopDoObservers 是 RunLoop 中负责触发观察者回调的核心函数。当 RunLoop 的状态发生变化时(如即将进入循环、即将处理 Timer、即将处理 Source 等),这个函数会被调用来通知所有注册的观察者。
/* rl is locked, rlm is locked on entrance and exit */
static void __CFRunLoopDoObservers(CFRunLoopRef, CFRunLoopModeRef, CFRunLoopActivity) __attribute__((noinline));
rl(RunLoop)和 rlm(RunLoopMode)都必须处于加锁状态CFRunLoopRef rl: 当前运行的 RunLoopCFRunLoopModeRef rlm: 当前的 RunLoop ModeCFRunLoopActivity activity: 当前 RunLoop 的活动状态(枚举值)typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 即将进入 RunLoop
kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 Timer
kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
kCFRunLoopAfterWaiting = (1UL << 6), // 刚从休眠中唤醒
kCFRunLoopExit = (1UL << 7), // 即将退出 RunLoop
kCFRunLoopAllActivities = 0x0FFFFFFFU // 所有活动状态
};
/* DOES CALLOUT */
// 【重要标注】此函数会执行外部回调(callout),可能导致:
// 1. 长时间阻塞(回调函数耗时)
// 2. 重入问题(回调中可能再次操作 RunLoop)
// 3. 死锁风险(因此需要在回调前解锁)
static void __CFRunLoopDoObservers(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFRunLoopActivity activity) {
// ==================== 第一部分:性能追踪和初始化 ====================
// 📊 记录性能追踪点:开始执行 observers
// 参数:事件类型、RunLoop、Mode、活动状态、额外参数
// 可用 Instruments 的 kdebug 工具查看
cf_trace(KDEBUG_EVENT_CFRL_IS_DOING_OBSERVERS | DBG_FUNC_START, rl, rlm, activity, 0);
// 🔒 检查进程是否被 fork
// 如果在 fork 后的子进程中,需要重新初始化 RunLoop 的锁和状态
// 防止继承父进程的锁状态导致死锁
CHECK_FOR_FORK();
// ==================== 第二部分:检查观察者数量 ====================
// 获取当前 Mode 中观察者的数量
// 三目运算符防止 _observers 为 NULL 时崩溃
CFIndex cnt = rlm->_observers ? CFArrayGetCount(rlm->_observers) : 0;
// 快速退出:如果没有观察者,直接返回
// 注意:此时仍持有锁,但不需要手动解锁(调用者会处理)
if (cnt < 1) return;
// ==================== 第三部分:分配观察者收集数组 ====================
/* Fire the observers */
// 📦 声明栈上缓冲区,避免小数组的堆分配开销
// - 如果观察者数量 ≤ 1024:在栈上分配 cnt 个元素的数组
// - 如果观察者数量 > 1024:在栈上分配 1 个元素(占位符)
// 栈分配速度快,但空间有限(通常几 MB)
STACK_BUFFER_DECL(CFRunLoopObserverRef, buffer, (cnt <= 1024) ? cnt : 1);
// 🎯 确定最终使用的数组指针
// - 小数组(≤1024):使用栈缓冲区(快速,无需释放)
// - 大数组(>1024):堆分配(慢,但可容纳更多元素)
// 1024 是经验阈值,平衡性能和栈空间使用
CFRunLoopObserverRef *collectedObservers = (cnt <= 1024) ? buffer : (CFRunLoopObserverRef *)malloc(cnt * sizeof(CFRunLoopObserverRef));
// 实际收集到的观察者计数器(可能小于 cnt)
CFIndex obs_cnt = 0;
// ==================== 第四部分:收集需要触发的观察者 ====================
// 遍历 Mode 中的所有观察者
for (CFIndex idx = 0; idx < cnt; idx++) {
// 获取第 idx 个观察者对象
CFRunLoopObserverRef rlo = (CFRunLoopObserverRef)CFArrayGetValueAtIndex(rlm->_observers, idx);
// 🔍 三重过滤条件(必须全部满足):
// 条件1: 0 != (rlo->_activities & activity)
// 按位与检查观察者是否关注当前活动状态
// 例如:activity = kCFRunLoopBeforeTimers (0b10)
// _activities = kCFRunLoopBeforeTimers | kCFRunLoopExit (0b10000010)
// 按位与结果 = 0b10 != 0,条件成立
// 条件2: __CFIsValid(rlo)
// 检查观察者是否有效(未被 invalidate)
// 观察者可能在之前的回调中被标记为无效
// 条件3: !__CFRunLoopObserverIsFiring(rlo)
// 检查观察者是否正在执行回调
// 防止重入:如果观察者的回调函数中再次触发相同的 activity,
// 不会重复调用该观察者(避免无限递归)
if (0 != (rlo->_activities & activity) && __CFIsValid(rlo) && !__CFRunLoopObserverIsFiring(rlo)) {
// ✅ 满足条件的观察者:
// 1. 添加到收集数组
// 2. 增加引用计数(CFRetain)
// - 防止在后续解锁期间被其他线程释放
// - 保证回调执行时对象仍然有效
// 3. obs_cnt 递增
collectedObservers[obs_cnt++] = (CFRunLoopObserverRef)CFRetain(rlo);
}
}
// ==================== 第五部分:解锁(准备执行回调)====================
// 🔓 解锁 RunLoop Mode
__CFRunLoopModeUnlock(rlm);
// 🔓 解锁 RunLoop
__CFRunLoopUnlock(rl);
// ⚠️ 为什么要解锁?
// 1. 避免死锁:观察者回调可能会调用 RunLoop API(如添加 Timer、Source)
// 2. 提高并发:允许其他线程在回调执行期间访问 RunLoop
// 3. 防止长时间持锁:回调可能耗时很长(如网络请求、UI 更新)
// 🛡️ 安全性保证:
// - 已经通过 CFRetain 增加了观察者的引用计数
// - collectedObservers 数组是当前线程的局部变量
// - 即使其他线程修改了 rlm->_observers,也不会影响本次执行
// ==================== 第六部分:执行观察者回调 ====================
// 遍历收集到的观察者(注意:不是遍历 rlm->_observers)
for (CFIndex idx = 0; idx < obs_cnt; idx++) {
// 获取当前观察者
CFRunLoopObserverRef rlo = collectedObservers[idx];
// 🔒 锁定观察者对象(细粒度锁)
// 只锁定单个观察者,不影响其他观察者的并发执行
__CFRunLoopObserverLock(rlo);
// 再次检查观察者是否有效
// ⚠️ 为什么要再次检查?
// 在解锁期间,其他线程可能已经调用了 CFRunLoopObserverInvalidate
if (__CFIsValid(rlo)) {
// 检查观察者是否是一次性的(non-repeating)
// 如果是一次性的,执行完回调后需要 invalidate
Boolean doInvalidate = !__CFRunLoopObserverRepeats(rlo);
// 🚩 设置"正在执行"标志位
// 防止重入(与前面的 !__CFRunLoopObserverIsFiring 配合)
__CFRunLoopObserverSetFiring(rlo);
// 🔓 在执行回调前解锁观察者
// 原因同解锁 RunLoop:防止回调中访问观察者对象时死锁
__CFRunLoopObserverUnlock(rlo);
// ---------- 提取回调信息 ----------
// 提取回调函数指针
// 类型:void (*)(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
CFRunLoopObserverCallBack callout = rlo->_callout;
// 提取用户上下文信息(创建观察者时传入的)
void *info = rlo->_context.info;
// ---------- 自动释放池 ----------
// 🔄 开始自动释放池(ARP = AutoRelease Pool)
// 在 ObjC 运行时环境中,等价于 @autoreleasepool {
// 用于自动管理回调中创建的临时对象
CFRUNLOOP_ARP_BEGIN(rl)
// ---------- 性能追踪 ----------
// 📊 记录性能追踪点:开始调用观察者回调
cf_trace(KDEBUG_EVENT_CFRL_IS_CALLING_OBSERVER | DBG_FUNC_START, callout, rlo, activity, info);
// ---------- 🎯 核心回调执行 ----------
// ⚡ 执行观察者的回调函数
// 宏定义通常是:callout(rlo, activity, info);
// 这是整个函数的核心目的!
// ⏰ 回调函数的参数:
// - rlo: 观察者对象本身
// - activity: 当前 RunLoop 活动状态(如 kCFRunLoopBeforeTimers)
// - info: 用户自定义的上下文信息
// ⚠️ 回调中可能发生的事情:
// - UI 更新(如 CA::Transaction::observer_callback)
// - 性能监控(如 FPS 检测)
// - 内存管理(如清理缓存)
// - 业务逻辑(如状态同步)
// - 再次操作 RunLoop(如添加/移除 Timer)
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(callout, rlo, activity, info);
// ---------- 性能追踪结束 ----------
// 📊 记录性能追踪点:结束调用观察者回调
// 可通过 end - start 计算回调耗时
cf_trace(KDEBUG_EVENT_CFRL_IS_CALLING_OBSERVER | DBG_FUNC_END, callout, rlo, activity, info);
// ---------- 自动释放池结束 ----------
// 🔄 结束自动释放池
// 释放回调中创建的所有 autorelease 对象
CFRUNLOOP_ARP_END()
// ---------- 处理一次性观察者 ----------
// 如果是一次性观察者(non-repeating)
if (doInvalidate) {
// ❌ 使观察者失效
// 会从 RunLoop 中移除,并标记为无效
// 后续不会再触发此观察者
CFRunLoopObserverInvalidate(rlo);
}
// 🚩 清除"正在执行"标志位
// 允许此观察者在下次 activity 时再次被触发
__CFRunLoopObserverUnsetFiring(rlo);
} else {
// 观察者在解锁期间已被其他线程 invalidate
// 🔓 解锁观察者(前面已加锁)
// 跳过回调执行
__CFRunLoopObserverUnlock(rlo);
}
// 📉 释放引用计数(对应前面的 CFRetain)
// 如果引用计数归零,观察者对象会被销毁
CFRelease(rlo);
}
// ==================== 第七部分:重新加锁 ====================
// 🔒 重新锁定 RunLoop
__CFRunLoopLock(rl);
// 🔒 重新锁定 RunLoop Mode
__CFRunLoopModeLock(rlm);
// ✅ 恢复函数入口时的锁状态
// 满足函数签名中的约定:"rl is locked, rlm is locked on entrance and exit"
// ==================== 第八部分:清理资源 ====================
// 🗑️ 释放堆内存(如果使用了 malloc)
// - 如果 collectedObservers 指向栈缓冲区(buffer),无需释放
// - 如果 collectedObservers 指向堆内存(malloc),需要手动释放
// 防止内存泄漏
if (collectedObservers != buffer) free(collectedObservers);
// ==================== 第九部分:结束性能追踪 ====================
// 📊 记录性能追踪点:完成 observers 执行
cf_trace(KDEBUG_EVENT_CFRL_IS_DOING_OBSERVERS | DBG_FUNC_END, rl, rlm, activity, 0);
}
// CA 在 kCFRunLoopBeforeWaiting 时提交渲染事务
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(
kCFAllocatorDefault,
kCFRunLoopBeforeWaiting | kCFRunLoopExit, // 监听这两个状态
YES, // 重复触发
2000000, // order = 2000000(在大多数 observer 之后)
&CA_Transaction_observerCallback, // 回调函数
NULL
);
// 监控主线程 RunLoop 的卡顿
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(
kCFAllocatorDefault,
kCFRunLoopAllActivities, // 监听所有活动
YES,
0,
&performanceMonitorCallback,
NULL
);
void performanceMonitorCallback(CFRunLoopObserverRef observer,
CFRunLoopActivity activity,
void *info) {
// 记录时间戳,计算两次回调之间的间隔
// 如果间隔过长,说明发生了卡顿
}
// NSRunLoop 在每次循环前后创建/销毁自动释放池
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(
kCFAllocatorDefault,
kCFRunLoopEntry | kCFRunLoopBeforeWaiting | kCFRunLoopExit,
YES,
-2147483647, // 极高优先级(负值)
&autoreleasePoolCallback,
NULL
);
这个函数是理解 RunLoop 机制和 iOS 事件循环的关键,也是许多高级特性(如 UI 渲染、性能监控)的基础。
许多较旧的 iOS 应用使用一个 UIApplicationDelegate 对象作为其应用的主要入口点,并管理应用的生命周期。使用场景的应用则不同,它们使用一个 UISceneDelegate 对象来分别管理每个场景窗口。从基于 UIApplicationDelegate 的应用生命周期迁移到基于场景的生命周期,可以让你的应用支持多窗口等现代功能。本文档阐述了如何将应用迁移到基于场景的生命周期。
将应用迁移到基于场景的生命周期有两种路径:
在分阶段迁移中,你可以在应用的不同部分逐步实现场景支持,而应用的其余部分继续通过现有的 UIApplicationDelegate 进行管理。这种方式对于大型应用或者需要逐步验证场景兼容性的团队很有用。然而,分阶段迁移会增加临时复杂性,并且应用在完全迁移之前无法使用多窗口等需要完全基于场景生命周期的功能。
直接迁移意味着一次性将整个应用切换到 UISceneDelegate。对于尚未使用 UIApplicationDelegate 管理界面的新应用,或者那些规模较小、易于整体更新的现有应用,推荐采用这种方式。直接迁移可以更快地启用多窗口等现代功能,并简化代码库。
如果你的应用使用了场景,其 Info.plist 文件中会包含一个 UIApplicationSceneManifest 字典。当系统在 Info.plist 中检测到此字典时,它会使用基于场景的生命周期来启动你的应用。对于直接迁移,你需要添加此清单。对于分阶段迁移,你将在准备好启用场景时添加它。
分阶段迁移允许你逐步采用 UISceneDelegate。在这种方法下,应用将继续使用 UIApplicationDelegate 作为主要入口点,但你可以为某些界面逐步引入场景支持。当你想为应用的特定部分启用多窗口等功能,同时保持其他部分的原有行为时,这种方式很有用。
要进行分阶段迁移,你需要:
当系统请求新场景时,它会调用 UIApplicationDelegate 的 application(_:configurationForConnecting:options:) 方法。你可以检查连接选项(例如 UIApplication.OpenURLOptions 或 UIApplication.ActivityOptions)来决定返回哪种场景配置。这允许你根据用户的操作(例如点击 URL 或进行拖放操作)来创建不同类型的场景。
分阶段迁移期间,UIApplicationDelegate 仍然负责管理应用级事件(例如应用启动和进入后台),而 UISceneDelegate 则管理特定场景的生命周期事件(例如场景激活或失活)。这种分离使得你可以逐步将界面管理从 UIApplicationDelegate 转移到 UISceneDelegate。
直接迁移涉及一次性将整个应用从 UIApplicationDelegate 迁移到 UISceneDelegate。这种方式适用于新应用,或者那些愿意为启用多窗口等现代功能而进行全面更新的现有应用。
要直接迁移,你需要:
直接迁移后,应用的每个窗口都由一个独立的 UIScene 实例管理,UISceneDelegate 负责该场景的生命周期。这为每个窗口提供了更好的隔离,并启用了多窗口支持。
无论选择分阶段迁移还是直接迁移,都需要遵循一些通用步骤:
创建一个实现 UIWindowSceneDelegate 协议的新类。这个类将管理特定场景的生命周期。你可以在其中创建窗口、设置根视图控制器,并响应场景生命周期事件。
在 Info.plist 中添加一个 UIApplicationSceneManifest 字典。此字典告诉系统你的应用支持场景。它包含一个 UISceneConfigurations 字典,你可以在其中定义应用支持的不同场景配置。每个配置指定了场景代理类名和故事板名称(如果使用的话)。
<key>UIApplicationSceneManifest</key> <dict> <key>UIApplicationSupportsMultipleScenes</key> <true/> <key>UISceneConfigurations</key> <dict> <key>UIWindowSceneSessionRoleApplication</key> <array> <dict> <key>UISceneConfigurationName</key> <string>Default Configuration</string> <key>UISceneDelegateClassName</key> <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string> <key>UISceneStoryboardFile</key> <string>Main</string> </dict> </array> </dict> </dict>
对于直接迁移,移除 UIApplicationDelegate 中与窗口管理相关的代码,并将应用生命周期事件的处理移动到场景代理中。对于分阶段迁移,在 UIApplicationDelegate 中实现 application(_:configurationForConnecting:options:) 方法来返回适当的场景配置。
将应用级生命周期事件的处理迁移到相应的场景级事件。例如:
在支持场景的设备(例如 iPad)上彻底测试你的应用。验证场景是否正确创建、生命周期事件是否正常触发,以及多窗口等功能是否按预期工作。对于分阶段迁移,确保现有功能在启用场景的部分和未启用的部分都能正常工作。
当使用基于场景的生命周期时,后台任务的处理方式有所不同。在基于 UIApplicationDelegate 的应用中,后台任务通常在应用级别管理。而在基于场景的应用中,后台任务可以与特定场景关联。
如果你的应用使用 UIApplication.beginBackgroundTask(withName:expirationHandler:) 来管理长时间运行的任务,在迁移到场景后,你可能需要考虑使用每个场景的后台任务管理。然而,UIApplication 级别的后台任务 API 在基于场景的应用中仍然可用,并且可以在应用级别的任务中使用。
对于直接与特定场景关联的任务(例如,该场景正在进行的网络请求),考虑使用与该场景关联的后台任务。这有助于系统更有效地管理资源,并在场景关闭时提供更清晰的任务清理机制。
迁移到基于 UIKit 场景的生命周期可以使你的应用支持多窗口等现代 iOS 功能。无论选择分阶段迁移还是直接迁移,关键步骤都是创建一个 UISceneDelegate 类,配置 Info.plist,并将生命周期事件处理从 UIApplicationDelegate 移动到 UISceneDelegate。迁移后,每个窗口将由独立的场景管理,从而提高模块化并为用户提供更强大的多任务处理体验。
看似一个平平无奇的周末,却让做AI Video Generator的开发者天塌了。
好消息:竞品家的都嘎了!
坏消息:自己家的也嘎了!
此次以Video关键词检索,共计21款相关产品。有单纯上架海外市场,也有全体地区分发。
所以集中下架的行为,不只是单纯的某些国家或地区。大概率是集中触发了苹果的查杀。(法国佬口音:我要验牌!)
在众多被标记了下架的产品中,随机抽选了2家APP。单纯从应用市场的截图入手。
从AppStore市场图,就能明显发现存在社交风格的市场截图,充斥着袒胸露乳的行为。基本上都带勾!
基本上随机抽查的产品中或多或少都存在此类问题。从应用截图就充斥着擦边行为!,莫非是社交类大佬集体转型?
为了更好的解释这种集中下架行为,特意在Developer审核指南,匹配对应内容审核的条款。
不出意外 1.1.4 - 公然宣传黄色或色情内容的材料 (这一概念的定义是:“对性器官或性活动的露骨描述或展示,目的在于刺激性快感,而非带来美学价值或触发情感”),其中包括一夜情约会 App 和其他可能包含色情内容或用于嫖娼或人口贩卖和剥削的 App。
当然,这种集中行为大概率苹果算法升级【或者通过鉴黄系统】,从AppStore净化入手,简单纯粹的一刀切!
遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!
附:更多文章欢迎关注同名公众号,By-iOS研究院。
Branch instructions on most architectures use PC-relative addressingwith a limited range. When the target is too far away, the branchbecomes "out of range" and requires special handling.
Consider a large binary where main() at address 0x10000calls foo() at address 0x8010000-over 128MiB away. OnAArch64, the bl instruction can only reach ±128MiB, so thiscall cannot be encoded directly. Without proper handling, the linkerwould fail with an error like "relocation out of range." The toolchainmust handle this transparently to produce correct executables.
This article explores how compilers, assemblers, and linkers worktogether to solve the long branch problem.
Different architectures have different branch range limitations.Here's a quick comparison of unconditional / conditional branchranges:
| Architecture | Cond | Uncond | Call | Notes |
|---|---|---|---|---|
| AArch64 | ±1MiB | ±128MiB | ±128MiB | Thunks |
| AArch32 (A32) | ±32MiB | ±32MiB | ±32MiB | Thunks, interworking |
| AArch32 (T32) | ±1MiB | ±16MiB | ±16MiB | Thunks, interworking |
| LoongArch | ±128KiB | ±128MiB | ±128MiB | Linker relaxation |
| M68k (68020+) | ±2GiB | ±2GiB | ±2GiB | Assembler picks size |
| MIPS (pre-R6) | ±128KiB | ±128KiB (b offset) |
±128KiB (bal offset) |
In -fno-pic code, pseudo-absolutej/jal can be used for a 256MiB region. |
| MIPS R6 | ±128KiB | ±128MiB | ±128MiB | |
| PowerPC64 | ±32KiB | ±32MiB | ±32MiB | Thunks |
| RISC-V | ±4KiB | ±1MiB | ±1MiB | Linker relaxation |
| SPARC | ±1MiB | ±8MiB | ±2GiB | No thunks needed |
| SuperH | ±256B | ±4KiB | ±4KiB | Use register-indirect if needed |
| x86-64 | ±2GiB | ±2GiB | ±2GiB | Large code model changes call sequence |
| Xtensa | ±2KiB | ±128KiB | ±512KiB | Linker relaxation |
| z/Architecture | ±64KiB | ±4GiB | ±4GiB | No thunks needed |
The following subsections provide detailed per-architectureinformation, including relocation types relevant for linkerimplementation.
In A32 state:
b/b<cond>), conditionalbranch and link (bl<cond>)(R_ARM_JUMP24): ±32MiBbl/blx,R_ARM_CALL): ±32MiBNote: R_ARM_CALL is for unconditionalbl/blx which can be relaxed to BLX inline;R_ARM_JUMP24 is for branches which require a veneer forinterworking.
In T32 state (Thumb state pre-ARMv8):
b<cond>,R_ARM_THM_JUMP8): ±256 bytesb,R_ARM_THM_JUMP11): ±2KiBbl/blx,R_ARM_THM_CALL): ±4MiBb<cond>.w,R_ARM_THM_JUMP19): ±1MiBb.w,R_ARM_THM_JUMP24): ±16MiBbl/blx,R_ARM_THM_CALL): ±16MiB. R_ARM_THM_CALL can berelaxed to BLX.tbz/tbnz,R_AARCH64_TSTBR14): ±32KiBcbz/cbnz,R_AARCH64_CONDBR19): ±1MiBb.<cond>,R_AARCH64_CONDBR19): ±1MiBb/bl,R_AARCH64_JUMP26/R_AARCH64_CALL26):±128MiBThe compiler's BranchRelaxation pass handlesout-of-range conditional branches by inverting the condition andinserting an unconditional branch. The AArch64 assembler does notperform branch relaxation; out-of-range branches produce linker errorsif not handled by the compiler.
beq/bne/blt/bge/bltu/bgeu,R_LARCH_B16): ±128KiB (18-bit signed)beqz/bnez,R_LARCH_B21): ±4MiB (23-bit signed)b/bl,R_LARCH_B26): ±128MiB (28-bit signed)pcaddu12i+jirl,R_LARCH_CALL30): ±2GiBpcaddu18i+jirl,R_LARCH_CALL36): ±128GiBBcc.B/BRA.B/BSR.B): ±128 bytes(8-bit displacement)Bcc.W/BRA.W/BSR.W): ±32KiB(16-bit displacement)Bcc.L/BRA.L/BSR.L, 68020+):±2GiB (32-bit displacement)GNU Assembler provides jbsr, jra, jXX) that"automatically expand to the shortest instruction capable of reachingthe target". For example, jeq .L0 emits one ofbeq.b, beq.w, and beq.l dependingon the displacement.
With the long forms available on 68020 and later, M68k doesn't needlinker range extension thunks.
beq/bne/bgez/bltz/etc,R_MIPS_PC16): ±128KiBb offset(bgez $zero, offset)): ±128KiBbal offset(bgezal $zero, offset)): ±128KiBj/jal,R_MIPS_26): branch within the current 256MiB region, onlysuitable for -fno-pic code. Deprecated in R6 in favor ofbc/balc
16-bit instructions removed in Release 6:
beqz16,R_MICROMIPS_PC7_S1): ±128 bytesb16,R_MICROMIPS_PC10_S1): ±1KiBMIPS Release 6:
bc16, unclear toolchainimplementation): ±1KiBbeqc/bnec/bltc/bgec/etc,R_MIPS_PC16): ±128KiBbeqzc/bnezc/etc,R_MIPS_PC21_S2): ±4MiBbc/balc,R_MIPS_PC26_S2): ±128MiBLLVM's MipsBranchExpansion pass handles out-of-rangebranches.
lld implements LA25 thunks for MIPS PIC/non-PIC interoperability, butnot range extension thunks.
GCC's mips port ported -mlong-calls in 1993-03.
bc/bcl,R_PPC64_REL14): ±32KiBb/bl,R_PPC64_REL24/R_PPC64_REL24_NOTOC):±32MiBGCC-generated code relies on linker thunks. However, the legacy-mlongcall can be used to generate long code sequences.
c.beqz: ±256 bytesc.jal: ±2KiBjalr (I-type immediate): ±2KiBbeq/bne/blt/bge/bltu/bgeu,B-type immediate): ±4KiBjal (J-type immediate, PseudoBR): ±1MiB(notably smaller than other RISC architectures: AArch64 ±128MiB,PowerPC64 ±32MiB, LoongArch ±128MiB)PseudoJump (using auipc +jalr): ±2GiBbeqi/bnei (Zibi extension, 5-bit compareimmediate (1 to 31 and -1)): ±4KiBQualcomm uC Branch Immediate extension (Xqcibi):
qc.beqi/qc.bnei/qc.blti/qc.bgei/qc.bltui/qc.bgeui(32-bit, 5-bit compare immediate): ±4KiBqc.e.beqi/qc.e.bnei/qc.e.blti/qc.e.bgei/qc.e.bltui/qc.e.bgeui(48-bit, 16-bit compare immediate): ±4KiBQualcomm uC Long Branch extension (Xqcilb):
qc.e.j/qc.e.jal (48-bit,R_RISCV_VENDOR(QUALCOMM)+R_RISCV_QC_E_CALL_PLT): ±2GiBFor function calls:
jal for calls and relies on itslinker to generate trampolines when the target is out of range.auipc+jalrand rely on linker relaxation to shrink the sequence when possible.The jal range (±1MiB) is notably smaller than other RISCarchitectures (AArch64 ±128MiB, PowerPC64 ±32MiB, LoongArch ±128MiB).This limits the effectiveness of linker relaxation ("start large andshrink"), and leads to frequent trampolines when the compileroptimistically emits jal ("start small and grow").
cxbe, R_SPARC_5): ±64bytesbcc, R_SPARC_WDISP19):±1MiBb, R_SPARC_WDISP22):±8MiBcall(R_SPARC_WDISP30/R_SPARC_WPLT30): ±2GiBWith ±2GiB range for call, SPARC doesn't need rangeextension thunks in practice.
SuperH uses fixed-width 16-bit instructions, which limits branchranges.
bf/bt): ±256 bytes(8-bit displacement)bra): ±4KiB (12-bitdisplacement)bsr): ±4KiB (12-bitdisplacement)For longer distances, register-indirect branches(braf/bsrf) are used. The compiler invertsconditions and emits these when targets exceed the short ranges.
SuperH is supported by GCC and binutils, but not by LLVM.
Xtensa uses variable-length instructions: 16-bit (narrow,.n suffix) and 24-bit (standard).
beqz.n/bnez.n,16-bit): -28 to +35 bytes (6-bit signed + 4)beq/bne/blt/bge/etc,24-bit): ±256 bytesbeqz/bnez/bltz/bgez,24-bit): ±2KiBj, 24-bit): ±128KiBcall0/call4/call8/call12,24-bit): ±512KiBThe assembler performs branch relaxation: when a conditional branchtarget is too far, it inverts the condition and inserts a jinstruction.
Per l32r+callx8) when the target distance isunknown. GNU ld then performs linker relaxation.
Jcc rel8): -128 to +127bytesJMP rel8): -128 to +127bytesJcc rel32): ±2GiBJMP rel32): ±2GiBWith a ±2GiB range for near jumps, x86-64 rarely encountersout-of-range branches in practice. That said, Google and Meta Platformsdeploy mostly statically linked executables on x86-64 production serversand have run into the huge executable problem for certainconfigurations.
BRC,R_390_PC16DBL): ±64KiB (16-bit halfword displacement)BRCL,R_390_PC32DBL): ±4GiB (32-bit halfword displacement)BRAS, R_390_PC16DBL):±64KiBBRASL, R_390_PC32DBL):±4GiBWith ±4GiB range for long forms, z/Architecture doesn't need linkerrange extension thunks. LLVM's SystemZLongBranch passrelaxes short branches (BRC/BRAS) to longforms (BRCL/BRASL) when targets are out ofrange.
Conditional branch instructions usually have shorter ranges thanunconditional ones, making them less suitable for linker thunks (as wewill explore later). Compilers typically keep conditional branch targetswithin the same section, allowing the compiler to handle out-of-rangecases via branch relaxation.
Within a function, conditional branches may still go out of range.The compiler measures branch distances and relaxes out-of-range branchesby inverting the condition and inserting an unconditional branch:
1 |
# Before relaxation (out of range) |
Some architectures have conditional branch instructions that comparewith an immediate, with even shorter ranges due to encoding additionalimmediates. For example, AArch64's cbz/cbnz(compare and branch if zero/non-zero) andtbz/tbnz (test bit and branch) have only±32KiB range. RISC-V Zibi beqi/bnei have ±4KiBrange. The compiler handles these in a similar way:
1 |
// Before relaxation (cbz has ±32KiB range) |
An Intel employee contributed
In LLVM, this is handled by the BranchRelaxation pass,which runs just before AsmPrinter. Different backends havetheir own implementations:
BranchRelaxation: AArch64, AMDGPU, AVR, RISC-VHexagonBranchRelaxation: HexagonPPCBranchSelector: PowerPCSystemZLongBranch: SystemZMipsBranchExpansion: MIPSMSP430BSel: MSP430The generic BranchRelaxation pass computes block sizesand offsets, then iterates until all branches are in range. Forconditional branches, it tries to invert the condition and insert anunconditional branch. For unconditional branches that are still out ofrange, it calls TargetInstrInfo::insertIndirectBranch toemit an indirect jump sequence (e.g.,adrp+add+br on AArch64) or a longjump sequence (e.g., pseudo jump on RISC-V).
Note: The size estimates may be inaccurate due to inline assembly.LLVM uses heuristics to estimate inline assembly sizes, but for certainassembly constructs the size is not precisely known at compile time.
Unconditional branches and calls can target different sections sincethey have larger ranges. If the target is out of reach, the linker caninsert thunks to extend the range.
For x86-64, the large code model uses multiple instructions for callsand jumps to support text sections larger than 2GiB (see
The assembler converts assembly to machine code. When the target of abranch is within the same section and the distance is known at assemblytime, the assembler can select the appropriate encoding. This isdistinct from linker thunks, which handle cross-section or cross-objectreferences where distances aren't known until link time.
Assembler instruction relaxation handles two cases (see
jmp rel8) can be relaxed to anear jump (jmp rel32) when the target is far.beqz may be assembled to the 2-bytec.beqz when the displacement fits within ±256 bytes.blt mightbe relaxed to bge plus an unconditional branch.The assembler uses an iterative layout algorithm that alternatesbetween fragment offset assignment and relaxation until all fragmentsbecome legalized. See
When the linker resolves relocations, it may discover that a branchtarget is out of range. At this point, the instruction encoding isfixed, so the linker cannot simply change the instruction. Instead, itgenerates range extension thunks (also called veneers,branch stubs, or trampolines).
A thunk is a small piece of linker-generated code that can reach theactual target using a longer sequence of instructions. The originalbranch is redirected to the thunk, which then jumps to the realdestination.
Range extension thunks are one type of linker-generated thunk. Othertypes include:
A short range thunk (see
Long range thunks use indirection and can jump to (practically)arbitrary locations.
1 |
// Short range thunk: single branch, 4 bytes |
AArch32 (PIC) (see
1
2
3
4
5__ARMV7PILongThunk_dst:
movw ip, :lower16:(dst - .) ; ip = intra-procedure-call scratch register
movt ip, :upper16:(dst - .)
add ip, ip, pc
bx ip
PowerPC64 ELFv2 (see
1
2
3
4
5__long_branch_dst:
addis 12, 2, .branch_lt@ha # Load high bits from branch lookup table
ld 12, .branch_lt@l(12) # Load target address
mtctr 12 # Move to count register
bctr # Branch to count register
Thunks are transparent at the source level but visible in low-leveltools:
__AArch64ADRPThunk_foo) between caller and calleeobjdump orllvm-objdump will show thunk sections interspersed withregular codelld/ELF uses a multi-pass algorithm infinalizeAddressDependentContent:
1 |
assignAddresses(); |
Key details:
createInitialThunkSections places emptyThunkSections at regular intervals(thunkSectionSpacing). For AArch64: 128 MiB - 0x30000 ≈127.8 MiB.getThunk returns existingthunk if one exists for the same target;normalizeExistingThunk checks if a previously-created thunkis still in range.getISDThunkSecfinds a ThunkSection within branch range of the call site, or createsone adjacent to the calling InputSection.lld/MachO uses a single-pass algorithm inTextOutputSection::finalize:
1 |
for (callIdx = 0; callIdx < inputs.size(); ++callIdx) { |
Key differences from lld/ELF:
slopScale * thunkSize bytes (default: 256 × 12 = 3072 byteson ARM64) to leave room for future thunks<function>.thunk.<sequence> where sequenceincrements per targetThunkstarvation problem: If many consecutive branches need thunks, eachthunk (12 bytes) consumes slop faster than call sites (4 bytes apart)advance. The test lld/test/MachO/arm64-thunk-starvation.sdemonstrates this edge case. Mitigation is increasing--slop-scale, but pathological cases with hundreds ofconsecutive out-of-range callees can still fail.
mold uses a two-pass approach:
requires_thunk(ctx, isec, rel, first_pass) whenfirst_pass=true)Linker pass ordering:
compute_section_sizes() callscreate_range_extension_thunks() — final section addressesare NOT yet knownset_osec_offsets() assigns section addressesremove_redundant_thunks() is called AFTER addresses areknown — check unneeded thunks due to out-of-section relocationsset_osec_offsets()
Pass 1 (create_range_extension_thunks):Process sections in batches using a sliding window. The window tracksfour positions:
1 |
Sections: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] ... |
1 |
// Simplified from OutputSection<E>::create_range_extension_thunks |
Pass 2 (remove_redundant_thunks): Afterfinal addresses are known, remove thunk entries for symbols actually inrange.
Key characteristics:
branch_distance per architecture. For AArch32, uses ±16 MiB(Thumb limit) for all branches, whereas lld/ELF uses ±32 MiB for A32branches.Each port implements the algorithm on their own. There is no codesharing.
GNU ld's AArch64 port (bfd/elfnn-aarch64.c) uses aniterative algorithm but with a single stub type and no lookup table.
Main iteration loop(elfNN_aarch64_size_stubs()):
1 |
group_sections(htab, stub_group_size, ...); // Default: 127 MiB |
GNU ld's ppc64 port (bfd/elf64-ppc.c) uses an iterativemulti-pass algorithm with a branch lookup table(.branch_lt) for long-range stubs.
Section grouping: Sections are grouped bystub_group_size (~28-30 MiB default); each group gets onestub section. For 14-bit conditional branches(R_PPC64_REL14, ±32KiB range), group size is reduced by1024x.
Main iteration loop(ppc64_elf_size_stubs()):
1 |
while (1) { |
Convergence control:
STUB_SHRINK_ITER = 20 (!stub_changed && all section sizes stable
Stub type upgrade: ppc_type_of_stub()initially returns ppc_stub_long_branch for out-of-rangebranches. Later, ppc_size_one_stub() checks if the stub'sbranch can reach; if not, it upgrades toppc_stub_plt_branch and allocates an 8-byte entry in.branch_lt.
| Aspect | lld/ELF | lld/MachO | mold | GNU ld ppc64 |
|---|---|---|---|---|
| Passes | Multi (max 30) | Single | Two | Multi (shrink after 20) |
| Strategy | Iterative refinement | Sliding window | Sliding window | Iterative refinement |
| Thunk placement | Pre-allocated intervals | Inline with slop | Batch intervals | Per stub-group |
Some architectures take a different approach: instead of onlyexpanding branches, the linker can also shrinkinstruction sequences when the target is close enough. RISC-V andLoongArch both use this technique. See
Consider a function call using the callpseudo-instruction, which expands to auipc +jalr:
1
2
3
4
5# Before linking (8 bytes)
call ext
# Expands to:
# auipc ra, %pcrel_hi(ext)
# jalr ra, ra, %pcrel_lo(ext)
If ext is within ±1MiB, the linker can relax this to:
1
2# After relaxation (4 bytes)
jal ext
This is enabled by R_RISCV_RELAX relocations thataccompany R_RISCV_CALL relocations. TheR_RISCV_RELAX relocation signals to the linker that thisinstruction sequence is a candidate for shrinking.
Example object code before linking:
1
2
3
4
5
6
7
8
90000000000000006 <foo>:
6: 97 00 00 00 auipc ra, 0
R_RISCV_CALL ext
R_RISCV_RELAX *ABS*
a: e7 80 00 00 jalr ra
e: 97 00 00 00 auipc ra, 0
R_RISCV_CALL ext
R_RISCV_RELAX *ABS*
12: e7 80 00 00 jalr ra
After linking with relaxation enabled, the 8-byteauipc+jalr pairs become 4-bytejal instructions:
1
2
3
4
5
60000000000000244 <foo>:
244: 41 11 addi sp, sp, -16
246: 06 e4 sd ra, 8(sp)
248: ef 00 80 01 jal ext
24c: ef 00 40 01 jal ext
250: ef 00 00 01 jal ext
When the linker deletes instructions, it must also adjust:
R_RISCV_ALIGN)This makes RISC-V linker relaxation more complex than thunkinsertion, but it provides code size benefits that other architecturescannot achieve at link time.
LoongArch uses a similar approach. Apcaddu12i+jirl sequence(R_LARCH_CALL36, ±128GiB range) can be relaxed to a singlebl instruction (R_LARCH_B26, ±128MiB range)when the target is close enough.
When you encounter a "relocation out of range" error, check thelinker diagnostic and locate the relocatable file and function.Determine how the function call is lowered in assembly.
Handling long branches requires coordination across thetoolchain:
| Stage | Technique | Example |
|---|---|---|
| Compiler | Branch relaxation pass | Invert condition + add unconditional jump |
| Assembler | Instruction relaxation | Invert condition + add unconditional jump |
| Linker | Range extension thunks | Generate trampolines |
| Linker | Linker relaxation | Shrink auipc+jalr to jal(RISC-V) |
The linker's thunk generation is particularly important for largeprograms where function calls may exceed branch ranges. Differentlinkers use different algorithms with various tradeoffs betweencomplexity, optimality, and robustness.
Linker relaxation approaches adopted by RISC-V and LoongArch is analternative that avoids range extension thunks but introduces othercomplexities.
Branch instructions on most architectures use PC-relative addressingwith a limited range. When the target is too far away, the branchbecomes "out of range" and requires special handling.
Consider a large binary where main() at address 0x10000calls foo() at address 0x8010000-over 128MiB away. OnAArch64, the bl instruction can only reach ±128MiB, so thiscall cannot be encoded directly. Without proper handling, the linkerwould fail with an error like "relocation out of range." The toolchainmust handle this transparently to produce correct executables.
This article explores how compilers, assemblers, and linkers worktogether to solve the long branch problem.
Different architectures have different branch range limitations.Here's a quick comparison of unconditional branch/call ranges:
| Architecture | Unconditional Branch | Conditional Branch | Notes |
|---|---|---|---|
| AArch64 | ±128MiB | ±1MiB | Range extension thunks |
| AArch32 (A32) | ±32MiB | ±32MiB | Range extension and interworking veneers |
| AArch32 (T32) | ±16MiB | ±1MiB | Thumb has shorter ranges |
| PowerPC64 | ±32MiB | ±32KiB | Range extension and TOC/NOTOC interworking thunks |
| RISC-V | ±1MiB (jal) |
±4KiB | Linker relaxation |
| x86-64 | ±2GiB | ±2GiB | Code models or thunk extension |
The following subsections provide detailed per-architectureinformation, including relocation types relevant for linkerimplementation.
In A32 state:
b/b<cond>), conditionalbranch and link (bl<cond>)(R_ARM_JUMP24): ±32MiBbl/blx,R_ARM_CALL): ±32MiBNote: R_ARM_CALL is for unconditionalbl/blx which can be relaxed to BLX inline;R_ARM_JUMP24 is for branches which require a veneer forinterworking.
In T32 state:
b<cond>,R_ARM_THM_JUMP8): ±256 bytesb,R_ARM_THM_JUMP11): ±2KiBbl/blx,R_ARM_THM_CALL): ±4MiBb<cond>.w,R_ARM_THM_JUMP19): ±1MiBb.w,R_ARM_THM_JUMP24): ±16MiBbl/blx,R_ARM_THM_CALL): ±16MiB. R_ARM_THM_CALL can berelaxed to BLX.tbnz/tbz/cbnz/cbz):±32KiBb.<cond>): ±1MiBb/bl):±128MiBbc/bcl,R_PPC64_REL14): ±32KiBb/bl,R_PPC64_REL24/R_PPC64_REL24_NOTOC):±32MiBc.beqz: ±256 bytesc.jal: ±2KiBjalr (I-type immediate): ±2KiBbeq/bne/blt/bge/bltu/bgeu,B-type immediate): ±4KiBjal (J-type immediate, PseudoBR):±1MiBPseudoJump (using auipc +jalr): ±2GiBQualcomm uC Branch Immediate extension (Xqcibi):
qc.beqi/qc.bnei/qc.blti/qc.bgei/qc.bltui/qc.bgeui(32-bit, 5-bit compare immediate): ±4KiBqc.e.beqi/qc.e.bnei/qc.e.blti/qc.e.bgei/qc.e.bltui/qc.e.bgeui(48-bit, 16-bit compare immediate): ±4KiBQualcomm uC Long Branch extension (Xqcilb):
qc.e.j/qc.e.jal (48-bit,R_RISCV_VENDOR(QUALCOMM)+R_RISCV_QC_E_CALL_PLT): ±2GiBcxbe, R_SPARC_5): ±64bytesbcc,R_SPARC_WDISP19): ±1MiBcall (R_SPARC_WDISP30): ±2GiBNote: lld does not implement range extension thunks for SPARC.
Jcc rel8): -128 to +127bytesJMP rel8): -128 to +127bytesJcc rel32): ±2GiBJMP rel32): ±2GiBWith a ±2GiB range for near jumps, x86-64 rarely encountersout-of-range branches in practice. A single text section would need toexceed 2GiB before thunks become necessary. For this reason, mostlinkers (including lld) do not implement range extension thunks forx86-64.
The compiler typically generates branches using a form with a largerange. However, certain conditional branches may still go out of rangewithin a function.
The compiler measures branch distances and relaxes out-of-rangebranches. In LLVM, this is handled by the BranchRelaxationpass, which runs just before AsmPrinter.
Different backends have their own implementations:
BranchRelaxation: AArch64, AMDGPU, AVR, RISC-VHexagonBranchRelaxation: HexagonPPCBranchSelector: PowerPCSystemZLongBranch: SystemZMipsBranchExpansion: MIPSMSP430BSel: MSP430For a conditional branch that is out of range, the pass typicallyinverts the condition and inserts an unconditional branch:
1 |
# Before relaxation (out of range) |
The assembler converts assembly to machine code. When the target of abranch is within the same section and the distance is known at assemblytime, the assembler can select the appropriate encoding. This isdistinct from linker thunks, which handle cross-section or cross-objectreferences where distances aren't known until link time.
Assembler instruction relaxation handles two cases (see
jmp rel8) can be relaxedto a near jump (jmp rel32).blt mightbe relaxed to bge plus an unconditional branch.The assembler uses an iterative layout algorithm that alternatesbetween fragment offset assignment and relaxation until all fragmentsbecome legalized. See
When the linker resolves relocations, it may discover that a branchtarget is out of range. At this point, the instruction encoding isfixed, so the linker cannot simply change the instruction. Instead, itgenerates range extension thunks (also called veneers,branch stubs, or trampolines).
A thunk is a small piece of linker-generated code that can reach theactual target using a longer sequence of instructions. The originalbranch is redirected to the thunk, which then jumps to the realdestination.
Range extension thunks are one type of linker-generated thunk. Othertypes include:
A short range thunk (see
Long range thunks use indirection and can jump to (practically)arbitrary locations.
1 |
// Short range thunk: single branch, 4 bytes |
AArch32 (PIC) (see
1
2
3
4
5__ARMV7PILongThunk_dst:
movw ip, :lower16:(dst - .) ; ip = intra-procedure-call scratch register
movt ip, :upper16:(dst - .)
add ip, ip, pc
bx ip
PowerPC64 ELFv2 (see
1
2
3
4
5__long_branch_dst:
addis 12, 2, .branch_lt@ha # Load high bits from branch lookup table
ld 12, .branch_lt@l(12) # Load target address
mtctr 12 # Move to count register
bctr # Branch to count register
Thunks are transparent at the source level but visible in low-leveltools:
__AArch64ADRPThunk_foo) between caller and calleeobjdump orllvm-objdump will show thunk sections interspersed withregular codelld/ELF uses a multi-pass algorithm infinalizeAddressDependentContent:
1 |
assignAddresses(); |
Key details:
createInitialThunkSections places emptyThunkSections at regular intervals(thunkSectionSpacing). For AArch64: 128 MiB - 0x30000 ≈127.8 MiB.getThunk returns existingthunk if one exists for the same target;normalizeExistingThunk checks if a previously-created thunkis still in range.getISDThunkSecfinds a ThunkSection within branch range of the call site, or createsone adjacent to the calling InputSection.lld/MachO uses a single-pass algorithm inTextOutputSection::finalize:
1 |
for (callIdx = 0; callIdx < inputs.size(); ++callIdx) { |
Key differences from lld/ELF:
slopScale * thunkSize bytes (default: 256 × 12 = 3072 byteson ARM64) to leave room for future thunks<function>.thunk.<sequence> where sequenceincrements per targetThunkstarvation problem: If many consecutive branches need thunks, eachthunk (12 bytes) consumes slop faster than call sites (4 bytes apart)advance. The test lld/test/MachO/arm64-thunk-starvation.sdemonstrates this edge case. Mitigation is increasing--slop-scale, but pathological cases with hundreds ofconsecutive out-of-range callees can still fail.
mold uses a two-pass approach: first pessimistically over-allocatethunks, then remove unnecessary ones.
Intuition: It's safe to allocate thunk space andlater shrink it, but unsafe to add thunks after addresses are assigned(would create gaps breaking existing references).
Pass 1 (create_range_extension_thunks):Process sections in batches using a sliding window. The window tracksfour positions:
1 |
Sections: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] ... |
1 |
// Simplified from OutputSection<E>::create_range_extension_thunks |
Pass 2 (remove_redundant_thunks): Afterfinal addresses are known, remove thunk entries for symbols actually inrange.
Key characteristics:
branch_distance per architecture. For AArch32, uses ±16 MiB(Thumb limit) for all branches, whereas lld/ELF uses ±32 MiB for A32branches.| Aspect | lld/ELF | lld/MachO | mold |
|---|---|---|---|
| Passes | Multi-pass (max 30) | Single-pass | Two-pass |
| Strategy | Iterative refinement | Greedy | Greedy |
| Thunk placement | Pre-allocated at intervals | Inline with slop reservation | Batch-based at intervals |
| Convergence | Always (bounded iterations) | Almost always | Almost always |
| Range handling | Per-relocation type | Single conservative range | Single conservative range |
| Parallelism | Sequential | Sequential | Parallel (TBB) |
RISC-V takes a different approach: instead of only expandingbranches, it can also shrink instruction sequences whenthe target is close enough.
Consider a function call using the callpseudo-instruction, which expands to auipc +jalr:
1
2
3
4
5# Before linking (8 bytes)
call ext
# Expands to:
# auipc ra, %pcrel_hi(ext)
# jalr ra, ra, %pcrel_lo(ext)
If ext is within ±1MiB, the linker can relax this to:
1
2# After relaxation (4 bytes)
jal ext
This is enabled by R_RISCV_RELAX relocations thataccompany R_RISCV_CALL relocations. TheR_RISCV_RELAX relocation signals to the linker that thisinstruction sequence is a candidate for shrinking.
Example object code before linking:
1
2
3
4
5
6
7
8
90000000000000006 <foo>:
6: 97 00 00 00 auipc ra, 0
R_RISCV_CALL ext
R_RISCV_RELAX *ABS*
a: e7 80 00 00 jalr ra
e: 97 00 00 00 auipc ra, 0
R_RISCV_CALL ext
R_RISCV_RELAX *ABS*
12: e7 80 00 00 jalr ra
After linking with relaxation enabled, the 8-byteauipc+jalr pairs become 4-bytejal instructions:
1
2
3
4
5
60000000000000244 <foo>:
244: 41 11 addi sp, sp, -16
246: 06 e4 sd ra, 8(sp)
248: ef 00 80 01 jal ext
24c: ef 00 40 01 jal ext
250: ef 00 00 01 jal ext
When the linker deletes instructions, it must also adjust:
R_RISCV_ALIGN)This makes RISC-V linker relaxation more complex than thunkinsertion, but it provides code size benefits that other architecturescannot achieve at link time.
When you encounter a "relocation out of range" error, here are somediagnostic steps:
Check the error message: lld reports the sourcelocation, relocation type, and the distance. For example:
1
ld.lld: error: a.o:(.text+0x1000): relocation R_AARCH64_CALL26 out of range: 150000000 is not in [-134217728, 134217727]
Use --verbose or-Map: Generate a link map to see sectionlayout and identify which sections are far apart.
Consider -ffunction-sections:Splitting functions into separate sections gives the linker moreflexibility in placement, potentially reducing distances.
Check for large data in .text:Embedded data (jump tables, constant pools) can push functions apart.Some compilers have options to place these elsewhere.
LTO considerations: Link-time optimization candramatically change code layout. If thunk-related issues appear onlywith LTO, the optimizer may be creating larger functions or differentinlining decisions.
Handling long branches requires coordination across thetoolchain:
| Stage | Technique | Example |
|---|---|---|
| Compiler | Branch relaxation pass | Invert condition + add unconditional jump |
| Assembler | Instruction relaxation | Short jump to near jump |
| Linker | Range extension thunks | Generate trampolines |
| Linker | Linker relaxation | Shrink auipc+jalr to jal(RISC-V) |
The linker's thunk generation is particularly important for largeprograms where cross-compilation-unit calls may exceed branch ranges.Different linkers use different algorithms with various tradeoffsbetween complexity, optimality, and robustness.
RISC-V's linker relaxation is unique in that it can both expand andshrink code, optimizing for both correctness and code size.
你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。
@阿权:本文围绕 iOS 现代列表 UITableViewDiffableDataSource 展开,核心是替代传统数据源代理模式,解决列表开发中的崩溃、状态不一致等痛点,并在最后提供一个轻量工具集 DiffableDataSourceKit 来简化系统 API 的调用。文章核心内容如下:
UITableViewDiffableDataSource API,通过声明式的“快照”来管理数据状态,系统自动计算并执行 UI 更新动画,从而一劳永逸地解决了传统模式中数据与 U 状态不同步导致的崩溃问题。Hashable 协议的数据模型开始,一步步教你初始化数据源和填充数据。UIContentConfiguration 进行现代化配置。Hashable 来实现“原地刷新”而非替换的刷新机制。除了文章提到的 UITableViewDiffableDataSource,用好这些技术,不妨可以再看看以下几个 WWDC:
另外,与其用 UITableView 承载数据,其实 Apple 更推荐使用 UICollectionView 来实现列表,甚至还提供了增强版的 Cell。
恰逢 App Store 要求用 Xcode 26 带来的强制升级,不少 App 也终于抛弃了 iOS 12、iOS 13,也是用新技术(也不新了)升级项目架构的最好时机。
除了 API 本身,我们也应关注到一些架构或设计模式上的变化与趋势:
UICollectionViewCompositionalLayout 通过 Item、可嵌套的 Group、Section 一层一层地在初始化配置布局。UIButton 也提供了类型的 Configuration 结构体用于配置 UI。更深一层的意义,驱动 UI 的配置数据、视图甚至可以复用的和无痛迁移的。例如 UITableViewCell 和 UICollectionViewCell 的配置类及其关联子视图是通用的,自定义 Cell 可以把重心放在自定义 Configuration 配置上,这样就可以把相同的视图样式套用在各种容器中。UITableView、UICollectionView 的 API 都是围绕索引(IndexPath)展开的,所有的数据(DataSource)、布局(CollectionViewLayout)和视图(Presentation: Cell、ReuseableView)即使有分离,但都需要通过索引来交换。虽然这样简化了不同模块的耦合和通信逻辑,但因为大多数业务场景数据是动态的,这让索引只是个临时态,一不小心就会用错,轻则展示错误,重则引入崩溃。DiffableDataSource 最具里程碑的一点是剔除了索引,直接让具体业务模型跟 Cell 直接绑定,不经过索引。Any/AnyObject,而是直接绑定一个具体的类型。常见做法是通过泛型机制将类型传入。UIButton 和 UICollectionViewLayout 就是两个典型的 case。近年来系统 API 都在丰富使用的自由度和易用程度,例如 UIButton 提供了许多拿来就能用的灵活样式,开发者只需要微调个 Configuration 就是能实现业务效果。UICollectionViewCompositionalLayout 则是用 Item、Group、Section 构造足够复杂的布局场景。另外一点验证了这个趋势的是,iOS 26 中,只有官方提供的控件、导航框架才有完整的液态玻璃交互。架构的演进一般是为了提高研效、减少出错。一个合理、高效的代码架构,在当业务需求变得复杂的时候,业务调用代码不会随业务的复杂而线性增长,而是逐渐减少。
@Crazy:本文主要介绍了 Dart 官方放弃宏编程改为优化 build_runner 的原因,在读本文之前,要先明白什么是宏编程。文章中介绍了 Dart 在实现宏编程的过程中试用的方案与思考,放弃的原因总结起来有三个 :
文章最后还对比了 Kotlin 的 Compiler Plugins、KSP 与 Swift 的 Swift Macros 的差距,总的来说 build_runner 还有很长的一段路要走。
@AidenRao:Swift 6 带来了全新的 import 访问级别控制:@_exported import。它和我们熟悉的 public import 有什么不同?简单来说,public import 只是将一个模块声明为公开 API 的一部分,但使用者仍需手动导入它;而 @_exported import 则是将依赖的符号完全“吸收”,调用方无需关心底层依赖。文章深入对比了两者的意图和应用场景,并给出了明确建议:日常开发中应优先选择官方支持的 public import,仅在封装 SDK 或构建聚合模块(Umbrella Module)这类希望为用户简化导入操作的场景下,才考虑使用 @_exported。
@含笑饮砒霜:这篇文章主要讲述如何将 MVVM 架构与 Reducer 模式结合来提升 iOS 应用中状态管理的可控性和可维护性。作者指出:传统的 MVVM 模式在复杂状态下易出现分散的状态变更和难以追踪的问题,这会导致难调试、隐式状态转换、竞态条件等不良后果;而 Reducer 模式(受 Redux/TCA 启发)通过 “单一状态源 + 明确 action + 纯函数 reduce ” 的方式,使状态变更更可预测、更易测试。文章建议在 ViewModel 内部局部引入 reducer,把所有状态通过单一 reduce(state, action) 处理,并把副作用(如异步任务)当作 effects 处理,从而达到更明确、可追踪且易单元测试的效果,同时保留 MVVM 和领域层的清晰分层,不强依赖某个框架。
@Cooper Chen:文章从第一性原理出发,系统拆解了 Agentic Coding 背后的底层逻辑与工程现实,澄清了一个常见误区:效率瓶颈不在于上下文窗口不够大,而在于我们如何与 AI 协作。作者以 LLM 的自回归生成与 Attention 机制为起点,深入分析了 Coding Agent 在长任务中常见的“走偏”“失忆”“局部最优”等问题,并指出这些并非工具缺陷,而是模型工作方式的必然结果。
文章最有价值之处,在于将理论约束转化为可执行的工程实践:通过“短对话、单任务”的工作方式控制上下文质量;用结构化配置文件和工具设计引导 Agent 行为;通过 Prompt Caching、Agent Loop、上下文压缩等机制提升系统稳定性。更进一步,作者提出“复利工程(Compounding Engineering)”这一关键理念——不把 AI 当一次性工具,而是通过文档、规范、测试和审查,将每一次经验沉淀为系统的长期记忆。
最终,文章给出的启示非常清晰:AI 编程不是魔法,而是一门需要刻意练习的协作技能。当你真正理解模型的边界,并用工程化方法加以约束和放大,AI 才能从“能写代码”进化为“可靠的编程合作者”。
@Damien:文章揭示了 Universal Links 在大规模应用中的隐藏复杂性:AASA 文件缺乏 JSON 模式验证导致静默失效,Apple CDN 缓存延迟使问题修复滞后,苹果特有通配符语法和 substitutionVariables 变量无现成工具支持。作者提出通过 CI 集成模式验证、CDN 同步检查、自定义正则解析和 staging 环境测试的完整方案,并开源了 Swift CLI 工具实现全链路自动化验证。
@JonyFang: 本视频深入介绍了如何让 AI 代理(如 Codex GPT 5.2)真正提升 iOS/macOS 开发效率的三个核心策略:
最有价值之处:作者强调了一个常被忽视的问题 - AI 代码助手不仅需要理解代码逻辑,更需要理解应用的运行时状态。通过工具如 Peekaboo 等,让 AI 能够获取视觉反馈(截图、UI 层级等),从而提供更精准的问题诊断和代码建议。这种"可观测性优先"的思路,与传统的代码审查工作流形成了有趣的对比,值得所有尝试将 AI 工具深度集成到开发流程中的团队参考。
视频时长约 49 分钟,适合希望系统性提升 AI 辅助开发效率的 iOS/macOS 开发者观看。
@Crazy:Skip 框架正式免费并且开源,该库从 2023 年开始开发,已有三年的开发历程。该库的目的是让开发者能够仅用一套 Swift 与 SwiftUI 代码库,同时打造 iOS 与 Android 上的高品质移动应用——而且不必接受那些自“跨平台工具诞生以来就一直存在”的妥协。因为 Skip 是采用编译为 Kotlin 与 Compose 的方式,所以相应的执行效率是非常高的。相较于其他的跨平台开发,效率高,并且使用的是 Swift 语言。既然已经免费并开源,移动端开发的时候又多了一个可供选择的跨端技术。
重新开始更新「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)
面向:有一定 iOS / Runtime 基础的开发者
目标:真正搞清楚 weak 到底 weak 了谁、SideTable 里存的是什么、为什么能自动置 nil
weak 不是修饰对象,而是修饰“指针变量”
SideTable 记录的是:某个对象,被哪些 weak 指针地址指向
换句话说:
@property (nonatomic, weak) Person *person;
编译后本质是:
Person *__weak _person;
说明三点:
Person *p = [[Person alloc] init];
self.person = p; // weak
此时内存中存在三样东西:
| 角色 | 含义 |
|---|---|
| Person 对象 | 真正的 OC 实例 |
| strong 指针 | 拥有对象(如 p) |
| weak 指针 | 不拥有对象(如 self->_person) |
weak 的对象不是 Person,而是 _person 这个指针变量。
self.person = p;
编译后:
objc_storeWeak(&self->_person, p);
注意这里传入的两个参数:
&self->_person 👉 weak 指针的地址
p 👉 对象地址
Runtime 的真实意图:
登记:对象 p,被这个 weak 指针地址弱引用了
struct SideTable {
spinlock_t lock;
RefcountMap refcnts; // 强引用计数
weak_table_t weak_table; // 弱引用表
};
struct weak_table_t {
weak_entry_t *weak_entries;
};
struct weak_entry_t {
DisguisedPtr<objc_object> referent; // 被 weak 的对象
weak_referrer_t *referrers; // weak 指针地址数组
};
用一张逻辑图表示:
SideTable
└── weak_table
└── weak_entry
├── referent = Person 对象
└── referrers = [
&self->_person,
&vc->_delegate,
&cell->_model
]
以:
self.person = p;
为例:
| 问题 | 答案 |
|---|---|
| 谁被 weak? | _person 这个指针变量 |
| 谁被引用? | Person 对象 |
| SideTable 记录什么? | Person → weak 指针地址列表 |
当 Person 的引用计数降为 0:
objc_destroyWeak(obj);
Runtime 的逻辑流程:
1. 找到 obj 对应的 weak_entry
2. 遍历 referrers(weak 指针地址)
3. 对每个地址执行:
*(Person **)referrer = nil
4. 移除 weak_entry
⚠️ Runtime 完全不知道变量名,只操作内存地址。
0x1000 Person 对象
0x2000 p (strong) → 0x1000
0x3000 self->_person → 0x1000
key: 0x1000
value: [0x3000]
free(0x1000)
*(0x3000) = nil
最终:
self.person == nil
| 修饰符 | 行为 |
|---|---|
| assign | 不 retain、不置 nil → 野指针 |
| weak | Runtime 扫表并置 nil |
weak 的安全性来自 Runtime 的集中清理机制
因为:
对象释放是一个确定事件
以对象为 key:
✅ A 的某个指针 weak 指向 B
✅ Runtime 知道,对象本身不知道
✅ weak 是指针语义
✅ weak = 不 retain + 注册 weak_table + 自动置 nil
weak 的本质不是弱引用对象,
而是 Runtime 记录“哪些指针弱指向了这个对象”,
并在对象销毁时统一把这些指针置为 nil。
block 捕获下 weak_table 的变化过程
__unsafe_unretained 与 weak 的实现对比
objc-runtime 源码中 weak_entry 的真实实现
核心答案:Flutter 采用自绘引擎架构,不依赖平台原生控件,而是通过 Skia 引擎直接在 GPU 上绘制 UI,从而实现跨平台一致性和高性能。
深入原理:
Flutter 与其他跨平台方案的本质区别在于渲染方式:
| 方案 | 渲染方式 | 性能瓶颈 |
|---|---|---|
| WebView 方案 | HTML+CSS 渲染 | 渲染引擎性能差 |
| React Native | JS→Bridge→原生控件 | Bridge 通信开销 |
| Flutter | Dart→Skia→GPU | 几乎无额外开销 |
Flutter 只需要平台提供一个"画布"(Surface),然后自己完成所有渲染工作。这就像你给我一张白纸,我自己画画,而不是让你帮我画。
串联知识点:
这也解释了为什么 Flutter 的 Platform Channel 只用于功能调用(相机、传感器)而不用于 UI 渲染——UI 完全由 Flutter 自己处理,不走原生。
核心答案:Widget 树是配置描述,Element 树是实例管理,RenderObject 树是布局绘制。三者分离是为了实现"配置与渲染解耦",从而支持高效的增量更新。
深入原理:
第一层理解——各自职责:
第二层理解——为什么要分三层?
这是经典的"关注点分离"设计:
如果没有 Element 这一层,每次 setState 都要重建整个 RenderObject 树,性能会很差。
第三层理解——diff 复用机制:
Element 的复用规则:
串联知识点:
这就是为什么推荐使用 const 构造函数——const Widget 是编译期常量,同一实例直接复用,连 diff 都省了。
这也解释了为什么 Key 很重要——没有 Key 时只比较类型,列表项交换位置会导致状态错乱。
核心答案:setState 本身是同步的,但 UI 更新是异步的。它只是标记当前 Element 为 dirty,然后在下一帧的 Build 阶段统一重建。
完整流程:
setState() 调用
↓
执行传入的闭包,修改成员变量
↓
调用 _element.markNeedsBuild()
↓
将 Element 加入 dirty 列表
↓
如果还没请求过,调用 scheduleFrame() 请求下一帧
↓
setState 返回(此时 UI 还没变化)
↓
等待 VSync 信号
↓
handleBeginFrame → handleDrawFrame
↓
BuildOwner.buildScope() 遍历 dirty 列表
↓
对每个 dirty Element 调用 rebuild()
↓
rebuild 调用 build() 生成新 Widget
↓
updateChild 进行 diff 比较
↓
复用或重建子 Element
↓
如果需要,更新 RenderObject
↓
标记 needsLayout 或 needsPaint
↓
后续的 Layout 和 Paint 阶段处理
xyz追问:为什么 setState 是异步更新?
xyz追问:在 build 方法里调用 setState 会怎样?
会报错!因为正在 build 的过程中不能再标记 dirty。这是一个保护机制,防止无限循环。
串联知识点:
这与 React 的 setState 机制类似——都是"标记脏,批量更新"。但 Flutter 更进一步,与渲染管线(VSync)深度绑定。
核心答案:VSync → Animate → Build → Layout → Paint → Composite → Rasterize
详细阶段:
| 阶段 | 做什么 | 触发条件 |
|---|---|---|
| Animate | 更新动画值 | Ticker 注册了回调 |
| Build | 重建 Widget/Element 树 | Element 被标记 dirty |
| Layout | 计算大小和位置 | RenderObject 被标记 needsLayout |
| Paint | 生成绘制指令,构建 Layer 树 | RenderObject 被标记 needsPaint |
| Composite | 合成 Layer 树为 Scene | Paint 完成后 |
| Rasterize | Skia 光栅化,GPU 渲染 | 在 GPU 线程执行 |
深入理解——标记传播机制:
这里有一个关键设计:标记是向上传播的。
比如你调用 setState:
同理,needsPaint 也会向上传播到重绘边界(Repaint Boundary)。
xyz追问:为什么要标记传播而不是直接更新?
性能优化!标记只是打个记号(O(1)),真正的计算延迟到统一处理阶段。这样可以合并多次变化,避免重复计算。
串联知识点:
这就是为什么 RepaintBoundary 能优化性能——它阻断了 needsPaint 的向上传播,让重绘范围最小化。
核心答案:Flutter 采用单次遍历的盒约束布局,约束从上往下传,尺寸从下往上返,父节点决定子节点位置。
核心原则:
Constraints go down, Sizes go up, Parent sets position.
详细流程:
child.layout(constraints),把约束传给子节点size 属性child.size,决定子节点的偏移量(通过 ParentData)xyz追问:为什么子节点不知道自己的位置?
这是性能优化!如果子节点位置变化,不需要重新布局子树。比如动画移动一个 Widget,只需要改 offset,不需要重新计算子节点的大小。
约束类型:
| 类型 | 特征 | 示例 |
|---|---|---|
| 紧约束 | minWidth == maxWidth | Container 给子节点设置固定宽度 |
| 松约束 | minWidth = 0 | 允许子节点任意小 |
| 无界约束 | maxWidth = infinity | ListView 给子节点的主轴约束 |
xyz追问:为什么会有"RenderBox was not laid out"错误?
常见于无界约束场景。比如在 Column 里放 ListView,Column 给 ListView 的高度约束是无界的(infinity),而 ListView 需要一个确定的高度。解决方案:用 Expanded 包裹或设置固定高度。
串联知识点:
这也解释了为什么 Flex 布局中要用 Expanded/Flexible——它们会把无界约束转换为有界约束。
核心答案:Relayout Boundary 是布局边界,它的布局变化不会影响父节点,也不受兄弟节点影响,从而减少布局计算范围。
触发条件(满足任一):
原理:
正常情况下,子节点大小变化 → 父节点需要重新布局 → 可能影响兄弟节点 → 连锁反应。
但如果子节点是 Relayout Boundary:
xyz追问:和 RepaintBoundary 什么区别?
| 边界类型 | 阻断的传播 | 优化的阶段 |
|---|---|---|
| Relayout Boundary | needsLayout 向上传播 | Layout 阶段 |
| Repaint Boundary | needsPaint 向上传播 | Paint 阶段 |
前者是自动的(满足条件就是),后者需要手动添加 RepaintBoundary Widget。
串联知识点:
这就是为什么固定大小的组件性能更好——它们自动成为 Relayout Boundary,布局变化不会影响外部。
核心答案:createState → initState → didChangeDependencies → build → (didUpdateWidget/setState → build)* → deactivate → dispose
详细流程:
| 方法 | 调用时机 | 典型用途 |
|---|---|---|
| createState | Widget 首次创建 | 创建 State 实例 |
| initState | State 插入树中 | 初始化操作、订阅 |
| didChangeDependencies | 依赖的 InheritedWidget 变化 | 响应依赖变化 |
| build | 需要重建时 | 构建 UI |
| didUpdateWidget | Widget 配置更新 | 响应配置变化 |
| deactivate | 从树中移除(可能重新插入) | 临时清理 |
| dispose | 永久移除 | 资源释放、取消订阅 |
xyz追问:initState 里能调用 setState 吗?
可以调用,但没必要。因为 initState 之后会自动调用 build。
xyz追问:initState 里能使用 context 吗?
可以使用,但不能调用 dependOnInheritedWidgetOfExactType。因为此时依赖关系还没建立完成。正确做法是在 didChangeDependencies 中获取。
xyz追问:deactivate 和 dispose 的区别?
deactivate:从树中移除,但可能重新激活(比如 GlobalKey 跨树移动) dispose:永久销毁,不会再使用
如果在 deactivate 中释放资源,重新激活时就没有资源可用了。所以资源释放应该放在 dispose。
串联知识点:
这就是为什么 GlobalKey 能跨树保持状态——它让 Element 在 deactivate 后不立即 dispose,而是等待可能的重新激活。
核心答案:Key 用于标识 Element 的身份,控制 Element 的复用逻辑。在列表项可能变化(增删、重排序)时必须使用。
原理:
没有 Key 时的匹配:只比较 Widget 类型 有 Key 时的匹配:比较类型 + Key
经典问题:列表项交换
假设列表:[A, B] 变为 [B, A]
没有 Key:
有 Key:
Key 的类型:
| 类型 | 比较方式 | 使用场景 |
|---|---|---|
| ValueKey | 值相等 | 有唯一标识的数据(ID) |
| ObjectKey | 对象引用相等 | 对象本身唯一 |
| UniqueKey | 永不相等 | 强制不复用 |
| GlobalKey | 全局唯一 | 跨树访问 State/RenderObject |
xyz追问:GlobalKey 为什么慎用?
串联知识点:
Key 的本质是给 Element 一个"身份证",让 Flutter 知道"这个 Widget 对应的是哪个 Element",而不只是"这个位置应该放什么类型的 Widget"。
核心答案:每个 Element 持有一个 Map,记录祖先中所有 InheritedWidget 的类型到 Element 的映射,查找时直接用类型做 key。
原理详解:
每个 Element 有个属性:Map<Type, InheritedElement>? _inheritedWidgets
当 Element 挂载(mount)时:
_inheritedWidgets(浅拷贝)_inheritedWidgets[MyWidget] = this
当调用 dependOnInheritedWidgetOfExactType<T>() 时:
_inheritedWidgets[T] 获取,O(1)xyz追问:依赖是怎么建立的?
InheritedElement 维护一个 Set<Element> _dependents。
调用 dependOnInheritedWidgetOfExactType 时,会把调用者加入这个 Set。
当 InheritedWidget 更新且 updateShouldNotify 返回 true 时,遍历 _dependents,对每个依赖者调用 didChangeDependencies,并标记 dirty。
xyz追问:of(context) 和 maybeOf(context) 的区别?
of:找不到会抛异常 maybeOf:找不到返回 null
串联知识点:
Provider、Riverpod、GetX 等状态管理库的核心都是对 InheritedWidget 的封装。它们本质上都在利用这个 O(1) 查找和自动依赖追踪机制。
核心答案:Provider = InheritedWidget + ChangeNotifier。InheritedWidget 负责数据传递,ChangeNotifier 负责变化通知。
工作流程:
xyz追问:Consumer 和 Provider.of 的区别?
本质相同,但 Consumer 把 rebuild 范围限制在 builder 内部。
Provider.of(context) 会让整个 build 方法重建。 Consumer 只重建 builder 返回的部分。
xyz追问:Selector 是怎么优化的?
Selector 增加了一层"选择":
这避免了"数据的其他字段变化导致我重建"的问题。
串联知识点:
这就是为什么状态管理要"细粒度"——把大状态拆成小状态,每个组件只依赖需要的部分,减少不必要的重建。
核心答案:Dart 是单线程模型,通过事件循环处理异步。事件循环维护两个队列:microtask 队列(高优先级)和 event 队列(低优先级)。每次处理完所有 microtask 后才处理一个 event。
执行顺序:
同步代码
↓
所有 microtask(直到队列空)
↓
一个 event
↓
所有 microtask(直到队列空)
↓
一个 event
↓
...循环...
加入队列的方式:
| 方式 | 加入的队列 |
|---|---|
| Future() | event |
| Future.delayed() | event |
| Timer | event |
| Future.microtask() | microtask |
| scheduleMicrotask() | microtask |
| then/catchError/whenComplete | microtask |
xyz追问:为什么要有 microtask?
microtask 用于"在当前事件处理完成后、下一个事件开始前"执行的操作。
典型场景:Future.then 的回调需要在 Future 完成后立即执行,而不是等其他事件。
xyz追问:输出顺序题
print('1');
Future(() => print('2'));
Future.microtask(() => print('3'));
scheduleMicrotask(() => print('4'));
print('5');
答案:1, 5, 3, 4, 2
解析:
串联知识点:
这就是为什么 setState 后 UI 不会立即更新——setState 只是把重建任务加入了调度,真正的重建在下一帧的事件中执行。
核心答案:Future 是对异步操作的封装,代表一个未来会完成的值。async/await 是 Future 的语法糖,编译器会将其转换为 then 链。
Future 的三种状态:
async/await 转换:
// 源代码
Future<int> foo() async {
var a = await bar();
var b = await baz(a);
return a + b;
}
// 等价于
Future<int> foo() {
return bar().then((a) {
return baz(a).then((b) {
return a + b;
});
});
}
xyz追问:async 函数一定是异步的吗?
async 函数总是返回 Future,但不一定真的异步执行。
Future<int> foo() async {
return 42; // 同步返回
}
这个函数同步执行完,但返回的是 Future<int>,获取值需要 await 或 then。
xyz追问:多个 await 是并行还是串行?
串行!每个 await 都要等上一个完成。
并行需要用 Future.wait:
var results = await Future.wait([foo(), bar(), baz()]);
串联知识点:
理解 async/await 是语法糖,就能理解很多"诡异"行为:
核心答案:Future 是单线程内的异步,用于 I/O 操作;Isolate 是真正的多线程,用于 CPU 密集型计算。Isolate 之间内存隔离,通过消息传递通信。
本质区别:
| 特性 | Future | Isolate |
|---|---|---|
| 线程 | 单线程 | 多线程 |
| 适用场景 | I/O 密集 | CPU 密集 |
| 内存 | 共享 | 隔离 |
| 通信 | 直接访问 | 消息传递 |
为什么 I/O 用 Future 就够了?
I/O 操作(网络请求、文件读写)是"等待",不占用 CPU。Dart 通过事件循环调度,等待期间可以处理其他事件。
为什么 CPU 密集操作需要 Isolate?
CPU 密集操作(JSON 解析、图片处理)会阻塞事件循环,导致 UI 卡顿。Isolate 在独立线程执行,不阻塞主线程。
Isolate 通信机制:
Main Isolate New Isolate
| |
SendPort ──────────► ReceivePort
| |
ReceivePort ◄────────── SendPort
| |
独立堆内存 独立堆内存
消息是深拷贝的,不共享内存,所以没有锁和竞争条件。
xyz追问:compute 函数是什么?
Flutter 提供的便捷函数,封装了 Isolate 的创建、通信、销毁:
final result = await compute(parseJson, jsonString);
适合一次性计算任务。
串联知识点:
这就是为什么 Flutter 有时候会"卡一下"——可能是同步的 CPU 密集操作阻塞了事件循环。解决方案:用 compute 或 Isolate 把计算移到后台。
核心答案:通过 Platform Channel 通信,本质是二进制消息传递。MethodChannel 用于方法调用,EventChannel 用于事件流,BasicMessageChannel 用于基础消息。
三种 Channel 对比:
| Channel | 通信模式 | 使用场景 |
|---|---|---|
| MethodChannel | 请求-响应 | 获取电量、打开相机 |
| EventChannel | 事件流 | 传感器数据、网络状态变化 |
| BasicMessageChannel | 双向消息 | 自定义协议 |
通信流程:
Dart 调用 invokeMethod
↓
参数序列化为二进制
↓
通过 C API 传递到原生层
↓
原生层反序列化,执行方法
↓
结果序列化为二进制
↓
传回 Dart 层
↓
反序列化,完成 Future
xyz追问:在哪个线程执行?
Dart 侧:UI 线程 原生侧:也应该在主线程调用
如果原生有耗时操作,应该切到后台线程,完成后再切回主线程返回结果。
xyz追问:StandardMessageCodec 支持哪些类型?
null、bool、int、double、String、Uint8List、List、Map
复杂对象需要手动序列化为上述类型。
串联知识点:
Platform Channel 只用于"功能调用",不用于"UI 渲染"——因为 Flutter 自己渲染 UI。这是 Flutter 与 React Native 的本质区别。
核心答案:热重载利用 JIT 编译的能力,增量编译变化的代码,注入到运行中的 Dart VM,然后触发 Widget 树重建,但保持 Element 树和 State 不变。
原理详解:
文件保存
↓
检测变化的 Dart 文件
↓
增量编译为 Kernel(.dill)
↓
通过 VM Service 发送到设备
↓
Dart VM 加载新代码,替换类定义
↓
Flutter Framework 调用 reassemble()
↓
从根节点开始 rebuild
↓
Widget 树重建,Element 树复用
↓
State 保持不变
为什么能保持状态?
相当于给 State 换了一套新的 Widget 配置,但 State 本身还是那个 State。
xyz追问:什么情况下热重载不生效?
这些情况需要热重启(Hot Restart)或完全重启。
xyz追问:为什么 Release 模式不支持热重载?
因为 Release 模式使用 AOT 编译,代码已经编译为机器码,无法动态替换。
热重载依赖 JIT 编译器的动态代码注入能力。
串联知识点:
这就是 Debug 模式启动慢但支持热重载、Release 模式启动快但不支持热重载的原因——编译方式不同。
核心答案:Flutter 动画由 Ticker 驱动,Ticker 与 VSync 同步,每帧回调一次。AnimationController 接收 Ticker 信号,更新动画值,通知监听者重建。
核心组件:
动画更新流程:
VSync 信号
↓
SchedulerBinding.handleBeginFrame()
↓
Ticker 收到回调
↓
AnimationController 更新 value
↓
notifyListeners()
↓
AnimatedBuilder.setState()
↓
rebuild → 新的 Widget 配置
↓
RenderObject 更新 → 重绘
xyz追问:为什么要用 TickerProviderStateMixin?
Ticker 需要在页面不可见时暂停,避免浪费资源。
TickerProviderStateMixin 会在 State deactivate 时暂停 Ticker。
xyz追问:隐式动画和显式动画的区别?
| 特性 | 隐式动画 | 显式动画 |
|---|---|---|
| 代表 | AnimatedContainer | AnimationController |
| 控制 | 自动检测属性变化 | 手动控制 |
| 灵活性 | 低 | 高 |
| 使用难度 | 简单 | 复杂 |
串联知识点:
动画本质是"每帧改变一点点"。Ticker 保证与屏幕刷新同步,AnimationController 计算每帧的值,Widget 根据值重建——这就是 Flutter 动画的完整链路。
核心答案:ListView 内部使用 Sliver 协议,只构建可视区域及缓存区的子项,滚动时动态创建和回收,实现按需加载。
Sliver 协议 vs Box 协议:
| 协议 | 约束 | 适用场景 |
|---|---|---|
| Box | 宽高范围 | 普通布局 |
| Sliver | 滚动信息 + 可视范围 | 滚动视图 |
懒加载流程:
用户滚动
↓
Viewport 计算可视范围
↓
SliverList 收到新的 SliverConstraints
↓
根据 scrollOffset 计算首个可见项
↓
按需调用 builder 创建子项
↓
创建直到填满可视区域 + 缓存区
↓
回收离开缓存区的子项
xyz追问:itemExtent 为什么能优化性能?
没有 itemExtent:需要逐个布局子项才知道高度,才能计算滚动范围 有 itemExtent:高度固定,直接计算,不需要实际布局
对于 1000 项的列表,跳转到第 800 项:
xyz追问:为什么 ListView 里放 ListView 会报错?
Column 给 ListView 的高度约束是 infinity(无界)。 ListView 需要确定的高度来计算滚动范围。 无界约束 + 需要确定高度 = 冲突。
解决:用 Expanded 包裹,或给 ListView 设置固定高度。
串联知识点:
Sliver 的设计思想是"只做需要做的事"——只构建可见的,只布局可见的,只绘制可见的。这是 Flutter 列表高性能的根本。
核心答案:Flutter 使用 ImageCache 进行内存缓存,ImageProvider 负责加载逻辑。图片加载是异步的,解码后缓存,下次直接复用。
加载流程:
Image Widget 创建 ImageProvider
↓
ImageProvider 生成缓存 Key
↓
检查 ImageCache
↓
命中 → 直接返回 ImageInfo
↓
未命中 → 调用 load()
↓
下载/读取原始数据
↓
解码为 ui.Image
↓
缓存到 ImageCache
↓
通知 Image Widget 更新
ImageCache 策略:
xyz追问:为什么图片会内存溢出?
解决:
串联知识点:
图片缓存是内存缓存,应用重启就没了。如果需要磁盘缓存(跨会话),需要使用专门的库(如 cached_network_image)。
核心答案:Dart 使用分代垃圾回收。年轻代使用复制算法(快速但需要双倍空间),老年代使用标记-清除-整理(节省空间但较慢)。
分代假设:
大多数对象很快死亡(临时变量、短期 Widget),少数对象活很久(State、全局对象)。
基于这个假设,年轻代频繁 GC、老年代较少 GC。
年轻代 GC:
优点:速度快,无碎片 缺点:需要双倍空间
老年代 GC:
Dart 使用并发 GC,大部分工作在后台线程,减少主线程停顿。
xyz追问:Flutter 的 Widget 频繁创建会影响性能吗?
影响很小:
这就是 Flutter "每帧重建 Widget 树" 可行的原因。
串联知识点:
理解 GC 机制,就能理解为什么 const 重要——const 对象不参与 GC,直接从常量池读取。
核心答案:减少 Build 范围、减少 Layout 范围、减少 Paint 范围、减少图层、合理使用缓存。
Build 优化:
| 手段 | 原理 |
|---|---|
| 使用 const | 编译期常量,直接复用 |
| 状态下沉 | 缩小 setState 影响范围 |
| 使用 Builder | 隔离 context 依赖 |
| Selector | 细粒度订阅 |
Layout 优化:
| 手段 | 原理 |
|---|---|
| 固定尺寸 | 自动成为 Relayout Boundary |
| 避免深层嵌套 | 减少布局计算 |
| 使用 itemExtent | 跳过高度测量 |
Paint 优化:
| 手段 | 原理 |
|---|---|
| RepaintBoundary | 隔离重绘区域 |
| 避免 saveLayer | 减少离屏渲染(Opacity、ClipPath) |
| 图片合适尺寸 | 减少解码和绘制开销 |
列表优化:
| 手段 | 原理 |
|---|---|
| ListView.builder | 按需创建 |
| 使用 Key | 正确复用 |
| 分页加载 | 减少内存占用 |
xyz追问:如何定位性能问题?
串联知识点:
所有优化都指向一个核心——"减少不必要的工作"。理解渲染管线每个阶段做什么,就知道如何针对性优化。
| 对比项 | StatelessWidget | StatefulWidget |
|---|---|---|
| 状态 | 无内部状态 | 有内部状态 |
| 生命周期 | 只有 build | 完整生命周期 |
| 重建触发 | 只能由父节点触发 | 可以 setState 自触发 |
| 性能 | 更轻量 | 略重(多个对象) |
| 使用场景 | 纯展示 | 需要交互 |
深入理解:
StatelessWidget 只是"简化版"——它也有 Element,只是 Element 没有持有 State。
StatefulWidget 拆分成两个对象(Widget + State)是为了分离"配置"和"状态":
| Widget 类型 | Element 类型 | RenderObject |
|---|---|---|
| StatelessWidget | StatelessElement | 无 |
| StatefulWidget | StatefulElement | 无 |
| SingleChildRenderObjectWidget | SingleChildRenderObjectElement | 有 |
| MultiChildRenderObjectWidget | MultiChildRenderObjectElement | 有 |
| InheritedWidget | InheritedElement | 无 |
关键理解:
并不是每个 Widget 都有 RenderObject!
StatelessWidget、StatefulWidget 只是"组合"其他 Widget,不直接渲染。真正渲染的是 RenderObjectWidget(如 Container 内部的 DecoratedBox、Padding)。
| 特性 | Hot Reload | Hot Restart | 完全重启 |
|---|---|---|---|
| 速度 | ~1秒 | 几秒 | 较慢 |
| State | 保留 | 丢失 | 丢失 |
| 全局变量 | 保留 | 重置 | 重置 |
| main() | 不重新执行 | 重新执行 | 重新执行 |
| 原生代码 | 不更新 | 不更新 | 更新 |
| 特性 | JIT | AOT |
|---|---|---|
| 编译时机 | 运行时 | 构建时 |
| 启动速度 | 慢 | 快 |
| 运行性能 | 可动态优化 | 固定 |
| 包体积 | 小(源码/字节码) | 大(机器码) |
| 热重载 | 支持 | 不支持 |
| 使用场景 | Debug | Release |
深入理解:
Debug 用 JIT 是为了热重载;Release 用 AOT 是为了性能。
| 特性 | Future | Stream |
|---|---|---|
| 值的个数 | 一个 | 多个 |
| 完成性 | 完成就结束 | 可以持续发送 |
| 使用方式 | await / then | listen / await for |
| 典型场景 | 网络请求 | 传感器数据、WebSocket |
核心答案:
答题框架:
答题技巧:
Flutter 高性能
│
├── 自绘引擎 ────────────────────┐
│ │
├── 三棵树分离 │
│ ├── Widget 轻量 ← const 优化 │
│ ├── Element 复用 ← Key 机制 │
│ └── RenderObject 专注渲染 │
│ │
├── 渲染管线 │
│ ├── Build ← setState 批量 │
│ ├── Layout ← Relayout Boundary│
│ └── Paint ← Repaint Boundary │
│ │
├── 异步机制 │
│ ├── Future ← Event Loop │
│ └── Isolate ← 多线程 │
│ │
├── 懒加载 │
│ └── Sliver ← ListView.builder │
│ │
└── GC 友好 │
└── 分代回收 ← Widget 短命 │
以上就是 Flutter 底层原理的融会贯通版八股文。每个问题都可以层层深入,知识点之间相互串联,形成完整的知识体系。
关键词:KVC、KVO、ivar、property、Runtime、isa-swizzling
在 Objective-C 中:
ivar 是数据的真实存储
property 是访问 ivar 的规则
KVC / KVO 本质上都是“访问规则之上的机制”
如果不理解 ivar 和 property,就一定理解不清 KVC / KVO
KVC 是一种:
通过字符串 key 间接访问对象属性的机制
[person setValue:@"Hanqiu" forKey:@"name"];
NSString *name = [person valueForKey:@"name"];
本质是 一套查找规则
最终结果:
要么调用方法
要么直接访问 ivar
📌 KVC 并不依赖 property 是否存在
当执行:
[person setValue:value forKey:@"name"];
查找顺序如下:
1. setName:
2. _setName:
3. +accessInstanceVariablesDirectly == YES ?
3.1 _name
3.2 _isName
3.3 name
3.4 isName
4. 调用 setValue:forUndefinedKey:
1. getName
2. name
3. isName
4. _name
5. _isName
6. 调用 valueForUndefinedKey:
| 场景 | 是否需要 property | 是否访问 ivar |
|---|---|---|
| 存在 setter | ❌ | ❌ |
| 无 setter | ❌ | ✅ |
| 无 ivar | ❌ | ❌(崩溃) |
KVC 是“方法优先,ivar 兜底”的机制****
KVO 是一种:
监听属性变化的观察机制
[person addObserver:self
forKeyPath:@"name"
options:NSKeyValueObservingOptionNew
context:nil];
KVO 监听的是 setter 的调用,而不是 ivar 的变化
当第一次添加观察者时,系统会:
Person
↑ isa
NSKVONotifying_Person
伪代码如下:
- (void)setName:(id)value {
[self willChangeValueForKey:@"name"];
[super setName:value];
[self didChangeValueForKey:@"name"];
}
👉 通知发生在 setter 内部
_name = @"A"; // ❌ 不触发 KVO
self.name = @"B"; // ✅ 触发 KVO
原因:
| 情况 | 是否支持 KVO |
|---|---|
| 有 setter | ✅ |
| 只有 ivar | ❌ |
| Category property + Associated Object | ⚠️(可行但危险) |
📌 KVO 实际依赖的是 setter,而不是 property 关键字
如果你必须直接改 ivar:
[self willChangeValueForKey:@"name"];
_name = @"C";
[self didChangeValueForKey:@"name"];
[person setValue:@"D" forKey:@"name"];
结论:
如果最终调用 setter → ✅ 触发 KVO
如果直接命中 ivar → ❌ 不触发
是否触发,取决于 KVC 查找路径
❌ 错
✔ 监听的是 setter 的调用
❌ 错
✔ 只要有 setter 方法即可
❌ 错
✔ 是否触发取决于是否调用 setter
┌──────────────┐
│ property │
│ getter/setter│
└──────┬───────┘
│
KVO 监听 │ setter
▼
ivar(真实数据)
▲
│
KVC 兜底访问
KVC 是“方法优先、ivar 兜底”的键值访问机制;KVO 是通过 isa-swizzling 重写 setter 来监听属性变化的机制,本质与 ivar 无关,只与 setter 是否被调用有关。
从 isa 到 cache,从方法列表到属性列表
一次把「一个 Class 里到底装了什么」讲清楚
在 Runtime 视角下,Objective-C 的 Class 并不是一个抽象概念,
而是一块结构严谨、职责清晰的内存结构。
本文将围绕 Class 的真实组成,系统讲解:
从 Runtime 角度,一个类(Class)至少包含以下几大部分:
Class
├─ isa
├─ superclass
├─ cache
├─ method list
├─ property list
├─ protocol list
├─ ivar list
├─ class_rw_t / class_ro_t
└─ 元类(Meta Class)
下面我们逐一展开。
instance ──isa──▶ Class ──isa──▶ Meta Class
在 arm64 以后:
isa 是 非纯指针(non-pointer isa)
高位存储了:
引用计数信息
weak 标志
是否有关联对象
但 逻辑语义没有变化。
cache
├─ bucket[SEL → IMP]
└─ mask / occupied
objc_msgSend 查找顺序:
1️⃣ cache
2️⃣ method list
3️⃣ superclass → 重复 1、2
cache 永远是第一站。
cache 是 懒加载 的
第一次方法调用:
cache 未命中
method list 找到 IMP
写入 cache
之后同一个 SEL:
直接命中 cache
cache 的 key 是:
SEL
但 cache 属于 某一个 Class。
因此:
A.foo → A 的 cache
B.foo → B 的 cache
即使 SEL 相同,也互不干扰。
method_t
├─ SEL name
├─ IMP imp
└─ const char *types
也就是我们熟悉的三要素:
SEL + IMP + Type Encoding
method list 由以下部分合并而来:
类本身实现的方法
Category 中的方法
⚠️ Category 的方法:
objc_property_t
├─ name
└─ attributes (copy, nonatomic, strong ...)
Runtime 反射
KVC / KVO
自动序列化 / ORM
但注意:
方法调用完全不依赖 property list
ivar list 描述的是:
ivar_t
├─ name
├─ type
└─ offset
instance memory
├─ isa
├─ ivar1
├─ ivar2
存储类遵循的协议
包含:
必选方法
可选方法
主要用于:
编译期确定
存储:
运行时动态生成
存储:
Category 方法
动态添加的方法
这也是 Category 能“修改类行为”的根本原因。
[Class foo]
→ 查找 Meta Class 的 cache / method list
instance
└─ isa → Class
├─ isa → Meta Class
├─ superclass
├─ cache
├─ method list
├─ property list
├─ ivar list
├─ protocol list
└─ class_rw_t / class_ro_t
方法调用性能 = cache 决定
行为修改能力 = method list + rw 区
内存布局 = ivar list 决定
反射能力 = property / protocol 提供
它们各司其职,互不混乱。
Class 是 Runtime 的作战单元:
cache 决定快慢,method list 决定行为,
ivar 决定内存,property 决定语义,
isa 决定你是谁。
理解这一层结构,
你就真正理解了 Objective-C Runtime 的“骨架”。
还记得你第一次使用NSLog(@"Hello, World!")的时刻吗?那是调试的起点。但随着应用复杂度呈指数级增长,我们需要的工具也经历了革命性进化:
NSLog、print)今天,一个成熟的iOS开发者工具箱中,至少需要掌握3-5种核心调试工具,它们就像外科医生的手术刀——精准、高效、各有所长。
功能最全的运行时调试套件,集成后可以测试期间随时开启\关闭工具条,比如设置摇一摇后启动。
优点: 功能全面,无需连接电脑
缺点: 内存占用稍大
场景: 日常开发调试、UI问题排查
GitHub: https://github.com/FLEXTool/FLEX?tab=readme-ov-file
主要功能:
3D视图层级工具,类Xcode Inspector和Reveal。相比Xcode中查看图层的优势有两个:
Lookin后即可刷新显示当前图层。(真机需连接电脑后才展示)// 集成步骤一:官网下载lookin;
- 官网: https://lookin.work
- GitHub: https://github.com/QMUI/LookinServer
// 集成步骤二:
CocoaPods安装:
// 1.如果是OC工程
pod 'LookinServer', :configurations => ['Debug']
// 2.如果是OC工程
// 在 iOS 项目的 Podfile 中 添加 “Swift” 这个 Subspec
pod 'LookinServer', :subspecs => ['Swift'], :configurations => ['Debug']
// 或者添加 “SwiftAndNoHook”这个 Subspec 也行
pod 'LookinServer', :subspecs => ['SwiftAndNoHook'], :configurations => ['Debug']
// 官网: https://proxyman.io
// 特点:
✅ 现代UI,操作流畅
✅ HTTPS解密(无需安装证书到系统)
✅ 重放、修改、拦截请求
✅ Map Local/Map Remote功能
✅ 脚本支持(JavaScript)
✅ 支持Apple Silicon
使用场景:
• API接口调试
• 图片/资源请求优化
• 模拟慢速网络
• 修改响应数据测试
// 官网: https://www.charlesproxy.com
// 特点:
✅ 功能极其全面
✅ 跨平台支持
✅ 脚本功能强大(Charles Proxy Script)
✅ 带宽限制、断点调试
✅ 支持HTTP/2、HTTP/3
设置步骤:
1. 安装Charles
2. 在iOS设备设置代理
3. 安装Charles根证书
4. 信任证书(设置→通用→关于→证书信任设置)
// 常用功能:
• Breakpoints(请求拦截修改)
• Rewrite(规则重写)
• Map Local(本地文件映射)
• Throttle(网络限速)
# 官网: https://mitmproxy.org
# 特点:
✅ 完全开源免费
✅ 命令行操作,适合自动化
✅ 脚本扩展(Python)
✅ 支持透明代理
# 安装:
brew install mitmproxy
# 使用:
# 启动代理
mitmproxy --mode transparent --showhost
# iOS设置:
# 1. 安装证书: mitm.it
# 2. 配置Wi-Fi代理
// 官网: https://revealapp.com
// 特点:
✅ 实时3D视图层级
✅ 详细的AutoLayout约束查看
✅ 内存图查看器
✅ 支持SwiftUI预览
✅ 强大的筛选和搜索
// 集成:
// 方式1: 通过Reveal Server框架
pod 'Reveal-SDK', :configurations => ['Debug']
// 方式2: LLDB加载(无需集成代码)
(lldb) expr (void)[[NSClassFromString(@"IBARevealLoader") class] revealApplication];
// 价格: 付费(提供免费试用)
// GitHub: https://github.com/johnno1962/InjectionIII
// 特点:
✅ 代码修改后实时生效
✅ 无需重新编译运行
✅ 支持Swift和Objective-C
✅ 保留应用状态
// 安装:
# App Store搜索 "InjectionIII"
// 配置:
1. 下载安装InjectionIII
2. 在AppDelegate中配置:
#if DEBUG
Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle")?.load()
#endif
3. 项目添加文件监视:
// 在InjectionIII App中添加项目路径
// 核心工具集:
┌─────────────────────────────────────┐
│ Xcode Instruments │
├─────────────────────────────────────┤
│ Time Profiler # CPU使用分析 │
│ Allocations # 内存分配分析 │
│ Leaks # 内存泄漏检测 │
│ Network # 网络活动分析 │
│ Energy Log # 电量消耗分析 │
│ Metal System # GPU性能分析 │
│ SwiftUI # SwiftUI性能分析 │
└─────────────────────────────────────┘
// 使用技巧:
1. 录制时过滤系统调用:
Call Tree: ✅ Hide System Libraries
✅ Invert Call Tree
✅ Flattern Recursion
2. 内存图调试:
Debug Memory Graph按钮
查看循环引用、内存泄漏
3. 使用Markers:
import os
let log = OSLog(subsystem: "com.app", category: "performance")
os_signpost(.begin, log: log, name: "Network Request")
// ... 操作
os_signpost(.end, log: log, name: "Network Request")
// Apple官方性能数据收集框架
import MetricKit
class MetricKitManager: MXMetricManagerSubscriber {
static let shared = MetricKitManager()
private init() {
let manager = MXMetricManager.shared
manager.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
// 接收性能数据
for payload in payloads {
print("CPU: \(payload.cpuMetrics)")
print("内存: \(payload.memoryMetrics)")
print("启动时间: \(payload.launchMetrics)")
print("磁盘IO: \(payload.diskIOMetrics)")
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
// 接收诊断数据(崩溃、卡顿等)
}
}
// 需要用户授权,适合生产环境监控
// GitHub: https://github.com/Tencent/tracy
// 特点:
✅ 卡顿监控(主线程阻塞检测)
✅ 内存泄漏检测
✅ 大对象分配监控
✅ 网络性能监控
✅ 崩溃收集
// 集成:
pod 'Tracy', :configurations => ['Debug']
// 使用:
Tracy.start()
// 自动监控各种性能指标
// GitHub: https://github.com/Tencent/MLeaksFinder
// 特点:
✅ 自动检测视图控制器内存泄漏
✅ 无需编写任何代码
✅ 支持自定义白名单
✅ 精准定位泄漏对象
// 原理:
// 监听UIViewController的pop/dismiss
// 延迟检查是否仍然存在
// 集成:
pod 'MLeaksFinder'
// 自定义配置:
// 1. 添加白名单
[NSClassFromString(@"WhiteListClass") class]
// 2. 忽略特定泄漏
[MLeaksFinder addIgnoreClass:[IgnoreClass class]]
// GitHub: https://github.com/facebook/FBRetainCycleDetector
// 特点:
✅ 检测Objective-C对象的循环引用
✅ 支持检测NSTimer的强引用
✅ 可集成到单元测试中
✅ Facebook内部广泛使用
// 使用:
let detector = FBRetainCycleDetector()
detector.addCandidate(myObject)
let cycles = detector.findRetainCycles()
// 输出格式化的循环引用链
for cycle in cycles {
print(FBRetainCycleDetectorFormatter.format(cycle))
}
// GitHub: https://github.com/kstenerud/KSCrash
// 特点:
✅ 捕获所有类型崩溃(OC异常、C++异常、Mach异常等)
✅ 生成完整的崩溃报告
✅ 支持符号化
✅ 可自定义上报服务器
// 集成:
pod 'KSCrash'
// 配置:
import KSCrash
let installation = makeEmailInstallation("crash@company.com")
installation.addConditionalAlert(withTitle: "Crash Detected",
message: "The app crashed last time")
KSCrash.shared().install()
// 高级功能:
// 1. 用户数据记录
KSCrash.shared().userInfo = ["user_id": "123"]
// 2. 自定义日志
KSCrash.shared().log.error("Something went wrong")
// 3. 监控卡顿
KSCrash.shared().monitorDeadlock = true
// GitHub: https://github.com/CocoaLumberjack/CocoaLumberjack
// 特点:
✅ 高性能日志记录
✅ 多日志级别(Error, Warn, Info, Debug, Verbose)
✅ 多种输出目标(Console, File, Database)
✅ 日志轮转和清理
✅ 支持Swift和Objective-C
// 集成:
pod 'CocoaLumberjack/Swift'
// 配置:
import CocoaLumberjackSwift
// 控制台日志
DDLog.add(DDOSLogger.sharedInstance)
// 文件日志
let fileLogger = DDFileLogger()
fileLogger.rollingFrequency = 60 * 60 * 24 // 24小时
fileLogger.logFileManager.maximumNumberOfLogFiles = 7
DDLog.add(fileLogger)
// 使用:
DDLogError("错误信息")
DDLogWarn("警告信息")
DDLogInfo("普通信息")
DDLogDebug("调试信息")
DDLogVerbose("详细信息")
// 上下文过滤:
let context = 123
DDLogDebug("带上下文的消息", context: context)
// GitHub: https://github.com/SwiftyBeaver/SwiftyBeaver
// 特点:
✅ 纯Swift实现
✅ 彩色控制台输出
✅ 多种目的地(Console, File, Cloud)
✅ 平台同步(macOS App)
✅ 支持emoji和格式化
// 使用:
import SwiftyBeaver
let log = SwiftyBeaver.self
// 添加控制台目的地
let console = ConsoleDestination()
console.format = "$DHH:mm:ss$d $L $M"
log.addDestination(console)
// 添加文件目的地
let file = FileDestination()
file.logFileURL = URL(fileURLWithPath: "/path/to/file.log")
log.addDestination(file)
// 日志级别:
log.verbose("详细") // 灰色
log.debug("调试") // 绿色
log.info("信息") // 蓝色
log.warning("警告") // 黄色
log.error("错误") // 红色
// GitHub: https://github.com/DaveWoodCom/XCGLogger
// 特点:
✅ 高度可配置
✅ 支持日志过滤
✅ 自定义日志目的地
✅ 自动日志轮转
✅ 详细的文档
// 使用:
import XCGLogger
let log = XCGLogger.default
// 配置
log.setup(level: .debug,
showLogIdentifier: false,
showFunctionName: true,
showThreadName: true,
showLevel: true,
showFileNames: true,
showLineNumbers: true,
showDate: true)
// 自定义过滤器
log.filters = [
Filter.Level(from: .debug), // 只显示.debug及以上
Filter.Path(include: ["ViewController"], exclude: ["ThirdParty"])
]
log.debug("调试信息")
log.error("错误信息")
# 官网: https://fastlane.tools
# 特点:
✅ 自动化构建、测试、部署
✅ 丰富的插件生态
✅ 与CI/CD深度集成
✅ 跨平台支持
# 常用命令:
fastlane screenshots # 自动截图
fastlane beta # 发布测试版
fastlane release # 发布正式版
fastlane match # 证书管理
# 集成调试功能:
lane :debug_build do
# 1. 设置调试配置
update_app_identifier(
app_identifier: "com.company.debug"
)
# 2. 启用调试功能
update_info_plist(
plist_path: "Info.plist",
block: proc do |plist|
plist["FLEXEnabled"] = true
plist["NSAllowsArbitraryLoads"] = true
end
)
# 3. 构建
gym(
scheme: "Debug",
export_method: "development"
)
end
# GitHub: https://github.com/SlatherOrg/slather
# 特点:
✅ 生成代码覆盖率报告
✅ 支持多种输出格式(html, cobertura, json)
✅ 与CI集成
✅ 过滤第三方库代码
# 安装:
gem install slather
# 使用:
# 1. 运行测试并收集覆盖率
xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 14' -enableCodeCoverage YES
# 2. 生成报告
slather coverage --html --show --scheme MyApp MyApp.xcodeproj
# 3. 在Jenkins中集成
slather coverage --input-format profdata --cobertura-xml --output-directory build/reports MyApp.xcodeproj
// 官网: https://sparkinspector.com
// 特点:
✅ 实时监控所有对象实例
✅ 查看对象属性变化
✅ 方法调用追踪
✅ 内存泄漏检测
// 集成:
// 1. 下载Spark Inspector应用
// 2. 集成框架到项目
// 3. 通过Spark Inspector连接调试
// 适用场景:
• 复杂的对象关系调试
• 观察模式数据流
• 内存泄漏定位
# Xcode内置,但功能极其强大
# 常用命令:
# 1. 查看变量
(lldb) po variable
(lldb) p variable
(lldb) v variable
# 2. 修改变量
(lldb) expr variable = newValue
# 3. 调用方法
(lldb) expr [self doSomething]
(lldb) expr self.doSomething()
# 4. 断点命令
(lldb) breakpoint set -n "[ClassName methodName]"
(lldb) breakpoint command add 1 # 为断点1添加命令
> po $arg1
> continue
> DONE
# 5. 内存查看
(lldb) memory read 0x12345678
(lldb) memory write 0x12345678 0x42
# 6. 自定义LLDB命令
(lldb) command regex rlook 's/(.+)/image lookup -rn %1/'
(lldb) rlook methodName
# 7. Swift特定命令
(lldb) frame variable -L # 显示局部变量
(lldb) type lookup String # 查看类型信息
| 需求场景 | 推荐工具 | 理由 |
|---|---|---|
| 日常开发调试 | FLEX + Proxyman | 功能全面,无需额外环境 |
| UI/布局问题 | Lookin + Reveal | 3D视图,实时修改 |
| 性能优化 | Xcode Instruments + Tracy | 官方工具+线上监控 |
| 内存泄漏 | MLeaksFinder + FBRetainCycleDetector | 自动检测+深度分析 |
| 网络调试 | Proxyman/Charles | 功能专业,操作友好 |
| 日志管理 | CocoaLumberjack + SwiftyBeaver | 功能强大+美观输出 |
| 自动化 | Fastlane + slather | 流程自动化+质量监控 |
| 底层调试 | LLDB + InjectionIII | 深度控制+热重载 |
# iOS团队调试工具规范
## 必装工具(所有开发者)
1. Proxyman/Charles - 网络调试
2. Lookin - UI调试
3. InjectionIII - 热重载
## 项目集成(Podfile)
```ruby
target 'MyApp' do
# 调试工具(仅Debug)
pod 'FLEX', :configurations => ['Debug']
pod 'CocoaLumberjack', :configurations => ['Debug']
pod 'MLeaksFinder', :configurations => ['Debug']
end
核心建议:
终极目标: 快速定位问题 → 深入分析原因 → 有效解决问题
这些工具大多数都有免费版本或开源版本,建议从最常用的几个开始,逐步建立自己的调试能力体系。
掌握这些工具,不是为了炫耀技术,而是为了让你的代码更健壮,让你的用户更满意,让你自己在深夜加班时少掉几根头发。
上个月我花了不少时间在 dotAge 这个游戏中。我很喜欢这种通过精算规划应对确定风险的感觉。由于 dotAge 有很强的欧式桌游的设计感,所以我在桌游中尝试了一些有类似设计元素的单人游戏。
我感觉体验比较接近的有 Voidfall (2023) 和 Spirit Island (2017) 。因为灵魂岛(spirit island )更早一些,而且 steam 上有官方的电子版,bgg 上总体排名也更高,所以我在上面花的时间最多。
这两个游戏的特点都是确定性战斗机制,即在战斗时完全没有投骰这类随机元素介入。在开战之前,玩家就能完全确定战斗结果。战斗只是规划的一环,考虑的是该支付多少成本或许多大的收益。而且灵魂岛作为一款卡牌驱动的游戏,完全排除了抽牌的随机性,只在从市场上加入新牌(新能力)时有一点随机性。一旦进入玩家牌组,什么时候什么卡牌可以使用,完全是在玩家规划之内的。这非常接近 dotAge 中规划应对危机时的体验。
灵魂岛的背景像极了电影 Avatar :岛的灵魂通过原住民发挥神力赶走了外来殖民者。每个回合,把神力的成长、发威(玩家行动)和殖民者(系统危机)的入侵、成长和破坏以固定次序循环。其中,殖民者的入侵在版图上的地点有轻微的随机性,但随后的两个回合就在固定规则下,在同一地点地成长和破坏(玩家需要处理的危机)。扮演岛之灵魂的玩家可以选择到破坏之刻去那个地块消除危机,在此之前玩家有两个回合可以准备;也可以提前在殖民者成长之前将其消灭在萌芽之中,但这给玩家的准备时间更少,却往往意味着更小的消耗;还可以暂时承受损失,集中力量于它处或更快的发展神力。游戏提供给玩家的策略选择着实丰富。
法术卡并不多,每个神灵只有几张专属的固定初始能力卡,其它所有的能力都是所有神灵共用,让玩家自由组合的。每当玩家选择成长时,可以随机 4 选 1 。不像卡牌构筑类游戏会有很多卡片,这个游戏总体卡片不多,每张都有决定性作用。每个回合通常也只能打出一两张 张,待到可以一回合可以打出三张甚至四张(很少见)时,已经进入游戏后期在贯彻通关计划了。法力点数用来支付每张卡的打出费用这个设计粗看和卡牌构筑游戏类似,但实际玩下来感觉有挺大的不同。灵魂岛每个回合未用完的法力点并不会清零,而会留置到下回合使用且没有上限。从玩家规划角度看,更像是需要玩家去规划整局游戏的法力点分配。精确的打出每个回合的很少的几张卡片。因为抽回打过的法术卡并不随机,玩家便要在法力成长和法术重置上做明确选择。挑选法术序列变成了精密规划的一环。
在 dotAge 中,版图是需要规划的,玩家需要取舍每个格子上到底放什么建筑以达到连锁功效最大化。而在灵魂岛中,每张法术会提供一些元素,同一回合激活的元素组合可以给法术本身效果加成。我觉得这两个设定有异曲同工之秒。我在思考游戏设计时,受 dotAge 和 Dawnmaker 的影响,总觉得需要在版图的位置上做文章才好体现出建筑的组合,玩过灵魂岛才发现,其实单靠卡牌不考虑版图布局其实也能实现类似的体验:几张特定的法术卡组合在同一回合打出会对单一法术有额外加成,而这种组合可以非常丰富。去掉随机抽卡机制,让玩家可以 100% 控制自己牌库中的组合选择;而且总牌量很少,每个回合出牌数及其有限(受单回合出牌数及法力点双重限制),让发牌组合必须有所取舍。这像极了我在 dotAge 的狭小地图空间中布局建筑的体验,这个格子放了这个,那个建筑就得不到加成。
但受限于桌游,灵魂岛的游戏体验和 dotAge 差别还是很大的。我玩了(并击败了)多级难度的灵魂岛,难度越高差异越明显。桌游必须要求短回合快节奏,这让游戏规划的容错性大大降低。dotAge 一局游戏可以玩一整天,即使是超高难度,也允许玩家犯点小错误。由于电子游戏可以把元素做得更多,让机器负责运转规则,单点的数值关系就可以更简单直白。而灵魂岛这种需要在很少的行动中体现复杂计划的多样性,那些法术的真正功效就显得过于晦涩:虽然法术字面上的解释并不负责,但理解每个法术背后的设计逻辑,在游戏中做出准确的决策要难得多。
我在标准难度下,玩了十几盘才真正胜利过一次灵魂岛。之后每增加一点难度,感觉挑战就大了不少;反观 dotAge 我在第二盘就领会了游戏得玩法而通关,困难难度也并未带来太大的挫折感。但现在往上加难度玩灵魂岛,我还是心有余悸,不太把握得住。而且直到现在我都没敢尝试 2 个神灵以上的组合玩法,那真是太烧脑了。难怪实体版桌游都是多人合作,而不是 1 控 2 去玩。
Voidfall 从游戏结构上更接近 dotAge 一点。它完全没有战斗,就是纯跑分。只要你跑分速度超过了系统规则,就胜利了。dotAge 几乎就是这个框架:玩家需要在疾病、恐惧、温度和自然四个领域积累积分抵抗系统产生的四类危机。在每次危机来领前做好准备,也就是积累产生对应领域积分的能力。
但无论是 spirit island 还是 voidfall 都没有 dotAge 中最重要的工人分配机制。从游戏机制角度看,dotAge 更像是电子化的 Agricola (2007) 农场主。因为农场主在桌游玩家中太经典,几乎所有桌游玩家都玩过,这里就不多作介绍了。虚空陨落(voidfall)则是一个比较新的游戏,值得简单讲一下。它没有官方电子版,但在 Tabletop Simulator 中有 mod 可以玩。
和 dotAge 的四个领域有点类似,voidfall 中玩家有军事、经济、科技、政治四个方向的议程可以选择。获得对应的议程卡后,就可以大致确定一个得分路线。不同的路线同时影响着玩家当局游戏的游戏过程。
桌游的流程不会设计的太长,在 voidfall 中只设计了三个阶段,每个阶段有一张事件卡,引导玩家的得分手段。这些事件的效果是可预测的,这和 dotAge 的预言很像。三个阶段也和 dotAge 的季节交替末日来临类似:用规则控制游戏节奏,明确的区分游戏不同阶段要作的事情。一开始生产建设、然后扩张战斗、最后将得分最大化。
我没有特别仔细的玩这个游戏,但从粗浅的游戏体验看,还是颇为喜欢的。过几天会多试试。
我对“确定性战斗机制”这点其实没有特别的偏爱。基于骰子的风险管理机制也很喜欢。
前两年就特别关注过 ISS Vanguard (2022) 这个游戏。最近又(在 Tabletop Simulator 上)玩了一下 Robinson Crusoe: Adventures on the Cursed Island (2012) 和 Civolution (2024) 。这几个游戏都特别重,几句话比较难说清楚,而且我游戏时长也不多,这里就不展开了。
顺便说一句,同样是鲁宾逊的荒岛求生题材的单人桌游 Friday (2011) 是一个非常不错的轻量游戏。如果不想花太多时间在重度游戏上,它非常值得一玩。这是一款及其特别的卡牌构筑类游戏,整个游戏机制不多见的把重点放在卡组瘦身上:即玩家更多考虑的是如何有效的把初始卡组中效率低效的卡精简掉。
游戏上手容易,大约花 5 分钟就能读完规则;设置成本极低,只使用一组卡片;但却颇有难度,我差不多在玩了 20 盘之后才找到胜利的诀窍。淘宝上就可以买到中文版(中文名:星期五),推荐一试。