阅读视图

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

苹果谷歌纷纷调低官方抽成,苹果谷歌全球抽成比例汇总

一、苹果中国区抽成“紧急”下调

2026年3月12日,苹果突然宣布中国区AppStore官方抽成从 30% 改为 25%,小型开发者抽成从15% 改为 12%2026年3月15日生效来源

想必,今天大家都被这个截图刷屏了吧。

图片.png

为什么说“紧急”呢?
1、“根据与中国监管部门的沟通”,写得很清楚,是中国监管部门推动的;
2、“自3月15日起”,约等于立刻生效,对比谷歌的三个月后生效,凸显一个“急”;
3、“调整无需开发者在此之前签署新条款”,手续流程都免了,直接生效!
2、“更新版协议的简体中文版将于一个月内在 Apple开发者网站上线”,流程后面再补,先上线!

不知道苹果发生了什么,但是感觉很爽。有种苹果被工信部发了违规整改通知的感觉(DDDD),让苹果也尝尝工信部的厉害,马上整改,立刻上线!哈哈哈。

中国开发者什么都不用做,代码都不用改,就额外增(bai)加(piao) 3%~5% 的收益。

感谢那些为此做出贡献的人!

补充:有律师说出了苹果紧急“降税”的真相 ,有兴趣的可以点开看看。

二、谷歌将陆续降低全球抽成并开放三方支付

苹果紧急降低抽成除了迫于监管压力,估计也迫于竞争对手的压力。

早在3月4日,谷歌在安卓开发者网站发布了一篇博客《选择和开放的新时代》宣布将陆续在全球降低抽成,开放第三方支付,并且后续除了《小型开发者计划》外,还会新推出《应用体验计划》和《游戏升级计划》来让利开发者

《应用体验计划》《游戏升级计划》的本质:质量换费率。通过经济激励(降低费率)来引导开发者提升应用和游戏的整体品质。开发者必须达到相应的技术集成和体验标准,来满足计划条件,才能获得费率减免。举例说明,比如,游戏类必须集成 Play Games Services 功能(如成就系统、现代玩家个人资料认证)。Play Console 中的“Android Vitals”指标,确保应用在崩溃率、ANR(无响应)率等方面符合谷歌的健康度标准。

计划的具体内容,谷歌尚未公布。

谷歌将现有的抽成拆成了两部分:
Google商店服务费:标准20%、参加上述新计划15%、小型开发者10%、订阅10%(取最小值)
Google支付服务费:约5%(每个地区可能不一样)

在美国、英国和欧洲经济区 (EEA),支付服务费为 5%。其他地区的支付服务费详情谷歌后续公布。

商店服务费,只要你在谷歌商店上架就要交,不管你用谷歌支付还是三方支付;
支付服务费,用谷歌支付就要交,用三方支付不交。

谷歌最终抽成比例:
官方支付抽成:15%~25%
三方支付抽成:10%~20%

谷歌新政策全球上线后,官方支付和三方支付只差5%,三方支付还得加上3%左右的通道费,和官方支付相比,三方支付毫无竞争力,这也是为什么谷歌敢在全球开放三方支付的原因。

需要注意的是,这次费率变化并非即刻生效,而是将分时间、逐步在全球不同地区推广:

各区域的推出日期 地区 《应用体验计划》《游戏升级计划》上线地区
2026年6月30日 欧洲经济区、英国、美国  
2026年9月30日 澳大利亚 澳大利亚、欧洲经济区、英国、美国
2026年12月31日 日本、韩国 日本、韩国
2027年9月30日 世界其他地区 世界其他地区

三、苹果、谷歌全球抽成比例汇总

目前,谷歌和苹果,在全球都面临着反垄断、三方支付、三方商店的压力,革命一旦发起,就像星星之火一样会传递到全世界,一会这个国家闹,一会那个国家闹。面对这样的情况,谷歌和苹果却走出了不一样的应对路数。

1、谷歌全球统一标准

谷歌,将在2026年到2027年陆续在全球执行统一的新标准,开放三方支付、开放三方商店。

Google商店服务费:标准20%、参加上述新计划15%、小型开发者10%、订阅10%(取最小值)
Google支付服务费:约5%(每个地区可能不一样)

官方支付抽成:15%~25%
三方支付抽成:10%~20%

全球实行时间线:

各区域的推出日期 地区 《应用体验计划》《游戏升级计划》上线地区
2026年6月30日 欧洲经济区、英国、美国  
2026年9月30日 澳大利亚 澳大利亚、欧洲经济区、英国、美国
2026年12月31日 日本、韩国 日本、韩国
2027年9月30日 世界其他地区 世界其他地区

2、苹果按闹施政

从目前来看,苹果是按闹施政,谁闹我就便宜点,不闹就维持原样。但感觉不是长久之计,说不定苹果后续也会像谷歌那样统一标准。目前情况来看,谷歌还是眼光更长远一些,走在了前面,胸襟更大。

以下是苹果当前(2026.3.13)全球费率情况

地区 官方内购参考佣金 三方支付苹果抽成 备注
欧盟 13% - 20%,官方文档 15%~20% 欧盟计费很复杂,还会按安装量抽成
日本 15% - 26%,官方文档 10%~15%,外部链接购买 10%~21%,应第三方购买  
韩国 15% ~ 30% 11% ~ 26%  
美国 15% - 30% 0%,外部链接购买 海外公司可以申请;必须同时提供内购作为备选;仍然向苹果上报收入用于审计
中国 12% ~ 25%,官方文档 不允许三方支付  
其它 15% ~ 30% 不允许三方支付

和谷歌一样,苹果也把抽成拆了商店服务费+支付服务费,从上表可以看到三方支付和官方支付比也没有优势。

美国外链支付比较特殊,可以做到0%费率,但同样要满足三方支付的苛刻条件:必须接入官方内购作为备选、有苹果警告弹窗、仍然需要上报三方收入给苹果审计。

如果你对三方支付感兴趣可以看看我往期文档《三方支付真的香吗?日本iOS、Google三方支付调研报告 》,这篇虽然讲得是日本,但三方支付的接入流程和要求,全球都是一样的。

Flutter的状态管理工具

一、Provider

1.原理

Provider 本质上是基于 Flutter 的InheritedWidget 实现的,核心思想是数据自上而下传递,形成一个「数据提供者 - 消费者」的树形结构。

2、使用示例

2.1 定义可监听的状态模型(继承 ChangeNotifier) 核心:数据变化时调用 notifyListeners() 通知组件刷新


class LoginStatusModel extends ChangeNotifier {
  bool _isLogin = false;

  bool get isLogin => _isLogin;

  void updateLoginStatus(bool isLogin) {
    _isLogin = isLogin;
    notifyListeners();  // 关键:通知所有订阅的组件刷新
  }
}

2.2 使用 Provider或其子类,包裹 App实例,并将 状态模型实例作为值传递

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => LoginStatusModel(), 
      child: const MyApp()、
    ),
  );
}

2.3 使用状态数据,在需要监听数据变化的Widget中,使用Provider.of、Consumer获取数据:

  @override
  Widget build(BuildContext context) {
    // 使用 Consumer 监听 CounterModel
    return Consumer<LoginStatusModel>(
      builder: (context, loginStatus, child) {
        return Text('${loginStatus.isLogin}');
      },
    );
  }
}

// 也可以使用  Provider.of() 来获取:
Text('${Provider.of<LoginStatusModel>(context, listen: false).isLogin}')
特性 Provider.of<T>(context) Consumer<T> context.watch<T>() (推荐)
主要用途 灵活获取,常用于非 build 方法中 build 中获取并直接构建子 Widget build 中获取数据用于逻辑判断或属性赋值
是否监听变化 取决于 listen 参数 (默认 true) 是 (自动监听) 是 (自动监听)
代码位置 任意位置 (build 内/外,异步方法中) 只能在 build 方法的 return 树中 只能在 build 方法体内 (return 之前)
是否需要 builder 不需要 需要 (builder 回调) 不需要
典型场景 按钮点击事件、定时器、初始化逻辑 需要根据数据动态生成整个 Widget 时 需要根据数据决定 Widget 的属性 (颜色、文本) 时
性能优化 可设置 listen: false 避免不必要重绘 仅重建 Consumer 及其子节点 重建当前 Widget

A. Provider.of<T>(context)

这是最原始的方法。它的关键在于第二个参数 listen

  • listen: true (默认)

    • 行为:监听数据变化。如果数据变了,当前 Widget 会重建
    • 限制:只能在 build 方法中使用(因为重建需要触发 build)。
    • 缺点:如果在 build 中用默认值,会导致整个父 Widget 重绘,不够精细。
  • listen: false (常用)

    • 行为:不监听数据变化。只获取当前的实例对象。
    • 场景:在事件回调(如 onPressed)、initState、或者异步方法中调用修改数据的方法(如 increment())。
    • 优势:不会因为数据变化导致当前 Widget 无谓重绘。
Widget build(BuildContext context) {
  // ✅ 获取数据 (自动监听)
  final counter = context.watch<CounterModel>(); 
  
  // 可以在这里做逻辑处理
  final color = counter.count > 10 ? Colors.red : Colors.green;
  final text = counter.count > 10 ? '太多了!' : '正常';

  return Column(
    children: [
      // 使用处理后的数据
      Text(text, style: TextStyle(color: color)),
      
      // 按钮事件 (必须用 read 或 Provider.of(..., listen: false))
      ElevatedButton(
        onPressed: () => context.read<CounterModel>().increment(),
        child: Text('增加'),
      )
    ],
  );
}

B. Consumer 是一个 Widget。它的作用是将“获取数据”和“构建 UI”合二为一。

  • 特点:它提供了一个 builder 函数。只有当数据变化时,只有这个 Consumer 节点及其子节点会重绘,它的父兄弟节点不会重绘。
  • 场景:当你需要根据数据直接返回一个新的 Widget 结构时。

// ✅ 场景:只想让这段文字区域刷新,不影响周围的布局

Consumer<CounterModel>(
  builder: (context, counter, child) {
    // counter 就是 CounterModel 实例
    return Text(
      '当前计数: ${counter.count}',
      style: TextStyle(fontSize: 24, color: Colors.blue),
    );
  },
  // child 参数可用于优化:传递不变的子组件,避免每次重绘都重建它
  // child: Icon(Icons.star), 
)

C. context.watch<T>() —— Consumer 的语法糖 (现代推荐)

这是 provider 6.0+ 版本后最推荐的写法。它等价于 Provider.of<T>(context, listen: true),但写法更简洁。

  • 特点:直接在 build 方法体中使用,返回数据对象。
  • 场景:当你需要在 build 方法中获取数据,用来计算属性、做条件判断,或者组合多个数据源时。
  • 注意:调用 watch 的代码所在的 整个 Widget 的 build 方法 会在数据变化时重跑。如果该 Widget 很大,可能不如 Consumer 精准。
Widget build(BuildContext context) {
  // ✅ 获取数据 (自动监听)
  final counter = context.watch<CounterModel>(); 
  
  // 可以在这里做逻辑处理
  final color = counter.count > 10 ? Colors.red : Colors.green;
  final text = counter.count > 10 ? '太多了!' : '正常';

  return Column(
    children: [
      // 使用处理后的数据
      Text(text, style: TextStyle(color: color)),
      
      // 按钮事件 (必须用 read 或 Provider.of(..., listen: false))
      ElevatedButton(
        onPressed: () => context.read<CounterModel>().increment(),
        child: Text('增加'),
      )
    ],
  );
}

总结使用口诀

  • 改数据 (按钮/事件) ➡️ 用 read (或 of(..., listen: false))
  • 显数据 (局部刷新) ➡️ 用 Consumer
  • 显数据 (简单逻辑) ➡️ 用 watch
  • 初始化 (生命周期) ➡️ 用 of(..., listen: false)

二、RiverPod

1.原理

简洁表达:

  • 中心化管理:通过 ProviderContainerProviderScope)统一管理所有状态,状态封装在 Provider 中,脱离 Widget 上下文;

  • 精准订阅分发:基于 Ref 实现 Widget/Provider 对状态的订阅,状态变化时仅通知订阅者,最小化重建;

  • 无上下文 + 类型安全:解决了传统 Provider 的核心痛点,同时通过静态类型检查提升开发效率。

Ref (通常通过 WidgetRef 在 UI 中使用) 是整个状态管理系统的核心控制器上下文对象,是widget和provider,Provider和Provider沟通的唯一桥梁。

2、使用示例

  • 必须使用 ProviderScope 包裹整个应用。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    const ProviderScope( // 👈 必须包裹这里
      child: MyApp(),
    ),
  );
}
2.1 简单状态:@riverpod (替代 StateProvider)
// counter_provider.dart 文件,供后续订阅分发使用
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 使用 @riverpod 注解,运行 build_runner 后会自动生成 CounterProvider
@riverpod
class Counter extends _$Counter {
  @override
  int build() {
    // 初始值
    return 0;
  }

  // 定义修改状态的方法
  void increment() {
    state++; // 👈 直接修改 state 属性,自动通知监听者
  }

  void reset() {
    state = 0;
  }
}

2.2 复杂状态:AsyncNotifier (替代 FutureProvider + StateNotifier)

用于处理异步操作(如网络请求)并管理复杂状态。这是 Riverpod 最强大的部分。

import 'package:flutter_riverpod/flutter_riverpod.dart';

// 模拟用户模型
class User {
  final String name;
  final int age;
  User({required this.name, required this.age});
}

// 定义 AsyncNotifier
@riverpod
class CurrentUser extends _$CurrentUser {
  @override
  Future<User> build() async {
    // 模拟网络延迟
    await Future.delayed(const Duration(seconds: 2));
    
    // 模拟可能发生的错误
    // if (someCondition) throw Exception("Failed to load");

    return User(name: "Alice", age: 25);
  }

  // 修改用户信息的方法
  Future<void> updateAge(int newAge) async {
    state = const AsyncValue.loading(); // 手动设置加载状态
    
    try {
      await Future.delayed(const Duration(seconds: 1)); // 模拟 API 调用
      final user = state.value!; // 获取旧数据
      state = AsyncValue.data(User(name: user.name, age: newAge)); // 更新数据
    } catch (e, st) {
      state = AsyncValue.error(e, st); // 处理错误
    }
  }
}
2.3 组合状态:派生数据 (Derived State)

在一个 Provider 中读取另一个 Provider,实现数据联动。

@riverpod
String userNameRef(UserNameRef ref) {
  // 监听 CurrentUser Provider
  final userAsync = ref.watch(currentUserProvider);

  // 处理异步状态
  return userAsync.when(
    data: (user) => user.name,
    loading: () => "加载中...",
    error: (_, __) => "加载失败",
  );
}
A. 使用 ConsumerWidget (推荐)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 继承 ConsumerWidget
class HomePage extends ConsumerWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. 监听简单状态 (Counter)
    // ref.watch 会自动订阅,数据变化时重建此 Widget
    final count = ref.watch(counterProvider); 

    // 2. 监听异步状态 (CurrentUser)
    final userAsync = ref.watch(currentUserProvider);

    return Scaffold(
      appBar: AppBar(title: const Text("Riverpod Demo")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // 显示异步用户数据
            userAsync.when(
              data: (user) => Text("你好, ${user.name} (年龄: ${user.age})"),
              loading: () => const CircularProgressIndicator(),
              error: (err, stack) => Text("错误: $err"),
            ),
            
            const SizedBox(height: 20),

            // 显示计数
            Text("计数: $count", style: const TextStyle(fontSize: 24)),
            
            const SizedBox(height: 20),

            // 3. 修改状态 (使用 ref.read 或 ref.notifier)
            ElevatedButton(
              onPressed: () {
                // 方式 A: 直接调用生成的 notifier 方法 (推荐)
                ref.read(counterProvider.notifier).increment();
                
                // 方式 B: 如果是 AsyncNotifier
                // ref.read(currentUserProvider.notifier).updateAge(26);
              },
              child: const Text("增加计数"),
            ),
            
            ElevatedButton(
              onPressed: () {
                // 触发异步更新
                ref.read(currentUserProvider.notifier).updateAge(30);
              },
              child: const Text("更新用户年龄 (异步)"),
            ),
          ],
        ),
      ),
    );
  }
}

B. 在非 Widget 类中使用 (Riverpod 的杀手锏)

由于不依赖 Context,你可以在任何地方(如路由守卫、服务类、甚至 main 函数之后)访问状态。

// 例如:在一个普通的 Dart 类中
class AnalyticsService {
  final Ref ref; // 注入 Ref

  AnalyticsService(this.ref);

  void logCount() {
    // 直接读取当前值,不订阅变化 (类似 listen: false)
    final currentCount = ref.read(counterProvider);
    print("当前计数是: $currentCount");
  }
  
  void subscribeToCount() {
    // 也可以手动监听变化
    ref.listen(counterProvider, (previous, next) {
      print("计数从 $previous 变成了 $next");
    });
  }
}

特性 Provider (旧) Riverpod (新)
依赖 Context ✅ 强依赖 (BuildContext) ❌ 无依赖 (WidgetRefRef)
类型安全 ⚠️ 运行时检查 (容易崩溃) ✅ 编译时检查 (配合代码生成)
异步支持 🆗 需要 FutureProvider 🚀 原生强大 (AsyncValue, when)
状态组合 😐 较难,容易嵌套地狱 🤩 极简 (ref.watch 其他 Provider)
测试难度 😫 需要 Mock Context 😃 极易 (直接创建 ProviderContainer)
代码量 多 (样板代码) 少 (配合 @riverpod 宏)
学习曲线 低 (但精通难) 中 (概念多,但逻辑清晰)
  1. 始终使用代码生成 (@riverpod) :不要手动编写 Provider(...),让宏帮你处理类型安全和样板代码。

  2. 拆分小 Provider:不要试图用一个 Provider 管理所有状态。将计数器、用户信息、主题设置拆分成不同的 Provider,然后按需组合。

  3. 善用 AsyncValue:处理异步数据时,利用 .when() 方法优雅地处理 loadingdataerror 三种状态,避免大量的 if/else 判断。

  4. 区分 watch 和 read

    • 在 build 方法中需要重建 UI时用 ref.watch
    • 事件回调(如按钮点击)中修改数据时用 ref.read(...).notifier
    • 非 build 环境(如服务类)中用 ref.read 或 ref.listen

以下是自己的理解修正:

  1. watch:是订阅者。它监听 Provider 的数据变化,一旦变化,自动触发 UI 刷新(或 Provider 重算)。

  2. read:是获取动作

    • 事件回调(如按钮点击)中,我们使用 ref.read(provider.notifier) 来获取控制器,然后调用它的方法来修改数据
    • 修改数据后,Riverpod 会自动通知所有 watch 该数据的地方进行刷新。
  3. notifier:是控制器(遥控器)。它持有修改数据的方法(如 increment)。

你的目的 应该用什么? 结果
显示数据 (Text, Image, List) ref.watch() 数据变,UI 自动刷新 ✅
按钮点击/手势 (修改数据) ref.read(...).notifier 获取控制器,修改数据 ✅
按钮点击/手势 (读取参数) ref.read() 获取当前值,用于逻辑判断 ✅
定时器/异步回调 ref.read() 获取最新值,避免闭包旧值 ✅
纯 Dart 类/服务 ref.read() 访问全局状态 ✅
Build 中显示数据 ref.read() ❌ 界面不会更新 (Bug)

Swift 从入门到精通-第三篇

第9章:协议与扩展

9.1 协议 (Protocol) - 定义接口

协议定义了一组方法、属性或其他要求的蓝图:

protocol Greetable {
    var name: String { get }  // 只读属性要求
    func greet()              // 方法要求
}

protocol Describable {
    var description: String { get }
}

遵循协议:

struct User: Greetable, Describable {
    let name: String
    let email: String
    
    // 必须实现协议要求
    func greet() {
        print("Hi, I'm (name)!")
    }
    
    var description: String {
        return "User: (name) <(email)>"
    }
}

let user = User(name: "Alice", email: "alice@example.com")
user.greet()        // Hi, I'm Alice!
print(user.description)

9.2 协议扩展 - 提供默认实现

// 在扩展中提供默认实现
extension Greetable {
    func greet() {
        print("Hello, my name is (name)")
    }
}

struct Employee: Greetable {
    let name: String
    // 不需要实现 greet(),使用默认实现
}

let employee = Employee(name: "Bob")
employee.greet()  // Hello, my name is Bob

协议扩展的强大之处:

  • 可以给协议添加默认行为
  • 遵循者可以选择使用默认实现或自定义
  • 这是"面向协议编程"的核心

9.3 协议组合

protocol Named {
    var name: String { get }
}

protocol Aged {
    var age: Int { get }
}

struct Person: Named, Aged {
    var name: String
    var age: Int
}

// 函数接受同时遵循多个协议的类型
func wishHappyBirthday(to celebrator: Named & Aged) {
    print("Happy birthday (celebrator.name), you're (celebrator.age)!")
}

let person = Person(name: "Malcolm", age: 21)
wishHappyBirthday(to: person)

9.4 带关联类型的协议

protocol Container {
    associatedtype Item  // 关联类型
    var count: Int { get }
    mutating func append(_ item: Item)
    subscript(i: Int) -> Item { get }
}

struct IntStack: Container {
    // 自动推断 Item = Int
    private var items: [Int] = []
    
    var count: Int { items.count }
    
    mutating func append(_ item: Int) {
        items.append(item)
    }
    
    subscript(i: Int) -> Int {
        return items[i]
    }
}

// 也可以显式指定
double Stack: Container {
    typealias Item = Double  // 显式指定
    // ...
}

9.5 扩展系统类型

// 给 Int 添加方法
extension Int {
    var squared: Int {
        return self * self
    }
    
    func times(_ action: () -> Void) {
        for _ in 0..<self {
            action()
        }
    }
}

print(5.squared)  // 25
3.times {
    print("Hello!")
}
// Hello!
// Hello!
// Hello!

// 给 Collection 添加方法
extension Collection {
    var isNotEmpty: Bool {
        return !isEmpty
    }
}

[1, 2, 3].isNotEmpty  // true

第10章:泛型编程

10.1 为什么要用泛型?

想象你要写交换两个值的函数:

// 只能交换整数
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temporaryA = a
    a = b
    b = temporaryA
}

// 只能交换字符串
func swapTwoStrings(_ a: inout String, _ b: inout String) {
    let temporaryA = a
    a = b
    b = temporaryA
}

重复代码!用泛型可以写一个通用的:

func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temporaryA = a
    a = b
    b = temporaryA
}

var someInt = 3
var anotherInt = 107
swapTwoValues(&someInt, &anotherInt)

var someString = "hello"
var anotherString = "world"
swapTwoValues(&someString, &anotherString)

<T>类型参数,代表任意类型。

10.2 泛型类型

// 泛型栈
struct Stack<Element> {
    private var items: [Element] = []
    
    mutating func push(_ item: Element) {
        items.append(item)
    }
    
    mutating func pop() -> Element? {
        return items.popLast()
    }
    
    var topItem: Element? {
        return items.last
    }
    
    var isEmpty: Bool {
        return items.isEmpty
    }
}

var intStack = Stack<Int>()
intStack.push(1)
intStack.push(2)
print(intStack.pop()!)  // 2

var stringStack = Stack<String>()
stringStack.push("hello")
stringStack.push("world")

10.3 类型约束

// 要求 T 必须遵循 Comparable 协议
func findIndex<T: Equatable>(of valueToFind: T, in array: [T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}

let doubleIndex = findIndex(of: 9.3, in: [3.14159, 0.1, 0.25])
let stringIndex = findIndex(of: "Andrea", in: ["Mike", "Malcolm", "Andrea"])

常用类型约束:

  • T: Equatable - 可以比较相等
  • T: Comparable - 可以比较大小
  • T: Hashable - 可以作为字典的 key
  • T: SomeProtocol - 遵循某个协议

10.4 关联类型与 where 子句

protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}

// 要求两个容器的 Item 相同且可比较
func allItemsMatch<C1: Container, C2: Container>(_ container1: C1, _ container2: C2) -> Bool
    where C1.Item == C2.Item, C1.Item: Equatable {
    
    if container1.count != container2.count {
        return false
    }
    
    for i in 0..<container1.count {
        if container1[i] != container2[i] {
            return false
        }
    }
    return true
}

10.5 不透明返回类型 (some)

protocol Shape {
    func draw() -> String
}

struct Triangle: Shape {
    var size: Int
    func draw() -> String {
        var result = [String]()
        for length in 1...size {
            result.append(String(repeating: "*", count: length))
        }
        return result.joined(separator: "\n")
    }
}

struct Square: Shape {
    var size: Int
    func draw() -> String {
        let line = String(repeating: "*", count: size)
        return Array(repeating: line, count: size).joined(separator: "\n")
    }
}

// 返回遵循 Shape 协议的某种类型,但隐藏具体是什么
func makeShape() -> some Shape {
    return Triangle(size: 3)
}

// 也可以返回不同的,只要都是 Shape
func makeRandomShape() -> some Shape {
    Bool.random() ? Triangle(size: 3) : Square(size: 3)
}

some 的好处:

  • 调用者不需要知道具体类型
  • 编译器可以进行类型优化
  • 在 SwiftUI 中非常常用(some View

第11章:错误处理

11.1 定义错误类型

enum VendingMachineError: Error {
    case invalidSelection                    // 选择无效
    case insufficientFunds(coinsNeeded: Int) // 金额不足,附带需要多少钱
    case outOfStock                          // 缺货
}

任何遵循 Error 协议的类型都可以表示错误。

11.2 抛出错误

class VendingMachine {
    var inventory = [
        "Candy Bar": Item(price: 12, count: 7),
        "Chips": Item(price: 10, count: 4),
        "Pretzels": Item(price: 7, count: 11)
    ]
    var coinsDeposited = 0
    
    func vend(itemNamed name: String) throws {
        guard let item = inventory[name] else {
            throw VendingMachineError.invalidSelection
        }
        
        guard item.count > 0 else {
            throw VendingMachineError.outOfStock
        }
        
        guard item.price <= coinsDeposited else {
            throw VendingMachineError.insufficientFunds(coinsNeeded: item.price - coinsDeposited)
        }
        
        // 执行购买逻辑
        coinsDeposited -= item.price
        var newItem = item
        newItem.count -= 1
        inventory[name] = newItem
        
        print("Dispensing (name)")
    }
}

throws 标记表示这个函数可能抛出错误。

11.3 处理错误

do-catch

let vendingMachine = VendingMachine()
vendingMachine.depositCoins(8)

do {
    try vendingMachine.vend(itemNamed: "Candy Bar")
} catch VendingMachineError.invalidSelection {
    print("Invalid selection.")
} catch VendingMachineError.outOfStock {
    print("Out of stock.")
} catch VendingMachineError.insufficientFunds(let coinsNeeded) {
    print("Insufficient funds. Please insert an additional (coinsNeeded) coins.")
} catch {
    print("Unexpected error: (error)")
}

try? - 转换为可选值

// 成功返回结果,失败返回 nil
if let result = try? someThrowingFunction() {
    // 使用结果
} else {
    // 处理失败
}

// 等效于
do {
    let result = try someThrowingFunction()
} catch {
    // 忽略错误
}

try! - 强制解包(危险!)

// 确定不会失败时使用
let result = try! someThrowingFunction()
// 如果失败了,程序会崩溃

11.4 defer - 清理资源

func processFile(filename: String) throws -> String {
    let file = try openFile(filename)
    
    defer {
        closeFile(file)  // 无论如何都会执行
    }
    
    if filename.isEmpty {
        throw FileError.invalidName  // 先执行 defer,再抛出错误
    }
    
    return try readFile(file)
}  // 正常返回也会执行 defer

11.5 Result 类型

enum NetworkError: Error {
    case badURL
    case noData
    case decodingError
}

func fetchUser(completion: (Result<User, NetworkError>) -> Void) {
    // 模拟网络请求
    let success = Bool.random()
    if success {
        completion(.success(User(name: "Alice")))
    } else {
        completion(.failure(.noData))
    }
}

// 使用
fetchUser { result in
    switch result {
    case .success(let user):
        print("Got user: (user.name)")
    case .failure(let error):
        print("Error: (error)")
    }
}

第12章:内存管理

12.1 ARC (自动引用计数)

Swift 使用 ARC 自动管理内存:

  • 每次创建实例,引用计数 +1
  • 每次引用消失,引用计数 -1
  • 引用计数为 0,内存被释放

你不需要手动管理,但要理解引用关系。

12.2 强引用循环问题

class Person {
    let name: String
    var apartment: Apartment?  // 强引用
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("(name) is being deinitialized")
    }
}

class Apartment {
    let unit: String
    var tenant: Person?  // 强引用
    
    init(unit: String) {
        self.unit = unit
    }
    
    deinit {
        print("Apartment (unit) is being deinitialized")
    }
}

var john: Person? = Person(name: "John Appleseed")
var unit4A: Apartment? = Apartment(unit: "4A")

john!.apartment = unit4A
unit4A!.tenant = john

john = nil
unit4A = nil
// 没有打印 deinit 消息!内存泄漏了!

问题: Person 持有 Apartment,Apartment 持有 Person,形成一个环,引用计数永远不会归零。

12.3 弱引用 (Weak Reference)

class Apartment {
    let unit: String
    weak var tenant: Person?  // 弱引用!
    
    init(unit: String) {
        self.unit = unit
    }
    
    deinit {
        print("Apartment (unit) is being deinitialized")
    }
}

// 现在当 john = nil,Person 实例会被释放
// 然后 Apartment 的 tenant 自动变成 nil

弱引用的特点:

  • 不增加引用计数
  • 指向的实例释放后自动变成 nil
  • 必须是可选类型(因为可能为 nil)

什么时候用弱引用?

  • 父子关系中的子(如 Apartment 和 tenant)
  • 委托模式 (Delegate pattern)

12.4 无主引用 (Unowned Reference)

class Customer {
    let name: String
    var card: CreditCard?
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("(name) is being deinitialized")
    }
}

class CreditCard {
    let number: UInt64
    unowned let customer: Customer  // 无主引用
    
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
    }
    
    deinit {
        print("Card #(number) is being deinitialized")
    }
}

var alice: Customer? = Customer(name: "Alice")
alice!.card = CreditCard(number: 1234_5678_9012_3456, customer: alice!)

alice = nil
// 两个实例都被释放

无主引用的特点:

  • 不增加引用计数
  • 不是可选类型
  • 指向的实例释放后变成 dangling pointer(悬挂指针)

什么时候用无主引用?

  • 确定引用的实例永远比自己活得长
  • 不会造成循环引用的强关系

12.5 闭包中的循环引用

class HTMLElement {
    let name: String
    let text: String?
    
    // 闭包捕获 self,形成循环引用!
    lazy var asHTML: () -> String = {
        if let text = self.text {
            return "<(self.name)>(text)</(self.name)>"
        } else {
            return "<(self.name) />"
        }
    }
    
    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
    }
    
    deinit {
        print("(name) is being deinitialized")
    }
}

var paragraph: HTMLElement? = HTMLElement(name: "p", text: "Hello")
let html = paragraph!.asHTML()
paragraph = nil
// 没有 deinit!循环引用!

解决:捕获列表

lazy var asHTML: () -> String = { [weak self] in
    guard let self = self else {
        return ""
    }
    if let text = self.text {
        return "<(self.name)>(text)</(self.name)>"
    } else {
        return "<(self.name) />"
    }
}

捕获列表语法:

  • [weak self] - 弱引用 self
  • [unowned self] - 无主引用 self
  • [x = someValue] - 捕获时复制值而不是引用

Buildable Folder & Group & Folder Reference in Xcode

深入理解代替单纯记忆

问题背景

  • 在开发iOS项目时,希望将一堆图片资源放入Main Bundle中,但又不希望资源在Bundle的最顶层目录中,希望自定义目录
  • 但一时想不到该如何解决,于是想到FolderGroup等概念
  • 经过简单搜索后,发现Xcode对于这两个概念的定义还是有些差异的
  • 于是继续查阅学习了一番,编写本文,方便后续查阅和分享

本文提到的内容,参考的Xcode版本为26.0(17A324)和26.3(17C529)

Buildable Folder

  • Buildable Folder是自Xcode 16(2024年6月)引入的概念,初衷是为了减少代码管理中的冲突问题
  • 后续新建的工程或者新建Folder时,默认都是Buildable Folder

官方原文如下:

Minimize project file changes and avoid conflicts with buildable folder references. Convert an existing group to a buildable folder with the Convert to Folder context menu item in the Project Navigator. Buildable folders only record the folder path into the project file without enumerating the contained files, minimizing diffs to the project when your team adds or removes files, and avoiding source control conflicts. To use a folder as an opaque copiable resource, the default behavior before Xcode 16, uncheck the Build Folder Contents option in the File Inspector.

Buildable Folder如何降低代码冲突

  1. 先添加1个普通Group--BuildableFolderTest,project文件的变化如下所示: image.png

  2. 然后向BuildableFolderTest Group中添加ABC.swift文件后,project文件的变化如下: image.pngimage.png

    这说明Group目录下的文件,都要在project文件中进行记录

  3. 继续,将BuildableFolderTest Group通过Convert to Folder选项转为Folder(Buildable Folder)后,project文件的变化如下: image.pngimage.png

  4. 然后再向BuildableFolderTest这个Folder中添加DEF.swift文件后,发现project文件没有任何变化

所以,project文件仅记录了Folder自身,至于目录中的文件是不会记录在project文件中,所以会减少因团队多人同时修改Project文件导致的代码冲突

Apply to Each File vs Apply Once to Folder

当创建Folder(Buildable Folder)后,选中Folder,在File inspector中会看到有个Build Rule,有两个选择:Apply to Each FileApply Once to Folder,默认是Apply to Each File

image.png

Apply Once to Folder

Apply Once to Folder开启后,project文件是什么样?

image.pngimage.pngimage.png

当开启该模式时,通过查看目录下的每个文件可以看出,文件是没有Target归属的概念的。同样,在该目录下创建新文件也不需要选择Target

再配合Xcode Buildable Folders中所提到的To use a folder as an opaque copiable resource, the default behavior before Xcode 16, uncheck the Build Folder Contents option in the File Inspector.

其实,Apply Once to Folder就是Xcode 16之前的Folder,之前叫Folder Reference (在Xcode 16之前,创建Folder时,官方名称就叫做Folder Reference)

  • Folder Reference一般是用作资源包,目录下不包含源代码
  • 另一个Folder Reference重要作用是可以在Bundle中自定义目录

Buildable Folder vs Folder Reference

Buildable Folder顾名思义,其中的内容是由编译系统参与的

  • 所以Buildable Folder中可以放源代码文件,并可以参与编译,打包到最终可执行文件中;也可以制定源文件的Target
  • Folder Reference则保留老的逻辑,不参与编译,用作资源包,即使放入源代码文件也无法选择Target,只能当做普通文件资源处理

Create Group with Folder

同样是在Xcode 16开始的另一个变化是,创建Group时由原来的不自动创建磁盘物理目录(Folder)变为自动创建。当然,仍可以创建没有FolderGroup,原文如下:

Create groups with associated folders by default when using the New Group and New Group from Selection commands in the Project Navigator. To create a group without a folder, hold the Option key in the context menu to reveal the New Group without Folder variant of the command.

[Group without Folder] vs [Group] vs [Folder(Buildable Folder)] vs [Folder Reference]

特性 Group without Folder Group Buildable Folder Folder Reference
Project Navigator 图标 image.png image.png image.png image.png
是否对应磁盘目录 ❌ 不必须 ✅ 必须 ✅ 必须 ✅ 必须
工程结构是否可与磁盘不同 ✅ 可以 ❌ 基本一致 ❌ 必须一致 ❌ 必须一致
.pbxproj 是否记录每个文件 ✅ 会 ✅ 会 ❌ 不会 ❌ 不会
新增文件是否修改 .pbxproj ✅ 会 ✅ 会 ❌ 不会 ❌ 不会
Git 冲突概率
是否参与编译系统
是否自动编译源码 ✅(自动发现目录中的源码)
Bundle 中是否保留目录结构 ❌ 通常不会 ❌ 通常不会 ❌ 通常不会 会保留(如果被加入 Bundle)
默认是否进入 Bundle ❌ 否 ❌ 否 ❌ 否 仅在选中 Target 时自动加入
典型用途 逻辑分组 常规项目结构 源码目录 资源目录
  • 当前(Xcode 26),默认的Group和Folder组合是Group with Folder + Buildable Folder。这可能也意味着这两项是日常最常用的

回答开始的问题

  • 既然是想打包资源放入Bundle,并自定义目录,那必然是Folder Reference

参考

iOS必看!Deepseek给的Runtime实现原理,通俗易懂~

iOS Runtime 消息转发机制完全解析

写在前面

在Objective-C的世界里,方法调用并不是像C++那样在编译时就确定要执行的函数地址,而是一个运行时动态绑定的过程。当我们写下 [receiver message] 这样的代码时,编译器实际上会将其转换为 objc_msgSend(receiver, @selector(message)) 的调用。这个 objc_msgSend 函数会负责在接收者所属的类及其父类的方法列表中查找对应的实现并执行。

那么问题来了:如果一直找到根类NSObject都没有找到这个方法的实现,会发生什么?

很多开发者都见过这样的崩溃信息:unrecognized selector sent to instance 0xXXXXXXXX。这正是因为消息发送失败,而Runtime也没有找到合适的方式处理这条消息,最终通过 doesNotRecognizeSelector: 抛出的异常。

但在这个崩溃发生之前,Objective-C的Runtime给了我们三次"拯救"的机会,这就是本文要详细讲解的消息转发机制


第一章:消息发送机制回顾

在深入探讨消息转发之前,有必要先回顾一下完整的消息发送流程,因为消息转发正是这个流程中处理失败情况的最后保障。

1.1 objc_msgSend的工作流程

当我们向一个对象发送消息时,Runtime系统会按照以下步骤查找方法的实现:

  1. 检查目标对象是否为nil:如果接收者为nil,Objective-C的特性是忽略该消息,程序不会崩溃(这在很多情况下简化了代码逻辑)。如果为nil且消息有返回值,基本数据类型的返回值为0,对象类型的返回值为nil。

  2. 查找缓存:每个类都有一个缓存(cache),用于存储最近使用过的方法。Runtime会首先在该类的缓存中查找方法的实现(IMP)。如果找到,直接调用该实现。

  3. 查找当前类的方法列表:如果在缓存中没有找到,Runtime会从当前类的方法列表中查找。方法列表以数组形式组织,查找过程会遍历整个列表(已排序的列表使用二分查找,否则线性查找)。

  4. 沿着继承链向上查找:如果在当前类中没有找到,Runtime会沿着继承链逐级向上查找父类的方法列表和缓存,直到根类NSObject为止。

  5. 动态方法解析:如果一直找到根类都没有找到方法的实现,Runtime会进入"动态方法解析"阶段,给类一个机会动态添加方法的实现。

  6. 消息转发:如果动态方法解析没有添加实现(或者添加后仍然无法处理),Runtime会进入"消息转发"流程。

  7. 抛出异常:如果所有转发尝试都失败,最终会调用 doesNotRecognizeSelector: 抛出异常,程序崩溃。

这个流程可以用下面的流程图清晰地展示:

flowchart TD
    A[向对象发送消息] --> B{接收者为nil?}
    B -->|是| C[忽略消息/返回0/nil]
    B -->|否| D[查找缓存]
    
    D --> E{缓存中找到IMP?}
    E -->|是| F[调用IMP]
    E -->|否| G[在当前类方法列表中查找]
    
    G --> H{当前类中找到?}
    H -->|是| F
    H -->|否| I[在父类方法列表中查找]
    
    I --> J{父类中找到?}
    J -->|是| F
    J -->|否| I
    
    J -->|一直查到NSObject仍未找到| K[动态方法解析]
    
    K --> L{动态添加了实现?}
    L -->|是| F
    L -->|否| M[消息转发流程]
    
    M --> N{转发成功?}
    N -->|是| F
    N -->|否| O[doesNotRecognizeSelector:\n抛出异常]

1.2 方法的本质:SEL、IMP与Method

要深入理解消息转发,我们需要先了解Objective-C中方法的三个核心概念:

SEL(选择器):是方法的名字,在Runtime中用 objc_selector 结构体表示。在运行时,不同类的同名方法的选择器是相同的。SEL在Runtime中会被唯一化,因此可以使用 == 来比较两个SEL是否相等。

IMP(函数指针):是方法的实现,本质上是一个函数指针,指向方法实现的首地址。它的定义如下:

typedef id (*IMP)(id self, SEL _cmd, ...);

每个IMP都至少包含两个参数:self(消息接收者)和_cmd(这个方法的SEL)。

Method(方法):是用于表示方法定义的结构体,包含三个成员:

struct method_t {
    SEL name;      // 方法名
    const char *types;  // 方法类型编码
    IMP imp;       // 方法实现
}

当我们调用一个方法时,就是从SEL到IMP的映射过程。Runtime维护了每个类的方法列表(method list),这个列表存储了该类定义的所有方法。消息转发机制本质上是在这个映射过程失败后的补救措施。


第二章:消息转发的三个阶段

当消息发送流程无法找到对应的IMP时,Runtime会启动消息转发机制。这个机制分为三个阶段,每个阶段都给开发者一次处理这条"无法识别"的消息的机会。

2.1 第一阶段:动态方法解析

这是消息转发的第一道防线。当Runtime在当前类和父类中都找不到方法的实现时,会首先调用 +resolveInstanceMethod:(对于实例方法)或 +resolveClassMethod:(对于类方法)。

2.1.1 resolveInstanceMethod的工作原理

这个方法的定义如下:

+ (BOOL)resolveInstanceMethod:(SEL)sel

当这个方法被调用时,Runtime给了我们一个机会:可以动态地为这个SEL添加一个实现。如果添加成功并返回YES,Runtime会重新启动消息发送流程,这次就能找到方法的实现了。

这个方法最典型的应用场景是处理 @dynamic 属性。@dynamic 告诉编译器不要自动生成属性的getter和setter方法,我们会在运行时动态提供它们。

2.1.2 实战:动态添加方法实现

让我们通过一个具体的例子来理解这个过程:

#import <objc/runtime.h>

@interface Person : NSObject
@property (nonatomic, copy) NSString *name;  // 注意:我们使用@dynamic
@end

@implementation Person
@dynamic name;  // 告诉编译器不要自动生成getter/setter

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(name)) {
        // 动态添加getter方法
        class_addMethod(self, sel, (IMP)dynamicNameGetter, "@@:");
        return YES;
    }
    else if (sel == @selector(setName:)) {
        // 动态添加setter方法
        class_addMethod(self, sel, (IMP)dynamicNameSetter, "v@:@");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

// getter方法的实现
id dynamicNameGetter(id self, SEL _cmd) {
    // 通过关联对象获取存储的值
    return objc_getAssociatedObject(self, @selector(name));
}

// setter方法的实现
void dynamicNameSetter(id self, SEL _cmd, NSString *newName) {
    // 通过关联对象存储值
    objc_setAssociatedObject(self, @selector(name), newName, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
@end

现在,当我们调用:

Person *p = [[Person alloc] init];
[p setName:@"张三"];
NSLog(@"%@", [p name]);  // 输出:张三

尽管Person类并没有真正实现name的getter和setter方法,但在消息发送过程中,Runtime调用了 resolveInstanceMethod:,我们动态添加了这两个方法的实现,因此程序能够正常运行。

2.1.3 方法签名的类型编码

在调用 class_addMethod 时,我们需要指定方法的类型编码(types)。这个编码字符串描述了方法的返回类型和参数类型。例如:

  • "v@:" 表示返回void,有两个参数:id和SEL(即标准的实例方法)
  • "@@" 表示返回id,有两个参数:id和SEL(标准的getter方法)
  • "v@:@" 表示返回void,有三个参数:id、SEL和id(标准的setter方法)

完整的类型编码表:

编码 含义
c char
i int
s short
l long
q long long
C unsigned char
I unsigned int
S unsigned short
L unsigned long
Q unsigned long long
f float
d double
B BOOL/C++ bool
v void
* char* (字符串)
@ id (对象)
# Class (类对象)
: SEL (选择器)
^type 指向type的指针

2.1.4 类方法的动态解析

对于类方法,我们需要重写 +resolveClassMethod:

+ (BOOL)resolveClassMethod:(SEL)sel {
    if (sel == @selector(classMethod)) {
        // 注意:这里添加方法的目标是元类(metaclass)
        Class metaClass = objc_getMetaClass(class_getName(self));
        class_addMethod(metaClass, sel, (IMP)dynamicClassMethodImp, "v@:");
        return YES;
    }
    return [super resolveClassMethod:sel];
}

需要注意的是,类方法是存储在元类(metaclass)中的,因此我们需要获取元类来添加类方法的实现。

2.1.5 动态方法解析的时机

动态方法解析发生在消息发送流程失败之后,但在消息转发之前。如果你希望每次调用这个方法时都能走动态解析,注意这个方法只会被调用一次(因为一旦添加了实现,后续调用就能直接找到IMP了)。

2.2 第二阶段:快速消息转发

如果动态方法解析没有添加实现(或者返回NO),Runtime会进入消息转发的第二阶段:快速消息转发。

这个阶段的核心是 forwardingTargetForSelector: 方法。Runtime会调用这个方法,期望它能返回一个能够处理这条消息的对象。

2.2.1 forwardingTargetForSelector的定义

- (id)forwardingTargetForSelector:(SEL)aSelector

这个方法的职责是:当对象无法处理某个消息时,返回一个能够处理该消息的对象。Runtime会将原始消息转发给这个返回的对象,就好像它才是原始的消息接收者一样。

这个机制非常高效,因为它只是简单地改变消息的接收者,不需要创建 NSInvocation 对象,也没有复杂的参数处理。

2.2.2 实战:将消息转发给备用对象

假设我们有一个 Person 类,它不包含 run 方法,但我们有一个 Car 类实现了 run 方法:

@interface Car : NSObject
- (void)run;
@end

@implementation Car
- (void)run {
    NSLog(@"Car is running");
}
@end

@interface Person : NSObject
@end

@implementation Person
- (id)forwardingTargetForSelector:(SEL)aSelector {
    if (aSelector == @selector(run)) {
        // 返回一个可以处理run消息的Car对象
        return [[Car alloc] init];
    }
    return [super forwardingTargetForSelector:aSelector];
}
@end

现在执行以下代码:

Person *person = [[Person alloc] init];
[person run];  // 输出:Car is running

尽管 Person 对象并没有 run 方法,但通过 forwardingTargetForSelector:,我们将 run 消息转发给了 Car 对象,程序能够正常运行。

2.2.3 模拟多重继承

Objective-C不支持多重继承,但通过快速消息转发,我们可以实现类似多重继承的效果。一个对象可以将自己没有实现的方法转发给其他对象,从外部看就像这个对象继承了多个类的功能。

例如,我们可以创建一个类,它能够处理来自多个不同类的方法:

@interface MultiClass : NSObject
@property (nonatomic, strong) Car *car;
@property (nonatomic, strong) House *house;
@end

@implementation MultiClass
- (instancetype)init {
    if (self = [super init]) {
        _car = [[Car alloc] init];
        _house = [[House alloc] init];
    }
    return self;
}

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if ([_car respondsToSelector:aSelector]) {
        return _car;
    } else if ([_house respondsToSelector:aSelector]) {
        return _house;
    }
    return [super forwardingTargetForSelector:aSelector];
}
@end

这样,MultiClass 的实例就能同时响应 CarHouse 的方法,达到了类似多重继承的效果。

2.2.4 注意事项

使用 forwardingTargetForSelector: 时有几点需要注意:

  1. 不要返回self:如果在这个方法中返回self,会造成无限循环,因为Runtime会再次尝试向self发送消息。
  2. 这个方法主要用于转发给其他对象,不适合修改消息本身。
  3. 返回的对象不必与原始接收者有继承关系,任何对象都可以。
  4. 如果返回nil或self,则进入下一阶段:完整消息转发。

2.3 第三阶段:完整消息转发

如果前两个阶段都无法处理消息,Runtime会进入最后一个阶段:完整消息转发。这是消息转发机制中最强大、最灵活但也最复杂的阶段。

这个阶段涉及两个方法:

  • methodSignatureForSelector::获取方法的签名(参数类型和返回类型)
  • forwardInvocation::转发封装了消息的 NSInvocation 对象
flowchart TD
    A[消息转发第二阶段返回nil] --> B[调用methodSignatureForSelector:]
    
    B --> C{返回有效的方法签名?}
    C -->|否| D[调用doesNotRecognizeSelector:\n抛出异常]
    C -->|是| E[创建NSInvocation对象]
    
    E --> F[调用forwardInvocation:\n并将NSInvocation传入]
    
    F --> G{在forwardInvocation:中\n处理消息?}
    G -->|否| D
    G -->|是| H[消息处理成功]
    
    H --> I[将返回值传递给\n原始消息发送者]

2.3.1 methodSignatureForSelector: 的作用

methodSignatureForSelector: 的定义如下:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector

Runtime调用这个方法的目的是获取方法的签名信息,包括方法的返回类型和参数类型。有了这些信息,Runtime才能创建 NSInvocation 对象。

如果这个方法返回nil,Runtime会直接调用 doesNotRecognizeSelector: 并抛出异常,程序崩溃。因此,在实现完整消息转发时,我们必须为无法处理的消息提供一个有效的方法签名。

2.3.2 创建方法签名

方法签名可以通过多种方式创建:

// 方式1:使用字符串创建(类型编码)
NSMethodSignature *signature1 = [NSMethodSignature signatureWithObjCTypes:"v@:"];

// 方式2:从已有方法获取
NSMethodSignature *signature2 = [self methodSignatureForSelector:@selector(existingMethod)];

// 方式3:从协议获取
struct objc_method_description desc = protocol_getMethodDescription(protocol, selector, YES, YES);
NSMethodSignature *signature3 = [NSMethodSignature signatureWithObjCTypes:desc.types];

类型编码字符串的格式和之前 class_addMethod 中使用的格式一致。

2.3.3 forwardInvocation: 的核心作用

forwardInvocation: 的定义如下:

- (void)forwardInvocation:(NSInvocation *)anInvocation

methodSignatureForSelector: 返回了有效的方法签名后,Runtime会创建一个 NSInvocation 对象,该对象封装了这条消息的所有信息:

  • 消息的目标(target)
  • 消息的选择器(selector)
  • 所有的参数
  • 等待填充的返回值

然后将这个 NSInvocation 对象作为参数传递给 forwardInvocation: 方法。在这个方法中,我们可以:

  1. 将消息转发给其他对象
  2. 修改消息的选择器、参数或目标
  3. 直接处理消息并设置返回值
  4. 甚至"吃掉"消息,什么都不做(这样就不会崩溃)

2.3.4 实战:完整消息转发的实现

下面是一个完整的示例,演示如何实现完整消息转发:

@interface Person : NSObject
@end

@interface Car : NSObject
- (void)run;
@end

@implementation Car
- (void)run {
    NSLog(@"Car is running");
}
@end

@implementation Person
// 第一步:提供方法签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if (aSelector == @selector(run)) {
        // 返回run方法的签名:"v@:" 表示返回void,两个参数:id, SEL
        return [NSMethodSignature signatureWithObjCTypes:"v@:"];
    }
    return [super methodSignatureForSelector:aSelector];
}

// 第二步:转发调用
- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL selector = [anInvocation selector];
    
    // 创建备用对象
    Car *car = [[Car alloc] init];
    
    // 检查备用对象是否能响应这个选择器
    if ([car respondsToSelector:selector]) {
        // 将消息转发给备用对象
        [anInvocation invokeWithTarget:car];
    } else {
        // 如果备用对象也不能处理,调用父类实现(最终会抛出异常)
        [super forwardInvocation:anInvocation];
    }
}
@end

执行测试代码:

Person *person = [[Person alloc] init];
[person run];  // 输出:Car is running

2.3.5 修改消息内容后转发

完整消息转发的一个强大之处在于,我们可以在转发前修改消息的内容。例如,我们可以修改方法的选择器:

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL originalSelector = [anInvocation selector];
    
    if (originalSelector == @selector(run)) {
        // 修改选择器为drive
        [anInvocation setSelector:@selector(drive)];
        
        Car *car = [[Car alloc] init];
        if ([car respondsToSelector:@selector(drive)]) {
            [anInvocation invokeWithTarget:car];
            return;
        }
    }
    
    [super forwardInvocation:anInvocation];
}

我们也可以修改参数:

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL selector = [anInvocation selector];
    
    if (selector == @selector(setAge:)) {
        // 获取原始参数
        int age;
        [anInvocation getArgument:&age atIndex:2]; // 前两个参数是self和_cmd
        
        // 修改参数值(例如:限制年龄范围)
        if (age < 0) age = 0;
        if (age > 150) age = 150;
        
        // 设置修改后的参数
        [anInvocation setArgument:&age atIndex:2];
    }
    
    // 转发给实际处理的对象
    if ([_realObject respondsToSelector:selector]) {
        [anInvocation invokeWithTarget:_realObject];
    } else {
        [super forwardInvocation:anInvocation];
    }
}

2.3.6 处理返回值

NSInvocation 也能处理返回值。我们可以从 anInvocation 中获取返回值,修改它,或者设置自己的返回值:

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    // 先尝试转发给备用对象
    if ([_backup respondsToSelector:[anInvocation selector]]) {
        [anInvocation invokeWithTarget:_backup];
        
        // 获取返回值
        char returnType[10];
        strcpy(returnType, [[anInvocation methodSignature] methodReturnType]);
        
        if (returnType[0] == '@') { // 返回对象类型
            id result = nil;
            [anInvocation getReturnValue:&result];
            
            // 可以修改返回值
            if (result == nil) {
                result = @"Default Value";
                [anInvocation setReturnValue:&result];
            }
        }
        return;
    }
    
    [super forwardInvocation:anInvocation];
}

2.3.7 转发给多个对象

完整消息转发甚至可以将一个消息转发给多个对象。这在某些设计模式中很有用,例如观察者模式或责任链模式:

@interface MessageChain : NSObject
@property (nonatomic, strong) NSArray *handlers;
@end

@implementation MessageChain
- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL selector = [anInvocation selector];
    BOOL handled = NO;
    
    for (id handler in self.handlers) {
        if ([handler respondsToSelector:selector]) {
            [anInvocation invokeWithTarget:handler];
            handled = YES;
            // 可以选择是否继续转发给下一个处理器
            // break;
        }
    }
    
    if (!handled) {
        [super forwardInvocation:anInvocation];
    }
}
@end

2.4 三个阶段的关系与选择

这三个阶段是递进的关系:如果第一阶段处理了,第二阶段就不会触发;如果第二阶段处理了,第三阶段就不会触发。

选择使用哪个阶段取决于你的需求:

  • 动态方法解析:适合在运行时动态添加方法实现,例如处理 @dynamic 属性、实现轻量级的代理模式。
  • 快速消息转发:适合简单地将消息转发给另一个对象,性能最好,但不能修改消息内容。
  • 完整消息转发:最强大、最灵活,可以修改消息内容、参数、返回值,甚至可以将消息转发给多个对象,但性能开销也最大。

第三章:深入源码分析

了解理论之后,让我们深入Runtime的源码,看看消息转发机制究竟是如何实现的。这里我们基于苹果开源的objc4源码进行分析。

3.1 从消息发送到消息转发的转折点

objc_msgSend 的核心实现中,如果方法查找失败,会调用 lookUpImpOrForward 函数。这个函数的简化逻辑如下:

IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
                       bool initialize, bool cache, bool resolver) {
    IMP imp = nil;
    bool triedResolver = NO;
    
    // 尝试从缓存和方法列表中查找
    // ...
    
    // 如果没有找到实现
    if (resolver && !triedResolver) {
        // 调用动态方法解析
        _class_resolveMethod(cls, sel, inst);
        triedResolver = YES;
        // 重新尝试查找
        goto retry;
    }
    
    // 动态解析失败,返回转发IMP
    imp = (IMP)_objc_msgForward_impcache;
    
    return imp;
}

关键点在于:当动态方法解析失败后,lookUpImpOrForward 会返回一个特殊的IMP:_objc_msgForward_impcache。这个IMP指向的是消息转发的入口函数。

3.2 消息转发的入口:__objc_msgForward

_objc_msgForward_impcache 最终会调用到 __objc_msgForward 函数。在x86_64架构的汇编实现中,这个函数的逻辑大致是:

ENTRY __objc_msgForward
    // 跳转到消息转发的核心实现
    jmp __objc_forward_handler
END_ENTRY __objc_msgForward

__objc_forward_handler 是一个C函数,它会调用到CoreFoundation框架中的 __forwarding__ 函数。这就是消息转发的真正核心实现。

3.3 CoreFoundation中的__forwarding__函数

__forwarding__ 函数是消息转发机制的心脏。虽然苹果没有开源CoreFoundation的全部代码,但通过反汇编和分析,我们可以还原其大致逻辑:

int __forwarding__(void *frameStackPointer, int isStret) {
    // 获取消息的接收者和选择器
    id receiver = *(id *)frameStackPointer;
    SEL sel = *(SEL *)(frameStackPointer + sizeof(id));
    
    // 尝试快速转发
    id forwardingTarget = nil;
    if ([receiver respondsToSelector:@selector(forwardingTargetForSelector:)]) {
        forwardingTarget = [receiver forwardingTargetForSelector:sel];
        if (forwardingTarget != nil && forwardingTarget != receiver) {
            // 转发给目标对象
            return objc_msgSend(forwardingTarget, sel, ...);
        }
    }
    
    // 快速转发失败,尝试完整转发
    NSMethodSignature *signature = nil;
    if ([receiver respondsToSelector:@selector(methodSignatureForSelector:)]) {
        signature = [receiver methodSignatureForSelector:sel];
    }
    
    if (signature == nil) {
        // 没有方法签名,无法继续
        [receiver doesNotRecognizeSelector:sel];
        return 0;
    }
    
    // 创建NSInvocation对象
    NSInvocation *invocation = [NSInvocation _invocationWithMethodSignature:signature frame:frameStackPointer];
    
    // 调用forwardInvocation:
    if ([receiver respondsToSelector:@selector(forwardInvocation:)]) {
        [receiver forwardInvocation:invocation];
    } else {
        [receiver doesNotRecognizeSelector:sel];
    }
    
    // 获取返回值
    // ...
    return 0;
}

从这个伪代码可以看出,__forwarding__ 函数完整地实现了我们之前讨论的消息转发流程:

  1. 尝试快速转发
  2. 如果快速转发没有返回合适的对象,尝试获取方法签名
  3. 如果方法签名有效,创建 NSInvocation 并调用 forwardInvocation:
  4. 如果所有步骤都失败,调用 doesNotRecognizeSelector: 抛出异常

3.4 日志调试技巧

Runtime提供了一个调试函数 instrumentObjcMessageSends,可以让我们查看消息发送和转发的详细过程:

extern void instrumentObjcMessageSends(BOOL flag);

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 开启消息发送日志
        instrumentObjcMessageSends(YES);
        
        Person *person = [[Person alloc] init];
        [person run];
        
        // 关闭日志
        instrumentObjcMessageSends(NO);
    }
    return 0;
}

运行程序后,在 /tmp/msgSends- 目录下会生成日志文件,内容类似于:

+ Person NSObject initialize
+ Person NSObject new
- Person NSObject init
- Person forwardingTargetForSelector: run
- Person methodSignatureForSelector: run
- Person forwardInvocation:
- Person doesNotRecognizeSelector: run

通过这个日志,我们可以清楚地看到消息转发的每一步调用过程,对于理解和调试消息转发非常有帮助。


第四章:消息转发的应用场景

消息转发机制不仅仅是理论上的知识点,它在实际开发中有很多实用的应用场景。

4.1 防止崩溃:安全的消息调用

一个常见的应用场景是防止因为调用未实现方法而导致的崩溃。例如,我们可以创建一个安全的代理对象,当目标对象不能响应某个消息时,不是崩溃而是返回一个默认值:

@interface SafeProxy : NSObject
@property (nonatomic, weak) id target;
@end

@implementation SafeProxy
- (id)forwardingTargetForSelector:(SEL)aSelector {
    // 如果target可以响应,直接转发
    if ([_target respondsToSelector:aSelector]) {
        return _target;
    }
    return self; // 让完整转发来处理
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    // 为任何方法提供默认签名(返回对象类型)
    return [NSMethodSignature signatureWithObjCTypes:"@@:"];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    // 不处理消息,只设置返回值为nil
    id nilValue = nil;
    [anInvocation setReturnValue:&nilValue];
}
@end

使用这个SafeProxy,我们可以安全地调用任何方法:

Person *person = [[Person alloc] init];
SafeProxy *proxy = [[SafeProxy alloc] init];
proxy.target = person;

// 如果person实现了run方法,正常执行
[proxy run]; 

// 如果person没有实现fly方法,不会崩溃,而是返回nil
id result = [proxy fly]; // result = nil,没有崩溃

4.2 模拟多继承

如前所述,通过消息转发可以实现类似多继承的效果。这在某些设计模式中非常有用,例如"装饰器"模式或"代理"模式。

4.3 API兼容性处理

在开发中,我们经常会遇到iOS系统版本升级导致API变化的情况。通过消息转发,我们可以优雅地处理这种变化:

@interface CompatibilityHandler : NSObject
@end

@implementation CompatibilityHandler
+ (void)load {
    // 交换forwardInvocation:方法
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class cls = [UIDevice class];
        SEL originalSelector = @selector(forwardInvocation:);
        SEL swizzledSelector = @selector(compatibility_forwardInvocation:);
        
        // 方法交换的实现...
    });
}

- (void)compatibility_forwardInvocation:(NSInvocation *)invocation {
    SEL selector = [invocation selector];
    
    if (selector == @selector(isLowPowerModeEnabled)) {
        // 低电量模式是iOS 9.0引入的
        if (@available(iOS 9.0, *)) {
            // 如果系统支持,转发给原始实现
            [invocation invoke];
        } else {
            // 如果不支持,返回默认值NO
            BOOL defaultValue = NO;
            [invocation setReturnValue:&defaultValue];
        }
    } else {
        // 其他消息正常转发
        [self compatibility_forwardInvocation:invocation];
    }
}
@end

4.4 实现AOP(面向切面编程)

通过消息转发,我们可以实现简单的AOP编程,在不修改原有类的情况下添加额外的逻辑:

@interface AspectProxy : NSObject
@property (nonatomic, strong) id target;
@property (nonatomic, copy) void (^beforeBlock)(SEL);
@property (nonatomic, copy) void (^afterBlock)(SEL);
@end

@implementation AspectProxy
- (id)forwardingTargetForSelector:(SEL)aSelector {
    // 必须返回nil才能进入完整转发
    return nil;
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    return [_target methodSignatureForSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL selector = [anInvocation selector];
    
    // 执行前置逻辑
    if (_beforeBlock) {
        _beforeBlock(selector);
    }
    
    // 转发给目标对象
    if ([_target respondsToSelector:selector]) {
        [anInvocation invokeWithTarget:_target];
    }
    
    // 执行后置逻辑
    if (_afterBlock) {
        _afterBlock(selector);
    }
}
@end

4.5 实现动态代理

在RxSwift等响应式编程框架中,消息转发被广泛用于实现动态代理,拦截方法调用并将它们转换为信号流:

// RxSwift中拦截方法的简化实现
@interface RXMessageSentObserver : NSObject
// ... 
@end

@implementation _RXObjCRuntime
- (void)interceptMethod:(SEL)selector ofClass:(Class)cls {
    // 1. 创建子类
    // 2. 重写forwardInvocation:
    // 3. 在forwardInvocation:中创建信号
}
@end

4.6 JSPatch等热修复框架的实现原理

热修复框架如JSPatch利用消息转发机制来实现动态替换OC方法的实现。基本原理是:

  1. 将要修复的类的 forwardInvocation: 方法替换为自己的实现
  2. 将原方法的IMP指向 _objc_msgForward,强制进入消息转发流程
  3. forwardInvocation: 中,执行JavaScript代码

第五章:性能考量与最佳实践

消息转发机制虽然强大,但使用不当可能会带来性能问题。

5.1 性能开销分析

不同阶段的消息转发性能开销不同:

阶段 性能开销 主要原因
正常消息发送 极小 直接查找IMP并调用
动态方法解析 较小 只执行一次,后续调用正常
快速消息转发 中等 需要调用Cocoa方法,但流程简单
完整消息转发 较大 需要创建NSInvocation对象,处理参数和返回值

为什么完整消息转发开销大

  1. 需要调用 methodSignatureForSelector: 获取方法签名
  2. Runtime需要根据方法签名创建 NSInvocation 对象
  3. NSInvocation 需要拷贝参数和设置返回值
  4. 整个流程涉及多次Objective-C方法调用

5.2 性能优化建议

根据性能开销,我们应遵循以下最佳实践:

  1. 优先使用快速消息转发:如果只是简单地将消息转发给另一个对象,尽量使用 forwardingTargetForSelector:,避免使用完整转发。

  2. 缓存方法签名:如果在完整转发中经常处理同一类消息,可以缓存方法签名,避免每次调用 methodSignatureForSelector:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    static NSMutableDictionary *signatureCache;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        signatureCache = [NSMutableDictionary dictionary];
    });
    
    NSString *selString = NSStringFromSelector(aSelector);
    NSMethodSignature *signature = signatureCache[selString];
    if (!signature) {
        signature = [NSMethodSignature signatureWithObjCTypes:"v@:"];
        signatureCache[selString] = signature;
    }
    return signature;
}
  1. 避免频繁触发转发:如果一个方法经常被调用,最好不要依赖消息转发来处理它。考虑在 resolveInstanceMethod: 中动态添加IMP,这样后续调用就和正常方法一样快了。

5.3 调试消息转发

当遇到与消息转发相关的bug时,可以使用以下调试技巧:

  1. 使用instrumentObjcMessageSends:开启日志,查看消息转发的每一步。

  2. 添加日志输出:在转发方法中添加日志:

- (id)forwardingTargetForSelector:(SEL)aSelector {
    NSLog(@"📱 Forwarding %@ to another target", NSStringFromSelector(aSelector));
    // ...
}
  1. 使用断点调试:在 forwardInvocation: 中设置断点,检查 NSInvocation 的内容。

  2. 检查方法签名:常见的崩溃原因是 methodSignatureForSelector: 返回了不正确的签名。可以使用以下代码验证签名:

NSMethodSignature *sig = [self methodSignatureForSelector:@selector(someMethod:)];
NSLog(@"Signature: %s", [sig methodReturnType]); // 检查返回类型
for (NSUInteger i = 0; i < [sig numberOfArguments]; i++) {
    NSLog(@"Arg %lu: %s", i, [sig getArgumentTypeAtIndex:i]);
}

5.4 与其他动态特性的比较

消息转发与Objective-C的其他动态特性既有联系又有区别:

特性 目的 触发时机
消息转发 处理无法识别的消息 方法查找失败后
方法交换 交换两个方法的IMP 运行时主动执行
动态添加方法 为类添加新方法 运行时主动执行
KVO 监听属性变化 创建子类并重写setter

重要区别

  • 消息转发是被动的,只有在正常消息发送失败后才会触发
  • 方法交换、动态添加方法是主动的,我们可以在任何时候执行
  • KVO是利用Runtime创建子类并重写方法,本质上也是动态特性的一种应用

第六章:面试深度解析

消息转发是iOS面试中的高级话题。下面梳理一些常见的面试题和深度解析。

6.1 基础问题

Q1:OC中给nil对象发送消息会发生什么?

解析:给nil发送消息是安全的,不会崩溃。Runtime在 objc_msgSend 中会首先检查接收者是否为nil,如果是nil,直接返回。返回值的类型取决于方法声明的返回类型:

  • 如果返回对象类型,返回nil
  • 如果返回整型,返回0
  • 如果返回结构体,返回的结构体各字段都是0
  • 如果返回浮点类型,返回0.0

Q2:unrecognized selector sent to instance 这个异常是怎么产生的?

解析:当向一个对象发送它无法处理的消息,且消息转发机制也无法处理时,Runtime最终会调用 doesNotRecognizeSelector: 方法。NSObject 中该方法的默认实现就是抛出这个异常。也就是说,这个异常是消息转发流程失败的最后结果。

Q3:消息转发分哪几个阶段?每个阶段的作用是什么?

解析:消息转发分为三个阶段:

  1. 动态方法解析:调用 resolveInstanceMethod:/resolveClassMethod:,允许开发者动态添加方法实现。

  2. 快速消息转发:调用 forwardingTargetForSelector:,允许将消息转发给另一个对象。

  3. 完整消息转发:调用 methodSignatureForSelector: 获取方法签名,然后创建 NSInvocation 对象并调用 forwardInvocation:,允许修改消息内容或转发给多个对象。

6.2 进阶问题

Q4:如何在运行时动态添加方法?

解析:在 resolveInstanceMethod: 中使用 class_addMethod 函数:

void dynamicMethodIMP(id self, SEL _cmd) {
    NSLog(@"动态添加的方法被调用");
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(dynamicMethod)) {
        class_addMethod(self, sel, (IMP)dynamicMethodIMP, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

Q5:快速转发和完整转发有什么区别?如何选择?

解析:主要区别在于:

  1. 需要重载的方法数量:快速转发只需重载 forwardingTargetForSelector:,完整转发需要重载 methodSignatureForSelector:forwardInvocation: 两个方法。

  2. 功能强大程度:快速转发只能简单地改变消息接收者,不能修改消息内容;完整转发可以修改消息的参数、选择器、返回值等。

  3. 性能开销:快速转发性能更好,完整转发需要创建 NSInvocation 对象,开销较大。

选择建议

  • 如果只是想将消息转发给另一个对象,且不需要修改消息内容,优先使用快速转发
  • 如果需要修改消息内容、参数、返回值,或者需要将消息转发给多个对象,使用完整转发

Q6:消息转发可以用来实现多重继承吗?和真正的多重继承有什么区别?

解析:可以通过消息转发实现类似多重继承的效果。区别在于:

  • 真正的多重继承是将多个类的功能合并到一个对象中
  • 通过消息转发实现的"伪多继承",功能仍然分散在不同的对象中,只是通过转发机制让外部看起来像一个对象处理了所有消息

Q7:如果消息转发的方法本身也找不到实现会怎样?

解析:这是一个容易忽略的细节。如果消息转发的方法(如 forwardingTargetForSelector:)本身没有实现,Runtime也会按照同样的流程查找它的实现。如果找不到,同样会触发消息转发。但通常情况下,这些方法在 NSObject 中都有默认实现,所以不会出现这种情况。

Q8:如何调试消息转发过程?

解析:可以使用以下方法:

  1. 使用 instrumentObjcMessageSends(YES) 开启日志
  2. 查看 /tmp/msgSends- 目录下的日志文件
  3. 在转发方法中添加断点和日志输出
  4. 使用反汇编工具分析 __forwarding__ 函数

6.3 高级问题

Q9:消息转发和method swizzling有什么关系?能结合使用吗?

解析:消息转发和method swizzling是两种不同的动态特性,但可以结合使用。例如,可以实现一个通用的方法拦截机制:

// 1. 先将原方法的IMP替换为_objc_msgForward
Method method = class_getInstanceMethod(cls, originalSelector);
method_setImplementation(method, _objc_msgForward);

// 2. 再添加一个转发方法
class_addMethod(cls, @selector(customForward:), (IMP)customForwardIMP, "v@:@");

// 3. 交换forwardInvocation:方法
Method originalForwardMethod = class_getInstanceMethod(cls, @selector(forwardInvocation:));
Method swizzledForwardMethod = class_getInstanceMethod(cls, @selector(customForwardInvocation:));
method_exchangeImplementations(originalForwardMethod, swizzledForwardMethod);

这种技术被用于RxSwift等框架的方法拦截功能。

Q10:如何实现一个通用的消息转发中心,能够记录所有无法识别的消息?

解析:可以创建一个基类,所有需要日志功能的类都继承自这个基类:

@interface LoggingBase : NSObject
@property (nonatomic, strong) NSMutableArray *unrecognizedMessages;
@end

@implementation LoggingBase
- (instancetype)init {
    if (self = [super init]) {
        _unrecognizedMessages = [NSMutableArray array];
    }
    return self;
}

- (id)forwardingTargetForSelector:(SEL)aSelector {
    // 记录无法识别的消息
    NSString *message = [NSString stringWithFormat:@"%@: %@", self, NSStringFromSelector(aSelector)];
    [_unrecognizedMessages addObject:message];
    
    // 可以选择转发给默认处理对象
    return [DefaultHandler sharedHandler];
}

// 可以添加一个方法来导出日志
- (void)exportUnrecognizedMessages {
    NSLog(@"Unrecognized messages: %@", _unrecognizedMessages);
}
@end

Q11:消息转发机制在ARC下有什么特别需要注意的地方?

解析:ARC下使用消息转发时需要注意:

  1. 内存管理:在 forwardInvocation: 中处理对象参数时,ARC会自动处理内存管理,但要注意不要造成循环引用。

  2. 方法签名:方法签名的类型编码必须准确,特别是在有对象参数或返回值时。错误的类型编码可能导致ARC下的内存管理错误。

  3. 返回值处理:当从 forwardInvocation: 返回时,Runtime会根据方法签名自动处理返回值的retain/release。如果方法签名不准确,可能导致内存泄漏或崩溃。

  4. 使用 __unsafe_unretained:在某些情况下,可能需要使用 __unsafe_unretained 来避免ARC自动插入的retain/release操作干扰转发逻辑。

Q12:从源码层面分析,消息转发和消息发送的性能差异主要体现在哪些方面?

解析:从源码层面看,性能差异主要体现在:

  1. 正常消息发送:汇编实现,查找缓存后直接跳转,几条指令就能完成。

  2. 动态方法解析:需要调用Objective-C方法,但只执行一次,后续调用恢复正常。

  3. 快速转发:需要调用 forwardingTargetForSelector:,这是一个完整的Objective-C方法调用,涉及消息发送流程。但无需创建复杂的对象。

  4. 完整转发

    • 需要调用 methodSignatureForSelector: 获取签名
    • Runtime需要遍历方法签名,解析每个参数的类型
    • 创建 NSInvocation 对象需要分配内存
    • NSInvocation 需要拷贝参数值
    • 调用 forwardInvocation: 方法
    • 转发后需要处理返回值

这些步骤加起来,完整转发的性能开销可能是正常消息发送的几十倍甚至上百倍。


第七章:总结与展望

7.1 消息转发机制的核心价值

Objective-C的消息转发机制是其动态性的集中体现,它给了开发者三次机会来处理无法识别的消息:

  1. 动态方法解析:让我们能够在运行时动态添加方法实现
  2. 快速消息转发:让我们能够将消息简单地转发给其他对象
  3. 完整消息转发:让我们能够完全掌控消息的处理过程

这三次机会形成了一个从简单到复杂的递进结构,开发者可以根据需求选择合适的层次进行干预。

7.2 设计思想解读

消息转发机制的设计体现了几个重要的软件工程思想:

  1. 容错性:系统提供了容错机制,允许程序在出现问题时尝试恢复,而不是直接崩溃。

  2. 渐进式干预:提供了三个层次的干预机会,每个层次都有不同的复杂度和能力,开发者可以根据需要选择。

  3. 开闭原则:通过消息转发,我们可以在不修改原有类的情况下,扩展类的功能,符合开闭原则。

  4. 责任链模式:消息转发本质上是一个责任链模式的实现,每个阶段都有机会处理消息,如果处理不了就传递给下一阶段。

7.3 与其他语言动态特性的对比

与其他动态语言相比,Objective-C的消息转发机制有独特之处:

语言 类似特性 特点
Objective-C 消息转发 分三个阶段,功能强大,与Runtime紧密结合
Ruby method_missing 类似forwardInvocation:,但更简洁
Python getattr 属性访问的fallback机制
JavaScript Proxy 可以拦截对象的各种操作

其中,Ruby的 method_missing 和Objective-C的 forwardInvocation: 最为相似。不同之处在于,Objective-C提供了更细粒度的控制(三个阶段),而Ruby只提供了一个统一的入口。

7.4 未来展望

随着Swift的兴起,Objective-C的使用场景在减少,但消息转发机制的设计思想仍然值得学习:

  1. Swift的动态特性:Swift虽然强调静态类型安全,但也提供了反射机制和 @objc 动态特性。理解消息转发有助于理解Swift中与Objective-C交互的部分。

  2. 跨平台开发:像Flutter这样的跨平台框架,在实现平台通道时也借鉴了消息转发的思想。

  3. AOP编程:面向切面编程在现代开发中越来越重要,消息转发是实现AOP的基础技术之一。

7.5 最后的思考

消息转发机制是Objective-C Runtime皇冠上的明珠,它展示了动态语言的强大能力。掌握消息转发,不仅能帮助我们写出更健壮的代码,还能让我们更深入地理解Objective-C的设计哲学。

在实际开发中,我们应当合理使用消息转发机制:

  • 在需要的地方使用,但不要滥用
  • 优先考虑性能更好的方案(如快速转发优先于完整转发)
  • 做好日志和调试,确保转发逻辑正确

最终,消息转发机制体现了编程语言设计中的一个重要思想:给予开发者更多的控制权,同时也赋予更多的责任。当我们决定使用消息转发时,我们实际上是在说:"我知道这条消息可能无法被正常处理,但我有办法解决这个问题。"

这种思想超越了具体的编程语言,是每个优秀程序员都应该具备的能力——在系统无法自动处理的情况发生时,能够提供优雅的降级方案。


参考资料

  1. Apple官方文档:forwardInvocation:
  2. Objective-C Runtime源码 (objc4-818.2)
  3. 《Effective Objective-C 2.0》 - Matt Galloway
  4. 《Objective-C Runtime Programming Guide》 - Apple Inc.

iOS NotificationCenter Observer 的隐性性能代价

引言

iOS 9 之后,Apple 为 NSNotificationCenter 的 target-action 模式引入了 zeroing weak reference。当 observer 对象被释放后,系统自动将内部的 weak reference 置为 nil,不再向其投递通知,也不会产生野指针 crash。

Apple 文档对此也有明确说明:

If your app targets iOS 9.0 and later or macOS 10.11 and later, you don't need to unregister an observer in its dealloc method.

这条规则被广泛接受,许多团队因此在代码规范中不再严格要求管理 observer 生命周期。然而,"不会 crash"并不等于"没有性能影响"。本文通过一个真实的线上卡死案例,探讨 NotificationCenter observer 管理不当可能带来的隐性性能问题。

一个线上卡死案例

在一次线上卡死监控中,我们发现一类卡死的比例明显上升。主线程被卡住 13 秒,CPU 占用 98.8%,处于 running 状态(不是锁等待)。堆栈顶部如下:

_weak_unregister_no_lock
_objc_moveWeak
__CFXNotificationRegistrarAddObserver
SomeTimeViewModel.componentMount()     ← 调用 NotificationCenter.default.addObserver(...)

卡死发生在一个 ViewModel 的初始化阶段——调用 addObserver(self, selector:, name:, object:) 注册通知时。注册一个通知 observer 本身应该是一个非常轻量的操作,为什么会导致 13 秒的卡死?

经过排查,这个问题的根因并不是"忘记 removeObserver 导致 dead entries 累积"(事实上代码在 dealloc 链路中已经正确调用了 removeObserver),而是一个更容易被忽视的问题:同一个通知名下积累了过多的 live observers

NotificationCenter 的内部机制

要理解这个问题,需要了解 NSNotificationCenter 在 iOS 9+ 中的内部机制。

注册表结构

当调用 addObserver:selector:name:object: 时,NotificationCenter 在内部维护一个注册表(registrar),按通知名称索引,存储所有注册信息。每条注册信息大致包括:

  • 一个指向 observer 的 weak reference
  • selector
  • notification name
  • object filter

这些信息存储在 CoreFoundation 内部的数据结构(类似于哈希表 + 数组)中。

扩容与 weak reference 迁移

和 HashMap 类似,NotificationCenter 的内部存储在容量不足时会扩容——分配更大的存储空间,将现有条目迁移到新位置。

对于包含 weak reference 的条目,迁移过程需要通过 ObjC runtime 的 objc_moveWeak 将 weak reference 从旧内存地址搬迁到新地址。这个操作涉及:

  1. _weak_unregister_no_lock:从 runtime 的 side table 中注销旧地址
  2. _weak_register_no_lock:在 side table 中注册新地址

单次操作很快,但如果某个通知名下积累了大量条目,扩容时需要逐个迁移所有 live entries 的 weak reference,累积耗时就可能达到秒级。

两种问题模式

NotificationCenter 的 entries 膨胀来自两个方面,它们可以独立存在,也可以叠加:

模式一:dead entries 累积(不调 removeObserver 的短生命周期对象)

当 observer 被释放时,其 weak reference 自动置 nil,但注册条目本身不会被移除。对于频繁创建和销毁的对象(如 Feed 中的各类 Component),如果不在 deinit 中 removeObserver,NotificationCenter 会持续累积 dead entries。

模式二:live entries 过多(长生命周期对象大量注册同一通知)

即使每个 observer 都正确管理了 removeObserver,如果大量长生命周期对象同时注册同一个通知,live entries 的数量本身就可能很大。

案例分析

回到开头的卡死案例。我们排查后发现,触发卡死的通知名 .tabBarDidChangeSelectedIndex 在整个 App 中有 31 个文件 注册了 observer,涵盖 Feed、社交、个人资料页、IM、Notice、电商 等几乎所有主要模块。

在 IM 模块的 会话 列表中,架构设计如下:

  • 每个会话对应一个持久化的 ViewModel(存储在字典中,不会被频繁销毁)
  • 每个 ViewModel 在 init 时创建一棵包含 50+ 子组件的组件树
  • 其中 ViewModel 本身和 TimeViewModel 各注册了一次 .tabBarDidChangeSelectedIndex

对于一个有 200 个会话的用户,仅 会话 列表就贡献了 400 个 live observers。这些 observer 的生命周期管理是正确的(dealloc 时通过组件树的 unmount 链路移除),但它们的数量本身就是问题。

再叠加 App 其他模块的 observer(包括可能存在的 dead entries),这个通知名下的总 entries 数量相当可观。当新增一个 observer 触发内部存储扩容时,迁移所有 entries 的累积耗时就造成了 13 秒的卡死。

被忽视的关键点

这个案例有一个容易被忽视的教训:即使 observer 生命周期管理完全正确(deinit 中有 removeObserver),也不意味着没有性能风险。 问题不在于单个 observer 的正确性,而在于同一个通知名下的 observer 总量。

哪些场景容易踩坑

1. 热门通知名 + 大量模块共同注册

像 Tab 切换、App 前后台、网络状态变化这类全局通知,往往被 App 中大量模块同时监听。每个模块的注册看起来都合理,但总量可能超出预期。

2. 持久化对象在 init 阶段无差别注册

如果一个对象会存在很久(如 1:1 对应数据模型的 ViewModel),且在 init 阶段就注册通知,那么所有实例的 observer 都会持续存在。即使只有屏幕上可见的几个实例真正需要响应通知,其余实例的注册也在白白增加 entries 总量。

3. 短生命周期对象不调 removeObserver

对于频繁创建和销毁的对象(如 Feed 滑动过程中的各种 Component),如果不在 deinit 中 removeObserver,每次销毁都会留下一个 dead entry。随着用户使用时间增长,dead entries 不断累积。

4. 组件树放大效应

在 TTKC 等组件化框架中,一个容器可能包含数十个子组件,每个子组件可能独立注册通知。容器的数量 × 子组件的数量 = 总 observer 数量,放大效应显著。

建议

按需注册:只为可见的实例注册 observer

对于列表中的 ViewModel,如果通知只用于更新 UI 展示(如刷新时间文本),那么只有屏幕上可见的实例才需要注册。可以在 Cell 即将显示时注册,在不可见时移除:

override func cellWillDisplay() {
    super.cellWillDisplay()
    NotificationCenter.default.addObserver(self, selector: #selector(onTabBarChange(_:)),
                                           name: .tabBarDidChangeSelectedIndex, object: nil)
}

override func cellDidEndDisplay() {
    super.cellDidEndDisplay()
    NotificationCenter.default.removeObserver(self, name: .tabBarDidChangeSelectedIndex, object: nil)
}

对于 200 个会话的用户,这将 observer 数量从 200 减少到 ~10-20(可见 Cell 数量)。

集中式 observer:N 个独立注册 → 1 个集中处理

如果多个同类对象都需要响应同一个通知,考虑用一个集中式 observer 替代 N 个独立注册:

// 在 DataController 中注册一次
NotificationCenter.default.addObserver(self, selector: #selector(onTabBarChange(_:)),
                                       name: .tabBarDidChangeSelectedIndex, object: nil)

@objc func onTabBarChange(_ notification: NSNotification) {
    for viewModel in viewModelDict.values {
        viewModel.handleTabBarChange()
    }
}

N 个 observer 注册变为 1 个,彻底消除了这个通知名下的数量问题。

对于短生命周期对象:在 deinit 中 removeObserver

deinit {
    NotificationCenter.default.removeObserver(self)
}

这一行代码的作用不是防 crash(iOS 9+ 不需要),而是及时清理 NotificationCenter 内部的注册条目,避免 dead entries 累积。

考虑使用 block-based API + 显式 token 管理

block-based API 返回一个 opaque token,移除时通过 token 精确定位,语义更清晰:

private var observerToken: NSObjectProtocol?

func setup() {
    observerToken = NotificationCenter.default.addObserver(
        forName: .someNotification, object: nil, queue: .main
    ) { [weak self] _ in
        self?.handleNotification()
    }
}

deinit {
    if let token = observerToken {
        NotificationCenter.default.removeObserver(token)
    }
}

需要注意的是,block-based API 不使用 zeroing weak reference——如果 block 中 strong capture 了 self,会导致循环引用。block 中必须使用 [weak self],且必须在合适时机 remove token。

在 Code Review 中关注

建议在 Code Review 中对以下模式保持敏感:

  • 这个通知名在 App 中有多少处注册?是否是"热门通知"?
  • 注册 observer 的对象有多少个实例同时存在?
  • 是否在 init 阶段就注册,但实际上只在可见时才需要?
  • 短生命周期对象是否在 deinit 中调了 removeObserver?

小结

NotificationCenter 的性能问题有两个维度:

  1. 单个 observer 的生命周期管理:短生命周期对象不调 removeObserver,导致 dead entries 累积
  2. 同一通知名下的 observer 总量:即使每个 observer 都正确管理了生命周期,大量 live observers 本身就是性能风险

第一个问题比较符合直觉,容易在 Code Review 中发现。第二个问题更隐蔽——每个模块的注册看起来都合理,但当一个大型 App 中有几十个模块同时注册同一个通知时,总量就可能超出 NotificationCenter 内部数据结构的性能安全边界。

Apple 的"不需要 removeObserver"是关于正确性的保证,不是关于性能的保证。在大型 App 中,NotificationCenter observer 需要像内存一样被视为一种有限资源来管理。

UITableView 在 width=0 时 reloadData 被"空转消费"导致 Cell 显示错乱

深入理解代替单纯记忆

本文中的问题和排查过程由作者完成,文章编写由Cursor完成

一、问题现象

一个 UITableView 在特定时序下出现了诡异的显示错乱:

  • 数据源有 2 条数据 [数据 B, 数据 A]numberOfRowsInSection 返回 2
  • 但 UITableView 显示了 2 条完全相同的数据 A
  • 通过日志发现 cellForRowAtIndexPath 只被调用了 1 次(row=1),row=0 从未被请求

数据源没有问题,UITableView 却跳过了 row=0 的 cell 请求。

二、场景结构

出问题的 VC 架构如下:

ContainerVC(容器,通过 frame 动画实现滑入/滑出)
  └── containerView(承载内容的 view,初始位置在屏幕外)
        └── ListVC.view(子 VC,内含 UITableView)

关键行为:

  • ContainerVC 通过 present 弹出,containerView 初始在屏幕外,然后通过 frame 动画滑入
  • ListVCinit 中注册通知,数据变化时调用 reloadData
  • ContainerVC dismiss 后不会释放,下次打开复用同一个实例

三、复现步骤

  1. 打开 ContainerVCcontainerView 滑入,UITableView 显示 [数据 A],正常
  2. 关闭(dismiss),ContainerVC 及其子 VC 仍然存活
  3. 此时外部数据变化,通知触发 reloadData,数据源变为 [数据 B, 数据 A]
  4. 再次打开 ContainerVC

预期:显示 [数据 B, 数据 A]

实际:显示 [数据 A, 数据 A]

四、排查过程

4.1 排除数据源问题

日志确认 numberOfRowsInSection 返回 2,两条数据标识符不同。数据源正确。

4.2 怀疑 reloadData 在 off-screen 时异常

dismiss 后通知仍在触发 reloadData(view.window == nil),怀疑这导致了 UITableView 内部状态不一致。

但通过对照实验推翻了这个假设:我们有另一个功能相同但布局实现不同的 ContainerVC_B。替换后,即使同样在 off-screen 时触发 reloadData,重新打开后 cellForRowAtIndexPath 正确执行了 2 次

结论:off-screen 时的 reloadData 不是问题,问题在 ContainerVC 自身的实现。

4.3 对比两个容器的实现差异

逐行对比发现,关键差异在 ListVC.view 的 AutoLayout 约束上。

ContainerVC_B(正常)—— 约束相对于 containerView:

// containerView 尺寸通过 frame 设定,是固定值
containerView.frame = CGRectMake(0, offScreenY, fixedWidth, fixedHeight);

// ListVC.view 的宽度 = containerView.width = 固定值
[listVC.view mas_makeConstraints:^(MASConstraintMaker *make) {
    make.leading.trailing.equalTo(containerView);
}];

ContainerVC(异常)—— 约束跨越了视图层级:

// containerView 尺寸也是固定的
containerView.frame = CGRect(x: offScreenX, y: 0, width: fixedWidth, height: fixedHeight)

// 但 headerView 的 trailing 锚定到了 VC 主 view 的 safeArea
headerView.snp.makeConstraints { make in
    make.leading.equalToSuperview()                              // = containerView.leading
    make.trailing.equalTo(view.safeAreaLayoutGuide.snp.trailing) // = VC 主 view 的右边缘
}

// ListVC.view 跟着 headerView 走
listVC.view.snp.makeConstraints { make in
    make.leading.trailing.equalTo(headerView) // width = headerView.width
}

这个跨视图层级的约束就是根因。

五、根因分析

5.1 跨视图约束如何导致 width=0

headerViewcontainerView 的子视图,但它的 trailing 约束锚定到了 VC 主 viewsafeAreaLayoutGuide.trailing

AutoLayout 解析约束时,会将所有边的位置转换到共同祖先的坐标系中计算。当 containerView 在屏幕外时:

headerView.leading  = containerView.leading  ≈ 844(屏幕外)
headerView.trailing = view.safeArea.trailing  ≈ 800(屏幕右边缘)

trailing(800) < leading(844) → 宽度为负 → 被压缩为 0

ListVC.viewleading.trailing 跟着 headerViewtableView.width = 0

ContainerVC_B 的约束全部相对于 containerView,后者的尺寸是 frame 设定的固定值,不随位置变化,所以 tableView 始终有有效宽度。

5.2 reloadData 在 width=0 时为什么会导致显示错乱?

根据日志观察到的现象,推测因果链如下:

  1. reloadData 在 width=0 时被触发。UITableView 计算可见行数为 0,因此不调用 cellForRow,也不回收旧 cell。但 UITableView 内部可能认为这次 reload 已经完成。

  2. reload 被"空转消费"—— 流程走了,但实际什么都没刷新。旧的 cell(第一次打开时创建的 CellA)仍然挂在 tableView 的 subview 上。

  3. containerView 滑入屏幕、tableView width 从 0 恢复正常时,触发了 layoutSubviews。但 UITableView 不再将其视为一次完整的 reload,而是当作尺寸变化引起的增量布局

  4. 增量布局中,UITableView 发现 row=0 位置已有一个 cell(上次残留的 CellA),直接复用,不调用 cellForRow。仅对 row=1 调用 cellForRow,返回数据 A 的 cell。

  5. 最终两行都显示数据 A。

六、修复

ListVC.viewleading.trailing 约束改为相对于 containerView

// 修复前:width 间接依赖 headerView(跨视图约束,position-dependent)
listVC.view.snp.makeConstraints { make in
    make.leading.trailing.equalTo(headerView)
}

// 修复后:width 直接依赖 containerView(固定尺寸,position-independent)
listVC.view.snp.makeConstraints { make in
    make.leading.trailing.equalTo(containerView)
}

containerView 的 width 是通过 frame 设定的固定值,不随位置变化。改动后 tableView 在任何时刻都有有效宽度,reloadData 不会被空转消费。

七、总结

归根到底,这是UITableView 的 reloadData 时的一个边界行为

当 tableView 的 bounds 宽度(或高度)为 0 时,reloadData 会走内部流程(查询行数),但可能不会创建或回收任何 cell。后续尺寸恢复时,UITableView 按增量布局处理,可能复用之前残留的旧 cell。

这可能不一定是 UITableView 的 bug,而是合理的优化 —— 没有可见区域时不创建 cell。但如果约束写法导致 tableView 在不该为 0 的时候 width 为 0,这个行为就会引发显示错乱。

排查建议

cellForRowAtIndexPath 的调用次数不符合预期时,优先检查 tableView 在 reloadData 时刻的 frame:

NSLog(@"reloadData: frame=%@, window=%@",
    NSStringFromCGRect(self.tableView.frame),
    self.tableView.window);

如果 width 或 height 为 0,reloadData 就会被空转消费。

# iOS Block 深度解析

全面剖析 Block 的本质、底层结构、内存管理、变量捕获、循环引用、线程安全、调试技巧与最佳实践。 力求深入而易懂——用类比代替术语堆砌,用图表代替大段代码。


一、Block 的本质

Block 是 C 语言层面的匿名函数 + 自动捕获上下文变量的能力 的组合体。

它不是 Objective-C 独有的特性,而是 Apple 对 C 语言的扩展(Clang 编译器实现),所以在 C、C++、Objective-C、Objective-C++ 中都可以使用。

一句话概括 Block 的本质:

Block 是一个封装了函数指针和捕获变量的 Objective-C 对象。

这意味着 Block 同时具备两重身份:

  • 作为函数:可以被调用、传参、返回
  • 作为对象:有 isa 指针,可以被 copy/release,参与 ARC 内存管理

类比理解: 普通函数像一台固定在车间里的机器——你去找它,它帮你加工。Block 像一台可以搬走的便携机器——你能带着它走,而且它随身携带了自己需要的原材料(捕获的变量)。

1.1 从编译器视角看 Block 的诞生

Clang 编译 Block 时经历的变换过程:

源代码层               编译器中间表示层              机器码层

^{ NSLog(@"Hi"); }
        │
        ▼
  语法解析为 BlockExpr
        │
        ▼
  分析捕获变量列表
  (遍历 Block 体内所有引用的外部变量)
        │
        ▼
  生成 Block_layout 结构体定义
  (根据捕获变量数量和类型动态确定结构体大小)
        │
        ▼
  将 Block 体内的代码提取为一个独立的 C 函数
  (函数名通常为 __文件名_block_func_序号)
  (第一个参数为 Block 结构体指针)
        │
        ▼
  在原位置生成结构体初始化代码
  (填充 isa、invoke、descriptor、捕获变量)
        │
        ▼
  ARM64 机器码

关键洞察: Block 的"闭包"能力不是运行时魔法,而是编译期的代码变换——编译器帮你把自由变量"打包"进了一个结构体。这就像你要出差,把办公桌上需要的文件全装进行李箱带走,到了酒店打开就能继续工作。

1.2 Block 与其他语言闭包的本质差异

┌──────────────┬────────────────────────────────────────────┐
│    语言       │    闭包实现方式                               │
├──────────────┼────────────────────────────────────────────┤
│ JavaScript   │ 通过作用域链引用外部变量(共享同一个变量)        │
│              │ 闭包和外部代码修改的是同一个变量                 │
│              │ 不需要特殊关键字                               │
├──────────────┼────────────────────────────────────────────┤
│ Swift        │ 默认捕获引用(和 JS 类似,共享变量)             │
│              │ [value] 显式值捕获                            │
│              │ 闭包是引用类型                                 │
├──────────────┼────────────────────────────────────────────┤
│ OC Block     │ 默认值捕获(拷贝一份副本)                      │
│              │ 需要 __block 才能共享变量                      │
│              │ Block 有 Stack/Malloc/Global 三种存储位置      │
│              │ 需要显式/隐式 copy 才能延长生命周期               │
├──────────────┼────────────────────────────────────────────┤
│ C++ Lambda   │ [=] 值捕获,[&] 引用捕获                     │
│              │ 编译为匿名类的 operator()                     │
│              │ 和 OC Block 最相似                            │
└──────────────┴────────────────────────────────────────────┘

OC Block 的独特之处:
  1. 默认值捕获 → 安全但反直觉(修改需要 __block)
  2. 有栈→堆迁移的概念 → 其他语言的闭包都直接在堆上
  3. 是 OC 对象 → 参与引用计数,有循环引用问题

1.3 为什么 Apple 选择了默认"值捕获"

这个设计决策背后有深层考虑:

JavaScript"引用捕获"经常制造 Bugfor (var i = 0; i < 3; i++) {
      setTimeout(function() { console.log(i); }, 0);
  }
  // 输出 3, 3, 3(而非期望的 0, 1, 2)
  // 因为闭包共享了变量 i,循环结束时 i = 3

OC"值捕获"避免了这类问题:
  
  for (int i = 0; i < 3; i++) {
      dispatch_async(queue, ^{ NSLog(@"%d", i); });
  }
  // 输出 0, 1, 2 ✓
  // 每个 Block 在创建时拍了一张 i 的快照

Apple 的设计哲学:
  "大多数场景下,Block 只需要读取变量的值,不需要修改"
  "让安全的事情成为默认,不安全的事情需要显式声明(__block)"
  
  这是典型的 Pit of Success 设计理念 ——
  让开发者默认就掉进成功的坑里,想犯错反而需要额外的努力。

二、Block 的底层结构

编译器会将每个 Block 转换为一个结构体 + 一个函数

Block 变量(指针)
    │
    ▼
┌──────────────────────────────────────┐
│           Block_layout 结构体          │
├──────────────────────────────────────┤
│  isa 指针          → 指向 Block 的类   │
│  flags             → 标志位            │
│  reserved          → 保留字段          │
│  invoke            → 函数指针 (核心)    │
│  descriptor        → 描述信息指针       │
│  ─────────────────────────────────── │
│  captured_var_1    → 捕获的变量 1       │
│  captured_var_2    → 捕获的变量 2       │
│  ...                                  │
└──────────────────────────────────────┘

类比: 把 Block 想象成一个快递包裹:

  • isa 是包裹类型标签(普通件/到付件/国际件)
  • flags 是物流状态码
  • invoke 是"使用说明书"(告诉你怎么打开和使用内容物)
  • descriptor 是"装箱清单"(描述包裹尺寸和特殊处理要求)
  • 捕获的变量就是包裹里的内容物

2.1 flags 标志位详解

flags 不是一个简单的整数,它是一个位域(bitfield),每一位都有特定含义:

flags 位域布局 (32 bit):

 31  30  29  28  27  26  25  24        16  15         1   0
┌───┬───┬───┬───┬───┬───┬───┬──── ─ ────┬───┬──── ─ ───┬───┐
│   │   │   │   │   │   │   │           │   │          │   │
└───┴───┴───┴───┴───┴───┴───┴──── ─ ────┴───┴──── ─ ───┴───┘
  │   │   │   │                   │               │
  │   │   │   │                   │               └── bit 0: BLOCK_DEALLOCATING
  │   │   │   │                   │                   正在被释放
  │   │   │   │                   │
  │   │   │   │                   └── bit 1~15: 引用计数(存储在这里!)
  │   │   │   │                       堆上 Block 的 retainCount
  │   │   │   │
  │   │   │   └── bit 24: BLOCK_NEEDS_FREE
  │   │   │       表示是堆上的 Block,需要 free 释放
  │   │   │
  │   │   └── bit 25: BLOCK_HAS_COPY_DISPOSE
  │   │       表示有 copy_helper 和 dispose_helper
  │   │       (即捕获了对象或 __block 变量)
  │   │
  │   └── bit 26: BLOCK_HAS_CTOR
  │       捕获的变量有 C++ 构造函数
  │
  └── bit 30: BLOCK_HAS_SIGNATURE
      表示有方法签名(可通过 NSMethodSignature 获取参数/返回值类型)

隐藏知识: Block 的引用计数不像普通 OC 对象存在 SideTable 中,而是直接编码在 flags 的 bit 1~15 中。这是一个性能优化——避免了每次 retain/release 都要查 SideTable 的哈希表。这意味着 Block 最大引用计数为 2^15 - 1 = 32767,不过实际场景中绰绰有余。

2.2 descriptor 的多态结构

descriptor 不是固定结构,它根据 Block 捕获的内容有不同的版本

版本 1(不捕获对象/不捕获 __block 变量):
┌─────────────────────────────┐
│  unsigned long reserved     │  → 0unsigned long size         │  → Block 结构体总字节数
└─────────────────────────────┘

版本 2(捕获了对象或 __block 变量,BLOCK_HAS_COPY_DISPOSE = 1):
┌─────────────────────────────┐
│  unsigned long reserved     │
│  unsigned long size         │
│  void (*copy_helper)()      │  → 新增:拷贝时调用
│  void (*dispose_helper)()   │  → 新增:销毁时调用
└─────────────────────────────┘

版本 3(有方法签名,BLOCK_HAS_SIGNATURE = 1):
┌─────────────────────────────┐
│  unsigned long reserved     │
│  unsigned long size         │
│  void (*copy_helper)()      │  → 可能有
│  void (*dispose_helper)()   │  → 可能有
│  const char *signature      │  → 新增:Block 的类型编码字符串
└─────────────────────────────┘
    例如 signature = "v8@?0" 表示 void(^)(void)

这种多态设计的好处:
  - 不捕获对象的 Block,descriptor 更小,节省内存
  - 编译器根据情况选择最紧凑的版本
  
类比:就像飞机上的行李标签——
  国内经济舱行李只需要简单标签(版本 1)
  含易碎品的行李需要额外的特殊处理标签(版本 2)
  国际航班行李还需要海关申报信息(版本 3

2.3 invoke 函数的隐含参数

当你写这样的代码:

  int x = 10;
  void(^block)(int) = ^(int y) { printf("%d", x + y); };
  block(20);

编译器生成的 invoke 函数签名是:

  static void __main_block_func_0(
      struct __main_block_impl_0 *__cself,  // ← 隐含的第一个参数!
      int y                                  // ← 你写的参数
  )

调用过程:
  block(20)
      → block->invoke(block, 20)
         ↑ Block 把自己作为第一个参数传入
         这样 invoke 函数就能访问 Block 结构体中捕获的变量

这和 OC 方法调用极其类似:
  [obj doSomething]objc_msgSend(obj, @selector(doSomething))
  block(20)          → block->invoke(block, 20)

2.4 Block 的类型编码(Type Encoding)

Block 在 runtime 中也有类型签名,这让它可以与 NSInvocation 配合使用:

Block 签名编码规则:

  void(^)(void)             → "v8@?0"
  void(^)(int)              → "v12@?0i8"
  NSString *(^)(int, BOOL)  → "@20@?0i8B12"

编码字符含义:
  v  = void
  @  = 对象指针
  @? = Block 类型(@ 表示对象,? 表示 Block)
  i  = int
  B  = BOOL
  数字 = 参数在栈帧中的偏移量

获取 Block 签名的方式:
  从 descriptor 的 signature 字段读取
  可用 NSMethodSignature 解析为可读格式

这个签名使得 Block 可以被 NSInvocation 动态调用,
也使得 libffi 能够在运行时对 Block 做各种 hook 和转发操作。

三、Block 的三种类型

Block 根据存储位置分为三种类型,这是理解 Block 内存管理的关键:

┌─────────────────────────────────────────────────────────────┐
│                      内存布局                                 │
│                                                              │
│  高地址  ┌──────────────┐                                     │
│         │     栈 Stack   │ ← __NSStackBlock__                 │
│         │   (向下增长)    │   栈上的 Block,离开作用域即销毁       │
│         ├──────────────┤                                     │
│         │              │                                     │
│         │     堆 Heap   │ ← __NSMallocBlock__                 │
│         │              │   堆上的 Block,引用计数管理            │
│         ├──────────────┤                                     │
│         │   全局/静态区   │ ← __NSGlobalBlock__                │
│         │   Data Segment│   全局 Block,程序结束才销毁           │
│         ├──────────────┤                                     │
│  低地址  │   代码区 Text  │                                     │
│         └──────────────┘                                     │
└─────────────────────────────────────────────────────────────┘

3.1 三种类型的判定规则

类型 条件 生命周期 isa 指向
__NSGlobalBlock__ 不捕获任何外部局部变量 与程序同生共死 _NSConcreteGlobalBlock
__NSStackBlock__ 捕获了外部局部变量(MRC 或未被强引用) 与所在栈帧同生共死 _NSConcreteStackBlock
__NSMallocBlock__ Stack Block 经 copy 引用计数为 0 时销毁 _NSConcreteMallocBlock

类比理解:

  • 全局 Block → 写在教科书上的公式——永远在那里,谁都能用
  • 栈 Block → 写在白板上的草稿——会议结束就擦掉
  • 堆 Block → 拍了照片保存在手机里的公式——照片在就在,删了就没了

3.2 三种类型的 retain/copy/release 行为差异

                    retain        copy          release
                    ──────        ────          ───────
GlobalBlock         什么都不做     返回自身        什么都不做
                    (它是不死的)   (不需要 copy)   (不能被释放)

StackBlock          什么都不做     拷贝到堆上      什么都不做
                    (栈管理)      返回堆上新副本   (栈自动管理)
                                  ★这是唯一有效操作

MallocBlock         引用计数 +1   引用计数 +1     引用计数 -1
                                  (不会重新拷贝)   (归零则释放)

关键洞察:
  - 对 StackBlock 做 retain 是无效的(不增加引用计数)
  - 必须 copy 才能将其"救"到堆上
  - 这就是为什么 Block 属性要用 copy 而不是 strong(MRC 时代的历史原因)
  - ARC 下 strongcopy 对 Block 效果相同(编译器自动插入 copy

面试陷阱: "对一个已经在堆上的 Block 再做 copy 会发生什么?"——答案是只增加引用计数(+1),不会创建新副本。这和 NSMutableArray copy 会创建新对象是不同的行为。

3.3 ARC 下的自动 copy —— 编译器到底做了什么

在 ARC 环境下,以下场景编译器会自动将栈 Block copy 到堆上

自动 copy 触发场景:
├── ① Block 作为函数/方法返回值
│      编译器在 return 语句处插入 objc_retainBlock() → 内部调用 _Block_copy()
│
├── ② Block 赋值给 __strong 修饰的变量
│      void(^block)(void) = ^{ ... };
│      编译器在赋值处插入 objc_retainBlock()
│
├── ③ Block 作为 GCD API 的参数
│      dispatch_async 内部实现中调用 _Block_copy()
│
├── ④ Block 作为 Cocoa 框架中含 usingBlock 的方法参数
│      框架内部负责 copy
│
└── ⑤ Block 被传入方法的参数(编译器启发式判断)
       如果方法签名暗示会保存 Block(如 completion handler),编译器插入 copy

编译器不会自动 copy 的场景(ARC 下罕见但存在):
├── Block 作为函数参数传递时,如果调用者没有保存它
│   (此时 Block 可能在栈上,但因为是同步使用所以安全)
└── 使用 __unsafe_unretained 修饰的变量接收 Block

3.4 为什么 Block 要有栈这个中间状态?

设计哲学:性能与安全的权衡

如果 Block 一律在堆上创建(像 Swift 闭包那样):
  ✅ 简单,不需要考虑栈→堆迁移
  ❌ 每次创建 Block 都要 malloc,频繁的堆分配 + GC 压力

OC Block 的策略:
  ✅ 大量临时 Block(如 for 循环中的 Block)直接在栈上创建和销毁
     无 malloc 开销、无引用计数开销
  ✅ 只有需要"逃逸"的 Block 才 copy 到堆上
  ⚠️ 代价是引入了栈→堆迁移的复杂性

类比理解:
  栈 Block → 临时工,干完活就走,不占编制
  堆 Block → 正式员工,办了入职手续(malloc),有档案(引用计数)
  全局 Block → 终身教授,和学校同在

在 ARC 时代,编译器自动处理迁移,开发者几乎无感。
但理解这个机制对排查内存问题至关重要。

3.5 逃逸(Escape)与非逃逸(Non-Escape)

这个概念虽然在 Swift 中用 @escaping 关键字显式化了,但在 OC Block 中一直存在,只是没有语法层面的区分:

什么是逃逸?
  Block 的使用范围"逃"出了它被创建的那个函数作用域。

非逃逸 Block(不需要 copy 到堆上):
  - 在当前函数内同步调用然后丢弃
  - 例如:enumerateObjectsUsingBlocksortUsingComparator
  - 函数结束时 Block 还在栈上,随栈帧销毁即可
  
逃逸 Block(必须 copy 到堆上):
  - 被保存到属性/实例变量中
  - 被异步 dispatch 执行
  - 作为返回值传出当前函数
  - 被传入会保存它的 API(如 NSNotificationCenterblock observer)

逃逸 Block 的特征:
  ┌──────────────────────────────────────────────────────┐
  │ Block 在创建者的作用域结束之后仍然可能被调用 → 逃逸      │
  │ Block 只在创建者的作用域内被调用 → 非逃逸                │
  └──────────────────────────────────────────────────────┘

Swift 把这个概念升级为编译器强制检查:
  - func doWork(completion: @escaping () -> Void)   // 编译器知道会逃逸
  - func doWork(block: () -> Void)                  // 默认非逃逸,性能更好

OC 中虽然没有编译器检查,但 ARC 的自动 copy 机制帮你兜了底:
  该 copy 的时候自动 copy,不需要显式操心。

四、变量捕获机制

这是 Block 最核心、最容易出问题的部分。

4.1 捕获规则总览

┌──────────────────┬────────────────────┬──────────────────────┐
│    变量类型        │    捕获方式          │    Block 内能否修改    │
├──────────────────┼────────────────────┼──────────────────────┤
│ 局部基本类型变量    │ 值拷贝 (copy)       │ 不能(编译报错)       │
│ 局部对象类型变量    │ 指针值拷贝 (copy)    │ 不能改指针指向         │
│                  │                    │ 能改指向对象的属性      │
│ __block 局部变量   │ 封装为堆上结构体引用  │ 能                   │
│ 静态局部变量       │ 指针引用 (不拷贝)     │ 能                   │
│ 全局变量          │ 不捕获 (直接访问)     │ 能                   │
│ self             │ 强引用捕获           │ 能访问属性/方法        │
└──────────────────┴────────────────────┴──────────────────────┘

4.2 局部变量的值捕获 —— 为什么是"快照"

捕获时刻:Block 定义时(不是调用时)

int a = 10;

void(^block)(void) = ^{
    // 这里的 a 是定义时拷贝进来的值 = 10
};

a = 20;
block();  // 输出 10,不是 20

原理: 编译器在 Block 结构体中新增了一个 int a 成员变量,在 Block 创建的那一刻,将外部 a 的值拷贝进去。之后外部 a 的变化与 Block 内部无关。

Block 结构体                外部栈帧
┌──────────────┐           ┌──────────────┐
│ isa           │           │              │
│ invoke        │           │ a = 20       │  ← 外部已改为 20
│ ...           │           │              │
│ a = 10        │ ← 独立副本 │              │
└──────────────┘           └──────────────┘
      ↑ Block 内部读到的始终是 10

为什么编译器禁止修改? 不是技术上不能改(结构体成员当然可以改),而是改了会造成语义困惑——开发者期望改的是外部变量 a,但实际改的是内部副本,外部完全无感。编译器选择了"报错"而非"允许但行为诡异"。

类比: 值捕获就像你拍了一张照片。照片定格了那一瞬间的画面。之后现实场景怎么变化,照片里的内容不会变。而且你在照片上涂改也改不了现实。

4.3 对象类型的值捕获 —— 指针拷贝的微妙之处

对象类型的捕获容易让人困惑,因为需要区分"指针"和"指针所指的对象":

NSMutableArray *arr = [NSMutableArray new];

void(^block)(void) = ^{
    [arr addObject:@"hello"];  // ✅ 合法!
    arr = [NSMutableArray new]; // ❌ 编译报错!
};

为什么前者合法,后者报错?

Block 拷贝的是"指针的值"(即对象的地址),不是对象本身。

┌───── Block 结构体 ─────┐          ┌─── NSMutableArray ───┐
│                        │          │                      │
│  arr = 0x1234 (拷贝) ──┼────→     │  contents: [...]     │
│                        │          │                      │
└────────────────────────┘          └──────────────────────┘
                                          ↑
┌───── 外部栈帧 ─────────┐               │
│                        │               │
│  arr = 0x1234 (原件) ──┼───────────────┘
│                        │
└────────────────────────┘

两个 arr 指针虽然是独立副本,但都指向同一个 NSMutableArray 对象。
所以:
  - [arr addObject:] → 通过指针操作对象 → 合法(没改指针本身)
  - arr = xxx        → 修改指针本身 → 报错(和修改 int a 是一样的道理)

4.4 静态局部变量与全局变量 —— 为什么不需要捕获

为什么静态变量和全局变量不需要"捕获"?

全局变量:
  存储在数据段(Data Segment),地址在编译期确定
  程序任何地方都可以通过固定地址直接访问
  → Block 内直接用地址访问,不需要拷贝到结构体

静态局部变量:
  虽然作用域是局部的,但存储在数据段
  地址在编译期确定,生命周期是全程序
  → Block 捕获的是变量的指针(地址),不是值
  → 通过指针间接访问,所以能读也能改

  Block 结构体中存储的是:int *countPtr = &count;
  访问时:*(countPtr) = *(countPtr) + 1;

对比:
  局部变量 → 值拷贝(因为栈帧会销毁,地址会失效)
  静态变量 → 指针拷贝(地址永远有效)
  全局变量 → 不捕获(直接用符号地址)

类比:
  局部变量 → 别人白板上的草稿,你必须抄一份带走(值拷贝)
  静态变量 → 图书馆书架上的书,你只需要记住书架号(指针拷贝)
  全局变量 → 墙上的公告,不需要抄,大家都能看到(不捕获)

4.5 __block 修饰符的深层原理

__block 不是简单的"允许修改",它触发了一个复杂的底层变换

原始代码:                   编译器转换后:
                            
__block int a = 10;    →    struct __Block_byref_a {
                                void *__isa;
                                __Block_byref_a *__forwarding;  // 关键!
                                int __flags;
                                int __size;
                                int a;           // 真正的值
                            };
                            
                            __Block_byref_a a = {
                                .isa = 0,
                                .__forwarding = &a,  // 指向自己
                                .a = 10
                            };

理解 __block 的类比: 普通变量捕获就像你把书抄了一页带走——你在副本上改东西,原书不变。而 __block 就像把整本书放进一个共享文件柜(堆上的 __Block_byref 结构体),然后给所有需要的人配一把钥匙(指针)——大家打开柜子看到的、改的都是同一本书。

__forwarding 指针的精妙设计

╔══════════════════════════════════════════════════════════════════╗
║  __forwarding 存在的根本原因:                                     ║
║  解决"同一个 __block 变量可能同时被栈上代码和堆上 Block 访问"的问题   ║
╚══════════════════════════════════════════════════════════════════╝

阶段一:Block 还在栈上时

  Stack 栈帧
  ┌─────────────────────┐
  │ __Block_byref_a     │
  │ ├── __forwarding ───┼──→ 指向自己(栈上地址)
  │ └── a = 10          │
  └─────────────────────┘
  
  此时所有访问 a 的代码都通过:byref->__forwarding->a
  由于 __forwarding 指向自己,等价于 byref->a
  看起来多此一举?往下看——

阶段二:Block 被 copy 到堆上

  Stack                        Heap
  ┌─────────────────────┐     ┌─────────────────────┐
  │ __Block_byref_a     │     │ __Block_byref_a     │
  │ ├── __forwarding ───┼──┬──┼── __forwarding ───┼──→ 指向自己(堆上)
  │ └── a = 10          │  │  │ └── a = 10          │
  └─────────────────────┘  │  └─────────────────────┘
                           │         ↑
                           └─────────┘
                     栈上的 __forwarding 被修改!
                     现在指向堆上副本

  关键效果:
  - 栈上代码访问 a → byref_stack->__forwarding->a → 堆上的 a ✓
  - Block 内访问 a  → byref_heap->__forwarding->a  → 堆上的 a ✓
  - 两边修改的是同一个 a!

如果没有 __forwarding:
  - 栈上代码修改 byref_stack->a = 20,改的是栈上的副本
  - Block 内读取 byref_heap->a,读的是堆上的副本
  - 两边不一致!Bug!

类比: __forwarding 就像一个邮件转发地址。一开始你住在家里(栈上),所有信都寄到家里地址。后来你搬到公司(堆上),你在家里设置了邮件转发——所有寄到家里的信都会自动转到公司。这样无论别人往哪个地址寄信,最终都送到你手上(堆上)。

__block 变量被多个 Block 捕获时

__block int a = 10;

void(^block1)(void) = ^{ a = 20; };
void(^block2)(void) = ^{ a = 30; };

底层发生了什么?

block1 copy 到堆时:
  → __Block_byref_a 从栈 copy 到堆(第一次)
  → block1 结构体中保存指向堆上 byref 的指针

block2 copy 到堆时:
  → 发现 __Block_byref_a 已经在堆上了(通过 __forwarding 判断)
  → 不再重复 copy,直接引用计数 +1
  → block2 结构体中保存同一个堆上 byref 的指针

结果:
  block1 和 block2 共享同一个堆上的 __Block_byref_a
  修改的是同一个 a
  __Block_byref_a 的引用计数 = 2(被两个 Block 持有)

__block 与对象类型的特殊交互(MRC vs ARC)

__block 在 MRC 和 ARC 下对对象类型变量的行为完全不同!

MRC 下:
  __block id obj = [[NSObject alloc] init];
  → __Block_byref 结构体中的 obj 不会被 retain
  → 可以用来避免循环引用(因为不强引用)
  → 这是 MRC 时代避免循环引用的手段之一

ARC 下:
  __block id obj = [[NSObject alloc] init];
  → __Block_byref 结构体中的 obj 会被 strong 引用
  → 不能用来避免循环引用!
  → 这是很多从 MRC 迁移到 ARC 的项目踩过的坑

ARC 下避免循环引用应该用 __weak,不是 __block

总结:
  MRC: __block 不 retain 对象 → 可用于打破循环引用
  ARC: __block 会 retain 对象 → 不能打破循环引用,需要 __weak

4.6 对象类型变量的捕获与内存管理

捕获对象时的引用关系:

NSObject *obj = [[NSObject alloc] init];

void(^block)(void) = ^{
    NSLog(@"%@", obj);
};

┌──────────────────┐         ┌───────────────┐
│ Block (堆上)       │         │ NSObject 实例  │
│ ├── isa            │         │               │
│ ├── invoke         │         │  retainCount  │
│ └── obj (strong) ──┼────→    │  (被 Block +1) │
└──────────────────┘         └───────────────┘

Block copy 到堆时,会调用 descriptor 中的 copy_helper,
对捕获的对象执行 _Block_object_assign:
  - 强引用 (默认) → 等同于 retain
  - __weak 修饰    → 弱引用,不增加引用计数
  - __unsafe_unretained → 不增加引用计数,不置 nil(危险)

_Block_object_assign 的内部逻辑

void _Block_object_assign(void *destAddr, const void *object, int flags) {
    
    flags 决定行为:
    
    BLOCK_FIELD_IS_OBJECT (3):    // 捕获的是 OC 对象_Block_retain_object(object)
        → 等价于 [object retain] 或 objc_storeStrong
        → 如果是 __weak,走 objc_initWeak 路径
    
    BLOCK_FIELD_IS_BLOCK (7):     // 捕获的是另一个 Block_Block_copy(object)
        → 被捕获的 Block 也会被 copy 到堆上(递归 copy)
    
    BLOCK_FIELD_IS_BYREF (8):     // 捕获的是 __block 变量_Block_byref_copy(object)
        → 将 __block 结构体 copy 到堆上
        → 修改 __forwarding 指针
    
    BLOCK_FIELD_IS_WEAK (16):     // __weak 修饰的对象objc_initWeak(destAddr, object)
        → 注册到 SideTable 的弱引用表中
}

4.7 self 的捕获 —— 隐式 vs 显式

═══════════════════════════════════════════════════════════════
  self 捕获是循环引用的最大根源,必须深入理解
═══════════════════════════════════════════════════════════════

显式捕获(容易意识到):
  ^{ [self doSomething]; }     // 明确写了 self
  ^{ self.name = @"xxx"; }     // 明确写了 self

隐式捕获(容易忽略!):
  ^{ _name = @"xxx"; }         // 直接访问 ivar,编译器转为 self->_name
                                // 同样强引用捕获了 self!
  
  ^{ [self->_delegate call]; } // 同理

  ^{ _block(); }               // 如果 _block 是实例变量
                                // 也隐式捕获了 self

更隐蔽的情况:
  ^{ doSomething(); }          // 如果 doSomething 是当前类的方法
                                // 编译器转为 [self doSomething]
                                // 隐式捕获 self

  ^{ NSLog(@"%@", _array[0]); }  // _array 是 ivar → 捕获了 self

  ┌──────────────────────────────────────────────────────┐
  │ 规则:Block 内只要访问了实例变量或调用了实例方法,        │
  │       就一定捕获了 self,无论有没有写 "self." 前缀       │
  └──────────────────────────────────────────────────────┘

4.8 捕获变量的内存对齐

Block 结构体中捕获变量的排列遵循 C 语言的内存对齐规则:

假设捕获了以下变量:
  char c;     // 1 byte
  int i;      // 4 bytes  
  double d;   // 8 bytes

在 Block 结构体中的排列:
  
  偏移量 0~7:   Block_layout 固有字段(isa, flags 等)
  ...
  偏移量 N:     char c    (1 byte)
  偏移量 N+1~N+3: padding (3 bytes 填充,对齐到 4)
  偏移量 N+4:   int i     (4 bytes)
  偏移量 N+8:   double d  (8 bytes)

编译器的优化:
  有时编译器会重新排列捕获变量的顺序
  把大类型放前面,小类型放后面
  以减少 padding,使 Block 结构体更紧凑

这对日常开发影响不大,但在分析 crash log 中 Block 结构体
的内存布局时,理解对齐规则有助于定位问题。

五、循环引用的本质与解法

5.1 循环引用的形成

经典循环引用:

self → 强引用 → block → 强引用 → self

┌──────────┐  strong   ┌──────────┐  strong   ┌──────────┐
│  self     │─────────→│  Block   │─────────→│  self     │
│ (对象)     │          │ (堆上)    │          │ (同一个)   │
└──────────┘          └──────────┘          └──────────┘
      ↑                                          │
      └──────────────────────────────────────────┘
      引用计数永远不归零,两者都无法释放 → 内存泄漏

类比: 循环引用就像两个人互相握手不肯先松开——只要对方不松手我也不松手,结果两个人永远僵持在那里。__weak 就是让其中一个人用力气很小的方式搭在对方手上(不算真正的"握"),一旦对方松开,自己的手自动滑落。

5.2 复杂循环引用链(实际项目中更常见)

不是所有循环引用都是直接的 self  block  self

间接循环引用(三角环):
  self  viewModel  completionBlock  self
  
  ViewController 持有 ViewModel
  ViewModel 持有 completionBlock
  completionBlock 捕获了 ViewController(self)
   三者形成环,都无法释放

更隐蔽的多层环:
  self  manager  handler  service  callback  self

  ┌────────┐    ┌─────────┐    ┌─────────┐    ┌──────────┐
   self    │──→│ manager │──→│ handler │──→│ callback 
  └────────┘    └─────────┘    └─────────┘    └──────────┘
                                                  
      └────────────────────────────────────────────┘

NSTimer 的经典循环引用:
  self  timer (strong)
  timer  target: self (strong,NSTimer 强引用 target)
  timer  block (如果用 block API)

  NSTimer 的特殊性:
  - RunLoop 强引用 timer
  - timer 强引用 target (self)
  - 即使 self 不强引用 timer,RunLoop  timer  self 也会导致 self 不释放
  - 必须手动 invalidate timer 才能打破

NSTimer 循环引用的根本原因:
  ┌──────────────────────────────────────────────────────────┐
   NSTimer 的设计缺陷:它强引用 target 直到 invalidate     
   这打破了常规的"弱引用 delegate"范式                      
   解决方案:使用 iOS 10+ 的 block-based API + weakSelf,    
   或者使用 NSProxy 中间人模式                              
  └──────────────────────────────────────────────────────────┘

5.3 解决方案的原理对比

方案一:__weak(推荐)

self  strong  Block  weak  self
                         
                    弱引用不增加引用计数
                    self 释放后自动置 nil

特点:
   安全,self 释放后 weakSelf 自动为 nil
   Block 调用时需要判断 weakSelf 是否为 nil
  ⚠️ Block 执行过程中 self 可能随时被释放(多线程场景)

─────────────────────────────────────────

方案二:__weak + __strong(Weak-Strong Dance)

Block 外部:__weak typeof(self) weakSelf = self;
Block 内部:__strong typeof(weakSelf) strongSelf = weakSelf;

执行流程:
  ① Block 被调用
  ② strongSelf = weakSelf(如果 self 已释放,strongSelf = nil,提前 return)
  ③ strongSelf 临时持有 self,保证 Block 执行期间 self 不被释放
  ④ Block 执行完毕,strongSelf 出栈,临时强引用消失

特点:
   保证 Block 执行期间 self 存活
   Block 执行完后不阻止 self 释放
   最佳实践

─────────────────────────────────────────

方案三:__block + 手动置 nilMRC 遗留思路)

__block typeof(self) blockSelf = self;
self.block = ^{
    [blockSelf doSomething];
    blockSelf = nil;  // 手动打破循环
};

self  Block  blockSelf  self(执行后 blockSelf = nil 打破)

特点:
  ⚠️ Block 必须被执行才能打破循环
  ⚠️ 如果 Block 永远不被调用  内存泄漏
   不推荐

5.4 Weak-Strong Dance 的深层理解

为什么单纯 __weak 在某些场景下不够?

场景:Block 执行到一半时 self 被释放

__weak typeof(self) weakSelf = self;
self.block = ^{
    [weakSelf doStep1];    // ① weakSelf 非 nil,执行成功
    
    // ──── 此时另一个线程释放了 self ────
    
    [weakSelf doStep2];    // ② weakSelf 变成 nil!不执行了
    weakSelf.name = @"xx"; // ③ 也不执行
    // 步骤不完整,数据可能处于不一致状态
};

加了 __strong 后:

__weak typeof(self) weakSelf = self;
self.block = ^{
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) return;  // self 已死,整体不执行
    
    [strongSelf doStep1];     // ✅ 安全
    [strongSelf doStep2];     // ✅ 安全(strongSelf 临时持有,self 不会中途释放)
    strongSelf.name = @"xx";  // ✅ 安全
    
    // Block 执行完,strongSelf 出栈,临时强引用消失
    // 不影响 self 的正常释放
};

关键理解:
  __strong 创建的是一个临时的、局部的强引用
  它只在 Block 执行期间生效
  Block 不执行时,它不存在,不会造成循环引用
  Block 执行时,它临时延长 self 的生命周期
  Block 执行完,它随着栈帧销毁而消失

类比: Weak-Strong Dance 就像电影院的座位预留机制。__weak 是"不预留座位"——你来了有空位就坐,来晚了位子可能被撤了。__strong 是在进场(Block 开始执行)时确认一下"这个座位还在不在",如果在就暂时锁定它,看完电影(Block 执行完)自动解锁。

5.5 不是所有 Block 都会循环引用

不会循环引用的场景:
├── UIView 动画 Block
│   └── [UIView animateWithDuration:animations:] 
│       系统持有 Block,Block 引用 self,但 self 不持有 Block
│       → 单向引用,不成环
│
├── GCD 一次性 Block
│   └── dispatch_async(queue, ^{ self.xxx; });
│       GCD 持有 Block(直到执行完),Block 引用 self
│       self 不持有 GCD 的 Block → 不成环
│       ⚠️ 但如果 dispatch_after 延时很长,self 的释放会被推迟
│
├── 局部变量 Block
│   └── void(^block)(void) = ^{ self.xxx; }; block();
│       block 是局部变量,函数结束即销毁 → 不成环
│
├── NSArray/NSDictionary 的 enumerate Block
│   └── [array enumerateObjectsUsingBlock:^{ self.xxx; }];
│       Block 同步执行完即释放 → 不成环
│
├── Masonry / SnapKit 的约束 Block
│   └── [view mas_makeConstraints:^{ make.top.equalTo(self.view); }];
│       Block 同步执行完即释放 → 不成环
│
└── 判断标准:
    ┌─────────────────────────────────────────────────┐
    │ 画一条从 self 出发的"持有链"                       │
    │ 如果能绕回 self → 循环引用                         │
    │ 如果不能绕回 self → 安全                           │
    │                                                  │
    │ self → 属性 → Block → self (环!)                │
    │ 系统 → Block → self (不成环,安全)                │
    │ 局部 → Block → self (不成环,安全)                │
    └─────────────────────────────────────────────────┘

5.6 循环引用的检测方法

检测循环引用的实用手段:

1. dealloc 日志法(最简单)
   在类的 dealloc 方法中打印日志
   如果页面退出后看不到日志 → 该对象没有被释放 → 可能存在循环引用

2. Instruments - Leaks
   Xcode 自带工具,能自动检测泄漏的对象
   可以看到泄漏对象的引用关系图
   局限:不是所有循环引用都会被 Leaks 检测到

3. Instruments - Allocations(更可靠)
   查看对象的生命周期(分配和释放历史)
   如果一个对象只有 alloc 没有 dealloc → 泄漏
   可以按类名过滤,非常方便

4. Memory Graph Debugger(推荐)
   Xcode 调试时点击左下角的 Memory Graph 按钮
   会以图形方式展示所有对象的引用关系
   循环引用会被清晰地标注出来(紫色警告图标)
   ⭐ 最直观的检测方式

5. 第三方工具
   MLeaksFinder(腾讯开源):自动检测 UIViewController 的泄漏
   FBRetainCycleDetector(Facebook 开源):运行时检测循环引用
   两者可以配合使用
   
6. Debug Memory Graph + lldb
   在 Memory Graph 中选中可疑对象
   在 lldb 中执行 po 命令查看对象详情
   使用 malloc_history 命令追踪对象分配堆栈

排查思路流水线:
  dealloc 没触发 → Memory Graph 看引用关系 → 找到环 → 分析哪个引用应该用 weak

六、Block 与内存管理的进阶话题

6.1 Block 的 copy 语义链

Block copy 时发生的事情(连锁反应):

Block copy 到堆上
    │
    ├── Block 结构体从栈拷贝到堆(malloc + memcpy)
    │
    ├── 调用 descriptor->copy_helper
    │   │
    │   ├── 对捕获的 OC 对象执行 _Block_object_assign
    │   │   ├── strong 对象 → retain(引用计数 +1)
    │   │   ├── weak 对象   → objc_initWeak(注册弱引用)
    │   │   └── block 对象  → 递归 _Block_copy
    │   │
    │   └── 对 __block 变量执行 _Block_object_assign
    │       ├── __block 结构体从栈 copy 到堆
    │       ├── 修改栈上 __forwarding 指向堆上副本
    │       └── 对 __block 内部的 OC 对象执行相应的 retain/weak
    │
    └── 修改 isa 指针:_NSConcreteStackBlock → _NSConcreteMallocBlock
        修改 flags:设置 BLOCK_NEEDS_FREE 位,引用计数初始化为 1

6.2 Block 的 dispose 语义链

Block 引用计数归零时:

Block release → retainCount == 0
    │
    ├── 调用 descriptor->dispose_helper
    │   │
    │   ├── 对捕获的 OC 对象执行 _Block_object_dispose
    │   │   ├── strong 对象 → release(引用计数 -1)
    │   │   ├── weak 对象   → objc_destroyWeak(注销弱引用)
    │   │   └── block 对象  → 递归 _Block_release
    │   │
    │   └── 对 __block 变量执行 _Block_object_dispose
    │       └── __block 结构体引用计数 -1,归零则:
    │           ├── 对内部 OC 对象执行 release/destroyWeak
    │           └── free(__block 结构体)
    │
    └── free(block) 释放 Block 堆内存

6.3 Block 的 retain/release 实现细节

_Block_copy 的内部逻辑(简化版):

void *_Block_copy(const void *arg) {
    struct Block_layout *src = (struct Block_layout *)arg;
    
    if (src->flags & BLOCK_NEEDS_FREE) {
        // 已经在堆上了 → 只增加引用计数
        latching_incr_int(&src->flags);  // flags 中的引用计数 +1
        return src;
    }
    
    if (src->flags & BLOCK_IS_GLOBAL) {
        // 全局 Block → 什么都不做,返回自身
        return src;
    }
    
    // 栈上 Block → 拷贝到堆上
    struct Block_layout *dst = malloc(src->descriptor->size);
    memmove(dst, src, src->descriptor->size);      // 整体内存拷贝
    
    dst->isa = _NSConcreteMallocBlock;              // 改 isa
    dst->flags |= BLOCK_NEEDS_FREE;                 // 标记为堆 Block
    dst->flags = (dst->flags & ~0xFFFF) | 1;        // 引用计数 = 1
    
    if (dst->flags & BLOCK_HAS_COPY_DISPOSE) {
        dst->descriptor->copy_helper(dst, src);      // 处理捕获变量
    }
    
    return dst;
}

性能洞察:
  - Block copy 涉及 malloc + memmove + 可能的多次 retain
  - 这就是为什么频繁创建和 copy Block 有性能开销
  - 也是为什么 GCD 内部对 Block 的处理做了大量优化

6.4 Block 属性用 copy 还是 strong?

MRC 时代:
  @property (nonatomic, copy) void(^block)(void);
  
  必须用 copy!
  如果用 retain,Block 仍然在栈上,函数返回后 Block 失效 → 野指针 crash
  copy 会把 Block 从栈拷贝到堆上,延长生命周期

ARC 时代:
  @property (nonatomic, copy) void(^block)(void);
  @property (nonatomic, strong) void(^block)(void);
  
  两者效果完全相同!
  ARC 编译器对 Block 赋值时自动插入 _Block_copy
  无论你写 copy 还是 strong,底层都会执行 copy 操作

  但惯例上仍然写 copy,原因:
  ① 代码自文档化——看到 copy 就知道"这是 Block,有特殊的内存语义"
  ② 向后兼容——万一哪天代码被挪到 MRC 环境也能正确工作
  ③ 团队共识——Apple 官方文档和社区都推荐 copy

  ┌─────────────────────────────────────────────────┐
  │ ARC 下用 strong 也完全正确,但写 copy 更规范     │
  └─────────────────────────────────────────────────┘

七、Block 与线程安全

7.1 Block 本身的线程安全性

Block 对象一旦创建完成(copy 到堆上后),其内部状态是只读的。
invoke 指针、descriptor、捕获的变量值都不会再变。

因此:
  ✅ 多线程同时调用(invoke)同一个 Block → 安全(只读操作)
  ✅ 多线程同时对同一个 Block 做 retain/release → 安全
     (引用计数操作是原子的,使用了 OSAtomicCompareAndSwapInt)
  ❌ 如果 Block 捕获了可变对象,多线程调用时对该对象的修改 → 不安全

  ┌──────────────────────────────────────────────────────────┐
  │ Block 的"壳"是线程安全的,但"内容物"不一定是。              │
  │ 就像一个上了锁的保险箱(Block)里面放了一把没有安全锁的刀    │
  │ (NSMutableArray)——保险箱是安全的,但刀可以伤人。            │
  └──────────────────────────────────────────────────────────┘

7.2 捕获变量的线程安全问题

场景一:多个线程通过 Block 读写同一个 __block 变量

  __block int counter = 0;
  
  for (int i = 0; i < 1000; i++) {
      dispatch_async(concurrentQueue, ^{
          counter++;  // 多线程同时 ++ → 数据竞争!结果不可预期
      });
  }
  
  问题:counter++ 不是原子操作(读-改-写三步)
  解决:用 dispatch_barrier_async 或 @synchronized 或 os_unfair_lock

场景二:Block 捕获的对象在另一个线程被释放

  __weak typeof(self) weakSelf = self;
  dispatch_async(bgQueue, ^{
      // 此时 self 可能已经被主线程释放
      [weakSelf doSomething];  // weakSelf 可能为 nil → 消息发给 nil,安全但无效
      
      NSLog(@"%@", weakSelf.name); // 同理,可能返回 nil
  });
  
  这不是 crash,但可能导致逻辑不正确
  → 需要 Weak-Strong Dance

场景三:Block 中修改捕获的可变集合

  NSMutableArray *arr = [NSMutableArray new];
  
  dispatch_async(queue1, ^{ [arr addObject:@"A"]; });
  dispatch_async(queue2, ^{ [arr addObject:@"B"]; });
  
  两个 Block 捕获同一个 arr 指针(指向同一个可变数组)
  同时修改 → crash(NSMutableArray 非线程安全)
  
  解决:
  ① 使用串行队列保护
  ② 每个 Block 使用独立的 copy
  ③ 使用并发队列 + barrier

7.3 Block 与 GCD 的线程交互模式

常见模式及其线程安全分析:

模式 1:主线程 → 后台 → 回主线程

  dispatch_async(bgQueue, ^{
      id result = [self heavyComputation];  // 后台线程
      
      dispatch_async(dispatch_get_main_queue(), ^{
          self.label.text = result;          // 主线程更新 UI
      });
  });
  
  线程安全性:
  - heavyComputation 在后台线程执行 → 不能操作 UI
  - result 是局部变量,被内层 Block 值捕获 → 安全
  - 内层 Block 在主线程执行 → 可以操作 UI ✓
  - 注意:self 被两层 Block 捕获 → 是否循环引用取决于 self 是否持有 queue

模式 2:dispatch_group 汇聚多个异步任务

  dispatch_group_t group = dispatch_group_create();
  __block NSArray *data1, *data2;
  
  dispatch_group_async(group, queue, ^{ data1 = [self fetchData1]; });
  dispatch_group_async(group, queue, ^{ data2 = [self fetchData2]; });
  
  dispatch_group_notify(group, mainQueue, ^{
      [self updateUIWithData1:data1 data2:data2];  // 两个任务都完成后
  });
  
  线程安全性:
  - data1 和 data2 用 __block 修饰,多个 Block 共享同一个堆上变量
  - 但因为两个 async 的 Block 各写各的变量 → 不冲突
  - notify 的 Block 在两个 async 都完成后才执行 → 此时 data1/data2 已写入
  - 安全 ✓(但如果多个 Block 写同一个变量就不安全了)

八、Block 在底层框架中的角色

8.1 GCD 中的 Block

dispatch_async(queue, block)

执行流程:
    │
    ├── 1. Block 被 copy 到堆上(GCD 内部调用 _Block_copy)
    │
    ├── 2. Block 被封装进 dispatch_continuation_t 结构体
    │      ┌──────────────────────────────┐
    │      │ dispatch_continuation_t       │
    │      │ ├── do_vtable (虚表指针)       │
    │      │ ├── dc_func (执行函数)         │
    │      │ ├── dc_ctxt (Block 指针)       │
    │      │ ├── dc_voucher              │
    │      │ └── dc_priority             │
    │      └──────────────────────────────┘
    │      放入 queue 的 FIFO 链表
    │
    ├── 3. 线程池中的 worker thread 取出 continuation
    │      调用 dc_func(dc_ctxt) → block->invoke(block)
    │
    └── 4. 执行完毕后 _Block_release(block)
           Block 引用计数 -1,归零则触发 dispose 链

dispatch_sync 的差异:
  - 同步调用不需要 copy Block(Block 在调用者栈上即可)
  - 调用者线程阻塞等待,Block 在 queue 的线程上执行
  - 执行完后调用者才继续,此时 Block 仍然有效
  - 但要注意死锁:在串行队列中 dispatch_sync 到同一队列 → 死锁!

8.2 RunLoop 中的 Block

CFRunLoopPerformBlock(runloop, mode, block)

    │
    ├── Block 被 copy 到堆上
    ├── 挂载到 RunLoop 指定 mode 的 _blocks 链表
    ├── RunLoop 在对应 mode 的迭代中遍历链表执行
    └── 执行后从链表移除并 release

RunLoop 与 Block 的生命周期关系:
  - Block 提交后到执行前,一直被 RunLoop 持有
  - 如果 RunLoop 切换到其他 mode,Block 不会执行
  - 如果 RunLoop 退出,未执行的 Block 会被 release

performSelector:withObject:afterDelay: 的底层也是 RunLoop + Timer + Block

8.3 Notification/KVO 中的 Block

id token = [[NSNotificationCenter defaultCenter]
    addObserverForName:@"xxx"
    object:nil
    queue:nil
    usingBlock:^(NSNotification *note) {
        // 这个 Block 被 NotificationCenter 持有
        // 直到 removeObserver 才释放
    }];

生命周期陷阱:
  NotificationCenter → observer(内部对象) → Block → self
                                                     ↑
  如果 self 持有 token 并在 dealloc 中 removeObserver:
  - self 的 dealloc 永远不会调用(因为 Block 强引用 self)
  - token 永远不会被 remove
  → 经典死锁式内存泄漏

解决:Block 内必须用 weakSelf

8.4 Block 作为 Associated Object

objc_setAssociatedObject(self, key, block, OBJC_ASSOCIATION_COPY_NONATOMIC);

  - COPY 策略会调用 _Block_copy
  - Block 被关联到对象上,对象释放时 Block 才释放
  - 如果 Block 捕获了该对象 → 循环引用!
  - 这是很多第三方库(如方法交换添加 Block 回调)的常见泄漏原因

8.5 Block 在 KVO 中的新 API

iOS 11+ 提供了基于 Block 的 KVO API:

  self.observation = [self.model observe:@selector(name) 
                                 options:NSKeyValueObservingOptionNew 
                                 changeHandler:^(Model *model, ...) {
      // 注意:Block 参数直接给了被观察对象,不需要 self
      // Apple 有意设计成不需要捕获 self
      NSLog(@"%@", model.name);
  }];

  self.observation 持有 observation token
  observation token 持有 Block
  Block 引用 model(不是 self)
  → 不形成循环引用
  → observation token 在 self dealloc 时被释放
  → 自动移除观察者

这个设计是 Apple 总结了无数 KVO 内存泄漏问题后的改良方案。

九、Block 的性能考量

9.1 Block 的开销分析

Block 的性能开销来源:

1. 创建开销
   ├── 栈 Block:几乎为零(只是栈指针移动 + 结构体初始化)
   ├── 堆 Block:malloc + memcpy + 可能的多次 retain
   └── 全局 Block:零开销(编译期确定)

2. 调用开销
   ├── 通过函数指针间接调用(和 C 函数指针相同)
   ├── 比 OC 方法调用快(没有 objc_msgSend 的查找过程)
   ├── 比直接函数调用慢(多一次指针解引用)
   └── 和 C++ 虚函数调用类似的性能级别

3. 销毁开销
   ├── 栈 Block:零(栈帧弹出即可)
   ├── 堆 Block:dispose_helper + free + 可能的多次 release
   └── 全局 Block:永不销毁

性能对比(从快到慢):
  直接函数调用 ≈ 内联 Block
  > C 函数指针调用 ≈ Block 调用
  > objc_msgSend(方法调用)
  > performSelector

9.2 编译器对 Block 的优化

编译器在开启优化时(-O1 及以上)会对 Block 做以下优化:

1. 内联优化
   如果 Block 在定义后立即调用且只使用一次
   编译器可能将其内联,消除 Block 开销

2. 栈提升为全局
   如果 Block 不捕获变量,即使写在函数内部
   编译器也会将其提升为 GlobalBlock

3. copy 消除
   如果编译器能证明 Block 不会逃逸出当前作用域
   可能跳过 copy 操作

4. 捕获变量合并
   多个 Block 捕获相同变量时,编译器可能优化内存布局

9.3 大量 Block 场景的性能优化建议

场景:高频回调(如滚动监听、动画帧回调)

问题:
  每次回调都创建新 Block → 频繁 malloc/free
  Block 捕获大量对象 → 频繁 retain/release

优化策略:

  ① 复用 Block:将 Block 保存为属性,避免重复创建
     // Bad:每次滚动都创建新 Block
     scrollView.didScroll = ^{ [self handleScroll]; };
     
     // Good:初始化时创建一次
     self.scrollHandler = ^{ [weakSelf handleScroll]; };
     scrollView.didScroll = self.scrollHandler;

  ② 减少捕获变量:只捕获真正需要的变量
     // Bad:隐式捕获整个 self(包含所有 ivar 的引用)
     ^{ _array = ...; _dict = ...; }
     
     // Good:只传入需要的对象
     NSMutableArray *arr = _array;
     ^{ [arr addObject:...]; }

  ③ 考虑用函数指针替代 Block(极致性能场景)
     在 C 层面的高频回调中,函数指针比 Block 更轻量
     因为没有结构体创建、copy、dispose 的开销
     
  ④ 使用 dispatch_block_create 的 DISPATCH_BLOCK_NO_QOS_CLASS 标志
     避免 QoS 传播的额外开销

十、Block 的调试技巧

10.1 在运行时识别 Block 类型

调试时经常需要确认 Block 的类型和捕获信息:

lldb 命令:
  (lldb) po block
  → 输出 Block 的描述,包含类型信息

  (lldb) po [block class]
  → __NSGlobalBlock__ / __NSStackBlock__ / __NSMallocBlock__

  (lldb) po [block superclass]
  → NSBlock

  (lldb) memory read --size 8 --count 5 (void *)block
  → 读取 Block 结构体的前 5 个字段(isa, flags, reserved, invoke, descriptor)

  (lldb) po (void *)((void **)block)[3]
  → 读取 invoke 函数指针地址

  (lldb) image lookup -a <invoke 地址>
  → 反查 invoke 函数对应的源代码位置
     通常输出类似:__ClassName_methodName_block_invoke

10.2 在汇编层面识别 Block 调用

Block 调用在 ARM64 汇编中的特征:

Block 创建:
  通常会看到 __copy_helper_block_ 和 __destroy_helper_block_ 的引用
  以及 ___block_descriptor_ 相关的符号

Block 调用:
  ldr x8, [x0, #16]    // 从 Block 结构体偏移 16 字节处加载 invoke 指针
  blr x8                // 跳转到 invoke 函数执行
  
  ↑ 这两条指令是 Block 调用的标志性模式
  x0 既是 Block 指针,也作为 invoke 的第一个参数(隐含 self)

Block 捕获变量访问:
  在 invoke 函数内部,通过 x0(Block 指针)+ 偏移量 来访问捕获的变量
  ldr x8, [x0, #32]    // 访问第一个捕获变量(偏移量取决于结构体布局)

10.3 排查 Block 相关的 Crash

常见 Block Crash 类型及排查方法:

1. EXC_BAD_ACCESS —— 调用已释放的 Block
   原因:栈 Block 在栈帧销毁后被调用
   特征:crash 在 block->invoke(block, ...) 处
   排查:检查 Block 是否被正确 copy 到堆上
         MRC 下尤其常见

2. EXC_BAD_ACCESS —— Block 内访问已释放的对象
   原因:Block 用 __unsafe_unretained 捕获了一个已释放的对象
   特征:crash 在 Block 内部的 objc_msgSend 处
   排查:将 __unsafe_unretained 改为 __weak

3. Block 为 nil 时调用 → 直接 crash
   原因:Block 指针为 nil 时,调用会触发 EXC_BAD_ACCESS
         因为底层是 block->invoke(block),nil 解引用
   特征:crash 地址通常是 0x10 附近(nil + invoke 偏移量)
   排查:调用前判空 → if (block) { block(); }
   
   ┌──────────────────────────────────────────────────┐
   │ OC 方法调用可以安全地发给 nil → [nil doSomething]    │
   │ Block 调用不能发给 nil → nil() 会 crash!            │
   │ 这是一个容易被忽略的差异。                             │
   └──────────────────────────────────────────────────┘

4. 野 Block(Block 指针指向已被回收的内存)
   原因:使用 __unsafe_unretained 接收 Block,Block 被释放后指针未置 nil
   特征:crash 地址随机,表现不稳定
   排查:不要用 __unsafe_unretained 存储 Block

10.4 使用 clang 查看 Block 编译后的 C++ 代码

在终端中执行以下命令,可以看到 Block 被编译器转换后的 C++ 代码:

  clang -rewrite-objc main.m -o main.cpp

这会把所有 Block 转换为对应的结构体和函数,帮助你理解底层原理。

输出中你会看到:
  - __main_block_impl_0 结构体(Block 的结构体)
  - __main_block_func_0 函数(Block 的 invoke 函数)
  - __main_block_desc_0 结构体(Block 的 descriptor)
  - __Block_byref_xxx 结构体(__block 变量的结构体)

注意:
  这个命令生成的代码是简化版的,和 ARC 实际编译结果有差异。
  它主要用于学习和理解原理,不是 100% 精确的编译输出。
  如果需要精确的汇编输出,使用 Xcode 的 Product → Perform Action → Assemble。

十一、Block 与 Swift 的桥接

11.1 OC Block 在 Swift 中的映射

OC Block 类型与 Swift 闭包类型的对应关系:

  OC:    void(^)(void)                → Swift: () -> Void
  OC:    void(^)(int, NSString *)     → Swift: (Int32, String) -> Void
  OC:    NSString *(^)(int)           → Swift: (Int32) -> String
  OC:    void(^)(BOOL *stop)          → Swift: (UnsafeMutablePointer<ObjCBool>) -> Void

桥接规则:
  ① OC 的 Block 类型自动桥接为 Swift 的 @convention(block) 闭包
  ② 参数和返回值类型按照 Swift-OC 桥接规则转换
  ③ nullable Block 映射为 Optional 闭包

在 Swift 中使用 OC Block API:
  OC 定义:
    - (void)fetchDataWithCompletion:(void(^)(NSArray *data, NSError *error))completion;
  
  Swift 调用:
    obj.fetchData { data, error in
        // data 是 [Any]?,error 是 Error?
    }

逃逸标注的影响:
  OC 中标注了 NS_NOESCAPE 的 Block 参数 → Swift 中映射为非逃逸闭包
  OC 中未标注的 Block 参数 → Swift 中映射为 @escaping 闭包

这意味着:
  如果 OC API 的 Block 参数是同步使用的,应该标注 NS_NOESCAPE
  这样 Swift 调用者不需要写 self.(非逃逸闭包不捕获 self 的强引用)

11.2 Swift 闭包在 OC 中的使用

Swift 闭包可以桥接到 OC Block,但有限制:

可以桥接的:
  ✅ 不捕获泛型类型的闭包
  ✅ 使用 @convention(block) 标注的闭包
  ✅ 返回值和参数都是 OC 可表达类型

不能桥接的:
  ❌ 捕获了 Swift 特有类型(如 struct、enum with associated values)
  ❌ 使用了 Swift only 的特性(如 throws、async)
  
@convention(block) 的作用:
  告诉 Swift 编译器:"把这个闭包按照 OC Block 的 ABI 来生成"
  而不是 Swift 原生闭包的 ABI

  let block: @convention(block) (Int) -> String = { num in
      return "\(num)"
  }
  
  这个闭包现在是一个合法的 OC Block 对象
  可以传给任何接受 Block 的 OC API

11.3 Swift 闭包 vs OC Block 的关键差异

┌───────────────────┬─────────────────────┬────────────────────────┐
   维度                OC Block              Swift 闭包            
├───────────────────┼─────────────────────┼────────────────────────┤
 默认捕获方式        值捕获                引用捕获                
 修改外部变量        需要 __block           默认就可以              
 存储位置          //全局             堆上(编译器优化除外)    
 逃逸标注           无(手动注意)          @escaping 编译器强制     
 循环引用处理        weakSelf/strongSelf   [weak self] 捕获列表     
 nil 安全           nil Block 调用 crash   Optional 闭包安全       
 类型系统           弱类型(仅 runtime)    强类型(编译期检查)     
 内存管理           ARC + 手动 copy       ARC                 
└───────────────────┴─────────────────────┴────────────────────────┘

Swift 的设计吸取了 OC Block 的教训:
  - 默认引用捕获  不需要 __block 的心智负担
  - @escaping 强制标注  编译期就发现逃逸问题
  - 闭包直接在堆上  没有栈堆迁移的复杂性
  - 捕获列表 [weak self]  比 weakSelf/strongSelf 更简洁
  - Optional 闭包  nil 安全,不会 crash

十二、Block 的常见坑与反模式

12.1 十大常见 Block 陷阱

陷阱 1:忘记 Block 可以为 nil
  self.completionHandler(result);
  // 如果 completionHandler 为 nil → crash!
  
  正确做法:
  if (self.completionHandler) {
      self.completionHandler(result);
  }

─────────────────────────────────────────

陷阱 2:在 dealloc 中依赖 weakSelf
  __weak typeof(self) weakSelf = self;
  self.block = ^{
      [weakSelf cleanup];  // dealloc 流程中 weakSelf 可能已经是 nil!
  };
  
  dealloc 时 weak 引用可能已经被清零(取决于时机)

─────────────────────────────────────────

陷阱 3:Block 内创建局部 strong 引用后以为不会循环引用
  __weak typeof(self) weakSelf = self;
  self.block = ^{
      __strong typeof(weakSelf) strongSelf = weakSelf;
      strongSelf.anotherBlock = ^{
          [strongSelf doSomething];  // strongSelf 是局部变量,但被内层 Block 捕获
          // self → block → 外层 Block → strongSelf 被内层 Block 捕获为强引用
          // 内层 Block 被赋值给 self.anotherBlock → 形成循环引用!
      };
  };
  
  Weak-Strong Dance 只保护一层,嵌套 Block 需要再次 weak!

─────────────────────────────────────────

陷阱 4:在栈 Block 出作用域后使用(MRC)
  void(^block)(void);
  {
      int x = 42;
      block = ^{ NSLog(@"%d", x); };
  }
  block();  // crash!Block 在栈上,出作用域后失效

─────────────────────────────────────────

陷阱 5:误以为 copy 会创建独立副本
  void(^block)(void) = ^{ NSLog(@"hello"); };
  void(^block2)(void) = [block copy];
  // block2 == block(堆上 Block copy 只增加引用计数)
  // 不像 NSMutableArray copy 会创建新对象

─────────────────────────────────────────

陷阱 6:dispatch_after 的 Block 延长了对象生命周期
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC), 
                 dispatch_get_main_queue(), ^{
      [self doSomething];  // self 至少活到 30 秒后
  });
  
  用户关闭页面后,ViewController 30 秒内不会被释放
  这不是"循环引用",但是"生命周期延长"

─────────────────────────────────────────

陷阱 7:在 Block 内使用 C 数组
  int arr[3] = {1, 2, 3};
  void(^block)(void) = ^{
      NSLog(@"%d", arr[0]);  // 编译错误!C 数组不能被 Block 捕获
  };
  
  C 数组不是一等公民,不能被值拷贝
  解决:使用 __block 修饰,或者用 NSArray/指针代替

─────────────────────────────────────────

陷阱 8:同步 Block 中的 return 语义
  - (BOOL)check {
      [array enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {          if ([obj isKindOfClass:[NSString class]]) {
              return;  // 这个 return 只退出 Block,不退出 check 方法!
          }
      }];
      return NO;  // 无论如何都会执行到这里
  }
  
  Block 内的 return 退出的是 Block,不是外层方法

─────────────────────────────────────────

陷阱 9:多次调用一次性 Block
  if (self.completion) {
      self.completion(result);
      self.completion(result);  // 二次调用!可能导致重复操作
  }
  
  最佳实践:调用后立即置 nil
  if (self.completion) {
      void(^completion)(id) = self.completion;
      self.completion = nil;
      completion(result);
  }

─────────────────────────────────────────

陷阱 10:忽略 Block 的 copy 开销(性能敏感场景)
  for (int i = 0; i < 10000; i++) {
      self.handler = ^{ ... };  // 每次循环都创建新 Block 并 copy 到堆上
  }
  
  在热路径中频繁创建和赋值 Block 可能导致性能问题
  解决:Block 不变时在循环外创建一次

12.2 Block 的最佳实践总结

✅ DO(推荐做法):
  1. Block 属性用 copy(即使 ARC 下 strong 等效,copy 更清晰)
  2. 使用 Weak-Strong Dance 处理循环引用
  3. 调用 Block 前检查是否为 nil
  4. Completion Block 调用后置 nil
  5. 使用 typedef 为复杂 Block 类型定义别名
  6. API 中的 Block 参数注明是否逃逸(NS_NOESCAPE)
  7. 嵌套 Block 中的每一层都检查循环引用

❌ DON'T(避免做法):
  1. 不要在 Block 内直接访问 ivar(容易隐式捕获 self)
  2. 不要用 __unsafe_unretained 代替 __weak(野指针风险)
  3. 不要在 ARC 下用 __block 来打破循环引用(ARC 下 __block 会 retain)
  4. 不要在不确定是否为 nil 的情况下直接调用 Block
  5. 不要在高频回调中频繁创建新 Block
  6. 不要在 Block 内做耗时操作而不考虑线程
  7. 不要忘记 NSTimer/NotificationCenter 的 Block 生命周期管理

十三、Block vs Delegate vs Notification —— 如何选择

三种回调机制的对比:

┌──────────────┬──────────────┬──────────────┬──────────────┐
│   维度        │   Block       │   Delegate    │ Notification │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ 关系         │ 一对一        │ 一对一        │ 一对多       │
│ 耦合度       │ 高(代码内联)│ 低(协议隔离)│ 最低(解耦)  │
│ 代码位置     │ 就地书写      │ 分散在方法中  │ 分散 + 跨文件│
│ 类型安全     │ 中            │ 高            │ 低           │
│ 内存风险     │ 循环引用      │ 较少          │ 忘记 remove  │
│ 适合场景     │ 一次性回调    │ 多方法协议    │ 广播通知     │
│ 调试难度     │ 较容易        │ 中            │ 难追踪       │
└──────────────┴──────────────┴──────────────┴──────────────┘

选择建议:

  单一回调(如网络请求完成)→ Block
    优势:代码集中,上下文清晰
    例:[api fetchDataWithCompletion:^(Data *d) { ... }];

  多个回调方法(如 UITableView 的数据源)→ Delegate
    优势:职责明确,方法独立,可选实现
    例:UITableViewDataSource 有多个 required/optional 方法

  一对多广播(如登录状态变化通知所有页面)→ Notification
    优势:完全解耦,任意对象可监听
    例:用户登出后通知所有页面刷新

  混合使用:实际项目中经常组合使用
    例:网络层用 Block 回调给业务层,
        业务层通过 Notification 广播给 UI 层

十四、知识体系脑图

Block
├── 本质
│   ├── C 语言扩展(Clang 实现)
│   ├── 匿名函数 + 上下文捕获
│   ├── 底层是 OC 对象(有 isa)
│   ├── 编译期变换:源码 → 结构体 + 函数
│   └── 默认值捕获的设计哲学(Pit of Success)
│
├── 底层结构
│   ├── Block_layout 结构体
│   │   ├── isa → 类型标识(Global/Stack/Malloc)
│   │   ├── flags → 位域(引用计数 + 多种标志)
│   │   ├── invoke → 函数指针(隐含 self 参数)
│   │   └── descriptor → 多态结构(size/copy/dispose/signature)
│   ├── 捕获变量存储在结构体尾部(动态大小)
│   └── 类型编码(Type Encoding)与 NSMethodSignature
│
├── 三种类型
│   ├── GlobalBlock(不捕获局部变量,在数据段)
│   ├── StackBlock(捕获变量,在栈上,生命周期同栈帧)
│   ├── MallocBlock(copy 后在堆上,引用计数管理)
│   ├── 为什么有栈?性能优化,避免不必要的 malloc
│   └── 逃逸与非逃逸的概念
│
├── 变量捕获(核心难点)
│   ├── 局部基本类型 → 值拷贝(快照语义)
│   ├── 局部对象 → 指针拷贝 + ARC 内存管理(strong/weak)
│   ├── __block → 封装为 __Block_byref 结构体
│   │   ├── __forwarding 保证栈堆访问一致性
│   │   ├── 多 Block 共享时引用计数管理
│   │   └── MRC vs ARC 下对对象的不同行为
│   ├── 静态变量 → 指针捕获(地址不变,无需拷贝值)
│   ├── 全局变量 → 不捕获(直接按地址访问)
│   └── self 的隐式捕获(访问 ivar 也会捕获 self)
│
├── 循环引用
│   ├── 本质:强引用环导致引用计数无法归零
│   ├── 直接环 vs 间接环(多层持有链)
│   ├── __weak(打破强引用)
│   ├── __weak + __strong(Weak-Strong Dance,保证执行完整性)
│   ├── NSTimer 的特殊循环引用
│   ├── 判断标准:从 self 出发能否画回 self
│   └── 检测方法(Memory Graph / Instruments / MLeaksFinder)
│
├── 内存管理
│   ├── copy 链:malloc → memcpy → copy_helper → retain/weak/递归copy
│   ├── dispose 链:dispose_helper → release/destroyWeak → free
│   ├── 引用计数存储在 flags 位域中(非 SideTable)
│   ├── ARC 下编译器自动 copy5 种场景)
│   └── copy vs strong 属性修饰符的选择
│
├── 线程安全
│   ├── Block 对象本身的线程安全性(只读 + 原子引用计数)
│   ├── 捕获变量的线程安全问题
│   ├── __block 变量的数据竞争
│   └── GCD + Block 的常见线程安全模式
│
├── 性能
│   ├── 创建:栈≈0 / 堆=malloc+copy / 全局=0
│   ├── 调用:比 objc_msgSend 快,比直接调用慢一点
│   ├── 销毁:栈=0 / 堆=dispose+free
│   ├── 编译器优化:内联、全局提升、copy 消除
│   └── 高频场景的优化建议
│
├── 框架应用
│   ├── GCD(dispatch_continuation_t 封装)
│   ├── RunLoop(Block 链表管理)
│   ├── Notification/KVO(Block 生命周期陷阱)
│   ├── Associated Object(COPY 策略注意循环引用)
│   └── KVO 新 API 的设计改进
│
├── 调试技巧
│   ├── lldb 命令查看 Block 类型和捕获信息
│   ├── 汇编层面识别 Block 调用
│   ├── 常见 Block Crash 类型及排查
│   └── clang -rewrite-objc 查看编译产物
│
├── Swift 桥接
│   ├── OC Block → Swift 闭包的类型映射
│   ├── @convention(block) 的作用
│   ├── NS_NOESCAPE 对 Swift 侧的影响
│   └── Swift 闭包 vs OC Block 的关键差异
│
├── 常见坑与反模式
│   ├── nil Block 调用 crash
│   ├── 嵌套 Block 的循环引用
│   ├── dispatch_after 延长对象生命周期
│   ├── Block 内 return 的语义
│   ├── C 数组不能被 Block 捕获
│   └── 一次性 Block 多次调用
│
└── 设计选择
    ├── Block vs Delegate vs Notification 的取舍
    └── Block 回调的最佳实践

十五、面试高频考点速查

问题 核心答案
Block 的本质是什么? 封装了函数指针和捕获变量的 OC 对象(结构体),有 isa 指针
Block 的 invoke 函数有什么特点? 第一个隐含参数是 Block 结构体自身指针,类似 OC 的 self
Block 有几种类型?怎么判定? Global(不捕获局部变量)、Stack(捕获了,在栈上)、Malloc(copy 后在堆上)
什么时候 Block 会从栈 copy 到堆? 作为返回值、赋值给 strong 变量、传给 GCD、ARC 编译器自动处理
为什么要有栈 Block? 性能优化,临时 Block 无需 malloc/free,生命周期随栈帧
Block 的引用计数存在哪里? flags 字段的 bit 1~15,不在 SideTable 中
为什么局部变量在 Block 内不能修改? 捕获的是值副本,修改副本无意义且语义混乱,编译器直接禁止
__block 的底层原理? 将变量封装为 __Block_byref 堆上结构体,通过 __forwarding 指针保证栈堆访问一致
__forwarding 为什么存在? 解决 Block copy 后栈上代码和堆上 Block 访问同一个 __block 变量的一致性问题
多个 Block 捕获同一个 __block 变量会怎样? 共享同一个堆上 __Block_byref 结构体,引用计数管理
__block 在 MRC 和 ARC 下有什么区别? MRC 下不 retain 对象(可打破循环引用),ARC 下会 retain(不能打破循环引用)
访问 ivar 会捕获 self 吗? 会,编译器将 _name 转为 self->_name,隐式强引用捕获 self
循环引用怎么产生的? self 持有 Block,Block 强引用捕获 self,形成引用环
__weak__unsafe_unretained 区别? weak 对象释放后自动置 nil;unsafe_unretained 不置 nil,可能野指针
Weak-Strong Dance 的意义? weak 避免循环引用,strong 保证 Block 执行期间 self 不被中途释放
Block 捕获 self 一定循环引用吗? 不一定,只有 self(直接或间接)持有 Block 时才会形成环
Block 和 C 函数指针的区别? Block 能捕获上下文,是 OC 对象,参与 ARC;函数指针都不能
Block 和 Swift 闭包的主要区别? Block 默认值捕获,Swift 默认引用捕获;Block 有栈→堆迁移,Swift 闭包直接在堆上
Block 调用比 objc_msgSend 快还是慢? 快,Block 通过函数指针直接调用,省去了方法查找(SEL→IMP)的过程
Block 为 nil 时调用会怎样? Crash(EXC_BAD_ACCESS),不像 OC 消息发送给 nil 是安全的
_Block_object_assign 做了什么? 根据 flags 对捕获变量做 retain/initWeak/递归copy/byref_copy 等操作
GCD Block 需要 weakSelf 吗? 通常不需要,因为 self 不持有 GCD Block,不成环。但长延时的 dispatch_after 会推迟 self 释放
Block 的 copy 属性在 ARC 下还有意义吗? 功能上 strong 等效,但 copy 更具自文档性,是社区推荐写法
如何检测循环引用? Xcode Memory Graph Debugger、Instruments Leaks/Allocations、MLeaksFinder
什么是逃逸 Block? 超出创建它的函数作用域后仍可能被调用的 Block,必须 copy 到堆上
NSTimer 为什么容易循环引用? NSTimer 强引用 target,RunLoop 强引用 timer,即使 self 不强引用 timer,仍可能泄漏
Block 线程安全吗? Block 对象本身线程安全(只读),但捕获的可变对象的操作不是线程安全的

单元测试系列:如何测试不愿暴露的私有状态

引言

在多人协作的大型工程中,编写单元测试时有一个绑定出现的矛盾:生产代码追求封装,尽可能把实现细节藏在 private 后面;测试代码需要观测和控制内部状态,才能验证逻辑是否正确。

当你依赖的某个属性被另一个团队从 internal 改成了 private,你的测试就从"编译通过"变成了"编译失败"。这在日常开发中反复上演。

本文聚焦于一个具体问题:当被测对象的关键状态被封装为私有时,测试该怎么写? 我们按照推荐优先级介绍三种模式,并讨论如何让这类测试在多人协作中保持可维护。


一、问题的本质

以一个典型场景为例:一个管理器(Manager)内部持有若干数据源(DataSource),它的公有方法 totalCount() 聚合所有数据源的计数。你想测试的是聚合逻辑——非子集的数据源应该被加总,子集的应该被排除。

要测试这个逻辑,你需要让不同数据源返回不同的计数值。但数据源存储在管理器的 private 属性中,计数值也是数据源的 private 属性。你"两头都摸不到"。

直觉的反应是"把它改成 internal 就好了"——但在多人协作的项目中,放松封装来迁就测试往往会带来更大的问题。被放松的接口会被其他模块误用,而且你也不一定有权修改别人的代码。

所以我们需要在不修改生产代码的前提下解决这个问题。


二、三种模式

模式一:通过公有行为间接验证

原则:不直接访问私有状态,而是通过被测对象的公有方法观察其行为。

// 不要直接读取私有变量
XCTAssertEqual(object.privateCount, 10)

// 通过公有接口验证行为
object.performAction()
XCTAssertEqual(object.publicResult(), expectedValue)

适用场景:被测对象有足够的公有 API 来覆盖验证需求。

局限:当你需要设置特定的内部状态来测试某个分支时(例如"当未读数为 10 时,聚合应返回 10"),仅靠公有 API 可能无法将对象置于期望状态。此时需要下一个模式。

模式二:子类覆写

当需要控制被测对象内部依赖的返回值时,可以在测试文件中定义一个子类,覆写返回内部状态的方法:

private class StubDataSource: RealDataSource {
    private var mockValues: [QueryType: Int] = [:]

    func setValue(_ value: Int, for type: QueryType) {
        mockValues[type] = value
    }

    override func getValue(for type: QueryType) -> Int {
        return mockValues[type] ?? 0
    }
}

被测对象调用 getValue(for:) 时,实际执行的是 Stub 的逻辑,返回我们预设的值。不需要修改任何生产代码。

适用条件:被覆写的方法是非 final 的。

注意:Stub 子类应保持轻量,只覆写必要的方法。如果父类初始化有副作用(注册通知、启动定时器等),需要留意。

模式三:运行时注入

模式二解决了"让依赖返回可控值"的问题,但还有一个问题:如何把 Stub 塞进被测对象?

理想情况下,被测对象应通过构造器或属性注入依赖。但在大型存量项目中,很多类的依赖是内部创建并存储在 private 属性中的,没有公开的注入点。

此时,对于 NSObject 子类,可以借助 Objective-C 运行时直接操作 ivar:

private func setIvar<T>(_ name: String, on object: AnyObject, value: inout T) {
    let cls: AnyClass = type(of: object)
    guard let ivar = class_getInstanceVariable(cls, name) else {
        XCTFail("Cannot find ivar '(name)' — property may have been renamed.")
        return
    }

    let actualSize = computeIvarSize(ivar, in: cls)
    guard actualSize == MemoryLayout<T>.size else {
        XCTFail("Ivar '(name)' size mismatch — type may have changed.")
        return
    }

    let offset = ivar_getOffset(ivar)
    let ptr = Unmanaged.passUnretained(object).toOpaque().advanced(by: offset)
    ptr.assumingMemoryBound(to: T.self).pointee = value
}

这段代码包含两层防护,对应两类变更场景:

生产代码变更 防护机制 测试行为
属性被改名 class_getInstanceVariable 返回 nil XCTFail + 安全返回
属性名不变,类型变了 MemoryLayout<T>.size 与 ivar 实际大小不匹配 XCTFail + 安全返回

这确保了不论生产代码如何变化,测试都报错(failure)而非崩溃(crash)

限制:仅适用于 NSObject 子类。纯 Swift 类没有 ObjC 运行时元数据,此方法不可用。

选择优先级

优先级 模式 条件 风险
1 通过公有行为验证 公有 API 足以覆盖
2 子类覆写 方法非 final
3 运行时注入 NSObject 子类,无注入点 中(需防护)

模式三是"最后手段",不是常规工具。如果你发现自己频繁使用它,更值得推动的是让生产代码提供正式的依赖注入接口。


三、让这类测试在协作中存活

解决了技术问题之后,还有一个同样重要的问题:在多人协作的环境中,这些测试能否被团队中的其他人理解和维护?

3.1 封装脆弱操作,暴露清晰意图

运行时注入是"脆弱"的——它依赖属性名字符串、内存布局等编译器无法检查的假设。关键原则是:把所有脆弱操作封装在一个辅助方法中,让每个测试方法只表达业务意图。

// 辅助方法封装了所有运行时细节
private func injectStubDataSources(_ entries: [(id: String, isSubset: Bool, ds: StubDataSource)]) {
    // ... runtime injection logic ...
}

// 测试方法只表达意图
func test_totalCount_excludesSubset() {
    let primary = makeStub(count: 10)
    let subset = makeStub(count: 5)
    injectStubDataSources([
        (id: "primary", isSubset: false, ds: primary),
        (id: "filter",  isSubset: true,  ds: subset)
    ])

    XCTAssertEqual(manager.totalCount(), 10)
}

这样做的好处:

  • 单点维护:属性改名或类型变更时,只需修改 injectStubDataSources 一处。

  • 可读性:团队成员读到测试方法时,看到的是"注入一个非子集数据源(10)和一个子集数据源(5),期望聚合结果为 10",不需要理解运行时细节。

3.2 用命名传递信息

在多人协作中,测试方法名是最重要的"文档"。一个好的命名应该在不打开方法体的情况下就能传达:测什么、在什么条件下、期望什么结果。

test_[被测方法]_[场景]

test_totalCount_excludesSubsetDataSources
test_totalCount_multipleDataSources_sumsNonSubset
test_totalCount_allSubset_returnsZero

当这些测试出现在 CI 的失败报告中时,任何人——即使从未接触过这个模块——都能从方法名推断出问题所在。

3.3 报错,不要崩溃

这是使用 unsafe 技术时最重要的设计原则。

  • 失败(Failure) :CI 报告中标注 test_totalCount_excludesSubset FAILED: Cannot find ivar 'dataContextMap'。开发者 5 秒内定位问题。

  • 崩溃(Crash) :CI 报告中只有 EXC_BAD_ACCESS (code=1, address=0x...)。开发者需要调试半小时。

每一步 unsafe 操作前都必须有 guard ... else { XCTFail(...); return } 的防护链。没有例外。


小结

处理私有成员的测试难题,本质上是在封装性可测试性之间找到平衡。在不修改生产代码的前提下,三种模式提供了递进的解决方案:

  1. 优先通过公有行为验证——最安全,零风险。

  2. 子类覆写控制返回值——利用多态,风险低。

  3. 运行时注入作为兜底——突破封装,但必须有防护。

技术方案之外,同样重要的是协作层面的设计:将脆弱操作封装在一处、用命名传递意图、确保测试报错而非崩溃。这些原则让测试不仅"能用",而且"能活"——在团队成员轮换、生产代码持续演进的环境中,持续发挥保护作用。

# Flutter Engine、Dart VM、Runner、iOS 进程与线程 —— 深度解析

一、整体架构总览

┌─────────────────────────────────────────────────────────┐
│                    iOS 进程 (Process)                     │
│  ┌───────────────────────────────────────────────────┐  │
│  │              Runner (iOS Host App)                 │  │
│  │  ┌─────────────────────────────────────────────┐  │  │
│  │  │           FlutterEngine 实例                  │  │  │
│  │  │  ┌───────────────────────────────────────┐  │  │  │
│  │  │  │            Dart VM                     │  │  │  │
│  │  │  │  ┌─────────────────────────────────┐  │  │  │  │
│  │  │  │  │       Dart Isolate (main)        │  │  │  │  │
│  │  │  │  │    (你写的 Dart 业务代码)          │  │  │  │  │
│  │  │  │  └─────────────────────────────────┘  │  │  │  │
│  │  │  │  ┌─────────────────────────────────┐  │  │  │  │
│  │  │  │  │    Dart Isolate (spawned)        │  │  │  │  │
│  │  │  │  │    (compute / Isolate.spawn)     │  │  │  │  │
│  │  │  │  └─────────────────────────────────┘  │  │  │  │
│  │  │  └───────────────────────────────────────┘  │  │  │
│  │  │                                             │  │  │
│  │  │  ┌──────────┐ ┌──────────┐ ┌────────────┐  │  │  │
│  │  │  │ UI Thread│ │IO Thread │ │ GPU Thread  │  │  │  │
│  │  │  │(Platform)│ │          │ │ (Raster)    │  │  │  │
│  │  │  └──────────┘ └──────────┘ └────────────┘  │  │  │
│  │  └─────────────────────────────────────────────┘  │  │
│  │                                                    │  │
│  │  ┌──────────────────────────────────────────────┐ │  │
│  │  │  Native iOS 代码 (AppDelegate, ViewController)│ │  │
│  │  └──────────────────────────────────────────────┘ │  │
│  └───────────────────────────────────────────────────┘  │
│                                                          │
│  iOS Main Thread ─── GCD Queues ─── 其他系统线程           │
└─────────────────────────────────────────────────────────┘

二、逐层深度解析

2.1 iOS 进程 (Process)

iOS 进程是最顶层的容器,是操作系统分配资源的基本单位。

  • 每个 iOS App 运行在独立的沙盒进程中,由 launchd 守护进程启动
  • 进程拥有独立的虚拟内存空间(通常 4GB 虚拟地址空间)
  • 进程内包含:代码段、数据段、堆、栈、内存映射区域
  • 一个进程内可以有多个线程

关键点: 无论你的 Flutter App 多复杂,在 iOS 上它始终运行在一个进程内。Runner、FlutterEngine、Dart VM 都是这个进程内的组成部分。

iOS 进程
├── 进程内存空间
│   ├── Runner 的 Native 代码 (Objective-C/Swift)
│   ├── Flutter Engine 的 C++ 代码
│   ├── Dart VM 的运行时
│   ├── Dart 堆内存 (Dart Heap)
│   └── 共享库 (dylib)
├── 文件描述符表
├── 信号处理表
└── 线程表 (所有线程)

2.2 iOS 线程 (Thread)

线程是CPU 调度的基本单位,同一进程内的线程共享内存空间。

iOS 中的关键线程类型

线程 说明
Main Thread UI 线程,所有 UIKit 操作必须在此执行
GCD Worker Threads libdispatch 管理的线程池
pthread POSIX 线程,底层线程创建方式

Flutter 创建的线程

Flutter Engine 启动后会在 iOS 进程内创建4 个核心线程(Task Runner):

┌──────────────────────────────────────────────────────┐
│                  Flutter 四大线程                       │
├──────────────┬───────────────────────────────────────┤
│ Platform     │ 复用 iOS Main Thread                    │
│ Thread       │ 处理 Platform Channel、插件调用、原生交互   │
├──────────────┼───────────────────────────────────────┤
│ UI Thread    │ 独立 pthread                            │
│ (Dart Thread)│ 运行 Dart 代码、构建 Widget Tree/Layer Tree│
├──────────────┼───────────────────────────────────────┤
│ Raster       │ 独立 pthread                            │
│ Thread       │ GPU 光栅化,将 Layer Tree 转为 GPU 指令    │
├──────────────┼───────────────────────────────────────┤
│ IO Thread    │ 独立 pthread                            │
│              │ 图片解码、资源加载等耗时 IO 操作             │
└──────────────┴───────────────────────────────────────┘

重要区分: Flutter 的 "UI Thread" 不是 iOS 的 Main Thread。Flutter 的 Platform Thread 才是 iOS 的 Main Thread。

2.3 Runner (iOS Host App)

Runner 是 Flutter 工程中 ios/Runner 目录下的 iOS 宿主工程,本质上就是一个标准的 iOS App。

Runner 的职责

Runner (Xcode Project)
├── AppDelegate.swift / .m
│   └── 创建 FlutterEngine / FlutterViewController
├── Info.plist
│   └── App 配置、权限声明
├── Assets.xcassets
│   └── App Icon、LaunchImage
├── Main.storyboard (可选)
│   └── LaunchScreen
└── Frameworks/
    ├── Flutter.framework        ← Flutter Engine 二进制
    └── App.framework            ← 编译后的 Dart 代码 (AOT)

Runner 的生命周期

iOS 系统启动进程
    │
    ▼
main() 函数执行
    │
    ▼
UIApplicationMain() 
    │
    ▼
AppDelegate 初始化
    │
    ├── 创建 FlutterEngine 实例
    │       │
    │       ├── 初始化 Dart VM
    │       ├── 创建 4 个 Task Runner (线程)
    │       └── 加载 Dart AOT snapshot
    │
    ├── 创建 FlutterViewController
    │       │
    │       ├── 关联 FlutterEngine
    │       ├── 创建 Metal/OpenGL 渲染表面
    │       └── 注册 Platform Channels
    │
    └── App 进入 RunLoop

Runner 与 FlutterEngine 的关系

// 典型的 AppDelegate 代码
@UIApplicationMain
class AppDelegate: FlutterAppDelegate {
    // FlutterAppDelegate 内部持有 FlutterEngine
    // 它本质上是一个 UIApplicationDelegate 的实现
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // 此时 FlutterEngine 已经被创建并启动
        // Dart VM 已经初始化
        // main.dart 的 main() 已经开始执行
        
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

核心理解:Runner 是"壳",FlutterEngine 是"核心"。 Runner 负责 iOS 层面的事务(权限、推送、深链接等),FlutterEngine 负责 Flutter 的一切。

2.4 Flutter Engine

Flutter Engine 是用 C/C++ 编写的核心运行时,是连接 Dart 代码和底层操作系统的桥梁。

Engine 的组成

FlutterEngine
├── Dart Runtime (Dart VM)
│   ├── Dart Isolate 管理
│   ├── GC (垃圾回收器)
│   └── JIT / AOT 编译器
│
├── Shell (壳层)
│   ├── Platform Shell (iOS 适配层)
│   │   ├── Metal 渲染后端
│   │   ├── 触摸事件转发
│   │   └── 系统 API 桥接
│   └── Task Runner 管理
│       ├── PlatformTaskRunner → iOS Main Thread
│       ├── UITaskRunner → Dart 执行线程
│       ├── RasterTaskRunner → GPU 光栅化线程
│       └── IOTaskRunner → IO 线程
│
├── Skia / Impeller (图形引擎)
│   ├── 2D 渲染 API
│   ├── 文字排版 (txt 库 / libtxt)
│   └── 图片解码
│
├── Text Layout (文字排版引擎)
│
└── Platform Channel 机制
    ├── MethodChannel
    ├── BasicMessageChannel
    └── EventChannel

Engine 在 iOS 中的存在形式

Flutter.framework (约 40~50 MB, Release 模式)
├── Flutter (Mach-O 动态库)
│   ├── Dart VM 运行时
│   ├── Skia / Impeller 图形库
│   ├── Shell 层 (iOS 适配)
│   └── ICU 国际化数据
└── Headers/
    ├── FlutterEngine.h
    ├── FlutterViewController.h
    ├── FlutterPlugin.h
    └── FlutterChannels.h

App.framework (Dart 业务代码编译产物)
├── App (Mach-O 动态库)
│   ├── Dart AOT Snapshot (机器码)
│   └── 资源数据
└── flutter_assets/
    ├── kernel_blob.bin (Debug 模式)
    ├── vm_snapshot_data
    ├── isolate_snapshot_data
    └── AssetManifest.json

2.5 Dart VM

Dart VM 是 FlutterEngine 内部的虚拟机运行时,负责执行 Dart 代码。

Dart VM 的两种运行模式

┌─────────────────────────────────────────┐
│              Debug 模式 (JIT)              │
│                                          │
│  Dart 源码 → Kernel Binary → JIT 编译     │
│           → 解释执行 + 热编译为机器码        │
│                                          │
│  特点:支持 Hot Reload / Hot Restart       │
│       有 Dart VM 完整编译器                 │
│       性能较低                             │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│            Release 模式 (AOT)              │
│                                          │
│  Dart 源码 → AST → IR → ARM64 机器码       │
│           → 直接 CPU 执行                  │
│                                          │
│  特点:不支持 Hot Reload                   │
│       无 JIT 编译器 (体积更小)              │
│       性能接近原生                          │
└─────────────────────────────────────────┘

Dart VM 的内存结构

Dart VM 内存布局
├── New Space (新生代)
│   ├── Semi-Space A (活跃区)
│   └── Semi-Space B (备用区)
│   └── Scavenger GC (复制算法, STW 很短)
│
├── Old Space (老生代)
│   ├── 大对象区
│   └── 普通对象区
│   └── Mark-Sweep / Mark-Compact GC
│
├── Code Space (代码区)
│   └── AOT 编译后的机器码
│
├── Image Space (镜像区)
│   ├── vm_snapshot_data
│   └── isolate_snapshot_data
│
└── Isolate 独有内存
    ├── 每个 Isolate 有独立的堆
    └── Isolate 间不共享内存 (通过消息传递通信)

Dart Isolate 与 iOS 线程的关系

┌─────────────────────────────────────────────────┐
│                                                  │
│  Dart Isolate ≠ iOS Thread                       │
│  但 Dart Isolate 运行在 iOS Thread 之上            │
│                                                  │
│  ┌─────────────┐      ┌─────────────────┐       │
│  │ Root Isolate │ ──→  │ UI Thread        │       │
│  │ (main)       │      │ (固定绑定)        │       │
│  └─────────────┘      └─────────────────┘       │
│                                                  │
│  ┌─────────────┐      ┌─────────────────┐       │
│  │ Spawned      │ ──→  │ Dart VM 线程池中   │       │
│  │ Isolate      │      │ 的某个 pthread    │       │
│  └─────────────┘      └─────────────────┘       │
│                                                  │
│  一个 Isolate 在同一时刻只在一个线程上运行          │
│  但 Dart VM 可以将 Isolate 调度到不同线程上         │
│                                                  │
└─────────────────────────────────────────────────┘

关键区别:

  • Isolate 是 Dart 的并发模型,拥有独立的堆内存,通过 SendPort/ReceivePort 通信
  • Thread 是 OS 级别的执行单元,共享进程内存
  • Root Isolate 始终运行在 Flutter 的 UI Thread
  • Isolate.spawn() 创建的 Isolate 运行在 Dart VM 管理的线程池中

三、协作流程深度解析

3.1 一帧的渲染流程

时间线 ──────────────────────────────────────────────────────→

VSync 信号到达 (16.67ms 一次 @60fps)
    │
    │  ① Platform Thread (iOS Main Thread)
    │  ┌──────────────────────────────┐
    │  │ 接收 VSync 回调               │
    │  │ 通过 CADisplayLink            │
    │  │ 通知 UI Thread 开始新一帧      │
    │  └──────────────┬───────────────┘
    │                 │
    │  ② UI Thread (Dart Thread)
    │  ┌──────────────▼───────────────┐
    │  │ 执行 Dart 代码                │
    │  │ ├── Build Phase (Widget)      │
    │  │ ├── Layout Phase (大小/位置)   │
    │  │ ├── Paint Phase (绘制指令)     │
    │  │ └── 生成 Layer Tree            │
    │  └──────────────┬───────────────┘
    │                 │
    │  ③ Raster Thread (GPU Thread)
    │  ┌──────────────▼───────────────┐
    │  │ 接收 Layer Tree               │
    │  │ ├── Skia/Impeller 光栅化      │
    │  │ ├── 生成 GPU 指令             │
    │  │ └── 提交到 Metal/OpenGL       │
    │  └──────────────┬───────────────┘
    │                 │
    │  ④ iOS 合成器 (Core Animation)
    │  ┌──────────────▼───────────────┐
    │  │ 合成 Flutter 层与原生 UI 层    │
    │  │ 提交到屏幕显示                 │
    │  └──────────────────────────────┘

3.2 Platform Channel 调用流程

Dart 代码 (UI Thread)                     Native 代码 (Platform Thread)
       │                                           │
       │  MethodChannel.invokeMethod('getBattery')  │
       │  ─────────────────────────────────────→    │
       │  [序列化为二进制消息]                         │
       │  [从 UI Thread 调度到 Platform Thread]       │
       │                                           │
       │                            ┌──────────────▼──────┐
       │                            │ iOS Main Thread      │
       │                            │ 执行原生代码          │
       │                            │ UIDevice.current     │
       │                            │   .batteryLevel      │
       │                            └──────────────┬──────┘
       │                                           │
       │  ←─────────────────────────────────────   │
       │  [结果序列化]                               │
       │  [从 Platform Thread 调度回 UI Thread]      │
       │                                           │
       ▼                                           ▼
  收到 Future 结果                            调用完成

3.3 多 Engine 场景 (Add-to-App)

iOS 进程
├── Runner
│   ├── FlutterEngine A (主引擎)
│   │   ├── Dart VM (进程内唯一,共享)  ◄──── 重要!
│   │   ├── Root Isolate A
│   │   ├── 4 个线程 (Platform/UI/Raster/IO)
│   │   └── FlutterViewController A
│   │
│   ├── FlutterEngine B (第二个引擎)
│   │   ├── Dart VM (复用同一个)  ◄──── 同一个 Dart VM
│   │   ├── Root Isolate B (独立的 Isolate)
│   │   ├── 4 个线程 (Platform共享/UI独立/Raster独立/IO独立)
│   │   └── FlutterViewController B
│   │
│   └── FlutterEngineGroup (管理多引擎)
│       └── 共享 Dart VM,减少约 99% 的额外内存开销

核心要点: 一个 iOS 进程中只有一个 Dart VM 实例,但可以有多个 FlutterEngine,每个 Engine 有自己独立的 Root Isolate。


四、关系总结图

┌─────────────────────────────────────────────────────┐
│                                                      │
│  包含关系 (从外到内):                                  │
│                                                      │
│  iOS 进程                                             │
│    └── Runner (iOS Host App)                         │
│          └── FlutterEngine                           │
│                ├── Dart VM (进程唯一)                  │
│                │     └── Dart Isolate (可多个)         │
│                ├── Skia / Impeller                    │
│                └── Shell (平台适配层)                   │
│                                                      │
│  运行关系 (线程维度):                                  │
│                                                      │
│  iOS Main Thread ═══ Flutter Platform Thread          │
│       │                    │                          │
│       │                    ├── 插件调用                 │
│       │                    ├── 原生 UI 交互             │
│       │                    └── 生命周期管理              │
│       │                                               │
│  Flutter UI Thread ─── Dart Root Isolate 运行于此       │
│       │                    │                          │
│       │                    ├── Widget 构建              │
│       │                    ├── 布局计算                 │
│       │                    └── 绘制指令生成              │
│       │                                               │
│  Flutter Raster Thread ─── GPU 光栅化于此               │
│       │                                               │
│  Flutter IO Thread ─── 资源加载/图片解码于此             │
│       │                                               │
│  Dart VM 线程池 ─── spawned Isolate 运行于此            │
│                                                      │
└─────────────────────────────────────────────────────┘

五、常见误区澄清

误区 1:Flutter 的 UI Thread 就是 iOS 的 Main Thread

错!

  • Flutter Platform Thread = iOS Main Thread
  • Flutter UI Thread 是单独的 pthread,专门运行 Dart 代码
  • 在 Dart 代码里调用 Platform Channel 时,消息从 UI Thread 发送到 Platform Thread(即 iOS Main Thread)

误区 2:Dart Isolate 就是一个线程

错!

  • Isolate 是 Dart 的并发抽象,拥有独立的堆内存
  • Isolate 运行在线程之上,但两者不是一一对应关系
  • Dart VM 内部使用线程池来调度 Isolate
  • Root Isolate 固定绑定在 UI Thread 上,但 spawned Isolate 可能被调度到不同的线程

误区 3:多个 FlutterEngine 就有多个 Dart VM

错!

  • 一个 iOS 进程中只有一个 Dart VM
  • 多个 FlutterEngine 共享同一个 Dart VM
  • 每个 FlutterEngine 有自己独立的 Root Isolate
  • 使用 FlutterEngineGroup 可以高效创建多引擎,内存开销极小

误区 4:Runner 就是 Flutter

错!

  • Runner 只是一个标准的 iOS App 工程(壳)
  • 真正的 Flutter 运行时是 Flutter.framework 中的 FlutterEngine
  • Runner 可以同时包含原生 Swift/ObjC 代码和 Flutter 页面
  • 在 Add-to-App 场景中,Runner 甚至不叫 Runner,就是你已有的 iOS 工程

六、实际影响与性能调优启示

场景 涉及组件 优化方向
界面卡顿 UI Thread (Dart) 减少 build() 复杂度,使用 const Widget
光栅化卡顿 Raster Thread 减少 saveLayerclipPath 等 GPU 密集操作
平台通信慢 Platform Thread ↔ UI Thread 减少 Channel 调用频率,批量传输数据
图片加载慢 IO Thread 预缓存、降低分辨率、使用 precacheImage
内存爆炸 Dart VM (Isolate Heap) 控制 Isolate 数量、及时释放大对象
原生插件阻塞 Platform Thread (Main Thread) 插件内部开子线程处理耗时逻辑
App 启动慢 Engine 初始化 + Dart VM 启动 预热 Engine (FlutterEngine 提前初始化)

soluna 外挂 C 模块

soluna 集成了 lua 虚拟机,但默认构建方式是将 lua 库静态链接到唯一的执行文件中。这将导致无法以动态库的形式外挂 Lua 的 C 扩展。

这是因为,如果独立编译 Lua 的 C 扩展库,通常需要链接 Lua 的 C API 。标准的方法是动态链接 lua 实现,如果静态链接 liblua.a ,会导致进程中有多份 lua 的实现。在 Lua 的历史版本中,这将导致运行期错误。

这是因为,Lua 的实现中有一个静态的“空”对象,所有的 nil 都指向这个对象。如果进程空间中有多份 Lua 实现,就会出现多个空对象。运行时的数据结构中会引用这个空对象,而不同副本的实现将“空”和自身保留的“空”对象引用做比较时,就会出现错误的判断。

在更早期的版本,出现这种链接出现的项目,bug 会隐藏得很深。所以后来 Lua 增加了 luaL_checkversion() ,倡议在外部库初始化时调用,除了检查版本号,还会检查当前执行的 lua 实现是否和虚拟机创建时用的实现是同一个副本。

但不知道怎样正确链接 lua 的项目(保证进程中只有一份 Lua 实现)还是太多,从 Lua 5.4 以后,这个“空”对象就被移入了 lua_State 这个运行期结构。以牺牲一点运行时的代价,挽救那些似乎永远也搞不懂“加载和链接”的程序员。终于,错误的链接 Lua 也能不出错了。

但我还是认为,在同一进程中置入多份 Lua 实现是不好的。

注:这也是 Windows 动态库的一个独有问题。因为 Windows 的 DLL 不允许有未完成的符号,必须在编译链接时指定所有符号(Lua C API)的来源;如果是 Linux ,可以不链接 Lua C API 的库,在运行时加载动态库,加载器就能把进程内的对应符号装载起来。


回到题头的问题:soluna 静态链接了 Lua ,并未导出 C API ,要用 C 写额外的库怎么办?

曾经在 Ant Engine 中,我采用了一个方法:提供一个假的代理动态库,提供所有 Lua C API 的符号。外部库可以动态链接它,而它将所有 Lua C API 调用转发到 engine 内部链接的 Lua 实现中。

这样做的好处是,即使是预编译好的 Lua C 库,只要它正确的以动态链接形式链接了 Lua ,就能直接被 Ant Engine 加载。如果不需要外部库,这个代理库也可以不发布。

今天,我想给 soluna 加上类似的特性,但尝试了新的方案:外部库在构建时额外实现一个简单的入口函数,它不依赖真的 Lua 实现,而是链接 soluna 项目中的 extlua/extlua.c 这个 Lua API 代理实现。再由 soluna 的定制加载器来加载这个外部库。

比如,我有一个叫做 foobar 的外部库,原本的实现是这样的:

static int
lhello(lua_State *L) {
    lua_pushstring(L, "Hello World");
    return 1;
}

extern int
luaopen_foobar(lua_State *L) {
    luaL_Reg l[] = {
        { "hello", lhello },
        { NULL, NULL },
    };
    luaL_newlib(L, l);
    return 1;
}

当我们编译成动态库时,导出的 luaopen_foobar() 是库的入口。lua 的 require 可以正确的导入它。但这个实现依赖若干 lua C APIs ,例如 lua_pushstring() 等。

如何在 soluna 里正确加载它呢?我们需要在调用 luaopen_foobar() 这个入口函数前,将进程中的 Lua C APIs 注入这个动态库。

在这个方案中,只需要链接 soluna 项目中的 extlua/extlua.c 单个文件,然后导出一个额外的库入口函数:

extern int
extlua_init(lua_State *L) {
    luaapi_init(L);
    luaL_Reg l[] = {
        { "ext.foobar", luaopen_foobar },
        { NULL, NULL },
    };
    luaL_newlib(L, l);
    return 1;
}

这个函数的第一行需要调用 luaapi_init(L) ,它的实现在 extlua.c 中。然后用 luaL_newlib() 注入原有的模块入口函数即可。

luaapi_init(L) 并不依赖任何 Lua 的内部实现,只依赖 Lua 的一个官方宏 lua_getextraspace() 完成了注入 Lua C APIs 的魔法。

这是个有趣的技巧:

lua_getextraspace(L) 的官方定义是这样的:

#define lua_getextraspace(L)    ((void *)((char *)(L) - LUA_EXTRASPACE))

每个 Lua_State 结构前都保留有一个指针的空间,可以用来传递数据。soluna.external.load 会构建一个空的 Lua 虚拟机,并把所有 Lua C APIs 的引用放在它的 extraspace 。因为上面的 extlua_init() 是一个标准的 lua_CFunction ,所以可以用标准函数 package.loadlib 读出。传入这个带 C APIs 的空 Lua 虚拟机,luaapi_init() 就能正确的导入所有 API 了。随后的 luaL_newlib() 会把所有真正的入口函数放在这个空虚拟机中。当然,只是一些字符串(入口名)和 C 函数指针。

接下来,soluna.external.load 再从这个虚拟机中把整个入口函数表复制到当前虚拟机,并销毁掉这个临时虚拟机,就完成了整个外部模块的动态加载。


soluna.extlib(name) 的实现是这样的:

function soluna.extlib(name)
    local extlua = require "soluna.extlua"
    local filename = assert(package.searchpath(name, package.cpath))
    settings = settings and soluna.settings()
    local entry = assert(package.loadlib(filename, settings.extlua_entry))
    return extlua.load(entry)
end

要使用上面例子中的放在 sample.dll 中的库 ext.foobar 只需要这样:

local libs = soluna.extlib "sample"
local foobar = require "ext.foobar"
assert(libs["ext.foobar"] == foobar)

即使要静态链接 sample 模块(iOS 不支持动态库,可能必须静态链接),只需要采用以下编译方案即可正确工作:

  1. 静态链接 sample 模块
  2. 不要链接 extlua/extlua.c
  3. luaapi_init() 定义为一个空函数
  4. extlua_init() 这个入口函数导入
  5. 用 soluna.extlua.load(入口函数) 加载

Macbook Neo:苹果重回校园的起点 -- 肘子的 Swift 周报 #126

issue126.webp

Macbook Neo:苹果重回校园的起点

上周,苹果推出了若干新款硬件产品。与以往的发布会不同,这次发布显得异常低调。起初我只对其中新发布的显示器感兴趣,但在看到不少数码媒体对 Macbook Neo 配置的吐槽后,也不由得多留意了这款产品。相较于其“减配”的表象,我更从其精准的定价中看到了苹果重返教育市场的决心。

十几年前,苹果还曾是教育硬件市场的重要参与者。那些在校园中使用苹果设备成长起来的学生,也有相当一部分在进入社会后顺理成章地成为苹果软硬件的长期消费者。但随着谷歌持续加大在 Chromebook 上的投入,而苹果又缺乏更具价格竞争力的产品,这一以 K12 为核心的市场逐渐被对手占据(Chromebook 曾一度拿下美国基础教育市场近 60% 的份额)。这不仅让苹果损失了一部分收入,更重要的是削弱了其在青少年群体中、围绕台式机与笔记本这种计算形态所建立的品牌亲和力。相比平板设备,笔记本在教学体验、适用场景、耐用性以及 IT 集中管理等方面依然具有明显优势。

在服务优先的今天,硬件往往与生态深度绑定。Chromebook 早早培养出一大批习惯使用 Google Docs 的年轻用户。随着年龄增长与数据的积累,即便他们日后具备购买苹果设备的能力,也很难再与苹果的服务生态形成深度绑定,更难形成真正的品牌信仰。

Neo 精准的定价改变了这一局面。599的起售价、599 的起售价、499 的教育优惠,让更多孩子有机会在学校就开始使用苹果设备、拥有 Apple ID,从而顺着苹果“预设”的轨迹,逐步购买更多产品与服务。至于被广泛批评的"减配"——A18 Pro 芯片对 K12 日常使用场景而言完全足够,它缺的不是性能,而是定位本就如此。苹果用移动端芯片换来了激进的定价空间,这是一笔算得很清楚的账。

采用订阅制的 Apple Creator Studio,同样展现了苹果希望让更多人与其生态建立长期联系的野心。对于学校而言,廉价硬件+强大的创作软件套件,构成了闭环。Macbook Neo 的硬件性能或许不算强劲,但足以在每台设备约 4–5 年的生命周期中提供稳定、可用的体验,让使用者逐步融入苹果的服务体系。从这个角度来看,MacBook Neo 更像是苹果抛向 Z 世代与 Alpha 世代的一枚“生态锚点”。

太多消费者和数码博主过于聚焦于苹果产品是否炫酷、是否有创新,却忘记了苹果的来时路——教育硬件市场深植于它的基因之中,今天的成功源于数十年前的积累,而现在它需要补上最近十几年的空缺。对于本周报的读者来说,Neo 大概率不是你的菜。但这并不妨碍它成为一款极具针对性、也颇具野心的产品——不是用来赚快钱的,而是苹果为未来二十年的生态版图所做的一次长期押注。

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

原创

跨域传递 NSManagedObjectContext 为什么在 Swift 6.2 中不再报错?真正的变化不在编译器

当同一段与并发有关的代码在旧版 Xcode 中无法通过,却在新版 Xcode 26(Swift 6.2)中顺利编译时,你第一时间会想到什么?很多人最初的判断可能是 Swift 编译器的并发分析(如 Region-Based Isolation)又进化了,但现实并没有这么简单。本文记录了我最近遇到的一次非常有意思的排查过程:从测试失败出发,通过构建最小复现用例,一步步追溯到 Core Data 的 SDK interface,最终发现,问题的关键并不完全在 Swift 编译器本身,而是框架的导入语义发生了变化——在新的 SDK 中,NSManagedObjectContext 获得了 NS_SWIFT_SENDABLE 等宏标注,使其在 Swift 中拥有了 Sendable 语义。

尽管 SwiftData 是未来苹果生态最重要的持久化框架,但作为其基础的 Core Data 并没有被苹果冷落。在过去几年中,苹果一直在默默改善其在 Swift 6 中的兼容性和并发体验,这是一个很好的现象。

近期推荐

Notepad.exe — Swift 新特性的第一个实验场

Swift 6 出了新语法?Xcode 太重,Playground 又太慢。Notepad.exe 让你在 30 秒内写代码、跑结果,专注验证想法本身。支持多版本工具链切换,集成模拟器,随开随用。


Swift 语言 2 月新动态 (What's new in Swift: February 2026 Edition)

Karen ChuDave Lester 在官方博客上整理了 2026 年 2 月 Swift 社区的生态动态。内容不仅涵盖了 Swift 在 FOSDEM(全球最大开源会议)上的活跃表现,还推介了多项重磅的开源进展与 Swift Evolution 提案。其中的 SE-0506 尤为让我惊喜。该提案为 withObservationTracking 增加了 Options 参数,开发者现在可以精确控制是观察变化前(willSet)、变化后(didSet)还是对象的生命周期(如 deinit)。并且通过 withContinuousObservationTracking 无需再手动递归注册,即可实现稳定、自动循环的连续事件追踪。

SE-0506 提案的通过意义重大。它不仅完美补齐了状态追踪的时机控制和连续性能力,更标志着 Swift 原生的 Observation 已经彻底成熟——它不再仅仅是 SwiftUI 的“专属附庸”,而是真正蜕变为了 Swift 语言中足以应对各种工业级、高性能状态流调度的核心基础设施。


写在 2026 年的 macOS 输入法开发规范

vChewing 唯音输入法 的作者 ShikiSuen 基于多年深耕 macOS 输入法的开发经验,全面梳理了 InputMethodKit (IMK) 的历史包袱,以及它在 Swift 6 严格并发检查下暴露出的种种痛点。文章深入探讨了 NSConnection 的命名规范、启用沙盒的必要性、MainActor 隔离冲突,以及高频中英切换(CapsLock)导致的 ARC 拥堵、macOS 26 Liquid Glass 机制下 NSWindow 运存不释放等棘手问题。面对苹果“上古框架”与现代 Swift 并发模型的碰撞,作者没有停留在抱怨上,而是提出了一套像“风险控制模型”一样的工程规范——将控制器退化为纯转发层、把业务逻辑剥离到弱引用 Session、使用运存自查自尽、彻底避开 IMKCandidates 等。

可贵的是,ShikiSuen 基于上述思路开发并开源了 IMKSwift 库。它为 Swift 6+ 提供了 @MainActor 完整隔离的 IMKInputSessionController 基类,完美覆盖了原生 IMKInputController 的并发地雷区。如果你需要开发 macOS 桌面端应用或输入法,这个库能让你无需手动 Hack,就能写出类型安全、无 data-race 警告的现代代码,非常值得学习与使用。


SwiftUI 的洋葱式架构:Swift Effects 实践 (SwiftUI, Swift Effects: A Beautiful Onion Architecture)

在 SwiftUI 中处理数据加载状态几乎是每个应用都会面对的问题:loadingloadedfailed 三种状态往往伴随着网络请求、缓存、日志记录等副作用逻辑,很容易让视图代码逐渐变得臃肿。Salgara 在本文中提出了一种类似 Onion Architecture 的思路:通过 ViewState + Effect Handlers 将 Fetch、缓存、日志等副作用拆分为多个可组合层级,并利用 AsyncSequence 与可注入的 Effect Handler 驱动状态变化,使 UI 仅根据状态进行渲染。这样一来,视图保持纯粹,而数据获取与副作用则沿着一条清晰的“Effect 管道”逐层流动。同时,这种结构也天然具备良好的可测试性——测试代码可以直接拦截并模拟数据源返回值,从而验证完整的状态转换流程。

Salgara 坦言,这种架构目前仍然是实验性的:原型优先,并尝试将一切视为视图(Everything as a View)。随着越来越多开发者从不同角度思考并尝试构建更符合 SwiftUI 特性的架构,这类探索不仅可能让 SwiftUI 本身受益,也有机会反过来丰富整个声明式编程范式,而不再只是复制其他 UI 框架的既有实践。


Spec-Driven Development:当 AI 写代码之后

随着 Cursor、Claude Code 等 AI 编程智能体(Agent)的普及,开发者们正面临一个新的痛点:当 AI 能在几分钟内跨越几十个文件生成上千行代码时,人类该如何有效审查?又该如何应对 AI 在长流程中逐渐出现的“上下文遗忘(Context Decay)”与幻觉问题?为此,一种新的开发范式正在逐渐成形:Spec-Driven Development(SDD)。在这一模式下,开发者的主要任务不再是直接编写代码,而是定义清晰的规格(Spec),再由 AI 根据这些规格生成实现。

Snow 通过四篇文章系统梳理了这一思路:从 “Vibe Coding” 的局限出发,介绍以规格为核心的开发流程,并进一步探讨规格在未来软件工程中的角色——代码或许不再是项目的中心,而只是规格的衍生物。

在 AI 逐渐承担实现细节的时代,软件工程的重心或许正在从“写代码”转向“表达意图”。SDD 尝试在人类的模糊意图与 AI 的无差别生成之间,建立一层强有力的约束。


为 SwiftUI Preview 构建一个 Mini Linker (Building a Mini Linker for SwiftUI Previews)

在 Xcode 26.3 的 mcpbridge 提供的众多工具中,RenderPreview 可以直接返回 SwiftUI Preview 的截图,方便 AI 进行分析。对于暂时无法使用 Xcode 26.3 mcpbridge 的开发者,Hesham Salman 在本文中介绍的思路以及配套工具,同样可以实现类似的能力。其技术亮点在于利用 SwiftSyntax 构建声明依赖图,再通过 BFS 找出当前 Preview 真正需要的最小源文件集合,从而避免编译整个 App Target 带来的构建等待。

本文的精华在于思路:利用 SwiftSyntax + BFS 快速定位 Preview 依赖的代码片段。过去 SwiftSyntax 的使用门槛较高,但在 AI 辅助开发逐渐普及的今天,它正逐渐成为理解代码结构的重要基础设施。即便你不像 Hesham Salman 那样熟练掌握该工具,了解其基本能力后,也可以借助 AI 将类似思路落地——而这类工具在过去往往只属于少数熟悉编译器工具链的开发者。


Swift 的规模化实践:TelemetryDeck 的分析服务 (Swift at scale: building the TelemetryDeck analytics service)

很多人讨论 Swift on Server 时,关注点往往停留在“能不能用”,而 TelemetryDeck 给出的则是一个更实际的答案:不仅能用,而且已经支撑起一项面向开发者、每月处理超过 1600 万用户数据的 analytics 服务。Daniel Jilg 在这篇文章中回顾了团队为何选择 Swift + Vapor 构建后端,并分享了不少来自生产环境的经验:例如如何借助 Codable 简化 API 编解码与校验、为什么应让 DTO 更贴近 controller、以及为何缓存 TTL、API 版本管理和错误监控这些“老生常谈”,在规模化的生产环境中往往才是真正的护城河。


是时候告别 SwiftGen 了吗? (Get Rid of Your SwiftGen Dependency)

很长一段时间,开发者需要依赖类似 SwiftGen 这样的工具来解决 Apple 资源系统中的一个老问题:资源访问是字符串类型(stringly-typed)。无论是 localization key、图片名称还是颜色资产,一旦拼写错误,往往只能在运行时才会暴露问题。Asser Osama 指出,随着 String Catalog(.xcstrings)与 Asset Catalog Symbols 的引入与逐步完善,Xcode 已经能够在编译阶段自动生成资源符号,这种原生能力在不少现代项目中或许已经足以替代 SwiftGen。

需要说明的是,“移除依赖”的前提是项目完全运行在标准的 Xcode 生态中。Xcode 的符号生成属于构建系统内部机制,而不是 Swift 编译器或 Swift Package Manager 的能力——这意味着对于使用 Bazel、Buck 等非标准构建系统的团队来说,SwiftGen 仍然可能是更可移植、更可控的选择。

工具

SwiftUI Agent Skill

Paul Hudson 编写的 SwiftUI Agent Skill,旨在帮助开发者编写更智能、更简洁、更现代的 SwiftUI 代码。该项目发布仅两天便获得了 1k+ Star。

在过去几周中,本周报已经推荐了不少知名开发者编写的各类 Skill。尽管这些 Skill 都凝聚了作者的经验,但我仍不建议开发者直接“拿来即用”。至少应在采用前完整阅读一遍:Skill 更像是作者对自己数十甚至上百篇文章经验的提炼,而不是可以直接替代思考的“最佳实践”。开发者在理解其背后的设计思路后,再根据自己的开发习惯与项目需求进行取舍,这样才能更大地发挥它们的价值。

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

Macbook Neo:苹果重回校园的起点 - 肘子的 Swift 周报 #126

上周,苹果推出了若干新款硬件产品。与以往的发布会不同,这次发布显得异常低调。起初我只对其中新发布的显示器感兴趣,但在看到不少数码媒体对 Macbook Neo 配置的吐槽后,也不由得多留意了这款产品。相较于其“减配”的表象,我更从其精准的定价中看到了苹果重返教育市场的决心。

[转载]移动端开发稳了?AI 目前还无法取代客户端开发,小红书的论文告诉你数据

原文地址

近期,由小红书联合多伦多大学等高校的研究人员发布了 《SWE-Bench Mobile》(2602.09540) 论文,内容主要是评估 LLM 智能体在处理真实生产级移动端应用开发任务时的能力,并提出了首个针对该领域的基准测试——SWE-Bench Mobile

这个论文对比之前那些简单的需求场景,明显更具备说服力,最重要的是,用真实的数据给目前的 AI 狂热浇一浇冷水

Image 17

目前的编程基准测试大多集中在孤立的算法问题,而 SWE-Bench 则是关注 GitHub 上的 Bug 修复,然而真实的工业级移动端开发汪汪更为复杂:

  • 多模态输入:开发者需要根据产品需求文档(PRD)和 Figma 设计稿等来写代码
  • 复杂的工程环境:中大厂的移动端代码库通常规模巨大( 5GB 以上),且涉及 Swift 与 Objective-C 混编、特定系统 API 及复杂的 UI 交互,还有编译环境影响
  • 任务类型多样化:不限于 Bug 修复,更多是功能开发和 UI 增强

所以研究团队从目前小红书自己的真实产品流水线中提取了 50 个具有代表性的开发任务,构建了该基准测试:

  • 数据集组成

    • 50 个真实任务:源自实际的产品需求
    • 449 个人工验证的测试用例:平均每个任务 9.1 个测试点,用于评估功能正确性
    • 多模态支持:70% 的任务附带 Figma 设计链接,92% 附带参考图
  • 代码库规模:基于约 5GB 大小的真实 iOS 生产代码库(Swift/Objective-C)

  • 任务复杂度:平均每个任务涉及修改 4.2 个文件,远超之前的基准测试

Image 18

整个基准的规则是:

  • 70% 任务包含 Figma
  • 92% 包含参考图片
  • 平均 PRD 长度 450 字

每个任务包含:

  • 一个统一 diff 补丁(patch)输出
  • 综合测试套件(平均 9.1 个测试案例)
  • 任务难度分级:从简单 UI 调整到复杂跨模块改造

Image 19

对于任务两个关键指标:

  • 任务成功率:所有测试通过的任务比例

  • 测试通过率:所有测试案例通过的比率

Image 20

而对于 LLM,论文评估了 22 种 不同的“智能体-模型”配置,涵盖了四个主流框架:

  • 商业智能体:Cursor、Codex (由 DeepSeek/OpenAI 等模型驱动)、Claude Code
  • 开源智能体:OpenCode

评估维度包括:任务完成率、任务复杂度影响、成本效果对比、多次运行稳定性、Prompt 设计影响等。

而根据论文可以得出结论:当前 AI 在生产级的软件工程力存在巨大局限性:

  • 成功率极低表现最好配置的成功率仅为 12% ,大多数任务以“实现不完整”告终,但测试通过率最高可到 28%,说明部分任务可以部分正确生成,但没能完全部署成功
  • 智能体架构十分重要 :同一个底层模型,在 Cursor 框架下的成功率为 12%,但在 OpenCode 下仅为 2%,智能体的工具调用、上下文管理等设计与模型本身同等重要
  • 商业模型占优:商业闭源智能体在处理大型代码库时的稳定性和正确性显著优于开源方案
  • 复杂度陷阱任务涉及 1-2 个文件时成功率为 18%,但当涉及 7 个以上文件时,成功率骤降至 2% ,显示出模型在跨文件长程推理方面的短板
  • “防御性编程”提示词更有效:研究发现,使用基于“防御性编程”(原则的简洁提示词,比复杂的提示词能让成功率提升 7.4%

Image 21

Image 22

对于失败,论文还针对失败类型归类:

  • 缺失关键功能标志位或 Feature Flag 是主要的失败原因
  • 其次是 数据模型缺失
  • 再者是 incomplete patch(文件覆盖不足)等问题

这些失败的类似,在一定程度上反映了智能体对真实工程流程、跨文件依赖、与视觉设计的理解严重不足,也就是这些问题是“工程级问题”,而不是“语言问题”:

所以哪怕换成 Android / Flutter,这类跨文件工程理解问题仍然存在。

基于这些数据,论文认为当前 LLM Agent 尽管在单一代码生成上有突破,但在端到端工程上下文(包含设计、代码库理解、工程流程)仍远未达到企业生产标准

另外,论文也有一个有趣的结论数据,主要统计了各 Agent + Model 的每任务成本(美元)和平均耗时(分钟),例如:

  • Cursor + Opus 4.5 : $3.50 / 15 min
  • Codex + GLM 4.6 : $1.30 / 13.3 min
  • OpenCode + GLM 4.6 : $0.13 / 32.5 min
  • OpenCode + Opus 4.5 : $9.33 / 8.2 min

Image 23

对此可以看出来:

  • Codex + GLM 4.6 是性价比最高
  • OpenCode 极便宜但成功率低
  • OpenCode + Opus 4.5 是最贵但效果很差(2%)

最后,下图是论文的最终结果对比,例如在 Success 和 Pass 上:

  • Cursor + Opus 4.5 → 12% / 28.1%
  • Codex + GLM 4.6 → 12% / 19.6%
  • OpenCode + GLM 4.6 → 8%

Image 24

这么看,OpenCode 的实际数据表现是真的一般。

这个在同一个模型,在不同 agent 上的成功率也有所体现,OpenCode 再一次被鞭尸:

Image 25

所以,可以看出来,目前的 AI 智能体离独立完成中大型移动开发还有很大距离,主要瓶颈在于多模态理解、大规模代码导航和跨文件逻辑一致性等。

另外,SWE-Bench Mobile 采用了托管基准挑战(Hosted Benchmark)模式 ,不公开测试集答案,以防止数据泄露到未来的模型训练中。

最后,论文只针对原生 iOS 开发进行测试,没有测试 Android 原生、Flutter、RN 等其他情况,按照一般直觉,这些框架的 AI 表现应该会好于 iOS 原生,当然这也只是我的个人直觉,真实数据还是得有企业做过 Benchmark 才知道。

不过至少从目前看,在移动端开发领域写代码上,至少比前端安全性高一些?你怎么看?

iOS App 安全加固流程记录,代码、资源与安装包保护

项目上线前的安全处理,经常被放在发布流程的最后一步。很多团队在代码开发阶段关注功能实现,等到准备提交 App Store 时,才开始思考应用被反编译或资源被提取的问题。

在一个包含 Swift + Flutter 模块的项目中,我们曾经遇到过这样一个情况:测试包被外部获取后,对方直接解压 IPA,通过类名和资源目录快速定位了核心模块。那次经历之后,我们把 iOS app 保护单独整理成一套固定流程,并加入到发布前的检查清单中。

这篇文章按实际操作过程记录一个流程。工具不会只有一个,而是组合使用系统能力、命令行工具以及 Ipa Guard 等二进制处理工具。


一、检查 IPA 内部结构

在进行任何保护操作之前,可以先观察当前 IPA 包含的信息。

.ipa 文件复制一份并改名为 .zip

mv app.ipa app.zip
unzip app.zip

进入目录:

Payload/AppName.app

此时可以看到:

  • 可执行二进制文件
  • 图片资源
  • json 配置
  • HTML / JS
  • Storyboard 或 xib
  • embedded.mobileprovision

如果资源目录中存在明显业务含义的文件,例如:

vip_purchase_bg.png
subscription_config.json
payment_success.html

那么即使没有阅读代码,也能推测应用功能结构。


二、在源码阶段减少符号暴露

在 IPA 层处理之前,可以在 Xcode 构建阶段减少调试信息。

Release 配置中可以检查两个选项:

Strip Debug Symbols During Copy = YES
Deployment Postprocessing = YES

构建完成后,用命令查看二进制中的字符串:

strings AppBinary | grep ViewController

如果能看到大量业务类名,例如 OrderManagerVipViewController,说明符号仍然暴露。

源码阶段可以通过脚本或重命名策略减少可读性,但很多项目已经进入稳定阶段,不希望再修改代码结构。这时可以转向 IPA 级处理。


三、对 IPA 二进制进行符号混淆

在编译完成的情况下,可以通过 Ipa Guard 直接对 IPA 包进行处理,而不需要修改项目源码。

加载 IPA 后,工具会解析其中的 Mach-O 二进制结构,并列出类名与方法列表。

在界面中可以看到类似结构:

代码模块
 ├─ OC 类
 ├─ Swift 类
 ├─ OC 方法
 └─ Swift 方法

加载ipa

实际操作时,我们只勾选包含业务逻辑的类,例如:

OrderManager
VipSubscriptionController
PaymentService

处理后,这些名称会被替换为无意义字符串,从而降低反编译可读性。

Ipa Guard 支持 Objective-C、Swift、Flutter、Unity3D 等多种开发平台,因此混合项目也可以统一处理。


四、处理资源文件结构

代码不是唯一需要保护的内容。资源文件往往更容易暴露信息。

在 Ipa Guard 的资源模块中,可以选择处理以下文件类型:

  • 图片
  • json
  • js
  • html
  • mp3
  • xib
  • storyboard

工具会执行两类操作:

1. 文件名混淆

例如:

vip_background.png

会变为:

a9d3f21.png

这样在解包 IPA 时无法通过名称判断用途。 文件名称

2. 修改 MD5

图片或资源的 MD5 值也可以被修改,这可以打散资源特征值。

处理完成后,重新解压 IPA 可以看到所有资源名称已经变为随机字符串。 md5


五、处理 HTML 与 JS 文件

如果应用包含 H5 页面,需要额外处理 JS 与 HTML 文件。

在构建阶段可以使用前端压缩工具,例如:

terser
uglify-js

压缩完成后再由 Ipa Guard 修改资源名称。

这样做的效果是:

  • 文件内容被压缩
  • 文件名称失去语义

即使解包 IPA,也很难通过资源结构还原功能模块。


六、删除调试信息

很多项目在构建过程中会留下调试日志或符号信息。

Ipa Guard 提供调试信息清理功能,可以删除:

  • 自动注释
  • 调试符号
  • 部分字符串信息

处理后可以再次检查:

strings AppBinary

输出内容会明显减少。


七、重新签名并安装测试

任何 IPA 内容修改都会导致签名失效。

因此混淆完成后需要重新签名。

可以使用签名工具,例如:

kxsign sign my.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i

参数 -i 会尝试直接安装到连接的设备。

也可以使用 Ipa Guard 内置签名模块,在混淆完成后直接选择证书并生成新 IPA。

设备测试阶段主要检查:

  • 页面加载是否正常
  • 动态调用方法是否失效
  • H5 页面是否可以打开
  • 是否出现崩溃日志

八、发布阶段生成最终 IPA

测试通过后,需要重新签名生成发布版本。

发布阶段只需要更换证书:

Distribution Certificate
App Store Provisioning Profile

生成的 IPA 将用于提交 App Store。

发布类型 IPA 不允许直接安装到设备,但可以通过 Xcode Organizer 或上传工具提交审核。


iOS app 保护并不是单一技术,而是一组连续操作:减少符号暴露、混淆代码名称、处理资源文件、清理调试信息、重新签名并验证运行。

参考链接:ipaguard.com/tutorial/zh…

iOS App 性能测试工具怎么选?使用克魔助手(Keymob)结合 Instruments 完成

在移动应用开发中,性能测试不是某个阶段才开始做的事情。很多问题在开发早期就已经发生,只是在功能逐渐增多之后才表现出来。例如:

  • 页面滚动出现卡顿
  • 内存持续增长
  • 启动时间越来越长

如果只依赖单一工具去分析这些问题,往往会比较吃力。实际项目中更常见的做法是多工具组合使用,让每个工具负责不同方面。

这里结合一次真实项目中的测试,介绍一套比较实用的 iOS App 性能测试流程。


性能测试通常关注哪些指标

在开始之前,需要先确定要观察哪些数据。常见的性能指标包括:

  • CPU 使用率
  • 内存占用
  • 帧率(FPS)
  • 网络请求
  • 应用能耗

不同阶段关注的重点会有所不同。开发阶段通常更关注函数级性能,而测试阶段更关注设备整体运行情况。


第一方面,设备本机性能监控

在很多团队里,测试人员并不一定使用 Mac 环境。如果需要在 Windows 或 Linux 上查看设备性能,就需要借助设备监控工具。

我在项目中比较常用的是 克魔助手(Keymob) 来做这方面的数据采集。

它的作用主要是:

  • 查看设备运行时 CPU / 内存 / FPS
  • 指定某个 App 进行监控
  • 记录性能变化趋势

这类监控通常用于快速发现问题出现的时间点。


使用克魔助手监控 App 性能

实际操作过程比较简单。

连接设备

准备一台测试设备,然后:

  1. 使用数据线连接 iPhone
  2. 打开克魔助手
  3. 等待设备识别完成

设备识别后可以看到当前设备信息。


进入性能图表

在左侧导航中选择:

性能图表

这里会显示设备当前的资源使用情况。


选择监控指标

在界面右上角可以选择需要观察的指标,例如:

  • CPU
  • 内存
  • FPS

如果只是测试页面流畅度,通常只需要勾选 CPU 和 FPS。 图表


指定要监控的应用

点击 选择 App

输入应用名称即可找到目标应用。 也可以同时勾选 系统总 CPU,用来判断设备整体负载。 选择app


开始测试

点击 开始 按钮之后,就可以在手机上执行测试流程,例如:

  • 打开首页
  • 滑动列表
  • 进入详情页

性能图表会实时显示资源变化。

通过观察曲线可以判断:

  • 哪个操作触发了 CPU 峰值
  • 是否出现持续高占用

第二层:深入分析工具

设备监控工具只能告诉我们问题出现在哪里,但不能直接解释原因。

当发现异常之后,通常需要回到开发工具进行深入分析。


Instruments

Instruments 是 iOS 官方提供的性能分析工具。

它可以分析:

  • 方法调用耗时
  • 内存分配
  • GPU 渲染
  • 线程状态

例如,当设备监控发现某个操作 CPU 突然升高,可以用 Instruments 再跑一次相同操作。

这样可以找到具体的函数或对象。


一个案例

有一次测试人员反馈:

“进入某个页面之后滑动明显卡顿。”

排查过程是这样的:

第一步

使用克魔助手监控 CPU 与 FPS。

发现滑动列表时 CPU 占用突然升高,同时 FPS 出现下降。


第二步

在 Mac 上使用 Instruments 重新测试。

最终定位到问题原因:

页面滚动时触发了大量图片解码。


第三步

修改代码,将图片解码改为后台线程处理。

再次测试后 CPU 曲线明显平稳。


为什么不建议只依赖一个工具

有些开发者希望找到一个全能工具,但在实际项目中很少存在这种工具。

更合理的方式通常是:

设备监控工具,用于观察设备运行情况

开发分析工具,用于定位具体代码问题

这样可以形成一个完整的测试流程。

性能测试并不是某个阶段才进行的工作,而是贯穿整个开发周期的过程。只要在每个版本发布前进行简单的性能监控,就可以提前发现很多潜在问题。

参考链接:keymob.com/tutorial/zh…

# iOS weak 原理详解

一、weak 是什么

weak 是 Objective-C / Swift 中的一种弱引用修饰符。它的核心行为只有两条:

  1. 不增加引用计数:持有对象但不影响对象的生命周期
  2. 对象释放时自动置 nil:不会产生野指针

这个"自动置 nil"是 weak 最关键的特性,也是它和 __unsafe_unretained(不置 nil,会变野指针)的根本区别。


二、为什么需要 weak

循环引用问题

两个对象互相强引用,谁都释放不了:

A 强引用 BB 的引用计数 +1B 强引用 AA 的引用计数 +1)

想释放 A → 但 B 还在引用 AA 释放不了
想释放 B → 但 A 还在引用 BB 释放不了
→ 内存泄漏

把其中一方改成 weak 就打破了循环:

A 强引用 BB 的引用计数 +1B 弱引用 AA 的引用计数不变)

外部释放 AA 的引用计数归零 → A 被销毁 → A 释放对 B 的强引用 → B 也被销毁

常见场景:delegate、block 捕获 self、父子视图关系。


三、weak 的底层实现:SideTable

这是 weak 原理的核心。Runtime 维护了一套 SideTable 数据结构来管理 weak 引用。

3.1 整体架构

全局有一个 SideTable 数组(StripedMap<SideTable>)
包含 64 个 SideTable(根据对象地址哈希分配到不同的表,减少锁竞争)

每个 SideTable 包含三样东西:
+---------------------------+
|  spinlock_t 自旋锁         |   用于多线程安全
+---------------------------+
|  RefcountMap 引用计数表     |   存储对象的引用计数(非 isa 优化时)
+---------------------------+
|  weak_table_t 弱引用表     |   存储所有 weak 指针的信息
+---------------------------+

3.2 weak_table_t 的结构

weak_table_t
+-----------------------------------+
|  weak_entry_t *weak_entries       |   哈希数组
|  size_t num_entries               |   当前条目数
|  uintptr_t mask                   |   哈希掩码(数组大小 - 1)
|  uintptr_t max_hash_displacement  |   最大哈希冲突偏移
+-----------------------------------+

每个 weak_entry_t 对应一个被弱引用的对象:
weak_entry_t
+-----------------------------------+
|  referent(被弱引用的对象地址)      |   Key:对象是谁
|  referrers(弱引用指针的地址数组)   |   Value:谁在弱引用它
+-----------------------------------+

用人话说就是:Runtime 维护了一张大表,Key 是对象地址,Value 是所有指向这个对象的 weak 指针的地址列表

类比理解:

想象一个"粉丝登记簿"。每个明星(对象)有一页,上面记着所有粉丝(weak 指针)的联系方式。明星退役(对象释放)时,工作人员翻到那一页,逐个通知粉丝"他退了"(置 nil),然后撕掉这一页。


四、weak 的完整生命周期

4.1 创建 weak 引用时发生了什么

当你写 __weak id weakObj = obj; 时,Runtime 调用 objc_initWeak,完整流程:

objc_initWeak(&weakObj, obj)
    |
    v
storeWeak(&weakObj, obj)
    |
    v
1. 根据 obj 的地址,哈希计算找到对应的 SideTable
    |
    v
2. 加锁(SideTable  spinlock)
    |
    v
3.  weak_table 中查找 obj 对应的 weak_entry_t
   - 如果不存在:创建一个新的 weak_entry_t,插入 weak_table
   - 如果已存在:直接使用
    |
    v
4.  &weakObj(weak 指针的地址)添加到 weak_entry_t  referrers 数组中
    |
    v
5. 解锁
    |
    v
6. 返回 obj(weakObj 现在指向 obj,但不增加引用计数)

4.2 读取 weak 引用时发生了什么

当你使用 weakObj 时(比如 [weakObj doSomething]),Runtime 调用 objc_loadWeakRetained

objc_loadWeakRetained(&weakObj)
    |
    v
1. 读取 weakObj 当前指向的对象
    |
    v
2. 如果对象正在被释放(deallocating)→ 返回 nil
    |
    v
3. 如果对象还活着  对它做一次 retain(引用计数 +1)
    |
    v
4. 返回对象(调用方用完后会 release)

为什么读取时要 retain? 防止你拿到对象后、使用之前的瞬间,对象被其他线程释放。retain 一下确保对象在使用期间不会消失。

这也是为什么常见的模式是:

__weak typeof(self) weakSelf = self;
[obj doSomething:^{
    __strong typeof(weakSelf) strongSelf = weakSelf;  // 读取时 retain
    if (!strongSelf) return;                           // 如果已释放就退出
    [strongSelf doWork];                               // 安全使用
}];

__strong typeof(weakSelf) strongSelf = weakSelf 这一步就触发了 retain,保证 block 执行期间 self 不会消失。

4.3 对象释放时发生了什么(最关键的部分)

当一个被弱引用的对象引用计数归零时,dealloc 过程中会清理所有 weak 引用。

完整流程:

对象引用计数归零
    |
    v
objc_object::rootDealloc()
    |
    v
object_dispose()
    |
    v
objc_destructInstance(obj)
    |
    v
clearDeallocating(obj)
    |
    v
clearDeallocating_slow(obj)
    |
    v
1. 根据 obj 地址找到对应的 SideTable
    |
    v
2. 加锁
    |
    v
3.  weak_table 中查找 obj 对应的 weak_entry_t
    |
    v
4. 遍历 weak_entry_t  referrers 数组
   对每个 weak 指针地址:*referrer = nil  (置 nil!)
    |
    v
5.  weak_table 中删除这个 weak_entry_t
    |
    v
6. 解锁
    |
    v
7. 释放对象内存(free)

第 4 步就是 weak 自动置 nil 的核心:Runtime 遍历所有指向这个对象的 weak 指针,把它们全部设为 nil。

4.4 weak 引用被覆盖或销毁时

当 weak 指针指向新对象或超出作用域时,Runtime 调用 objc_destroyWeak

objc_destroyWeak(&weakObj)
    |
    v
storeWeak(&weakObj, nil)
    |
    v
1. 找到旧对象的 SideTable
    |
    v
2. 从旧对象的 weak_entry_t  referrers 中移除 &weakObj
    |
    v
3. 如果 referrers 为空了,删除这个 weak_entry_t

五、SideTable 的哈希设计

5.1 为什么用 64 个 SideTable

如果只有一个全局的 SideTable,所有线程操作 weak 引用时都要抢同一把锁,性能极差。

64 个 SideTable 通过对象地址哈希分散到不同的表上,不同表用不同的锁,大幅减少了锁竞争。

对象 A(地址 0x1000)→ 哈希 → SideTable[3]  → 锁3
对象 B(地址 0x2000)→ 哈希 → SideTable[17] → 锁17
对象 C(地址 0x3000)→ 哈希 → SideTable[3]  → 锁3(和A竞争,但概率低)

5.2 weak_entry_t 内部的优化

weak_entry_t 内部存储 referrers(弱引用指针数组)有两种模式:

  • 内联模式(inline):当弱引用数量不超过 4 个时,直接用一个固定大小的数组存储(WEAK_INLINE_COUNT = 4),避免堆内存分配
  • 动态模式(outline):超过 4 个时,切换为动态分配的哈希数组

大多数对象的 weak 引用数量不超过 4 个(通常就一两个 delegate),所以内联模式覆盖了大部分场景,性能更好。


六、weak 和 isa 的关系

6.1 isa 中的 weakly_referenced 位

现代 Objective-C 使用"优化的 isa"(Non-pointer isa),把引用计数和一些标志位直接存在 isa 指针里:

isa 指针(64位):
| 1bit  | 1bit        | 1bit            | 33bit    | ...   |
| nonptr| has_assoc   | has_cxx_dtor    | shiftcls | ...   |
|       |             |                 |          |       |
|       |             |                 |          | weakly_referenced (1bit)

weakly_referenced 位标记这个对象是否有 weak 引用。dealloc 时,如果这个位是 0,就跳过 weak 清理流程,加速释放。

dealloc 快速路径:
  if (!nonpointer) → 慢路径
  if (weakly_referenced) → 需要清理 weak 表 → 慢路径
  if (has_assoc) → 需要清理关联对象 → 慢路径
  if (has_cxx_dtor) → 需要调用 C++ 析构 → 慢路径
  否则 → 直接 free,最快

所以一个没有 weak 引用、没有关联对象、没有 C++ 析构的纯 OC 对象,释放速度是最快的。


七、weak 的性能开销

weak 不是"免费的",它有实实在在的性能开销:

操作 开销
创建 weak 引用 哈希查找 SideTable + 加锁 + 插入 weak_entry
读取 weak 引用 读取 + retain + autorelease(或 release)
对象释放时 哈希查找 + 加锁 + 遍历所有 weak 指针置 nil + 删除条目

对比 __unsafe_unretained:创建和读取都只是普通的指针赋值和读取,几乎零开销。代价是对象释放后变成野指针。

实际影响:在绝大多数场景下,weak 的开销完全可以忽略。但在极端高频的场景下(比如每秒创建销毁上万个弱引用对象),可以考虑用 __unsafe_unretained 配合手动管理来优化。


八、weak 和 autoreleasepool 的关系

在 MRC 和早期 ARC 实现中,读取 weak 变量会自动将对象注册到 autoreleasepool:

id obj = objc_loadWeak(&weakObj);
// 等价于:
id obj = objc_loadWeakRetained(&weakObj);
objc_autorelease(obj);

这意味着在一个循环里频繁读取 weak 变量,会导致 autoreleasepool 膨胀:

for (int i = 0; i < 100000; i++) {
    NSLog(@"%@", weakObj);  // 每次读取都往 pool 里加一个
}
// pool 里积累了 10 万个对象,直到 pool drain 才释放

解决方案:在循环外用 strong 变量接住,循环里用 strong 变量。

现代 ARC(编译器优化后)在很多场景下已经不走 autorelease 了,但理解这个机制仍然重要。


九、Tagged Pointer 和 weak

Tagged Pointer 是苹果对小对象(短 NSString、小 NSNumber 等)的优化:把值直接编码在指针里,不是真正的堆对象。

对 Tagged Pointer 做 weak 引用时:

  • 因为它不是真正的对象,没有引用计数,不会被"释放"
  • Runtime 检测到是 Tagged Pointer 后,不会走 SideTable 的注册/清理流程
  • weak 指针直接存储 Tagged Pointer 的值,永远不会被置 nil

十、Swift 的 weak 和 Objective-C 的区别

维度 Objective-C weak Swift weak
类型 可以是任意 OC 对象 必须是 Optional 类型
类限制 只能用于 class 类型(AnyObject)
底层机制 SideTable Swift 有自己的实现,但原理类似
unowned 没有直接等价物 有 unowned(类似 unsafe_unretained,但 debug 模式有检查)

Swift 的 unowned vs weak

  • weak:可选类型,对象释放后变 nil,有 SideTable 开销
  • unowned:非可选类型,假设对象一定还活着。释放后访问会在 debug 模式 crash(比野指针更安全)。性能比 weak 好(不走 SideTable)

十一、常见面试问题

Q1:weak 是怎么实现自动置 nil 的?

Runtime 维护了一个全局的 SideTable 结构,其中的 weak_table 以对象地址为 Key,以所有指向该对象的 weak 指针地址数组为 Value。对象 dealloc 时,Runtime 从表中找到所有 weak 指针,逐个置 nil,然后删除表项。

Q2:weak 和 assign 有什么区别?

assign 只是简单的指针赋值,对象释放后指针变成野指针(指向已释放的内存)。weak 会在对象释放时自动置 nil,安全。assign 用于基本类型(int、CGFloat 等),weak 用于对象类型。

Q3:为什么 weak 比 strong 慢?

strong 只是引用计数的原子操作(+1/-1)。weak 需要额外的哈希查找、加锁、SideTable 操作。读取时还需要 retain 保证线程安全。但在绝大多数场景下差异可忽略。

Q4:一个对象可以有多少个 weak 引用?

理论上没有限制。weak_entry_t 内部先用 4 个内联槽位,超过后切换为动态哈希数组,可以按需增长。

Q5:weak 对象在什么线程被置 nil?

在触发 dealloc 的那个线程。谁释放了最后一个 strong 引用,就在谁的线程上走 dealloc 流程,进而清理 weak 表。


十二、一句话总结

weak 的本质就是 Runtime 维护了一张"对象 -> 弱指针列表"的全局哈希表(SideTable 中的 weak_table)。创建 weak 引用时注册,读取时 retain 保安全,对象释放时遍历置 nil 后删除条目。代价是哈希查找和加锁的开销,换来的是零野指针的安全性。

# iOS 电量优化详解

一、电量是怎么被消耗的?

手机电池的电量本质上就是电能。App 的各种操作最终都会驱动硬件工作,硬件工作就要消耗电能。

主要的耗电硬件:

┌────────────────────────────────────────────────────┐
│                    App 的各种操作                     │
├──────┬──────┬──────┬──────┬──────┬──────┬──────────┤
│ CPU  │ GPU  │ 网络  │ 定位  │ 屏幕 │ 传感器│ 蓝牙/NFC │
│      │      │模块   │模块   │背光  │      │          │
├──────┴──────┴──────┴──────┴──────┴──────┴──────────┤
│                    电池                              │
└────────────────────────────────────────────────────┘

关键认知:硬件有两种状态——空闲态和活跃态。

空闲态几乎不耗电,活跃态耗电量可能是空闲态的 10-100 倍。电量优化的核心就是:尽量让硬件处于空闲态,减少活跃态的持续时间。


二、iOS 的电量管理机制

2.1 合并唤醒(Coalescing)

iOS 不会让硬件被频繁地"唤醒-休眠-唤醒-休眠"。它会把多个 App 的小任务合并到同一个时间窗口集中处理。

不合并:
  App1 ─▮─────────▮─────────▮─────────   (每 10 秒唤醒一次)
  App2 ──────▮─────────▮─────────▮────   (每 10 秒唤醒一次)
  CPU   ─▮───▮──▮──▮───▮──▮──▮───▮──▮   (被唤醒了 9 次)

合并后:
  App1 ─▮─────────▮─────────▮─────────
  App2 ─▮─────────▮─────────▮─────────   (和 App1 对齐)
  CPU   ─▮─────────▮─────────▮─────────   (只被唤醒 3 次)

对开发者的启示: 不要自己用精确的 Timer 去定时做事,用系统提供的 API(如 BGTaskScheduler),让系统帮你合并。

2.2 能量计量(Energy Gauges)

iOS 在系统层面持续监控每个 App 的能量消耗。如果你的 App 耗电异常:

  • 设置 → 电池 里会显示高耗电
  • 系统可能会限制你的后台执行时间
  • App Store 审核可能因为耗电问题被拒
  • 用户看到你耗电高就卸载了

2.3 后台执行限制

iOS 对后台 App 的电量管控非常严格:

状态 允许做什么 时间限制
前台 任何事 无限制
后台(刚切走) 完成当前任务 约 30 秒(可申请延长到 ~3 分钟)
后台(挂起) 什么都不能做 0(被冻结)
后台模式(音乐/导航/VoIP等) 特定任务 持续但受监控

App 被挂起后,CPU 完全不分配给它,所以不耗电。 这是 iOS 比 Android 省电的核心原因之一。


三、八大耗电场景与优化

3.1 CPU —— 最大的耗电户

为什么耗电

CPU 频率越高、负载越重、持续时间越长,耗电越多。

常见问题

问题 场景
死循环 / 忙等待 while(flag) {} 没有 sleep
过度计算 主线程做复杂的 JSON 解析、图片处理
Timer 间隔太短 每 0.01 秒刷新一次,但界面根本看不出差别
后台还在跑 切后台了 Timer 还在走

优化策略

1. 避免忙等待,用事件驱动替代轮询

❌ 轮询:每 0.1 秒检查一次数据有没有准备好
   while (!dataReady) { usleep(100000); }

✅ 事件驱动:数据好了通知我
   NotificationCenter / KVO / Completion Handler / Combine

2. Timer 的电量陷阱

NSTimer / DispatchSourceTimer 默认是精确触发的,会阻止 CPU 进入深度休眠。

优化方式:给 Timer 加 tolerance(容差)

timer.tolerance = interval * 0.1  // 允许 10% 的偏差

加了 tolerance 后,系统可以把你的 Timer 和其他 Timer 合并触发,减少 CPU 唤醒次数。

苹果的建议:tolerance 至少设为间隔的 10%。

3. 用合适的 QoS(Quality of Service)

iOS 的任务队列有不同的优先级,低优先级的任务系统会安排在"电量友好"的时间执行:

QoS 级别 用途 CPU 调度
.userInteractive UI 更新、动画 最高优先级,立即执行
.userInitiated 用户触发的操作(点击后加载) 高优先级
.default 默认 中等
.utility 长时间任务(下载、导入) 低优先级,省电模式可能延迟
.background 用户不关心何时完成(预加载、备份) 最低,系统自行安排

原则:不需要立即响应的任务,用 .utility.background 系统会在电量充足或充电时才执行这些任务。

3.2 网络 —— 隐形的耗电大户

为什么网络特别耗电

蜂窝网络模块(4G/5G)有三种功耗状态:

空闲态(Idle)─── 几乎不耗电
    │  有数据要发送
    ▼
升频态(Ramp Up)─── 功耗急剧上升(从空闲到全速需要 1-2 秒)
    │
    ▼
全速态(Active)─── 高功耗传输数据
    │  数据传完
    ▼
拖尾态(Tail)─── 仍保持高功耗约 10-15 秒!等待可能的后续请求
    │  超时无新数据
    ▼
空闲态(Idle)

关键问题在"拖尾态": 传完数据后,蜂窝模块不会立刻休眠,而是保持活跃 10-15 秒等待新数据。如果你的 App 每 20 秒发一个小请求,蜂窝模块就永远无法进入空闲态。

❌ 零散请求(蜂窝模块永远醒着):
  请求──拖尾──请求──拖尾──请求──拖尾──请求──拖尾
  ████████████████████████████████████████████  全程高功耗

✅ 批量请求(只唤醒一次):
  ──────────批量请求──拖尾──────────────────────
  ░░░░░░░░░░█████████████░░░░░░░░░░░░░░░░░░░░  大部分时间低功耗

优化策略

1. 请求合并(Batching)

不要每个事件都立即发网络请求。把多个请求攒在一起,一次性发送。

例如:埋点数据不要实时上报,累积 20 条或间隔 30 秒批量上报。

2. 避免轮询,用推送替代

❌ 每 30 秒轮询一次服务器检查新消息
✅ 用 APNs 推送通知客户端有新消息

3. 适配网络类型

  • WiFi 比蜂窝省电得多(没有拖尾态问题)
  • 大文件下载、数据同步等操作尽量在 WiFi 环境下进行
  • NWPathMonitorReachability 判断当前网络类型

4. 减少数据传输量

  • 开启 HTTP 压缩(gzip / br)
  • 用 HTTP/2 的头部压缩
  • 图片用 WebP / HEIF 替代 PNG/JPEG
  • API 只返回需要的字段(GraphQL 的优势)
  • 合理使用缓存(URLCacheETagLast-Modified

5. 超时和重试策略

  • 设置合理的超时时间(不要太长等不来也不放手)
  • 重试用指数退避(1s → 2s → 4s → 8s),不要固定间隔疯狂重试
  • 失败后等 WiFi 或充电时再重试

3.3 定位 —— 精度越高越耗电

各精度的耗电对比

精度 API 耗电 适用场景
最佳精度 kCLLocationAccuracyBest 极高(GPS 全速运转) 导航
10 米 kCLLocationAccuracyNearestTenMeters 跑步记录
100 米 kCLLocationAccuracyHundredMeters 附近商家
公里级 kCLLocationAccuracyKilometer 天气、城市级服务
3公里级 kCLLocationAccuracyThreeKilometers 很低 粗略地理围栏
显著位置变化 startMonitoringSignificantLocationChanges 极低 只在基站切换时触发

GPS 芯片功耗约 25-35mW,WiFi 定位约 5-10mW,基站定位约 1-2mW。

优化策略

1. 用够了就关

开始定位 → 拿到位置 → 立即 stopUpdatingLocation

很多 App 犯的错误:开启定位后忘了关,GPS 一直在后台运转。

2. 用最低够用的精度

外卖 App 展示附近餐厅用 100 米精度足够了,不需要 Best。只有导航才需要最高精度。

3. 用"显著位置变化"替代持续定位

如果你只需要在用户换了个区域时更新内容(比如新闻 App 根据城市推荐),用 startMonitoringSignificantLocationChanges。它基于基站切换触发,几乎不额外耗电。

4. distanceFilter 过滤无意义的更新

locationManager.distanceFilter = 50  // 移动 50 米以上才回调

默认是 kCLDistanceFilterNone(每次都回调),设一个合理的值可以大幅减少回调次数。

5. allowsBackgroundLocationUpdates 谨慎使用

只有导航、运动记录等真正需要后台定位的场景才开启。开启后要搭配 pausesLocationUpdatesAutomatically = true,让系统在检测到用户静止时自动暂停。

3.4 GPU / 图形渲染

耗电的渲染操作

操作 为什么耗电
离屏渲染 需要额外的帧缓冲区,GPU 要来回切换上下文
大量透明度混合 每一层都要计算混合,层越多越慢
大图缩小显示 GPU 要对大图做缩放计算
实时模糊(UIBlurEffect) 每帧都要对底层内容做高斯模糊
高帧率动画 120Hz 的计算量是 60Hz 的两倍

优化策略

1. 避免不必要的离屏渲染

触发离屏渲染的操作:
  - cornerRadius + masksToBounds(圆角裁剪)
  - shadow(阴影,没有设 shadowPath 时)
  - mask(遮罩)
  - group opacity(组透明度)

优化方式:
  - 圆角:用贝塞尔曲线预先裁剪成圆角图片,或在绘图时直接画圆角
  - 阴影:设置 shadowPath,避免实时计算阴影形状
  - 模糊:对静态内容用截图+模糊的方式,而不是实时 UIVisualEffectView

2. 图片大小匹配显示大小

一张 3000x3000 的图显示在 100x100 的 ImageView 里,GPU 每帧都要缩放。应该在加载时就缩放到显示尺寸。

3. 降低不必要的帧率

不是所有动画都需要 60fps / 120fps。滚动和交互动画需要高帧率,但一个缓慢变化的进度条用 30fps 就够了。

CADisplayLink 可以设置 preferredFramesPerSecond

3.5 蓝牙(BLE)

两种扫描模式的耗电差异

模式 耗电 说明
主动扫描(Active Scan) 蓝牙模块持续发射扫描请求
被动监听 只监听广播包

优化策略

  • 扫描到目标设备后立即停止扫描
  • 设置 CBCentralManagerScanOptionAllowDuplicatesKey = NO,避免重复上报同一个设备
  • 后台扫描比前台限制更严格,系统会自动降低扫描频率
  • 不需要实时数据时,用 notify 替代 read(让外设主动通知,而不是 App 轮询读取)

3.6 后台任务

beginBackgroundTask 的正确用法

切后台时申请额外执行时间来完成当前任务:

关键要点:
① 一定要在超时回调里调用 endBackgroundTask,否则系统会杀掉你的 App
② 不要用它来"偷偷"执行长时间任务
③ 系统给的时间在 iOS 13+ 只有约 30 秒(以前是 3 分钟)

BGTaskScheduler(iOS 13+)

用于安排后台任务,系统会在合适的时间执行:

类型 用途 触发条件
BGAppRefreshTask 数据刷新(拉新闻、同步) 系统根据用户使用习惯决定
BGProcessingTask 重计算任务(数据库清理、ML训练) 通常在充电 + WiFi 时

系统会综合考虑电量、网络、充电状态、用户使用习惯来决定何时执行你的任务。 你只需要提交任务,不需要操心何时执行。

3.7 推送通知

静默推送的耗电陷阱

静默推送(content-available: 1)会唤醒 App 在后台执行代码。如果推送频率太高(比如每分钟一次),相当于 App 每分钟被唤醒一次,持续消耗 CPU 和网络。

苹果会限制频率: 如果系统检测到你的静默推送太频繁,会开始丢弃推送。

优化: 静默推送只用于"有重要数据需要预加载"的场景,不要当成轮询的替代品。

3.8 传感器

传感器 耗电 优化
加速度计 用 CMMotionManager 的合理更新频率,不用就 stop
陀螺仪 同上
磁力计(指南针) headingFilter 过滤微小变化
气压计 按需使用
摄像头 极高 分辨率调到够用即可,不用就释放
麦克风 用 VAD(语音活动检测)避免持续录音

四、电量监控与测量

4.1 开发阶段

Instruments - Energy Log

Xcode 的 Instruments 提供 Energy Log 模板,能看到:

  • CPU 活动(Overhead 级别:0-20)
  • 网络活动
  • 定位活动
  • GPU 活动
  • 前台/后台状态

每个指标用 0-20 的等级表示功耗水平。

Xcode Energy Gauges

Debug Navigator 里实时显示 Energy Impact(低/中/高/极高),直观但粗略。

Energy Impact 的颜色含义:

  • 绿色(低):正常
  • 黄色(中):有优化空间
  • 红色(高/极高):需要关注

sysdiagnose

在设备上触发 sysdiagnose(同时按 音量上 + 音量下 + 电源键),生成一份详细的系统诊断报告,包含详细的电量日志。

4.2 线上监控

MetricKit(iOS 13+)

苹果提供的官方线上性能监控框架,每 24 小时汇总一次数据:

Metric 说明
MXCPUMetric CPU 使用指令数
MXGPUMetric GPU 使用时间
MXNetworkTransferMetric 网络传输量(上/下行)
MXLocationActivityMetric 定位活动时间
MXCellularConditionMetric 蜂窝信号质量(信号差时更耗电)
MXAppRunTimeMetric 前台/后台运行时间

这些数据以直方图形式提供,包含 P50/P90/P99 分位值。 可以帮你了解真实用户的耗电情况。

Xcode Organizer - Energy Reports

Xcode → Window → Organizer → Energy,可以看到线上用户的能量报告。如果你的 App 被系统判定为"耗电异常",这里会有日志。

4.3 电量归因:到底是谁在耗电?

当发现 App 耗电高时,排查思路:

1. CPU 高?
   └── 用 Time Profiler 找到热点函数
       └── 是主线程还是子线程?
       └── 是否有不必要的循环/计算?
       └── 后台是否有 Timer 在跑?

2. 网络频繁?
   └── 用 Network instrument 看请求频率和数据量
       └── 是否有轮询?
       └── 请求是否可以合并?
       └── 是否在蜂窝网络下做了大量传输?

3. 定位一直开着?
   └── 检查 CLLocationManager 的 start/stop 配对
       └── 精度是否过高?
       └── 后台是否还在定位?

4. GPU 负载高?
   └── 用 Core Animation instrument 检查离屏渲染
       └── 是否有不必要的透明度混合?
       └── 帧率是否过高?

五、Low Power Mode(低电量模式)适配

用户开启低电量模式后,系统会:

  • 降低 CPU/GPU 频率
  • 减少后台活动
  • 降低屏幕亮度
  • 关闭 5G(降回 4G)
  • 停止自动下载和邮件获取

你的 App 应该监听并适配:

检测方式 说明
ProcessInfo.processInfo.isLowPowerModeEnabled 查询当前状态
NSProcessInfoPowerStateDidChangeNotification 监听状态变化

适配建议:

  • 低电量模式下降低动画帧率或关闭动画
  • 停止非关键的后台数据同步
  • 降低定位精度
  • 减少网络请求频率
  • 延迟非紧急的计算任务

六、优化原则总结

六字箴言:少做、晚做、批量做

原则 含义 例子
少做 能不做就不做 不需要的数据不请求,不在屏的 View 不渲染
晚做 能推迟就推迟 非关键 SDK 延迟初始化,后台任务等充电时做
批量做 能合并就合并 网络请求合并,埋点批量上报

优化优先级

按耗电影响从大到小:

1. 🔴 网络(尤其是蜂窝网络的拖尾效应)
2. 🔴 定位(GPS 持续开启)
3. 🟡 CPU(后台 Timer、忙等待、过度计算)
4. 🟡 GPU(离屏渲染、高帧率)
5. 🟢 蓝牙(持续扫描)
6. 🟢 传感器(持续采集)

一张检查清单

□ Timer 是否设置了 tolerance?
□ 切后台后是否停止了不必要的 Timer / 定位 / 蓝牙扫描?
□ 网络请求是否有合并?是否有缓存?
□ 定位精度是否是最低够用的?用完是否关闭了?
□ 是否有轮询可以用推送替代?
□ 后台任务是否用了 BGTaskScheduler 而不是自己计时?
□ 图片是否压缩到了合适的尺寸?
□ 是否适配了低电量模式?
□ 大型计算任务是否标记了合适的 QoS?
□ 是否用 MetricKit 监控了线上耗电数据?
❌