阅读视图

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

深入理解 Swift Codable:从基础到进阶

目录

  1. Codable 简介
  2. 与 HandyJSON 的差异
  3. 最小可用示例
  4. 字段映射:CodingKeys
  5. 可选值、缺失字段与默认值
  6. 枚举解析与回退策略
  7. 日期、Data 与 KeyStrategy
  8. 自定义编解码:进阶技巧
  9. 快速回顾
  10. 结语

本文示例代码仓库:github.com/wutao23yzd/…

Codable 简介

Codable 是 Swift 4 引入的协议组合,等价于 Encodable & Decodable。只要让自定义类型遵循 Codable,Swift 编译器即可 自动合成 JSON / Property-list 的编解码代码,免去了早期手写 init(from:) / encode(to:) 的大量样板。

场景:网络层 JSON ↔︎ Model、持久化、本地缓存、跨进程消息

与 HandyJSON 的差异

大多数项目中使用HandyJson作为序列化方案,因为使用方便,不需要考虑异常情况处理。但相较于 HandyJSON 依赖 Mirror 与 Objective-C Runtime 的反射机制、在编译期几乎不进行类型检查且遇到错配时常以 Any 兜底的“弱类型”方案,Codable 由 Swift 官方维护,采用编译期自动合成编解码逻辑,无需额外运行时注入,既避免了反射带来的性能开销,也让所有字段在编译阶段就能获得严格的类型安全保障;同时,Codable 纯值语义实现让大对象的 JSON 解析更高效、更易优化。综合 性能、类型安全 与 长期维护 三大维度,Codable 显著优于依赖第三方维护且潜在风险更多的 HandyJSON,因此在现代 iOS 项目中,使用 Codable 并逐步弃用 HandyJSON 是更可持续、可靠的选择。

Tips

  • HandyJSON 的反射式实现里,JSON 字段与模型属性类型不一致时,尝试用 Any 或默认值把解析继续做完。
  • 当我们说 “Codable 纯值语义实现更高效” 时,强调的是在大多数 JSON-Model 场景下,推荐用 struct(值类型)承载数据——能最大化编译期优化、减少 ARC 与指针跳转,让解析大 JSON 更快、更省内存。

最小可用示例

struct User: Codable {
    let id: Int
    var username: String
    var age: Int?
}

let json = "{\"id\":1,\"username\":\"Tom\",\"age\":21}".data(using: .utf8)!
let user = try? JSONDecoder().decode(User.self, from: json)

无需实现任何函数,User 即可在编译期获得自动合成的 init(from:)encode(to:)

字段映射:CodingKeys

后端接口常使用 snake_case(下划线分隔),而 Swift 倾向于 camelCase(驼峰)。可通过:

struct User: Codable {
    var userId: Int
    var userName: String

    enum CodingKeys: String, CodingKey {
        case userId = "user_id"
        case userName = "user_name"
    }
}

如果全局皆为 snake_case,可以让 JSONDecoder 使用 keyDecodingStrategy = .convertFromSnakeCase,省去逐字段编写 CodingKeys

struct User: Codable {
  let userId: Int
  var userName: String
  var age: Int?
}
let json = "{\"user_id\":1,\"user_name\":\"Tom\", \"age\":21}".data(using: .utf8)!
      
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
      
let user = try? decoder.decode(User.self, from: json)

可选值、缺失字段与默认值

类型兼容的容错解码

后端有时把数值字段当字符串返回,或反之。直接 decode(Int.self, forKey: ...) 遇到类型错配会抛错甚至导致 try? 解码为 nil。可通过 KeyedDecodingContainer 扩展兼容多种物理类型:

extension KeyedDecodingContainer {
    
    func decodeIfPresent(_ type: Int.Type, forKey key: Key) throws -> Int? {
        if let intValue = try? decode(Int.self, forKey: key) {
            return intValue
        }

        if let stringValue = try? decode(String.self, forKey: key),
           let intFromString = Int(stringValue) {
            return intFromString
        }
        return nil
    }
}

比如,下面的demo代码中,json字符串 age是字符串,User模型中ageInt类型

struct User: Codable {
  let id: Int
  var username: String
  var age: Int?
}
let json = "{\"id\":1,\"username\":\"Tom\",\"age\":\"21\"}".data(using: .utf8)!

let user = try? JSONDecoder().decode(User.self, from: json)

Tips
在绝大多数,Codable 的自动合成已经足够。属性类型的格式多变,比如:可以是Int、String、Int,以及动态不确定的情况,建议手写 init(from:)解码,(往往还要配对写 encode(to:))。

@Default 属性包装器

如果接口缺少某字段,Swift 默认会抛错;若字段标记为可选则变成 nil,但业务经常希望有 合理默认值。通过属性包装器封装一次即可:

protocol DefaultValue { 
    associatedtype Value: Codable
    static var defaultValue: Value { get } 
}

@propertyWrapper
struct Default<T: DefaultValue>: Codable {
    var wrappedValue: T.Value
}

extension Default: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = (try? container.decode(T.Value.self)) ?? T.defaultValue
    }
}

extension KeyedDecodingContainer {
  func decode<T>( _ type: Default<T>.Type, forKey key: Key) throws -> Default<T> where T: DefaultValue {
    try decodeIfPresent(type, forKey: key) ?? Default(wrappedValue: T.defaultValue)
   }
}

extension String {
    enum Empty: DefaultValue {
        static let defaultValue = ""
    }
    enum Zero: DefaultValue {
        static let defaultValue = "0"
    }
}

extension Int {
    enum Zero: DefaultValue {
        static let defaultValue = 0
    }
}

extension Bool {
    enum False: DefaultValue {
        static let defaultValue = false
    }
    enum True: DefaultValue {
        static let defaultValue = true
    }
}

extension Double {
    enum Zero: DefaultValue {
        static let defaultValue = 0.0
    }
}

extension Default {
    typealias True = Default<Bool.True>
    typealias False = Default<Bool.False>
    typealias EmptyString = Default<String.Empty>
    typealias ZeroString = Default<String.Zero>
    typealias ZeroDouble = Default<Double.Zero>
}

在下面的Demo代码中,json字符串中,只有2个字段,但User模型,却有6个字段,我们通过属性包装器添给相应字段加了默认值,如果不添加,Swift 默认会抛错。

struct User: Codable {
    let id: Int
    var username: String
    
    var age: Int?
    
    @Default.EmptyString
    var city: String
    
    @Default.True
    var gender: Bool
    
    @Default<Int.Zero>
    var count: Int
}

let json = "{\"id\":1,\"username\":\"Tom\"}".data(using: .utf8)!
let user = try? JSONDecoder().decode(User.self, from: json)

Tips

  • 建议为常用默认值(0""false 等)列出 TypeAlias,方便复用。
  • 避免盲目把所有字段设为可选并在业务层解包,集中在模型层兜底更安全。

枚举解析与回退策略

新增服务端枚举值,老版本 App 解析时会崩溃。解决思路:为枚举声明默认 case,在解码失败时兜底。

protocol CodableEnumeration: RawRepresentable, Codable where RawValue: Codable {
    static var defaultCase: Self { get }
}

extension CodableEnumeration {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        do {
            let decoded = try container.decode(RawValue.self)
            self = Self.init(rawValue: decoded) ?? Self.defaultCase
        } catch {
            self = Self.defaultCase
        }
    }
}

下面的demo代码中,Jsongender为2,但Gender 枚举并没有定义对应的枚举值,于是解析成了unknown

enum Gender: Int, CodableEnumeration {
    case unknown = -1
    case male = 0
    case female = 1
    
    static let defaultCase: Self = .unknown
}

struct User: Codable {
    let id: Int
    var username: String
    
    var age: Int?
    
    var gender: Gender
    
    @Default.EmptyString
    var city: String
    
    @Default<Int.Zero>
    var count: Int
}
let json = "{\"id\":1,\"username\":\"Tom\", \"gender\": 2}".data(using: .utf8)!
let user = try? JSONDecoder().decode(User.self, from: json)

日期、Data 与 KeyStrategy

  • DateDecodingStrategy
    当用 JSONDecoder 把 JSON 里的时间字段解码成 Date 时,可以告诉解码器 “这个字段的格式是什么”
    • .iso8601:标准 RFC-3339
    • .secondsSince1970 / .millisecondsSince1970
    • .formatted(DateFormatter)——完全自定义
  • DataDecodingStrategy
    决定JSON 里的那段内容要如何还原成 Swift 的 Data,常用的策略只有两个
    • .base64 ——默认
    • .custom——自定义解析策略
  • keyDecodingStrategy / EncodingStrategy
    驼峰和下划线编解码时的一对互逆策略
    • .convertFromSnakeCase.convertToSnakeCase

示例: 日期解析

    let jsonISO   = #"{"created_at":"2025-06-07T12:34:56Z"}"#.data(using: .utf8)!
    let jsonSecs  = #"{"created_at":1720353296}"#.data(using: .utf8)!
    let jsonCustom = #"{"created_at":"07/06/2025 12:34"}"#.data(using: .utf8)!
    struct Payload: Codable { let createdAt: Date }
    
    let dec = JSONDecoder()
    dec.keyDecodingStrategy = .convertFromSnakeCase
    //  ISO-8601
    dec.dateDecodingStrategy = .iso8601
    print(try? dec.decode(Payload.self, from: jsonISO).createdAt)

    // 秒
    dec.dateDecodingStrategy = .secondsSince1970
    print(try? dec.decode(Payload.self, from: jsonSecs).createdAt)

    // 自定义格式
    let f = DateFormatter()
    f.dateFormat = "dd/MM/yyyy HH:mm"
    f.locale = Locale(identifier: "en_US_POSIX")
    f.timeZone = TimeZone(secondsFromGMT: 0)
    dec.dateDecodingStrategy = .formatted(f)
    print(try? dec.decode(Payload.self, from: jsonCustom).createdAt)

Data解析

// 1.base64
struct Avatar: Codable { let raw: Data }
let json = "{\"raw\":\"R0lGODlhAQABAIAAAACwAAAAAAQABAAA\"}".data(using: .utf8)!
let avatar = try? JSONDecoder().decode(Avatar.self, from: json)  // 默认.base64

// 2.自定义解析Data
struct Payload: Codable { let blob: Data }

let dec = JSONDecoder()
dec.dataDecodingStrategy = .custom { decoder in
    let container = try decoder.singleValueContainer()
    let hex = try container.decode(String.self)
    guard let data = Data(hexString: hex) else {
        throw DecodingError.dataCorruptedError(in: container,
            debugDescription: "Hex string is invalid")
    }
    return data
}

let json1 = "{\"blob\":\"48656c6c6f\"}".data(using: .utf8)!
let obj1  = try? dec.decode(Payload.self, from: json1)

自定义编解码:进阶技巧

泛型响应

在许多网络层封装中,常见的做法是使用泛型结构体作为响应模型,例如:

struct ApiResponse<T: Codable>: Codable {
    var code: Int
    var message: String
    var data: T?
}

当泛型参数 T 遵循 Codable 协议时,Swift 的编解码机制能够自动完成嵌套对象的递归解析,几乎无需手动干预 比如:

enum ErrorCode :Int, Codable {
    case Success = 0
    
    case Failed = -1
}

struct ApiCustomResponse<T: Codable>: Codable {
    var code: ErrorCode = .Failed
    var message: String?
    var data: T?
}

struct User: Codable {
  let id: Int
  var username: String
  var age: Int?
}

class NetWork {
   static func request<T: Codable>(_ modelType: T.Type = T.self) throws -> T {
       let json = "{\"code\":0, \"data\":{\"id\":1,\"username\":\"Tom\",\"age\":21}}".data(using: .utf8)!
       return try JSONDecoder().decode(modelType, from: json)
    }
}

do {
    let user = try NetWork.request(ApiCustomResponse<User>.self)
    print("\(user)")
} catch {
    print("\(error)")
}

示例中 JSON 字符串被成功解码为ApiCustomResponse<User>实例,data 字段为具体业务模型

多态模型

data 字段根据 kind 不同返回不同子结构,可在 init(from:) 中先 decode kind 再 switch 动态 decode —— Swift 5.9 引入 any Codable 将进一步简化。

如下示例代码:同一个 data 节点会因为 kind 不同而呈现 完全不同的内部结构;在 init(from:) 里 先解标签再 switch,手动调用相应的 decode(SubType.self, …)


enum Kind: String, Codable { case photo, video, audio }

protocol Media: Codable {}

struct Photo: Media  { let url: URL; let width: Int; let height: Int }
struct Video: Media  { let url: URL; let duration: Double; let codec: String }
struct Audio: Media  { let url: URL; let bitrate: Int }

struct Wrapper: Codable {
    let kind: Kind
    let data: Media          // ← 不同子类型都实现 Media

    enum CodingKeys: String, CodingKey { case kind, data }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        kind = try c.decode(Kind.self, forKey: .kind)

        switch kind {
        case .photo:
            data = try c.decode(Photo.self, forKey: .data)
        case .video:
            data = try c.decode(Video.self, forKey: .data)
        case .audio:
            data = try c.decode(Audio.self, forKey: .data)
        }
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(kind, forKey: .kind)

        switch data {
        case let p as Photo: try c.encode(p, forKey: .data)
        case let v as Video: try c.encode(v, forKey: .data)
        case let a as Audio: try c.encode(a, forKey: .data)
        default:
            throw EncodingError.invalidValue(data,
                .init(codingPath: c.codingPath, debugDescription: "Unknown media type"))
        }
    }
}

快速回顾

  • Codable 自动合成:遵循 Codable 即可获得编解码能力,避免手写样板。
  • 字段映射:CodingKeys + JSONDecoder.keyDecodingStrategy = .convertFromSnakeCase 两种思路兼容后端 snake_case。
  • 容错解码策略:@Default 属性包装器、带 defaultCase 的枚举、手写 init(from:) 兜底。
  • 进阶技巧:泛型响应、多态模型、日期 & Data 自定义策略。

结语

Codable 并非“能用即止”的黑盒。深入理解其自动合成规则、容错边界与自定义扩展点后,可以大幅提升 稳定性代码可维护性。借助属性包装器与协议抽象,将解码 策略 前置到 Model 层,实现“上游正确,下游简单”。

社交的本质是价值交换,请不要浪费别人的时间。

序言

2025年也已经过去一半,简单聊聊这半年遇到的一些奇葩的人和事儿。顺便讲一个真事儿,给大家当乐子

在最近半年遇到最多的就是白嫖党,套话的人占多数。如果不尊重知识付费,那么就请自己尝试就好。另外,4.3(a)的过审是不区语言栈的,最终都是通过生成ipa传包。

白嫖客.jpg

大家都是成年人了,不用互相套路挺没意思的。既得不到自己有用的内容,还浪费了别人的时间。免费的才是最贵的。大环境不容易钱不好赚了,都想不花钱,少花钱,把自己的问题解决了,所以也针对性的写了免费的应对方案,帮助那些真正有需要的人。

wechat_2025-06-07_102057_849.png

过于包过这件事

很多人遇到4.3(a),想到就是包过甚至之言不过别人包过才xxxx。对于我来说做咨询就是副业中最费力不讨好一个事情,所以合适就聊不合适就算了。每个人的情况都不一样其他类型产品,基本上按照方案都可以顺利通过,但是社交的传奇的修仙的,都是作为Appstore重灾区,100%包过和骗钱有什么区别?

同时,如果是付费的用户那肯定是尽吾所能,解他人之围。但是想白嫖就算了,有那被人浪费的时间,我不如继续花心思在自营的产品上。

提到自营产品,总有人喜欢问有什么好项目搞?。对不起,无可奉告。这种问题本身就是伪命题,赚钱的告诉你,你也未必信,不赚钱的给你说了,你又不会做。 所以,少问一些无效沟通的问题。

套话.jpg

讲个真事儿

故事的主人公,张三-我之前的同事,李四-我之前同事的同事。

张三:我朋友这边有一个原生项目需要处理,你看看有时间搞不。
我:可以啊,反正聊聊看。我说这种肯定是不免费的。
张三:我给他说了大概了费用。那我拉个群,具体的你们自己聊。

过了3~5分钟。

李四:我有着之前下架的账号的代码,现在想上架。因为3~4年没有做iOS
感觉有些力不从心。就是想把那套代码搞上架,但是怕4.3。
我:之前产品是主动下架还是手动上架,需求是不是上架就可以?

李四:手动下架,是因为担心有风险,现在又想重新上架。
我:那这块情况费用大概是xxxx。如果不需要改代码只是出方案是xxxx。

李四:不需要改代码,太久不做了搞不来了。然后我之前也是iOS。这套代码现在运行不起来
我也不知道因为什么,不知道咋弄了。
我(心想是张三介绍的):信得过,那代码发给我看看,反正都是原生的也行吧。信不过,就自己研究就好了。

李四找代码打包发邮箱。在这期间不停讲项目背景,每个月自己分红多少多少。
之前业务有多大多大。什么都提了,维度没有提钱。

--------------
其实这个时候,已经意识到是个白嫖党了,碍于张三的面子。我花了30分钟把代码跑起来。
--------------

我:这代码第三方库不少,反正跑起来费用的话还是之前说的xxxx。
李四:你看能不能重构一下?我担心¥#%#……#¥!#(除了没有提钱,又说了一车轱辘无用的废话)

--------------
其实这个时候,我已经不想做了没意思。纯白嫖来的,费用这块只字未提,需求却从上架又升级
到了重构+上架。
--------------

我:行吧,先不扯别的了。我这边还有其他的事情,这套代码跑起来打包你。你自己来吧。
李四:那行吧。

--------------
至此一共浪费了我2个小时,心里想着跑起来的代码就是顺水人情了。我以为这事情已经结束了,
我是万万没想到,晚上竟然还有后续。
--------------

张三:你什么情况?咋搞不了了?
我:能搞啊,我不想搞了。第一个这哥们是来白嫖,钱只字不提。第二个这哥们需求都没有搞清楚,
优柔寡断的。
张三,打断我:行,能干就行。我去谈一下,刚刚也给说什么重构啥的。我给他报个3xxxx。
我:估计没戏,反正你谈吧。谈的成,就搞呗。

--------------
过了2个小时之后,张三给我打电话过来。
--------------

张三:我也是服了,非得喊我去吃饭。我过去的时候,他们3个人在星巴克里,拿着一杯蜜雪冰城。
我:我k,这也太那啥了
张三:我本来不想去,到了还问我吃饭没有。我说还没有,就到附近找一个小餐馆。我把费用给他们
说了。结果,那几个人也是一顿吹牛皮。
我:猜到了,我上午沟通的时候就感觉挺没意思的。
张三:情况是李四3个月前就答应别人了,结果一直没给别人弄。现在别人急着要,李四现在露底了。
他们的需求就是上架,李四想重构,把这套东西抓自己手里。另外三爷子,安卓端都是稳定的,肯定
是不愿意的,跟他们尬聊了半天。我看也没啥意思,就撤了。李四最后还是说那我自己来吧。
我:这不是又白瞎了。


--------------
截止于此,我和张三花费的时间差不多有5~6个小时,结果是0收获。
--------------

所以,希望大家都能远离这种无效社交,不要被垃圾人浪费自己的时间

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

由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(三)

在这里插入图片描述

概述

从 WWDC 23 开始,苹果推出了崭新的数据库框架 SwiftData。而在今年的 WWDC 24 中苹果再接再厉,为 SwiftData 2.0 加入了全新的历史记录追踪(History Trace)、“墓碑(Tombstone)”等诸多激动人心的新功能。

在这里插入图片描述

那么,究竟什么是 History Trace?我们又该如何驾驭它呢?

在本篇博文中,您将学到如下内容:

  1. SwiftData 2.0 中全新的 History Trace 机制如何改变游戏规则?
  2. 如何实时监听 SwiftData 持久存储中数据的变化?

这是本系列第三篇博文。闲言少叙,让我们马上开始 SwiftData 精彩的探究之旅吧!

Let‘s dive in!!!;)


5. SwiftData 2.0 中全新的 History Trace 机制如何改变游戏规则?

在今年的 WWDC 24 中,苹果推出了 SwiftData 2.0。新版本 SwiftData 在增强原有功能的基础之上还添加了一系列全新的特性,其中就包括 History Trace

在这里插入图片描述在这里插入图片描述

历史记录追踪(History Trace)机制专门用来查询 SwiftData 模型数据更改的历史记录,主要来说它有以下几种用途:

  • 了解数据存储何时发生了更改?发生了什么更改?即使记录从数据库中被彻底删除之后仍然可以获取其部分信息(“墓碑”机制);
  • 了解如何使用该信息构建远程服务器同步;
  • 处理进程外的更改事件;

History Trace “雪中送炭”的一个用处是同步 App 和 Widgets 间的数据变化,这在 SwiftData 2.0 之前我们需要用比较复杂的手段才可以完成,而用历史跟踪则会非常简单。

那么有的小伙伴可能要问了:History Trace 对于 SwiftData 后台数据更改与界面之间的同步又有什么用呢?

其实,History Trace 的核心是监听持久存储上数据的改变,而我们 App 中不同模型上下文都对应着同一个底层存储,这意味着:我们可以在主上下文中利用 History Trace 捕捉到其它私有上下文对底层数据修改的“蛛丝马迹”!


注意:History Trace 只存在于 SwiftData 2.0 以后的框架中,这意味只有在不低于 iOS 18+、watchOS 11+ 以及 macOS 15+ 等这些系统上才可以使用它。


而且更妙的是,我们不再需要“囫囵吞枣”似得刷新整个视图,只需刷新变化对象所对应的那个视图即可。

不过,首先我们必须先能够知道 SwiftData 底层数据究竟在何时发生了改变。

6. 如何实时监听 SwiftData 持久存储中数据的变化?

众所周知,SwiftData 是建立在 CoreData 基础之上的。这意味着在运行时它必然隐藏了 CoreData “活着的灵魂”。

在 CoreData 中我们可以通过 NSPersistentStoreRemoteChange 消息来监听本地存储是否发生改变,这同样也可以适用于 SwiftData。

在这里插入图片描述

所不同的是:现在我们无法细粒度捕获某个特定 NSPersistentStoreCoordinator 上的变化,只能全盘“照单全收”。不过,这并不影响大局。

我们只需在之前 ContentView 视图的代码上“略施小计”,即可捕获后台线程中私有上下文里数据的更改:

struct ContentView: View {
    
    @Environment(\.modelContext) var modelContext
    @Query var items: [Item]
    @State var refreshID = false
        
    var body: some View {
        NavigationStack {
            VStack {
                List(items) { item in
                    Text(item.name).font(.headline.weight(.heavy))
                }
                .id(refreshID)
            }
            .toolbar {
                ToolbarItem(placement:.topBarTrailing) {
                    Button("New", systemImage: "plus.app") {
                        Task.detached {
                            let modelContext = ModelContext(.preview)
                            
                            let item = Item(name: "\(Int.random(in: 0...10000))")
                            modelContext.insert(item)
                            
                            try! modelContext.save()
                            
                            await MainActor.run {
                                refreshID.toggle()
                            }
                        }
                    }
                    .foregroundStyle(.white)
                    .tint(.green)
                }
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange).receive(on: DispatchQueue.main)) { _ in
            print("DB changed!")
        }
    }
}

从上面的代码能够看到,我们在视图里使用 onReceive 修改器方法在主线程中稳妥的监听了 NSPersistentStoreRemoteChange 消息。

在 Xcode 预览中运行看一下效果:

在这里插入图片描述

如您所愿,现在我们已经可以顺利捕获到 SwiftData 本地持久存储上的变化啦!

不过,目前我们仍然是通过强制刷新整个视图来触发后台私有上下文新增数据显示的。

别急,利用上面介绍的 History Trace 机制,我们在下一篇博文将尝试来重构它。

总结

在本篇博文中,我们介绍了 SwiftData 2.0 中新引入的历史记录追踪(History Trace)机制;我们还讨论了如何在 SwiftUI 中实时监听本地持久存储中数据的变化。

在下一篇博文中,我们将会把 History Trace 应用在实际的代码中以解决后台线程与 App 界面间数据同步的问题,敬请期待吧!

感谢观赏,再会!8-)

由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(二)

在这里插入图片描述

概述

从 WWDC 23 开始,苹果推出了崭新的数据库框架 SwiftData。默认在 SwiftData 中所有对数据的操作都会在主线程中进行,稍有不慎就会让 App 变得“鹅行鸭步”

在这里插入图片描述

那么,对于耗时的数据操作我们该如何优雅的面对?又如何让界面与其“一心一力”的同步呢?

在本篇博文中,您将学到如下内容:

  1. SwiftData 如何在后台改变数据?
  2. 如何将后台的更改同步到界面中?

这是本系列第二篇博文。闲言少叙,让我们马上开始 SwiftData 精彩的探究之旅吧!

Let‘s dive in!!!;)


3. SwiftData 如何在后台改变数据?

现在虽然我们已经圆满解决了之前那个崩溃问题,但是 SwiftData 中数据操作的“水还很深”,值得大家进一步“磨砥刻厉”的研究一番。

首先,我们从简单且实用的话题的聊起:SwiftData 如何在后台修改数据?

SwiftData 对于数据的操作是通过模型上下文来完成的,而通过之前的介绍可知:主模型上下文(Main Model Context,以下简称为主上下文)只能在主线程或 MainActor 上修改数据,而私有模型上下文则适合在其它线程或 Actor 中操作数据。

假设这样一种常见的场景:我们的 App 要在启动时生成大量数据,如果将这一操作用主上下文在主线程上执行就会阻塞界面,这在 App 开发中是绝对不能容忍的!

所以,一种方法就是将它们放在私有上下文在后台线程中执行。

将之前 ContentView 视图的代码略作修改,我们现在暂时抛弃 Model 类型,下面所有的代码都只涉及 Item 托管类型:

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @Query var items: [Item]
    
    var body: some View {
        VStack {
            if let item = items.first {
                Text(item.name)
            }
        }
        .padding()
        .task {
            Task.detached {
                let modelContext = ModelContext(.preview)                
                let item = Item(name: "\(Int.random(in: 0...10000))")
                modelContext.insert(item)
                
                try! modelContext.save()
            }
        }
    }
}

从上面的代码可以看到,我们在 ContentView 显示时创建了一个包含随机值的 Item,并视图通过 @Query 将其“抓取”到主界面上显示。

值得注意的是,我们还做了下面几件事:

  • 通过 Task.detached 创建了一个“分离”任务以确保“脏活累活”都在后台线程中运行;
  • 使用 ModelContext 构造器创建了一个私有上下文,该上下文一旦创建就会和它处在的线程或 Actor 所绑定;

到目前为止一切都很简单惬意,不是吗?

不过当我们编译运行后,视图中心却空空如也!创建的 Item 跑哪去了呢?

在这里插入图片描述

4. 如何将后台的更改同步到界面中?

其实,后台线程新创建的 Item 托管对象就在那里,只是它还没有被同步到主上下文中而已。

对于目前的情况来说,SwiftUI 中的 @Query 只能自动同步主上下文中数据的改变,私有上下文中的改变却不在此列。这意味着:我们上面在后台线程中新增的 Item 对象并不能及时刷新到界面中。

这该如何是好呢?

一种简单却略显“粗暴”的方式是,在后台线程插入新 Item 对象后立即强制刷新 UI:

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @Query var items: [Item]
    @State var refreshID = false
    
    var body: some View {
        NavigationStack {
            VStack {
                List(items) { item in
                    Text(item.name).font(.headline.weight(.heavy))
                }
                .id(refreshID)
            }
            .toolbar {
                ToolbarItem(placement:.topBarTrailing) {
                    Button("New", systemImage: "plus.app") {
                        Task.detached {
                            let modelContext = ModelContext(.preview)
                            
                            let item = Item(name: "\(Int.random(in: 0...10000))")
                            modelContext.insert(item)
                            
                            try! modelContext.save()
                            
                            await MainActor.run {
                                refreshID.toggle()
                            }
                        }
                    }
                    .foregroundStyle(.white)
                    .tint(.green)
                }
            }
        }
    }
}

从上面的代码不难看出,我们每次在后台新插入 Item 对象后立即刷新了 List 视图,这样做会导致 SwiftUI 重新计算 @Query 宏中 items 的内容。

在这里插入图片描述

如此这般,我们即可在界面中及时反映出后台线程里私有上下文所导致的 SwiftData 数据变化了。

虽说手动刷新整个视图可以勉强“得偿所愿”,但它毕竟会对渲染性能造成或多或少的潜在影响。有没有更好的方法呢?

答案是肯定的!

总结

在本篇博文中,我们讨论了如何在后台线程处理 SwiftData 的数据操作,又如何将这些更改同步到界面中去。

在下一篇博文里,我们将会介绍 SwiftData 2.0 中新引入的 History Trace 机制,并用它来更优雅的解决问题。

感谢观赏,再会 8-)

由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(一)

在这里插入图片描述

概述

从 WWDC 23 开始,苹果推出了全新的数据库框架 SwiftData。它借助于 Swift 语言简洁而富有表现力的特点,抛弃了以往数据库所有的额外配置文件,只靠纯代码描述就可以干脆利索的让数据库的创建和增删改查(CRUD)一气呵成。

在这里插入图片描述

在本系列博文中,我们将从一个简单而“诡异”的运行“事故”开始,有理有据的深入探寻一番 SwiftData 中耐人寻味的“那些事儿”。

在本篇博文中,您将学到如下内容:

  1. 崩溃!又见崩溃!
  2. 寻根问底

这是本系列第一篇博文。闲言少叙,让我们马上开始 SwiftData 精彩的探究之旅吧!

Let‘s dive in!!!;)


1. 崩溃!又见崩溃!

“事故”的起因很简单,我们在 SwiftData 中创建了两个简单的托管类型 Item 和 Model。

其中,Model 类型里包含了指向 Item 的关系属性 item:

@Model
class Item {
    var name: String
    var timestamp: Date
    
    init(name: String) {
        self.name = name
        timestamp = .now
    }
}

@Model
class Model {
    
    static let UniqID = UUID(uuidString: "3788ABA9-043C-4D34-B119-5D69D486CBBA")!
    var mid: UUID
    
    @Relationship
    var item: Item?
    
    init(mid: UUID, item: Item? = nil) {
        self.mid = mid
        self.item = item
    }
    
    static var shared: Model = {
        let desc = FetchDescriptor<Model>()
        let context = ModelContext(.preview)
        
        if let result = try! context.fetch(desc).first {
            return result
        } else {
            let new = Model(mid: UniqID)
            context.insert(new)
            try! context.save()
            return new
        }
    }()
}

从上面的代码还可以看到,我们为 Model 添加了一个单例静态属性 shared,因为我们不希望创建多个 Model 的实例。

为了更好地在 Xcode 预览中调试代码,我们为 ModelContainer 扩展了一个 preview 静态属性用来获取模型容器中的测试数据:

extension ModelContainer {
    static var preview: ModelContainer = {
        try! ModelContainer(for: .init([Model.self, Item.self]), configurations: .init(isStoredInMemoryOnly: true))
    }()
}

接下来,我们构建 SwiftUI 界面以生成和显示模型容器中的持久数据。

从下面的代码可以看到,当 ContentView 视图显示时我们创建了一个新的 Item 记录,并将它设置到 Model.shared 对象的 Item 关系上,然后将 Item 中随机的值显示在视图中央:

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
        
    var body: some View {
        VStack {
            if let item = Model.shared.item {
                Text(item.name)
            }
        }
        .padding()
        .task {
            let item = Item(name: "\(Int.random(in: 0...10000))")
            modelContext.insert(item)
            
            let model = Model.shared
            modelContext.insert(model)
            model.item = item
            
            try! modelContext.save()
        }
    }
}

然而,就是上面这几十行简单的代码竟然会立即导致运行时的崩溃:

在这里插入图片描述

从上图中可以看到,貌似崩溃直接发生在汇编代码中并没有对应任何源代码,这看起来不妙。

让我们来仔细看看崩溃的具体描述:

SwiftData/PersistentModel.swift:172: Fatal error: attempting to relate model - PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-coredata://DCDD7A8E-316D-4281-BD5C-ED76FF2F6E46/Model/p1), implementation: SwiftData.PersistentIdentifierImplementation) with model context - SwiftData.ModelContext to destination model - Optional(SwiftData.PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-swiftdata://Item/1B72D6AD-F2B6-436D-9817-AA803717A211), implementation: SwiftData.PersistentIdentifierImplementation)) from destination's model context - SwiftData.ModelContext

那么现在问题来了:头发茂盛的小伙伴们能不能通过上面的源代码和崩溃信息确认崩溃真正的原因呢?大家自己先试一下吧。

2. 寻根问底

稍微“剧透一下”:如果在上述数据模型中不使用 @Relationship 来描述对象之间的关系,那么崩溃就会“烟消云散”。

这似乎意味着,上述错误和 SwiftData 中的 Relationship 连接有着“如胶似漆”的关系,果真如此吗?

再仔细观察一下崩溃信息的内容,它仿佛暗示着错误和模型上下文(ModelContext)息息相关:

... with model context - SwiftData.ModelContext to ... model context - SwiftData.ModelContext

回忆一下,在 CoreData 中如果父托管对象包含一个子对象,那么如果它们承载于不同的托管对象上下文(NSManagedObjectContext)在保存时就会发生崩溃

为什么会出现这种情况?一种可能是父对象和子对象不是由同一个 NSManagedObjectContext 创建的,比如:子对象出生于后台线程中的托管对象上下文。

在 SwiftData 中,情况与此几乎如出一辙。回顾一下 Model.shared 静态属性的代码:

static var shared: Model = {
    let desc = FetchDescriptor<Model>()
    let context = ModelContext(.preview)
    
    if let result = try! context.fetch(desc).first {
        return result
    } else {
        let new = Model(mid: UniqID)
        context.insert(new)
        try! context.save()
        return new
    }
}()

看到了吗?我们根据 ModelContainer.preview 创建了一个新的 ModelContext,但这个模型上下文和 Model#items 关系中对应对象的上下文真的一致吗?

马上确认一下:我们新建 Item 托管对象的模型上下文是如何诞生的

在代码中不难发现,它是通过 modelContainer 修改器方法从 App 的 WindowGroup 中传入的:

@main
struct MyWatch_App: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(.preview)
        }
    }
}

然后在 ContentView 中通过 @Environment 引入到视图中:

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
}

注意,貌似它们都对应同一个 ModelContainer.preview 模型容器,但其实它们却有着云泥之别:

  • 用 modelContainer 修改器从 App 的 WindowGroup 传入的上下文实际对应着 ModelContainer 容器中的主上下文
  • 而在 Model.shared 中用 ModelContext 创建的上下文则是容器的一个私有上下文

主上下文必须在主线程或 MainActor 中使用,而私有上下文可以运行在任何其它线程或 Actor 中。

在这里插入图片描述

所以,上面崩溃的前因后果已经很明晰了:**我们的 Model 是从私有上下文中创建的,而它 Item 关系所对应的对象却是从主上下文中创建的。**这在将数据保存到 SwiftData 的持久存储中时必然会引起上下文不一致,从而导致榱崩栋折。

知道了原因,解决起来就很简单了。

一种直观的方法是,同样在 ModelContainer.preview 的主上下文中创建 Model 的共享实例:

@MainActor
static var shared: Model = {
    let desc = FetchDescriptor<Model>()
    
    // 获取 ModelContainer.preview 的主上下文
    let context = ModelContainer.preview.mainContext
    
    if let result = try! context.fetch(desc).first {
        return result
    } else {
        let new = Model(mid: UniqID)
        context.insert(new)
        try! context.save()
        return new
    }
}()

注意:因为 ModelContainer.preview.mainContext 必须在主线程上使用,所以它是被 @MainActor 所修饰着的,因而这一修饰符也必须“传染”到 shared 静态属性自身上。

在这里插入图片描述

运行代码,一切崩溃都变得风吹云散了!我们 Model.shard 关系中 Item 的随机值顺利显示在了视图的中心,棒棒哒!💯

总结

在本篇博文中,我们介绍了一个导致 SwiftData 支持的应用发生轰然崩溃的问题,并随后讨论了它的前因后果以及解决之道。

在下一篇博文里,我们会接着讨论 SwiftData 如何在后台处理数据以及如何将它们同步到界面中;我们还会在后续文章中介绍 SwiftData 2.0 中新祭出的 History Trace 和“墓碑”机制,敬请期待吧。

感谢观赏,再会!8-)

用异步序列优雅的监听 SwiftData 2.0 中历史追踪记录(History Trace)的变化

在这里插入图片描述

概述

WWDC 24 一声炮响为我们送来 Swift 6.0 的同时,也颇为“低调”的推出了 SwiftData 2.0。在新版本的 SwiftData 中,苹果为其新增了多个激动人心的新特性,其中就包括历史记录追踪(History Trace)。

在这里插入图片描述

不过,历史记录追踪目前看起来似乎有些“白璧微瑕”,略微让人有些不爽。在这里就让我们看看如何利用 Swift 结构化并发中的异步序列(AsyncSequence)来“补苴罅漏”吧。

在本篇博文中,您将学到如下内容:

  1. SwiftData 2.0 中的历史记录追踪
  2. 一个小小的美中不足...
  3. 异步序列的“将伯之助”

相信通过本篇的学习,小伙伴们在精进 Swift 异步序列技艺的同时又能了然 SwiftData 2.0 的新“脾性”,何乐而不为呢?

闲言少叙,让我们马上开始吧!Let‘s go!!!;)


1. SwiftData 2.0 中的历史记录追踪

历史记录追踪(History Trace)是 SwiftData 2.0 中新推出的一种查询 SwiftData 数据库内容变化的机制。

History Trace “降生”的意义在于:利用它我们现在可以观察到不同模型上下文、不同进程以及系统不同组件对数据库内容的更改行为了。 在这里插入图片描述在这里插入图片描述

举个例子:比如在 WatchOS 系统中包含共享同一个数据库(通过 App Groups)的 App 和 Widget。当 Widget 添加了一条记录后,我们的 App 如何能够知晓呢?

一种方法是在 App 进入前台时(active)被动读取数据库来发现变化。不过,更好的方法是让数据库自己主动告诉我们:底层数据发生了改变,需要秃头码农们的及时处理。

这可以通过在界面中监听 NSPersistentStoreRemoteChange 消息来实现:

.onReceive(NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange).receive(on: DispatchQueue.main)) { _ in
    NSLog("数据库发生了变动!")
}

在得知数据库发生变化之后,我们随即就可以利用 History Trace 来“恣意”读取具体的历史 Change 记录了:

private func handleChangeInMainContext() {
    let mainContext = modelContext
    var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
    historyDesc.predicate = #Predicate { trans in
        trans.author == "Widgets"
    }
    
    let transactions = try! mainContext.fetchHistory(historyDesc)
    for trans in transactions {
        for change in trans.changes {
            // 具体处理实现从略...
        }
    }
}

如上代码所示,当监听到底层数据库发生变动时我们可以调用 handleChangeInMainContext() 方法来查询所有实际变更的记录。从中我们还可以发现,我们利用了 #Predicate 宏来进行结果过滤从而只关注小组件(Widgets)引起的改变。

2. 一个小小的美中不足...

不知小伙伴们发现了没有,虽说利用 NSPersistentStoreRemoteChange 可以圆满的监听到 SwiftData 数据库的改变,但这种方式感觉把“监听”和“处理”操作隔离开了,无法从逻辑上体现出 Swift 语言的简洁和优雅。

参考苹果对于监听设备位置坐标改变实现的升级,我们希望在 SwiftData 2.0 的 History Trace 里也能用类似下面的代码来“抽丁拔楔”:

for await change in modelContext.persistentStoreChanges {
    // 对数据库中的改变进行处理...
}

看到这么熟悉且散发着 Swifty 范儿的“美味”代码,小伙伴们想必都会有一个似曾相识的“身影”映入脑海。别犹豫,大声说出来!它就是:异步序列

3. 异步序列的“将伯之助”

异步序列是 Swift 5.5+ 中跟随结构化异步模型推出的一种数据类型。系统内置框架本身就包含了海量异步序列,我们也可以遵守 AsyncSequence 协议来实现自己的异步序列。

在这里插入图片描述

从之前的代码可以发现,我们对于历史记录的查询是在模型上下文对象上进行的。所以我们可以进一步扩展 ModelContext 类型来实现我们对应的异步序列:

extension ModelContext {
    var historyChanges: any AsyncSequence<(changes: [HistoryChange], token: DefaultHistoryToken?), Error> {
        NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange).tryMap { _ in
            
            var changes = [HistoryChange]()
            let context = self
            var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
            if let author = context.author {
                historyDesc.predicate = #Predicate { trans in
                    trans.author != author
                }
            }
            
            let transactions = try context.fetchHistory(historyDesc)
            for trans in transactions {
                for change in trans.changes {
                    changes.append(change)
                }
            }
            
            let token = transactions.last?.token
            return (changes, token)
        }
        .values
    }
}

如上代码所示,我们为 ModelContext 扩展了一个 historyChanges 实例属性,它的类型即为一个异步序列:any AsyncSequence<(changes: [HistoryChange], token: DefaultHistoryToken?), Error>。

现在,我们可以这样抓取 historyChanges 属性中的历史变更记录了:

.task {
    do {
        for try await result in modelContext.historyChanges {
            for change in result.changes {
                // 处理历史追踪记录
            }
            
            if let token = result.token {
                // 删除已处理到历史记录
            }
        }
    } catch {
        print(error.localizedDescription)
    }
}

注意,目前我们 historyChanges 异步序列的元素(Element)类型为 [HistoryChange],这是利用发布器(Publisher)实例的 values 属性本身就是一个异步序列这个特性来实现的 。

在这里插入图片描述

我们还可以按单个 HistoryChange 来捕获历史的更改记录,这可以通过 AsyncStream 辅助类型来完成:

typealias ChangeSequenceElement = (change: HistoryChange?, token: DefaultHistoryToken?)

private static var cancel = Set<AnyCancellable>()
var historyChanges: any AsyncSequence<ChangeSequenceElement, Error> {
    AsyncThrowingStream { c in
        NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { result in
                switch result {
                case .finished:
                    c.finish()
                case .failure(let failure):
                    c.finish(throwing: failure)
                }
                
            }, receiveValue: {[unowned self] _ in
                let context = self
                var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
                if let author = context.author {
                    historyDesc.predicate = #Predicate { trans in
                        trans.author != author
                    }
                }
                
                do {
                    let transactions = try context.fetchHistory(historyDesc)
                    for trans in transactions {
                        for change in trans.changes {
                            c.yield((change, nil))
                        }
                    }
                    
                    let token = transactions.last?.token
                    c.yield((nil, token))
                } catch {
                    c.yield(with: .failure(error))
                }
            })
            .store(in: &Self.cancel)
}
    }

如上代码所示,我们通过 AsyncThrowingStream 类型将 NSPersistentStoreRemoteChange 消息的监听以及数据库历史变更记录查询这两种操作,行云流水般一气呵成。

但是,这种实现需要一个静态的 Set<AnyCancellable>() 集合来保存订阅,而且不能细粒度控制监控的取消。这时,我们可以通过返回可取消对象(Cancellable)来解决:

typealias ChangeSequenceElement = (change: HistoryChange?, token: DefaultHistoryToken?)
func getHistoryChanges() -> (changes: any AsyncSequence<ChangeSequenceElement, Error>, cancel: AnyCancellable) {
    var cancel: AnyCancellable = AnyCancellable({})
    let stream = AsyncThrowingStream<ChangeSequenceElement, Error> { c in
        cancel = NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { result in
                switch result {
                case .finished:
                    c.finish()
                case .failure(let failure):
                    c.finish(throwing: failure)
                }
                
            }, receiveValue: {[unowned self] _ in
                let context = self
                var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
                if let author = context.author {
                    historyDesc.predicate = #Predicate { trans in
                        trans.author != author
                    }
                }
                
                do {
                    let transactions = try context.fetchHistory(historyDesc)
                    for trans in transactions {
                        for change in trans.changes {
                            c.yield((change, nil))
                        }
                    }
                    
                    let token = transactions.last?.token
                    c.yield((nil, token))
                } catch {
                    c.yield(with: .failure(error))
                }
            })
    }
    
    return (stream, cancel)
}

这样一来,我们就可以在适当的时候取消历史追踪记录的监听了:

struct ContentView: View {
    
    @Environment(\.modelContext) var modelContext
    @Query var items: [Item]
    @State var cancel: AnyCancellable?
        
    var body: some View {
        NavigationStack {
            VStack {
                // 视图界面逻辑从略...
            }
            .onDisappear {
            // 在视图“消失”时取消监听
                cancel?.cancel()
            }
            .task {
                let (stream, cancel) = modelContext.getHistoryChanges()
                self.cancel = cancel
                do {
                    for try await result in stream {
                        if let change = result.change {
                            switch change {
                            case .insert(_):
                                print("插入一个新 Item")
                            case .update(_):
                                print("一个 Item 被更新")
                            case .delete(let historyDelete):
                                if let history = historyDelete as? DefaultHistoryDelete<Item> {
                                    print("\(history.tombstone[\Item.name] ?? "null") 已被删除!")
                                }
                            @unknown default:
                                fatalError()
                            }
                        }
                        
                        if let token = result.token {
                            try! modelContext.deleteTransactions(before: token)
                        }
                    }
                } catch {
                    print(error.localizedDescription)
                }
            }
        }
    }
}

从上面的代码可以看到,我们在视图退出渲染树时取消了历史记录的监听。其中部分代码的实现出自于《由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐》这一系列 6 篇博文中,想要进一步了解的小伙伴们可以移步观看。

至此,我们利用 Swift 5.5+ 新并发模型中的异步序列成功的改造了 SwiftData 2.0 中历史记录追踪的监听实现,小伙伴们还不赶紧自己一个大大的赞!棒棒哒💯

总结

在本篇博文中,我们讨论了如何利用 Swift 5.5+ 新并发模型中的异步序列更优雅的监听 SwiftData 2.0 中历史追踪记录(History Trace)的变化,颇具 Swifty 范儿,你值得拥有!

感谢观赏,再会啦!8-)

利用 Scriptable 实现iOS 小组件实时查看网站访问数据

📊 利用 Scriptable 实现 iOS 小组件实时查看网站访问数据(基于 51LA)

你是否想随时在 iPhone 主屏查看自己网站的访问数据?
如果你使用的是 51LA 网站统计,配合 iOS 上的 Scriptable 工具,只需几十行代码,就能实现一个美观实用的小组件,实时显示网站的访问统计数据。

本文将手把手教你实现这个小工具。


🧰 所需工具

Scriptable

一个可以通过使用JavaScript来创建我们自己想要的小组件的应用(App Store 免费下载)

51.LA访问统计

www.51.la/

国内网站访问统计平台

你的网站

可以添加自定义 <script> 标签的网站


⚙️ 第一步:获取 51LA 统计代码(如有即可跳过)

  1. 前往 v6.51.la/report/setu…,根据网站教程安装统计代码

🧑‍💻 第二步:在 Scriptable 中创建脚本

打开 Scriptable,点击右上角「+」新建脚本,粘贴以下代码,并将其中的 你的ID 替换为你的统计代码 ID(即第一步图中打码的ID)

// 获取访问数据
async function fetchVisitorStats() {
  try {
    const url = "https://v6-widget.51.la/v6/你的ID/quote.js?";
    const request = new Request(url);
    const result = await request.loadString();
    const numbers = result.match(/(?<=<\/span><span>).*?(?=<\/span><\/p>)/g);
    return {
      todayVisitors: numbers[1],
      yesterdayVisitors: numbers[3],
      monthlyVisits: numbers[5],
      totalVisits: numbers[6]
    };
  } catch (error) {
    console.error("Failed to fetch visitor stats:", error);
    return {
      todayVisitors: '加载中',
      yesterdayVisitors: '加载中',
      monthlyVisits: '加载中',
      totalVisits: '加载中'
    };
  }
}

// 添加千位分隔符格式化函数
function formatNumberWithCommas(numStr) {
  if (!/^\d+$/.test(numStr)) return numStr;
  return numStr.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

// 渲染 widget
async function createWidget(data) {
  const widget = new ListWidget();

  // 设置背景图片
  const bgImgUrl = "https://img.picui.cn/free/2025/06/06/6842b18d06be0.png";
  const bgReq = new Request(bgImgUrl);
  const bgImage = await bgReq.loadImage();
  widget.backgroundImage = bgImage;

  widget.setPadding(12, 14, 12, 14);

  // 格式化所有数据
  const formattedData = {
    todayVisitors: formatNumberWithCommas(data.todayVisitors),
    yesterdayVisitors: formatNumberWithCommas(data.yesterdayVisitors),
    monthlyVisits: formatNumberWithCommas(data.monthlyVisits),
    totalVisits: formatNumberWithCommas(data.totalVisits)
  };

  // 顶部标签
  const tagStack = widget.addStack();
  tagStack.layoutHorizontally();
  tagStack.setPadding(2, 8, 2, 8);
  tagStack.backgroundColor = new Color("#FFFFFF", 0.2);
  tagStack.cornerRadius = 12;

  const tagText = tagStack.addText("个站数据");
  tagText.font = Font.regularSystemFont(9);
  tagText.textColor = new Color("#FFFFFF");

  widget.addSpacer(6);

  // 总访问量区域
  const countStack = widget.addStack();
  countStack.layoutHorizontally();
  countStack.centerAlignContent();

  // 左边数字
  const totalVisitsText = countStack.addText(formattedData.totalVisits);
  totalVisitsText.font = new Font("DIN Alternate", 30);
  totalVisitsText.textColor = new Color("#FFFFFF");

  countStack.addSpacer(6);

  // 右边文字下移 10px
  const rightStack = countStack.addStack();
  rightStack.layoutVertically();
  rightStack.addSpacer(10);

  const descText = rightStack.addText("总访问量");
  descText.font = Font.regularSystemFont(11);
  descText.textColor = new Color("#698F9F");

  widget.addSpacer(8);

  // 添加访问数据行
  function addVisitRow(label, value) {
    const row = widget.addStack();
    row.layoutHorizontally();

    const left = row.addText(label);
    left.font = Font.regularSystemFont(13);
    left.textColor = new Color("#FFFFFF");

    row.addSpacer();

    const right = row.addText(value.toString());
    right.font = new Font("DIN Alternate", 13);
    right.textColor = new Color("#31BDF9");
  }

  addVisitRow("今日访问", formattedData.todayVisitors);
  widget.addSpacer(4);
  addVisitRow("昨日访问", formattedData.yesterdayVisitors);
  widget.addSpacer(4);
  addVisitRow("本月访问", formattedData.monthlyVisits);

  return widget;
}

// 主逻辑
const data = await fetchVisitorStats();
const widget = await createWidget(data);
Script.setWidget(widget);
Script.complete();
widget.presentSmall();

📱 第三步:添加小组件到桌面

1.长按 iPhone 桌面进入编辑模式

2.点击左上角「+」,搜索并添加 Scriptable 小组件

3.长按组件 → 编辑 → 选择你刚刚创建的脚本

💡 延伸玩法

你可以与AI工具“友好对话”让他帮你创造出更多好玩的小组件。

示例:请你帮我使用 JS 在 Scriptable 中创建一个XXX小组件,请注意查询 Scriptable 官方文档,编写的代码要符合官方文档要求。

参考资料:

51la统计美化

blog.leonus.cn/2022/51la.h…

Scriptable 官方文档
docs.scriptable.app/


我有一个设计周刊

每周,我都会塞几样小东西进这封信里:

一个页面的转角、一个工具的灵性、一段话的温度。 没人知道它会改变谁的哪一天。

xiaobot.net/p/DesignStr…

image.png

iOS开发:关于日志框架

在移动端开发中,我们为什么需要日志打印框架?

在 iOS 移动端开发中,我们需要日志打印框架的原因主要有以下几点:

  1. 便于调试和排查问题
    日志可以帮助开发者快速定位代码执行流程和异常发生的位置,提升调试效率。
  2. 记录关键运行信息
    日志能记录用户操作、网络请求、错误信息等,有助于分析用户行为和应用运行状况。
  3. 方便线上问题追踪
    在正式环境中,日志可以帮助开发者远程收集崩溃、异常等信息,及时发现和修复线上 bug。
  4. 分级管理日志信息
    日志框架通常支持不同级别(如 debug、info、warning、error),可以灵活控制输出内容,避免信息过载。
  5. 统一日志格式,便于维护
    日志框架可以统一日志输出格式,方便团队协作和后续维护。
  6. 支持日志持久化和上传
    一些日志框架支持将日志保存到本地或上传到服务器,便于后续分析和追踪。

小结
日志打印框架是 iOS 开发中不可或缺的工具,能大大提升开发、测试和运维的效率与质量。

我的实践经历--日志帮我甩锅

咳咳,上面一本正经的说了那么多理由和好处,你或许会说,好像和我们这种小米旮旯没啥事,没错我曾经也是这么认为的,直到我遇到了这样一个项目:

App中,集成了多个不同业务厂家的SDK,而且每个SDK都是闭源的,负责不同的业务,然后在某个版本,App崩溃率飙升!

接着,作为App的主工程的开发者,我被领导各种“问候”。

于是我这个壳App拉着各路SDK开发商一起开了线上会议:

SDK1厂商:我们SDK质量杠杠的!

SDK2厂商:我们SDK没毛病!

SDK3厂商:不是我说,在座的各位都是...

我:要不然,我们都依赖同一个日志输出模块吧,看看打底啥情况?

SDK1厂商、SDK2厂商、SDK3厂商:行,就这么干!

几日后,SDK3厂商:大哥们,我错了,是我们的问题。

小结

有的时候,使用日志框架,目的并没有那么简单,特别是像这种多方集成时候,自证自己清白往往比解决bug更为重要,因为不能保证每个开发都有基本素养!

所以用哪个框架呢?

推荐使用CocoaLumberjack

GitHub - CocoaLumberjack/CocoaLumberjack: A fast & simple, yet powerful & flexible logging framework for macOS, iOS, tvOS and watchOS

说起这个库,那真的是绝对老牌,绝对好用,绝对实力!

下面是 CocoaLumberjack 在 iOS 项目中的详细使用示例,包括集成、基本用法和常见配置。

1. 集成 CocoaLumberjack

使用 CocoaPods:

在你的 Podfile 中添加:

pod 'CocoaLumberjack/Swift'

然后执行:

pod install

2. 基本配置

AppDelegate.swift 中进行初始化配置:

import UIKit
import CocoaLumberjackSwift

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // 添加控制台日志输出
        DDLog.add(DDOSLogger.sharedInstance) // 使用系统 os_log

        // 添加文件日志输出
        let fileLogger: DDFileLogger = DDFileLogger() // 创建文件日志
        fileLogger.rollingFrequency = TimeInterval(60*60*24) // 24小时滚动一次
        fileLogger.logFileManager.maximumNumberOfLogFiles = 7 // 最多保存7个日志文件
        DDLog.add(fileLogger)

        return true
    }
}

3. 日志打印示例

在你的代码中直接使用如下方式打印不同级别的日志:

import CocoaLumberjackSwift

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        DDLogVerbose("这是 Verbose 日志")
        DDLogDebug("这是 Debug 日志")
        DDLogInfo("这是 Info 日志")
        DDLogWarn("这是 Warning 日志")
        DDLogError("这是 Error 日志")
    }
}

4. 日志级别控制

你可以通过设置全局日志级别来控制输出:

// 只输出 Info 及以上级别的日志
dynamicLogLevel = .info

5. 自定义日志格式(可选)

你可以自定义日志输出格式:

class CustomLogFormatter: NSObject, DDLogFormatter {
    func format(message logMessage: DDLogMessage) -> String? {
        return "\(logMessage.timestamp) [\(logMessage.level)] \(logMessage.message)"
    }
}

// 在初始化时添加
let formatter = CustomLogFormatter()
DDOSLogger.sharedInstance.logFormatter = formatter

6. 查看日志

  • 控制台日志会直接输出到 Xcode 控制台。
  • 文件日志默认保存在 Library/Caches/Logs 目录下,可以通过 fileLogger.logFileManager.logsDirectory 获取路径。

小结:
CocoaLumberjack 功能强大,配置灵活,适合中大型项目的日志管理需求。你可以根据实际需要进一步扩展和定制。

注意:

  • 除了自定义日志格式,我们其实还可以自定义log文件管理器类,以便于更好的自定义路径,以及排序进行将日志进行分类。
  • DDLogVerbose("这是 Verbose 日志")使用固然方便,但是每个使用的位置都必须进行import CocoaLumberjack操作,非常繁琐,建议做一层简单的二次封装,使用起来更加简单。

日志上传到服务器

日志输出到本地,我们需要定期或者在有需求的时候,静默或者让客户主动上传日志,便于我们通过日志进行问题分析,下面是打包上传的几个步骤:

下面是一个结合 SSZipArchive、Moya,将 CocoaLumberjack 生成的日志文件压缩并上传到服务器的完整示例流程。


1. 获取 CocoaLumberjack 日志文件路径

import CocoaLumberjackSwift

func getLogFilePaths() -> [String] {
    if let fileLogger = DDLog.allLoggers.compactMap({ $0 as? DDFileLogger }).first {
        return fileLogger.logFileManager.sortedLogFilePaths
    }
    return []
}

2. 使用 SSZipArchive 压缩日志文件

import SSZipArchive

func zipLogFiles(logFilePaths: [String], zipPath: String) -> Bool {
    return SSZipArchive.createZipFile(atPath: zipPath, withFilesAtPaths: logFilePaths)
}

3. 使用 Moya 上传压缩包

定义 Moya Target

import Moya

enum LogUploadAPI {
    case uploadLog(zipFileURL: URL)
}

extension LogUploadAPI: TargetType {
    var baseURL: URL { URL(string: "https://your.server.com")! }
    var path: String { "/api/upload/log" }
    var method: Moya.Method { .post }
    var sampleData: Data { Data() }
    var task: Task {
        switch self {
        case .uploadLog(let zipFileURL):
            let formData = MultipartFormData(provider: .file(zipFileURL), name: "file", fileName: "logs.zip", mimeType: "application/zip")
            return .uploadMultipart([formData])
        }
    }
    var headers: [String : String]? {
        ["Content-type": "multipart/form-data"]
    }
}

4. 组合流程:压缩并上传

func uploadLogs() {
    // 1. 获取日志文件路径
    let logFilePaths = getLogFilePaths()
    guard !logFilePaths.isEmpty else { return }

    // 2. 压缩日志文件
    let zipPath = NSTemporaryDirectory().appending("logs.zip")
    let zipSuccess = zipLogFiles(logFilePaths: logFilePaths, zipPath: zipPath)
    guard zipSuccess else { return }

    // 3. 上传压缩包
    let provider = MoyaProvider<LogUploadAPI>()
    let zipFileURL = URL(fileURLWithPath: zipPath)
    provider.request(.uploadLog(zipFileURL: zipFileURL)) { result in
        switch result {
        case .success(let response):
            print("上传成功: \(response.statusCode)")
        case .failure(let error):
            print("上传失败: \(error)")
        }
    }
}

5. 调用时机与上传

你可以在需要上传日志的地方调用 uploadLogs() 方法即可。

这里需要注意上传时机与场景非常重要:

我之前遇到过这样一件啼笑皆非的事情,用户反馈App会闪退,运维人员让客户在触发意见反馈页面,进而触发日志上传,结果就在意见反馈页面崩溃了......

一定要保证日志上传的业务场景不会崩溃,同时也不要影响用户体验!


注意:

  • 请根据你的服务器接口实际调整 baseURLpath
  • 日志文件较大时,建议在后台线程处理压缩和上传。
  • 上传完成后可根据需要删除本地 zip 文件。

总结

  • 日志框架绝对要纳入到App开发中的基建建设中;
  • 日志有的时候并不是需要找到自己的问题,而有可能是需要自证自己;
  • CocoaLumberjack是我用的比较多的日志框架,推荐给大家;
  • 日志不仅需要沙盒保存,需要上合适的时机,被动或者主动上传到服务器,以便于远程分析问题。

SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效

在这里插入图片描述

概述

作为 Apple 开发中的全栈秃头老码农们,我们不但需要精通代码编写更需要有过硬的界面设计艺术功底。为了解决撸码与撸图严重脱节这一窘境,苹果从 iOS 13(macOS 11)开始引入了 SF Symbols 图形字符。

在这里插入图片描述

有了 SF Symbols,我们现在可以从系统内置“千姿百态”的图形字符库中毫不费力的恣意选取心爱的图像来装扮我们的 App 了。我们还可以更进一步为它们添加优美流畅的动画效果。

在本篇博文中,您将学到如下内容:

  1. 符号动画,小菜一碟!
  2. 自动触发动画
  3. 更顺畅的符号过渡特效
  4. 所见即所得:SF Symbols App
  5. 完整源代码

在 WWDC 24 中,苹果携手全新的 SF Symbols 6.0 昂首阔步而来,让小伙伴们的生猛撸码愈发如虎添翼。

那还等什么呢?让我们马上开始玩转符号动画之旅吧!

Let’s go!!!


1. 符号动画,小菜一碟!

SF Symbols 是兼容 Apple 多个平台的一套系统、完整、优美的图形字符库。从伴随着 SwiftUI 1.0(iOS 13)横空出世那年算起,到现在已经进化到 SF Symbols 6.0 版本了。

在这里插入图片描述

它的 Apple 官方网站在此: SF Symbols,大家可以前去观赏其中的细枝末节。

目前,最新的 SF Symbols 6.0 内置了超过 6000 枚风格各异的图形字符,等待着小伙伴们的顽皮“采摘”。

SF Symbols 字符库不仅仅包含静态字符图像,我们同样可以在 SwiftUI 和 UIKit 中轻松将其升华为鲜活的动画(Animations)和过渡(Transitions)效果。

下面,我们在 SwiftUI 中仅用一个 symbolEffect() 视图修改器即让字符栩栩如生了:

Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
    .symbolEffect(.wiggle, options: .repeat(.continuous))

在这里插入图片描述

我们还可以恣意改变动画的种类,比如从 wiggle 改为 variableColor 效果:

Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
    .symbolEffect(.variableColor, options: .repeat(.continuous))

在这里插入图片描述

我们甚至可以更进一步,细粒度定制 variableColor 动画效果的微妙细节:

Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
    .symbolEffect(.variableColor.cumulative, options: .repeat(.continuous))

在这里插入图片描述

2. 自动触发动画

除了一劳永逸的让动画重复播放以外,我们还可以自动地根据 SwiftUI 视图中的状态来触发对应的动画。

如下代码所示,只要 animTrigger 状态发生改变,我们就播放 wiggle 动画 2 次(每次间隔 2 秒):

VStack {
    Image(systemName: "bell.circle")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .symbolRenderingMode(.hierarchical)
        .frame(width: 150)
        .symbolEffect(.wiggle, options: .repeat(.periodic(2, delay: 2)), value: animTrigger)

    Button("触发动画") {
        animTrigger.toggle()
    }
}

在这里插入图片描述

我们还可以用 symbolEffect() 修改器的另一个重载版本,来手动控制动画的开始和停止:

VStack {
    Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(width: 150)
        .symbolEffect(.wiggle, options: .repeat(.continuous), isActive: animPlaying)
    
    Button(animPlaying ? "停止动画" : "开始动画") {
        animPlaying.toggle()
    }
}

在这里插入图片描述

如上代码所示,当 animPlaying 状态为真时我们播放动画,当它为假时则停止动画。

3. 更顺畅的符号过渡特效

SF Symbols 字符图形库除了提供变幻莫测的海量动画以外,还弥补了强迫症码农们对于不同字符切换过渡时僵硬、不自然的“心结”。

比如,在下面的代码中我们根据 notificationsEnabled 是否开启,切换显示了不同的图形字符:

@State var notificationsEnabled = false

Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
    .resizable()
    .aspectRatio(contentMode: .fit)
    .frame(width: 66)

但是,这样做却释放出一些“行尸走肉”的气息,让用户非常呲楞:

在这里插入图片描述

所幸的是,利用 contentTransition() 视图修改器我们可以将其变得行云流水、一气呵成:

Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
    .resizable()
    .aspectRatio(contentMode: .fit)
    .contentTransition(.symbolEffect(.replace))
    .frame(width: 66)

在这里插入图片描述

我们还可以用 symbolVariant() 修改器来重构上面的代码,效果保持不变:

Image(systemName: "bell")
    .resizable()
    .aspectRatio(contentMode: .fit)
    .symbolVariant(!notificationsEnabled ? .slash : .none )
    .contentTransition(.symbolEffect(.replace))
    .frame(width: 66)

通过 symbolRenderingMode() 修改器,我们还能在过渡特效基础之上再应用字符的其它特殊渲染效果,比如分层:

Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolRenderingMode(.hierarchical)
.contentTransition(.symbolEffect(.replace))
.frame(width: 66)

在这里插入图片描述

当然,如果我们愿意的话同样可以更加细粒度地定制过渡的类型(downUp):

Image(systemName: "bell")
    .resizable()
    .aspectRatio(contentMode: .fit)
    .symbolVariant(!notificationsEnabled ? .slash : .none )
    .symbolRenderingMode(.hierarchical)
    .contentTransition(.symbolEffect(.replace.downUp))
    .frame(width: 66)

在这里插入图片描述

4. 所见即所得:SF Symbols App

上面我们介绍了 SF Symbs 动画和过渡中诸多“妙计和花招”。

不过平心而论,某个或者某几个字符可能更适合某些特定的动画和过渡效果,那我们怎么才能用最快的速度找到它们最佳的动画“伴侣”呢?

除了通过撸码经验和 SF Symbols 官方文档以外,最快的方法恐怕就是使用 macOS 上的 SF Symbols App 了:

在这里插入图片描述

我们可以在 developer.apple.com/sf-symbols 下载 SF Symbols App。

还拿上面第一个例子中的字符来举例,我们可以在 SF Symbols App 中随意为它应用各种动画效果,直到满意为止:

在这里插入图片描述

我们再如法炮制换一个 AirPods “把玩”一番:

在这里插入图片描述

至此,我们完全掌握了 SwiftUI 中 SF Symbols 符号的动画和过渡特效,小伙伴们一起享受这干脆利落、丝般顺滑的灵动风味吧!

5. 完整源代码

本文对应的全部源代码在此,欢迎品尝:

import SwiftUI

struct ContentView: View {
    
    @State var notificationsEnabled = false
    @State var animPlaying = false
    @State var animTrigger = false
    
    var body: some View {
        NavigationStack {
            Form {
                LabeledContent(content: {
                    Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 66)
                    
                }, label: {
                    Text("生硬的过渡")
                })
                .frame(height: 100)
                
                LabeledContent(content: {
                    //Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
                    Image(systemName: "bell")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .symbolVariant(!notificationsEnabled ? .slash : .none )
                        .contentTransition(.symbolEffect(.replace))
                        .frame(width: 66)
                    
                }, label: {
                    Text("流畅的过渡")
                })
                .frame(height: 100)
                
                LabeledContent(content: {
                    Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .symbolRenderingMode(.hierarchical)
                        .contentTransition(.symbolEffect(.replace))
                        .frame(width: 66)
                        
                    
                }, label: {
                    Text("按层过渡")
                })
                .frame(height: 100)
                
                LabeledContent(content: {
                    //Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
                    Image(systemName: "bell")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .symbolVariant(!notificationsEnabled ? .slash : .none )
                        .symbolRenderingMode(.hierarchical)
                        .contentTransition(.symbolEffect(.replace.downUp))
                        .frame(width: 66)
                        
                    
                }, label: {
                    Text("downUP 按层过渡")
                })
                .frame(height: 100)
                
                HStack {
                    VStack {
                        Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .frame(width: 150)
                            .symbolEffect(.wiggle, options: .repeat(.continuous), isActive: animPlaying)
                        
                        Button(animPlaying ? "停止动画" : "开始动画") {
                            animPlaying.toggle()
                        }
                    }
                    
                    VStack {
                        Image(systemName: "bell.circle")
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .symbolRenderingMode(.hierarchical)
                            .frame(width: 150)
                            .symbolEffect(.wiggle, options: .repeat(.periodic(2, delay: 2)), value: animTrigger)

                        Button("触发动画") {
                            animTrigger.toggle()
                        }
                    }
                }
                .buttonStyle(.borderless)
                .frame(height: 100)
                .padding()
            }
            .font(.title2)
            .navigationTitle("符号动画与过渡演示")
            .toolbar {
                
                ToolbarItem(placement: .topBarLeading) {
                    Text("大熊猫侯佩 @ \(Text("CSDN").foregroundStyle(.red.gradient))")
                        .foregroundStyle(.gray)
                        .font(.headline.weight(.heavy))
                }
                
                ToolbarItem(placement: .primaryAction) {
                    Button("开启或关闭通知") {
                        withAnimation {
                            notificationsEnabled.toggle()
                        }
                    }
                }
            }
        }
    }
}

#Preview {
    ContentView()
}

总结

在本篇博文中,我们讨论了如何在 SwiftUI 中花样玩转 SF Symbols 符号动画和过渡特效的各种“姿势”,我们最后还介绍了 macOS 中 SF Symbols App 的“拔刀相助”让撸码更加轻松!

感谢观赏,再会了!8-)

使用令牌(Token)进一步优化 SwiftData 2.0 中历史记录追踪(History Trace)的使用

在这里插入图片描述

概览

在今年的 WWDC 24 中,苹果将 SwiftData 升级为了 2.0 版本。其中对部分已有功能进行了增强的同时也加入了许多全新的特性,比如历史记录追踪(History Trace)、“墓碑”(Tombstone)等。

在这里插入图片描述

我们可以利用 History Trace 来跟踪 SwiftData 持久存储中数据的变化,利用令牌我们还可以进一步优化 SwiftData 的使用效率。

在本篇博文中,您将学到如下内容:

  1. 历史记录追踪机制简介
  2. 使用令牌(HistoryToken)过滤历史记录
  3. 删除过期的历史记录

相信有了令牌的加持,必将为 SwiftData 历史记录追踪锦上添花、百尺竿头!

闲言少叙,让我们马上开始 History Trace 的优化之旅吧!

Let‘s go!!!;)


1. 历史记录追踪机制简介

简单来说,今年苹果在 WWDC 24 上新祭出的历史记录追踪(History Trace)可以让我们更加轻松的监控 SwiftData 持久存储中数据的变化。如此一来,我们即可以非常 nice 的同步模型上下文之间、进程之间以及系统组件与 App 之间的数据变化了。

在这里插入图片描述在这里插入图片描述

历史记录追踪(History Trace)机制专门用来查询 SwiftData 模型数据更改的历史记录,主要来说它有以下几种用途:

  • 了解数据存储何时发生了更改?发生了什么更改?即使记录从数据库中被彻底删除之后仍然可以获取其部分信息(“墓碑”机制);
  • 了解如何使用该信息构建远程服务器同步;
  • 处理进程外的更改事件;

在 SwiftData 2.0 中,苹果为模型上下文(ModelContext)新增了 fetchHistory() 以及一系列相关的方法专门为历史记录追踪功能而服务:

在这里插入图片描述

利用它们我们只需寥寥几行代码即可监听数据变动,仿佛“樽前月下”:

let mainContext = self.modelContext
    
var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
historyDesc.predicate = #Predicate { trans in
    trans.author == "BG"
}

let transactions = try! mainContext.fetchHistory(historyDesc)
for trans in transactions {
    
    for change in trans.changes {
        let modelID = change.changedPersistentIdentifier
        guard let changedItem = mainContext.model(for: change.changedPersistentIdentifier) as? Item else { continue }
        
        switch change {
        case .insert(let historyInsert):
            print("find insert")
            // 处理记录插入
        case .update(let historyUpdate):
            print("find update")
            // 处理记录更新
        case .delete(let historyDelete):
            print("find del")
            // 处理记录删除
        @unknown default:
            fatalError()
        }
    }
}

在上面的代码中,我们主要做了这样几件事:

  • 利用模型上下文的 author 属性排除了非后台 ModelContent 修改的历史记录;
  • 通过 Change 记录的 changedPersistentIdentifier 属性抓取了修改后的托管对象;
  • 根据具体的 Change 类型(新增、更改和删除)来做出妥善的后续处理;

虽然上面的代码没有任何问题,不过需要注意的是历史追踪记录本身也是需要存储在持久数据库中的。这意味着:随着 History Trace 的持续监听这些追踪记录会让数据库的体积变得不堪重负,更尴尬的是这些过期的“累赘”往往已经没有再使用的价值了。

那么我们该如何是好呢?

别着急,HistoryToken 可以为我们解忧排愁!

2. 使用令牌(HistoryToken)过滤历史记录

准确的说,历史令牌(HistoryToken)其实是一种协议:

在这里插入图片描述

因为我们往往都是与 SwiftData 中的默认存储(Store)打交道,所以我们需要使用系统提供的遵循 HistoryToken 协议的实体类型:DefaultHistoryToken。

在这里插入图片描述

默认历史令牌本身包含历史追踪记录发生的时间,并且其本身遵守可比较(Comparable)协议,所以我们可以比较两个令牌来判断它们的时效性。

@State var historyToken: DefaultHistoryToken?

private func handleChangeInMainContext() {
                
    let mainContext = modelContext
    var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
    if let token = historyToken {
        historyDesc.predicate = #Predicate { trans in
            // 排除旧令牌对应的历史记录
            trans.author == "BG" && trans.token > token
        }
    } else {
        historyDesc.predicate = #Predicate { trans in
            trans.author == "BG"
        }
    }
    
    
    let transactions = try! mainContext.fetchHistory(historyDesc)
    for trans in transactions {
        for change in trans.changes {
            // 处理具体的历史追踪记录
        }
    }
    
    // 保存最后一个历史令牌
    historyToken = transactions.last?.token
}

如上代码所示:我们在每次监听历史追踪记录后还不忘保存最后一个历史令牌。这样做的好处是,我们就可以在下一次抓取历史追踪记录时排除过期的记录了。

3. 删除过期的历史记录

虽然上面我们已经能够悠然自得的通过历史令牌来排除过期的历史追踪记录,但是这些“累赘”还仍然顽强的占据着 SwiftData 持久存储数据库的宝贵空间。长此以往,这些无用的历史记录可能会让我们的 App 臃肿不堪。

其实,SwiftData 提供了专门的 deleteHistory() 方法来删除指定的历史追踪记录:

在这里插入图片描述

一般情况下,在过去监听中已经被抓取过的历史追踪记录我们都可以统统删掉:

extension ModelContext {
    
    func deleteTransactions(before token: DefaultHistoryToken) throws {

        var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
        
        descriptor.predicate = #Predicate {
            // 删除早于指定 token 的所有历史追踪记录
            $0.token < token
        }
        
        try self.deleteHistory(descriptor)
    }
}

可以看到,我们删除历史记录的代码非常简单直接:只需将数据库中比指定 token 要早的历史追踪记录删除即可。

现在,我们适配 SwiftData 的 App 在使用 History Trace 时变得又快又好,底层的数据库也始终保持着苗条的身材,棒棒哒!!!

总结

在本篇博文中,我们讨论了如何使用令牌进一步优化 SwiftData 2.0 中历史记录追踪机制的使用;我们随后还介绍了删除数据库中无用追踪记录的方法。

感谢观赏,再会啦!8-)

SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决

在这里插入图片描述

0. 问题现象

我们 watchOS 中的 App 和 Widgets 共享同一个 SwiftData 底层数据库,但是在 App 中对数据库所做的更改并不能被 Widgets 所感知。换句话说,App 更新了数据但在 Widgets 中却看不到。

在这里插入图片描述

如上图所示:我们的 App 在切换至后台之前会随机更新当前的驻场英雄,而驻场英雄会在 Widget 中显示。不过,目前我们的 Widget 中却并未识别到任何驻场英雄,这是怎么回事?又该如何解决呢?

在本篇博文中,您将学到如下内容:

  1. 问题现象
  2. 示例代码
  3. 推本溯源
  4. 解决之道

本文编译及运行环境:Xcode 16 + watchOS 11。


1. 示例代码

首先是 SwiftData 数据模型:

import Foundation
import SwiftData

@Model
class Hero {
    var hid: UUID
    var name: String
    var power: Int
    var residentCount: Int = 0
    var timestamp: Date
    
    init(name: String, power: Int) {
        self.hid = UUID()
        self.name = name
        self.power = power
        timestamp = .now
    }
    
    func update() {
        timestamp = .now
    }
    
    private static let HeroInfos: [(name: String, power: Int)] = [
        ("黑悟空", 10000),
        ("钢铁侠", 5000),
        ("灭霸他爸", 500000),
    ]
    
    @MainActor
    static func spawnHeros(forPreview: Bool = true) {
        let container = forPreview ? ModelContainer.preview : .shared
        let context = container.mainContext
        
        if !forPreview {
            let desc = FetchDescriptor<Hero>()
            if try! context.fetchCount(desc) > 0 {
                return
            }
        }
        
        for hero in HeroInfos {
            let new = Hero(name: hero.name, power: hero.power)
            context.insert(new)
        }
        
        try! context.save()
    }
}

@Model
class Model {
    private static let UniqID = UUID(uuidString: "3788ABA9-043C-4D34-B119-5D69D486CBBA")!
    
    var mid: UUID
    
    @Relationship(deleteRule: .nullify)
    var residentHero: Hero?
    
    init(mid: UUID) {
        self.mid = mid
        self.residentHero = nil
    }
    
    @MainActor
    static var shared: Model = {
        let context = ModelContainer.auto.mainContext
        let predicate = #Predicate<Model> { model in
            model.mid == UniqID
        }
        
        let desc = FetchDescriptor(predicate: predicate)
        if let result = try! context.fetch(desc).first {
            return result
        } else {
            let new = Model(mid: UniqID)
            context.insert(new)
            try! context.save()
            return new
        }
    }()
    
    // 随机产生驻场英雄
    @MainActor
    func chooseResidentHero() {
        let context = ModelContainer.auto.mainContext
        let desc = FetchDescriptor<Hero>(sortBy: [.init(\Hero.power)])
        
        if let hero = try! context.fetch(desc).randomElement() {
            residentHero = hero
            hero.residentCount += 1
            try! context.save()
        }
    }
}

可以看到,我们的 App 由 Hero 和 Model 两种数据模型构成。其中,在 Model 里我们以关系(@Relationship)的形式将驻场英雄字段 residentHero 连接到 Hero 类型上。

接下来是 watchOS App 主视图的源代码:

struct ContentView: View {
    
    @Environment(\.scenePhase) var scenePhase
    @Environment(\.modelContext) var modelContext
        
    var body: some View {
        NavigationStack {
            Group {
                // 具体实现从略...
            }
            .navigationTitle("英雄集合")
        }
        .onChange(of: scenePhase) {_, new in
            if new == .inactive {
                Model.shared.chooseResidentHero()           // 1
                WidgetCenter.shared.reloadAllTimelines()    // 2
            }
        }
    }
}

从上面的代码能够看到,当 App 切换至非活动状态(inactive)时我们做了两件事:

  1. 为 Model 随机选择一个驻场英雄,并将新的关系保存到持久存储中;
  2. 刷新 Widgets 时间线从而促使小组件界面的刷新;

最后,是我们 watchOS Widget 界面的源代码:

struct IncValueWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            if let residentHero = Model.shared.residentHero {
                VStack(alignment: .leading) {
                    HStack {
                        Label(residentHero.name, systemImage: "person.and.background.dotted")
                            .foregroundStyle(.red)
                            .minimumScaleFactor(0.5)
                        Spacer()
                        Text("已驻场 \(residentHero.residentCount) 次")
                            .font(.system(size: 12))
                            .foregroundStyle(.secondary)
                    }
                    
                    HStack {
                        Text("战斗力 \(residentHero.power)")
                            .minimumScaleFactor(0.5)
                        Spacer()
                        Button(intent: EnhancePowerIntent()) {
                            Image(systemName: "bolt.ring.closed")
                        }
                        .tint(.green)
                    }
                }
            } else {
                ContentUnavailableView("英雄都放假了...", systemImage: "xmark.seal")
            }
        }
        .fontWeight(.heavy)
    }
}

可以看到当 Widget 的界面刷新后,我们尝试从共享 Model 实例的 residentHero 关系中读取出对应的驻场英雄,然后将其显示在小组件中。

在 Xcode 预览中差不多是这个样子滴:

在这里插入图片描述

然而,现在执行的结果是:App 明明更新了共享 Model 中的驻场英雄,但是 Widget 里却“涛声依旧”的显示“英雄都在放假”呢?

这样一个简单的代码逻辑却无法让我们得偿所愿,为什么呢?

2. 推本溯源

虽然上面代码简单的不要不要的,但其中有仍有几个关键“隐患”点在调试时需要排除:

  1. App 在进入后台前是否更新驻场英雄数据到持久存储上了?
  2. 在更新驻场英雄后是否确保 Widget 被及时刷新了?
  3. 刷新后的 Widget 是否可以确保与 App 共享同一个持久存储?

第一条很好排除,只需要在 App 对应的代码行上设置断点然后观察其执行结果即可。

第二条需要在 Widget 界面视图中设置断点,然后用调试器附着到小组件执行进程上观察即可。

经过测试可以彻底排除前两个潜在“故障点”。福尔摩斯曾经说过:“当你排除一切不可能的情况。剩下的,不管多难以置信,那都是事实

所以,问题的原因一定是 App 和 Widget 之间没有正确同步它们的底层数据。

回到共享 Model 静态属性的代码中,可以看到我们的 shared 属性其实是一个惰性(lazy)属性:

@MainActor
static var shared: Model = {
    let context = ModelContainer.auto.mainContext
    let predicate = #Predicate<Model> { model in
        model.mid == UniqID
    }
    
    let desc = FetchDescriptor(predicate: predicate)
    if let result = try! context.fetch(desc).first {
        return result
    } else {
        let new = Model(mid: UniqID)
        context.insert(new)
        try! context.save()
        return new
    }
}()

这意味着:当它被求过值后,后续的访问不会再重新计算这个值了。

当我们在 Widget 里第一次访问它时,其 residentHero 关系字段中还未包含对应的驻场英雄。当 App 更新了驻场英雄后,Widget 中原来的 Model.shared 对象并不会自动刷新来反映持久存储中数据的改变。这就是问题的根本原因!

3. 解决之道

在了然了问题的根源之后,解决起来就是小菜一碟了。

最简单的方法,我们只需将原来的惰性属性变为计算属性即可。这样一来,我们即可确保在每次访问 Model 的共享单例时它的内容都会得到及时的刷新:

@MainActor
static var liveShared: Model {
    let context = ModelContainer.auto.mainContext
    let predicate = #Predicate<Model> { model in
        model.mid == UniqID
    }
    
    let desc = FetchDescriptor(predicate: predicate)
    if let result = try! context.fetch(desc).first {
        return result
    } else {
        let new = Model(mid: UniqID)
        context.insert(new)
        try! context.save()
        return new
    }
}

如上代码所示,我们将之前的惰性属性变为了“活泼”的计算属性,这样 Widget 每次访问的 Model 共享实例都会是“最新鲜”的:

struct IncValueWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            if let residentHero = Model.liveShared.residentHero {
                // 原代码从略...
            } else {
                ContentUnavailableView("英雄都放假了...", systemImage: "xmark.seal")
            }
        }
        .fontWeight(.heavy)
    }
}

编译并再次运行 App,当切换至对应 Widget 后可以看到我们的驻场英雄闪亮登场啦:

在这里插入图片描述

至此,我们解决了博文开头那个问题,棒棒哒!💯

总结

在本篇博文中,我们讨论了 SwiftData 共享数据库在 App 中做出的改变,却无法被 对应 Widgets 感知的问题。我们随后找出了问题的原因并“一发入魂”将其完美解决。

感谢观赏,再会啦!8-)

SwiftUI 在 iOS 18 中的 ForEach 点击手势逻辑发生改变的解决

在这里插入图片描述

概述

原本在 iOS 17 中运行良好的 SwiftUI 代码突然在 iOS 18 无法正常工作了,具体表现为原来视图中的的点击手势无法响应。

这是怎么回事呢?

在这里插入图片描述

且看分解!Let’s go!!!;)


问题现象

在这里插入图片描述

从下面的演示代码可以看到,我们在 List 容器的 ForEach 视图上添加了点击手势:

List {
    ForEach(showingDialogues, id: \.self) { dialog in
        Text(dialog)
            .font(.title3.weight(.bold))
            .listRowSeparator(.hidden)
    }
    .onTapGesture {
        if index < dialogues.count {
            showingDialogues.append(dialogues[index])
            index += 1
        } else {
            isShowQuizView = true
        }
    }
}

这在 iOS 17 中工作正常,但是升级至 iOS 18 后点击却无任何反应,检查发现是点击手势的回调并没有被触发。

解决之道

解决非常简单,我们只需将原来直接附着在 ForEach 上的手势修改器移动至外层(比如 List 上)即可:

List {
    ForEach(showingDialogues, id: \.self) { dialog in
        Text(dialog)
            .font(.title3.weight(.bold))
            .listRowSeparator(.hidden)
    }
}
.listStyle(.plain)
.onTapGesture {
    if index < dialogues.count {
        showingDialogues.append(dialogues[index])
        index += 1
    } else {
        isShowQuizView = true
    }
}

究其原因,我们注意到:之前旧代码中视图在显示时 showingDialogues 状态数组的内容为空。

@State var showingDialogues = [String]()

这在 iOS 18 之前的系统里,ForEach 仍会产生可点击范围,但是在 iOS 18 中并不会。

我们猜测原因是 iOS 18 对视图可见性检查更加严格了。因为按照常理来说当 ForEach 构造器对应的集合为空时其不应该再产生可点击的范围,所以这是 iOS 18 行为更加严谨的表现。

在这里插入图片描述

希望本篇博文可以一解大家的燃眉之急。

总结

在本篇博文中,我们讨论了 iOS 18 中的 SwiftUi ForEach 视图点击逻辑和之前略有不同的情况,并给出解决方法。这可能是 SwiftUI 在 iOS 18 系统中变得更加严谨了。

感谢观赏!再会啦!8-)

Time Profiler 性能分析

使用 Time Profiler 查看关键函数调用耗时情况,从而分析和解决问题

1.59 s   15.4%1.31 s     closure #6 in LineChartView.generateSingleLineChart(from:)
579.00 ms    5.6%490.00 ms     closure #6 in LineChartView.generateGroupLineChart(from:)
524.00 ms    5.0%423.00 ms     closure #1 in closure #4 in LineChartView.generateLineChart(from:)
269.00 ms    2.6%221.00 ms     closure #1 in closure #6 in LineChartView.generateOtherLineChart(from:)
92.00 ms    0.8%69.00 ms     closure #1 in closure #1 in closure #4 in LineChartView.ChartSectionView.body.getter


1.06 s   21.2%884.00 ms     closure #6 in LineChartView.generateSingleLineChart(from:)

let chartContent = ForEach(data) { model in
            // LineMark for data
226 ms            LineMark(
43 ms                x: .value("Time", model.timestamp),
49 ms                y: .value("Value", model.value)
            )
166 ms            .foregroundStyle(viewModel.getLineColor(for: model.name))
561 ms            .lineStyle(StrokeStyle(lineWidth: chartLineWidth, lineCap: .round, lineJoin: .round))
        }

325.00 ms    6.5%259.00 ms     closure #1 in closure #4 in LineChartView.generateLineChart(from:)
return Chart {
            ForEach(data) { model in
102 ms            LineMark(
18 ms               x: .value("Time", model.timestamp),
18 ms                y: .value("Value", model.value)
                )
185 ms              .lineStyle(StrokeStyle(lineWidth: chartLineWidth, lineCap: .round, lineJoin: .round))
            }
        }

306.00 ms    6.1%261.00 ms     closure #6 in LineChartView.generateGroupLineChart(from:)

let chartContent = ForEach(data) { model in
            // LineMark for data
58 ms             LineMark(
10 ms               x: .value("Time", model.timestamp),
6 ms               y: .value("Value", model.value)
            )
67 ms            .foregroundStyle(by: .value("Field", model.name))
158 ms              .lineStyle(StrokeStyle(lineWidth: chartLineWidth, lineCap: .round, lineJoin: .round))
        }
144.00 ms    2.8%123.00 ms     closure #1 in closure #6 in LineChartView.generateOtherLineChart(from:)

return Chart {
            ForEach(data) { model in
25 ms             LineMark(
7 ms             x: .value("Time", model.timestamp),
6 ms             y: .value("Value", model.value)
                )
23 ms            .foregroundStyle(model.color)
77 ms             .lineStyle(StrokeStyle(lineWidth: chartLineWidth, lineCap: .round, lineJoin: .round))
            }
        }
42.00 ms    0.8%42.00 ms     initializeWithCopy for LineChartData

1. 分析 Time Profiler 的耗时情况

  • 闭包内部耗时较高

    • generateSingleLineChart 中,ForEach 循环内部的闭包花费了 884 毫秒,其中 .lineStyle(...) 调用占了 561 毫秒,说明对每个数据点的样式计算非常耗时。
    • 同理,generateLineChartgenerateGroupLineChart 中,也分别花费了 259 毫秒和 261 毫秒。这表明在遍历数据并生成 LineMark 时,SwiftUI 在计算各个修饰符(如 lineStyleforegroundStyle)的过程中开销较大。
  • 重复计算和视图重构

    • 每次调用这些生成图表的函数时,都需要重新计算 x 轴、y 轴的最小值、最大值、刻度值等,而且对每个数据点都要构建一个新的 LineMark。如果数据点较多,这些计算和视图构造操作叠加起来就会变得非常耗时。
  • 样式计算问题

    • 例如在 generateSingleLineChart 中,对每个数据点调用 viewModel.getLineColor(for:) 和创建 StrokeStyle 都消耗了大量时间。这提示我们,样式的计算和生成可能没有复用,每次都在重新构建。

2. 关于 NavigationLink 导航后返回页面时卡顿现象

  • 重绘整个视图
    • 当从 LineChartView 跳转到子页面后返回,SwiftUI 会重新调用 body 生成视图。即使数据没有改变,所有的闭包(如 generateSingleLineChartgenerateGroupLineChartgenerateLineChart 等)都会被重新执行,导致整个图表视图重新计算和绘制。
  • 视图重构导致的重复计算
    • 由于 SwiftUI 的声明式特性,每次视图出现时都会重新计算其内部状态,进而触发大量重复的计算操作(比如对时间范围、轴值和样式的计算),所以返回时会明显卡顿,耗时达到 56 秒。

3. 优化思路和解决方案

针对上述问题,可以从以下几个方面考虑优化:

a. 数据和样式的预计算与缓存
  • 提前计算图表数据范围和样式
    • x 轴和 y 轴的计算、刻度值生成、以及各个数据点对应的样式等计算移到 viewModel 中,提前计算好并缓存。这样在视图构建时,只需直接使用缓存结果,避免重复计算。
  • 缓存 StrokeStyle 和颜色
    • 如果 chartLineWidthlineCaplineJoin 等参数不会变化,可以将生成的 StrokeStyle 定义为常量,避免在 ForEach 内部重复创建。
b. 减少不必要的视图重构
  • 局部视图拆分和懒加载

    • 将复杂且耗时的图表部分拆分成独立的子视图,利用 SwiftUI@StateObject@ObservedObject 缓存数据,防止因父视图重新构建而全部重绘。
    • 如果页面上图表较多,可以考虑使用 LazyVStack 替换 VStack,使得只有当视图真正需要显示时才进行计算和绘制。
  • 视图缓存策略

    • 当数据不频繁变化时,可以考虑在视图中利用 .drawingGroup() 或其他缓存手段,将渲染结果缓存成图像,在下一次展示时直接使用缓存图像而不是重新绘制所有的线条。
c. 关注 SwiftUI 的绘图和布局机制
  • 审查 Chart 的内部实现
    • SwiftUIChart 组件可能在处理大量数据点时没有做到最优。如果可能,考虑对数据进行适当的抽样或者合并,减少绘制的元素数量。
  • 分析 View 重构触发机制
    • 检查是否存在不必要的状态更新或绑定,导致整个 LineChartView 被频繁重构。如果能做到局部状态隔离,只让真正需要更新的部分重绘,也会大大降低耗时。

总结

  1. 问题定位

    • 耗时主要在 ForEach 循环中生成各个 LineMark 的过程中,特别是 .lineStyle 的计算,以及在视图重构时重复计算各项轴值和样式。
    • 导航返回时,因为整个视图重新构建,所有的图表闭包都会再次执行,造成明显卡顿。
  2. 优化方案

    • 预计算和缓存:在 viewModel 中提前计算好各个图表需要的数据、刻度、样式等,避免在视图中重复计算。
    • 减少重复视图构建:将耗时较大的图表部分拆分为独立的子视图,并使用懒加载、缓存视图或 .drawingGroup() 技术来减少重绘。
    • 调整数据量:如果数据点非常多,可以考虑做数据抽样或聚合,降低绘图时的计算量。

MCP 与 Vibe Coding

最近 Twitter / X 上很多人开始讨论「MCP」「Vibe Coding」这种新兴概念,很多是程序员圈子里最新兴起的一些“工作流”和“写代码的新思维方式”总结出来的 buzzword

1️⃣ 什么是 MCP?


📌 MCP = Minimum Cognitive Programming

👉 中文大致可以理解为 “最小认知负担编程”,或者叫 “最小认知编程”


🔍 核心理念

  • 代码不追求「最优算法」或者「最完美设计」,而是追求「对当前自己(或团队)最容易理解和维护」的代码。

  • 写代码的时候,优先考虑 降低认知负担,而不是过度追求工程优雅 / 高度抽象。

  • 口号式表达:

    “If it works and you can read it next week, it's good code.”

    “能跑,能读,能维护,就行。”


MCP 背后的动因

1️⃣ 现在很多代码工程、框架、抽象过度复杂,维护者学习成本高

2️⃣ GPT / CopilotAI 辅助时代,更需要写「简单直观可读的代码」,AI 也能帮你“补全”复杂性

3️⃣ 工程实践发现大部分时间是“读代码”,不是写代码,MCP 提倡写 “别人和自己下周能看懂的代码”


🛠 MCP 实践方法

做法 对应 MCP 思路
优先用简单数据结构 少用花哨设计模式
减少抽象层级 不要无谓的加继承、多态
函数写短点 减少嵌套,易读优先
变量名取清晰 不用“高大上”名字,语义直观即可
不 premature optimization 不是核心瓶颈,先保证逻辑对、代码易读

🏷 一句话总结 MCP

👉 MCP = 优先写“认知负担小,易读,易维护”的代码,不刻意炫技,实用主义风格编程


2️⃣ 什么是 Vibe Coding?


📌 Vibe Coding = Vibe-driven coding style

👉 中文可以叫做:“氛围驱动型编程”“带节奏感的编程”


🔍 核心理念

  • 编程是一种创造性活动,要创造好的“氛围 / vibe”,提升开发者的愉悦感、心流状态 → 更高效、更快乐地编程。
  • Vibe Coding 不是传统工程术语,更像是一种开发者心态 / 工作流理念,强调 轻松、自由、创造感

Vibe Coding 常见表现

行为 说明
播放喜欢的背景音乐 营造 flow
一次写完一整段逻辑 保持 coding groove,减少打断
边写边 refactor,随心所欲 不被 rigid 规则卡死
不过度关注“架构正确性” 先爽,后整理
AI Copilot / ChatGPT 辅助流畅写 AI 帮补节奏感,提升 Vibe

🎨 Vibe Coding 背后的思潮

  • 反思“过度流程化、过度工具化”导致开发变枯燥 → 要让写代码重新变成 “创造活动”,而不是“苦活累活”。
  • AI 辅助写代码后,工程师更能 focus 在 “表达意图” 和 “flow” 上,而不是底层语法和繁琐设计。
  • 对抗程序员 burnout → 保持写代码的乐趣、节奏感。

🏷 一句话总结 Vibe Coding

👉 Vibe Coding = 营造写代码的 flow 和节奏感,优先保持创造力和愉悦感,开发过程“带 vibe”更高效更快乐。


3️⃣ MCP + Vibe Coding 的结合(现在很多 Twitter 上就是这种玩法)

  • MCP 让代码「简单、可维护」 → 减少认知负担
  • Vibe Coding 让写代码过程「顺畅、有节奏」 → 提高创造体验
  • AI(Copilot / ChatGPT / Cursor 等)正好促进了这两种思路落地AI 写代码时,如果 prompt 简单明确 + 代码风格易读,AI 补全就更好;Vibe CodingAI 也能增强 flow

4️⃣ 我们怎么学 / 怎么用?


✅ 对工作实践的建议:

做法 MCP 思路 Vibe Coding 思路
日常代码多写「易读、简单」 用普通 for/if,不追求炫技语法 保持 coding flow,先写再调
配合 Copilot / ChatGPT 生成易懂 prompt,易维护代码 一边生成一边调整,保持节奏
少“过度设计” YAGNI 原则,不写用不到的抽象 先写 MVP,保持写的爽感
重视团队代码可维护性 别让自己下周都看不懂 不卷“架构洁癖”

5️⃣ 总结一句话:

MCP = 认知负担最小 → 易读、易维护的实用代码

Vibe Coding = 编程带节奏、保持 flow → 让写代码更有创造力和愉悦感


6️⃣ 参考学习资源

  • Twitter/X 上 #MCP #VibeCoding 标签,很多大佬会发心得
  • “AI 辅助开发工作流”MCP + Vibe Coding 最配合 AI 使用场景
  • Cursor / Copilot / GPT + IDE → 是 MCP + Vibe Coding 的典型工具场景


📚 针对我现在工作场景的 MCP + Vibe Coding 落地方案

我当前的工作场景(iOS + React Native + Swift/SwiftUI + 组件化 + Charts + SDK 工程 + 架构优化)


🎯 我当前典型场景特点

✅ iOS 开发(Swift / SwiftUI / UIKit / React Native

✅ 组件化架构改造期(拆分 IndicatorKit / MarketModule / RealtimeQuoteModule / StockDetailKit 等)

✅ 旧代码 OC/Swift 混杂,技术债多

Charts 图表复杂,易写成高复杂代码

✅ 有跨团队协作,需要代码清晰可维护

✅ 需要加快交付节奏,提升自信和 flow


🛠 1️⃣ 如何落地 MCP 思维(最小认知负担编程)


✅ a) 组件化拆分阶段

传统写法 MCP 优化思路
为了架构“完美”,抽象一堆协议 + 多层 wrapper MVP 阶段先写简单自包含组件,协议 / 抽象层逐步演进
IndicatorKit / MarketModule 拆分过度工程化 先拆物理模块 + 清晰 API,后期视需要再抽象复用层
为复用提前设计复杂泛型 优先“功能先跑通 + 接口直白”

✅ b) Charts 图表模块

传统写法 MCP 优化思路
复杂 config 驱动,ViewModel 太厚 ChartViewModel 分层 + 每张图有独立 config struct,保持易读
Charts 配置“动态拼拼凑凑” 统一简化模型,明确哪些参数“必要”,哪些是“可选”
过度用 Combine / 高阶 Publisher 嵌套 普通数据 flow + 明确 side effect,易维护优先

✅ c) OC/Swift 混合代码

传统写法 MCP 优化思路
急于“重构替换全部” 逐步“封装接口 + 兼容”迁移,阶段性成果可见
复杂 bridging 轻量 bridge,先保证可用可维护

✅ d) 日常写代码习惯调整

改进建议
函数写短(<= 20 行)
变量名直接、具备业务语义
避免 premature optimization
“能跑能读”优先,不急于设计过度通用性
UI 层用 SwiftUI 时,保持 View 纯净,逻辑放 ViewModel

🎵 2️⃣ 如何落地 Vibe Coding 思维(节奏感编程)


✅ 工具搭配(针对 iOS 场景)

工具 推荐理由
Cursor IDE + iOS 工程 作为副 IDEutility / demo,体验 Vibe Coding
ChatGPT + Codex 生成 Swift / SwiftUI / Charts 代码,提速
GitHub Copilot (Xcode plugin) Xcode 里直接 AI 补全,写 Swift / SwiftUI 体验 Vibe
Raycast + GPT 插件 快速查代码片段 / 生成代码
Music App + 固定 coding playlist 帮你 build flowVibe Coding 很推崇)

✅ 我的编码习惯调整

场景 推荐做法
日常组件开发 “先跑通 flow” → 再局部优化
复杂 Charts 图表交互 先实现交互完整 MVP → 再逐步优化性能 / 结构
新模块 API 设计 边写边聊 ChatGPTAI 辅助完善 API 设计
文档同步 AI 帮你“生成接口注释 / 使用示例”,提升文档质量
遇到复杂 legacy 代码 GPT 帮解释 → 保持节奏改造,不钻牛角尖

✅ 心态层面

👉 MCP + Vibe Coding = 给自己“减压 + 加 flow

👉 当前阶段不用急于追求“架构大师范”,先做“能跑能维护 + 组件清爽 + 节奏流畅”

我需要记住一句话

「架构是“演进”出来的,不是“设计”出来的,MCP + Vibe Coding 最适合架构重构期的风格。」


🏆 3️⃣ 总结关键策略

MCP → 减少认知负担,代码可读性第一

Vibe Coding → 编码过程保持 flow,先做 MVP,后期优化

结合 AI → 提速 + 降低心智成本

逐步打造属于自己风格的 “轻盈架构师” 路线



AI 圈 / 工程圈 “缩写撞车” MCP

📌 1️⃣ AI 圈的 MCP 是什么?

Model Context Protocol (MCP) 👉 是最近开源圈 / AI 圈热炒的一个开放协议

用途

  • AI 大模型(比如 GPT-4oClaudeLLaMA)能够通过标准协议 安全访问本地或远程资源

  • 典型场景包括:

    • 让大模型访问文件系统
    • 让大模型调用 API
    • 让大模型读写数据库
    • 做本地 agent 的插件协议(有点类似 “ChatGPT 插件协议”)

出处

  • 目前 GitHub 上有很多 Awesome MCP Servers 列表,收集各种 MCP server 实现。
  • 比如 github.com/awesome-mcp 这种列表。

📌 2️⃣ 之前讲的 MCP?

Minimum Cognitive Programming (MCP) 👉 是一种编程理念 / 编码风格,源自 Twitter / 开发者圈子提倡:

核心思想

  • 编写认知负担最小的代码
  • 保证代码可读、可维护
  • 不追求 “完美架构”,而追求 “写出来能看懂 + 能改 + 能交付”
  • 很适合 AI 辅助编程、现代敏捷开发场景

出处

  • Twitter / X 上很多工程师最近发 “MCP 风格编码” 的帖子,鼓励这种写法,尤其配合 Copilot / GPT 编码时代。

📌 3️⃣ 撞名字?

  • 两者名字恰好都是 MCP(缩写相同)
  • 但是领域完全不同
AI 圈的 MCP 之前讲的 MCP
Model Context Protocol Minimum Cognitive Programming
协议标准(AI agent 圈) 编程理念(工程圈 / 开发圈)
帮大模型调用外部资源 帮开发者降低代码复杂度
有 GitHub awesome 列表 多是 Twitter 圈理念流行
技术协议 编程思维方式

📌 4️⃣ 总结一句话:

👉 AI agent 圈的 MCP = Model Context Protocol,是 AI Agent 圈的开放协议; 👉 之前讲的 MCP = Minimum Cognitive Programming,是工程师圈最近推崇的代码风格理念

苹果审核被拒4.1-Copycats过审技巧实操

背景

最近后台有留言遭遇了苹果审核被拒Guideline 4.1 - Design - Copycats,需要咨询解惑。

苹果给出的原文如下:

This app or its metadata appears to still misrepresenting itself as another 
popular app or game already available on the App Store, 
from a developer's website or distribution source, or from a third-party platform.

简单来说就是和Appstore知名度较高的产品撞车了,被苹果判断为抄袭行为。

说得再直白一点,抄作业的时候把仅供参考也抄上去了

触发原因

一般侵权事件多半出现于迭代的产品,至少代码不是4.3(a)就证明代码层面是过关了,相对于4.3(a)来说可以说是不值一提。

大概有两种作案心理心理,第一种是自己的之前下架的产品想重回巅峰,心存侥幸。第二种是把头部产品的内容、色调通通照搬。

解决方案

第一步

不要急于修改,要先良性沟通。 从自身设计、布局、玩法去解释差异性,向苹果审核员晓之以理,动之以情,并且直言不讳的告诉审核团队自己在本次修改中,修改了哪些内容。在Logo可以考虑上传UI的设计稿作为佐证说明

如果第一步正常触发审核且在进入正在审核前收到邮件,那么快的话可能30~40分钟可以顺利通过。

fixbug.png

第二步

如果解释无效,苹果的态度依旧强烈维持原判。那这种情况下考虑从代码层面入手,优先调整Logo、App名称、副标题。 在Appstore市场截图考虑模拟器截取,不使用UI设计的截图。

在主色调进行差异性整改,去修改App内部的功能。建议把Appstore元数据修改和App风格修改分两步走

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

相关推荐

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

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

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

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

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

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

图片

SwiftUI 6.0(iOS 18)新增的网格渐变色 MeshGradient 解惑

在这里插入图片描述

概述

在 SwiftUI 中,我们可以借助渐变色(Gradient)来实现更加灵动多彩的着色效果。从 SwiftUI 6.0 开始,苹果增加了全新的网格渐变色让我们对其有了更自由的定制度。

在这里插入图片描述

因为 gif 格式图片自身的显示能力有限,所以上面的动图无法传神的还原实际的美妙效果。强烈建议大家在模拟器或真机上运行本文中的示例代码。

在本篇博文中,您将学到如下内容:

  1. 渐变色的前世今生
  2. 动画加持,美轮美奂
  3. 综合运用

闲言少叙,让我们马上进入渐变色的世界吧!

Let‘s dive in!!!;)


1. 渐变色的前世今生

在 SwiftUI 中小伙伴们时常会用渐变色(或称为阶梯色)来装扮我们的界面。

在这里插入图片描述

在 SwiftUI 1.0(iOS 13)中有 3 种渐变色类型,它们分别是:线性渐变色 LinearGradient、辐射渐变色 RadialGradient、以及角度渐变色 AngularGradient。

在这里插入图片描述

而在 SwiftUI 3.0(iOS 15)中,苹果又添加了一款椭圆渐变色 EllipticalGradient:

在这里插入图片描述

为了能够更加轻松的使用单一颜色的渐变色,苹果从 SwiftUI 4.0(iOS 16)开始干脆将其直接“融入”到 Color 的实例中去了:

在这里插入图片描述

我们可以这样使用它:

Text("Hello Panda")
.foregroundStyle(.red.gradient)

在 WWDC 24 中,苹果再接再厉为 SwiftUI 6.0(iOS 18)添加了全新渐变色风格:网格渐变色(MeshGradient ):

在这里插入图片描述

别被它的名字所吓到,其实它只是用纵横交错的方格来进一步细粒度控制颜色渐变的自由度,仅此而已。

使用网格渐变色很简单,我们只需创建一个 MeshGradient 实例即可:

MeshGradient(
        width: 2,
        height: 2,
        points: [.init(x: 0, y: 0),.init(x: 1, y: 0), .init(x: 0, y: 1), .init(x: 1, y: 1)],
        colors: [.red, .green, .blue, .yellow]
    )

如上代码所示:我们创建了一个 2 x 2 网格渐变色,并将其左上角、右上角、左下角、右下角的颜色依次设置为红色、绿色、蓝色以及黄色:

在这里插入图片描述

现在我们“静如处子”的网格渐变色貌似略显“呆滞”。别急,通过适当地调整其内部各个网格边框的基准点(或者颜色),我们可以让它行云流水般的“动如脱兔”。

2. 动画加持,美轮美奂

上面说过,要想动画网格渐变色很简单。我们只需使用若干状态来实时的描述 MeshGradient 中每个网格边框的相对位置以及网格内的颜色即可。

首先,我们创建一个 positions 数组来表示每个网格的边框,注意这是一个 3 x 3 网格:

@State var positions: [SIMD2<Float>] = [
        .init(x: 0, y: 0), .init(x: 0.2, y: 0), .init(x: 1, y: 0),
        .init(x: 0, y: 0.7), .init(x: 0.1, y: 0.5), .init(x: 1, y: 0.2),
        .init(x: 0, y: 1), .init(x: 0.9, y: 1), .init(x: 1, y: 1)
    ]

接下来,我们利用定时器连续调整 positions 里所有非顶角网格边框的相对位置。排除顶角网格的原因是:我们不想让整个网格渐变色在顶点被裁剪:

在这里插入图片描述

具体实现代码如下所示:

let timer = Timer.publish(every: 1/9, on: .current, in: .common).autoconnect()
    
let colors: [Color] = [
    .purple, .red, .yellow,
    .blue, .green, .orange,
    .indigo, .teal, .cyan
]

func randomizePosition(
    currentPosition: SIMD2<Float>,
    xRange: (min: Float, max: Float),
    yRange: (min: Float, max: Float)
) -> SIMD2<Float> {
    let updateDistance: Float = 0.01

    let newX = if Bool.random() {
        min(currentPosition.x + updateDistance, xRange.max)
    } else {
        max(currentPosition.x - updateDistance, xRange.min)
    }

    let newY = if Bool.random() {
        min(currentPosition.y + updateDistance, yRange.max)
    } else {
        max(currentPosition.y - updateDistance, yRange.min)
    }

    return .init(x: newX, y: newY)
}

MeshGradient(
        width: 3,
        height: 3,
        points: positions,
        colors: colors
    )
    .animation(.bouncy, value: positions)
    .onReceive(timer, perform: { _ in
        positions[1] = randomizePosition(
            currentPosition: positions[1],
            xRange: (min: 0.2, max: 0.9),
            yRange: (min: 0, max: 0)
        )
        
        positions[3] = randomizePosition(
            currentPosition: positions[3],
            xRange: (min: 0, max: 0),
            yRange: (min: 0.2, max: 0.8)
        )
        
        positions[4] = randomizePosition(
            currentPosition: positions[4],
            xRange: (min: 0.3, max: 0.8),
            yRange: (min: 0.3, max: 0.8)
        )
        
        positions[5] = randomizePosition(
            currentPosition: positions[5],
            xRange: (min: 1, max: 1),
            yRange: (min: 0.1, max: 0.9)
        )
        
        positions[7] = randomizePosition(
            currentPosition: positions[7],
            xRange: (min: 0.1, max: 0.9),
            yRange: (min: 1, max: 1)
        )
    })
    .animation(.bouncy, value: positions)
    .ignoresSafeArea()

编译并在 Xcode 预览中运行一见分晓:

在这里插入图片描述

再次重申:上面动图“颗粒感”很强是因为 gif 图片本身对颜色限制(最多显示 256 种颜色)的原因,实际效果会相当丝滑顺畅。

现在,我们不但可以恣意描绘静态渐变色,利用些许动画我们还可以让它活灵活现的呈现“秾姿故薰欲醉眼,芳信暗传尝苦心”之意境,棒棒哒!💯

3. 综合运用

下面是一个将网格渐变色溶入到我们实际应用中的演示代码。在代码中我们做了这样几件事:

  • 用不同状态控制不同的动画效果
  • 使用 mask 将网格渐变色嵌入到文本视图中
  • 扩展 View 以实现更简洁的视图方法

全部源代码在此:

import SwiftUI

extension View {
    @ViewBuilder
    func scaleEffect(_ ratio: CGFloat) -> some View {
        scaleEffect(x: ratio, y: ratio)
    }
}

struct ContentView: View {
    
    @State var bgAnimStart = false
    @State var shadowAnimStart = false
    
    @State var positions: [SIMD2<Float>] = [
        .init(x: 0, y: 0), .init(x: 0.2, y: 0), .init(x: 1, y: 0),
        .init(x: 0, y: 0.7), .init(x: 0.1, y: 0.5), .init(x: 1, y: 0.2),
        .init(x: 0, y: 1), .init(x: 0.9, y: 1), .init(x: 1, y: 1)
    ]

    let timer = Timer.publish(every: 1/9, on: .current, in: .common).autoconnect()
    
    let colors1: [Color] = [
        .purple, .red, .yellow,
        .blue, .green, .orange,
        .indigo, .teal, .cyan
    ]
    
    let colors2: [Color] = [
        .black, .red, .blue,
        .black, .teal, .blue,
        .blue, .red, .black
    ]

    func randomizePosition(
        currentPosition: SIMD2<Float>,
        xRange: (min: Float, max: Float),
        yRange: (min: Float, max: Float)
    ) -> SIMD2<Float> {
        let updateDistance: Float = 0.01

        let newX = if Bool.random() {
            min(currentPosition.x + updateDistance, xRange.max)
        } else {
            max(currentPosition.x - updateDistance, xRange.min)
        }

        let newY = if Bool.random() {
            min(currentPosition.y + updateDistance, yRange.max)
        } else {
            max(currentPosition.y - updateDistance, yRange.min)
        }

        return .init(x: newX, y: newY)
    }
    
    func createMeshGradientView(_ colors: [Color]) -> some View {
        MeshGradient(
            width: 3,
            height: 3,
            points: positions,
            colors: colors
        )
        .animation(.bouncy, value: positions)
        .onReceive(timer, perform: { _ in
            positions[1] = randomizePosition(
                currentPosition: positions[1],
                xRange: (min: 0.2, max: 0.9),
                yRange: (min: 0, max: 0)
            )
            
            positions[3] = randomizePosition(
                currentPosition: positions[3],
                xRange: (min: 0, max: 0),
                yRange: (min: 0.2, max: 0.8)
            )
            
            positions[4] = randomizePosition(
                currentPosition: positions[4],
                xRange: (min: 0.3, max: 0.8),
                yRange: (min: 0.3, max: 0.8)
            )
            
            positions[5] = randomizePosition(
                currentPosition: positions[5],
                xRange: (min: 1, max: 1),
                yRange: (min: 0.1, max: 0.9)
            )
            
            positions[7] = randomizePosition(
                currentPosition: positions[7],
                xRange: (min: 0.1, max: 0.9),
                yRange: (min: 1, max: 1)
            )
        })
    }
    
    let text = Text("Hello Panda")
        .font(.system(size: 108, weight: .heavy, design: .rounded))
        .foregroundStyle(.red.gradient)

    var body: some View {
         
        NavigationStack {
            ZStack {
                
                createMeshGradientView(colors1)
                    //.blur(radius: 30.0)
                    .opacity(0.8)
                
                text
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .opacity(0.01)
                    .background {
                        createMeshGradientView(colors2)
                            .mask {
                                text
                                    .scaleEffect(bgAnimStart ? 1.1 : 1.0)
                                    .rotationEffect(.degrees(bgAnimStart ? -10 : 0))
                            }
                            .shadow(color: shadowAnimStart ? .green : .black, radius: 10)
                    }
                
                
            }
            .ignoresSafeArea()
            .navigationTitle("Mesh Gradient 演示")
            .toolbar {
                Text("大熊猫侯佩 @ \(Text("CSDN").foregroundStyle(.red))")
                    .foregroundStyle(.primary.secondary)
                    .font(.headline)
            }
        }
        .task {
            withAnimation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true)) {
                shadowAnimStart = true
            }
            
            withAnimation(.snappy(duration: 0.66, extraBounce: 15.0).repeatForever(autoreverses: true)) {
                bgAnimStart = true
            }
        }
    }
}

#Preview {
    ContentView()
}

总结

在本篇博文中,我们讨论了 SwiftUI 6.0(iOS 18)中全新网格渐变色 MeshGradient 的使用,并随后介绍如何利用酷炫的动画升华它的动态效果。

感谢观看,再会啦!8-)

SwiftUI 如何取得 @Environment 中 @Observable 对象的绑定?

在这里插入图片描述

概述

从 SwiftUI 5.0(iOS 17)开始,苹果推出了全新的 Observation 框架。它作为下一代内容改变响应者全面参与到数据流和事件流的系统中。

在这里插入图片描述

有了 Observation 框架的加持,原本需要多种状态类型的 SwiftUI 视图现在只需要 3 种即可大功告成,它们分别是:@State、@Environment 以及 @Bindable。

在 SwiftUI 中,我们往往会使用 @Environment 来完成视图继承体系中状态的非直接传递,但是在这种情况下我们却无法获取到它的绑定,造成些许不便。

在本篇博文中,我们就来谈谈如何解决这一问题:

  1. ObservableObject 与 @EnvironmentObject 的旧范儿
  2. 问题现象
  3. 解决之道

Let‘s go!!!;)


1. ObservableObject 与 @EnvironmentObject 的旧范儿

在 SwiftUI 5.0 之前以 @EnvironmentObject 方式跨继承传递状态的视图中,我们可以轻易的获取对应对象的绑定,如下代码所示:

class OldModel: ObservableObject {
    @Published var isSheeting = false
}

struct Home: View {
    
    @EnvironmentObject var oldModel: OldModel
    
    var body: some View {
        Text("Home")
            .sheet(isPresented: $oldModel.isSheeting) {
                Text("Good to go!!!")
            }
    }
}

从上面代码可以看到,用 @EnvironmentObject 修饰的模型类型 oldModel 会“自动”产生对应的绑定形态 $oldModel,这样我们就可以很方便的将其绑定传递到需要的视图中去。

那么,用 Observation 框架中新的 @Observable 和 @Environment 组合来传递跨视图继承体系的状态又会如何呢?

让我们一窥究竟。

2. 问题现象

现在将上面的代码修改为 @Observable 和 @Environment 组合的方式来传递环境变量:

@Observable
class Model {
    var isSheeting = false
}

struct ContentView: View {
    
    @Environment(Model.self) var model
    
    var body: some View {
        Text("Main")
            .sheet(isPresented: $model.isSheeting) {
                Text("Sheeting View")
            }
    }
}

那么问题来了,此时编译器会大声抱怨:根本没有 $model 这样的东西存在!

在这里插入图片描述

可见使用 @Environment(Model.self) 定义的状态对象没有自动生成对应的值绑定,即使 Model 绝对是可观察的(意味着背后一定潜伏着绑定“幽灵”)。

诚然,一种看似简单的解决方法就是使用 Swift 5.9 中新的内嵌 @State 语法:

struct ContentView: View {
    @Environment(Model.self) var model
    
    var body: some View {
        @State var stateModel = model
        
        VStack {
            Text("Main")
            
            Button("Sheeting") {
                stateModel.isSheeting = true
            }
        }
        .sheet(isPresented: $stateModel.isSheeting) {
            Text("Sheeting View")
        }
    }
}

不过这种方式会导致 @State 状态处在创建视图的“外部”,可能会将其变为常量从而阻止实际值的更新。当然,这只是一种潜在的可能性,也可能我们 App 运行的毫无问题。不过,无论如何调试器都会在 App 运行时提出“严正警告”:

在这里插入图片描述

那么,对此我们还能做些什么呢?

3. 解决之道

一种方法是写一个视图包装器,然后将 Model 对象在其中转换为绑定的形式:

struct ContentView: View {
    
    @Environment(Model.self) var model
    
    @ViewBuilder
    func createBody() -> some View {
        let bindableModel = Bindable(model)
        
        VStack {
            Text("Main")
            
            Button("Sheeting") {
                bindableModel.wrappedValue.isSheeting = true
            }
        }
        .sheet(isPresented: bindableModel.isSheeting) {
            Text("Sheeting View")
        }
    }
    
    var body: some View {
        createBody()
    }
}

因为我们将原来的 @Environment 状态显式转换成了可绑定状态,所以在编译和运行时都没有任何问题。

在这里插入图片描述

其实,按照这种思路我们可以再进一步简化实现代码:

struct ContentView: View {
    
    @Environment(Model.self) var model
    
    var body: some View {
        let bindableModel = Bindable(model)
        
        VStack {
            Text("Main")
            
            Button("Sheeting") {
                bindableModel.wrappedValue.isSheeting = true
            }
        }
        .sheet(isPresented: bindableModel.isSheeting) {
            Text("Sheeting View")
        }
    }
}

如上代码所示,我们还是使用内联变量定义。不过所不同的是,这次我们创建的是 model 对应的可绑定值而不是状态值。所以这次运行不会有任何问题,因为我们没有在外部创建“孤苦伶仃”的 @State 状态。

或者我们干脆一步到位,直接在 body 中使用“原汁原味”的 @Bindable 宏:

struct ContentView: View {
    
    @Environment(Model.self) var model
    
    var body: some View {
        @Bindable var bindableModel = model
        
        VStack {
            Text("Main")
            
            Button("Sheeting") {
                bindableModel.isSheeting = true
            }
        }
        .sheet(isPresented: $bindableModel.isSheeting) {
            Text("Sheeting View")
        }
    }
}

现在,我们成功的将 @Environment 中 @Observable 对象的绑定抽取出来,并且彻底摆脱了中间讨厌 wrappedValue 的横插一刀,棒棒哒!💯

希望本文在某些情景下会给小伙伴们一些启迪,若是如此深感欣慰。

总结

在本篇博文中,我们讨论了为什么不能在 SwiftUI 中 @Environment 的 @Observable 对象上使用绑定(Binding),我们随后讨论了如何巧妙地解决这一问题。

感谢观赏,再会啦!8-)

❌