普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月13日首页

Flutter刷新机制与重建优化

作者 MonkeyKing
2026年4月13日 08:18

Flutter 作为跨平台开发框架,其流畅性的核心依赖于高效的刷新与渲染机制。但在实际开发中,很多开发者都会遇到“界面卡顿”“不必要重建”等问题——明明只是修改一个简单的文本,却导致整个页面重建;明明优化了代码,却依然出现掉帧。本质上,这都是对 Flutter 刷新机制、Widget 重建逻辑理解不透彻导致的。

本文将从底层源码出发,拆解 Flutter 刷新机制的核心流程(从状态更新到界面渲染),剖析 Widget 重建的触发条件与底层逻辑,再结合实战场景,给出可落地的重建优化方案,帮你彻底解决 Flutter 刷新卡顿、性能损耗问题,写出高效、流畅的 Flutter 页面。

核心要点:Flutter 刷新的本质是“状态驱动”,重建的核心是“Widget 树对比”,优化的关键是“减少不必要的 Widget 构建与渲染”。

一、前置基础:Flutter 刷新的核心概念

在解析刷新机制前,先明确三个核心概念,避免陷入细节误区——这三个概念贯穿整个刷新与重建流程,是理解后续内容的基础:

1. Widget:不可变的描述性对象

Flutter 中所有界面元素都是 Widget,但其本质是“对界面的不可变描述”(immutable),本身不负责渲染,也不持有状态。Widget 的核心作用是“告诉 Flutter 如何构建界面”,一旦创建,其属性(props)不可修改——若需修改界面,必须通过“创建新的 Widget 实例”来实现。

源码层面,Widget 类的核心定义(精简版):

abstract class Widget {
  const Widget({ this.key });
  final Key? key;

  // 核心方法:创建Element实例,Widget是描述,Element是实际渲染的载体
  @protected
  Element createElement();

  // 用于Widget树对比,判断是否需要重建
  @override
  bool operator ==(Object other) => identical(this, other) || (other is Widget && runtimeType == other.runtimeType && key == other.key);

  @override
  int get hashCode => Object.hash(runtimeType, key);
}

关键注意:Widget 的 == 运算符重写逻辑,决定了后续“Widget 树对比”的核心规则——只有 runtimeType(Widget 类型)和 key 都相同,才会被认为是“同一个 Widget” ,否则会被判定为新 Widget,触发重建。

2. Element:Widget 的实例化与渲染载体

Widget 只是“描述”,而 Element 才是 Flutter 渲染树(Render Tree)的核心节点,负责管理 Widget 的生命周期、状态和渲染逻辑。每个 Widget 都会对应一个 Element 实例,Element 会持有 Widget 的引用,并根据 Widget 的描述,创建对应的 RenderObject。

核心流程:Widget → createElement() → Element → createRenderObject() → RenderObject(负责绘制)。

Element 的核心作用: - 连接 Widget(描述)和 RenderObject(渲染); - 管理状态(StatefulWidget 的 State 由 Element 持有); - 参与 Widget 树对比,决定是否需要重建 RenderObject。

3. State:可变状态的管理者

对于需要动态更新的界面(如点击按钮修改文本),需使用 StatefulWidget,其可变状态由 State 类管理。State 持有 Widget 的引用,通过 setState() 方法触发状态更新,进而触发界面刷新。

核心注意:setState() 是 Flutter 刷新的“入口”,但其本质是“标记当前 Element 为脏(dirty)”,并通知 Flutter 框架进行后续的刷新流程,而非直接重建 Widget。

二、深度解析:Flutter 刷新机制完整流程(源码级)

Flutter 刷新机制的核心是“状态驱动刷新”,整个流程从 setState() 调用开始,到界面渲染结束,分为 4 个核心步骤,结合源码逻辑逐一拆解,让你看清每一步的底层操作。

1. 第一步:setState() 触发状态标记(脏标记)

当我们调用 setState(() { ... }) 时,本质是调用了 State 类的 setState 方法,其源码(精简版)如下:

void setState(VoidCallback fn) {
  assert(fn != null);
  assert(() {
    if (_debugLifecycleState == _StateLifecycle.defunct) {
      throw FlutterError(...);
    }
    return true;
  }());
  // 执行状态修改逻辑
  final Object? result = fn() as dynamic;
  // 标记当前Element为脏,并添加到全局脏队列
  _element!.markNeedsBuild();
}

关键逻辑:_element!.markNeedsBuild() —— 该方法会将当前 State 对应的 Element 标记为“脏(dirty)”,并将其加入 Flutter 框架的“脏元素队列(dirtyElements)”中,等待下一次刷新周期处理。

补充:Flutter 采用“异步刷新”机制,不会在 setState() 调用后立即刷新,而是等待当前事件循环结束后,统一处理脏元素队列,避免频繁刷新导致性能损耗。

2. 第二步:刷新信号触发(Vsync 信号)

Flutter 刷新依赖于屏幕的 Vsync(垂直同步)信号,默认刷新频率为 60Hz(约 16.67ms 每帧)。当脏元素队列不为空时,Flutter 会在收到 Vsync 信号后,启动刷新流程,核心入口是 ScheduleBinding 类的 handleDrawFrame 方法。

核心逻辑:Vsync 信号触发后,Flutter 会遍历脏元素队列,对每个脏 Element 执行“重建 + 重绘”操作,确保每帧只刷新一次,避免掉帧。

3. 第三步:Widget 树对比与 Element 重建(核心步骤)

这是刷新机制中最关键的一步——Flutter 不会每次刷新都重建整个 Widget 树,而是通过“Widget 树对比(Diffing)”,只重建变化的部分,这也是 Flutter 高效刷新的核心优化。

核心流程(以 StatefulWidget 为例):

  1. Element 被标记为脏后,会调用 build() 方法,生成新的 Widget 树(称为“新树”);

  2. 将新树与当前持有的旧 Widget 树(旧树)进行对比(Diffing 算法);

  3. 根据对比结果,决定是否重建 Element 和 RenderObject:

    1. 若新树与旧树的 Widget “相同”(runtimeType 和 key 都一致):则复用当前 Element 和 RenderObject,只更新其属性(如 Text 的 data、Container 的 color);
    2. 若新树与旧树的 Widget “不同”:则销毁旧的 Element 和 RenderObject,创建新的 Element 和 RenderObject,触发完整重建;
    3. 若 Widget 树的结构发生变化(如新增、删除 Widget):则对应位置的 Element 和 RenderObject 会被重建,未变化的部分会被复用。

关键注意:Widget 树对比的核心是“key”——如果没有设置 key,Flutter 会默认根据 Widget 的 runtimeType 对比,容易导致“误判”,进而触发不必要的重建(后续优化部分会详细说明)。

4. 第四步:RenderObject 重绘与合成渲染

当 Element 重建完成后,会通知对应的 RenderObject 更新绘制信息(如尺寸、颜色、布局),RenderObject 会执行 paint() 方法进行绘制,生成图层(Layer)。

最后,Flutter 会将所有 RenderObject 生成的图层进行合成,提交给 GPU 渲染到屏幕上,完成一次完整的刷新。

总结刷新流程

setState() → 标记 Element 为脏 → 加入脏队列 → 收到 Vsync 信号 → Widget 树对比 → 重建变化的 Element/RenderObject → 重绘合成 → 渲染到屏幕。

三、关键剖析:Widget 重建的触发条件(避坑核心)

很多开发者的误区是:“只要调用 setState(),就会重建整个页面”——其实不然,重建的触发与否,取决于 Widget 树对比的结果。以下是 4 种常见的重建触发场景,结合源码逻辑和实际案例,帮你精准避坑。

1. 场景1:setState() 触发当前 Widget 及其子 Widget 重建(默认行为)

当在某个 StatefulWidget 的 State 中调用 setState() 时,默认会触发该 State 对应的 Widget 的 build() 方法,生成新的子 Widget 树,进而触发子 Widget 的对比与重建。

示例(错误示范):

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    print("HomePage build"); // 每次setState都会打印
    return Scaffold(
      body: Column(
        children: [
          Text("计数:$_count"),
          // 子Widget,每次HomePage build都会重建
          ChildWidget(),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            _count++; // 只修改计数,却导致ChildWidget重建
          });
        },
      ),
    );
  }
}

问题:每次点击按钮,_count 变化,调用 setState() 会触发 HomePage 的 build() 方法,进而重建 ChildWidget——但 ChildWidget 与 _count 无关,属于“不必要重建”,会造成性能损耗。

2. 场景2:Widget 类型或 key 变化,触发强制重建

根据 Widget 的 == 运算符逻辑,若新生成的 Widget 与旧 Widget 的 runtimeType 或 key 不同,会被判定为“新 Widget”,触发对应的 Element 和 RenderObject 销毁与重建,即使其他属性完全一致。

示例(key 使用不当):

// 错误示范:每次build都生成新的Key
Widget build(BuildContext context) {
  return ListView.builder(
    itemCount: 10,
    itemBuilder: (context, index) {
      // 每次build都会创建新的ValueKey,导致ItemWidget强制重建
      return ItemWidget(key: ValueKey("item_$index"), index: index);
    },
  );
}

问题:每次父 Widget 重建,itemBuilder 都会生成新的 ValueKey,导致 ItemWidget 的 key 变化,即使 index 不变,也会触发 ItemWidget 重建,严重影响列表滚动流畅性。

3. 场景3:父 Widget 重建,子 Widget 未做缓存,触发重建

即使子 Widget 与父 Widget 的状态无关,若父 Widget 重建,且子 Widget 未做任何缓存优化,默认会重新创建子 Widget 实例,触发对比与重建(即使对比后发现可以复用,也会产生不必要的构建开销)。

本质原因:父 Widget 的 build() 方法每次执行,都会重新创建所有子 Widget 的实例,即使子 Widget 的属性没有变化。

4. 场景4:InheritedWidget 状态变化,触发依赖组件重建

InheritedWidget 是 Flutter 中跨组件状态共享的核心,当 InheritedWidget 的状态变化时,所有依赖它的子组件(通过 context.dependOnInheritedWidgetOfExactType 获取状态)都会被标记为脏,触发重建。

注意:只有“依赖”该 InheritedWidget 的组件会重建,不依赖的组件不会受到影响——这是 InheritedWidget 的优化特性,避免不必要的重建。

四、实战优化:减少 Widget 重建的 6 个核心方案(可直接落地)

优化的核心原则:只重建“必须重建”的 Widget,复用“无需变化”的 Widget,减少不必要的构建和渲染开销。以下 6 个方案,从易到难,覆盖日常开发中 90% 的重建优化场景,结合示例代码,可直接应用到项目中。

优化1:使用 const 构造函数,缓存无状态 Widget

对于无状态 Widget(StatelessWidget),若其属性不会变化,可使用 const 构造函数——const Widget 会在编译期创建,且会被缓存,即使父 Widget 重建,也不会重新创建 const Widget 实例,避免对比和重建开销。

优化示例:

// 优化前:无const构造函数,每次父Widget重建都会创建新实例
class ChildWidget extends StatelessWidget {
  const ChildWidget({super.key}); // 优化:添加const构造函数

  @override
  Widget build(BuildContext context) {
    print("ChildWidget build");
    return const Text("固定文本,不会变化");
  }
}

// 父Widget中使用
Widget build(BuildContext context) {
  return Column(
    children: [
      Text("计数:$_count"),
      const ChildWidget(), // 关键:添加const,复用缓存的实例
    ],
  );
}

效果:父 Widget 调用 setState() 时,ChildWidget 不会重建,因为其是 const 实例,Widget 树对比时会判定为“同一个 Widget”,直接复用。

优化2:合理使用 Key,避免误判重建

Key 的核心作用是“帮助 Flutter 识别 Widget 的唯一性”,合理使用 Key 可以避免 Widget 树对比时的误判,减少不必要的重建,尤其适用于列表、动态添加/删除 Widget 的场景。

核心使用原则:

  • 列表场景:使用 ValueKey(基于唯一标识,如 id)、ObjectKey,避免使用 IndexKey(列表排序变化时会导致重建);
  • 动态 Widget 场景:给每个动态生成的 Widget 分配唯一的 Key,确保 Widget 树对比时能正确识别复用;
  • 无需动态变化的 Widget:无需设置 Key(默认即可),避免多余的 Key 对比开销。

优化示例(列表场景):

// 优化前:使用IndexKey(排序变化时触发重建)
// 优化后:使用ValueKey(基于item的唯一id)
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    final item = items[index];
    // 基于item的唯一id创建Key,即使列表排序变化,也能正确复用
    return ItemWidget(key: ValueKey(item.id), item: item);
  },
);

优化3:使用 StatefulBuilder 局部刷新,避免全局重建

当只需刷新页面中的某个局部组件(而非整个页面)时,可使用 StatefulBuilder,将局部状态与全局状态分离,只触发局部组件的重建,避免全局 Widget 树重建。

优化示例(局部刷新按钮文本):

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print("HomePage build"); // 只会打印一次,不会因局部刷新重建
    return Scaffold(
      body: Center(
        child: StatefulBuilder(
          builder: (context, setState) {
            int localCount = 0;
            return Column(
              children: [
                Text("局部计数:$localCount"),
                ElevatedButton(
                  onPressed: () {
                    // 只触发StatefulBuilder内部的重建,不影响外部HomePage
                    setState(() {
                      localCount++;
                    });
                  },
                  child: const Text("局部刷新"),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

优化4:使用 RepaintBoundary 隔离渲染层,减少重绘

重建和重绘是两个不同的概念:重建是“重新创建 Widget/Element”,重绘是“重新绘制 RenderObject”。即使 Widget 没有重建,若其所在的渲染层发生变化,也会触发重绘。

RepaintBoundary 的核心作用是“将组件隔离在独立的渲染层(Layer)”,当该组件的内容未变化时,即使父组件重绘,该组件也不会重绘;只有当组件自身内容变化时,才会重绘自己的渲染层。

适用场景:列表项、固定不变的头部/底部、频繁刷新的组件(如倒计时)与其他组件隔离。

优化示例:

// 列表项添加RepaintBoundary,避免一个列表项重绘导致所有列表项重绘
ListView.builder(
  itemCount: 100,
  itemBuilder: (context, index) {
    return RepaintBoundary(
      child: ListItem(
        index: index,
        data: items[index],
      ),
    );
  },
);

注意:不要过度使用 RepaintBoundary——每个 RepaintBoundary 都会创建一个独立的 Layer,过多的 Layer 会增加内存开销,适可而止即可。

优化5:使用 AutomaticKeepAliveClientMixin 缓存列表项

在列表(如 ListView、PageView)中,当列表项滚动出屏幕时,Flutter 会默认销毁其 Element 和 RenderObject,再次滚动到屏幕时,会重新创建和重建,导致列表滚动卡顿(尤其是复杂列表项)。

使用 AutomaticKeepAliveClientMixin 可以缓存列表项的状态和渲染信息,即使列表项滚动出屏幕,也不会被销毁,再次滚动到屏幕时,直接复用,避免重建和重绘。

优化示例:

class KeepAliveItem extends StatefulWidget {
  const KeepAliveItem({super.key, required this.index});
  final int index;

  @override
  State<KeepAliveItem> createState() => _KeepAliveItemState();
}

class _KeepAliveItemState extends State<KeepAliveItem> with AutomaticKeepAliveClientMixin {
  // 必须重写该方法,返回true表示需要缓存
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context); // 必须调用super.build(context)
    print("KeepAliveItem ${widget.index} build"); // 只打印一次
    return Text("列表项 ${widget.index}");
  }
}

效果:列表项滚动出屏幕后,再次滚动回来,不会重新 build,直接复用缓存的实例,提升列表滚动流畅性。

优化6:拆分 Widget,分离可变与不可变部分

将页面拆分为“可变部分”和“不可变部分”,将可变状态封装在独立的 StatefulWidget 中,不可变部分封装为 StatelessWidget(并使用 const 构造函数),这样当可变状态变化时,只有可变部分会重建,不可变部分不会受到影响。

优化示例(拆分前 vs 拆分后):

// 拆分前:所有内容都在一个StatefulWidget中,任何状态变化都触发全局重建
class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("首页")), // 不可变部分
      body: Column(
        children: [
          Text("计数:$_count"), // 可变部分
          const Text("固定文本"), // 不可变部分
        ],
      ),
    );
  }
}

// 拆分后:可变部分单独封装
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: const AppBar(title: Text("首页")), // 不可变,const缓存
      body: Column(
        children: [
          CountWidget(), // 可变部分,单独封装
          const Text("固定文本"), // 不可变,const缓存
        ],
      ),
    );
  }
}

// 可变部分:只在计数变化时重建
class CountWidget extends StatefulWidget {
  const CountWidget({super.key});

  @override
  State<CountWidget> createState() => _CountWidgetState();
}

class _CountWidgetState extends State<CountWidget> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text("计数:$_count"),
        ElevatedButton(onPressed: () => setState(() => _count++), child: const Text("增加")),
      ],
    );
  }
}

效果:点击按钮时,只有 CountWidget 会重建,HomePage、AppBar、固定文本等不可变部分不会重建,减少大量不必要的构建开销。

五、进阶优化:刷新性能调试工具与实战技巧

优化的前提是“找到问题”——只有定位到哪些 Widget 在不必要重建、哪些组件存在重绘开销,才能针对性优化。以下是 Flutter 官方推荐的调试工具和实战技巧,帮你快速定位刷新问题。

1. 调试工具:打开“显示重绘区域”

在 Flutter 开发工具中,打开 More Actions → Debug Paint → Show Repaint Rainbow,此时屏幕上会用不同颜色标记重绘的区域:

  • 重绘时,区域会闪烁对应颜色;
  • 若某个区域频繁闪烁,说明该区域存在频繁重绘,需优化(如使用 RepaintBoundary 隔离)。

2. 调试技巧:打印 build 日志,定位重建问题

在每个 Widget 的 build() 方法中添加 print 日志,查看哪些 Widget 在不必要的情况下被重建,进而定位问题根源(如父 Widget 重建、Key 使用不当等)。

示例:

@override
Widget build(BuildContext context) {
  print("${runtimeType} build"); // 打印当前Widget的类型,定位重建
  return ...;
}

3. 进阶技巧:使用 Provider/Riverpod 进行状态管理,精准控制刷新范围

使用状态管理框架(如 Provider、Riverpod),可以将状态与 UI 分离,并且只让“依赖该状态”的组件重建,不依赖的组件不会受到影响,进一步减少不必要的重建。

核心优势:状态管理框架会自动跟踪组件对状态的依赖,当状态变化时,只通知依赖该状态的组件刷新,比手动拆分 Widget 更高效、更简洁。

六、总结:Flutter 刷新与重建优化的核心逻辑

Flutter 刷新机制的核心是“状态驱动、按需重建”,优化的本质是“减少不必要的 Widget 构建和 RenderObject 重绘”,总结三个核心要点,帮你快速掌握优化精髓:

  1. 理解 Widget/Element/RenderObject 的关系:Widget 是描述,Element 是载体,RenderObject 是渲染核心,重建的是 Element,重绘的是 RenderObject;
  2. 避免不必要重建的关键:用 const 缓存无状态 Widget、合理使用 Key、拆分可变与不可变部分、局部刷新替代全局刷新;
  3. 减少重绘的关键:用 RepaintBoundary 隔离渲染层、用 AutomaticKeepAliveClientMixin 缓存列表项,结合调试工具定位重绘问题。

其实 Flutter 的刷新与重建优化并不复杂,核心是“看透底层逻辑,按需优化”——不需要盲目添加优化代码,而是先定位问题,再针对性使用对应的优化方案,才能既保证界面流畅,又避免过度优化带来的维护成本。

记住:最好的优化,是“不做不必要的操作”——只重建需要重建的组件,只重绘需要重绘的部分,这才是 Flutter 高性能开发的核心。

昨天以前首页

Flutter组件封装:Sliver 中的 Container 对应组件NSliverContainer

作者 SoaringHeart
2026年4月10日 14:39

一、需求来源

项目中遇到一些代 Sliver 老代码,需要经常调整显示样式,感觉特别麻烦就想封装一个 Container 那样功能比较丰富的基础组件。

const NSliverContainer({
  super.key,
  required this.sliver,
  this.margin,
  this.padding,
  this.decoration,
  this.foregroundPadding,
  this.foregroundDecoration,
  this.opacity,
  this.ignoring,
  this.offstage,
});

/// 外边距
final EdgeInsetsGeometry? margin;

/// 内边距
final EdgeInsetsGeometry? padding;

/// 背景装饰器
final Decoration? decoration;

/// 前景装饰器内间距
final EdgeInsetsGeometry? foregroundPadding;

/// 前景装饰器
final Decoration? foregroundDecoration;

/// 透明度
final double? opacity;

/// 是否忽略事件
final bool? ignoring;

/// 是否 offstage
final bool? offstage;

simulator_screenshot_337523A2-F925-465B-B63C-A6BC1368526B.png_副本.png

二、使用示例

Widget buildSliverContainer() {
  return NSliverContainer(
    margin: const EdgeInsets.all(8),
    padding: const EdgeInsets.all(8),
    decoration: BoxDecoration(
      color: Colors.purple[50],
      borderRadius: const BorderRadius.all(Radius.circular(8)),
      border: Border.all(color: Colors.blue),
    ),
    foregroundPadding: const EdgeInsets.all(8),
    foregroundDecoration: BoxDecoration(
      color: Colors.green.withOpacity(0.6),
      borderRadius: const BorderRadius.all(Radius.circular(24)),
      border: Border.all(color: Colors.blue),
      image: DecorationImage(
        image: AssetImage(Assets.imagesBgJiguang),
      ),
    ),
    // opacity: 0.3,
    // offstage: true,
    sliver: SliverPadding(
      padding: const EdgeInsets.all(0.0),
      sliver: SliverList.list(
        children: [
          Text("NSliverContainer"),
          Text("NSliverContainer1"),
          Text("NSliverContainer2"),
        ],
      ),
    ),
  );
}

三、源码 NSliverContainer

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';

/// sliver 族 Container
class NSliverContainer extends StatelessWidget {
  const NSliverContainer({
    super.key,
    required this.sliver,
    this.margin,
    this.padding,
    this.decoration,
    this.foregroundPadding,
    this.foregroundDecoration,
    this.opacity,
    this.ignoring,
    this.offstage,
  });

  /// 外边距
  final EdgeInsetsGeometry? margin;

  /// 内边距
  final EdgeInsetsGeometry? padding;

  /// 背景装饰器
  final Decoration? decoration;

  /// 前景装饰器内间距
  final EdgeInsetsGeometry? foregroundPadding;

  /// 前景装饰器
  final Decoration? foregroundDecoration;

  /// 透明度
  final double? opacity;

  /// 是否忽略事件
  final bool? ignoring;

  /// 是否 offstage
  final bool? offstage;

  /// 子组件
  final Widget sliver;

  @override
  Widget build(BuildContext context) {
    Widget current = sliver;

    /// padding
    if (padding != null) {
      current = SliverPadding(
        padding: padding!,
        sliver: current,
      );
    }

    if (foregroundDecoration != null) {
      current = DecoratedSliver(
        decoration: foregroundDecoration!,
        position: DecorationPosition.foreground,
        sliver: current,
      );
    }

    if (foregroundPadding != null) {
      current = SliverPadding(
        padding: foregroundPadding!,
        sliver: current,
      );
    }

    /// decoration
    if (decoration != null) {
      current = DecoratedSliver(
        decoration: decoration!,
        sliver: current,
      );
    }

    /// margin(最外层)
    if (margin != null) {
      current = SliverPadding(
        padding: margin!,
        sliver: current,
      );
    }

    if (opacity != null) {
      current = SliverOpacity(
        opacity: opacity!,
        sliver: current,
      );
    }

    if (ignoring != null) {
      current = SliverIgnorePointer(
        ignoring: ignoring!,
        sliver: current,
      );
    }

    if (offstage != null) {
      current = SliverOffstage(
        offstage: offstage!,
        sliver: current,
      );
    }

    return current;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null));
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
    properties.add(DiagnosticsProperty<Decoration>('bg', decoration, defaultValue: null));
    properties.add(DiagnosticsProperty<Decoration>('fg', foregroundDecoration, defaultValue: null));
    properties.add(DiagnosticsProperty<double>('opacity', opacity, defaultValue: null));
    properties.add(DiagnosticsProperty<bool>('ignoring', ignoring, defaultValue: null));
    properties.add(DiagnosticsProperty<bool>('offstage', offstage, defaultValue: null));
  }
}

最后、总结

1、NSliverContainer 基于 SliverPadding、 DecoratedSliver、 SliverOpacity、 SliverIgnorePointer、 SliverOffstage 等 sliver 官方组件组合而成,可以放心使用。

github

聊聊我最近都干了些什么,AI 时代的手动撸码人

作者 season_zhu
2026年4月1日 09:47

前言

许久未更新内容了,除了被公司的项目倒腾、拉扯之外,其实最近几个月还是干了许多事情的。我就随便聊聊吧。


一、RxStudy 项目尝试同时集成Flutter模块与UniApp模块

其实这个尝试,没有使用AI的功能,完全就是我自己无聊做的一点尝试,我将自己的UniAppPlayAndroid打包成为wgt,然后把GetXStudy项目的Flutter模块全部都集成到RxStudy项目,做了一个超级大杂烩,并且尝试几个端的通信,大家看看效果。

项目截图

玩安卓 原生 Flutter UniApp.gif

二、RxStudy 项目从 CocoaPods 向 Tuist 迁移

CocoaPods 停止维护的消息,iOS 开发者应该都有所耳闻。趁着这个机会,我拿自己 2019 年就开始维护的 RxStudy 练手项目做了一次大迁移。

迁移方案:CocoaPods → Tuist + Swift Package Manager (SPM)

迁移耗时:前前后后大概 3天(从开始到项目能跑起来)。说实话,本来以为会花费更多时间,没想到有了 AI 的帮助,大概就花了这么点时间就搞定了,大大出乎我的意料。

迁移内容

  • Project.swift 定义项目结构、Targets、SPM 依赖引用
  • Tuist/Package.swift 管理 20+ 第三方库的 SPM 版本
  • 本地 Package 封装(HUD、网络请求封装、工具类、路由框架)
  • 双 Target 架构:RxStudy(UIKit + RxSwift) 和 SwiftUIApp(SwiftUI)

![Tuist + SPM 架构图]

AI 表现:大部分时间花在 Tuist 配置文件的编写上,AI 生成的代码基本可以直接使用,复盘时发现主要还是项目结构本身比较规范。


三、RxStudy 项目从 UIKit 向 SwiftUI 迁移

在完成 CocoaPods 向 Tuist 迁移后,我又给 AI 安排了一个新任务:把 RxSwift 里的 UIKit 代码向 SwiftUI 进行迁移。

迁移策略:采用双 Target 并行架构,而非一次性替换

Target 技术栈 说明
RxStudy UIKit + RxSwift 原有代码
SwiftUIApp SwiftUI + @Observable + async/await 新迁移代码

迁移结果:SwiftUIApp 这个 Target 里的代码,95% 都是 AI 写的,我只是给出了部分建议,以及尝试在两个 Target 中复用网络请求层代码。

迁移模块(共10个):

模块 功能
Home Banner + 文章列表
Project 项目分类
PublicNumber 公众号
Tree 体系结构(二级树形)
Mine 用户中心
Login 登录
Collect 收藏列表
Coin 积分明细
CoinRankList 积分排行榜
Search 热搜 + 搜索结果

技术栈变化

类型 迁移前 迁移后
状态管理 RxSwift (RxSwift 6.9.0) @Observable + async/await
状态绑定 RxCocoa SwiftUI 原生
网络层 Moya + RxSwift Moya + async/await

说明:SwiftUI 迁移没有使用 Combine,而是使用了 iOS 17+ 的 @Observable 宏和 Swift 的 async/await,代码更简洁。

项目截图

ScreenRecording_04-01-2026 09-38-01_1.gif

AI 表现:对迁移的功能表示满意,尤其是网络请求层的复用处理得不错。不过 SwiftUI 部分复杂的交互动画(比如下拉刷新 + 列表滚动 + 头部视差效果),还是需要自己动手调整。


四、UniAppPlayAndroid 小程序 Vue2 向 Vue3 升级

实际上我很久之前写过一个 UniApp 版本的玩安卓,只是很久没有维护了。由于我想把这个 UniApp 打包的 wgt 文件在 HarmonyOS Next 里通过小程序运行,但 uniCloud 环境仅支持 Vue3 版本的小程序。

想着 AI 不用白不用,于是让它帮我进行迁移。

迁移耗时:大约 2小时 完成全部迁移。

技术栈变化

类型 Vue2 Vue3
Vue 2.x 3.4.21
状态管理 Vuex Pinia 2.1.7
构建工具 webpack Vite 5.2.8
uni-app 旧版本 3.0.0-alpha
页面写法 Options API Composition API

支持平台

平台 状态 说明
H5 使用 Vite 代理解决跨域
微信小程序 完全支持
Android App 可编译 wgt 热更新包
iOS App 可编译 wgt 热更新包
HarmonyOS Next 存在 WebView bug,使用条件编译规避

典型问题与解决方案

问题 解决方案
根目录缺少 index.html 创建 Vue 3 入口 HTML
uview-plus 样式找不到 改用原生组件
可选链 ?. 不支持 替换为 && 短路求值
CORS 跨域 Vite devServer 代理
HarmonyOS WebView 崩溃 使用条件编译显示占位页

AI 表现:18个页面全部迁移完成,有完整的迁移文档和迁移指南。迁移过程中遇到的一些边界问题,AI 给出的解决方案都比较合理。


五、HarmonyStudy 项目 HarmonyOS Next 代码 5.0 向 6.0 迁移

让 AI 将项目从 5.0 向 6.0 迁移,它顺便把一些第三方库也帮我进行了迁移和升级。

路由系统 API 重大变更

// 5.0 (已废弃)
router.pushNamedRoute({ name: 'pageName', params: {} })
router.getParams()

// 6.0
router.push({ uri: 'pages/pageName', params: {} })

LoadingDialog 兼容性问题

  • 5.0:CustomDialogController 必须在正确的 UI 上下文中创建
  • 6.0 解决方案:引入 @jxt/xt_hud 库,通过全局 UIContext 初始化

第三方库依赖

版本 说明
@ohos/axios 2.2.7 HTTP 网络请求
@pura/harmony-utils 1.4.0 工具库
@jxt/xt_hud 3.4.0 Loading/Toast(6.0 新增)
@ohos/imageknife 3.2.8 图片加载缓存

项目截图: 配合上面UniAppPlayAndroid的Vue2到Vue3的升级,我终于可以在打包好的wgt文件在HarmonyOS Next正常运行起来了。

录屏2026-04-01 09.17.53.gif

AI 表现:路由迁移采用了最小改动方案,保留兼容性。AI 还顺便优化了 Router 类的实现,并完成了 Network HAR 模块的封装。


六、GetXStudy 项目优化代码

我个人觉得这个 Flutter 项目可以优化的地方有限,但 AI 还是给了一些不少的中肯意见,没事就让它跑跑,还是做了不少提交。

优化内容

优化项 详情 状态
修复废弃 API MaterialStateProperty → WidgetStateProperty ✅ 已完成
替换 print 8处 print → logger.d() ✅ 已完成
图片压缩 launchImage.png 4.9MB → 可压缩 70-90% ✅ 已完成
Git Hooks 添加 pre-commit 自动化检查脚本 ✅ 已完成
清理导入 5处未使用的 import 移除 ✅ 已完成
密码安全 明文存储 → flutter_secure_storage 加密 ✅ 已完成
网络缓存 减少约 60% 重复请求 ✅ 已完成
异常处理 统一 ErrorHandler 工具类 ✅ 已完成

AI 表现:提供了详细的优化报告(OPTIMIZATION_REPORT.md、ADDITIONAL_OPTIMIZATION.md),优化效果量化可查。


结论

AI 使用组合:Claude + MLG 4.7 和 Claude + MiniMax 2.5

实话实说

  • 对于 AI 的使用我并不算特别多,MLG 是试用了同事的,后来 MiniMax 因为个人原因买了 490 元的套餐
  • AI 确实解放了不少生产力,比如自己有的时候不太想写的代码,或者需要阅读理解的旧代码
  • 对于迁移、分析这种事情 AI 表现不错
  • 对于移动端开发,如果你写好一个模板,让它按照模板写一些功能与业务它也接得住
  • 不要期望它写过于复杂的交互就可以

几个项目的共同特点

  • 都是基于 WanAndroid 开放 API 的客户端
  • 都是一个人维护的个人项目
  • 都经历了较大的技术架构升级

个人感悟:时常在想,就这么付费上班,是不是也挺肉疼。后来想想,上班没那么累,下班可以正常走,也算行吧。


附录:项目地址

项目 GitHub 地址 相关分支
RxStudy (iOS) seasonZhu/RxStudy refactor/tuist-migration (CocoaPods→Tuist)
refactor/swiftui-migration (UIKit→SwiftUI)
develop_flutter (集成Flutter、UniApp模块)
UniAppPlayAndroid (跨平台) seasonZhu/UniAppPlayAndroid develop_vue3 (Vue2→Vue3)
HarmonyStudy (HarmonyOS) seasonZhu/HarmonyStudy develop_os6 (5.0→6.0)
GetXStudy (Flutter) seasonZhu/GetXStudy optimize-project (代码优化)

作者 GitHub@seasonZhu

Swift 6.3 正式发布支持 Android ,它能在跨平台发挥什么优势?

2026年3月30日 11:23

最近 Swift 发布了 6.3 版本,而这个版本最特殊的地方在于:把 Android SDK 作为首个官方发布版本给加了进来,其实这个话题在去年的 《Swift 官方正式支持 Android》我们就已经聊过,而这两天正式版的发布,也是广大 Swift 开发者最高涨的时刻,iOSer 终于也有了自己的原生跨平台基础了:

那 Swfit for Android 到底是什么?其实和之前我们聊的一样,目前并不是在 Android 上原生跑 SwiftUI ,这个能力目前是一个 SKIP 的第三方项目在做, Swfit for Android 主要完成了「Swift 官方支持把 Swift 代码交叉编译到 Android」。

所以 Swfit for Android 目前主要由三个部分组成:

  • Swift Toolchain:目标平台上的 Swift 编译器、标准库、LLVM 后端

  • Swift SDK for Android:给 Android 目标平台准备的 Swift 库、头文件、配置

  • Android NDK:提供 Android 的系统头文件、链接器、目标架构工具链等

也就是说,它的核心原理是「交叉编译」:

  • 需要 macOS / Linux 上装 Swift 工具链
  • 需要 Android target 的 Swift SDK artifact bundle
  • 需要 Android NDK 的 sysroot、linker、headers
  • 由 Swift 编译器把代码交叉编译成 Android 可执行文件或本地库(.so
swift build --swift-sdk x86_64-unknown-linux-android28 --static-swift-stdlib

最终结果来看,就是把代码的构建产物变成 Android 上可运行的 ELF 二进制。

其实这也是在 iOS 的 LLVM 的技术领域,比如 KMP 目前大多也是利用 iOS 分支的 LLVM 交叉编译还到鸿蒙 ,所以 Swift 编译器本身还是走在自己的前端和 LLVM 后端,只是 target 换成 Android,例如:

  • x86_64-unknown-linux-android28
  • aarch64-unknown-linux-android28

这里的 android28 可以看出来,是明确绑定到特定 Android API Level ,而 Swift runtime / Foundation 依赖的一些能力也需要较新的 Android API,所以会用 API 28 为基础。

当然,Swift 与 Android 的 Java/Kotlin 的协调桥梁不出意外是 JNI,当然这里不会让你手写 JNI,而是用自动桥接工具来完成。

而在实际应用层面,Swift 官方现在推荐的不是 “整 App 全 Swift”,而是“Swift 库 + Kotlin/Java 壳” ,比如官方 examples 仓库里推荐方案 `hello-swift-java,它的结构是:

  • 一个 Swift package / Swift library
  • 一个 Kotlin Android app(Jetpack Compose UI)
  • Kotlin 调 Swift,不需要你手写 JNI,交给 swift-java 自动生成 Java wrapper 和 JNI bindings

也就目前而言,推荐的是 business logic / algorithms / libraries 写成 Swift ,而前端仍然保持标准 Kotlin/Java Android app 形态 ,简单来说就是:

  • UI:Kotlin / Jetpack Compose
  • 共享逻辑:Swift
  • 桥接层:swift-java / JNI

这强烈的即视感,不就是最初的 KMP 那会么,CMP 还没支持 UI 的时候,KMP 也是这样的路线

比如 Swift 官方提供的例子:一个 Android ( weather-app )和 Swift 库( weather-lib ),用于获取当前位置的天气信息,weather-lib 内部使用 swift-openapi-generator 调用 OpenMeteo 天气 API ,并公开LocationFetcher protocol , 然后 swift-java 和 JNI 自动生成 Java Wrapper ,从而让 Kotlin 可以直接调用 Swift Library。

而在这次 Swift 官方开源的项目里,核心的项目就是:swift-javaswift-java-jni-core ,他们分别作为“上层桥接工具”与“底层 JNI 基建”支撑起整个 Swfit for Android 的生态。

其实也很有趣,Kotlin 在 Android 跑 JVM ,在 iOS 跑 KN 二进制;而现在反过来 Swift 跑 Android ,也是跑二进制。

swift-java-jni-core

swift-java-jni-core 是一个 Swift-friendly 的 JNI 低层封装,本质上是 jni.h 上的一层薄封装,加上一些预打包的类型转换能力,用来和 JVM / Android Runtime(ART)交互,也就是:

  • 负责 JVM/ART 句柄
  • 找类、找方法
  • 处理线程、锁、引用
  • 做 Java/Swift 类型桥接

它的整个结构大概为:

Sources/  
├── CSwiftJavaJNI/          // C 模块:纯 JNI 头文件 ABI  
└── SwiftJavaJNICore/       // Swift 模块:所有上层封装  
    ├── VirtualMachine/     // JVM 句柄、线程、锁  
    ├── BridgedValues/      // 类型桥接  
    └── *.swift             // 类型系统、签名、Mangling  

整个链路从 Swift 应用代码开始,通过 JavaValue 协议进行类型转换,然后通过 JavaVirtualMachine 管理 JVM 交互,最终利用 CSwiftJavaJNI 的 C 接口调用实际的 JVM 实现:

swift-java

swift-java 是更高一层的互操作工具平台,大致可以分为:

  • Swift 调 Java
  • Java 调 Swift
  • 自动生成绑定代码
  • Android 上支持 jextract --mode=jni,因为 Android/ART 没有服务端 Java 那套 FFM 路线的前提条件

在实现上主要有两个代码生成管道:

1、Swift 调用 Java

  • 通过反射分析 Java 类文件,生成 Swift Wrapper
  • 使用 Swift 宏(@JavaClass@JavaMethod)在编译时展开为 JNI 调用

更具体的就是通过 swift-java wrap-java 命令,工具在运行时利用 Java 反射读取 Java 类(包括 .jar 文件),给每个 Java 类生成对应的 Swift 类型,生成的 Swift 类型使用 @JavaClass@JavaMethod@JavaField 等宏进行标注

用途
@JavaClass 声明一个 Swift 类型是 Java 类的包装
@JavaInterface 声明一个 Swift 类型是 Java 接口的包装
@JavaMethod 将 Swift 方法变为调用 Java 方法的桥接
@JavaField 访问 Java 实例字段
@JavaStaticField 访问 Java 静态字段
@JavaImplementation 在 Swift 中实现 Java native 方法

例如,可以将HelloSwiftMain类型扩展为符合ParsableCommand接口,并使用 Swift 参数解析器来处理 Java 提供的参数:

import ArgumentParser
import SwiftJNI

@JavaClass("org.swift.jni.HelloSwiftMain")
struct HelloSwiftMain: ParsableCommand {
  @Option(name: .shortAndLong, help: "Enable verbose output")
  var verbose: Bool = false

  @JavaImplementation
  static func main(arguments: [String], environment: JNIEnvironment? = nil) {
    let command = Self.parseOrExit(arguments)
    command.run(environment: environment)
  }
  
  func run(environment: JNIEnvironment? = nil) {
    print("Verbose = \(verbose)")
  }
}

所以,对应也存在类型映射的需求:

Java type Swift type
boolean Bool
byte Int8
char UInt16
short Int16
int Int32
long Int64
float Float
double Double
void Void (rare)
T[] [T]
String String
Java class Swift class Swift module
java.lang.Object JavaObject SwiftJava
java.lang.Class<T> JavaClass<T> SwiftJava
java.lang.Throwable Throwable SwiftJava
java.net.URL URL JavaNet

2、Java 调用 Swift(jextract)

主要是让 Java 程序调用 Swift 库 ,生成 Java 绑定和 Swift thunk 文件,支持 FFM 和 JNI 两种生成模式。

比如 jextract 这个流程会分两步:

  • Swift Thunk:用 @_cdecl 暴露 C 符号入口
  • Java 绑定:生成 Java 代码,通过 JNI 或 FFM 调用这些 C 入口

所以,整个 swift-java 主要就是提供自动化方式生成 Swift/Java 绑定,其中:

  • jni 模式兼容性最广,主要支持 Android
  • FFM 模式更偏 Java 22+/25+ 服务端场景,不是 Android 主战场

也就是,swift-java 不只是支持 Android ,它还可以支持 Java Web 场景,野心还是有的。

而整个链路上其实是「Swift - 编译成本地库 - 通过生成的 JNI/Java wrapper 暴露给 Kotlin/Java 调用」 这样一个实现:

image-20260330104416812

那聊到这里,目前局限性也很明显了,它没有一个「 Swift UI for Android 」的支持,它能够让 iOS 的同学把自己的业务逻辑或者纯 Swift 代码共享给 Android ,但是 UI 还是得 Android 自己写。

另外,Swift 社区官方论坛里也有人提到:目前的 Swift Android SDK 下,二进制体积过大,其中一个原因是 Foundation 依赖的 ICU 很重,因为你需要把 Swift runtime、Foundation、ICU 这些东西都带到 Android app 。

同时,在系统 API 上还是差了点意思,比如你需要调用 Andorid 的系统 API 时,大概需要:

Swift ↔ JNI ↔ Java/Kotlin ↔ Android 系统 API

目前这个链路还没有全套完整的官方实现,大概需要后续社区和官方继续补齐,所以当前更多是逻辑和算法等场景的复用,直接调用系统 API 还是会麻烦一些

当然,最终使用过程里,也可以把 Swfit 打包成独立的 Lib,比如在官方例子里的 hello-swift-raw-jni-library ,通过就可以构建出 hello-swift-raw-jni-library-release.aar

./gradlew :hello-swift-raw-jni-library:bundleReleaseAar

所以对于 Swift for Android 来说,目前还是处于起步阶段,作为第一个正式版的起步。

最后,不得不说,语言的最终归宿就是跨平台,UI 的最终归宿也是,现在的 Swift for Android 就像是当年的 KMP ,从语言的跨平台开始切入,未来要发展, Swift UI for Android 的路径看起来也不是不可能,至于最终能否发展起来,这就考验 Swift 社区的运营水平了。

image.png

链接

github.com/swiftlang/s…

github.com/swiftlang/s…

www.swift.org/documentati…

Flutter的状态管理工具

作者 zhangkai
2026年3月13日 15:53

一、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)

Flutter 实现手势缩放丝滑的 K 线(内涵源码)

作者 比特鹰
2026年3月6日 11:14

本文基于 Flutter 框架,从 Canvas 绘制、K 线数据结构、蜡烛图核心绘制逻辑、MA 指标实现,到手势冲突优化,全方位拆解金融 APP K 线图开发流程,分享实战问题与解决方案,助力开发者快速实现流畅可落地的 K 线组件。

在金融类 APP 开发中,K 线图是必不可少的组件之一,体验直接可导致用户数量的流失

本文将通过 Flutter 框架,并结合实际的开发经验,从 Canvas 绘制基础、数据结构定义、核心绘制逻辑、技术指标实现到手势系统优化,全方位的拆解 K 线图的开发过程,分享我开发过程中遇到的问题以及解决方案,帮助你掌握 Flutter K 线图开发技巧

先看最终效果

925596b7e263610b1bab63b6fa1529cd.png

一、Canvas 绘制基础

首先我们得先学习 Flutter 中的 Canvas 绘制

懂 Canvas 绘制基础可直接跳过这条段,想要在 Flutter 中自定义绘制,核心需要通过 CustomPaint + CustomPainter

在动手之前需要先把 Flutter Canvas 坐标系规规则给理解一下

  • 原点 (0,0) 在绘制区域的左上角
  • x 轴向右为正
  • y 轴向下为正

与我们日常认知的“y轴向上为正”不同,需要记住这一点,这是避免绘制错位的关键

简单 Demo

为快速熟悉Canvas的使用方式,我们先实现一个简单的Demo,绘制一个填充圆形和一根线条,掌握Paint配置、坐标计算及Canvas绘制方法:

import 'package:flutter/material.dart';

class CanvasApp extends StatefulWidget {
  const CanvasApp({super.key});

  @override
  State<CanvasApp> createState() => _CanvasAppState();
}

class _CanvasAppState extends State<CanvasApp> {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: CustomPaint(painter: DemoPainter()),
    );
  }
}

class DemoPainter extends CustomPainter {
  final fill = Paint()
    ..style = PaintingStyle.fill
    ..color = Colors.blue;
  final stroke = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 6
    ..color = Colors.black;

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    canvas.drawCircle(center, 60, fill); // 绘制填充圆形
    canvas.drawLine(center, center + Offset(80, -40), stroke); // 绘制线条
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

上面的 Demo中,通过Paint配置绘制样式(填充/描边、颜色、线宽)

在paint方法中通过Canvas的drawCircle、drawLine方法完成绘制

shouldRepaint 返回false表示不重复绘制,提升性能

二、K线图数据结构定义

接下来我们就需要先了解 K 线数据接口的定义,进入 K 线的开发

首先需定义规范的数据结构,存储单根K线的核心信息

一根完整的K线包含开盘价、最高价、最低价、收盘价、成交量和时间戳六大核心字段,对应的数据结构如下:

class CandleEntity {
  double open;    // 开盘价
  double high;    // 最高价
  double low;     // 最低价
  double close;   // 收盘价
  double vol;     // 成交量
  int? time;      // 时间戳(毫秒)
}

CandleEntity 类是K线图开发的“数据载体”,后面所有绘制逻辑(蜡烛、均线)均围绕该类的实例展开

实际开发中,也可以根据需求扩展字段,比如添加均线值列表(maValueList),用于存储单根K线对应的各类均线数据

三、单根K线绘制逻辑

K线图的核心是 Candle 的绘制,单根 Candle 由实体部分(开盘价与收盘价之间的矩形)和影线部分(最高价与最低价之间的线段)组成,而且需区分阳线(涨)和阴线(跌),绘制逻辑如下

价格与屏幕坐标映射

因为Canvas坐标系和实际价格维度不一样,所以得把价格转换成屏幕上的Y坐标。核心逻辑就是用当前K线数据集的最高价、最低价算缩放比例,再把价格映射成屏幕坐标,公式如下:

double getY(double y) => (maxValue - y) * scaleY + _contentRect.top;

  • maxValue 为当前K线数据集的最高价
  • scaleY 为价格维度的缩放比例
  • _contentRect.top 为绘制区域的顶部坐标

通过这个公式可确保价格越高,对应的屏幕Y坐标越小

单根蜡烛绘制逻辑

单根蜡烛的绘制需处理三个核心细节:阳线与阴线的颜色区分、实体部分的最小高度(避免十字星看不见)、动态影线宽度(根据缩放级别调整,提升视觉体验)

完整代码如下:

/// 绘制单根蜡烛图
/// [curPoint] 当前 K 线数据
/// [canvas] 画布
/// [curX] 当前 K 线的 X 坐标(中心点)
void drawCandle(CandleEntity curPoint, Canvas canvas, double curX) {
  // 将价格转换为屏幕 Y 坐标
  var high = getY(curPoint.high); // 最高价对应的 Y 坐标
  var low = getY(curPoint.low); // 最低价对应的 Y 坐标
  var open = getY(curPoint.open); // 开盘价对应的 Y 坐标
  var close = getY(curPoint.close); // 收盘价对应的 Y 坐标
  double r = mCandleWidth / 2; // 实体半宽

  // 动态影线宽度计算:根据缩放级别平滑调整影线宽度,缩放越小影线越粗
  double lineR = _calculateDynamicShadowWidth() / 2; // 影线半宽

  // 阳线(涨):开盘价 >= 收盘价
  if (open >= close) {
    // 确保实体有最小可见高度(避免十字星看不见)
    if (open - close < mCandleLineWidth) {
      open = close + mCandleLineWidth;
    }
    chartPaint.color = this.chartColors.upColor; // 阳线颜色(如红色)
    // 绘制实体矩形(从收盘价到开盘价)
    canvas.drawRect(
      Rect.fromLTRB(curX - r, close, curX + r, open), chartPaint);
    // 绘制上下影线(从最高价到最低价)
    canvas.drawRect(
      Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
  }
  // 阴线(跌):收盘价 > 开盘价
  else if (close > open) {
    // 确保实体有最小可见高度
    if (close - open < mCandleLineWidth) {
      open = close - mCandleLineWidth;
    }
    chartPaint.color = this.chartColors.dnColor; // 阴线颜色(如绿色)
    // 绘制实体矩形(从开盘价到收盘价)
    canvas.drawRect(
      Rect.fromLTRB(curX - r, open, curX + r, close), chartPaint);
    // 绘制上下影线
    canvas.drawRect(
      Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
  }
}

上面的代码中,通过判断开盘价与收盘价的大小区分阳阴线,动态调整实体高度和影线宽度,确保在不同缩放级别下,K线都能清晰显示,提升用户体验

四、技术指标实现

K线图除了蜡烛本身,还需展示各类技术指标,其中移动平均线(MA)是最常用的指标之一

MA的实现核心是滑动窗口算法,通过维护固定周期的收盘价累加和,计算每个周期的均值,时间复杂度为O(n)

MA均线计算逻辑

/// 计算移动平均线(Moving Average)
/// [dataList] K 线数据列表
/// [maDayList] 均线周期列表,例如 [5, 10, 20] 表示计算 MA5、MA10、MA20
static calcMA(List<KLineEntity> dataList, List<int> maDayList) {
  // ma[i] 保存第 i 个周期的累加和
  List<double> ma = List<double>.filled(maDayList.length, 0);
  if (dataList.isNotEmpty) {
    for (int i = 0; i < dataList.length; i++) {
      KLineEntity entity = dataList[i];
      final closePrice = entity.close;
      // 为每个 K 线创建 MA 值列表
      entity.maValueList = List<double>.filled(maDayList.length, 0);
      // 计算每个周期的 MA 值
      for (int j = 0; j < maDayList.length; j++) {
        ma[j] += closePrice; // 累加当前收盘价
        // 达到周期时开始计算均值
        if (i == maDayList[j] - 1) {
          entity.maValueList?[j] = ma[j] / maDayList[j];
        }
        // 滑动窗口:减去最早的值,保持窗口大小
        else if (i >= maDayList[j]) {
          ma[j] -= dataList[i - maDayList[j]].close;
          entity.maValueList?[j] = ma[j] / maDayList[j];
        }
      }
    }
  }
}

上面即是实现 MA均线计算的逻辑,通过双重循环实现多周期MA计算:外层循环遍历所有K线数据,内层循环针对每个均线周期,累加收盘价,当达到周期长度时计算均值,后续通过滑动窗口更新均值(减去滑出窗口的收盘价,加上新的收盘价),确保计算高效

MA均线绘制逻辑

当完成逻辑的计算之后,通过绘制线段实现绘制,核心是获取相邻两根K线的MA值对应的屏幕坐标,调用drawLine方法完成绘制

void drawMaLine(CandleEntity lastPoint, CandleEntity curPoint, Canvas canvas,
                              double lastX, double curX) {
  // 获取均线线条宽度
  final lineWidth = _calculateMainIndicatorWidth();
  for (int i = 0; i < (curPoint.maValueList?.length ?? 0); i++) {
    if (i == 3) break; // 控制均线显示数量(如只显示前3条)
    if (lastPoint.maValueList?[i] != 0) {
      // 绘制相邻两根K线的MA线段,区分不同均线颜色
      drawLine(lastPoint.maValueList?[i], curPoint.maValueList?[i], canvas,
                              lastX, curX, this.chartColors.getMAColor(i),
                              lineWidth: lineWidth);
    }
  }
}

五、手势系统

交互体验在 K 线图中是非常重要的,必须要支持缩放、拖拽、点击、长按这四个核心的手势

但是 Flutter 的手势系统有一个手势竞技场(Gesture Arena)的机制,导致有手势冲突的问题

下面我提供了解决方案

手势冲突解决方案

问题描述:如果同时用了 HorizontalDrag 拖拽 和 ScaleGesture 缩放,这两个手势会互相抢焦点,导致双指缩放时,水平滑动会被拖拽抢走,缩放就断了,有种卡顿的感觉

解决办法很简单:

用 Listener 组件处理先判断有几根手指在屏幕上,再自动切换是拖拽还是缩放,互不干扰:

  • 一根手指(_pointerCount < 2):只走拖拽逻辑,让 K 线图左右滑动,看更早的数据
  • 两根及以上手指(_pointerCount ≥ 2):只走缩放逻辑,让 K 线图放大缩小,看细节或看整体

缩放 + 拖拽

Listener(
  onPointerDown: (_) => setState(() => _pointerCount++),
  onPointerUp: (_) => setState(() => _pointerCount--),
  onPointerCancel: (_) => setState(() => _pointerCount--),
  child: RawGestureDetector(
    scaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
      () => ScaleGestureRecognizer(),
      (instance) {
        instance
          ..onStart = (details) {
            // 保存基线值,用于缩放计算
            _scaleBase = 1.0;
            _scaleXBase = mScaleX;
            // 计算缩放锚点(焦点对应的 K 线索引)
            _anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
          }
          ..onUpdate = (details) {
            // 检测手指数量变化,重置基线
            if (_pointerCount != _lastPointerCount) {
              _scaleBase = details.scale;
              _scaleXBase = mScaleX;
              _anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
            }
            if (_pointerCount < 2) {
              // 单指:拖拽,调整滑动偏移量
              final delta = details.focalPointDelta.dx / mScaleX;
              mScrollX = (mScrollX + delta).clamp(0.0, maxScrollX);
            } else {
              // 双指:缩放,控制缩放范围(0.2~4.0)
              final relativeScale = details.scale / _scaleBase;
              mScaleX = (_scaleXBase * relativeScale).clamp(0.2, 4.0);
              // 焦点锚定:保持缩放中心不动,提升体验
            }
          }
          ..onEnd = (details) {
            // 单指拖拽结束:启动惯性滚动
            if (_pointerCount == 0 && _lastPointerCount == 1) {
              _onFling(details.velocity.pixelsPerSecond.dx);
            }
          };
      },
    ),
    // 长按、点击手势配置
    longPressGestureRecognizer: ...,
    tapGestureRecognizer: ...
  ),
);

其它手势实现

(1)点击手势

点击手势点击主要做两件事:切换十字线显示、画趋势线,通过 TapGestureRecognizer 实现

  • 普通模式:点一下 K 线图,十字线就显示 / 隐藏,同时会显示这根 K 线的详细数据,比如开盘价、收盘价
  • 趋势线模式:点两下,第一下记起点,第二下记终点,就能画出趋势线
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(TapGestureRecognizer instance) {
instance.onTapUp = (details) {
// 普通点击模式:切换十字线显示状态
if (!widget.isTrendLine &&
painter.isInMainRect(details.localPosition)) {
if (_isCrossLocked) {
// 十字线已显示,点击则隐藏
_isCrossLocked = false;
isOnTap = false;
mInfoWindowStream.sink.add(null); // 清空信息弹窗
} else {
// 十字线未显示,点击则显示并锁定
_isCrossLocked = true;
isOnTap = true;
mSelectX = details.localPosition.dx;
}
notifyChanged();
}

// 趋势线模式:记录点击的坐标点
if (widget.isTrendLine && !isLongPress && enableCordRecord) {
enableCordRecord = false;
Offset p1 = Offset(getTrendLineX(), mSelectY);

// 第一次点击:创建趋势线的起点
if (!waitingForOtherPairofCords) {
lines.add(TrendLine(
p1, Offset(-1, -1), trendLineMax!, trendLineScale!));
}
// 第二次点击:完成趋势线的终点
if (waitingForOtherPairofCords) {
var a = lines.last;
lines.removeLast();
lines.add(
TrendLine(a.p1, p1, trendLineMax!, trendLineScale!));
waitingForOtherPairofCords = false;
} else {
waitingForOtherPairofCords = true;
}
notifyChanged();
}
};
},
),

(2)长按手势

长按手势长按用来移动十字线、调整趋势线,通过 LongPressGestureRecognizer 实习那

  • 普通模式:长按屏幕并移动手指,十字线会跟着手指走,实时显示指到哪里的 K 线信息
  • 趋势线模式:长按画好的趋势线,就能拖动调整位置,方便修改
LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<
LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(),
(LongPressGestureRecognizer instance) {
instance
// 长按开始
..onLongPressStart = (details) {
isOnTap = false;
isLongPress = true;

// 普通模式:记录十字线位置
if ((mSelectX != details.localPosition.dx ||
 mSelectY != details.globalPosition.dy) &&
!widget.isTrendLine) {
mSelectX = details.localPosition.dx;
notifyChanged();
}

// 趋势线模式:初始化位置记录
if (widget.isTrendLine && changeinXposition == null) {
mSelectX = changeinXposition = details.localPosition.dx;
mSelectY = changeinYposition = details.globalPosition.dy;
notifyChanged();
}
if (widget.isTrendLine && changeinXposition != null) {
changeinXposition = details.localPosition.dx;
changeinYposition = details.globalPosition.dy;
notifyChanged();
}
}
// 长按移动 - 更新十字线位置
..onLongPressMoveUpdate = (details) {
// 普通模式:跟随手指移动十字线
if ((mSelectX != details.localPosition.dx ||
 mSelectY != details.globalPosition.dy) &&
!widget.isTrendLine) {
mSelectX = details.localPosition.dx;
mSelectY = details.localPosition.dy;
notifyChanged();
}

// 趋势线模式:移动趋势线
if (widget.isTrendLine) {
// 计算相对移动距离
mSelectX = mSelectX +
(details.localPosition.dx - changeinXposition!);
changeinXposition = details.localPosition.dx;
mSelectY = mSelectY +
(details.globalPosition.dy - changeinYposition!);
changeinYposition = details.globalPosition.dy;
notifyChanged();
}
}
// 长按结束
..onLongPressEnd = (details) {
isLongPress = false;
enableCordRecord = true; // 启用趋势线坐标记录
// 长按结束后锁定十字线,保持显示
if (!widget.isTrendLine) {
_isCrossLocked = true;
isOnTap = true; // 保持 isOnTap 为 true 以显示十字线
} else {
mInfoWindowStream.sink.add(null); // 趋势线模式清空信息弹窗
}
notifyChanged();
};
},
),


double getY(double y)  => (maxValue - y) * scaleY + _contentRect.top;

六、总结

最费时间就是缩放和拖拽的冲突问题

后面借鉴 Interactive Chart 这个开源项目的实现思路,用“Listener + ScaleGesture”实现水平移动和缩放,解决了这个问题,缩放和拖拽都丝滑不卡顿

总结一下,本篇文章主要讲解 Canvas 绘制、坐标映射、K 线图绘制基础,并解决了手势冲突的问题

其实K线图开发看着复杂,只要把绘制、数据处理、手势这几个核心模块拆解开,逐一突破,就能轻松搞定,做出高效、流畅、能落地的组件

本文的思路和代码,大家可以直接用到实际项目里,也能根据业务需求扩展功能(比如MACD、RSI指标、成交量显示、行情标注等),希望能帮到正在做Flutter K线图的小伙伴,少走弯路、快速落地!

源码:github.com/kian-lian/c…

参考:

团队招募 | 共同探索技术边界

AI 时代已经到来,当下最好的破局机会,就是加入一家有潜力的 AI 公司

比特鹰致力于将每位成员,打造成 AI 时代的超级个体,在为用户创造价值的同时实现人生梦想

以下岗位持续开放中:

  • 后端开发工程师
  • 前端开发工程师
  • AI 应用开发工程师
  • 爬虫工程师
  • 大数据开发工程师
  • HR 人事

如果您想在 AI 时代实现百倍的个人提升,欢迎加入我们
联系方式:join@biteagle.xyz

【Flutter×鸿蒙】通关手册(二):FVM 不认鸿蒙 SDK?4步手动塞进去

作者 TT_Close
2026年3月5日 18:26

系列导航:

我第一次让 FVM 管理鸿蒙版 Flutter SDK 时,前后踩了 4 个坑,花了大半天才跑通。事后复盘发现,每个坑都不难,只是没人提前告诉我"为什么要这样做"。这篇把整个过程拆成 5 关,每关讲清「为什么」和「怎么做」,争取让你 20 分钟一次通关。

前置条件:请先完成第一篇的全部内容——DevEco Studio 已安装,ohpm、node、hvigorw 在终端里都能正常调用。


🗺️ 通关路线图

关卡 任务 预计耗时
第1关 安装 FVM 2 min
第2关 克隆鸿蒙版 SDK 5 min(取决于网速)
第3关 修复版本"身份证" 3 min
第4关 指定鸿蒙 SDK 路径 1 min
第5关 全绿验证 2 min

🎯 第 1 关:安装 FVM

目标

让终端认识 fvm 命令。

为什么需要 FVM

一句话——让不同项目用不同版本的 Flutter,互不干扰。比如项目 A 用官方 3.24 跑 Android/iOS,项目 B 用鸿蒙版 3.35.8。FVM 就是 Flutter 的"版本档案柜",每个抽屉放一个版本。

📋 操作

# macOS(在终端里执行,这是用 Homebrew 包管理器安装 FVM)
brew install fvm
# Windows(在 cmd 或 PowerShell 中执行,这是用 Chocolatey 包管理器安装 FVM)
choco install fvm

安装完后,配置 FVM 缓存路径。把以下两行写入 ~/.zshrc(上一篇介绍过,这是 Mac 终端的配置文件):

# FVM 存放所有 Flutter 版本的目录
export FVM_CACHE_PATH=$HOME/fvm
# 让 FVM 的默认版本可以直接用 flutter 命令调用
export PATH="$HOME/fvm/default/bin:$PATH"

保存后执行下面这条命令,让刚才的配置立即生效(否则要关掉终端重新打开):

source ~/.zshrc

✅ 验证

# 查看 FVM 版本号,确认安装成功
fvm --version

看到版本号(如 3.1.4)就过关了。

⚠️ 如果报 command not found:Mac 用户确认已安装 Homebrew(执行 brew --version 看有没有输出);Windows 用户确认已安装 Chocolatey(执行 choco --version)。如果包管理器本身都没装,请先去官网安装。


🎯 第 2 关:克隆鸿蒙版 SDK

目标

把华为的鸿蒙版 Flutter 放进 FVM 管辖。

为什么不能直接 fvm install

正常装 Flutter 只需要 fvm install 3.24.0,FVM 会自动去 GitHub 下载。但鸿蒙版是华为团队在 AtomGit(国内代码托管平台)上单独维护的,FVM 的世界里它根本不存在。所以我们要"手动入库"——自己下载代码,放到 FVM 的档案柜里,假装它一直在那。

⚠️ 本关最大的坑:分支名和版本号是两回事!

仓库的分支叫 oh-3.35.7-dev,看到 3.35.7 你会以为版本就是 3.35.7。但实际上代码里的版本已经迭代到了 3.35.8-ohos-0.0.2

类比:Git 分支叫 feature/login-v1,但代码早就改到 v3 了。分支名是创建时起的,不会跟着版本号自动更新。

千万别拿分支名当版本号用,团队必须统一用 3.35.8-ohos-0.0.2 这个真实版本号。

📋 操作

# --depth 1 只取最新代码,省空间(省去几个 GB 的历史记录)
git clone -b oh-3.35.7-dev --depth 1 
https://atomgit.com/openharmony-tpc/flutter_flutter.git 
~/fvm/versions/3.35.8-ohos-0.0.2

注意看:clone 命令里分支名是 oh-3.35.7-dev,但目标文件夹名是 3.35.8-ohos-0.0.2——这不是写错了,上面已经解释了为什么不一样。

💡 怎么确认真实版本号? clone 完后执行 ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter --version 看输出。如果加入已有团队,直接看项目的 .fvmrc 文件(命令:cat .fvmrc)。

✅ 验证

# 确认文件下载成功(ls = 列出目录内容)
ls ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter

文件存在就过关。

⚠️ 如果报 No such file or directory:回去检查 clone 命令是否执行成功。常见原因是网络超时(AtomGit 在国内,通常不需要梯子,但公司内网可能有限制)。重新执行 clone 前,先删掉残留目录:rm -rf ~/fvm/versions/3.35.8-ohos-0.0.2,再重试。


🎯 第 3 关:修复版本"身份证"

目标

让 FVM 正确识别这个 SDK 的版本号。

为什么要做这步

clone 下来的 SDK 有两张"证件":

  1. version 文件——相当于身份证,一行文本写着版本号
  2. bin/cache/flutter.version.json——相当于内部档案,JSON 格式的详细版本信息

问题是,这两张证件上都写着 0.0.0-unknown(因为鸿蒙团队是从开发分支构建的,没有打标准标签)。但我们的文件夹名叫 3.35.8-ohos-0.0.2。FVM 一查——名字对不上,直接翻脸。

⚠️ 不做这步的后果:FVM 会弹出 "Version mismatch" 并试图删掉你的 SDK 重装。如果看到了这个弹窗,千万不要选任何选项,按 Ctrl+C(Mac 也是 Ctrl 不是 Cmd)退出,回来做这步。

📋 操作

macOS / Linux:

cd ~/fvm/versions/3.35.8-ohos-0.0.2

# 第一步:改"身份证"
echo -n "3.35.8-ohos-0.0.2" > version

# 第二步:初始化 Flutter 引擎(首次运行会下载 Dart SDK,需要等 1-3 分钟)
bin/flutter --version

# 第三步:改"内部档案"(把所有 0.0.0-unknown 替换成正确的版本号)
sed -i '' 's/0.0.0-unknown/3.35.8-ohos-0.0.2/g' bin/cache/flutter.version.json

Windows PowerShell:

# 进入 SDK 所在目录
cd $env:USERPROFILE\fvm\versions\3.35.8-ohos-0.0.2

# 第一步:改"身份证"
"3.35.8-ohos-0.0.2" | Set-Content version -NoNewline

# 第二步:初始化引擎
bin\flutter --version

# 第三步:改"内部档案"(PowerShell 的查找替换写法)
(Get-Content bin\cache\flutter.version.json) -replace '0.0.0-unknown', '3.35.8-ohos-0.0.2' | Set-Content bin\cache\flutter.version.json

⚠️ 三步的顺序不能乱——第二步会生成 flutter.version.json 文件,第三步才有东西可改。如果你先执行了第三步,会报文件不存在。

✅ 验证

# 回到任意目录都可以执行(fvm list = 列出 FVM 管理的所有 Flutter 版本)
fvm list

看到 Version 列显示 3.35.8-ohos-0.0.2(不是空白、不是 Need setup、不是 0.0.0-unknown),这关就过了。

02_fvm_list.png ⚠️ 如果还是显示异常,逐一排查两张"证件":

# 检查"身份证"内容
cat ~/fvm/versions/3.35.8-ohos-0.0.2/version
# 应该输出:3.35.8-ohos-0.0.2(没有多余空行)

# 检查"内部档案"有没有残留的 0.0.0-unknown
cat ~/fvm/versions/3.35.8-ohos-0.0.2/bin/cache/flutter.version.json
# 里面所有 version 字段应该都是 3.35.8-ohos-0.0.2

如果 version 文件内容不对,重新执行第一步;如果 JSON 里还有 0.0.0-unknown,重新执行第三步。


🎯 第 4 关:指定鸿蒙 SDK 路径

目标

让 Flutter 知道鸿蒙的 SDK(OpenHarmony SDK)装在哪。

为什么不用环境变量

我试过 HOS_SDK_HOMEOHOS_SDK_HOME 等环境变量,时灵时不灵。原因是不同方式打开的终端(VS Code 内置终端 vs 系统终端 vs CI 环境)加载配置文件的顺序不一样,变量可能没被读到。flutter config 会把路径写入 Flutter 自己的配置文件,不管从哪里启动都能读到,最稳。

📋 操作

# 把鸿蒙 SDK 的位置"写死"到 Flutter 的配置里(一次性操作)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter config \
--ohos-sdk="/Applications/DevEco-Studio.app/Contents/sdk"

⚠️ Windows 用户路径改为:--ohos-sdk="C:\Program Files\Huawei\DevEco Studio\sdk"

请根据 DevEco Studio 实际安装路径调整。不确定装在哪?打开 DevEco Studio → Settings → SDK 页面可以看到路径。

终端输出 Setting "ohos-sdk" value to "..." 就成功了。

✅ 验证

不急,下一关一起验收。


🎯 第 5 关:全绿验证

目标

flutter doctor 中 HarmonyOS toolchain 一栏显示绿色对勾。

📋 操作

# 运行 Flutter 的环境诊断工具(-v 表示显示详细信息)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor -v

✅ 验证

关注输出中的 HarmonyOS 那一栏:

[✓] HarmonyOS toolchain - develop for HarmonyOS devices
    • OpenHarmony Sdk at /Applications/DevEco-Studio.app/Contents/sdk,
      available api versions has [22:default]
    • Ohpm version 6.0.1
    • Node version v18.20.1
    • Hvigorw binary at .../hvigor/bin/hvigorw

看到 [✓] 加上 4 个子项都有值 = 通关!

02_flutter_doctor.png 💡 你可能会看到 Flutter 那栏有几个 ! 警告(channel 不标准、upstream 不是官方地址)。这是鸿蒙版的正常现象,完全不影响开发和打包,放心忽略。

⚠️ 如果 HarmonyOS 那栏还是红叉,按优先级排查:

  1. SDK not found → 回第 4 关检查 config 路径是否正确
  2. ohpm/hvigorw missing → 回第一篇检查环境变量
  3. Version mismatch → 回第 3 关检查两张"证件"

🔧 附加关:FVM 的"碎碎念"

通关后你会发现,每次用 fvm flutter xxx 时 FVM 都会弹 "not a valid version" 的警告让你确认。这不是报错,只是 FVM 在说:"这个版本号我在官方列表里查不到,你确定要用吗?"

三种应对方式:

  1. 手动按 y——每次弹出输入 y 回车
  2. 自动确认——命令前加 yes |
yes | fvm flutter doctor
  1. 绕过 FVM——直接用绝对路径调用,完全不弹警告:
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor

我推荐第三种,路径虽长但最省心。可以设个快捷方式(alias)缩短它:

# 把这行加到 ~/.zshrc 里(alias = 给一条长命令起个短名字)
alias hflutter="$HOME/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter"

保存后 source ~/.zshrc,之后直接 hflutter runhflutter doctor 就行。


🏆 通关总结

项目 状态
FVM ✅ 已安装
鸿蒙版 Flutter SDK ✅ ~/fvm/versions/3.35.8-ohos-0.0.2
version 文件 ✅ 已修复
flutter.version.json ✅ 已修复
flutter config --ohos-sdk ✅ 已配置
flutter doctor HarmonyOS ✅ 全绿

回顾核心逻辑:FVM 只管官方 Flutter,鸿蒙版要我们手动塞进去(第 2 关);塞进去后"证件"信息对不上,需要手动修正(第 3 关);最后告诉 Flutter 鸿蒙 SDK 在哪(第 4 关)。理解了这条线,以后鸿蒙版 SDK 升级换版本号,你也能照样搞定。

如果中途卡住,大概率是版本号写错了——检查文件夹名、version 文件内容、flutter.version.json 里的版本号,三者必须完全一致


下一篇预告:SDK 准备好了,接下来要把你的老 Flutter 项目跑到鸿蒙上——听起来就是敲几行命令的事?没那么简单。→ 【Flutter×鸿蒙】通关手册(三):debug 包也要签名,这点和 Android 差远了

flutter接入三方库运行报错:Error running pod install

作者 王晓枫
2026年3月3日 17:33

最近在研究flutter,在flutter中引入第三方webview_flutter后,运行iOS设备时报错,具体报错如下:

Error running pod install

Error launching application on iPhone 15.

看问题时iOS的cocoapod在执行pod install命令下载第三方库时出现问题,如是我们找到项目中ios目录,使用xcode打开项目看看情况,

打开后发现项目包错Module 'webview_flutter_wkwebview' not found,很明显这里第三方没有下载下来

如是我们打开控制台cd 到项目的iOS目录,执行pod install这时候我们能看到控制台提示:/Library/Ruby/Gems/2.6.0/gems/ffi-1.17.0-arm64-darwin/lib/2.6/ffi_c.bundle (LoadError)

/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54:in `require': cannot load such file -- ffi_c (LoadError)

这个错误表明 Ruby 在尝试加载 ffi_c 时失败了,通常是因为 ffi gem 没有正确安装或与系统 Ruby 不兼容

 重新安装 ffi gem

ffi gem 可能没有正确安装或损坏,可以尝试重新安装。

卸载ffi gem:

gem uninstall ffi

#如果报权限问题就使用

sudogem uninstall ffi

重新安装 ffi gem:

gem install ffi

#如果报权限问题就使用

sudo gem install ffi

升级ruby环境

本地环境很多依赖于默认的ruby环境,所以我们可以使用rvm管理和安装多个版本ruby

安装 rvm:

\curl -sSL get.rvm.io | bash -s stable

安装一个 Ruby 版本(如 3.0.0):

rvm install 3.0.0

rvm use 3.0.0 --default

验证 Ruby 版本:

ruby-v

查看所有ruby:

rvm list

在安装ruby的时候我出现了下面的包错

Error running '__rvm_make -j6',

please read /Users/apple/.rvm/log/1739849439_ruby-3.0.0/make.log

我怀疑指定的OpenSSL版本可能没生效,于是干脆通过brew uninstall openssl命令把最新版本的OpenSSL卸载了,再次执行上面的命令一切正常🎉!

解决方案

如果不局限于安装Ruby 3.0版本,那么可以通过安装更高的Ruby版本解决该问题,可以参考这篇文章RVM - 安装最新Ruby版本。

如果一定要安装Ruby 3.0版本,请安装1.1版本的OpenSSL,并卸载最新版本,同时指定使用HomeBrew安装的OpenSSL完成安装:

安装1.1版本的OpenSSL

brew install openssl@1.1

卸载最新版本的OpenSSL

brew uninstall openssl

指定使用HomeBrew安装的OpenSSL完成安装

rvm install ruby-3.0.0 --with-openssl-dir=brew --prefix openssl

如果不想卸载最新版本,可以通过brew link命令切换(链接)openssl的版本完成安装:

安装1.1版本的OpenSSL

brew install openssl@1.1

切换OpenSSL的版本为1.1

brew link --overwrite openssl@1.1

--overwrite参数的作用是强制切换。如果不使用该参数,可以先执行brew unlink openssl命令后再执行brew link openssl@1.1命令完成切换。

指定使用HomeBrew安装的OpenSSL完成安装

rvm install ruby-3.0.0 --with-openssl-dir=brew --prefix openssl@1.1

执行完成后再次执行pod install 报错: `find_spec_for_exe':can't find gem cocoapods (>= 0.a) with executable pod (Gem::GemNotFoundException)

因使用 brew 安装工具导致 ruby 环境错乱, 执行pod install时报错提示找不到 gem 可执行文件

Traceback (most recent call last):    2: from /usr/local/bin/pod:23:in '    1: from /Library/Ruby/Site/2.6.0/rubygems.rb:294:in activate_bin_path'/Library/Ruby/Site/2.6.0/rubygems.rb:275:in `find_spec_for_exe': can't find gem cocoapods (>= 0.a) with executable pod (Gem::GemNotFoundException)

解决办法:

重新安装 ruby 环境(默认安装最新版本)

rvm reinstall ruby --disable-binary

运行结果

mruby-1.3.0 - #removing src/mruby-1.3.0 - please waitmruby-1.3.0 - #removing rubies/mruby-1.3.0 - please waitRVM does not have prediction for required space for mruby-1.3.0, assuming 150MB should be enough, let us know if it was not.Checking requirements for osx.Certificates bundle '/usr/local/etc/openssl@1.1/cert.pem' is already up to date.Requirements installation successful.Installing Ruby from source to: /Users/jack/.rvm/rubies/mruby-1.3.0, this may take a while depending on your cpu(s)...mruby-1.3.0 - #downloading 1.3.0, this may take a while depending on your connection...mruby-1.3.0 - #extracting 1.3.0 to /Users/jack/.rvm/src/mruby-1.3.0 - please waitmruby-1.3.0 - #compiling - please waitmruby-1.3.0 - #installing - please waitInstall of mruby-1.3.0 - #completeRequired ruby-2.7.0 is not installed.To install do: 'rvm install "ruby-2.7.0"'

重新安装 cocoapods

 gem install cocoapods

运行结果:

Successfully installed cocoapods-1.9.3Parsing documentation for cocoapods-1.9.3Done installing documentation for cocoapods after 1 seconds1 gem installed

再重新执行pod install就OK

Homebrew,是Mac OS X上的软件包管理工具,使用起来非常方便,安装任意软件包时 brew 会自动下载其依赖;

RubyGems 提供了ruby社区gem的托管服务,主要用于下载、安装使用 ruby 软件包

平常 iOS 开发使用 cocoapods 等工具都是使用 gems 进行安装管理,当使用 brew 安装软件包时有可能因依赖导致 ruby 环境错乱,不建议混合使用(使用 brew 也可以安装 cocoapods 而且很方便)

再次运行项目,又发现新的包错: 'Flutter/Flutter.h not found'

这里这样做

1、为了保守起见先备份ios文件

2、删除iOS文件

3、cd到项目跟目录执行命令重新创建ios项目

flutter create .

4、将备份的ios重要文件替换进来

详细见:www.kindacode.com/article/flu…

iOS 深度解析

作者 忆江南
2026年3月2日 18:07

目录

  1. iOS 启动流程
  2. 启动优化
  3. 网络优化
  4. RunLoop
  5. Runtime
  6. 卡顿监控
  7. AFNetworking
  8. SDWebImage

1. iOS 启动流程

1.1 启动的宏观阶段划分

iOS App 的启动可分为两个大阶段:pre-main 阶段(main 函数执行之前)和 post-main 阶段(main 函数执行之后到首帧渲染完成)。

  • 冷启动(Cold Launch):App 完全不在内存中,需要从磁盘加载所有资源,经历完整的 pre-main 和 post-main 流程。
  • 热启动(Warm Launch):App 进程虽然被终止,但部分数据仍然在系统内核的页缓存中(page cache),此时 dyld 加载速度会更快。
  • 恢复启动(Resume):App 只是从后台切回前台,不涉及进程创建,严格意义上不算"启动"。

1.2 Pre-main 阶段详解

1.2.1 内核阶段(Kernel)

当用户点击 App 图标时,系统通过 launchd 进程(PID=1)fork 出一个新的进程。内核为新进程完成以下工作:

  • 创建进程:分配 PID,创建虚拟内存空间(每个进程都有独立的 4GB/16EB 虚拟地址空间)。
  • ASLR(Address Space Layout Randomization):生成一个随机偏移值(slide),将 Mach-O 的加载基地址随机化,防止固定地址攻击。ASLR 是在内核层面实现的,每次启动 slide 不同。
  • 加载可执行文件:将 Mach-O 的头部和 Load Commands 映射到虚拟内存中(注意是映射,不是全部读入物理内存,利用的是 mmap 和按需缺页机制)。

1.2.2 dyld 阶段(Dynamic Linker)

dyld(dynamic link editor)是 Apple 的动态链接器,它是第一个在用户态运行的代码。Apple 在 iOS 13/macOS 11 之后将 dyld 升级到了 dyld3 和后来的 dyld4,引入了启动闭包(Launch Closure)机制。

dyld 的核心工作流程:

a) 加载动态库(Load Dylibs)

dyld 根据 Mach-O 的 LC_LOAD_DYLIB 等 Load Commands,递归地加载所有依赖的动态库。每个动态库自身也可能依赖其他动态库,形成一棵依赖树。系统共享库(如 UIKit、Foundation)通过 dyld shared cache(共享缓存)提前合并优化,存放在 /System/Library/Caches/com.apple.dyld/ 下,加载速度极快。

动态库的加载过程:

  • 解析 Mach-O Header,验证魔数(Magic Number)、CPU 架构、文件类型。
  • 读取 Load Commands,确定各 Segment(__TEXT__DATA__LINKEDIT)的内存映射方式。
  • 调用 mmap() 将文件内容映射到虚拟内存。
  • 由于使用了 Copy-on-Write(COW)技术,只读段可以被多个进程共享物理内存。

b) Rebase(基址重定位)

由于 ASLR 的存在,Mach-O 中所有写死的内部指针地址都需要加上 slide 偏移量。这个过程就是 Rebase。

Rebase 主要操作 __DATA 段中的指针。现代的 chained fixups(链式修正)格式将 rebase 信息直接编码在指针值中,减少了 __LINKEDIT 的大小,也加速了处理。

Rebase 的性能瓶颈不在于计算(加法操作极快),而在于 Page Fault:当访问尚未加载到物理内存的虚拟页时,会触发缺页中断,内核需要从磁盘读取对应的页并进行解密验证(如果开启了代码签名验证)。

c) Bind(符号绑定)

Bind 处理的是对外部动态库符号的引用。App 中调用的 NSLogobjc_msgSend 等函数,在编译时并不知道它们的真实地址,需要在运行时通过符号名查找。

  • Lazy Binding(懒绑定):大部分外部函数调用使用懒绑定,第一次调用时才通过 dyld_stub_binder 查找真实地址并回填到 __DATA.__la_symbol_ptr(Lazy Symbol Pointer)中,后续调用直接跳转,不再走 dyld。
  • Non-Lazy Binding(非懒绑定):部分符号(如 Objective-C 类引用、全局变量指针)需要在启动时立即绑定,存放在 __DATA.__nl_symbol_ptr(Non-Lazy Symbol Pointer)中。
  • Weak Binding(弱绑定)__attribute__((weak)) 修饰的符号需要搜索所有已加载的镜像来确定是否有强定义覆盖,开销较大。

d) dyld3/dyld4 的 Launch Closure

dyld3 引入了 Launch Closure(启动闭包)机制——将首次启动时的解析结果(依赖关系、rebase/bind 信息、初始化顺序等)序列化保存到磁盘。后续启动时直接读取闭包文件,跳过大量解析工作。

dyld4 进一步引入了 PrebuiltLoaderSet,对 App 的启动路径做了更激进的预计算。

1.2.3 Objective-C Runtime 初始化

dyld 在完成所有动态库的加载和绑定后,会调用注册的初始化函数。ObjC Runtime 的初始化是其中最重要的一步:

  • map_images:当新的 Mach-O 镜像被映射到内存时调用。Runtime 解析 __DATA.__objc_classlist__DATA.__objc_catlist(Category 列表)、__DATA.__objc_protolist(Protocol 列表)等 section,将类、分类、协议注册到全局表中。
  • 类的实现(Realize):将类从磁盘格式转换为运行时格式,设置 superclass 指针、method list、ivar layout 等。这个过程是懒加载的——只有第一次使用类时才会 realize。
  • Category 的附加:将 Category 中的方法、属性、协议"织入"到对应的类中。方法会被插入到方法列表的前面,这就是 Category 能"覆盖"原类方法的原因。
  • load_images:调用所有类和 Category 的 +load 方法。调用顺序:先按编译顺序调用父类的 +load,再调用子类的,最后调用 Category 的。+load 在所有类完成注册后、任何 +initialize 之前执行。

1.2.4 C++ 静态初始化器

所有标记了 __attribute__((constructor)) 的函数以及 C++ 全局对象的构造函数会在此阶段被调用。它们通过 __DATA.__mod_init_func section 记录。

1.2.5 执行 main 函数

完成以上所有步骤后,dyld 调用 App 可执行文件的入口点,即 main() 函数。

1.3 Post-main 阶段详解

1.3.1 UIApplicationMain

main() 函数通常只做一件事:调用 UIApplicationMain()。这个函数完成:

  • 创建 UIApplication 单例对象。
  • 创建 App Delegate 对象。
  • 启动主线程的 RunLoop(CFRunLoopGetMain())。
  • 加载 Info.plist,如果指定了 Main Storyboard,则加载并实例化初始 ViewController。

1.3.2 Application Lifecycle Callbacks

按照 iOS 13+ 的 Scene-based Life Cycle(多窗口架构):

  1. application:didFinishLaunchingWithOptions: — App 级别的初始化入口。
  2. scene:willConnectToSession:options: — Scene 连接。
  3. sceneWillEnterForeground: — 即将进入前台。
  4. sceneDidBecomeActive: — 已激活,用户可交互。

1.3.3 首帧渲染(First Frame Render)

首帧渲染标志着用户可以看到 App 的实际界面。系统在第一次 CATransaction commit 时将渲染树提交给 Render Server(一个独立进程 backboardd),完成 GPU 合成并上屏。

Apple 的 App Launch InstrumentCA::Transaction::commit() 中第一帧绘制完成作为启动结束的标志。

1.4 Mach-O 文件格式补充

Mach-O 是 macOS/iOS 的可执行文件格式,理解它对理解启动流程至关重要:

区域 内容
Header 魔数、CPU 类型、文件类型(MH_EXECUTE/MH_DYLIB)、Load Commands 数量
Load Commands 描述文件布局的元数据:段的位置和大小、动态库依赖、入口点、代码签名位置等
__TEXT 只读、可执行:机器码(__text)、ObjC 方法名(__objc_methname)、字符串常量(__cstring)等
__DATA 可读写:全局变量、ObjC 类数据、符号指针表等
__DATA_CONST 启动后只读:ObjC 类列表、协议列表等(rebase/bind 后被 mprotect 设为只读)
__LINKEDIT 动态链接器使用的元数据:符号表、字符串表、rebase/bind 操作码、代码签名等

2. 启动优化

2.1 度量体系

2.1.1 Apple 官方指标

  • TTID(Time to Initial Display):App 进程创建到第一帧渲染完成的时间。Apple 建议冷启动控制在 400ms 以内。
  • MetricKitMXAppLaunchMetric 提供生产环境的启动耗时数据(p50/p90/p99)。
  • DYLD_PRINT_STATISTICS:设置此环境变量可在控制台输出 pre-main 阶段各步骤的耗时。

2.1.2 自建度量

+load 或进程创建时记录起始时间戳,在首帧 viewDidAppear:CADisplayLink 回调中记录结束时间戳,差值即为端到端启动时间。注意要使用 mach_absolute_time()clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) 获取高精度时间,避免使用 NSDate(会受 NTP 校时影响)。

2.2 Pre-main 阶段优化

2.2.1 减少动态库数量

每个自定义动态库都会增加 dyld 的加载、rebase、bind 开销。Apple 建议自定义动态库不超过 6 个

优化手段:

  • 将多个小型动态 framework 合并为一个。
  • 能用静态库的场景优先使用静态库(静态库在编译链接阶段就合并到了主二进制中,不增加 dyld 的运行时负担)。
  • 使用 xcframework 统一管理多架构,避免重复链接。

2.2.2 减少 ObjC 元数据

  • 减少类和 Category 的数量:每个 ObjC 类都需要在 map_images 阶段注册到 Runtime 的全局类表中,每个 Category 都需要被合并到宿主类。大量无用的类会拖慢这个过程。
  • 清理无用代码:使用 LinkMap 文件分析各模块大小,结合 AppCode 的 Inspect Code 或开源工具(如 fuiperiphery)找出未使用的类和方法。
  • Swift 优势:Swift 的结构体和枚举不经过 ObjC Runtime,不产生 map_images 的注册开销。能用 Swift 值类型代替 ObjC 类的场景应优先考虑。

2.2.3 消灭 +load 方法

+load 方法在启动的极早期串行执行(持有 Runtime 的全局锁),任何耗时操作都会直接阻塞启动。

替代方案:

  • +initialize:懒加载,在类第一次收到消息时调用,且只调用一次(线程安全由 Runtime 保证)。将初始化逻辑从 +load 迁移到 +initialize 可以将开销延后到实际使用时。
  • __attribute__((constructor)) 也应减少:与 +load 类似,在 main() 之前执行。

2.2.4 二进制重排(Binary Reordering)

原理:App 启动时并非所有代码都会被立即执行。由于虚拟内存的分页机制(iOS 上每页 16KB),启动时执行的函数如果分散在不同的页中,会导致大量 Page Fault。每次 Page Fault 需要从磁盘读取一页并进行代码签名验证(对于加密的 App),耗时约 0.10.3ms。如果启动路径上有 2000 次 Page Fault,累计开销可达 200600ms。

做法

  1. 使用 Clang 的 SanitizerCoverage-fsanitize-coverage=func,trace-pc-guard)编译代码,在每个函数入口插入回调,记录启动路径上所有被调用的函数及其顺序。
  2. 生成 Order File.order 文件),按启动调用顺序列出函数符号。
  3. 在 Xcode 的 Build Settings 中设置 Order File 路径,链接器会按指定顺序排布函数,使启动路径上的函数尽量集中在连续的页中,减少 Page Fault。

效果:对于大型 App,Page Fault 次数可减少 30%70%,带来 100300ms 的启动提升。

2.2.5 dyld3/dyld4 闭包缓存

现代 iOS 系统已默认使用 dyld3 闭包。开发者能做的是确保不破坏闭包缓存的有效性——每次 App 更新后首次启动闭包需要重新生成,这属于不可避免的开销。

2.3 Post-main 阶段优化

2.3.1 任务分级与延迟加载

didFinishLaunchingWithOptions: 中的初始化任务按优先级分为三类:

优先级 任务类型 执行时机
P0 崩溃监控、AB 实验框架 didFinishLaunching 最前面,同步执行
P1 网络库初始化、用户登录态恢复 didFinishLaunching 中异步执行
P2 分享 SDK、推送注册、非首屏功能 首帧渲染后延迟执行(通过 RunLoop idle 或延时 dispatch)

关键原则:首帧渲染前只做必须做的事

2.3.2 首页渲染优化

  • 缓存上次的首页截图:在启动时展示缓存截图(skeleton screen 或快照),让用户感知到"已打开",待真实数据加载完成后替换。
  • 减少首页视图层级:使用 Instruments 的 View Debugger 分析视图层级深度,减少不必要的嵌套。
  • 避免首帧同步网络请求:使用本地缓存数据渲染首帧,网络数据到达后差量更新。

2.3.3 子线程预加载

将不需要在主线程执行的初始化任务放到并发队列中并行执行:

  • 数据库初始化和预热。
  • 预加载常用的图片资源到内存缓存。
  • 预建立 HTTP/2 连接(TCP + TLS 握手)。

注意:UIKit 操作必须在主线程,CoreData 的 NSManagedObjectContext 要注意线程隔离。

2.3.4 启动任务调度框架

大型 App 通常会搭建启动任务调度框架,支持:

  • 声明式地定义任务、依赖关系和线程要求。
  • 自动拓扑排序确定执行顺序。
  • 并行执行无依赖关系的任务。
  • 监控每个任务的耗时,自动上报异常。

2.4 持续劣化防护

  • CI 卡口:在 CI 流水线中集成启动耗时测试(使用 XCTest + MetricKit 或自定义打点),设置阈值,超标则阻断合入。
  • LinkMap 体积监控:监控二进制体积增长(尤其是 __DATA 段的增长),它与 rebase/bind 耗时正相关。
  • +load 扫描:通过静态分析工具在编译期扫描新增的 +load 方法。

3. 网络优化

3.1 网络请求的全链路分析

一次 HTTPS 请求的完整链路:

DNS 解析 → TCP 三次握手 → TLS 握手 → 请求发送 → 服务器处理 → 响应接收 → 数据解析

每个环节都有优化空间。

3.2 DNS 优化

3.2.1 传统 DNS 的问题

  • 解析延迟:首次解析需要递归查询根域名服务器 → 顶级域名服务器 → 权威域名服务器,耗时 50~200ms,极端情况下可达数秒。
  • DNS 劫持:运营商 LocalDNS 可能返回篡改的 IP 地址,将用户引导到广告页或错误服务器。
  • 调度不精准:运营商 DNS 的出口 IP 与用户的实际 IP 可能不在同一地区,导致 CDN 调度到非最优节点。
  • DNS 缓存不可控:系统 DNS 缓存(res_9_getaddrinfo)的 TTL 由服务端控制,App 无法主动管理。

3.2.2 HTTPDNS

HTTPDNS 通过 HTTP/HTTPS 协议直接向 DNS 服务商(如阿里云 HTTPDNS、腾讯云 HTTPDNS)发送域名解析请求,绕过运营商 LocalDNS。

核心优势:

  • 防劫持:使用 HTTPS 通道加密传输,运营商无法篡改。
  • 精准调度:可以携带客户端真实 IP(EDNS Client Subnet),CDN 能调度到最优节点。
  • 可控缓存:App 自主管理 DNS 缓存和预解析策略。

实现要点:

  • 预解析:App 启动时对常用域名发起预解析,将结果缓存在本地。
  • 缓存策略:本地维护 IP 缓存池,设置合理的 TTL。TTL 过期后异步刷新,期间仍使用旧 IP("乐观缓存"策略),避免解析等待。
  • 降级机制:HTTPDNS 服务异常时自动降级到系统 DNS。
  • SNI 问题:使用 HTTPDNS 后,HTTPS 请求的 Host 头是 IP 地址,需要手动设置 SNI(Server Name Indication)字段为原始域名,否则 TLS 握手会因证书不匹配而失败。在 NSURLSession 中需要实现 URLSession:didReceiveChallenge:completionHandler: 代理方法处理证书验证。

3.2.3 DNS-over-HTTPS (DoH) / DNS-over-TLS (DoT)

iOS 14+ 原生支持 DoH/DoT(通过 NEDNSSettingsManager),但这是系统级别的配置,App 级别的定制灵活性不如 HTTPDNS。

3.3 连接优化

3.3.1 连接复用

  • HTTP/1.1 Keep-Alive:在同一个 TCP 连接上串行发送多个请求,避免每次请求都建立新连接。但存在 队头阻塞(Head-of-Line Blocking) 问题——前一个请求未完成时后续请求必须等待。
  • HTTP/2 多路复用(Multiplexing):在单个 TCP 连接上并行发送多个请求/响应,通过帧(Frame)和流(Stream)的概念实现真正的并发。一个连接可以同时承载上百个请求。但 TCP 层的队头阻塞依然存在——一个丢包会阻塞整个连接上的所有流。
  • HTTP/3 (QUIC):基于 UDP,在传输层消除了队头阻塞。每个流独立进行丢包重传,互不影响。同时集成了 TLS 1.3,握手延迟更低(0-RTT/1-RTT)。iOS 15+ 的 NSURLSession 默认支持 HTTP/3。

3.3.2 预连接(Pre-connect)

在用户可能发起请求之前,提前完成 TCP + TLS 握手,使后续请求可以直接发送数据。

实现方式:使用 NSURLSession 的连接预热 API,或自行管理连接池。

3.3.3 连接迁移(Connection Migration)

传统 TCP 连接以四元组(源 IP、源端口、目的 IP、目的端口)标识,当用户从 WiFi 切换到蜂窝时,源 IP 变化导致连接断开。QUIC 使用 Connection ID 标识连接,网络切换时连接不中断,实现无缝迁移。

3.4 数据传输优化

3.4.1 数据压缩

  • Gzip/Brotli:在 HTTP 响应头中设置 Content-Encoding: gzip/br。Brotli 压缩率比 gzip 高 15~25%,特别适合文本类数据。NSURLSession 自动处理 gzip 解压。
  • Protocol Buffers / FlatBuffers:使用二进制序列化替代 JSON。Protobuf 体积比 JSON 小 310 倍,解析速度快 20100 倍。适用于高频接口和大数据量场景。
  • 增量更新(Delta Sync):只传输变化的部分,而非全量数据。可以使用 JSON Patch(RFC 6902)或自定义 diff 算法。

3.4.2 请求合并与批处理

将多个小请求合并为一个批量请求,减少网络往返次数(RTT)。例如将 10 个独立的埋点上报请求合并为 1 个批量请求。

3.4.3 精简数据

  • 按需请求字段:使用 GraphQL 或接口的 fields 参数,只请求客户端真正需要的字段,减少无用数据传输。
  • 分页加载:对列表类数据实施分页,避免一次加载全量数据。

3.5 缓存策略

3.5.1 HTTP 缓存

  • 强缓存Cache-Control: max-age=3600Expires 头。在有效期内直接使用本地缓存,不发起网络请求。
  • 协商缓存ETag / If-None-MatchLast-Modified / If-Modified-Since。客户端携带标识请求服务器,若资源未变则返回 304,节省传输带宽。
  • NSURLSession 的缓存策略:通过 NSURLRequest.cachePolicy 控制,NSURLCache 自动管理磁盘和内存缓存。

3.5.2 业务层缓存

  • 将接口返回数据持久化到本地(SQLite、文件),优先展示缓存数据,网络数据到达后更新 UI("先展示后刷新"策略)。
  • 对于不频繁变化的数据(如配置信息),使用较长的本地缓存有效期。

3.6 弱网优化

  • 超时策略:针对不同网络质量动态调整超时时间。WiFi 下 15s,4G 下 20s,3G/2G 下 30s。
  • 重试策略:指数退避(Exponential Backoff)+ 抖动(Jitter)。避免重试风暴压垮服务器。只对幂等请求(GET、PUT)重试,POST 请求需要业务层保证幂等性。
  • 网络质量检测:通过 NWPathMonitor(Network Framework)实时监听网络状态变化,结合 RTT、丢包率估算网络质量,动态降级(如切换到低分辨率图片)。
  • 多通道竞速:在 WiFi 和蜂窝同时可用时,并行发起请求,取先返回的结果。NSURLSessionConfiguration.multipathServiceType 支持 MPTCP(Multipath TCP)。

3.7 安全层优化

  • TLS 1.3:将握手往返从 2-RTT(TLS 1.2)减少到 1-RTT,支持 0-RTT 恢复(PSK,Pre-Shared Key)。iOS 12.2+ 默认支持。
  • 证书固定(Certificate Pinning):在 App 内预埋服务器证书的公钥哈希,防止中间人攻击。需要注意证书轮换的运维流程。
  • OCSP Stapling:服务器在 TLS 握手时主动提供证书状态(是否被吊销),避免客户端额外查询 OCSP 服务器。

3.8 监控体系

  • URLSessionTaskMetrics(iOS 10+):提供每个请求的详细时间线——DNS 解析时间、连接建立时间、TLS 握手时间、请求发送时间、响应接收时间等。这是做网络性能分析的核心数据源。
  • 端到端监控指标:成功率、平均耗时、P99 耗时、DNS 解析耗时、首字节时间(TTFB)、错误类型分布等。
  • 网络链路追踪:在请求头中注入 Trace ID,贯穿客户端 → CDN → 网关 → 后端服务,实现全链路问题定位。

4. RunLoop

4.1 RunLoop 的本质

RunLoop 本质上是一个 事件循环(Event Loop) 机制。它让线程在没有任务时进入休眠(不消耗 CPU),在有任务时被唤醒处理事件。没有 RunLoop 的线程执行完任务就会退出;有了 RunLoop,线程可以常驻内存,随时响应事件。

RunLoop 与线程是一一对应的关系:

  • 主线程的 RunLoop 在 UIApplicationMain 中自动创建和启动。
  • 子线程的 RunLoop 默认不创建,需要手动调用 [NSRunLoop currentRunLoop]CFRunLoopGetCurrent() 时才会懒加载创建。
  • RunLoop 保存在一个全局的 CFMutableDictionaryRef 中,以 pthread_t 作为 key。

4.2 RunLoop 的核心架构

4.2.1 三大核心对象

a) CFRunLoopSource(输入源)

  • Source0(非端口事件源):不能主动唤醒 RunLoop,需要手动调用 CFRunLoopSourceSignal() 标记为待处理,再调用 CFRunLoopWakeUp() 唤醒 RunLoop。触摸事件、performSelector:onThread: 等使用 Source0 分发。
  • Source1(端口事件源):基于 Mach Port,能主动唤醒 RunLoop。系统内核通过 Mach Port 发送消息来通知事件,如硬件事件(触摸/锁屏/摇晃)首先由 IOKit 通过 Mach Port 传递给 SpringBoard,再由 SpringBoard 通过 Mach Port 分发给对应的 App 进程。App 内部的 Source1 接收到事件后,通常会封装成 Source0 在主线程 RunLoop 中处理。

b) CFRunLoopTimer(定时器源)

基于时间的触发器,与 NSTimer 是 toll-free bridged 的。Timer 的触发时间并非绝对精确——它依赖于 RunLoop 的运行状态。如果 RunLoop 正在处理一个耗时任务,Timer 的回调会被延迟到当前任务完成后才执行。Timer 有一个 tolerance(容差)属性,系统可以在 fireDate ± tolerance 范围内选择最佳触发时机以节能。

c) CFRunLoopObserver(观察者)

可以监听 RunLoop 的状态变化:

状态 含义
kCFRunLoopEntry 即将进入 RunLoop
kCFRunLoopBeforeTimers 即将处理 Timer
kCFRunLoopBeforeSources 即将处理 Source
kCFRunLoopBeforeWaiting 即将进入休眠
kCFRunLoopAfterWaiting 刚从休眠中唤醒
kCFRunLoopExit 即将退出 RunLoop

4.2.2 RunLoop Mode

RunLoop 在某一时刻只能运行在一个 Mode 下。每个 Mode 包含独立的 Source/Timer/Observer 集合。切换 Mode 时,当前 Mode 下的 Source/Timer/Observer 不会被处理。

常用 Mode:

  • kCFRunLoopDefaultModeNSDefaultRunLoopMode:默认 Mode,App 空闲时运行在此 Mode。
  • UITrackingRunLoopMode:ScrollView 滑动时切换到此 Mode。这就是为什么 NSTimer 在 Default Mode 下注册时,滑动 ScrollView 期间 Timer 不触发——因为 RunLoop 此时运行在 Tracking Mode 下。
  • kCFRunLoopCommonModesNSRunLoopCommonModes:这不是一个真正的 Mode,而是一个"模式集合"的标记。被标记为 Common 的 Source/Timer/Observer 会被同步到所有被标记为 Common 的 Mode 中。默认情况下 Default Mode 和 Tracking Mode 都是 Common Mode。将 Timer 添加到 Common Modes 可以让它在滑动时也能触发。

4.3 RunLoop 的运行机制(核心循环)

RunLoop 的核心运行逻辑(简化版):

  1. 通知 Observer:即将进入 RunLoop(kCFRunLoopEntry)。
  2. 通知 Observer:即将处理 Timer(kCFRunLoopBeforeTimers)。
  3. 通知 Observer:即将处理 Source0(kCFRunLoopBeforeSources)。
  4. 处理所有待处理的 Source0 事件。
  5. 如果有 Source1(Mach Port 消息)待处理,跳转到步骤 9 直接处理。
  6. 通知 Observer:即将进入休眠(kCFRunLoopBeforeWaiting)。
  7. 休眠,等待唤醒。线程通过 mach_msg() 系统调用陷入内核态,让出 CPU。可以被以下事件唤醒:
    • Mach Port 消息到达(Source1 事件、Timer 触发、CFRunLoopWakeUp() 调用)。
    • 超时(RunLoop 有一个超时参数)。
    • 被外部手动唤醒。
  8. 通知 Observer:刚从休眠中被唤醒(kCFRunLoopAfterWaiting)。
  9. 处理唤醒事件:
    • 如果是 Timer 到期:处理 Timer 回调。
    • 如果是 dispatch_main_queue 的 block:执行 block(GCD 派发到主队列的任务通过 RunLoop 的 Source1 唤醒主线程执行)。
    • 如果是 Source1 事件:处理 Source1 回调。
  10. 判断是否需要退出(Mode 中没有任何 Source/Timer、被外部停止、超时等)。
  11. 如果不退出,跳转到步骤 2 继续循环。
  12. 通知 Observer:即将退出 RunLoop(kCFRunLoopExit)。

4.4 RunLoop 与系统功能的关系

4.4.1 AutoreleasePool

主线程 RunLoop 注册了两个 Observer 与 AutoreleasePool 配合:

  • 第一个 Observer 监听 kCFRunLoopEntry(优先级最高,保证在所有回调之前):调用 _objc_autoreleasePoolPush() 创建自动释放池。
  • 第二个 Observer 监听 kCFRunLoopBeforeWaiting(优先级最低,保证在所有回调之后):调用 _objc_autoreleasePoolPop() 释放旧池中的对象,再调用 _objc_autoreleasePoolPush() 创建新池。同时监听 kCFRunLoopExit:调用 _objc_autoreleasePoolPop() 做最终释放。

这意味着主线程上被 autorelease 的对象会在每次 RunLoop 循环即将休眠时被释放。

4.4.2 事件响应

硬件事件(触摸)传递链:

  1. 硬件产生中断 → IOKit.framework 封装为 IOHIDEvent。
  2. 通过 Mach Port 传递给 SpringBoard 进程。
  3. SpringBoard 判断前台 App,通过 Mach Port 传递给 App 进程。
  4. App 主线程 RunLoop 的 Source1 被唤醒,回调 __IOHIDEventSystemClientQueueCallback()
  5. Source1 内部触发 Source0(__UIApplicationHandleEventQueue())。
  6. Source0 中进行 Hit Test、手势识别、UIResponder 事件分发。

4.4.3 UI 刷新

setNeedsLayoutsetNeedsDisplay 等调用不会立即触发布局/绘制,而是标记为"需要更新"。主线程 RunLoop 注册了一个 Observer 监听 kCFRunLoopBeforeWaitingkCFRunLoopExit,在回调中遍历所有标记了需要更新的视图,执行实际的 layout、display、render 操作,最终打包提交给 Render Server。

这就是 Core Animation 的 Transaction 机制

4.4.4 GCD 与 RunLoop

当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会唤醒主线程的 RunLoop(通过向 RunLoop 的 dispatch port 发送 Mach 消息),RunLoop 在循环中检测到 dispatch port 有消息后,会调用 _dispatch_main_queue_callback_4CF() 来执行 block。

4.4.5 performSelector:afterDelay:

performSelector:withObject:afterDelay: 实际上是创建了一个 Timer 添加到当前线程的 RunLoop 中。如果当前线程没有 RunLoop(子线程默认没有),这个方法不会执行。

4.5 RunLoop 的实际应用

  • 常驻子线程:为子线程创建 RunLoop 并添加一个永不触发的 Port(防止 RunLoop 因没有 Source/Timer 而退出),使线程常驻内存,随时可以接收任务。AFNetworking 2.x 和 SDWebImage 早期版本都使用过这个技巧。
  • NSTimer 滑动不停:将 Timer 添加到 NSRunLoopCommonModes
  • 卡顿监控:通过 Observer 监听 RunLoop 状态,检测主线程 Source 处理或休眠前等待是否超时(详见卡顿监控章节)。
  • 线程保活(Thread Keep-Alive):网络库中用于在子线程持续接收回调。
  • 任务拆分:将大量计算任务拆分成小块,每次 RunLoop 循环处理一块,避免长时间阻塞主线程(类似协程的思想)。

5. Runtime

5.1 Runtime 的本质

Objective-C Runtime 是一个用 C/C++/汇编编写的运行时库,它实现了 ObjC 的面向对象特性和动态性。ObjC 是一门动态语言——许多决定(调用哪个方法、对象是什么类型)被推迟到运行时。

核心思想:消息发送(Messaging)。ObjC 中的方法调用 [obj method] 会被编译器转换为 objc_msgSend(obj, @selector(method)),由 Runtime 在运行时查找并执行对应的实现。

5.2 对象模型

5.2.1 对象(id / objc_object)

每个 ObjC 对象本质上是一个结构体,其第一个成员是 isa 指针,指向该对象所属的类。

从 ARM64 开始,Apple 使用了 Tagged PointerNon-pointer ISA 优化:

Tagged Pointer:对于 NSNumberNSDate、短字符串等小对象,指针本身就直接存储了对象的值,不需要在堆上分配内存。判断方法:指针的最高位(ARM64)或最低位(x86_64)为 1 则是 Tagged Pointer。Tagged Pointer 不是真正的对象,没有 isa、没有 retain/release 开销,内存效率和访问速度极高。

Non-pointer ISA(优化的 isa):在 64 位系统上,isa 不再是单纯的类指针。64 位中只有 33~44 位用于存储类地址,其余位存储了:

  • 引用计数extra_rc,19 位,存储引用计数减 1 的值)。当 extra_rc 溢出时,将一半的引用计数转存到 SideTable 的 RefcountMap 中,has_sidetable_rc 标志位置 1。
  • 是否有关联对象has_assoc)。
  • 是否有 C++ 析构函数has_cxx_dtor)。
  • 是否使用了弱引用weakly_referenced)。
  • 是否正在释放deallocating)。

5.2.2 类(objc_class)

类也是一个对象(元类的实例),继承自 objc_object。关键成员:

  • isa:指向元类(metaclass)。
  • superclass:指向父类。
  • cache:方法缓存(cache_t),使用哈希表存储最近调用的方法,加速消息发送。
  • bits / class_rw_t
    • class_ro_t(Read-Only):编译期确定的只读数据——方法列表、属性列表、ivar 列表、协议列表、实例大小等。存储在 Mach-O 的 __DATA_CONST 段中。
    • class_rw_t(Read-Write):运行时创建的可读写数据,包含对 class_ro_t 的引用,以及运行时动态添加的方法、属性、协议列表。
    • class_rw_ext_t:iOS 14+ 优化,只有在类被运行时修改过(如添加了 Category、使用了 class_addMethod)时才会创建 class_rw_ext_t,约 90% 的类不需要,节省大量内存(Apple 称全系统节省约 14MB)。

5.2.3 元类(Metaclass)

  • 实例对象的 isa → 类对象。
  • 类对象的 isa → 元类对象。
  • 元类对象的 isa → 根元类(NSObject 的元类)。
  • 根元类的 isa → 自身。
  • 根元类的 superclass → NSObject 类。

这个链条解释了为什么实例方法存储在类中,类方法存储在元类中——消息发送总是沿着 isa 链查找方法。

5.3 消息发送机制(objc_msgSend)

5.3.1 快速查找(缓存查找)

objc_msgSend 是用汇编语言编写的(ARM64),追求极致性能。

执行流程:

  1. 判断 receiver 是否为 nil(Tagged Pointer 的特殊处理)。
  2. 通过 receiver 的 isa 找到类对象。
  3. 在类的 cache_t(方法缓存)中查找 SEL 对应的 IMP。cache_t 是一个开放寻址的哈希表,使用 SEL 的地址值做 mask 运算得到索引,查找效率接近 O(1)。
  4. 如果命中缓存(Cache Hit),直接跳转到 IMP 执行——整个过程几十纳秒,纯汇编实现。

5.3.2 慢速查找(方法列表查找)

缓存未命中时,进入 C/C++ 实现的 lookUpImpOrForward 函数:

  1. 在当前类的 class_rw_t 中搜索方法列表。方法列表已按 SEL 地址排序(在类 realize 时排序),使用二分查找,时间复杂度 O(log n)。
  2. 如果未找到,沿 superclass 链向上逐级查找父类的方法列表(每级都先查缓存再查方法列表)。
  3. 如果一直到 NSObject(根类)都未找到,进入消息转发流程。
  4. 如果找到了,将 SEL→IMP 的映射写入当前类的 cache_t(注意是写入最初接收消息的类的缓存,不是找到方法的那个父类的缓存)。

5.3.3 方法缓存(cache_t)的实现细节

  • 哈希表使用 掩码(mask) 而非取模,因为 mask 可以用位与运算(& mask)替代除法,更快。
  • 缓存容量始终是 2 的幂次,初始容量为 4(ARM64)。
  • 当缓存使用率超过 3/4(75%) 时,容量翻倍并清空所有旧缓存(而非 rehash),因为 Apple 认为缓存的时间局部性很强,旧缓存大概率不再需要。
  • 类在第一次收到消息时分配缓存。

5.4 消息转发机制(Message Forwarding)

当消息发送的快速查找和慢速查找都未找到方法实现时,进入消息转发的三个阶段:

5.4.1 第一阶段:动态方法解析(Dynamic Method Resolution)

Runtime 调用:

  • 实例方法:+resolveInstanceMethod:
  • 类方法:+resolveClassMethod:

在这个方法中,类有机会动态地为 SEL 添加一个 IMP(通过 class_addMethod)。如果返回 YES 且添加了方法,Runtime 会重新执行消息发送流程。

应用场景:@dynamic 属性的实现、Core Data 的 NSManagedObject 动态生成属性的 getter/setter。

5.4.2 第二阶段:快速转发(Fast Forwarding / Forwarding Target)

Runtime 调用 -forwardingTargetForSelector:

在这个方法中,可以返回另一个对象来处理这条消息(消息转发给备用接收者)。这一步效率很高,因为直接对新对象执行 objc_msgSend,不需要创建 NSInvocation

应用场景:多重代理(将消息转发给多个对象)、组合模式的简化实现。

5.4.3 第三阶段:完整转发(Normal Forwarding)

Runtime 依次调用:

  1. -methodSignatureForSelector::返回方法的类型签名(NSMethodSignature),描述参数类型和返回值类型。
  2. -forwardInvocation::接收一个封装了完整调用信息的 NSInvocation 对象,可以修改目标、参数、甚至调用多次。

这是最灵活但最慢的阶段,NSInvocation 的创建涉及堆分配和参数拷贝。

如果以上三个阶段都未处理,最终调用 -doesNotRecognizeSelector:,抛出经典的 "unrecognized selector sent to instance" 异常。

5.5 Method Swizzling

通过 Runtime 函数交换两个方法的 IMP,实现 AOP(面向切面编程)。

核心 API:

  • method_exchangeImplementations:交换两个 Method 的 IMP。
  • class_replaceMethod:替换某个 SEL 的 IMP。
  • method_setImplementation:设置某个 Method 的 IMP。

陷阱与最佳实践

  • 必须在 +load 中执行(或用 dispatch_once 保证只执行一次),避免竞态条件。
  • 必须调用原始实现:Swizzle 后的方法中要调用"看似递归实际不是"的原始方法(因为 IMP 已经交换了)。
  • 父类方法问题:如果当前类没有实现目标方法(继承自父类),直接交换会影响父类。正确做法是先 class_addMethod 尝试添加,成功则只需 class_replaceMethod 替换父类的实现到当前类的新 SEL,失败(说明当前类已有实现)才 method_exchangeImplementations
  • _cmd 问题:Swizzle 后方法内部的 _cmd 值是交换后的 SEL,可能导致日志、KVO 等依赖 _cmd 的逻辑出错。

5.6 关联对象(Associated Objects)

通过 objc_setAssociatedObject / objc_getAssociatedObject 为已存在的类动态添加"属性"(实际是绑定的键值对)。

内部存储结构

全局维护一个 AssociationsManager(自带锁),内部是一个 AssociationsHashMap

AssociationsHashMap: { 对象地址(disguised_ptr_t) → ObjectAssociationMap }
ObjectAssociationMap: { key(const void*) → ObjcAssociation(policy + value) }
  • 关联对象不存储在对象本身的内存中,而是存储在全局的哈希表中,以对象地址为 key。
  • 对象销毁时(dealloc),Runtime 检查 isa 的 has_assoc 标志位,如果为 1,则调用 _object_remove_associations() 清除该对象的所有关联对象。
  • 关联策略:OBJC_ASSOCIATION_ASSIGN(弱引用)、OBJC_ASSOCIATION_RETAIN_NONATOMIC(强引用,非原子)、OBJC_ASSOCIATION_COPY_NONATOMIC(拷贝)等,语义与 property 属性一致。

5.7 Category 的实现原理

Category 在编译后生成 category_t 结构体,包含:方法列表、属性列表、协议列表(但没有 ivar 列表,这就是 Category 不能添加实例变量的原因——实例变量列表在编译期确定,存储在 class_ro_t 中,不可修改)。

加载过程

  1. map_images 阶段,Runtime 遍历所有镜像的 __objc_catlist section,收集所有 Category。
  2. 调用 attachCategories() 将 Category 的方法列表倒序插入到类的方法列表数组的前面(使用 attachListsATTACH_EXISTING 方式)。
  3. 因此,后编译的 Category 的方法会排在最前面,最先被找到——这就是 Category "覆盖"原类方法的真相(原方法仍然存在,只是排在后面不会被优先找到)。

多个 Category 有同名方法时:取决于编译顺序(Build Phases → Compile Sources 中的文件顺序),最后编译的 Category 的方法排在最前面。

5.8 Weak 引用的实现

全局 Weak 表:Runtime 维护一个全局的 SideTable(实际上是一个 StripedMap,包含 64 个 SideTable 以减少锁竞争),每个 SideTable 包含:

  • spinlock_t:自旋锁,保护并发访问。
  • RefcountMap:存储对象的额外引用计数(extra_rc 溢出时使用)。
  • weak_table_t:弱引用表,核心结构。

weak_table_t 是一个哈希表,以对象地址为 key,value 是 weak_entry_t,包含所有指向该对象的 weak 指针的地址。

weak 指针的赋值过程

  1. 调用 objc_initWeak()(或 objc_storeWeak())。
  2. 如果旧值非 nil,从旧对象的 weak_entry_t 中移除该 weak 指针。
  3. 如果新值非 nil,将该 weak 指针注册到新对象的 weak_entry_t 中。

对象销毁时清除 weak 引用

  1. dealloc_objc_rootDeallocrootDeallocobject_disposeobjc_destructInstance
  2. objc_destructInstance 中:清除关联对象 → 清除弱引用(weak_clear_no_lock)→ 清除 SideTable 引用计数。
  3. weak_clear_no_lock:遍历对象的 weak_entry_t 中所有 weak 指针地址,将它们全部置为 nil。

这就是 weak 指针在对象销毁后自动变为 nil 的底层机制。

5.9 KVO 的底层实现

KVO(Key-Value Observing)完全依赖 Runtime 实现:

  1. 当对某个对象的属性添加 KVO 观察时,Runtime 动态创建一个该对象所属类的子类(命名为 NSKVONotifying_OriginalClass)。
  2. 将对象的 isa 指向这个动态子类(isa swizzling)。
  3. 动态子类重写了被观察属性的 setter 方法,在 setter 中插入:
    • willChangeValueForKey: → 调用原始 setter → didChangeValueForKey:
    • didChangeValueForKey: 内部触发 observeValueForKeyPath:ofObject:change:context: 回调。
  4. 动态子类还重写了 class 方法(返回原类而非 NSKVONotifying_ 前缀的子类,对外隐藏 KVO 的实现细节),以及 dealloc(清理观察)和 _isKVOA(标识 KVO 类)。

6. 卡顿监控

6.1 卡顿的定义与原理

iOS 设备的屏幕刷新率通常为 60Hz(ProMotion 设备最高 120Hz),意味着每帧的渲染时间预算为 16.67ms(60fps)或 8.33ms(120fps)。如果主线程在一帧的时间内未完成 UI 更新的所有工作(布局计算、绘制、图层合成提交),就会导致掉帧(Frame Drop),用户感知为卡顿。

渲染流水线(Render Pipeline):

App 进程(CPU)                      Render Server(GPU)
┌─────────────────┐                  ┌──────────────────┐
│ Layout          │                  │ 图层树解码       │
│ Display (Draw)  │ ──Commit──────→  │ 纹理上传         │
│ Prepare         │   Transaction    │ 合成渲染         │
│ Commit          │                  │ 显示             │
└─────────────────┘                  └──────────────────┘
        ← 一帧 16.67ms →                 ← 一帧 16.67ms →

CPU 和 GPU 是流水线式工作的。CPU 在当前帧完成布局和绘制后提交给 GPU,GPU 在下一帧完成合成渲染。任一环节超时都会导致掉帧。

6.2 卡顿的常见原因

CPU 侧

  • 复杂布局计算:Auto Layout 的约束求解是多项式时间复杂度,视图层级深、约束多时开销显著。
  • 文本计算与渲染NSAttributedString 的排版(Text Kit / Core Text)、行高计算、折行计算。
  • 图片解码UIImage 在首次渲染时才进行解码(从 PNG/JPEG 压缩格式解码为位图),大图的解码可能耗时数十毫秒。
  • 对象创建与销毁:大量对象的 alloc/dealloc(尤其涉及 ARC 的 retain/release 操作和 SideTable 锁竞争)。
  • 数据库/文件 I/O:主线程同步读写磁盘。
  • 锁等待:主线程等待子线程持有的锁。

GPU 侧

  • 离屏渲染(Offscreen Rendering)cornerRadius + masksToBoundsshadowmaskgroup opacity 等会触发离屏渲染,GPU 需要额外创建帧缓冲区。
  • 过度绘制(Overdraw):大量重叠的不透明图层导致 GPU 重复渲染。
  • 大图纹理:超大图片上传到 GPU 的纹理缓存,占用大量显存和带宽。
  • 图层爆炸:大量 CALayer 导致合成开销增大。

6.3 卡顿监控方案

6.3.1 方案一:RunLoop Observer 监控

原理:主线程的所有任务都在 RunLoop 中执行。通过监听 RunLoop 的状态变化,检测两个关键时间间隔:

  • kCFRunLoopBeforeSources 到 kCFRunLoopBeforeWaiting(Source 处理阶段):如果这个间隔过长,说明 Source0 事件处理耗时过久(如触摸事件处理中有耗时操作)。
  • kCFRunLoopAfterWaiting 到下一次 kCFRunLoopBeforeWaiting(被唤醒后的处理阶段):如果这个间隔过长,说明被唤醒后的任务处理耗时过久。

实现思路

  1. 在主线程注册一个 CFRunLoopObserver,监听所有状态变化。
  2. 在 Observer 回调中记录状态变化的时间戳和当前状态。
  3. 创建一个子线程,用信号量(dispatch_semaphore)定期检测(如每 50ms 一次)主线程 RunLoop 是否长时间停留在某个状态。
  4. 如果连续多次(如 3 次)检测到主线程处于同一个状态超过阈值(如 250ms),判定为卡顿。
  5. 在子线程中抓取主线程的调用堆栈。

卡顿判定策略

  • 超过 1 帧(16ms):微卡顿,通常不记录。
  • 超过 3 帧(50ms):轻微卡顿。
  • 超过 250ms:明显卡顿,需要记录堆栈。
  • 超过 3s:严重卡顿(ANR),需要立即上报。

6.3.2 方案二:子线程 Ping(心跳检测)

原理:子线程定期向主线程发送一个"心跳"任务(通过 dispatch_async 派发到主队列),如果主线程在规定时间内未能执行该任务,则认为主线程被阻塞。

实现思路

  1. 子线程设置一个 flag 为 false,通过 dispatch_async(dispatch_get_main_queue(), ^{ flag = true; }) 发送心跳。
  2. 子线程等待一段时间(如 500ms 或 1s)。
  3. 检查 flag:如果仍为 false,说明主线程在此期间一直忙碌,判定为卡顿。
  4. 抓取主线程堆栈。

优缺点比较

  • RunLoop Observer 方案更精确,能定位到具体的 RunLoop 阶段,但实现复杂。
  • 心跳检测方案简单可靠,但只能检测到"主线程忙",无法区分是哪种任务导致的。

6.3.3 方案三:CADisplayLink 帧率监控

利用 CADisplayLink 的回调计算实际帧率。CADisplayLink 会在每次屏幕刷新前调用回调,如果两次回调的间隔超过 16.67ms,说明发生了掉帧。

局限性:只能检测掉帧的发生和严重程度,无法直接获取卡顿原因的堆栈信息。通常作为辅助监控手段,与上述方案配合使用。

6.3.4 方案四:基于 MetricKit(iOS 14+)

MXHangDiagnostic 提供系统级别的卡顿诊断信息,包括卡顿时长和调用堆栈。MXCPUExceptionDiagnostic 报告 CPU 异常使用情况。

优点是零性能开销(系统在后台采集),缺点是数据延迟(次日推送),适合线上监控而非实时调试。

6.4 堆栈采集

卡顿检测到后,最关键的是采集主线程的调用堆栈,用于定位卡顿的根因。

6.4.1 基于 mach_thread API

使用 task_threads() 获取所有线程列表,通过 thread_get_state() 获取目标线程(主线程)的寄存器状态(包含 PC、FP、LR 等),然后沿着 Frame Pointer(FP)链回溯调用栈,结合 DWARF 调试信息或 dSYM 文件符号化。

6.4.2 基于 backtrace() / backtrace_symbols()

标准 POSIX 接口,但只能获取当前线程的堆栈,无法跨线程采集。

6.4.3 基于 PLCrashReporter

开源的崩溃报告库,提供了安全的跨线程堆栈采集能力(信号安全、锁安全),是业界常用方案。

6.5 堆栈聚合与分析

  • 调用树合并:将多次采集的堆栈按调用路径合并成火焰图/调用树,识别热点函数。
  • 符号化:将内存地址转换为函数名+偏移量,需要对应版本的 dSYM 文件。使用 atos 命令或 dwarfdump 工具。
  • 去噪:过滤系统框架的堆栈帧(如 CFRunLoopRunSpecificmach_msg_trap),聚焦业务代码。

6.6 治理策略

  • 文本异步计算:使用 NSAttributedStringboundingRectWithSize: 在子线程预计算文本高度。
  • 图片异步解码:在子线程用 CGBitmapContextCreate + CGContextDrawImage 强制解码图片,主线程直接使用解码后的位图。
  • 预排版/预计算:Cell 的高度、布局信息在数据到达时在子线程预计算完成,主线程直接使用。
  • 按需加载:屏幕外的 Cell 不进行复杂渲染。
  • 减少离屏渲染:用 UIBezierPath + CAShapeLayer 替代 cornerRadius + masksToBounds;用 shadowPath 替代自动计算的阴影。
  • 异步绘制:使用 drawRect: 在后台线程绘制位图,再赋值给 CALayer.contents(参考 Texture/AsyncDisplayKit 框架的思想)。

7. AFNetworking

7.1 整体架构

AFNetworking 是 iOS/macOS 上最流行的网络库。目前主流版本为 AFNetworking 4.x,完全基于 NSURLSession(3.x 开始移除了 NSURLConnection 支持)。

核心架构分层:

┌────────────────────────────────────────────┐
│           AFHTTPSessionManager            │  ← 最高层:便捷 HTTP 接口
│     (GET/POST/PUT/DELETE 等快捷方法)       │
├────────────────────────────────────────────┤
│           AFURLSessionManager             │  ← 核心层:Session 管理
│   (NSURLSession delegate 的完整实现)       │
├────────────────────────────────────────────┤
│  AFURLRequestSerialization                │  ← 请求序列化
│  (HTTP/JSON/PropertyList Request)         │
├────────────────────────────────────────────┤
│  AFURLResponseSerialization               │  ← 响应反序列化
│  (HTTP/JSON/XML/Image/PropertyList)       │
├────────────────────────────────────────────┤
│  AFSecurityPolicy                         │  ← 安全策略(HTTPS/证书验证)
├────────────────────────────────────────────┤
│  AFNetworkReachabilityManager             │  ← 网络状态监听
└────────────────────────────────────────────┘

7.2 AFURLSessionManager 深入解析

7.2.1 核心职责

AFURLSessionManager 是整个库的心脏,它:

  • 持有并管理一个 NSURLSession 实例。
  • 实现了 NSURLSessionDelegateNSURLSessionTaskDelegateNSURLSessionDataDelegateNSURLSessionDownloadDelegate 四个协议的所有关键方法。
  • 维护一个 mutableTaskDelegatesKeyedByTaskIdentifier 字典,将每个 NSURLSessionTask 映射到一个 AFURLSessionManagerTaskDelegate 对象,实现任务级别的回调隔离。

7.2.2 线程安全设计

  • 使用 NSLock(名为 lock)保护 mutableTaskDelegatesKeyedByTaskIdentifier 字典的并发访问。
  • NSURLSession 的 delegate 回调在一个专用的串行 OperationQueueoperationQueue.maxConcurrentOperationCount = 1)上执行,保证回调的串行化,避免多线程问题。
  • 完成回调(success/failure block)默认 dispatch 到主队列(completionQueue 默认为 dispatch_get_main_queue()),保证 UI 更新的线程安全。开发者也可以自定义 completionQueuecompletionGroup

7.2.3 任务代理(AFURLSessionManagerTaskDelegate)

每个 NSURLSessionTask 对应一个 AFURLSessionManagerTaskDelegate 实例,它负责:

  • 收集响应数据:在 URLSession:dataTask:didReceiveData: 中将接收到的数据追加到 mutableData 中。
  • 跟踪上传/下载进度:通过 NSProgress 对象提供 KVO 兼容的进度更新。
  • 任务完成时:根据 responseSerializer 反序列化响应数据,在 completionQueue 上回调 success/failure block。

7.2.4 KVO 与通知机制

AFNetworking 大量使用了 KVO 和 NSNotification:

  • NSURLSessionTaskstate 属性进行 KVO 观察,当任务状态变为 completed 时自动清理。
  • 任务 resume/suspend/complete 时发送全局通知(如 AFNetworkingTaskDidResumeNotification),方便外部监听(如网络活动指示器 AFNetworkActivityIndicatorManager)。
  • 使用 Method Swizzling 交换了 NSURLSessionTaskresumesuspend 方法,在调用时发送通知。这是因为 NSURLSession 不对 task 的 state 变化发送 KVO 通知,AF 需要自己实现。

7.3 请求序列化(AFURLRequestSerialization)

7.3.1 AFHTTPRequestSerializer

基础的 HTTP 请求序列化器:

  • 设置通用 HTTP Header(User-Agent、Accept-Language、Authorization 等)。
  • 将参数字典编码为 URL query string(GET/HEAD/DELETE)或 HTTP body(POST/PUT/PATCH)。
  • 参数编码规则:对键值对进行百分号编码(Percent Encoding),嵌套字典和数组使用方括号语法(key[subkey]=valuekey[]=value)。
  • multipartFormData:支持 multipart/form-data 编码,用于文件上传。内部使用 AFMultipartBodyStream(自定义的 NSInputStream 子类)实现流式上传,避免将整个文件载入内存。

7.3.2 AFJSONRequestSerializer

继承自 AFHTTPRequestSerializer,将参数字典使用 NSJSONSerialization 编码为 JSON 格式放入 HTTP Body,设置 Content-Typeapplication/json

7.4 响应序列化(AFURLResponseSerialization)

响应序列化器负责验证响应的合法性并将数据转换为目标格式。

7.4.1 验证机制

所有序列化器都继承自 AFHTTPResponseSerializer,它的 validateResponse:data:error: 方法检查:

  • HTTP 状态码是否在 acceptableStatusCodes(默认 200~299)范围内。
  • 响应的 Content-Type 是否在 acceptableContentTypes 集合中。

如果验证失败,生成对应的 NSErrorAFURLResponseSerializationErrorDomain),并将响应数据放入 error.userInfo[AFNetworkingOperationFailingURLResponseDataErrorKey] 中,方便调试。

7.4.2 AFJSONResponseSerializer

使用 NSJSONSerialization 将 Data 解析为字典/数组。支持自动移除 JSON 中的 NSNull 值(removesKeysWithNullValues 属性)。

7.4.3 AFImageResponseSerializer

将 Data 解码为 UIImage。支持自动解压(inflate)——在子线程强制解码图片位图,避免在主线程首次渲染时的解码开销(与 SDWebImage 的思路一致)。

7.5 安全策略(AFSecurityPolicy)

7.5.1 三种验证模式

模式 说明 安全级别
AFSSLPinningModeNone 使用系统默认的证书链验证
AFSSLPinningModeCertificate 将服务器证书与 App 内预埋的证书进行完整比对 最高
AFSSLPinningModePublicKey 只比对证书中的公钥(Public Key) 高(推荐)

7.5.2 证书验证流程

  1. 获取服务器返回的证书链(SecTrustRef)。
  2. 设置锚点证书(Anchor Certificates)为 App 预埋的证书。
  3. 调用 SecTrustEvaluateWithError() 进行系统级证书链验证。
  4. 根据 Pinning Mode:
    • Certificate Mode:逐一比对证书的 DER 编码数据。
    • PublicKey Mode:提取证书的公钥数据进行比对。
  5. validatesDomainName:是否验证证书中的域名与请求域名匹配。

7.5.3 公钥固定的优势

比证书固定更灵活——即使服务器更换了证书(只要使用相同的密钥对),App 无需更新。

7.6 网络可达性(AFNetworkReachabilityManager)

基于 SCNetworkReachability(SystemConfiguration 框架),监听网络状态变化。

核心流程:

  1. 使用 SCNetworkReachabilityCreateWithAddressSCNetworkReachabilityCreateWithName 创建 reachability 引用。
  2. 设置回调函数,当网络状态变化时触发。
  3. 将 reachability 引用加入 RunLoop(CFRunLoopGetMain())以持续监听。
  4. 回调中解析 SCNetworkReachabilityFlags,判断:
    • 是否可达(kSCNetworkReachabilityFlagsReachable)。
    • 是否通过 WWAN(kSCNetworkReachabilityFlagsIsWWAN)。

注意:SCNetworkReachability 检测的是"是否有网络路径",不是"是否能真正连通互联网"。飞行模式能检测到,但连上 WiFi 但无法上网的情况检测不到。

7.7 与 Alamofire 的对比

Alamofire 是 AFNetworking 作者在 Swift 生态下的重写,核心思想一致但做了现代化改进:

对比维度 AFNetworking Alamofire
语言 Objective-C Swift
并发模型 GCD + NSOperationQueue Swift Concurrency (async/await)
请求构建 Mutable URL Request 链式调用 + Request 协议
响应处理 Block 回调 Result + async/await
拦截器 需自行封装 内置 RequestInterceptor 协议
重试 需自行实现 内置 RetryPolicy

8. SDWebImage

8.1 整体架构

SDWebImage 是 iOS 上最广泛使用的图片加载和缓存库。其核心设计哲学是将复杂的图片加载流程封装为简洁的 API(如 sd_setImageWithURL:),同时提供高度可定制的扩展点。

架构分层:

┌──────────────────────────────────────────────────┐
│              UIView+WebCache                     │  ← 最上层:UIKit 扩展
│  (UIImageView / UIButton 的分类方法)              │
├──────────────────────────────────────────────────┤
│              SDWebImageManager                   │  ← 核心调度器
│  (协调缓存查找和网络下载)                          │
├──────────────┬───────────────────────────────────┤
│ SDImageCache │  SDWebImageDownloader             │  ← 缓存 / 下载
│ (内存+磁盘)   │  (网络下载管理)                    │
├──────────────┴───────────────────────────────────┤
│ SDWebImageDownloaderOperation                    │  ← 下载操作
│ (基于 NSURLSessionDataTask 的下载单元)             │
├──────────────────────────────────────────────────┤
│ SDImageCoder / SDImageTransformer                │  ← 编解码 / 变换
│ (PNG/JPEG/GIF/WebP/HEIF 编解码, 圆角/缩放等)      │
└──────────────────────────────────────────────────┘

8.2 加载流程全景

当调用 [imageView sd_setImageWithURL:url] 时,完整的执行流程:

Step 1:取消旧任务 取消该 UIImageView 上一次尚未完成的图片加载任务(通过关联对象存储的 operation key)。这避免了 Cell 复用场景下的图片错乱问题。

Step 2:设置占位图 如果提供了 placeholder,立即在主线程设置占位图。

Step 3:查询缓存 SDWebImageManager 调用 SDImageCache 查询缓存:

  • 内存缓存查询SDMemoryCache(基于 NSCache)中以 URL 的 MD5/SHA256 哈希为 key 查找。命中则直接返回。
  • 磁盘缓存查询:如果内存未命中,在串行 I/O 队列ioQueue)中异步查询磁盘缓存。磁盘缓存使用文件存储,文件名为 URL 的 MD5 哈希值。查询过程包括:
    1. 检查文件是否存在(fileExistsAtPath:)。
    2. 读取文件数据。
    3. 对图片进行解码(从 PNG/JPEG 数据解码为位图)。
    4. 将解码后的图片写入内存缓存(回填)。

Step 4:网络下载 如果缓存完全未命中(或设置了 SDWebImageRefreshCached 选项),启动网络下载:

  • SDWebImageDownloader 创建或复用一个 SDWebImageDownloaderOperation
  • 同一个 URL 的多次请求会被合并(Coalescing)——只发一次网络请求,结果回调给所有等待者。这通过 URLOperations 字典(以 URL 为 key)实现。
  • 下载操作基于 NSURLSessionDataTask

Step 5:图片处理 下载完成后:

  1. 在子线程进行图片解码(Decode)。
  2. 如果设置了 SDImageTransformer(如圆角、缩放、高斯模糊),在子线程执行变换。
  3. 将处理后的图片同时写入内存缓存和磁盘缓存。

Step 6:回调主线程 在主线程设置 imageView.image,触发 UI 更新。支持渐变动画(SDWebImageTransition)。

8.3 缓存机制深入解析

8.3.1 内存缓存(SDMemoryCache)

继承自 NSCache,具备以下特性:

  • 自动淘汰:当系统内存紧张时,NSCache 会自动释放对象。开发者可以设置 countLimit(最大数量)和 totalCostLimit(最大总开销,以图片像素数为 cost)。
  • 线程安全NSCache 内部使用锁保护,可以在任意线程安全访问。
  • 弱引用表(mapTable):SDWebImage 额外维护了一个 NSMapTable(weakToStrongObjects),当 NSCache 因内存压力淘汰了某张图片时,如果该图片仍被某个 UIImageView 持有(强引用),通过 mapTable 仍然可以找到它,避免不必要的重新解码/下载。

8.3.2 磁盘缓存(SDDiskCache)

  • 存储格式:原始的图片数据(未解码的 PNG/JPEG/WebP 数据),不是解码后的位图。这大幅减少了磁盘空间占用。
  • 文件命名:URL 的 MD5 哈希值作为文件名,避免特殊字符问题。
  • 过期策略:默认缓存保留 1 周maxDiskAge = 60 * 60 * 24 * 7)。
  • 容量限制:可设置 maxDiskSize(最大磁盘缓存大小),超限时按最近最久未使用(LRU) 策略淘汰——根据文件的 NSFileModificationDate(修改日期)排序,优先删除最旧的文件,直到缓存大小降至限制的一半。
  • 清理时机
    • App 进入后台时(UIApplicationDidEnterBackgroundNotification)触发异步清理。
    • App 终止时(UIApplicationWillTerminateNotification)触发清理。
    • 开发者手动调用 clearDiskOnCompletion:

8.3.3 缓存 Key 的计算

默认使用完整的 URL 字符串作为缓存 key。开发者可以通过 SDWebImageManagercacheKeyFilter block 自定义 key 生成逻辑(例如去除 URL 中的 token 参数,使相同内容的不同签名 URL 共享缓存)。

如果使用了 SDImageTransformer,变换后的图片使用 originalKey + transformerKey 作为缓存 key,与原图分开缓存。

8.4 图片解码机制

8.4.1 为什么需要预解码

UIImageimageWithData: 创建的图片是未解码的——它只是持有压缩的图片数据。只有在图片首次被渲染到屏幕上时(CALayerdisplay 方法中),Core Animation 才会调用解码器将其解码为位图。这个解码发生在主线程,可能导致掉帧。

SDWebImage 的策略是在子线程提前解码(Force Decode / Decompressing),将位图缓存到内存中,主线程直接使用解码后的位图,消除主线程解码开销。

8.4.2 解码实现

解码的核心步骤:

  1. 创建 CGBitmapContext(位图上下文),指定颜色空间、每像素字节数、Alpha 通道信息。
  2. 使用 CGContextDrawImageCGImageRef 绘制到上下文中——这一步触发实际的解码。
  3. 从上下文中获取解码后的 CGImageRef,创建新的 UIImage

内存占用计算:一张 1000×1000 的图片解码后占用 1000 × 1000 × 4 bytes = 4MB(RGBA 格式,每像素 4 字节)。因此,SDWebImage 提供了 SDImageCoderDecodeScaleDownLimitBytes 选项,对超大图片进行降采样后再解码,避免内存暴涨。

8.4.3 渐进式解码(Progressive Decoding)

对于 JPEG 等支持渐进式加载的格式,SDWebImage 可以在下载过程中边下载边解码。每接收一段数据就解码一次,UI 上展示从模糊到清晰的渐进效果。

通过 SDImageCoderProgressiveCoder 协议实现,每次调用 updateIncrementalData:finished: 更新数据并产生部分解码的图片。

8.4.4 编解码器架构(SDImageCoder)

SDWebImage 5.x 使用了协议化的编解码器架构:

  • SDImageCoder 协议定义了 canDecodeFromData:decodedImageWithData:encodedDataWithImage: 等方法。
  • 内置编解码器:SDImageIOCoder(PNG/JPEG/TIFF/GIF 静图)、SDImageGIFCoder(GIF 动图)、SDImageAPNGCoder(APNG)。
  • 可扩展:通过 SDImageCodersManager 注册自定义编解码器,如 SDImageWebPCoder(WebP 支持)、SDImageHEICCoder(HEIC 支持)。
  • 解码器按注册的逆序遍历(后注册的优先),调用 canDecodeFromData: 判断哪个解码器能处理当前数据格式。

8.5 下载机制深入

8.5.1 SDWebImageDownloader

  • 维护一个 NSOperationQueuedownloadQueue),控制最大并发下载数(默认 6)。
  • 支持 LIFO(后进先出)和 FIFO(先进先出)两种执行顺序。LIFO 适合瀑布流场景——用户快速滑动时,最新可见的 Cell 的图片优先下载。通过设置 Operation 之间的依赖关系实现 LIFO。
  • 支持 HTTP Header 自定义、认证(URLCredential)、超时配置等。

8.5.2 SDWebImageDownloaderOperation

继承自 NSOperation,内部封装了一个 NSURLSessionDataTask

关键设计:

  • 回调合并:使用 callbackBlocks 数组存储所有对同一 URL 的下载回调。当下载完成时,遍历数组逐一回调。
  • 后台下载:支持 App 进入后台后继续下载(通过 UIApplication.beginBackgroundTaskWithExpirationHandler:)。
  • 响应数据拼接:在 URLSession:dataTask:didReceiveData: 中将数据追加到 NSMutableDataimageData),下载完成后一次性交给解码器。
  • 取消机制:调用 cancel 时取消 NSURLSessionDataTask,从 callbackBlocks 中移除对应的回调。如果所有回调都被移除,则取消整个下载任务。

8.5.3 URL 请求去重(Coalescing)

SDWebImageDownloader 维护一个 URLOperations 字典(以 URL 为 key,以 SDWebImageDownloaderOperation 为 value)。当新请求到来时:

  • 如果该 URL 已有进行中的下载操作,直接将新的回调添加到现有 Operation 的 callbackBlocks 中,不创建新的网络请求。
  • 如果没有,创建新的 Operation 并加入队列。

这种设计在列表场景下极为高效——同一张头像被多个 Cell 引用时,只会发起一次网络请求。

8.6 UIView+WebCache 的设计

通过 ObjC Runtime 的关联对象机制,为 UIImageView 等视图绑定当前的加载操作。

核心流程:

  1. 调用 sd_setImageWithURL: 时,先通过 sd_cancelCurrentImageLoad 取消当前关联的旧操作。
  2. 使用 objc_setAssociatedObject 将新的 SDWebImageCombinedOperation 关联到视图上。
  3. 加载完成或 Cell 复用时,通过 objc_getAssociatedObject 获取并取消/检查操作状态。

这解决了经典的 Cell 复用导致图片错乱问题:当 Cell 被复用时,旧 Cell 的下载完成回调中设置的图片会被忽略(因为旧操作已被取消)。

8.7 动图支持

8.7.1 GIF / APNG

SDWebImage 使用 SDAnimatedImageView(继承自 UIImageView)播放动图。其内部实现:

  • 使用 CADisplayLink 驱动动画帧切换。
  • 按需解码:不一次性解码所有帧(一个 GIF 可能有数百帧,全部解码会占用大量内存),而是维护一个帧缓存(NSMutableDictionary),预解码当前帧附近的若干帧(预取缓冲区),按需释放远离当前播放位置的帧。
  • 帧缓冲区大小根据可用内存动态调整。

8.7.2 WebP / HEIF

通过可插拔的编解码器支持:

  • SDImageWebPCoder:使用 libwebp 库进行 WebP 编解码。
  • SDImageHEICCoder:使用系统 ImageIO 框架进行 HEIF 编解码(iOS 11+)。

8.8 性能优化细节

  • 异步 I/O:磁盘缓存的所有读写操作都在专用的串行 ioQueue 上异步执行,不阻塞主线程。
  • 解码降采样:对于超大图片(如 4000×3000 的相机照片),先使用 CGImageSourceCreateThumbnailAtIndex 进行降采样到目标显示尺寸,再解码。这比先解码再缩放效率高得多——直接操作压缩数据,内存峰值大幅降低。
  • 内存警告响应:监听 UIApplicationDidReceiveMemoryWarningNotification,立即清空内存缓存(NSCacheremoveAllObjects)。
  • URL 黑名单:对于下载失败的 URL(非超时错误),加入 failedURLs 集合,短期内不再重试,避免无效请求浪费资源(可通过 SDWebImageRetryFailed 选项关闭此行为)。
  • Prefetch(预加载)SDWebImagePrefetcher 支持批量预加载图片到缓存中,适用于已知用户即将浏览的内容(如下一页的列表数据)。

8.9 SDWebImage 5.x 的架构升级

SDWebImage 5.x 相比 4.x 做了大量架构优化:

特性 4.x 5.x
编解码 硬编码在内部 协议化(SDImageCoder)
缓存 固定实现 协议化(SDImageCache Protocol)
下载 固定实现 协议化(SDImageLoader Protocol)
变换 需第三方库 内置 SDImageTransformer
动图 FLAnimatedImage 依赖 内置 SDAnimatedImage
指标 SDImageLoadIndicator

协议化设计使得每个组件都可以被替换为自定义实现,极大提升了灵活性。


总结

上述八个知识点构成了 iOS 开发中性能优化与底层原理的核心体系:

  • 启动流程启动优化帮助我们理解 App 从点击图标到用户可见的完整链路,并从 pre-main 和 post-main 两个阶段系统性地优化启动速度。
  • 网络优化覆盖了从 DNS 到数据传输、从连接管理到弱网对抗的全链路优化策略。
  • RunLoop 是 iOS 事件驱动模型的基石,理解它才能理解触摸事件、Timer、UI 刷新等核心机制的运作方式。
  • Runtime 是 Objective-C 动态性的根基,消息发送、方法缓存、消息转发、KVO、Category 等特性都建立在它之上。
  • 卡顿监控将 RunLoop 和性能分析结合,提供了从检测到治理的完整方案。
  • AFNetworkingSDWebImage 作为两个最经典的第三方库,它们的架构设计、线程安全策略、性能优化思路值得深入学习和借鉴。

移动端开发稳了?AI 目前还无法取代客户端开发,小红书的论文告诉你数据

2026年3月2日 16:09

近期,由小红书联合多伦多大学等高校的研究人员发布了 《SWE-Bench Mobile》(2602.09540) 论文,内容主要是评估 LLM 智能体在处理真实生产级移动端应用开发任务时的能力,并提出了首个针对该领域的基准测试——SWE-Bench Mobile

这个论文对比之前那些简单的需求场景,明显更具备说服力,最重要的是,用真实的数据给目前的 AI 狂热浇一浇冷水

目前的编程基准测试大多集中在孤立的算法问题,而 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 个文件,远超之前的基准测试

整个基准的规则是:

  • 70% 任务包含 Figma
  • 92% 包含参考图片
  • 平均 PRD 长度 450 字

每个任务包含:

  • 一个统一 diff 补丁(patch)输出
  • 综合测试套件(平均 9.1 个测试案例)
  • 任务难度分级:从简单 UI 调整到复杂跨模块改造

对于任务两个关键指标:

  • 任务成功率:所有测试通过的任务比例
  • 测试通过率:所有测试案例通过的比率

而对于 LLM,论文评估了 22 种 不同的“智能体-模型”配置,涵盖了四个主流框架:

  • 商业智能体:Cursor、Codex (由 DeepSeek/OpenAI 等模型驱动)、Claude Code
  • 开源智能体:OpenCode

评估维度包括:任务完成率、任务复杂度影响、成本效果对比、多次运行稳定性、Prompt 设计影响等。

而根据论文可以得出结论:当前 AI 在生产级的软件工程力存在巨大局限性:

  • 成功率极低表现最好配置的成功率仅为 12% ,大多数任务以“实现不完整”告终,但测试通过率最高可到 28%,说明部分任务可以部分正确生成,但没能完全部署成功
  • 智能体架构十分重要 :同一个底层模型,在 Cursor 框架下的成功率为 12%,但在 OpenCode 下仅为 2%,智能体的工具调用、上下文管理等设计与模型本身同等重要
  • 商业模型占优:商业闭源智能体在处理大型代码库时的稳定性和正确性显著优于开源方案
  • 复杂度陷阱任务涉及 1-2 个文件时成功率为 18%,但当涉及 7 个以上文件时,成功率骤降至 2% ,显示出模型在跨文件长程推理方面的短板
  • “防御性编程”提示词更有效:研究发现,使用基于“防御性编程”(原则的简洁提示词,比复杂的提示词能让成功率提升 7.4%

对于失败,论文还针对失败类型归类:

  • 缺失关键功能标志位或 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

对此可以看出来:

  • 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%

这么看,OpenCode 的实际数据表现是真的一般。

这个在同一个模型,在不同 agent 上的成功率也有所体现,OpenCode 再一次被鞭尸:

所以,可以看出来,目前的 AI 智能体离独立完成中大型移动开发还有很大距离,主要瓶颈在于多模态理解、大规模代码导航和跨文件逻辑一致性等。

另外,SWE-Bench Mobile 采用了托管基准挑战(Hosted Benchmark)模式 ,不公开测试集答案,以防止数据泄露到未来的模型训练中。

最后,论文只针对原生 iOS 开发进行测试,没有测试 Android 原生、Flutter、RN 等其他情况,按照一般直觉,这些框架的 AI 表现应该会好于 iOS 原生,当然这也只是我的个人直觉,真实数据还是得有企业做过 Benchmark 才知道。

不过至少从目前看,在移动端开发领域写代码上,至少比前端安全性高一些?你怎么看?

Flutter调试组件:打印任意组件尺寸位置信息 NRenderBox

作者 SoaringHeart
2026年2月28日 22:20

一、需求来源

当页面元素特别多,比较杂,又必须获取某个组件尺寸位置时,一个个加 GlobalKey 有太麻烦,这是使用一个封装好的组件就特别有用了。然后就有了 NRenderBox 组件,可以打印出子组件的位置及尺寸。

二、使用

NRenderBox(
  child: Container(
    padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
    decoration: BoxDecoration(
      color: Colors.transparent,
      border: Border.all(color: Colors.blue),
      borderRadius: BorderRadius.all(Radius.circular(0)),
    ),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        NNetworkImage(
          width: 50,
          height: 60,
          url: AppRes.image.urls.random ?? '',
        ),
        Text("选项"),
      ],
    ),
  ),
)
flutter: NRenderBox rect: Rect.fromLTRB(88.5, 322.0, 157.5, 413.0)

三、NRenderBox源码

import 'package:flutter/material.dart';

/// 点击打印尺寸
class NRenderBox extends StatefulWidget {
  const NRenderBox({
    super.key,
    required this.child,
  });

  final Widget child;

  @override
  State<NRenderBox> createState() => _NRenderBoxState();
}

class _NRenderBoxState extends State<NRenderBox> {
  final renderKey = GlobalKey();

  RenderBox? get renderBox {
    final ctx = renderKey.currentContext;
    if (ctx == null) {
      return null;
    }
    final box = ctx.findRenderObject() as RenderBox?;
    return box;
  }

  Offset? get renderPosition {
    return renderBox?.localToGlobal(Offset.zero);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      key: renderKey,
      onTap: () {
        if (renderBox == null) {
          return;
        }
        final position = renderPosition;
        final size = renderBox!.size;
        final rect = Rect.fromLTWH(position!.dx, position.dy, size.width, size.height);
        debugPrint("$widget rect: $rect");
      },
      child: widget.child,
    );
  }
}

github

iOS相机权限获取

作者 傅里叶
2026年2月27日 11:25
语言:Flutter
问题:获取相机弹窗的权限不出来,iOS系统设置里面对应app也没有相机选项。是宏没有打开
解决方法:打开宏

Podfile

# post_install do |installer|
#   installer.pods_project.targets.each do |target|
#     flutter_additional_ios_build_settings(target)
#     # 为所有 Pod 目标启用 dSYM 生成
#     target.build_configurations.each do |config|
#       if config.name == 'Release'
#         config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
#       end
#     end
#   end
# end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)

    target.build_configurations.each do |config|

      if config.name == 'Release'
        config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
      end

      #  permission_handler 必须的宏配置
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
        '$(inherited)',

        'PERMISSION_CAMERA=1',

      ]

    end
  end
end

这段 Podfile 的 post_install 配置是正确的


一、post_install 是什么 🔧

post_install do |installer|

意思是:

CocoaPods 安装完成后执行这段脚本。

也就是在执行:

pod install

之后运行。

用途:

  • 修改 iOS 编译参数
  • 给插件加配置
  • 修复 Flutter / iOS 插件问题

Flutter 权限插件基本都用这个。


二、installer.pods_project.targets.each do |target|

installer.pods_project.targets.each do |target|

意思是:

遍历所有 Pod 插件。

例如:

  • permission_handler
  • mobile_scanner
  • ffmpeg_kit
  • camera
  • etc

这段代码会对 所有插件生效

相当于:

给所有插件统一设置编译参数。


三、flutter_additional_ios_build_settings(target)

flutter_additional_ios_build_settings(target)

意思:

Flutter 自动生成的 iOS配置。

它会自动设置:

  • Swift版本
  • 架构支持
  • Bitcode
  • iOS版本兼容性

这是 Flutter 官方必须的一行。

不能删。


四、target.build_configurations.each do |config|

target.build_configurations.each do |config|

意思:

遍历 Debug / Release 编译模式。

iOS有三种模式:

  • Debug(开发)
  • Profile(性能分析)
  • Release(发布)

这段代码:

对每个模式分别设置参数。


五、DEBUG_INFORMATION_FORMAT = dwarf-with-dsym

if config.name == 'Release'
  config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
end

意思:

Release版本生成 dSYM 文件。

作用:

  • 崩溃日志解析
  • Crashlytics
  • TestFlight 崩溃分析

否则:

  • 崩溃日志看不懂

这是一个好习惯配置 👍

和权限无关。


六、最重要的一段(权限宏)⭐

config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [  '$(inherited)',  'PERMISSION_CAMERA=1',]

这是关键。

意思:

给 iOS 编译器设置宏定义。

等价于:

#define PERMISSION_CAMERA 1

告诉 permission_handler:

编译相机权限代码。


七、$(inherited) 是什么

'$(inherited)',

意思:

保留系统原来的设置。

如果不写:

可能破坏 Flutter 默认配置。

必须保留。

相当于:

在原来的基础上增加权限宏。


八、PERMISSION_CAMERA=1 是什么 ⭐

'PERMISSION_CAMERA=1',

意思:

打开相机权限模块。

permission_handler 内部代码类似:

#if PERMISSION_CAMERA
// 相机权限代码
#endif

如果没有:

#if PERMISSION_CAMERA

条件不成立。

相机代码不会编译进去。

结果就是:

  • request() 不弹窗
  • denied
  • 设置里没有权限

之前就是这个问题。


九、整体逻辑总结(核心理解)

配置现在等价于:

安装所有插件
Flutter配置生效
Release版本生成崩溃符号
开启相机权限模块

流程:

pod install
   ↓
执行 post_install
   ↓
给所有插件加 PERMISSION_CAMERA=1
   ↓
Xcode 编译
   ↓
permission_handler 包含相机权限代码
   ↓
request() 正常弹窗

十、建议升级为完整权限配置(推荐)🔥

建议改成:

config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [  '$(inherited)',  'PERMISSION_CAMERA=1',  'PERMISSION_PHOTOS=1',  'PERMISSION_MICROPHONE=1',]

原因:

以后大概率会用:

  • 相册
  • 麦克风
  • 视频

否则未来又会踩坑。


十一、最关键结论

现在这段:

'PERMISSION_CAMERA=1',

就是让:

Permission.camera.request();

真正生效的原因。

没有它:

iOS 会表现为:

  • 不弹窗
  • denied
  • 设置没有权限

flutter存储知识点总结

作者 zhangkai
2026年2月26日 16:36

一、数据存储

1、本地持久化存储SharedPreferences

SharedPreferences为轻量级存储,存储少量简单数据,键值对形式,不适合大量、复杂数据的存储。

import 'package:shared_preferences/shared_preferences.dart'; 
// 存储数据 
Future<void> saveData() async { 
// 获取 SharedPreferences 实例 
final prefs = await SharedPreferences.getInstance(); 
// 存储不同类型的数据 await prefs.setString('user_token', 'abc123456'); // 字符串
await prefs.setInt('user_age', 25); // 整数 a
wait prefs.setBool('is_login', true); // 布尔值 
await prefs.setDouble('height', 1.75); // 浮点数 
await prefs.setStringList('hobbies', ['读书', '运动']); // 字符串列表 } 

// 读取数据 Future<void> readData() async { 
final prefs = await SharedPreferences.getInstance(); 
// 读取数据(第二个参数是默认值,避免 null) 
String? token = prefs.getString('user_token') ?? ''; 
int age = prefs.getInt('user_age') ?? 0;
bool isLogin = prefs.getBool('is_login') ?? false; 
print('token: $token, age: $age, isLogin: $isLogin'); 
} 
// 删除数据 
Future<void> removeData() async { 
final prefs = await SharedPreferences.getInstance(); await prefs.remove('user_token'); 
// 删除单个键 
// await prefs.clear(); 
// 清空所有数据 }

2、Provider存储

Provider 是运行时的内存状态管理工具非本地持久化存储,Provider 存储的数据只在 App 运行时有效,重启后丢失。它的核心价值是让数据在多个 Widget 之间共享、响应式更新,是 “内存级” 的数据存储与共享方案。适用于需要跨组件共享、实时响应更新的运行时数据。 Provider的原理是基于 Flutter 原生的 InheritedWidget 实现的,而 InheritedWidget 的核心特性就是通过 Context 向上查找共享数据

  • 从 Provider 中读取 / 监听数据:必须依赖 context(因为要确定查找的上下文范围);

  • 从 Provider 中修改数据:通常也需要 context,但有替代方案(无需 Context);

  • 初始化 / 注入 Provider:不需要 context(在根节点创建时)。

2.1 定义数据模型
import 'package:flutter/foundation.dart';

class ContractDataModel extends ChangeNotifier {
  // ========== 核心数据字段 (仅保留3个示例) ==========

  // 1. 房源类型名称 (来自第一步)
  String _goodsTypeName = '';

  // 2. 租客姓名 (来自第二步)
  String _customerName = '';

  // 3. 租金单价 (来自第三步)
  String _unitPrice = '';

  // ========== Getters ==========
  String get goodsTypeName => _goodsTypeName;
  String get customerName => _customerName;
  String get unitPrice => _unitPrice;

  // ========== 统一更新方法 ==========
  /// 更新核心数据
  /// 只要传入的值不为 null 就更新,允许空字符串覆盖原有数据
  void updateCoreInfo({
    String? goodsTypeName,
    String? customerName,
    String? unitPrice,
  }) {
    if (goodsTypeName != null) _goodsTypeName = goodsTypeName;
    if (customerName != null) _customerName = customerName;
    if (unitPrice != null) _unitPrice = unitPrice;

    // 通知监听者重建 UI
    notifyListeners();
  }

  // ========== 验证数据是否完整 (示例) ==========
  bool validateCoreInfo() {
    if (_goodsTypeName.isEmpty) return false;
    if (_customerName.isEmpty) return false;
    if (_unitPrice.isEmpty) return false;
    return true;
  }

  // ========== 获取所有数据的 Map ==========
  Map<String, dynamic> toMap() {
    return {
      'goodsTypeName': _goodsTypeName,
      'customerName': _customerName,
      'unitPrice': _unitPrice,
    };
  }

  // ========== 清空所有数据 ==========
  void clear() {
    _goodsTypeName = '';
    _customerName = '';
    _unitPrice = '';

    notifyListeners();
  }
}
2.2 更新provider中的数据
final contractModel = Provider.of<ContractDataModel>(context, listen: false);
contractModel.updatePersonInfo(
  customerCertificateId: _customerCertificateId,
  customerName: _customerName,
);
2.3 获取provider,提取内部的数据
late _contractDataModel = Provider.of<ContractDataModel>(context, listen: false);
if(_contractDataModel.signClientType == '1'){//企业
  _customerCertificateType = 'Z';
}else{
  _customerCertificateType = '';
}

tips: 使用late的作用是什么? late 是用来修饰延迟初始化变量的关键字。

  • late用来声明变量时无法立即赋值的问题,Provider依赖context,而 context 通常在 Widget 的 build 方法、initState的位置才能获取,声明无法获取从而报错。
  • 允许变量非空但延迟复制。(Dart空安全核心)Dart 开启空安全后,未用 ? 标记的变量必须声明时赋值或用 late 修饰。

image.png

二、Provider 流程页面剖析

image.png

应该这样理解:

✅ Provider 是在 CreateReserveStepPage 中创建的(第52行)

✅ 三个 Step Widget 各自有自己独立的 context

✅ 但它们都是 ChangeNotifierProvider 的子孙节点

✅ 所以它们都能通过各自的 context 向上查找,找到同一个 Provider 实例

或者说:

✅ 三个 Widget 的 context 都能访问到在 CreateReserveStepPage 中创建的 Provider,因为它们都在 Provider 的子树中。

如下:

class CreateReserveStepPage extends StatefulWidget {
final Map params;
const CreateReserveStepPage({Key key,this.params}) : super(key: key);
@override
_CreateReserveStepPage createState() => _CreateReserveStepPage();
}

class _CreateReserveStepPage extends State<CreateReserveStepPage> {
final BrnMetaHorizontalStepsManager _stepsManager = BrnMetaHorizontalStepsManager();
int _currentIndex = 0;
bool _isCompleted = false;
Timer _timer;
int _elapsed = 0;
Map<String, dynamic> _contractParams = {};
final List<String> _stepTitles = ['合同信息', '租客/入住人信息', '账单/补充信息'];
// 验证回调函数
Function _validateContractWidget;
Function _validatePersonWidget;
Function _validateBillWidget;
// 保存数据回调函数
Function _savePersonWidget;
Function _saveBillWidget;
// 添加ScrollController
final ScrollController _scrollController = ScrollController();
void initState() {
  super.initState();
}

@override
void dispose() {
  _scrollController.dispose();
  super.dispose();
}

@override
Widget build(BuildContext context) {
  return ChangeNotifierProvider(
    create: (_) => ContractDataModel(),
    builder: (providerContext, child) {
      return Scaffold(
        appBar: BrnAppBar(
          title: '创建签约',
          leading: IconButton(
            icon: Icon(Icons.arrow_back_ios_new, color: Colors.black),
            onPressed: () => BoostUtil.finish(),
          ),
        ),
        body: Column(
          children: [
            _stepsManager.buildSteps(
              steps: _stepTitles.map((title) => BrunoStep(stepContentText: title)).toList(),
              currentIndex: _currentIndex,
              isCompleted: _isCompleted,
            ),
            const SizedBox(height: 24),
            Expanded(
              child: SingleChildScrollView(
                controller: _scrollController,
                child: _buildStepContent(_currentIndex),
              ),
            ),
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
              decoration: BoxDecoration(
                color: Colors.white,
                border: Border(top: BorderSide(color: Color(0xFFF0F0F0), width: 1)),
              ),
              child: _buildBottomButtons(providerContext),
            ),
          ],
        ),
      );
    },
  );
}

Widget _buildStepContent(int index) {
  switch (index) {
    case 0:
      return ReservationStepContractWidget(
        params: this.widget.params,
        onDataChanged: _handleContractDataChanged,
        onValidateCallback: (validateFunc) {
          _validateContractWidget = validateFunc;
        },
      );
    case 1:
      return ReservationStepPersonWidget(
        onDataChanged: _handlePersonDataChanged,
        onValidateCallback: (validateFunc) {
          _validatePersonWidget = validateFunc;
        },
        onSaveCallback: (saveFunc) {
          _savePersonWidget = saveFunc;
        },
      );
    case 2:
      return ReservationStepBillWidget(
        onDataChanged: _handleBillDataChanged,
        onValidateCallback: (validateFunc) {
          _validateBillWidget = validateFunc;
        },
        onSaveCallback: (saveFunc) {
          _saveBillWidget = saveFunc;
        },
      );
    default:
      return SizedBox();
  }
}

iOS + AI ,国外一个叫 Rork Max 的项目打算替换掉 Xcode

2026年2月21日 17:34

最近看到一个很有意思的项目,它是一个由国外 Rork 团队推出的 AI 移动应用开发平台,宣称是“全球首个在浏览器中构建原生 Swift 应用的 AI 工具”,也就是,你可以不需要 Mac 和 Xcode ,同时一次性完成 iPhone、手表、iPad、电视和 Vision Pro 的应用,甚至还有 AR 和 3D 支持。

所以它的产品逻辑是:用户只需在浏览器中输入自然语言描述,AI 就会自动生成 SwiftUI 代码,然后编译并在云端模拟器中运行,最后支持一键发布到 App Store。

什么 swift 版本 uniapp ?

听起来有点玄乎,但是实际上其实就是 Rork 在后端部署了大量的物理 Mac 节点或 Mac 云实例,当你开始一个项目时,系统就会动态分配一台运行着 XcodeiOS SDK 的 Mac 给对应会话。

也就是所有的编译、链接、资产打包过程都在真实的 macOS 环境下完成,生成的是 100% 的原生 Swift/SwiftUI 代码。

所以实际上就是:Cloud 版本的 Xcode/Mac ,然后搭配 Claude Code 和 Opus 4.6 ,然后生成对应的 iOS App 并提交 Apple Store 审核。

而 Rork 在这里也是采用了类似于云游戏的实时视频流协议(低延迟传输),所以你在浏览器里的每一次点击都会传回云端 Mac 的模拟器,画面变化再实时推送到前端

实际上就是一个远程主机,本质和 AI Studio 类似。

当然,Rork Max 的核心肯定还是他们的 Agent 管理和产品流程,这里的 AI Agent 除了利用 Opus 4.6 写代码之外,还要管理它的所有报错,测试运行和工程管理,同时 Rork 内置了 App Store Connect 的自动化流程,用户登录 Apple ID 后,AI 可以代理证书配置、App 打包和提审等流程。

从这里看,Rork Max 的客户更多的可能是非开发者,所以它的目标是将复杂的工程基座(Mac 硬件 + Xcode SDK + 苹果证书体系)完全抽象化,让开发者只需要关注逻辑和创意

另外,这里 Rork 自己强调了“非模版化”。它不是通过预设模版拼凑应用,而是通过大模型实时推理,通过自己实现的“持续上下文注入”的技术,让 AI 记住你之前所有对 UI 的微调,确保跨平台迁移时风格的一致性。

实际上它更多是一个从零构建、测试、安装并上架的 Apple 体系生产平台。它直接把“idea → 上架 App Store 的原生 Swift 应用”压缩成一个网页操作,从而大幅度降低了门槛。

官方演示视频中,从零到可玩的游戏原型大概 30–60 分钟:

另外 Rork 也表示后续会支持直接导入老项目的功能,不过对于这种场景,基本都是已经有开发者维护的项目场景,我比较怀疑是否会有受众,虽然貌似真的有:

目前已经有一些 Rork max 用户开始体验,反馈褒贬不一,但是我是没真实体验的,因为 Rork Max 的价格还是挺感人:

为什么不体验其他的?因为我看到所有说不好用的回复里,官方都是问:你是否打开了 Rork Max

当然,觉得它有意思的原因,也是它这个产品形态或者是未来的代表之一,开发者不再需要装什么 IDE 或者 SDK ,甚至都不需要纠结是 win 还是 mac 甚至 linux ,只需要一个入口,就可以完成需要开发,当然,那时候如果真的到来的话,也许开发者也不是开发者了,可能更多只是 token 账单的消费者。

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

作者 亮哥666
2026年2月15日 17:03

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


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

需要配置哪些环境变量?

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

为什么要配置镜像?

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

常用的中国镜像:

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

我的具体配置

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

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

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

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

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

配置完成后

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

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

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

踩坑记录

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

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

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

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

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

不配置会怎样?

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

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

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

PATH 之外的环境变量

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

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

三、配置文件的区别

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

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

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

配置完后让其生效:

source ~/.zshrc

四、什么是 Shell?

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

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


五、Bash 和 Zsh 是什么?

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

继承关系

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

六、macOS 默认用哪个 Shell?

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

查看当前 Shell:

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

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

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

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

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

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

最终苹果的做法:

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

七、Oh-My-Zsh 是什么?

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

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

八、Zsh 命名趣事

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

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

❌
❌