普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月25日掘金 iOS

React Native 通信机制详解 - 新架构

作者 Tamarous
2025年11月25日 10:39

React Native 通信机制详解 - 新架构

一、概述

本篇是 React Native 通信机制详解的第二篇文章,主要介绍 React Native 新架构下的通信机制。如果想要了解旧架构下的通信机制,可以参考上一篇文章。

二、示例

我们仍然从前文末展示的新架构下注册一个原生模块的示例开始。如果你有兴趣自己动手实践下整个过程,可以参考 React Native 的官方文档

1.定义模块规范(TypeScript)

首先,我们需要使用 TypeScript 定义模块的接口规范。通过继承 TurboModule 接口,我们可以声明模块的方法签名,包括参数类型和返回值类型。

// CalcModule.ts
import type { TurboModule } from 'react-native/Libraries/TurboModule/RCTExport';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  add(a: number, b: number): Promise<number>;
}

export default TurboModuleRegistry.get<Spec>('CalcModule');

最后将这个文件命名为 NativeCalcModule.ts。文件名中的 Native 前缀是一个约定的命名方式,表示这是一个原生模块,这样 Codegen 工具才能生成对应的原生代码。

2.使用 Codegen 生成接口代码

接下来,我们使用 Codegen 工具将 TypeScript 定义转换为 Objective-C 桥接代码以及 JSI 绑定代码。

.
├── CalcModule
│   ├── CalcModule-generated.mm
│   └── CalcModule.h
├── CalcModuleJSI-generated.cpp
├── CalcModuleJSI.h
├── RCTAppDependencyProvider.h
├── RCTAppDependencyProvider.mm
├── RCTModulesConformingToProtocolsProvider.h
├── RCTModulesConformingToProtocolsProvider.mm
├── RCTThirdPartyComponentsProvider.h
├── RCTThirdPartyComponentsProvider.mm
├── ReactAppDependencyProvider.podspec
└── ReactCodegen.podspec.json

1 directory, 12 files

在生成目录下,CalcModule.hCalcModule-generated.mm是 Objective-C 桥接代码,CalcModuleJSI.hCalcModuleJSI-generated.cpp是 JSI 绑定代码。

3.实现原生模块

最后,我们实现原生模块的具体功能。

@interface CalcModule : NSObject <NativeCalcSpec>
@end

@implementation CalcModule
RCT_EXPORT_MODULE()

- (void)add:(double)a b:(double)b resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
{
  NSNumber *result = @(a + b);
  resolve(result);
}
@end

三、模块注册机制

让我们结合上文中的示例代码生成的内容,来深入了解新架构下模块注册和调用的完整流程。

3.1 Codegen 生成代码

Objc 桥接层

Objective-C 这一层首先定义了一个 NativeCalcSpec 协议,它继承自 RCTBridgeModuleRCTTurboModule。开发者需要自己来实现这个协议,来提供 add 方法的实际实现。其次,定义了一个 NativeCalcSpecBase 类,它是 NativeCalcSpec 协议的基类,用于实现一些公共的方法。最后,定义了一个 NativeCalcSpecJSI 类,它是一个 Objective-C 桥接类,继承自 ObjCTurboModule,用于构建方法映射表并将 C++ 调用转发给 Objective-C 方法。

// CalcModule.h
#ifndef CalcModule_H
#define CalcModule_H

NS_ASSUME_NONNULL_BEGIN

@protocol NativeCalcSpec <RCTBridgeModule, RCTTurboModule>

- (void)add:(double)a
          b:(double)b
    resolve:(RCTPromiseResolveBlock)resolve
     reject:(RCTPromiseRejectBlock)reject;

@end

@interface NativeCalcSpecBase : NSObject {
@protected
facebook::react::EventEmitterCallback _eventEmitterCallback;
}
- (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *)eventEmitterCallbackWrapper;

@end

namespace facebook::react {
  /**
   * ObjC++ class for module 'NativeCalc'
   */
  class JSI_EXPORT NativeCalcSpecJSI : public ObjCTurboModule {
  public:
    NativeCalcSpecJSI(const ObjCTurboModule::InitParams &params);
  };
} // namespace facebook::react

NS_ASSUME_NONNULL_END
#endif // CalcModule_H
// CalcModule-generated.mm
#import "CalcModule.h"

@implementation NativeCalcSpecBase

- (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *)eventEmitterCallbackWrapper
{
  _eventEmitterCallback = std::move(eventEmitterCallbackWrapper->_eventEmitterCallback);
}
@end

namespace facebook::react {
  
  static facebook::jsi::Value __hostFunction_NativeCalcSpecJSI_add(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
    return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, PromiseKind, "add", @selector(add:b:resolve:reject:), args, count);
  }

  NativeCalcSpecJSI::NativeCalcSpecJSI(const ObjCTurboModule::InitParams &params)
    : ObjCTurboModule(params) {
      
        methodMap_["add"] = MethodMetadata {2, __hostFunction_NativeCalcSpecJSI_add};
        
  }
} // namespace facebook::react
JSI 桥接层

JSI 的这一层中主要是定义了两个类: NativeCalcCxxSpecJSINativeCalcCxxSpec。前者是一个 C++ 抽象类,定义了模块方法的 C++ 接口。后者是一个 C++ 模板类,用于创建 Host Object,将 JavaScript 调用转发给原生方法,以及处理参数和返回值的类型转换。

// CalcModuleJSI.h
#include <ReactCommon/TurboModule.h>
#include <react/bridging/Bridging.h>

namespace facebook::react {
  class JSI_EXPORT NativeCalcCxxSpecJSI : public TurboModule {
  protected:
    NativeCalcCxxSpecJSI(std::shared_ptr<CallInvoker> jsInvoker);

  public:
    virtual jsi::Value add(jsi::Runtime &rt, double a, double b) = 0;
  };

  template <typename T>
  class JSI_EXPORT NativeCalcCxxSpec : public TurboModule {
  public:
    jsi::Value create(jsi::Runtime &rt, const jsi::PropNameID &propName) override {
      return delegate_.create(rt, propName);
    }

    std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& runtime) override {
      return delegate_.getPropertyNames(runtime);
    }

    static constexpr std::string_view kModuleName = "CalcModule";

  protected:
    NativeCalcCxxSpec(std::shared_ptr<CallInvoker> jsInvoker)
      : TurboModule(std::string{NativeCalcCxxSpec::kModuleName}, jsInvoker),
        delegate_(reinterpret_cast<T*>(this), jsInvoker) {}


  private:
    class Delegate : public NativeCalcCxxSpecJSI {
    public:
      Delegate(T *instance, std::shared_ptr<CallInvoker> jsInvoker) :
        NativeCalcCxxSpecJSI(std::move(jsInvoker)), instance_(instance) {

      }

      jsi::Value add(jsi::Runtime &rt, double a, double b) override {
        static_assert(
            bridging::getParameterCount(&T::add) == 3,
            "Expected add(...) to have 3 parameters");

        return bridging::callFromJs<jsi::Value>(
            rt, &T::add, jsInvoker_, instance_, std::move(a), std::move(b));
      }

    private:
      friend class NativeCalcCxxSpec;
      T *instance_;
    };

    Delegate delegate_;
  };

} // namespace facebook::react
#include "CalcModuleJSI.h"

namespace facebook::react {

  static jsi::Value __hostFunction_NativeCalcCxxSpecJSI_add(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) {
    return static_cast<NativeCalcCxxSpecJSI *>(&turboModule)->add(
      rt,
      count <= 0 ? throw jsi::JSError(rt, "Expected argument in position 0 to be passed") : args[0].asNumber(),
      count <= 1 ? throw jsi::JSError(rt, "Expected argument in position 1 to be passed") : args[1].asNumber()
    );
  }

  NativeCalcCxxSpecJSI::NativeCalcCxxSpecJSI(std::shared_ptr<CallInvoker> jsInvoker)
    : TurboModule("CalcModule", jsInvoker) {
    methodMap_["add"] = MethodMetadata {2, __hostFunction_NativeCalcCxxSpecJSI_add};
  }
} // namespace facebook::react

3.2 原生模块实现

开发者需要创建一个遵循 NativeCalcSpec 协议的类:

@interface CalcModule : NSObject <NativeCalcSpec>
@end

@implementation CalcModule
RCT_EXPORT_MODULE()

- (void)add:(double)a b:(double)b resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
{
  NSNumber *result = @(a + b);
  resolve(result);
}
@end

并且用 RCT_EXPORT_MODULE() 宏来将这个类暴露给 React Native。RCT_EXPORT_MODULE()在编译时会展开成:

+ (NSString *)moduleName { return @"CalcModule"; }
+ (void)load { RCTRegisterTurboModule(self); }

这个过程同旧架构中的 RCT_EXPORT_MODULE() 宏类似,但也不完全相同。旧架构中的RCT_EXPORT_MODULE()宏会在编译时调用RCTRegisterModule(class)将模块注册到RCTModuleRegistry中,而新架构中的RCT_EXPORT_MODULE()宏则会在运行时调用RCTRegisterTurboModule(class)将模块注册到TurboModuleRegistry中。

3.3 RCTRegisterTurboModule 实现原理

RCTRegisterTurboModule 是新架构中的核心注册机制,它主要完成以下三个任务:

1.创建模块工厂

static std::unordered_map<std::string, TurboModuleFactory> turboModuleFactories;

void RCTRegisterTurboModule(Class moduleClass) {
  NSString *moduleName = [moduleClass moduleName];
  turboModuleFactories[moduleName.UTF8String] = [moduleClass](jsi::Runtime &runtime) {
    return std::make_shared<NativeCalcSpecJSI>(runtime, moduleClass);
  };
}

这里使用 C++ 模板机制创建了一个模块工厂函数,用于按需创建模块实例。工厂函数以模块名为键,存储在全局的 turboModuleFactories 映射表中。

2.维护模块元数据

class TurboModuleMetadata {
  std::string name;
  std::unordered_map<std::string, MethodMetadata> methods;
  std::shared_ptr<CallInvoker> jsInvoker;
};

void RCTRegisterTurboModule(Class moduleClass) {
  // ... 前面的代码 ...
  
  // 收集模块的方法信息
  auto metadata = std::make_shared<TurboModuleMetadata>();
  metadata->name = moduleName.UTF8String;
  metadata->jsInvoker = jsInvoker;
  
  // 通过运行时反射获取模块的方法列表
  unsigned int methodCount;
  Method *methods = class_copyMethodList(moduleClass, &methodCount);
  for (unsigned int i = 0; i < methodCount; i++) {
    Method method = methods[i];
    metadata->methods[sel_getName(method_getName(method))] = {
      .argumentCount = method_getNumberOfArguments(method) - 2,
      .returnType = method_getReturnType(method)
    };
  }
  free(methods);
  
  // 存储元数据
  turboModuleMetadata[moduleName.UTF8String] = metadata;
}

这部分代码负责维护模块的元数据,包括模块名、方法列表、参数类型等信息。这些信息在后续的方法调用中用于参数类型检查和转换。

3.JSI 绑定

void RCTRegisterTurboModule(Class moduleClass) {
  // ... 前面的代码 ...
  
  // 创建 JSI 绑定
  jsi::Runtime &runtime = ...; // 获取 JSI 运行时
  auto moduleObject = jsi::Object(runtime);
  
  // 为每个方法创建 JSI 函数
  for (const auto &method : metadata->methods) {
    moduleObject.setProperty(
      runtime,
      method.first.c_str(),
      jsi::Function::createFromHostFunction(
        runtime,
        jsi::PropNameID::forAscii(runtime, method.first),
        method.second.argumentCount,
        [metadata, method](jsi::Runtime &rt, const jsi::Value &thisVal, const jsi::Value *args, size_t count) {
          // 参数类型检查和转换
          // 调用原生方法
          // 返回结果转换
          return ...; // 返回 JSI 值
        }
      )
    );
  }
  
  // 将模块对象注册到 TurboModuleRegistry
  runtime.global().getProperty(runtime, "__turboModuleProxy")
    .asObject(runtime)
    .setProperty(runtime, moduleName.UTF8String, moduleObject);
}

这部分代码通过 JSI 机制将原生模块暴露给 JavaScript 环境。它为每个原生方法创建对应的 JSI 函数,使其能够直接访问原生代码,无需通过 Bridge 进行序列化和反序列化。

通过以上三个步骤,RCTRegisterTurboModule 完成了新架构中原生模块的注册过程。

3.4 小结

在新架构下,定义一个原生模块需要先在 JavaScript 层定义一个 Spec 文件,然后用 Codegen 生成 JSI、C++ 的胶水代码。下面总结一下这些不同层次代码的作用:

JavaScript 层

Spec 接口:TypeScript 定义的模块接口规范 TurboModuleRegistry:负责获取和管理 TurboModule 实例

C++ 层

NativeCalcCxxSpecJSI:C++ 抽象类,定义了模块方法的 C++ 接口 NativeCalcCxxSpec:C++ 模板类,用于创建 Host Object TurboModule:所有 TurboModule 的基类

ObjC++ 层

NativeCalcSpecJSI:ObjC++ 桥接类,继承自 ObjCTurboModule ObjCTurboModule:ObjC++ TurboModule 的基类

Objective-C 层

NativeCalcSpec:Objective-C 协议,定义了原生模块需要实现的方法 CalcModule:开发者实现的原生模块类

四、通信流程详解

让我们继续分析下 JavaScript 和原生模块实现通信的原理。

当 JavaScript 代码首次导入模块时:

import CalcModule from './NativeCalcModule';

实际上是调用了 TurboModuleRegistry.get<Spec>('CalcModule') 方法。这个方法的执行流程如下:

  1. 检查模块缓存:首先检查模块是否已经被加载过,如果已加载则直接返回缓存的实例。

  2. 获取原生模块:如果模块未加载,则通过 JSI 调用原生方法 __turboModuleProxy.getModule('CalcModule')

  3. 创建 Host Object:原生层会调用之前注册的模块工厂函数,创建一个 NativeCalcCxxSpec 实例作为 Host Object。

  4. 绑定方法:Host Object 会将所有在 TypeScript 中定义的方法绑定到 JavaScript 对象上。

  5. 缓存模块实例:将创建的模块实例缓存起来,以便后续使用。

  6. 返回 JavaScript 对象:最终返回一个 JavaScript 对象,该对象的方法实际上是与原生方法绑定的 JSI 函数。

当 JavaScript 调用 CalcModule.add(1, 2) 时:

  1. JavaScript 引擎执行调用:JavaScript 引擎识别到这是对一个对象方法的调用。

  2. JSI 拦截调用:由于 CalcModule 是一个 Host Object,其方法调用会被 JSI 拦截。

  3. 查找方法映射:JSI 根据方法名 add 在 Host Object 的 methodMap_ 中查找对应的处理函数,找到 __hostFunction_NativeCalcCxxSpecJSI_add

  4. 执行 Host 函数:调用 __hostFunction_NativeCalcCxxSpecJSI_add 函数,传入 JavaScript 运行时、模块实例和参数数组。

  5. 参数类型转换:Host 函数会将 JavaScript 参数转换为 C++ 类型,例如将 args[0]args[1] 转换为 double 类型。

return static_cast<NativeCalcCxxSpecJSI *>(&turboModule)->add(
  rt,
  count <= 0 ? throw jsi::JSError(rt, "Expected argument in position 0 to be passed") : args[0].asNumber(),
  count <= 1 ? throw jsi::JSError(rt, "Expected argument in position 1 to be passed") : args[1].asNumber()
);
  1. 调用 C++ 方法:转发调用到 NativeCalcCxxSpecJSI::add 方法。

  2. 桥接到 ObjC++:C++ 层通过 bridging::callFromJs 将调用转发到 ObjC++ 层的 NativeCalcSpecJSI

class Delegate : public NativeCalcCxxSpecJSI {
    public:
      jsi::Value add(jsi::Runtime &rt, double a, double b) override {
        static_assert(
            bridging::getParameterCount(&T::add) == 3,
            "Expected add(...) to have 3 parameters");

        return bridging::callFromJs<jsi::Value>(
            rt, &T::add, jsInvoker_, instance_, std::move(a), std::move(b));
      }
};
  1. ObjC++ 层处理NativeCalcSpecJSIinvokeObjCMethod 方法负责将 JSI 参数转换为 Objective-C 类型,并创建 Promise 对象。
- (jsi::Value)invokeObjCMethod:(jsi::Runtime &)runtime
                   methodKind:(MethodKind)methodKind
                   methodName:(const char *)methodName
                      method:(SEL)selector
                        args:(const jsi::Value *)args
                        count:(size_t)count {
  // 创建 Promise 对象
  if (methodKind == MethodKind::Promise) {
    return createPromise(runtime,
      ^(jsi::Runtime &rt, std::shared_ptr<Promise> promise) {
        // 创建 resolve 和 reject 回调
        RCTPromiseResolveBlock resolveBlock = ^(id result) {
          promise->resolve(rt, convertObjCValueToJSIValue(rt, result));
        };
        RCTPromiseRejectBlock rejectBlock = ^(NSString *code, NSString *message, NSError *error) {
          promise->reject(rt, [code UTF8String], [message UTF8String], error);
        };
        
        // 调用原生方法
        [instance selector:args[0].asNumber()
                        b:args[1].asNumber()
                  resolve:resolveBlock
                   reject:rejectBlock];
    });
  }
}
  1. 原生方法执行:原生方法执行完成后,通过 Promise 的 resolve 或 reject 回调返回结果。

  2. 结果返回

  • 如果执行成功,调用 resolve 回调,将 Objective-C 的结果值转换为 JSI 值。
  • 如果执行失败,调用 reject 回调,将错误信息传递给 JavaScript。
  • JSI 层将结果传递给 Promise 对象,最终返回给 JavaScript 层。

这种多层转发机制不仅确保了 JavaScript 和原生代码之间的类型安全和高效通信,还保持了代码的模块化和可维护性。完整的通信流程如下图所示:

sequenceDiagram
    participant JS as JavaScript
    participant TMR as TurboModuleRegistry
    participant HO as Host Object
    participant JSI as JSI Interface
    participant JSIC as JSI C++ Bindings
    participant OBJC as ObjC++ Bridge
    participant Native as Native Module

    %% 模块注册阶段
    Note over JS,Native: 模块注册阶段
    JS->>JS: 定义TypeScript接口规范(Spec)
    Native->>Native: 实现NativeCalcSpec协议
    
    %% Codegen生成代码
    Note over JSI,Native: Codegen生成代码
    JSI->>JSIC: 生成NativeCalcCxxSpecJSI抽象类
    JSIC->>JSIC: 生成NativeCalcCxxSpec模板类
    OBJC->>OBJC: 生成NativeCalcSpecJSI类
    
    %% 运行时注册
    Note over TMR,Native: 运行时注册
    Native->>OBJC: 调用RCTRegisterTurboModule注册模块
    OBJC->>OBJC: 创建NativeCalcSpecJSI实例
    OBJC->>OBJC: 构建methodMap_方法映射表
    OBJC->>JSI: 通过JSI注册模块到JavaScript运行时
    JSI->>TMR: 注册到TurboModuleRegistry全局对象
    
    %% JavaScript调用阶段
    Note over JS,Native: JavaScript调用阶段
    JS->>TMR: TurboModuleRegistry.get<Spec>('CalcModule')
    TMR->>JSI: 检查模块是否已注册
    JSI->>JSIC: 创建C++层Host Object
    JSIC->>HO: 通过NativeCalcCxxSpec创建JavaScript对象
    JSIC->>HO: 绑定方法到JavaScript对象
    HO-->>JS: 返回JavaScript对象

    %% 方法调用和结果返回阶段
    Note over JS,Native: 方法调用和结果返回阶段
    JS->>HO: CalcModule.add(a, b)
    HO->>JSIC: 调用__hostFunction_NativeCalcCxxSpecJSI_add
    JSIC->>JSIC: 参数类型检查和转换(args[0].asNumber())
    JSIC->>OBJC: 通过methodMap_查找对应的方法
    OBJC->>OBJC: 调用NativeCalcSpecJSI.invokeObjCMethod
    OBJC->>OBJC: 创建Promise(resolve/reject回调)
    OBJC->>Native: 调用add:b:resolve:reject:
    Native->>Native: 执行原生计算逻辑
    Native-->>OBJC: 调用resolve回调返回结果
    OBJC-->>JSIC: 将ObjC值转换为JSI值类型
    JSIC-->>HO: 将结果传递给Promise对象
    HO-->>JS: 通过Promise resolve返回结果

五、小结

通过上述详细分析,我们深入了解了 React Native 新架构下 JavaScript 和原生模块之间的通信机制。相比旧架构,新架构在通信方面有以下几个显著改进:

  1. 直接通信:通过 JSI(JavaScript Interface)实现 JavaScript 和原生代码的直接通信,无需通过 Bridge 进行序列化和反序列化,大幅提升性能。

  2. 类型安全:借助 Codegen 工具,从 TypeScript 接口定义自动生成原生代码,确保了 JavaScript 和原生代码之间的类型一致性,减少了运行时错误。

  3. 模块化设计:TurboModule 系统允许按需加载原生模块,减少了应用启动时间和内存占用。

  4. 多层架构:新架构采用了多层设计(JavaScript、JSI、C++、ObjC++、Objective-C),每一层都有明确的职责,使得代码更加模块化和可维护。

React Native 通信机制详解 - 旧架构

作者 Tamarous
2025年11月25日 10:37

React Native 通信机制详解 - 旧架构

一、概述

前文中,我们介绍了 React Native 的新旧两种架构,并在文章末尾介绍了如何在这两种架构中注册和实现一个原生模块。由于篇幅限制,我们并未对其底层原理进行深入介绍。接下来,我们将聚焦于此,深入探讨 React Native 新旧架构下原生模块和 JavaScript 调用的底层实现。

本篇是 React Native 通信机制详解系列的第一篇文章,主要介绍 React Native 旧架构下的通信机制。如果想要了解 React Native 新架构下的通信机制,请点击这里

二、示例

让我们回顾一下前文中注册原生模块的过程:

1.定义原生模块类

首先需要创建一个继承自 NSObject 并遵循 RCTBridgeModule 协议的类。

// CalcModule.h
#import <React/RCTBridgeModule.h>

@interface CalcModule : NSObject <RCTBridgeModule>
@end

2.实现模块方法

在实现文件中,我们使用 RCT_EXPORT_MODULE() 宏来导出模块,并通过 RCT_EXPORT_METHOD 宏来导出具体的方法。

// CalcModule.m
#import "CalcModule.h"

@implementation CalcModule

RCT_EXPORT_MODULE()

RCT_EXPORT_METHOD(add:(NSInteger)a
                  b:(NSInteger)b
                  callback:(RCTResponseSenderBlock)callback)
{
    NSInteger result = a + b;
    callback(@[@(result)]);
}

@end

3.导出模块到 JavaScript

最后,我们在 JavaScript 端使用这个原生模块。

// Calculator.js
import { NativeModules } from 'react-native';

const { CalcModule } = NativeModules;

export const add = (a, b) => {
  return new Promise((resolve) => {
    CalcModule.add(a, b, (result) => {
      resolve(result);
    });
  });
};

三、模块注册机制

在旧架构中,原生模块的注册和原生模块中方法的暴露分别是通过 RCT_EXPORT_MODULE()RCT_EXPORT_METHOD宏来实现的。将上述代码进行宏展开:

// RCT_EXPORT_MODULE() 宏展开
@implementation CalcModule

// 生成模块名映射函数
+ (NSString *)moduleName {
    return @"CalcModule";
}

// 注册模块到 Bridge
+ (void)load {
    RCTRegisterModule(self);
}

// RCT_EXPORT_METHOD 宏展开
- (void)__rct_export__add_a_b_callback {
    RCTMethodInfo methodInfo = {
        .objcName = "add:b:callback:",
        .jsName = "add",
        .isSync = NO
    };
    RCTRegisterMethodInfo(self, methodInfo);
}

// 原始方法实现
- (void)add:(NSInteger)a
          b:(NSInteger)b
  callback:(RCTResponseSenderBlock)callback
{
    NSInteger result = a + b;
    callback(@[@(result)]);
}

@end

RCT_EXPORT_MODULE() 宏会在类的 +load 方法中调用 RCTRegisterModule(class) 函数,将模块注册到 Bridge。RCT_EXPORT_METHOD 宏则会调用 RCTRegisterMethodInfo,将方法信息注册到 Bridge。

// RCTRegisterModule 实现
static NSMutableArray<Class> *RCTModuleClasses;
static dispatch_once_t onceToken;

void RCTRegisterModule(Class moduleClass)
{
    dispatch_once(&onceToken, ^{
        RCTModuleClasses = [NSMutableArray new];
    });

    // 确保类遵循 RCTBridgeModule 协议
    if ([moduleClass conformsToProtocol:@protocol(RCTBridgeModule)]) {
        // 添加到全局模块注册表
        [RCTModuleClasses addObject:moduleClass];
    }
}

这个方法的本质是将当前 module 添加到全局变量 RCTModuleClasses 中。

// RCTRegisterMethodInfo 实现
static NSMutableDictionary<NSString *, NSMutableSet<RCTMethodInfo *> *> *methodsMap;
static dispatch_once_t methodMapOnceToken;

void RCTRegisterMethodInfo(Class moduleClass, RCTMethodInfo methodInfo)
{
    dispatch_once(&methodMapOnceToken, ^{
        methodsMap = [NSMutableDictionary new];
    });

    // 获取或创建模块的方法集合
    NSString *moduleName = [moduleClass moduleName];
    NSMutableSet *methods = methodsMap[moduleName];
    if (!methods) {
        methods = [NSMutableSet new];
        methodsMap[moduleName] = methods;
    }

    // 添加方法信息到方法集合
    RCTMethodInfo *info = [[RCTMethodInfo alloc] init];
    info.objcName = methodInfo.objcName;
    info.jsName = methodInfo.jsName;
    info.isSync = methodInfo.isSync;
    [methods addObject:info];
}

这个方法的本质是将当前 module 对应的方法信息添加到全局变量 methodsMap 中。

当 RCTBridge 初始化时,会遍历 RCTModuleClasses 数组,为每个注册的模块类创建实例:

- (void)initModules
{
    // 遍历已注册的模块类
    for (Class moduleClass in RCTModuleClasses) {
        // 创建模块实例
        id<RCTBridgeModule> module = [[moduleClass alloc] init];
        
        // 将模块实例添加到 bridge 的模块表中
        NSString *moduleName = [moduleClass moduleName];
        _modulesByName[moduleName] = module;
    }
}

当 JavaScript 端调用 CalcModule.add 方法时,会先经过 MessageQueue:

// MessageQueue.js
class MessageQueue {
  callNativeMethod(moduleID, methodID, params, onFail, onSucc) {
    // 将调用信息放入队列
    this.enqueueNativeCall(moduleID, methodID, params, onFail, onSucc);
    // 触发队列刷新
    this.flushQueue();
  }

  flushQueue() {
    // 将队列中的调用序列化为 JSON
    const calls = JSON.stringify(this._queue);
    // 通过 Bridge 发送到原生层
    global.nativeFlushQueueImmediate(calls);
  }
}

而在 React Native 初始化时,会通过 JavaScriptCore 引擎为 JavaScript 环境注入一个全局函数 nativeFlushQueueImmediate。这个注入过程发生在 RCTBridge 初始化时:

// RCTBridge.m
- (void)setupJSExecutor
{
    // 获取 JSContext
    JSContext *context = [JSContext new];
    
    // 注入全局函数
    context[@"nativeFlushQueueImmediate"] = ^(JSValue *calls) {
        // 将 JSValue 转换为 NSString
        NSString *callsString = [calls toString];
        
        // 解析调用请求
        NSArray *requests = [NSJSONSerialization JSONObjectWithData:[callsString dataUsingEncoding:NSUTF8StringEncoding]
                                                         options:0
                                                           error:NULL];
        
        // 处理每个调用请求
        for (NSDictionary *request in requests) {
            NSNumber *moduleID = request[@"moduleID"];
            NSString *methodName = request[@"methodID"];
            NSArray *args = request[@"args"];
            
            // 转发到 Bridge 处理
            [self _handleRequestNumber:moduleID
                              method:methodName
                           arguments:args];
        }
    };
}

因此当 JavaScript 调用 global.nativeFlushQueueImmediate(calls) 时,实际上是调用了上述注入的函数。这个函数会将 JavaScript 传入的调用队列转换为原生数据结构,然后解析出每个调用请求的模块 ID、方法名和参数,最终将请求转发给 RCTBridge_handleRequestNumber:method:arguments: 方法处理

// RCTBridge.m
- (void)_handleRequestNumber:(NSNumber *)requestNumber
                    method:(NSString *)method
                   arguments:(NSArray *)arguments
{
    // 序列化参数
    NSData *messageData = [NSJSONSerialization dataWithJSONObject:arguments
                                                        options:0
                                                          error:NULL];
    // 发送到原生模块
    [self enqueueJSCall:method args:messageData];
}

最后,原生层通过 invokeMethod 方法找到并执行对应的原生实现:

- (void)invokeMethod:(NSString *)moduleName
           methodName:(NSString *)methodName
            arguments:(NSArray *)arguments
{
    // 获取模块的方法集合
    NSMutableSet<RCTMethodInfo *> *methods = methodsMap[moduleName];
    
    // 查找对应的方法信息
    RCTMethodInfo *methodInfo = nil;
    for (RCTMethodInfo *info in methods) {
        if ([info.jsName isEqualToString:methodName]) {
            methodInfo = info;
            break;
        }
    }
    
    if (methodInfo) {
        // 获取模块实例
        id<RCTBridgeModule> module = _modulesByName[moduleName];
        
        // 通过 performSelector 调用原生方法
        SEL selector = NSSelectorFromString(methodInfo.objcName);
        [module performSelector:selector withArguments:arguments];
    }
}

四、流程图

sequenceDiagram
    participant JS as JavaScript
    participant Bridge as Bridge
    participant Native as Native Module
    participant Runtime as Runtime System

    %% 模块注册阶段
    Note over Native: 定义原生模块类 CalcModule
    Native->>Runtime: RCT_EXPORT_MODULE() 宏展开
    activate Runtime
    Runtime->>Runtime: 生成 moduleName 方法
    Runtime->>Runtime: 生成 load 方法
    Runtime->>Bridge: RCTRegisterModule(CalcModule)
    Bridge->>Bridge: 将模块类添加到 RCTModuleClasses
    deactivate Runtime

    Native->>Runtime: RCT_EXPORT_METHOD 宏展开
    activate Runtime
    Runtime->>Runtime: 生成方法信息 RCTMethodInfo
    Runtime->>Bridge: RCTRegisterMethodInfo(CalcModule, methodInfo)
    Bridge->>Bridge: 将方法信息添加到 methodsMap
    deactivate Runtime

    %% Bridge 初始化阶段
    Note over Bridge: RCTBridge 初始化
    Bridge->>Bridge: initModules
    activate Bridge
    Bridge->>Native: 创建模块实例
    Native-->>Bridge: 返回模块实例
    Bridge->>Bridge: 保存实例到 _modulesByName
    deactivate Bridge

    Bridge->>JS: setupJSExecutor
    activate JS
    JS->>JS: 注入 nativeFlushQueueImmediate
    deactivate JS

    %% JavaScript 调用阶段
    Note over JS: 调用 CalcModule.add()
    JS->>JS: 创建 Promise
    JS->>Bridge: MessageQueue.callNativeMethod
    activate Bridge
    Bridge->>Bridge: enqueueNativeCall
    Bridge->>Bridge: flushQueue
    Bridge->>Bridge: nativeFlushQueueImmediate
    Bridge->>Bridge: 解析调用请求
    Bridge->>Native: invokeMethod
    deactivate Bridge

    activate Native
    Native->>Native: 执行 add 方法
    Native->>JS: 通过 callback 返回结果
    deactivate Native

    JS->>JS: Promise resolve

五、小结

本篇文章主要介绍了 React Native 旧架构下原生模块和 JavaScript 调用的底层实现。通过对模块注册机制和 JavaScript 调用流程的分析,我们深入了解了 React Native 旧架构下的通信机制。希望本文能帮助你更好地理解 React Native 的通信机制。

中国身份证注册美区 Apple Developer 个人账号完整教程

作者 HBLOG
2025年11月25日 10:10

一、注册前准备

必备条件

  1. 已有 Apple ID,并开启双重认证(2FA)。
  2. 年满 18 岁。
  3. 拥有中国身份证,姓名需与 Apple ID 一致(拼音形式)。
  4. 准备一个美国地址和电话号码(可用虚拟地址、Google Voice 等)。
  5. 有可以支付美元的信用卡或虚拟卡(用于支付年费 $99)。

二、虚拟信用卡推荐

如果没有国际信用卡,可以使用以下虚拟美卡服务:

名称

特点

适合场景

Wise 虚拟卡

支持美元账户,真实银行背景,审核简单

个人开发者首选

Revolut 卡

欧系金融牌照,美元支付稳定

海外支付频繁用户

StatesCard

专为 App Store、美区订阅设计

美区 Apple 年费支付

Payoneer 虚拟卡

有美元收款账户,可绑 Apple ID

有外贸或收款需求者

建议:提前充值 120 美元,避免支付时因汇率或手续费导致失败。

三、注册步骤

步骤 1:调整 Apple ID 设置

  1. 登录你的 Apple ID 账户。
  2. 国家或地区改为美国(United States)
  3. 填写美国地址(州名、城市、邮编)与美国手机号。
  4. 确认 Apple ID 的姓名与身份证拼音一致。
  5. 开启双重认证(2FA)。

步骤 2:进入开发者注册页

  1. 访问 Apple Developer 官网,点击 “加入 Apple Developer Program”。developer.apple.com/programs/en…
  2. 登录你的 Apple ID,选择 Individual(个人) 类型。
  3. 点击 “Start Your Enrollment” 开始注册。

步骤 3:填写个人信息

  • Entity Type(类型):Individual / Sole Proprietor

  • Full Name(姓名):与身份证拼音一致,例如

    LIU HAIHUA

  • Country / Region(国家/地区):United States

  • Address(地址):填写美国地址(可使用转运或虚拟办公地址)

  • Phone(电话):填写 +1 开头的美国号码

  • Email(邮箱):你的 Apple ID 邮箱

注意:填写信息必须与付款信息一致,否则可能被拒。

步骤 4:支付年费

  • 年费为 99 美元/年
  • 使用国际信用卡或虚拟卡支付。
  • 账单地址需与虚拟卡提供的美国账单地址一致。
  • 支付成功后,等待苹果审核。

步骤 5:身份验证与审核

  1. 支付后,Apple 可能会要求上传身份证。
    • 选择“Government ID”,上传中国身份证正反面照片。
    • 也可选择“Passport”,上传护照。
  2. 审核人员可能会通过邮件或电话联系确认身份。
  3. 一般 1~3 个工作日即可通过。
  4. 通过后,你会收到 “Welcome to the Apple Developer Program” 邮件。

四、注册后操作

  • 登录 App Store Connect 上传应用。
  • 可发布到除中国大陆以外的所有地区。
  • 支持 TestFlight、内购(In-App Purchase)等完整功能。

五、常见问题与注意事项

问题

说明

是否必须美国护照?

不需要,中国身份证或护照均可。

支付失败怎么办?

检查卡片是否支持美元支付、账单地址是否匹配。

虚拟卡会被拒吗?

大部分能通过,但某些卡需多尝试。

账号审核多久?

通常 1~3 个工作日,节假日顺延。

可以上架中国区吗?

不行,美区账号上架不含中国大陆区。

六、建议与总结

  • 若目标是全球上架(包括美国、日本、欧盟等市场),推荐使用美区个人账号
  • 若仅面向国内用户,可使用中国区账号(支持支付宝支付)。
  • 注册美区账号前,务必:
    • 统一姓名拼音;
    • 准备美元信用卡;
    • 使用美国账单地址;
    • 备份所有资料(身份证、付款截图、收据)。

毕业 30 年同学群:一场 AI 引发的“真假难辨”危机 -- 肘子的 Swift 周报 #112

作者 东坡肘子
2025年11月25日 07:51

issue112.webp

毕业 30 年同学群:一场 AI 引发的“真假难辨”危机

大学毕业快三十年了,同学们大多忙于事业与生活,群里常年冷清。但上周四晚上,一阵久违的热闹突然打破了沉寂。

一位十几年未露面的同学重新加入群聊,说家里遭遇了变故,向大家求助。很快就有人提出质疑——这真的是本人吗?毕竟群里多数人从事法律相关工作,职业敏感度让他们对任何异常格外警觉。

语音通话、视频聊天,一轮轮验证下来,仍有人不放心:“现在 AI 造假太容易了,光凭这些可不够。”直到更多细节被摆上桌面——共同经历、内部昵称、只有我们知道的旧梗——大家才最终确认身份,随即伸出援手,帮助他度过暂时的难关。

严格来说,并不能怪大家变得多疑。在如今一部手机就能“换头”“变声”的时代,“眼见为实”早已不再成立。社交媒体上布满了由 AI 生成的各种离奇情景,人们对“离谱视频”的耐受度不断被拉高:谁家的宠物不会说话、做饭呢?也许哪天真看到 UFO 降落,都不会再令人惊讶——我们对未知的恐惧阈值,正在被悄悄重塑。

随之而来的,是一种新的信息焦虑:过去担心的是获取不及时、不全面;如今担心的是获取的内容是否真实。“可信来源”,正在变成一件稀缺品。

当然,这一切不能算在 AI 头上。AI 本质上只是工具,真正利用它造假、行骗的仍然是人,只是欺骗的方式变了、成本更低了。

在这种背景下,重拾真实与信任,只会变得愈发困难。 或许,最终仍需“魔法打败魔法”:数字签名、可信时间戳、区块链验证……它们未必是完美解法,但至少是值得探索的方向。

从小我母亲就常说:“从善如登,从恶如崩”。

信任亦如此——重建远比摧毁艰难,却也正因如此才显得格外珍贵。

本期内容 | 前一期内容 | 全部周报列表

🚀 《肘子的 Swift 周报》

每周为你精选最值得关注的 Swift、SwiftUI 技术动态

近期推荐

深入 iMessage 底层:一个 Agent 是如何诞生的

作为 Apple 生态的开发者,我们常常会面对一个微妙的悖论:系统本身具备强大的能力,但这些能力并不一定以公开 API 的形式向开发者开放。iMessage 正是其中的典型 —— 它深度融入 iOS 与 macOS,是用户日常沟通的核心载体,却始终没有提供可供开发者使用的自动化接口。imessage-kit 的作者 LingJueYa,分享他在构建这一工具时的探索路径。但其核心挑战几乎都来自 Apple 平台本身:解析以 2001 为纪元的时间戳、从二进制 plist 中恢复 NSAttributedString 内容、在 macOS 的沙盒体系下安全地读取资源、以及与 AppleScript 这种历史悠久的自动化机制打交道。


SwiftUI 已死? UIKit 的回归之路 (2025: The Year SwiftUI Died)

Jacob Bartlett 的这篇文章标题颇具挑衅意味,他提出了一个值得讨论的观点:2025 年也许是 SwiftUI 的拐点,而不是它的巅峰。核心论点在于,苹果将 @Observable 宏和 updateProperties() 方法引入 UIKit,使其具备了现代化的状态管理能力;同时,AI 辅助编程工具的成熟极大降低了编写 UIKit 代码的成本(而 AI 对 SwiftUI 这种声明式范式的理解仍显不足)。

苹果对 SwiftUI 的长期投入不会动摇,尤其考虑到它在多端适配上的天然优势。与此同时,AI 的普及也让许多以 SwiftUI 入门的开发者更容易使用 UIKit 来补齐性能或能力上的不足。选择不一定要非此即彼,可以将两者结合起来用得更好。 这样更好吧


UIKit 中的视图自动刷新 (Automatic property observation in UIKit with @Observable)

UIKit 在 iOS 26 中正式引入了对 Swift Observation 的原生支持。当你在 updateProperties()中读取 @Observable 对象的属性时,UIKit 会自动追踪这些依赖关系,并在数据变化时按需刷新对应视图。Natalia Panferova 通过一个混合 UIKit + SwiftUI 的实际案例展示了这一特性在跨框架数据共享时的便利性。文章还介绍了 iOS 18 的向下兼容方案:在 Info.plist 中添加 UIObservationTrackingEnabled 键,并将更新逻辑放在 viewWillLayoutSubviews() 中即可实现相同效果。


SwiftData 对 AttributedString 的原生支持 (How SwiftData Represents AttributedString in Core Data Storage)

尽管 AttributedString 本身符合 Codable,但过去开发者并不能直接在 SwiftData 模型中将其作为属性使用。这一限制终于在 iOS 26 中被解除 —— 苹果显然为它开了一条“特别通道”,如今它已经可以像 Int、String 这样的基础类型一样直接存储了。DataScout for SwiftData(SwiftData 数据库分析应用)的作者 Oleksii Oliinyk 在维护工具时遭遇了相关的崩溃问题,并顺势深入分析了其背后的实现机制。

SwiftData 允许开发者将符合 Codable 协议的类型作为模型属性,这本身是一项十分强大的能力。但它的底层处理方式可能与许多人预想的并不相同。我在《在 SwiftData 模型中使用 Codable 和枚举的注意事项》中对 SwiftData 的 Codable 转换逻辑与潜在陷阱做了更系统的介绍。此外,如果你需要在 iOS 26 之前保存 AttributedString,可以参考苹果开发者论坛上的这个帖子


AnyLanguageModel:统一 Apple 平台的 LLM 接口 (Introducing AnyLanguageModel: One API for Local and Remote LLMs on Apple Platforms)

AnyLanguageModel 是由 Mattt 开发的统一 Swift 大模型接口库,我们已在第 109 期周报中介绍过其核心理念。该库以 Foundation Models 的 API 设计为基础,在保持开发者熟悉的使用方式的同时,统一支持多种模型提供方,包括本地模型(Core ML、MLX、llama.cpp、Ollama)以及云端模型(OpenAI、Anthropic、Google Gemini 等),大幅降低了跨 API、跨运行方式所带来的集成负担,也让探索开源模型变得更容易。

在这篇文章中,Mattt 进一步介绍了 AnyLanguageModel 的设计思路、跨后端的能力抽象及 Swift 6.1 package traits 在控制依赖体积中的作用。值得一提的是,尽管苹果当前尚未在 Foundation Models 中提供图像输入能力,AnyLanguageModel 已为 Claude 等模型补上这一功能,使视觉-语言场景也能在 Apple 平台上顺利落地。


Approachable Concurrency 与 MainActor by Default

无论最终走向如何,Swift 6.2 中引入的 Approachable Concurrency 注定会在 Swift 发展史上留下浓重的一笔。它在某些场景下显著降低了并发编程的心智负担,但也让不少开发者感到“越解释越困惑”。


构建可扩展的白标 iOS 应用 (How to Build Scalable White-Label iOS Apps: From Multi-Target to Modular Architecture)

白标产品(White-Label)指的是一个架构灵活、可在不同客户之间复用的通用 App 框架,能够按需更换品牌皮肤与功能配置(例如通用的订餐 App 模板)。Pawel Kozielecki 在这篇长文中系统梳理了 iOS White-Label 应用的演进路线,将其划分为三个阶段:基础品牌定制、自定义 UI/UX,以及完全模块化。在此基础上,他对比了三种常见实现策略——多 Target、资源复制、模块化架构,并指出随着客户数量与差异化需求增长,真正能够长期扩展、避免维护失控的方案只有模块化(Modular Architecture)。文章还结合大量实战细节,讨论了审核、签名、资源与配置分层、测试与 CI 等白标项目在规模化过程中的关键挑战。

工具

QuickLayout: 声明式 UIKit 布局库

既然 UIKit 已经可以无缝集成 Observation 了,那么以 SwiftUI 风格编写布局代码也就不足为奇了。由 Constantine Fry 开发的 QuickLayout 便提供了这样的能力,其已被 Meta 在 Instagram 的核心功能中使用。你可以直接以如下的方式进行布局:

import QuickLayout

@QuickLayout
class MyCellView: UIView {

  let titleLabel = UILabel()
  let subtitleLabel = UILabel()

  var body: Layout {
    HStack {
      VStack(alignment: .leading) {
        titleLabel
        Spacer(4)
        subtitleLabel
      }
      Spacer()
    }
    .padding(.horizontal, 16)
    .padding(.vertical, 8)
  }
}

UIKit 和 SwiftUI 从来不是对立面,而是两套可互相借鉴的 UI 思维模型。现阶段 SwiftUI 的优势在于抽象与一致性,而高性能、精细控制、工具链支持等仍是 UIKit 的特长。


SettingsKit

几乎所有应用都需要设置界面,虽然编写难度不高,但当选项不断增加时,维护成本也会随之上升。Aether 开发的 SettingsKit 正是为此而生。它让 SwiftUI 开发者可以快速构建一个可扩展、样式统一、并带有搜索能力的设置界面,并内置分组、卡片、侧栏等多种风格,适用于中大型设置模块的搭建。


KurrentDB-Swift

Kurrent(原 EventStoreDB)是一款专门用于事件存储的数据库,它不仅保存系统的最新状态,还能完整记录每一次变化的历史,非常适用于金融、物流、零售、电商、SaaS 等需要强可追溯性的场景。作为 KurrentDB 的 Swift 客户端库,由 Grady Zhuo 开发的 KurrentDB-Swift 支持 Swift 6、async/await 流式订阅与事件读取,补上了 Swift 生态在 Event Sourcing 领域长期缺乏成熟工具的空白。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

把一年前写给自己用的翻译软件开源了 | TranslateP

作者 PJHubs
2025年11月25日 00:38

从 24 年 11 月 3 日开始到现在过去了整整一年,当初因为突然间欧陆词典的快捷键翻译彻底失效,导致我忍无可忍的自己从头开始写一个翻译软件给自己用。用业余零碎的时间完善了它,达到了我心中最低可用的效果,当初开发时借鉴了一些开源项目的实现,现在是时候也贡献出来了!

GitHub 开源仓库地址:github.com/windstormey…

app store 下载链接:apps.apple.com/cn/app/tran…

1.png

2.png

本来想写的有很多,但实在是没有更多精力了,就简单介绍下吧。

基于 SwiftUI 实现的在 macOS 上可无网络使用的菜单栏 AI 翻译小工具,目前暂时只支持英译中和中译英,可朗读原文也可查看音标,简单的 UI 样式调整都可以,支持双击两次 cmd + c 快捷键翻译。整个 app 的大小只有 1.3 MB,比一张 iPhone 照片还要小!

我自己用了整整一年的时间,没有任何使用压力,不用担心数据泄漏和网络环境问题,甚至还支持截图翻译。底层的技术都是相通的,feature list 里还有好多想做的没有实现,但经过了一年的时间,真正想做的事情早就应该做了,剩下没做的,大概率算不上核心诉求,也就作罢。

开源的主要目的是原计划年底前找到一个可尝试的商业化方法,尝试在 app 中增加一些内购或者收费点。但思来想去好久,始终找不到合适的收费点,在现如今的背景下,再做翻译工具软件已毫无价值,但意义对于每个人都不同,如果你感兴趣的话,可以阅读我的这两篇开发总结博客,可能有一些帮助。

# 《TranslateP 开发日志 01》- 做了一个翻译软件

# 《TranslateP 开发日志 02》- 最后一块拼图

昨天 — 2025年11月24日掘金 iOS

《Flutter全栈开发实战指南:从零到高级》- 17 -核心动画

2025年11月24日 17:01

引言

Snipaste_2025-11-24_16-55-23.png 动画是移动应用的灵魂,能够增强页面交互效果,比如:微信下拉刷新的旋转动画、支付宝页面切换的平滑过渡、抖音点赞动效等等这些炫酷的动画效果使你的App更加出彩。今天我们就来深入探讨Flutter中那些让人惊艳的动画效果,一步步掌握动画的核心技巧!

一、动画核心类介绍

1.1 动画的本质是什么?

在深入代码之前,我们先要理解动画的本质。简单来说,动画就是在一段时间内连续改变属性值的过程。

举个例子:

  • 一个按钮从透明变成不透明(改变opacity值)
  • 一个图标从左边移动到右边(改变position值)
  • 一个容器从小变大(改变size值)

用伪代码表示就是:

// 伪代码
开始值 = 0.0
结束值 = 1.0
持续时间 = 1.0 

// 在1秒内,当前值从0.0线性变化到1.0
当前值 = 开始值 + (结束值 - 开始值) * (已用时间 / 持续时间)

1.2 动画核心类

Flutter的动画系统建立在以下四个核心类基础之上:

// 1. AnimationController
AnimationController controller;

// 2. Animation
Animation<double> animation;

// 3. Tween
Tween<double> tween;

// 4. Curve
CurvedAnimation curvedAnimation;

如果把动画效果比作开车,别想歪了,哈哈!想象一下你将车从A点开到B点:

  • AnimationController就像是你的脚踩油门和刹车,控制车的启动、停止、加速、减速
  • Tween就像是导航,告诉你从A点(开始值)到B点(结束值)的路线
  • Animation就像是车速表,实时显示当前的速度值
  • Curve就像是道路状况,决定了你是匀速前进、先快后慢,还是有什么特殊的加速模式

1.3 核心类的职责

Animation - 值容器

Animation<double> sizeAnimation;

// 主要职责:
// - 持有当前动画值
// - 通知监听器值发生变化
// - 管理动画状态

// 添加值监听器
sizeAnimation.addListener(() {
  setState(() {
    // 当动画值变化时,重建Widget。。。
  });
});

// 添加状态监听器  
sizeAnimation.addStatusListener((status) {
  switch (status) {
    case AnimationStatus.dismissed:
      print('动画在开始状态'); 
      break;
    case AnimationStatus.forward:
      print('正向执行'); 
      break;
    case AnimationStatus.reverse:
      print('反向执行'); 
      break;
    case AnimationStatus.completed:
      print('执行完成'); 
      break;
  }
});

AnimationController

class _MyAnimationState extends State<MyAnimation> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController controller;
  
  @override
  void initState() {
    super.initState();
    
    // 创建动画控制器
    controller = AnimationController(
      duration: Duration(seconds: 2),  // 持续2秒
      vsync: this,                     // 垂直同步
    );
  }
  
  @override
  void dispose() {
    controller.dispose();  // 释放
    super.dispose();
  }
}

这里有个重要概念:vsync vsync的作用是防止动画在页面不可见时继续运行,造成浪费资源。通过SingleTickerProviderStateMixin,当页面被遮挡或切换到后台时,动画会自动暂停。

二、隐式动画

2.1 什么是隐式动画?

本质就是告诉Widget最终的状态是什么,Flutter会自动帮你生成过渡动画。

  • 普通Widget:不加任何动画效果,widget会很生硬的直接过渡过去;
  • 隐式动画Widget:从旧状态到新状态间有一个平滑的过渡,用户体验更好一些;

2.2 隐式动画组件

2.2.1 AnimatedContainer

AnimatedContainer:最常用的隐式动画组件,几乎所有的容器属性动画都可以用它来完成。

class AnimatedContainerExample extends StatefulWidget {
  @override
  _AnimatedContainerExampleState createState() => _AnimatedContainerExampleState();
}

class _AnimatedContainerExampleState extends State<AnimatedContainerExample> {
  // 定义可变属性
  double _width = 100.0;
  double _height = 100.0;
  Color _color = Colors.blue;
  BorderRadius _borderRadius = BorderRadius.circular(8.0);
  
  void _toggleAnimation() {
    setState(() {
      // 改变属性值
      _width = _width == 100.0 ? 200.0 : 100.0;
      _height = _height == 100.0 ? 200.0 : 100.0;
      _color = _color == Colors.blue ? Colors.green : Colors.blue;
      _borderRadius = _borderRadius == BorderRadius.circular(8.0) 
          ? BorderRadius.circular(50.0)   // 变成圆
          : BorderRadius.circular(8.0);   // 变回圆角矩形
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        AnimatedContainer(
          width: _width,
          height: _height,
          duration: Duration(seconds: 1),     // 动画时长
          curve: Curves.easeInOut,            // 动画曲线
          decoration: BoxDecoration(
            color: _color,
            borderRadius: _borderRadius,
          ),
          child: Center(
            child: Text(
              '点我',
              style: TextStyle(color: Colors.white),
            ),
          ),
        ),
        SizedBox(height: 20),
        ElevatedButton(
          onPressed: _toggleAnimation,
          child: Text('触发动画'),
        ),
      ],
    );
  }
}

AnimatedContainer支持的动画属性:

  • 尺寸widthheight
  • 颜色color
  • 边框圆角borderRadius
  • 内边距padding
  • 外边距margin
  • 阴影boxShadow
  • 变换transform

2.2.2 AnimatedOpacity

用来处理透明度变化的组件,非常适合实现显示/隐藏的过渡效果。

class AnimatedOpacityExample extends StatefulWidget {
  @override
  _AnimatedOpacityExampleState createState() => _AnimatedOpacityExampleState();
}

class _AnimatedOpacityExampleState extends State<AnimatedOpacityExample> {
  double _opacity = 1.0;  // 1.0 = 完全显示,0.0 = 完全隐藏
  
  void _toggleVisibility() {
    setState(() {
      _opacity = _opacity == 1.0 ? 0.0 : 1.0;
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        AnimatedOpacity(
          opacity: _opacity,
          duration: Duration(seconds: 1),
          curve: Curves.easeInOut,
          child: Container(
            width: 200,
            height: 200,
            color: Colors.blue,
            child: Center(
              child: Text(
                '淡入淡出',
                style: TextStyle(color: Colors.white, fontSize: 20),
              ),
            ),
          ),
        ),
        SizedBox(height: 20),
        ElevatedButton(
          onPressed: _toggleVisibility,
          child: Text(_opacity == 1.0 ? '隐藏' : '显示'),
        ),
      ],
    );
  }
}

使用场景:

  • 页面元素的显示/隐藏
  • 加载状态的过渡
  • 错误信息的淡入淡出

2.2.3 其他隐式动画组件

// AnimatedPadding - 内边距动画
AnimatedPadding(
  padding: EdgeInsets.all(_paddingValue),
  duration: Duration(seconds: 1),
  child: YourWidget(),
)

// AnimatedAlign - 对齐位置动画  
AnimatedAlign(
  alignment: _alignment,
  duration: Duration(seconds: 1),
  child: YourWidget(),
)

// AnimatedPositioned - 定位动画(注意:只能在Stack中使用)
AnimatedPositioned(
  left: _left,
  top: _top,
  duration: Duration(seconds: 1),
  child: YourWidget(),
)

2.3 隐式动画的工作原理

很多人会有疑问:为什么改变属性值就能产生动画?下面我们深入探讨其背后的实现原理:

sequenceDiagram
    participant U as 用户交互
    participant S as setState()
    participant W as 隐式动画Widget
    participant E as 动画引擎
    participant R as 渲染层
    
    U->>S: 触发状态变化
    S->>W: 重建Widget树
    W->>E: 检测到属性变化
    E->>E: 计算动画补间值
    loop 每一帧
        E->>W: 更新动画值
        W->>R: 重绘界面
    end
    E->>W: 动画完成

下面来看一段代码,来辅助理解隐式动画:

// 隐式动画的内部逻辑
class AnimatedContainer extends ImplicitlyAnimatedWidget {
  @override
  void didUpdateWidget(AnimatedContainer oldWidget) {
    super.didUpdateWidget(oldWidget);
    
    // 当属性发生变化时
    if (oldWidget.width != widget.width) {
      // 创建动画控制器
      // 创建补间动画
      // 启动动画
    }
  }
}

简单来说,当setState()被调用时:

  1. Widget树重建
  2. AnimatedContainer检测到属性值变化
  3. 自动创建动画控制器和补间动画
  4. 启动动画,在指定时间内平滑过渡到新值

三、补间动画

3.1 什么是补间动画?

"补间"这个词来源于动画制作领域,意思是在起始状态和结束状态之间过渡帧

在Flutter中,Tween就是做这个工作的:

  • 你告诉它起始值(begin)和结束值(end)
  • 它负责计算中间的所有值
// 创建一个从50到200的尺寸补间动画
Tween<double> sizeTween = Tween<double>(
  begin: 50.0,   // 开始值
  end: 200.0,    // 结束值
);

// 创建一个从红色到蓝色的颜色补间动画  
ColorTween colorTween = ColorTween(
  begin: Colors.red,
  end: Colors.blue,
);

3.2 Tween的完整使用流程

class TweenAnimationExample extends StatefulWidget {
  @override
  _TweenAnimationExampleState createState() => _TweenAnimationExampleState();
}

class _TweenAnimationExampleState extends State<TweenAnimationExample> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  late Animation<double> _sizeAnimation;
  late Animation<Color?> _colorAnimation;
  late Animation<double> _rotationAnimation;
  
  @override
  void initState() {
    super.initState();
    
    // 1. 创建动画控制器
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
    
    // 2. 创建补间动画
    _sizeAnimation = Tween<double>(
      begin: 50.0,
      end: 200.0,
    ).animate(_controller);
    
    _colorAnimation = ColorTween(
      begin: Colors.blue,
      end: Colors.red,
    ).animate(_controller);
    
    _rotationAnimation = Tween<double>(
      begin: 0.0,
      end: 2 * 3.14159,  // 2π弧度 = 360度
    ).animate(_controller);
    
    // 3. 启动动画
    _controller.forward();
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.rotate(
          angle: _rotationAnimation.value,
          child: Container(
            width: _sizeAnimation.value,
            height: _sizeAnimation.value,
            color: _colorAnimation.value,
            child: Center(
              child: Text(
                '动画中...',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        );
      },
    );
  }
}

3.3 Tween成员

Flutter提供了多种专门的Tween类:

// 尺寸
SizeTween(
  begin: Size(50, 50),
  end: Size(200, 200),
)

// 矩形区域
RectTween(
  begin: Rect.fromLTRB(0, 0, 50, 50),
  end: Rect.fromLTRB(0, 0, 200, 200),  
)

// 整数
IntTween(
  begin: 0,
  end: 100,
)

// 步进
StepTween(
  begin: 0,
  end: 100,
)

// 可以为自定义类型创建补间动画
Tween<YourCustomType>(
  begin: CustomType(),
  end: CustomType(),
)

四、动画曲线与控制器

4.1 动画曲线 - Curves

动画曲线本质就是模拟现实生活中的自然运动规律。

// 使用不同的动画曲线
Animation<double> animation = CurvedAnimation(
  parent: controller,
  curve: Curves.easeInOut,     // 先加速再减速
  reverseCurve: Curves.easeIn, // 反向动画
);

常用的动画曲线:

// 线性
Curves.linear

// 曲线
Curves.easeIn        // 先慢后快
Curves.easeOut       // 先快后慢  
Curves.easeInOut     // 先慢,中间快,后慢

// 弹性效果
Curves.bounceOut     // 回弹效果
Curves.elasticOut    // 弹簧效果

// 回弹效果
Curves.decelerate    // 先快后慢

4.2 不同曲线效果

下面创建一个不同曲线的例子:

class CurvesDemo extends StatefulWidget {
  @override
  _CurvesDemoState createState() => _CurvesDemoState();
}

class _CurvesDemoState extends State<CurvesDemo> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  final List<Curve> _curves = [
    Curves.linear,
    Curves.easeIn,
    Curves.easeOut,
    Curves.easeInOut,
    Curves.bounceOut,
    Curves.elasticOut,
  ];
  final List<String> _curveNames = [
    'linear',
    'easeIn', 
    'easeOut',
    'easeInOut',
    'bounceOut',
    'elasticOut',
  ];
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    )..repeat(reverse: true);  // 循环
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: _curves.length,
      itemBuilder: (context, index) {
        return Padding(
          padding: EdgeInsets.all(8.0),
          child: Column(
            children: [
              Text(_curveNames[index]),
              SizedBox(height: 10),
              Container(
                height: 50,
                child: AnimatedBuilder(
                  animation: _controller,
                  builder: (context, child) {
                    final animation = CurvedAnimation(
                      parent: _controller,
                      curve: _curves[index],
                    );
                    return Container(
                      width: 50 + animation.value * 200,  // 宽度从50到250
                      color: Colors.blue,
                      child: Center(
                        child: Text('${(animation.value * 100).round()}%'),
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

五、AnimationController

5.1 基本操作

AnimationController:控制动画的整个生命周期。

class AnimationControllerExample extends StatefulWidget {
  @override
  _AnimationControllerExampleState createState() => _AnimationControllerExampleState();
}

class _AnimationControllerExampleState extends State<AnimationControllerExample> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  late Animation<double> _animation;
  
  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
    
    _animation = Tween<double>(begin: 0, end: 300).animate(_controller);
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          height: _animation.value,
          width: _animation.value,
          color: Colors.blue,
        ),
        SizedBox(height: 20),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            ElevatedButton(
              onPressed: _controller.forward,  // 正向
              child: Text('播放'),
            ),
            ElevatedButton(
              onPressed: _controller.reverse,  // 反向
              child: Text('倒放'),
            ),
            ElevatedButton(
              onPressed: _controller.stop,     // 暂停
              child: Text('暂停'),
            ),
            ElevatedButton(
              onPressed: () {
                _controller.reset();           // 重置
                _controller.forward();
              },
              child: Text('重置并播放'),
            ),
          ],
        ),
        SizedBox(height: 20),
        Text('当前值: ${_animation.value.toStringAsFixed(1)}'),
        Text('当前状态: ${_controller.status.toString()}'),
      ],
    );
  }
}

5.2 使用技巧

// 动画控制的高级方法

// 1. 从特定值开始
_controller.animateTo(0.5);  // 从当前值动画到0.5

// 2. 相对动画
_controller.forward(from: 0.0);  // 从0.0开始正向动画

// 3. 重复动画
_controller.repeat(         // 无限重复
  min: 0.2,                 // 最小值
  max: 0.8,                 // 最大值
  reverse: true,            // 往返执行
  period: Duration(seconds: 1), // 循环周期
);

// 4. 获取动画信息
print('是否动画中: ${_controller.isAnimating}');
print('是否完成: ${_controller.isCompleted}');
print('是否停止: ${_controller.isDismissed}');

六、自定义显式动画与性能优化

6.1 自定义显式动画

有时候隐式动画不能满足我们的需求,这时候就需要自定义显式动画。

class CustomExplicitAnimation extends StatefulWidget {
  @override
  _CustomExplicitAnimationState createState() => _CustomExplicitAnimationState();
}

class _CustomExplicitAnimationState extends State<CustomExplicitAnimation> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  late Animation<double> _sizeAnimation;
  late Animation<double> _opacityAnimation;
  late Animation<Color?> _colorAnimation;
  
  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      duration: Duration(seconds: 3),
      vsync: this,
    );
    
    // 创建交错动画效果
    _sizeAnimation = Tween<double>(
      begin: 50.0,
      end: 200.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.0, 0.6, curve: Curves.easeInOut), // 只在前60%时间执行
    ));
    
    _opacityAnimation = Tween<double>(
      begin: 0.3,
      end: 1.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.2, 0.8, curve: Curves.easeIn), // 从20%到80%时间执行
    ));
    
    _colorAnimation = ColorTween(
      begin: Colors.blue,
      end: Colors.purple,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.5, 1.0, curve: Curves.easeOut), // 从50%时间开始执行
    ));
    
    _controller.forward();
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Opacity(
          opacity: _opacityAnimation.value,
          child: Container(
            width: _sizeAnimation.value,
            height: _sizeAnimation.value,
            decoration: BoxDecoration(
              color: _colorAnimation.value,
              borderRadius: BorderRadius.circular(20),
              boxShadow: [
                BoxShadow(
                  color: Colors.black.withOpacity(0.3),
                  blurRadius: 10,
                  offset: Offset(0, 5),
                ),
              ],
            ),
            child: Center(
              child: Text(
                '自定义动画',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 18,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

二者之间有什么区别呢?隐式动画 vs 显式动画

维度 隐式动画 显式动画
控制 自动控制 完全控制
代码量 简单 相对复杂
灵活性 有限 非常高
适用场景 简单属性变化 复杂动画序列

6.2 性能优化

动画性能很重要,不好的动画效果会让用户觉得应用卡顿。这里有几个优化技巧:

6.2.1 使用AnimatedBuilder局部重建

// 不推荐:整个页面重建
@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Opacity(
      opacity: _animation.value,
      child: TestWidget(), // 组件被重复重建
    ),
  );
}

// 推荐:只重建动画部分
@override
Widget build(BuildContext context) {
  return Scaffold(
    body: AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Opacity(
          opacity: _animation.value,
          child: child, // 使用缓存的child
        );
      },
      child: TestWidget(), // 组件只构建一次
    ),
  );
}

6.2.2 使用Transform代替布局属性

// 不推荐:改变尺寸会触发布局重新计算
Container(
  width: _animation.value,
  height: _animation.value,
)

// 推荐:Transform只影响绘制,不触发布局
Transform.scale(
  scale: _animation.value,
  child: Container(
    width: 100,  // 固定尺寸
    height: 100,
  ),
)

6.2.3 避免在动画中使用Opacity

// 不推荐:Opacity会导致整个子树重绘
Opacity(
  opacity: _animation.value,
  child: TestComplexWidget(),
)

// 推荐:使用颜色透明度
Container(
  color: Colors.blue.withOpacity(_animation.value),
  child: TestComplexWidget(),
)

// 推荐:使用FadeTransition
FadeTransition(
  opacity: _animation,
  child: TestComplexWidget(),
)

6.3 创建一个加载动画

下面用一个加载动画效果串一下上面所讲内容:

class SmoothLoadingAnimation extends StatefulWidget {
  @override
  _SmoothLoadingAnimationState createState() => _SmoothLoadingAnimationState();
}

class _SmoothLoadingAnimationState extends State<SmoothLoadingAnimation> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  late Animation<double> _rotationAnimation;
  late Animation<double> _scaleAnimation;
  late Animation<Color?> _colorAnimation;
  
  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      duration: Duration(milliseconds: 1500),
      vsync: this,
    )..repeat(reverse: true);
    
    _rotationAnimation = Tween<double>(
      begin: 0.0,
      end: 2 * 3.14159,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
    
    _scaleAnimation = Tween<double>(
      begin: 0.8,
      end: 1.2,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
    
    _colorAnimation = ColorTween(
      begin: Colors.blue[300],
      end: Colors.blue[700],
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.rotate(
          angle: _rotationAnimation.value,
          child: Transform.scale(
            scale: _scaleAnimation.value,
            child: Container(
              width: 60,
              height: 60,
              decoration: BoxDecoration(
                color: _colorAnimation.value,
                borderRadius: BorderRadius.circular(30),
                boxShadow: [
                  BoxShadow(
                    color: Colors.black.withOpacity(0.2),
                    blurRadius: 10,
                    offset: Offset(0, 5),
                  ),
                ],
              ),
              child: Icon(
                Icons.refresh,
                color: Colors.white,
                size: 30,
              ),
            ),
          ),
        );
      },
    );
  }
}

总结

核心知识点

  • 理解了Animation、AnimationController、Tween、Curve的作用
  • 掌握了动画状态的生命周期管理
  • 理解了隐式动画的自动管理机制
  • 学会了创建各种类型的补间动画
  • 学会了使用Curves让动画更自然
  • 学会了创建复杂的自定义动画
  • 学会了使用AnimatedBuilder优化性能

一个好的应用整体动画效果风格要一致,同时要保持流畅度,增强用户体验。通过本节学习,相信大家已经掌握了Flutter动画的核心概念和实战技巧。多动手实现各种动画效果,不断调试优化吧~~~ 有任何问题欢迎在评论区留言,看到会第一时间回复!

Xcode 没人想解决的问题:为什么苹果对平庸感到满意

作者 JarvanMo
2025年11月24日 09:27

十年的 iOS 开发,数千次崩溃,以及一个令人不安的事实:Xcode 的糟糕程度,恰好是苹果愿意容忍的程度。

如果你做 iOS 开发者超过,比方说,五分钟,你可能就经历过以下情景:

  • 你打开 Xcode

  • 你按下 ⌘B\text{⌘B}(构建/编译)。

  • 发生了点什么。但不是你预期的那个东西。

    • 构建挂起了。
    • 索引器卡死了。
    • 模拟器毅然决然地说:今天不是我工作的日子(即模拟器出故障或运行缓慢)。

image.png

然后,在库比蒂诺的某个角落,有人耸了耸肩,表示无所谓。

欢迎来到 Xcode 的世界:这是一款没人爱,人人忍,却被全球最有价值公司保护着的唯一一款 IDE(集成开发环境)。

但我今天不是来单纯发牢骚的。我搞 iOS 应用已经十多年了,从 Xcode 4 熬到了 16。经历了 Swift 的普及、Storyboards、SwiftUI、并发编程等等,所有的一切。

经过这几年的心力交瘁,我得出了一个残酷的结论:

Xcode 不是“意外变差的”。它目前的平庸程度,恰恰是苹果允许它达到的程度。


日常 Xcode 体验:一份痛苦清单

咱们说实话:Xcode 不只是有 Bug,它是以一种我们非常熟悉的方式持续地不可预测。

一个典型的 Xcode 日常是这样的:

  • 刚加了一个新文件, “Indexing…” 就卡住了 5 分钟。
  • 在一个大文件里输入 .\text{.} 的时候,自动补全直接冻结。
  • SwiftUI 预览可能工作了一次,然后就消失了,只留下 “Some previews failed.”
  • 构建(Build)成功了。App 没启动。模拟器崩了。Xcode 在撒谎。
  • 断点(Breakpoints) …自己消失了。
  • 你叹了口气。打开活动监视器,把那个索引进程杀掉。

你甚至已经不再惊讶了。而这才是更大的问题。

很多开发者已经停止质疑了。我们默默地接受了这种平庸。


Xcode 是如何变成一个瓶颈的

Xcode 一开始没这么烂。当然,它一直有点笨重——但至少还能应付。

然后一切都变了。

1. Swift 登场了

Swift 让 Xcode 的复杂性呈指数级增长。它是一个强大的语言,但编译器也非常重

再加上 Swift 每年都在变,Xcode 就必须疲于应付:

  • 旧的 Swift 项目
  • 正在过渡中的项目
  • 使用最新特性的新代码库

这可不是正常的 IDE 应该有的工作量。

2. SwiftUI 登场了

SwiftUI 预览本应实时显示 UI 变化。但它们经常:

  • 冻住
  • 悄无声息地失败
  • 像个挖矿程序一样耗干你的笔记本电池

开发者已经学会了不信任预览。如果一个开发工具教会你不要相信它显示的内容——那就有大问题了。

3. 年度操作系统更新周期

苹果每年都会发布新的 iOS、macOS、watchOS、tvOS,现在还有 visionOS。Xcode 必须全部支持。

每一年,都得来一遍。

那什么事情从来没有发生过呢?

“我们停下来,重构一下,花三年时间把工具链做得坚如磐石吧。”

结果就是:每年六月都有新东西。新的 Bug。新的临时解决方案。但 Xcode 还是那个 Xcode。


为什么苹果就是不修复它?

简单的答案:他们没必要

苹果垄断了整个生态

如果你想做 iOS App,你就得用 Xcode。就这么简单。

没有竞争。没有替代品。除非能帮到设备销售,否则苹果没有动力去投资提升开发者的幸福感。

这就是为什么你会看到:

  • CI/CD\text{CI/CD} 流水线在底层也必须使用 Xcode
  • IDE 工作流程被绑定在专有的代码签名过程上
  • 一群沮丧的开发者社区,却仍然在不断地交付应用

开发者的痛苦不是 KPI\text{KPI}

苹果在乎的是:

  • iPhone 销量
  • 生态系统锁定
  • 服务收入
  • App Store 收入
  • WWDC 制造的热度

这个清单里,根本没有这条:

“这个季度 Xcode 导致开发者浪费了 300 小时在构建失败上吗?”

如果它不影响收入或公众形象——那就不是优先事项。

WWDC\text{WWDC} 文化:炫酷 > 稳定

苹果的文化是宣布“下一步是什么”。新的 API\text{API}。新的功能。新的框架。

没有人会站在台上说:

“Xcode 现在速度提升了 50%,崩溃减少了 60%。”

这不带感。这在推特上火不起来。

“Vision Pro 新 API\text{API}” 才能火。


平庸的隐性成本

Xcode 最糟糕的部分不是崩溃。而是我们已经把这种感受常态化了:

“做 iOS 开发,就是这样的。”

我们学会了:

  • 像条件反射一样删除 DerivedData\text{DerivedData} 文件夹
  • 避免某些重构,因为索引器可能会挂掉
  • 禁用预览,改用 print\text{print} 语句来调试
  • 接受模拟器崩溃是工作的一部分

这会以一种看不见的方式拖慢团队进度。

  • 它破坏了心流状态。
  • 它消耗了认知能量。
  • 它给新开发者灌输了错误观念:现代开发流程就该是这个样子。

而且它悄悄地赶走了人才。一些非常牛的工程师——尤其是做后端的人——试了一下 iOS,体验了一下 Xcode,然后就再也没回来了。


一个“不平庸”的 Xcode 应该是什么样的

让我们幻想一下。

一个很棒的 Xcode 应该:

  • 启动很快。而不是“卡顿 40 秒来重建模块”。
  • 提供值得信任的预览,即使失败也能体面地给出提示。
  • 给出人能看懂的构建错误,而不是用另一种语言抛出编译器崩溃。
  • 允许你直观地调试异步代码。
  • 让你可靠地进行内部整理:重构、依赖升级和诊断。

如果苹果愿意把它当作一款产品来重新构想,而不仅仅是作为一个依赖工具,它可能会是世界上最好的 IDE\text{IDE}

但这需要高层把这件事列为优先事项。


那么,我们能做什么?

我们没有万亿美元的预算。但作为开发者,我们手上有两个筹码:

1. 要求苹果负责

不是通过空洞的愤怒,而是通过清晰的沟通:

  • 提交详细的反馈。
  • 公开谈论真实的用户体验问题。
  • 别再美化开发者体验了。

沉默对任何人都没有好处。

2. 呼吁更好的开发者文化

让我们停止说:

“唉,Xcode 就那样。”

让我们开始说:

“这坏了。而且这不应该是常态。”

最后的想法

Xcode 不是我们的私人仇敌。它是系统性选择的结果——一个刚好能用来发布 iOS 应用的工具。

但我们值得拥有更好的。

我们为苹果带来了世界上最具创新性的应用。

我们在他们的平台上建立了数十亿美元的业务。

我们学习了 Swift、SwiftUI、Async/Await、Combine、Concurrency。

我们遵守了所有模式。学会了所有规则。买了所有 Mac。

难道我们要求 Xcode 能正常工作,过分吗?

不用花哨。不用神奇。只要可靠就行。

在苹果把 Xcode 当作一款值得完善的产品之前,我们得到的就会是现在这个样子:

一个打磨得无比精美的硬件生态系统……运行在一个开发者们多希望自己能修复的,摇摇晃晃的软件地基之上。

欢迎关注我的公众号:OpenFlutter

SwiftUI 状态管理极简之道:从“最小状态”到“状态树”

作者 unravel2025
2025年11月24日 08:50

为什么“状态”是 SwiftUI 的牛顿第三定律?

在物理学里,力与反作用力成对出现;在 SwiftUI 里,状态变化与UI 反应也成对出现。

用户每一次点击、每一次网络返回,都相当于给系统施加了一个“力”,而 UI 必须以某种“反作用力”做出响应。

因此,状态管理不是可选技能,而是 SwiftUI 世界的万有引力。

最小状态原则(Minimal State)

先记住一句“咒语”: “能让 UI 正确且及时响应的最少状态到底是哪些?

凡是不在这份清单里的数据,一律:

  • 计算得出 → 用 computed property
  • 可推导 → 不用 @State
  • 临时存活 → 用 let 或局部变量

这样做的好处:

  1. 减少无效刷新,提升性能
  2. 降低心智负担,代码更易读
  3. 为后续拆分模块、复用组件扫清障碍

状态载具速查表

属性包装器 作用域 典型用途 备注
@State 当前 View 私有 局部 UI 小数据(如 String、Bool、Int) 值类型
@Binding 父子共享 将“引用”传给子 View,使其能修改父数据 不拥有数据
@StateObject 当前 View 私有 创建并持有引用类型(如 ObservableObject) 生命周期与 View 一致
@ObservedObject 任意 View 外部传入的 ObservableObject 不创建,只引用
@Environment 全局 系统级值(如 colorScheme、locale 等) 通过 key 读取
@EnvironmentObject 全局 自定义共享对象 需提前注入

本文不讨论 MVVM / MVC / TCA 等架构,只聚焦“状态本身如何存在、如何流动”。

从 0 到 1:状态是如何“被声明”的?

静态单状态 —— 宇宙奇点

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

注释:没有 @State,UI 永远不变,宇宙一片死寂。

引入第一个 @State —— 宇宙大爆炸

struct ContentView: View {
    @State private var statefulText: String = "Stateful Text"
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text(statefulText)          // 依赖状态
        }
        .padding()
    }
}

注释:现在 UI 可以随 statefulText 变化而刷新,但用户还没法干预。

让用户当“上帝”—— 引入交互

struct ContentView: View {
    @State private var statefulText: String = "Stateful Text"
    
    var body: some View {
        VStack {
            Button {
                statefulText = "Ouch!"  // 用户施加“力”
            } label: {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
            }
            Text(statefulText)          // 自动反应
        }
        .padding()
    }
}

注释:状态变化 ⇄ UI 反应 的环路闭合,宇宙开始演化。

多状态宇宙:枚举扛起大旗

把“加载中/加载成功/错误/哲学生命周期”抽象成枚举,一次性把状态机搬进 UI:

// 1. 定义状态机
enum ViewState {
    case loading
    case loaded
    case error
    case whoami
}

struct ContentView: View {
    // 2. 最小状态:当前处于哪一步
    @State private var currentState: ViewState = .loading
    // 3. 仅 loaded 才关心的文字
    @State private var statefulText: String = "We did it!"
    
    var body: some View {
        Group {
            switch currentState {
            case .loading:
                ContentUnavailableView("One moment please...",
                                       systemImage: "hourglass")
            case .loaded:
                loadedUI
            case .error:
                ContentUnavailableView("Oops!",
                                       systemImage: "x.circle")
            case .whoami:
                Text("Existential Crisis!")
            }
        }
        .task {
            // 4. 模拟网络请求
            do {
                try await Task.sleep(nanoseconds: 2_000_000_000)
                currentState = .loaded
            } catch {
                currentState = .error
            }
        }
    }
    
    // 把 loaded 状态 UI 拆成 computed property,可读性更好
    private var loadedUI: some View {
        VStack {
            Button("点我改文字") {
                statefulText = "Ouch!"
            }
            Button("进入哲学模式") {
                currentState = .whoami
            }
            Text(statefulText)
        }
        .padding()
    }
}

注释:

  • switch 做穷尽式匹配,编译器帮你检查漏掉的状态
  • .task 修饰符在视图出现时自动执行,离开即取消,比 onAppear 更安全

状态树:像公司一样分部门治理

当父 View 既要管“加载枚举”又要管“文字细节”时,责任过重。

把只跟 loaded 相关的状态下放给子 View,父级只保留“导航级”状态,形成状态树:

struct ContentView: View {
    @State private var currentState: ViewState = .loading
    
    var body: some View {
        Group {
            switch currentState {
            case .loading:
                ContentUnavailableView("One moment please...",
                                       systemImage: "hourglass")
            case .loaded:
                LoadedView(currentState: $currentState) // 只传绑定
            case .error:
                ContentUnavailableView("Oops!",
                                       systemImage: "x.circle")
            case .whoami:
                Text("Existential Crisis!")
            }
        }
        .task {
            do {
                try await Task.sleep(nanoseconds: 2_000_000_000)
                currentState = .loaded
            } catch {
                currentState = .error
            }
        }
    }
}

// 子 View:只关心 loaded 世界
struct LoadedView: View {
    @Binding var currentState: ViewState // 需要回跳父级,用 Binding
    @State private var statefulText: String = "We did it!" // 局部状态
    
    var body: some View {
        VStack(spacing: 20) {
            Button("改文字") {
                statefulText = "Ouch!"
            }
            Button("哲学模式") {
                currentState = .whoami
            }
            Text(statefulText)
                .font(.title)
        }
        .padding()
    }
}

注释:

  • 父 View 代码量瞬间减半,职责单一
  • 子 View 可独立预览、独立测试,甚至一键抽成 Swift Package给别的项目用

递归地追问“最小状态”

状态树不是一层就够。如果 LoadedView 里再出现子模块(比如点赞数、评论列表),继续问自己:

  1. 这些子模块是否必须由父级驱动?
  2. 能否把“点赞数”做成 @StateObjectLikesService,通过 @EnvironmentObject 注入?
  3. 能否把“评论列表”做成纯粹 @State 的局部数组,只在进入评论区才初始化?

每一层都回答一次“最小状态”问题,复杂度就被递归地压扁。

实战扩展:把状态树做成“可插拔”模块

假设未来要加“夜间模式”全局开关:

@main
struct MyApp: App {
    @StateObject private var theme = ThemeService() // 遵循 ObservableObject
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(theme) // 一次性注入
        }
    }
}

任意深层子 View 只需:

struct DeepChild: View {
    @EnvironmentObject var theme: ThemeService
    
    var body: some View {
        Text("深夜模式自动响应")
            .foregroundColor(theme.labelColor)
    }
}

关键点:

  • 全局状态绝不放在根 View 的 @State,而是 @StateObject + @EnvironmentObject
  • 业务层继续遵循“最小状态”,不依赖全局主题即可独立运行

总结与 Checklist

  1. 先写“死”的 UI,再慢慢声明状态,而不是一上来就 @State 满天飞。
  2. 每一次加状态前,背一遍咒语:“这是让 UI 正确且及时响应的最小集合吗?”
  3. 当状态超过 3 个且互相耦合,立刻画状态树:
    • 谁能独立?→ 拆子 View
    • 谁需共享?→ 拆 ObservableObject
    • 谁只读?→ 用 Environment
  4. 把“枚举 + switch”当成状态机语法糖,穷尽所有 case,让编译器当你 QA。

学习文章

  1. captainswiftui.substack.com/p/swiftui-c…

Flutter iOS 项目 UIScene 迁移指南

作者 MaoJiu
2025年11月23日 23:08


一、UIScene 是什么?

在 iOS 13 发布时,Apple 引入了 UIScene,正式把一个 App 的生命周期从「单个进程」拆成了「多个场景(Scene)」。
简单理解:

旧时代(AppDelegate) 新时代(UIScene)
一个 App 只有一个窗口 一个 App 可以同时有多个窗口(iPad 分屏、外接屏、多任务)
所有 UI 生命周期都在 AppDelegate 每个窗口(Scene)都有自己的 SceneDelegate 管理生命周期
applicationDidBecomeActive 等方法全局生效 每个 Scene 独立触发 sceneDidBecomeActive

二、2025 年最硬的刀:iOS 26 强制适配 UIScene 警告

未进行适配的iOS项目,运行后会有一个红色的警告:

三、Flutter 项目 UIScene 迁移步骤

前置条件

# pubspec.yaml
environment:
  sdk: ^3.10.0
  flutter: ">=3.38.0" 

方式一:自动迁移(推荐先试)

flutter config --enable-uiscene-migration
flutter clean
flutter run

看到 Finished migration to UIScene lifecycle 就说明成功了,直接跳到最后测试即可。如果有自定义代码,可能会提示手动迁移。

方式二:手动迁移(自定义过 AppDelegate 的项目)

1. 改造 AppDelegate

  • Swift
import UIKit
import Flutter

@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    // 不要再在这里注册插件了!
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  // 所有插件注册必须搬到这里
  func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
    GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
    
    // 你原来的 MethodChannel、PlatformView、原生代码全部搬到这里
    let channel = FlutterMethodChannel(
      name: "your_channel",
      binaryMessenger: engineBridge.applicationRegistrar.messenger()
    )
  }
}

2. 添加 Application Scene Manifest

打开 ios/Runner/Info.plist,加入以下内容:

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <false/> <!-- 普通 App 保持 false -->
    
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneClassName</key><string>UIWindowScene</string>
                <key>UISceneDelegateClassName</key><string>FlutterSceneDelegate</string>
                <key>UISceneConfigurationName</key><string>flutter</string>
                <key>UISceneStoryboardFile</key><string>Main</string>
            </dict>
        </array>
    </dict>
</dict>

3. (可选)创建自定义 SceneDelegate

如果你原来在 AppDelegate 里写了前后台逻辑,全部搬到这里:

// SceneDelegate.swift
import UIKit
import Flutter

class SceneDelegate: FlutterSceneDelegate {
  
  override func sceneDidBecomeActive(_ scene: UIScene) {
    super.sceneDidBecomeActive(scene)
    // 原来 applicationDidBecomeActive 的代码
  }
  
  override func sceneWillResignActive(_ scene: UIScene) {
    super.sceneWillResignActive(scene)
    // 原来 applicationWillResignActive 的代码
  }
}

然后把 Info.plist 里的 UISceneDelegateClassName 改成:

<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>

4. 清理重建

flutter clean
cd ios && pod install --repo-update && cd ..
flutter run

四、Add-to-App (ATA) 项目迁移

如果你的 Flutter 是嵌入现有 iOS App(Add-to-App),迁移更复杂,因为需要手动管理 FlutterEngine 和场景生命周期。以下是详细步骤(基于 Flutter 官方指南):

1. 在 application:configurationForConnecting:options: 中设置 Delegate Class

在你的现有 AppDelegate 中,为连接的场景配置 FlutterSceneDelegate。这确保每个新场景使用 Flutter 的场景委托。

Swift 示例:

override func application(
  _ application: UIApplication,
  configurationForConnecting connectingSceneSession: UISceneSession,
  options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
  let configuration = UISceneConfiguration(
    name: nil,
    sessionRole: connectingSceneSession.role
  )
  configuration.delegateClass = FlutterSceneDelegate.self  // 设置为 FlutterSceneDelegate
  return configuration
}

Objective-C 示例:

- (UISceneConfiguration *)application:(UIApplication *)application
    configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession
    options:(UISceneConnectionOptions *)options {
        UISceneConfiguration *configuration = [[UISceneConfiguration alloc] initWithName:nil sessionRole:connectingSceneSession.role];
        configuration.delegateClass = [FlutterSceneDelegate class];
        return configuration;
    }

2. 禁用多场景支持(除非必要)

Info.plist 中设置 UIApplicationSupportsMultipleScenesNO,避免不必要的多窗口复杂性。除非你的 App 明确需要 iPad 分屏或外部显示器支持。

<key>UIApplicationSupportsMultipleScenes</key>
<false/>

3. 手动创建并运行 FlutterEngine

在 SceneDelegate 的 scene:willConnectToSession:options: 中手动创建 FlutterEngine,运行它,注册插件,并设置视图层次。Flutter 不会自动处理引擎创建。

Swift 示例(子类 FlutterSceneDelegate):

class SceneDelegate: FlutterSceneDelegate {
  let flutterEngine = FlutterEngine(name: "my flutter engine")  // 手动创建引擎

  override func scene(
    _ scene: UIScene,
    willConnectTo session: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions
  ) {
    guard let windowScene = scene as? UIWindowScene else { return }
    window = UIWindow(windowScene: windowScene)

    flutterEngine.run()  // 运行引擎
    GeneratedPluginRegistrant.register(with: flutterEngine)  // 注册插件
    self.registerSceneLifeCycle(with: flutterEngine)  // 注册场景生命周期

    let viewController = ViewController(engine: flutterEngine)  // 创建 FlutterViewController
    window?.rootViewController = viewController
    window?.makeKeyAndVisible()
    
    super.scene(scene, willConnectTo: session, options: connectionOptions)
  }
}

Objective-C 示例:

@interface SceneDelegate : FlutterSceneDelegate
@property (nonatomic, strong) FlutterEngine *flutterEngine;
@end

@implementation SceneDelegate

- (instancetype)init {
    if (self = [super init]) {
        _flutterEngine = [[FlutterEngine alloc] initWithName:@"my flutter engine"];
    }
    return self;
}

- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session
                                    options:(UISceneConnectionOptions *)connectionOptions {
                                        if (![scene isKindOfClass:[UIWindowScene class]]) {
                                            return;
                                        }
                                        UIWindowScene *windowScene = (UIWindowScene *)scene;
                                        self.window = [[UIWindow alloc] initWithWindowScene:windowScene];

                                        [self.flutterEngine run];
                                        [GeneratedPluginRegistrant registerWithRegistry:self.flutterEngine];
                                        [self registerSceneLifeCycleWithFlutterEngine:self.flutterEngine];

                                        ViewController *viewController = [[ViewController alloc] initWithEngine:self.flutterEngine];
                                        self.window.rootViewController = viewController;
                                        [self.window makeKeyAndVisible];

                                        [super scene:scene willConnectToSession:session options:connectionOptions];
                                    }
@end

4. 注册和注销场景生命周期

  • 注册(registerSceneLifeCycle) :在 willConnectToSession 中调用,确保引擎接收场景回调。
  • 注销(unregisterSceneLifeCycle) :在场景断开(如 sceneDidDisconnect)或切换时调用,避免内存泄漏。

Swift 示例:

// 注册
self.registerSceneLifeCycle(with: flutterEngine)

// 注销(例如在 sceneDidDisconnect 中)
self.unregisterSceneLifeCycle(with: flutterEngine)

Objective-C 示例:

// 注册
[self registerSceneLifeCycleWithFlutterEngine:self.flutterEngine];

// 注销
[self unregisterSceneLifeCycleWithFlutterEngine:self.flutterEngine];

5. 替代方案:实现 FlutterSceneLifeCycleProvider

如果无法直接子类 FlutterSceneDelegate,实现 FlutterSceneLifeCycleProvider 协议,手动转发回调。

Swift 示例:

class SceneDelegate: UIResponder, UIWindowSceneDelegate, FlutterSceneLifeCycleProvider {
  var sceneLifeCycleDelegate: FlutterPluginSceneLifeCycleDelegate =
    FlutterPluginSceneLifeCycleDelegate()

  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
    sceneLifeCycleDelegate.scene(scene, willConnectTo: session, options: options)
    // 其他设置...
  }

  // 实现其他场景回调,如 sceneDidBecomeActive 等
}

6. 多场景支持

如果启用多场景,为每个场景单独创建 FlutterEngine,并在连接/断开时注册/注销。忘记注销可能导致重复回调或泄漏。

参考 Apple 指南:Migrating to the UIKit Scene-Based Life Cycle

五、Flutter 插件迁移

如果你的插件依赖 UI 生命周期(如权限请求、前后台切换),必须适配场景委托。并非所有插件都需要改;仅那些使用 UIApplicationDelegate UI 事件的才需更新。以下是详细步骤:

1. 更新 pubspec.yaml

确保插件支持新版本。

environment:
  sdk: ^3.10.0
  flutter: ">=3.38.0"

2. 采用 FlutterSceneLifeCycleDelegate

更新插件主类,实现协议以接收场景回调。

Swift 示例:

public final class MyPlugin: NSObject, FlutterPlugin, FlutterSceneLifeCycleDelegate {
  // 插件逻辑...
}

Objective-C 示例:

@interface MyPlugin : NSObject <FlutterPlugin, FlutterSceneLifeCycleDelegate>
// 插件逻辑...
@end

3. 注册插件为场景委托接收者

register(with:) 中,同时注册为 AppDelegate 和 SceneDelegate,支持向后兼容。

Swift 示例:

public static func register(with registrar: FlutterPluginRegistrar) {
  let instance = MyPlugin()
  registrar.addApplicationDelegate(instance)  // 保持 AppDelegate 支持
  registrar.addSceneDelegate(instance)  // 添加 SceneDelegate 支持
  // 其他注册,如 MethodChannel...
}

Objective-C 示例:

+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar {
    MyPlugin *instance = [[MyPlugin alloc] init];
    [registrar addApplicationDelegate:instance];
    [registrar addSceneDelegate:instance];
    // 其他注册...
}

4. 实现场景生命周期回调

实现协议方法,替换旧的 AppDelegate UI 事件。每个方法对应一个场景事件。

Swift 示例(关键方法):

// 场景连接(处理启动选项)
public func scene(
  _ scene: UIScene,
  willConnectTo session: UISceneSession,
  options connectionOptions: UIScene.ConnectionOptions?
) -> Bool {
  // 从 connectionOptions 处理启动 URL、UserActivity 等
  // 原来在 didFinishLaunchingWithOptions 的逻辑搬到这里
  return true
}

// 其他回调
public func sceneDidDisconnect(_ scene: UIScene) { /* 清理资源 */ }
public func sceneWillEnterForeground(_ scene: UIScene) { /* 前台准备 */ }
public func sceneDidBecomeActive(_ scene: UIScene) { /* 激活逻辑 */ }
public func sceneWillResignActive(_ scene: UIScene) { /* 暂停逻辑 */ }
public func sceneDidEnterBackground(_ scene: UIScene) { /* 后台处理 */ }
public func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) -> Bool { /* URL 处理 */ }
public func scene(_ scene: UIScene, continue userActivity: NSUserActivity) -> Bool { /* 续接活动 */ }
public func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) -> Bool { /* 快捷方式 */ }

Objective-C 示例(关键方法):

- (BOOL)scene:(UIScene*)scene willConnectToSession:(UISceneSession*)session options:(nullable UISceneConnectionOptions*)connectionOptions {
    // 处理启动选项
    return YES;
}

// 其他回调
- (void)sceneDidDisconnect:(UIScene*)scene { }
- (void)sceneWillEnterForeground:(UIScene*)scene { }
- (void)sceneDidBecomeActive:(UIScene*)scene { }
- (void)sceneWillResignActive:(UIScene*)scene { }
- (void)sceneDidEnterBackground:(UIScene*)scene { }
- (BOOL)scene:(UIScene*)scene openURLContexts:(NSSet<UIOpenURLContext*>*)URLContexts { return YES; }
- (BOOL)scene:(UIScene*)scene continueUserActivity:(NSUserActivity*)userActivity { return YES; }
- (BOOL)windowScene:(UIWindowScene*)windowScene performActionForShortcutItem:(UIApplicationShortcutItem*)shortcutItem completionHandler:(void (^)(BOOL))completionHandler { return YES; }

5. 迁移启动逻辑

将从 application:didFinishLaunchingWithOptions: 的逻辑移到 scene:willConnectToSession:options:,因为 UIScene 后 launchOptions 可能为 nil。

6. 测试与发布

  • 测试所有 UI 事件(如推送、权限)。
  • 保持 AppDelegate 注册以支持旧 App。

六、最后

做完以上,你的 Flutter iOS 项目就彻底拥抱了 UIScene 了。

官方文档:
docs.flutter.dev/release/bre…

适合iOS开发的一种缓存策略YYCache库 的原理

作者 sweet丶
2025年11月23日 23:21

YYCache 是 iOS 上一个高性能的缓存框架,它由内存缓存 YYMemoryCache 和磁盘缓存 YYDiskCache 两部分组成。

核心总览

YYCache 的核心设计目标是 高效、线程安全和高性能。它通过以下方式实现这一目标:

  1. 分层设计:内存缓存提供极速访问,磁盘缓存提供大容量存储。
  2. LRU 淘汰算法:两者都使用 LRU 算法来管理缓存项,确保高频数据留在缓存中。
  3. 数据结构优化
    • 内存缓存:结合 NSDictionary 和双向链表。
    • 磁盘缓存:结合 SQLite 和文件系统。
  4. 锁策略优化:使用 pthread_mutex 锁来保证线程安全,性能优于 @synchronizeddispatch_semaphore

为了更直观地理解其核心工作原理,我们可以用以下流程图来展示其数据结构和关键操作:

image.png

上图揭示了YYCache的核心架构,下面我们来详细拆解图中各个部分的工作原理。

一、YYMemoryCache (内存缓存) 原理

YYMemoryCache 使用了一种非常经典且高效的数据结构组合:双向链表 + 哈希表

1. 核心数据结构:_YYLinkedMapNode_YYLinkedMap

  • _YYLinkedMapNode:链表节点。
    @interface _YYLinkedMapNode : NSObject {
        @package
        __unsafe_unretained _YYLinkedMapNode *_prev; // 指向上一节点
        __unsafe_unretained _YYLinkedMapNode *_next; // 指向下一节点
        id _key;      // 缓存的键
        id _value;    // 缓存的值
        NSUInteger _cost;   // 开销成本(用于成本计算)
        NSTimeInterval _time; // 访问时间
    }
    @end
    
  • _YYLinkedMap:一个双向链表,用于管理所有节点。
    @interface _YYLinkedMap : NSObject {
        @package
        CFMutableDictionaryRef _dic; // 哈希表,用于O(1)的存取
        NSUInteger _totalCost;      // 总开销
        NSUInteger _totalCount;     // 总数量
        _YYLinkedMapNode *_head;    // 链表头(MRU,最近使用)
        _YYLinkedMapNode *_tail;    // 链表尾(LRU,最久未使用)
    }
    @end
    

2. 工作原理

存取过程与LRU管理

其工作流程可以精确地描述为以下步骤:

sequenceDiagram
    participant A as Client(客户端)
    participant M as YYMemoryCache
    participant D as _dic (哈希表)
    participant L as 双向链表

    A->>M: setObject:forKey:
    M->>D: 通过key查找节点
    alt 节点已存在
        M->>L: 更新节点value,将节点移至_head
    else 节点不存在
        M->>M: 创建新节点
        M->>D: 插入新节点
        M->>L: 将新节点插入至_head
        M->>M: _totalCount++, _totalCost++
        loop 超过限制(count/cost)
            M->>L: 移除_tail节点(LRU)
            M->>D: 删除对应key
            M->>M: 更新_totalCount, _totalCost
        end
    end

    A->>M: objectForKey:
    M->>D: 通过key查找节点
    alt 节点存在
        M->>L: 将节点移至_head
        M->>A: 返回value
    else 节点不存在
        M->>A: 返回nil
    end

线程安全YYMemoryCache 使用 pthread_mutex 锁来保证上述所有操作(_dic 的读写、链表的修改)的线程安全性。它在每个操作开始时加锁,结束时解锁。


二、YYDiskCache (磁盘缓存) 原理

YYDiskCache 的设计更为复杂,它采用了一种智能的混合存储策略,根据 value 的大小选择不同的存储方式,以在性能和空间之间取得平衡。

1. 核心思想:SQLite + 文件系统

  • SQLite 数据库

    • 存储所有的 元数据(key, 文件名,大小,访问时间等)。
    • 如果 value 很小(例如小于 16KB),直接将其作为 BLOB 数据存储在数据库的某一列中
    • 优势:对于小数据,读写非常快,并且数据库事务保证了操作的原子性。
    • 方便实现 LRU 淘汰算法,只需要通过 SQL 语句操作元数据即可。
  • 文件系统

    • 如果 value 很大(例如大于 16KB),则将其写入单独的文件,在数据库中只记录其文件名和路径。
    • 优势:避免大文件塞满 SQLite 数据库,导致性能下降。文件系统对于大文件的读写效率更高。

2. 工作流程

存储过程:

  1. 根据 key 在数据库中查询记录。
  2. 判断 value 的数据大小。
  3. 小数据:直接写入 SQLite 的 data 列。如果之前是文件存储,则删除对应的文件。
  4. 大数据:将数据写入一个文件,并在数据库的 filename 列记录文件名。如果之前 SQLite 的 data 列有数据,则清空。
  5. 更新数据库中的元信息(大小、访问时间等)。

读取过程:

  1. 根据 key 从数据库中查询记录。
  2. 如果记录中有文件名(filename 不为空),则从文件系统中读取该文件。
  3. 如果记录中没有文件名,则直接从数据库的 data 列读取数据。
  4. 更新访问时间:每次读取后,都会在数据库中更新该记录的 last_access_time 字段,这对于实现 LRU 至关重要。

淘汰机制:

  1. 当磁盘缓存的总大小或总数量超过限制时,触发清理。
  2. 通过一条 SQL 查询,按照 last_access_time 升序排列(最久未使用的在前),获取需要淘汰的项。
  3. 根据查询结果,如果该项有文件,则删除文件;最后,从数据库中删除该记录。

三、YYCache 的整体协作

  1. 写入缓存

    • 先写入 YYMemoryCache
    • 再异步写入 YYDiskCache
  2. 读取缓存

    • 首先在 YYMemoryCache 中查找,找到则返回并更新链表。
    • 如果内存中没有,则去 YYDiskCache 中查找。
    • 如果在磁盘中找到,则将其返回给用户,并根据需要(可配置)写回 YYMemoryCache,以便下次快速访问。

总结

特性 YYMemoryCache YYDiskCache
存储介质 内存 磁盘 (SQLite + 文件系统)
数据结构 双向链表 + 哈希表 数据库表 + 文件
线程安全 pthread_mutex 串行队列 + dispatch_semaphore
淘汰算法 LRU (链表移动) LRU (SQL 按时间排序)
性能 极快,O(1) 较快,对小数据优化好
容量 受内存限制 受磁盘空间限制

YYCache 的成功在于其对经典算法和数据结构的深刻理解,并结合 iOS 平台特性进行了精妙的工程优化,使其成为了一个非常出色和可靠的缓存组件。

昨天以前掘金 iOS

Re: 0x03. 从零开始的光线追踪实现-多球体着色

作者 壕壕
2025年11月23日 15:18

目标

上一节 已经实现一个球显示在窗口中央,这节的目标是显示多个球。

本节最终效果

image.png

先显示两个球

我们先来想像现实场景,假设你桌子有一个有显示器,此时你举起手机录屏,你能很直观认识到手机离你更近,显示器离你更远,你的眼睛就是那个摄像机,它发出的射线,肯定是先到手机,再到显示器。
现在我们代码做得事情就是,球就是“手机”,背景(天空)就是“显示器”,通过 intersect_sphere 我们可以计算出把“显示器”挡住的“手机”。

回到之前的代码,只显示一个球,也就是满足光线跟球相交时,就告诉 fragment shader 这里要应该显示某个颜色

if (intersect_sphere(ray, sphere) > 0) {
  return vec4f(1, 0.76, 0.03, 1);
}

现在我们要显示两个球,所以先弄一个数组。需要注意到,这里用 constant 是因为 MSL(Metal Shading Language)规定 Program scope variable must reside in constant address space(程序作用域的变量,必须放在常量地址空间),总之就是你要是写个函数外的常量,那就用 constant 把它放到常量地址空间去。

constant u32 OBJECT_COUNT = 2;

constant Sphere scene[OBJECT_COUNT] = {
  { .center = vec3f(0., 0., -1.), .radius = 0.5 },
  { .center = vec3f(0., -100.5, -1.), .radius = 100. },
};

声明结束后,在 fragment shader 函数内循环匹配光线相交。
我们把离咱们最近的值定义为 closest_t,初始值给个 Metal 内置的常量 FLT_MAX,它表示 float 的最大值(因为我们用了 float 类型),然后循环通过调用 intersect_sphere 计算的值 t 去更新 closest_t(因为 intersect_sphere 没匹配到会返回 -1,所以很显然我们要判断 t > 0.,同时要再判断下这个 t 是比已知最近的还要近的值,也就是要满足 t < closest_t)。

fragment vec4f fragmentFn(Vertex in [[stage_in]], constant Uniforms &uniforms [[buffer(1)]]) {
  // ...
  let ray = Ray { origin, direction };
  var closest_t = FLT_MAX;
  for (u32 i = 0; i < OBJECT_COUNT; ++i) {
    var t = intersect_sphere(ray, scene[i]);
    if (t > 0. && t < closest_t) {
      closest_t = t;
    }
  }
  if (closest_t < FLT_MAX) {
    return vec4f(1, 0.76, 0.03, 1);
  }
  return vec4f(sky_color(ray), 1);
}

于是就会显示

image.png

改颜色

这里因为我们设置的颜色是相同,所以连在一块根本分不清哪跟哪,所以我们可以让离得近得更亮,离得远的更暗,给原先设置的颜色再乘上一个值,saturate 这个是 MSL 内置的函数,作用是把小于 0 的转成 0,大于 1 的转成 1,在 [0,1][0, 1] 范围内的不变,等于讲,大的就乘多一点,小的就乘少一点,符合近得更亮,远得更暗的要求

fragment vec4f fragmentFn(Vertex in [[stage_in]], constant Uniforms &uniforms [[buffer(1)]]) {
  // ...
  let ray = Ray { origin, direction };
  var closest_t = FLT_MAX;
  for (u32 i = 0; i < OBJECT_COUNT; ++i) {
    var t = intersect_sphere(ray, scene[i]);
    if (t > 0. && t < closest_t) {
      closest_t = t;
    }
  }
  if (closest_t < FLT_MAX) {
    return vec4f(1, 0.76, 0.03, 1) * saturate(1. - closest_t);
  }
  return vec4f(sky_color(ray), 1);
}

现在我们能看到这个效果

image.png

实现目标效果

其实到这一步,只是换个颜色,为了实现目标效果,我们直接用 closest_t 作为基础值,在它的基础上转成颜色向量

if (closest_t < FLT_MAX) {
  return vec4f(saturate(closest_t) * 0.5);
}

这样就能实现最终效果


最后总结一下代码

#include <metal_stdlib>

#define let const auto
#define var auto

using namespace metal;

using vec2f = float2;
using vec3f = float3;
using vec4f = float4;

using u8 = uchar;
using i8 = char;
using u16 = ushort;
using i16 = short;
using i32 = int;
using u32 = uint;
using f16 = half;
using f32 = float;
using usize = size_t;

struct VertexIn {
  vec2f position;
};

struct Vertex {
  vec4f position [[position]];
};

struct Uniforms {
  u32 width;
  u32 height;
};

struct Ray {
  vec3f origin;
  vec3f direction;
};

struct Sphere {
  vec3f center;
  f32 radius;
};

constant u32 OBJECT_COUNT = 2;
constant Sphere scene[OBJECT_COUNT] = {
  { .center = vec3f(0., 0., -1.), .radius = 0.5 },
  { .center = vec3f(0., -100.5, -1.), .radius = 100. },
};

f32 intersect_sphere(const Ray ray, const Sphere sphere) {
  let v = ray.origin - sphere.center;
  let a = dot(ray.direction, ray.direction);
  let b = dot(v, ray.direction);
  let c = dot(v, v) - sphere.radius * sphere.radius;
  let d = b * b - a * c;
  if (d < 0.) {
    return -1.;
  }
  let sqrt_d = sqrt(d);
  let recip_a = 1. / a;
  let mb = -b;
  let t = (mb - sqrt_d) * recip_a;
  if (t > 0.) {
    return t;
  }
  return (mb + sqrt_d) * recip_a;
}

vec3f sky_color(Ray ray) {
  let a = 0.5 * (normalize(ray.direction).y + 1);
  return (1 - a) * vec3f(1) + a * vec3f(0.5, 0.7, 1);
}

vertex Vertex vertexFn(constant VertexIn *vertices [[buffer(0)]], uint vid [[vertex_id]]) {
  return Vertex { vec4f(vertices[vid].position, 0, 1) };
}

fragment vec4f fragmentFn(Vertex in [[stage_in]], constant Uniforms &uniforms [[buffer(1)]]) {
  let origin = vec3f(0);
  let focus_distance = 1.0;
  let aspect_ratio = f32(uniforms.width) / f32(uniforms.height);
  var uv = in.position.xy / vec2f(f32(uniforms.width - 1), f32(uniforms.height - 1));
  uv = (2 * uv - vec2f(1)) * vec2f(aspect_ratio, -1);
  let direction = vec3f(uv, -focus_distance);
  let ray = Ray { origin, direction };
  var closest_t = FLT_MAX;
  for (u32 i = 0; i < OBJECT_COUNT; ++i) {
    var t = intersect_sphere(ray, scene[i]);
    if (t > 0. && t < closest_t) {
      closest_t = t;
    }
  }
  if (closest_t < FLT_MAX) {
//    return vec4f(1, 0.76, 0.03, 1);
//    return vec4f(1, 0.76, 0.03, 1) * saturate(1. - closest_t);
    return vec4f(saturate(closest_t) * 0.5);
  }
  return vec4f(sky_color(ray), 1);
}

BLE 通信设计与架构落地

2025年11月22日 12:35

面向低功耗、稳定可靠 BLE 通信方案,从软件设计目标、方案选型到分层架构与关键流程,配套流程图与实现要点,直达量产质量与研发效率。

背景与目标

  • 背景:电机控制、状态采集、故障诊断与 OTA 升级都依赖移动端与设备端的低延迟、低功耗、稳定连接。
  • 目标:低功耗、快速连接、高可靠传输、应用层安全、可扩展协议、易维护 SDK。

架构总览

  • 分层清晰:UI → SDK → 协议 → BLE 客户端 → 设备固件 → 服务/状态机
  • 控制与状态分离:控制通道低延迟,状态通道稳定流式;OTA 独立不阻塞控制

截屏2025-11-22 12.45.56.png

方案选型

  • 传输:基于 GATT;命令走写入特征,设备通过通知特征上行响应与事件。
  • 编码:采用起止分隔符与转义机制,配合 XOR 校验保证帧边界与数据一致性;帧结构为 7E | cmd(1) | len(2) | payload | xor(1) | 7E
  • 命令集:包含应用版本查询、应用控制设置、仪表信息上报、控制响应等,区分查询型与设置型。
  • 解析:先进行包格式与校验验证,再按协议掩码解析为结构化的状态/响应模型。
  • MTU:优先协商更大 MTU 值,写入根据协商结果自动选择短写或长写以提升吞吐。
  • 服务与特征:定义稳定的服务 UUID 与写/通知特征 UUID;扫描阶段可结合常见服务进行辅助过滤提升发现成功率。

连接生命周期

  • 秒级发现、自动检测系统已连/已配对设备;MTU 协商后开启 Notify 再发指令
flowchart TB
  A[扫描广播] --> B{过滤设备}
  B -->|匹配UUID/厂商数据| C[建立GATT连接]
  C --> D[MTU协商]
  D --> E[订阅Notify特征]
  E --> F[开启通知]
  F --> G[正常通信]
  H --> I{断链?}
  I -->|是| J[退回扫描/优先重连]
  I -->|否| H

指令与应答

  • 上层采用统一命令模型构建请求,协议层负责组帧与发送;设备通过通知返回响应或状态,协议处理器完成验证与解析,上层按请求-响应模式匹配回调。
sequenceDiagram
  participant App
  participant SDK
  participant Device

  App->>SDK: send(Command)
  SDK->>SDK: 组帧(7E/转义/XOR)
  SDK->>Device: 写入数据包
  Device-->>SDK: 通知上行数据包
  SDK-->>SDK: 验证/解析为模型
  SDK-->>App: 回调(响应/状态)

服务与特征

  • 服务与特征保持稳定命名,便于跨端协作与维护;扫描阶段可结合通用服务过滤以提升发现效率。
  • 写入策略根据 MTU 协商结果选择短写/长写,兼顾效率与兼容性。
flowchart LR
  CtrlSvc[控制服务 0xFFF0] --> cmd_write[cmd_write: Write NR]
  CtrlSvc --> cmd_notify[cmd_notify: Notify]
  StateSvc[状态服务 0xFFF1] --> state_notify[state_notify: Notify]
  OTASvc[OTA服务 0xFFF2] --> ota_write[ota_write: Write]
  OTASvc --> ota_notify[ota_notify: Notify]

协议帧设计

  • 结构:7E | cmd(1) | len(2,BE) | payload(N) | xor(1) | 7E
  • 转义:对分隔符与转义字符采用双字节转义,保证帧边界不被误判。
  • 校验:使用 XOR 校验覆盖命令、长度与载荷,快速验证数据一致性。
  • 验证:先校验起止与最小长度,再重算校验对比,失败立即丢弃并上报错误。

安全策略(可选)

  • 基于明文帧 + 校验的基本可靠性方案;如需加强安全,可在载荷层加入签名与时间戳,或在连接建立后协商会话密钥并进行载荷加密与认证。

OTA 升级

  • 使用长写支持大数据包传输,结合 MTU 协商提升吞吐;分片与断点续传由上层控制,升级流程与控制通道隔离,保障常规通信不受影响。
flowchart LR
  Start[开始升级] --> Prepare[校验包/签名/版本]
  Prepare --> Switch[进入DFU模式/暂停控制通道]
  Switch --> Chunk[分片发送]
  Chunk --> Ack{应答/校验通过?}
  Ack -->|否| Retry[重传/断点续传]
  Ack -->|是| Commit[提交/应用新固件]
  Commit --> Reboot[重启/回到正常模式]
  Reboot --> End[升级完成]

低功耗策略

  • 广播:普通 300–500ms;待机 800–1200ms;事件触发短时提升
  • 连接:interval=30–50msslaveLatency=4–10timeout=4–6s
  • MTU:协商至 185,分片随 ATT 与机型差异自适应
  • 节流:状态合并与节流(100–200ms);小包合帧减少唤醒

关键实现要点

  • 重传窗口:大小 3–5;超时 300–500ms;指数退避防抖
  • 指令模型:区分必答控制与可跳过状态;明确重试与时限
  • 线程模型:移动端 BLE 回调线程与业务线程解耦;设备侧事件与控制循环分离
  • 指标与日志:连接时延、MTU、吞吐、丢包、重试、握手耗时、失败原因

调试与调优

  • 吞吐压测:不同 MTU 下 pps/重试率 对比;寻找最佳分片
  • 稳定性:高干扰场景(电机 PWM/金属环境)重连成功率与耗时
  • 兼容性:iOS/Android 栈差异(如 Android GATT 133);连接后强制 discoverServices
  • 低功耗验证:记录电流曲线,量化参数调整影响
  • OTA 可靠性:断电/断链恢复、双镜像回滚、进度一致性

常见坑与规避

  • iOS 后台:后台模式声明与速率控制;避免系统挂起
  • Android 缓存:特征缓存可能写旧;连接后重新 discoverServices
  • MTU 协商:必须在连接成功后;返回值可能不等于实际可用
  • Notify 订阅:先订阅再发指令;避免早期响应丢失
  • 多连接:一车一连;设备侧拒绝第二连接请求

实现思路要点

  • 命令模型统一、组帧规范化、解析结构化,方便扩展与回归测试。
  • 请求-响应严格时序:先订阅响应流,再发送请求,避免早期响应丢失。
  • 设备数据流按设备维度隔离,支持多设备并发管理与订阅清理。
  • 日志与指标贯穿链路:连接与协商、吞吐与丢包、解析与错误,辅助定位与调优。

结语

  • 核心在于稳定、可靠与可维护。围绕 GATT 传输、稳健的帧/转义/校验与清晰的请求-响应模式,既保证 eBike 场景的低功耗与高可靠,又为后续安全与功能扩展留足空间。

[WWDC 21]Detect and diagnose memory issues 笔记

作者 songgeb
2025年11月23日 12:25

developer.apple.com/videos/play…

概述

本Session主要解释了App内存由哪些部分组成,并介绍了可以使用Performace XCTests工具对内存问题进行排查、分析

Impact of Memory footprint

好的内存管理可以提升用户体验,可以表现在

  1. Faster application activation,因为内存控制的好,所以app进入后台时不易被系统终止,重新激活回到前台时也更快
  2. Responsive experience,更高的响应速度
  3. Complex workflows,内存控制的好则意味着可以增加更多更消耗内存的功能
  4. Wider device compatibility,控制好内存则可以兼容到更老的机器

Memory footprint

本小节主要介绍Memory footprint都是有哪些内容组成

Dirty memory consists of memory written by your application. It also includes all heap allocations such as when you use malloc, decoded image buffers, and frameworks.

Compressed memory refers to any dirty pages that haven't recently been accessed that the memory compressor has compressed. These pages will be decompressed on access.

Tools for profiling memory

Performance XCTests

可以使用XCTests去检查各个功能的性能表现,比如

  • Memory utilization,内存利用情况
  • CPU usage
  • Disk writes
  • Hitch rate,卡顿率
  • Wall clock time,功能的耗时情况
  • Application launch time

Memory utilization 示例

func testSaveMeal() {
    let app = XCUIApplication()
    let options = XCTMeasureOptions()
    options.invocationOptions = [.manuallyStart]
    measure(metrics: [XCTMemoryMetric(application: aapp)1
        options: options) {
        app.launch()
        startMeasuring()
        app.cells.firstMatch.buttons["Save meal"].firstMatch.tap()
        let savedButton = app.cells.firstMatch.buttons["Saved"].firstMatch
        XCTAssertTrue (savedButton.waitForExistendce(timeout: 30)
    }
}

上述代码检测的是点击“Save meal”按钮后内存的变化情况,变化情况如下图所示:

  1. Metric项可以选择不同的内存检测指标,如内存峰值还是普通内存值
  2. 底部柱状图表示是多次执行的情况
  3. Average项表示的是所选Metric的均值情况
  4. Baseline、Max STDDEV(最大标准差)则可以用来设置检测基线和上下浮动阈值
  5. 当检测结束后,可以通过Result查看本次检测结果变好了还是变差了

Diagnostic collection

Xcode 13中引入了两个有力的诊断数据Ktrace files和Memory graphs

执行Performance XCTests时可以开启他们

Ktrace files

是一种专用的文件类型,用于分析卡顿问题,可以用Instrument直接打开

详情可以参考

Memory graphs

某一时刻,App内存中所有对象及引用关系数据

  • 在日常使用Xcode debug App时,我们也能看到内置的Memory graphs功能
  • 下图为运行完XCTests后,结果中Memory graphs文件,也可以单独进行分析

Types of memory issues

内存问题类型有多种,本session中会介绍到Leaks(内存泄漏)和Heap size issues

Leaks

对于内存泄漏问题,可以使用leaks命令对钱文忠的Memory graphs文件进行分析,查找泄漏的代码堆栈、是否存在循环引用

Heap size issues

Heap allocations regressions

堆内存占用的劣化问题

为减少使用堆内存开辟空间导致内存占用劣化,我们可以这样做:

  • Remove unused allocations
  • Shrink overly large allocations
  • Deallocated memory you are finished with
  • Wait to allocate memory until you need it

Session中提到,官方提供了如vmmap等一系列命令对前面生成的Memory graphs文件分析

Fragmentation

Fragmentation译为碎片化

如何可以减少碎片化

  • Allocate objects with similar lifetimes close to each other
  • Aim for 25% fragmentation
  • Use autorelease pools
  • Pay extra attention to long running processes
  • Use allocations track in Instruments

也可以使用vmmap等命令查看碎片比例

  • 上图中FRAG是碎片比例
  • DIRTY SIZE表示碎片化导致的Dirty pages情况
  • DIRTY+SWAP FRAG SIZE表示的是碎片空间大小

总结

总结一下官方推荐的检测、诊断内存问题的最佳实践

第一步:先是检测

  1. 针对业务功能/场景编写Performance XCTests
  2. 设置baseline(基线)进行测试
  3. 如果发现有regression(劣化),则收集诊断数据(Memory graphs or Ktrace)

第二步:诊断

  1. 检查最易发现的内存泄漏
  2. 再使用各种命令查看是否有堆内存劣化

🔥 一句话解释 SNI

作者 忆江南
2025年11月22日 19:39

SNI 是 TLS 握手时,客户端提前告诉服务器:”我想访问的是哪一个域名“。

它的作用是让服务器在 TLS 握手阶段就能知道要给哪个域名的证书


🎯 为什么需要 SNI?

因为现在很多网站是 多域名共享同一个 IP

例如:

1.2.3.4 上托管了:
- api.a.com
- img.a.com
- pay.a.com

如果没有 SNI:

客户端连接 1.2.3.4:443 → TLS 握手开始
服务器此时 不知道你想访问哪个域名的证书
→ 就没法送对证书
→ 导致握手失败

所以 TLS 扩展 “SNI” 就诞生了。


🧠 SNI 在 TLS 握手里到底发生了什么?

TLS 1.2 / 1.3 都一样,基本流程是:

ClientHello:
    - TLS Versions
    - Cipher Suites
    - Extensions:
        - SNI = "api.example.com"   ← 就是这里!
        - ALPN (比如 h2/h3)
        -

服务器收到 ClientHello 后,看到 SNI:

哦,你访问的是 api.example.com!
那我给你发 api.example.com 的证书

然后握手继续。


🔥 SNI 与 “IP 直连(DNS Mapping)” 有什么关系?

这是你现在最关心的一点:

你做 DNS 优化时会把域名替换成 IP:

https://api.example.com/user
→
https://203.107.1.1/user   (IP直连)

如果你 不加 Host header

服务器会认为:

你要访问 203.107.1.1
那我发给你“203.107.1.1”对应的证书(基本不存在)
→ TLS 握手立即失败

所以你必须:

req.setValue("api.example.com", forHTTPHeaderField: "Host")

⚠️ 更重要的是:
“Host” header 会被 URLSession 自动用作 SNI 的域名

所以 TLS 握手会变成:

SNI = "api.example.com"

→ 即使你是连的 IP,TLS 仍然能拿到对应的域名证书
→ 握手成功

这就是 IP 直连 + HTTPS 能工作的原因。


🎯 用一句更工程化的话总结:

SNI 就是 TLS 握手阶段的 Host header。
客户端会在 ClientHello 里把这个域名告诉服务器,让服务器知道发哪一套证书。

基于 easy_rxdart 的轻量响应式与状态管理架构实践

2025年11月22日 12:48

面向 Flutter/Dart 的响应式与状态管理,easy_rxdart 提供统一的 Stream/Reactive 操作与 Model + InheritedWidget 组合,覆盖防抖/节流/去重、错误恢复、第三方库响应式封装。核心设计旨在减少样板代码、提升组合能力与可读性,让业务逻辑围绕流与状态自然生长,适配中小型到复杂场景的架构演进。

方案选型

  • 响应式核心:以 Stream 为主干,扩展操作符满足事件流需求;用 Reactive<T> 统一链式与组合语义。
  • 状态管理:Model 基于 Listenable,通过 EasyModel<T> 提供上下文,watch/read/listen 精准区分重建与副作用。
  • 第三方整合:通过扩展与工具方法,对 dio、权限、图片选择、存储等提供一致的响应式调用。
  • 取舍与对比:相较 BLoC,减少事件/状态样板,强调“以流为中心”的组合与直观 Model 触发;相较 Riverpod,更贴近 Flutter 机制(InheritedWidget + AnimatedBuilder),简单可控;需要跨层依赖时,用 EasyModelManager 做全局管理。

架构设计

  • 目录分层:
    • 核心:Reactive<T>、流操作扩展、ModelEasyModel<T>EasyStreamControllerSubject 包装。
    • 扩展:面向 Stream/Reactive/Widget/第三方库 的便捷操作。
    • 工具:debounce/throttle/distinct、时间/格式化、定时器组、网络/连接状态。
    • Mixin:应用与路由生命周期、订阅管理。
  • 模块职责:
    • Reactive<T>:包装 Stream<T> 提供 map/flatMap/where/combineLatest/zip/concat/listen,兼容 rxdart。
    • Model + EasyModel<T>:版本与微任务去重策略的最小重建;watch/read/listen 三分法。
    • Stream 扩展:debounceTime/throttleTime/distinctUntilChanged/retryWithDelay/withLatestFrom/buffer/window/sample/audit 等。
    • 管理与集成:EasyModelManager.lazyPut/get/put/delete/reset 全局依赖;第三方能力响应式化。

整体流程图

截屏2025-11-22 12.45.56.png

核心数据流

  • 事件输入:来自控件、网络、定时器、第三方库等。
  • 操作符链:集中完成过滤、限流、错误恢复与组合。
  • 状态触发:Model.notifyListeners() 驱动 UI 最小重建;toReactive 将状态投射为流用于组合。
  • 副作用订阅:无需重建时,用 listen 执行副作用。
flowchart LR
  UI[TextField / Gesture] --> S[Stream<String> / Stream<void>] --> O[debounceTime / throttleTime / distinctUntilChanged] --> M[map / flatMap / combine / zip] --> ST[Model 状态 或 Reactive 输出] --> R[rebuild 或 side-effect]

最小可用示例

定义模型

class CounterModel extends Model {
  int _count = 0;
  int get count => _count;
  void increment() {
    _count++;
    notifyListeners();
  }
}

提供与消费

EasyModel<CounterModel>(
  model: CounterModel(),
  child: Builder(
    builder: (context) {
      final model = EasyModel.watch<CounterModel>(context)!;
      return Column(
        children: [
          Text('${model.count}')
          ,
          ElevatedButton(
            onPressed: () => EasyModel.read<CounterModel>(context)?.increment(),
            child: const Text('Add'),
          ),
        ],
      );
    },
  ),
);

文本输入搜索流

final input = StreamController<String>.broadcast();

final searchStream = input.stream
  .debounceTime(const Duration(milliseconds: 300))
  .distinctUntilChanged()
  .flatMapValue((q) => fetchResult(q))
  .retryWithDelay(count: 3, delay: const Duration(milliseconds: 500));

searchStream.listen((items) {
});

状态到流的桥接

将模型状态投射为 Reactive<T>,用于组合或跨组件订阅。

final counterReactive = model.toReactive(() => model.count);
counterReactive.map((v) => 'Count: $v').listen((text) {
});

第三方集成示例(网络请求)

合理结合错误恢复与重试。

Stream<List<User>> getUsers() =>
  Stream.fromFuture(dio.get('/users'))
    .map((resp) => parseUsers(resp.data))
    .retryWithDelay(count: 2, delay: const Duration(seconds: 1))
    .onErrorReturnItem(<User>[]);

关键设计细节

  • 重建控制:Model 使用版本与微任务去重策略,避免短时间内重复触发。watch 触发构建,read 不触发构建,listen 用于副作用。
  • 订阅生命周期:控制器/Subject 包装统一“谁创建谁销毁”;Mixin 自动清理路由/应用生命周期绑定。
  • 错误治理:timeoutTime/retryWithDelay/onErrorReturn/onErrorResumeNext/defaultIfEmpty/materialize/dematerialize
  • 组合能力:merge/concat/combineLatest/zip/withLatestFrom;窗口与缓冲:windowCount/windowTime/bufferCount/bufferTime

典型场景落地

  • 输入框防抖搜索:debounceTime + distinctUntilChanged + flatMapValue + retryWithDelay
  • 滑动或点击行为治理:对交互加 debounce/throttle/distinct
  • 从状态驱动 UI:Model 维护最小状态集,EasyModel<T> 向下传递,构建边界清晰。
  • 复杂流编排:并发/序列/压缩三类组合,对应 merge/concat/zip

流程图:网络请求装配线

flowchart TD
  REQ[请求触发] --> F[Future -> Stream] --> RETRY[retryWithDelay] --> MAP[map / 解析] --> FALLBACK[onErrorReturnItem 或 defaultIfEmpty] --> OUT[输出到 Model / Reactive] --> UI[UI 重建 或 副作用]

性能与工程实践

  • 边界清晰:将“重建”与“副作用”拆分,避免过度重建。
  • 优先扩展操作符:用扩展而非手工逻辑,减少不可预期状态。
  • 错误兜底:所有外部 IO 流建议配置兜底值与重试策略。
  • 资源回收:统一关闭控制器与订阅;跨页面订阅用 Mixin 自动清理。
  • 可测试性:流管线易单测,模型可通过版本与哈希策略验证通知行为。

与主流方案的协作

  • 与 Riverpod 协作:外层管理依赖,内层用 Model + Reactive 做流编排与最小重建。
  • 与 BLoC 协作:保留既有事件/状态结构时,将副作用和组合逻辑沉到 Stream 扩展与 Reactive

适用边界

  • 最佳适配:事件主导交互、网络数据装配、轻到中型状态管理、端上能力整合。
  • 不适配:跨团队大型复杂域模型、严格 CQRS/DDD 的大规模事件场景,建议与更重型框架配合。

总结与落地建议

  • easy_rxdart 将响应式与状态管理统一到可组合的流与轻量模型之上,降低样板与心智负担。
  • 建议从“输入防抖 + 网络装配 + 模型驱动”起步,逐步引入窗口/缓冲与生命周期治理,避免一开始过度工程化。

实践清单

  • 输入框搜索:debounceTime + distinctUntilChanged + flatMapValue + retryWithDelay
  • 列表滚动埋点:throttleTime + mapNotNull + bufferTime
  • 登录态与页面联动:Model.toReactive + combineLatest2 + defaultIfEmpty
  • 网络兜底:timeoutTime + onErrorReturnItem + retryWithDelay

苹果悄悄上线网页版 App Store!官方出品调研竞品更方便~

作者 iOS研究院
2025年11月21日 14:26

苹果悄然推出了网页版 App Store(官网地址:apps.apple.com/cn),无需依赖 iOS 或 macOS 设备,只要打开浏览器,无论是安卓手机、Windows 电脑还是其他终端,都能轻松访问 App Store 的丰富内容。不过目前网页版仅支持浏览、搜索应用,屏蔽了下载功能改为了分享。

企业微信20251121-141812.png

核心亮点:多地区快速切换 + 全设备专区适配

网页版 App Store 最让人惊喜的,莫过于无门槛切换全球地区商店。用户只需修改网址中的两位地区代码(遵循《ISO 31666-1》标准,小写格式),就能一键跳转至对应国家 / 地区的 App Store,比如:

无需注册登录,也不用切换账号地区,就能直接查看目标地区的应用榜单、同类型产品分布,以及特定应用的价格、评分、用户评论等核心信息,操作简单到离谱。

同时,网页版几乎 1:1 复刻了移动端 App Store 的视觉设计和功能布局,还新增了设备专区切换功能—— 左侧菜单栏可直接选择 Mac、iPad、Vision、Watch 等设备,无需拥有对应硬件,就能直观查看应用在不同设备上的展示效果,比如 iPad 端的 5 图排版、Watch 端的适配界面等。

2.png

核心实用场景,精准匹配开发者与产品人核心需求

1. 出海竞品调研提速,摆脱第三方工具束缚

过去做海外市场调研,查看不同地区 App Store 的榜单动态、竞品详情,只能依赖点点数据这类第三方平台。不仅要完成登录流程,还面临加载迟缓、数据滞后的问题,部分关键数据甚至需要开通 VIP 才能解锁。网页版 App Store 直接打通全球地区商店通道,无需借助任何额外工具,就能实时获取目标地区的竞品核心信息,从榜单趋势到应用评分、评论、价格等详情全掌握,让出海调研效率大幅提升。

2. 多设备适配核查零门槛,独立开发者福音

独立开发者或小型团队往往难以配齐 Mac、iPad、Vision、Apple Watch 等所有苹果设备,给多设备适配调研带来阻碍。网页版的设备专区切换功能恰好解决了这一难题,左上角下拉菜单即可一键切换至对应设备的专属应用页面。想确认自家应用在 Mac 端的展示效果,或是调研 Watch 端的热门应用类型,只需打开浏览器就能直观查看,零成本完成多设备适配验证。

3. 跨平台无障碍访问,安卓 / Windows 用户不用再借设备

产品经理、运营人员常需调研 App Store 上的应用,但如果手边只有安卓手机或 Windows 电脑,此前只能向同事借用苹果设备才能完成。网页版 App Store 打破了设备系统限制,任何浏览器都能直接访问,跨平台即可轻松浏览应用详情,再也不用为查询一个应用地址而四处借设备。

4. 应用链接分享更高效,告别繁琐查找流程

运营或市场人员需要产品的 App Store 链接时,过去要么翻找存档文档,要么通过第三方平台搜索跳转后复制 URL。现在网页版提供了集中式的应用聚合入口,直接打开网页搜索应用名称,就能快速复制浏览器 URL 一键分享,甚至可让同事自行搜索获取,彻底省去反复沟通查找的麻烦,缩短信息传递路径。

5. 大屏交互体验升级,操作效率再提升

电脑端的大屏优势在网页版 App Store 中得到充分发挥,配合键盘输入搜索,操作比手机端更高效。无论是批量筛选竞品、同时对比多个应用详情,还是沉浸式浏览应用截图与用户评论,体验都更为流畅直观。这种差异就像网页版视频对比手机端,在信息获取和操作便捷性上都有明显提升,让应用调研和探索更省心。

结语

苹果这次低调上线的网页版 App Store,没有大肆宣传,却精准戳中了开发者、产品人、运营等群体的核心需求。它打破了设备和地区的限制,让 App Store 的内容触达更便捷,无论是竞品调研、跨设备适配查看,还是日常应用浏览、分享,都变得更高效、更省心。

对于开发者和产品人来说,这无疑是一份惊喜福利,也让我们看到了苹果在生态开放上的微小但重要的进步。如果你常需要和 App Store 打交道,不妨赶紧收藏网址,体验这份 “无门槛逛店” 的快乐~

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

[WWDC]Why is my app getting killed 学习笔记

作者 songgeb
2025年11月21日 10:34

WWDC2020-[Video]Why is my app getting killed?

该session重点讲解了iOS App在后台可能被系统终止的原因

同时也介绍了自iOS 14开始,MetricKit推出了新的能力和新的数据用以诊断和通缉App在前后台被系统终止的情况,即MXForegroundExitData和MXBackgroundExitData

iOS App在后台可能被系统终止的原因有:

  1. Crash

  2. Watchdog

  3. CPU resource limit

  4. Memory footprint exceeded

  5. Memory pressure exit(jetsam)

  6. Background task timeout

无论App在前台还是后台被系统终止,MetricKit都提供了诊断和统计数据,

  • 开发者一方面可以在程序中通过订阅MXForegroundExitData和MXBackgroundExitData来查看
  • 同时在Xcode Organizer中也可以查看,详细请参考下文

Crash

  • 在Xcode Organizer中可以查看崩溃信息,同时在代码中也可以通过MXCrashDiagnostic获取崩溃信息

Watchdog

  • 在App中一些关键的状态变化时(如App启动、前后台切换),系统Watchdog会设置超时限制(20s),如果超时时间内一直没有完成(也就是App卡住),App就会被终止
  • 这种问题预示着可能有死锁(如主线程中gcd sync行为)、无限循环代码逻辑(如无限递归调用)
  • 模拟器场景,或者连接debugger调试时不会触发Watchdog的终止行为
  • 在代码中可以通过MXCrashDiagnostic查看是否存在Watchdog终止App的情况

CPU resource limit

  • 当在在后台App持续消耗过高CPU资源时,系统会记录CPU异常信息
  • Xcode Organizer中可以查看,对应着Energy
  • 代码中可以通过MXCPUExceptionDiagnositic获取信息
  • 同时异常此时也会记录到MXBackgroundExitData中

Memory footprint exceeded

  • 如果要适配iPhone 6s以前的设备,要保证App的内存占用不要超过200MB

  • 当App进入后台,为尽可能降低系统因其他应用内存占用而把我们App杀死的可能性,最好让我们App内存占用降低到50MB以下

  • App在一些关键的过渡过程中(如启动、前后台切换),如果耗时过长(超过大概20s)Watchdog会终止App

    • 注意,当App连接debugger时,是不会被Watchdog终止的

Memory pressure exit(jetsam)

  • 当应用在后台时,其他应用占用了太大内存,系统为了给其他在前台的App足够的内存空间,会把在后台的应用杀死,也叫做被系统(丢弃)jetsam了

  • jetsam事件并不意味着App有bug,这是系统的行为,但却预示着在前台的App占用过多的内存

  • 如果我们的App被系统jetsam了该怎么办

    • 在App进入后台时保存好状态信息,如View Controller Stack、Draft input、Media playback position
    • 使用UIKit State Restoration相关的API,App即使被jetsam了,也会很快恢复到原来的样子
    • App在后台时尽量保持内存占用在50MB以下,被终止的概率会下降,但系统并不保证一定不会终止

Background task timeout

  • 对于短暂的且重要的后台任务(通过UIApplication.beginBackgroundTask执行的),如果没有执行endBackgroundTask或者任务时间太长,都会导致超时,超时时间大概30s,时间到达后,任务还未结束(endBackgroundTask),App就会被系统杀死。如果超时时间内结束,则可以正常的进入suspended状态
  • 把每个任务看做只有30s的炸弹的导火线,一旦App到了后台,导火线就被点燃了
  • 如果希望后台任务有更长的时间处理则要用Background Tasks框架
  • 关于iOS App进入后台后会发生什么可以参考--iOS App进入后台时会发生什么根据官方文档(Extending your app’s background exec - 掘金

参考

Swift 多线程读变量安全吗?

2025年11月21日 01:13

前文,我们讲了在 Rust 中多线程读 RefCell 变量不安全的例子(见 Rust RefCell 多线程读为什么也 panic 了?),同样的例子,如果在 Swift 中,多线程读变量安全吗?

先看测试用例:

class Object {
    let value: String
    init(value: String) {
        self.value = value
    }

    deinit {
        print("Object deinit")
    }
}

class Demo {
    var object: Object? = Object(value: String("Hello World"))

    func foo() {
        var tmp = object
        object = nil
        (0..<10000).forEach { index in
            DispatchQueue.global().async {
                do {
                    let v = tmp
                }
                usleep(100)
                print(index, tmp)
            }
        }
    }
}

let demo = Demo()
demo.foo()

多次运行后,没有崩溃

当我们读一个变量时,编译器会自动帮我们插入引用计数的逻辑,类似如下,当对象引用计数为 0 时会释放。

do {
    swift_retain(tmp)
    let v = tmp
    swift_release(tmp)
}

按 Rust 中读 RefCell 变量的思路分析看,Swift 在读变量时也会涉及 retain、release 来写引用计数,为什么 Swift 中不会崩溃呢?

我们来扒一下 Swift 的源码:github.com/swiftlang/s…

1) swift_retain

引用计数 +1,主要代码如下:

在这里插入图片描述

refCounts 表示引用计数,定义如下,可以看出 refCounts 是一个原子变量,这也是保证线程安全的关键。

class RefCounts {
  std::atomic<RefCountBits> refCounts;
  ...
}

masked->refCounts.increment(object, 1)对应函数如下: 在这里插入图片描述

有两处关键代码:

第一个红框表示读取当前引用计数,这是一个原子的读取。

第二个红框,表示 CAS(Compare-And-Swap)更新引用计数,这也是一个原子操作,逻辑如下:

  • **比较 (Compare)**:看内存中 refCounts 的当前值,是否还等于刚才读到的 oldbits
  • 如果相等,则交换:相等说明在计算期间,没有其他线程修改过它,则直接将内存中的值更新为 newbits,并返回 true,循环结束
  • 如果不相等,则重置:不相等说明在计算期间,有其他线程抢先修改了内存,此时会将 oldbits 更新为内存中那个最新的、被其他线程改过的值,并返回 false,继续循环,用新的 oldbits 再算一次

可以看出 swift_retain 中对引用计数的读写操作都是原子的。

2) swift_release

引用计数 -1,主要代码如下:

在这里插入图片描述

执行 -1 的代码如下:

在这里插入图片描述

和 swift_retain 很类似,包含两个步骤:

第一个红框是原子的读引用计数。

第二个红框是 CAS 原子的写引用计数。

另外,这里还有另一个点需要注意,swift_release CAS 写引用计数时,传的参数是std::memory_order_release

std::memory_order_release 的作用是避免指令重排,表示在该指令执行完成之前,在代码里写在该指令前面的所有内存操作,必须全部同步到内存中,绝对不允许重排到该指令之后执行。

举个例子:

假设线程 A 在使用对象,然后释放它:

// 线程 A
myObject.someData = 100 // 1. 写数据
// ... 使用完毕 ...
release(myObject)       // 2. 减少引用计数 (可能降为0)

如果没有 std::memory_order_release,CPU 或编译器可能会进行指令重排,把 1 和 2 的顺序颠倒,也就是说,可能先减少了引用计数,再写入数据。

如果发生这种情况,可能导致对一个已释放的对象进行写操作,导致崩溃(Use-After-Free)。

可以对比看下 swift_retain 时传入的参数是**std::memory_order_relaxed**,这是一种性能开销最小、限制最少的内存排序选择,它只保证这个操作本身是原子的,但不保证和其他代码的执行顺序。这是因为 retain 时不会导致对象释放,即使在引用计数写入后执行代码,也不会有影响。

更多内容,欢迎订阅公众号「非专业程序员Ping」!

Swift6 @retroactive:Swift 的重复协议遵循陷阱

作者 RickeyBoy
2025年11月20日 23:36

欢迎大家给我点个 star!Github: RickeyBoy

背景:一个看似简单的 bug

App 内有一个电话号码输入界面,在使用时用户需要从中选择注册电话对应的国家,以获取正确的电话区号前缀(比如中国是 +86,英国是 +44 等)。

Step 1:入口 Step 2:缺少区号 期望结果
image1.png image2.png image3.png

这是一个看似很简单的 bug,无非就是写 UI 的时候漏掉了区号,那么把对应字段拼上去就行了嘛。不过一番调查之后发现事情没有那么简单。

列表是一个公用组件,我们需要在列表中显示国家及其电话区号,格式像这样:"🇬🇧 United Kingdom (+44)"。所以之前在 User 模块中添加了这个extension:

    extension Country: @retroactive DropdownSelectable {
        public var id: String {
            code
        }
    
        public var displayValue: String {
            emoji + "\t(englishName) ((phoneCode))"
        }
    }

原理一看就明白,displayValue 代表的是展示的内容。但是最终结果展示错误了:明明将电话区号 ((phoneCode)) 拼在了上面,为什么只显示了国家名称:"🇬🇧 United Kingdom"?

代码可以编译。测试通过。没有警告。但功能在生产环境中却是坏的。

顺便说一下,什么是 DropdownSelectable?

DropdownSelectable 是我们 DesignSystem 模块中的一个协议,它使任何类型都能与我们的下拉 UI 组件配合使用:

    protocol DropdownSelectable {
        var id: String { get }           // 唯一标识符
        var displayValue: String { get } // 列表中显示的内容
    }

Part 1: extension 不起作用了

发现问题

经过调试后,我们发现了根本原因:Addresses 模块已经有一个类似的 extension

    // 在 Addresses 模块中
    extension Country: @retroactive DropdownSelectable {
        public var displayValue: String {
            emoji + "\t(englishName)"  // 没有电话区号
        }
    }
Step 1 Step 2
image4.png image5.png

Addresses 模块不需要电话区号,只需要国家名称。这对地址列表来说是合理的。

但关键是:Addresses extension 在运行时覆盖了我们 User extension。我们以为在使用 User 模块的extension(带电话区号),但 Swift 随机选择了 Addresses 的 extension(不带电话区号)。

这就是关键问题。

冲突:同时存在两个拓展协议

代码中发现的两处冲突的拓展协议:

在 User 模块中(我们以为在使用的):

    extension Country: @retroactive DropdownSelectable {
        public var id: String {
            code
        }
        public var displayValue: String {
            emoji + "\t(englishName) ((phoneCode))"  // ✅ 带电话区号
        }
    }

在 Addresses 模块中(实际被使用的):

    extension Country: @retroactive DropdownSelectable {
        public var id: String {
            code
        }
        public var displayValue: String {
            emoji + "\t(englishName)"  // ❌ 不带电话区号
        }
    }

两个模块都有各自合理的实现理由:

  • User 模块:电话号码输入界面需要电话区号
  • Addresses 模块:地址表单不需要电话区号,只需要国家名称

每个开发者都在实现需求时添加了他们需要的内容。代码编译没有警告,新需求测试通过,没人预料到会对旧的需求产生影响。

同时,确实 Swift 也是允许在不同模块中使用相同的 extension。那么到底发生了什么,我们又是如何解决的呢?

Part 2: 为什么会发生这种情况 - Swift 模块系统解析

要理解为什么这是一个问题,我们需要理解 Swift 的模块系统是如何工作的。有趣的是:通常情况下,在不同模块中有相同的 extension 是完全没问题的。但协议遵循是一个特殊情况。

正常情况:extension 在模块间通常工作良好

假设你为一个类型添加了一个辅助方法:

    // 在 UserModule 中
    extension Country {
        var displayValue: String {
            return emoji + "\t(englishName) ((phoneCode))"
        }
    }
    // 在 AddressesModule 中
    extension Country {
        var displayValue: String {
            return emoji + "\t(englishName)"
        }
    }

这完全可以!每个模块看到的是它自己的extension:

  • UserModule 中的代码调用 displayValue 会得到带 phoneCode 的结果
  • AddressesModule 中的代码调用 displayValue 会得到不带 phoneCode 的结果

为什么可以: 常规 extension 方法在编译时根据导入的模块来解析。Swift 根据当前模块的导入准确知道要调用哪个方法。

特殊情况:协议遵循是全局的

但协议遵循的工作方式不同。当你写:

    extension Country: DropdownSelectable {
        var displayValue: String { ... }
    }

你不只是在添加一个方法。你在做一个全局声明:"对于整个应用程序,Country 遵循 DropdownSelectable。"

所以当你创建两个相同的遵循时,会导致重复遵循错误

    // 在 UserModule 中
    extension Country: DropdownSelectable {
        var displayValue: String {
            return emoji + "\t(englishName) ((phoneCode))"
        }
    }
    // 在 AddressesModule 中
    extension Country: DropdownSelectable {
        var displayValue: String {
            return emoji + "\t(englishName)"
        }
    }

当你构建链接两个模块的应用时,Swift 编译器或链接器会报错,类似这样:

'Country' declares conformance to protocol 'DropdownSelectable' multiple times

Part 3: 引入 @retroactive 破坏了编译器检查

剩余问题:这怎么能编译通过?

基本上,如果我们遇到重复遵循错误,编译器会阻止我们。但是为什么这段代码可以正常存在?

一切问题都可以被归咎于 @retroactive

什么是 @retroactive?

在 Swift 6 中,Apple 引入了 @retroactive 关键字来让跨模块遵循变得明确:

    extension Country: @retroactive DropdownSelectable {
        // 让一个外部类型
        // 遵循一个外部协议
    }

你需要使用 @retroactive 当:

  • 类型定义在不同的模块中(例如,来自模块 A 的 Country
  • 协议定义在不同的模块中(例如,来自模块 B 的 DropdownSelectable
  • 你在第三个模块中添加遵循(例如,在 UserModuleAddressesModule 中)

为什么 @retroactive 会破坏编译器检查重复编译问题?

没有 @retroactive 的情况下,重复遵循已经是编译时错误。但有了 @retroactive,问题变得更加棘手 —— 因为现在你明确声明了影响整个应用运行时的东西,而不仅仅是你的模块。

当你写 @retroactive 时,你在说:

"我要为一个我不拥有的现有类型添加遵循,作用于整个 App。"

这意味着编译器允许你 追溯地/逆向地(retroactively) 为在其他地方定义的类型添加遵循。这很强大,但也改变了 Swift 检查重复的方式。

关键点:

Swift 在每个模块内强制执行重复遵循规则,但不跨模块。换句话说,编译器只检查它当前正在构建的代码。

  • 每个生产者模块(UserModule、AddressesModule)单独编译时是正常的(它只"看到"自己的遵循)。到目前为止是正常的。
  • 导入两者的消费者(至少你有一个,就是你的 app target!),会构建失败,因为它看到了两个相同的协议遵循

添加 @retroactive 之后:

使用 @retroactive,Swift 将一些检查推迟到链接时,所以两个模块都能成功编译,即使它们都在声明相同的全局遵循。

重复只有在链接之后才会变得可见,当两个模块都被加载到同一个运行时镜像中时 —— 而那时,编译器已经太晚无法阻止它了。

这就是为什么这些重复可以"逃过"编译器的安全检查,导致令人困惑的运行时级别的 bug。

运行时发生了什么

当链接器发现 (Country, DropdownSelectable) 有两个实现时:

  • 选项 A:UserModule 的实现(带电话区号)
  • 选项 B:AddressesModule 的实现(不带电话区号)

它只能注册一个。所以它根据链接顺序选择一个 —— 基本上是链接器首先处理的那个模块。另一个遵循会被静默忽略。

这解释了为什么 UserModule 的实现被忽略了。

Part 4: 解决方案 - 包装结构体来拯救

幸运的是我们有一个非常简单的修复方法:使用包装类型

解决方案模式

不要让 Country 本身遵循协议,而是包装它:

    // UserModule 示例
    struct CountryWithPhoneDropdown: DropdownSelectable {
        let country: Country
        var id: String { country.code }
        var displayValue: String {
            country.emoji + "\t(country.englishName) ((country.phoneCode))"
        }
    }
    // AddressModule 示例
    struct CountryAddressDropdown: DropdownSelectable {
        let country: Country

        var id: String { country.code }
        var displayValue: String {
            country.emoji + "\t(country.englishName)"
        }
    }
    // 使用方式
    countries.map { CountryWithPhoneDropdown(country: $0) }
    countries.map { CountryAddressDropdown(country: $0) }

Part 5: 预防 — 如何防止它再次发生

当然,如果想要不仅是修复这个问题,而是预防这个问题,那么可以通过在工作流程中添加静态分析CI 检查来轻松避免重复的 @retroactive 遵循。

这确保任何重复的 @retroactive 遵循在到达生产环境之前被发现,避免类似的运行时错误。

结语

这个 bug 根本不是简单的 UI 问题,想要彻底解决就需要深度理解 Swift 的运行机制。协议拓展可以跨模块重复,但协议遵循是全局的,@retroactive 叠加 Swift 的这种能力造成了这次的 bug。

一旦我们理解了这一点,修复就很简单了。

❌
❌