阅读视图

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

iOS - 从 @property 开始

核心概念

本质Property 是一组访问器方法的声明 (setter/getter) ,编译器可以自动“合成”「访问器」以及「底层存储(ivar)」,并且允许用点语法调用。

  • 例如:
    @property (nonatomic) NSInteger age;
    
  • 编译器等价(自动合成):
    {
        NSInteger _age; // 可选的“底层存储” (backing ivar)
    }
    - (NSInteger)age { return _age; }              // getter
    - (void)setAge:(NSInteger)age { _age = age; }  // setter
    

好处:统一内存语义(strong/weak/copy...)、线程原子性控制(atomic/nonatomic)、可读性与 KVC/KVO 兼容。


常见属性修饰符

  • 读写性
    • readwrite:可读可写(默认)
    • readonly:只读
  • 原子性
    • atomic:保证“单次访问器调用”的原子性,速度慢。(默认)
      • 注意:atomic慢,且不保证你“对该对象做的一系列操作”是线程安全的;也不保证顺序、事务或对象内部的并发安全,实际场景还是需要显式同步。
    • nonatomic:不做同步,速度快。
  • 内存语义修饰符
    • strong:持有关系,引用计数+1,新值 retain,旧值 release.
      • 场景:一般对象所有权、父持子
    • weak:不持有,引用计数不变,对象释放时指针置空。
      • 场景:避免循环引用,如 delegate,子持父、IBOutlet
      • 注意:访问时可能已经变 nil。
    • copy:生成不可变副本,setter 执行 -(id)copy 方法。
      • 场景:阻止赋值可变对象的后续修改,block入堆。
    • assign:位拷贝,引用计数不变。
      • 场景:用于标量和结构体
      • 注意:对象指针使用 assign 会产生悬垂指针
    • unsafe_unretained:不持有,引用计数不变,对象释放不会置空。
      • 场景:以往无weak可用时使用的。
  • 其他
    • getter=isEnabled/setter=setFoo:指定自定义 setter/getter。
    • class:类属性。

何时存储(背后存储backing ivar的规则)

  • 会有存储
    • 在类的@interface或类扩展里声明@property
    • 没有显式使用@dynamic,且没有同时手写 setter + getter 的。
  • 不会有存储
    • category里声明的@property
    • 使用@dynamic的, 承诺运行时提供访问器的。
    • 已经实现了 getter + setter 的。
    • 协议@protocol里的@property
    • 类属性。
  • 例外和细节
    • readonly 若你实现了 getter,则不会再自动合成 ivar
    • “类属性”没有ivar实例,通常用static或者其他存储来实现存储。
      @interface Config : NSObject
      @property (class, nonatomic, copy) NSString *build;
      @end
      
      @implementation Config
      static NSString *_build;
      + (NSString *)build { return _build; }
      + (void)setBuild:(NSString *)b { _build = [b copy]; }
      @end
      
    • 分类里的属性如何有“存储”?
      • 分类里的属性需要通过关联对象实现存储。
      #import <objc/runtime.h>
      @interface UIView (Badge)
      @property (nonatomic, copy) NSString *badgeText; // 分类里不会有 ivar
      @end
      
      @implementation UIView (Badge)
      static const void *kBadgeKey = &kBadgeKey;
      
      - (void)setBadgeText:(NSString *)badgeText {
          objc_setAssociatedObject(self, kBadgeKey, badgeText, OBJC_ASSOCIATION_COPY_NONATOMIC);
      }
      - (NSString *)badgeText {
          return objc_getAssociatedObject(self, kBadgeKey);
      }
      @end
      

@dynamic@synthesize计算属性

  • @dynamic

    • 作用:告诉编译器,不需要生成访问器和ivar,也不要因为找不到方法而告警。
    • 场景:Core Data 的NSManagedObject子类:
      @interface Book : NSManagedObject
      @property (nonatomic, copy) NSString *title;
      @end
      
      @implementation Book
      @dynamic title; // 访问器由运行时(Core Data)注入;编译器不生成也不报缺实现
      @end
      
  • @synthesize

    • 作用:让编译器为@property生成 getter/setter 以及背后存储 ivar,并把属性名映射到自定义 ivar 名。
  • 计算属性

    • 作用:不依赖存储,按需计算。

Propertyivar 的区别

  1. ivar == 纯存储
  2. property == 访问这个存储的“方法接口”
  3. 大多数情况使用 self.age,在 init/dealloc/自定义访问器内部 常用 _age 直接访问,避免递归等问题。

SwiftUI布局之AnchorPreferences

SwiftUI 中的 AnchorPreferences:连接父子视图的几何桥梁

在 SwiftUI 中,数据流通常是单向的 —— 父视图向下传递数据。但有时我们希望子视图能告诉父视图一些信息,比如自己的尺寸、位置、坐标区域。这时,AnchorPreferences 就是我们的「秘密武器」。


🌱 什么是 AnchorPreferences?

AnchorPreferences 是 SwiftUI 的一个 View 修饰符,可以让你从子视图中提取出与布局相关的几何信息(如位置、尺寸、Bounds 等),并通过 PreferenceKey 向上传递给父视图。

一句话总结它的用途:

让父视图获取子视图在全局布局中的几何信息。


🧩 基础用法示例

我们先从一个简单的例子开始。

假设我们想知道某个子视图(一个按钮)在父视图中的位置。

struct AnchorPreferenceExample: View {
    @State private var buttonFrame: CGRect = .zero

    var body: some View {
        VStack {
            Text("按钮位置:\(Int(buttonFrame.origin.x)), \(Int(buttonFrame.origin.y))")
                .padding()

            Button("点我") {}
                .anchorPreference(key: BoundsPreferenceKey.self, value: .bounds) { anchor in
                    anchor
                }
        }
        .backgroundPreferenceValue(BoundsPreferenceKey.self) { anchor in
            GeometryReader { geo in
                if let anchor {
                    let rect = geo[anchor]
                    Color.clear
                        .onAppear { buttonFrame = rect }
                        .onChange(of: rect) { buttonFrame = $0 }
                }
            }
        }
    }
}

struct BoundsPreferenceKey: PreferenceKey {
    static var defaultValue: Anchor<CGRect>? = nil
    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = nextValue() ?? value
    }
}

📖 解析一下

  1. .anchorPreference(key:value:transform:)

    • 从当前视图提取一个「锚点」信息,比如 .bounds。
    • 存储在一个 PreferenceKey 中。
  2. .backgroundPreferenceValue(_:)

    • 父视图读取子视图上传的锚点信息。
    • 通过 GeometryProxy[anchor] 获取实际的坐标与尺寸。
  3. PreferenceKey

    • 负责定义数据类型与合并策略(多个子视图时如何处理)。

⚡ 实战案例:弹窗跟随按钮位置

来看一个常见需求:

点击按钮后弹出一个菜单,而菜单要自动出现在按钮正下方。

struct FloatingMenuExample: View {
    @State private var showMenu = false
    
    var body: some View {
        ZStack(alignment: .topLeading) {
            Color(UIColor.systemBackground)
                .ignoresSafeArea()

            Button("显示菜单") {
                withAnimation { showMenu.toggle() }
            }
            .anchorPreference(key: MenuAnchorKey.self, value: .bounds) { $0 }
        }
        .overlayPreferenceValue(MenuAnchorKey.self) { anchor in
            GeometryReader { geo in
                if let anchor, showMenu {
                    let rect = geo[anchor]
                    VStack(spacing: 0) {
                        Text("🍎 Apple")
                        Text("🍊 Orange")
                        Text("🍌 Banana")
                    }
                    .padding()
                    .background(RoundedRectangle(cornerRadius: 12).fill(.ultraThinMaterial))
                    .overlay(
                        RoundedRectangle(cornerRadius: 12)
                            .stroke(Color.black.opacity(0.1))
                    )
                    .shadow(radius: 5)
                    .position(x: rect.midX, y: rect.maxY + 40)
                    .transition(.opacity.combined(with: .move(edge: .top)))
                }
            }
        }
    }
}

struct MenuAnchorKey: PreferenceKey {
    static var defaultValue: Anchor<CGRect>? = nil
    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = nextValue() ?? value
    }
}

✨ 运行效果:

  • 点击「显示菜单」,一个浮动弹窗出现在按钮正下方;
  • 无论按钮在什么位置,菜单都自动对齐;
  • 不需要 GeometryReader 嵌套在子视图内 —— 一切由 AnchorPreferences 完成。

⚙️ 工作原理解析

AnchorPreferences 的核心思想是 “布局信息在 SwiftUI 的 View Tree 上传播”

子视图通过 anchorPreference -> PreferenceKey
         ↓
父视图通过 backgroundPreferenceValue / overlayPreferenceValue 读取
         ↓
利用 GeometryProxy[anchor] 将相对锚点转换为具体坐标

可以理解为 SwiftUI 的「几何信息管道」:

角色 作用
.anchorPreference() 子视图上传几何锚点
PreferenceKey 存储锚点信息
.overlayPreferenceValue() 父视图读取锚点信息
GeometryReader 将 Anchor 转为实际位置

🧱 常用 Anchor 类型

类型 描述
.bounds 当前视图的边界矩形
.topLeading / .bottomTrailing 等 具体角位置
.center 中心点
.rect(in:) 自定义矩形区域(从 GeometryProxy 获取)

例如:

.anchorPreference(key: MyKey.self, value: .topLeading) { $0 }

🚀 应用场景举例

场景 描述
💬 Tooltip 定位 获取目标控件位置,显示气泡提示
🎯 弹窗/菜单 动态跟随点击位置
🧭 路径动画 两个 View 之间画线连接(如流程图)
📦 自适应布局 根据子项布局动态调整父容器的对齐方式
🔍 高亮引导 新手引导框高亮控件(可定位按钮位置)

🧠 进阶:多个 Anchor 合并

多个子视图都可以通过相同的 PreferenceKey 上传 Anchor,

父视图会在 reduce() 方法中收到多个值,可用于批量布局。

例如:

struct MultiAnchorKey: PreferenceKey {
    static var defaultValue: [Anchor<CGRect>] = []
    static func reduce(value: inout [Anchor<CGRect>], nextValue: () -> [Anchor<CGRect>]) {
        value.append(contentsOf: nextValue())
    }
}

这样就能在父视图中获取所有子项的位置,用于绘制连线或分布动画。


🎯 小结

特性 说明
📡 数据方向 子 → 父
💡 功能 传递几何信息(位置、尺寸)
🧩 关键组件 .anchorPreference()、PreferenceKey、.overlayPreferenceValue()
🧱 典型应用 Tooltip、菜单、引导、高亮框、连线图
AnchorPreferences 是 SwiftUI 布局系统中一块隐藏的宝石。

掌握它,就能实现很多 UIKit 中要手动计算坐标的复杂布局,而无需任何 Frame 操作。


🪶 结语

SwiftUI 的核心思想是“声明式布局”,但 AnchorPreferences 给了我们“命令式的洞口”——可以在纯声明式框架里实现自定义的几何逻辑。

一旦掌握它,你可以做出很多令人惊叹的交互,比如微信小程序弹窗、指向动画、可跟踪的标签定位等等。


【Swift 可选链】从“如果存在就点下去”到“安全穿隧”到空合运算符

什么是可选链(Optional Chaining)

一句话:“当某个实例可能是 nil 时,允许你用 问号? 一路点下去;只要链中任何一环为 nil,整条表达式就优雅地返回 nil,而不会崩溃。”

与“强制解包 !”的生死对比

写法 成功时 失败时 是否安全
a!.b 拿到值 运行时崩溃 ❌ 不安全
a?.b 拿到值 返回 nil ✅ 安全
class Person {
    var residence: Residence?   // 可选类型,默认 nil
}

class Residence {
    var numberOfRooms = 1
}

let john = Person()

// 1. 强制解包——危险
// let roomCount = john.residence!.numberOfRooms
// 运行时崩溃:Unexpectedly found nil while unwrapping

// 2. 可选链——安全
if let roomCount = john.residence?.numberOfRooms {
    print("房间数:\(roomCount)")   // 不会进来,因为 residence 是 nil
} else {
    print("无法获取房间数")          // 走到这里,安全!
}

可选链的 4 条核心规则

  1. 链上任意环节为 nil → 整条表达式立即返回 nil,后续代码不再执行。
  2. 返回值总是可选类型:即使原属性是 Int,经过可选链后也变成 Int?
  3. 可连续多级链(A?.B?.C),但不会叠加可选层数;String? 再多层链也是 String?
  4. 不仅能点属性,还能 调方法、取下标、赋值;失败时返回 Void?nil

完整模型:Person → Residence → Room / Address

class Room {
    let name: String
    init(name: String) {
        self.name = name
    }
}

class Address {
    var buildingName: String?
    var buildingNumber: String?
    var street: String?
    
    // 返回可选 String
    func buildingIdentifier() -> String? {
        if let name = buildingName {
            return name
        }
        if let num = buildingNumber, let street = street {
            return "\(num) \(street)"
        }
        return nil
    }
}

class Residence {
    var rooms: [Room] = []
    var address: Address?
    
    // 计算属性
    var numberOfRooms: Int {
        rooms.count
    }
    
    // 下标
    subscript(i: Int) -> Room? {
        get {
            return i < rooms.count ? rooms[i] : nil
        }
        set {
            if let newValue, i <= rooms.count {
                if i == rooms.count {
                    rooms.append(newValue)
                }
                else {
                    rooms[i] = newValue
                }
            }
        }
    }
    
    // 无返回值方法,默认是返回Void
    func printNumberOfRooms() {
        print("这个房子有 \(numberOfRooms) 个房间")
    }
}

class Person {
    var residence: Residence?
}

实战场景 1:访问属性

let p = Person()
//  residence 为 nil,链式失败 → 返回 nil
let roomCount: Int? = p.residence?.numberOfRooms
print(roomCount as Any)   // nil

实战场景 2:调用方法

// 失败时返回 Void?,可利用与 nil 比较
if p.residence?.printNumberOfRooms() == nil {
    print("方法没执行,因为 residence 是 nil")
}

实战场景 3:通过下标读写

p.residence?[0] = Room(name: "主卧")   // 失败,不会崩溃
// 给 residence 赋值后再试
p.residence = Residence()
p.residence?[0] = Room(name: "主卧")   // 成功添加
print(p.residence?.rooms.first?.name ?? "无房间")  // 主卧

实战场景 4:多级链(链中链)

p.residence?.address = Address()
p.residence?.address?.street = "Infinite Loop"
let streetName: String? = p.residence?.address?.street
print(streetName as Any)   // Optional("Infinite Loop")

// 再深一层:调用返回可选值的方法
let id: String? = p.residence?.address?.buildingIdentifier()
print(id as Any)           // nil(因为 buildingName/Number 都为空)

// 在方法返回后继续链
let firstChar: Character? = p.residence?.address?.buildingIdentifier()?.first
print(firstChar as Any)    // nil

可选链的赋值操作也有返回值

A?.B = C 整体返回 Void?,可用来判断赋值是否成功。

func createAddress() -> Address {
    print("⚠️ 这行会打印吗?")
    return Address()
}
// 赋值失败,createAddress() 不会执行
let result: Void? = (p.residence = nil)
(p.residence?.address = createAddress())
// 控制台无输出,证明短路了

常见踩坑与调试技巧

  1. 链太长看不清?用断点看每一步的中间值。
  2. 忘了返回值是可选?直接当非可选用会编译错误。
  3. try?as? 混用时,注意可选层级不会叠加,但可读性会变差,建议拆行。
  4. 在 @objc 协议 或 KVO 中,可选链无法直接观察,需先解包再观察。

扩展场景:在日常业务里花式用链

  1. JSON 嵌套解析
let city: String? = json["user"]?.["address"]?.["city"]?.stringValue
  1. 路由跳转判空
if navigationController?.topViewController?.isKind(of: DetailVC.self) == true { ... }
  1. 链式动画
view?.layer?.animate()?.next()?.start()
  1. Combine 管道
publisher?.flatMap { $0.optionalField?.publisher } // 依旧只需一个 ?

为什么需要 ??

可选链让我们安全地拿到可选值,但业务里更常见的是:“拿不到就算了,给个备胎。”

这时空合运算符(Nil-Coalescing Operator)?? 就是最佳接盘侠。

?? 基础回顾

let roomCount = john.residence?.numberOfRooms ?? 0

解读:

  • 链成功 → 返回真实房间数
  • 链任意环节为 nil → 返回 0
  • 结果类型退化成非可选 Int,直接可用,无需再解包

6 个实战场景,把 ?? 用到极致

  1. 多级链 + 自定义默认值
// 业务:显示“城市+街道”,拿不到就显示“未知地址”
let addressText = p.residence?.address?.street ?? "未知地址"
// 再升一级:整条都为空时显示“火星”
let finalText = addressText.isEmpty ? "火星" : addressText
  1. 方法链返回值是可选
// buildingIdentifier() 返回 String?
let badgeText = p.residence?.address?.buildingIdentifier() ?? "暂无门牌"
  1. 下标访问越界 or key 不存在
let scores = ["Alice": [80, 90], "Bob": []]
let aliceFirst = scores["Alice"]?.first ?? 0   // 80
print(aliceFirst)
let bobFirst   = scores["Bob"]?.first   ?? 0   // 0(数组空)
print(bobFirst)
let cindyFirst = scores["Cindy"]?.first ?? 0   // 0(key 不存在)
print(cindyFirst)
  1. 与 try? 混用——解析 JSON 一行代码
let username = (try? JSONDecoder().decode(User.self, from: data))?.name ?? "游客"
  1. 与 as? 混用——VC 安全取值
let indexPath = tableView.indexPathForSelectedRow
let cell = tableView.cellForRow(at: indexPath ?? IndexPath(row: 0, section: 0))
  1. 链式动画缺省回调
UIView.animate(
    withDuration: 0.3,
    animations: { self.view.alpha = 0 },
    completion: { _ in
        self.dismissAnimation?() ?? self.defaultDismiss() // 备胎动画
    }
)

性能陷阱:?? 的右表达式何时执行?

?? 是短路的:

  • 左值非 nil → 右值根本不会求值
  • 左值 nil → 才会计算右值

因此可以放心把昂贵构造放在右边:

// 数据库查询很耗资源,仅当缓存为 nil 时才查
let config = loadCache()?.config ?? loadFromDB()

与三目运算符的区别

写法 是否强制解包 可读性表现
a != nil ? a : b 需要手动解包 啰嗦
a ?? b 编译器自动处理 简洁

可选链 + ?? 的 3 条最佳实践

  1. 先链后合:把 ?? 放在最外层,保证链的每一步都可读。
  2. 默认值类型匹配:Swift 类型推导严格,Int??Int 不会自动合并。
  3. 日志友好:给默认值加前缀标识,方便灰度排查。
let uid = user?.id ?? "unknown_uid"

一道面试真题

写出编译通过的表达式:在“链可能失败”且“失败后要抛错”的场景下,如何把 ??throw 结合?

一个答案:

let url = Bundle.main.url(forResource: "config", withExtension: "json")
            ?? { throw AppError.missingConfig }()

利用立即执行闭包把 throw 包成表达式,满足 ?? 右侧要求。

Swift 反初始化器详解——在实例永远“消失”之前,把该做的事做完

为什么要“反初始化”

  1. ARC 已经帮我们释放了内存,但“内存”≠“资源”。

    可能你打开过文件、有过数据库连接、订阅过通知、甚至握着 GPU 纹理句柄。

  2. 反初始化器(deinit)是 Swift 给你“最后一声道别”的钩子:

    实例即将被销毁 → 系统自动调用 → 你可以把文件关掉、把硬币还回银行、把日志写盘……

  3. 只有 class 有 deinit,struct / enum 没有;一个类最多一个 deinit;不允许手动显式调用。

deinit 的 6 条铁律

  1. 无参无括号:
class MyCls {
    deinit { // 不能写 deinit() { ... }
        // 清理代码
    }
}
  1. 自动调用,调用顺序:子类 deinit 执行完 → 父类 deinit 自动执行。
  2. 实例“还没死”:deinit 里可访问任意 self 属性,甚至可调用实例方法。
  3. 不能自己调、不能重载、不能抛异常、不能带 async。
  4. 如果实例从未被真正强引用(例如刚 init 就赋 nil),deinit 不会触发。
  5. 若存在循环引用(strong reference cycle),deinit 永远不会触发——必须先解环。

示例

import Foundation

// MARK: - 银行:管理游戏世界唯一货币
@MainActor
class Bank {
    // 静态共享实例 + 私有初始化,保证“全世界只有一家银行”
    static let shared = Bank()
    private init() {}
    
    // 剩余硬币,private(set) 让外部只读
    private(set) var coinsInBank = 10_000
    
    /// 发放硬币;返回实际发出的数量(可能不够)
    func distribute(coins number: Int) -> Int {
        let numberToVend = min(number, coinsInBank)
        coinsInBank -= numberToVend
        print("银行发放 \(numberToVend) 枚,剩余 \(coinsInBank)")
        return numberToVend
    }
    
    /// 回收硬币
    func receive(coins number: Int) {
        coinsInBank += number
        print("银行回收 \(number) 枚,当前 \(coinsInBank)")
    }
}

// MARK: - 玩家:从银行拿硬币,离开时自动归还
@MainActor
class Player {
    var coinsInPurse: Int
    
    /// 指定构造器:向银行申请“启动资金”
    init(coins: Int) {
        let received = Bank.shared.distribute(coins: coins)
        coinsInPurse = received
        print("玩家初始化,钱包得到 \(received)")
    }
    
    /// 赢钱:从银行再拿一笔
    func win(coins: Int) {
        let won = Bank.shared.distribute(coins: coins)
        coinsInPurse += won
        print("玩家赢得 \(won),钱包现在 \(coinsInPurse)")
    }
    
    /// 反初始化器:人走茶不凉,硬币先还银行
    @MainActor
    deinit {
        print("玩家 deinit 开始,归还 \(coinsInPurse)")
        Bank.shared.receive(coins: coinsInPurse)
        print("玩家 deinit 结束")
    }
}

// MARK: - 游戏主流程
@MainActor
func gameDemo() {
    print("=== 游戏开始 ===")
    
    // 1. 创建玩家;注意用可选类型,因为玩家随时可能 leave
    var playerOne: Player? = Player(coins: 100)
    // 如果不加调试打印,可简写:playerOne?.win(coins: 2000)
    if let p = playerOne {
        print("玩家当前硬币:\(p.coinsInPurse)")
        p.win(coins: 2_000)
    }
    
    // 2. 玩家离开游戏;引用置 nil → 强引用归零 → deinit 被调用
    print("玩家离开,引用置 nil")
    playerOne = nil
    
    print("=== 游戏结束 ===")
}

gameDemo()

运行结果

=== 游戏开始 ===
银行发放 100 枚,剩余 9900
玩家初始化,钱包得到 100
玩家当前硬币:100
银行发放 2000 枚,剩余 7900
玩家赢得 2000,钱包现在 2100
玩家离开,引用置 nil
玩家 deinit 开始,归还 2100
银行回收 2100 枚,当前 10000
玩家 deinit 结束
=== 游戏结束 ===

3 个高频扩展场景

  1. 关闭文件句柄
class Logger {
    private let handle: FileHandle
    init(path: String) throws {
        handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
    }
    deinit {
        handle.closeFile()   // 文件一定会被关掉
    }
}
  1. 注销通知中心观察者
class KeyboardManager {
    private var tokens: [NSObjectProtocol] = []
    init() {
        tokens.append(
            NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in }
        )
    }
    deinit {
        tokens.forEach(NotificationCenter.default.removeObserver)
    }
}
  1. 释放手动分配的 C 内存 / GPU 纹理
class Texture {
    private var raw: UnsafeMutableRawPointer?
    init(size: Int) {
        raw = malloc(size)
    }
    deinit {
        free(raw)          // 防止内存泄漏
    }
}

常见踩坑与排查清单

现象 可能原因 排查工具
deinit 从不打印 出现强引用循环 Xcode Memory Graph / leaks 命令
子类 deinit 未调用 父类 init 失败提前 return 在 init 各阶段加打印
访问属性崩溃 在 deinit 里访问了 weak / unowned 已释放属性 改用 strong 或提前判空

小结:把 deinit 当成“遗嘱执行人”

  1. 它只负责“身后事”:释放非内存资源、归还全局状态、写日志。
  2. 它不能保命:如果实例因为循环引用一直活着,就永远走不到 deinit。
  3. 它不能抢戏:别在 deinit 里做耗时任务(网络、IO),否则可能阻塞主线程或单元测试。
  4. 用好 weak / unowned + deinit,可以让 Swift 代码在“自动”与“可控”之间取得最佳平衡。

深入底层:deinit 在 SIL & 运行时到底做了什么

swiftc -emit-sil main.swift mainsil

  1. SIL(Swift Intermediate Language)视角

    编译器会为每个类生成一个 sil_vtable,里面存放了类中的所有方法,可以看到deinit中调用的是Player.__deallocating_deinit

    image.png

    Player.__deallocating_deinit中调用的 Player.__isolated_deallocating_deinit

    image.png

    Player.__isolated_deallocating_deinit中调用Player.deinit

    image.png

    伪代码:

   sil @destroy_Player : $@convention(method) (@owned Player) -> () {
     bb0(%0 : $Player):
       // 1. 调用 deinit
       %2 = function_ref @$s4main6PlayerCfZ : $@convention(thin) (@owned Player) -> ()
       %3 = apply %2(%0) : $@convention(method) (@guaranteed Player) -> @owned Builtin.NativeObject // user: %4
       // 2. 销毁存储属性
       destroy_addr %0.#coinsInPurse
       // 3. 释放整个对象内存
       strong_release %5
   }

结论:deinit 只是“销毁流水线”里的一环;先跑 deinit,再跑成员销毁,最后归还堆内存。

  1. 运行时视角

    Swift 对象头部有一个 32-byte 的 HeapObject,其中 refCounts 字段采用“Side Table” 策略。

    当最后一次 swift_release 把引用计数降到 0 时,会立即跳到 destroy 函数指针 → 也就是上面的 SIL 函数。

    因此:

    • deinit 执行线程 = 最后一次 release 发生的线程;
    • deinit 执行耗时 ≈ 对象大小 + 成员销毁耗时 + 你写的代码耗时;
    • 如果 deinit 里再产生强引用(例如把 self 塞进全局数组),对象会被“复活”,但 Swift 5.5 之后禁止这种 resurrection,会直接 trap。

多线程与 deinit 的 4 个实战坑

场景 风险 正确姿势
子线程释放主线程创建的实例 deinit 里刷新 UI DispatchQueue.main.asyncMainActor.assertIsolated()
deinit 里加锁 可能和 init 锁顺序相反 → 死锁 尽量无锁;必须加锁时统一层级
deinit 里用 unowned 访问外部对象 外部对象可能已释放 改用 weak 并判空
deinit 里继续派发异步任务 任务持有 self → 循环复活 使用 Task { [weak self] in ... }

与 Objective-C 的交叉:dealloc vs deinit

  1. 继承链
@objc class BaseNS: NSObject {
   deinit { print("Swift deinit") }   // 实际上会生成 -dealloc 方法
}

编译器把 deinit 映射成 Objective-C 的 -dealloc,并在末尾自动插入 [super dealloc](ARC 下自动插入)。
2. 混编时序

  • Swift 侧先跑完 deinit;
  • 再跑 Objective-C 侧生成的 -dealloc
  • 最后 NSObject 的 -dealloc 释放 isa 与 ARC 附带内存。
  1. 注意点

    若你在 Objective-C 侧手动 override -dealloc,记得不要显式调用 [super dealloc](ARC 会自动加),否则编译报错。

Swift 5.9 新动向:move-only struct 的 deinit

SE-0390 已经落地 move-only ~Copyable struct,也可以写 deinit!

struct FileDescriptor: ~Copyable {
    private let fd: Int32
    init(path: String) throws { fd = open(path, O_RDONLY) }
    deinit {               // struct 也能有 deinit!
        close(fd)
    }
}

规则:

  • 只要值被消耗(consume)或生命周期结束,deinit 就执行;
  • 不能同时实现 deinitCopyable
  • 用于文件句柄、GPU 描述符等“必须唯一所有权”场景,彻底告别 class + deinit 的性能损耗。

一张“思维导图”收尾

class 实例
   │
   ├─ refCount == 0 ?
   │     ├─ 否:继续浪
   │     └─ 是:进入 destroy 流水线
   │           1. 子类 deinit 跑
   │           2. 父类 deinit 跑
   │           3. 销毁所有存储属性
   │           4. 归还堆内存
   │
   ├─ 线程:最后一次 release 线程
   ├─ 复活:Swift 5.5+ 禁止,直接 trap

彩蛋:把 deinit 做成“叮”一声

#if DEBUG
deinit {
    // 只调一次,不会循环引用
    DispatchQueue.main.async {
        AudioServicesPlaySystemSound(1057) // 键盘“叮”
    }
}
#endif

每次对象销毁都会“叮”,办公室同事会投来异样眼光,但你能瞬间听出内存泄漏——当该响的没响,就说明循环引用啦!

Swift 并发编程新选择:Mutex 保护可变状态实战解析

前言

Swift 5.5 带来 async/await 与 Actor 后,「用 Actor 包一层」几乎成了默认答案。

但在日常开发里,我们经常会遇到两种尴尬:

  1. 只想保护一个计数器、缓存或 token,却不得不把整段逻辑都改成异步;
  2. 把对象放到 @MainActor 后,发现后台线程也要用,结果到处是 await。

Apple 在 Swift 5.9 前后把 Mutex 正式搬进标准库(通过 Synchronization 模块),给“同步但不想异步”的场景提供了第三条路。

Mutex 是什么(一句话先记住)

Mutex = 互斥锁,同步、阻塞、轻量。

它只干一件事:同一时刻最多一个线程进入临界区,保证对共享状态的“读-改-写”原子化。

与 Actor 的“异步消息”不同,Mutex 的等待是阻塞线程,所以临界区必须短、快、不阻塞。

基础用法:从 0 到 1 保护一个计数器

  1. 引入模块(Xcode 15+/Swift 5.9 自带)
import Synchronization
  1. 定义线程安全的 Counter
final class Counter: Sendable {          // ① Sendable 空标记即可,Mutex 本身已 Sendable
    private let mutex = Mutex(0)         // ② 初始值 0

    /// 加 1,同步返回
    func increment() {
        mutex.withLock { value in
            value += 1                   // ③ 闭包内 value 是 inout,直接改
        }
    }

    /// 减 1
    func decrement() {
        mutex.withLock { value in
            value -= 1
        }
    }

    /// 读值,也要拿锁
    var count: Int {
        mutex.withLock { value in
            return value                 // ④ 只读,同样原子
        }
    }
}
  1. 客户端代码——完全同步
let counter = Counter()
counter.increment()
print(counter.count)   // 1

要点回顾

  • withLock<T> 泛型返回,既能读也能写;
  • 闭包里的 valueinout,修改即生效;
  • 锁的持有时间 = 闭包运行时间,务必短。

让属性看起来“像正常变量”——封装 getter/setter

extension Counter {
    var count: Int {
        get {
            mutex.withLock { $0 }        // $0 就是 value,直接返回
        }
        set {
            mutex.withLock { value in
                value = newValue
            }
        }
    }
}

// 使用方无感
counter.count = 10
print(counter.count) // 10

与 @Observable 搭档——让 SwiftUI 刷新

Mutex 只保护值,不会触发属性观察器。

若直接 @Observable final class Counter,视图不会刷新。

需要手动告诉 Observation 框架:

@Observable
final class Counter: Sendable {
    private let mutex = Mutex(0)

    var count: Int {
        get {
            access(keyPath: \.count)          // ① 读标记
            return mutex.withLock { $0 }
        }
        set {
            withMutation(keyPath: \.count) {  // ② 写标记
                mutex.withLock { $0 = newValue }
            }
        }
    }
}

SwiftUI 端无额外成本:

struct ContentView: View {
    @State private var counter = Counter()

    var body: some View {
        VStack {
            Text("\(counter.count)")
            Button("++") { counter.increment() }
            Button("--") { counter.decrement() }
        }
    }
}

Actor or Mutex?一张决策表帮你 10 秒选

维度 Mutex Actor
同步/异步 同步、阻塞 异步、非阻塞
适用场景 极短临界区(赋值、累加) 长时间任务、IO、网络
性能 极轻量,纳秒级锁 微秒毫秒,调度开销
语法侵入 无 async 强制 async/await
Sendable Mutex 已 Sendable,类标即可 Actor 引用即 Sendable
调试难度 简单,栈清晰 异步堆栈难追踪

“只想保护一两行, Mutex 别犹豫; 流程长、要并发, Actor 顶上。”

扩展场景实战

  1. 高频读写缓存(图片、Token)
final class ImageCache: Sendable {
    private let cache = Mutex([String: Image]())

    func image(for key: String) -> Image? {
        cache.withLock { $0[key] }
    }

    func save(_ image: Image, for key: String) {
        cache.withLock { dict in
            dict[key] = image
        }
    }
}
  1. 统计接口 QPS
final class Stats: Sendable {
    private let counter = Mutex(0)
    private let start = Date()

    func record() {
        counter.withLock { $0 += 1 }
    }

    var qps: Double {
        counter.withLock { Double($0) / start.timeIntervalSinceNow * -1 }
    }
}
  1. 保护非 Sendable 的 C 句柄
final class SQLiteHandle: @unchecked Sendable {
    private let db: UnsafeMutableRawPointer
    public init(db: UnsafeMutableRawPointer) {
        self.db = db
    }
    
    private let lock = Mutex(())

    func execute(_ sql: String) {
        lock.withLock { _ in
            sqlite3_exec(db, sql, nil, nil, nil)   // 临界区
        }
    }
}

踩坑与提醒

  1. 长任务别用 Mutex

    一旦临界区阻塞 IO,整个线程池都会被卡死,比 Actor 还惨。

  2. 递归加锁会死锁

    Mutex 不可重入,同一线程重复拿锁直接挂起;Actor 不会。

  3. 锁粒度要细

    大对象整颗锁会变成性能瓶颈,可拆成多颗 Mutex 或按 Key 分片。

  4. Swift 6 数据竞争检查

    打开 -strict-concurrency=complete 后,凡是非 Sendable 全局变量都会报错;用 Mutex 包一层即可通过。

小结

Actor 把“线程安全”装进黑盒子,让开发者用消息思考;Mutex 把“锁”暴露给你,却换回最简洁的同步代码。

两者不是谁取代谁,而是互补:

  • 短、频、快 → Mutex
  • 长、流、异步 → Actor

iOS 26 你的 property 崩了吗?

本文首次发表在快手大前端公众号

背景

iOS 26 Runtime 新增特性,对 nonatomic (非原子) 属性的并发修改更加容易产生崩溃。系统合成的 setter 方法会短暂地存入一个哨兵值 0x400000000000bad0 ,而该值可能会被另一个并发访问此属性的线程所读取。如果程序因访问这个哨兵值而崩溃,则表明正在访问的属性存在线程安全问题。

崩溃示例:

核心改动

对于 nonatomic strong 属性的赋值操作,编译时会自动生成对 objc_storeStrong 函数的调用。

示例:

@property (nonatomic, strong) NSObject *obj1;

系统生成的 setter 方法:

Example`-[ViewController setObj1:]:
    0x1046298c4 <+0>:  sub    sp, sp, #0x30
    0x1046298c8 <+4>:  stp    x29, x30, [sp, #0x20]
    0x1046298cc <+8>:  add    x29, sp, #0x20
    0x1046298d0 <+12>: stur   x0, [x29, #-0x8]
    0x1046298d4 <+16>: str    x1, [sp, #0x10]
    0x1046298d8 <+20>: str    x2, [sp, #0x8]
    0x1046298dc <+24>: ldr    x1, [sp, #0x8]
    0x1046298e0 <+28>: ldur   x8, [x29, #-0x8]
    0x1046298e4 <+32>: adrp   x9, 5159
    0x1046298e8 <+36>: ldrsw  x9, [x9, #0xba4]
    0x1046298ec <+40>: add    x0, x8, x9
--> 0x1046298f0 <+44>: bl     0x105600a10               ; symbol stub for: objc_storeStrong
    0x1046298f4 <+48>: ldp    x29, x30, [sp, #0x20]
    0x1046298f8 <+52>: add    sp, sp, #0x30
    0x1046298fc <+56>: ret    

objc_storeStrong 在旧版本的的实现:

void objc_storeStrong(id *location, id obj) {
    // 1. 先用一个临时变量 prev 持有旧值
    id prev = *location;
    
    // 2. 如果新旧值相同,直接返回,避免不必要的内存操作
    if (obj == prev) {
        return;
    }
    
    // 3. 对新值执行 retain,使其引用计数+1
    objc_retain(obj);
    
    // 4. 将指针指向新值
    *location = obj;
    
    // 5. 对旧值执行 release,使其引用计数-1
    objc_release(prev);
}

反汇编 objc_storeStrong 在 iOS 26 新版本的实现:

void objc_storeStrong_iOS_26(id *location, id obj) {
    // 1. 读取旧值
    // ldr x20, [x0]
    id prev = *location;

    // 2. 检查新旧值是否相同,相同则直接返回
    // cmp x20, x1
    // b.eq ... (跳转到函数末尾)
    if (prev == obj) {
        return;
    }

    // 为了后续操作,保存新值 obj 和地址 location
    // mov x19, x1  (x19 = obj)
    // mov x21, x0  (x21 = location)
    id new_obj_saved = obj;
    id* location_saved = location;

    // 3. 【核心改动】向属性地址写入哨兵值
    // mov  x8, #0xbad0
    // movk x8, #0x4000, lsl #48  --> x8 = 0x400000000000bad0
    // str  x8, [x0]
    *location = (id)0x400000000000bad0; // 调试陷阱

    // 4. 对新值执行 retain
    // mov x0, x1
    // bl objc_retain
    objc_retain(obj);

    // 5. 将真正的新值写入属性地址,覆盖哨兵值
    // str x19, [x21]
    *location_saved = new_obj_saved;

    // 6. 释放旧值(通过尾调用优化)
    // mov x0, x20
    // b objc_release
    // 这相当于 return objc_release(prev);
    objc_release(prev);
}

为了更主动地暴露 nonatomic 属性的线程安全问题,objc_storeStrong 函数在 iOS 26 中增加了一个关键步骤。

旧实现 (时序:Retain -> Assign -> Release)

  1. objc_retain(newValue);
  2. *location = newValue;
  3. objc_release(oldValue);

新实现 (时序:写入哨兵值 -> Retain -> Assign -> Release)

  1. *location = 0x4...bad0; // <-- 新增:写入哨兵值

  2. objc_retain(newValue);

  3. *location = newValue;

  4. objc_release(oldValue);

旧实现中数据竞争触发崩溃需要满足的条件:

  1. 对象状态:prev 对象的引用计数 == 1,执行完 objc_release 之后 prev 对象被释放。

  2. 线程时序:读线程获取到了 prev 对象,并未对 prev 对象的引用计数+1,写线程执行完 objc_release(prve),读线程仍在继续使用 prev。

  3. 行为前提:读线程必须对这个已成为悬垂指针的 prev 地址执行解引用操作,但是这是一个必要不充分条件,因为该内存可能已被重用,不一定会触发崩溃。

新实现通过引入哨兵值,将不确定的崩溃条件转变为一个确定的、主动触发的机制:

  1. 定义"危险窗口": 写线程在 objc_storeStrong 内部创建了一个明确的"危险窗口"——从写入哨兵值 (*location = 0x4...bad0) 开始,到写入新值 (*location = obj) 结束。访问哨值触发崩溃与旧值 prev 对象的引用计数无关
  2. 简化触发条件: 只要读线程的读取操作落入这个时间窗口内,它必然会获取到哨兵值。对这个非法的哨兵地址进行任何解引用操作,都将必然、立即触发一个带有明确特征 (0x4...bad0) 的 EXC_BAD_ACCESS 崩溃。

另外新的崩溃机制并非替换了旧的崩溃逻辑,而是与之叠加,因此极大地放大了崩溃的概率。

崩溃场景

当一个线程(线程 A)正在为属性赋值,并已写入哨兵值但尚未写入新值时,*location 处于 "危险窗口"。此时,另一个线程(线程 B)的并发读写操作会导致崩溃。

崩溃场景一:写写并发 → objc_release 崩溃

  1. 线程 A:执行 setter,向属性地址写入哨兵值 0x4...bad0。

  2. 线程 B:并发执行 setter,调用 objc_storeStrong 函数。

  3. 关键点:线程 B 此时读到了 "旧值" 是线程 A 写入的哨兵值 0x4...bad0。

  4. 崩溃:objc_storeStrong 在赋值完成后,尝试调用 objc_release(旧值),实际上执行了 objc_release(0x4...bad0)。由于这是一个无效的对象地址,程序立即崩溃,堆栈栈顶指向 objc_release。

复现代码:

崩溃栈顶:

崩溃场景二:读写并发 → objc_retain 崩溃

  1. 线程 A:执行 setter,写入哨兵值 0x4...bad0。

  2. 线程 B:此时执行 getter 来读取该属性。

  3. 关键点:getter 直接从内存中返回了当前的哨兵值 0x4...bad0。

  4. 崩溃:ARC 为了保证对象生命周期,会对这个值执行 retain 操作。这导致系统调用 objc_retain(0x4...bad0)。同样,由于这是一个无效地址,程序崩溃,堆栈栈顶指向 objc_retain。

复现代码:

崩溃栈顶:

根因修复

iOS 26 Runtime 针对 property 新增的哨兵机制,其目的是主动暴露潜藏的多线程数据竞争问题。因此,修复的根本目标是解决底层的线程冲突。

最直接快速的修复方案是把 nonatomic 修改为 atomic。它能有效地规避 iOS 26 此次更新导致的,访问哨兵 0x400000000000bad0 触发的崩溃问题。

需要注意的是 atomic 也有一些局限性,只保证 setter 或 getter 本身是原子操作。如果有一系列依赖该属性的操作,atomic 无法保护整个操作序列是线程安全的。典型场景比如数组、字典的更新,atomic 可以保证线程安全的获取数组或字典对象,但是无法保证对数组和字典的增删是线程安全的,此时需要用锁或者队列覆盖系列复合操作来保证线程安全。

影响范围

这是一次由操作系统 Runtime 变更引发的、波及全量线上版本的崩溃问题。当用户升级操作系统后,代码库中所有潜藏的 nonatomic 数据竞争问题都将被新的"哨兵"机制主动暴露,导致崩溃呈现高度分散的特点,增加了问题处置的复杂性。如下所示,不仅分布为多个崩溃堆栈,并且每个堆栈的 App 版本跨度非常大。

对于线上 App 版本,如果不做任何止损操作,iOS 26 系统的用户崩溃率将比存量系统激增近两个数量级。

Ekko(安全气垫)

崩溃波及全量的线上 App 版本,对于线上历史版本, 从用户体验的角度出发,我们不能够任由崩溃发生,也不能简单粗暴地强制用户升级 App。那么如何在允许的的规则范围内进行崩溃止损呢?快手的答案是使用 Ekko(安全气垫)。

Ekko 是什么?

Ekko 是快手自研的全新的安全气垫框架,命名源自英雄联盟的艾克,他的 R 技能可以回到数秒前位置并恢复生命值,非常契合快手安全气垫的技术实现。Ekko 核心机制是:在异常发生之后,App 闪退之前,通过修改程序执行流,在代码逻辑上等价于绕过执行发生异常的函数,从而让 App 免于崩溃。Ekko 兜底偶现崩溃的场景下,当目标函数未发生崩溃时,执行逻辑不会受任何影响。

以典型的数组越界为例:

Ekko 兜底 objectAtIndex: 后,上述代码在异常发生后,执行逻辑上等价于:

Ekko 简介:

  • 平台覆盖:iOS & Android

  • 兜底能力:在 iOS 端能处理包括 Mach 异常在内的所有崩溃类型。在 Android 端能处理 Java Exception 和 Native Exception。

  • 稳定可靠:兜底的核心逻辑在异常发生后执行,对正常运行的 App 不发生作用。Ekko 系统上线至今,已在线上稳定运行超过一年,多次在异常退出类型的故障处置中发挥关键作用,为快手 App 的稳定性提供了坚实的保障。

Ekko 兜底实践

iOS 传统的安全气垫会通过 hook Objective-C 可能会抛异常的系统方法,在替换的方法内,添加 try catch 或者校验异常参数,防御已知的、可枚举的风险点,从而避免崩溃发生。

因为访问 0x400000000000bad0 触发了 bad access 类型的 Mach 异常能不能被 try catch 住呢?答案是不可以的。但是 Mach 同 Exception 一样,也是两段式的处理,当异常发生时,内核会挂起出错线程,并向用户态发起“问询”,并等待用户态的响应,然后根据用户态的回复决定是否终止进程还。这个问询等待回复后决策的机制是 Ekko 兜底 Mach 异常的关键所在。

针对此次 nonatomic 哨兵值崩溃,快手稳定性组通过 Ekko,对访问哨兵地址 0x400000000000bad0 触发的崩溃类型进行了统一兜底,拦截了用户百万次量级的崩溃。兜底主要处理以下两种系统堆栈触发的崩溃场景:

  • 场景一:objc_release
    • 兜底策略: 检测到参数为哨兵地址时,直接返回,不执行任何操作。
    • 业务影响: 无额外影响。此操作仅跳过了一次无效的 release,避免了崩溃。
  • 场景二:objc_retain
    • 兜底策略: 检测到参数为哨兵地址时,中断原始 retain 流程,并向上层返回 nil。

    • 业务影响:可控降级。上层业务代码在获取该属性值后会得到 nil。需要和相关业务方沟通并确认,业务逻辑能够正确处理 nil 返回值,兜底后的效果可接受。

本文仅是 Ekko 系列的开篇,后续我们将通过公众号,为大家详细介绍 Ekko 的技术实现细节,对相关内容感兴趣的可以关注公众号,敬请期待后续的更新~~

iOS 26 开始强制 UIScene ,你的 Flutter 插件准备好迁移支持了吗?

在今年的 WWDC25 上,Apple 发布 TN3187 文档,其中明确了要求:“在 iOS 26 之后的版本,任何使用最新 SDK 构建的 UIKit 应用都必须使用 UIScene 生命周期,否则将无法启动” :

实际上 UIScene 不是什么新鲜东西,反而是一个老古董,毕竟它是在 iOS 13 中引入的,它的核心思想是将应用的“进程”生命周期和“UI 实例”的生命周期分离,让应用可以同时管理多个独立的 UI 实例 。

而在此之前,iOS 主要围绕单体模型 UIApplicationDelegate 来实现生命周期管理,例如:

  • 负责处理应用进程的启动与终止 application(_:didFinishLaunchingWithOptions:) / applicationWillTerminate(_:)
  • 所有与 UI 状态相关的事件,例如应用进入前台并变得活跃 (applicationDidBecomeActive(_:)) 或进入后台 (applicationDidEnterBackground(_:))
  • 窗口管理 AppDelegate 拥有并管理着应用唯一的 UIWindow 实例
  • 处理系统级事件,包括响应远程推送通知、处理通过 URL Scheme 如 Deeplink 等

所以可以明显看到,这种单体模型的架构最根本的缺陷在于,将应用进程与 UI 界面紧密绑定,导致整个应用只有一个统一的 UI 状态。

但是这在之前对于 Flutter 来说并没有什么问题,因为 Flutter 默认本身就是一个单页面的架构,虽然存在 UIScene ,但是 AppDelegate 就满足需求了,所以在本次迁移到 UIScene 生命周期之前,Flutter 在 iOS 平台上的整个原生集成都围绕着 UIApplicationDelegate 构建 ,而随着本次 TN3187 的要求,Flutter 不得不开始完全迁移到 UIScene 模型。

对于 UIScene 模型,整个逻辑主要入了三个概念:

  • UIScene:代表应用 UI 的一个独立实例,绝大多数情况下开发者熟悉的就是 UIWindowScene,它管理着一个或多个窗口以及相关的 UI
  • UISceneSession:持久化对象,它代表一个场景的配置和状态,比如即使其对应的 UIScene 实例因为资源回收等原因被系统断开连接或销毁,UISceneSession 依然存在,保存着恢复该场景所需的信息,是实现状态恢复的关键
  • UISceneDelegate:作为 UIScene 的代理,它专门负责管理特定场景的生命周期事件,例如连接、断开、进入前台、进入后台等

所以到这里,可以很明显看出来,UIApplicationDelegateUISceneDelegate 有了进一步的明显分割:

  • UIApplicationDelegate :处理进程级别的事件,比如应用启动和终止的,并负责处理推送通知的注册等全局任务
  • UISceneDelegate :接管了所有与 UI 相关的生命周期管理,包括场景的创建与连接 (scene(_:willConnectTo:options:)),活跃 (sceneDidBecomeActive(_:));进入后台 (sceneDidEnterBackground(_:));以及断开连接 (sceneDidDisconnect(_:)) 等

具体大概会是以下的关系变化:

AppDelegate SceneDelegate 新增 范围与职责转移 关键行为差异
application(_:didFinishLaunchingWithOptions:) scene(_:willConnectTo:options:) application(_:configurationForConnecting:options:) AppDelegate 转移到 SceneDelegateAppDelegate 仍处理非 UI 的全局初始化(如三方库配置),SceneDelegate 负责创建 UIWindow 和设置根视图控制器 AppDelegatedidFinishLaunchingWithOptions 在应用冷启动时仅调用一次,SceneDelegatewillConnectTo 在每个场景(窗口)创建时都会调用。
applicationDidBecomeActive(_:) sceneDidBecomeActive(_:) - 从应用级转移到场景级,AppDelegate 的方法在场景模型下不再被调用 sceneDidBecomeActive 针对单个场景,允许对不同窗口进行独立的激活处理
applicationWillResignActive(_:) sceneWillResignActive(_:) - 从应用级转移到场景级,AppDelegate 的方法在场景模型下不再被调用 sceneWillResignActive 针对单个场景,例如当一个窗口被另一个应用(如 Slide Over)遮挡时触发
applicationDidEnterBackground(_:) sceneDidEnterBackground(_:) - 从应用级转移到场景级,AppDelegate 的方法在场景模型下不再被调用 sceneDidEnterBackground 允许对每个场景的状态进行独立保存。
applicationWillEnterForeground(_:) sceneWillEnterForeground(_:) - 从应用级转移到场景级,AppDelegate 的方法在场景模型下不再被调用 sceneWillEnterForeground 在应用冷启动时也会被调用,而 applicationWillEnterForeground 不会。这是迁移过程中常见的逻辑错误来源
application(_:open:options:) scene(_:openURLContexts:) - 从应用级转移到场景级,AppDelegate 的方法在场景模型下不再被调用 scene(_:openURLContexts:) 接收到的 URL 会被路由到最合适的场景进行处理
application(_:continue:restoreHandler:) scene(_:continue:) - 从应用级转移到场景级 scene(_:continue:) 允许为特定场景恢复用户活动状态
applicationWillTerminate(_:) sceneDidDisconnect(_:) application(_:didDiscardSceneSessions:) applicationWillTerminate 仍表示整个应用的终止,sceneDidDisconnect 表示场景被系统回收资源(可能重连),didDiscardSceneSessions 表示用户通过应用切换器关闭了场景(永久销毁) 职责更加细化,sceneDidDisconnect 不等于应用终止,而 didDiscardSceneSessions 是清理被用户主动关闭的场景资源的入口。
application(_:didReceiveRemoteNotification:fetchCompletionHandler:) - - 职责保留在 AppDelegate,推送通知是进程级事件,不与特定 UI 实例绑定 即使在场景模型下,推送通知的接收和处理逻辑仍然主要位于 AppDelegate

而对于 Flutter Framework 层面的变化,主要有:

  • 引擎渲染逻辑:Flutter 需要修改 GPU 线程的管理方式,之前引擎主要是根据 UIApplication 的全局通知来暂停或恢复渲染,而迁移后必须改为监听基于单个 UIScene 的通知,以正确处理多窗口下的渲染暂停和恢复

  • 废弃 API 替换:引擎和框架代码中之前使用了 UIApplication.shared.keyWindow API 来获取应用的窗口,这些调用都必须被替换

  • 插件注册机制:由于 FlutterViewController 的创建时机发生变化,插件的注册和关联 FlutterEngine 的机制也需要重构,确保在正确的时机与正确的引擎实例关联

而对于Flutter 插件来说, 任何依赖于 UI 生命周期事件或需要与 UI 窗口交互的插件都可能受到了影响,Flutter 官方对第一方插件进行了大规模的迁移 :

  • url_launcher_ios:需要获取当前窗口来呈现浏览器视图
  • local_auth_darwin:进行生物识别认证时需要与 UI 交互
  • image_picker_ios:需要呈现图片选择界面
  • google_sign_in_ios:需要弹出登录窗口
  • quick_actions_ios:处理主屏幕快捷操作,其回调方法从 AppDelegate 转移到了 SceneDelegate

而对于 Flutter 应用开发者,Flutter 提供了一条自动化和通用的手动迁移方式:

1、自动化迁移(推荐):如果你的 Flutter 项目的原生 iOS 部分(ios 文件夹)没有经过大量定制化修改,可以使用 Flutter CLI 提供的实验性功能来自动完成迁移。

  • 在终端中运行以下命令,开启 UIScene 自动迁移开关

    flutter config --enable-uiscene-migration
    
  • 然后正常地构建或运行你的 iOS 应用

             flutter build ios
             ///or
             flutter run
    
  • 在构建过程中,Flutter 工具会检查项目配置,如果符合条件会自动执行以下操作:

    • 修改 AppDelegate.swift(或 .m),移除过时的 UI 生命周期回调

    • ios/Runner/ 目录下创建一个新的 SceneDelegate.swift(或 .h/.m)文件继承自 FlutterSceneDelegate

    • 更新 Info.plist 文件,添加必要的 UIApplicationSceneManifest 配置

  • 迁移成功后,会在构建日志中看到 "Finished migration to UIScene lifecycle" 的提示,如果项目过于复杂无法自动迁移,工具会给出警告,并提示你进行手动迁移

2、手动迁移:对于那些有复杂原生代码、自定义 AppDelegate 或其他特殊配置的应用,需要手动迁移:

  • 修改 AppDelegate.swift

    • 打开 ios/Runner/AppDelegate.swift,删除所有与 UI 生命周期相关的方法,例如 applicationDidBecomeActiveapplicationWillResignActiveapplicationDidEnterBackgroundapplicationWillEnterForeground (可以参考前面的表格)
    • 保留 application(_:didFinishLaunchingWithOptions:) 方法,但确保其中只包含应用级的初始化逻辑(如注册插件、配置三方服务),移除所有创建和设置 window 的代码
    • 确保 AppDelegate 类继承自 FlutterAppDelegate(如果之前不是的话),或者遵循 FlutterAppLifeCycleProvider 协议
  • 创建 SceneDelegate.swift

    • 在 Xcode 中,右键点击 Runner 文件夹,选择 "New File..." -> "Swift File",命名为 SceneDelegate.swift

    • 将以下代码粘贴到新文件,这段代码定义了一个最简的 SceneDelegate,它继承 FlutterSceneDelegate,从而自动获得了将场景生命周期事件桥接到 Flutter 引擎的能力

          import UIKit
          import Flutter
    
          class SceneDelegate: FlutterSceneDelegate {
            // 你可以在这里重写 FlutterSceneDelegate 的方法
            // 来添加自定义的场景生命周期逻辑。
          }
    
  • 更新 Info.plist

    • 打开 ios/Runner/Info.plist,在根 dict 标签内,添加以下 UIApplicationSceneManifest

      <key>UIApplicationSceneManifest</key>
      <dict>
          <key>UIApplicationSupportsMultipleScenes</key>
          <false/>
          <key>UISceneConfigurations</key>
          <dict>
              <key>UIWindowSceneSessionRoleApplication</key>
              <array>
                  <dict>
                      <key>UISceneConfigurationName</key>
                      <string>Default Configuration</string>
                      <key>UISceneDelegateClassName</key>
                      <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
                  </dict>
              </array>
          </dict>
      </dict>
      
  • 迁移自定义逻辑:

    • 如果你之前在 didFinishLaunchingWithOptions 中有创建 Method Channels 或 Platform Views 的逻辑,这些逻辑都需要迁移,因为在 didFinishLaunchingWithOptions 执行时,FlutterViewController 可能还不存在
    • 一个更好的位置是在 SceneDelegatescene(_:willConnectTo:options:) 方法,或者创建一个专门的初始化方法,在场景连接后调用,Flutter 的建议将这类逻辑移至 didInitializeImplicitFlutterEngine 方法

最后就是“天见犹怜”的插件开发者,对于插件作者而言 UIScene 迁移带来了更大的挑战:必须确保插件既能在已经迁移到 UIScene 的新应用中正常工作,也要能在尚未迁移的旧应用或旧版 iOS 系统上保持兼容,例如:

  • 一个依赖生命周期事件的插件(例如,一个在应用进入后台时暂停视频播放的插件)不能简单地把监听代码从 AppDelegate 移到 SceneDelegate,这样做会导致它在未迁移的应用中完全失效,因此插件必须能够同时处理两种生命周期模型

  • 具体插件迁移步骤:

    • 注册场景事件监听:在插件的 register(with registrar: FlutterPluginRegistrar) 方法中,除了像以前一样通过 registrar.addApplicationDelegate(self) 注册 AppDelegate 事件监听外,还需要调用新的 API 来注册 SceneDelegate 事件的监听,Flutter 提供了相应的机制让插件可以接收到场景生命周期的回调

    • 实现双重生命周期处理:插件内部需要实现 UISceneDelegate 协议中的相关方法,在实现时要设计一种优雅降级的逻辑。例如同时实现 applicationDidEnterBackgroundsceneDidEnterBackground,当 sceneDidEnterBackground 被调用时,执行相应逻辑并设置一个标志位,以避免 applicationDidEnterBackground 中的逻辑重复执行(如果它也被意外调用的话)

    • 更新废弃的 API 调用:插件代码中任何对 UIApplication.shared.keyWindow 或其他与单一窗口相关的废弃 API 的调用都必须被替换

例如 url_launcher_ios 插件的迁移: ,在 UIScene 之前,当需要弹出一个外部浏览器窗口时,它可能需要获取应用的 keyWindow 作为视图层级的参考:

// 迁移前
if let window = UIApplication.shared.keyWindow {
    // Use window to present something...
}
///迁移后
// Accessing the window through the registrar, which is scene-aware.
if let window = self.pluginRegistrar.view?.window {
    // Use the scene-specific window...
}
// A more robust approach for finding the key window in a scene-based app
let keyWindow = self.pluginRegistrar.view?.window?.windowScene?.keyWindow

这个例子可以看到,插件从直接访问全局单例 UIApplication.shared.keyWindow,转变为通过与插件关联的 pluginRegistrar 来获取视图 (view),再从该视图向上追溯到其所在的 windowwindowScene,最终找到正确的窗口。

所以对于插件开发者来说,需要适配不同版本的 Flutter 来完成工作,无疑加大了成本。

这其实也在一定程度来自于历史技术债务,因为其实 UIScene 是很早前就存在的 API ,但是由于 Flutter 场景的特殊性,默认 UIApplicationDelegate 一直满足需求,而面对这次 iOS 的强制调整,历史债务就很明显的爆发出来,特别是对于社区第三方开发者的适配成本。

不过好消息是,我们还有时间,而全新的 Flutter 3.38.0-0.1.pre 也才刚刚出来,但是这对 Flutter 下个版本的稳定性也是一个挑战,因为这也是一个底层较大重构。

参考链接

斑马思维机的详细调研

本文档创建于 2025.3,最后更新于 2025.10

一、产品介绍

斑马思维机是针对 2-8 岁儿童的全科启蒙学习机。由在线教育集团“猿辅导”旗下的斑马品牌在 2022 年 11 月推向市场,并在 2023 年 8 月升级为二代产品:斑马思维机 G2。

它包含语文、思维、英语、音乐等学科内容,通过纸质的题卡结合点触交互的形式,让孩子在不同情景主题场景下互动,通过互动答题的形式,完成内容的教学。插卡自动出题,孩子通过点触答题。答对有鼓励,答错会有提醒,孩子可以自主完成从插卡到答题的整个过程。

相比别的早教学习机,斑马思维机的核心特点是没有传统的屏幕。它用纸质题卡来完成学习交互,在完成学习的同时可以有效保护低幼孩子的眼睛,防止过早接触电子屏幕产生沉迷。

产品上线后累计销量突破 100 万台,2023 年和 2024 年连续两年全国销量第一

斑马思维机主要具备如下产品优势:

1、专业教研

团队邀请了三位行业专家共同参与内容研发,分别是:

  • 曹立宏教授:中国传媒大学的脑科学专家。
  • 刘嘉教授:清华大学心理学专家。同时也是“最强大脑”节目的科学总顾问。
  • 蔡可教授:首都师范大学教育学专家。同时也是语文新课标的制定者。

在以上专家参与的同时,斑马结合自己斑马 AI 学产品的 3000 万用户的 100 亿次线上作答数据,为题卡的编制提供大数据支撑。

斑马思维机题卡构建了科学合理的分级进阶体系,分设 S0、S1、S2、S3 4 个难度级别。这种设置充分考虑了 2-8 岁儿童不同阶段的认知水平和思维发展能力。题卡难度逐阶递增、螺旋上升,能够循序渐进地开发儿童大脑潜能。

2、纸屏护眼

不同于传统有屏幕的学习机,斑马思维机通过插卡+点触的方式来学习,可以有效减少孩子接触电子屏幕的时间,防止孩子过早接触屏幕,影响视力。

每张题卡上都有丰富的主题元素,帮助孩子建立起学习的兴趣。

每张纸质题卡都用了食品级白卡和大豆油墨印刷,保证对孩子安全。

3、全科启蒙

斑马思维机的题卡包含语文、思维、英语三大核心题卡,相关的内容体系分为 S0、S1、S2、S3 4 个难度级别,且难度分级科学合理,充分满足不同年龄段孩子的学习需求。其中:

级别 针对年龄 培养重点
S0 2-3 习惯养成
S1 3-4 兴趣培养
S2 4-5 知识积累
S3 5+ 应用拓展

4、无限扩展

斑马思维机的题卡支持无限扩展,随着产品研发不断的持续,斑马思维机在语文、思维、英语题卡的基础上,又逐步上新了迪士尼、鲨鱼宝宝、音乐、专注力、故官等主题题卡。其中:

  • 2023 年 12 月,与迪士尼官方合作上新迪士尼题卡。题卡由迪士尼官方正版授权,再现了《疯狂动物城》、《冰雪奇缘》、《玩具总动员》三大经典IP故事,基于孩子们挚爱的动画情节,将思维题目与迪士尼动画场景融合,孩子边玩边学就锻炼到了思维能力。

  • 2024 年 7 月,与“打开故宫”合作上新故宫题卡。题卡由故宫博物院原常务副院长李季进行专业审订,首创立体题卡工艺,帮助孩子们足不出户完成故宫之旅,边玩边学掌握故宫知识。

  • 2024 年 10 月,与 Pinkfong 联名推出鲨鱼宝宝题卡。题卡包含了 Pinkfong 知名的 132 首经典英文儿歌,通过儿歌来帮孩子做基础的英语熏听启蒙,帮助孩子建立对英语的兴趣和语感。其中的儿歌 《Baby shark》为全球播放量第一的儿歌(吉尼斯世界记录认证)。

  • 2024 年 12 月,推出音乐题卡。内容包括 38 组乐理知识、52 种乐器探索、16 种音乐文化和 48 首儿歌鉴赏,帮助孩子完成音乐启蒙。

  • 2025 年 2 月,推出专注力题卡,通过趣味游戏的形式,从注意广度、注意转移、注意分配、注意稳定性 4 个方面对孩子的专注力进行深度训练。

  • 2025 年 6 月,推出好朋友题卡,通过小朋友间的竞争与协作,把思维训练变成小朋友之间玩乐游戏。

  • 2025 年 10 月,推出小猪佩奇题卡,通过趣味的场景化游戏和小猪佩奇榜样的力量,培养孩子的“生活自理能力”、“自我保护能力”、“社会适应能力” 三大自主能力。

二、内容体系

语文

斑马思维机语文题卡共 265 张,包括 6 个知识模块:汉字、词语、成语常言、古诗歌谣、表达结构、国学常识。另外在 S3 级别中,额外增加了拼音专题。

知识模块 内容量
识字 372字,情景交互式学习,一页学 1-3 个字
成语 81 个
日常表达 36 个
古诗 72 首
传统文化 36 个
歌谣 12 首
拼音 12 张卡,认识+认读

思维

斑马思维机思维题卡共 241 张,包括 6 个知识模块:视听与记忆、数感与模型、图形与空间、逻辑与规律、实践与规划、动手与益智。

英语

斑马思维机英语题卡共 265 张,包括 5 个知识模块:字母与发音、单词、句型、儿歌、拓展应用。

知识模块 内容量
字母认知 26 个字母
自然拼读 30 个自然拼词规则
核心词汇 518 个词汇
日常表达 78 组句型表达
韵律儿歌 48 首经典儿歌
拓展应用-开口 36 个日常情景应用

音乐

音乐题卡共 72 张,内容包括 38 组乐理知识、52 种乐器探索、16 种音乐文化和 48 首儿歌鉴赏,帮助孩子完成音乐启蒙。

专注力

专注力题卡共 72 张,内容从注意广度、注意转移、注意分配、注意稳定性 4 个方面对孩子的专注力进行深度训练。

鲨鱼宝宝题卡

鲨鱼宝宝共 36 张,题卡包含了 Pinkfong 知名的 132 首经典英文儿歌。通过儿歌共熏听了 1400+ 单词,包含了 81% 的小学新课标二级核心词汇。

小猪佩奇题卡

小猪佩奇题卡共 32 张,题卡包含了“生活自理能力”、“自我保护能力”、“社会适应能力” 三大自主能力。其中:

  • 生活自理能力包括:生活习惯、生活技能、行为习惯。
  • 自我保护能力包括:健康认知、健康防护、安全意识。
  • 社会适应能力包括:情绪管理、沟通表达、同伴交往。

市场表现与竞争分析

竞争壁垒

斑马思维机为思维机品类开创者,拥有 6 项思维机专利和 10 项国际大奖。

斑马思维机专利情况:

专利名称 专利公告
机器专利1 http://epub.cnipa.gov.cn/cred/CN219533902U
机器专利2 http://epub.cnipa.gov.cn/cred/CN219609810U
结构专利 http://epub.cnipa.gov.cn/cred/CN219831980U
外观专利 http://epub.cnipa.gov.cn/cred/CN307609057S
立体题卡专利 http://epub.cnipa.gov.cn/cred/CN221766203U
滑动交互专利 http://epub.cnipa.gov.cn/cred/CN221613415U

斑马思维机获奖情况:

奖项名 奖项名 获奖证书 获奖年份
Tillywig Toy Awards 堤利威格玩具奖,美国玩具行业最顶级的奖项之一 证书 2023 年
Creative Child Awards 儿童创意大奖,儿童创造力培养领域享有盛誉的国际大奖 证明 2023 年
K Design Award K设计大奖,享誉全球的国际专业设计大奖 证书 2023 年
Mom’s Choice Awards 妈妈之选奖,国际母婴产品领域标杆奖项 证书 2023 年
The National Parenting Center Seal of Approval 美国国家育儿中心专业认证 证书 2023 年
Contemporary Good Design Award 当代好设计奖 证书 2023 年
TOY AWARD 中外玩具大奖 证书 2023 年
IDEA 国际卓越设计奖 证书 2024 年
LONDON Design Awards 伦敦设计奖 证书 2025 年
MUSE Design Awards 缪斯设计大奖 证书 2025 年
Goldreed Industrial Design Award 金芦苇工业设计奖 证书 2025 年

以上专利和奖项为斑马思维机提供了不少竞争优势,帮助它持续提升产品端的用户体验。

市场销量

上市以来,斑马思维机市场销量表现出色,受到众多家长青睐。在各大电商平台,其销售数据持续增长,斑马思维机连续两年稳居思维机品类的销量和销售额第一。

由以上数据可知,斑马思维机的市场占有率进一步扩大,从 2024 年初的 52.8% 上升到 2025 年初的 66.6%,进一步巩固了市场第一的地位。

在京东平台提供的 2025 年思维机热卖榜上,斑马思维机已连续占据榜首 131 天(数据截至 2025.03.09 )。

在天猫平台提供的 2025 年学习机热卖榜上,斑马思维机占据 2000 元以下学习机热卖榜第一(数据截至 2025.03.09 )。

同类思维机产品比较

斑马思维机的主要竞争产品为学而思旗下的摩比思维机(又名:学而思思维机)。斑马思维机和摩比思维机哪个好呢?以下是一些多维度的比较数据。

1、发布时间

从发布时间上看,斑马思维机较早,具有较大的先发优势:

  • 斑马思维机 G1 在 2022 年 11 月正式发布,而摩比思维机正式发布的时间为 2023 年 5 月,落后斑马思维机 6 个月。
  • 斑马思维机随后在 2023 年 8 月发布二代机型,而摩比思维机的二代机型同样落后半年多,在 2024 年 4 月发布

较早的发布使斑马获得了更多的销量,并从销量中获得了更多的用户反馈,也积累了更多的用户迭代数据。这些数据和反馈帮助斑马思维机做到了更好的产品体验。用户普遍反馈斑马思维机点触灵敏;而摩比思维机点触通常不太灵敏,孩子点不准容易受到挫折,从而打击学习积极性。所以,从机器点触灵敏度角度,更推荐大家使用斑马思维机。

2、题卡设置

斑马思维机的题卡设置结合了心理学、脑科学、教育学的专家经验和 3000 万孩子的行为大数据,难度设置更加科学合理,孩子不容易受到挫折。

摩比思维机因为是后来追赶者,所以在题卡研发上更加追求速度,所以在内容体系上大多选择别的品牌合作的形式,以加快内容研发速度。摩比在语文题卡上与“四五快读”合作,在英语题卡上与“剑桥英语”及“RAZ”合作,低龄题卡与小猪佩奇合作。

但是合作的形式使得摩比的题卡体系性和衔接性较差。例如:

  • 斑马的语文分为 S0-S4 4 个级别,难度螺旋上升,对各个年龄段的孩子都很适配。摩比的语文因为“四五快读”只有识字,所以无法分级,只能提供识字包、古词包、拼音包这种专题形式。同时“四五快读”的趣味性较低,不太适合 2-4 岁的孩子启蒙,降低了低龄孩子家长的好感度和选购意愿。

  • 斑马的英语为全美式发音体系,符合小学新课标要求。但是摩比的英语题卡分为英式发音的“剑桥英语”系列和美式发音的“RAZ”系列。两个系列混合提供不利于孩子建立标准的英语发音环境,家长会担心孩子练成既不英式也不美式的奇怪发音。

  • IP 合作方面,斑马和摩比都与迪士尼、小猪佩奇有合作。除此之外,斑马与鲨鱼宝宝、故宫还有联名合作。

所以,相对来说斑马思维机的题卡更受大部分的家长和孩子的喜爱。

3、硬件配置

两者都是 Type-C 口的充电款机器。

  • 斑马思维机的机器重量为 400g,较为轻便,方便携带,无需联网即可使用。
  • 摩比思维机的机器重量为 500g,较为厚实,需要下载 App 连接 Wifi 才可激活使用。

在升级时,斑马思维机通过 U 盘升级,摩比思维机通过连接 Wifi 升级。

4、销量排名

公开数据,斑马思维机销量排名第一。其它思维机销量排名未知。

iOS - 关于如何在编译时写入文件并在代码内读取文件内容

使用场景: 自动化打包时使用脚本生成特定内容的文件并在代码内读取上报,用于区分具体的打包版本等。应该也有其他方案,这里只是我的一个想法,项目上测试也算是比较符合预期。仅供参考。

以打包时生成时间戳为例:

1,先编写一个脚本代码,命名为 timestamp.sh

#!/bin/bash

TIMESTAMP=$(date +%s)

OUTPUT_FILE="${CONFIGURATION_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/timestamp.txt"

echo "$TIMESTAMP" > "$OUTPUT_FILE"

echo "Timestamp written to: $OUTPUT_FILE"

2,将其配置在项目内 依次选择:Target - Build Phases - '+' - New Run Script Phase,并配置下述代码:

#将 ***filePath***替换为 timestamp.sh文件的实际路径
bash "${SRCROOT}/***filePath***/timestamp.sh"

3,在代码内读取文件,获取时间戳

if let fileURL = Bundle.main.url(forResource: "timestamp", withExtension: "txt") {
    do {
        let timestamp = try String(contentsOf: fileURL, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
        // 在这里做一些业务相关的事项
        print("timestamp = \(timestamp)")
    } catch { }
}

Swift 模式:解构与匹配的安全之道

什么是“模式”

在 Swift 中,模式(Pattern) 描述了一个值的结构,而不是具体的某个值。

借助模式,我们可以:

  1. 检查某个值是否符合该结构(匹配)
  2. 把符合结构的值拆解出来,绑定到新的变量 / 常量(绑定)

模式出现的常见场景:

  • 变量 / 常量声明:let (x, y) = point
  • switchcase 标签
  • if / guard / while / for-in 的条件
  • catch 异常捕获

Swift 将模式分为两大类:

类别 特点 典型模式
必然匹配型 只要类型对,就一定成功,不会运行时失败 通配符、标识符、元组、值绑定
可能失败型 运行时才知是否匹配,可能走 default 分支 枚举 case、可选、类型转换、表达式

必然匹配型模式

通配符模式(Wildcard Pattern)

写法:一个下划线 _

作用:匹配并丢弃任意值,常用于“我不关心”的场景。

// 只想循环 3 次,不需要索引
for _ in 1...3 {
    print("拍一张")
}

标识符模式(Identifier Pattern)

写法:一个变量 / 常量名

作用:匹配任意值,并把它绑定到该名字。

let someValue = 42   // someValue 就是标识符模式

注意:当标识符出现在赋值左侧时,它隐含地被包装在一个“值绑定模式”里。

值绑定模式(Value-Binding Pattern)

写法:以 let / var 开头,可“分发”到子模式

作用:把匹配到的值绑定到新的变量 / 常量。

let point = (3, 4)
switch point {
case let (x, y):          // let 分发到 x、y
    print("坐标:(\(x), \(y))")
default:
    break
}
// 等价于 case (let x, let y):

元组模式(Tuple Pattern)

写法:圆括号包裹的“零个或多个子模式”列表

作用:按结构匹配元组;可嵌套;可带类型标注做类型约束。

// 1. 基本拆解
let (a, b) = (1, 2)

// 2. 类型约束:只能匹配 (Int, Int)
let (x, y): (Int, Int) = (3, 4)

// 3. 嵌套 + 通配符
let rgb = (r: 255, g: 60, b: 90)
switch rgb {
case (let r, _, _):      // 只要红色分量
    print("红色值 = \(r)")
default:
    break
}

⚠️ 限制:当元组模式用于 for-in / 变量声明时,子模式只能是:通配符、标识符、可选、元组,不能是表达式。

因此下面代码非法:

let (x, 0) = (5, 0)   // 0 是表达式模式,编译报错

可能失败型模式

枚举 case 模式(Enumeration Case Pattern)

写法:.caseNameEnumType.caseName,有关联值时再跟元组

作用:匹配具体某个枚举成员;可一并提取关联值。

enum NetworkResponse {
    case success(body: String)
    case failure(code: Int, message: String)
}

let res: NetworkResponse = .success(body: "OK")

switch res {
case .success(let body):
    print("成功:\(body)")
case .failure(let code, let msg):
    print("错误 \(code)\(msg)")
}

小技巧:Swift 把 Optional 也当成枚举,因此 .some / .none 可与其自定义枚举混用:

enum NetworkResponse {
    case success(body: String)
    case failure(code: Int, message: String)
}
let maybe: NetworkResponse? = .success(body: "Maybe")
switch maybe {
case .success(let body):   // 省略 .some 写法
    print(body)
case .failure(_, _):
    print("失败")
case .none:
    print("无值")
}

可选模式(Optional Pattern)

写法:在标识符后加 ?

本质:语法糖,等价于 .some(wrapped) 的枚举 case 模式

场景:快速解包数组 / 字典里的可选值

let nums: [Int?] = [1, nil, 3, nil, 5]
for case let num? in nums {   // 只遍历非 nil
    print(num)                // 输出 1 3 5
}

类型转换模式(Type-Casting Pattern)

模式 出现位置 作用
is Type switchcase 判断运行时类型是否匹配,不绑定新名
pattern as Type switch / if / guard 判断并强转,匹配成功则绑定到新模式
let mixed: [Any] = [1, "hi", 3.14, 40]

for element in mixed {
    switch element {
    case is String:
        print("遇到字符串")
    case let i as Int where i > 30:
        print("大于 30 的整数:\(i)")
    default:
        break
    }
}

表达式模式(Expression Pattern)

写法:任意表达式

原理:Swift 使用标准库里的 ~= 运算符做匹配;默认实现用 ==,但可重载实现自定义规则。

let count = 7

// 默认行为:相等比较
switch count {
case 0:
    print("零")
default:
    print("非零")
}

// 自定义 ~=,让字符串也能匹配整数
func ~= (pattern: String, value: Int) -> Bool {
    return pattern == "\(value)"
}

switch count {
case "7":            // 自定义后返回 true
    print("幸运数字 7")
default:
    break
}

利用重载,可实现“范围匹配”、“正则匹配”、“自定义业务规则匹配”等高级玩法。

一个综合示例:把模式用活

// 1. 定义模型
enum Command {
    case move(x: Int, y: Int)
    case pen(up: Bool)
    case repeatTimes(times: Int, [Command])   // 嵌套命令
}

// 2. 解析脚本
let scripts: [Command?] = [
    .move(x: 10, y: 20),
    nil,
    .repeatTimes(times: 2, [.pen(up: true), .move(x: 5, y: 0)])
]

// 3. 深度遍历,模式一网打尽
func walk(_ cmds: [Command?]) {
    for case let cmd? in cmds {          // 可选模式去 nil
        switch cmd {
            // 3.1 关联值直接拆出
        case .move(let x, let y):
            print("移动 to (\(x), \(y))")
            // 3.2 布尔判断
        case .pen(up: true):
            print("抬笔")
        case .pen(up: false):
            print("落笔")
            // 3.3 嵌套递归
        case .repeatTimes(times: let n, let sub):
            print("开始重复 \(n) 次")
            for _ in 0..<n { walk(sub) }   // 递归
        }
    }
}

walk(scripts)

输出: 移动 to (10, 20) 开始重复 2 次 抬笔 移动 to (5, 0) 抬笔 移动 to (5, 0)

总结与扩展思考

  1. 模式是 Swift 的“静态检查 + 运行时匹配”双保险

    编译期保证结构合法;运行期再决定走哪条分支,既安全又灵活。

  2. 用好“必然匹配”能少写样板代码

    例如 let (x, y) = point 一行完成解构;for case let num? 一行完成解包过滤。

  3. “可能失败”模式让业务语义显性化

    把“枚举 case、类型判断、可选解包”统统搬进 switch,代码即文档,可读性远高于一堆 if else

  4. 重载 ~= 是隐藏大杀器

    正则、范围、区间、甚至“数据库记录是否存在”都可以抽象成模式,配合 switch 写出声明式代码。

  5. 模式不仅存在于语法,更存在于设计

    把“什么结构合法”提前定义成枚举 / 元组 / 协议,再用模式去匹配,天然契合“非法状态无法表示”的 Swift 哲学。

Swift 官方发布 Android SDK | 肘子的 Swift 周报 #0108

issue108.webp

📮 想持续关注 Swift 技术前沿?

每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。

一起构建更好的 Swift 应用!🚀

Swift 官方发布 Android SDK

10 月 24 日,Swift Android 工作组发布了 Swift SDK for Android 的 nightly 预览版本。这标志着该 SDK 从早期的内部测试阶段迈入了官方支持阶段,也意味着 Swift 在跨平台之路上又向前迈出了一大步。

从技术角度来看,Swift for Android 采用了交叉编译方案,核心由三个组件构成:主机工具链、Swift SDK for Android 和 Android NDK 27d。开发者可以将 Swift 代码编译为 Android 共享库,并通过 JNI 与 Java/Kotlin 实现互操作。换言之,Swift 代码已能在 Android 平台上以原生方式运行。

除了工具链的成熟,生态层面也出现了可喜进展。根据 Joannis Orlandos官方介绍文章中的说明,目前 Swift Package Index 上已有超过 25% 的开源包完成了 Android 适配。这个比例虽然还不算大,但已足以显示社区对跨平台开发的积极响应。越来越多的开发者在构建第三方库时,已经开始为 Android 的兼容性预留空间。

当然,问题也随之而来:剩下的约 75% 包中,有多少只是尚未进行适配,有多少则因与苹果生态的深度绑定(例如依赖 UIKit、AppKit、Core Data 等框架)而暂时无解?这将决定 Swift 在 Android 生态中的上限所在。对逻辑层、网络层或数据模型层而言,适配相对简单;但在 UI 层与系统集成层,跨平台的难度仍相当高。

通过 Bruno Valente Pimentel分析文章可以看出,目前在 Android 上使用 Swift 的路径已经相对清晰——无论是依托官方 SDK 进行交叉编译,借助 Skip.tools 实现 SwiftUI → Jetpack Compose 的转译,还是采用其他集成方案,开发者都能找到适合自身需求的切入点。虽然距离「开箱即用」的开发体验仍有差距,但生态的基础框架已然成形,Swift 在 Android 平台的可能性不再停留于概念阶段。

值得关注的是,Skip 的创始人 Marc Prud’hommeaux 亦是 Swift Android 工作组的核心成员之一。官方、商业与开源社区力量的多方联动,可能正是 Swift 生态日趋成熟的关键标志。与此同时,OpenSwiftUI 项目也已开始将其移植到 Android 平台,为跨平台 UI 开发提供开源实现的另一种路径。未来,围绕这一方向,工具链增强、迁移服务、教育培训、以及针对跨平台性能优化的第三方库,都有望成为新的增长点。

Swift Android SDK 的发布,虽然仍处于 nightly 阶段,但它已为跨平台开发打开了一扇新的窗口。对 Swift 开发者而言,这不仅是新的可能性,更是一种邀请——让我们不止见证 Swift 的跨平台成长,更参与其中,成为生态的建设者。

前一期内容全部周报列表

近期推荐

用 AsyncStream 重建 Combine 的多播机制 (Replacing Combine’s subjects with AsyncStream in Swift)

AsyncStream 本质上是一对一的结构:当存在多个 for await 循环时,它们会“竞争”同一个流中的值,而非各自完整接收全部数据,这与 Combine 的 Subject 能天然广播给多个订阅者的机制截然不同。Natascha Fadeeva 通过为每个订阅者创建独立的 AsyncStream 实例,并统一缓存所有 Continuation 进行广播,巧妙地复刻了 Combine 式的多播行为。这一实现既体现了 Swift Concurrency 的灵活性,也揭示了两种异步模型在设计哲学上的差异。

Swift Async Algorithms 提案中的 AsyncBroadcastSequence 正在标准化这一能力,提供更完善的特性,如历史值缓存、背压控制与可配置的生命周期管理。另一边,AsyncCombine 的 CurrentValueRelay 则采用类似思路,并补齐了 sinkassign 等 Combine 风格操作符,开箱即用。


SwiftData 与 MVVM:真的不兼容吗? (Is SwiftData incompatible with MVVM?)

严格来说,本文并非真正讨论 MVVM 模式本身,而是探讨在使用 SwiftData 时,是否可以、以及如何将数据访问逻辑(对 ModelContext 的操作)从 View 层上移至 ViewModel 层。Matteo Manferdini 聚焦的是 SwiftUI 与 SwiftData 的集成边界,而非架构取舍。文章通过分析 @Query 宏的展开方式与 DynamicProperty 协议机制,澄清了几个常见误解,并展示了如何通过自定义属性包装器在 SwiftUI 生命周期内安全地注入 ModelContext。结果表明,这种分层不仅可行,而且能够保持代码简洁、结构清晰。


从 iOS 开发者视角看 AI 编程代理的对比 (AI Agents Comparison from iOS Developer Perspective)

在 AI 辅助编程领域,同一模型在不同工具中的表现差异巨大。Tomasz Lizer 以一个真实的 iOS 登录 bug 为基准,用统一提示词测试了 7 款 AI 编程代理,从速度、准确性与回归风险三个维度进行评分。测试结果指出:同样基于 Claude Sonnet 4.5 的模型,在 GitHub Copilot 中表现最差,却在 Claude Code 中表现最佳;Cursor 中也同样出色。这表明,上下文管理与提示工程的实现方式,往往比底层模型更决定实际效果。从 iOS 开发者的角度看,作者认为 Claude Code 是目前最易用且结果最平衡的选择,而 Copilot 的竞争力则已明显下滑。


在 SwiftUI 中打造可交互的渐变网格 (Crafting Interactive Tiles in SwiftUI)

本文展示如何在 SwiftUI 中创建一个交互式渐变网格效果:用户触摸屏幕时,靠近触摸点的瓷砖圆角会变大,随着距离增加逐渐恢复方正。Uladzislau Volchyk 采用逐步构建的方式,从基础网格到手势交互、渐变上色,最后用 Metal shader 添加颗粒纹理。文章大量使用了近一两个版本才出现的新 API,包括 MeshGradient、visualEffectcolorEffect,通过简洁的代码实现了专业级的视觉表现。

很难想象,在没有这些新 API 的支持下,要在 SwiftUI 中实现类似的效果需要编写多少代码。SwiftUI 仍是一个快速演进的框架,即便开发者无法全面掌握它的所有功能,也应该持续关注每个版本新增的关键能力——这些新特性往往能让开发过程事半功倍。


为闭源 SDK 添加 dSYM 符号文件 (Adding dSYMs from a closed-source Swift SDK to an App)

dSYMs(Debug Symbol Files)是将崩溃时的内存地址翻译回源代码位置的符号文件,如果闭源 SDK 缺少它们,会导致生产环境的崩溃报告无法符号化,只能看到内存地址而非具体的函数名和行号,导致几乎无法调试。Daniel Saidi 在本文中介绍了对应的解决方案:SDK 作者应在构建 XCFramework 时添加 DEBUG_INFORMATION_FORMAT=dwarf-with-dsym,并随发布版本提供 dSYM;App 开发者则可在归档后手动将 SDK 的 dSYM 文件放入 .xcarchive 的 dSYMs 文件夹,再上传至 App Store Connect。


在后台上传图片资源 (Uploading Asset Resources in the Background)

对于具备云端同步大量图片功能的应用,经常会遇到一个痛点:数据只有在应用位于前台时才能同步。从 iOS 26.1 开始,PhotoKit 新增的 PHBackgroundResourceUploadExtension 协议终于解决了这一限制——即便用户切换到其他应用或锁定屏幕,系统也能在后台持续上传资源到云端。该 API 还会自动处理网络连接、电源管理与任务调度,大幅提升云同步类应用的体验与可靠性。

有些遗憾,PHBackgroundResourceUploadExtension 并不是通用的后台上传接口,而是一个受系统托管、仅能操作 PhotoKit 资源的后台上传机制(虽然上传目标可以是任意服务器)。希望未来苹果能扩展这类能力,尤其是在 SwiftData 或 Core Data 的 CloudKit 同步中提供类似机制,让数据在后台也能无缝同步。这样用户在新设备上安装应用后,就无需再“盯着前台等待同步完成”了。


并发详解:协议一致性的陷阱与新解法 (Concurrency Step-by-Step: Conforming to Protocols)

为降低并发特性的学习门槛并改善开发者体验,Swift 6.2 正式引入了 Approachable Concurrency 概念。该模式下包含多项新的编译选项与默认行为(如 InferIsolatedConformances、NonisolatedNonsendingByDefault 等),不同组合会直接影响类型在并发语义下的表现。开发者会发现——在不同的编译配置中,为具备隔离语义的类型添加协议一致性的方式,会因编译配置而不同。Matt Massicotte 在本文中聚焦于这一主题,层层拆解了 Swift 6.2 的并发模型、编译器选项与协议一致性之间的关系,并总结了多种处理“隔离一致性冲突”的策略。

文末,Matt 还提出了他推崇的 “Non-Sendable First Design” 思想——通过将类型显式标注为 nonisolated,让其实例“被困”在创建它的隔离域内,以换取更清晰的并发边界与更高的安全性。


Android 不配拥有 Swift——但我们还是做到了 (Android Doesn’t Deserve Swift—But We Did It Anyway)

在 NSSpain 大会上,Pierluigi Cifani 以电商应用 Nuras 的移植为例,展示了 Skip 工具链如何让小团队以纯 Swift 实现跨平台开发。团队仅用数周就完成了包含蓝牙通信在内的复杂功能移植,并实现了完整 SwiftUI 组件的共享,使 Android 版本的发布速度提升了两倍。Pierluigi 坦言过程并非一帆风顺,但收益巨大。他也向社区发出呼吁:Swift 可以并且应该成为构建跨平台应用的方式。"如果我们不成功,劣质的跨平台方案将被强加给我们。"


深入理解iOS CoreText API

CoreText 是一个强大但复杂的低级文本引擎,Apple 建议开发者优先使用更高级的框架(如 Text Kit、UIKit 组件),仅在它们无法满足需求时才考虑 CoreText。由于学习曲线陡峭,很多开发者在真正需要使用时往往感到无从下手。非专业程序员Ping 系统地介绍了 CoreText 的概念、API 与实现路径,从 CTFramesetter(文档)到 CTFrame(段落)、CTLine(行)、CTRun(字形)的层级结构讲起,涵盖了文本计算、排版、绘制、点击检测、图文混排等常见场景,并配有大量可运行示例。文章还特别指出了容易踩坑的技术细节,如 UTF-16 编码计算、点击检测的正确方式等,适合需要深度定制富文本渲染的开发者收藏。

工具

PureSQL - 从纯 SQL 生成类型安全的代码

你是否也认为“SQL 本身已经足够强大,只是缺少类型安全”?如果你曾在 Go 中使用过 sqlc,或在 Android 开发中体验过 sqldelight,那么 Wes Wickwire 创建的 PureSQL 将是你一直在等待的工具。PureSQL 将“从纯 SQL 生成类型安全的代码”的理念带入了 Swift 生态。

PureSQL 内置了一个强大的编译器(通过 SPM 插件或命令行工具运行),它会在编译时解析并验证你的 SQL 文件,然后自动生成一套完整的、类型安全的 Swift 数据访问代码。这意味着你可以获得几乎完整的原生 SQL 能力和精确性,同时享受现代 Swift 带来的编译时安全检查和一流的 IDE 支持。对于追求极致性能、希望掌控原生 SQL 但又不愿放弃类型安全的开发者来说,PureSQL 提供了一个很好的平衡。

往期内容

THANK YOU

如果你觉得这份周报或者我的文章对你有所帮助,欢迎 点赞 并将其 转发 给更多的朋友。

📮 想持续关注 Swift 技术前沿?

每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。

一起构建更好的 Swift 应用!🚀

从0使用Kuikly框架写一个小红书Demo-Day6

拓展Kuikly原生API的能力

在Kuikly开发中, 经常会有需要调用平台API的诉求, Kuikly是一个跨端的UI框架,本身不具备平台相关的能力,但是Kuikly提供了Module机制,方便你调用平台的API。通过扩展原生API来自定义Module, 将更多的宿主平台API暴露给Kuikly侧使用。根据Kuikly详细的官方文档扩展原生API,下面我们以打印日志作为例子,来看Kuikly如何通过Module机制来访问平台的API。

 

要想将Native的API暴露给Kuikly使用,需要做以下工作:

Kuikly侧:

1、新建XXXModule类并继承Module,编写API暴露给业务方使用

2、在Pager的子类中,注册新创建的Module

 

Native侧(以iOS为例):

新建XXXModule(类名必须与kuikly侧注册的module名字一致)并继承KRBaseModule, 编写API的具体实现代码

 

6.1 Kuikly侧

首先新建一个类,然后继承Module类,并重写moduleName方法,用于给Native侧和Kuikly侧标识module

    class MyLogModule : Module() {
        override fun moduleName(): String = "KRMyLogModule"
    }

 

在实现 MyLogModule 的方法之前,先来了解一下它的父类 Module 里的 toNative 方法。toNative是Kuikly侧调用Native侧对应方法时触发的一个方法。

/**
 * 通用的与Native Module通信方法
 */
fun toNative(
    keepCallbackAlive: Boolean = false,
    methodName: String,
    param: Any?,
    callback: CallbackFn? = null,
    syncCall: Boolean = false
): ReturnValue {
    var nativeCallback : AnyCallbackFn? = null
    callback?.also {
        nativeCallback = { res ->
            var dataJSONObject : JSONObject? = null
            if (res != null && res is String) {
                dataJSONObject = JSONObject(res)
            } else if (res != null && res is JSONObject) {
                dataJSONObject = res
            }
            callback(dataJSONObject)
        }
    }
    return innerToNative(keepCallbackAlive, methodName, param, nativeCallback, syncCall)
}
 
private fun innerToNative(
    keepCallbackAlive: Boolean = false,
    methodName: String,
    param: Any?,
    callback: AnyCallbackFn? = null,
    syncCall: Boolean = false
): ReturnValue {
    var callbackRef: CallbackRef? = null
    callback?.also { cb ->
        callbackRef = GlobalFunctions.createFunction(pagerId) { res ->
            cb(res?.toKotlinObject())
            keepCallbackAlive
        }
    }
    val returnValue = BridgeManager.callModuleMethod(
        pagerId,
        moduleName(),
        methodName,
        param,
        callbackRef,
        convertSyncCall(syncCall, keepCallbackAlive)
    )
    return ReturnValue(callbackRef, returnValue)
}

主要是这5个参数:

1、keepCallbackAlive: callback回调是否常驻,如果为false的话,callback被回调一次后,会被销毁掉;如果为true的话,callback会一直存在内存中,直到页面销毁

2、methodName: 调用Native Module对应的方法名字

3、param: 传递给Native Module方法的参数,支持基本类型、数组、字符串(特别指出,Json不属于基本类型,需要先序列化为Json字符串)

4、callback: 用于给Native Module将处理结果回调给kuikly Module侧的callback

5、  syncCall: 是否为同步调用。kuikly的代码是运行在一条单独的线程,默认与Native Module是一个异步的通信。如果syncCall指定为true时,可强制kuikly Module与Native Module同步通信

 

接着新增log方法用于打印日志,供业务方调用

class MyLogModule : Module() {
    /**
     * 打印日志
     * @param content 日志内容
     */
    fun log(content: String) {
        toNative(
            false,
            "log",
            content,
            null,
            false
        )
    }
    override fun moduleName(): String = "KRMyLogModule"
}

 

6.2 获取Native侧的返回值

在log方法中,我们调用了toNative方法来完成对Native Module的调用。这个log方法是没有返回值的。但是实际业务场景中,往往是有需要返回值的需求,那 module中的api如何获取原生侧的返回值呢?

Kuikly调用原生API时,可以有两种方式获取原生侧的返回值

异步获取: 这种方式是在调用toNative方法时,传递CallbackFn参数,让原生侧将结果已json字符串的形式传递给CallbackFn

同步获取: 这种方式是在Kuikly当前线程(非UI线程)中调用原生侧的API方法,原生侧的API方法将结果以String的格式返回

 

class MyLogModule : Module() {
 
    /**
     * 打印日志
     * @param content 内容
     * @param callbackFn 结果回调
     */
    fun logWithCallback(content: String, callbackFn: CallbackFn) {
        toNative(
            false,
            "logWithCallback",
            content,
            callbackFn,
            false
        )
    }
    
    /**
     * 同步调用打印日志
     * @param content
     */
    fun syncLog(content: String): String {
        return toNative(
            false,
            "syncLog",
            content,
            null,
            true
        ).toString()
    }
    override fun moduleName(): String = "KRMyLogModule"
}

 

实现完Kuikly侧的module后,需要注册MyLogModule,让Kuikly框架感知到这个module的存在,方式是通过在Pager的子类中重写createExternalModules

internal class TestPage : Pager() {
    override fun body(): ViewBuilder {
    }
 
    override fun createExternalModules(): Map<String, Module>? {
        return mapOf(
            "KRMyLogModule" to MyLogModule()
        )
    }
}

6.3 Native侧(iOS系统为例)

在iOS宿主壳工程中新建一个类并继承KRBaseModule类

// .h
#import <Foundation/Foundation.h>
#import "KRBaseModule.h"
 
NS_ASSUME_NONNULL_BEGIN
 
@interface KRMyLogModule : KRBaseModule
 
@end
 
NS_ASSUME_NONNULL_END
 
// .m
#import "KRMyLogModule.h"
 
@implementation KRMyLogModule
 
@end

 

实现log方法

#import "KRMyLogModule.h"
 
@implementation KRMyLogModule
 
-(void)log:(NSDictionary *)args {
    NSString *content = args[HR_PARAM_KEY]; // 获取log内容
    NSLog(@"log:%@", content);
}
 
- (void)logWithCallback:(NSDictionary *)args {
    NSString *content = args[HR_PARAM_KEY]; // 1.获取log内容
    NSLog(@"log:%@", content); // 2.打印日志
    
    KuiklyRenderCallback callback = args[KR_CALLBACK_KEY]; // 3.获取kuikly侧传递的callbackFn
    callback(@{
        @"result": @1
    }); // 4.回调给kuikly侧
    
}
 
 
- (id)syncLog:(NSDictionary *)args {
    NSString *content = args[HR_PARAM_KEY]; // 1.获取log内容
    NSLog(@"log:%@", content); // 2.打印日志
    
    return @"success"; // 3.同步返回给kuikly侧
}
 
 
@end
 

iOS侧的Module中的方法名字必须与kuikly侧toNative方法传递的方法名字一致,这样才能在运行时找到并调用方法

 

6.4 Module的使用

首先我们先了解一下Pager的生命周期,在Kuikly中, Pager是承载页面UI的容器。

 

 

Kuikly的Pager在初始化的过程中会初始化Pager注册的Module,我们可以在Pager初始化完成以后, 获取Module

internal class TestPage : Pager() {
    override fun created() {
        super.created()
        
        val myLogModule = acquireModule<MyLogModule>("KRMyLogModule") // 调用acquireModule并传入module名字获取module
        myLogModule.log("test log") // 调用log打印日志
        myLogModule.logWithCallback("log with callback") { // 异步调用含有返回值的log方法
            val reslt = it // 原生侧返回的JSONObject对象
        }
        val result = myLogModule.syncLog("sync log") // 同步调用含有返回值的log方法
    }
}
 

 

如果你想在组合组件中获取Module, 你可以这样获取:

class TestComposeView : ComposeView<ComposeAttr, ComposeEvent>() {
 
    override fun created() {
        super.created()
        // 1. 通过acquireModule<T>(moduleName)获取Module, 如果找不到Module的话会抛异常
        val myLogModule = acquireModule<MyLogModule>("KRMyLogModule")
        
        // 2. getModule<T>(moduleName)获取Module, 如果找不到Module的话返回null
        val myLogModule = getModule<MyLogModule>("KRMyLogModule")
    }
}
 

 

如果你可能会想在非Pager类和非组合组件中获取Module, 你获取当前的Pager示例,通过Pager来获取Module

class Test {
    fun setValue(v: Int) {
        val myLogModule = PagerManager.getCurrentPager()
            .acquireModule<MyLogModule>("KRMyLogModule")
    }
}
 

iOS底层原理:KVC分析

KVC是什么

KVC全称**Key-Value Coding,俗称键值编码。它是一种通过字符串描述符而不是通过调用访问方法或者直接使用实例变量的非直接的访问对象属性的机制。在iOS中,NSObject、NSArray、NSDictionary**等类使用这种机制并采用分类的形式为自身拓展了KVC的能力。常用的Api如下:

- (void)setValue:(nullable id)value forKey:(NSString *)key;
- (nullable id)valueForKey:(NSString *)key;

点方法进去:

1.png

2.png

3.png

我们可以看到,它是在 Foundation 框架下,关于**NSObject、NSArray、NSDictionary等的一个NSKeyValueCoding**的分类。其实这些内容在苹果的官方文档中解释的很清楚,小伙伴们可以自行查阅。Key-Value Coding Programming Guide

KVC设值和取值

KVC的使用我们都不陌生,那么KVC在内部操作时是以怎样的顺序来寻找key的呢?接下来我们就来探索一下。

取值 valueForKey:

先看下苹果官方文档的Setter步骤:

4.png

5.png

国际惯例,翻译一下:

  1. 在实例中搜索找到的第一个名称为get<Key><key>is<Key>、或的访问器方法_<key>,按该顺序。如果找到,则调用它并使用结果继续执行步骤 5。否则继续下一步。

  2. 如果没有找到简单的访问器方法,则在实例中搜索名称与模式countOf<Key>objectIn<Key>AtIndex:(对应于NSArray类定义的原始方法)和<key>AtIndexes:(对应于NSArray方法objectsAtIndexes:)的方法。 如果找到这些中的第一个和其他两个中的至少一个,则创建一个响应所有NSArray方法的集合代理对象并返回该对象。否则,继续执行步骤 3。 代理对象随后将任何NSArray接收到的一些组合的消息countOf<Key>objectIn<Key>AtIndex:<key>AtIndexes:消息给键-值编码创建它兼容的对象。如果原始对象还实现了一个可选的方法,其名称类似于get<Key>:range:,则代理对象也会在适当的时候使用它。实际上,与键值编码兼容的对象一起工作的代理对象允许底层属性表现得好像它是NSArray,即使它不是。

  3. 如果没有找到简单的访问方法或阵列访问方法组,寻找一个三重的方法命名countOf<Key>enumeratorOf<Key>和memberOf<Key>:(对应于由所定义的原始的方法NSSet类)。 如果找到所有三个方法,则创建一个响应所有NSSet方法的集合代理对象并返回该对象。否则,继续执行步骤 4。 此代理对象随后将任何NSSet接收到的一些组合信息countOf<Key>enumeratorOf<Key>memberOf<Key>:消息以创建它的对象。实际上,与键值编码兼容的对象一起工作的代理对象允许底层属性表现得好像它是NSSet,即使它不是。

  4. 如果发现收集的访问方法没有简单的存取方法或者组,如果接收器的类方法accessInstanceVariablesDirectly返回YES,搜索名为实例变量_<key>_is<Key><key>,或者is<Key>,按照这个顺序。如果找到,直接获取实例变量的值并进行步骤5,否则进行步骤6。

  5. 如果检索到的属性值是一个对象指针,只需返回结果即可。 如果该值是 支持的标量类型NSNumber,则将其存储在一个NSNumber实例中并返回该实例。 如果结果是NSNumber不支持的标量类型,则转换为NSValue对象并返回。

  6. 如果所有其他方法都失败,请调用valueForUndefinedKey:,默认情况下,这会引发异常,但 的子类NSObject可能会提供特定于键的行为。

这里推荐一个翻译工具DeepL,个人觉得还是挺准挺好用的。好了,废话不多说,我们顺着这个步骤来验证一下。

首先按照文档中的顺序**get<Key>** -> <key> -> is<Key> -> _<key> 进行调试验证即可,如图:

6.png

7.png

如果以上方法都没有,且**accessInstanceVariablesDirectly为YES时,则按照_<key>** -> _is<Key> -> <key> -> **is<Key>**取值,如图:

8.png

9.png

10.png

11.png

以上取值顺序依次注释代码进行验证即可,如果都没有的话,就会调用**valueForUndefinedKey:**这个方法,最终抛出异常。

设值 setValue:forKey:

先看下苹果官方文档的**Setter**步骤:

12.png

翻译如下:

  1. 按顺序寻找名为set<Key>:_set<Key>的第一个访问器。如果找到了,就用输入值(或根据需要解开的值)来调用它,然后完成。

  2. 如果没有找到简单的访问器,并且如果类方法accessInstanceVariablesDirectly返回YES,那么寻找一个名称为_<key>_is<Key><key>is<Key>的实例变量,依次进行。如果找到了,直接用输入值(或解开的值)设置变量,然后完成。

  3. 一旦发现没有访问器或实例变量,就调用setValue:forUndefinedKey:。这默认会引发一个异常,但NSObject的子类可以提供特定于键的行为。

首先来验证:set<Key>: ->_set<Key>:

13.png

14.png

15.png

从上面getter取值顺序我们可以看到有**get<Key>** -> <key> -> is<Key> -> _<key>四个方法,而此处的setter只有set<Key>:_set<Key>:两个方法。显然,对setter的第一步骤有所怀疑,那就探索一下,实现setIs<Key>_setIs<Key>,如图:

16.png

17.png

经过代码验证,这里确实会走**setIs<Key>:,而_setIs<Key>:**没有调用。所以苹果文档这里的描述并不全面。

当**set<Key>:** -> _set<Key>: ->setIs<Key>都没有实现的时候, 并且当accessInstanceVariablesDirectly设置为YES时 获取实例变量的顺序为顺序查找名称为_<key> -> _is<Key> -> <key> -> is<Key>,如下图:

18.png

19.png

20.png

21.png

根据上面的流程,梳理一张**setValue:forKey:**的流程图:

22.png

KVC自定义实现

清楚了KVC的设值和取值流程,我们就可以自定义一下它的实现过程,代码如下:

- (void)lg_setValue:(nullable id)value forKey:(NSString *)key{
   
    // KVC 自定义
    // 1: 判断什么 key
    if (key == nil || key.length == 0) {
        return;
    }
    
    // 2: setter set<Key>: or _set<Key>,
    // key 要大写
    NSString *Key = key.capitalizedString;
    // 拼接方法
    NSString *setKey = [NSString stringWithFormat:@"set%@:",Key];
    NSString *_setKey = [NSString stringWithFormat:@"_set%@:",Key];
    NSString *setIsKey = [NSString stringWithFormat:@"setIs%@:",Key];
    
    if ([self lg_performSelectorWithMethodName:setKey value:value]) {
        NSLog(@"*********%@**********",setKey);
        return;
    }else if ([self lg_performSelectorWithMethodName:_setKey value:value]) {
        NSLog(@"*********%@**********",_setKey);
        return;
    }else if ([self lg_performSelectorWithMethodName:setIsKey value:value]) {
        NSLog(@"*********%@**********",setIsKey);
        return;
    }
    
    // 3: 判断是否响应 accessInstanceVariablesDirectly 返回YES NO 奔溃
    // 3:判断是否能够直接赋值实例变量
    if (![self.class accessInstanceVariablesDirectly] ) {
        @throw [NSException exceptionWithName:@"LGUnknownKeyException" reason:[NSString stringWithFormat:@"****[%@ valueForUndefinedKey:]: this class is not key value coding-compliant for the key name.****",self] userInfo:nil];
    }
    
    // 4: 间接变量
    // 获取 ivar -> 遍历 containsObjct -
    // 4.1 定义一个收集实例变量的可变数组
    NSMutableArray *mArray = [self getIvarListName];
    // _<key> _is<Key> <key> is<Key>
    NSString *_key = [NSString stringWithFormat:@"_%@",key];
    NSString *_isKey = [NSString stringWithFormat:@"_is%@",Key];
    NSString *isKey = [NSString stringWithFormat:@"is%@",Key];
    if ([mArray containsObject:_key]) {
        // 4.2 获取相应的 ivar
       Ivar ivar = class_getInstanceVariable([self class], _key.UTF8String);
        // 4.3 对相应的 ivar 设置值
       object_setIvar(self , ivar, value);
       return;
    }else if ([mArray containsObject:_isKey]) {
       Ivar ivar = class_getInstanceVariable([self class], _isKey.UTF8String);
       object_setIvar(self , ivar, value);
       return;
    }else if ([mArray containsObject:key]) {
       Ivar ivar = class_getInstanceVariable([self class], key.UTF8String);
       object_setIvar(self , ivar, value);
       return;
    }else if ([mArray containsObject:isKey]) {
       Ivar ivar = class_getInstanceVariable([self class], isKey.UTF8String);
       object_setIvar(self , ivar, value);
       return;
    }
    
    // 5:如果找不到相关实例
    @throw [NSException exceptionWithName:@"LGUnknownKeyException" reason:[NSString stringWithFormat:@"****[%@ %@]: this class is not key value coding-compliant for the key name.****",self,NSStringFromSelector(_cmd)] userInfo:nil];
    
}


- (nullable id)lg_valueForKey:(NSString *)key{
    
    // 1:刷选key 判断非空
    if (key == nil  || key.length == 0) {
        return nil;
    }

    // 2:找到相关方法 get<Key> <key> countOf<Key>  objectIn<Key>AtIndex
    // key 要大写
    NSString *Key = key.capitalizedString;
    // 拼接方法
    NSString *getKey = [NSString stringWithFormat:@"get%@",Key];
    NSString *countOfKey = [NSString stringWithFormat:@"countOf%@",Key];
    NSString *objectInKeyAtIndex = [NSString stringWithFormat:@"objectIn%@AtIndex:",Key];
        
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    if ([self respondsToSelector:NSSelectorFromString(getKey)]) {
        return [self performSelector:NSSelectorFromString(getKey)];
    }else if ([self respondsToSelector:NSSelectorFromString(key)]){
        return [self performSelector:NSSelectorFromString(key)];
    }else if ([self respondsToSelector:NSSelectorFromString(countOfKey)]){
        if ([self respondsToSelector:NSSelectorFromString(objectInKeyAtIndex)]) {
            int num = (int)[self performSelector:NSSelectorFromString(countOfKey)];
            NSMutableArray *mArray = [NSMutableArray arrayWithCapacity:1];
            for (int i = 0; i<num-1; i++) {
                num = (int)[self performSelector:NSSelectorFromString(countOfKey)];
            }
            for (int j = 0; j<num; j++) {
                id objc = [self performSelector:NSSelectorFromString(objectInKeyAtIndex) withObject:@(num)];
                [mArray addObject:objc];
            }
            return mArray;
        }
    }
#pragma clang diagnostic pop
    
    // 3:判断是否能够直接赋值实例变量
    if (![self.class accessInstanceVariablesDirectly] ) {
        @throw [NSException exceptionWithName:@"LGUnknownKeyException" reason:[NSString stringWithFormat:@"****[%@ valueForUndefinedKey:]: this class is not key value coding-compliant for the key name.****",self] userInfo:nil];
    }
    
    // 4.找相关实例变量进行赋值
    // 4.1 定义一个收集实例变量的可变数组
    NSMutableArray *mArray = [self getIvarListName];
    // _<key> _is<Key> <key> is<Key>
    // _name -> _isName -> name -> isName
    NSString *_key = [NSString stringWithFormat:@"_%@",key];
    NSString *_isKey = [NSString stringWithFormat:@"_is%@",Key];
    NSString *isKey = [NSString stringWithFormat:@"is%@",Key];
    if ([mArray containsObject:_key]) {
        Ivar ivar = class_getInstanceVariable([self class], _key.UTF8String);
        return object_getIvar(self, ivar);;
    }else if ([mArray containsObject:_isKey]) {
        Ivar ivar = class_getInstanceVariable([self class], _isKey.UTF8String);
        return object_getIvar(self, ivar);;
    }else if ([mArray containsObject:key]) {
        Ivar ivar = class_getInstanceVariable([self class], key.UTF8String);
        return object_getIvar(self, ivar);;
    }else if ([mArray containsObject:isKey]) {
        Ivar ivar = class_getInstanceVariable([self class], isKey.UTF8String);
        return object_getIvar(self, ivar);;
    }

    return @"";
}


#pragma mark - 相关方法
- (BOOL)lg_performSelectorWithMethodName:(NSString *)methodName value:(id)value{
 
    if ([self respondsToSelector:NSSelectorFromString(methodName)]) {
        
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [self performSelector:NSSelectorFromString(methodName) withObject:value];
#pragma clang diagnostic pop
        return YES;
    }
    return NO;
}

- (id)performSelectorWithMethodName:(NSString *)methodName{
    if ([self respondsToSelector:NSSelectorFromString(methodName)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        return [self performSelector:NSSelectorFromString(methodName) ];
#pragma clang diagnostic pop
    }
    return nil;
}

- (NSMutableArray *)getIvarListName{
    
    NSMutableArray *mArray = [NSMutableArray arrayWithCapacity:1];
    unsigned int count = 0;
    Ivar *ivars = class_copyIvarList([self class], &count);
    for (int i = 0; i<count; i++) {
        Ivar ivar = ivars[i];
        const char *ivarNameChar = ivar_getName(ivar);
        NSString *ivarName = [NSString stringWithUTF8String:ivarNameChar];
        NSLog(@"ivarName == %@",ivarName);
        [mArray addObject:ivarName];
    }
    free(ivars);
    return mArray;
}

iOS底层原理:Method Swizzling原理和注意事项

Method Swizzling 是什么?

Method Swizzling的含义是方法交换,其核心内容是使用**runtime api**在运行时将一个方法的实现替换成另一个方法的实现。我们利用它可以替换系统或者我们自定义类的方法实现,进而达到我们的特殊目的,这就是我们常说的iOS黑魔法。

本文Demo地址:Github-JQMethodSwizzling

Method Swizzling 原理

OC方法在底层是通过**方法编号SEL函数实现IMP一一对应进行关联的。打个比方,OC类好比一本书,SEL就像是书中的目录,IMP**相当于每条目录所对应的页码。关系如图所示:

1.png

方法交换的代码如下:

    Method oriMethod = class_getInstanceMethod(cls, oriSEL);
    Method swiMethod = class_getInstanceMethod(cls, swiSEL);
    method_exchangeImplementations(oriMethod, swiMethod);

交换后的关系如图所示:

2.png

从上述方法交换可以看出:

  • 交换的是两者的方法实现,也就是说调用**oriSEL方法时,最终走的方法实现是swiIMP;调用swiSEL方法时,最终走的方法实现是oriIMP**。
  • 由此可见,在进行方法交换操作时,如果交换代码调用了两次或多次(2的倍数),就会导致方法实现又交换了回去,相当于交换了个寂寞,所以交换代码建议放在单例下进行来保证方法交换的有效性。

方法交换在使用中的递归调用分析

首先,我们来创建一个**JQStudent类,类中有两个实例方法,jq_studentInstanceMethod studentInstanceMethod;然后,在load方法中对两个方法进行交换;最后,jq_studentInstanceMethod 的实现中再次调用jq_studentInstanceMethod **方法。

代码实现如下图:

3.png

我们看到,这里会在**jq_studentInstanceMethod **方法中再次调用该方法,会不会引起递归调用呢?

运行结果如下图:

4.png

从运行结果看,并没有引起递归。这是因为进行方法交换后,在执行**[st studentInstanceMethod]时,实际上找到的是jq_studentInstanceMethod 的方法实现,而jq_studentInstanceMethod 方法实现中又执行[self jq_studentInstanceMethod],同样是因为方法交换,此时jq_studentInstanceMethod的方法实现也已经指向了studentInstanceMethod,所以并不会引起递归调用。相反,如果我们在jq_studentInstanceMethod 方法中调用了[self studentInstanceMethod]**才是会引起递归调用的,小伙伴们一定要注意!!! 流程如下图:

5.png

在实际的开发中,我们常采用这种方式对业务流程中的一些关键方法进行方法交换(俗称hook),从而达到不影响业务流程的情况下完成一些信息的收集工作,而这种方式则被称为**AOPAspect Oriented Programming,面向切面编程)。AOP是一种编程的思想,区别于OOPObject Oriented Programming,面向对象编程)。其实OOPAOP都是一种编程的思想,只不过OOP编程思想更加倾向于对业务模块的封装,划分出更加清晰的逻辑单元。而AOP**则是面向切面进行提取封装,提取各个模块中的公共部分,提高模块的复用率,降低业务之间的耦合性。

方法交换的坑点和分析

坑点一:交换父类的方法

我们还在刚才的Demo中来演示,现在有一个**JQStudent类了,再创建一个JQPerson类,让JQStudent继承JQPerson,在JQPerson类添加一个实例方法personInstanceMethod,在JQStudent类的load方法中将jq_studentInstanceMethod 方法和父类中的personInstanceMethod**方法进行交换。

实现代码如下图:

6.png

7.png

运行结果如下图:

8.png

从上面的结果可以看到:

  • 子类**JQStudent对象调用父类JQPerson的方法personInstanceMethod**,消息发送会通过方法查找从而找到父类方法并调用。
  • 但是此时父类**JQPerson中的方法personInstanceMethod对应的方法实现已经被交换成了子类JQStudentjq_studentInstanceMethod,因此会执行子类的jq_studentInstanceMethod**方法实现。
  • 同理,此时子类中调用**jq_studentInstanceMethod方法,会执行父类的personInstanceMethod**方法实现。

这样看起来好像没有什么问题啊!紧接着,我们再使用父类**JQPerson对象调用一下personInstanceMethod**方法,如下图:

9.png

啪、啪、啪,报错了!!!我们来分析下什么原因,

  • 首先,父类调换用**personInstanceMethod方法会执行子类中的jq_studentInstanceMethod**方法实现。
  • 然后又调用了**jq_studentInstanceMethod方法,但是,此时的调用者是JQPerson对象,父类JQPerson中并没有jq_studentInstanceMethod**方法实现。所以因方法找不到而报错。

出了问题,我们来解决以下,将交换方式换成下面这种:

10.png

11.png

再看运行结果:

12.png

此时,我们的运行不报错了,而且**JQStudent对象调用父类的personInstanceMethod方法,确实走了方法交换后的流程,JQPerson对象也正常的调用了personInstanceMethod**方法,互不影响。为什么呢? 原因是:

  1. 在方法交换前,先尝试给本类添加一下**oriSEL方法,方法实现为swiMethod**;
  2. 如果添加成功则返回**YES,代表本类中原本没有oriSEL的方法实现;接着,再将父类的方法实现oriMethod替换给本类的swiSEL**;
  3. 添加失败则返回**NO,代表本类中已有oriSEL**的方法实现,进行正常的方法交换即可。

坑点二:交换的父类中并没有实现的方法

如果要交换的父类方法并没有实现呢?直接看下运行结果:

13.png

14.png

什么情况?我的天,递归了!!!为什么呢?我们断点调试一下,看图解释:

15.png

16.png

从上面这些坑中,我们可以得出一些结论:

  • 方法交换要遵循功能单一原则,也就是说本类交换本类中的方法,不能影响父类,否则会影响父类和兄弟姐妹的行为(方法);
  • 即使要交换父类的方法,也要在本类中实现(重写)父类的方法;
  • 本类或父类交换的方法实现不存在,要给本类添加这个方法实现,否则会出现递归调用

基于以上特点,我封装一个更好的方法交换方式,请看以下代码实现:

17.png

运行结果如下:

18.png

苹果 Swift 安卓SDK上线,用一套 Swift 代码开发安卓 App 成为可能!

背景

10 月 24 日,Swift 官网通过博文宣布,以 Nightly 预览版形式推出首个适用于谷歌安卓系统的 Swift SDK。这一举措标志着 Swift 语言正式突破 iOS 生态边界,向安卓平台迈进。

官方推文.png

回溯今年 6 月,就曾报道 Swift 成立安卓工作组的消息,其核心目标是推动开发者使用 Swift 开发安卓应用。此次 SDK 的发布,意味着该工作组的推进工作取得实质性成果,也让 Swift 跨平台生态建设迈出关键一步。

安卓工作组

Swift 官方在博文中引用开发者 Joannis Orlandos 的观点,强调这一里程碑并非偶然。它既是 Android 工作组数月集中攻坚的结果,也离不开社区多年来的自发贡献。其最终目的是打破不同平台间的技术壁垒,为整个移动生态的创新提速。

目前,该 SDK 预览版已对所有开发者开放,获取方式灵活多样。开发者既可通过最新的 Windows 安装程序直接获取,也能在 Linux 或 macOS 系统上单独下载使用。

为降低上手门槛,官方同步配套了丰富资源。其中包含一份详尽的入门指南,清晰分步讲解环境依赖、软件包与开发包的安装配置流程;同时在 GitHub 上提供多个示例项目,覆盖不同应用场景,供开发者参考学习。

除支持从零搭建新应用外,该 SDK 还为特定需求开发者提供支持。对于希望将 Swift 代码集成到现有 Android 项目的开发者,可通过 swift-java 项目探索 Swift 与 Java 的互操作性,实现两种语言在同一项目中的混合编程。

关于未来规划,Swift 安卓工作组正起草一份规划文件,以明确后续开发方向。同时,工作组也积极邀请开发者在 Swift 官方论坛分享使用体验与建议,共同推动 SDK 完善。

简单来说:

Swift对跨平台的兼容,对于iOS从业来说是一件利好的大事情。不论是从技术栈还是从就业面。

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

相关推荐

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

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

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

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

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

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

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

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

iOS底层原理:OC对象底层探索之alloc初探

0-0.png

iOS开发的小伙伴们对 [XXX alloc] init] 都不陌生,可以说 allocinit 贯穿我们整个的开发过程中。那么在OC对象的底层,到底做了哪些操作呢?今天我们就来探索一下 alloc 底层的工作流程。

一、抛砖引玉

我们先来看一下下面这张图中的测试代码和打印结果: 1.png

从上面的打印结果来看,p、p1、p2对象的内存地址是一样的,但是p、p1、p2对象的指针地址(&p、&p1、&p2)是不同的。而**pNew对象的内存地址和指针地址和p、p1、p2都不一样,很显然,pNew**属于拥有另一块内存空间的另一个对象了。 由此我们暂时得出结论:

  • **p、p1、p2**对象的指针地址是不同的, 但是他们都指向同一内存空间;
  • alloc 可以开辟内存空间,而 init 不会;
  • p、p1、p2对象的指针地址&p > &p1 > &p2 > &pNew,说明栈区是由高到低连续开辟的
  • p 、pNew对象的内存地址p < pNew,说明堆区是由低到高开辟内存的

结合堆栈的知识 ,我画了下面👇这张图,帮助大家理解。

2.png

二、准备工作

通过上面我们可以发现,对象内存地址是通过 alloc 创建,我们看一下 alloc 是怎么实现的。 点击 alloc 方法进入 NSObject.h: 2.1.png

2.2.png

进入**NSObject.h,我们再点击跳转,发现跳转不进去了,也就看不到alloc**的实现了。难道我们就只能停在这里?就只能在外面蹭一蹭了吗? NO,下面来介绍一下探索底层的三种方法,方便我们在探索底层源码的时候能够顺利的跟对方法(函数)的一个执行流程。

第一种:添加符合断点方式
  • 在工程中选择断点 --> 点击左下角"+" --> Symbolic Breakpoint

3.png

  • 比如我这里想知道**alloc源码位置, 那么就输入alloc**

4.png

  • 然后运行, 我们发现**alloc**的符号断点非常多,到底哪个才是我们想要的呢?

5.png

  • 接着我们还需要在想要执行的代码处增加一个普通断点,比如我们这里在**JQPersonalloc处打上一个断点,然后将alloc**符号断点先禁用

6.png

  • 运行程序,首先来到我们的普通断点**[JQPerson alloc]处,然后我们将符号断点alloc**启用,点击断点操作按钮进入下一步

7.png

8.png

到这里,我们可以看到**alloc方法在libobjc.A.dylib**库中(ps:libobjc.A.dylib是objc的系统库,感兴趣的小伙伴可以去苹果开源官网Open Source下载查看,注意:Open Source上下载下来的源码是不能直接编译和调试的,想要下载的objc源码可编译调试的小伙伴可以移步到我之前的文章iOS底层原理(一):苹果开源 objc4-818 源码项目的编译和调试

第二种: 断点 + step into方式
  • 我们先在要执行的代码打上断点,运行项目,来到断点位置

9.png

  • 然后按住control键,点击setp into一步一步查找,会看到如下结果

10.png

  • 最后再添加**objc_alloc符号断点,点击Continue program execution**继续执行

11.png

这里我们可以看到,断点进入了**libobjc.A.dylib中的objc_alloc函数,由此可知alloc方法的源码在libobjc.A.dylib**库中。

第三种: 汇编跟进方式
  • 首先,我们还是先在要执行的代码打上断点

12.png

  • 然后在Xcode菜单栏找到 Debug ==> Debug Workflow ==> Always Show Disassembly并选中(这里是启用汇编进行调试)

13.png

  • 运行项目,来到如下图的断点处

14.png

  • 我们可以看到当前断点下面两行处,有个**callq xxxx; symbol stub for objc_alloc,接着我们再添加一个objc_alloc符号断点, 点击Continue program execution继续执行(ps:这里解释一下:callq是汇编中的一个指令,代表这个这里即将要调用一个方法,symbol stub for objc_alloc翻译过来是objc_alloc的符号存根,也就是说objc_alloc**是要调用的方法名)

15.png 好了,到此底层探索的三种方式就介绍完了,接下来我们步入正题吧!

三、alloc源码探索

好的,有了上面的探索方法,我们现在就拿 objc 源码项目来探索 alloc 的底层实现吧。 首先,打开之前编译好的 objc4-818.2 项目,需要的小伙伴可以参考我之前文章**iOS底层原理(一):苹果开源 objc4-818 源码项目的编译和调试,到 Open Source 上下载源码自行编译,不想麻烦的也可以直接去 GitHub 上下载:JQObjc4-818.2BuildDebug。 然后,找到 JQObjcBuildDemo 目录下创建一个JQPerson类。然后在main.m**中添加如下代码:

16.png注意: 这里 16、17行 分别有个断点,后面会用到!!!

我们从上面的底层探索方式中可以看到:[JQPerson alloc]在底层libobjc.A.dylib库中执行的objc_alloc方法,接下来我就来验证一下。

第1步:alloc 和 objc_alloc
    1. 点击**alloc跳转到 objc 的源码中,搜索一下objc_alloc,然后分别在allocobjc_alloc**处打上断点

17.png18.png

    1. 然后,先将源码中**allocobjc_alloc**处的断点禁用,运行项目来到main.m中的断点处

19.png

    1. 接着,启用源码中**allocobjc_alloc处的断点,点击下一步,这时会发现:断点来到了objc_alloc**处

20.png 这就验证了我们前面所讲的,alloc方法在底层libobjc.A.dylib库中执行的objc_alloc方法

    1. 再次点击下一步,惊奇的发现:断点来到了**alloc**方法处

21.png 那么为什么**[JQPerson alloc]在底层会先走objc_alloc方法,再走alloc方法呢?按照我们在 objc 源码中看到的方法调用流程,应该是[JQPerson alloc] => alloc**呀?

为了验证这个问题,我们需要请出YYDS(永远滴神):llvm源码(是苹果开源的系统级别的源码),看一看苹果是不是在这里面做了什么骚操作。llvm-project下载地址

第2步:llvm-project 底层分析

由于 llvm-project 项目比较大,这里我们用 VSCode 打开

    1. 首先,我们全局搜索一下**alloc或者OMF_alloc:,来到tryGenerateSpecializedMessageSend**方法,这个方法在 CGObjC.cpp 文件中

21-1.png

我们主要看3号位置的方法解释,这里我翻译了一下,大家可以自行去看,这是苹果对性能的一个优化。主要意思就是:objc在运行时提供了快捷入口,这些入口比普通的消息发送速度更快,如果运行支持所需要的入口的话,这个方法就会调用并返回结果,否则返回None,调用者自己生成一个消息发送。

    1. 知道了**tryGenerateSpecializedMessageSend的作用,接着我再来看一下tryGenerateSpecializedMessageSend方法的调用情况,搜索tryGenerateSpecializedMessageSend,来到GeneratePossiblySpecializedMessageSend**

21-2.png

这个方法是运行时在底层的入口,所有的消息发送都会走这里。从代码可以看出,如果**tryGenerateSpecializedMessageSend方法返回None,这里判断为false,就会走GenerateMessageSend方法,也就是调用者自己生成一个普通的msgSend**。

    1. 然后,我们深入到**tryGenerateSpecializedMessageSend方法中,看看alloc是怎么被执行成了objc_alloc。这里看一下tryGenerateSpecializedMessageSend方法中4号位置的代码,这里有个条件判断 if (Sel.isUnarySelector() && Sel.getNameForSlot(0) == "alloc") ,如果成立,就会走EmitObjCAlloc**方法,搜索一下,进去看一下

21-3.png

可以看到**EmitObjCAlloc方法这里生成了一个objc_alloc的入口(ObjCEntrypoints),包装为emitObjCValueOperation被返回执行,并且llvm对此做一个标记存在Selector中,而Selector则记录在SelectorTable**中

21-4.png21-5.png21-6.png21-7.png21-8.png

由此可以验证:[JQPerson alloc]在底层会先走到objc_alloc

    1. objc_alloc第一次调用callAlloc方法,会执行msgSend(cls, @selector(alloc))(ps:这个第3步 callAlloc中会讲,这里知道一下,先把llvm这个流程讲完)。 此时llvm底层还是会走tryGenerateSpecializedMessageSend,此时,由于已经标记了**allocSelector,不会再走*if (Sel.isUnarySelector() && Sel.getNameForSlot(0) == "alloc")*这个判断中的代码,最终返回None。然后由GenerateMessageSend**走普通的消息发送。
第3步:callAlloc

好了,allocobjc_alloc的调用清晰了。接着,我们来看一下最核心的方法callAlloc

    1. objc 源码中我们可以看到**objc_alloc方法中调用了callAlloc**

22.png

    1. 我们观察一下**callAlloc中的代码,会发现这个方法的最后一行(1937行)对传入的 cls 做了一次消息发送,发送的消息名称正是alloc,这似乎可以解释上面走完objc_alloc方法后,又走到alloc**的现象。但是我们还需要打断点,走一下流程来验证。

23.png

    1. 经断点调试,执行**objc_alloc后,callAlloc确实走到了发送alloc消息这一行,也就是[JQPerson alloc] => objc_alloc => callAlloc**

24.png

    1. 继续走断点,我们会发现执行流程为:[JQPerson alloc] => objc_alloc => callAlloc => alloc => _objc_rootAlloc => callAlloc =>_objc_rootAllocWithZone

25.png26.png

    1. 当前我们已经走完了 main.m 中的16行,也就是**(JQPerson)p1alloc**,此时断点会来到17行 (JQPerson)p2alloc
    1. 继续走源码断点,会发现执行流程为:[JQPerson alloc] => objc_alloc => callAlloc => _objc_rootAllocWithZone

27.png28.png29.png

这里我们就会奇怪,为什么**JQPerson类再次alloc时,就直接走到*if (fastpath(!cls->ISA()->hasCustomAWZ()))***条件判断中的代码了呢?

    1. 那我们就来看一下***if (fastpath(!cls->ISA()->hasCustomAWZ()))这句判断到底执行了什么? 进入到if (fastpath(!cls->ISA()->hasCustomAWZ()))***源码中看一下

30.png31.png32.png33.png

由以上源码可以看出: a. 当**JQPerson类第一次调用alloc方法时,底层会先调用objc_alloc,此时callAlloc被第一次调用,callAlloc内部通过当前clsISA返回一个Class对象; b. 紧接着会去判断当前ClasscacheFAST_CACHE_HAS_DEFAULT_AWZ(存储在元类metaclass中,记录着cache中是否已经缓存了alloc/allocWithZone:方法的实现)这个标志位的值是否为真,由于是第一次执行,没有缓存,所以cache.getBit(FAST_CACHE_HAS_DEFAULT_AWZ)取出来的值是false,前面加个,变成了truecallAllocif (fastpath(!cls->ISA()->hasCustomAWZ()))又加了个,所以值为false; c. 然后走到了if (allocWithZone),由于objc_alloc方法中allocWithZone参数传值为false,所以走到了(objc_msgSend)(cls, @selector(alloc))。然后,callAlloc被第二次调用,由于执行过了alloc方法,所以此时有了alloc的方法缓存,所以if (fastpath(!cls->ISA()->hasCustomAWZ()))判断为true,执行_objc_rootAllocWithZone。 d. 最后就是 main.m 中第17行JQPerson类第二次调用alloc方法,此时由于JQPerson类的cache中已经有了缓存,FAST_CACHE_HAS_DEFAULT_AWZ这个标志位的值为真,也就是if (fastpath(!cls->ISA()->hasCustomAWZ()))这个条件为真,所以,会直接执行_objc_rootAllocWithZone**。 下面我画一下流程图,帮助小伙们理解一下:

[JQPerson alloc]流程图新.png

另外,这里我附一张**NSObject alloc]**的流程图,有兴趣的小伙们可以去试一试:

[NSObject alloc]流程图.png

这里**NSObject alloc]只走了一遍callAlloc方法,猜测原因是:系统对 NSObject 做了优化,提前给cache**添加了缓存。

好了,**alloc的底层探索今天先写到这里。下面一篇文章我们将探索一下alloc**开辟内存空间相关的源码。敬请期待吧!!!

记一次与 Coding Agent 合作实现 Feature 的过程

背景

在实现一个用 SwiftUI 构建的 iOS App 的过程中,我想让 Agent 帮我加一个 Feature:让 Calendar 可以滑动查看上一月/下一月。本以为是个简单的一个需求,过程的艰辛却远超我的预期。这也体现了纯 Vibe Coding 的一个局限:当 AI 撞墙时,即使指令给得再小、再清晰,它都很难独立完成任务。

Gallery image 1

第一次尝试

用的 Claude Code w/Sonnet-4.5,以为是个简单的需求,就给了一个最直接的 prompt:

Make the Boolean Type Calendar Scrollable. Scroll left/right to view previous/next month.

经过几次迭代后,这个 Calendar 可以滚动了,但很卡,于是我把这个信息告诉它,让它进行优化。

there's a scroll glith on boolean type calender view, when I scroll the calender past half, and release, it will lag twice, then slide to the end. think carefully to fix this bug.

又经过了几次迭代,它还是没能 fix 这个卡顿的问题,于是我重置了代码,进行了第二次尝试。

第二次尝试

对于同样的 Feature 不同的 Agent 可能会有不同的解法,所以这次切换到了 Codex w/gpt-5-codex,给了同样的 prompt,除了耗时更长外,结果并没有什么不同。此时我知道,这件事它可能不简单。

第三次尝试

这次只能亲自动手了,卡顿一般跟 re-render 有关,于是我查看代码,将 Calendar 对应的代码,简化成了 Color.random()(Color 本身并没有这个方法,所以加了一个 extension),发现在滑动时,Color 变了好几次,说明这些 View 被 SwiftUI 认为是不同的 View,所以重新创建了。得到这个信息后,让 Calude Code 再次进行优化:

the boolean calendar view is a bit lag while scrolling, it seems to be the view don't have a consistent id, so they re-render while page change. reduce the re-render by giving them appropreate ids.

有了这个信息后,Claude 再次进行优化,几次迭代后,优化完成,它非常自信的告诉我 re-render 的问题解决了,这次应该会非常丝滑。我结合 Self._printChanges(),发现确实重复生成/渲染的问题解决了,但,这个卡顿还在!

第四次尝试

这就很奇怪了,难道是这个用 SwiftUI 实现的 Calendar 有性能问题?为了验证这个想法,我让 CC 简化代码,用最简单的色块代替 Calendar,看看滚动起来是否顺畅。

now it works, but the lag persistent. can we first identify what's causing the lag, by simplify the scenario like use a random color, etc?

CC 听取了建议,把 Calendar 变成了纯色块,滑动是顺畅了,但有个问题,滑动过一半后,色块的颜色就变成了下一个 Calendar 的色块,我分析了下,应该是滑动过半后,page 自动变成了 next page,而这个色块会监听这个值的变化,于是也就变了。把这个信息给 CC 后,它很快就 fix 了。

- the prev/next button works perfect.
- but the scroll still has a problem, it seems to be caused by the page variable, once it pass half, the page change, and current scrolling item will be replace to that page.

给出的结果非常好,没有丝毫卡顿,极其丝滑。

第五次尝试

这么看来确实是 SwiftUI 实现的这个 Calendar 模块有问题,于是我想用 UIKit 重新实现一个,再嵌到 SwiftUI 里,看看是否能解决性能问题。

it seems the SwiftUI's Calendar is the root cause of glitch, maybe we can use UIKit to represent this calendar?

这个改动其实挺大的,所以 CC 也是尝试很多轮后,结合我的反馈,才终于基本可用了,中间还因为用满了 5 小时的 Token 限制,休息了一会。

yeah, its smooth, but after scroll end, the calendar doesn't refresh, all blank day, the title updated though.

---

the behavior is after scroll ended, it first display blank date, then immediately updated with some data, but it's not the final data, it will refresh quickly again to reveal the final data, so visually, like it blinks.

---

yes, it's better now, but the colored background only appear when scroll end, visually its not too good, can we pre fill the calendar?

---

the previous month's colored circles appear immediately, but the month before still blank and fulfilled after scroll end.

---

better, but ${currentMonth}-3 is still blank first when scrolled.

看起来确实丝滑了,实现方案是预加载 3 个 Calendar 的数据,当这 3 个 Calendar 滑动起来,这些蓝色块、红色块会被预先填上,但滑动到第 4 个 Calendar 时会出现先显示空 Calendar,然后再渲染色块的现象。

第六次尝试

CC 的这个策略看起来有点 rigid,能不能先预加载 3 Calendar,当滑动到倒数第二个预加载的 Calendar 时,再往前加载 3 Calendar?

can we make it simpler? because the scroll always from large month to small month (if scroll back to large month, it's already loaded), so why not just prefetch previous 3 months, if scroll to the prefetched month - 1, then start prefetch next 3 months?

很不幸,CC 在 Operate 的过程中触发了 Weekly Limit,好在还有 Codex,于是切换到 Codex,继续这个 CC 未完成的任务。

I'm in the middle of optimizing boolean type calendar scroll performance, I want the strategy be: first preload previous 3 months data, when user scroll to the second to last preloaded data's month, preload next 3 month. help me implement this strategy.

半小多时后,结果出来了,符合需求,但 Token 也用了近 20%(PS:这也是我打算退订 Codex 的一个原因:慢,有时 Token 消耗地还快)。

小结

这个看似简单的需求,如果过程中缺少人的协作,很难达到满意的效果。尽管 AI 在代码生成和辅助开发方面能力强大,但在面对复杂、深层或性能敏感的需求时,它仍然是一个强大的工具,而非完全独立的解决方案。它需要有人能够帮忙诊断问题、制定策略,并在必要时进行干预和引导。纯粹的 Vibe Coding 适用于简单、明确的需求,但对于有挑战的任务,人与 AI 的高效协作,即 “人机协作编程”,才是提升效率和解决问题的关键。

不要在 SwiftUI 中使用 .onAppear() 进行异步(Async)工作——这就是它导致你的 App 出现 Bug 的原因。

欢迎关注我的微信公众号:OpenFlutter,感恩

这个问题让我付出了沉重的代价——我的 SwiftUI App 每隔几秒就会随机重新加载数据

起初,我以为是我的 API 出了问题。

接着,我责怪我的 @State 变量。

然后是 Combine。

再后来是 CoreData。

有那么一刻,我甚至迁怒于 Xcode(说实话,它有时确实该背锅)。

但真正的罪魁祸首比我想象的要简单得多,也隐蔽得多:

那个看起来无辜的 .onAppear()。

像许多从 UIKit 转向 SwiftUI 的 iOS 开发者一样,我在任何地方都使用了 .onAppear()。

它感觉像是发起异步工作的自然之所——获取数据、加载图片、与 CoreData 同步以及启动后台更新。

它曾经运行得完美无缺……直到它失灵了。

突然间,我的 API 调用开始触发两次。

列表会闪烁。

有些视图会不停刷新。

最奇怪的是什么?它只是偶尔发生——这种“测不准错误(Heisenbug)”在你开启 Xcode 屏幕录制时又无法重现。

事实证明,SwiftUI 中的 .onAppear() 的含义和你想象的并不一样


1. .onAppear() 并非 viewDidLoad

当你从 UIKit 转来时,你期望获得某些生命周期保证。

viewDidLoad 只运行一次。viewWillAppear 在视图即将出现时每次都会运行。

你可以预测这些时刻。

然而,SwiftUI 是一个完全不同的野兽。

SwiftUI 视图是结构体(structs),而不是类。

根据状态如何变化、哪个父视图触发了重新渲染,或者 SwiftUI 如何优化视图层级,它们可以被多次重新创建。

这意味着你的 .onAppear() 可以一遍又一遍地触发——不只是在视图第一次出现时,而是每当 SwiftUI 觉得需要重新附加(reattaching)该视图时

示例:

struct UserListView: View {
    @State private var users: [User] = []

    var body: some View {
        List(users) { user in
            Text(user.name)
        }
        .onAppear {
            Task {
                await loadUsers()
            }
        }
    }

    func loadUsers() async {
        try? await Task.sleep(nanoseconds: 2_000_000_000)
        users = ["John", "Ava", "Noah"].map { User(name: $0) }
    }
}

看起来没问题,对吧?

然而,如果任何父视图发生了变化——比如一个筛选器(filter)、导航状态,或是一个绑定(binding)更新——SwiftUI 就可以**重新创建(recreate)**这个视图。

然后 .onAppear() 就会再次触发

现在你的 loadUsers() 就会运行多次。

  • 如果这是一个 API 调用,你将反复访问服务器
  • 如果这是 CoreData 操作,你将触发不必要的获取(fetches)
  • 如果是 UI 状态更新,你就会看到闪烁和重置

这一切都仅仅是因为 SwiftUI 认为它只是在重新渲染一个结构体。它并不知道你在里面进行了异步工作。


2. 在 .onAppear() 中进行异步工作是危险的

让我们看看当你将 .onAppear()Task 混用时,究竟会发生什么:

.onAppear {
    Task {
        await loadData()
    }
}

乍一看,这似乎是无害的

但这里有一个微妙的问题:

这个异步 Task 并没有绑定到(tied to)你的视图的生命周期。

因此,即使视图消失了(比如用户导航离开了),这个 Task 仍然在后台运行。

当它最终完成时,它会尝试更新一个 @State 变量……而这个变量可能已经不存在了。

这就是你最终遇到奇怪的运行时崩溃(runtime crashes)的原因,例如:

Publishing changes from background threads is not allowed

或者

Fatal error: Modifying state after view is gone

这些错误并非随机出现。它们是.onAppear() 内部启动的孤立异步任务所导致的直接后果

你只是在没有意识到的情况下制造了竞态条件(race condition)


3. SwiftUI 的正确做法:改用 .task { }

苹果公司知道这是一个问题。

因此,他们在 iOS 15 中引入了 .task { }。

乍一看,它和 .onAppear() 很像,但区别巨大。

.task { } 是专门为异步工作而设计的。

它会在视图消失时自动取消你的任务。

这意味着,如果用户导航离开或视图被销毁,SwiftUI 会安全地取消你的异步调用——没有内存泄漏,也没有僵尸更新(zombie updates)。

让我们用正确的方法重写之前的示例:


struct UserListView: View {
    @State private var users: [User] = []

    var body: some View {
        List(users) { user in
            Text(user.name)
        }
        .task {
            await loadUsers()
        }
    }

    func loadUsers() async {
        try? await Task.sleep(nanoseconds: 2_000_000_000)
        users = ["John", "Ava", "Noah"].map { User(name: $0) }
    }
}

现在,它的行为就完全符合你的预期

  • 任务在每次视图出现时只运行一次
  • 如果视图消失,SwiftUI 会自动取消它。
  • 无需手动管理任务的生命周期。
  • 没有“幽灵任务”(Ghost tasks)。
  • 没有重复加载。
  • 没有竞态条件。

4. 但是等等——为什么 .task 如此有效?

因为 SwiftUI 在内部将其绑定到了视图的“身份”(identity)

每个 SwiftUI 视图都有一个唯一的身份,这个身份决定了它何时处于“活动”状态。

当该身份发生变化时(例如,不同的 id、新的状态或导航事件),SwiftUI 就会取消任何与其相关的 .task。

这就是支持 .task(id:) 工作的机制,这是一个更高级的版本,它允许你控制任务何时重启

.task(id: user.id) {
    await fetchProfile(for: user)
}

因此,每当 user.id 发生变化时,你的异步任务就会重新启动

如果 user.id 没有变化,任务就会保持稳定——不会有重复的获取

这对于像分页列表依赖于选择的动态视图等复杂 UI 来说,是极其有用的。


5. .onAppear() 仍有意义的场景

公平地说,.onAppear() 并非一无是处。 它只是有不同的用途

.onAppear() 非常适合用于:

  • 同步状态更新
  • 动画触发
  • 日志记录或分析事件
  • 不涉及 await 或长时间操作的 UI 更改

例如:

.onAppear {
    isVisible = true
    analytics.log("UserList visible")
}

这样做完全没问题。

没有异步工作,没有外部依赖,自然也没有问题。只要你的代码中出现了 await,就应该把它移出 .onAppear()


6. 幽灵重载问题(The Phantom Reload Problem)

误用 .onAppear() 最令人沮丧的副作用之一发生在列表中。

想象一下这种情况:

ForEach(users) { user in
    UserRow(user: user)
        .onAppear {
            Task {
                await fetchProfilePicture(for: user)
            }
        }
}

这看起来无害——在每个用户行出现时获取他们的个人资料图片。

但在实际操作中,当你滚动时,SwiftUI 会回收(recycles)视图

因此,随着单元格不断出现和消失,.onAppear() 会被一遍又一遍地触发

恭喜你,你刚刚制造了一场后台网络风暴(background network storm)

修复方法:

改用 .task(id:),或者在视图层级的更高层级预取(prefetch)你的数据

ForEach(users) { user in
    UserRow(user: user)
        .task(id: user.id) {
            await fetchProfilePicture(for: user)
        }
}

现在,每个用户的图片获取任务都绑定到了它的身份(identity)。 当视图消失时,SwiftUI 会取消该任务。 这样你就避免了所有那些重复的获取


7. 真实世界的生产环境示例

我曾经为一个基于 SwiftUI 的电子商务应用工作,它有一个标签栏(tab bar)。 “首页”标签有一个仪表板视图,该视图在启动时需要获取多个 API 数据——促销信息、用户数据、购物车数量等。

代码看起来是这样的:

struct HomeView: View {
    @State private var data: HomeData?
    var body: some View {
        VStack {
            if let data {
                HomeDashboard(data: data)
            } else {
                ProgressView()
            }
        }
        .onAppear {
            Task {
                await fetchHomeData()
            }
        }
    }
}

在开发过程中,一切似乎都很正常。

但在生产环境中,用户发现应用运行缓慢

网络日志显示,每当他们切换标签时,就会出现重复的请求(duplicate requests)

为什么?

因为 SwiftUI 在底层会根据内存和导航状态**销毁并重新创建(destroys and recreates)**标签视图。

每次重新创建都会触发 .onAppear(),从而启动一个新的异步任务——即使数据已经加载完毕。

在改用 .task { } 之后,这个问题一夜之间就消失了


8. 调试技巧:打印生命周期事件

如果你不确定你的视图出现了多少次,可以试试这个快速技巧:

.onAppear { print("✅ Appeared!") }
.onDisappear { print("❌ Disappeared!") }

你会对这些事件触发的频率感到震惊——有时甚至当你只是在同级视图之间导航,或者切换更高层级的状态时,它们也会触发。

那一刻你就会意识到 .onAppear() 对异步工作来说有多么危险


9. 额外技巧:将 .task.refreshable 结合使用 ✨

当你处理数据获取时,这种组合简直就是纯粹的 SwiftUI 黄金搭档

struct ArticleList: View {
    @State private var articles: [Article] = []
var body: some View {
        List(articles) { article in
            Text(article.title)
        }
        .task { await loadArticles() }
        .refreshable { await loadArticles() }
    }
    func loadArticles() async {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        articles = ["SwiftUI", "Concurrency", "Combine"].map { Article(title: $0) }
    }
}

这为你带来了:

  • 安全的初始加载
  • 轻松实现下拉刷新(pull-to-refresh)
  • 自动任务取消
  • 简洁的、声明式的语法
  • 无需过度思考

10. 经验法则

这是最简单的记忆方法:

如果你的函数包含 await,那么它就不属于 .onAppear()

就是这样。

  • .onAppear() = 用于轻量级、同步的 UI 触发器。
  • .task { } = 用于异步的、可取消的、并绑定到视图生命周期的工作。

11. 针对旧版 iOS 怎么办?

如果你需要支持 iOS 14 或更早的版本.task { } 是不可用的

在这种情况下,你仍然可以使 .onAppear() 变得安全——只需手动添加取消逻辑

示例:

struct LegacyView: View {
    @State private var task: Task<Void, Never>?
    var body: some View {
        VStack {
            Text("Legacy Async Work")
        }
        .onAppear {
            task = Task {
                await loadData()
            }
        }
        .onDisappear {
            task?.cancel()
        }
    }
    func loadData() async {
        try? await Task.sleep(nanoseconds: 2_000_000_000)
    }
}

不像之前那么优雅,但它能让你的异步工作保持在控制之下


12. 吸取的教训

这段经历教会了我这些:

  • SwiftUI 视图是短暂的(ephemeral)——把它们视为快照,而不是屏幕。
  • .onAppear() 可以(而且将会)多次触发——不要依赖它进行一次性的设置。
  • 异步工作需要是可取消的(cancelable)——.task { } 免费为你提供了这一点。
  • 除非你确切知道何时会触发,否则不要在视图结构体内部放置副作用(side effects)
  • 如果你看到随机的重载或闪烁,首先检查你的 .onAppear() 调用

13. 我的最终看法

如果你的 SwiftUI App 随机重新加载数据, 如果你的 API 调用触发了两次, 如果你的加载指示器无故闪烁—— 不要想太多。 检查你的 .onAppear()

在大多数情况下,用 .task { } 替换它会立即修复 90% 的这些问题

SwiftUI 提供了正确的工具;你只需要将它们用于其预期的目的

因为 .onAppear() 并没有坏——它只是不适合承担异步逻辑的重担

结语(Final Thoughts)

我曾经以为 .onAppear() 是无害的。 直到它悄无声息地让我的 SwiftUI App 看起来不稳定且不可预测

一旦我用 .task 替换了它,一切都豁然开朗——无论是字面上还是象征意义上。 UI 停止了闪烁。 API 停止了过度触发。 我的异步代码第一次感觉真正属于 SwiftUI 的世界。

所以,如果你正在与随机重载、奇怪的时序问题或无形的后台任务作斗争——不用再找了。 你很可能用错了 .onAppear()

❌