阅读视图

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

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 下个版本的稳定性也是一个挑战,因为这也是一个底层较大重构。

参考链接

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**开辟内存空间相关的源码。敬请期待吧!!!

不要在 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()

基于 Metal 的 iOS 全景视频播放器

项目简介

PNPlayer 是一个基于 Metal 框架开发的 iOS 全景视频播放器,支持 360° 全景视频播放和直观的手势控制。与传统的视频播放器不同,PNPlayer 能够让用户通过手势自由旋转视角,仿佛置身于视频场景之中,带来极具沉浸感的观看体验。

项目地址:github.com/linghugoogl…,欢迎 Star 和 Fork!

核心功能特性

PNPlayer 具备以下关键功能:

  • 🎥 高质量全景视频播放,支持 360° 全视角浏览
  • 🎮 流畅的手势控制,通过拖拽实现视角旋转
  • ⏯️ 完整的播放控制界面,包括播放 / 暂停、进度调节
  • 🔊 音频同步播放,提供完整的音视频体验
  • 🔄 实时渲染优化,确保流畅的播放体验

技术实现亮点

PNPlayer 采用了先进的技术栈和架构设计,值得开发者关注:

1. 底层技术栈

  • 渲染引擎:采用 Metal + Metal Shading Language,充分利用 iOS 设备的 GPU 性能
  • 视频处理:基于 AVFoundation + AVPlayer,实现高效的视频解码和播放控制

2. 视频播放流程

PNPlayer 设计了高效的视频数据处理流水线:

视频文件 → AVPlayerAVPlayerItemVideoOutput → CVPixelBuffer → MTLTexture

核心处理由 VideoTextureManager 负责,通过 AVPlayerItemVideoOutput 提取视频帧数据,再通过 CVMetalTextureCache 将像素缓冲区转换为 Metal 纹理,最终交由 GPU 渲染。

3. 全景渲染机制

全景渲染的核心在于将平面视频映射到球面几何体:

球面几何体 → 顶点着色器 → 纹理映射 → 片段着色器 → 屏幕输出
  • SphereGeometry 生成高精度球面网格(包含顶点和 UV 坐标)
  • 顶点着色器应用 MVP 矩阵变换,实现 3D 空间定位
  • 视频纹理通过 UV 坐标精确映射到球面
  • 相机控制器处理用户手势,实时更新视角旋转

4. 着色器核心代码

Metal 着色器是实现全景渲染的关键:

// 顶点着色器:3D 坐标变换
vertex VertexOut vertex_main(VertexIn in [[stage_in]],
                            constant Uniforms& uniforms [[buffer(1)]]) {
    out.position = uniforms.modelViewProjectionMatrix * float4(in.position, 1.0);
    out.texCoord = in.texCoord;
    return out;
}

// 片段着色器:纹理采样
fragment float4 fragment_main(VertexOut in [[stage_in]],
                             texture2d<float> colorTexture [[texture(0)]],
                             sampler colorSampler [[sampler(0)]]) {
    return colorTexture.sample(colorSampler, in.texCoord);
}

Swift 下标(Subscripts)详解:从基础到进阶的完整指南

什么是下标

官方一句话:“类、结构体、枚举可以用下标(subscripts)快速访问集合、列表、序列中的元素,而无需再写专门的存取方法。”

换句话说:someArray[index]someDictionary[key] 这种“中括号”语法糖,就是下标。

你自己写的类型也能拥有这种“中括号”魔法。

下标语法 101

subscript(index: Int) -> ReturnType {
    get {
        // 返回与 index 对应的值
    }
    set(newValue) {   // 可省略 (newValue)
        // 用 newValue 保存
    }
}

要点速记:

  1. 用关键字 subscript 开头,不是 func
  2. 参数列表可以是任意个数、任意类型。
  3. 返回值也可以是任意类型。
  4. 可以是只读(省略 set),也可以是读写。
  5. 不支持 inout 参数。

只读下标:最简单的入门示例

需求:做一个“n 乘法表”结构体,通过 table[6] 直接得到 n * 6

struct TimesTable {
    let multiplier: Int
    
    // 只读下标:省略了 get 关键字
    subscript(index: Int) -> Int {
        multiplier * index
    }
}

let threeTimesTable = TimesTable(multiplier: 3)
print(threeTimesTable[6])   // 输出 18

注意:

  • 没有 set,外界只能读不能写。
  • 如果写成 threeTimesTable[6] = 100 会直接编译报错。

可读写下标:让下标也能“赋值”

需求:自己封装一个“固定长度”的数组,禁止越界。

struct SafeArray<Element> {
    private var storage: [Element]
    
    init(repeating: Element, count: Int) {
        storage = Array(repeating: repeating, count: count)
    }
    
    // 可读可写下标
    subscript(index: Int) -> Element {
        get {
            // 越界直接崩溃,提前暴露问题
            precondition(index >= 0 && index < storage.count,
                         "Index \(index) out of range 0..<\(storage.count)")
            return storage[index]
        }
        set {
            precondition(index >= 0 && index < storage.count,
                         "Index \(index) out of range 0..<\(storage.count)")
            storage[index] = newValue
        }
    }
}

var sa = SafeArray(repeating: 0, count: 5)
sa[2] = 10
print(sa[2])   // 10
// sa[7] = 1   // 运行时触发 precondition 失败

多参数、多维度:二维矩阵实战

struct Matrix {
    let rows: Int, cols: Int
    private var grid: [Double]
    
    init(rows: Int, cols: Int) {
        self.rows = rows
        self.cols = cols
        grid = Array(repeating: 0.0, count: rows * cols)
    }
    
    // 二维下标
    subscript(row: Int, col: Int) -> Double {
        get {
            precondition(indexIsValid(row: row, col: col))
            return grid[row * cols + col]
        }
        set {
            precondition(indexIsValid(row: row, col: col))
            grid[row * cols + col] = newValue
        }
    }
    
    func indexIsValid(row: Int, col: Int) -> Bool {
        row >= 0 && row < rows && col >= 0 && col < cols
    }
    
    // 调试打印:方便看扁平化结果
    func debug() {
        print("grid = \(grid)")
    }
}

var m = Matrix(rows: 2, cols: 2)
m[0, 1] = 1.5
m[1, 0] = 3.2
m.debug()          // grid = [0.0, 1.5, 3.2, 0.0]

下标重载:一个类型多个“中括号”

下标也能像函数一样“重载”:参数类型或数量不同即可。

struct MultiSub {
    // 1. 通过 Int 索引
    subscript(i: Int) -> String {
        "Int 下标:\(i)"
    }
    
    // 2. 通过 String 索引
    subscript(s: String) -> String {
        "String 下标:\(s)"
    }
    
    // 3. 两个参数
    subscript(x: Int, y: Int) -> String {
        "二维:(\(x), \(y))"
    }
}

let box = MultiSub()
print(box[5])          // Int 下标:5
print(box["hello"])    // String 下标:hello
print(box[2, 3])       // 二维:(2, 3)

类型下标(static / class):不依赖实例也能用[]

实例下标必须“先有一个对象”;类型下标直接挂在类型上,用法类似 Type[key]

enum AppTheme {
    case light, dark
    
    // 类型下标:根据字符串返回颜色
    static subscript(name: String) -> UInt32? {
        switch name {
        case "background":
            return 0xFFFFFF
        case "text":
            return 0x000000
        default:
            return nil
        }
    }
}

// 无需实例
let bgColor = AppTheme["background"]   // 0xFFFFFF
  • 结构体/枚举用 static subscript
  • 类如果想让子类可覆写,用 class subscript

常见陷阱与调试技巧

  1. 越界问题

    Matrixprecondition 在运行时断言,开发阶段建议全开,生产环境可换成 guard + 抛出错误。

  2. 性能陷阱

    下标语法糖容易隐藏复杂逻辑。若 get 里做大量计算,会让“一行代码”拖慢整体。必要时加缓存或改用方法。

  3. 与函数歧义

    下标不支持 inout,也不能 throws。若需要这些能力,请定义成方法。

  4. 可读性

    滥用多参数下标会降低可读性。建议保持“语义直观”,例如 matrix[row, col] 很直观,但 foo[a, b, c, d] 就要谨慎。

总结

核心 3 句话:

  1. 下标 = “中括号”语法糖,语法像计算属性。
  2. 参数、返回值随便定,可重载,不支持 inout。
  3. 实例下标最常用,类型下标适合“全局字典”式场景。

扩展场景:自定义 JSON、缓存、稀疏矩阵

(1)“SwiftyJSON”式下标

struct JSON {
    private var raw: Any
    
    init(_ raw: Any) { self.raw = raw }
    
    // 允许 json["user"]["name"] 一路点下去
    subscript(key: String) -> JSON {
        guard let dict = raw as? [String: Any],
              let value = dict[key] else { return JSON(NSNull()) }
        return JSON(value)
    }
    
    // 支持数组
    subscript(index: Int) -> JSON {
        guard let arr = raw as? [Any], index < arr.count else { return JSON(NSNull()) }
        return JSON(arr[index])
    }
    
    var stringValue: String? { raw as? String }
}

let json = JSON(["user": ["name": "Alice"]])
if let name = json["user"]["name"].stringValue {
    print(name)   // Alice
}

(2)LRU 缓存下标

final class Cache<Key: Hashable, Value> {
    private var lru = NSCache<NSString, AnyObject>()
    
    subscript(key: Key) -> Value? {
        get { lru.object(forKey: "\(key)" as NSString) as? Value }
        set {
            if let v = newValue { lru.setObject(v as AnyObject, forKey: "\(key)" as NSString) }
            else { lru.removeObject(forKey: "\(key)" as NSString) }
        }
    }
}

(3)稀疏矩阵

当 99% 都是 0 时,可用字典存“非零下标”:

struct SparseMatrix {
    private var storage: [String: Double] = [:]
    
    subscript(row: Int, col: Int) -> Double {
        get { storage["\(row),\(col)"] ?? 0.0 }
        set {
            if newValue == 0.0 {
                storage.removeValue(forKey: "\(row),\(col)")
            } else {
                storage["\(row),\(col)"] = newValue
            }
        }
    }
}

下标 × 属性包装器:让“语法糖”再甜一点

从 Swift 5.1 开始,Property Wrapper 把“存储逻辑”抽成了可复用的注解;

把“下标”与“属性包装器”放在一起,可以做出带访问钩子的数组/字典,而调用端仍然只用一对中括号。

场景:线程安全数组

需求:

  • 对外像普通数组一样用 [] 读写;
  • 内部用 DispatchQueue 做同步锁;
  • 编译期自动注入,无需手写 queue.async {}

实现思路:

  1. @propertyWrapper 做一个泛型 ThreadSafeBox
  2. wrappedValue 里用 queue.sync { ... } 实现读写;
  3. 再给它一个下标投影(projectedValue 返回自身),让外部能用 $ 语法拿到“带下标的实例”。

代码:

import Foundation

@propertyWrapper
class ThreadSafeArray<Element> {
    private var storage: [Element] = []
    private let queue = DispatchQueue(label: "sync.array", attributes: .concurrent)
    
    init(wrappedValue: [Element]) {
        self.wrappedValue = wrappedValue
    }
    
    var wrappedValue: [Element] {
        get { queue.sync { storage } }
        set { queue.async(flags: .barrier) { self.storage = newValue } }
    }
    
    // 对外暴露“自身”做下标
    var projectedValue: ThreadSafeArray { self }
    
    // 下标:读,写
    subscript(index: Int) -> Element {
        get { queue.sync { storage[index] } }
        set { queue.async(flags: .barrier) { self.storage[index] = newValue } }
    }
    
    // 方便扩展 append、count 等
    func append(_ element: Element) {
        queue.async(flags: .barrier) { self.storage.append(element) }
    }
    
    var count: Int {
        queue.sync { storage.count }
    }
}

/* ============ 使用端 ============== */
class ViewModel {
    // 看似普通数组,实则线程安全
    @ThreadSafeArray var numbers: [Int] = []
    
    func demo() {
        // 1. 直接读写(走 wrappedValue)
        numbers = [1, 2, 3]
        
        // 2. 用下标(走 projectedValue)
        $numbers[0] = 99
        print($numbers[0])   // 99
        
        // 3. 并发安全
        DispatchQueue.concurrentPerform(iterations: 1000) { i in
            $numbers.append(i)
        }
        print("final count:", $numbers.count) // 1003
    }
}
let vm = ViewModel()
vm.demo()

关键点:

  • projectedValue 返回 self,于是 $numbers 就能继续用 []
  • 读写分离:读用 sync,写用 async(flags: .barrier),多读单写模型。
  • 对调用者完全透明,看起来像普通数组,却自带锁。

场景:@UserDefault 下标版

系统自带的 UserDefaults 标准写法:

UserDefaults.standard.set(true, forKey: "darkMode")

我们可以把“键”做成下标,并且用属性包装器自动同步:

@propertyWrapper
class UserDefault<T> {
    let key: String
    let defaultValue: T
    
    init(key: String, defaultValue: T) {
        self.key = key
        self.defaultValue = defaultValue
    }
    
    var wrappedValue: T {
        get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
    
    // 允许 $ 语法直接取下标
    var projectedValue: UserDefault { self }
    
    // 下标:支持动态 key
    subscript(_ suffix: String) -> T {
        get {
            let fullKey = "\(key)_\(suffix)"
            return UserDefaults.standard.object(forKey: fullKey) as? T ?? defaultValue
        }
        set {
            let fullKey = "\(key)_\(suffix)"
            UserDefaults.standard.set(newValue, forKey: fullKey)
        }
    }
}

/* ============ 使用端 ============== */
@MainActor
struct Settings {
    @UserDefault(key: "darkMode", defaultValue: false)
    static var darkMode: Bool
    
    // 投影后支持 $darkMode["iPhone"] 这种动态 key
    static func demo() {
        darkMode = true
        print(darkMode)                    // true
        
        $darkMode["iPad"] = false
        print($darkMode["iPad"])           // false
    }
}

Settings.demo()

下标与 Result Builder 的化学反应

SwiftUI 的 ViewBuilder 让大家见识到“DSL”之美;

其实我们自己也能用 Result Builder + 下标 做出“声明式语法”。

示例:做一个极简版 JSON DSL,支持这种写法:

let obj = JSON {
    "user" {
        "name" <<< "Alice"
        "age"  <<< 25
    }
}

实现要点:

  1. @resultBuilderJSONBuilder
  2. <<< 运算符把 key ~ value 塞进字典;
  3. JSON 支持 subscript(key: String) -> JSONNode 实现嵌套。
@resultBuilder
enum JSONBuilder {
    static func buildBlock(_ components: JSONNode...) -> JSONNode {
        JSONNode(children: components)
    }
}

struct JSONNode {
    enum Value {
        case string(String)
        case number(Double)
        case object([String: JSONNode])
    }
    var value: Value?
    var children: [JSONNode] = []
}

struct JSON {
    private var root: JSONNode = JSONNode()
    
    init(@JSONBuilder content: () -> JSONNode) {
        root = content()
    }
    
    // 关键下标:支持链式嵌套
    subscript(key: String) -> JSONNode {
        get {
            guard case .object(let dict) = root.value,
                  let node = dict[key] else { return JSONNode() }
            return node
        }
        set {
            if case .object(var dict) = root.value {
                dict[key] = newValue
                root.value = .object(dict)
            } else {
                root.value = .object([key: newValue])
            }
        }
    }
}

infix operator <<< : AssignmentPrecedence
func <<< (lhs: JSONNode, rhs: Any) {
    // 简化版:把 rhs 转成 JSONNode 并赋值
}

性能深度剖析:下标真的“零成本”吗?

  1. 纯“转发”下标(直接读数组)

    • 经编译器优化后,与手动裸数组访问无差别;
    • -O -whole-module-optimization 下会内联。
  2. preconditionsync 锁的下标

    • 运行时多了一次函数调用 + 条件判断;

    • 若放在热点循环,建议:

      a. 把“裸指针”提取到循环外;

      b. 或者用 withUnsafeBufferPointer 一次性处理。

  3. 多参数下标

    • 调用约定与多参函数相同,不会额外装箱;
    • 但泛型参数过多时可能触发 specialization 爆炸,注意模块划分。

Flutter插件与包的本质差异

原文:xuanhu.info/projects/it…

Flutter插件与包的本质差异

1 核心概念定义

1.1 Flutter包(Package)

  • 纯Dart实现:仅包含Dart语言编写的逻辑代码
  • 跨平台特性:不依赖任何原生平台(Android/iOS)API
  • 典型用例
    // 日期格式化工具包示例
    class DateFormatter {
      static String formatDateTime(DateTime dt) {
        return '${dt.year}-${dt.month}-${dt.day}'; 
      }
    }
    

1.2 Flutter插件(Plugin)

  • 混合架构:包含Dart接口 + 平台特定实现
  • 平台通道:通过MethodChannel进行通信
    // Dart端调用原生摄像头
    final cameraPlugin = CameraPlugin();
    final imagePath = await cameraPlugin.takePicture(); 
    
  • 原生层实现
    // Android端Java实现
    public class CameraPlugin implements MethodCallHandler {
      @Override
      public void onMethodCall(MethodCall call, Result result) {
        if (call.method.equals("takePicture")) {
          dispatchTakePictureIntent(result); // 启动相机
        }
      }
    }
    

2 底层通信机制剖析

2.1 平台通道(Platform Channel)工作原理

sequenceDiagram
    Flutter->>Native: 调用方法 (MethodCall)
    Native->>Native: 执行原生操作
    Native->>Flutter: 返回结果 (Result)

2.2 数据类型映射表

Dart类型 Android类型 iOS类型
int java.lang.Integer NSNumber
double java.lang.Double NSNumber
String java.lang.String NSString
Uint8List byte[] FlutterStandardTypedData

3 实战场景对比

3.1 何时使用纯包

  • UI组件库:如pub.dev/packages/fl…
  • 业务逻辑封装:JWT令牌解析工具
  • 状态管理:Riverpod状态管理库
// 纯Dart包实现状态管理
final authProvider = Provider<User>((ref) {
  return User.fromToken(JWTParser.parse(token));
});

3.2 何时必须用插件

// 蓝牙插件使用示例
FlutterBlue flutterBlue = FlutterBlue.instance;
flutterBlue.scan().listen((scanResult) {
  print('发现设备: ${scanResult.device.name}');
});

4 混合开发进阶技巧

4.1 FFI(外部函数接口)替代方案

import 'dart:ffi';

typedef NativeAddFunc = Int32 Function(Int32, Int32);

final dylib = DynamicLibrary.open('libmath.dylib');
final add = dylib.lookupFunction<NativeAddFunc, NativeAddFunc>('add');

void main() {
  print('3+5=${add(3, 5)}'); // 直接调用C函数
}

4.2 插件性能优化策略

  • 批处理调用:减少平台通道通信次数
  • 二进制传输:使用ByteData代替Base64
  • 后台线程:耗时操作脱离UI线程
// 图像处理优化示例
final Isolate isolate = await Isolate.spawn(_processImage, imageData);

static void _processImage(Uint8List data) {
  // 在独立isolate中处理
  final result = applyFilters(data); 
  Isolate.exit(result);
}

5 企业级项目架构

5.1 分层架构设计

lib/
├── domain/       # 业务逻辑层(纯Dart包)
├── infrastructure/ # 基础设施层
│   ├── api_client.dart # 网络请求(纯Dart)
│   └── sensors.dart    # 传感器(插件)
└── presentation/ # UI层

5.2 联邦插件(Federated Plugins)

graph TD
    A[接口包] --> B[Android实现包]
    A --> C[iOS实现包]
    A --> D[Web实现包]

6 版本兼容性管理

6.1 多平台支持矩阵

插件版本 Flutter SDK Android API iOS版本
1.x >=2.0 21+ 11+
2.x >=3.3 24+ 13+

6.2 依赖冲突解决方案

dependency_overrides:
  plugin_core: 1.2.3 # 强制统一核心版本

总结

::: tabs#platform

@tab 核心差异

  • :纯Dart逻辑复用,适用于UI组件/工具类
  • 插件:平台桥接器,用于硬件/系统服务访问

@tab 选型决策树

flowchart TD
    A[需要访问硬件/系统API?] -->|是| B[使用插件]
    A -->|否| C[开发纯Dart包]

@tab 未来演进

  • WebAssembly支持:Dart与Rust的FFI深度整合
  • 宏编程:Dart 3元编程简化插件开发
  • 统一渲染引擎:减少平台特定代码需求

::: tip 最佳实践建议
1. **优先纯包架构**:90%业务逻辑应保持平台无关
2. **插件轻量化**:原生层仅做必要代理
3. **性能监控**:使用`devtools`检查通道调用耗时
4. **联邦插件策略**:支持多平台渐进增强
:::

```dart
// 健康检查工具(同时使用包和插件)
void checkHealth() {
  final memory = SystemInfo.getMemoryUsage(); // 插件访问系统API
  final status = HealthAnalyzer.analyze(memory); // 纯Dart分析逻辑
  print('系统健康度: ${status.level}');
}

结论

技术选型

考量维度 包(Package) 插件(Plugin)
开发成本 ★★☆☆☆ ★★★★☆
跨平台一致性 ★★★★★ ★★★☆☆
系统能力访问
热重载支持 部分支持
空安全支持 100% 依赖原生实现

演进趋势预测

  1. 插件包融合:Dart FFI技术将缩小两者差异
  2. Wasm跨平台:WebAssembly可能替代部分原生插件
  3. AI代码生成:GPT工程自动生成平台通道代码

原文:xuanhu.info/projects/it…

❌