普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月4日掘金 iOS

脱离 SwiftUI 也能用 @Observable:深入理解 withObservationTracking 的玩法、坑点与 Swift 6 突围

作者 unravel2025
2025年12月4日 08:08

前言

iOS 17 引入的 @Observable 宏让 SwiftUI 刷新机制大变天,但官方文档只告诉你“在 View 里用就行”。

如果我们想在 非 SwiftUI 场景(比如 NetworkLayer、ViewModel、Unit Test)里监听属性变化,就只能靠 withObservationTracking

@Observable 是什么

特性 @Observable ObservableObject + @Published
适用系统 iOS 17+ iOS 13+
刷新粒度 属性级(仅变更的属性触发视图更新) 对象级(整个 ObjectWillChange 触发更新)
依赖协议 无需额外协议 必须继承 ObservableObject 协议
非 SwiftUI 监听 使用 withObservationTracking 闭包 使用 objectWillChange 发布者(Publisher)

一句话: @Observable 是 Swift 5.9 宏加持的“轻量级可观测对象”,专为 SwiftUI 优化,但 宏本身不限制使用场景,所以我们可以在任意线程/任意模块里手动订阅。

withObservationTracking 原理解剖

函数签名(简化):

func withObservationTracking<T>(
  _ apply: () -> T,          // ① 访问属性 → 被记录
  onChange: @autoclosure () -> @Sendable () -> Void  // ② 属性“即将”变时回调
) -> T

关键行为:

  1. apply 闭包里访问到的任何被 @Observable 标记类的存储属性,都会被加入“本次观测清单”。
  2. 当清单里任意属性发生 willSet 时,系统会执行 onChange;
  3. onChange 只会被触发一次,之后如果想继续监听,必须手动重新调用 withObservationTracking,即“递归订阅”。

最小可运行示例

import Observation

// 1. 声明被观测的模型
@Observable
class Counter {
    var count = 0
}

// 2. 声明监听者
class CounterObserver {
    let counter: Counter
    
    init(counter: Counter) {
        self.counter = counter
    }
    
    /// 开始监听,打印最新值
    func observe() {
        // ① apply 闭包:读取属性 → 被系统记录
        // ② onChange:count 即将改变时调用(旧值仍可见)
        withObservationTracking {
            print("当前计数:\(counter.count)")
        } onChange: { [weak self] in
            // 系统只给一次通知,想持续监听必须重新调用 observe()
            print("检测到变化,重新订阅")
            self?.observe()
        }
    }
}

// 3. 客户端代码
let counter = Counter()
let observer = CounterObserver(counter: counter)

// 启动监听
observer.observe()

// 制造变化
counter.count = 1   // 控制台:检测到变化,重新订阅 → 当前计数:1
counter.count = 2   // 同上

坑点 1:onChange 给出的是“旧值”

因为 onChange 触发在 willSet 阶段,所以闭包里再读属性仍是老数据。

解决思路:把读取动作推迟到下一个 RunLoop → 拿到 didSet 之后的新值。

func observe() {
    withObservationTracking {
        print("RunLoop 前读取 → 旧值:\(counter.count)")
    } onChange: { [weak self] in
        // 关键:异步到下一轮
        DispatchQueue.main.async {
            print("RunLoop 后读取 → 新值:\(self?.counter.count ?? -1)")
            self?.observe() // 继续监听
        }
    }
}

坑点 2:模板代码太多 → 二次封装

每次手写“递归 + 异步”太烦,可以提炼成一个可重用的泛型助手:

import Observation

/// 让任意闭包“持续”被监听,自动 re-subscribe
/// - Parameter execute: 需要跟踪属性的读取闭包
public func keepObserving<T: AnyObject>(
    target: T,
    execute: @escaping @Sendable () -> Void
) {
    Observation.withObservationTracking {
        execute()
    } onChange: {
        DispatchQueue.main.async {
            keepObserving(target: target, execute: execute)
        }
    }
}

使用姿势:

class CounterObserver {
    let counter: Counter
    init(counter: Counter) { self.counter = counter }
    
    func start() {
        // 捕获 [weak target] 防止循环引用
        keepObserving(target: self) { [weak self] in
            guard let self else { return }
            print("封装后读取:\(self.counter.count)")
        }
    }
}

坑点 3:Swift 6 语言模式 + Sendable

开启 Swift 6 后,编译器会报错:

Capture of 'self' with non-sendable type 'CounterObserver?' in a `@Sendable` closure

原因:

withObservationTracking 的 onChange 要求 @Sendable,意味着闭包里不能捕获“非 Sendable”的引用类型。

@Observable 类默认包含可变状态,无法自动符合 Sendable;如果我们把 Observer 标成 @MainActor,又会触发“MainActor隔离属性不能出现在 Sendable 闭包”的新错误。

折中方案

  1. 把 Observer 整体标为 @MainActor
  2. 在闭包内部再包一层 Task { @MainActor in ... } 异步读取;
  3. 或者 给 Observer 打上 @unchecked Sendable 并自行保证线程安全(例如全部属性都通过 DispatchQueue 或 Actor 同步)。

示例:@MainActor 内再开 Task

@MainActor
class CounterObserver: Sendable /* 手动保证 */ {
    init(counter: Counter) {
        self.counter = counter
    }
    
    let counter: Counter
    
    nonisolated func start() {
        Observation.withObservationTracking { [weak self] in
            // 这里不能直接接触 counter,因为它被隔离在 @MainActor
            // 只记录“我关心”的事实,不读取值
            return () // 仅触发跟踪
        } onChange: { [weak self] in
            Task { @MainActor in
                guard let self else { return }
                print("Swift6 模式新值:\(self.counter.count)")
                self.start() // 继续
            }
        }
    }
}

注意:由于 apply 闭包不能跨 Actor 读取,我们只能“空跑”跟踪,再在 Task 里安全取值。

这会导致 一次 onChange 只能异步拿到值,且如果属性变化非常快,中间事件可能丢失。

在 Swift 6 严苛模式下,Combine 的 @Published 仍是更成熟的答案。

完整可落地模板

//
//  ObservableUtils.swift
//  用前导入 Observation 模块
//

import Foundation
import Observation

/// 线程安全且支持 Swift 6 的“属性监听”工具箱
public actor ObservableListener {
    
    private var token: (() -> Void)? // 用于将来做手动取消
    
    /// 持续监听目标对象指定 KeyPath 的新值
    /// - Parameters:
    ///   - object: 被 @Observable 标记的实例
    ///   - keyPath: 要读取的 KeyPath
    ///   - handler: 变化后异步回调新值
    public func keep<T: Any, V>(
        object: T,
        keyPath: KeyPath<T, V>,
        handler: @escaping (V) -> Void
    ) where T: Observable {
        // 用 Task 保证与 actor 隔离
        Task {
            Observation.withObservationTracking {
                // 空读取,只为登记依赖
                _ = object[keyPath: keyPath]
            } onChange: { [weak object] in
                guard let object else { return }
                Task { @MainActor in
                    // 下一轮 RunLoop 拿到新值
                    handler(object[keyPath: keyPath])
                }
                // 继续监听
                self.keep(object: object, keyPath: keyPath, handler: handler)
            }
        }
    }
}

// ====== 使用示例 ======
@Observable
class User {
    var name = "Tom"
    var age  = 18
}

let listener = ObservableListener()
let user = User()

Task {
    await listener.keep(object: user, keyPath: \.name) { newName in
        print("用户名已变更为:\(newName)")
    }
}

user.name = "Jerry"   // → 用户名已变更为:Jerry
user.name = "Spike"   // → 用户名已变更为:Spike

总结与思考

  1. 宏 ≠ 魔法

    @Observable 只是帮你自动生成 ObservationRegistrar 代码,真正的订阅逻辑仍依赖 withObservationTracking

  2. willSet 语义是最大绊脚石

    这意味着它更适合“触发刷新”而不是“精确拿到新值”。如果你必须依赖“每一次新值”,要么异步到下一轮,要么回到 Combine。

  3. 递归订阅是官方默许的“官方模式”

    别嫌它丑,目前 API 就是这样设计的;封装后可以让调用方无感。

  4. Swift 6 的 Sendable 检查让“跨 Actor 读取”几乎无解

    除非苹果将来放宽 withObservationTracking@Sendable 要求,否则在严苛并发场景下,Combine/AsyncSequence 才是更稳妥的事件源。

  5. 适用场景推荐

    • ✅ 局部刷新:日志面板、调试计数器、调试 UI。
    • ✅ SwiftUI 外部但主线程内:Widget 的 Timeline 生成、Preview 更新。
    • ⚠️ 高吞吐实时数据:音频采样、传感器 120Hz 上报 → 建议 Combine + 环形缓冲区。
    • ❌ 需要线程跳变/Actor 隔离:Swift 6 模式下成本

学习资料

  1. www.donnywals.com/observing-p…

iOS 知识点 - Category / Extension / Protocol 小合集

作者 齐生1
2025年12月3日 16:27

谈到 OC 基础,错不开的三种机制:Category / Extension / Protocol

它们分别解决了:

  • Category:不修改类源代码、不继承的前提下,给已有类 “添加方法”(组织文件、系统类加功能、AOP 风格 hook 等)。
  • Extention: 在实现文件里补充声明 私有 属性、实例变量。
  • Protocol: 只定义 “接口规范”(方法/属性的声明),不提供实现,用于解耦(代码只依赖协议,不依赖具体类)& 多态(不同类实现同一协议,都可赋给协议限定类型id<Protocol>

Category

  • 概念:category 是一种给已有类(包括系统类)增加实例方法/类方法的机制,不需要子类化,也不需要访问原类源码。

  • 限制

    • 不能直接增加新实例变量(ivar),但是可以通过 关联对象 间接添加 “类似属性” 的存储。
        #import <objc/runtime.h>
    
        static const void *kNameKey = &kNameKey;
    
        @implementation NSObject (Name)
    
        - (void)setName:(NSString *)name {
            objc_setAssociatedObject(self, kNameKey, name, OBJC_ASSOCIATION_COPY_NONATOMIC);
        }
    
        - (NSString *)name {
            return objc_getAssociatedObject(self, kNameKey);
        }
        
        @end
    
    • 不能直接访问原类 pivate 类型的变量/方法(同子类),必须要原类在 .h 中公开声明。
  • 编译后的本质category 在编译后,额外的方法会被编译器 “合并” 到原类的 method_list 中,runtime 加载类时一起注册。

    • 简化过程
      1. 编译:每个 .m 中的 @implementation ClassName (CategoryName) 生成一个 category_t 结构,其中包含:
        • class name
        • instance methods list
        • class methods list
        • protocol list
      2. 程序加载:runtimeload_images 时遍历所有 category
        • 找到对应 class
        • 把 category 的 methods_list 插入到 class 的 methods_list 列表前方。(同名方法覆盖原来的实现)
  • ⚠️注意事项

    • 如果分类与原类(或其他分类)有同名方法,后加载的 method 会 覆盖前面的实现(行为依赖于加载顺序)
  • 经典用途

    • 给类添加方法(NSArray+Utils.h 等)
    • 拆分类的实现,按照功能分块(常用于 swift 代码风格)
    • 方法交换(日志、埋点、hook 等)

runtime 讲解篇:juejin.cn/post/757172…

延伸名词 runtimeload_images(面向切面编程)

  • 概念load_images 是 runtime 的一个内部函数,在 dyld 把一个新的 Mach-O image (主程序 / 动态库 / 插件) 加载进来时,会回调 runtime 的 map_images / load_images 这一整套流程。

  • 接口作用

    • 注册 image 里的 类列表、分类列表、协议列表、选择子 等;
    • 把 category 的 方法/协议 挂载到对应的类上;
    • 收集并按照一定顺序调用 +load 方法(先类,后分类)。

你也可以简化理解成:每当一块儿新的二进制文件被载入进程,runtime 就用 load_images 把这块儿里的 oc 元数据接入到系统里。

延伸名词 AOP(面向切面编程)

含义:一种编程思想,能够在「不改动原有代码逻辑」的前提下,在指定的“切面点”上插入额外逻辑(如埋点、日志、监控、权限校验等)。

核心概念 含义
切点(Pointcut) 想“切入”的位置,比如方法调用前/后
通知(Advice) 在切点执行的额外逻辑(before、after、around)
切面(Aspect) 切点 + 通知 的组合
织入(Weaving) 把这些逻辑动态插入代码执行流程的过程

iOS 实现 AOP 的方法

  1. 方法交换:在交换方法中实现新的逻辑
  2. 消息转发:利用 forwardInvocation:resolveInstanceMethod: 在运行时拦截消息,再“转发”到自己的处理逻辑。
  3. 三方库:Aspects

Extension

  • 概念: 在类的实现被编译前,给它再“补充”一些

    • 方法声明
    • 属性声明
    • 额外 ivar(实例变量)
  • ⚠️注意事项:

    • Extension 必须和 @implementation MyClass 在同一个编译单元或可见范围,这样编译器才会把它当成类定义的一部分,生成 ivar 和访问器方法 (setter/getter)。

Protocol

  • 概念:接口规范与解耦,只写签名、不写实现。
  • 可以包含
    • 实例/类方法
    • Property 声明。

@required@optional 的意义和成本

编译期行为

  • @required:类遵循了这个协议,但是没实现 required 方法 → 编译器 warning
  • @optional:完全由你自己决定是否实现,编译器不强制。

运行时行为

  • 协议本身只是一堆元数据(protocol_t),runtime 存储了:
    • 协议有哪些 @required@optional 的方法;
    • 哪些是实例 / 类方法;
    • 哪些是 property。
  • 但是消息派发是不看协议的:
    • 派发时只看这个对象的方法列表里有没有该 selector;
    • 至于这个 selector 来自哪个协议、是否声明在协议里,派发阶段都不关系。
    /// 因此,在调用时常配合:
    if ([self.delegate respondsToSelector:@selector(doOptionalThing)]) {
        [self.delegate doOptionalThing];
    }
    

协议里的 property 到底是什么?

前置知识:协议只写签名,不写实现。

  • 实际上在协议中声明的 @property (nonatomic, copy) NSString *name; 仅仅等价于 setter、getter 的声明(无实现、无 ivar)。
- (NSString *)name;
- (void)setName:(NSString *)name;

protocol 经典用途

代理模式

  • 例如:vc 处理在 view 上的交互事件,view 通过代理调用 vc 实际处理事件的方法。
/// View.h
@interface View : UIView

@property (nonatomic, weak) id<MyViewDelegate> delegate;

@end

/// View.m
- (void)buttonTapped {
    if ([self.delegate respondsToSelector:@selector(didClickButton:)]) {
        [self.delegate didClickButton:self];
    }
}

/// ViewController.m
@interface ViewController () <MyViewDelegate>
@end

@implementation ViewController

#pragma mark - MyViewDelegate
- (void)didClickButton:(MyView *)view {
    // 处理事件
}
  • 这样就做到了:解耦 + 多态 + 依赖倒置

模块/组件之间的接口抽象 类型约束/API设计

protocol vs 继承

协议 继承
本质 接口集合,不带实现、不带存储 类型扩展机制,带实现、带存储
语义关系 描述 “能做” 某些事情 描述 “就是” 某种事物
数量 一个类可实现 个协议 一个类只能有 个直接父类(多继承例外)
主要用途 解耦、抽象、多态 复用、建立层次结构
编译期检查 检查是否实现 required 方法(warning) 检查 override 签名、类型转换等
运行时检查 conformsToProtocol:
 class_conformsToProtocol
isKindOfClass:
 isMemberOfClass:

延伸知识点 isKindOfClass:isMemberOfClass:

  • isKindOfClass:: 是不是这个类或它的子类
- (BOOL)isKindOfClass:(Class)aClass {
    for (Class c = object_getClass(self); c; c = class_getSuperclass(c)) {
        if (c == aClass) return YES;
    }
    return NO;
}
  • isMemberOfClass:: 是不是这个类本身(不包括子类)
- (BOOL)isMemberOfClass:(Class)aClass {
    return object_getClass(self) == aClass;
}

对于实例对象(instance)

@interface Animal : NSObject
@end

@interface Dog : Animal
@end

Animal *a = [Animal new];
Dog    *d = [Dog new];

// isKindOfClass:
[a isKindOfClass:[Animal class]]; // YES  (Animal 本身)
[a isKindOfClass:[Dog class]];    // NO   (Animal 不是 Dog 家族)

[d isKindOfClass:[Animal class]]; // YES  (Dog 是 Animal 的子类,被认为是 Animal 家族的一员)
[d isKindOfClass:[Dog class]];    // YES  (Dog 本身)

// isMemberOfClass:
[a isMemberOfClass:[Animal class]]; // YES  (a 的 class 恰好是 Animal)
[a isMemberOfClass:[Dog class]];    // NO   (class 是 Animal,不是 Dog)

[d isMemberOfClass:[Animal class]]; // NO   (class 是 Dog,不是 Animal)
[d isMemberOfClass:[Dog class]];    // YES  (class 恰好是 Dog)

对于类对象(class)

/// ❌错误的用法
// [Animal isKindOfClass:Animal]; // 左侧的 “Animal” 被 oc 语法解释为消息接受者,右侧的 “Animal” 会被认作 “类型名”,编译器报错参数异常。
// 

// ✅正确的用法
[Animal isSubclassOfClass:[Animal class]]; // YES (正经用法)
[Animal isKindOfClass:[Animal class]];     // NO  (不正经用法)
  • [Animal isKindOfClass:[Animal class]]; 为什么输出 NO?
    • 首先,在 runtime 讲解中已知,类本身也是对象,类型为 Class
    • 再结合 isKindOfClass: 方法的实现:
      1. object_getClass(self) 开始循环向上查询
      2. self 是类对象本身(class object), 首次查询的结果就是 metaClass object;
      3. class object != metaClass object,已经错过了类对象本身,因此返回 NO。

独立开发者亲测:MLX框架让我的App秒变AI原生!15年iOS老兵的2025新感悟

作者 JZXStudio
2025年12月3日 11:16

大家好,我是K哥,一个写了15年iOS代码的独立开发者。从Objective-C时代一路写到Swift,见证过App Store的黄金十年,也熬过内卷最狠的那几年。但2025年,我第一次感受到——AI真的不是噱头,而是每个iOS开发者都能亲手掌控的生产力革命

这一切,都源于苹果在WWDC25正式力推的 MLX框架


🚀 以前做AI功能?难如登天

过去想在App里加个智能推荐、图像生成或语音理解,要么调用第三方API(贵+慢+隐私风险),要么自己啃PyTorch(iOS端部署?别想了)。作为独立开发者,既没算力也没团队,AI功能基本是“看看就好”。

但MLX彻底改变了游戏规则。


🔥 MLX到底强在哪?亲测三大颠覆点

✅ 1. 本地跑大模型,不联网也能AI原生

MLX专为Apple Silicon优化,M1/M2/M3芯片直接硬件加速。我在Mac mini M2上跑7B参数的文本生成模型,响应速度比某些云API还快!用户数据全程留在设备端,隐私合规不再是难题——这在欧盟DSA和国内个保法时代太重要了。

✅ 2. Swift + Python双支持,老iOS人无缝上手

作为Swift死忠粉,我本担心要重学Python生态。结果MLX同时提供Swift和Python API!我用Swift直接调用预训练模型,几行代码就给我的笔记App加上了“智能摘要”和“灵感扩写”功能。不用改架构,不依赖后端,三天上线AI模块——真·秒变AI原生。

✅ 3. 不仅能推理,还能本地微调!

最震撼的是:MLX支持在Mac上直接训练和微调模型。我用自己积累的用户行为数据(脱敏后)微调了一个小模型,个性化推荐准确率提升40%。以前这得租GPU集群,现在一台Mac搞定,成本趋近于零。


💡 给同行的3条实战建议

  1. 别等“完美模型”:从小场景切入,比如“图片自动打标签”、“输入补全”、“语音转待办事项”,用户感知强,开发量小。
  2. 善用Lazy Computation:MLX的延迟计算特性可大幅减少内存占用,这对iPhone内存敏感场景至关重要。
  3. 关注多模态融合:2025年趋势是“文本+图像+音频”联动。比如用户拍张图,App自动生成图文笔记——MLX全支持!

🌟 写在最后:这是属于独立开发者的黄金时代

曾几何时,我们觉得AI是大厂的游戏。但MLX的出现,把顶级AI能力交到了每一个Mac用户手中——而你我,正是最懂如何把它变成好产品的那群人。

15年iOS开发教会我:工具会变,平台会变,但“解决用户真实问题”的初心不变。而今天,MLX给了我们前所未有的杠杆。

如果你也在做独立App,别犹豫——去Apple Developer官网下载MLX,跑通第一个Demo。你会发现,AI原生,真的只差一行import。


#iOS开发 #AI原生应用 #MLX框架 #独立开发者 #AppleSilicon #WWDC25 #程序员日常 #AI创业 #Swift开发 #2025技术趋势

❌
❌