Swift 并发正被更广泛地接纳 - 肘子的 Swift 周报 #133
从 Swift 5.5 引入符合现代编程思想的新并发模型算起,一转眼快 5 年了。从 5.5 到目前的 6.3,Swift 社区一直在采用小步迭代的方式,积极推进并发 API 的演进。但在应对过多的新关键字、复杂的隔离概念以及一些容易引发困扰的“反模式”时,这个过程对开发者来说并不算顺利。
从 Swift 5.5 引入符合现代编程思想的新并发模型算起,一转眼快 5 年了。从 5.5 到目前的 6.3,Swift 社区一直在采用小步迭代的方式,积极推进并发 API 的演进。但在应对过多的新关键字、复杂的隔离概念以及一些容易引发困扰的“反模式”时,这个过程对开发者来说并不算顺利。
上线一周,下载量是零。
说实话这很正常,冷启动就是这样。但我还是想把「健康手账」这个项目的一些设计思路写出来,因为做的过程中有几个决策点我觉得挺有意思,适合和做 iOS 工具类 App 的朋友聊聊。
这类 App 的竞品多到数不清。我当时下载了七八个,用下来有个共同问题:录入太麻烦。
打开 App、点击新建、手动输入收缩压、再输舒张压、再输脉搏——三个字段,最快也要 15 秒。对于每天早晚要测两次血压的高血压患者,这个摩擦力不小。更别说帮父母操作,老人对数字键盘并不友好。
我想解决的核心问题就一个:把「记一次数据」压缩到 3 秒以内。
最直接的想法是自动识别——用手机摄像头拍血压计,OCR 识别数值。我试了一周,识别率在不同光线下差异很大,而且用户还得配合把手机对准屏幕,反而更麻烦。
第二个方案是预设范围的快捷选择,类似「上次是 128/82,这次有变化吗?」。问题是这个交互对新用户完全不直觉,而且首次录入没有历史数据根本跑不起来。
最后用的方案是物理拨轮(Picker 风格,但带阻尼感的自定义实现)。收缩压、舒张压、脉搏各一个拨轮,默认值锁定在上次录入附近,打开就能拨,拨完直接存。实际操作下来,熟悉之后真的 3-5 秒能完成一次录入。
这个交互特别适合老年用户,因为拨轮比点击键盘容错率高——拨过了再拨回来,不需要删除重输。
健康数据好记,但「今天血压偏高是因为没睡好还是昨晚喝酒了」这种因果关系很难追踪。
我在 HealthRecord 上加了一个 tagIDs: [String] 字段,对应一套可自定义的状态标签(StatusTag)。内置的有「降压药」「运动」「好睡眠」「黑咖啡」等,用户每次录入时可以顺手打几个印章。
@Model
final class HealthRecord {
var id: UUID
var timestamp: Date
var systolic: Int?
var diastolic: Int?
var pulse: Int?
var weight: Double?
var tagIDs: [String] // 关联当次的干预行为
var profileID: String = "default"
}
趋势图里,这些标签会作为事件标记叠加在折线上。比如连续几天运动后血压数值的变化,一眼就能看出来。这个设计借鉴了运动 App 里「训练日志」的思路,但放在健康场景下我觉得更有价值,因为慢病管理真正需要的是「行为-数据」的对照。
大多数健康 App 的数据只能在 App 里看,或者最多导出 CSV。但去医院看诊时,医生没时间看你手机屏幕,更不可能帮你分析折线图。
我加了一个「生成就医报告」功能,一键输出标准格式 PDF:患者基本信息、最近 N 天的血压/体重数据表格、趋势图、备注。打印出来或者直接发给医生。
这个功能在开发时我有点犹豫要不要做,感觉实现成本不低(PDF 布局、图表渲染都要搞一遍)。但想想「数据记了,但医生看不懂」这个痛点,还是做了。说实话现在觉得这是产品里最有差异化的地方。
做完拨轮之后我想,录入的最大摩擦其实不是界面操作,而是「打开 App 这个动作本身」。
用 AppIntents 实现了一个 Siri 快捷指令,说「用健康手账记录健康数据」直接跳到录入界面,不需要找图标、不需要滑动。实现上用了一个 NotificationCenter 的广播机制——intent perform 之后 post 一个通知,主视图监听到就弹出录入 sheet。
struct LogHealthRecordIntent: AppIntent {
static let title: LocalizedStringResource = "记录健康数据"
static let openAppWhenRun: Bool = true
@MainActor
func perform() async throws -> some IntentResult {
try await Task.sleep(for: .milliseconds(200))
NotificationCenter.default.post(name: .healthLogShowInputSheet, object: nil)
return .result()
}
}
延迟 200ms 是因为 App 冷启动时视图层级还没就绪,直接 post 通知会丢失。这个 bug 我在真机上踩了才发现,模拟器里完全复现不了。
数据全部用 SwiftData 存在本地。这是一个主动决策,不是因为懒得做后端。
健康数据比较敏感,尤其是帮父母记录的场景,很多用户对「数据上云」有顾虑。本地存储让这个顾虑直接消失,也不需要注册账号、不需要联网。
iCloud 同步作为可选项保留,用 CloudKit 实现,在设置里手动开启。代码里的处理也很直接:
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: icloud ? .automatic : .none
)
买断制也是基于同样的逻辑——慢病患者要长期用,订阅制的心理负担对他们不友好。
HealthRecord 上有个 profileID 字段,支持创建多个独立档案。这个场景是子女帮父母管理健康数据时用的:爸妈各一个档案,切换一下就能看各自的趋势。
下次陪父母去复诊,不用临时整理数据,直接切到对应档案导出 PDF 就完事了。
趋势图的异常检测现在还比较简陋,只是超过阈值就标红,没有考虑到「白大衣高血压」这种场景下连续几次都偏高但实际没问题的情况。这块我想引入一个滑动窗口均值,但暂时还没动。
血压分类标准支持切换(国内标准 vs ACC/AHA 2017),但界面上没有做到位,大多数用户根本发现不了这个设置在哪。
这个项目目前还在冷启动阶段,有兴趣的朋友可以在 App Store 搜「健康手账」试试——特别是家里有需要记血压的长辈的,帮他们装一个比较实在。
如果你也在做类似的健康或工具类 App,欢迎在评论区聊聊你在数据录入和用户习惯培养上的做法,我挺好奇不同产品的解法有什么差异的。
在现代移动应用中,数据如同血液般流淌于每个功能模块之间。然而,网络并非永远可靠,用户期待的是无缝的体验——无论在地铁隧道中、飞行模式下,还是在信号微弱的乡村。这种期待催生了对数据持久化与缓存策略的深度思考。一次关于本地数据丢失的故障排查,让我们意识到:数据的生命周期管理远比简单的"保存与读取"复杂得多。本文将从实际案例出发,探讨如何构建一个既能保证数据一致性,又能提供流畅离线体验的存储架构。
// 初级做法:滥用UserDefaults
UserDefaults.standard.set(userProfile, forKey: "currentUser")
UserDefaults.standard.set(accessToken, forKey: "authToken")
UserDefaults.standard.set(products, forKey: "cachedProducts")
然而,UserDefaults本质上是一个plist文件,适合存储配置信息和小量数据,但不适合存储复杂对象或大量数据。当应用需要存储用户聊天记录、商品目录或离线文章时,我们需要更专业的解决方案。
下图展示了不同存储方案的选择路径,帮助开发者根据数据特性做出合理决策:
![]()
随着应用复杂度增加,直接在各种业务模块中操作不同存储方案会导致代码高度耦合。更好的做法是构建一个统一的数据访问层(Data Access Layer),为上层业务提供一致的接口。
// 统一存储协议
protocol DataStorageProtocol {
associatedtype T
func save(_ item: T, forKey key: String) -> AnyPublisher<Void, StorageError>
func load(forKey key: String) -> AnyPublisher<T, StorageError>
func delete(forKey key: String) -> AnyPublisher<Void, StorageError>
func clear() -> AnyPublisher<Void, StorageError>
}
// 具体实现:UserDefaults存储
class UserDefaultsStorage<T: Codable>: DataStorageProtocol {
private let userDefaults: UserDefaults
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
func save(_ item: T, forKey key: String) -> AnyPublisher<Void, StorageError> {
return Future<Void, StorageError> { promise in
do {
let data = try self.encoder.encode(item)
self.userDefaults.set(data, forKey: key)
promise(.success(()))
} catch {
promise(.failure(.encodingFailed))
}
}.eraseToAnyPublisher()
}
}
这种抽象带来了多重好处:业务代码无需关心底层是使用UserDefaults、Core Data还是文件系统;存储实现可以独立替换;统一的错误处理;以及易于测试的接口。
缓存不仅仅是"保存一份数据副本",而是需要精心设计的策略。一个完整的缓存系统需要考虑以下维度:
让我们设计一个支持多级缓存的智能系统:
class SmartCacheManager {
// 内存缓存(快速但易失)
private let memoryCache = NSCache<NSString, NSData>()
// 磁盘缓存(持久但较慢)
private let diskStorage: DataStorageProtocol<Data>
// 网络层用于刷新数据
private let networkService: NetworkServiceProtocol
func fetchData<T: Codable>(for key: String,
maxAge: TimeInterval = 300, // 默认5分钟
forceRefresh: Bool = false) -> AnyPublisher<T, Error> {
// 1. 检查是否需要强制刷新
guard !forceRefresh else {
return fetchFromNetwork(key: key)
}
// 2. 检查内存缓存
if let cachedData = memoryCache.object(forKey: key as NSString) as Data?,
let cachedItem = decodeData(cachedData) as T? {
return Just(cachedItem)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
// 3. 检查磁盘缓存
return diskStorage.load(forKey: key)
.tryMap { data in
// 检查缓存是否过期
if self.isCacheValid(for: key, maxAge: maxAge) {
return try JSONDecoder().decode(T.self, from: data)
} else {
throw CacheError.expired
}
}
.catch { _ in
// 4. 缓存无效或不存在,从网络获取
return self.fetchFromNetwork(key: key)
}
.eraseToAnyPublisher()
}
}
下图展示了智能缓存系统的工作流程,从数据请求到返回的完整决策链:
![]()
我们可以设计一个基于操作队列的同步管理器:
class SyncManager {
private let operationQueue = OperationQueue()
private let pendingOperationsStorage: DataStorageProtocol<[SyncOperation]>
// 记录待同步的操作
func enqueueOperation(_ operation: SyncOperation) {
// 保存到本地,确保即使应用崩溃也不会丢失
var pendingOps = (try? pendingOperationsStorage.load(forKey: "pending")) ?? []
pendingOps.append(operation)
pendingOperationsStorage.save(pendingOps, forKey: "pending")
// 添加到操作队列
operationQueue.addOperation(operation)
}
// 监听网络状态变化
func setupNetworkObserver() {
NotificationCenter.default.publisher(for: .networkReachable)
.sink { [weak self] _ in
self?.retryPendingOperations()
}
.store(in: &cancellables)
}
}
这种设计确保了即使用户在离线状态下进行操作,这些操作也会被安全地保存,并在网络恢复时自动同步。
数据持久化不仅关乎功能,更直接影响应用性能。我们需要在多个维度上寻找平衡点:
对于敏感数据如用户凭证,我们应使用iOS的Keychain服务:
class SecureStorage {
func saveSecureItem(_ item: String, forKey key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: item.data(using: .utf8)!
]
SecItemDelete(query as CFDictionary) // 先删除旧项
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
}
对于大量数据的存储,我们需要考虑分页加载和懒加载策略,避免一次性加载过多数据导致内存压力。
数据持久化与缓存策略是移动应用架构中最为基础也最为复杂的一环。它不仅仅是技术选择的问题,更是对用户体验、性能表现和安全保障的综合考量。
通过构建统一的数据访问层,我们实现了存储实现的解耦;通过智能缓存策略,我们平衡了性能与数据新鲜度;通过离线优先的同步机制,我们确保了应用的可用性;通过性能优化措施,我们保障了应用的流畅运行。
这再次印证了本系列文章的核心思想:优秀的架构设计在于预见复杂性并提前规划应对策略。当数据层稳固可靠时,上层业务开发便能够专注于创造价值,而不必担心数据丢失、同步冲突或性能瓶颈。在数据驱动的时代,一个精心设计的数据持久化架构,是应用成功的基石,也是技术卓越的体现。
上线三周,成就页的打开率掉到了 2%。我盯着这个数字看了好一会儿,意识到设计完全错了。
「雁过留痕」是我做的一个足迹记录 App,核心思路是把你走过的路变成可量化的探索面积(km²),用 25m 精度网格覆盖地图,慢慢把省份和城市染色。这个核心玩法我觉得还不错,但成就系统上线之后直接拖了后腿。
最早的版本只有几个维度:总距离、总录制天数、省份解锁数量。听起来挺完整,上线之后发现问题很具体——用户解锁了头三个徽章,然后成就页就再也不打开了。
原因其实事后想想很显然:目标太稀疏,中间段完全是空白期。你解锁了「初探者」,下一个目标要再走 500km 才能到「漫游者」,这中间几个月看不到任何进展反馈,等于告诉用户「别来了」。
游戏设计里有个基本原则:玩家需要随时都能看到「我离下一个里程碑还有多远」。我第一版完全忽略了这件事。
重做的核心思路是把成就拆成多个 Track,每个 Track 内部是连续的多级徽章,保证任意时刻都有「快到了」的感觉。
enum BadgeTrack: String, CaseIterable, Identifiable {
case all
case exploration // 面积、城市、省份
case consistency // 连续打卡、累计月数
case china // 省级/大区解锁
case world // 全球探索
case pro // Pro 会员专属
func matches(_ definition: BadgeDefinition) -> Bool {
self == .all || definition.badgeTrack == self
}
}
这个分组做出来之后,成就页的平均停留时长从 8 秒涨到了 19 秒。说实话这个数字比我预期高,主要原因我猜是「中国赛道」——省份解锁这个玩法对国内用户有天然吸引力,很多人打开成就页就是去看自己还差哪几个省。
成就判断需要的数据维度很多:总距离、连续天数、省份数量、面积……最开始每个徽章自己去查数据库,成就页一打开要跑几十次查询,加载卡顿肉眼可见。
后来抽了一个 BadgeMetrics 结构统一做一次聚合,所有徽章判断共用同一份数据:
struct BadgeMetrics {
let totalDistanceKilometers: Double
let recordedDays: Int
let currentStreakDays: Int
let longestStreakDays: Int
let chinaProvinceCount: Int
let chinaAreaKm2: Double
let cityUnlockCount: Int
let globalAreaKm2: Double
static func build(
stats: TraceStats,
segments: [TraceSegment],
geo: GeographicProfile,
proMembershipActive: Bool,
proMembershipActivatedAt: Date?
) -> BadgeMetrics { ... }
}
顺带提一个细节:segments 在 build 里有一步过滤,只保留 pointCount >= 8 的记录。这个阈值对应大约 10-15 秒的有效移动,过滤掉了打开 App 又马上锁屏的噪声。这个数值调了好几次,太小的话徽章进度会被一堆无效数据撑高,用户觉得「奇怪,我没走多少怎么进度涨这么快」,反而破坏信任感。
省份解锁要判断「这个 GPS 点是否在某个省内」,但 GPS 原始数据是 WGS-84,国内地图用 GCJ-02,直接拿坐标去匹配行政区边界,边境附近会出现「明明走在省内却没解锁」的情况。
我在 GeographicProfile 里做了坐标系转换,省级和地市级边界数据全部内置,不走网络请求。好处是离线也能正常触发成就,坏处是包体增加了大概 3MB——这个取舍我觉得值,足迹类 App 很多场景就是在没网络的山里。
下一步想加「状态徽章」:比如「连续 30 天记录」解锁之后不是永久持有,断了会变灰,需要重新激活。
但我现在真的没想清楚该不该做。
没压力就没粘性,这个逻辑说得通,健身 App 基本都用这套。但足迹记录和健身不一样——用户可能就是出去旅行才开,平时根本不用,强迫他们「每天打卡」会让 App 变成一个焦虑来源。我不想做那种让人觉得「没开就有罪恶感」的产品。
但如果完全没有时间压力,成就全部永久持有,用户解锁完一批之后可能又回到当初那个 2% 的困境。
这个矛盾我现在还没有好答案。如果你做过类似的游戏化设计,或者作为用户对「会过期的成就」有什么感受,真想听听。
调参数调到一半,我顺手往测试账号里又存了 200 块,纯粹是因为看硬币掉落太过瘾。那一刻我觉得这个 App 的核心方向对了。
存钱 App 不少,但我自己用了一圈,基本上三天就卸载了。问题不是功能不够,而是「存进去」这个动作没有任何正向反馈。往银行账户转 500 块,余额多了个数字,然后呢?没声音没动静,脑子里毫无反应,下次就很难再重复这个行为。做「聚沙攒钱」大概花了三个月,现在刚上线,核心想解决的就是这一件事:让存款这个动作本身变得有意思。
这是整个 App 里我花时间最多的部分。存款的时候,硬币从屏幕上方掉落,碰到罐子边缘会弹一下,堆在罐底。存的金额越大,掉的硬币越多。用 SpriteKit 的物理引擎实现,核心逻辑大概是这样:
func spawnCoins(count: Int, into scene: SKScene) {
for _ in 0..<count {
let coin = SKSpriteNode(imageNamed: "coin")
coin.physicsBody = SKPhysicsBody(circleOfRadius: coin.size.width / 2)
coin.physicsBody?.restitution = 0.4
coin.physicsBody?.friction = 0.6
let startX = CGFloat.random(in: scene.size.width * 0.3...scene.size.width * 0.7)
coin.position = CGPoint(x: startX, y: scene.size.height + 20)
scene.addChild(coin)
}
}
restitution 控制弹性,friction 控制摩擦。这两个值调了挺久——最开始设 restitution = 0.8,硬币在罐里弹来弹去像乒乓球,完全不对;换成 0.1,又像石头直接沉底,没有金属感。来回试了大概二十组,0.4 + 0.6 是我自己觉得最接近「真实硬币掉进陶瓷罐」的感觉。就是在调这个参数的过程中,我忍不住顺手存了那笔 200 块。
产品结构上做了两种模式。
愿望模式:适合「我要攒钱买 AirPods Max」这种有明确目标的场景,设目标金额,每次存款推进度条,距离目标还差多少天一目了然。
聚沙模式:基于 DCA(定期定额)逻辑,设定每周或每月固定存入金额,内置复利计算器,输入年化收益率之后可以看到 N 年后的预估结果,适合想养成长期储蓄习惯的场景。
两种模式放在一起,设计阶段我自己也担心会让人觉得混乱。但在早期十几个测试用户里,有三四个两个模式都开着——一个用来存旅行基金,一个用来强迫自己每月定存。这个比例让我觉得放在一起是对的,两种心理状态确实可以并存。
参考了健身 App 的逻辑,把可见的里程碑作为习惯强化手段。徽章判断条件全部基于 StatsSummary 这个结构体,包括总存款金额、连续天数、存款时间段等等:
BadgeDefinition(id: "streak_7", name: "Week Streak",
description: "Deposit 7 days in a row",
category: "streak") {
$0.currentStreak >= 7
},
BadgeDefinition(id: "night_owl", name: "Night Owl",
description: "Deposit 10 times at night",
category: "special") {
$0.nightDeposits >= 10
}
「Night Owl」和「Early Bird」是我比较喜欢的两个,晚上存了 10 次和早上存了 10 次分别解锁。有测试用户看到「Night Owl」的时候说「这个 App 懂我」,这个反馈挺好的——徽章在记录的不只是金额,还有一个人存钱的时间节奏。
这个模块有点意思。我不想手写几百句鸡汤,所以用了组合逻辑——18 个「主语」乘以 18 个「谓语」,生成 324 种组合,足够一年内不重复。
比如「固定的存钱节奏」+「会让焦虑一点点淡下去」,「一杯奶茶的钱」+「能抵消很多小小的冲动消费」。有些组合挺通顺,有些拼出来确实略生硬,读起来像机器写的。生硬的那些我做了一个黑名单手动过滤,大概淘汰了 40 句,剩下的整体可读性还不错。说白了,这是个半自动流程,机器打草稿,人工做最后一道筛。
订阅定价改了两次。最开始想做纯免费带广告,后来发现存钱 App 里放广告体验很糟,用户存钱存到一半弹出来一个游戏广告,心情直接崩了。改成一次性内购之后反而顺一些。
数据备份功能上线比预想晚了一个版本。有个测试用户换手机之后数据全没了,找我反馈,最后一条消息就是「我的数据没了」,然后就没再说话。我盯着那条消息看了挺久,没法回复什么。那之后备份功能直接插队到下个版本,别的需求全往后推。用户数据这件事,v1 就该做好,没有借口。
「聚沙模式」的 UI 一开始做得太复杂,复利计算器有七八个输入项,我自己用的时候都觉得烦,后来砍掉大半只留核心参数。试了三个方案,最后全删了重来。功能多不等于有用。
目前有个穿模问题没有根治:硬币数量一多,相互重叠之后会出现轻微穿透。我现在的做法是限制单次最大生成数量,同时用 categoryBitMask 给硬币单独分一个碰撞分组,让它们只和罐壁、罐底以及彼此发生碰撞,不影响 UI 层的其他节点:
coin.physicsBody?.categoryBitMask = PhysicsCategory.coin
coin.physicsBody?.collisionBitMask = PhysicsCategory.coin | PhysicsCategory.jar
coin.physicsBody?.contactTestBitMask = PhysicsCategory.jar
这样能减少无关碰撞计算,但硬币堆多了之后还是会穿模,治标不治本。有做过 SpriteKit 堆叠物理的朋友吗?是怎么处理这个问题的?
多年来,我注意到开发者成长的一个规律。
教程很适合学习语法。课程有助于理解概念。但在某个阶段,最大的提升来自于阅读有经验的团队如何在真实代码库中解决真实问题。
不是示例项目,不是演示应用,而是真正上线的产品。
那种处理你根本想不到的边界情况的代码。那种经历了三年功能迭代仍然健壮的架构。那种只有亲眼看到它们在大规模下运作才能理解的决策。
我花了不少时间浏览开源 iOS 应用,以下是我认为真正值得深入研究的七个。每一个都能教会你不同的东西——关于架构、安全、设计模式,或者仅仅是良好的工程习惯。
以下是清单。
仓库: mozilla-mobile/firefox-ios
许可证: MPL 2.0
这是 Mozilla 为 iOS 打造的完整浏览器,完全使用 Swift 编写。
这是一个庞大的代码库,而这正是它的价值所在。你很少有机会看到如此规模的项目如何在一个地方同时处理标签页管理、同步、遥测、内存压力和无障碍访问等问题。
最让我惊讶的是,尽管项目规模巨大,代码的可读性却相当高。Mozilla 积极标记 good first issue 工单,贡献流程文档也非常完善。
你可以学到:
如果你好奇一个生产级浏览器在底层是什么样子,这里是最好的起点。
许可证: GPL-3.0
Signal 是一款注重隐私的即时通讯应用,数百万人信赖它进行安全通信。
从学习角度来看,Signal 代码库最有趣的地方在于它在每一层都极其认真地对待安全问题。端到端加密、安全本地存储、密钥管理——这些不是事后补充,而是嵌入到架构本身之中。
该应用还非常实际地混合使用了 UIKit 和 SwiftUI,这反映了当今许多生产应用的真实面貌——不是纯粹地使用其中一种,而是经过深思熟虑的混合方案。
你可以学到:
阅读 Signal 的代码会改变你对自己应用中数据处理的思考方式。
仓库: wordpress-mobile/WordPress-iOS
许可证: GPL-2.0
这是 Automattic 官方的 WordPress 应用——最成熟的开源 iOS 项目之一。
该代码库涵盖了真正广泛的 iOS 挑战:Core Data、REST 和 GraphQL 网络请求、富文本编辑、离线同步、模块化架构。很难找到一个项目能同时涉及这么多领域。
WordPress 让我印象最深的是它的贡献体验。文档详尽,上手流程顺畅,项目周围有真正的导师文化。如果你想做出第一个有意义的开源贡献,这里是最好的起点之一。
你可以学到:
仓库: Ranchero-Software/NetNewsWire
许可证: MIT
NetNewsWire 是一款免费的 RSS 阅读器,支持 iOS 和 macOS,由 Brent Simmons 开发。
如果你不熟悉这个名字,Brent 是 Apple 开发者社区最具影响力的元老之一。他几十年来一直在构建 Mac 和 iOS 应用,这在代码库的每一个角落都体现得淋漓尽致。
我喜欢 NetNewsWire 的地方在于它的 Swift 代码多么干净、多么地道。没有过度设计,没有不必要的抽象,只有结构良好的代码,恰好做它需要做的事。
它也是我见过的 iOS 和 macOS 之间跨平台代码共享的较好范例之一。项目规模足够小,你实际上可以通读整个代码库并理解所有部分是如何连接的。
你可以学到:
如果你想从头到尾研究一个代码库,这是我推荐的仓库。
仓库: wireapp/wire-ios
许可证: GPL-3.0
Wire 是一款安全的即时通讯应用,支持语音通话、视频通话和群聊——全部默认加密。
对于 iOS 开发者来说,Wire 特别有趣的地方在于它的真实 WebRTC 集成。如果你好奇音频和视频通话在 iOS 代码层面到底是如何工作的,这是为数不多的能让你看到完整实现的开源项目之一。
该项目也是大规模模块化 Swift 架构的良好范例。它被拆分为边界清晰、定义明确的模块,这使得在如此规模的项目中导航比预期要容易。
你可以学到:
许可证: AGPL-3.0
这是下一代 Matrix 客户端,也是本列表中最现代的代码库之一。
Element X 完全使用 SwiftUI 构建,底层基于 matrix-rust-sdk。仅凭这个组合就值得研究——你能看到 SwiftUI 在生产规模下的使用,也能看到 Rust 和 Swift 如何通过 FFI 在真实应用中进行通信。
项目非常活跃,团队响应迅速,并且定期为新人标记 issue。如果你想找一个能反映 iOS 开发未来方向的项目——SwiftUI 优先,性能关键层用 Rust 编写——就是它了。
你可以学到:
许可证: Apache-2.0
Kickstarter 开源了他们的整个原生 iOS 应用,这是 iOS 社区中被引用最多的代码库之一。
它被广泛引用的原因在于其严谨性。函数响应式编程、MVVM 架构、依赖注入,以及真正有意义的测试覆盖率。每种模式在整个项目中都得到了一致的应用,这使它作为参考极其有用。
他们的 Pull Request 风格和代码审查文化也值得学习。从阅读他们的 PR 和提交信息中,你能学到和代码本身一样多的东西。
你可以学到:
七个仓库确实很多。你不需要全部看完。
我的建议是:选择一个与你当前工作或好奇心相关的项目。克隆它,在 Xcode 中运行。然后挑一个功能——也许是登录流程,也许是同步层,也许是他们如何处理导航——从头到尾读一遍。
不要试图一次理解整个代码库。聚焦于一条代码路径,从 UI 层一直追踪到数据层。
你花一个下午阅读生产级代码所学到的东西,比跟着教程学一个月还要多。
如果你想做出自己的第一个开源贡献,这些项目中的大多数都会积极标记 good first issue 工单。这意味着有一个明确的入口在等着你。
阅读优秀的代码是一种会随时间悄然复利的好习惯。
你会开始注意到你从未想到过的模式。你会开始理解为什么某些架构决策会存在。你会培养出一种直觉——什么样的代码容易修改,什么样的代码对每一次改动都充满抗拒。
这些都不是来自某个突破性的瞬间。它们来自于持续地接触精心编写的代码,让这些模式重塑你对自己工作的思考方式。
希望这份清单能给你一个好的起点。
选一个。克隆它。开始阅读。
最近上线了一个叫「声境护照」的 iOS App,做的事情说起来很简单:番茄钟 + 环境音 + 数据可视化。但我想聊的不是功能本身,而是做这个 App 过程中一些有意思的设计决策——尤其是「把专注数据包装成旅行叙事」这条路到底值不值得走。
我用过很多专注类 App,Forest、潮汐、番茄ToDo,都挺好用。但用着用着有个感受:完成了专注,然后呢?数字加一,然后就没了。
说实话,这种「完成即消失」的感觉有点可惜。你花了 25 分钟认真写东西,这件事值得被记住。所以我想做一个让每次专注都留下「印记」的工具。
护照的比喻就是从这里来的——每次专注是一次起飞,声景是目的地,累计时长变成飞行里程,连续打卡天数是你的「航班记录」。
游戏化成长系统是这个 App 最核心的部分,我在 ExpeditionModels.swift 里把整个探险体系建模成章节 + 任务的结构:
struct ExpeditionChapterDefinition: Identifiable, Codable, Equatable {
let id: String
let sceneId: String
let cityName: String // 对应一个声景目的地
let tagline: String
let bonusBounces: Int
let missions: [ExpeditionMissionDefinition]
}
enum ExpeditionMissionKind: String, Codable {
case sessionCount // 完成 N 次专注
case focusMinutes // 累计 N 分钟
case deepFocusCount // 深度专注 N 次
}
每个「城市章节」绑定一个声景 ID,完成章节里的任务才能解锁下一个城市。这样声音选择就不只是 UI 装饰,而是有推进感的目标。
ExpeditionMissionKind 只有三种,我故意控制得很少。试了几个方案,加过「连续打卡天数」「特定时段专注」等类型,最后都删了——任务类型越多,用户反而不知道该干什么。
每次专注结束会弹出一张战报,这个战报除了展示当次数据,还会给出下一次专注的建议。这个逻辑在 SessionReportSheetViewModel 里:
func nextActionAdvice(taskCompleted: Bool) -> SessionNextActionAdvice {
let remainingTodayPlan = max(0,
store.weeklyPlanTodayTargetSegments - store.weeklyPlanTodayActualSegments
)
let streakHint: String
if store.streakDays >= 5 {
streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
} else if store.streakDays >= 2 {
streakHint = "再坚持 1-2 天可进入稳定习惯区。"
} else {
streakHint = "建议先连续 3 天完成每日最小闭环。"
}
// ...
}
这里有个取舍:建议文案是写死的字符串模板,不是 AI 生成的。我考虑过接 LLM,但一来成本不好控制,二来我发现这类「行为引导」场景其实不需要千变万化的文案,固定的几条反而更有仪式感,用户知道这是 App 在认真跟踪自己的状态。
连续天数的分层(1天 / 2-4天 / 5天以上)是我根据习惯养成的一般规律拍的,不是什么严格实验得出的结论。连续 3 天是个心理门槛,5 天以上用户大概率已经进入节奏了,策略应该从「建立习惯」切换到「维持节奏」。
这是我觉得最值得展开讲的部分。
专注类 App 的自然增长渠道几乎只有两条:AppStore 搜索,和用户分享。Forest 靠的是「种了一棵树」的视觉,潮汐靠的是精美的音景截图。我的切入点是「数据卡片」——把当次战报或周回顾渲染成一张可以直接发朋友圈的图片。
ShareCardFormatter 负责格式化卡片里的时间信息,战报卡片、成就徽章卡片、周回顾卡片用的日期格式各不相同(yyyy/MM/dd HH:mm vs yyyy/MM/dd),看起来细节,但如果格式乱掉整张卡片的质感就垮了。
卡片设计我做了三个版本,第一版太「仪表盘」,数字密密麻麻;第二版太「极简」,信息量不够,朋友看不出你做了什么;第三版找到了平衡——突出时长和等级称号,次要展示声景和任务名,底部放一行小字的里程数。
统计相关的逻辑我拆成了两个 Service:
StatsService:纯数据聚合,负责按时间范围汇总 FocusLog,输出 StatsData
GrowthService:负责把 FocusLog 转换成 GrowthProfile,计算等级、经验值、称号这两个 Service 都是无状态的纯函数风格,输入 logs 数组输出结果,在多个 ViewModel(StatsSheetViewModel、ProfileSheetViewModel、WeekReviewSheetViewModel)里复用。
有一个小设计:当 focusLogs 为空时,会调用 StatsService.createDemoFocusLogs() 生成演示数据。新用户第一次打开统计页不会看到空白界面,而是看到一个「如果你用了两周会是什么样子」的预览。这个 onboarding 细节我觉得挺重要——空页面对新用户很劝退。
App 刚上线 1.3 版本,下载量还很少,老实说基本还在 0 起步阶段。
有几个功能是做到一半放在 _disabled_features 目录里的——统计报告、周回顾、分享卡片这些模块代码都写完了,但 UI 打磨还不够,我没有在 1.3 开放。这种「功能写完了但藏起来」的状态有点难受,但比发出去然后体验很差要好。
声景库目前内容量不够丰富,「东京雨夜」「咖啡馆白噪音」这类场景音是有的,但城市章节太少,探险系统的推进感不强。这是接下来要重点补的。
还有一个我没想清楚的问题:护照 + 飞行里程这套叙事对喜欢旅行的用户很有共鸣,但对完全不在意这个比喻的用户来说可能显得有点奇怪。这个产品定位的边界到底在哪,我还在摸索。
如果你也在做类似的「工具 + 游戏化」方向的 iOS App,或者对专注类产品有什么看法,欢迎在评论区聊聊——我对「游戏化到底会不会让用户厌倦」这个问题挺好奇的,想听不同角度的判断。
在鸿蒙上做呼吸动画,我以为最难的是 ArkTS 语法,结果最麻烦的是——我根本不知道用户的设备跑到哪一档了。
呼吸动画是「呼吸视界」这个 App 的核心体验:吸气时圆圈缓慢扩张,屏气时保持,呼气时收缩。这个动画一旦卡顿,「跟着 App 呼吸」的节奏就断了,用户能感觉到「哪里不对」,但不会告诉你是帧率问题。
先说一下这个 App 是干什么的,方便后面的技术背景理解。
「呼吸视界」(iOS App Store ID: 6758613852)做的是结构化呼吸训练引导——4-7-8 呼吸法、盒式呼吸、Wim Hof 法这些。网上这些方法的文字说明很多,但照着文字练,你得自己数秒、记顺序,练着练着就分心了。
我做这个的起因很功利:开会前容易紧张,想找个东西帮我两分钟之内把状态重置一下。找了一圈没找到合适的,就自己写了。
App 有三块核心功能:带动画节奏的引导式练习、本地持久化的训练记录、以及一个课程进度系统(不只是单次练习,而是完整的训练计划)。iOS 版目前评分 5 分,样本量不大,但有个用户说「可以跟随练习呼吸,保持稳定的心情」——说实话这个反馈比我预期的更朴实,我自己用下来觉得更直接的感受是:开会前真的有用,两分钟够了。
鸿蒙版最近发布,把移植过程里踩的几个坑整理一下。
呼吸动画用 GPU 渲染时效果最好,过渡顺滑,缩放曲线自然。但鸿蒙设备碎片化比 iOS 严重得多,中低端机上 GPU 渲染直接掉帧,整个动画变得一顿一顿的。
所以我做了一套自适应降级:检测到性能不足时切到 Canvas fallback 模式,同时把当前渲染质量分成 high、balanced、low 三档。
问题来了:切换阈值怎么定?
我的判断方式是盯 frameMs(单帧渲染耗时)和连续低帧计数:
// 连续低帧超过阈值时触发降级
if (frameMs > 22 && consecutiveLowFpsCount >= 3) {
// 22ms ≈ 45fps,低于此值且连续3帧 → 切 balanced
adaptRenderer('degrade quality -> balanced');
consecutiveLowFpsCount = 0;
}
if (frameMs > 33 && consecutiveLowFpsCount >= 3) {
// 33ms ≈ 30fps,连续3帧 → 切 canvas fallback
adaptRenderer('switch renderer -> canvas fallback');
}
这个阈值不是凭感觉拍的,是我把 hilog 日志抓出来跑脚本分析的结果。日志里会输出每帧的 fps、frameMs、tickMs 以及当前渲染质量档位,降级事件会打 BF_PERF_ADAPT 标签,比如 degrade quality -> balanced 或者 switch renderer -> canvas fallback。
对独立开发者来说这套日志分析挺重要——没有 QA、没有用户主动反馈卡顿,只能靠工具自己发现问题。我在没有真机的情况下,靠日志回放重现了好几个卡顿场景。
目前 Canvas 模式下动画过渡还是不如 GPU 顺滑,这个还在打磨,算是没解决干净的问题。
App 的调性是「平静克制」,UI 上我比较在意所有间距、圆角、阴影、动画时长要统一。如果哪个地方直接写了魔法数字,整体质感就散了。
鸿蒙版我把所有设计 token 收进一个 Style.ets,导出四个命名空间:SPACE、RADIUS、SHADOW、MOTION。问题是开发过程中很容易手滑——改某个组件时直接写 borderRadius(8) 而不是 RADIUS.card,这种事我自己也干过。
所以我写了一个 check_design_foundation.py,逻辑很简单:用 path.read_text() 读取关键文件内容,用字符串匹配检查是否包含预期的 token 引用。比如检查 AppBackdrop.ets 里有没有 export struct AppBackdrop,检查 SheetBackground.ets 里有没有调用 AppBackdrop(,检查根页面 RootPage.ets 里有没有 AppBackdrop({。
不是正则匹配魔法数字(那个误报太多),而是检查「关键结构是否存在」——更像一个架构约束验证器。
真实案例:有一次我重构了背景组件 AppBackdrop,改了对外接口,但忘了更新 SheetBackground 里的调用方式,就是被这个脚本拦下来的。如果没有这个检查,这个问题可能得等到真机运行时才会发现。
我还把几个类似的脚本整合进一个 check_foundation_alignment.py,统一管理:设计 token 校验、按压反馈检查、页面过渡检查、i18n 对等检查、路由检查——提交前一起跑,哪个挂了去修哪个。独立开发没有 code review,这套东西算是自己给自己兜底。
App 支持中英双语,维护四个语言文件:strings_app_en.ets、strings_app_zh-Hans.ets 以及对应的 base 版本。每次加新功能往里填 key,英文填了忘了填中文,或者反过来,这种事经常发生。
check_i18n_keys.py 做的事很直白:把四个文件里的 key 全部提取出来做集合差运算,输出「哪些 key 在英文有但中文没有」以及反向的情况。
这个脚本帮我发现过好几次漏掉的 key,有时候漏的是边缘功能的文案,有时候是一个按钮标题——后者如果漏了,用户看到的就是 key 字符串本身,很难看。
ProgramProgressRecord 记录用户在某个训练计划里完成了哪些 session、当前在第几阶段。数据全部本地存储,没有云同步。
说实话云同步我也不想做。OAuth 接入、服务器费用、隐私合规、多端数据冲突处理……这一套对独立开发者来说投入产出比太低。用户的训练记录放本地就够了,鸿蒙的 Preferences 和 RelationalStore 用起来比我预期顺手,持久化这块没遇到太大麻烦。
用户可以自由设置吸气、屏气、呼气各阶段的时长。这个功能的交互我试了三版:滑动条、数字步进器、转盘——感觉都差点意思。滑动条精度不够,步进器操作次数太多,转盘在小屏上很难操作。
这个目前还搁着,UI 做得比较简陋。如果你做过类似的时长输入控件——尤其是整数秒精度、范围大概 1-30 秒的场景——很想听听你用了什么方案。
我最近把一套已经在线上跑通的项目内埋点代码,抽成了一套可复用的 iOS SDK。原本我以为难点只是“把代码搬出去”,后来才发现,真正难的是哪些能力该留在 SDK、埋点上报发送请求到底要不要sdk来接管、日志怎么做才方便进行测试验证、文档和版本号怎么跟上。这篇文章分享的是这套 SDK 被真实接入反馈一步步打磨出来的过程。
最开始我以为,做这套埋点 SDK,就是把项目里那套已经跑通的代码抽出来就可以了,后来发现没那么简单。
真正麻烦的地方,是代码抽出去之后才出现的:
因为我后来确定了一件事:把埋点代码抽成 SDK,难点从来不是“把代码搬出去”,而是让它在接入、调试、验证、埋点上报这些环节里都真的可用。
起因其实很简单,我手上有一个已经在线上跑通的 iOS APP 项目,里面已经有一套比较完整的埋点能力:
于是,领导就给我提出一个需求:让我封装一个埋点sdk,把固定用户属性和公共事件属性都封装在sdk中,让sdk内部自动获取这些属性值,iOS同事在其他项目中进行埋点上报的时候,就不需要再单独写一套固定用户属性和公共事件属性上报的代码了,直接使用sdk的能力就可以了。
所以,这次的目标很明确,是把这些已经被项目验证过的能力,封装成一个其他 App 项目也能对接的 SDK。
我最开始手上拥有的,是一套项目里我写好的埋点管理代码。
这种埋点管理类在业务项目里很常见,一开始也确实好用,因为它把所有事都接住了:
以上8点它全部都管。截一小段原来的调用入口,就能看出这种写法的特点:
func track(_ eventName: SC_MQ09EventName,
properties: [String: Any] = [:],
timestamp: Date? = nil) {
let resolvedTimestamp = timestamp ?? Date()
var payload = buildEventPayload(
eventName: eventName,
properties: properties,
timestamp: resolvedTimestamp
)
guard JSONSerialization.isValidJSONObject(payload) else {
SuperCoderNetLog("[MQ09] invalid payload for \(eventName.rawValue): \(payload)")
return
}
do {
let data = try JSONSerialization.data(withJSONObject: payload, options: [])
routeEventPayload(
payload: payload,
payloadData: data,
allowRetryStore: true,
eventName: eventName.rawValue
)
} catch {
SuperCoderNetLog("[MQ09] encode failed: \(error.localizedDescription)")
}
}
这段代码本身没有错,问题在于,它已经同时在做几件事:决定事件时间、构建请求参数、校验 JSON、准备失败重试需要的数据、再把埋点上报请求发送出去。
在一个项目里,这样写能很快推进,但一旦你想复用到别的项目,就会发现它太像“项目现场代码”,而不是一层可以被其他 App 直接依赖的通用能力。
这种写法在“单项目快速推进”阶段没问题,但一旦你想跨项目复用,它马上就会暴露两个大问题。
第一,职责太杂。
它既有通用能力,又有业务语义。比如某些页面事件、某些业务字段、某些页面触发时机,这些本来只属于当前项目,但也被混进了同一层埋点管理代码。
第二,边界不清。
你很难回答一个问题:
到底哪些是“埋点 SDK 应该负责的”,哪些只是“当前这个业务项目碰巧这么写了”。
这也是我后来感受最强的一点:
项目里能跑通,不代表它已经具备跨项目复用条件。
真正开始封装 SDK 之后,我先做的不是写代码,而是先把边界想清楚。
我想清楚了以下3点:
比如这些:
这些东西不依赖某个具体页面,也不属于某个特定业务,很适合收进 SDK。
比如:
这部分如果硬塞进 SDK,SDK 很快就会变成“这个项目专用库”,复用价值就没了。
我同事跟我说,一般SDK能够发送请求上报埋点,他们都会选择直接用SDK上报,但我在做的过程中,还是觉得做成可选吧,因为不一定所有项目都愿意把网络请求交给 SDK。
有的项目想要的是:
有的项目则希望:
所以我最后没有把发送写死,而是保留了两条路:
track / setUserProperties
标准接法的入口最后被压得很薄:
public func track(
eventName: String,
properties: [String: Any] = [:],
timestamp: Date? = nil,
eventType: String = "track"
) {
let rawParams = ZZHAnalyticsJSONSanitizer.dictionary(properties)
let payload = makeEventPayload(
eventName: eventName,
properties: rawParams,
timestamp: timestamp,
eventType: eventType
)
sendPayloadIfPossible(
payload,
endpointType: .event,
startLogContext: .event(eventName: eventName, params: rawParams)
)
}
业务方只需要告诉 SDK:我要发哪个事件,带哪些业务参数,至于公共字段怎么补、时间怎么格式化、请求怎么发、日志怎么打,都留在 SDK 里面处理。
这个决定看起来只是 API 设计,实际上后来直接影响了后面埋点日志输出功能的业务逻辑。
我也没有直接把旧的上报方式整条推翻,这轮更稳的做法其实是:
这件事现在回头看也特别值得记下来。
因为复杂项目里,真正危险的不是代码抽得慢,而是你一上来就直接替换项目里正在使用的那条上报路径,这样一旦 SDK 和旧逻辑有没对齐的地方,往往要到真实验收时才会暴露出来。
如果这套 SDK 只停留在“我本地能跑”,其实没什么特别值得写的。
真正让它有工程价值的,是iOS同事在他的项目中接入时遇到的SDK出现的各种问题。
distinct_id 和 account_id 应该怎么传一开始最容易掉进去的坑,是想把这两个用户标识字段设计得很“完整”,但后来对着真实项目接进去时,我才发现这不只是字段怎么传的问题。
distinct_id 这条比较清楚:
distinct_id == device_id真正麻烦的是 account_id。
一开始我把它理解成“有就带,没有也不影响上报”,代码里也确实是这么处理的。
但真实接入时,很快就暴露出一个更具体的问题:
很多 App 都是先初始化 SDK,后面才从 Adjust SDK 异步拿到 adid。
也就是说,问题不只是“account_id 要不要传”,而是:
SDK 初始化完成的时候,account_id 很可能还拿不到。
后来产品把规则也确认得更明确了:
distinct_id 必须有account_id 的值就是外部拿到的 adjustid
account_id 不是初始化时必须就有,但外部拿到 adjustid 后要立刻传给 SDKaccount_id
account_id 还要作为用户属性,再主动补报一次 user_setOnce
一开始我以为这是字段传值规则的问题,后来接入时才发现,真正麻烦的是 SDK 已经初始化好了,adjustid 却还没回来。
所以后面真正的修改方式,不是继续讨论 account_id 到底算“可选”还是“不可选”,而是把 SDK 补成初始化完成后也能继续更新 account_id。
最后 SDK 对外多补充了一个明确方法:
Adjust.adid { adid in
guard let adid, adid.isEmpty == false else { return }
ZZHAnalyticsSDK.shared.setAccountID(adid)
}
也就是说,这件事最后真正定下来的,不是一句“account_id 可选”,而是:
distinct_id 一开始就由项目传进来account_id 等 Adjust 返回后,再立刻传给 SDKaccount_id
user_setOnce 用户属性 account_id
这比我一开始那种“先把规则写简单一点再说”的理解,要更接近真实项目接入。
一开始用户属性更新方式可以传字符串,这在设计上很灵活,但真实接入时很容易变成:
所以后来我只保留了两种明确的写法:
user_setuser_setOnce这个改动看起来不大,但它背后的意思是:
SDK 不是只负责“让你能传”,还要尽量避免接入方传错、传乱。
一开始 SDK 只有自动重试 2 次。
这对临时网络失败来说够用,但接入方很快会问一个问题:
如果这次重试两次都失败,下次 App 重启以后怎么办?
这个问题一出来,我就知道,这不再只是“重试几次”的问题,而是“第一次生成的请求内容要不要保存下来”的问题。
因为一旦你要支持 App 重启后继续重发,就意味着:
所以后来这一块的核心原则就变成:
重试永远基于第一次生成的请求内容,而不是重新构建请求参数。
这也是我觉得很值得写出来的一条工程经验。
很多人会默认“失败了再组合一次参数”,但埋点这种东西,真这么做,最后发出去的内容就可能和第一次不一样了。
而且这件事后面我越看越觉得不能偷懒,因为变化的根本不只是接口的参数 time。
如果你让 SDK 失败后重新组一次参数,可能一起变掉的还有:
所以后来我对这条原则的理解就更明确了:
埋点请求一旦生成,就应该尽量把它当成那一刻的快照。
ta_app_install 事件上报的时间后来为什么还要单独修改这也是产品验收时发现的一个问题。
一开始我会默认觉得:
time 用当前时间这对绝大多数事件都没问题。
但 ta_app_install 不一样。
因为产品验收时看的不只是传参的 time,还会一起看:
#install_timeinstall_ts_bjinstall_ts_utcinstall_ts_time这几个时间字段本质上都应该指向同一个安装时间点。
当前项目之所以一直没出问题,是因为业务层本来就主动把安装时间传给了这条事件。
但 SDK 标准接法如果只是:
ZZHAnalyticsSDK.shared.track(eventName: "ta_app_install")
旧逻辑会直接把发起这次上报时的当前时间,写进这个事件的 time 字段。
这样一来,这个事件里的 time,和安装相关字段就不是同一个时间来源了。
这个问题特别能说明一件事:
把代码封装成 SDK,只是第一步,等产品真的开始逐个字段检查时,你才会知道还有哪些地方没处理对。
后来的修法也很克制:
ta_app_install 且外部没传 timestamp 时,才默认改用 SDK 保存的安装时间这件事让我后面更确定,SDK 真正难的不是“第一版怎么设计”,而是:
当产品拿着真实字段来验的时候,你能不能只改那一个真正有问题的地方,而不是顺手把整套时间逻辑都推倒。
如果说前面这些问题还在解决“SDK 能不能用”,那埋点日志系统解决的是另一件事:
SDK 能不能帮助测试和产品快速确认埋点参数有没有传对。
最开始 SDK 里的日志更像网络请求调试日志:
但这类日志有个问题:
做 SDK 的人能看懂,测试拿去检查埋点参数就很难受。
因为测试真正关心的不是网络请求内部过程,而是:
所以后来日志被拆成了两类:
代码里也尽量保持这个拆分方式:
public func send(snapshot: ZZHAnalyticsRequestSnapshot,
completion: @escaping (Bool) -> Void) {
var request = URLRequest(url: snapshot.url)
request.httpMethod = "POST"
request.httpBody = snapshot.bodyData
#if DEBUG
ZZHAnalyticsDebugStartLog(Self.startLog(for: snapshot), snapshot: snapshot)
#endif
URLSession.shared.dataTask(with: request) { data, response, error in
if let error {
#if DEBUG
ZZHAnalyticsDebugLog(Self.failureLog(for: snapshot, error: error))
#endif
completion(false)
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
#if DEBUG
ZZHAnalyticsDebugLog(
Self.responseLog(for: snapshot, response: response, data: data, success: false)
)
#endif
completion(false)
return
}
#if DEBUG
ZZHAnalyticsDebugLog(
Self.responseLog(for: snapshot, response: httpResponse, data: data, success: true)
)
#endif
completion(true)
}.resume()
}
这段代码的重点,不是“加了几行日志”,而是把日志按使用场景拆开:请求发出去之前,先打印一条发起日志,让人能看到这次准备发什么;请求回来之后,再打印一条结果日志,让人能看到这次到底有没有成功。
发起日志最终打印的是:
它解决的问题很明确:
它能够让开发者、产品、测试清晰地知道埋点是否有正确发起上报。
结果日志则继续保留:
它解决的是另一层问题:
埋点上报请求最终到底成功没有,服务端返回了什么。
做到这里,我以为已经完成任务了。
后来同事那边又提了一个很真实的诉求:
他们项目里本来就有一个悬浮日志窗口,测试和产品会直接在 App 里看埋点日志,如果 SDK 内部只把日志打到 Xcode 控制台,对他们来说是远远不够的。
这时我才意识到,日志不只是“打印出来”,还得“送出去”。
于是后来又补了一层日志代理:
最终对外暴露的协议是这样的:
public protocol ZZHAnalyticsLogDelegate: AnyObject {
func analyticsSDK(
_ sdk: ZZHAnalyticsSDK,
didReceiveEventStartLog message: String,
eventName: String,
params: [String: Any]
)
func analyticsSDK(
_ sdk: ZZHAnalyticsSDK,
didReceiveUserPropertyStartLog message: String,
updateType: String,
params: [String: Any]
)
func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
didReceiveEventResultLog message: String)
func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
didReceiveUserPropertyResultLog message: String)
}
这个代理方法不复杂,但它把两件事拆清楚了:一边是 SDK 原样日志 message,另一边是业务自己可能想再打印一条简洁日志用的 eventName / updateType / params。
这一层能力看起来很小,但它把日志从“开发调试工具”变成了“测试和产品也能直接用的快速确认埋点参数有没有传对的工具”。
做到这一步,我自己的总结是:
很多 SDK 日志的问题,是日志只对 SDK 开发者有用,对测试和产品没有用。
这是我在真实项目接入时遇到的一个具体问题。
一开始我把发起日志代理设计成:
message:完整日志原文params:给接入方自己打印一条简洁发起日志看起来很合理,但接入后我发现,同样叫 params,在不同接法下代表的内容并不一样。
如果你走的是:
track(eventName:properties:)
setUserProperties(...)
那 params 很好理解,就是业务方最开始传进来的参数。
如果你走的是:
sendEventPayloadSnapshot(payload)
sendUserPropertyPayloadSnapshot(payload)
那 SDK 拿到的已经是完整的请求参数了。
这时 SDK 内部根本没法再判断:
所以这时发起日志里的 params,默认只能是请求参数里现成的 properties
而当前我这个项目,真实主路径其实更接近这一种。
也就是说,当前 SuperCoder / MQ09 不是一开始就完全走标准 track / setUserProperties,而是更多时候先在项目里把完整请求参数组装好,再交给 SDK 发送上报请求。
这也是为什么我后面会专门把这一点写进接入文档里。因为如果不把“当前项目接法”和“标准 SDK 接法”拆开讲,同事看到日志里的 params,会很容易误以为 SDK 自己把业务参数改掉了。
这个区别后来我专门写进了使用文档中,以免iOS同事理解有误。
后面又出现了第三种情况。
SDK 会自动补发一组固定用户属性,比如:
countryinstall_ts_bjinstall_ts_utcinstall_ts_timeinstall_ts_time_timezone这组属性不是业务方手动传的,但测试又希望在发起日志里直接看到它们。
所以最后我又单独给它做了一个特例:
params 继续代表外部原始入参params 特例代表 SDK 这次自动补发的固定字段这件事说明了一个问题:
同一个参数名,看起来一样,但在不同使用方式下,代表的内容可能不一样。
这也是 SDK 设计里很容易忽略、但真实接入时很容易暴露的问题。
如果只看代码,这套 SDK 其实已经“能用了”。
但我觉得,真正决定它能不能被其他同事真正的在项目中使用,不只是代码。
还有以下这些东西:
我这轮就真实踩到了几个这样的坑:
这些问题不容小觑,是 SDK 工程化本身的一部分。
因为对接入方来说,他们真正关心的是:
所以,SDK 不是“代码抽出来”就结束了,它还要能被别人低成本接上。
而且到后面我还发现,低成本接上这件事,其实也分两层。
第一层是:
第二层是:
pod install 的时候会不会还要处理私有仓库认证这轮我其实只把第一层基本收住了。
也就是说,SDK 代码和接入文档已经比一开始成熟很多了,但分发体验还没有完全走到最理想的形态。现在依然更接近“私有 git + tag”的方式,而不是那种更标准、更省心的私有 Specs 仓接法。
这也让我后面更确定一件事:
SDK 的工程化,不只是代码和 README,还包括分发基础设施到底有没有跟上。
最后,总结 6 个我这次做 SDK 后真正踩出来的经验。
很多时候,项目里的代码只是刚好满足当前业务,想让其他项目也能用,还需要重新拆清楚:哪些放进 SDK,哪些留在项目里。
不是所有项目都希望 SDK 直接发请求。让 SDK 同时支持“构建参数”和“直接发送”,接入成本会小很多。
埋点怕的不是失败,而是失败后重新组参数,导致最后发出去的内容和第一次不一样。只要涉及重试,就尽量保存第一次生成的请求内容。
对 SDK 开发者好用,不代表对测试好用。日志里能不能一眼看到 URL、参数、响应和成功、失败,才决定这套日志有没有价值。
文档不准、tag 不同步、示例代码不对,都会让接入方直接踩坑。SDK 想让别人顺利接入,就不能只管代码。
我这轮最大的感受就是这个,很多一开始觉得“设计得挺好”的地方,最后都是在真实项目接入、同事反馈和测试检查参数时,才暴露出问题。
一个埋点 SDK 真正的完成度,不取决于它能不能发请求,而取决于它能不能被其他项目低成本接入、被测试高效验参、被版本稳定发布。
这可能也是我这轮工作里,最值得留下来的那部分。
你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。
@Crazy:Android MessageQueue 是从 Android 的核心框架,从 API1 就已经存在了,这次 Android 针对它进行了重构,是非常大的优化。
原始 MessageQueue 存在的问题: Android 的 MessageQueue 在过去的二十多年里靠着一把 monitor 同步锁保护,虽然没有大的问题,但是在多核多优先级场景下,这把锁会引发多线程争用同一把锁,并且进一步引发高优先级 UI 线程被低优先级后台线程间接拖慢。
新 MessageQueue 设计核心 DeliQueue: 新的 DeliQueue 采用 lock-free 数据结构的设计方式来解决上面的问题,简单用一句话来描述就是 “可以无锁写入的多生产者线程与独占排序和结构整理能力的单消费者 Looper 线程。” 核心方式就是利用原子操作来替代锁,下面我们把 lock-free 拆解一下,不涉及很多的源码。
long seq = when != 0 ? ((long) sNextInsertSeq.getAndAdd(this, 1L) + 1L) : ((long) sNextFrontInsertSeq.getAndAdd(this, -1L) - 1L);
if (WaitState.isCounter(waitState)) { // 情况 A:looper 已醒 } else if (msg.when >= WaitState.getTSMillis(waitState)) { // 情况 B:新消息不比当前 deadline 更早,我们不需要唤醒 } else if (msg.isAsynchronous()) { // 情况 C:新消息更早,且是 async(绕过消息屏障),需要唤醒 } else { // 情况 D:我们需要看消息屏障状态,决定是否需要唤醒 if (blockedByBarrier) { newWaitState = WaitState.incrementDeadline(waitState); checkBarrier = barrier; needWake = false; } else { newWaitState = WaitState.initCounter(); checkBarrier = null; needWake = true; } }
private static final long MPTR_TEARDOWN_MASK = 1L << 63; // 生产者增引用(incrementMptrRefs) while (true) { final long oldVal = mMptrRefCountValue; if ((oldVal & MPTR_TEARDOWN_MASK) != 0) { return false; // 已 teardown,拒绝新引用 } if (sMptrRefCount.compareAndSet(this, oldVal, oldVal + 1L)) { return true; } } // 生产者减引用(decrementMptrRefs) long oldVal = (long) sMptrRefCount.getAndAdd(this, -1L); if (oldVal - 1 == MPTR_TEARDOWN_MASK) { LockSupport.unpark(mLooperThread); // 我是最后一个活引用,且 looper 在等 } // 与 wake 的协作 private void concurrentWake() { if (incrementMptrRefs()) { try { nativeWake(mPtr); } finally { decrementMptrRefs(); } } }
// 代码位置 MessageStack,所有线程都可以调用 public int moveMatchingToFreelist(Message.MessageCompare compare, Handler h, int what, Object object, Runnable r, long when) { Message current = (Message) sTop.getAcquire(this); Message prev = null; Message firstRemoved = null; int numRemoved = 0; while (current != null) { if (messageMatches(current, compare, h, what, object, r, when) && current.markRemoved()) { if (firstRemoved == null) { firstRemoved = current; } current.clearReferenceFields(); // nextFree links each to-be-removed message to the one processed before. current.nextFree = prev; prev = current; numRemoved++; } current = current.next; } if (firstRemoved != null) { Message freelist; do { freelist = mFreelistHeadValue; firstRemoved.nextFree = freelist; // prev points to the last to-be-removed message that was processed. } while (!sFreelistHead.compareAndSet(this, freelist, prev)); } return numRemoved; } // looper 准备派发的消息已被并发删除 if (found != null && !peek) { if (!found.markRemoved()) { continue; // 别人已经把它标记为删除,重新找下一条 } mStack.remove(found); } // looper 线程 MessageStack.pop(looper 线程): if (!m.markRemoved()) { return null; // 别人已经标记了,我让出 }
最后我们总结一下新的设计的整体流程: 拿插入序号(原子 getAndAdd,1 条 CPU 指令) -> 写消息字段(不用同步控制) -> mStack.pushMessage(Treiber stack CAS push。失败重试,平均 1–2 次 CAS) -> 唤醒决策循环(读 mWaitState,CAS 写新状态) -> 可能调 concurrentWake。完成整体 pushMessage 操作,全程没有 synchronized 关键字,最坏情况也只有几次 CAS retry,最快路径 0 次内核调用,大大减轻了系统负担。
整篇文章其实不止写了 lock-free 数据结构的设计,其余还有很多,比如 Treiber stack、比如如何利用双链表机制是让 Looper 在线程内高效地把某个节点从 stack 链中摘掉。还有 Google 如何利用 Perfetto 和 PerfettoSQL 进行大量的 trace 分析,确认问题以及修复问题后的验证。可以说这篇文章中的每一部分都可以拿出来单独写一篇比较好的操作指南针,也可以看出 Google 在针对 MessageQueue 的修改上是有多么的慎重,以及在这种多线程上的恐怖控制力,可以说这是一篇值得所有人反复阅读的文章。
@ChengzhiHuang: sqlite 是常见的端用存储,一般也都会辅以开启 Write-Ahead Logging(WAL) 模式提升性能。对于一些低存储用户,我们还会辅以开启 incremental_vacuum 定期整理 .-wal 文件进一步减少磁盘占用(注:直接使用 vacuum 是不被推荐的,但是如果数据库本身已经存在,则必须先执行一次完整的 vacuum 才能开启 incremental_vacuum,因此最好是新建的时候默认打开)。本文对 incremental_vacuum 进行了进一步的细分,研究了配置不同的阈值(每次清理的页数)下,整体数据库的表现。大家可以参考自己数据库的实际情况选择不同阈值分批 incremental_vacuum 。
同时提醒大家记得在 incremental_vacuum 完成后再手动进行 checkpoint 才能有效减少磁盘占用,不然只是缩小了 free pages 的数量。
@阿权:本文作者在开启 Swift InferSendableFromCaptures(SE-0418)特性后,遇到 SwiftUI 导航修饰器传递视图构造器函数引用的 Actor 隔离警告的问题。根本原因是:警告只是 Swift 5 迁移模式下的一个产物,升级到 Swift 6 后并不是问题。怎么理解呢?
InferSendableFromCaptures)来提前测试 Swift 6 的行为时,它可能会暴露一些问题,但由于底层的检查模型仍是旧版的,所以会产生一些在最终模型中并不存在的“过渡性警告”。如何去一步步找到问题的根因也是文章的重点,通过作者的探索也能给到我们一些开发实践的建议:
-enable-upcoming-feature 等标志在旧语言模式下测试新特性时,要意识到看到的警告可能带有“过渡性”特征,需要结合最终的语言模型来理解其真正含义。@Barney:这篇文章系统梳理了 Swift 里 lazy 属性的行为边界,重点不是语法本身,而是它在 SwiftUI 里的常见误用。作者先回顾了 lazy 适合解决的几类问题:延迟昂贵初始化、缓存只需计算一次的结果,以及依赖 self 的初始化;随后指出一个很容易踩的坑:SwiftUI 的 View 是值类型且会频繁重建,而 lazy 首次访问时需要发生写入,这使它既不适合作为稳定缓存,也无法直接放进 body 所依赖的视图属性里。文章给出的实践建议也很明确:在 SwiftUI 中优先用 @State、@StateObject 或对象持有者管理生命周期,把 lazy 留给 class、service、formatter 或计算代价较高的缓存对象。对经常在 SwiftUI 中做性能优化的同学很有参考价值。
@DylanYang:作者基于 SwiftUI 的 PreferenceKey 与锚点系统,实现了一款可复用的引导组件,无需依赖 UIKit 即可完成视图高亮、圆角镂空遮罩、自适应提示卡片展示与多步骤平滑动画切换。该组件适配导航栈、滚动视图、安全区、弹窗等各类场景,通过 tutorialSpotlight modifier 和 tutorialSpotlightSource modifier 即可快速接入,还支持自定义高亮内边距、圆角、背景点击关闭等配置,能便捷搭建完整的界面引导流程。感兴趣的开发同学可以阅读下具体的实现过程。
@david-clang:SwiftPM 长期受限于 Git 全量克隆导致的解析缓慢与磁盘占用过大。受此影响的 Ordo One 公司提交了优化提案 (PR #9870),通过引入源码归档下载路径实现大幅优化。该方案能保持 Public API 不变,且无需开发者修改 Package.swift 。其核心流程如下:
git ls-remote --tags —— 发现可用版本(无新增 API,与现有机制一致)。Package.swift —— 检查工具版本(Tools Version)的兼容性。降级机制 :
git clone --mirror)。基准测试与性能收益:
swift-composable-architecture (TCA)、SwiftLint 等不同规模(9 至 67 个依赖项)的知名开源项目。分别对比新旧方案在冷解析(清空 .build 与全局缓存,模拟 CI 环境)和热解析(保留全局共享缓存,模拟本地开发)下的耗时与磁盘增量。.build/ 目录的磁盘占用平均锐减 3 倍(例如,某重度依赖项目的体积由 1.8GB 缩减至约 600MB)。重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考
具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)
我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。
关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参
同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom 。
🚧 表示需某工具,🌟 表示编辑推荐
预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)
claude setup-token 命令,得到一个 sk 开头的 key安装完成后跳到下面的「配置 Secrets 和 Workflow」章节。
适用场景:组织策略不允许装第三方 App、需要更严格的权限控制、使用 AWS Bedrock / Vertex AI。
create-app.html
.pem 文件(妥善保管)| 权限类别 | 权限项 | 级别 |
|---|---|---|
| Repository permissions | Contents | Read & Write |
| Repository permissions | Issues | Read & Write |
| Repository permissions | Pull requests | Read & Write |
| Account permissions | 无 | — |
.pem 文件进入 repo → Settings → Secrets and variables → Actions → New repository secret
| Secret 名称 | 值 |
|---|---|
ANTHROPIC_API_KEY |
你的 Anthropic API Key(sk-ant- 开头) |
CLAUDE_CODE_OAUTH_TOKEN(可选,替代上一条) |
用 claude setup-token 生成的 OAuth Token |
APP_ID(自定义 App 才需要) |
App 设置页里的 App ID |
APP_PRIVATE_KEY(自定义 App 才需要) |
.pem 文件的完整内容 |
⚠️ 绝对不要把 API Key 写在代码里,只通过 Secrets 引用。
ANTHROPIC_API_KEY和CLAUDE_CODE_OAUTH_TOKEN二选一即可,下面示例以 API Key 为主,OAuth 用法是把anthropic_api_key:换成claude_code_oauth_token:。
在 repo 中创建 .github/workflows/claude.yml:
如果用官方 Claude App:
1 |
name: Claude Assistant |
如果用自定义 GitHub App:
1 |
name: Claude with Custom App |
if 用 actor 白名单(github.actor == '你的用户名'),不要只用”排除 bot”的黑名单if 再叠一层 contains(<事件正文>, '@claude') 判断——没有触发词直接 skip,省 Action 额度也避免被误触include_comments_by_actor 同步设置用户白名单(注意:它只过滤评论,不一定覆盖 issue body / PR description,所以上面两条 if 校验是必须的纵深防御)permissions: 块按需最小化授权——只读场景 contents/pull-requests/issues 都给 read 即可;要提 PR 才给 write
claude_args 里 Bash 不要裸开——用 Bash(git:*),Bash(gh:*),... 这种命令前缀白名单收紧allowed_bots 保持默认空值(不要设 *)show_full_output 保持默认 false${{ secrets.XXX }} 引用,不硬编码permissions 里授予的全部能力——所以前两条最关键配置完成后,在 issue 里评论 @claude 你好,如果一切正常,Claude 会在几秒内回复。
我在 App Store 搜「专注计时」,前十名的截图几乎一模一样:一个大圆圈倒计时,白底或深色背景,偶尔配个绿色进度环。点进去功能也差不多,计时、响铃、记录时长,结束。
作为开发者我看这些截图的第一反应是:这赛道已经死了吗?还是说用户根本不在意差异化?
我选择赌后者不成立。声境护照的核心假设是:计时工具留不住人,不是因为功能不够,而是因为「完成一次专注」这件事本身没有叙事——没有积累感,没有值得回头看的东西。所以我把每次专注包装成一段「声音旅行」:选声景、积里程、攒护照印章,结束后拿一张可以发朋友圈的战报卡片。
下面聊几个做这个 App 时真正踩过坑的技术决定。
探险章节的数据模型是我返工次数最多的部分。
早期我把任务进度直接存在 definition 结构里,targetValue、progress、completedAt 全塞一起。有次用户完成任务后触发回写逻辑,targetValue 被意外覆盖成了 0——因为写进度的地方用了同一个赋值路径——任务直接从列表里消失了。用户以为是 bug,其实是数据结构没设计好。
后来拆成了「定义层」和「状态层」两套结构:
// 定义层:静态配置,只读,不随用户行为变化
struct ExpeditionMissionDefinition: Identifiable, Codable {
let id: String
let title: String
let kind: ExpeditionMissionKind // sessionCount / focusMinutes / deepFocusCount
let targetValue: Int
let rewardMiles: Int
}
// 状态层:只存进度和完成时间戳
struct ExpeditionMissionState: Identifiable, Codable {
let id: String
var progress: Int
var completedAt: Date? // nil = 未完成
var completed: Bool { completedAt != nil }
}
两层通过 id 关联,定义层只从远端下发,本地不写。这样之后就算服务端更新了任务内容,也不会碰用户本地的进度状态。deepFocusCount 的判定逻辑是单次时长超过 20 分钟且中途没有中断,这个阈值调了四五次才定下来,最开始设的是 15 分钟,太容易达到,用户没有「深度」的感觉。
会话结束后 App 会给一条下一步建议,根据当天计划完成情况和连续打卡天数动态生成:
let streakHint: String
if store.streakDays >= 5 {
streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
} else if store.streakDays >= 2 {
streakHint = "再坚持 1-2 天可进入稳定习惯区。"
} else {
streakHint = "建议先连续 3 天完成每天的最低目标。"
}
早期版本阈值是 3/7/14,对应「习惯养成」里常见的节点说法。结果我发现第一个门槛「再坚持 4 天」对新用户压力很大——刚用第一天,看到这句话心理上已经开始算成本了。
改成 2/5 之后,第一个提示变成「再坚持 1-2 天」,心理距离近了很多。我没有大样本数据来证明这个改动有多少提升,但从我自己用和几个测试用户的反馈来看,看到「1-2 天」比看到「4 天」更容易当天再开一次计时。
说白了就是:第一个里程碑要近到「今晚就能拿到」。
新用户第一次打开,专注记录为空,统计页一片白——这是工具类 App 最难看的冷启动体验。
处理方式是:focusLogs 为空时用 StatsService.createDemoFocusLogs() 生成假数据填充,同时打 isDemo: true 标记,UI 层显示「这是示例数据」的提示。几乎所有 ViewModel 都是这一行:
private var logs: [FocusLog] {
store.focusLogs.isEmpty
? StatsService.createDemoFocusLogs()
: store.focusLogs
}
这个方案有个明显缺陷:demo 数据是静态写死的,不会根据时区或当前时间调整,周一打开看到的「本周统计」热力图和周五打开是一样的。这是我已知的技术债,下个版本会改成基于当前时间动态生成。但在真实数据出现之前,给用户看一个「满血状态」的统计页,比空白页的跳失率要低——这是我在几个类似工具上观察到的规律,所以先凑合用着。
会话结束后可以导出一张数据卡片:时长、效率指数、声景名、里程和等级称号。这个功能我试了三个方案。
Core Graphics 手绘:可控性最高,但每次改卡片样式要同时维护 UI 代码和绘制代码两套,改了一个忘了另一个,有次导出的卡片和 App 里显示的样式差了半个版本,挺尴尬的。
WKWebView 渲染 HTML:样式灵活,服务端可以随时更新模板,但首次渲染有明显延迟,用户点「生成卡片」之后要等将近一秒才出图,这个等待感在分享场景里特别割裂。
最后选了 SwiftUI 视图截图:UI 和卡片共用同一套组件,改一处两边同步,维护成本低。代价是在部分低端设备上截图后文字抗锯齿发虚,看起来不如原生渲染清晰。我接受这个取舍——大多数用户用的是近三年的机型,发虚的问题不常见。
App 现在刚上线,还在冷启动阶段。我目前卡在一个判断上:「护照 + 里程」这套叙事,对重度效率用户来说会不会显得幼稚?
我身边用这类工具的人大概分两种:一种要的是纯粹的效率,恨不得界面越简单越好;另一种喜欢打卡晒图,仪式感对他们来说本身就是动力。声境护照明显是为第二种人做的,但我不确定第一种人会不会因为「太花哨」直接关掉。
你们做工具类 App 的时候,怎么处理这两类用户的需求冲突?或者说,你们自己用专注工具,更看重哪一面?
![]()
凌晨三点,楼里只剩空调低鸣。林屿坐在工位前,盯着
SwiftUI里的List,像盯着一个多年的老朋友。这个老朋友不坏,甚至称得上可靠。可今天,他忽然觉得不对劲了。页面能跑,交互也顺,但那层说不清的“高级感”,总像隔着一层雾,伸手能碰到,握住却没有。问题出在哪?他顺着代码往下摸,摸到最后,才发现真正的悬念从来不在样式,而在工具选错了场子。
List 的替代方案每当你打算在 SwiftUI 里做一个可滚动页面时,第一反应往往是用 List。这很正常。List 名声在外,又是系统组件,出手就带着几分正统气息。
![]()
但话说回来,它并不总是最合适的选择。
List 最擅长的,是展示那种整齐划一的统一数据。比如邮箱列表、待办事项、联系人,这类内容结构规整,节奏统一,List 处理起来得心应手。
可如果不是这种场景,画风就变了。
对于其他更灵活、更讲究布局和视觉层次的页面,ScrollView 搭配 lazy stack,几乎总是更好的方案。
![]()
这篇文章要讲的,就是如何在 SwiftUI 中构建一个自定义的可滚动容器,让我们对 look and feel 拥有真正精细、可控的掌握力。
ScrollView 这几年,已经不是昨日黄花先说一句实在话。
过去几年里,SwiftUI 对 ScrollView + lazy stacks 的性能做了相当大的改进。它早已不是那个“能用,但心里发毛”的角色。今天的它,已经足够稳,足够快,也足够灵活。
![]()
所以,如果你展示的不是那种几十万条统一数据,比如邮箱、待办清单这种超大规模列表,那么:
ScrollViewis a way to go.
这句话轻描淡写,实际上意味深长。
它的意思不是 “List 不行”,而是:
如果你的页面不依赖大规模统一数据的复用机制,那你就没必要把自己绑死在 List 上。
工具有长处,也有边界。看不见边界,迟早吃亏。
CardioBot 的现状:已经不错,但还不够狠这是林屿自己独立开发的 CardioBot app。
![]()
上面有 4 张截图:前两张是当前版本,后两张是林屿想达到的效果。
现在这款 app 使用的是标准 List。而且说句公道话,它现在的界面观感并不差,作者自己也很喜欢它目前的 look and feel。
但人一旦开始较真,就回不了头。
![]()
林屿决定重新审视自己的 UI。目标并不激进,不是要把界面改得面目全非,而是要做到两件事:
这类优化最难。它不是“重做”,而是“进一寸”。可往往,真正拉开差距的,就是这一寸。
List 已经不再对味了CardioBot 展示的是不同类型的健康指标。问题在于,这些内容不是统一数据集,而是一组风格不同、职责不同的内容块。
林屿用了多种 card 类型,比如:
HeroCardTintedCardRegularCard看到这里,症结就露出来了。
如果数据并不统一,那么使用 List 去做 cell recycling,其实就没多大意义。List 的一身本事,主要是为海量、统一、标准化的数据而生。可这里是一桌散席,不是整齐列队。
![]()
林屿当然也试过继续依赖 List。
他可以通过一些 list-specific view modifiers 做出接近目标的样子,比如:
listRowBackgroundlistItemTintlistRowInsets它们在 List 内部确实很好使,像一把趁手的短刀。
可惜,刀再锋利,也有出鞘范围。
这些 list-specific view modifiers 一旦离开 List view,立刻失灵。也就是说,这些能力是 List 私有的,不可外借。
![]()
结果就是:
你想在 List 之外维持相同风格,就必须额外补样式。补来补去,补成了拆东墙补西墙,最后不是代码发虚,就是视觉跑偏。
这就不是“能不能做”的问题了,而是“做得值不值”。
Container View APIs
幸运的是,SwiftUI 后来引入了 Container View APIs。
这套 API 看起来安安静静,实际上杀伤力很大。它允许我们把 SwiftUI 视图先拆解,改点东西,再重新组合回来。
这意味着什么?
![]()
意味着你不再只是“使用容器”,而是可以“制造容器”。
你可以借助 Container View APIs 构建可复用的容器视图,像 List、Form,甚至任何高度自定义的东西。
说穿了,这是一种权限的变化。
以前你在用系统给的积木。
现在你很Happy的开始自己烧砖。
ScrollingSurface
由于林屿的 app 中每个页面都采用 ScrollView 加 lazy stack,所以他提炼出了一个统一类型:ScrollingSurface。
public struct ScrollingSurface<Content: View>: View {
public enum Direction {
case vertical(HorizontalAlignment)
case horizontal(VerticalAlignment)
}
let direction: Direction
let spacing: CGFloat?
let content: Content
public init(
_ direction: Direction = .vertical(.leading),
spacing: CGFloat? = nil,
@ViewBuilder content: () -> Content
) {
self.spacing = spacing
self.direction = direction
self.content = content()
}
public var body: some View {
switch direction {
case .horizontal(let alignment):
ScrollView(.horizontal) {
LazyHStack(alignment: alignment, spacing: spacing) {
content
}
.scrollTargetLayout() // 告诉滚动系统:这里是目标布局区域
.padding()
}
case .vertical(let alignment):
ScrollView(.vertical) {
LazyVStack(alignment: alignment, spacing: spacing) {
content
}
.scrollTargetLayout() // 垂直方向同理
.padding()
}
}
}
}
他的意思很直接:ScrollingSurface 本质上就是对 ScrollView 和 LazyVStack / LazyHStack 的一个简单包装。根据方向不同,切换成垂直或水平滚动容器。
![]()
但别小看这个“简单”。
因为它做了三件很重要的事:
林屿会把 ScrollingSurface 作为 app 每个页面的 root view。
这不是偷懒,这是定规矩。
![]()
规矩一旦立住,后面的样式和结构才能不乱套。
DividedCard
接下来,UI 里的关键原语出现了:DividedCard。
它最重要的地方,在于使用了 Group(subviews:),这是 SwiftUI Container View API 的一部分。
public struct DividedCard<Content: View>: View {
let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
Group(subviews: content) { subviews in
if !subviews.isEmpty {
VStack(alignment: .leading) {
ForEach(subviews) { subview in
subview
if subviews.last?.id != subview.id {
Divider()
.padding(.vertical, 8) // 在每个子视图之间插入分隔线
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(
.regularMaterial,
in: RoundedRectangle(cornerRadius: 32) // 给整体包上圆角卡片背景
)
}
}
}
}
Group(subviews:) 到底妙在哪?这招很关键。
它允许我们把通过 @ViewBuilder 传进来的视图拆成一个个子视图。
![]()
换句话说,你不再只能把一整坨内容当黑盒来用,而是能看到里面每个子项,并逐个处理它们。
林屿在 DividedCard 里干的事情很漂亮:
subviews
Divider,但最后一个不加结果就是:
一组原本只是“连续排列的内容”,立刻拥有了卡片感、分组感和边界感。
因为很多产品界面都存在这样的结构:
以前你可能要在每个地方重复写 Divider、padding、background、cornerRadius`,写多了就腻,改起来更烦。
![]()
现在不同了。DividedCard 把这套规则提炼成了一个可复用 primitive。
这就是架构的味道:
不是“这页看着对”,而是“以后都能对”。
SectionedSurface
另一个很有意思的 UI primitive,是 SectionedSurface。
public struct SectionedSurface<Content: View>: View {
let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
ForEach(sections: content) { section in
if !section.content.isEmpty {
section.header.padding(.top) // 给 section 的 header 增加顶部间距
section.content
section.footer
}
}
}
}
它使用了 ForEach(sections:),这个能力可以从传入的视图中提取所有的 Section,然后做统一处理。
林屿这里做了两件事:
这看着朴素,实际上很实用。
![]()
因为在真实业务里,section 常常是动态的。
某块有数据,就该显示;没数据,就该消失。
如果每个页面都自己处理一遍这些逻辑,迟早会写成一锅粥。
而 SectionedSurface 把这类规则直接吸收到了容器层。
页面只负责描述内容,容器负责决定组织方式。
这就叫分寸。
代码里有分寸,界面就不会失态。
List 后,NavigationLink 的箭头去哪了?很多人一旦不用 List,很快就会发现少了点什么。
没错,就是 NavigationLink 右侧那个熟悉的小箭头,也就是 chevron。
在 List 中,它会自动出现在 trailing edge,系统帮你安排得明明白白。可一旦离开 List,这个默认样式就没了。
![]()
林屿的办法很干脆:写一个自定义 ButtonStyle。
public struct NavigationButtonStyle: ButtonStyle {
public func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.label
.opacity(configuration.isPressed ? 0.7 : 1) // 按下时微微变淡,增加反馈感
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary) // 补回 List 风格的右侧箭头
}
.contentShape(.rect) // 扩大点击区域,让整行都可点
}
}
extension ButtonStyle where Self == NavigationButtonStyle {
public static var navigation: Self { .init() }
}
这一招的好处在于,它不是临时补救,而是顺势把“导航型按钮”的风格单独抽了出来。
![]()
以后只要写:
.buttonStyle(.navigation)
整页涉及导航的按钮,就能统一表现。
这才像回事。
高手不是把洞补上,高手是顺手把墙也砌直。
SummaryView
下面这段代码,展示了前面这些新原语在 app 中的实际用法。
public struct SummaryView: View {
let summary: SummaryStore
public var body: some View {
ScrollingSurface {
SectionedSurface {
coachSection
activitySection
recoverySection
vitalsSection
heartRateSection
alcoholicBeveragesSection
}
}
.buttonStyle(.navigation) // 统一套用导航按钮样式
}
@ViewBuilder private var activitySection: some View {
Section {
if !summary.metrics.workouts.isEmpty {
DividedCard {
ForEach(summary.metrics.workouts, id: \.workout.uuid) { snapshot in
NavigationLink {
WorkoutDetailsView(snapshot: snapshot)
} label: {
WorkoutView(snapshot: snapshot)
}
}
}
}
} header: {
SectionHeader(
.horizontal,
title: Text("activitySection"),
systemImage: "figure.run"
)
.tint(.orange)
}
}
}
表面上看,它的使用方式和 List API 非常像:
Section
NavigationLink
但底层已经换了天地。
![]()
林屿通过:
ScrollingSurfaceDividedCardSectionedSurfaceNavigationButtonStyle重新拼出了类似 List 的使用体验,同时拿回了对 look and feel 的精准控制。
更妙的是,如果某个页面压根不需要 sections,只要把 SectionedSurface 去掉即可,其余 primitive 仍然能继续复用。
这就说明它们不是页面特供,而是真正的可复用 building blocks。
![]()
到了这一步,已经不是“替代 List”那么简单了。
这是在搭自己的界面语言。
List 非叛逆,懂了取舍是清醒最后,林屿把话说得很准。
在 SwiftUI 里替换 List,并不是要放弃一个强大的组件。它真正要表达的是:
不是背叛
List,而是为场景选择正确的工具。
如果你面对的是大型、统一的数据集,List 依旧是极好的选择,毫无问题。
![]()
但当你的 UI 需要更细致的结构、更独特的样式、更符合产品自身 design language 的表达时,现代 SwiftUI 已经给了我们足够的自由。
借助 ScrollView、lazy stacks 和 Container View APIs,我们不只是可以重建 List 的能力,某些时候甚至能够超越它。
像 ScrollingSurface、DividedCard、SectionedSurface 这样的自定义 primitive,证明了一件事:
真正成熟的 SwiftUI 代码,不只是把视图摆出来,而是把可复用的规则提炼出来。
性能、清晰度、设计语言,三者并行不悖。
这才是正路。
![]()
List,只是看透了它天快亮的时候,林屿合上电脑,办公室的灯光仍旧冷,心里却亮了。
他没有把 List 当成敌人。
也没有为了“自定义”而自定义。
![]()
他只是终于明白:
组件从来不是信仰,它只是工具。
该用 List 的时候,别拧巴;
该用 ScrollView 和自定义容器的时候,也别手软。
很多人写 UI,写到最后,写成了对系统组件的依赖。
可真正厉害的人,写到最后,会慢慢长出自己的容器、自己的规则、自己的语言。
![]()
那一刻,所谓 List replacement,其实已经不重要了。
重要的是,他终于从“会用组件”,走到了“会造秩序”。
而这,才是这篇文章最狠的一刀。
在移动应用开发中,网络层如同人体的循环系统,负责所有数据的吞吐与交换。一个常见的起点是直接使用URLSession或Alamofire发起请求,并在闭包回调中处理响应。然而,随着业务复杂度攀升,这种模式迅速演变为"回调地狱"——深层嵌套的回调、分散各处的错误处理、难以维护的重复代码。更严峻的是,它催生了视图控制器与网络逻辑的紧密耦合,使得单元测试举步维艰,状态管理混乱不堪。本文旨在剖析网络层设计的核心痛点,并探索一条通往清晰、健壮、可测试的声明式数据流架构之路。
让我们从一个典型的用户列表请求开始,它需要处理加载状态、分页、错误展示和最终的数据渲染。传统实现方式将网络请求、状态管理、错误处理和UI更新全部混杂在视图控制器中:
// 传统方式:嵌套回调与分散的状态管理
class UserListViewController: UIViewController {
var users: [User] = []
var currentPage = 1
var isLoading = false
func loadUsers() {
guard !isLoading else { return }
isLoading = true
showLoadingIndicator()
// 直接发起网络请求,处理回调
let url = URL(string: "https://api.example.com/users?page=\(currentPage)")!
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
DispatchQueue.main.async {
// 状态管理、错误处理、数据解析全部混在一起
self?.isLoading = false
self?.hideLoadingIndicator()
if let error = error {
self?.showErrorAlert(message: error.localizedDescription)
return
}
// 更多嵌套处理...
}
}.resume()
}
}
这种模式暴露了多个架构问题:状态管理脆弱、错误处理重复、业务逻辑耦合、可测试性差。当应用需要添加请求重试、缓存、日志等功能时,每个网络请求都需要重复修改,维护成本急剧上升。
更深层次的问题在于,这种紧耦合的设计违反了单一职责原则。视图控制器本应专注于UI呈现和用户交互,却被赋予了过多与网络相关的职责。这种架构上的缺陷会导致代码的"技术债"快速积累,随着功能增加,代码的可读性和可维护性急剧下降。
解决上述问题的第一步是分离关注点。我们应构建一个独立的基础网络层,其核心职责是接收请求配置,发起网络调用,并返回标准化响应。下图展示了分层网络架构中各层的职责与数据流向:
![]()
通过引入Combine框架的Publisher,我们将异步回调转换为声明式的数据流。基础网络层现在只负责最纯粹的HTTP通信,为上层构建提供了稳定的基石:
// 基础网络服务协议
protocol NetworkServiceProtocol {
func perform(_ request: NetworkRequest) -> AnyPublisher<NetworkResponse, NetworkError>
}
这种分层设计的核心优势在于每一层都有明确的职责边界。基础网络层专注于HTTP协议的实现,中间件层处理横切关注点,API客户端层负责业务逻辑与网络协议的转换,业务服务层则封装具体的业务领域逻辑。这种清晰的边界使得每一层都可以独立演化、独立测试,大大提升了系统的可维护性。
一个健壮的网络层需要处理横切关注点,例如自动添加认证令牌、统一日志记录、响应缓存、网络状态监测等。中间件模式是解决此问题的优雅方案。
中间件是一个在请求发出前和收到响应后能够介入处理的管道组件。下图展示了中间件在请求/响应流程中的位置和作用:
通过串联多个中间件,我们可以形成灵活的处理管道。例如,认证中间件自动为需要认证的请求添加Token,错误处理中间件检查401状态码并触发Token刷新流程。这种设计使得横切逻辑模块化且可插拔,极大提升了代码的可维护性和可测试性。
统一错误处理是另一个关键。我们应定义业务相关的错误类型,并在网络层与业务层之间建立清晰的错误转换层:
enum APIError: Error, LocalizedError {
case networkUnreachable
case requestTimeout
case serverError(message: String)
case clientError(code: Int, message: String)
case unauthorized
// ... 其他错误类型
var errorDescription: String? {
// 提供用户友好的错误信息
switch self {
case .networkUnreachable: return "网络似乎断开了,请检查连接"
case .requestTimeout: return "请求超时,请稍后重试"
case .serverError(let message): return "服务器开小差了: \(message)"
case .clientError(_, let message): return message
case .unauthorized: return "登录已过期,请重新登录"
default: return "发生未知错误"
}
}
}
这种统一的错误处理机制确保了整个应用对错误有一致的处理方式,无论是网络层错误、业务逻辑错误还是数据解析错误,都能通过统一的接口暴露给上层,使得错误处理逻辑可以集中管理,而不是分散在各个视图控制器中。
最终,网络层需要优雅地服务于业务层和表现层。在MVVM或类似架构中,ViewModel应通过声明式数据流驱动UI。这种模式带来了根本性转变:UI成为状态的被动反映。
class UserListViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let userService: UserServiceProtocol
func loadUsers() {
isLoading = true
errorMessage = nil
userService.fetchUsers(page: 1)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
}, receiveValue: { [weak self] newUsers in
self?.users = newUsers
})
.store(in: &cancellables)
}
}
在视图控制器中,我们只需观察ViewModel的状态变化:
private func bindViewModel() {
viewModel.$users
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.tableView.reloadData()
}
.store(in: &cancellables)
viewModel.$isLoading
.receive(on: DispatchQueue.main)
.sink { [weak self] isLoading in
isLoading ? self?.showLoading() : self?.hideLoading()
}
.store(in: &cancellables)
}
这种声明式绑定彻底解耦了网络逻辑与视图控制器,使代码更易于测试和维护。网络请求的状态(加载中、成功、失败)通过ViewModel的@Published属性单向流动到UI,实现了清晰的数据流管理。
下图展示了声明式数据流在MVVM架构中的完整工作流程,从用户交互到网络请求,再到UI更新的完整闭环:
这种架构的最大优势在于其可预测性。由于数据流是单向的,我们可以清晰地追踪状态变化的来源和去向。当出现问题时,调试也变得相对简单——我们只需要关注状态是如何变化的,而不是在复杂的回调嵌套中寻找问题。
网络层的演进,是从"如何发起请求"到"如何管理数据流"的思维跃迁。通过分层设计,我们分离了HTTP通信、横切逻辑和业务转换;通过中间件模式,我们实现了关注点分离与功能可插拔;通过声明式数据流,我们创建了可预测、可测试的状态驱动UI。
这种架构演进不仅仅是技术实现的变化,更是开发思维的转变。它要求我们从"命令式"的思维方式转向"声明式"的思维方式,从关注"如何做"转向关注"是什么"。这种转变带来的好处是深远的:代码更加清晰、测试更加容易、维护成本大幅降低。
一个优秀的网络层不仅是技术的实现,更是架构思想的体现。它像一条精心设计的高速公路,确保数据安全、高效、可靠地抵达目的地,同时为未来的扩展——如离线缓存、实时同步、性能监控——预留了接口。当网络层稳固如磐石,开发者便能更专注于创造业务价值,而非深陷于回调的泥潭。
在 iOS 26 上,视频播放器使用的 libass 字幕渲染器遭遇了严重的兼容性问题。当字幕指定的字体在系统中找不到时,libass 的 CoreText 后端会尝试 fallback 到系统字体路径:
/System/Library/PrivateFrameworks/FontServices.framework/CorePrivate/PingFangUI.ttc
然而,这个路径在 iOS 26 的沙盒机制下被系统拦截,导致 fallback 失败,内嵌 ASS/SSA 字幕中的中文字符完全无法渲染。用户看到的只是一片空白或乱码。
| 字幕类型 | 问题描述 |
|---|---|
| 内嵌 ASS/SSA | 中文字符完全不显示,字体 fallback 失败 |
| 外挂 ASS/SSA | 某些字体无法渲染,fallback 到被拦截的路径 |
| SRT(内嵌提取后) | 带有 <font face="xxx"> 标签的 SRT,freetype 尝试加载指定字体失败 |
iOS 26 引入了一个沙盒安全限制,阻止了 libass 对系统字体的访问。libass 的字体回退机制无法获取 PingFang 字体,导致整个字幕渲染失败。
视频播放
↓
禁用内嵌字幕(避免 libass 走系统字体路径)
↓
提取内嵌字幕流为 SRT(通过 FFmpeg)
↓
SRT 无字体定义,通过 freetype 渲染器 + 指定中文字体显示
↓
同时对外挂 ASS 字幕做字体名替换(指向 CoreText 已注册的字体)
通过 FFmpeg 提取视频中的字幕流,转换为无字体定义的 SRT 格式:
// 使用 FFmpeg 提取字幕流
FFmpegWrapperAPI *ffAPI = [[FFmpegWrapperAPI alloc] init];
ffAPI.inputPath = videoPath;
// -map 0:s:N 选择特定字幕流
NSString *command = [NSString stringWithFormat:@"-map 0:s:%d", trackIndex];
[ffAPI runFFmpegAPI:videoPath
outputPath:srtOutputPath
prefix:nil
command:command
async:YES];
ASS 字幕中的字体名出现在两处:
[V4+ Styles] 定义行:Style: Name,Fontname,Fontsize,...
[Events] Dialogue 行内覆盖标签:{\fn字体名}
// 替换 Dialogue 行内的 {\fn任意字体名} 覆盖标签
NSRegularExpression *fnTagRegex = [NSRegularExpression
regularExpressionWithPattern:@"\\{\\\\fn[^}\\\\]+"
options:NSRegularExpressionCaseInsensitive
error:®exError];
modifiedText = [fnTagRegex stringByReplacingMatchesInString:modifiedText
options:0
range:NSMakeRange(0, modifiedText.length)
withTemplate:[NSString stringWithFormat:@"{\\fn%@", kTargetFontName]];
// 替换 Style 定义行的 Fontname 字段
NSRegularExpression *styleLineRegex = [NSRegularExpression
regularExpressionWithPattern:@"^(Style\\s*:\\s*[^,]+,)([^,]+)(,.*)$"
options:0
error:®exError];
// ...
SRT 字幕中的字体名出现在 HTML 标签中:
// 替换 <font face="任意内容">
NSRegularExpression *fontFaceRegex = [NSRegularExpression
regularExpressionWithPattern:@"<font\\s+face\\s*=\\s*([\"'])[^\"']*\\1"
options:NSRegularExpressionCaseInsensitive
error:®exError];
问题描述:用户选择中文内嵌字幕,但实际显示的是英文字幕。
根因分析:
videoSubTitlesIndexes 数组索引 0 是 "Disable"
0,第二条是 1
错误的映射:
用户选择 VLC 索引 1(第一条字幕)→ 错误地映射为 FFmpeg 索引 1 → 提取了第二条字幕
代码修复:
// 修复前(错误)
int ffmpegTrackIndex = trackIndex + 1;
// 修复后(正确)
int ffmpegTrackIndex = (int)subtitleIndex - 1;
// VLC 索引 1 → FFmpeg 索引 0
// VLC 索引 2 → FFmpeg 索引 1
日志验证:
[updateSubtitleUrl] iOS 26 拦截内嵌字幕: VLC subtitleIndex=1,
→ FFmpeg trackIndex=0 // 修复后正确映射到第一条字幕流
<font> 标签问题问题描述:内嵌字幕提取为 SRT 后,部分 SRT 仍无法显示中文。
日志分析:
[ExtractSub] SRT 前 200 字:
1
00:00:00,000 --> 00:00:03,018
<font face="方正准圆简体" size="21"><b>...
根因分析:虽然 FFmpeg 提取时没有字体定义,但某些视频的字幕流本身已包含 <font face="xxx"> 标签。这些标签导致 VLC 的 freetype 渲染器尝试加载指定字体,同样失败并 fallback 到被拦截的路径。
修复:在加载 SRT 前,批量替换所有 <font face="xxx"> 标签:
NSString *srtText = [NSString stringWithContentsOfFile:srtUrl.path encoding:NSUTF8StringEncoding error:nil];
NSString *replaced = [self replaceSrtFontNamesInText:srtText];
[replaced writeToFile:srtUrl.path atomically:YES encoding:NSUTF8StringEncoding error:nil];
问题描述:硬编码的字体名列表无法覆盖所有可能出现的字体名。
原方案:
NSArray *fontNamesToReplace = @[
@"微软雅黑", @"微软雅黑", @"SimHei", @"SimSun",
@"黑体", @"宋体", @"楷体", // ...
];
问题:总有漏网之鱼,如 方正准圆简体、Noto Sans CJK SC 等。
改进方案:正则 + 字符串匹配兜底
正则覆盖任意字体名,字符串匹配处理边缘情况:
// 正则:替换所有 {\fn任意字体名} → {\fnSource Han Sans CN}
// 兜底:字符串匹配常见字体名
modifiedText = [self fallbackReplaceFontNamesInText:modifiedText];
┌─────────────────────────────────────────────────────────────────┐
│ iOS 26 字幕兼容架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ handleiOS26 │ │ updateSubtitleUrl │ │convertSubtitle│ │
│ │ SubtitleOn │ │ (内嵌拦截) │ │ (外挂处理) │ │
│ │ Playing │ │ │ │ │ │
│ └──────┬──────┘ └──────┬────────┘ └──────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ @available(iOS 26.0, *) 守卫 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 禁用内嵌字幕 │ │ FFmpeg 提取 │ │ 字体名替换 │ │
│ │ (libass) │ │ SRT │ │ (正则+兜底) │ │
│ └──────────────┘ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ freetype 渲染 │ │
│ │ + 指定中文字体 │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
在真实业务里,UI 自动化往往卡在几类问题上:
近期探索方向是:用系统无障碍(Accessibility)能力看见界面,用 命令行工具 驱动模拟器;把写脚本交给 AI,把测什么、对不对交给人(其实交给AI应该也可以)。
这样可以把自动化从少数工程师专属(如测试开发岗位)拉回到测试与交付都能参与的节奏里。
iOS 为视障用户暴露的无障碍信息,会在系统侧形成一棵无障碍树:控件文案、(若开发配置)唯一标识、大致几何信息都可以被读取。类比前端:界面是渲染结果,无障碍树更接近可被程序消费的语义结构。
在命令行驱动模拟器这一层,目前AXe 在易用性、能力完整度和可脚本化程度上综合表现最好,因此方案明确为 无障碍树 + AXe:
除下文分条说明外,这里想单独强调一点:编写与排障时,describe-ui 拉起的无障碍树仍是最快、成本最低的定位与断言手段;但在结构复杂的原生页面,或 WebView / H5 等无障碍信息不完整、控件不可见的场景里,完全可以把 AXe 截屏交给 AI 分析——既可用于结果校验(布局是否异常、关键视觉元素是否出现),也可在 AI 协助下从画面反推点击坐标 / 热区,再固化为 touch、像素级辅助脚本等步骤。相比过去「只能死磕无障碍树或完全依赖人工看图写坐标」的传统做法,可选路径更多:树优先、截图与 AI 作补充;人工判断与模型辅助看图可以组合使用,而不必二选一。
传统模式下,测试同学往往要先补编程与框架知识;AI 辅助时,自然语言 + 页面结构文本即可闭环迭代:
sequenceDiagram
participant QA as 测试/业务
participant AI as AI 助手
participant SIM as 模拟器 + CLI
QA->>AI: 用自然语言描述端到端流程与验收点
AI->>SIM: 按需读取无障碍树(describe-ui 或等价能力)
SIM-->>AI: 返回页面结构文本
AI-->>QA: 交付可执行脚本(.steps / Shell 等)
QA->>SIM: 本地执行脚本
SIM-->>QA: 某步失败或状态不符
QA->>AI: 反馈失败步骤 + 当前页面结构文本
AI->>SIM: 必要时再次拉取树或调整定位策略
AI-->>QA: 修改后的脚本
Note over QA,SIM: 人负责测什么、怎样算对;AI负责怎么点、怎么判、怎么改
降低的难度:不必从零掌握语法与定位细节,把翻译为可执行步骤外包给模型。
同一类问题(例如点不到、断言失败),用文本无障碍树通常比反复传图更省、更稳:
flowchart LR
subgraph fail["脚本失败 / 状态异常"]
A["失败步骤 + 上下文"]
end
subgraph pathText["推荐:文本路径"]
T1["拉取无障碍树输出"]
T2["grep / 条件分支 / 贴给 AI 分析"]
T3["改 label / id / 等待 / 分支逻辑"]
end
subgraph pathImg["必要时:视觉路径"]
I1["截图"]
I2["人工或 AI 看布局 / H5 等"]
I3["改坐标或视觉辅助逻辑"]
end
A --> T1
T1 --> T2
T2 --> T3
A -.->|"仅当树不够用"| I1
I1 --> I2
I2 --> I3
降低的难度:排障从猜界面加大量截图对话变成结构化文本 diff,更适合日常高频使用。
把 token 与人力集中在编写与改版修复,执行阶段不依赖模型:
flowchart TB
subgraph once["一次性 / 低频"]
W1["新流程:描述需求"]
W2["AI 生成首版脚本"]
W3["人确认可重复跑通"]
end
subgraph daily["高频:回归执行"]
R1["CI 或本地直接跑脚本"]
R2["零模型调用"]
end
subgraph rare["偶发:UI 改版"]
U1["脚本失效"]
U2["贴新无障碍树 + 失败信息"]
U3["AI 小步修补"]
end
W1 --> W2
W2 --> W3
W3 --> R1
R1 --> R2
R1 --> U1
U1 --> U2
U2 --> U3
U3 --> R1
降低的难度:把自动化从持续烧对话/烧图变成可沉淀的脚本资产,更容易在团队里推广。
经验法则:默认仍以 describe-ui 无障碍树为主;遇到复杂原生页、Web 页树信息不足时,再用 AXe 截图 + AI 做结果校验或反推坐标,与「只靠树或只靠人眼」相比,路径更灵活。
按复杂度递进,避免一上来就做大而全框架:
.steps):适合线性、无分支的流程,结构简单、可读性强。该小节展示目前已经在工程中应用的案例。
辅助 QA 验证某类资源是否生效——从打开 App,进入资源相关页面并选用资源,再进入另一处资源应用页面触发使用,最终以 截图呈现结果,形成可重复结论(中途可配合 describe-ui 做关键状态断言)。
flowchart TD
A[启动并进入 App] --> B[进入资源入口页]
B --> C[选用目标资源]
C --> D[进入资源应用页]
D --> E[触发资源使用]
E --> F[无障碍树断言关键状态]
F --> G[截图固化结果]
落地要点:关键路径优先 accessibilityIdentifier 或稳定 label;WebView 区域用 touch 或坐标兜底;异步生效处加重试或等待;截图偏重最终留档与对非研发可读的佐证,日常仍以无障碍树文本断言为主。
swift是面向协议编程,果然名不虚传
IteratorProtocol 协议
public protocol IteratorProtocol<Element> {
associatedtype Element
mutating func next() -> Element?
}
这样所有遵守了IteratorProtocol协议的类型,都是可以使用next方法的,这已经很完美了。但是!迭代器只能消费一次, 这里举一个不恰当的例子:
let numbers = [10, 20, 30]
// 从序列要一个迭代器(IteratorProtocol)
var it = numbers.makeIterator()
// 一步一步消费
print(it.next() as Any) // Optional(10)
print(it.next() as Any) // Optional(20)
print(it.next() as Any) // Optional(30)
print(it.next() as Any) // nil —— 已经到头了
// 同一个 it 再 next,永远是 nil(状态已经走到结束)
print(it.next() as Any) // nil
print(it.next() as Any) // nil
想再从头遍历一遍,不能指望复活这个it,只能再向序列要一个新的迭代器:
var it2 = numbers.makeIterator()
print(it2.next() as Any) // Optional(10) —— 又从第一个开始
但是这里的makeIterator是sequence协议要求提供的东西,之所以说这个例子不恰当,是因为我似乎在用已经解决的问题去回答问题,这里不应该把sequence牵涉进来。
那么,接下来的例子将非常合适。
struct CountFromTo: IteratorProtocol {
var current: Int
let end: Int
init(from: Int, through: Int) {
current = from; end = through
}
mutating func next() -> Int? {
guard current <= end else { return nil }
defer { current += 1 }
return current
}
}
var it = CountFromTo(from: 3, through: 5)
while let x = it.next() { print(x) } // 耗尽
print(it.next()) //nil,因为之前已经耗尽了
// 不能复活it,只能再来一个新的迭代器实例
var it2 = CountFromTo(from: 3, through: 5)
print(it2.next() as Any) // 又从 3 开始
var it = CountFromTo(from: 3, through: 5)
现在假设我们是swift标准库团队开发人员,要实现Array,我们需要提供给开发者类似以下这些功能
for x in arrarr.map { }、arr.filter { }的功能和别的“能挨个读一遍某个东西”的方法用同一套API下标 arr[i]可以实现“挨个读一遍”的功能,但是正如我们提到的for x in arr / arr.map这种功能,它们只想对每个元素做某事,不需要关心下标。
for x in arr {
print(x)
}
//可以通过这种方式实现
var __iterator = arr.获取iterator()
while let x = __iterator.next() {
print(x)
}
map大致如下
func mapSimple<T>(_ transform: (Element) -> T) -> [T] {
var result: [T] = []
var it = 获取iterator()
while let x = it.next() {
result.append(transform(x))
}
return result
}
可以看出不论实现哪个功能都需要array有一个获取iterator的方法,给这个方法起名叫做makeIterator,也就是说array既要有next方法,又要有makeiterator的方法,我们把这两个方法都放入一个起名为sequence的protocol中,这就是sequence的由来了。Sequence 是 Swift 中最轻量的遍历协议。一个类型只要遵守 Sequence,就能用 for-in 遍历。实现了 Sequence的结构体或类 必须关联一个遵守 IteratorProtocol 的类型,
Sequence 是工厂:生产迭代器
IteratorProtocol 是产品:实际遍历逻辑
所以不能说实现了Sequence就是是实现了IteratorProtocol.
仅仅实现Sequence协议,你的类型就能享受所有Sequence的默认extension方法:map、filter、reduce、contains(Element: Equatable)、reversed。
//Sequence 协议:
protocol Sequence<Element> {
associatedtype Element where Self.Element == Self.Iterator.Element
associatedtype Iterator: IteratorProtocol
func makeIterator() -> Iterator
}
Sequence 够用了吗?Sequence 只保证:能 makeIterator(),按顺序 next() 一个个拿。
适合:for-in、map、filter 等扫一遍的事。
但日常还会遇到:
第 3 个元素是谁?(随机访问某一位)
有多少个?(count)
第一个、最后一个下标怎么表示?
只靠 Iterator:只能往后走,不能跳到中间,也不一定有常数时间的长度概念(有些序列是无限的、或算长度很贵)。
所以要在 Sequence 上再叠一层:能按下标(或索引)访问、有明确首尾——这就是 Collection 的由来。
Collection 在解决什么?在能遍历之上,再约定像容器一样用下标访问的能力。典型能力包括(概念上):
有 startIndex / endIndex
能用 collection[index] 读元素(subscript)
索引可以 index(after:) 往后走(不一定只是 Int + 1,字符串的 Index 就复杂)
往往还能提供 count(有的集合是 O(n) 算出来)
public protocol Collection: Sequence {
associatedtype Index: Comparable
var startIndex: Index { get }
var endIndex: Index { get }
subscript(position: Index) -> Element { get }
func index(after i: Index) -> Index
}
`Collection` 协议**继承自** `Sequence` 协议,因此任何遵守 `Collection` 的类型**自动满足** `Sequence` 的所有要求。
Array 是最典型的 Collection:下标是 Int,从 0 到 count-1。
Sequence ←── 更基础:只保证能遍历
↑
Collection ←── 继承 Sequence,并加:索引 + 下标访问 + …
![]()
在 Swift 里遵守 Collection 只能说明它是可按索引访问的一段序列,不一定是自己拥有一块独立存储的容器。例如Range,遵守RandomAccessCollection属于 Collection 一族
let r = 0..<10
print(r.count) // 10
print(r[r.startIndex]) // 0
这里并没有一个数组在内存里存 0,1,2,...,9;Range 只是用起点、终点描述区间,按需算出元素。它更像区间视图,不是传统意义上的数组那种容器。
本文使用 文章同步助手 同步
朋友小X在一家小公司从事安卓开发工作。
有一天老板想做一个功能。用户能通过前端网页,调用起原生安卓应用支持的功能,如人脸识别等。
前端开发主要使用Javascript进行开发,安卓应用使用Kotlin进行开发。
Javascript是动态语言,Kotlin是静态语言。
动态语言的优缺点
静态语言的优缺点
而小X的公司,没有为前端开发人员提供语法检查工具,只能靠前端开发自查自改。
负责前端网页的妹子是一位新手,写代码经常出现各种语法问题,且不知道如何在Android Webview上进行有效调试。
为了调试,前端妹子只会在有可能出问题的代码,添加alert函数,通过提示框的信息,来进行调试。
使用这种方式进行开发,开发效率非常低下,进度远远落后于计划。
但是老板催着功能赶紧上线,为了尽快上线,小X只能陪着加班,帮助前端妹子排查问题。
陪着妹子加班两天后,小X忍受不了天天要陪加班的状态。
他通过搜索,找到了工具和方法,可让前端在Android Webview高效调试代码。并把方法教给了前端妹子。
前端妹子知道如何用调试工具后,自己可以进行调试,大大提高开发效率,不用小X天天陪着加班了。
刚刚提到的工具,就是Edge + ADB。
ADB是什么?
ADB 的全称是Android Debug Bridge, 是一种功能多样的命令行工具。
ADB 命令可用于执行各种设备操作(例如安装和调试应用),并提供对 Unix shell(可用来在设备上运行各种命令)的访问权限。
现在主流浏览器,包括微软的Edge,还有Android应用自带的Webview,使用的是谷歌出品的浏览器内核。
几年前,遇到类似的问题,只能通过用Chrome浏览器+ADB进行调试。
现在Edge浏览器成了主流,也有和Chrome一样功能,而且不用像Chrome,需下载额外的浏览器插件。
因此,我们的工具选择Edge + ADB。
对于更复杂的调试场景,开发者可以考虑使用专业工具如WebDebugx,它是一款跨平台移动端网页调试工具,提供类似Chrome DevTools的完整调试体验,支持iOS和Android设备远程调试网页和WebView内容,包括网络监控、性能分析和JavaScript控制台集成等功能。
这里以大多数公司常见的Windows系统为例,介绍如何安装及使用。
ADB version
edge://inspect
其他步骤与在电脑上调试网页步骤类似
有些人在做前期准备时,会遇到各种问题,解决方法汇总如下
硬件设置
软件设置
Android设备未显示“允许USB调试”对话框
iOS上架全流程避坑指南速存!
作为一名独立开发者,今天来和大家分享一下将「楼里」这款应用从iOS打包到上架的全流程。iOS打包到上架,对个人开发者来说就像“九九八十一难”,但只要一步步来,也能顺利完成。下面,我会毫无保留地分享每一个关键步骤。
证书申请篇
准备一台Mac电脑:这是前提条件,没有Mac的同学可能需要借力或者购买云服务。
申请苹果开发者账号:费用为688元/年,这是开启iOS开发大门的钥匙。
证书生成:苹果官方提供了Certificates、Identifiers、Profiles的申请流程,建议自己本地生成.p12私钥证书,这样更安全也更方便后续操作。
此外,使用工具如AppUploader可以在Windows、Linux或Mac系统中直接申请iOS证书,无需依赖Mac电脑,简化证书管理流程。
ICP备案篇
准备服务器:有免费和付费的选择,根据自己的需求来。
申请域名:域名价格差异大,好的域名更贵,建议提前规划。
备案流程:选择App备案,分为初审(平台审)和终审(管局审),正常情况下7天内可以通过。全国互联网安全管理服务平台是备案的重要一环,特别是产品功能基本开发完毕后,审核人员会仔细查看产品。如果App/小程序,还需要线下面签;如果是网站,则可以线上完成。
App打包篇
使用UniApp开发,打包工具为HBuilderX,这可以大大提高开发效率。
App图标处理:去掉Alpha通道,确保图标显示正常。
启动界面:创建自定义的storyboard作为启动界面,提升用户体验。
广告标识:去掉使用广告标识(IDFA)的勾选,保护用户隐私。
云打包:使用申请的证书文件进行云打包,用回复的链接下载iOS安装包。
发布上架篇
或者使用AppUploader工具上传IPA文件到App Store,支持在Windows、Mac或Linux系统上操作,无需Mac电脑,比Transporter更高效,且能批量上传应用截图和管理描述信息。
资料准备:准备产品的10张截图、推广文本、描述、关键词等资料,这些都是审核的重要依据。
图标与截图:App图标大小为1024x1024,直角边,确保在各设备上显示效果最佳。
隐私政策与技术支持:提供隐私政策网址 (URL) 和技术支持网址 (URL),可以用github或Notion搭建静态网站,方便用户查看。
App供应情况:按情况填需要上架的地区,确保应用能够在目标市场上线。
整个流程下来,虽然复杂,但只要一步步来,每一个细节都处理到位,就能够成功将应用上架到iOS平台。希望今天的分享能够对大家有所帮助。