阅读视图

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

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

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 平台特性进行了精妙的工程优化,使其成为了一个非常出色和可靠的缓存组件。

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

目标

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

本节最终效果

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 通信设计与架构落地

面向低功耗、稳定可靠 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 笔记

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

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 的轻量响应式与状态管理架构实践

面向 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!官方出品调研竞品更方便~

苹果悄然推出了网页版 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 学习笔记

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 多线程读变量安全吗?

前文,我们讲了在 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 的重复协议遵循陷阱

欢迎大家给我点个 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。

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

让弹幕飞一会儿!一个轻量级iOS弹幕库的实现与使用

🚀 让弹幕飞一会儿!一个轻量级iOS弹幕库的实现与使用

本文完整源码地址:github.com/chengshixin…

🎯 前言

"前方高能!"、"233333"、"awsl"... 这些熟悉的弹幕是不是让你想起了在B站追番的快乐时光?弹幕已经成为现代视频应用的标配功能,它不仅能够增强用户互动,还能创造独特的社区氛围。

今天,我要为大家介绍一个我自己开发的轻量级iOS弹幕库——BarrageView!这个库不仅功能丰富,而且代码优雅,绝对是你在iOS应用中集成弹幕功能的不二之选!

✨ 功能特色

🎮 四大弹幕方向

  • 从右到左:经典模式,B站同款
  • 从左到右:反向思维,别具一格
  • 从上到下:竖屏专属,瀑布流效果
  • 从下到上:逆流而上,视觉冲击

🔄 两种播放模式

  • 单次播放:适合展示重要信息
  • 循环播放:营造持续的热闹氛围

🎨 全面自定义

  • 字体大小、颜色随心配
  • 背景透明度自由调节
  • 移动速度精准控制
  • 弹幕间距智能避让

🛠 快速上手

基本使用

// 创建弹幕视图
let barrageView = BarrageView(frame: CGRect(x: 0, y: 100, width: view.bounds.width, height: 200))

// 设置弹幕数据
barrageView.setBarrageData([
    "前方高能预警!",
    "这个功能太棒了!",
    "iOS开发者福音",
    "已star,感谢作者"
])

// 开始播放
barrageView.startBarrage()

高级配置

// 自定义样式和效果
barrageView.setDirection(.rightToLeft)
barrageView.setPlayMode(.loop)
barrageView.setSpeed(80.0)
barrageView.setFontSize(18.0)
barrageView.setTextColor(.red)
barrageView.setTextBackgroundColor(UIColor.black.withAlphaComponent(0.8))

🔧 核心实现原理

🎪 弹幕调度机制

BarrageView采用定时器+动画的双重调度机制:

// 定时器负责生成新弹幕
timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
    self?.createBarrageLabel()
}

// UIView动画负责移动效果
UIView.animate(withDuration: duration, delay: 0, options: .curveLinear) {
    label.frame.origin = endPoint
}

🚦 智能避让算法

为了防止弹幕重叠,我们实现了智能的位置检测:

private func isYPositionOccupied(y: CGFloat, labelHeight: CGFloat) -> Bool {
    for label in activeLabels {
        let labelMinY = label.frame.minY - 10  // 预留安全间距
        let labelMaxY = label.frame.maxY + 10
        
        if y > labelMinY && y < labelMaxY {
            return true  // 位置被占用
        }
    }
    return false  // 位置可用
}

⏸️ 流畅的暂停恢复

通过CALayer扩展实现精准的动画控制:

extension CALayer {
    func pauseAnimation() {
        let pausedTime = convertTime(CACurrentMediaTime(), from: nil)
        speed = 0.0
        timeOffset = pausedTime
    }
    
    func resumeAnimation() {
        let pausedTime = timeOffset
        speed = 1.0
        timeOffset = 0.0
        beginTime = 0.0
        let timeSincePause = convertTime(CACurrentMediaTime(), from: nil) - pausedTime
        beginTime = timeSincePause
    }
}

📱 实际应用场景

🎥 视频播放器

// 在视频播放时启动弹幕
func videoDidStartPlaying() {
    barrageView.startBarrage()
}

func videoDidPause() {
    barrageView.pauseBarrage()
}

func videoDidResume() {
    barrageView.resumeBarrage()
}

🎮 直播互动

// 收到新消息时实时添加弹幕
func didReceiveNewMessage(_ message: String) {
    var currentData = barrageView.barrageTexts
    currentData.append(message)
    barrageView.setBarrageData(currentData)
}

🎉 活动庆典

// 节日特效弹幕
func setupFestivalBarrage() {
    barrageView.setTextColor(.red)
    barrageView.setFontSize(20)
    barrageView.setBarrageData(festivalWishes)
}

🚀 性能优化技巧

内存管理

  • 使用weak self避免循环引用
  • 及时移除完成动画的标签
  • 合理控制同时显示的弹幕数量

动画优化

  • 使用curveLinear保证匀速运动
  • 避免频繁创建销毁对象
  • 复用UILabel减少内存分配

🔮 未来规划

  • 支持富文本弹幕
  • 添加弹幕点击事件
  • 实现3D弹幕效果
  • 支持弹幕轨道管理
  • 添加弹幕过滤机制

💫 结语

BarrageView不仅仅是一个工具库,更是我对iOS动画和用户体验的一次深度探索。通过这个项目,我学习到了:

  • 🎯 精准的动画控制:如何让弹幕平滑移动又不失性能
  • 🧠 智能的布局算法:如何避免弹幕间的"交通事故"
  • 🎨 优雅的代码设计:如何构建可扩展、易维护的架构

如果你对这个项目感兴趣,欢迎:

Stargithub.com/chengshixin…

🐛 提交Issue → 反馈bug或提出新功能建议

🔀 Pull Request → 一起让这个项目变得更好


让我们的应用也拥有B站一样的弹幕文化吧! 🎊

"弹幕虽小,却能承载万千情感;代码虽简,却能创造无限可能。"

本文由BarrageView作者撰写,转载请注明出处。

Apple更新App审核条款,严打擅自与第三方 AI 共享个人数据的应用

App审核条款变更

最近iOS开发者该都收到了Apple发来了更新审核条款的邮件,原文内容如下:

  • 1.2.1(a): This new guideline specifies that creator apps must provide a way for users to identify content that exceeds the app’s age rating, and use an age restriction mechanism based on verified or declared age to limit access by underage users.
  • 2.5.10: This language has been deleted ("Apps should not be submitted with empty ad banners or test advertisements.”).
  • 3.2.2(ix): Clarified that loan apps may not charge a maximum APR higher than 36%, including costs and fees, and may not require repayment in full in 60 days or less.
  • 4.1(c): This new guideline specifies that you cannot use another developer's icon, brand, or product name in your app's icon or name, without approval from the developer.
  • 4.7: Clarifies that HTML5 and JavaScript mini apps and mini games are in scope of the guideline.
  • 4.7.2: Clarifies that apps offering software not embedded in the binary may not extend or expose native platform APIs or technologies to the software without prior permission from Apple.
  • 4.7.5: Clarifies that apps offering software not embedded in the binary must provide a way for users to identify content that exceeds the app’s age rating, and use an age restriction mechanism based on verified or declared age to limit access by underage users.
  • 5.1.1(ix): Adds crypto exchanges to the list of apps that provide services in highly regulated fields.
  • 5.1.2(i): Clarifies that you must clearly disclose where personal data will be shared with third parties, including with third-party AI, and obtain explicit permission before doing so.

翻译过来就是:

  • 1.2.1(a): 这项新的指南明确规定,创作者应用(creator apps)必须提供一种方式,让用户能够识别超出应用年龄分级的内容,并使用基于验证或声明年龄的年龄限制机制来限制未成年用户访问这些内容。

  • 2.5.10: 此措辞已被删除(原措辞为:“应用不应提交带有空白广告横幅或测试广告。”)。

  • 3.2.2(ix): 澄清了贷款应用收取的最高年利率 (Maximum APR) 不得高于 36% (包括所有成本和费用),并且不得要求在 60 天或更短时间内全额偿还贷款。

  • 4.1(c): 这项新的指南明确规定,未经该开发者批准,您不得在您的应用图标或名称中使用其他开发者的图标、品牌或产品名称

  • 4.7: 澄清了 HTML5 和 JavaScript 小程序(mini apps)和迷你游戏(mini games) 属于该指南的管辖范围。

  • 4.7.2: 澄清了提供未嵌入二进制文件的软件的应用,未经 Apple 事先许可,不得向该软件扩展或暴露原生平台 API 或技术。

  • 4.7.5: 澄清了提供未嵌入二进制文件的软件的应用,必须提供一种方式,让用户能够识别超出应用年龄分级的内容,并使用基于验证或声明年龄的年龄限制机制来限制未成年用户访问这些内容。

  • 5.1.1(ix):加密货币交易所 (crypto exchanges) 加入到提供高度监管服务的应用列表。

  • 5.1.2(i): 澄清了您必须清楚地披露个人数据将与第三方(包括第三方 AI)共享的位置,并在共享之前获得明确的许可

值得一提的是,Apple新规首次明确要求,各类应用若要把用户个人数据提供给第三方 AI,必须事先公开说明并获得用户授权

苹果公司目前正着手调整其 App Store 政策,此举被视为对即将到来的 AI 时代,尤其是对 2026 年即将发布的全新、更智能的 Siri 的战略性准备。

据传闻,下一代 Siri 将具备更强大的跨应用语音操作能力,其部分核心技术据悉将由 Google 的 Gemini 模型提供支持。

政策调整背后的考量

苹果在此时更新开发者指南,主要目标之一是加强对用户隐私的保护,特别是要防止应用程序在用户不知情或未经同意的情况下,将个人数据传输给 AI 服务提供商或其他相关公司。

这次政策修改的关键意义不在于引入了全新的数据保护概念,而在于苹果首次将 AI 相关的企业和技术明确纳入了既有的监管框架

具体变化和影响

原有的审核规则 5.1.2 (i) 已经要求开发者在分享用户数据前必须透明披露并获得用户许可,并禁止未经允许地“使用、传输或分享”个人信息。这一规定是苹果为遵守如欧盟 GDPR、加州 CCPA 等全球隐私法规的重要举措,旨在确保用户对个人数据拥有控制权。违规应用将面临被下架的风险。

新版本在这一要求的基础上加入了更具针对性的明确措辞:开发者必须清楚说明个人数据会被提供给哪些第三方——包括第三方 AI,并且在数据共享操作发生前,必须获取用户的明确授权

这一变化预计将对那些依赖 AI 技术来收集、处理用户数据以提供个性化服务或特定功能的应用程序产生影响。然而,由于“AI”是一个广阔的范畴,既包含大型语言模型(LLM),也涵盖各种机器学习技术,目前尚不清楚苹果将以何种程度和力度去执行这一新要求。

Homebrew 5.0:并行加速、MCP 加持,与 Intel 的最后倒计时 -- 肘子的 Swift 周报 #0111

issue111.webp

🚀 《肘子的 Swift 周报》

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

Homebrew 5.0:并行加速、MCP 加持,与 Intel 的最后倒计时

几天前,我像往常一样在输入 brew update 后顺手执行了 brew upgrade。出乎意料的是,终端里突然出现了从未见过的画面——大量组件与工具并行下载、整齐排列、同时推进。短暂的惊讶之后,我才从新闻中得知:Homebrew 已经发布了 5.0 版本

此次更新内容相当丰富。除了默认启用并行下载外,还正式将 Raspberry Pi、ARM 迷你 PC、Windows ARM 上的 WSL2 等 ARM64/AArch64 设备纳入 Tier 1 支持,并新增多项指令与能力。其中,官方提供的本地 MCP 服务尤为引人注目。通过 brew mcp-server,开发者可以让 AI Agent 自动操作 Homebrew,意味着 brew 也顺利接入了正在兴起的 AI 工作流。这是一项颇具时代感的更新。

不过,并非所有消息都同样令人愉快。随着 macOS Tahoe 26 大概率成为最后一个支持 Intel x86_64 的版本,Homebrew 也相应调整了自身的支持策略:从 2026 年 9 月起,Intel Mac 将被降级为 Tier 3;到 2027 年 9 月(或更晚),对 Intel 的支持则可能完全终止。

不可否认,在过去十余年里,Intel 架构为苹果带来了庞大的“潜在用户”:它能原生运行 Windows,让许多本不属于 Mac 生态的用户因兼容性而选择苹果设备,为苹果的市场份额提供了关键支撑。如今,随着 Apple Silicon 的成熟,Intel Mac 注定会与 MOS 6502、PowerPC 等一同成为苹果硬件发展史上的重要篇章,在不久的将来缓缓落幕。

回顾历史,每次 CPU 架构转换都激发了苹果在产品设计上的创新灵感,催生出许多具有时代印记的经典产品——从 68k 时代的 Lisa 开创图形界面先河,到 PowerPC 时代的 iMac G3 半透明美学和彩色贝壳本,再到 Intel 时代的小白/小黑 MacBook 以及重新定义轻薄的 MacBook Air。在 M 系列芯片时代,Apple 在性能、能效与系统集成上实现了跨越式提升,但在硬件外观与工业设计语言上,尚未出现能够留下强烈时代烙印的革新之作。

期待尽早看到可以计入史册的新设计,别让我们等太久。

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

近期推荐

Grow on iOS 26:UIKit + SwiftUI 混合架构下的 Liquid Glass 适配实战

Grow 是一款在 173 个国家和地区获得 App Store 编辑推荐、拥有超过 18 万五星评价的健康管理应用。在适配 iOS 26 的 Liquid Glass 设计语言时,团队遇到了不少挑战:如何在 UIKit + SwiftUI 混合架构下实现原生的 morph 效果?如何精确控制 Scroll Edge Effect?如何处理自定义导航栏元素的动态尺寸?Grow 的开发者之一 Shuhari,分享了团队在这次适配过程中的实战经验。文章涵盖 Sheet、Navigation、Popover 等场景的改造方案,深入探讨 UIBarButtonItem 尺寸计算、CABackdropLayer 副作用处理等底层细节,还展示了如何利用 Core Text 创造“玻璃文字”效果。所有核心概念都配有完整的 Demo 工程


警惕参数化测试中的陷阱 (Pitfalls of Parameterized Tests)

参数化测试 (Parameterized Tests)是 Swift Testing 中颇具代表性的新特性,它让开发者能够在最小化重复代码的同时扩大测试覆盖范围,为同一逻辑轻松验证多组输入。然而 Alex Ozun 在大规模迁移实践中发现,这项功能虽然便捷,却也暗藏不少容易忽略的陷阱,甚至可能悄悄降低测试的有效性。文章结合多个示例展示了一些常见陷阱,并提出了如避免在 #expect 两侧重复使用测试参数、明确区分示例测试与属性测试等多项实践建议。


为任务显式指定“身份” (Task Identity)

在 SwiftUI 中,task / onAppear 会在视图“出现”时执行一次,但它们并不会像视图那样自动跟踪依赖——如果任务闭包依赖了某个状态,该状态变化后任务本身不会自动重新触发。Chris Eidhof 以加载远程图片为例,展示了这一容易被忽略的问题,并建议为任务显式指定“身份”(identity),例如使用 .task(id: url),让相关依赖(如 URL 或由多个值组合而成的复合标识)参与任务的重新执行条件,使 SwiftUI 能在依赖更新时取消旧任务并启动新任务。作者提醒,凡是在视图中使用 task / onAppear 时,都应确保相关的依赖已经体现在任务的身份(identity)中。


Objective-C API 引发的 Unicode 错误 (One Swift mistake everyone should stop making today)

Swift 已经诞生十年了,但在日常开发中开发者使用的很多 Swift API 仍只是对 Objective-C API 的简单包装,这可能会引发一些容易忽视的严重问题。Paul Hudson 在本文中就通过 replacingOccurrences(of:with:) 展示了这种情况:在处理由多个 Unicode 标量组成的字符(如国旗表情)时,该方法可能会“误拆”字符、匹配不存在的序列,从而生成完全错误的结果。Paul 的建议非常简单:在 Swift 中应优先使用原生的 replacing(_:with:),它能够正确地按字符语义处理 Unicode,避免这些诡异且难以排查的字符串错误。

随着 Foundation 在 Swift 社区重构完成,在 macOS 等平台上,对于具备类似功能的 API,通常应优先选择新 Foundation 中提供的 Swift 原生版本。这样不仅可以避免上述问题,而且也提前为跨平台做好准备。


和 Christian 一起学习 Swift 并发 (Learning About Swift Concurrency (from Matt Massicotte’s Blog) with a Zettelkasten)

Swift 的并发演进并非一帆风顺,引入 Approachable Concurrency 概念后,不同编译选项组合甚至可能得到完全不同的编译结果,理解成本也随之水涨船高。Christian Tietze 原本只打算做一个简短演示:展示如何使用卡片盒笔记法(Zettelkasten)来消化 Matt Massicotte 关于 Swift 并发的博客文章,结果在实作过程中不断撞见更深层的复杂性——例如:actor 无法直接满足带有 nonisolated 要求的 Sendable 协议,除非显式将成员标记为 nonisolatednonisolated(unsafe)。等他回过神来,视频已经录到了 80 分钟。

视频很好地呈现了“深入学技术”的真实面貌:不是线性的知识堆叠,而是充满困惑、假设以及有待日后用代码与文档验证的开放问题。同时也侧面证明,卡片盒笔记法非常适合应对 Swift 并发这类复杂且持续演进的主题,通过构建可搜索、可链接的笔记网络,承载理解在时间维度上的逐步收敛。


Claude Code Skills 功能介绍以及使用经验

Ein Verne 在本文中介绍了 Claude 新推出的 Skills 机制 —— 一种用于扩展 Claude 能力的模块化体系。相比 MCP、Slash Commands 和传统插件,Skills 更强调可组合性、可移植性以及对上下文窗口的友好使用方式。每个 Skill 都以独立文件夹的形式存在,包含名称、描述、操作指令(SKILL.md)、可执行脚本、参考文档与资源文件等。Claude 会在执行任务时自动扫描并匹配合适的技能,并通过“渐进式披露(Progressive Disclosure)”按需加载细节,从而显著降低上下文消耗。作者认为,Skills 本质上将“提示词工程”演进为“工作流工程”,让 Claude 从通用智能助手进一步迈向可维护的智能基础设施形态。


在 iOS 中集成 Rust:基于 UniFFI 的多平台工作流 (Multiplatform with Rust on iOS)

就像许多 Swift 开发者希望把代码带出苹果生态一样,iOS 本身也对其他开发语言保持着相当开放的态度。Tjeerd in 't Veen 在这篇文章中分享了一份详实的 Rust + iOS 集成指南,展示如何通过 Mozilla 的 UniFFI 将 Rust 代码优雅地接入到 iOS 项目中。UniFFI 能将 Rust 的 enum 自动映射为 Swift enum,并把函数名从 snake_case 转为 camelCase,让 Rust 模块在 Swift 侧看起来就像原生 API。

文章给出了一整套可落地的工作流:从创建 Rust 库、为多种 iOS 架构构建静态库、打包 XCFramework,到最终封装成 Swift Package,每一步都有详细说明与常见陷阱提示。这套方案不仅让 iOS 工程可以像使用普通 Swift 包一样消费 Rust 逻辑,也为后续在 Android 等平台复用同一份 Rust 代码打下了良好基础。

工具

VisualDiffer 2:从 Objective-C 到 Swift 的重生

Davide Ficano 将其经营多年的 macOS 文件对比工具 VisualDiffer 完全开源,并从 Objective-C 彻底重写为 Swift。这不是简单的语言迁移或 AI 辅助转换,而是一次从零开始的手工重构。

核心功能保持不变:

  • 🟩 直观对比 - 并排展示目录差异,用颜色标识新增、修改或缺失的文件
  • 🧩 深入分析 - 支持文件级别的逐行对比(基于 UNIX diff)
  • 🧹 智能过滤 - 自动排除版本控制文件(.git、.svn)和系统文件(.DS_Store)
  • 性能优化 - 支持多种对比策略,从快速的日期/大小对比到精确的逐字节对比

Reddit 上,作者坦言自己依旧非常欣赏 Objective-C,但 Swift 的潜力让他愿意承受迁移的巨大成本。UI 层(特别是 NSTableView 与 delegate 模式)的重写过程尤为艰难,早期充满了并发属性标注,但随着理解加深,Swift 的优势逐渐显现。


FSWatcher:高性能的 Swift 原生文件系统监控库

十里 在开发图片压缩工具 Zipic 时,需要实时感知图片文件变化以便进行及时处理,为此开发了 FSWatcher。这是一个基于 macOS/iOS 底层 kqueue 机制的文件系统监控库,采用事件驱动而非轮询方式,资源消耗极低。

核心特性:

  • 🎯 智能过滤:支持按文件类型、大小、修改时间等多维度过滤,并可链式组合
  • 🔍 预测性忽略:自动识别并跳过自身生成的输出文件(如 *_compressed.jpg),避免循环触发
  • 📁 递归监控:可监控整棵目录树,支持深度限制与排除规则
  • 现代 API:完整支持 Combine、async/await 以及传统闭包回调模式

该库非常适合作为图片处理流程的监听器、开发工具的热重载组件,或构建轻量化自动备份系统等需要实时文件变动感知的场景。


SFSymbolKit:零维护的类型安全 SF Symbols 库

市面上已有不少用于改进 SF Symbols 使用体验的库,但 LiYanan 的 SFSymbolKit 仍然颇具特色:所有符号与可用性信息都由工具直接从系统框架自动生成,一键即可完成更新,真正做到无需人工维护。

核心优势:

  • 数据源可靠:直接读取 /System/Library/PrivateFrameworks/SFSymbols.framework/,与系统 100% 同步
  • 完全自动化:运行 ./update_symbols.sh 即可更新,无需手动添加新符号
  • 版本感知:自动生成 @available 属性,编译时检查符号兼容性
  • 用户自助:任何人都可以在本地更新,不依赖作者发版

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

Swift 6 迁移常见 crash: _dispatch_assert_queue_fail

我的 Github:github.com/RickeyBoy/R…

大量 iOS 内容欢迎大家关注~

最近在将公司项目迁移到 Swift 6 的过程中,解决了好几个相似的 crash。关键字如下

    _dispatch_assert_queue_fail
    
    "%sBlock was %sexpected to execute on queue [%s (%p)]
    
    Task 208: EXC_BREAKPOINT (code=1, subcode=0x103546f18)

在这里记录和分享,希望遇到相似的问题之后能够更快的解决。

Crash 1 信息

image1.png

image2.png

原因与解决 1

首先根据 crash 的提示,可以清楚地知道:Block 预期在一个线程上执行,而实际在另一个线程上执行。第一反应:大概率是主线程 vs 后台线程之间的不一致导致的。

如果经常熟练处理 Swift 6 升级的小伙伴就知道,一定是有地方标记了 @MainActor,也就意味着相对应的方法一定是要在主线程上执行的;而实际阴差阳错,在后台线程执行了。

所以接下来可能需要找,到底哪里线程不一致呢?我们根据代码来寻找即可。

image3.png

不难找到,根据 Combine 调用链,可以发现其中一处对于 userPublisher 的监听时,选择了在 global 上去执行后面的操作,所以这里需要将这一个逻辑去掉。

于此同时,对于 userPublisher 的发布,我们也最好将其默认放在主线程上,因为他是和 UI 相关的,所以需要做这样的改动:

image4.png

坑点

目前是不是觉得好像这个类型的 crash 不算很难解决?没错,这个 crash 的提示相对清楚,知道大概原因后去找就相对容易了。

不过需要注意的是,当使用 Combine 框架遇上这类型的 crash 时,crash 断点只会发生在 Publisher 而不是 Observer 处,所以我们需要自己去寻找调用方,看下在哪里出现了线程使用不一致的问题。

Crash 2 信息

好的,那么同类型的一个 crash 再来看看:

image5.png

报错信息就不贴了,和上一个 crash 是一样的,都是:"%sBlock was %sexpected to execute on queue [%s (%p)]

这里可以看到,crash 断点处在子线程,也是在 AsyncStream 发布处断点。那么根据经验推断,可以大概知道原因:

  1. 此处发布的时候,处在子线程
  2. 下游调用方,一定有某个地方要求在主线程
  3. 实际线程与要求线程不一致,所以导致 crash

原因与解决 2

这里寻找过程就不赘述了。原来的发布者是处在子线程,而后面的监听者处在主线程,因此需要改在主线程发布。

image6.png

Swift 一个小型游戏对象模型渐进式设计(五)——Swift 并发世界:把 Attackable 搬进 actor

为什么“并发”突然成了刚需

真实场景里:

  • 游戏服务器:32 条网络线程并发处理玩家技能;
  • 客户端:主线程发动画,后台线程算伤害,Timer 触发 dot;
  • 单机多核:SceneKit 物理回调、Vision 识别、Swift Concurrency Task 同时读写同一 BOSS 的血量。

如果还用传统锁:

objc_sync_enter(self)
hp -= damage
objc_sync_exit(self)

轻则性能抖动,重则死锁;而 Swift 5.5 起的 Actor 模型 把“互斥”升级为消息队列,编译期即可检查“跨 actor 引用是否安全”,让“数据竞争”成为编译错误。

Actor 101:30 秒速览

  1. 定义
actor Boss {
    var hp: Double = 100
    func takeDamage(_ amount: Double) {
        hp = max(0, hp - amount)
    }
}
  1. 调用规则
  • 内部:同步函数,直接访问 hp
  • 外部:必须通过 await 异步消息,编译器自动加队列。
let boss = Boss()
await boss.takeDamage(10)   // 编译通过
boss.hp                     // ❌ 编译错误:actor-isolated
  1. 关键保证

Actor 隔离域(isolation domain):同一时间只有一条消息在执行,天然“可线性化”(Serializability)。

把协议能力搬进 actor

目标:

  • 不破坏前两篇的泛型协议架构;
  • 让任何实体既能以“值语义”跑在单线程,也能以“ actor 引用”跑在多线程;
  • 客户端/服务器共用同一套算法。
  1. 定义并发版协议
/// 可并发受伤
protocol ConcurrentWoundable: AnyObject {
    associatedtype Value: NumericValue
    func takeDamage(_ amount: Value) async
    var currentHp: Value { get }
}

注意:

  • AnyObject 限制只让 class/actor 符合,因为需要共享引用;
  • 方法标记 async,调用方必须 await
  1. 让 actor 直接符合
actor ConcurrentBoss<Value: NumericValue>: ConcurrentWoundable {
    private(set) var hp: Value
    let maxHp: Value
    
    init(hp: Value, maxHp: Value) {
        self.hp = hp; self.maxHp = maxHp
    }
    
    func takeDamage(_ amount: Value) async {
        hp = max(Value(0), hp - amount)
    }
    
    nonisolated var currentHp: Value { hp }   // 只读快照,无需 await
}

nonisolated 关键字:编译器允许外部同步读取,但不能写。

  1. 并发安全暴击算法

把上篇的 DamageCalculator 泛型算法保持值语义,计算过程无锁;只有最后 takeDamage 进 actor 才排队。

let calc = AnyDamageCalculator(Double.self) { base in base * 1.5 }
let damage = calc.calculate(base: 50)          // 无锁计算
await boss.takeDamage(damage)                  // 一次消息

分离“计算”与“状态变更”:计算无锁、变更串行,兼顾性能与安全。

分布式 Actor:跨进程也能 “await boss.takeDamage”

Swift 5.9 起引入 distributed actor,同一语法即可跨进程/跨机器:

distributed actor RemoteBoss: ConcurrentWoundable {
    distributed func takeDamage(_ amount: Value) async {
        hp = max(Value(0), hp - amount)
    }
}

调用方:

let boss = try await RemoteBoss.resolve(id: bossID, using: .init())
await boss.takeDamage(30)

底层由 Swift gRPC 传输消息,开发者零成本获得分布式对象模型。

实战:并发 Boss 战模拟器

场景:

  • 4 个玩家并发放技能,伤害随机;
  • 1 个后台线程每 0.5 s 触发 dot;
  • 1 个渲染线程每帧读血量更新 UI;

代码:

protocol NumericValue: Comparable & Sendable {
    static func + (lhs: Self, rhs: Self) -> Self
    static func - (lhs: Self, rhs: Self) -> Self
    static func * (lhs: Self, rhs: Self) -> Self
    static func / (lhs: Self, rhs: Self) -> Self
    static func > (lhs: Self, rhs: Self) -> Bool   // 与标量乘
    init(_ value: Int)                               // 能从整数字面量初始化
}
extension Double: NumericValue {}

/// 可并发受伤
protocol ConcurrentWoundable: AnyObject {
    associatedtype Value: NumericValue
    func takeDamage(_ amount: Value) async
    var currentHp: Value { get }
}

// 1. 并发 BOSS
actor BossBattle: @preconcurrency ConcurrentWoundable {
    private(set) var hp: Double
    let maxHp: Double
    init(hp: Double) {
        self.hp = hp;
        self.maxHp = hp
    }
    
    func takeDamage(_ amount: Double) async {
        hp = max(0, hp - amount)
        if hp == 0 { print("BOSS 被击败!") }
    }
    
    var currentHp: Double { hp }
}

// 2. 玩家技能
func playerTask(id: Int, boss: BossBattle) async {
    for _ in 0..<5 {
        let damage = Double.random(in: 5...15)
        await boss.takeDamage(damage)
        print("Player\(id) 造成 \(damage)")
        try? await Task.sleep(for: .milliseconds(.random(in: 100...300)))
    }
}

// 3. dot 后台
func dotTask(boss: BossBattle) async {
    while await boss.currentHp > 0 {
        await boss.takeDamage(3)
        print("dot 3 点")
        try? await Task.sleep(for: .milliseconds(500))
    }
}

// 4. 渲染线程(只读)
func renderTask(boss: BossBattle) async {
    while await boss.currentHp > 0 {
        let hp = await boss.currentHp
        print("UI 血量:\(Int(hp))")
        try? await Task.sleep(for: .seconds(1/60))
    }
}

// 5. 启动

Task {
    let boss = BossBattle(hp: 100)
    let _ = await withDiscardingTaskGroup { group in
        for i in 1...4 {
            group.addTask {
                await playerTask(id: i, boss: boss)
            }
        }
        
        group.addTask {
            await dotTask(boss: boss)
        }
        
        group.addTask {
            await renderTask(boss: boss)
        }
    }
}

运行结果(节选):

Player3 造成 11.0
Player1 造成 8.0
dot 3 点
UI 血量:78
...
BOSS 被击败!

全程无需手动加锁,编译器保证任何时刻只有一条消息在修改 hp

与 SwiftUI 无缝衔接

@MainActor
final class BossModel: ObservableObject {
    private let boss = BossBattle(hp: 100)
    
    @Published private(set) var hpText = ""
    
    func start() async {
        await renderLoop()
    }
    
    @MainActor
    private func renderLoop() async {
        while await boss.currentHp > 0 {
            hpText = "血量 \(Int(await boss.currentHp))"
            try? await Task.sleep(for: .seconds(1))
        }
        hpText = "BOSS 被击败"
    }
    
    func attack() async {
        await boss.takeDamage(Double.random(in: 10...20))
    }
}

@MainActor 保证所有 SwiftUI 状态更新跑在主线程;业务逻辑在后台 actor 串行执行,零数据竞争。

常见坑与最佳实践

  1. 在 actor 里访问全局可变状态

    同样要 await,否则编译报错。

  2. nonisolated 只能读,不能写;写必须走消息。

  3. 不要把长时间阻塞代码(sleep、sync I/O)直接放进 actor,会卡住消息队列;应拆到 Task.detachedAsyncSequence

  4. 跨 actor 调用时,值类型会被拷贝,不要传递大型数组;可改用 AsyncSequence 流式输出。

  5. 分布式 actor 的方法参数/返回值必须遵循 Codable,否则无法序列化

Swift 一个小型游戏对象模型渐进式设计(四)——类型擦除与 Existential:当泛型遇见动态派发

为什么“泛型”还不够

上一篇我们写出了这样的代码:

let calc: any DamageCalculator<Double> = CritCalculator(rate: 1.5)

它编译得快、跑得也快,但当你想把它存进数组、或者作为属性逃逸到运行时,就会遇到三个灵魂问题:

  1. 编译器不知道具体类型有多大,如何分配内存?
  2. 协议里有 associatedtype,为什么不能用 DamageCalculator 直接当做类型?
  3. 同样一句 calculate(base:),为什么有时走内联、有时走虚表?

答案都指向同一个机制:Existential Container(存在性容器),社区俗称“类型擦除盒”。

Existential 是什么

Swift 把“符合某个协议的值”打包成一种统一大小的盒子,这个盒子就叫 existential。

语法层面:

  • any Protocol // Swift 5.6+ 显式 existential
  • 老代码里的 Protocol // 隐式 existential,即将被逐步废弃

盒子内部到底长什么样?继续看。

Existential Container 的内存布局

以 64 bit 为例,标准布局 5 个字(40 byte):

+-------- 0:  value buffer (3 ptr = 24 byte)  
+--------24:  value witness table (VWT)  
+--------32:  protocol witness table (PWT)  
  1. value buffer

    • 小值(Int、Double、CGPoint…)直接内联;
    • 大值(String、Array、自定义 class)堆分配,buffer 存指针;
  2. VWT

    管理“值语义”生命周期:拷贝、销毁、搬移。

  3. PWT

    管理“协议方法”派发地址,相当于 C++ 的 vtable。

结论:哪怕只是一个 Double,装进 any NumericValue 后也会膨胀到 40 字节;如果频繁在数组里拷贝,就会带来隐式堆分配和缓存抖动。

关联类型协议的“额外”盒子

当协议带 associatedtype 时,existential 还需要一份通用签名(generic environment),用于在运行时保存类型元数据。

因此:

let x: any Attackable        // ❌ 编译错误:associatedtype Value 未定
let y: any Attackable<Int>   // ✅ Swift 5.9 新语法:parameterized existential

后者内部比“无关联类型”再多 8 byte,总计 48 byte。

苹果在 WWDC23 给出的性能警告:< 3 个 witness 方法且 value ≤ 24 byte 时,existential 才基本无额外开销;否则请考虑“手写类型擦除”或“泛型特化”。

实战:手写 AnyDamageCalculator

目标:

  • 对外暴露固定大小(无动态盒子);
  • 对内保存任意具体计算器;
  • 仍保持 Value 泛型参数。
  1. 定义抽象基类(引用语义)
class AnyDamageCalculatorBox<Value: NumericValue>: DamageCalculator {
    func calculate(base: Value) -> Value { fatalError("abstract") }
}
  1. 定义具体盒子(泛型类)
final class ConcreteBox<T: DamageCalculator>: AnyDamageCalculatorBox<T.Value> {
    private let concrete: T
    init(_ concrete: T) { self.concrete = concrete }
    override func calculate(base: Value) -> Value {
        concrete.calculate(base: base)
    }
}
  1. 定义值包装(对外类型)
struct AnyDamageCalculator<Value: NumericValue>: DamageCalculator {
    private let box: AnyDamageCalculatorBox<Value>
    
    init<C: DamageCalculator>(_ concrete: C) where C.Value == Value {
        self.box = ConcreteBox(concrete)
    }
    
    func calculate(base: Value) -> Value {
        box.calculate(base: base)
    }
}
  1. 使用:
let crit = CritCalculator(rate: 1.5)
let erased: AnyDamageCalculator<Double> = AnyDamageCalculator(crit)
array.append(erased)   // 数组元素大小 = 1 ptr,无 existential 盒子
  • 内存大小:8 byte(一个 class 指针);
  • 拷贝成本:一次 ARC retain;
  • 方法派发:虚表一次,但不再额外带 VWT/PWT。

Swift 5.9 新武器:Parameterized Existential

let list: [any DamageCalculator<Double>] = [
    CritCalculator(rate: 1.5),
    MultiplierCalculator(upstream: CritCalculator(rate: 2), multiplier: 1.2)
]

编译器会自动生成“隐藏盒子”,但仍带 48 byte 拷贝成本。

适合场景:

  • 原型阶段、快速迭代;
  • 对性能不敏感的工具代码;

高性能路径(渲染、音频、网络解析)继续用手写擦除或泛型特化。

类型擦除的通用套路(模板)

任何带 associatedtype 的协议,都可以套下面 4 步:

  1. 创建 AnyProtocolBase<AssociatedType> 抽象类;
  2. 创建 ConcreteBox<T: Protocol> 具体类,持有 T
  3. 创建 AnyProtocol<AssociatedType> 值类型,内部存 AnyProtocolBase 指针;
  4. 对外 API 全部 override / forward 到抽象类。

什么时候用哪种形态?

需求 \ 方案        泛型特化   any Protocol   手写擦除
------------------------------------------------------------
编译期已知类型       ✅          ❌             ❌
需要进数组/逃逸      ❌          ✅             ✅
对性能极度敏感       ✅          ❌             ✅
不想写样板代码       ✅          ✅             ❌(可用宏)

一句话:编译期能定类型就用泛型;运行时再决定就用擦除;原型阶段先 any 再说。

Swift 一个小型游戏对象模型渐进式设计(三)——把能力再抽象一层,写一套“伤害计算器”框架

为什么要“再抽象一层”

上两篇我们已经用协议把“攻击”拆成了能力插件,但遗留了一个硬核问题:

  • 游戏前期用 Int 足够,后期为了避免除法误差想换成 Double,甚至金融级精度要用 Decimal
  • 如果给每种数值类型都复制一份协议,就会出现 AttackableIntAttackableDouble…爆炸式增长。

Swift 的泛型(Generic)+ 关联类型(associatedtype)可以“一次性”写出算法,然后让编译器在调用点自动生成对应版本的代码,既保证类型安全,又保持运行时零成本。

把 Attackable 升级成泛型协议

  1. 定义“数值”契约

先约定一个“可运算、可比较”的基本协议,把 +*/> 等运算符包进去:

protocol NumericValue: Comparable {
    static func + (lhs: Self, rhs: Self) -> Self
    static func * (lhs: Self, rhs: Self) -> Self
    static func / (lhs: Self, rhs: Self) -> Self
    static func > (lhs: Self, rhs: Self) -> Bool   // 与标量乘
    init(_ value: Int)                               // 能从整数字面量初始化
}
  1. 让标准库类型自动符合

Swift 5.7 之后可以用 extension 给标准库类型“批量”实现:

extension Int: NumericValue {}
extension Double: NumericValue {}
extension Decimal: NumericValue {
    static func *(lhs: Decimal, rhs: Double) -> Decimal {
        lhs * Decimal(rhs)
    }
}

FloatCGFloat 同理)

  1. 泛型版 Attackable
protocol Attackable {
    associatedtype Value: NumericValue   // ① 关联类型
    func attack() -> Value
}

注意:

① 这里不能再给 attack() 提供默认实现,因为返回类型是泛型,不同数值的“默认伤害”语义不同;

② 如果确实想提供默认,可以再包一层泛型扩展

给“默认伤害”一个泛型实现

利用协议扩展的“where 子句”只对特定数值生效:

extension Attackable where Value == Double {
    func attack() -> Value { 10.0 }
}
extension Attackable where Value == Int {
    func attack() -> Value { 10 }
}
extension Attackable where Value == Decimal {
    func attack() -> Value { Decimal(10) }
}

这样任何符合者只要 Value 是上述三种之一,不实现 attack() 也能编译通过;想定制就再写一遍覆盖即可。

把“伤害计算器”也做成泛型组件

需求:

  • 支持“暴击”、“易伤”、“免伤”多层修正;
  • 算法写一次,对 Int / Double / Decimal 全部生效;
  • 编译期决定类型,无运行时派发。
  1. 定义计算器协议
protocol DamageCalculator<Value> {
    associatedtype Value: NumericValue
    /// 传入基础伤害,返回最终伤害
    func calculate(base: Value) -> Value
}
  1. 默认实现:暴击 * 1.5
struct CritCalculator<Value: NumericValue>: DamageCalculator {
    let rate: Value   // 暴击倍率
    
    func calculate(base: Value) -> Value {
        base * rate
    }
}
  1. 链式组合:装饰器模式
struct MultiplierCalculator<Value: NumericValue>: DamageCalculator {
    let upstream: any DamageCalculator<Value>  // 上游计算器
    let multiplier: Double
    
    func calculate(base: Value) -> Value {
        let upstreamDamage = upstream.calculate(base: base)
        return upstreamDamage * multiplier
    }
}

使用:

let crit: any DamageCalculator<Double> = CritCalculator(rate: 1.5)
let vulnerable = MultiplierCalculator(upstream: crit, multiplier: 1.2)  // 易伤 +20%
let final = vulnerable.calculate(base: 100)   // 100 * 1.5 * 1.2 = 180.0

把计算器塞进实体——“能力注入”

我们不再让实体“继承”伤害逻辑,而是把计算器当成属性注入:

struct Warrior<Value: NumericValue>: Attackable {
    let calculator: any DamageCalculator<Value>
    
    func attack() -> Value {
        let base: Value = Value(50)        // 自己定基础值
        return calculator.calculate(base: base)
    }
}

使用:

let warriorD = Warrior<Double>(calculator: vulnerable)
print(warriorD.attack())   // 90.0

一个文件里同时玩三种精度

let wInt    = Warrior<Int>(calculator: CritCalculator(rate: 2))
let wDouble = Warrior<Double>(calculator: CritCalculator(rate: 2))
let wDec    = Warrior<Decimal>(calculator: CritCalculator(rate: 2))

print(wInt.attack())     // 100
print(wDouble.attack())  // 100.0
print(wDec.attack())     // 100

同一套算法,编译器自动生成三份特化(specialization)代码,运行时无盒子、无动态派发。

性能实测:零开销承诺是否兑现?

测试环境:M1 Mac / Swift 5.9 / -O 优化

let p = Warrior<Double>(calculator: CritCalculator(rate: 1.8))
measure {
    for _ in 0..<1_000_000 { _ = p.attack() }
}

结果:

  • 泛型特化版本:0.047 s
  • 手写 Double 专用版本:0.046 s

差距在 2% 以内,属于测量误差;汇编层面已无线程堆分配、无 protocol witness 调用。

什么时候回到引用语义?

  1. 计算器需要状态缓存(如随机种子、CD 计时)且要共享;
  2. 需要继承 NSObjec 以兼容 KVO / Core Data;
  3. 需要互斥锁、原子引用计数。

其余场景继续 struct + 泛型协议

最终决策清单(速查表)

需求场景 首选方案 备选方案
只是多态 protocol 默认实现 class + override
多精度算法 泛型 protocol + associatedtype 宏/模板代码生成
共享可变状态 class actor
值语义 + 组合 struct + protocol
运行时动态替换 class + objc SwiftUI 的 AnyView 类型擦除
❌