普通视图

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

【AFNetworking】OC 时代网络请求事实标准,Alamofire 的前身

作者 探索者dx
2026年4月14日 00:39

【AFNetworking】OC 时代网络请求事实标准,Alamofire 的前身

iOS三方库精读 · 第 9 期


一、一句话介绍

AFNetworking 是 iOS / macOS 平台上最早也是最成功的 Objective-C HTTP 网络库,它让网络请求从繁琐的 NSURLConnection / NSURLSession API 变得简洁优雅,成为 OC 时代的事实标准。

属性
GitHub Stars 33k+
最新版本 4.0.0
License MIT
支持平台 iOS 9+ / macOS 10.10+ / watchOS / tvOS
维护状态 维护模式(Maintenance Mode)

二、为什么选择它

原生痛点(2011 年的 iOS 世界)

在没有 AFNetworking 之前,iOS 开发者不得不面对:

  • NSURLConnection 样板代码:每次请求都要实现 delegate 回调,代码分散难以维护
  • JSON 解析手动处理:iOS 5 之前没有 NSJSONSerialization,需要第三方库
  • 图片加载无缓存:网络图片每次都要重新下载,没有内存/磁盘缓存
  • 网络状态监听复杂:Reachability API 繁琐,代码量大
  • HTTPS 配置困难:自签名证书、SSL Pinning 需要深入了解安全 API

AFNetworking 核心优势

  1. 一行代码发起请求:GET / POST / PUT / DELETE 统一 API,无需 delegate
  2. 自动序列化:JSON / XML / Plist / Image 响应自动解析
  3. UIImageView CategorysetImageWithURL: 一行搞定网络图片加载
  4. Reachability 内建:实时监听网络状态,断网自动提示
  5. SSL Pinning 支持:证书校验一行配置,安全合规
  6. Block 回调:告别分散的 delegate,代码逻辑更清晰

原生 API vs AFNetworking

场景 原生 NSURLSession AFNetworking
GET 请求 10+ 行代码 + delegate [manager GET:success:failure:]
JSON 解析 NSJSONSerialization 手动调用 自动解析为 NSDictionary / NSArray
图片加载 需自行实现缓存 setImageWithURL: 一行
网络监听 Reachability 复杂 API setReachabilityStatusChangeBlock:
文件上传 构造 multipart 请求繁琐 POST:constructingBodyWithBlock:

三、核心功能速览

基础层 概念解释、环境配置、基础用法

环境配置

CocoaPods(OC 项目首选)

pod 'AFNetworking', '~> 4.0'

Swift Package Manager

// Package.swift
dependencies: [
    .package(url: "https://github.com/AFNetworking/AFNetworking.git", from: "4.0.0")
]

Swift 项目桥接

// {ProjectName}-Bridging-Header.h
#import <AFNetworking/AFNetworking.h>
#import "UIImageView+AFNetworking.h"

基础 GET 请求

// AFNetworking 4.x (OC)
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];

[manager GET:@"https://jsonplaceholder.typicode.com/users"
  parameters:nil
    progress:nil
     success:^(NSURLSessionDataTask *task, id responseObject) {
         NSLog(@"成功: %@", responseObject);
     }
     failure:^(NSURLSessionDataTask *task, NSError *error) {
         NSLog(@"失败: %@", error.localizedDescription);
     }];

进阶层 最佳实践、性能优化、线程安全

POST 请求带参数

NSDictionary *params = @{
    @"name": @"张三",
    @"email": @"zhangsan@example.com",
    @"age": @28
};

[manager POST:@"https://api.example.com/users"
   parameters:params
     progress:nil
      success:^(NSURLSessionDataTask *task, id responseObject) {
          NSLog(@"创建成功: %@", responseObject);
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          NSLog(@"创建失败: %@", error);
      }];

文件上传

NSData *imageData = UIImageJPEGRepresentation(image, 0.8);

[manager POST:@"https://api.example.com/upload"
   parameters:nil
    constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
        [formData appendPartWithFileData:imageData
                                    name:@"file"
                                fileName:@"photo.jpg"
                               mimeType:@"image/jpeg"];
    }
     progress:^(NSProgress *progress) {
         NSLog(@"上传进度: %.0f%%", progress.fractionCompleted * 100);
     }
      success:^(NSURLSessionDataTask *task, id responseObject) {
          NSLog(@"上传成功");
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          NSLog(@"上传失败: %@", error);
      }];

图片加载(UIImageView Category)

#import "UIImageView+AFNetworking.h"

// 基础用法
[imageView setImageWithURL:[NSURL URLWithString:@"https://example.com/avatar.jpg"]];

// 带占位图
[imageView setImageWithURL:[NSURL URLWithString:@"https://example.com/avatar.jpg"]
         placeholderImage:[UIImage imageNamed:@"default_avatar"]];

// 取消加载(cell 复用场景)
[imageView cancelImageDownloadTask];

网络状态监听

[[AFNetworkReachabilityManager sharedManager] setReachabilityStatusChangeBlock:
    ^(AFNetworkReachabilityStatus status) {
        switch (status) {
            case AFNetworkReachabilityStatusNotReachable:
                NSLog(@"无网络连接");
                break;
            case AFNetworkReachabilityStatusReachableViaWiFi:
                NSLog(@"WiFi 连接");
                break;
            case AFNetworkReachabilityStatusReachableViaWWAN:
                NSLog(@"蜂窝网络");
                break;
            default:
                break;
        }
    }];

[[AFNetworkReachabilityManager sharedManager] startMonitoring];

HTTPS 证书校验

manager.securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate];
manager.securityPolicy.allowInvalidCertificates = YES;  // 允许自签名
manager.securityPolicy.validatesDomainName = YES;

深入层 源码解析、设计思想、扩展定制

核心类关系

AFURLSessionManager (核心)
    ├── AFHTTPSessionManager (HTTP 封装)
    │       ├── requestSerializer (请求序列化)
    │       └── responseSerializer (响应序列化)
    ├── AFSecurityPolicy (安全策略)
    └── AFNetworkReachabilityManager (网络监听)

Category 扩展:
    UIImageView+AFNetworking (图片加载)
    UIProgressView+AFNetworking (进度条)
    AFNetworkActivityIndicatorManager (状态栏网络指示器)

Session Manager 核心设计

// AFHTTPSessionManager 继承 AFURLSessionManager
// 职责分离:网络层、请求构建、响应解析各自独立

@interface AFHTTPSessionManager : AFURLSessionManager
@property (nonatomic, strong) AFHTTPRequestSerializer <AFURLRequestSerialization> *requestSerializer;
@property (nonatomic, strong) AFHTTPResponseSerializer <AFURLResponseSerialization> *responseSerializer;
@end

// 序列化协议设计
@protocol AFURLRequestSerialization <NSObject>
- (NSURLRequest *)requestBySerializingRequest:(NSURLRequest *)request
                                withParameters:(id)parameters
                                         error:(NSError * __autoreleasing *)error;
@end

四、实战演示

场景:完整的网络请求封装

// APIClient.h - 统一网络请求封装
#import <AFNetworking/AFNetworking.h>

@interface APIClient : AFHTTPSessionManager
+ (instancetype)sharedClient;

// 业务方法
- (void)fetchUsers:(void(^)(NSArray *users, NSError *error))completion;
- (void)createUser:(NSDictionary *)params
        completion:(void(^)(NSDictionary *user, NSError *error))completion;
- (void)uploadAvatar:(UIImage *)image
           completion:(void(^)(NSString *url, NSError *error))completion;
@end

// APIClient.m
@implementation APIClient

+ (instancetype)sharedClient {
    static APIClient *instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        NSURL *baseURL = [NSURL URLWithString:@"https://api.example.com/"];
        instance = [[self alloc] initWithBaseURL:baseURL];
        
        // 配置请求/响应序列化
        instance.requestSerializer = [AFJSONRequestSerializer serializer];
        instance.responseSerializer = [AFJSONResponseSerializer serializer];
        instance.requestSerializer.timeoutInterval = 30;
        
        // 添加通用请求头
        [instance.requestSerializer setValue:@"application/json"
                             forHTTPHeaderField:@"Content-Type"];
        
        // HTTPS 配置
        instance.securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeNone];
    });
    return instance;
}

- (void)fetchUsers:(void(^)(NSArray *users, NSError *error))completion {
    [self GET:@"users"
      parameters:nil
        progress:nil
         success:^(NSURLSessionDataTask *task, id responseObject) {
             completion(responseObject, nil);
         }
         failure:^(NSURLSessionDataTask *task, NSError *error) {
             completion(nil, error);
         }];
}

- (void)createUser:(NSDictionary *)params
        completion:(void(^)(NSDictionary *user, NSError *error))completion {
    [self POST:@"users"
   parameters:params
     progress:nil
      success:^(NSURLSessionDataTask *task, id responseObject) {
          completion(responseObject, nil);
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          completion(nil, error);
      }];
}

- (void)uploadAvatar:(UIImage *)image
           completion:(void(^)(NSString *url, NSError *error))completion {
    NSData *data = UIImageJPEGRepresentation(image, 0.8);
    
    [self POST:@"upload"
   parameters:nil
    constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
        [formData appendPartWithFileData:data
                                    name:@"avatar"
                                fileName:@"avatar.jpg"
                               mimeType:@"image/jpeg"];
    }
     progress:nil
      success:^(NSURLSessionDataTask *task, id responseObject) {
          NSString *url = responseObject[@"url"];
          completion(url, nil);
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          completion(nil, error);
      }];
}

@end

五、源码亮点

进阶层 值得借鉴的用法

序列化器组合模式

// 请求序列化器可插拔
manager.requestSerializer = [AFJSONRequestSerializer serializer];   // JSON
manager.requestSerializer = [AFPropertyListRequestSerializer serializer];  // Plist

// 响应序列化器可插拔
manager.responseSerializer = [AFJSONResponseSerializer serializer];  // JSON
manager.responseSerializer = [AFXMLParserResponseSerializer serializer];  // XML
manager.responseSerializer = [AFImageResponseSerializer serializer];  // Image

Block 回调替代 Delegate

// 传统 delegate 分散在多个方法
// AFNetworking 用 block 聚合逻辑
[manager GET:url
  parameters:params
    progress:^(NSProgress *progress) {
        // 进度回调
    }
     success:^(NSURLSessionDataTask *task, id responseObject) {
        // 成功回调
    }
     failure:^(NSURLSessionDataTask *task, NSError *error) {
        // 失败回调
    }];

深入层 设计思想解析

NSURLSession 封装架构

// AFURLSessionManager 核心方法
- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request
                            completionHandler:(void (^)(NSURLResponse *, id, NSError *))handler {
    // 1. 创建 task
    NSURLSessionDataTask *task = [self.session dataTaskWithRequest:request];
    
    // 2. 存储 task delegate(用于回调)
    self.mutableTaskDelegatesKeyedByTaskIdentifier[@(task.taskIdentifier)] = delegate;
    
    // 3. 返回 task
    return task;
}

// task delegate 处理各种回调
- (void)URLSession:(NSURLSession *)session
          dataTask:(NSURLSessionDataTask *)dataTask
    didReceiveData:(NSData *)data {
    // 累积数据,最终一次性回调
    [mutableData appendData:data];
}

UIImageView Category 的缓存设计

// UIImageView+AFNetworking 内部使用 AFImageDownloader
// 自动实现内存缓存 + 磁盘缓存

@interface AFImageDownloader : NSObject
@property (nonatomic, strong) AFImageCache *imageCache;  // 内存缓存
@property (nonatomic, strong) NSURLCache *urlCache;       // 磁盘缓存
@end

// 缓存策略
- (void)downloadImageForURLRequest:(NSURLRequest *)request
                        completion:(void (^)(UIImage *, NSError *))completion {
    // 1. 先查内存缓存
    // 2. 再查磁盘缓存
    // 3. 最后发起网络请求
}

六、踩坑记录

问题 1:iOS 9+ ATS 限制

问题:iOS 9 默认要求 HTTPS,HTTP 请求被阻止。

原因:App Transport Security (ATS) 默认禁止明文 HTTP。

解决

<!-- Info.plist 添加例外 -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

问题 2:Response Serializer 编码问题

问题:返回中文乱码或解析失败。

原因:服务器返回非 UTF-8 编码,AFJSONResponseSerializer 默认 UTF-8。

解决

AFHTTPResponseSerializer *serializer = [AFHTTPResponseSerializer serializer];
serializer.stringEncoding = NSUTF8StringEncoding;  // 或其他编码
manager.responseSerializer = serializer;

问题 3:Cell 复用图片错乱

问题:UITableView 快速滚动时,图片显示错误。

原因:Cell 复用时未取消之前的下载任务。

解决

- (void)prepareForReuse {
    [super prepareForReuse];
    [self.imageView cancelImageDownloadTask];  // 取消之前的下载
    self.imageView.image = nil;
}

问题 4:后台任务未处理

问题:App 进入后台后,下载任务中断。

原因:NSURLSession 默认不支持后台。

解决

// 使用后台 session configuration
NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"com.example.background"];
AFHTTPSessionManager *manager = [[AFHTTPSessionManager alloc] initWithSessionConfiguration:config];

问题 5:Swift 混编桥接头文件缺失

问题:Swift 项目中使用 AFNetworking 报找不到模块。

原因:未配置 Objective-C Bridging Header。

解决

  1. 创建 {ProjectName}-Bridging-Header.h 文件
  2. 添加 #import <AFNetworking/AFNetworking.h>
  3. Build Settings → Swift Compiler - General → Objective-C Bridging Header 设置路径

七、延伸思考

AFNetworking vs Alamofire 横向对比

维度 AFNetworking Alamofire
语言 Objective-C Swift
首发年份 2011 2014
GitHub Stars 33k+ 41k+
最低版本 iOS 9.0 iOS 12.0
包体积 ~500KB ~300KB
维护状态 维护模式 活跃开发
Combine 集成 ❌ 不支持 ✅ DataRequest.publisher
async/await ❌ 不支持 ✅ async(...)
SwiftUI 支持 ❌ 不支持 需自行封装
学习曲线 中等 低(链式 API)
OC 兼容性 原生 需桥接

API 对照迁移表

AFNetworking (OC) Alamofire (Swift)
[manager GET:success:failure:] AF.request(url).response()
[manager POST:parameters:success:failure:] AF.request(url, method: .post, parameters: params)
[manager downloadTaskWithRequest:progress:destination:completion:] AF.download(url).downloadProgress()
[manager uploadTaskWithRequest:from:progress:completion:] AF.upload(multipartFormData:)
AFNetworkReachabilityManager NWPathMonitor (系统 API)
AFSecurityPolicy ServerTrustManager + PinnedCertificatesTrustEvaluator
UIImageView+AFNetworking AlamofireImage / Kingfisher

选型建议

选 AFNetworking:

  • 维护遗留 OC 项目,迁移成本高
  • 项目需要支持 iOS 9 ~ iOS 11
  • 团队 OC 技术栈成熟,Swift 经验少

选 Alamofire:

  • 新项目,Swift 为主要语言
  • 需要 Combine / async-await 集成
  • 想要更活跃的社区和持续更新
  • 最低支持 iOS 12+

渐进式迁移策略

  1. 共存阶段:OC 模块继续用 AFNetworking,新 Swift 模块用 Alamofire
  2. 抽象层:统一封装 NetworkService 协议,底层可替换
  3. 按模块迁移:优先迁移独立的业务模块,而非逐个请求修改
  4. 测试覆盖:迁移前后确保集成测试通过

八、参考资源

官方资源

相关文章

系列 Demo 仓库


本期互动

小作业

查看你现有的 OC 项目中 AFNetworking 的使用方式,尝试将一个简单的 GET 请求改写为 Alamofire 版本,对比代码量和可读性变化。

思考题

AFNetworking 从 1.x 到 4.x 经历了 NSURLConnection → NSURLSession 的底层迁移,如果你是核心开发者,你会如何设计 API 以保持向后兼容?

读者征集

你在从 AFNetworking 迁移到 Alamofire 的过程中遇到过哪些坑?欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ Lottie] [✓ MarkdownUI] [✓ SDWebImage] [✓ SnapKit] [✓ ListDiff] [✓ RxSwift] [→ AFNetworking] [○ Charts]

被 Vibe 摧毁的版权壁垒,与开发者的新护城河 -- 肘子的 Swift 周报 #131

作者 东坡肘子
2026年4月14日 07:56

issue131.webp

被 Vibe 摧毁的版权壁垒,与开发者的新护城河

Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用(当然,也有分析者指出,这一安排同样有助于防止模型蒸馏,并锁定企业级客户)。但即便这头“猛兽”被暂时按住,当前主流 AI 模型的代码能力,已经足以让复制一款产品变得轻而易举。

上周,Reddit 上一位开发者宣称,自己花了一年时间“逆向 SwiftUI API”,打造了一个全新的 Swift Web 框架。帖子行文流畅、术语考究,一度吸引了不少关注。但 Paul Hudson 很快现身评论区打假:所谓“独立研发”,实际上只是将他的 MIT 开源项目 Ignite 做了简单的字符串替换,甚至连原作者带有个人风格的代码注释都原封不动保留,随后将整个仓库压缩为一次提交以抹除历史,并违规改为具有传染性的 GPL 协议。社区中不少开发者都怀疑,这套“逆向 SwiftUI”的叙事本身也是由 AI 生成。更耐人寻味的是,该作者本就是 Ignite 的主要贡献者之一——当 Vibe Coding 将“重新打包一个项目”的成本降至极低时,“我参与过”本身也可能成为一种模糊责任边界的话术。

几乎在同一时间,macOS 上精致的 AI 工作状态监控应用 Vibe Island 在发布后不久便遭遇了像素级仿制。尽管仿制者打着“开源替代品”的旗号公开了代码,这依然对原作者的商业销售与创作热情造成了明显冲击。然而,即便作者希望采取法律手段,也将面临一个新的时代难题:在确权与维权过程中,他可能需要证明其作品具备足够的人类独创性,并说明 AI 生成内容的参与程度,否则将面临更高的不确定性。

事实上,代码的法律壁垒正从“确权端”开始松动。上个月,中国版权保护中心正式启用新版《计算机软件著作权登记申请表》及相关审查新规,明确要求经办人实名承诺“未使用 AI 开发编写代码、撰写文档或生成登记材料”,并在审查中重点评估人类智力投入是否达到著作权法所要求的“独创性”门槛;缺乏实质人类参与的内容,将难以获得确权。违规者还可能被纳入失信名单,并与个人征信挂钩。

这一趋势也与欧美近期的判例方向趋于一致:如果一段代码主要由 AI 根据提示词快速“改写或重组”生成,其获得著作权保护的可能性将显著降低。

我们必须承认一个残酷的事实:“我有一个绝妙的 Idea,并把它 Vibe 成代码”,已经不足以构成商业壁垒。这种名为 Vibe Coding 的新范式,不仅改变了开发流程、显著提升了效率,也从三个方向同时动摇了软件版权体系的基础逻辑:确权门槛提高、侵权举证变难、功能复刻被默许。

令人遗憾的是,即便这些克隆项目饱受争议,它们依然在 GitHub 上收获了不菲的 Star。这说明,在获取成本极低的前提下,仅靠道德呼吁,已经很难阻止人们对“免费平替”的追逐。

或许,正如我们在 第 120 期周报中讨论 Skip 的开源举措 时所提到的那样——在代码实现成本趋近于零、应用随时可能被 AI 一键克隆的当下,闭门造车“卖工具”将变得愈发困难。与用户建立真实连接,将“出品方的信誉与社区的信任”转化为不可复制的品牌资产,或许才是未来开发者真正的核心竞争力与护城河。

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

近期推荐

Swift Blog Carnival: Tiny Languages

Swift 社区正在发起第一届 Blog Carnival,四月的主题是 Tiny LanguagesChristian Tietze 邀请开发者围绕这一主题撰写博客:自定义 DSL、Result Builder、脚本解析器、路由规则……任何与“微型语言”相关的思考都可以作为切入点,截止日期为 5 月 1 日。

目前已有三篇投稿:

  • Matt Massicotte 回顾了他 从 Rake 到 Make,再到各类 Swift 任务运行器的探索历程,坦言至今仍未找到理想替代品
  • Chris Liscio 分享了 Capo 应用内置 DSL 的设计:用于描述键盘与 MIDI 绑定,基于 Point-Free 的 swift-parsing 库构建
  • Nicolas Zinovieff 则展示了一个 符号数学 DSL 的实验:通过协议与运算符重载,让 (1 + 2 * "X") * (3 - "Y")成为合法的 Swift 表达式,并在提供具体值时惰性求值,核心实现不超过 300 行

在 macOS 菜单中清晰显示当前选中状态 (Indicating Selection in macOS Menus Using SwiftUI)

SwiftUI 提供了不少用于表达选择的组件,例如 PickerToggleMenu,但如何清晰地引导用户进行选择,并准确标识当前选中项,并没有想象中那样理所当然。Gabriel Theodoropoulos 从最基础的 Button 出发,一步步演进到 PickerToggle,系统梳理了几种常见方案及其各自的局限。文章的价值不在于给出“唯一正确解”,而在于提醒开发者:SwiftUI 提供的标准组件,并不会自动带来最佳的用户呈现效果。很多时候,你仍需在“系统一致性”与“实现自由度”之间做出权衡。


构建 List 的替代组件 (Building List replacement in SwiftUI)

如何在 ListScrollView + LazyStack 之间做出选择,一直是困扰不少 SwiftUI 开发者的问题。在本文中,Majid Jabrayilov 在重构其 CardioBot 应用时,选择基于 SwiftUI 的 Container View API(iOS 17+)构建了三个可复用的 UI 组件:ScrollingSurface、DividedCard、SectionedSurface,以替代 List。这些组件在使用方式上与 List + Section 高度相似,但彻底摆脱了 listRowBackgroundlistItemTint 等仅在 List 中生效的限制。

List 并非只是“有默认样式的 LazyVStack”——两者在底层架构、滚动控制、与导航容器的协同、以及大数据集下的性能表现上均有本质差异。如何在两者之间做出综合判断,可以参考这篇旧文。


AppIntents meet MCP

当大家还在将 AppIntents 视为 Siri 与快捷指令的“配套工具”时,Florian Schweizer 给出了一个更值得关注的方向:将 AppIntents 直接暴露为 MCP(Model Context Protocol)工具,从而让 LLM 能够调用你的应用能力。Florian 基于 SwiftMCP,通过宏构建 MCP Server,并将 AppIntents 无缝映射为 MCP Tools,使 AI Agent 能够直接调用应用中的 Intent,实现跨应用自动化。

去年便有传闻,苹果正在为其生态系统引入 MCP 支持。或许再过两个月,在 WWDC 26 上答案便会揭晓。


创建交互式小组件 (Ride the Lightning Air: Building Interactive WidgetKit Widgets)

很多开发者都会被 WidgetKit 的文档误导,错将 AppIntentTimelineProvider 视为实现交互按钮的关键——实则不然。它是为“用户可配置 Widget”(如长按后编辑设置项)准备的,与交互行为并无直接关系。而真正用于实现交互(按钮点击触发行为)的,依然是最基础的 TimelineProviderWesley Matlock 通过一个虚构航空公司的登机状态 Widget,完整演示了正确路径:使用 TimelineProvider + Button(intent:) + AppGroup 共享存储来构建交互式 Widget。

整个数据流形成一个清晰的闭环:用户操作 → Intent 执行 → 状态写入 → Widget 刷新 → UI 更新。


文件存储与 iCloud:从本地到云端的完整认知

在 iOS / macOS 开发和使用中,文件存储往往被当作“基础能力”,但它实际上直接决定了数据的生命周期与系统行为。

Working with files and directories in iOS 一文中,Natascha Fadeeva 系统梳理了 App Sandbox 的结构,以及 DocumentsLibraryCaches 等目录的职责划分,帮助开发者建立“什么数据该放在哪里”的基本认知,并说明如何避免无关文件被 iCloud 备份。

Howard Oakley 撰写的 Understanding and Testing iCloud 则从系统层面揭示了这些数据的“后续命运”。iCloud 并非单一服务,而是由 CloudKit、iCloud Drive、系统更新等多个子系统组成,不同数据类型对应不同的同步与备份路径。

文件的存储位置,并不仅仅是组织问题,更是在定义它是否会被备份、同步,以及在设备之间如何流转。

因此,iCloud 问题往往不是“是否开启同步”这么简单,而是涉及客户端、网络、缓存以及服务端限流等多个环节。

工具

Bad Dock: 让你的 Dock 图标动起来

这是一个“离谱但严肃”的 macOS 实验项目。Eric Martz 利用公开的 NSDockTile / NSDockTilePlugin API,绕过 Big Sur 之后 Dock 图标的 squircle 限制,将视频流直接渲染进 Dock 图标。实现思路并不复杂,但非常完整:使用 AVAssetReader 解码视频、主动降帧至约 12fps、通过 ring buffer 控制内存占用,最终将一个“整活想法”打磨成了结构清晰的技术验证。

这类项目的价值不在功能本身,而在于展示:系统 API 的边界,往往比文档写得更远。

补充说明:该项目实现的是运行时动态 Dock 图标(应用运行时持续绘制);应用退出后,仅能通过 NSDockTilePlugin 保留静态自定义图标。

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

昨天以前首页

被 Vibe 摧毁的版权壁垒,与开发者的新护城河 - 肘子的 Swift 周报 #131

作者 Fatbobman
2026年4月13日 22:00

Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用

【DGCharts】iOS 图表渲染事实标准——8 种图表类型、高度可定制,3 行代码画出一条折线

作者 探索者dx
2026年4月13日 10:25

【DGCharts】iOS 图表渲染事实标准——8 种图表类型、高度可定制,3 行代码画出一条折线

iOS三方库精读 · 第 11 期


一、一句话介绍

DGCharts(原名 Charts)是一个用于 iOS/macOS/tvOS 的图表渲染库,它是 Android 端 MPAndroidChart 的 Swift 移植版,让在 UIKit 与 SwiftUI 中绘制专业级折线图、柱状图、饼图等 8 种图表类型变得像配置数据一样简单。

属性 信息
⭐ GitHub Stars 27.5k+
最新稳定版 5.1.0
License Apache 2.0
支持平台 iOS 13+ / macOS 10.14+ / tvOS 13+
语言 Swift(纯 Swift,无 OC 接口)

二、为什么选择它

原生痛点

苹果官方没有提供专用图表框架(Swift Charts 直到 iOS 16 才随 SwiftUI 一起发布),在此之前,iOS 开发者要想画一条像样的折线图,要么:

  • 用 CoreGraphics 手撸贝塞尔曲线 → 代码量大,动画难实现
  • 用 CALayer + CAAnimation → 正确实现坐标系变换极其麻烦
  • 引入 WebView 渲染 ECharts → 性能差、交互延迟

DGCharts 解决的核心痛点:

  1. 零 CoreGraphics 基础可上手:数据 → DataSet → Chart,三行完成
  2. 内置 8 种图表类型:折线、柱状、饼、雷达、散点、K 线、气泡、组合图
  3. 原生手势支持:缩放、拖拽、高亮点击,全部开箱即用
  4. 可定制到像素级别:颜色、字体、轴线、动画均可覆盖
  5. iOS 13+,Swift Charts 的前辈:大量存量 App 仍在使用,理解它有助于迁移评估

与 Swift Charts 的核心区别(一眼对比)

维度 DGCharts Swift Charts(苹果官方)
平台要求 iOS 13+ iOS 16+
UI 框架 UIKit + SwiftUI SwiftUI only
图表类型 8 种(含 K 线/雷达) 折/柱/面积/点(4 种)
动画控制 精细帧级控制 声明式,较难定制
手势交互 内置缩放/平移 需自己实现
维护状态 社区维护,活跃 Apple 官方,稳定更新

三、核心功能速览

基础层(新手必读)

环境集成

SPM(推荐):

// Package.swift 或 Xcode Add Package Dependency
// URL: https://github.com/danielgindi/Charts.git
// from: "5.1.0"

CocoaPods:

pod 'DGCharts', '~> 5.1.0'
最简用法:3 行画折线图
import DGCharts

let chartView = LineChartView(frame: view.bounds)

// 准备数据
let entries = (0..<10).map { ChartDataEntry(x: Double($0), y: Double.random(in: 10...100)) }
let dataSet = LineChartDataSet(entries: entries, label: "销售额")
chartView.data = LineChartData(dataSet: dataSet)

view.addSubview(chartView)

基础层 概念:ChartDataEntry 是最小数据单元(x, y),XxxChartDataSet 是同类数据的集合(样式配置在这里),XxxChartData 是图表的数据容器,XxxChartView 是最终渲染视图。


进阶层(最佳实践)

8 种图表类型一览
类名 用途
LineChartView 折线图(支持曲线插值)
BarChartView 柱状图(支持堆叠/分组)
PieChartView 饼图 / 甜甜圈图
RadarChartView 雷达图(蜘蛛网图)
ScatterChartView 散点图
CandleStickChartView K 线图(OHLC)
BubbleChartView 气泡图
CombinedChartView 组合图(折+柱叠加)
常用样式定制
// 折线数据集样式
let dataSet = LineChartDataSet(entries: entries, label: "趋势")
dataSet.colors = [.systemBlue]          // 线条颜色
dataSet.lineWidth = 2.0                  // 线宽
dataSet.circleColors = [.systemBlue]     // 数据点颜色
dataSet.circleRadius = 4.0               // 数据点半径
dataSet.drawFilledEnabled = true         // 开启渐变填充
dataSet.fillColor = .systemBlue
dataSet.fillAlpha = 0.3
dataSet.mode = .cubicBezier              // 贝塞尔平滑曲线

// 图表视图样式
lineChartView.rightAxis.enabled = false  // 隐藏右轴
lineChartView.xAxis.labelPosition = .bottom
lineChartView.animate(xAxisDuration: 1.0, easingOption: .easeInOutQuart)
交互设置
chartView.scaleXEnabled = true           // 允许 X 轴缩放
chartView.scaleYEnabled = false          // 禁止 Y 轴缩放
chartView.dragEnabled = true             // 允许拖拽
chartView.pinchZoomEnabled = true        // 双指缩放
chartView.doubleTapToZoomEnabled = false // 关闭双击缩放
X 轴自定义 Formatter
// 将数字索引转为日期字符串
class DateAxisValueFormatter: AxisValueFormatter {
    private let dates = ["1月", "2月", "3月", "4月", "5月", "6月"]
    func stringForValue(_ value: Double, axis: AxisBase?) -> String {
        let index = Int(value)
        return index < dates.count ? dates[index] : ""
    }
}
chartView.xAxis.valueFormatter = DateAxisValueFormatter()

深入层(源码视角)

DGCharts 的核心渲染架构遵循 Renderer 模式

  • ChartView → 入口,持有 ChartData 和多个 DataRenderer
  • DataRenderer(如 LineChartRenderer)→ 负责具体图形绘制,使用 CGContext
  • ChartViewBase → 提供手势识别、坐标变换 Transformer
  • Transformer → 负责将数据坐标系映射到屏幕坐标系(核心矩阵变换)

每次 data 被 set,触发 notifyDataSetChanged()calcMinMax()setNeedsDisplay(),最终回调 draw(_ rect:) 由各 Renderer 完成绘制。


四、实战演示

场景:股票日 K 线 + 成交量组合图

// Swift UIKit,iOS 17+
import UIKit
import DGCharts

class StockChartVC: UIViewController {

    private lazy var combinedChart: CombinedChartView = {
        let chart = CombinedChartView()
        chart.legend.enabled = true
        chart.rightAxis.enabled = false
        chart.xAxis.labelPosition = .bottom
        chart.animate(xAxisDuration: 0.8)
        return chart
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        view.addSubview(combinedChart)
        combinedChart.frame = CGRect(x: 16, y: 100,
                                      width: view.bounds.width - 32,
                                      height: 320)
        setupChart()
    }

    private func setupChart() {
        // ---- K 线数据 ----
        let candleEntries: [CandleChartDataEntry] = [
            CandleChartDataEntry(x: 0, shadowH: 185, shadowL: 162, open: 165, close: 178),
            CandleChartDataEntry(x: 1, shadowH: 190, shadowL: 170, open: 178, close: 188),
            CandleChartDataEntry(x: 2, shadowH: 195, shadowL: 175, open: 188, close: 177),
            CandleChartDataEntry(x: 3, shadowH: 182, shadowL: 165, open: 177, close: 169),
            CandleChartDataEntry(x: 4, shadowH: 175, shadowL: 160, open: 169, close: 172),
        ]
        let candleSet = CandleChartDataSet(entries: candleEntries, label: "K 线")
        candleSet.shadowColor = .darkGray
        candleSet.shadowWidth = 1.5
        candleSet.decreasingColor = .systemRed     // 阴线颜色
        candleSet.decreasingFilled = true
        candleSet.increasingColor = .systemGreen   // 阳线颜色
        candleSet.increasingFilled = true
        candleSet.neutralColor = .systemGray

        // ---- 成交量柱状图 ----
        let barEntries = [
            BarChartDataEntry(x: 0, y: 3200),
            BarChartDataEntry(x: 1, y: 5400),
            BarChartDataEntry(x: 2, y: 4100),
            BarChartDataEntry(x: 3, y: 6800),
            BarChartDataEntry(x: 4, y: 3900),
        ]
        let barSet = BarChartDataSet(entries: barEntries, label: "成交量(手)")
        barSet.colors = [.systemBlue.withAlphaComponent(0.5)]
        barSet.axisDependency = .right  // 绑定右轴(虽然禁用了,仅用于数据缩放)

        // ---- 组合 ----
        let combined = CombinedChartData()
        combined.candleData = CandleChartData(dataSet: candleSet)
        combined.barData = BarChartData(dataSet: barSet)

        // X 轴 Formatter
        combinedChart.xAxis.valueFormatter = IndexAxisValueFormatter(
            values: ["Mon", "Tue", "Wed", "Thu", "Fri"]
        )
        combinedChart.xAxis.granularity = 1
        combinedChart.data = combined
    }
}

五、源码亮点

进阶层:值得借鉴的用法

MarkerView——点击数据点显示气泡
// 自定义 Marker,继承 MarkerView
class BalloonMarker: MarkerView {
    private var label: String = ""

    override func refreshContent(entry: ChartDataEntry, highlight: Highlight) {
        label = String(format: "%.1f", entry.y)
        super.refreshContent(entry: entry, highlight: highlight)
    }

    override func draw(context: CGContext, point: CGPoint) {
        // 用 CoreGraphics 绘制气泡背景 + 文字
        let attrs: [NSAttributedString.Key: Any] = [
            .font: UIFont.systemFont(ofSize: 12),
            .foregroundColor: UIColor.white
        ]
        let attrStr = NSAttributedString(string: label, attributes: attrs)
        let size = attrStr.size()
        let rect = CGRect(x: point.x - size.width / 2 - 8,
                          y: point.y - size.height - 12,
                          width: size.width + 16, height: size.height + 8)
        context.setFillColor(UIColor.systemBlue.cgColor)
        UIBezierPath(roundedRect: rect, cornerRadius: 6).fill()
        attrStr.draw(in: rect.insetBy(dx: 8, dy: 4))
    }
}

// 绑定
chartView.marker = BalloonMarker()
chartView.drawMarkers = true

深入层:设计思想解析

1. Transformer 矩阵变换

DGCharts 用一个 CGAffineTransform 维护数据坐标 → 屏幕坐标的映射:

屏幕坐标 = 数据坐标 × scale + offset + chart padding

每次缩放/平移,只更新 transform 矩阵然后 setNeedsDisplay,避免重新计算所有点,是渲染性能的核心保障。

2. Protocol-Oriented Renderer
// 每种图表有独立 Renderer,统一协议
public protocol DataRenderer: AnyObject {
    func drawData(context: CGContext)
    func drawValues(context: CGContext)
    func drawExtras(context: CGContext)
    func drawHighlighted(context: CGContext, indices: [Highlight])
}

遵循开闭原则,新增图表类型只需实现此协议,不修改任何现有代码。

3. ChartHighlighter — 触摸高亮的寻值逻辑

触摸事件 → getHighlight(x:y:) → 二分查找最近 x → 在该 x 的所有 DataSet 中找最近 y → 返回 Highlight(x:y:dataSetIndex:) → 触发 highlightValue。整套流程在主线程同步完成,保证交互响应 < 16ms。


六、踩坑记录

问题 1:数据更新后图表没有刷新

  • 原因:修改了 entries 数组但没有通知图表
  • 解决
    chartView.data?.notifyDataChanged()
    chartView.notifyDataSetChanged()
    

问题 2:X 轴标签显示 0, 1, 2 而不是自定义文字

  • 原因:没有设置 xAxis.valueFormatter 或者 granularity 不正确
  • 解决
    xAxis.valueFormatter = IndexAxisValueFormatter(values: yourLabels)
    xAxis.granularity = 1.0   // 必须设置,否则可能跳步
    

问题 3:饼图中间的 "洞" 大小无法控制

  • 原因holeRadiusPercent 默认 0.5(即 50%)
  • 解决
    pieChart.holeRadiusPercent = 0.3  // 调整洞的比例,0 = 实心饼图
    

问题 4:动画结束后视图突然闪烁

  • 原因animateXanimateY 同时调用,两个动画结束时各触发一次 setNeedsDisplay
  • 解决:使用 animate(xAxisDuration:yAxisDuration:) 合并为一次调用

问题 5:在 SwiftUI 中使用时手势冲突

  • 原因:DGCharts 的 UIPanGestureRecognizer 与 SwiftUI ScrollView 的手势抢夺
  • 解决
    chartView.dragEnabled = false       // 在 ScrollView 内关闭拖拽
    chartView.pinchZoomEnabled = false  // 关闭缩放
    

问题 6:Swift Package Manager 拉取超时

  • 原因:Charts 仓库体积较大(含所有 Demo)
  • 解决:在 package.json 中指定 .upToNextMinor(from: "5.1.0"),避免每次重新解析

七、延伸思考

同类库对比

维度 DGCharts Swift Charts (Apple) AAChartKit Charts.js (WebView)
平台要求 iOS 13+ iOS 16+ iOS 11+ iOS (via WKWebView)
图表类型数 8 4 10+ 20+
性能 原生,高 原生,高 原生,良好 WebView,差
K 线图
SwiftUI 支持 UIViewRepresentable 原生 UIViewRepresentable WKWebViewRepresentable
维护状态 社区活跃 Apple 官方 活跃 Web 项目
学习曲线 中等 低(SwiftUI 声明式) 需了解 JS

推荐使用场景

  • ✅ iOS 13~15 的存量 App,无法使用 Swift Charts
  • ✅ 需要 K 线图、雷达图等特殊类型
  • ✅ 需要精细控制动画和交互手势
  • ✅ 同时支持 iOS 和 macOS 的多平台 App

不推荐场景

  • ❌ 全新 App,最低支持 iOS 16+,且只用 SwiftUI → 直接用 Swift Charts
  • ❌ 只需要一个简单静态饼图/柱图 → 手动 CoreGraphics 更轻量
  • ❌ 需要超复杂的动态可视化(如力导向图)→ 考虑 WebView + D3.js

八、参考资源


九、本期互动

小作业

使用 LineChartView 实现一个实时动态折线图:每秒追加一个随机数据点,并保持最多显示最近 20 个点(超出则移除最旧的点),同时保证图表 X 轴自动滚动跟随最新数据。完成后在评论区贴出你的核心更新逻辑。

思考题

DGCharts 的 Transformer 用矩阵变换把数据坐标映射到屏幕坐标,这种设计和 CALayertransform 有何本质区别?如果让你自己实现一套坐标系映射,你会选择哪种方案?为什么?

读者征集

下一期我们将深入 Hero(转场动画库)。你在项目中用过哪些自定义转场方案(Hero / UIViewControllerTransitioningDelegate / NavigationController Push 动画)?欢迎评论区分享你的实践经验,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新 ✅ 第1期:Alamofire · ✅ 第2期:SDWebImage · ✅ 第3期:Kingfisher · ✅ 第4期:SnapKit · ✅ 第5期:ListDiff · ✅ 第6期:RxSwift · ✅ 第7期:Lottie · ✅ 第8期:MarkdownUI · ✅ 第9期:AFNetworking · ➡️ 第11期:DGCharts · ○ 第12期:Hero

【RxSwift】Swift 版 ReactiveX,响应式编程优雅处理异步事件流

作者 探索者dx
2026年4月10日 23:20

【RxSwift】Swift 版 ReactiveX,响应式编程优雅处理异步事件流

iOS三方库精读 · 第 8 期


一、一句话介绍

RxSwift 是 ReactiveX 的 Swift 实现,将异步操作和事件统一为可观察序列(Observable),通过操作符进行声明式组合变换,极大简化复杂异步逻辑。

属性
GitHub Stars 24.5k+
最新版本 6.7.0
License MIT
支持平台 iOS 9+ / macOS 10.10+ / watchOS / tvOS

二、为什么选择它

原生痛点

在没有 RxSwift 之前,处理异步事件流往往面临:

  • 回调地狱:嵌套的网络请求、多层 callback 难以维护
  • 状态同步:UI 与数据模型的双向绑定需要大量 KVO / Notification / Delegate 样板代码
  • 线程切换:GCD 和 OperationQueue 手动管理容易出错
  • 错误处理:分散在各处的 try-catch,遗漏处理导致崩溃
  • 事件取消:Timer、Notification 观察者忘记移除,造成内存泄漏

RxSwift 核心优势

  1. 统一异步模型:网络请求、通知、KVO、Timer、手势统统归为 Observable,一套 API 走天下
  2. 声明式组合:通过 map/filter/flatMap 等操作符链式调用,代码如流水般清晰
  3. 自动资源管理:DisposeBag 机制确保订阅随生命周期自动释放
  4. 丰富的操作符:100+ 操作符覆盖变换、过滤、组合、错误处理、调度等场景
  5. RxCocoa 扩展:UIKit 控件开箱即用的双向绑定(UITextField、UIButton、UITableView 等)

原生 API vs RxSwift

场景 原生方式 RxSwift 方式
网络请求 URLSession + closure 嵌套 Observable + flatMap 链式
输入框实时搜索 addTarget + Timer 去抖 rx.text + debounce
表单验证 多个 KVO / Delegate combineLatest 一行搞定
Timer 管理 Timer.scheduledTimer + invalidate Observable.interval + disposed(by:)
通知监听 NotificationCenter + removeObserver NotificationCenter.rx.notification

三、核心功能速览

基础层 概念解释、环境配置、基础用法

环境配置

Swift Package Manager(推荐)

// Package.swift
dependencies: [
    .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.7.0")
]

CocoaPods

pod 'RxSwift', '~> 6.7'
pod 'RxCocoa', '~> 6.7'  # UIKit 扩展
pod 'RxRelay', '~> 6.7'  # Relay 组件

核心概念:Observable 三部曲

import RxSwift

let disposeBag = DisposeBag()

// 1. 创建 Observable
let observable = Observable<String>.create { observer in
    observer.onNext("Hello")
    observer.onNext("RxSwift")
    observer.onCompleted()
    return Disposables.create()
}

// 2. 订阅
observable
    .subscribe(
        onNext: { print("收到: \($0)") },
        onCompleted: { print("完成") }
    )
    // 3. 管理 disposal
    .disposed(by: disposeBag)

// 输出:
// 收到: Hello
// 收到: RxSwift
// 完成

进阶层 最佳实践、性能优化、线程安全

Subject 与 Relay

// PublishSubject: 只收到订阅后的事件
let publish = PublishSubject<String>()
publish.onNext("A")        // 不会收到
publish.subscribe { print($0) }  // 订阅
publish.onNext("B")        // ✅ 收到

// BehaviorSubject: 保留最新一个值,新订阅者立即收到
let behavior = BehaviorSubject(value: "初始值")
behavior.onNext("新值")
behavior.subscribe { print($0) }  // ✅ 立即收到 "新值"

// BehaviorRelay: UI 安全,永不触发 error/completed
let relay = BehaviorRelay(value: "初始值")
relay.accept("更新值")     // 用 accept 替代 onNext
relay.asObservable().subscribe { print($0) }

常用操作符

// 过滤
Observable.of(1, 2, 3, 4, 5)
    .filter { $0 % 2 == 0 }     // [2, 4]

// 变换
Observable.of(1, 2, 3)
    .map { $0 * 2 }             // [2, 4, 6]

// 去重
Observable.of("a", "b", "a", "c")
    .distinctUntilChanged()     // ["a", "b", "a", "c"] - 相邻去重

// 扁平化(网络请求链式)
searchText
    .flatMapLatest { query in
        return networkAPI.search(query)  // 自动取消上一个请求
    }

// 组合
Observable.combineLatest(
    usernameValid,
    passwordValid
) { $0 && $1 }                   // 表单验证

线程调度

Observable<String>.create { observer in
    // 后台执行耗时任务
    Thread.sleep(forTimeInterval: 1)
    observer.onNext("计算结果")
    observer.onCompleted()
    return Disposables.create()
}
.subscribe(on: ConcurrentDispatchQueueScheduler(qos: .background))  // 订阅线程
.observe(on: MainScheduler.instance)                               // 观察线程
.subscribe(onNext: { value in
    // UI 更新在主线程
    label.text = value
})
.disposed(by: disposeBag)

深入层 源码解析、设计思想、扩展定制

核心协议关系

ObservableType (协议)
    ↓
Observable (class)
    ↓ 创建
AnyObserver (观察者抽象)
    ↓ 订阅
Disposable (取消订阅)
    ↓ 持有
DisposeBag (资源回收容器)

Observer 模式实现

// Observable.subscribe 核心流程
func subscribe(_ observer: Observer) -> Disposable {
    // 1. 包装 observer
    let disposable = Disposables.create()
    
    // 2. 调用核心生产逻辑
    let sink = AnonymousSink(observer: observer, dispose: disposable)
    
    // 3. 返回 disposable 用于取消
    return disposable
}

// dispose 时移除订阅
final class DisposeBag {
    var disposables: [Disposable] = []
    deinit {
        disposables.forEach { $0.dispose() }
    }
}

四、实战演示

场景:实时搜索 + 表单验证 + TableView 绑定

import UIKit
import RxSwift
import RxCocoa

final class LoginViewController: UIViewController {
    private let disposeBag = DisposeBag()
    
    // UI
    private let emailField = UITextField()
    private let passwordField = UITextField()
    private let loginButton = UIButton(type: .system)
    private let tableView = UITableView()
    
    // 数据源
    private let suggestions = BehaviorRelay<[String]>(value: [])
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupBindings()
    }
    
    private func setupBindings() {
        // 1. 实时搜索(防抖 500ms + 去重)
        emailField.rx.text.orEmpty
            .debounce(.milliseconds(500), scheduler: MainScheduler.instance)
            .distinctUntilChanged()
            .filter { !$0.isEmpty }
            .flatMapLatest { [weak self] query -> Observable<[String]> in
                // 模拟搜索建议 API
                return self?.searchSuggestions(query) ?? .just([])
            }
            .bind(to: suggestions)
            .disposed(by: disposeBag)
        
        // 2. 表单验证(多输入组合)
        let emailValid = emailField.rx.text.orEmpty
            .map { $0.contains("@") && $0.contains(".") }
        
        let passwordValid = passwordField.rx.text.orEmpty
            .map { $0.count >= 6 }
        
        Observable.combineLatest(emailValid, passwordValid) { $0 && $1 }
            .bind(to: loginButton.rx.isEnabled)
            .disposed(by: disposeBag)
        
        // 3. TableView 数据绑定
        suggestions
            .bind(to: tableView.rx.items(cellIdentifier: "Cell")) { _, text, cell in
                cell.textLabel?.text = text
            }
            .disposed(by: disposeBag)
        
        // 4. 点击事件
        loginButton.rx.tap
            .withLatestFrom(Observable.combineLatest(
                emailField.rx.text.orEmpty,
                passwordField.rx.text.orEmpty
            ))
            .subscribe(onNext: { [weak self] email, password in
                self?.performLogin(email: email, password: password)
            })
            .disposed(by: disposeBag)
    }
    
    private func searchSuggestions(_ query: String) -> Observable<[String]> {
        // 模拟 API 请求
        return Observable.just(["\(query)@gmail.com", "\(query)@icloud.com"])
            .delay(.milliseconds(300), scheduler: MainScheduler.instance)
    }
    
    private func performLogin(email: String, password: String) {
        print("登录: \(email), 密码: \(password)")
    }
}

五、源码亮点

进阶层 值得借鉴的用法

操作符链式调用的 Builder 模式

// 每个操作符返回新的 Observable,支持无限链式
observable
    .map { transform($0) }       // 返回 Map<Source>
    .filter { predicate($0) }    // 返回 Filter<Map<Source>>
    .subscribe { ... }           // 返回 Disposable

takeUntil 实现自动取消

// 当 self.deallocated 时自动取消网络请求
networkRequest()
    .takeUntil(self.rx.deallocated)
    .subscribe(onNext: { ... })

深入层 设计思想解析

Producer-Consumer 模式

// Observable 是 Producer,产生事件
class Producer<Element>: Observable<Element> {
    func run(_ observer: Observer, cancel: Cancel) -> Sink {
        // 子类实现具体生产逻辑
    }
}

// Sink 是 Consumer,消费事件并管理生命周期
class Sink<Observer: ObserverType>: Disposable {
    let observer: Observer
    var disposed = false
    
    func dispose() {
        disposed = true
    }
}

操作符的实现模式

map 为例:

final class MapSink<Source, Result>: Sink<Result>, ObserverType {
    typealias Element = Source
    
    private let transform: (Source) -> Result
    
    func on(_ event: Event<Source>) {
        switch event {
        case .next(let element):
            let result = transform(element)  // 变换
            forwardOn(.next(result))         // 传递给下游
        case .error(let error):
            forwardOn(.error(error))
        case .completed:
            forwardOn(.completed)
        }
    }
}

六、踩坑记录

问题 1:订阅未释放导致内存泄漏

问题:在 ViewController 中订阅 Observable,页面释放后订阅仍在执行。

原因:未将 Disposable 加入 DisposeBag,或 DisposeBag 生命周期与 VC 不一致。

解决

// ❌ 错误:未持有 Disposable
observable.subscribe { ... }

// ✅ 正确:加入 DisposeBag
private let disposeBag = DisposeBag()
observable.subscribe { ... }
    .disposed(by: disposeBag)

问题 2:UI 更新不在主线程

问题:后台网络请求回调中更新 UI 导致崩溃。

原因:默认情况下 Observable 继承订阅者的线程上下文。

解决

// ❌ 错误:可能在后台线程
networkRequest()
    .subscribe(onNext: { label.text = $0 })

// ✅ 正确:显式切换到主线程
networkRequest()
    .observe(on: MainScheduler.instance)
    .subscribe(onNext: { label.text = $0 })

问题 3:flatMap 与 flatMapLatest 混淆

问题:快速输入搜索关键词,收到旧的请求结果。

原因flatMap 会保留所有内部 Observable,flatMapLatest 会自动取消上一个。

解决

// ❌ 错误:旧请求可能覆盖新结果
searchText.flatMap { searchAPI($0) }

// ✅ 正确:自动取消上一个请求
searchText.flatMapLatest { searchAPI($0) }

问题 4:Subject 发送 completed 后无法复用

问题:PublishSubject 发送 .completed 后,后续订阅收不到事件。

原因:Subject 一旦 terminated,状态不可逆转。

解决

// 方案 1:使用 Relay(不发送 completed)
let relay = PublishRelay<String>()

// 方案 2:重新创建 Subject
func resetSubject() {
    subject = PublishSubject<String>()
}

问题 5:share(replay:) 重复执行副作用

问题:多个订阅者导致网络请求被执行多次。

原因:默认每个订阅者独立触发 Observable 执行。

解决

// ❌ 错误:每个订阅触发一次请求
let request = api.fetchData()
request.subscribe { ... }  // 请求 1
request.subscribe { ... }  // 请求 2

// ✅ 正确:共享执行结果
let request = api.fetchData()
    .share(replay: 1)       // 缓存最近 1 个结果
request.subscribe { ... }   // 请求 1
request.subscribe { ... }   // 复用结果

问题 6:withUnretained 造成循环引用

问题:使用 withUnretained(self) 仍出现循环引用。

原因:闭包内额外强引用了 self。

解决

// ❌ 错误:闭包内强引用
observable
    .withUnretained(self)
    .subscribe { self, value in
        self.items.append(value)  // 强引用
    }

// ✅ 正确:使用 weak 或确保无循环
observable
    .subscribe(onNext: { [weak self] value in
        self?.items.append(value)
    })

七、延伸思考

RxSwift vs Combine 横向对比

维度 RxSwift Combine
开发商 社区 (ReactiveX) Apple 官方
最低版本 iOS 9+ iOS 13+
操作符数量 100+ 极其丰富 ~50 够用但较少
UI 绑定 RxCocoa 内建 需自行封装或用 SwiftUI
调试支持 RxSwift.Resources / debug() print() / handleEvents()
学习曲线 较陡(概念多) 中等
包体积 ~2MB 系统内建,0 额外
SwiftUI 集成 需桥接 原生支持
维护状态 活跃 Apple 官方维护

选型建议

选 RxSwift:

  • 项目需要兼容 iOS 13 以下
  • 团队有 RxJava / RxJS 经验,希望统一范式
  • 需要大量 UIKit 双向绑定(RxCocoa 非常成熟)
  • 需要 withLatestFrom 等 Combine 缺失的操作符

选 Combine:

  • 纯 SwiftUI 项目,Combine 原生集成最流畅
  • 不想引入第三方依赖,减少包体积
  • 新项目最低版本 ≥ iOS 13

迁移建议

对于已有 RxSwift 项目:

  • 短期内无需迁移,RxSwift 维护状态良好
  • 新增 SwiftUI 页面可用 Combine,与 RxSwift 共存
  • 使用 RxCombine 库实现互相转换

八、参考资源

官方资源

推荐文章

系列 Demo 仓库


本期互动

小作业

尝试用 RxSwift 实现一个「搜索建议」功能:输入框输入时防抖 300ms,发起网络请求获取建议列表,展示在 UITableView 中,并在评论区贴出你的关键代码片段。

思考题

如果让你从零实现 RxSwift 的 debounce 操作符,你会如何设计?需要考虑哪些边界情况?

读者征集

你在使用 RxSwift 时踩过哪些坑?欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ Lottie] [✓ MarkdownUI] [✓ SDWebImage] [✓ SnapKit] [✓ ListDiff] [→ RxSwift] [○ Charts]

【Lottie】让设计稿上的动效直接"活"在 App 里

作者 探索者dx
2026年4月8日 20:21

【Lottie】让设计稿上的动效直接"活"在 App 里

iOS三方库精读 · 第 5 期


一、一句话介绍

Lottie 是由 Airbnb 开源的跨平台动画库,它让 Adobe After Effects 导出的 JSON 动效文件在 iOS / Android / Web 上以矢量方式实时渲染,彻底消灭"设计交付 → 开发还原"之间的信息损耗。

属性 详情
⭐ Stars 25k+(GitHub)
最新稳定版 4.5.x
License Apache 2.0
支持平台 iOS 14+ / macOS 11+ / tvOS 14+ / visionOS 1+
SwiftUI 原生支持 ✅(4.0 起)

二、为什么选择它

原始痛点

在没有 Lottie 之前,设计师在 After Effects 中做好一个 3 秒的加载动画,开发要干这些事:

  1. 看着动效视频,逐帧拆解关键帧参数
  2. CAKeyframeAnimation / CAAnimationGroup 手写每一条时间曲线
  3. 颜色稍有偏差,回去对着设计稿截图像素级校准
  4. 动效改版?从头重写

这套流程不仅耗时(一个中等复杂动效需 1~3 天还原),还存在不可避免的还原偏差

核心优势

  • 零还原成本:AE 安装 bodymovin 插件,导出 JSON,开发侧 LottieView(animation: .named("xxx")) 一行接入,100% 还原
  • 矢量渲染,无损缩放:JSON 中存储的是贝塞尔曲线参数,任意分辨率下锐利清晰,不像 GIF 有马赛克
  • 极小体积:同等视觉效果的动效,Lottie JSON 通常比 GIF 小 80%~90%
  • 运行时动态换色:通过 DynamicProperty API,在不修改 JSON 文件的前提下替换任意图层的颜色、图片、文字,支持深色模式适配
  • 精细帧控制:可播放任意帧区间、设置播放速度、绑定手势进度,实现交互式动画

三、核心功能速览

基础层 新手必读:环境配置与基础播放

集成方式

Swift Package Manager(推荐)

project.yml(XcodeGen)中添加:

packages:
  lottie-ios:
    url: https://github.com/airbnb/lottie-ios.git
    minorVersion: 4.5.0

dependencies:
  - package: lottie-ios
    product: Lottie

或在 Xcode → File → Add Package Dependencies 搜索:

https://github.com/airbnb/lottie-ios

CocoaPods

pod 'lottie-ios', '~> 4.5'

准备动画 JSON

  1. 在 After Effects 中安装 bodymovin 插件
  2. 渲染导出 → 选择 JSON 格式
  3. xxx.json 拖入 Xcode 工程(确保勾选 Target Membership)
  4. 或从 LottieFiles.com 下载社区免费素材

SwiftUI 基础用法

// Swift 5.9+ / iOS 17+
import SwiftUI
import Lottie

struct ContentView: View {
    var body: some View {
        LottieView(animation: .named("loading"))  // 对应 loading.json
            .playing(.fromProgress(0, toProgress: 1, loopMode: .loop))
            .resizable()
            .scaledToFit()
            .frame(height: 200)
    }
}

UIKit 基础用法

import Lottie

let animationView = LottieAnimationView(name: "loading")
animationView.loopMode = .loop
animationView.contentMode = .scaleAspectFit
animationView.play()

view.addSubview(animationView)

进阶层 最佳实践:常用 API 与核心配置

LottieView 常用修饰符(SwiftUI)

LottieView(animation: .named("confetti"))
    // 播放控制
    .playing(.fromProgress(0, toProgress: 1, loopMode: .playOnce))
    .animationSpeed(1.5)                    // 1.5 倍速
    // 完成回调
    .animationDidFinish { completed in
        print("播放完成: \(completed)")
    }
    // 动态换色
    .valueProvider(
        ColorValueProvider(LottieColor(r: 1, g: 0.8, b: 0, a: 1)),
        for: AnimationKeypath(keypath: "**.Color")
    )

LottieAnimationView 常用 API(UIKit)

let av = LottieAnimationView(name: "success")

// 播放控制
av.play()                           // 从当前进度播放到末尾
av.pause()                          // 暂停
av.stop()                           // 停止并重置到开头

// 帧区间播放
av.play(fromFrame: 0, toFrame: 60, loopMode: .playOnce) { finished in
    // finished = true 表示正常播放完毕,false 表示被中断
}

// 手动控制进度(绑定手势)
av.currentProgress = 0.5           // 跳到 50% 位置

// 速度与循环
av.animationSpeed = 2.0
av.loopMode = .loop                 // .playOnce / .loop / .autoReverse / .repeat(3)

播放状态枚举速查

// SwiftUI LottieView.play() 参数
.playing()                                              // 无限循环
.playing(.fromProgress(0, toProgress: 1, loopMode: .loop))
.playing(.paused)                                       // 暂停
.playing(.paused(at: .progress(0.5)))                   // 暂停在 50%
.playing(.paused(at: .frame(30)))                       // 暂停在第 30 帧

深入层 源码视角:渲染架构与关键模块

渲染器选择

Lottie 4.x 提供三种渲染器,可在初始化时指定:

// 默认:Core Animation 渲染器,GPU 友好,支持大部分 AE 特性
LottieConfiguration.shared.renderingEngine = .automatic

// 强制 Core Animation(推荐生产环境)
LottieConfiguration.shared.renderingEngine = .coreAnimation

// 主线程渲染器(兼容性最佳,性能较差,4.x 已逐步弃用)
LottieConfiguration.shared.renderingEngine = .mainThread

关键模块职责

模块 职责
LottieAnimation JSON 解析,将 bodymovin 数据映射为内存模型
AnimationLayer CALayer 树构建器,将动画模型转为 Core Animation 结构
AnimationContext 时间线管理,处理帧率、时间缩放、循环逻辑
ValueProvider 动态属性注入点,DynamicProperty 系统的核心抽象

四、实战演示

场景:电商 App 加载页 + 下单成功动效,含动态换色适配品牌主色

import SwiftUI
import Lottie

// MARK: - 加载页动效(带品牌色动态换色)

struct BrandLoadingView: View {
    @Environment(\.colorScheme) var colorScheme

    /// 品牌主色(浅色/深色模式自适应)
    var brandColor: LottieColor {
        colorScheme == .dark
            ? LottieColor(r: 1.0, g: 0.85, b: 0.0, a: 1.0)   // 深色:亮黄
            : LottieColor(r: 0.9, g: 0.6, b: 0.0, a: 1.0)     // 浅色:金黄
    }

    var body: some View {
        ZStack {
            Color(.systemBackground).ignoresSafeArea()

            LottieView(animation: .named("loading_ring"))
                .playing(.fromProgress(0, toProgress: 1, loopMode: .loop))
                .animationSpeed(0.8)
                // 替换 JSON 中所有名为 "Primary Color" 图层的颜色
                .valueProvider(
                    ColorValueProvider(brandColor),
                    for: AnimationKeypath(keypath: "**.Primary Color.Color")
                )
                .resizable()
                .scaledToFit()
                .frame(width: 120, height: 120)

            Text("加载中...")
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .offset(y: 80)
        }
    }
}

// MARK: - 下单成功动效(单次播放 + 完成回调)

struct OrderSuccessView: View {
    @State private var showContent = false
    @Binding var isPresented: Bool

    var body: some View {
        VStack(spacing: 20) {
            LottieView(animation: .named("success_checkmark"))
                .playing(.fromProgress(0, toProgress: 1, loopMode: .playOnce))
                .animationDidFinish { _ in
                    // 动效播放完毕后展示订单详情
                    withAnimation(.easeIn(duration: 0.3)) { showContent = true }
                }
                .resizable()
                .scaledToFit()
                .frame(height: 160)

            if showContent {
                VStack(spacing: 8) {
                    Text("下单成功!")
                        .font(.title2).bold()
                    Text("预计 3~5 个工作日送达")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    Button("查看订单") { isPresented = false }
                        .buttonStyle(.borderedProminent)
                }
                .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
        .padding(32)
    }
}

要点说明:

  • AnimationKeypath(keypath: "**.Primary Color.Color")** 是通配符,匹配任意深度路径
  • 图层名称需与 AE 中一致,设计师导出前应统一命名规范
  • animationDidFinish 在 SwiftUI 中是 View 修饰符,回调在主线程执行

五、源码亮点

进阶层 值得借鉴的用法

链式 ValueProvider 叠加

多个 valueProvider 可链式叠加,分别控制不同图层:

LottieView(animation: .named("badge"))
    .playing()
    .valueProvider(
        ColorValueProvider(LottieColor(r: 1, g: 0.3, b: 0.3, a: 1)),
        for: AnimationKeypath(keypath: "Background.Color")
    )
    .valueProvider(
        ColorValueProvider(LottieColor(r: 1, g: 1, b: 1, a: 1)),
        for: AnimationKeypath(keypath: "Icon.**.Color")
    )
    .valueProvider(
        TextValueProvider("99+"),
        for: AnimationKeypath(keypath: "Badge.Text")
    )

手势驱动进度(类似 pull-to-refresh)

struct GestureDrivenAnimation: View {
    @GestureState private var dragOffset: CGFloat = 0
    @State private var progress: Double = 0

    var body: some View {
        LottieView(animation: .named("pull_refresh"))
            .playing(.paused(at: .progress(progress)))
            .resizable().scaledToFit().frame(height: 80)
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in state = value.translation.height }
                    .onChange(of: dragOffset) { _, new in
                        progress = Double(min(max(new / 120, 0), 1))
                    }
            )
    }
}

深入层 设计思想解析

1. Protocol-Oriented ValueProvider

ValueProvider 是一个协议,内部通过 AnyValueProvider 做类型擦除,使得颜色、数值、文字、图片等完全不同的类型可以共享一套注入 API:

// 库内抽象(简化版)
public protocol AnyValueProvider {
    var valueType: Any.Type { get }
    func hasUpdate(frame: AnimationFrameTime) -> Bool
    func value(frame: AnimationFrameTime) -> Any
}

// 使用侧无感知具体类型
animationView.setValueProvider(colorProvider, keypath: keypath)
animationView.setValueProvider(textProvider, keypath: keypath)

2. Keypath 通配符系统

类似 KVC,但专为 AE 图层树设计,支持 **(任意路径深度)和 *(单层通配):

"Button.Background.Color"      → 精确匹配
"**.Color"                     → 所有名为 Color 的属性
"Button.*.Color"               → Button 子级的任意图层的 Color

3. Core Animation 渲染器的零主线程原则

Lottie 4.x 的 Core Animation 渲染器将所有动画帧的计算预烘焙为 CAAnimation 关键帧,提交给 Render Server 后完全在主线程之外运行,即使主线程卡顿也不会影响动效流畅度——这正是它相比 mainThread 渲染器的核心优势。


六、踩坑记录

问题 1:JSON 加载返回 nil,动效不显示

原因:JSON 文件未加入 Target Membership,或文件名拼写错误(大小写敏感)。

解决:选中 JSON 文件 → Xcode 右侧 File Inspector → 勾选对应 Target;或用 URL 方式加载并捕获错误:

let animation = LottieAnimation.named("loading")  // 返回 Optional
// 或
if let url = Bundle.main.url(forResource: "loading", withExtension: "json") {
    let animation = try? LottieAnimation.loadedFrom(url: url)
}

问题 2:DynamicProperty 换色不生效

原因:Keypath 中的图层名称与 AE 中不一致(导出时 bodymovin 会对图层名做 URL 编码);或使用了 Core Animation 渲染器但该属性不支持动态修改。

解决:启用调试日志查看所有可用 Keypath:

// 打印动画内所有可被覆盖的属性路径
if let animation = LottieAnimation.named("badge") {
    let paths = animation.keypaths(for: .init(keypath: "**"))
    paths.forEach { print($0) }
}

问题 3:Swift 6 / Sendable 编译报错

原因:Lottie 4.4 以前部分类型未标注 @MainActor,在 Swift 6 严格并发检查下会报 Sendable 违规。

解决:升级到 Lottie 4.5+(已系统性修复 Swift 6 合规问题);或临时在模块级别关闭严格检查:

// 临时方案(不推荐长期保留)
import Lottie
nonisolated(unsafe) let sharedAnimation = LottieAnimation.named("loading")

问题 4:在 List / ScrollView 中大量 LottieView 导致卡顿

原因:每个 LottieView 初始化时都会同步解析 JSON 并构建 Layer 树,Cell 复用时重复创建开销大。

解决:预加载并缓存 LottieAnimation 对象,复用时只更新播放状态:

// 在 ViewModel / 缓存层提前加载
final class AnimationCache {
    static let shared = AnimationCache()
    private var cache: [String: LottieAnimation] = [:]

    func animation(named name: String) -> LottieAnimation? {
        if let cached = cache[name] { return cached }
        let anim = LottieAnimation.named(name)
        cache[name] = anim
        return anim
    }
}

// 使用
LottieView(animation: AnimationCache.shared.animation(named: "like_button"))
    .playing()

问题 5:autoReverse 循环模式在 SwiftUI 中反向播放后卡住

原因.autoReverse 在部分版本的 LottieView 中有已知 Bug,正向→反向后停在第 0 帧不再循环。

解决:用 .loop 替代,并在 animationDidFinish 中手动反转进度,或升级到最新 Lottie 版本。


问题 6:从网络 URL 加载动效时闪烁

原因:网络请求完成前 LottieView 已渲染了空状态,数据到来后重新布局导致闪烁。

解决:使用 LottieView 的 URL 加载重载 + showPlaceholder 搭配:

LottieView {
    try await LottieAnimation.loadedFrom(
        url: URL(string: "https://example.com/fireworks.json")!
    )
}
.playing()
.background { ProgressView() }  // 加载中占位

七、延伸思考

Lottie vs 主流动画方案横向对比

维度 Lottie Rive SwiftUI 原生动画 CAAnimation
文件格式 JSON (bodymovin) .riv (专有) 代码 代码
设计协作 AE 直出,零交接 Rive 编辑器 开发手写 开发手写
交互状态机 ⚠️ 有限 ✅ 内建 ⚠️ 有限
渲染性能 ✅ GPU 加速 ✅ 极佳
动态换色 ✅ DynamicProperty ✅ 输入驱动
包体积(库本身) ~4 MB ~2 MB 0 0
社区素材库 ✅ LottieFiles 海量 ⚠️ 较少
维护状态 活跃 活跃 Apple 官方 Apple 官方
学习曲线 低~中

推荐使用 Lottie 的场景

  • Splash Screen / 启动动画
  • Loading / 空状态 / 错误状态插画动效
  • 点赞、收藏、成功等一次性触发的微交互动效
  • 设计团队已有 AE 工作流,动效资产丰富
  • 需要在 LottieFiles 快速取用社区素材

不推荐使用 Lottie 的场景

  • 动效极简(仅 opacity / scale / translate)→ SwiftUI .animation() 即可
  • 需要复杂交互状态机(手势联动多个状态跳转)→ 考虑 Rive
  • 需要 3D 变换效果 → SceneKitRealityKit
  • 超大 JSON(> 2MB)在列表中大量实例化 → 需谨慎评估性能

八、参考资源


九、本期互动


小作业

在你自己的项目(或 Demo 工程)中实现一个点赞按钮

  1. 点击前显示灰色心形(未点赞状态,可用 SF Symbol 或 Lottie JSON)
  2. 点击时播放 Lottie 爱心爆炸动效(建议从 LottieFiles 搜索 "like" 下载)
  3. 播放完成后停留在点赞完成帧
  4. 再次点击恢复未点赞状态

完成标准:能在真机或模拟器上稳定运行,按钮不会出现状态错乱。欢迎在评论区贴出实现思路或截图!


思考题

Lottie 的 DynamicProperty 机制允许在运行时"注入"新的值覆盖 JSON 中预设的属性。这种控制反转(IoC)的设计思路,在 iOS 开发中还有哪些类似的应用?你会如何把这种思路用在自己的业务组件设计上?


读者征集

下一期选题投票正在进行!同时:你在使用 Lottie 时踩过哪些坑? 欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新

✅ 第1期:Alamofire · ✅ 第2期:Kingfisher · ✅ 第3期:MarkdownUI · ✅ 第4期:SnapKit · ➡️ 第5期:Lottie · ○ 第6期:待定

赛博探案集:用 Vision 框架在像素迷宫中“揪”出文字真凶

2026年4月7日 09:59

在这里插入图片描述

这里是后厂村阴影中最神秘的“全栈侦探事务所”。当你的 if-else 走到尽头,当你的 Bug 堆积如山,资深探长“老司机”就是你最后的救命稻草。本期案卷记录了一次关于“像素与文字”的离奇遭遇:实习生阿强因“人肉 OCR”识别截图密码失败,险些引发上线事故。面对这起“视力危机”,我们拒绝蛮力,祭出了 Apple 强大的 Vision 框架。这不仅是一篇关于如何用 Swift 实现 OCR(文字识别)的硬核教程,更是一场从构建“文字捕手”到破解“坐标迷宫”的技术探险。准备好了吗?泡好你的枸杞咖啡,跟随老司机的代码,一起揭开隐藏在图片像素背后的真相。

🕵️‍♂️ 引子

在一个雷雨交加的周五深夜,位于后厂村的“全栈侦探事务所”依然灯火通明。传说中,这里有一位代号为“老司机”的资深工程师,他不仅能用汇编语言写情书,还能在没有任何文档的遗留代码(Legacy Code)中自由穿梭。

就在刚刚,事务所的大门被撞开了。实习生阿强跌跌撞撞地跑进来,手里挥舞着一张模糊不清的截图,脸上写满了被产品经理折磨后的绝望。“老大!出大事了!这图片里藏着服务器的 Root 密码,但我手抄了三次都提示错误!现在上线倒计时只剩 30 分钟了!”

在这里插入图片描述

老司机缓缓放下手中早已凉透的黑咖啡,推了推鼻梁上那副防蓝光眼镜,嘴角勾起一抹神秘的微笑。“阿强,把你的‘人肉 OCR’停一停吧。在 Apple 的地盘上,我们有更优雅的武器——Vision 框架。”

在本次探案之旅中,您将学到如下内容:

  • 🕵️‍♂️ 引子
  • 🤖 第一章:不仅是扫码工具人的 Vision
  • 🛠️ 第二章:打造“文字捕手” (The Text Recognizer)
  • ⚠️ 老司机的技术批注:
  • 🎯 第三章:给真相画个圈 (Highlighting Found Text)
  • 🤝 终章:真相大白

他指尖在机械键盘上飞舞,屏幕上开始跳动起绿色的代码符文。“坐好,今晚带你见识一下,如何用机器学习的‘天眼’,让图片里的文字自己‘招供’。”

在这里插入图片描述


🤖 第一章:不仅是扫码工具人的 Vision

听好了,阿强。大多数人对 Apple Vision 框架的印象,还停留在扫个二维码或者条形码这种“小儿科”的阶段。这就好比你拿着一把激光剑去切西瓜——简直是暴殄天物!

实际上,Vision 就像是给你的 App 装上了一双“写轮眼”。它不仅能从图片中识别并定位文字(Text Detection),还能把图片里的特定区域剥离出来、在连续的视频帧里追踪物体、甚至检测你那僵硬的手势和坐姿!

在这里插入图片描述

我第一次跟 Vision 打交道的时候,是写了一个 Swift 命令行工具来移除图片背景 ✂️。那时候我就意识到,这玩意儿简直是修图师的噩梦,程序员的福音。但今天,我们要用它来做点更硬核的——文字识别

在这里插入图片描述


🛠️ 第二章:打造“文字捕手” (The Text Recognizer)

要在茫茫像素中提取文字,我们得先组装一个名为 TextRecognizer 的“审讯室”。在这个环节,我们要用到 Vision 的核心组件:RecognizeTextRequest

这就好比我们向系统提交一份“搜查令”,告诉它:“嘿,帮我把这张图里的字儿都给我找出来,而且要准(Accurate)!”

在这里插入图片描述

来看看这段代码,这可是我们的核心武器:

import Foundation
import SwiftUI
import Vision
 
struct TextRecognizer {
    var recognizedText = ""
    // 保存识别到的所有“线索”(观察结果)
    var observations: [RecognizedTextObservation] = []
 
    // 这个初始化器是异步的,因为查案需要时间,急不得
    init(imageResource: ImageResource) async {
        // 1. 创建搜查令:RecognizeTextRequest
        var request = RecognizeTextRequest()
        // 2. 将识别精度设置为 .accurate(我们要的是精准打击,不是瞎猜)
        request.recognitionLevel = .accurate
        
        // 3. 将 ImageResource 转换为 UIImage
        let image = UIImage(resource: imageResource)
        
        // 4. 重点来了!Vision 不吃 UIImage 这一套,它只认二进制数据 Data
        // 所以我们必须把图片“粉碎”成 PNG 数据
        if let imageData = image.pngData(),
           // 执行搜查任务(perform)。这一步可能会失败,所以用了 try? 来“掩耳盗铃”
           // 注意:这里是异步等待结果
           let results = try? await request.perform(on: imageData) {
            
            // 5. 将抓获的嫌疑人(观察结果)关进 observations 数组
            observations = results
        }
 
        // 6. 审讯环节:遍历每一个观察结果
        for observation in observations {
            // 获取可能性最高的那个“候选词”(topCandidates(1))
            // 就像指认现场,我们通常只信最像的那个
            let candidate = observation.topCandidates(1)
            if let observedText = candidate.first?.string {
                // 把招供的文字拼接到结果字符串里
                recognizedText += "\n\(observedText) "
            }
        }
    }
}

在这里插入图片描述

⚠️ 老司机的技术批注:

这里有个坑你要注意,阿强。RecognizeTextRequest 是个挑剔的家伙,它不能直接处理 Swift 的 ImageUIImage 对象,它需要生肉——也就是 Image Data

在这里插入图片描述

所以我们必须先把图片转成 Data 格式。另外,整个过程是 async(异步)的,毕竟机器学习这玩意儿虽然快,但也没快到能超越光速,我们得给 CPU 一点“思考”的时间。

在这里插入图片描述

接下来,我们把这个“文字捕手”集成到 SwiftUI 的视图里,让你亲眼看看效果:

import SwiftUI
 
struct TextRecognitionView: View {
    let imageResource: ImageResource
    // 状态变量,一旦侦探有了结果,界面就会刷新
    @State private var textRecognizer: TextRecognizer?
 
    var body: some View {
        List {
            // 展示嫌疑图片
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
            }
            .listRowBackground(Color.clear)
 
            // 展示审讯结果(识别出的文字)
            Section {
                // 如果 textRecognizer 还没初始化好,就先显示空字符串
                Text(textRecognizer?.recognizedText ?? "")
            } header: {
                Text("从图片中提取的证词")
            }
        }
        .navigationTitle("文字侦探")
        .task {
            // 重点:在 .task 修饰符里调用异步初始化器
            // 就像在后台偷偷干活,不阻塞主线程 UI 的渲染
            textRecognizer = await TextRecognizer(imageResource: imageResource)
        }
    }
}

这时候,阿强凑过来看着模拟器屏幕,只见原本模糊的截图下方,整整齐齐地列出了识别出来的文字。“卧槽,神了!连那个像‘1’又像‘l’的字符都分清了!”

在这里插入图片描述


🎯 第三章:给真相画个圈 (Highlighting Found Text)

“别急着庆祝,阿强。”我敲了敲桌子,“光把字认出来还不够,我们要做到按图索骥。既然 Vision 已经告诉了我们文字在哪里,我们就得在图片上把它们圈出来,就像犯罪现场的粉笔线一样。”

在这里插入图片描述

这里涉及到一个让很多新手头秃的概念:坐标系转换

Vision 返回的坐标是归一化的(Normalized),也就是说,它的 x 和 y 都在 0.0 到 1.0 之间。左下角是 (0,0),右上角是 (1,1)。但我们的屏幕图片是按像素画的,而且 UIKit/SwiftUI 的坐标原点通常在左上角。这就好比火星人给地球人指路,如果不好好翻译一下坐标,你画的框可能会飞到姥姥家去。

我们需要定义一个 Shape,专门用来画框:

import Foundation
import SwiftUI
import Vision
 
struct BoundsRect: Shape {
    // 这里存的是 Vision 给我们的“火星坐标”(归一化矩形)
    let normalizedRect: NormalizedRect
 
    func path(in rect: CGRect) -> Path {
        // 关键时刻!将归一化坐标转换为图片的实际像素坐标
        // origin: .upperLeft 是为了适配 SwiftUI 的坐标系习惯
        let imageCoordinatesRect = normalizedRect
            .toImageCoordinates(rect.size, origin: .upperLeft)
        return Path(imageCoordinatesRect)
    }
}

在这里插入图片描述

🔍 技术扩展: toImageCoordinates 这个方法虽然原文没细说,但它大概率是一个扩展方法(Extension),用于把 0~1 的小数映射到图片的 widthheight 上,并处理坐标原点的翻转。这一步至关重要,不做这一步,你的框框就会像没头苍蝇一样乱撞。

在这里插入图片描述


在这里插入图片描述

现在,我们把这个“现形符”贴到图片上:

struct TextRecognitionView: View {
    // ... 前面的代码 ...
    
    // 定义一个深红色的框,充满了悬疑感
    let boundingColor = Color(red: 0.31, green: 0.11, blue: 0.11)
 
    var body: some View {
        List {
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
                    .overlay {
                        // 如果侦探已经有了观察结果
                        if let observations = textRecognizer?.observations {
                            ForEach(observations, id: \.uuid) { observation in
                                // 遍历每一个观察点,画个圈圈诅咒...啊不,标记它
                                // observation.boundingBox 就是那个归一化的坐标
                                BoundsRect(normalizedRect: observation.boundingBox)
                                    .stroke(boundingColor, lineWidth: 3) // 描边
                            }
                        }
                    }
            }
            // ... 后面的代码 ...
        }
    }
}

在这里插入图片描述

随着代码重新编译运行,屏幕上的截图发生了变化。每一个单词周围都被套上了一个暗红色的方框,就像是被狙击手锁定的目标。

在这里插入图片描述


在这里插入图片描述

🤝 终章:真相大白

“看到了吗?”我指着屏幕上被红框圈出的一串字符,“那根本不是 Root 密码。”

阿强瞪大了眼睛,盯着那行被 Vision 精准识别出的文字:WIFI_PASSWORD: 12345678

“这……这就是隔壁会议室的 WiFi 密码?”阿强瘫软在椅子上,“我为了这个通宵了两天?”

在这里插入图片描述

我拍了拍他的肩膀,语重心长地说道:“虽然你是个笨蛋,但好在 Vision 框架足够聪明。记住,Vision 不仅仅能找字,它还能做更多事情——从视频里追踪隔壁老王的身影,到检测你是不是在偷偷抠脚(Body Pose Detection)。今天我们学的只是冰山一角,但也足够你在这个充满像素迷雾的开发世界里防身了。”

就这样,Vision 框架再次拯救了一个无知的灵魂(虽然并没有拯救他的加班费)。

在这里插入图片描述

希望宝子们喜欢这个故事,以及它背后的技术,但对于小伙伴们来说,利用 Apple 强大的 ML 能力去探索未知的旅程,才刚刚开始。

在这里插入图片描述

保持好奇,保持代码整洁,我们下个案子见。👋🙂 8-)

【SnapKit】优雅的 Swift Auto Layout DSL 库

作者 探索者dx
2026年4月7日 09:00

【SnapKit】优雅的 Swift Auto Layout DSL 库

iOS三方库精读 · 第 4 期


一、一句话介绍

SnapKit 是一个用于 iOS/macOS/tvOS 的 Swift Auto Layout DSL 库,它让繁琐的界面约束编写变得简洁优雅,是 UIKit 开发中最受欢迎的布局解决方案之一。

  • Stars: 19k+ ⭐
  • 最新版本: 5.7.0
  • License: MIT
  • 支持平台: iOS 12.0+ / macOS 10.13+ / tvOS 12.0+

二、为什么选择它

原生 NSLayoutConstraint 的痛点

在 UIKit 中,使用原生 API 创建约束通常是这样的:

// 原生方式 - 需要 4 行代码创建一个约束
let constraint = NSLayoutConstraint(
    item: view,
    attribute: .leading,
    relatedBy: .equal,
    toItem: superview,
    attribute: .leading,
    multiplier: 1.0,
    constant: 16
)
constraint.isActive = true
view.translatesAutoresizingMaskIntoConstraints = false

SnapKit 的核心优势

  1. 链式 DSL 语法:一行代码表达一个约束意图,代码可读性大幅提升
  2. 类型安全:编译期检查约束目标,避免运行时因字符串 API 导致的崩溃
  3. 自动管理:自动设置 translatesAutoresizingMaskIntoConstraints = false
  4. 动态更新:支持 updateConstraintsremakeConstraints,轻松应对动态布局
  5. 优先级支持:链式设置约束优先级,优雅处理约束冲突

三、核心功能速览

基础层 概念解释、环境配置、基础用法

环境要求与集成

SPM 集成:

// Package.swift
dependencies: [
    .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.0")
]

CocoaPods 集成:

pod 'SnapKit', '~> 5.7.0'

最简单的使用示例

import SnapKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let box = UIView()
        box.backgroundColor = .systemBlue
        view.addSubview(box)
        
        // 使用 SnapKit 创建约束
        box.snp.makeConstraints { make in
            make.center.equalToSuperview()
            make.width.height.equalTo(100)
        }
    }
}

进阶层 最佳实践、性能优化、线程安全

常用 API 一览

API 作用
makeConstraints 创建并激活约束
updateConstraints 更新已有约束(保持其他不变)
remakeConstraints 移除旧约束,创建新约束
removeConstraints 移除所有约束
prepareConstraints 预创建约束(不激活),用于条件判断

常见用法组合

// 1. 边距控制
view.snp.makeConstraints { make in
    make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16))
}

// 2. 相对布局
view1.snp.makeConstraints { make in
    make.top.left.equalToSuperview().offset(16)
    make.right.equalTo(view2.snp.left).offset(-8)
    make.height.equalTo(44)
}

// 3. 倍数与偏移
imageView.snp.makeConstraints { make in
    make.width.equalToSuperview().multipliedBy(0.5).offset(-16)
    make.height.equalTo(imageView.snp.width).multipliedBy(9.0/16.0)
}

// 4. 优先级设置
label.snp.makeConstraints { make in
    make.left.right.equalToSuperview().inset(16)
    make.top.equalToSuperview().offset(20)
    make.height.greaterThanOrEqualTo(20).priority(.required)
    make.height.lessThanOrEqualTo(100).priority(.high)
}

深入层 源码解析、设计思想、扩展定制

核心模块介绍

SnapKit 的架构设计非常精巧,主要包含以下几个核心组件:

  1. ConstraintMaker:DSL 的入口,提供链式调用接口
  2. ConstraintItem:封装约束的目标视图和属性
  3. Constraint:内部表示单个约束的数据结构
  4. ConstraintAttributes:约束属性的枚举封装

关键协议 ConstraintRelatableTarget 允许约束目标可以是:

  • 另一个视图 (UIView)
  • 数值 (CGFloat, Int)
  • 另一个约束项 (ConstraintItem)

这种设计使得 SnapKit 的 API 非常灵活,可以写出如 make.width.equalTo(100)make.width.equalTo(otherView) 这样自然的代码。


四、实战演示

下面是一个完整的登录界面布局示例,展示了 SnapKit 在实际业务场景中的应用:

import UIKit
import SnapKit

class LoginViewController: UIViewController {
    
    private let logoImageView = UIImageView()
    private let usernameTextField = UITextField()
    private let passwordTextField = UITextField()
    private let loginButton = UIButton(type: .system)
    private let forgotPasswordButton = UIButton(type: .system)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        setupConstraints()
    }
    
    private func setupViews() {
        view.backgroundColor = .systemBackground
        
        // Logo
        logoImageView.image = UIImage(systemName: "person.circle.fill")
        logoImageView.tintColor = .systemBlue
        logoImageView.contentMode = .scaleAspectFit
        view.addSubview(logoImageView)
        
        // Username
        usernameTextField.placeholder = "用户名"
        usernameTextField.borderStyle = .roundedRect
        usernameTextField.autocapitalizationType = .none
        view.addSubview(usernameTextField)
        
        // Password
        passwordTextField.placeholder = "密码"
        passwordTextField.borderStyle = .roundedRect
        passwordTextField.isSecureTextEntry = true
        view.addSubview(passwordTextField)
        
        // Login Button
        loginButton.setTitle("登录", for: .normal)
        loginButton.backgroundColor = .systemBlue
        loginButton.setTitleColor(.white, for: .normal)
        loginButton.layer.cornerRadius = 8
        view.addSubview(loginButton)
        
        // Forgot Password
        forgotPasswordButton.setTitle("忘记密码?", for: .normal)
        view.addSubview(forgotPasswordButton)
    }
    
    private func setupConstraints() {
        logoImageView.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide).offset(40)
            make.centerX.equalToSuperview()
            make.width.height.equalTo(80)
        }
        
        usernameTextField.snp.makeConstraints { make in
            make.top.equalTo(logoImageView.snp.bottom).offset(40)
            make.left.right.equalToSuperview().inset(32)
            make.height.equalTo(44)
        }
        
        passwordTextField.snp.makeConstraints { make in
            make.top.equalTo(usernameTextField.snp.bottom).offset(16)
            make.left.right.height.equalTo(usernameTextField)
        }
        
        loginButton.snp.makeConstraints { make in
            make.top.equalTo(passwordTextField.snp.bottom).offset(24)
            make.left.right.equalTo(usernameTextField)
            make.height.equalTo(48)
        }
        
        forgotPasswordButton.snp.makeConstraints { make in
            make.top.equalTo(loginButton.snp.bottom).offset(16)
            make.centerX.equalToSuperview()
        }
    }
}

关键要点:

  • 使用 view.safeAreaLayoutGuide 适配刘海屏
  • 通过 equalTo 复用约束,保持代码 DRY
  • 合理的间距和尺寸,确保界面美观

五、源码亮点

进阶层:值得借鉴的用法

链式调用的实现技巧

SnapKit 通过 @discardableResult 和返回 self 实现链式调用:

// 简化示意
struct ConstraintMaker {
    @discardableResult
    func equalTo(_ other: ConstraintRelatableTarget) -> ConstraintMaker {
        // 设置约束关系
        return self
    }
    
    @discardableResult
    func offset(_ amount: CGFloat) -> ConstraintMaker {
        // 设置偏移量
        return self
    }
}

类型安全的约束目标

使用协议和泛型确保编译期类型检查:

protocol ConstraintRelatableTarget {}
extension UIView: ConstraintRelatableTarget {}
extension CGFloat: ConstraintRelatableTarget {}
extension Int: ConstraintRelatableTarget {}

深入层:设计思想解析

Builder 模式的应用

ConstraintMaker 是 Builder 模式的典型应用:

  1. 分离构建与表示:DSL 描述意图,内部 Builder 构建实际约束
  2. 精细控制构建过程:支持 make/update/remake 不同构建策略
  3. 延迟执行:约束在闭包执行完毕后才真正创建和激活

Protocol-Oriented Programming

SnapKit 大量使用协议扩展实现功能:

// 所有视图自动获得 snp 属性
extension UIView {
    var snp: ConstraintDSL {
        return ConstraintDSL(view: self)
    }
}

这种设计让 SnapKit 可以无缝接入任何 UIView 子类,无需继承或修改原有类。


六、踩坑记录

问题 1:约束冲突导致界面异常

症状:控制台输出 "Unable to simultaneously satisfy constraints",界面布局错乱。

原因:SnapKit 自动设置 translatesAutoresizingMaskIntoConstraints = false,但如果视图在 Storyboard 或 Xib 中已有约束,会导致重复约束。

解决:确保代码创建的视图没有在其他地方添加约束,或使用 remakeConstraints 完全重制约束。

// 使用 remakeConstraints 清除旧约束
view.snp.remakeConstraints { make in
    make.edges.equalToSuperview()
}

问题 2:updateConstraints 找不到要更新的约束

症状:调用 updateConstraints 时约束没有变化,或控制台报错。

原因updateConstraints 只能更新已存在的约束。如果约束类型不同(如从 equalTo 改为 lessThanOrEqualTo),需要先用 remakeConstraints

解决:检查约束类型是否一致,不一致时使用 remakeConstraints

// ❌ 错误:尝试将 equalTo 更新为 lessThanOrEqualTo
view.snp.makeConstraints { make in
    make.width.equalTo(100)
}
view.snp.updateConstraints { make in
    make.width.lessThanOrEqualTo(200) // 不会生效
}

// ✅ 正确:使用 remakeConstraints
view.snp.remakeConstraints { make in
    make.width.lessThanOrEqualTo(200)
}

问题 3:在 UITableViewCell 中布局问题

症状:Cell 高度计算不正确,或复用时布局错乱。

原因:Cell 的 contentView 是实际容器,约束应该添加到 contentView 而非 Cell 本身。

解决:始终将子视图添加到 contentView,约束也相对于 contentView

class MyCell: UITableViewCell {
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        
        let label = UILabel()
        contentView.addSubview(label) // 注意是 contentView
        
        label.snp.makeConstraints { make in
            make.edges.equalTo(contentView).inset(16) // 相对于 contentView
        }
    }
}

问题 4:动画更新约束时闪烁

症状:使用 UIView.animate 更新 SnapKit 约束时出现闪烁或跳动。

原因:约束更新和布局刷新时机不正确。

解决:在动画块中先更新约束,然后调用 layoutIfNeeded()

// ✅ 正确的动画方式
view.snp.updateConstraints { make in
    make.width.equalTo(200)
}

UIView.animate(withDuration: 0.3) {
    self.view.layoutIfNeeded()
}

问题 5:与 SwiftUI 混用时的注意事项

症状:在 UIViewRepresentable 中使用 SnapKit 时约束不生效。

原因:SwiftUI 的生命周期和布局系统与 UIKit 不同。

解决:确保在 makeUIView 中创建约束,在 updateUIView 中使用 updateConstraintsremakeConstraints

struct MyView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        let subview = UIView()
        view.addSubview(subview)
        
        subview.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        // 更新约束
    }
}

七、延伸思考

与同类库的横向对比

维度 SnapKit PureLayout Cartography
语言 Swift Objective-C/Swift Swift
Stars 19k+ 7k+ 3k+
维护状态 ✅ 活跃 ⚠️ 维护较少 ⚠️ 已归档
API 风格 链式 DSL 方法调用 运算符重载
学习曲线
SwiftUI 支持 需桥接 需桥接 需桥接

推荐使用场景

推荐使用:

  • 纯 Swift UIKit 项目
  • 需要频繁动态更新布局的场景
  • 复杂界面,约束关系较多的页面
  • 团队已熟悉 Masonry(OC 版 SnapKit)

不推荐使用:

  • 纯 SwiftUI 项目(直接使用 SwiftUI 布局)
  • 零依赖要求的 SDK/框架开发
  • 极其简单的固定布局(原生代码量差异不大)

关于 Cartography 的说明

Cartography 是另一个流行的 Swift 布局 DSL,使用运算符重载(==>=<=)实现约束。虽然 API 非常优雅,但该项目目前已归档不再维护,不建议在新项目中使用。


八、参考资源


本期互动

小作业

尝试用 SnapKit 实现一个自适应高度的评论区 Cell,要求:

  1. 头像在左侧,固定 40x40
  2. 用户名在头像右侧,单行显示
  3. 评论内容在用户名下方,多行自适应高度
  4. 整体边距 16pt

完成后在评论区贴出你的 setupConstraints 方法代码。

思考题

如果你要自己实现一个类似的布局 DSL 库,你会如何设计 API 接口?是像 SnapKit 这样使用闭包和链式调用,还是像 Cartography 那样使用运算符重载?为什么?

读者征集

你在使用 SnapKit 时踩过哪些坑?或者有什么高级用法想分享?欢迎在评论区留言,优质回答会收录进下一期《踩坑记录》。

下一期选题投票:

  • A. RxSwift - 响应式编程库
  • B. Realm - 移动端数据库
  • C. Lottie - 动画渲染库

📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ MarkdownUI] [→ 本期 SnapKit] [○ 第5期]

苹果的罕见妥协:当高危漏洞遇上“拒升”潮 -- 肘子的 Swift 周报 #130

作者 东坡肘子
2026年4月7日 07:53

issue130.webp

苹果的罕见妥协:当高危漏洞遇上“拒升”潮

对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截至目前,已有上亿用户受到影响。

Coruna 主要针对 iOS 13 至 iOS 17 的设备,在过去几个月间,苹果已为这些系统推送了多次安全更新。DarkSword 则主要针对 iOS 18.4 至 18.7 的设备。尽管这部分设备均具备升级至 iOS 26 的硬件条件,但由于种种原因,仍有不少 iOS 18 用户选择按兵不动。

在很长一段时间里,苹果用户对于系统更新的态度都相当积极,这也是苹果生态的一大特色。但这一趋势在去年出现了变化——Liquid Glass 带来的巨大视觉冲击,让苹果用户中第一次出现了相当比例主动拒绝升级到 iOS 26 的现象。与此同时,为遵守英国《网络安全法》(Online Safety Act)的要求,苹果在 iOS 26.4 中为英国用户引入了强制年龄验证机制,由于验证条件严苛,不少成年用户甚至被系统强行锁入‘儿童模式’,进一步推动了英国用户停留在 iOS 18 或 iOS 26.3 的风潮。而拒绝安装新版本,意味着这部分用户同时放弃了后续所有安全补丁,让设备进一步暴露在潜在风险之下。

面对这一局面,苹果承受了明显的舆论压力与品牌风险。特别是在 3 月下旬,DarkSword 的完整攻击代码被泄露到了 GitHub 上,让这一国家级黑客工具瞬间平民化,直接迫使苹果必须采取紧急行动。最终,苹果罕见地为 iOS 18 单独推出了安全补丁 iOS 18.7.7,将原本仅用于 iOS 26 的防护机制回移植到旧系统。至此,苹果完成了针对本次高危漏洞的全部官方安全响应。

无论是苹果还是生态中的开发者,大多希望用户能积极跟进系统更新——既能减少多版本适配的维护负担,也能让用户尽快享受到新 API 带来的便利。但现实是,始终有一部分用户出于性能、续航、使用习惯乃至隐私等方面的考量,有意将设备锁定在某个版本。

本次事件或许会带来两个方向上的变化:苹果在压力下调整了长期坚守的更新策略,为刻意留守旧系统的用户做出了妥协;而事件本身的广泛传播,也可能促使更多用户从安全角度重新审视“能不更新就不更新”的惯性,回到积极更新的轨道。这种双向的改变,或许正是这场风波意料之外的收获。

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

近期推荐

通过 Animatable 深入 SwiftUI 动画 (Animatable in SwiftUI Explained - Complete Guide with Examples & Deep Dive)

网络上并不缺少探讨 SwiftUI 动画机制的文章,但 Sagar Unagar的这篇仍然提供了一个颇具启发性的切入点。他没有从隐式或显式动画入手,而是围绕 Animatable 协议做了一次系统梳理:从 animatableData 的作用,到 AnimatablePair 如何承载多个插值参数,再到通过自定义 VectorArithmetic 让更复杂的数据结构参与动画。文章最值得注意的一点在于其核心视角:SwiftUI 实际上是在“动画数据”,而非直接对视图进行动画处理。


在 Swift Package 中共享本地化资源 (Localization in Swift Packages)

Xcode 能为 .xcstrings 文件自动生成类型安全的 Swift 符号,但这些符号仅在资源所在的 module 内可见——一旦将本地化资源抽离为独立的 Localization 包,其他 feature 包便无法享受编译期检查的优势。Khan Winter 的解决方案相当直接:通过一个 bash 脚本解析 .xcstrings 的 JSON 结构,生成 public extension LocalizedStringResource 扩展,使所有模块都能以 .l10n.helloWorld 的形式访问翻译键。

其中一个颇具参考价值的细节是 Debug 模式下的 @dynamicMemberLookup 设计——访问不存在的键时仅记录日志而不崩溃,而在 Release 构建中仍保留完整的编译期校验。相比基于 Swift 可执行文件的方案,这种实现更加轻量,复制脚本即可使用。


Coordinator 全局导航模式 (SwiftUI Coordinator Pattern: Navigation Without NavigationLink)

尽管 SwiftUI 一直在丰富基于状态驱动的导航 API,但管理全局导航一直是 SwiftUI 中的一个“痛点”。Wesley Matlock 以一个五 Tab 的音乐收藏应用为例,展示了如何通过 Coordinator 模式将导航决策从 View 中抽离:用一个 Route 枚举统一描述所有目的地,由单一的 Coordinator 对象持有导航状态并执行跳转,View 只需声明“去哪”而无需关心“怎么去”。文章没有回避 NavigationPath 不透明、路由携带模型对象导致的 Hashable 困境等实际问题。对于大多数中等规模的 SwiftUI 应用来说,这是一个务实且易于落地的导航治理方案。


把 Hacking with Swift 的编程风格写进 AI (Teach your AI to write Swift the Hacking with Swift way)

Paul Hudson 和他的 Hacking with Swift 让很多开发者走上了 Swift 与 SwiftUI 的学习之路。在 AI 时代,Paul 不仅推出了面向苹果开发生态的各类专业 Skill,也开始尝试在与 AI 的协作中注入更具个人特质的编程风格。

在本文中,他分享了一份极具辨识度(且充满他标志性幽默)的 AGENTS.md 配置。这套规则不仅约束了 AI 的技术选型,还为 AI 注入了 Paul 的灵魂:强调先展示结果再解释原理、偏好清晰而非炫技、甚至包括在代码写得漂亮时适时地喊出一句 "Boom!"。与其说这是一份用于 AI 的“系统提示词”,不如说是在为 AI 定义一种编码哲学——某种程度上,这种方式正在将冷冰冰的“代码生成”推向带有人情味的“风格迁移”。


AI Agent 的道与术

在刚过去的 Let's Vision 2026 中,王巍(Onevcat) 发表了关于在大型开发团队中应用 AI Agent 的演讲。整场分享讨论的重点,并不是某个具体工具有多强,而是当代码实现成本被迅速压低后,团队该如何重新组织开发流程,以及工程师的价值该如何重新定位。

作为 LINE 应用开发团队的一员,Onevcat 在过去几个月中的工作重心也已明显发生变化。用他自己的话说,他正在逐步从传统意义上的 iOS 工程师,转向探索如何将 AI 应用于服务大型产品研发团队的实践者。这种角色上的变化,也让这场分享比一般的工具介绍更有说服力。

演讲围绕三个关键问题展开:如何控制上下文污染,如何把个人经验沉淀为团队可复用的 memory 与 skill,以及如何让协作模式从“人指挥多个 Agent”逐步走向更自动化的闭环。里面有不少相当接地气的实践建议,例如将 AGENTS.md 控制在精简范围内、为 Agent 提供模块定位与架构速查脚本、鼓励 Claude Code、Codex、OpenCode 等多种 harness 并存,以及通过 webhook、cron、pipeline 和自动验收机制让 Agent 真正进入团队流程。

演讲稿仓库 中不仅包含完整的 Slidev 源码,也保留了不少演讲配套材料,包括原始资料收集和与 AI 协作的完整 trace,值得一并阅读。


从零开始:用 AI 开发一个 iOS 原生 APP 完整指南

我经常会在社交媒体上看到一些零基础的“开发者”通过 AI 构建了自己的产品或服务。尽管我使用 AI 的时间也不短,但我仍然比较困惑:这条路径真的像大家描述的那样有效吗?Zachary Zhang 分享了他完全借助 AI 工具,从零构建并上架一款纯原生 iOS 应用(SwiftUI + Cloudflare 后端)的实战全过程。这篇文章最让我印象深刻的,是他严谨的“工程化管线”:在让 AI 写代码前,必须先生成结构化的 PRD 和 HTML 格式的视觉参考;而在工具选择上,他在项目“从 0 到 1”的冷启动阶段,极力推荐 Claude Code 等终端工具,以便更好地统览全局,一次性构建出合理的多文件项目架构。

或许你和我一样,对于 100% 基于 AI 的开发方式仍存疑惑。但在代码生成越来越廉价的今天,开发者的核心壁垒,正在加速向“需求精准拆解”、“系统架构把控”以及“面向报错的全局调度能力”转移。

工具

Slots:提高自定义 SwiftUI 组件设计效率的宏

将多个视图组合封装成可复用组件,是 SwiftUI 开发中的常见需求,对团队内部开发者或第三方库作者来说更是如此。但当组件包 title、icon、image、action 等多个泛型 View 插槽后,初始化器的组合数量往往会迅速膨胀。Kyle Bashour 创建的 Slots 宏,正是为了解决这类多 slot 组件的样板代码问题。

开发者只需声明组件的 slot 属性,宏便会按组合自动生成所需的初始化器,无需手写大量 init 重载。对于需要支持文本便捷写法的 slot,还可以通过 @Slot(.text) 自动获得 LocalizedStringKeyString 版本的初始化方式。 Slots 很适合用于构建设计系统中的 Card、Row、Banner、Toolbar 这类既要支持简单调用、又要保留高度定制能力的组件。


Explore SwiftUI:纯原生组件与修饰符的视觉速查图库

尽管 Apple 官方文档的质量在逐年改善,但对于以声明式和视觉驱动为主的 SwiftUI 来说,官方文档中依然缺乏足够直观的代码与 UI 效果对照,尤其是同一组件在 iOS、macOS 和 visionOS 等多平台上的表现差异。很多时候,开发者为了实现某个特定的 UI 细节,往往会去求助于复杂的第三方库或手写冗长的自定义视图,却忽略了 SwiftUI 本身可能已经提供了绝佳的原生解决方案。Florian 建立的 Explore SwiftUI 站点,正是一个为了解决这一痛点而生的“视觉速查字典”。它摒弃了任何第三方封装,纯粹以展示 Apple 官方内置组件的原生能力为核心。所有的代码示例都被剥离了无关的业务逻辑,保持极简,配以高质量的视觉预览,开发者只需“复制、粘贴、运行”即可直接验证效果。

书籍

SwiftUI Architecture: Patterns and Practices for Building Scalable Applications

这是一本 Mohammad Azam 在不久前出版的新书。它不是一本教你如何使用 VStack 或编写动画的入门书,而是一本纯粹探讨 SwiftUI 应用架构、数据流和现代工程化实践的进阶读物。

书中提供了大量直击生产环境痛点的解决方案,例如:如何构建全局的 Sheets 和 Toasts、如何利用 NavigationPath 设计解耦的多 Tab 编程式路由、以及如何使用 Property Wrapper 编写优雅的表单验证。尤为重要的是,作者并不是要向你灌输某种死板的架构模式,而是旨在帮助你建立真正的声明式心智模型。

或许有人觉得,在 AI 辅助编程盛行的时代,这类探讨架构的书籍还重要吗?借用 Mohammad Azam 在书中的观点:AI 让代码生成变得廉价,但也正因如此,系统架构的设计(边界的划分和状态所有权的明确)变得比以往任何时候都更加重要。

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

苹果的罕见妥协:当高危漏洞遇上“拒升”潮 - 肘子的 Swift 周报 #130

作者 Fatbobman
2026年4月6日 22:00

对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截止目前,已有上亿用户受到影响。

Swift Actor 为什么选择可重入设计?——一道让人深思的并发题

作者 探索者dx
2026年4月4日 19:34

Swift Actor 为什么选择可重入设计?——一道让人深思的并发题

iOS 进阶必修 · Swift 并发编程系列 第 2 期


面试官问你:"Swift 的 actor 是可重入的,你觉得这个设计合理吗?"

很多人第一反应是:可重入?那不是有 bug 风险吗?为什么不做成传统锁那样不可重入?

这篇文章就来彻底说清楚这件事。


先把概念说明白

可重入(Reentrant):actor 在 await 挂起时会释放自身的"访问权",其他任务可以趁机进入 actor 执行别的方法。

不可重入(Non-reentrant):actor 一旦被某任务占用,其他任务必须排队等待,直到当前任务彻底执行完(包括所有 await)。

用一句话概括差异:

可重入:await 是"暂时离开",锁被放开
不可重入:await 是"原地等待",锁被一直握着


如果 Actor 是不可重入的,会发生什么?

死锁:跨 actor 调用的必然结局

actor ServiceA {
    let b: ServiceB
    func doWork() async {
        await b.help()   // A 持锁,等待 B
    }
}

actor ServiceB {
    let a: ServiceA
    func help() async {
        await a.check()  // B 持锁,等待 A ← 死锁!
    }
}

两个 actor 互相持锁等待对方,经典死锁。

在真实业务里这种结构比比皆是——网络层调用缓存层,缓存层调用配置层,配置层又依赖某个共享状态…只要存在环形调用,就必然死锁。

而且这种死锁极难排查:没有崩溃日志,没有报错,App 就静静地卡在那里。

Actor 内部 async 调用,自己等自己

actor Logger {
    func log(_ msg: String) async {
        await writeToFile(msg)   // 不可重入 → 自己等自己 → 死锁
    }

    func writeToFile(_ msg: String) async {
        // 磁盘写入…
    }
}

这意味着什么?actor 内部完全不能出现 await。但现实中 actor 管理的资源(网络、磁盘、数据库)几乎无一例外需要异步操作。

不可重入 + async/await 生态,在逻辑上根本无法自洽。


那可重入会带来哪些坑?

可重入把死锁风险消除了,但代价是:await 挂起期间,actor 的状态可能被其他任务修改

坑 1:状态假设在 await 前后失效

这是最经典的重入陷阱,银行转账场景:

actor BankAccount {
    var balance: Double = 1000

    func withdraw(_ amount: Double) async throws {
        // ① 检查余额:1000 >= 800,通过
        guard balance >= amount else { throw InsufficientFundsError() }

        // ② await 挂起,actor 释放访问权
        //    另一个 withdraw(800) 趁机进来,也通过了 guard
        //    它先执行,balance 变成 200
        await logTransaction(amount)

        // ③ 回来继续执行:800 > 200,但已经没有再次检查!
        balance -= amount   // balance = 200 - 800 = -600,超支!
    }
}

// 并发:两个任务同时取 800
Task { try await account.withdraw(800) }
Task { try await account.withdraw(800) }
// 最终 balance = -600,资损!

问题的根源:guard 检查到 balance -= amount 之间夹着一个 await,整个操作不是原子的。

坑 2:不变量(Invariant)在 await 期间被破坏

actor DataPipeline {
    var isProcessing = false
    var buffer: [Data] = []

    func process() async {
        guard !isProcessing else { return }
        isProcessing = true   // 设置标志

        // await 挂起,另一个 process() 调用进来
        // 它看到 isProcessing = true,直接 return
        // 看起来没问题…但如果两个调用"同时"通过 guard 呢?
        // → 取决于调度时序,存在 TOCTOU(检查-使用时差)窗口
        await doHeavyWork()

        isProcessing = false
    }
}

正确应对可重入的三个模式

模式一:await 之前完成所有关键状态变更

actor BankAccount {
    var balance: Double = 1000

    // ✅ 正确写法
    func withdraw(_ amount: Double) async throws {
        guard balance >= amount else { throw InsufficientFundsError() }
        balance -= amount          // ← 先改状态(无 await,绝对原子)
        await logTransaction(amount)  // 再异步处理(状态已一致)
    }
}

规则guard 检查通过后,立刻完成状态变更,然后才 awaitawait 之后不再依赖之前检查过的条件。


模式二:原子卫兵——同步方法作为临界区

actor SafeQueue {
    private var items: [WorkItem] = []
    private var isRunning = false

    // 同步方法:无 await,绝对原子
    private func takeNext() -> WorkItem? {
        guard let item = items.first else { return nil }
        items.removeFirst()  // 取出即删除,不会被重入影响
        return item
    }

    func drainAll() async {
        guard !isRunning else { return }
        isRunning = true
        while let item = takeNext() {
            await item.execute()   // await 时 item 已从队列移除,安全
        }
        isRunning = false
    }
}

思路:把"检查 + 修改"合并进一个不含 await 的同步方法,让它成为原子操作。


模式三:状态机保护并发入口

actor TaskScheduler {
    private enum Phase { case idle, running, draining }
    private var phase: Phase = .idle

    func schedule(_ task: Task<Void, Never>) async {
        guard phase == .idle else { return }
        phase = .running           // ← await 之前切状态,拿到"令牌"
        await task.value           // 其他调用看到 .running,直接 return
        phase = .idle
    }
}

用状态机枚举而非 Bool 标志,让每种状态的含义更清晰,也更难被误用。


设计对比:可重入 vs 不可重入

维度 不可重入(传统锁语义) 可重入(Swift actor)
跨 actor 调用 ❌ 极易死锁 ✅ 安全
actor 内部 await ❌ 自己等自己,死锁 ✅ 正常工作
状态一致性 await 前后一致 ⚠️ 开发者自行保证
死锁风险 ❌ 高,且难排查 ✅ 无
正确性复杂度 低(锁语义直觉) 中(需理解挂起语义)
与 async/await 生态兼容性 ❌ 根本无法自洽 ✅ 天然融合

Apple 为什么必须选可重入

这是一道"两害取其轻"的工程决策题:

  • 死锁:不可预测,运行时无日志,难以复现,线上问题几乎无法定位
  • 重入陷阱:有规律可循(await 前完成状态变更),编码期可发现,有明确的防御模式

Apple 把不确定性更高、危害更大的风险消除了,把相对可控的复杂性留给开发者。

从语言设计角度看,这也与 Swift 的一贯哲学吻合:编译器负责能静态验证的安全,开发者负责剩下的语义正确性

Swift 6 的严格并发检查(-strict-concurrency=complete)正在把越来越多的重入问题提升为编译器警告,方向是对的。


实际项目中的选择建议

优先用可重入,配合以下纪律:

  1. 黄金法则await 之前必须完成所有关键状态变更,await 之后不再信任之前读取的条件
  2. 原子临界区:把"检查 + 修改"封装进无 await 的同步方法
  3. 状态机优先:用枚举状态机而非 Bool 标志管理并发入口
  4. 最小化 await 范围:需要保护的临界操作不要夹带 await
// 完整示例:安全的资源管理 actor
actor ResourceManager {
    private enum State { case idle, acquired, releasing }
    private var state: State = .idle
    private var resource: Resource?

    // ✅ 获取资源:先拿到"凭证"再 await
    func acquire() async throws -> Resource {
        guard state == .idle else { throw ResourceError.busy }
        state = .acquired            // 改状态在 await 之前
        let res = try await fetchResource()
        resource = res
        return res
    }

    // ✅ 释放资源:先清理状态再 await
    func release() async {
        guard state == .acquired else { return }
        let res = resource
        resource = nil               // 先清空
        state = .releasing
        await cleanupResource(res)
        state = .idle
    }
}

总结

问题 答案
可重入设计合理吗? 合理,是工程必要性决定的,不是妥协
不可重入的最大问题? 跨 actor 死锁 + 内部 async 调用死锁,且难排查
可重入最大的坑? await 前后状态假设失效,经典场景是 guard 通过后 await,回来状态已变
实际项目怎么用? 拥抱可重入,用"await 前完成状态变更"作为硬性编码纪律

可重入的坑有规律可循,死锁没有。选可重入,然后学会驾驭它。


延伸思考

Kotlin 协程的 Mutex 提供了不可重入的互斥锁,但它是手动使用的工具,而不是语言默认行为——与 Swift actor 的定位完全不同。Java 的 synchronized 则是可重入的(同一线程可以重复进入),与 Swift actor 的可重入语义有些类似,但实现机制不同。

Swift actor 的可重入设计,本质上是结构化并发思想的延伸:任务在 await 时让出资源,让其他任务有机会推进,整个系统的吞吐量更高,而不是让一个任务独占 actor 直到它的所有 await 全部完成。


如果你在项目里遇到过 actor 重入导致的 bug,欢迎评论区分享——是什么场景、如何发现、怎么修复的?优质案例会收录进下一期。


📅 本系列持续更新 ✅ 第 1 期:Swift Concurrency 基础精讲 · ➡️ 第 2 期:Actor 可重入设计深析(本期)· ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定

【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲

作者 探索者dx
2026年4月4日 19:17

【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲

iOS 进阶必修 · Swift 并发编程系列 第 1 期


一、一句话介绍

Swift Concurrency 是 Apple 在 Swift 5.5(iOS 15+)正式引入的原生并发框架,它让异步代码的编写、错误处理、线程安全变得声明式、结构化、且编译器可静态验证。

属性 信息
引入版本 Swift 5.5 / Xcode 13
运行时最低要求 iOS 13+(back-deploy)/ iOS 15+ 全功能
核心特性 async/await · Task · Actor · AsyncStream
与 Combine 关系 互补共存,AsyncSequence 可与 Combine 互转
官方文档 Swift Concurrency

二、为什么选择它

原生异步方案的痛点

在 Swift Concurrency 出现之前,iOS 异步编程长期面临这些问题:

旧方案 Swift Concurrency
回调嵌套(Callback Hell),可读性极差 async/await 线性写法,与同步代码几乎一致
DispatchQueue + 锁保护共享状态,极易出错 actor 编译器静态保证线程安全
DispatchGroup 聚合多个并行任务,样板代码多 async let / withTaskGroup 声明式并行
任务取消需要自行维护 flag,容易遗漏 结构化取消,父取消子自动跟随
线程切换 DispatchQueue.main.async {} 到处散落 @MainActor 注解,编译器强制保证主线程
Combine 学习曲线陡,操作符多 AsyncStream 原生支持,与 for await 天然融合

核心优势:

  • 可读性:async/await 让异步代码读起来像同步,减少 80% 认知负担
  • 安全性:actor 让数据竞争成为编译错误而非运行时崩溃
  • 结构化:父子任务形成树形结构,取消/错误自动传播
  • 可组合:AsyncSequence 统一了事件流、定时器、网络流的消费模型
  • 零依赖:语言内置,无需引入任何第三方库

三、核心功能速览

基础层(新手必读)

无需配置,开箱即用

Swift Concurrency 是语言特性,直接在 Xcode 13+ 的任意 Swift 文件中使用:

// Swift 5.5+ · iOS 13+ (back-deploy) / iOS 15+ (全功能)
import Foundation  // 仅需标准库

async/await:异步函数的声明与调用

// ✅ 声明异步函数:加 async 关键字
func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

// ✅ 调用:必须在 async 上下文中,用 await 挂起
Task {
    do {
        let user = try await fetchUser(id: 1)
        print(user.name)
    } catch {
        print("加载失败:\(error)")
    }
}

await挂起点而非阻塞点:挂起时线程被释放,恢复后可能在不同线程继续执行。这是 Swift Concurrency 高效的根本原因。


深入理解:await 挂起 vs 传统回调的线程行为

这是理解 Swift Concurrency 为何高效的关键,也是很多人初学时最容易混淆的地方。

传统 GCD 回调的线程行为

// 传统方式:调用线程不阻塞,但"上下文"从此丢失
func fetchData(completion: @escaping (Data) -> Void) {
    URLSession.shared.dataTask(with: url) { data, _, _ in
        // 回调所在线程:URLSession 内部线程(不确定,通常是子线程)
        completion(data!)
    }.resume()
}

// 调用方
fetchData { data in
    // ⚠️ 线程已改变,需要手动切回主线程
    DispatchQueue.main.async {
        self.label.text = "done"   // 上下文全靠开发者自己管理
    }
}
// 调用方线程立即继续往下跑(不等待,也不挂起)
print("这行代码立即执行,不等 fetchData 完成")

async/await 的线程行为

// async/await:await 是挂起点,调用线程被释放回线程池
func fetchData() async -> Data { ... }

func loadPage() async {
    print("当前线程:\(Thread.current)")  // 线程 A
    
    let data = await fetchData()          // ← 挂起点:线程 A 被释放,可去执行其他任务
    
    // 恢复后:可能是不同线程,但 Actor 上下文(如 @MainActor)被自动还原
    print("恢复线程:\(Thread.current)")  // 可能是线程 B,但上下文依然正确
    updateUI(data)                        // 如果在 @MainActor 中,编译器保证这里一定在主线程
}

两者最本质的区别:线程是否被"占用"

维度 传统 GCD 回调 async/await
调用方线程 继续运行(不等待,不挂起) 挂起,线程释放回线程池
等待期间 调用线程去干别的事(但无连接) 线程被其他任务复用
回调/恢复线程 由 GCD 队列决定,不确定 由调度器决定,保留 Actor 上下文
代码连续性 回调嵌套,逻辑分散 线性代码,逻辑连续
线程安全 手动管理,容易出错 编译器 + Actor 静态保证

⚠️ 注意:传统回调的调用方线程确实不阻塞,这点和 await 一样。但两者的关键区别在于:传统回调是"断开连接"继续跑,而 await 是"挂起等待"并能恢复连续执行上下文

为什么 async/await 不会导致线程爆炸

传统 GCD 的一个隐患:当你用 semaphore.wait()DispatchGroup.wait() 真正"等"结果时,线程被阻塞(占着资源啥也不干)。系统发现线程不够用时会持续创建新线程,最终导致线程爆炸。

// ❌ 危险:阻塞线程(传统方式的隐患)
let sema = DispatchSemaphore(value: 0)
fetchData { data in sema.signal() }
sema.wait()  // 线程在此阻塞,占着系统资源却无法被复用
             // 并发请求多时,可能导致线程数量爆炸

Swift Concurrency 的协作式线程池解决了这个问题:

// ✅ 协作式挂起:线程释放回池子,完全不阻塞
let data = await fetchData()
// 线程池始终维持在约等于 CPU 核数的小规模,高效复用

Swift Concurrency 的线程池设计

传统 GCD 线程池(可能爆炸)          Swift Concurrency 协作式线程池
┌──────────────────────────┐         ┌──────────────────────────┐
│ 线程1(等待网络,阻塞)     │         │ 线程1(执行 Task A)        │
│ 线程2(等待数据库,阻塞)   │         │ 线程2(执行 Task B)        │
│ 线程3(等待文件,阻塞)     │  vs     │ 线程3(执行 Task C)        │
│ 线程4(新建中...)         │         │ ← 线程数 ≈ CPU 核数         │
│ 线程N(继续新建...)  💥   │         │ Task 挂起时释放线程,不占用  │
└──────────────────────────┘         └──────────────────────────┘

一句话总结:

  • 传统回调:调用线程不等待,但回调上下文断开,线程安全靠自己保证,用 wait() 等待时会阻塞线程
  • async/await:挂起点释放线程,调度器恢复时还原上下文,Actor 保证线程安全,系统始终保持小规模线程池

这就是为什么同样是"异步",Swift Concurrency 在高并发场景下比传统 GCD 回调效率更高、更安全。


SwiftUI 中使用 .task 修饰符(推荐)

struct UserView: View {
    @State private var user: User?

    var body: some View {
        Text(user?.name ?? "加载中...")
            .task {
                // 视图消失时任务自动取消,无需手动管理
                user = try? await fetchUser(id: 1)
            }
    }
}

进阶层(最佳实践)

async let:并行执行多个任务

// ❌ 顺序执行:总耗时 = 500ms + 300ms + 200ms = 1000ms
let user    = try await fetchUser(id: 1)
let orders  = try await fetchOrders(uid: 1)
let profile = try await fetchProfile(uid: 1)

// ✅ async let 并行:总耗时 = max(500ms, 300ms, 200ms) = 500ms
async let user    = fetchUser(id: 1)
async let orders  = fetchOrders(uid: 1)
async let profile = fetchProfile(uid: 1)
let (u, o, p) = try await (user, orders, profile)
// 三行代码实现并行,耗时减半

withTaskGroup:动态数量的并行任务

// 并行下载数量不固定的图片列表
func downloadImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask { try await fetchImage(from: url) }
        }
        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Task:非结构化任务与取消

// 创建任务(继承当前 actor 上下文)
let task = Task(priority: .userInitiated) {
    for i in 1...100 {
        try Task.checkCancellation()   // 取消时自动 throw CancellationError
        await processItem(i)
    }
}

// 取消(协作式,不会强制停止)
task.cancel()

// Task.detached:不继承 actor 上下文,完全独立
Task.detached(priority: .background) {
    let result = await heavyComputation()
    await MainActor.run { updateUI(result) }
}

Continuation:桥接旧式回调 API

// 将旧式 completion block API 包装为 async 函数
func requestLocation() async throws -> CLLocation {
    try await withCheckedThrowingContinuation { continuation in
        locationManager.requestLocation { location, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let location {
                continuation.resume(returning: location)
            }
        }
    }
}
// ⚠️ resume 只能调用一次,多次调用会 crash

深入层(源码视角)

核心模块职责划分

特性 职责 适用场景
async/await 异步函数声明与挂起 任何异步 IO 操作
async let 静态数量并行任务 首页多接口聚合
Task 非结构化任务单元 按钮触发的独立操作
withTaskGroup 动态数量结构化并发 批量下载/处理
actor 数据竞争保护 共享状态管理
@MainActor 主线程强制约束 UI 更新
Sendable 跨边界类型安全 actor 参数/返回值
AsyncStream 自定义异步序列 事件流/实时数据

四、实战演示

场景:AI 流式问答 + 打字机渲染

这是目前最热门的应用场景之一,完整演示了 AsyncStream + Task + @MainActor 的协同工作。

// Swift 5.5+

// MARK: - 1. 流式 AI 服务层(可替换为真实 SSE 接口)

enum AIStreamService {
    /// Mock:逐字符推送,实际项目替换为 URLSession.bytes 读取 SSE
    static func stream(prompt: String) -> AsyncStream<String> {
        let response = "Swift Concurrency 让并发编程如行云流水," +
            "async/await 消除回调地狱,Actor 守护数据安全," +
            "AsyncStream 带来流式体验。🚀"

        return AsyncStream { continuation in
            Task {
                for char in response {
                    guard !Task.isCancelled else {
                        continuation.finish()
                        return
                    }
                    continuation.yield(String(char))
                    try? await Task.sleep(nanoseconds: 60_000_000) // 60ms/字
                }
                continuation.finish()
            }
        }
    }

    /// 接入真实 SSE 接口(生产参考)
    static func streamFromSSE(url: URL) -> AsyncStream<String> {
        AsyncStream { continuation in
            Task {
                let (bytes, _) = try await URLSession.shared.bytes(from: url)
                for try await line in bytes.lines {
                    guard line.hasPrefix("data: "),
                          let data = line.dropFirst(6).data(using: .utf8),
                          let json = try? JSONDecoder().decode(TokenResponse.self, from: data)
                    else { continue }
                    continuation.yield(json.token)
                }
                continuation.finish()
            }
        }
    }
}

// MARK: - 2. SwiftUI 打字机视图

struct TypewriterView: View {
    @State private var prompt = "Swift 并发编程"
    @State private var output = ""
    @State private var isStreaming = false
    @State private var streamTask: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            TextField("输入问题…", text: $prompt)
                .textFieldStyle(.roundedBorder)

            // 打字机光标效果
            Text(output + (isStreaming ? "▌" : ""))
                .font(.body)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
                .background(Color(.secondarySystemBackground))
                .cornerRadius(10)
                .animation(.none, value: output)

            HStack(spacing: 12) {
                Button(isStreaming ? "生成中…" : "开始生成") {
                    startStream()
                }
                .buttonStyle(.borderedProminent)
                .disabled(isStreaming)

                Button("停止") {
                    streamTask?.cancel()
                    isStreaming = false
                }
                .buttonStyle(.bordered)
                .tint(.red)
                .disabled(!isStreaming)
            }
        }
        .padding()
        .onDisappear { streamTask?.cancel() } // ✅ 离开页面时取消
    }

    private func startStream() {
        streamTask?.cancel()
        output = ""
        isStreaming = true
        streamTask = Task {
            for await token in AIStreamService.stream(prompt: prompt) {
                output += token  // SwiftUI 自动感知变化实时渲染
            }
            isStreaming = false
        }
    }
}

// MARK: - 3. UIKit 打字机控制器(@MainActor 保证 UI 安全)

@MainActor
class TypewriterViewController: UIViewController {
    private let textView = UITextView()
    private var streamTask: Task<Void, Never>?

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        streamTask?.cancel()  // ✅ 离开页面时取消,防止内存泄漏
    }

    @objc func startStream() {
        streamTask?.cancel()
        textView.text = ""
        streamTask = Task {
            for await token in AIStreamService.stream(prompt: "UIKit") {
                guard !Task.isCancelled else { break }
                textView.text += token
                // 自动滚到底部
                let range = NSRange(location: textView.text.count - 1, length: 1)
                textView.scrollRangeToVisible(range)
            }
        }
    }
}

这个示例完整演示了:AsyncStream 的创建与消费、Task 的取消管理、@MainActor 的 UI 安全保证、SwiftUI 和 UIKit 的两套接入方式。


五、源码亮点

进阶层:值得借鉴的设计

Actor 并发计数器(告别 DispatchQueue + 锁)

// ❌ 传统写法:容易因忘记加锁而出现数据竞争
class Counter {
    var value = 0
    let queue = DispatchQueue(label: "counter.queue")
    func increment() { queue.sync { value += 1 } }
}

// ✅ actor:编译器静态保证,忘加 await 直接报错
actor SafeCounter {
    private(set) var value = 0
    func increment() { value += 1 }
}

// 并发使用:1000 个任务同时递增,结果一定是 1000
let counter = SafeCounter()
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<1000 {
        group.addTask { await counter.increment() }
    }
}
print(await counter.value)  // 1000,绝无数据竞争

AsyncStream 资源安全回收

// 定时器流:onTermination 防止 timer 泄漏
func timerStream(interval: Double) -> AsyncStream<Int> {
    AsyncStream { continuation in
        var tick = 0
        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
            tick += 1
            continuation.yield(tick)
        }
        // ✅ 流取消/结束时自动调用,清理外部资源
        continuation.onTermination = { _ in
            timer.invalidate()
        }
    }
}

深入层:设计思想解析

结构化并发的思想来源

结构化并发的核心理念来自结构化编程的类比:就像 if/for/while 让控制流有明确的进入和退出点,结构化并发让并发任务的生命周期也有明确的边界

// 传统 GCD:任务生命周期不受控
func fetchData() {
    DispatchQueue.global().async {
        // 这个任务完全脱离 fetchData 的控制
        // fetchData 返回后,任务仍在跑
    }
}

// 结构化并发:任务生命周期受作用域约束
func fetchData() async {
    async let result = networkCall()   // 任务在这里创建
    let data = await result            // 函数返回前,任务必须完成
}                                      // ← 离开作用域,所有子任务保证已结束

三大核心约束

约束 含义
父子关系 子任务归属于父任务,父任务取消时子任务自动取消
生命周期包含 父任务不能在子任务完成之前结束
错误传播 子任务的错误必须传递给父任务处理

非结构化 vs 结构化对比

// ❌ 非结构化(Task.detached)—— 孤儿任务,生命周期不受控
Task.detached {
    await riskyOperation()  // 即使调用方已取消,这里仍然在跑
}

// ✅ 结构化(async let / TaskGroup)—— 任务有明确的父子关系
await withTaskGroup(of: String.self) { group in
    group.addTask { await fetch("A") }
    group.addTask { await fetch("B") }
    // 离开 withTaskGroup 之前,所有子任务保证结束
}

这套思想由 Nathaniel J. Smith 的 Notes on structured concurrency 奠基,Swift 从 5.5 开始通过 async letTaskGroupactor 全面落地。与 Kotlin 协程的 StructuredConcurrency 一脉相承,但 Swift 通过编译器强制实施,更难写错。


结构化并发:任务树模型

Swift Concurrency 引入了"结构化并发"概念——任务形成父子树形结构:

父任务(Task)
 ├── 子任务 A(async let)
 ├── 子任务 B(async let)
 └── TaskGroup
      ├── 子任务 C(addTask)
      └── 子任务 D(addTask)

关键特性:

  • 父取消 → 子自动取消:无需手动遍历
  • 子抛出错误 → 父捕获:错误自动冒泡
  • 父作用域结束 → 等待所有子完成:无任务泄漏

这与 Kotlin 协程的 StructuredConcurrency 思想一脉相承,但 Swift 通过编译器强制实施,更难写错。


Actor 的可重入设计

Actor 内部通过隐式串行队列保证数据安全,但它是可重入的:

actor BankAccount {
    var balance: Double = 1000

    // ⚠️ 重入陷阱:await 挂起期间,其他任务可进入 actor 修改 balance
    func withdrawUnsafe(amount: Double) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        await logTransaction(amount)  // 挂起!balance 可能被别的 withdraw 修改
        balance -= amount             // 此时 balance 可能已不足!
    }

    // ✅ 正确:先修改状态再 await
    func withdrawSafe(amount: Double) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        balance -= amount          // 先扣,在 await 之前完成关键状态变更
        await logTransaction(amount)
    }
}

规则:actor 中,await 之前必须完成所有关键状态变更。


六、踩坑记录

问题 1:Continuation.resume 调用了多次导致 crash

  • 原因:某些旧 SDK 的 completion block 可能被调用多次(如进度回调)
  • 解决:用 bool flag 保护,确保 resume 只执行一次
func safeContinuation<T>(_ block: (@escaping (T) -> Void) -> Void) async -> T {
    await withCheckedContinuation { continuation in
        var resumed = false
        block { value in
            guard !resumed else { return }
            resumed = true
            continuation.resume(returning: value)
        }
    }
}

问题 2:Task.detached 中直接更新 UI 导致崩溃

  • 原因Task.detached 不继承当前 actor 上下文,不在主线程
  • 解决:显式切回主线程
// ❌ 危险
Task.detached { self.label.text = "done" }

// ✅ 正确
Task.detached {
    let result = await process()
    await MainActor.run { self.label.text = result }
}

问题 3:视图消失后 Task 仍在运行,导致内存泄漏

  • 原因:Task 生命周期独立于视图,视图销毁后任务仍持有 self
  • 解决:SwiftUI 用 .task {} 修饰符(自动管理),UIKit 在 viewWillDisappear 中 cancel
// UIKit
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    loadTask?.cancel()
}

问题 4:Actor 重入性导致余额多扣

  • 原因:await 挂起期间其他任务进入 actor 修改共享状态
  • 解决:遵守"先修改状态,再 await"原则(见第五章深入层)

问题 5:AsyncStream 中 timer / 监听器未释放,持续运行

  • 原因:忘记实现 continuation.onTermination
  • 解决:每个 AsyncStream 必须实现 onTermination,清理外部资源
continuation.onTermination = { reason in
    timer.invalidate()
    notificationCenter.removeObserver(observer)
}

问题 6:withTaskGroup 中子任务抛出错误没有被感知

  • 原因:使用了 withTaskGroup(不抛出版),错误被吞掉
  • 解决:需要错误传播时,使用 withThrowingTaskGroup
// ✅ 任意子任务失败,整个 group 取消并抛出错误
try await withThrowingTaskGroup(of: Data.self) { group in
    for url in urls { group.addTask { try await fetch(url) } }
    for try await data in group { process(data) }
}

问题 7:在 iOS 13 / 14 上使用 actor 报链接错误

  • 原因:actor 运行时需要 iOS 15+ 的系统库支持;Xcode back-deploy 支持 async/await 但不完全支持 actor
  • 解决:确认最低 Deployment Target,或对 actor 用 @available(iOS 15, *) 包裹

七、延伸思考

与同类方案横向对比

方案 简介 学习曲线 线程安全 取消支持 适用场景
Swift Concurrency Swift 原生,语言级别支持 编译器保证(actor) 结构化取消 新项目首选
GCD + DispatchQueue 苹果传统并发方案 手动加锁,容易出错 无原生支持 老项目维护
Combine 响应式框架,操作符丰富 需手动 receive(on:) AnyCancellable 复杂数据流转换
PromiseKit 基于 Promise 的链式回调 无特殊支持 有限支持 OC/早期 Swift 项目
RxSwift 响应式编程全家桶 很高 需配置 scheduler Disposable 重度响应式架构

推荐使用场景

  • ✅ iOS 13+ 新项目,全面拥抱 Swift Concurrency
  • ✅ 需要并行聚合多个接口的页面(async let / TaskGroup)
  • ✅ 共享状态管理,替代 DispatchQueue + 锁(actor)
  • ✅ 实时数据流、WebSocket、AI 流式响应(AsyncStream)
  • ✅ 需要优雅取消的长时任务(下载、文件处理)

不推荐场景

  • ❌ 项目最低支持 iOS 12 及以下,部分特性无法使用
  • ❌ 已有大量 Combine 代码,短期内迁移成本过高
  • ❌ 需要复杂响应式操作符链(merge、combineLatest 等),Combine 更合适

迁移策略建议

  1. 新功能优先用 async/await,不强制改旧代码
  2. 旧接口Continuation 包装,对调用方透明
  3. Combine Pipeline 可通过 .values 属性转为 AsyncSequence 互通
  4. Swift 6 开启严格并发检查(-strict-concurrency=complete),提前消灭隐患

八、参考资源


九、本期互动

小作业

基于本文的 AsyncStream 示例,实现一个实时心跳检测器

  1. AsyncStream 每隔 1 秒 yield 一次当前时间戳
  2. 连续 5 次 yield 后,主动调用 continuation.finish() 结束流
  3. 在 SwiftUI 中用 .task {} 消费流,将每次时间戳展示在列表中
  4. 点击「停止」按钮时,通过 task.cancel() 终止流,并验证 onTermination 被调用

完成后在评论区贴出你的 AsyncStream 创建代码和 onTermination 实现,说明你是如何验证资源正确释放的。


思考题

Swift Concurrency 的 actor 选择了可重入设计——即 await 挂起时允许其他任务进入 actor 执行。你认为这个设计决策是合理的吗?

如果设计成不可重入(像传统锁一样),会带来哪些问题?可重入又会引发哪些坑?在你的实际项目中,你更希望哪种行为?


读者征集

下一期预计深入讲解 Swift Concurrency 进阶:自定义 AsyncSequence、结构化并发原理、Swift 6 严格并发检查实战

如果你在项目中迁移到 Swift Concurrency 时踩过坑(特别是 actor 重入、Sendable 编译报错、Task 泄漏等),欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!


📅 本系列持续更新 ➡️ 第 1 期:Swift Concurrency 基础精讲(本期)· ○ 第 2 期:Actor 可重入设计深析 · ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定

【Alamofire】优雅的 Swift 网络库——告别繁琐的 URLSession

作者 探索者dx
2026年4月4日 16:20

【Alamofire】优雅的 Swift 网络库——告别繁琐的 URLSession

iOS三方库精读 · 第 1 期


一、一句话介绍

Alamofire 是一个用于 iOS / macOS / watchOS / tvOS 的 Swift HTTP 网络库,它让发起网络请求、处理响应、上传/下载文件变得声明式、可组合、且极易阅读。

属性 信息
⭐ GitHub Stars ~41k
最新版本 5.x(当前 5.9+)
License MIT
支持平台 iOS 10+ / macOS 10.12+ / tvOS 10+ / watchOS 3+
Swift 最低版本 Swift 5.7+

二、为什么选择它

原生 URLSession 的痛点

苹果的 URLSession 功能完整,但在工程实践中会遇到这些问题:

原生 URLSession Alamofire
需要手动构建 URLRequest 链式 API,一行发起请求
响应解析需要大量样板代码 内建 Decodable 自动解析
上传/下载进度管理繁琐 原生支持进度回调
拦截器/重试需要自行实现 内建 RequestInterceptor
错误处理分散、不统一 统一的 AFError 体系

核心优势:

  • 声明式链式调用,代码意图一目了然
  • 内建 JSON/Decodable 解析,减少胶水代码
  • RequestInterceptor:拦截、重试、Token 刷新统一处理
  • EventMonitor:全链路可观测,调试/日志非常方便
  • async/await 原生支持(5.5 起)

三、核心功能速览

基础层(新手必读)

集成方式(SPM 推荐)

Package.swift 或 Xcode 的 Package Dependencies 中添加:

https://github.com/Alamofire/Alamofire.git

最简单的 GET 请求

// Swift 5.7+
import Alamofire

AF.request("https://httpbin.org/get").responseJSON { response in
    print(response.value ?? "No data")
}

使用 async/await(推荐)

let response = await AF.request("https://httpbin.org/get")
    .serializingDecodable(MyModel.self)
    .response

switch response.result {
case .success(let model): print(model)
case .failure(let error): print(error)
}

进阶层(最佳实践)

带参数的 POST 请求

let parameters: [String: Any] = ["username": "swift", "password": "123456"]

AF.request(
    "https://httpbin.org/post",
    method: .post,
    parameters: parameters,
    encoding: JSONEncoding.default,
    headers: ["Authorization": "Bearer your_token"]
)
.validate(statusCode: 200..<300)   // 自动校验状态码
.responseDecodable(of: LoginResponse.self) { response in
    // 直接拿到强类型 Model
}

文件上传(带进度)

AF.upload(
    multipartFormData: { form in
        form.append(fileData, withName: "file", fileName: "photo.jpg", mimeType: "image/jpeg")
    },
    to: "https://example.com/upload"
)
.uploadProgress { progress in
    print("上传进度:\(progress.fractionCompleted)")
}
.responseDecodable(of: UploadResult.self) { response in
    print(response.value)
}

文件下载

let destination = DownloadRequest.suggestedDownloadDestination()
AF.download("https://example.com/file.zip", to: destination)
    .downloadProgress { progress in
        print("下载进度:\(Int(progress.fractionCompleted * 100))%")
    }
    .responseURL { response in
        print("保存路径:\(response.fileURL)")
    }

深入层(源码视角)

Alamofire 5 的核心模块职责:

模块 职责
Session URLSession 的封装,全局入口(AF 是默认单例)
Request 体系 DataRequest / UploadRequest / DownloadRequest 三条请求链路
RequestInterceptor adapt 修改请求 + retry 重试逻辑分离
ResponseSerializer Data 转换为目标类型,可自定义扩展
EventMonitor 全链路事件监听,用于日志/埋点

四、实战演示

场景:带 Token 自动刷新的 API 客户端

这是工程中最常见的场景——Token 过期后自动刷新并重试原始请求。

// Swift 5.7+

// 1. 定义拦截器
final class AuthInterceptor: RequestInterceptor {
    private var accessToken: String = KeychainHelper.accessToken
    private var isRefreshing = false
    private var requestsToRetry: [RetryCompletion] = []

    // adapt:每次请求前注入 Token
    func adapt(_ urlRequest: URLRequest,
               for session: Session,
               completion: @escaping (Result<URLRequest, Error>) -> Void) {
        var request = urlRequest
        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
        completion(.success(request))
    }

    // retry:401 时触发刷新
    func retry(_ request: Request,
               for session: Session,
               dueTo error: Error,
               completion: @escaping RetryCompletion) {
        guard let response = request.task?.response as? HTTPURLResponse,
              response.statusCode == 401 else {
            completion(.doNotRetry)
            return
        }
        requestsToRetry.append(completion)
        guard !isRefreshing else { return }
        refreshToken { [weak self] success in
            self?.requestsToRetry.forEach { $0(success ? .retry : .doNotRetry) }
            self?.requestsToRetry.removeAll()
        }
    }

    private func refreshToken(completion: @escaping (Bool) -> Void) {
        isRefreshing = true
        AF.request("https://api.example.com/refresh",
                   method: .post,
                   parameters: ["refreshToken": KeychainHelper.refreshToken])
            .responseDecodable(of: TokenResponse.self) { [weak self] response in
                self?.isRefreshing = false
                if let token = response.value?.accessToken {
                    self?.accessToken = token
                    KeychainHelper.accessToken = token
                    completion(true)
                } else {
                    completion(false)
                }
            }
    }
}

// 2. 创建自定义 Session(全局单例,推荐)
enum APIClient {
    static let session = Session(interceptor: AuthInterceptor())

    static func fetchUserProfile() async throws -> UserProfile {
        try await session.request("https://api.example.com/profile")
            .validate()
            .serializingDecodable(UserProfile.self)
            .value  // throws on error
    }
}

// 3. 调用
Task {
    do {
        let profile = try await APIClient.fetchUserProfile()
        print("用户:\(profile.name)")
    } catch {
        print("请求失败:\(error)")
    }
}

这个示例涵盖了:Token 注入、自动刷新、队列等待、async/await 调用——工程级最常见的模式。


五、源码亮点

进阶层:值得借鉴的用法

链式调用设计

Alamofire 所有方法都返回 Self(请求对象本身),使得可以无限链式组合:

AF.request(url)
    .validate()                        // 校验
    .responseDecodable(of: T.self)     // 解析
    .uploadProgress { }               // 进度
// 每一步都是独立关注点,互不干扰

自定义 ResponseSerializer

// 扩展支持自定义格式(如 protobuf)
struct ProtobufSerializer<T: Message>: ResponseSerializer {
    func serialize(request: URLRequest?, response: HTTPURLResponse?,
                   data: Data?, error: Error?) throws -> T {
        guard let data = data else { throw AFError.responseSerializationFailed(...) }
        return try T(serializedData: data)
    }
}

深入层:设计思想解析

责任链模式(Chain of Responsibility)

RequestInterceptoradaptretry 两个钩子将「请求构造」与「失败重试」完全分离,任何一个环节都可以独立替换,不影响其他逻辑。这是典型的责任链 + 开闭原则实践。

EventMonitor:观察者模式的正确姿势

// 实现一个打印所有请求的 Logger
final class NetworkLogger: EventMonitor {
    func requestDidFinish(_ request: Request) {
        print("✅ \(request.request?.url?.absoluteString ?? "")")
    }
    func request<Value>(_ request: DataRequest,
                        didParseResponse response: DataResponse<Value, AFError>) {
        print("📦 StatusCode: \(response.response?.statusCode ?? 0)")
    }
}

// 注入 Session
let session = Session(eventMonitors: [NetworkLogger()])

不侵入业务代码,零耦合实现全链路可观测——比 print 打散在各处优雅得多。


六、踩坑记录

问题 1:responseJSON 废弃警告

  • 原因:5.5+ 起 responseJSON 被标记为 deprecated,官方推荐 responseDecodable
  • 解决:定义 Decodable Model,使用 .responseDecodable(of: MyModel.self)

问题 2:多个请求并发刷新 Token 导致死循环

  • 原因:多个请求同时 401,每个都触发了刷新逻辑
  • 解决:拦截器中用 isRefreshing flag + 队列缓存等待回调(见上方实战示例)

问题 3:AF.request 在 Background Task 中失效

  • 原因:默认 Session 使用前台 URLSession 配置
  • 解决:自建 Session 并传入 URLSessionConfiguration.background(withIdentifier:)
let config = URLSessionConfiguration.background(withIdentifier: "com.app.bg")
let bgSession = Session(configuration: config)

问题 4:上传大文件内存暴涨

  • 原因:使用 Data 形式上传会将整个文件加载进内存
  • 解决:使用 fileURL 形式上传,Alamofire 会以流式方式读取
AF.upload(fileURL, to: "https://example.com/upload")

问题 5:.validate() 没有按预期触发

  • 原因:没有加 .validate(),Alamofire 默认不对 4xx/5xx 报错
  • 解决:养成习惯,链式调用中永远加 .validate(statusCode: 200..<300)

问题 6:响应在主线程,但 UI 更新闪烁

  • 原因responseDecodable 默认回调在主队列,但复杂解析会短暂阻塞
  • 解决:用 queue: 参数将解析切到后台,主动 dispatch 到主线程更新 UI
AF.request(url).responseDecodable(of: T.self, queue: .global(qos: .userInitiated)) { response in
    DispatchQueue.main.async { /* 更新 UI */ }
}

七、延伸思考

同类库横向对比

语言 特点 学习曲线 维护状态
Alamofire Swift 功能全面,生态最成熟 活跃
Moya Swift 基于 Alamofire,API 抽象层 中高 活跃
URLSession + async/await Swift 零依赖,苹果原生 低(但样板多) 官方
AFNetworking Objective-C OC 项目首选 维护模式

推荐使用场景

  • ✅ 中大型 Swift 项目,需要统一网络层
  • ✅ 需要 Token 刷新、请求重试等复杂拦截逻辑
  • ✅ 需要完善的上传/下载进度管理
  • ✅ 团队协作,希望网络层有统一规范

不推荐场景

  • ❌ 简单脚本或极小型项目(引入成本 > 收益)
  • ❌ 纯 SwiftUI + async/await 项目,原生 URLSession 已经足够
  • ❌ 对包体积极度敏感的场景

八、参考资源


九、本期互动

小作业

基于本文的 AuthInterceptor 示例,扩展实现以下功能:当 Token 刷新失败(服务端返回 400)时,自动跳转到登录页,并取消所有等待中的请求。在评论区贴出你的关键代码实现。

思考题

Alamofire 的 RequestInterceptor 将「修改请求」和「重试决策」放在同一个对象里——你认为这是合理的设计吗?如果让你重新设计这个接口,你会如何拆分职责?

读者征集

下一期预计介绍 Kingfisher(图片加载库),如果你在使用 Kingfisher 时踩过坑,欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!


📅 本系列每周五晚更新 ➡️ 第1期:Alamofire · ○ 第2期:Kingfisher · ○ 第3期:待定 · ○ 第4期:待定

Harbeth:高性能Metal图像处理库,让你的图片处理速度飞起来!

2026年4月1日 10:40

🎯 项目简介

Harbeth是一个基于Metal的高性能图像处理库,为iOS和macOS开发者提供了一套简洁、高效的图像滤镜解决方案。它不仅支持传统的图像滤镜效果,还能处理HDR图像,让你的应用在图像处理方面如虎添翼。

用故障艺术美学建立动态RGB通道分离 实时检测边缘并添加霓虹灯发光效果
ShiftGlitch.gif EdgeGlow.gif

✨ 核心功能与优势

1. 高性能Metal渲染

  • 利用Metal GPU加速,处理速度比CPU实现快10-50倍
  • 支持命令缓冲区池管理,优化GPU资源使用
  • 双缓冲技术,进一步提升处理性能

2. 丰富的滤镜效果

  • 基础滤镜:亮度、对比度、饱和度、色相调整
  • 高级效果:高斯模糊、锐化、边缘检测、色调映射
  • 创意滤镜:复古、赛博朋克、电影效果、HDR增强
  • 自定义滤镜:支持自定义Metal着色器

超过 200+ 内置滤镜,组织成直观的类别,涵盖从基本颜色调整到高级艺术效果的各种功能

3. HDR图像处理

  • 支持rgba16Float和rgba32Float格式的HDR纹理
  • 内置HDR到SDR的色调映射算法
  • 保留HDR图像的细节和动态范围

4. 易用的API设计

  • 链式调用风格,代码简洁易读
  • 统一的输入输出接口,支持多种图像类型
  • 异步处理支持,避免主线程阻塞

5. 跨平台支持

  • 同时支持iOS、macOS和tvOS
  • 适配不同设备的Metal性能特性
  • 自动处理设备内存限制

🚀 快速开始

安装方式

// Swift Package Manager
.package(url: "https://github.com/yangKJ/Harbeth.git", from: "0.0.1")

// CocoaPods
pod 'Harbeth'

基础使用示例

import Harbeth

// 加载图像
let image = UIImage(named: "example")!

// 创建滤镜
let filter = C7Brightness(brightness: 0.2)

// 应用滤镜
let result = try? HarbethIO(element: image, filter: filter).output() as? UIImage

// 显示结果
imageView.image = result

链式滤镜示例

// 组合多个滤镜
let filters: [C7FilterProtocol] = [
    C7Brightness(brightness: 0.1),
    C7Contrast(contrast: 1.2),
    C7Saturation(saturation: 1.3),
    C7GaussianBlur(radius: 2.0)
]

// 应用滤镜链
let result = try? HarbethIO(element: image, filters: filters).output() as? UIImage

🎨 高级特性

1. 自定义Metal着色器

// 创建自定义滤镜
struct CustomFilter: C7FilterProtocol {
    var modifier: ModifierEnum {
        return .compute(kernel: "customKernel")
    }
    
    var factors: [Float] = [0.5, 0.5, 0.5]
}

// 应用自定义滤镜
let customFilter = CustomFilter()
let result = try? HarbethIO(element: image, filter: customFilter).output() as? UIImage

2. HDR图像处理

// 加载HDR图像
let hdrImage = UIImage(named: "hdr_example")!

// 应用HDR到SDR转换
let hdrFilter = HDRToSDR()
let result = try? HarbethIO(element: hdrImage, filter: hdrFilter).output() as? UIImage

3. 实时处理

// 实时处理相机捕获的图像
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
    // 异步处理
    HarbethIO(element: sampleBuffer, filter: C7Vibrance(vibrance: 0.5)).transmitOutput { result in
        switch result {
        case .success(let processedBuffer):
            // 处理成功,显示结果
            DispatchQueue.main.async {
                self.previewLayer.enqueue(processedBuffer)
            }
        case .failure(let error):
            print("处理失败: \(error)")
        }
    }
}

⚡ 性能优势

Harbeth在性能方面的表现令人印象深刻:

  • 处理速度:比Core Image快3-5倍,比CPU处理快10-50倍
  • 内存使用:智能纹理池管理,减少内存分配
  • 电池消耗:优化的GPU使用,降低能耗
  • 大图像处理:支持处理高分辨率图像和视频帧

📱 适用场景

Harbeth适用于各种需要图像处理的场景:

  1. 照片编辑应用:快速应用滤镜效果
  2. 相机应用:实时预览和处理
  3. 视频编辑:逐帧处理视频
  4. AR/VR应用:实时图像处理
  5. 社交媒体:快速滤镜效果
  6. HDR图像处理:专业图像处理

🖥️ macOS 支持

Harbeth 完全支持 macOS 平台,为桌面应用提供强大的图像处理能力,打造原生、优化的用户体验:

🎨 macOS 展示

探索 Harbeth 在 macOS 上的强大功能:

🌟 总结

Harbeth是一个功能强大、性能优异的Metal图像处理库,它不仅提供了丰富的滤镜效果,还支持HDR图像处理,为iOS和macOS开发者提供了一套完整的图像处理解决方案。

无论是快速原型开发还是生产环境应用,Harbeth都能满足你的需求。它的高性能特性让图像处理不再成为应用的性能瓶颈,而简洁的API设计则让开发过程更加愉快。

如果你正在寻找一个强大而灵活的图像处理库,Harbeth绝对值得尝试!

📁 项目链接

  • GitHub: github.com/yangKJ/Harb…
  • 文档: 详细的API文档和使用示例
  • 示例应用: 包含多种使用场景的示例代码

让Harbeth为你的应用添加绚丽的图像处理能力,让每一张图片都成为艺术品! 🎨✨

SwiftUI 如何使用 UIKit 组件

2026年3月31日 10:06

先理解问题是什么

现实情况是:SwiftUI 原生组件不够用。很多组件SwiftUI 自己没有直接提供,但 UIKit 里有。

那怎么办?苹果提供了一个"桥接协议":UIViewRepresentable


UIViewRepresentable 是什么

它是一个协议(Protocol) ,作用是:

把一个 UIKit 的 UIView包装成 SwiftUI 能认识的 View

你可以把它理解成一个翻译官,SwiftUI 和 UIKit 说的不是同一种语言,UIViewRepresentable 负责在中间翻译。

SwiftUI 世界          翻译官                    UIKit 世界
─────────────    ──────────────────────    ──────────────────
  some View  ←→  UIViewRepresentable  ←→   UIView(任意)

它要求你实现两个方法

protocol UIViewRepresentable {
    // 方法一:创建 UIKit 视图(只调用一次)
    func makeUIView(context: Context) -> 某种UIView
    
    // 方法二:更新 UIKit 视图(状态变化时调用)
    func updateUIView(_ uiView: 某种UIView, context: Context)
}

就这两个,不多(有没有想到什么,OC的NSProxy 是不是也是实现两个方法,虽然八杆子打不着,但是突然想到了)。

  • makeUIView → 负责初始化,相当于 viewDidLoad,只跑一次
  • updateUIView → 负责同步状态,SwiftUI 的数据变了,你要在这里手动更新 UIKit 视图

我写了一个BlurView,早期SwiftUI background不支持毛玻璃效果

struct BlurView: UIViewRepresentable {
    let style: UIBlurEffect.Style   // ← 从 SwiftUI 传进来的参数
    
    // 第一步:创建真实的 UIKit 视图
    func makeUIView(context: Context) -> some UIView {
        let view = UIView(frame: .zero)
        view.backgroundColor = .clear
        
        // 这才是核心:UIKit 的毛玻璃视图
        let blurEffect = UIBlurEffect(style: style)
        let blurView = UIVisualEffectView(effect: blurEffect)
        
        // 用 AutoLayout 让它撑满父视图
        blurView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(blurView)
        NSLayoutConstraint.activate([
            blurView.heightAnchor.constraint(equalTo: view.heightAnchor),
            blurView.widthAnchor.constraint(equalTo: view.widthAnchor)
        ])
        
        return view  // ← 把这个 UIKit 视图交给 SwiftUI 管理
    }
    
    // 第二步:状态更新时同步(这里暂时不需要做任何事)
    func updateUIView(_ uiView: UIViewType, context: Context) {
        // 如果 style 会动态变化,就在这里更新
    }
}

重点理解makeUIView 返回的那个 UIView,之后就由 SwiftUI 的布局系统接管了。你不需要手动设置 frame,SwiftUI 会帮你处理尺寸。


然后 View Extension 做了什么

extension View {
    func blurBackground(style: UIBlurEffect.Style) -> some View {
        ZStack {
            BlurView(style: style)  // ← UIKit 毛玻璃,铺在底层
            self                    // ← 原来的 SwiftUI 视图,叠在上层
        }
        //两个方法都行
        //self.background(BlurView(style: style))
    }
}

BlurView 在这里和任何 SwiftUI 原生 View 完全没有区别,可以直接放进 ZStack。这就是 UIViewRepresentable 的意义:让 UIKit 视图假装自己是 SwiftUI 视图


整体调用链是这样的

.blurBackground(style: .systemMaterial)
        ↓
    ZStack 叠加
   ┌────────────┐
   │  BlurView  │ ← UIViewRepresentable 在这里翻译
   │   (UIKit)  │   makeUIView() 被 SwiftUI 自动调用
   └────────────┘
        ↑
   self(原 SwiftUI 视图)叠在上面

什么时候用 UIViewRepresentable(有些SwfitUI 现在自己已经有了)

场景 推荐方案
毛玻璃、特效 UIVisualEffectViewUIViewRepresentable
地图 MKMapView → 或直接用 SwiftUI 的 Map
网页 WKWebViewUIViewRepresentable
富文本编辑 UITextViewUIViewRepresentable
相机预览 AVCaptureVideoPreviewLayerUIViewRepresentable
SwiftUI 能搞定的 直接用 SwiftUI,别绕弯子

还有一个兄弟协议:UIViewControllerRepresentable

如果你要包装的不是 UIView,而是整个 UIViewController(比如系统的图片选择器、分享弹窗),用这个:

struct ImagePickerView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIImagePickerController {
        return UIImagePickerController()
    }
    
    func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
        // 同上,同步状态用
    }
}

逻辑完全一样,只是把 UIView 换成了 UIViewController


总结

UIViewRepresentable 本质上就是:

实现两个方法(创建更新),让 SwiftUI 知道怎么驾驭一个 UIKit 视图

它解决的核心问题是:SwiftUI 和 UIKit 生命周期不同,这个协议负责在两套系统之间搭桥

BlurView 是一个非常标准的使用案例——SwiftUI 没有,UIKit 有,包一下,用上。

Swift 杀进 Android,Google 和 Apple 都要失眠了?

作者 黄林晴
2026年3月31日 08:49

本文同步自微信公众号 “Android技术圈”

Swift 终于正式杀进 Android 了。 不是社区 Demo,不是民间魔改,而是 Swift 官方第一次亲自发布 Android SDK。 这件事现在看起来像一条技术新闻,再往后看,很可能会变成移动开发格局变化的起点。

先别急着喊 “Kotlin 要完了”。 但也别把它轻飘飘理解成 “Swift 又做了个跨平台实验”。 因为这次最狠的地方在于:Swift 官方第一次给出了进入 Android 工程体系的真实路径。

cover.png

官方到底宣布了什么

比“Apple 要不要抢 Android 地盘”更值得看的,其实是官方这次到底把支持范围推进到了哪一步。

Swift 官方博客对这件事的表述,其实非常克制,但也非常明确。核心就三点:

  • Swift 6.3 包含首个官方 Android Swift SDK
  • 可以开始用 Swift 开发原生 Android 程序
  • 可以通过 Swift JavaSwift Java JNI Core,把 Swift 代码集成进现有 Kotlin / Java Android 应用

这三句话加起来,真正说明的是:

Swift on Android,已经从社区探索,进入官方支持阶段。

这一步为什么重要?

因为过去你看到的很多 “Swift 跑 Android”,本质上都还是社区项目、实验性方案,或者少数团队自己打补丁维护的链路。

而这次不一样。 这次是 Swift 官方把 SDK、文档、安装方式、交叉编译路径和互操作方案一起摆上台面。

对开发者来说,差别非常大。

社区方案是“能折腾出来”。 官方方案才是“值得认真观察是否能进生产”。

02_ecosystem_bridge.png

别高估,也别低估

这类新闻最容易出现两个极端判断。

第一种是:
“Kotlin 要凉了,Android 以后可以全用 Swift 写。”

第二种是:
“没意义,不就是把命令行程序编到 Android 上吗?”

这两种都不准确。

从 Swift 官方文档来看,今天已经明确成立的是:

  • 你可以把 Swift 代码交叉编译到 Android
  • 你可以在 Android 设备或模拟器上运行 Swift 程序
  • 你可以把 Swift 模块构建成共享库
  • 你可以让现有 Kotlin / Java Android 应用去调用这些 Swift 代码

这意味着什么?

意味着 Swift 现在已经不是“只能在 Apple 生态里玩”的语言了。 但它也还远远没到“Android 主流团队明天集体切语言”的程度。

它真正有价值的地方,不是在口号层。 而是在工程层。

它第一次让 Swift 有机会进入 Android 的真实项目结构里。

真正值得关注的,是这条工程路径

Swift 官方的 Android 入门文档其实很务实,没有画大饼。

要跑起来,你得准备 3 样东西:

  • Swift 6.3 toolchain
  • Swift SDK for Android
  • Android NDK 27d 或更高版本

然后通过 swift build --swift-sdk ... 做 Android 目标的交叉编译。

官方演示的第一步,也不是完整 App,而是先把一个 Swift 可执行程序编译到 Android 上运行。

很多人看到这里会下意识地说:

“那不还是离真正 Android App 很远?”

但官方文档后面紧接着补了一句特别关键的话:

Swift 模块可以构建为共享库,并被打进 Android 应用,再由 Java / Kotlin 代码调用。

这句话,才是整件事的核心。

因为这说明 Swift on Android 的第一落点,并不是 UI 层,也不是整项目重写。 而是更现实、更符合团队采纳路径的地方:

  • 共享业务逻辑
  • 数据模型
  • 网络层
  • 算法模块
  • 某些性能敏感代码

说白了,Swift 现在不是先来抢 Compose 的饭碗。 它更像是先从“共享模块语言”这个位置切进去。

03_shared_logic_path.png

这件事为什么会让很多 iOS 团队心动

对纯 Android 团队来说,这条新闻现在更像观察项。 但对已经重度使用 Swift 的团队,这个信号就完全不一样了。

过去很多团队都会面临一个老问题:

“iOS 侧已经有一套成熟的 Swift 代码资产了,Android 还要不要重写一遍?”

如果这些资产只是 UI,那没办法,平台差异太大。 但如果是业务规则、网络协议、加解密逻辑、通用数据处理,重写本身就是重复劳动。

Swift 官方 Android SDK 的出现,至少让这类团队开始有了一个新的选项:

把 Swift 资产往 Android 端延伸,而不是永远困在 Apple 生态内部。

这点其实很关键。

因为它改变的不是某个 API,而是开发者对 Swift 的心理预期。

过去大家默认 Swift 是: “写 iPhone、写 Mac、写 Apple 设备。”

现在这个认知边界开始松了。

Swift 正在从 Apple 平台语言,继续往真正的跨平台语言走。

Kotlin 会不会被威胁

短期答案很直接:不会。

Kotlin 在 Android 的位置,不是一两条技术新闻能撼动的。 它背后是 Android 官方支持、Jetpack 生态、Android Studio、社区实践、招聘市场和海量线上项目。

Swift 6.3 现在拿出来的是“首个官方 Android SDK”。 这离大规模生产落地,中间还隔着很多现实问题:

  • 工具链稳定性怎么样
  • 桥接 Kotlin / Java 的成本高不高
  • 调试链路顺不顺
  • CI 能不能稳定接入
  • 团队愿不愿意为它付出学习和维护成本

所以更合理的判断不是“Swift 替代 Kotlin”。

而是:

  • Kotlin 仍然会是 Android 主语言
  • Swift 可能成为共享逻辑和多端模块的新候选
  • 真正决定它能不能站稳的,是工程成熟度,不是新闻热度

04_adoption_stages.png

这一步对 Swift 自己更重要

从更大的角度看,这次最受益的,也许不是 Android,而是 Swift 本身。

一门语言如果长期绑定单平台,它再强,外界对它的认知上限也很明确。

只有当它开始稳定支持更多平台,开发者才会真正把它当成一门独立的软件工程语言,而不是某家平台公司的附属工具。

Swift 6.3 这次最值得玩味的地方就在这里。

它不是一句空泛的“我们支持跨平台”。 而是给出了 SDK、文档、安装方式、构建路径、互操作方案。

这说明 Swift 官方现在回答的问题,已经不再只是:

“Swift 能不能把 Apple 生态服务好?”

而是:

Swift 能不能成为一门覆盖更多平台的软件工程语言?

Android,就是这个问题里最关键的一块拼图。

开发者现在该怎么看

如果你是 Android 开发者,不需要焦虑,也没必要跟风重写任何项目。

你更应该做的是持续观察这几个点:

  • Swift Android SDK 后续更新速度快不快
  • Swift Java 的接入体验到底顺不顺
  • 有没有团队先把 Swift 用到 Android 共享模块里
  • 工具链能不能稳定进入 CI 和生产流程

如果你是 iOS / Swift 开发者,这件事反而更值得持续盯住。

因为这可能是 Swift 在未来两三年里,最有战略意义的一次边界扩张。

它未必马上改变你的项目。 但它很可能会慢慢改变未来的技术选型。

写在最后

Swift 6.3 的重点,不是简单一句“Swift 能写安卓了”。

更准确的说法应该是:

Swift 官方第一次正式发布 Android SDK,并给出了进入现有 Android 工程的可执行路径。

这一步还远远谈不上改写 Android 生态。 但它已经足够说明一件事:

平台边界,还在继续变薄。

你看好 Swift 在 Android 上的发展吗? 你觉得它会先成为共享逻辑工具,还是最终走到更完整的 Android 开发场景?

欢迎评论区聊聊。 如果你身边正好有做 iOS、Android、跨平台架构的朋友,也欢迎把这篇文章转给他们,一起讨论。

参考资料:

  • Swift 官方博客:Swift 6.3 Released
  • Swift 官方文档:Getting Started with the Swift SDK for Android

一墙之隔,不同的时空 -- 肘子的 Swift 周报 #129

作者 东坡肘子
2026年3月31日 07:50

issue129.webp

一墙之隔,不同的时空

一年一度的 Let's Vision 大会在上海如期举行,今年的主题是:“Born to Create, Powered by AI”。除了与 Swift、空间计算相关的常规 Session,大会还邀请了许多开发者分享他们在工作中对 AI 的应用与理解。通过这些讲师对 AI 工作流的介绍,我也受益匪浅。原本只能容纳 300 人的 AI 主题会场,里三层外三层站满了热情高涨的观众。

然而,在众多优秀的 Session 中,一场由 YuChe Cheng 准备的、名为《Let's Create 1-liner Code in Swift》的演讲却将我的注意力引向了另一个会场。这究竟是一个怎样的话题?带着疑问我走了进去。作为一个 LeetCode 积分 2200+ 的开发者,YuChe Cheng 在演讲中展示了如何通过 Foundation 以及 Swift Algorithms 提供的大量高阶函数,将原本平淡无奇的 For-loop 代码,转换成更加优雅、美观、极具 Swift 风格的 Function Chaining(1-liner code),并在易读性与性能之间取得了很好的平衡。

看着幻灯片上的 Function Chaining 被一次又一次地优雅迭代,我有种茅塞顿开的畅快。整整 30 分钟的演讲,让我始终处于一种纯粹的兴奋之中——这种感觉,通常只在我绞尽脑汁最终攻克了一个难题,或是深刻理解了一个新概念后才会涌现。

尽管与主会场只有一墙之隔,但由于 AI 话题的绝对热度,本场演讲的听众明显偏少。与其说我为许多人错失了一场精彩演讲而感到遗憾,我真正担心的其实是:随着 AI 的进一步渗透,许多开发者原本在追求功能之外所赋予代码的那份“气质”,会不会就此消亡?

开发者不应该只关心编译后冷冰冰的二进制功能,代码本身也是个人风格的载体。它就像文章一样,在输出逻辑与结果之外,还承载着美学表达,体现着编写者的个人品味与巧思。

在今年的 Let's Vision 上,我感觉我们正站在一个时间的十字路口:我们是该一味追求 AI 带来的极致高效,还是在拥抱变化的同时,依然让属于开发者的那份骄傲与手艺,在 AI 时代得以保留?

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

本期推荐

Swift 6.3 Released

从 Swift 6 开始,语言演进已经稳定在半年一个 minor 版本的节奏,上周 Swift 6.3 如期发布。与前几个版本相比,这一版本并未引入明显的重磅特性,更多是对既有体系的打磨:并发模型在诊断准确性方面有所改进,新增的 @c 特性(attribute)进一步强化了 C/C++ 互操作能力,同时编译优化的控制粒度也变得更加细致。

尽管如此,这一版本也释放出一个清晰的信号:Swift 正在从“以 Apple 平台为中心的应用开发语言”,逐步向“具备跨平台与系统级能力的通用语言”演进。Embedded Swift、Android 支持的持续推进,以及 SwiftPM 构建体系的统一,都在指向这一方向。对多数 iOS 开发者而言,短期体感或许有限,但从更长的时间维度来看,这更像是一次为未来铺路的基础性更新。


如何在 Swift 中承接尚未稳定的 JSON (Designing a type-driven JSON in Swift)

当 API 契约尚未稳定、前后端对字段的理解又经常漂移时,Swift 的强类型系统反而会放大数据与 JSON 之间转换时的边界问题。Roman Niekipielov 在本文中介绍了一个刻意做小的 JSONValue 类型,用来承接这类过渡阶段的 JSON 数据。相比 [String: Any],它保留了更明确的类型结构;相比直接编写 Codable 模型,又更适合应对频繁变化的契约。这个实现并不试图替代正式模型,而是将不确定性暂时限制在边界层。


Swift 原生 AI Agent 开发实践系列

市面上有大量开发者使用 Python、TypeScript 开发 AI Agent,但 Chris Karani 认为,Swift 的并发模型天然更适合 Agent 的隔离与调度,强类型系统和宏功能也带来了额外的安全保证。他用 6 篇文章、从多个角度实践了这一观点——从统一多个 LLM Provider 的 SDK Conduit,到基于 Apple Foundation Models 的 Agent 运行时 Colony,再到用 Metal 加速的上下文记忆管理。如果你正在考虑在 Apple 平台上构建 AI 功能,这个系列是目前少见的完整原生方案。


Liquid Glass 设计工作坊 (Talking Liquid Glass with Apple)

Danny Bolella 在纽约参加了苹果举办的 Liquid Glass 设计工作坊,与设计团队和 SwiftUI 工程师进行了为期三天的深入交流。本次活动传递出非常明确的信号:Liquid Glass 并非过渡性尝试,而是苹果未来数年的设计方向,且将在后续工具链中成为默认前提。与此同时,苹果反复强调“层级(Hierarchy)”的重要性——界面应围绕内容构建,控件只是服务于内容的辅助元素,应尽量退居边缘,让信息本身成为视觉与交互的中心。除此之外,Danny 还在本文中记录了其他一些 SwiftUI 工程师给出的建议和技巧。本文记录的内容可以帮助你更早理解这场设计演进的节奏与方向。


App Store Connect 大更新 (Apple Dropped 100+ New Metrics. Your Competitors Are Already Using Them)

苹果对 App Store Connect 进行了近年来最大的一次更新,一口气引入了 100+ 官方指标、按来源划分的 cohort 分析、同行基准对比(转化率与单下载收益)以及可通过 API 导出的订阅数据。Jessica Chung 在本文中对这些关键变化进行了系统梳理。由于所有数据均来自苹果一手统计,这意味着开发者在 ASO 和增长决策中,将不再依赖第三方估算,而可以直接基于真实用户行为进行分析与优化。更重要的是,这次更新补齐了长期缺失的关键能力:你可以追踪不同关键词与渠道带来的用户质量,建立从曝光、下载到订阅与续费的完整转化链路,并通过同行基准明确自身所处位置。

本次更新对于开发者而言无疑是利好,但对于部分第三方 App Store 分析服务来说,也在一定程度上提高了竞争门槛,促使其提供更具附加值的能力。


Package Traits in Xcode

在创建 SPM 时,某些依赖可能只被特定 API 使用,但一旦用户引入该包,即便不使用这些 API,也需要一并引入相关依赖。Package Traits 正是为了解决这一问题而引入的,它为 SPM 提供了一种声明可选特性的方式,使使用者能够按需启用功能,从而避免引入不必要的依赖。遗憾的是,在该功能推出后,一直只能在社区版本的 Swift 工具链中使用。随着 Xcode 26.4 的发布,Package Traits 终于获得了苹果官方支持,有望迎来更广泛的应用。Matt Massicotte 在本文中对该特性进行了介绍,并展示了其基本用法。


优化感官性能,让用户感觉更快 (Why your SwiftUI app feels slow even though Instruments says it’s fine?)

用户投诉响应慢,一定是应用性能问题吗?Rafał Dubiel 将关注点从“实际性能”转向“感知性能(Perceived Performance)”,讨论如何通过界面反馈与交互节奏,让用户感觉应用“更快”。例如通过 skeleton view、延迟加载,以及合理的动画与状态过渡来掩盖等待时间。作者指出,在许多场景下,用户体验的关键并不在于减少毫秒级的计算时间,而在于是否及时提供反馈。相比单纯优化性能指标,这种从用户感知出发的思路,往往更直接地影响用户对应用流畅度的判断。


在 SwiftUI 中控制行高 (Adjusting line height in SwiftUI on iOS 26)

iOS 26 为 SwiftUI 新增了 lineHeight(_:) modifier,用于控制文本相邻两行基线之间的距离。Natalia Panferova 在本文中对各种配置方式进行了详细对比:内置预设(.loose.tight)、基于字号倍数的 .multiple(factor:)、固定增量的 .leading(increase:),以及绝对值控制的 .exact(points:)。此外,lineHeight(_:) 与已有的 lineSpacing(_:) 并不相同:前者控制基线间距,后者控制行底到下一行行顶的距离。

Natalia Panferova 曾是 Apple SwiftUI 核心团队成员,参与过多个关键 API 的设计与开发。本月她刚刚出版了新书 The SwiftUI Way,面向有一定 SwiftUI 经验的开发者,聚焦于生产环境中的模式选择、常见反模式识别,以及如何与框架“顺势而为”而非对抗。

工具

Cove:Swift 6 编写的 macOS 开源数据库客户端

Cove 是由 Emanuele Micheletti 开发的一款原生 macOS 数据库客户端,整个项目完全使用 Swift 6 构建,目前已经支持 PostgreSQL、MySQL、MariaDB、SQLite、MongoDB、Redis、ScyllaDB、Cassandra 和 Elasticsearch 等多种后端。它采用 SwiftUI 搭配 AppKit 原生控件实现,没有走 Electron 或 Web 技术栈,因此整体更轻量,也更符合 macOS 用户熟悉的交互体验。

相比“又一个数据库 GUI”,Cove 更值得关注的是它的实现思路。作者将所有数据库能力统一抽象为 DatabaseBackend 协议,UI 层不包含任何针对特定后端的分支逻辑。无论是 SQL 数据库、Redis 这类键值数据库,还是 MongoDB、Elasticsearch 这类非关系型后端,最终都会被整理为统一的表格模型交由界面渲染。项目目前仍处于 v0.1.0 的早期阶段,但已经具备查询、结构浏览、编辑、SSH 隧道和多标签等基础能力。即便你并不打算把它作为日常数据库工具,Cove 依然是一个很值得 Swift 开发者研究的桌面应用架构样本。

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

ViewModifier 和 圆角以及渐变色

2026年3月30日 11:31

ViewModifier

是什么

把一组样式或 UI 结构打包成可复用的东西,用 .modifier() 链式调用贴到任意 View 上。

类比 UIKit

UIKit 里你会封装一个函数来复用样式:

func styleToolButton(_ button: UIButton) {
    button.titleLabel?.font = .systemFont(ofSize: 25)
    button.setTitleColor(.white, for: .normal)
    button.frame.size = CGSize(width: 30, height: 30)
}

ViewModifier 干的是同一件事,但它不只能改属性,还能在原有 View 外面包一层新的 View 结构,这是普通函数做不到的:

struct BadgeModifier: ViewModifier {
    func body(content: Content) -> some View {
        ZStack(alignment: .topTrailing) {
            content  // 原来的 View 原封不动
            Text("99")
                .background(Color.red)
                .clipShape(Circle())
                .offset(x: 10, y: -10)
        }
    }
}

Image(systemName: "bell").modifier(BadgeModifier())
Image(systemName: "message").modifier(BadgeModifier())

本质

本质就是一个语法糖,功能上等价于自定义一个 View 然后把其他 View 塞进去,但它能融入 SwiftUI 的链式调用语法,用起来跟 .font() .foregroundColor() 一模一样。


圆角 + 渐变色 + 描边

是什么

SwiftUI 没有 UIKit 那样直接设置 layer.borderWidth 的属性,填充和描边需要两个图层叠加来实现。

类比 UIKit

UIKit 两行搞定:

view.layer.borderWidth = 4
view.layer.borderColor = UIColor.green.cgColor

SwiftUI 必须用 ZStack 叠两个 RoundedRectangle:

.background(
    ZStack {
        RoundedRectangle(cornerRadius: 20)
            .stroke(model.color, style: StrokeStyle(lineWidth: 4))
        RoundedRectangle(cornerRadius: 20)
            .fill(gradientStyle)
    }
)

为什么先 stroke 再 fill

stroke(描边)默认居中描边,线宽一半在内一半在外。fill 只填充内部区域,所以 fill 会覆盖 stroke 内侧的那一半。先画 stroke 再盖 fill,能让 stroke 外侧的一半露出来,边框视觉上更完整。反过来的话内侧边框线被盖住,边框显得细一半。

本质

这块 UIKit 确实更直观,SwiftUI 的声明式思路在这个场景下反而绕了一圈


本质

SwiftUI 没有 layer,只有 Shape + 绘制规则


fill 和 stroke 的区别

操作 本质
fill 填充 Shape 内部
stroke 沿路径画边

stroke 的问题

.stroke(lineWidth: 4)

👉 描边在路径两侧(内 + 外)


推荐方案(更精准)

.strokeBorder(lineWidth: 4)

👉 描边完全在内部


推荐结构

.fill(...)
.overlay(stroke)

👉 语义清晰:先填充,再叠加边框

ViewModifier

本质是 (View) -> View,不是修改 View,而是生成新 View


Modifier 顺序

顺序不是语法问题,而是 View 树结构

描边本质

边框不是属性,而是绘制结果(Shape + stroke)


一墙之隔,不同的时空 - 肘子的 Swift 周报 #129

作者 Fatbobman
2026年3月31日 22:00

一年一度的 Let's Vision 大会在上海如期举行,今年的主题是:“Born to Create, Powered by AI”。除了与 Swift、空间计算相关的常规 Session,大会还邀请了许多开发者分享他们在工作中对 AI 的应用与理解。通过这些讲师对 AI 工作流的介绍,我也受益匪浅。原本只能容纳 300 人的 AI 主题会场,里三层外三层站满了热情高涨的观众。

❌
❌