阅读视图

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

浅尝辄止 GRDB.swift:iOS/macOS 数据持久化的使用感悟.md

GRDB.swift 是一个基于 SQLite 的 Swift 数据库工具包,专注于应用开发体验。本文将从核心概念、关键类、使用方法、最佳实践等维度全面介绍 GRDB。


目录


为什么选择 GRDB

在 iOS/macOS 数据持久化领域,开发者通常面临以下选择:

方案 优势 劣势
Core Data Apple 原生、与 SwiftUI 深度集成 学习曲线陡峭、性能开销大、调试困难
Realm API 简洁、实时同步 内存占用高、闭源、版本迁移复杂
FMDB 轻量、成熟 缺乏类型安全、API 偏 Objective-C 风格
SQLite.swift 类型安全、轻量 功能相对基础、缺少迁移和响应式
GRDB.swift 类型安全 + 功能完整 + 高性能 + 纯 Swift 学习成本略高于 SQLite.swift

GRDB 的核心优势:

  1. 纯 Swift 设计 — 完全契合 Swift 的类型系统和惯用范式
  2. 高性能 — 复杂查询性能比 Realm 快约 4.5 倍,批量插入快 2.3 倍
  3. 类型安全 — 通过 FetchableRecordPersistableRecord 协议和 Column 泛型实现编译时检查
  4. 响应式编程 — 内置 ValueObservation,原生支持 Combine
  5. 数据库迁移 — 内置 DatabaseMigrator,支持增量式 Schema 变更
  6. FTS5 全文搜索 — 一等公民支持,无需手写 SQL
  7. 轻量无依赖 — 基于 SQLite C API,无需额外运行时
  8. 活跃维护 — 自 2015 年起持续维护,社区活跃

快速开始

安装

Swift Package Manager:

dependencies: [
    .package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0")
]

CocoaPods:

pod 'GRDB.swift'

核心协议与关键类

GRDB 的 API 围绕一组核心协议和类构建,理解它们是高效使用 GRDB 的前提:

协议(Protocols)

协议 作用 说明
FetchableRecord 从数据库行读取数据 定义如何将查询结果映射为 Swift 类型
PersistableRecord 将数据写入数据库 定义如何将 Swift 类型持久化
MutablePersistableRecord 可变持久化记录 支持插入后自增 ID 回写等场景
TableRecord 表记录 声明表名,提供查询入口(如 FTS5 的 .matching()
DatabaseValueConvertible 数据库值转换 自定义类型与 SQLite 值的双向转换

类(Classes)

作用 说明
DatabasePool 连接池(推荐) 支持并发读写,多读连接 + 单写连接
DatabaseQueue 串行队列 适用于需要严格串行化访问的场景
DatabaseMigrator 数据库迁移 管理增量式 Schema 变更
ValueObservation 响应式观察 追踪数据库变化,自动触发重新查询
Configuration 配置 外键约束、日志、WAL 模式等
DatabaseError 错误类型 封装 SQLite 错误码和信息

辅助类型

类型 作用
Column 类型安全的列引用,支持链式查询
ForeignKey 外键定义,用于关联查询
FTS5Pattern FTS5 搜索模式
SQLLiteral 安全的 SQL 片段构建

数据库连接管理

DatabasePool vs DatabaseQueue

import GRDB

// 推荐:DatabasePool 支持并发读,适合大多数应用场景
var config = Configuration()
config.foreignKeysEnabled = true  // 启用外键约束
let dbPool = try DatabasePool(path: "/path/to/db.sqlite", configuration: config)

// 替代方案:DatabaseQueue 串行访问
let dbQueue = try DatabaseQueue(path: "/path/to/db.sqlite", configuration: config)

选择建议:

  • 优先使用 DatabasePool — 读操作可以并发执行,性能更好
  • 仅在需要严格串行化时使用 DatabaseQueue

读写操作

// 读操作
let users = try dbPool.read { db in
    try User.fetchAll(db)
}

// 写操作
try dbPool.write { db in
    try user.insert(db)
}

// 批量写入(整个闭包自动包裹在事务中,非常方便)
try dbPool.write { db in
    for todo in todos {
        try todo.insert(db)
    }
}

提示: DatabasePool.write { } 闭包默认就是一个事务,中途抛出异常会自动回滚,无需手动管理。


数据模型定义

GRDB 推荐使用 struct + Codable 模式定义模型。只要你的 struct 遵循 Codable,就能极简地接入 GRDB:

最简模型

import GRDB

struct User: Codable, FetchableRecord, PersistableRecord, Identifiable {
    var id: Int64
    var name: String
    var email: String
    var createdAt: Date

    static let databaseTableName = "users"
}

就这样,一个可用的 GRDB 模型就定义好了。Codable 负责自动编解码,FetchableRecord 支持查询,PersistableRecord 支持写入。

完整模型(带列名映射)

当 Swift 属性名(camelCase)和数据库列名(snake_case)不一致时,用 CodingKeys 映射:

struct Todo: Codable, FetchableRecord, PersistableRecord, Identifiable, Sendable {
    var id: String
    var title: String
    var isCompleted: Bool
    var createdAt: Date

    static let databaseTableName = "todos"

    // Swift 属性名 → 数据库列名映射
    enum CodingKeys: String, CodingKey {
        case id, title
        case isCompleted = "is_completed"
        case createdAt = "created_at"
    }

    // 类型安全的列引用,用于查询构建
    enum Columns {
        static let id = Column(CodingKeys.id)
        static let title = Column(CodingKeys.title)
        static let isCompleted = Column(CodingKeys.isCompleted)
        static let createdAt = Column(CodingKeys.createdAt)
    }
}

三个组件各司其职:

组件 职责
CodingKeys 属性名 ↔ 列名映射,Codable 自动使用
Columns 为查询提供类型安全引用,编译时检查列名
databaseTableName 声明对应的 SQLite 表名

数据库迁移

DatabaseMigrator 是 GRDB 的迁移系统,支持增量式 Schema 变更。每个迁移有唯一标识符,只会执行一次:

var migrator = DatabaseMigrator()

// v1: 创建初始表结构
migrator.registerMigration("v1_create_tables") { db in
    try db.create(table: "todos") { t in
        t.column("id", .text).primaryKey()
        t.column("title", .text).notNull()
        t.column("is_completed", .integer).notNull().defaults(to: 0)
        t.column("created_at", .datetime).notNull().defaults(to: Date())
    }

    try db.create(table: "tags") { t in
        t.column("id", .text).primaryKey()
        t.column("name", .text).notNull()
        t.column("color", .text)
    }

    // 外键 + 级联删除
    try db.create(table: "todo_tags") { t in
        t.column("todo_id", .text).notNull()
            .references("todos", onDelete: .cascade)
        t.column("tag_id", .text).notNull()
            .references("tags", onDelete: .cascade)
        t.primaryKey(["todo_id", "tag_id"])
    }
}

// v2: 增量添加新字段
migrator.registerMigration("v2_add_priority") { db in
    // 防御性检查:避免重复迁移导致崩溃
    let columns = try db.columns(in: "todos")
    if !columns.contains(where: { $0.name == "priority" }) {
        try db.alter(table: "todos") { t in
            t.add(column: "priority", .integer).defaults(to: 0)
        }
    }
}

// 执行迁移(自动执行所有尚未执行的版本)
try migrator.migrate(dbPool)

最佳实践:

  • 每个迁移使用唯一标识符(如 "v1_create_tables", "v2_add_priority"
  • 迁移中加入防御性检查,避免重复执行崩溃
  • 新增列使用 defaults(to:) 设置默认值,保证旧数据兼容
  • 合理使用 .references(..., onDelete: .cascade) 自动清理关联数据

CRUD 操作

插入

let todo = Todo(id: UUID().uuidString, title: "学习 GRDB", isCompleted: false, createdAt: Date())

// 基本插入
try dbPool.write { db in
    try todo.insert(db)
}

// 冲突时忽略(适合幂等写入,比如初始种子数据)
try todo.insert(db, onConflict: .ignore)

// Upsert(INSERT OR REPLACE,存在则更新)
try todo.save(db)

查询

// 按 ID 查询单条
let todo: Todo? = try dbPool.read { db in
    try Todo.fetchOne(db, key: id)
}

// 条件查询 + 排序
let activeTodos: [Todo] = try dbPool.read { db in
    try Todo
        .filter(Todo.Columns.isCompleted == false)
        .order(Todo.Columns.createdAt.desc)
        .fetchAll(db)
}

// 计数
let total = try dbPool.read { db in
    try Todo.fetchCount(db)
}

// 聚合查询(如获取最大排序序号)
let maxOrder: Int? = try dbPool.read { db in
    try Tag.select(max(Tag.Columns.sortOrder))
        .asRequest(of: Int?.self)
        .fetchOne(db)
}

更新

// 更新单个对象
try dbPool.write { db in
    var todo = try Todo.fetchOne(db, key: id)!
    todo.isCompleted = true
    try todo.update(db)
}

// 批量更新(高效:一条 SQL 搞定)
try dbPool.write { db in
    try Todo
        .filter(ids.contains(Todo.Columns.id))
        .updateAll(db, [
            Todo.Columns.isCompleted.set(to: true)
        ])
}

删除

// 删除单条
try dbPool.write { db in
    try Todo.deleteOne(db, key: id)
}

// 条件批量删除
try dbPool.write { db in
    try Todo.filter(Todo.Columns.isCompleted == true).deleteAll(db)
}

查询构建

GRDB 提供了强大的类型安全查询接口,基于 Column 泛型,告别字符串拼接 SQL:

常用查询模式

// WHERE + ORDER BY
let results = try Todo
    .filter(Todo.Columns.isCompleted == false)
    .order(Todo.Columns.createdAt.desc)
    .fetchAll(db)

// 多条件组合
let results = try Todo
    .filter(Todo.Columns.isCompleted == false)
    .filter(Todo.Columns.createdAt > yesterday)
    .order(Todo.Columns.createdAt.desc)
    .fetchAll(db)

// LIKE 模糊查询
let results = try Todo
    .filter(Todo.Columns.title.like("%GRDB%"))
    .fetchAll(db)

// IN 查询
let results = try Todo
    .filter([id1, id2, id3].contains(Todo.Columns.id))
    .fetchAll(db)

分页查询

// 第 3 页,每页 20 条
let page = try Todo
    .order(Todo.Columns.createdAt.desc)
    .limit(20, offset: 40)
    .fetchAll(db)

相比手写 SELECT * FROM todos LIMIT 20 OFFSET 40,类型安全查询能在编译期发现列名拼写错误,代码也更易读。


关联关系(Associations)

GRDB 提供了原生的关联系统,支持一对多和多对多关系。以经典的「文章-标签」多对多关系为例:

定义关联

// 标签
struct Tag: Codable, FetchableRecord, PersistableRecord, Identifiable {
    var id: String
    var name: String
    var color: String?
    static let databaseTableName = "tags"
}

// 中间表
struct PostTag: Codable, FetchableRecord, PersistableRecord {
    var postId: String
    var tagId: String
    static let databaseTableName = "post_tags"

    // 声明外键
    static let post = belongsTo(Post.self, using: ForeignKey([Columns.postId.name]))
    static let tag = belongsTo(Tag.self, using: ForeignKey([Columns.tagId.name]))
}

// 文章侧扩展
extension Post {
    static let postTags = hasMany(PostTag.self, using: ForeignKey([PostTag.Columns.postId.name]))
    static let tags = hasMany(Tag.self, through: postTags, using: PostTag.tag)
}

// 标签侧扩展
extension Tag {
    static let postTags = hasMany(PostTag.self, using: ForeignKey([PostTag.Columns.tagId.name]))
    static let posts = hasMany(Post.self, through: postTags, using: PostTag.post)
}

使用关联查询

// 查找某个标签下的所有文章(Join 过滤)
let posts = try Post
    .joining(required: Post.tags.filter(Tag.Columns.id == tagId))
    .fetchAll(db)

// 预加载关联数据(Eager Loading,避免 N+1 查询)
struct PostInfo: Decodable, FetchableRecord {
    var post: Post
    var tag: Tag
}

let results = try PostTag
    .including(required: PostTag.tag)
    .including(required: PostTag.post)
    .asRequest(of: PostInfo.self)
    .fetchAll(db)

响应式数据观察(ValueObservation)

ValueObservation 是 GRDB 最强大的特性之一 — 它能自动追踪查询依赖的表,当数据发生变化时自动重新执行查询,真正实现数据驱动 UI

基本用法

import Combine

// 定义观察器
let observation = ValueObservation.tracking { db in
    try Todo
        .filter(Todo.Columns.isCompleted == false)
        .order(Todo.Columns.createdAt.desc)
        .fetchAll(db)
}

// 转为 Combine Publisher
let cancellable = observation
    .publisher(in: dbPool, scheduling: .immediate)
    .sink(
        receiveCompletion: { print("完成: \($0)") },
        receiveValue: { todos in
            // 每次数据库中 todos 表变化,这里会自动收到最新数据
            print("当前待办: \(todos.map(\.title))")
        }
    )

在 SwiftUI 中使用

// ViewModel / Store 中订阅
@MainActor
class TodoStore: ObservableObject {
    @Published var todos: [Todo] = []
    private var cancellables = Set<AnyCancellable>()

    init(dbPool: DatabasePool) {
        ValueObservation.tracking { db in
            try Todo.order(Todo.Columns.createdAt.desc).fetchAll(db)
        }
        .publisher(in: dbPool, scheduling: .immediate)
        .receive(on: RunLoop.main)
        .sink { [weak self] todos in
            self?.todos = todos  // 自动触发 SwiftUI 视图刷新
        }
        .store(in: &cancellables)
    }
}

// SwiftUI 视图
struct TodoListView: View {
    @StateObject private var store: TodoStore

    var body: some View {
        List(store.todos) { todo in
            Text(todo.title)
        }
        // 不需要手动刷新,数据变化时列表自动更新
    }
}

核心机制: ValueObservation.tracking 会自动分析闭包中访问了哪些表。当这些表发生写入操作时,闭包会自动重新执行并发送新值。无需手动调用 reload 或发送通知。


全文搜索(FTS5)

GRDB 对 SQLite FTS5 全文搜索提供了开箱即用的支持:

创建 FTS5 虚拟表

migrator.registerMigration("create_search_index") { db in
    try db.create(virtualTable: "articles_fts", using: FTS5()) { t in
        t.tokenizer = .unicode61()  // Unicode 分词器
        t.column("title")
        t.column("body")
        t.column("author_id").notIndexed()  // 不参与搜索,仅用于关联
    }
}

执行搜索

struct ArticleFTS: Codable, FetchableRecord, PersistableRecord, TableRecord {
    var title: String
    var body: String
    var authorId: String
    static let databaseTableName = "articles_fts"
}

// 前缀搜索(输入 "swi" 能匹配 "swift")
let pattern = try FTS5Pattern(rawPattern: "swi*")
let results = try ArticleFTS
    .matching(pattern)
    .fetchAll(db)

TableRecord 协议为 FTS5 虚拟表提供了 .matching() 方法入口。

关于中文搜索

FTS5 的 unicode61 分词器对中文支持有限(按空格/标点分词)。如果需要中文搜索,常见做法是:

  • 使用第三方中文分词器(如 simple tokenizer)
  • 或结合 LIKE 模糊匹配作为兜底

与 Core Data / Realm 的对比

维度 GRDB Core Data Realm
学习曲线 低-中
类型安全 编译时检查 运行时 编译时检查
性能(查询) 极快(原生 SQLite) 中等 较慢(内存映射)
内存占用
响应式更新 ValueObservation + Combine NSFetchedResultsController 内置 LiveData
数据库迁移 DatabaseMigrator 轻量级迁移 自动但有限
全文搜索 FTS5 原生支持 需第三方 需第三方
跨平台 Apple 全平台 Apple 全平台 全平台
开源 MIT Apple 框架 Apache 2.0
包大小 ~2MB 系统内置 ~10MB+

适用场景建议:

  • GRDB — 中小型到大型应用,需要高性能和类型安全,希望完全控制数据库
  • Core Data — 已有 CoreData 遗留项目,或需要 iCloud 同步
  • Realm — 快速原型、需要实时跨设备同步、复杂的对象图管理

最佳实践

1. 模型层:统一使用 Codable + 协议组合

// 所有模型遵循统一的协议组合
struct MyModel: Codable, FetchableRecord, PersistableRecord, Identifiable, Sendable {
    // ...
}

2. 数据层:按领域拆分 Extension

当项目规模增长后,建议将数据库操作按领域拆分,保持单一职责:

Database/
├── DatabaseManager.swift           // 核心:连接、迁移、通用方法
├── DatabaseManager+Users.swift      // 用户相关
├── DatabaseManager+Orders.swift     // 订单相关
├── DatabaseManager+Search.swift     // 搜索逻辑
└── DatabaseManager+Observers.swift  // 响应式订阅

3. 列名映射:始终使用 CodingKeys + Columns

// CodingKeys 负责属性名 ↔ 列名映射
enum CodingKeys: String, CodingKey {
    case parentId = "parent_id"
}

// Columns 负责查询构建(类型安全)
enum Columns {
    static let parentId = Column(CodingKeys.parentId)
}

// 查询时直接用 Column,编译器会帮你检查拼写
try Model.filter(Model.Columns.parentId == targetId).fetchAll(db)

4. 迁移中加入防御性检查

migrator.registerMigration("v2_add_column") { db in
    // 检查列是否已存在,避免重复迁移导致崩溃
    let columns = try db.columns(in: "my_table")
    if !columns.contains(where: { $0.name == "new_column" }) {
        try db.alter(table: "my_table") { t in
            t.add(column: "new_column", .text)
        }
    }
}

5. 批量操作优先于循环单条操作

// 推荐:一条 SQL 搞定批量更新
try Todo
    .filter(ids.contains(Todo.Columns.id))
    .updateAll(db, [Todo.Columns.isCompleted.set(to: true)])

// 避免:N 次数据库往返
for id in ids {
    var todo = try Todo.fetchOne(db, key: id)!
    todo.isCompleted = true
    try todo.update(db)
}

6. 利用事务保证数据一致性

try dbPool.write { db in
    // 以下操作在同一个事务中,要么全部成功,要么全部回滚
    try order.insert(db)
    for item in orderItems {
        try item.insert(db)
    }
    try updateInventory(db, for: orderItems)  // 扣减库存
}

7. 善用 ValueObservation 驱动 UI

// 写入后不需要手动刷新 UI
// ValueObservation 会自动检测到表变化并重新发送数据
try dbPool.write { db in
    try newTodo.insert(db)  // 写入
}
// → Observer 自动收到新的 [Todo],SwiftUI 视图自动刷新

8. 软删除配合级联操作

// 软删除:保留恢复能力
try Todo
    .filter(ids.contains(Todo.Columns.id))
    .updateAll(db, [
        Todo.Columns.isDeleted.set(to: true),
        Todo.Columns.deletedAt.set(to: Date())
    ])

// 硬删除:依靠外键级联自动清理关联数据
t.column("todo_id", .text)
    .references("todos", onDelete: .cascade)  // 删除 todo 时自动清理关联

9. Configuration 中开启常用选项

var config = Configuration()

// 推荐开启外键约束
config.foreignKeysEnabled = true

// 开发阶段可以开启 SQL 日志
config.prepareDatabase { db in
    db.trace { print("SQL: \($0)") }
}

let dbPool = try DatabasePool(path: url.path, configuration: config)

总结

GRDB.swift 是一个设计精良的 SQLite 工具包,在类型安全、性能和 API 易用性之间取得了优秀的平衡。其核心优势在于:

  • 协议驱动设计FetchableRecord / PersistableRecord)让模型定义简洁直观,一个 struct + Codable 就能开始
  • 类型安全的查询构建Column 泛型)消除了字符串拼接 SQL 的隐患,编译时就能发现列名拼写错误
  • ValueObservation + Combine 实现了真正的响应式数据驱动,写入数据后 UI 自动刷新
  • DatabaseMigrator 让数据库 Schema 演进变得可管理,支持增量迁移和防御性编程
  • FTS5 原生支持 开箱即用全文搜索能力
  • 批量操作和事务 让 CRUD 代码既简洁又高效

如果你正在为 iOS/macOS 应用选择数据持久化方案,GRDB 是一个值得认真考虑的优秀选择。它既不像 Core Data 那样复杂,也不像 FMDB 那样缺乏类型安全,而是在功能和易用性之间提供了一个恰到好处的平衡点。


参考资料

iOS基于LSB图片水印方案

该方案在Xcode构建过程中对App Bundle内的PNG图片资源进行最低有效位(LSB)嵌入,将水印信息隐藏于像素数据中,不影响视觉效果且可追踪版权,当然其他的作用,自己进行体会。


一、方案核心

  • 水印算法:LSB(最低有效位)隐写术,将水印二进制串嵌入图片每个像素RGB分量的最低位。人眼无法察觉,满足“隐式增加”。
  • 处理时机:在Copy Bundle Resources阶段之后,对已拷贝到应用包中的图片进行原地修改,不污染源文件
  • 开关控制:通过Xcode Build Settings中的自定义变量ENABLE_INVISIBLE_WATERMARK控制是否执行,默认关闭。
  • 适用格式:PNG(无损压缩,LSB稳定),可扩展支持BMP。不处理JPEG(有损压缩会破坏水印)。

二、实现代码

2.1 Python脚本 invisible_watermark.py

将此脚本放入项目根目录的Scripts文件夹中。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import sys
import struct
from PIL import Image

# ================== 配置 ==================
# 水印内容(建议使用项目标识+构建时间,保证唯一性)
WATERMARK_TEXT = "COPYRIGHT_YOURAPP_2026"
# 水印起始标记(用于定位,避免误读)
START_MARKER = 0xAA  # 10101010
END_MARKER = 0x55     # 01010101
# ==========================================

def text_to_bits(text):
    """将字符串转为二进制位列表(含起始/结束标记)"""
    # 添加起始标记(8位)
    bits = [int(b) for b in format(START_MARKER, '08b')]
    # 添加文本长度(32位,大端)
    text_bytes = text.encode('utf-8')
    length_bits = []
    for byte in struct.pack('>I', len(text_bytes)):
        length_bits.extend([int(b) for b in format(byte, '08b')])
    bits.extend(length_bits)
    # 添加文本内容
    for byte in text_bytes:
        bits.extend([int(b) for b in format(byte, '08b')])
    # 添加结束标记(8位)
    bits.extend([int(b) for b in format(END_MARKER, '08b')])
    return bits

def embed_lsb(image_path, bits):
    """将二进制位嵌入图片的RGB最低有效位,返回是否成功"""
    img = Image.open(image_path).convert('RGB')
    pixels = img.load()
    width, height = img.size
    
    total_bits = width * height * 3  # 每个像素3个通道
    if len(bits) > total_bits:
        print(f"  错误: 图片容量不足,需要{len(bits)}位,实际{total_bits}位")
        return False
    
    idx = 0
    for y in range(height):
        for x in range(width):
            r, g, b = pixels[x, y]
            if idx < len(bits):
                r = (r & 0xFE) | bits[idx]
                idx += 1
            if idx < len(bits):
                g = (g & 0xFE) | bits[idx]
                idx += 1
            if idx < len(bits):
                b = (b & 0xFE) | bits[idx]
                idx += 1
            pixels[x, y] = (r, g, b)
            if idx >= len(bits):
                break
        if idx >= len(bits):
            break
    
    img.save(image_path, format='PNG', optimize=False)
    return True

def process_bundle(bundle_path):
    """遍历App Bundle中的PNG图片,嵌入水印"""
    processed = 0
    for root, dirs, files in os.walk(bundle_path):
        for file in files:
            if file.lower().endswith('.png'):
                file_path = os.path.join(root, file)
                try:
                    bits = text_to_bits(WATERMARK_TEXT)
                    if embed_lsb(file_path, bits):
                        print(f"  ✓ 已处理: {os.path.relpath(file_path, bundle_path)}")
                        processed += 1
                    else:
                        print(f"  ✗ 容量不足,跳过: {file_path}")
                except Exception as e:
                    print(f"  ✗ 处理失败 {file_path}: {e}")
    return processed

def main():
    if len(sys.argv) < 2:
        print("用法: python3 invisible_watermark.py <App Bundle路径>")
        sys.exit(1)
    
    bundle_path = sys.argv[1]
    if not os.path.isdir(bundle_path):
        print(f"错误: 路径不存在或不是目录: {bundle_path}")
        sys.exit(1)
    
    print(f"开始处理Bundle: {bundle_path}")
    print(f"水印内容: {WATERMARK_TEXT}")
    count = process_bundle(bundle_path)
    print(f"完成,共处理 {count} 个PNG图片")

if __name__ == '__main__':
    main()

2.2 Xcode Run Script 集成

在Xcode项目Target的Build Phases中添加一个新的Run Script Phase,放在Copy Bundle Resources之后,确保资源已拷贝到App Bundle。

脚本内容

# 不可见水印开关:仅在配置了ENABLE_INVISIBLE_WATERMARK且值为YES时执行
if [ "${ENABLE_INVISIBLE_WATERMARK}" != "YES" ]; then
    echo "🔇 不可见水印已禁用 (ENABLE_INVISIBLE_WATERMARK != YES)"
    exit 0
fi

# 检查Python3环境
if ! command -v python3 &> /dev/null; then
    echo "⚠️ 未找到python3,跳过水印处理"
    exit 0
fi

# 检查Pillow库,若未安装则自动安装(可选)
python3 -c "import PIL" 2>/dev/null
if [ $? -ne 0 ]; then
    echo "📦 安装Pillow库..."
    pip3 install Pillow --user --quiet
fi

# 获取App Bundle路径(针对模拟器和真机统一处理)
APP_PATH="${TARGET_BUILD_DIR}/${EXECUTABLE_FOLDER_PATH}"
if [ ! -d "$APP_PATH" ]; then
    echo "❌ 未找到App Bundle: $APP_PATH"
    exit 1
fi

# 脚本所在路径(假设放在项目根目录/Scripts/下)
SCRIPT_PATH="${SRCROOT}/Scripts/invisible_watermark.py"

if [ ! -f "$SCRIPT_PATH" ]; then
    echo "❌ 未找到水印脚本: $SCRIPT_PATH"
    exit 1
fi

echo "🔏 开始嵌入不可见水印..."
python3 "$SCRIPT_PATH" "$APP_PATH"
echo "✅ 水印嵌入完成"

2.3 开关配置方式

在Xcode项目的Build Settings中添加User-Defined Setting

  1. 选择Target → Build Settings
  2. 点击+Add User-Defined Setting
  3. 设置Key为ENABLE_INVISIBLE_WATERMARK
  4. 设置Value为YES(启用)或NO(禁用)

建议不同配置使用不同值

  • Debug:NO(加快构建)
  • Release:YES(正式包加水印)

通过Build Configuration下的ENABLE_INVISIBLE_WATERMARK分别设置即可。


三、验证水印存在性(可选,用于追踪)

如果需要从图片中提取水印以验证版权,可提供以下提取脚本(单独使用,不在构建时执行):

#!/usr/bin/env python3
import sys
from PIL import Image

def extract_lsb(image_path):
    img = Image.open(image_path).convert('RGB')
    pixels = img.load()
    width, height = img.size
    
    bits = []
    for y in range(height):
        for x in range(width):
            r, g, b = pixels[x, y]
            bits.append(r & 1)
            bits.append(g & 1)
            bits.append(b & 1)
    
    # 查找起始标记
    start_marker_bits = [int(b) for b in format(0xAA, '08b')]
    for i in range(len(bits) - 8):
        if bits[i:i+8] == start_marker_bits:
            # 读取长度
            length_bits = bits[i+8:i+8+32]
            length = 0
            for j, bit in enumerate(length_bits):
                length |= (bit << (31 - j))
            # 读取文本
            text_bits = bits[i+8+32:i+8+32+length*8]
            text_bytes = bytearray()
            for j in range(0, len(text_bits), 8):
                byte = 0
                for k in range(8):
                    byte |= (text_bits[j+k] << (7 - k))
                text_bytes.append(byte)
            # 验证结束标记
            end_pos = i+8+32+length*8
            end_marker_bits = [int(b) for b in format(0x55, '08b')]
            if bits[end_pos:end_pos+8] == end_marker_bits:
                print(f"提取水印: {text_bytes.decode('utf-8')}")
                return
    print("未检测到水印")

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print("用法: python3 extract_watermark.py <image.png>")
    else:
        extract_lsb(sys.argv[1])

四、方案优势

特性 说明
无感性 LSB水印肉眼完全不可见,不改变图片观感
源文件安全 只修改构建产物(DerivedData中的App Bundle),不触碰.xcassets原始图片
开关可控 通过Build Settings一键启用/禁用,不同配置灵活切换
自动化 集成到Xcode构建流程,无需手动操作
可追溯 支持从图片中提取水印,用于版权验证
轻量 仅依赖Python3 + Pillow,macOS自带环境,自动安装缺失库

五、注意事项

  1. 仅支持PNG:JPEG等有损压缩会破坏LSB水印,脚本会自动跳过非PNG文件。
  2. 性能影响:嵌入水印会增加构建时间(取决于图片数量和大小),建议仅在Release模式下启用。
  3. 图片容量:水印文本长度+标记位约需(8+32+文本长度*8+8)位。一张512x512的PNG可容纳约786432位(约96KB文本),完全满足需求。
  4. App Store合规:该水印不影响App功能,也未注入额外代码,符合App Store审核标准。

六、集成步骤

  1. invisible_watermark.py放入项目Scripts/文件夹。
  2. 在Xcode Build Phases中添加Run Script,粘贴上述脚本内容。
  3. 在Build Settings中添加User-Defined Setting ENABLE_INVISIBLE_WATERMARK,Release设为YES
  4. 正常构建,水印自动嵌入。

iOS runtime(2)-class结构和消息转发机制

1. class结构

一. class结构

其实类对象和元类对象的结构是相同的,元类对象是一种特殊的类对象.由于类对象和元类对象结构相同,但我们为什么感觉类对象只有对象方法列表,元类对象只有类对象列表呢,原因是不需要的数据都变为nil. 下图是class结构图

Class结构.png

#二. class_rw_t(可修改的)

class_rw_t里面的methods、properties、protocols是二维数组,是可读可写的,包含了类的初始内容、分类的内容.

class_rw_t的结构.png

三. class_ro_t(不可修改的)

class_ro_t里面的baseMethodList、baseProtocols、ivars、baseProperties是一维数组,是只读的,包含了类的初始内容.

class_ro_t的结构图.png

注:在runtime的过程中会将ro中的methods和分类中的methods合并到rw中的methods中,class的bits原来的指向是指向ro的,在runtime的过程中bits的指向由指向ro改变成指向rw

四. method_t

nmethod_t的结构体是对方法\函数的封装.

struct method_t{
      SEL name;      //函数名
      const char *types;    //编码(返回值类型、参数类型)
      IMP imp;         //指针函数的指针(函数地址)
};

IMP代表函数的具体实现

typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull,...);

SEL代表方法\函数名,一般叫做选择器,底层结构跟char *类似

  • 可以通过@selector()sel_registerName()获得.
  • 可以通过sel_getName()NSStringFromSelector()转成字符串.
  • 不同类中相同名字的方法,所对应的方法选择器是相同的.
typedef strct objc_selector *SEL;

types包含了函数返回值、参数编码的字符串

返回值 参数1 参数2 ..... 参数n

Type Encoding

Type Encoding.png

2. 方法缓存

Class内部结构中有个方法缓存(cache_t),用散列表(哈希表)来缓存曾经调用过的方法,可以提高方法的查找速度.

方法缓存图.png

散列表的原理:将key传递并计算出一个index(索引).

散列表原理.png

用key(selector)的值&_mask就是所需要的imp,如果取值&后selector和key值不相等,_mask-1后再做&的操作.存储的时候已经&_mask计算好了缓存在第几个位置,如果在计算的时候存储的位置有方法缓存,会做_mask-1后再&的操作.(_mask有个初始值,如果容量不足可以扩容,扩容的时候清空缓存).

3. 消息转发机制

一. objc_msgsend

OC的方法调用,也叫做消息机制,给方法调用者发送一条消息.

OC中的方法调用,其实都是转换成objc_msgsend函数调用的.

  • objc_msgsend的流程大致分为3个阶段:

    1.消息发送.

    2.动态方法解析.

    3.消息转发.

objc_msgSend执行流程 – 源码跟读流程

objc_msgSend执行流程.png

二. objc_msgSend执行流程01-消息发送

消息发送.png

如果调用的是父类的方法,会把方法缓存到当前类,如果调用的是自己的方法,会把方法的缓存到自己的类中.

三. objc_msgSend执行流程02-动态方法解析

动态解析的流程图.png

  1. 开发者可以实现以下方法,来动态添加方法实现.

  • +(BOOL)resolveInstanceMethod:(SEL)sel.

  • +(BOOL)resolveClassMethod:(SEL)sel.

  1. 动态解析过后,会重新走“消息发送”的流程.

  • 从receiverClass的cache中查找方法”这一步开始执行.

demo:

#import <Foundation/Foundation.h>

@interface CSPersion : NSObject
- (void)test;
@end

#import "CSPersion.h"
#import <objc/runtime.h>

void otherC(id self, SEL _cmd) {
    NSLog(@" %@-%s-%s",self,sel_getName(_cmd),__func__);
}

@implementation CSPersion

+ (BOOL)resolveInstanceMethod:(SEL)sel {
  
    if (sel == @selector(test)) {
        struct method_t *method = (struct method_t*)class_getInstanceMethod(self,@selector(other));
        class_addMethod([self class], sel, method->imp, method->types);
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}
+ (BOOL)resolveInstanceMethod:(SEL)sel {
  
    if (sel == @selector(test)) {
        struct method_t *method = (struct method_t*)class_getInstanceMethod(self,@selector(other));
        class_addMethod([self class], sel,method_getImplementation(methd), method_getTypeEncoding(method));
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}
+ (BOOL)resolveInstanceMethod:(SEL)sel {

    if (sel == @selector(test)) {
        class_addMethod([self class], sel, (IMP)otherC, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

- (void)test {
    NSLog(@"test ...");
}
- (void)other {
    NSLog(@"other...");
}
@end

我们有三种方式进行方法动态解析,还是建议用第二种方式,第二种方式比较清晰.

四. objc_msgSend的执行流程03-消息转发

消息转发的意思是把消息发送给别人,交给能够处理消息的人. 当- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;方法返回的签名types不为nil时,就会调用- (void)forwardInvocation:(NSInvocation *)anInvocation ;. 生成NSMethodSignature

NSMethodSignature  *signature = [[NSMethodSignature signatureWithObjCTypes:"i@:i"]];
NSMethodSignature *signature = [[MJStudent alloc] init] methodSignatureForSelector:@selector(test:)];

消息转发流程.png

demo
@interface Cat : NSObject
- (int)test:(int)age;
@end

@implementation Cat
- (int)test:(int)age {
    NSLog(@"%s",__func__);
    return age * age;
}
@end
/** 消息发送 */
@interface Student : NSObject
- (void)test:(int)age;
@end

@implementation Student

//+ (BOOL)resolveInstanceMethod:(SEL)sel
//{
//    class_addMethod(<#Class  _Nullable __unsafe_unretained cls#>, <#SEL  _Nonnull name#>, <#IMP  _Nonnull imp#>, <#const char * _Nullable types#>)
//}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if (aSelector == @selector(test:)) {
        
        // 测试一
//        return [NSMethodSignature signatureWithObjCTypes:"v20@0:8i16"];
        
        // 测试二
//        return [NSMethodSignature signatureWithObjCTypes:"i@:i"];
        
        // 测试三
//        return [[[Cat alloc] init] methodSignatureForSelector:aSelector];
    }
    return [super methodSignatureForSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    // 参数顺序:receiver、selector、other arguments
    
    // 测试一
//    int age;
//    [anInvocation getArgument:&age atIndex:2];
//    NSLog(@"%d", age + 10);
    
    // 测试二
    // anInvocation.target == [[MJCat alloc] init]
    // anInvocation.selector == test:
    // anInvocation的参数:15
//    [[[Cat alloc] init] test:15];
    
    // 测试三
    [anInvocation invokeWithTarget:[[Cat alloc] init]];
    int ret;
    [anInvocation getReturnValue:&ret];
    NSLog(@"%d", ret);
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 2.消息转发
        Student *stu = [[Student alloc] init];
        [stu test:10];
    }
    return 0;
}

####五. objc_msgSend-类方法消息转发 当+ (id)forwardingTargetForSelector:(SEL)aSelector为nil时,会继续调用+ (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector,如果methodSignatureForSelector 为nil,则会报一个非常经典的错误doesNotRecognizeSelector,我们可以看出从方法我们只有在methodSignatureForSelector 为nil时才会报错.

@interface CSCat : NSObject
+ (void)test;
- (void)test;
@end

@implementation CSCat
+ (void)test {
    NSLog(@"%s", __func__);
}

- (void)test {
    NSLog(@"%s", __func__);
}
@end
/** 类方法的转发过程 */
@interface CSPerson : NSObject
+ (void)test;
@end

@implementation CSPerson

+ (id)forwardingTargetForSelector:(SEL)aSelector {
    // objc_msgSend([[MJCat alloc] init], @selector(test))
    // [[[MJCat alloc] init] test]
    // 该方法显示与注释后有不同的结果
//    if (aSelector == @selector(test)) {
//        return [[CSCat alloc] init];
//    }
    
    return [super forwardingTargetForSelector:aSelector];
}

+ (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if (aSelector == @selector(test)) {
        return [NSMethodSignature signatureWithObjCTypes:"v@:"];
    }

    return [super methodSignatureForSelector:aSelector];
}

+ (void)forwardInvocation:(NSInvocation *)anInvocation {
    NSLog(@"1123");
}
@end
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        [CSPerson test];
    }
    return 0;
}
                            想了解更多iOS学习知识请联系:QQ(814299221)

iOS的KVO和KVC底层原理

1. KVO

一.KVO原理的使用与证明

我们在开发的过程中经常使用KVO和KVC,但是我们并不了解其底层原理和功能,今天我们来详细了解下底层原理.

KVO的机制比较隐蔽,所以我们通过写代码的方式去验证: 新建类Person

#import <Foundation/Foundation.h>
@interface Person : NSObject
@property (nonatomic, assign) int age;
@end

#import "Person.h"
@implementation Person
- (void)setAge:(int)age
{
    _age = age;
}
@end

给新建的Person类创建对象person1与person2,并对person1的age属性添加observer(键值观察)。

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person1 = [[Person alloc] init];
    self.person1.age = 1;
    
    self.person2 = [[Person alloc] init];
    self.person2.age = 2;
    /* 
    options: 有4个值,分别是:
    NSKeyValueObservingOptionOld 把更改之前的值提供给处理方法 
    NSKeyValueObservingOptionNew 把更改之后的值提供给处理方法 
    NSKeyValueObservingOptionInitial 把初始化的值提供给处理方法,一旦注册,立马就会调用一次。通常它会带有新值,而不会带有旧值。 
    NSKeyValueObservingOptionPrior 分2次调用。在值改变之前和值改变之后。 
   */
    // 给person1对象添加KVO监听
    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
    [self.person1 addObserver:self forKeyPath:@"age" options:options context:@"观察者"];
}

为了测试方便,点击屏幕改变age的值,在- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event方法里面修改person1的age属性值。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    self.person1.age = 22;
}
//当key路径对应的属性值发生改变时,监听器就会回调自身的监听方法,如下
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary<NSKeyValueChangeKey,id> *)change
                       context:(void *)contex
    
}

控制器销毁了,应当及时移除观察者。

- (void)dealloc {
    [self.person1 removeObserver:self forKeyPath:@"age"];
}

触摸手机屏幕,获得log.

2019-07-16 15:18:33.167839+0800 Student[1390:114826] 监听到<Person: 0x60000392c570>的age属性值改变了 - {
    kind = 1;
    new = 22;
    old = 1;
} - 观察者

想知道KVO都做了什么我们可以通过观察isa和对象的指针. person1-isa的指针的变化.png

person1的isa指针居然由Person变成了NSKVONotifying_Person,我们知道实例对象(person1、person2)的isa指针指向类对象(关于isa指针方面的知识,可以参考这篇文章,讲得比较容易理解。浅谈Objective-C的对象本质的理解),这样一来也就说明person1的直接类对象并不是Person,而是NSKVONotifying_Person这个类。 我们还可以进一步的确实是否生成了NSKVONotifying_Person这个类,我们在项目中创建一个NSKVONotifying_Person的类,再次运行项目的时候会报错:

2019-07-16 15:39:45.191295+0800 Student[1576:124208] [general] KVO failed to allocate 
class pair for name NSKVONotifying_Person, automatic key-value observing will not
 work for this class

同过这两种方式说明了当我们为person1的属性添加了观察者模式的之后,系统通过runtime会动态为我们创建一个继承自Person的类NSKVONotifying_Person.

其他证明KVO机制的方法
- (void)viewDidLoad {
    [super viewDidLoad];
    self.person1 = [[Person alloc] init];
    self.person1.age = 1;
    
    self.person2 = [[Person alloc] init];
    self.person2.age = 2;
    
    NSLog(@"person1添加KVO监听之前 - %p %p",
          [self.person1 methodForSelector:@selector(setAge:)],
          [self.person2 methodForSelector:@selector(setAge:)]);
    // 给person1对象添加KVO监听
    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
    [self.person1 addObserver:self forKeyPath:@"age" options:options context:@"kvo监听"];
    NSLog(@"person1添加KVO监听之后 - %p %p",
          [self.person1 methodForSelector:@selector(setAge:)],
          [self.person2 methodForSelector:@selector(setAge:)]);
}
//log日志:添加kvo机制前后isa指向的变化
2019-07-17 09:29:40.806381+0800 Student[1395:28559] person1添加KVO监听之前 - 0x10a930570 0x10a930570
2019-07-17 09:29:40.806719+0800 Student[1395:28559] person1添加KVO监听之后 - 0x10ac8b3d2 0x10a930570
(lldb) p IMP(0x10a930570)
(IMP) $0 = 0x000000010a930570 (Student`-[Person setAge:] at Person.m:13)
(lldb) p IMP(0x10ac8b3d2)
(IMP) $1 = 0x000000010ac8b3d2 (Foundation`_NSSetIntValueAndNotify)
- (void)viewDidLoad {
    [super viewDidLoad];
    self.person1 = [[Person alloc] init];
    self.person1.age = 1;
    
    self.person2 = [[Person alloc] init];
    self.person2.age = 2;

    // 给person1对象添加KVO监听
    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
    [self.person1 addObserver:self forKeyPath:@"age" options:options context:@"kvo监听"];
    NSLog(@"类对象 - %@ %@",
          object_getClass(self.person1),  // self.person1.isa
          object_getClass(self.person2)); // self.person2.isa
    
    NSLog(@"元类对象 - %@ %@",
          object_getClass(object_getClass(self.person1)), // self.person1.isa.isa
          object_getClass(object_getClass(self.person2))); // self.person2.isa.isa
}
//log日志:添加kvo机制前后isa指向的变化
2019-07-17 09:37:58.106744+0800 Student[1477:31924] 类对象 - NSKVONotifying_Person Person
2019-07-17 09:37:58.106895+0800 Student[1477:31924] 元类对象 - NSKVONotifying_Person Person

二. KVO的结构

kvo的全称是Key-Value Observing,俗称"键值监听",可以用与监听某个对象属性值的改变. 未使用kvo.png

使用kvo.png

kvo的原理.png

NSKVONotifying_MJperson中的class是重写父类的class方法,原因是屏蔽内部实现,隐藏NSKVONotifying_MJperson类. NSKVONotifying_MJperson是Runtime动态创建的一个类,是MJperson的一个子类.NSKVONotifying_MJperson的set方法会调用. 子类的set方法的实现:

-(void)setAge:(int) age{
    //Foundation框架的_NSSetIntValueAndNotify的方法.
    _NSSetIntValueAndNotify();
}

_NSSetIntValueAndNotify中调用了:

[self willChangeValueForkey:@"age"];
[super setAge:age];
[self didChangeValueForkey:@"age"];

didChangeValueForkey的实现:

-(void)didChangeValueForkey:(NSString *)key{
    //通知监听器,某某属性发生了改变
    [oberser observeValueForKeyPath:key ofObject:self change:nil context:nil];
}
佐证NSSetIntValueAndNotify的原理
#import "Person.h"
@implementation Person
- (void)setAge:(int)age {
    _age = age;
}
- (void)willChangeValueForKey:(NSString *)key {
    [super willChangeValueForKey:key];
 
    NSLog(@"willChangeValueForKey");
}
- (void)didChangeValueForKey:(NSString *)key {
    NSLog(@"didChangeValueForKey - begin");
   
    [super didChangeValueForKey:key];
   
    NSLog(@"didChangeValueForKey - end");
}
@end
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.person1 setAge:21];
}
//log日志:
2019-07-17 09:41:45.186419+0800 Student[1513:33394] willChangeValueForKey
2019-07-17 09:41:45.186572+0800 Student[1513:33394] didChangeValueForKey - begin
2019-07-17 09:41:45.186850+0800 Student[1513:33394] 监听到<Person: 0x600003013860>的age属性值改变了 - {
    kind = 1;
    new = 21;
    old = 1;
} - kvo监听
2019-07-17 09:41:45.186967+0800 Student[1513:33394] didChangeValueForKey - end
2019-07-17 09:41:45.187052+0800 Student[1513:33394] person1 age = 21,person2 age = 22

补充的问题 _NSSet*ValueAndNotify的存在.png

_NSSet*ValueAndNotify的内部实现:

[self willChangeValueForkey:@"age"];
 //原来set的实现
[self didChangeValueForkey:@"age"];

1.调用willChangeValueForkey.

2.调用原来的setter的实现.

3.调用didChangeValueForkey,didChangeValueForKey:内部会调用observer的observeValueForKeyPath:ofObject:change:context:方法.

面试问题

1.iOS用什么方式实现对一个对象的KVO?(KVO的本质是什么?) 利用RuntimeAPI动态生成一个子类,并且让instance对象的isa指向这个全新的子类; 当修改instance对象的属性时,会调用Foundation的_NSSetXXXValueAndNotify函数

willChangeValueForKey:

②父类原来的setter

didChangeValueForKey: didChangeValueForKey内部会触发监听器(Oberser)的监听方法( observeValueForKeyPath:ofObject:change:context:

2.如何手动触发KVO? 对监听的对象手动调用下面两行代码即可。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [self.person1 willChangeValueForKey:@"age"];
    [self.person1 didChangeValueForKey:@"age"];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    NSLog(@"监听到%@的%@属性值改变了 - %@ - %@", object, keyPath, change, context);
}

3.KVO与代理的效率问题?

KVO的效率比代理的效率低,因为KVO需要动态地生成一个类NSKVONotifying_className,耗时。

4.使用KVC给对象属性赋值,能不能触发KVO?

可以触发KVO。因为KVC本质上会调用属性的setXxx:方法。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"调用带下划线的成员变量");
    self.person1.age = 10;
}

5.直接修改成员变量会触发KVO嘛? 不会触发KVO,因为修改成员变量不会触发set方法。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"调用带下划线的成员变量");
    self.person1->_age = 10;
}

2. KVC

kvc的全称是Key-Value Coding,俗称"键值对编码",可以通过key来访问某个属性.

常见的API有:

  • - (void)setValue:(id)value forKeyPath:(NSString *)keyPath;
  • -(void)setValue:(id)value forKey:(NSString *)key;
  • -(id)valueForKeyPath:(NSString *)keyPath;
  • - (id)valueForKey:(NSString *)key;

key和keyPath的区别:

key:只能接受当前类所具有的属性,不管是自己的,还是从父类继承过来的,如view.setValue(CGRectZero(),key: "frame");

keypath: 除了能接受当前类的属性,还能接受当前类属性的属性,即可以接受关系链,如view.setValue(5,keypath: "layer.cornerRadius");

一. KVC的赋值和取值过程和原理

1. KVC的赋值过程

KVC在赋值的时候,按照setKey:、_setKey:的顺序查找对象是否有对应的方法实现,如果有的话就传递参数并调用方法,如过这两个方法都没有实现,则调用对象的+ (BOOL)accessInstanceVariablesDirectly方法,查看是否允许直接访问成员变量。下面我们证明一下:

A:证明先调用- (void)setAge:(NSUInteger)age方法,新建一个Person类,不添加任何属性,实现- (void)setAge:(NSUInteger)age、- (void)_setAge:(NSUInteger)age方法。初始化一个Person实例并对其进行KVC赋值,看系统调用结果。

#import <Foundation/Foundation.h>
@interface Person : NSObject
@property(nonatomic, assign) NSInteger age;
@end

#import "Person.h"
@implementation Person
- (void)setAge:(NSUInteger)age{
    NSLog(@"setAge : %lu",(unsigned long)age);
}
- (void)_setAge:(NSUInteger)age{
    NSLog(@"_setAge : %lu",(unsigned long)age);
}
@end

#import <Foundation/Foundation.h>
#import "Person.h"
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *person = [[Person alloc] init];
        [person setValue:@20 forKey:@"age"];
    }
    return 0;
}
//log日志
2019-07-16 16:47:55.228096+0800 Student[1841:143855] setAge : 20

B:将Person类中的- (void)setAge:(NSUInteger)age注释掉,保留- (void)_setAge:(NSUInteger)age,看系统调用结果。

#import "Person.h"
@implementation Person
//- (void)setAge:(NSUInteger)age{
//    NSLog(@"setAge : %lu",(unsigned long)age);
//}
- (void)_setAge:(NSUInteger)age{
    NSLog(@"_setAge : %lu",(unsigned long)age);
}
@end
//log日志
//2018-08-02 23:15:08.754741+0800 Student[1841:544138] _setAge : 20

由以上结果可见,我们调用方法 - (void)setValue:(id)value forKeyPath:(NSString *)keyPath;- (void)setValue:(id)value forKey:(NSString *)key;时,OC底层依次查找了setKey:_setKey:方法。

2. 如果没有setKey:_setKey:方法怎么办?

没有实现setKey:_setKey:方法,系统将查看+(BOOL)accessInstanceVariablesDirectly方法的返回结果(该方法默认返回YES),这个方法决定是否可以直接访问成员变量key。

注意:如果+(BOOL)accessInstanceVariablesDirectly方法返回了NO,那么就会调用setValue:forUndefinedKey:并抛出异常NSUnknownKeyException!

注意,这里面为什么提到对象的成员变量,而不是属性呢?
如果是属性的话,系统自动帮我们实现了set方法,所以KVC总是可以找到它需要的`setKey:`方法。如果是
成员变量,系统就不会为你实现set方法了.

KVC在访问成员变量时也严格按照_key、_isKey、key、isKey的顺序查找。下面我们将上面代码中- (void)setAge:(NSUInteger)age、- (void)_setAge:(NSUInteger)age注释掉,并添加四个成员变量_age、_isAge、age、isAge

#import <Foundation/Foundation.h>
@interface Person : NSObject
{
    int _age;
    int _isAge;
    int age;
    int isAge;
}
@end

#import "Person.h"
@implementation Person
+(BOOL)accessInstanceVariablesDirectly{
    return YES;
}
@end

通过设置断点观察对象成员变量值得变化,证明了 ‘严格按照_key、_isKey、key、isKey的顺序查找’的结论. setValue forkey的原理: kvc的赋值的过程.png

+(BOOL)accessInstanceVariablesDirectly的方法是用来确认是否可以访问成员变量, +(BOOL)accessInstanceVariablesDirectly默认是Yes. kvc的内部调用了①willChangeValueForkeydidChangeValueForkey两个方法,从而触发了kvo,所以不用实现set方法也可以调起kvo.

3. KVC的取值过程

- (id)valueForKey:(NSString *)key; - (id)valueForKeyPath:(NSString *)keyPath;方法取值的时候,按照getKey、key、isKey、_key的顺序查找对应方法,一旦找到就调用方法获取值。如果没有找到以上四个方法,同样会调用+(BOOL)accessInstanceVariablesDirectly方法,看是否具备直接访问成员变量的权限。与KVC的赋值过程相同,在查找成员变量的时候,也是严格按照 _key、_isKey、key、isKey的顺序查找的。找到了就直接取值,都没有找到的话,后果也是相同的,即调用setValue:forUndefinedKey:并抛出异常NSUnknownKeyException*!

#import "Person.h"
@interface Person ()
@end

@implementation Person
+ (BOOL)accessInstanceVariablesDirectly{
    return  YES;
}
- (int)getAge{
    NSLog(@"getAge");
    return 5;
}
- (int)age{
    NSLog(@"age");
    return 10;
}
- (int)isAge{
    NSLog(@"isAge");
    return 15;
}
- (int)_age{
    NSLog(@"_age");
    return 20;
}
@end

#import <Foundation/Foundation.h>
#import "Person.h"
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *person = [[Person alloc] init];
          [person valueForKey:@"age"];
    }
    return 0;
}

依次对getKey、key、isKey、_key方法进行注释,通过log日志可见KVC的取值时候调用的方法顺序依次为:getKey、key、isKey、_key。 Value forkey的原理: kvc的取值过程.png

                            想了解更多iOS学习知识请联系:QQ(814299221)

浅谈对Objective-C的对象本质的理解

1. Objective-C的本质

我们平时编写的OC代码,其实底层实现都是C/C++代码,类主要是基于C/C++的结构体的数据结构实现的,因为对象或者类有各种类型(NSArray *,NSDictionary *,CFfloat等),因为可以存储不同种类的数据,能够满足的这样的结构就是结构体.

为了证明OC的结构,所以可以转换成C++的代码,窥探内部的结构(有时候C++的代码也不一定能完全表示源码的情况,需要调试到汇编代码或源码查看).

我们可以通过终端进入到要窥探所在文件的位置,使用命令xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main.cpp.(如果电脑上面安装了多个版本的Xcode,转换为C++代码的时候会提示各种框架找不到的错误,一般是因为多个版本的Xcode路径冲突导致的,我们需要在终端指定一个Xcode的路径,例:sudo xcode-select --switch/Applications/Xcode10.0.app/Contents/Developer/).

注释:解释各种参数的翻译
xc就是Xcode的缩写。
xcrun是Xcode的一种工具。
-sdk iphoneos规定sdk需要运行在iOS系统上面。
clang是Xcode内置的llvm编译器前端,也是编译器的一种。
-arch xxx(arm64、i386、armv7...)指出iOS设备的架构。
参数 -rewrite-objc xx.m 是重写objc代码的指令(即重写xx.m文件) 。
-o newFileName.cpp 表示输出新的.cpp文件。

2. NSObject底层实现原理

NSObject底层原理.png

Class 定义为 : typedef struct objc_class *Class;也就是说Class是个结构体指针. 代码中[NSObject alloc]开辟空间给NSObject。obj的指针指向了isa的地址.isa的地址就是结构体的地址,原因是结构体的地址就是结构体中第一个成员的地址,而结构体只有一个成员,即isa指针的地址.

一. 例:student底层的原理

Student普通的结构.png

答:因为Student继承NSObject,也就继承了NSObject的数据结构,所以继承NSObject的8个字节,也就是NSobject中的isa的大小。

思考题:Student继承Person的结构.png

Person占据class_getInstanceSize=16 malloc_size=16, Student占据class_getInstanceSize=16 malloc_size=16,Person的变量实际用了12,但是由于内存对齐所以占用16.

二. 两种方法看内存大小

我们有这种方法在OC中表达一个类内存的大小.

<objc/runtime.h>文件提供class_getInstanceSize(Class _Nullable cls)方法,返回我们一个OC对象
的实例所占用的内存大小(可以说是结构体内存对齐之后的大小,8的倍数);
<malloc/malloc.h>文件提供 size_t malloc_size(const void *ptr)方法返回系统为这个对象分配的
内存大小(16的倍数)。

三. 内存对齐的原理(不全,后期添加)

我们先来看一些内存的例子,更加方便我们去理解内存分配和内存对齐原理:

  • 看一个没有成员变量的类的实例(以NSObject为例)
    NSObject *obj = [[NSObject alloc] init];
    NSLog(@"NSObject实例大小--> %zd",class_getInstanceSize([obj class]));
    NSLog(@"obj实际分配的内存%zd",malloc_size((const void *)obj));
//    NSObject实例大小--> 8
//    obj实际分配的内存16
  • 一个普通的类的实例,并且实例有且仅有唯一的成员变量(如:Student只有一个name属性)
@interface Student: NSObject
@property (nonatomic, copy) NSString *name;
@end;

@implementation  Student
@end;

Student *stu = [[Student alloc] init];
stu.name = @"Object-C";
NSLog(@"Student实例大小--> %zd",class_getInstanceSize([stu class]));
NSLog(@"stu实际分配的内存%zd",malloc_size((const void *)stu));
//     Student实例大小--> 16
//     stu实际分配的内存16
  • 一个普通的类的实例,并且实例有自己的成员变量(如:Student类,为其添加属性age、name等)
@interface Student: NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;
@end;

@implementation  Student
@end;

Student *stu = [Student new];
stu.name = @"Object-C";
stu.age = 25;
NSLog(@"Student实例大小--> %zd",class_getInstanceSize([stu class]));
NSLog(@"stu实际分配的内存%zd",malloc_size((const void *)stu));
//     Student实例大小-->24
//     stu实际分配的内存32

由以上三次测试:一个OC对象所占用的内存取决于这个对象成员变量的多少.但是同时,系统为其分配内存时,默认会分配最少16个字节的大小.OC中对象的内存小于16就等于16(是Core Foundation的规定),下面是Core Foundation的源码.

size_t instanceSize(size_t extraBytes){
    size_t size = alignedInstanceSize()+extraBytes;
    //CF requires all objects be at least 16 bytes.
    if (size < 16) size = 16;
    return size;
}

内存对齐的原则:结构体的大小必须是最大成员的倍数. 更多的内存对齐的知识--内存对齐 补充:sizeof不是个函数是个运算符,传入的时候是类型不是具体的对象,sizeof是在编译的时候进行计算的.

3. OC对象的分类

objective-C的对象,简称为OC对象,分为三种:

  1. instance对象(实例对象).
  2. class对象(类对象).
  3. meta-class(元类对象).

一. 实例对象

NSObject *object1 = [[NSObject alloc] init];
NSObject *object2 = [[NSObject alloc] init];

object1、object2是NSObject的instance对象(实例对象),它们是不同的两个对象,分别占据着两块不同的内存。instance对象是通过类alloc出来的对象,每次调用alloc都会产生新的instance对象.instance对象在内存中存储的信息包括:isa指针,其他成员变量。 实例对象存放的内容包含:

实例对象
isa
成员变量信息

二. 类对象

NSObject *object1 = [[NSObject alloc] init];
NSObject *object2 = [[NSObject alloc] init];
Class objectClass1 = [object1 class];
Class objectClass2 = [object2 class];
Class objectClass3 = [NSObject class];
Class objectClass4 = object_getClass(object1);//Runtime API
Class objectClass5 = object_getClass(object2);//Runtime API

objectClass1 ~ objectClass5都是NSObject的class对象(类对象).它们是同一个对象,每个类在内存中有且只有一个class对象. 类对象存放的内容包含:

类对象
isa
superclass
属性信息
对象方法信息
协议信息
成员变量信息
.............

class对象在内存中存储的信息主要包括:isa指针,superclass指针,类的属性信息(@property)、类的对象方法信息(instance method),类的协议信息(protocol)、类的成员变量信息(ivar).

三. 元类对象

获取一个类对象的元类对象的方法.

Class objectMetaClass = object_getClass([NSObject class]);//Runtime API
元类对象
isa
superclass
类方法信息
.............

objectMetaClassNSObject的meta-class对象(元类对象).每个类在内存中有且只有一个meta-class对象. meta-class对象和class对象的内存结构是一样的,但是用途不一样,在内存中存储的信息主要包括:isa指针,superclass指针,类的类方法信息(class method).

补充:

查看Class是否为meta-class:

BOOL result = class_isMetaClass([NSObject class]);

以下代码获取的objectClass是class对象,并不是meta-class对象

Class objectClass = [[NSObject class] class];

objcget-Class和object-getClass区别

objc_getClass 传入字符串类名返回类对象. 传入字符串类名返回类对象. 传入字符串类名返回类对象.
object_getClass 传入实例对象返回类对象. 传入类对象返回元类对象. 传入元类对象返回还是元类对象

四. isa和superClass

1. isa

isa的指向关系图.png

①instance的isa指向class,当调用对象方法时,通过instance的isa找到class,最后找到对象方法的实现进行调用. ②class的isa指向meta-class,当调用类方法时,通过class的isa找到meta-class,最后找到类方法的实现进行调用.

2. superClass

类对象的指向关系.png

当Student的instance对象要调用Person的对象方法时,会先通过isa找到Student的class,然后通过superclass找到Person的class,最后找到对象方法的实现进行调用. 元类对象的指向关系.png

当Student的class要调用Person的类方法时,会先通过isa找到Student的meta-class,然后通过superclass找到Person的meta-class,最后找到类方法的实现进行调用.

3. 经典的isa和superclass图谱

经典图片.png

  1. isa总结

  • instance的isa都是指向class.

  • class的isa都是指向meta-class.

  • meta-class的isa指向基类的meta-class.

  1. superClass总结

  • class的superClass指向父类的class.

  • 如果没有父类,superClass指针为nil.

  • meta-class的superClass指向父类的meta-class.

  • 基类meta-class的superClass指向基类的class.

  1. instance的调用轨迹

  • isa找到class,方法不存在,就通过superclass找父类.

  1. class调用类方法的轨迹

  • isa找meta-class,方法不存在,就通过superclass找父类.
  • 基类的meta-class方法不存在,就通过superclass找基类的class,如果没有找到就是nil.
4. isa地址运算

isa.png

isa的MASK地址.png

从64bit开始,isa需要进行一次位运算,才能计算出真实地址,superClass存储的地址值,直接就是父类的地址值,不用做位运算. 一个对象完整的结构.png

实例对象里只有成员变量没有方法,为什么实例对象的方法要存在类对象里,原因是只要存一份就够了,实例对象会创建多个.

                      想了解更多iOS学习知识请联系:QQ(814299221)

iOS 多技术栈混淆实现,跨平台 App 混淆拆解与组合

当项目从单一 iOS 原生扩展到 Flutter、React Native 或 Unity 时,混淆这件事会变得复杂。原因不在于工具少,而是每一层代码完全不同

  • Swift / Objective-C → Mach-O 符号
  • Flutter → Dart AOT + assets
  • React Native → JS bundle
  • Unity → DLL + 资源

如果只用一种 iOS 混淆工具,通常只能覆盖其中一部分。


不同技术栈暴露的信息完全不一样

拿一个混合项目举例(Flutter + 原生 + H5),解包 IPA 后可以看到:

AppBinary          // 原生代码
flutter_assets/    // Dart + 资源
main.jsbundle      // JS 逻辑
assets/            // 图片与配置

每一层的“暴露方式”不同:

技术 可被读取的内容
Swift / OC 类名、方法名、参数
Flutter Dart 符号(部分)、资源路径
React Native JS 逻辑
Unity DLL + AssetBundle

这意味着混淆必须分层处理。


原生层:符号混淆(iOS 混淆工具核心能力)

先看最传统的一层:Swift / Objective-C。

检查方式:

strings AppBinary | grep Controller

如果看到:

HomeViewController
PaymentManager

说明符号未处理。


处理方式

使用 Ipa Guard 这类 IPA 级别的 iOS 混淆工具:

  • 导入 IPA
  • 进入代码模块
  • 勾选类 / 方法 / 参数

执行后:

PaymentManager → a82kd3

这一步直接改变 Mach-O 符号,是跨平台项目中最“统一”的一层处理。


Flutter 层:Dart 混淆 + IPA 补充

Flutter 提供内置混淆:

flutter build ios --obfuscate --split-debug-info=./symbols

执行后:

  • Dart 符号被替换
  • 生成符号映射

但 IPA 解包后仍然可以看到:

assets/images/banner.png
config/app.json

补充处理

使用 Ipa Guard 的资源模块:

banner.png → x92kd.png
app.json → a83ks.json

这样 Dart 层 + 资源层同时处理。


React Native:JS 混淆 + 文件重命名

React Native 的关键在 JS bundle:

main.jsbundle

直接打开可以读。


处理步骤

1)压缩 JS:

terser main.js -o main.min.js

2)替换 bundle

3)用 Ipa Guard 修改文件名称:

main.jsbundle → k39sd.bundle

这样:

  • 内容不可读
  • 路径无语义

Unity:资源与 DLL 的组合处理

Unity 项目解包后:

Data/Managed/Assembly-CSharp.dll
Data/Resources/

DLL 可以被反编译,资源路径也能推断逻辑。


处理方式

  • 使用 Unity 构建参数减少符号
  • 在 IPA 层用 Ipa Guard 处理资源名称
  • 修改资源 MD5

例如:

level1.assetbundle → a82kd.bundle

统一处理:资源指纹与结构差异

跨平台项目中,资源重复是一个常见问题。

例如多个 App 使用同一套 UI:

banner.png
icon.png

即使改名,内容仍然一致。

处理方式

在 Ipa Guard 中开启 MD5 修改:

md5 banner.png

处理前后不同。

这一步可以打散资源特征。


七、调试信息清理

检查:

strings AppBinary | grep NSLog

或:

strings AppBinary | grep Flutter

如果存在调试信息,可以统一清理。Ipa Guard 支持删除调试符号和部分日志字符串。


签名工具

无论哪个技术栈,只要修改 IPA,就必须重新签名。

可以使用:

kxsign sign app.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i

如何在本地跑 Core ML 模型识别呼噜声,并用 iCloud 优雅同步?

大家好,我最近开发了一款App《SleepDiary(睡眠声音日记)》。

9771791b1b272012179e60c5853cedc8.jpg

作为一款睡眠监测类 App,核心业务逻辑可以用一句话概括:

录一整夜的音,把打呼噜和说梦话的片段摘出来,最后生成睡眠报告。

看似简单,但在工程实现上却困难重重:

  1. 隐私与成本问题:长达 8 小时的音频绝对不能一整段传到服务器端,这不仅会直接把你的服务器带宽跑破产,还会被用户骂死(谁敢把在卧室一整夜的录音全传到网上?)。
  2. 性能与功耗问题:放在端侧跑模型,势必要使用长时间的后台保活,如何避免手机发热和 OOM (Out Of Memory)?

经过最近这段时间的研究,我用 AVFoundation + Core ML + SwiftData 的纯血原生技术栈把这套流程跑通了。今天就和大家分享一下我的实现思路与踩坑日记。

一、端侧的 AI:硬核从零训练自己的鼾声分类模型

最初的设计方案很简单粗暴:开个录音,每秒去判断分贝,超过阈值就保存。但这完全不行,深夜翻身的声音、空调声、外面的汽车声都会被误判。

市面上现成的声音分类模型要么太大(动辄上百MB),要么对“鼾声”、“梦话”这种特定场景不够敏锐。于是我决定硬核一点——自己动手,从收集数据开始训练一个专用的轻量级神经网络(SnoreWave.mlpackage)。

1.1 数据收集与模型训练

为了让模型足够精准,我花了大量时间收集开源数据集并结合自己实录的各种“打雷级”打呼声(最终 1.2w 条数据)。 把杂乱的音频转换成模型能“看懂”的输入是第一步——将音频流转化为梅尔频谱图(Mel-spectrogram) 。这相当于将一维的声音信号,变成了二维的图像图像特征,然后再喂给我用深度学习框架搭建的 CNN(卷积神经网络)进行分类。

模型训练收敛后,我依靠 coremltools 将其转换为了 Apple 原生支持的 .mlpackage。为了控制 App 包体积并保证低功耗运转,这个模型被我极致压缩,剥离了非必要分支,达到了极高的预测效率。

1.2 AVAudioEngine 实时截流送显

有了自己的模型,下一步就是在 iOS 端跑通流式推理。 我们不使用高层的 AVAudioRecorder,而是使用 AVAudioEngine。因为它允许我们通过 installTap 在音频流经过的过程中“截胡”到 AVAudioPCMBuffer

然后在端侧把这个 Buffer 原样转化成模型需要的数组输入:

// 截胡音频流的伪代码
let inputNode = audioEngine.inputNode
let format = inputNode.inputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] (buffer, time) in
    // 捕获到音频帧后,交给我们自定义的分类器管线
    self?.audioCaptureService.processAudioBuffer(buffer)
}
audioEngine.prepare()
try audioEngine.start()

1.3 降维打击 OOM 崩溃:用 Actor 隔离模型生命周期

坑点来了!如果每次截取到一个 buffer,都在主线程或者随机的 Dispatch Queue 去实例化这个自定义模型进行预测,一晚上下来你的 App 必因为内存暴涨被系统强制 Kill 掉(Jetsam Event)。

解法:引入 Swift Actor 隔离与复用机制 

在《睡眠声音日记》中,我是用全局唯一的 Actor 来维持模型的单一生命周期,使用环形缓冲区去缓存几秒钟的声音片断,组合后一次性输出:

swift
actor EventDetectionPipeline {
    // 全局唯一持有我们自己训练好的模型实例
    private let model = try? SnoreWaveformCNN(configuration: MLModelConfiguration())
    
    func processAudioWindow(_ window: AudioWindow) async {
        // 将音频转化成梅尔频谱所需的 MLMultiArray
        guard let multiArray = window.toMLMultiArray() else { return }
        
        // 发起端侧离线推理
        if let prediction = try? model?.prediction(input: multiArray) {
            if prediction.classLabel == "snore" {
                // 命中目标:触发存储!
                await persistCapturedEvent(label: .snore)
            }
        }
    }
}

通过自己训练轻量级模型 + Actor 的串行数据处理,保证了模型资源的极致释放。即便后台连续疯狂推理 8 个小时,CPU 的平均占用率也能被压在极低的水平,用户即使整晚充着电,手机也完全不发烫。

二、存储的艺术:音频文件与 SwiftData 模型分离

识别完事件后,怎么持久化? 这引发了第二个大问题——千万别把音频这种大块二进制流全都写进 SwiftData 或者 Core Data!

2.1 相对路径是王道

我的存储策略是:结构化数据走 SwiftData(打点时间、标签量化数据),音频文件走沙盒原生写入。 在《睡眠声音日记》的 SleepEventRecord 模型中,我只存了一个相对路径(filePath)。

@Model
final class SleepEventRecord {
    var timestamp: Date
    var duration: TimeInterval
    var eventLabel: EventLabel // .snore, .speech, .cough
    var filePath: String? // 只存相对路径: "20240315/snore_0234.m4a"
    
    init(timestamp: Date, eventLabel: EventLabel) {
        self.timestamp = timestamp
        self.eventLabel = eventLabel
    }
}

为什么要相对路径?  因为沙盒路径(UUID)在每次应用重签或重新安装时是会变的。如果存绝对路径,第二天文件全找不到了!读取时,永远使用 FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent(filePath) 动态拼装。

2.2 防治 iCloud 把服务器挤爆

录了一晚上的高音质 M4A 文件,如果不加限制,系统的 iCloud 备份会自动把它们全传上去。用户那可怜的 5GB iCloud 很快就会爆满。因此,我在写入音频文件后,立马用原生 API 给文件打上“拒绝备份”的 Tag:

var url = documentDirectoryURL.appendingPathComponent(fileName)
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true // 保护用户的 iCloud 空间!
try url.setResourceValues(resourceValues)

三、私有 CloudKit 的优雅同步体验

音频不用同步了,但我们的 SleepSessionRecord(当晚评分,鼾声次数统计,分析数据)需要跨设备(尤其是和 Apple Watch 联动时)和防止在用户删掉 App 重装后丢失。

以往做 Core Data + CloudKit 繁琐得让人想死。但在 iOS 17 的 SwiftData 下,它变成了真正的“优雅”:我们甚至可以动态控制它的开启闭合。

我把开关值存到了 NSUbiquitousKeyValueStore(KVS),根据这个从远程同步过来的用户偏好,动态初始化 ModelContainer

// 在 SleepDiaryApp.swift 入口处动态配置容器
var sharedModelContainer: ModelContainer = {
    let schema = Schema([SleepSessionRecord.self, SleepEventRecord.self])
    
    // 读取 UserDefaults/KVS 的 iCloud 开关
    let isCloudSyncEnabled = UserDefaults.standard.bool(forKey: "iCloudSyncEnabled")
    
    let configuration = ModelConfiguration(
        schema: schema,
        isStoredInMemoryOnly: false,
        // 如果开启,赋予 private 数据库标识;如果不开启,设为 .automatic 或本地优先
        cloudKitDatabase: isCloudSyncEnabled ? .private("iCloud.xxxxx") : .none
    )
    
    do {
        return try ModelContainer(for: schema, configurations: [configuration])
    } catch {
        fatalError("Could not create ModelContainer: (error)")
    }
}()

依靠云端大容量配额下的 .private 标识,只要用户打开 iCloud 同步,他们在换了新手机后重新下载 App,所有的睡眠数据历史记录就会像魔法一样哗哗哗回到列表中。


四、写在最后

开发《睡眠声音日记》的这段时间里,我最大的感触是:苹果的原生护城河真的很香。  只依靠一套 Swift 兵器库:从 SwiftUI 的丝滑动画绘制、到 HealthKit 获取深度睡眠的联动、再到 Core ML 的底层加速,这是以往杂糅其它中间件完全得不到的性能优势和开发爽感。

感兴趣的同行们,可以在 App Store 搜  “睡眠声音日记-SleepDiary”  下载把玩一下,有任何架构或技术点上的建议,大家评论区见,或者私下找我交流。也欢迎吐槽!

深入剖析 SDAnimatedImageView:如何优雅地在 iOS 中实现高性能动态图渲染

在日常的 iOS 开发中,动态图(GIF、APNG、WebP)的展示几乎无处不在。然而,很多开发者在使用系统原生的 UIImageView 加载动态图时,往往会遭遇内存暴涨(OOM)或滑动卡顿的窘境。

作为 iOS 圈内最权威的图片处理框架,SDWebImage 为我们提供了一个非常好的解决方案——SDAnimatedImageView

本文将从系统痛点出发,结合 SDWebImage 最新源码,深度拆解 SDAnimatedImageView 的底层架构、核心属性机制,并分享在复杂业务场景下的避坑指南。

文中所涉及源码均基于 SDWebImage 5.x 版本,示例代码采用 Objective-C,Swift 开发者可参照类似逻辑使用。


一、系统原生方案的“三宗罪”

在了解 SDAnimatedImageView 之前,我们必须先明白系统原生方案到底差在哪里。

1. 内存爆炸

系统的 UIImage 在解析 GIF 时,采用“全量解码”策略。
一张体积仅为 2MB 的 GIF,如果包含 50 帧,系统会将其每一帧都解码成庞大的位图对象驻留在内存中。
解码后的位图大小 = 图片宽 × 高 × 4 字节(RGBA)。
假设宽高为 1000×1000,一帧就占约 4MB,50 帧就是 200MB,极易触发 OOM 崩溃。

2. 主线程阻塞

图片的解码过程默认在主线程同步进行,会导致明显的掉帧和卡顿。

3. 控制力极弱

系统几乎没有提供控制 GIF 播放进度、暂停、快进的 API。

SDAnimatedImageView 的诞生,正是为了彻底颠覆这种粗放的渲染模式。


二、核心架构:按需解码与帧缓冲池

SDAnimatedImageView 继承自 UIImageView,但它在内部重构了整个动态图渲染管线。其核心思想是:按需解码,以可控的内存开销换取极致的播放流畅度

1. 零内存的原始数据存储

它配合 SDAnimatedImage 使用。SDAnimatedImage 在初始化时,只保存动态图的原始文件数据(NSData),绝不提前解码任何一帧。此时,无论 GIF 有多少帧,内存占用几乎等于文件本身的大小。

// 从网络或本地获取 NSData
NSData *gifData = [NSData dataWithContentsOfFile:path];
// 创建 SDAnimatedImage,此时仅保留原始数据,不解码
SDAnimatedImage *animatedImage = [SDAnimatedImage imageWithData:gifData];

2. 智能帧缓冲池

当动画开始播放时,它不会一次性解码所有帧,而是维护一个滑动窗口式的缓冲池。在渲染当前帧的同时,后台异步线程会提前解码接下来的几帧放入内存;当某一帧不再处于缓冲窗口内时,其占用的内存会被立即释放。

3. VSync 级别的精准驱动机制

抛弃了传统的 NSTimer(容易受 RunLoop 阻塞影响导致掉帧),SDAnimatedImageView 底层采用了基于 VSync 信号的 CADisplayLink。它与屏幕刷新率完美同步,根据每一帧设定的 duration 精准计算渲染时机,保证动画如丝般顺滑,且在 App 退到后台时自动暂停,不浪费 CPU 资源。


三、源码级 API 解析(核心属性深挖)

很多开发者只把 SDAnimatedImageView 当作普通的 UIImageView 来用,这其实暴殄天物。以下几个核心属性,体现了框架设计的极致细节。

1. 性能调优:maxBufferSizeprefetchNumberOfFrames

@property (nonatomic, assign) NSUInteger maxBufferSize;
@property (nonatomic, assign) NSUInteger prefetchNumberOfFrames;
  • maxBufferSize:最大缓冲区大小(字节)。
    ⚠️ 重要纠正:很多人以为默认值 0 代表“不限制缓冲”,这是错误的!
    根据官方源码注释,0 代表 Auto(自动调整),框架会根据当前设备的内存压力动态计算缓冲上限。
    如果你需要极致的性能,可以设为 NSUIntegerMax(全缓冲,最高性能);如果内存极度吃紧,设为 1(代表无缓冲,最低内存)。

  • prefetchNumberOfFrames:预解码帧数,默认为 3~5 帧。
    增大它可以提高流畅度(尤其在高帧率动图中),但会增加内存;减小则会降低内存占用,但可能在复杂 GIF 时掉帧。
    这个值需要根据业务场景权衡。

2. 运行循环策略:runLoopMode

@property (nonatomic, strong) NSRunLoopMode runLoopMode;

⚠️ 源码纠正:普遍认为它的默认模式是 NSRunLoopCommonModes,但这并不完全准确。
官方源码的默认逻辑其实更智能:

// SDAnimatedImageView.m 中的 commonInit 片段
if ([[NSProcessInfo processInfo] processorCount] > 1) {
    _runLoopMode = NSRunLoopCommonModes;
} else {
    _runLoopMode = NSDefaultRunLoopMode;
}
  • 在多核设备上,默认为 NSRunLoopCommonModes,确保在 UIScrollView 滑动时,GIF 依然能流畅播放(因为滑动时 RunLoop 切换到了 UITrackingRunLoopMode)。
  • 在单核设备(老旧设备)上,默认降级为 NSDefaultRunLoopMode。目的是在滑动时主动暂停 GIF 播放,以节省宝贵的 CPU 资源用来保证列表滑动的流畅度。

3. 进阶播放控制(易被忽略的宝藏属性)

@property (nonatomic, assign) float playbackRate;                       // 播放速率,默认 1.0
@property (nonatomic, assign) BOOL clearBufferWhenStopped;              // 停止时是否清空缓冲池
@property (nonatomic, assign) BOOL shouldIncrementalLoad;               // 是否支持渐进式加载
  • playbackRate:支持 0.5 慢放、2.0 快进。这在实现类似“表情包编辑器”时非常有用。
  • clearBufferWhenStopped:停止动画时是否清空帧缓存(默认 NO)。
    实战意义极大:在复杂的 Feed 流中,当 Cell 滑出屏幕停止播放时,开启此属性可以立即释放掉该 GIF 占用的解码内存,大幅降低峰值内存。
  • shouldIncrementalLoad:是否支持渐进式加载(默认 YES)。
    配合网络下载,即使 GIF 只下载了 30%,它也能立刻播放已下载完成的那部分帧,带来“秒开”的体验。

四、实战:如何正确使用

1. 结合网络加载(最常用)

得益于 SDWebImage 的封装,日常开发中你甚至不需要手动创建 SDAnimatedImage,框架在下载完毕后会自动识别格式并适配。

步骤

  1. 在 Xib/Storyboard 中,将 UIImageViewCustom Class 改为 SDAnimatedImageView
    或纯代码创建:
    SDAnimatedImageView *imageView = [[SDAnimatedImageView alloc] init];
    
  2. 直接使用 sd_setImage 方法:
[self.imageView sd_setImageWithURL:[NSURL URLWithString:@"https://example.com/demo.gif"]
                  placeholderImage:[UIImage imageNamed:@"placeholder"]];

原理:SDWebImage 在下载完成后,会根据图片数据判断是否为动图(如检查 GIF 头部 GIF89a),如果是,会自动创建 SDAnimatedImage 实例并赋值给 animatedImage 属性,从而触发按需解码机制。

2. 本地动态图加载

如果是加载 Bundle 或沙盒中的本地数据,必须手动包装为 SDAnimatedImage 才能触发低内存机制:

// 从 Bundle 中获取 GIF 数据
NSString *path = [[NSBundle mainBundle] pathForResource:@"demo" ofType:@"gif"];
NSData *gifData = [NSData dataWithContentsOfFile:path];

// 关键步骤:转换为 SDAnimatedImage,保留原始 NSData
SDAnimatedImage *animatedImage = [SDAnimatedImage imageWithData:gifData];

// 赋值给 SDAnimatedImageView
self.imageView.animatedImage = animatedImage;  // 自动开始播放(若 autoPlayAnimatedImage 为 YES)

3. 手动控制播放

如果不想自动播放,可以设置 autoPlayAnimatedImage = NO,然后手动调用:

self.imageView.autoPlayAnimatedImage = NO;
self.imageView.animatedImage = animatedImage;
// 在合适的时机手动开始
[self.imageView startAnimating];

也可以获取当前播放状态:

NSUInteger currentFrame = self.imageView.currentFrameIndex;
NSUInteger currentLoop = self.imageView.currentLoopCount;

五、生产环境“避坑指南”

在将 SDAnimatedImageView 推向线上后,我们踩过几个深坑,这里分享给大家。

坑 1:XIB/Storyboard 忘记改 Class

这是排名第一的线上低级错误。视觉上看不出区别,GIF 也能播放,但内存监控会报警。
只要没有把 Custom Class 改为 SDAnimatedImageView,它底层就会退化为原生的全量解码模式
对策:在创建 ImageView 时,务必确认类型。

坑 2:缓存降级导致的“静态图”Bug

场景:首页用 SDAnimatedImageView 加载并缓存了一个 GIF。进入详情页,由于某些原因使用了原生的 UIImageView 加载同一个 URL。
现象:详情页的 GIF 变成了一张静态图。
原因:SDWebImage 的磁盘缓存中,为了保留 SDAnimatedImage 的特性,存储的是经过优化的特殊格式数据。普通的 UIImageView 从缓存读取后,由于不具备解码动态图的能力,只能显示第一帧。
对策:在项目架构层面,统一动态图加载组件,严禁混用原生 UIImageViewSDAnimatedImageView 加载同一个动态图 URL。

坑 3:WebP 动图不支持

SDAnimatedImageView 默认支持 GIF 和 APNG,但不支持 WebP 动图。如果你需要播放 WebP,必须引入独立的解码器。
正确集成方式(在 Podfile 中):

pod 'SDWebImageWebPCoder'

然后在 App 启动时注册:

#import <SDWebImageWebPCoder/SDImageWebPCoder.h>

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [SDImageCodersManager.sharedManager addCoder:SDImageWebPCoder.sharedCoder];
    return YES;
}

坑 4:长列表内存优化组合拳

在包含大量 GIF 的朋友圈或微博 Feed 流中,建议在 UITableViewCellprepareForReuse 中配合以下设置:

- (void)prepareForReuse {
    [super prepareForReuse];
    
    // 取消正在进行的图片加载
    [self.gifImageView sd_cancelCurrentImageLoad];
    
    // 停止播放并清空缓冲,极大缓解长列表内存压力
    self.gifImageView.clearBufferWhenStopped = YES;
    [self.gifImageView stopAnimating];
}

为什么这样做?

  • sd_cancelCurrentImageLoad 避免复用 Cell 时旧图片加载回调错乱。
  • clearBufferWhenStopped = YES 确保 Cell 离开屏幕后立即释放解码内存。
  • stopAnimating 停止 CADisplayLink 回调,节约 CPU。

坑 5:动画不播放的排查思路

如果 GIF 设置了但不播放,可以按以下顺序检查:

  1. 确认 animatedImage 属性不为 nil(如果是网络加载,检查 sd_setImage 的回调中是否成功)。
  2. 确认 autoPlayAnimatedImage 是否为 YES,或手动调用了 startAnimating
  3. 确认 runLoopMode 是否在当前 RunLoop 模式下被允许(常见于滑动时,若设置为了 NSDefaultRunLoopMode 则滑动时会暂停)。
  4. 确认图片数据是否完整(可尝试用 SDAnimatedImageimages 属性查看帧数)。

六、总结

SDAnimatedImageView 绝不仅仅是一个“能播 GIF 的 ImageView”。它通过 按需解码动态帧缓冲VSync 驱动 以及 设备自适应策略,在内存与性能之间找到了最优解。

理解并善用它的进阶属性(如 maxBufferSize 的 Auto 机制、clearBufferWhenStopped 等),不仅能让你的 App 告别动态图引发的 OOM 崩溃,更能体现出一名 iOS 开发者对底层渲染机制的深刻理解。在动态图渲染这一块,SDAnimatedImageView 依然是当前业界当之无愧的标杆。


互动时间:你在项目中遇到过哪些动态图相关的“奇葩”问题?欢迎在评论区留言,我们一起探讨最佳实践!

本地执行 IPA 混淆 无需上传致云端且不修改工程的方案

在很多团队里,混淆这一步常常被外包给在线加固服务:上传 IPA,等结果,下载再签名。流程确实顺手,但当项目涉及商业逻辑或私有算法时,这种方式总让人有点不踏实——完整的二进制、资源、接口结构都离开了本地环境。

后来我们把这一步彻底改成本地执行,不上传任何文件不改工程源码只操作已编译好的 IPA


一、先确认 IPA 当前长什么样

把构建好的 IPA 复制一份并解压:

unzip app.ipa

进入目录:

Payload/App.app

检查三个位置:

1)二进制可读信息

strings AppBinary | head

如果能看到:

UserManager
PaymentService
VipController

说明符号没有做处理。


2)资源目录结构

assets/images/vip_banner.png
config/payment.json

路径本身已经带有业务语义。


3)前端资源

main.jsbundle
index.html

这些文件如果未压缩,直接可读。


二、本地链路的核心思路

整个流程不依赖任何远程服务,结构如下:

IPA 文件
→ 本地解析
→ 本地混淆
→ 本地资源处理
→ 本地签名
→ 本地测试

关键在于:所有操作都发生在开发机器上。


先处理 JS / H5(如果存在)

如果项目中包含 WebView 或 React Native 模块,可以在 IPA 处理前压缩脚本。

例如:

terser main.js -o main.min.js

或者:

uglifyjs page.js -o page.min.js

压缩后再替换回 IPA 资源目录。

这样可以先降低 JS 层的可读性。


在本地执行 IPA 符号混淆

这一步是核心。

使用 Ipa Guard 这类本地运行的 IPA 混淆工具,可以直接处理 Mach-O 文件,而不需要源码。

操作过程:

  • 打开工具
  • 导入 IPA
  • 进入「代码模块」

可以看到:

OC 类
Swift 类
OC 方法
Swift 方法

在列表中选择需要处理的符号,例如:

UserManager
PaymentHandler
VipService

执行后:

UserManager → k39sd2

整个过程在本地完成,不会上传任何数据。


资源文件本地重写

继续在 Ipa Guard 的资源模块中操作。

勾选:

  • 图片
  • JSON
  • HTML
  • JS

执行后:

vip_banner.png → a82kd.png
payment.json → x92ks.json

工具会自动更新引用路径。

这一层的作用是让资源结构失去语义。


改变资源指纹(避免“同源识别”)

如果多个应用使用相同资源,文件内容会成为识别依据。

在 Ipa Guard 中开启 MD5 修改:

md5 banner.png

处理前后不同。

文件视觉效果不变,但指纹已经改变。


清理调试信息

检查:

strings AppBinary | grep NSLog

如果存在日志或调试字符串,可以在混淆阶段删除。

Ipa Guard 提供调试信息清理选项。


补充一个“简单校验机制”

为了避免 IPA 被二次篡改,可以在原生层加入简单校验:

  • 计算关键文件 hash
  • 启动时验证

例如:

if hash != expected { exit(0) }

这一步不依赖混淆工具,但可以作为补充。


本地完成签名与安装

混淆后 IPA 已失去原签名,需要重新签名。

可以使用:

kxsign sign app.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i

或者直接在 Ipa Guard 中配置证书。

连接设备后可以直接安装。


验证结果(这一步不能跳)

安装后重点检查:

  • 页面是否正常
  • 资源是否加载
  • 动态调用是否正常
  • WebView 内容是否可用

如果出现异常,通常是:

  • 某些符号被误混淆
  • 某些资源路径未正确更新

把 IPA 混淆完全放在本地执行,并不只是“更安全”的选择,它还带来一个实际好处:每一步都可控、可调试、可回滚。相比上传到云端处理,本地流程更适合需要长期维护的项目。

搭建一个云端Skills系统,随时随地记录TikTok爆款

最近 Claude Skills 很火。

但我观察了一圈,发现大家都在陷入一种“开发者的自嗨”。

绝大多数 Skills 的应用场景都被死死锁在 IDE 里,锁在开发者的电脑前。

这叫开发提效,不叫业务提效。

真正的业务发生在移动端,发生在你通勤、吃饭、甚至躺在床上刷 TikTok 的时候。

如果你的 AI 能力必须打开电脑、输入命令行才能调用,那它的时空效率就是零。

于是我抛弃本地的 Claude Code,基于 OpenHands 做了一套云端 Skills 系统。

效果极其简单粗暴:

我在刷 TikTok,看到一个爆款视频,点击复制链接,敲击 iPhone 背面三下。

wxv_4355007050494509070

20 秒后,我的飞书多维表格里自动新增了一行数据。

Image

这行数据包含了:这个视频的无水印文件、Gemini 拆解的镜头语言分析、爆款原因推导,以及一套可直接复用的 AI 视频生成提示词。

全过程我不需要打开电脑,不需要切换 APP,不需要等待。

这就是我今天要聊的:如何用 OpenHands + Skills + iOS 快捷指令,构建一套真正落地的业务自动化系统。

01 为什么 Claude Code 在业务侧是伪需求

先厘清两个概念:OpenHands 和 Claude Code。

Claude Code 是 Anthropic 官方推出的命令行工具,它是一个嵌入在你本地终端里的结对程序员。它的 Skills 本质是上下文记忆和本地工具接口。

它的优势是懂你的代码规范,能直接改你电脑里的文件。

但它有一个对于业务场景的致命弱点:它必须依附于你的会话,你不在,它就不动。

它是一个副驾驶(Copilot)。

而 OpenHands(前身 OpenDevin)是一个开源的、自主的 AI 软件工程师。它运行在 Docker 容器里,是一个独立的服务端 Agent。

Image

openhands.dev/

它是一个可以被封装成 API 服务的数字员工。

我看重 OpenHands 的核心理由只有一个:它可以 24 小时在线,并且可以通过 API 远程唤醒。

我做的这个 TikTok 分析系统,本质就是把 OpenHands 部署在服务器上,通过 FastAPI 暴露接口。

Claude Code 是给你用的工具;OpenHands 是你雇佣的、随时待命的员工。

🐵

小提示:FastAPI 的服务地址后加/docs就是文档了

02 业务视角:从 刷视频 到「数据入库」的闭环

对于做出海营销和短视频矩阵的朋友,拆解爆款是每天的必修课。

传统的流程极其反人类:

  1. 1. 手机刷到视频,点收藏。
  2. 2. 晚上回家打开电脑,把链接导出来。
  3. 3. 找第三方工具去水印下载。
  4. 4. 把视频传给 Gemini 分析。
  5. 5. 人工把分析结果复制粘贴到 Excel 或飞书。

这个链路太长,断点太多。任何需要延迟满足的流程,最终都会变成不了了之。

我的远程 Skills 方案,把这个流程压缩到了极致。

整个逻辑是这样的:

Image

用户端(前端)

利用 iOS 自带的快捷指令 + 背部轻点功能。

  • 动作:获取剪贴板内容(TikTok 链接)。
  • 触发:发送 HTTP POST 请求给我的服务器。
  • 反馈:手机震动一下,表示任务已接收。

Image

Image

服务端(后端)

OpenHands 接收到请求后,自主执行以下 Skills:

  1. Playwright Skill:

启动无头浏览器。这里有一个技术难点,TikTok 的反爬虫机制非常严格。如果用普通的 request 请求,成功率几乎为零。OpenHands 调用 Playwright 模拟真实浏览器行为,绕过 blob 协议,抓取真实的 MP4 视频流。这种方式的下载成功率稳定在 70%-80%

  1. Gemini Skill:

视频下载后,调用Gemini 2.5 Flash,快且便宜。它不只是看,它是理解。它可以识别拍摄角度(俯拍/特写)、运镜方式(推拉摇移)、BGM 节奏点、色彩心理学。

  1. Feishu Skill:

将清洗好的结构化数据(JSON),通过 API 写入飞书多维表格。

结果:

当你刷完半小时视频,打开飞书,几十个爆款视频的深度分析报告已经整整齐齐躺在那里了。

这才是 AI 赋能业务的本质:隐形化。

Image

Openhands 的 Skills 文档:

docs.openhands.dev/sdk/guides/…

03 举一反三:跨境电商的远程 Skills 玩法

这套架构的核心逻辑是:移动端触发 -> 服务端 API -> OpenHands 执行复杂 Skills -> 结果回传。

这个逻辑在出海业务里有无限的延展性。

我给几个具体的场景,你们可以拿去直接落地。

场景一:竞品独立站监控

  • 动作:在手机浏览器看到竞品的 Shopify 店铺,复制链接,触发 Shortcut。
  • Skills:OpenHands 调起爬虫 Skill 扫描该站点的新品上架情况、价格策略,并调用 SEO Skill 分析其关键词布局。
  • 产出:一份竞品分析简报直接推送到你的 Slack 或 钉钉。

场景二:亚马逊差评自动预警与回复草稿

  • 动作:系统监控到差评(自动触发,无需人工)。
  • Skills:OpenHands 读取差评内容,结合历史客服知识库 Skill,分析用户情绪,并模仿金牌客服的语气撰写 3 个版本的回复邮件。
  • 产出:草稿进入审核流,你只需要在手机上点批准。

场景三:广告素材批量生产

  • 动作:上传一张产品图到指定文件夹。
  • Skills:OpenHands 识别产品特征,调用 Midjourney 或 Runway 的 API,结合当下的流行趋势 Skill,自动生成 10 种不同风格的广告背景图。
  • 产出:素材自动同步到 Google Drive 供投放团队筛选。

04 为什么非要用 Agent Skills?写个 Python 脚本不行吗?

这是很多技术出身的朋友最容易陷入的误区。

你这个功能,我写个 Python 脚本 + 定时任务也能跑,为什么要搞这么复杂的 OpenHands Skills?

因为业务逻辑是流动的,而脚本是僵死的。

如果你写死了一个 Python 脚本:

  • 当 TikTok 的前端代码更新了 class 名,脚本报错,你得去修。
  • 当飞书的 API 接口变动,脚本报错,你得去修。
  • 当 Gemini 的模型参数调整,脚本报错,你得去修。

但在 OpenHands Skills 的架构下,我们定义的不是步骤,而是目标。

在我的 Skill 定义里,我告诉 OpenHands:你的任务是下载这个页面上的视频,如果常规方法失败,尝试模拟用户滚动;如果还失败,检查是否有验证码并尝试通过。

OpenHands 作为一个 Agent,它具备自主决策和自我修复的能力。

  • 它发现 TikTok 改了页面结构?它会尝试用视觉识别去定位播放按钮。
  • 它发现 API 报错?它会自主查阅文档或尝试备用节点。

在跨境出海这种平台规则朝令夕改的环境下,维护脚本的成本极高。

我们需要的是一个能够理解意图并自主寻找路径的智能体。

05 思路打开,Agentic Skills 的高级玩法

文章到这里,这套远程 Skills 系统的雏形已经搭建完毕。

但如果你觉得这就结束了,那你就小看了 Agentic Skills 的天花板。

我们现在的架构是“一个请求触发一个 Skill”,但这只是冰山一角。真正的威力在于 Multi-Skill Orchestration(多技能编排)。

  1. 1. Skill Chain(技能链)与递归调用

OpenHands 的 Skill 本质是可执行的逻辑单元。我们可以像写代码一样,让 Skill A 去调用 Skill B。

  • 比如定义一个 Base-Skill:只负责做基础的数据清洗。
  • 再定义一个 Pro-Skill:先调用 Base-Skill 处理数据,再把结果传给 Analysis-Skill,最后调用 Report-Skill 生成报告。

你可以构建一个自我迭代的 Agent。让它先写一段代码(Coding Skill),然后自己运行测试(Testing Skill),如果报错,递归调用 Coding Skill 进行修复,直到测试通过。

  1. 混合云架构(Hybrid Agent Architecture)

OpenHands 运行在 Docker 里,这意味着它可以部署在任何地方。

  • 私有化部署:对于涉及公司财务、用户隐私的数据,你可以把 OpenHands 部署在公司内网服务器上。
  • 公有云调用:对于需要访问外网(如 TikTok 下载、竞品分析)的任务,部署在 AWS 或 Vercel 上。

这样,通过 API 网关,你可以指挥内网的 Agent 去调用外网的 Agent,实现数据在安全域和互联网域之间的智能流转。

  1. “人机回环”的异步交互

谁说 API 只有“请求-响应”这一种模式? 在我的系统中,有些复杂任务(如竞品深度调研)可能需要运行 30 分钟。

  • 流程设计:OpenHands 接收任务 -> 立即返回 TaskID -> 后台异步执行。
  • 关键点:当 Agent 遇到无法决策的卡点(例如:这个验证码我解不开,或者这个竞品网站有两套价格体系,取哪套?),它可以主动通过飞书/Slack 给你发消息请求确认。

你点击确认后,Agent 继续执行。这才是真正的人机协作:AI 处理海量冗余信息,人类只在关键节点做决策。

在这个体系下,Skills 不再是静态的脚本,而是可生长、可组合的原子能力。

未来,你的个人服务器里可能运行着上百个这样的 Skills。它们是一群田螺姑娘,在你睡觉的时候,帮你监控市场、回复邮件、整理知识、优化代码。

而你,只需要握着手机,轻轻敲两下背部,就像魔法师挥动了魔杖。

这,才是 Agent 时代的真正玩法。

我用n8n+AI记忆系统 MemOS,给SHEIN 搭了个销售Agent

2025 做了很多场线下AI 跨境电商的沙龙交流,给我一个非常割裂的感觉。

现在AI领域已经迭代的很好了,但跨境电商大多都很传统,别说AI,连自动化数字化都还没做到。

所以如果用AI去升级会是一个超级大的机会,预判到2026年会有一个大爆发。

但这波爆发不是比谁更会铺货、不是谁的亚马逊生图更好看、不是谁的TK UGC 视频更真实

而是比谁更懂精细化运营。

其中,最典型的就是邮件回复。

现在大多都是用人工、或者用规则、最多上个知识库索引。

效果不用想都知道很差,没有灵魂。

因为AI没有记忆,记不住用户的画像。

记住了又有什么用呢?能把单纯是「客服」性质的回答,升级生成「促销转化」的销冠。

例如根据用户的身高三围推荐尺码、根据喜好推荐产品,甚至可以做连带销售的推荐提高客单价。

成本极低,ROI直接拉满。

这样的AI Agent你真的不想要吗?

今天就教你怎么做这样一个n8n+知识库 RAG+AI 记忆的 AGENT!!

这个邮件Agent 是一个典型,搞懂了这个逻辑之后,去跑别的 AI 数字员工,就很丝滑了。

为什么传统的 RAG 不行?

在开始搭建之前,我必须先说一个残酷的通用事实:市面上90%的 AI 客服都是“一次性”的。

你搭了一个基于 RAG(检索增强生成)的知识库,把几万字的退换货政策扔进去。客户问:“怎么退货?” AI 回答得滴水不漏。

但下一秒,客户问:“那我上次买的那件 M 码穿着紧,这次我是不是该换 L 码?”

这时候,你的 AI 傻了。

因为它没有记忆,或者说它的记忆在每轮对话结束后就清零了。

它不知道客户“上次”买了什么,也不知道客户“上次”反馈过 M 码紧。它只能冷冰冰地回复:“请提供您的订单号。”

这就是无状态的痛点。

要解决这个问题,我们需要一个能 读写记忆 的系统,而不仅仅是一个静态的文档库。

最近我挖到了一个王炸级的开源项目 —— MemOS 2.0「星尘 Stardust」。

Image

memos.openmem.net/cn/

它不仅仅是能存数据,它直接把“企业知识库”和“用户动态记忆” 打通了。看看下面这张图,MemOS 是怎么思考的:

Image

它帮我们解决了三个最核心的问题:

  1. 1. 静态知识库: 企业的 S.O.P、尺码表、物流政策,支持 PDF/Markdown/TXT 直接上传,扔进去就能查,这是底层的业务规范。
  2. 2. 动态记忆(用户的画像): 这是最关键的。用户说过的话(“我喜欢宽松点”)、用户的属性(“170cm/60kg”)、用户的历史行为,它会自动抓取并存储为长期记忆。

这就相当于给你的 AI 装了一个会自动记笔记的海马体。

Image

使用上,MemOS 支持把文件和 URL 直接导入知识库。

对话过程中记忆会持续更新并随着增长逐渐形成偏好记忆,并且能把文本、图片、文件、工具调用等信息统一记忆,必要时还能使用自然语言对已有记忆做纠错和清理。

而且,在配置的过程中,我发现了一个华点:系统会根据对话内容自动演化并更新记忆层,从而推动知识库的持续自进化。

  • 用户说:“我不吃辣” -> MemOS 自动写入偏好。
  • 用户说:“最近搬去上海了” -> MemOS 自动更新地区信息。

Image

卧槽??这不就是一直在困扰我的知识库动态更新的问题吗?

原本要手动去插入、更新之类的,现在你跟我说,直接对话就能自动更新了??

那我以前熬夜搭的流程算什么??

行吧,下面,直接上实操。

超级福利!!完整n8n工作流源码放文末了。

真的开箱即用了朋友们!!

落地场景

智能客服对于服装企业来说需求是很大的,几万个SKU能用 AI来管理的话,效率和产出都是成指数增长的。

我们就拿 SHEIN 为例。

Image

当然我没有SheIn的内部资料,我让GPT老师给我生成了好几个文档,涵盖售前的尺码推荐、物流、售后的退换货、洗护等政策。

Image

工作流实操!!

开始前先给大家看下整个流程是什么样的。

Image

整套系统的核心逻辑在于“身份锚定 + 双重检索 + 记忆闭环” 。

首先,n8n 利用 Gmail 的 threadId 锁定会话上下文,提取发件人邮箱作为唯一身份标识 user_id

接着,系统执行双路并行检索:

一路调用 /search/memory 获取业务文档(如尺码表、退货政策)及用户长期画像(如身高体重);

另一路调用 /get/message 拉取当前邮件往来的短期历史记录。

AI 将这些“静态规则”与“动态偏好”融合,生成兼具专业度与情绪价值的回复。

最后,通过 /add/message 将本次交互回写至 MemOS ,让 AI 的记忆随着每一次沟通自动进化,越用越懂客户。

这套逻辑的效果非常惊喜!!

因为前面的资料都是 AI 生成的,所以我把全部东西都扔到 Gemini 里,让它来给我们判断一下这个工作流的精准度如何。

1、知识库、上下文与短期记忆测试

这是第一次邮件,这里关键就看知识库是否能精准击中需求。

这里我介绍了我的数据,问选型之类的售前问题。

Image

直接看回复

Image

Gemini 老师的评价是很好:

Image

接下来测试一下短期记忆。

Image

这是第二轮了

此时,通过conversation_id能成功获取前面邮件的对话记录,也就是说成功把两封独立的邮件串起来了,完成了多次连续对话的能力。

Image

再看下回复效果:

Image

Gemini 老师表示满分:

Image

2、长期记忆测试

这次,我没有说自己的数据就直接让它推荐一条牛仔裤

Hi,

我这次想买 "SHEIN High Waist Straight Leg Jeans"。 还是以前的身材数据没变,请问这款牛仔裤我该选什么码? 我看评论说这个没有什么弹性,我很怕卡裆或者腰太紧。

回复效果:

Image

Gemini老师评价是依然发挥稳定哈哈哈:

Image

看来效果针不戳,但背后操作其实特别简单!!

相信我!!有手就行!!

接下来,我们逐个模块来看下。

1、MemOS知识库

到MemOS后台,进入知识库页面,直接右上角点添加知识库

memos-dashboard.openmem.net/cn/knowledg…

如图按要求输入名称就好了:

Image

接着把之前GPT老师给的资料,也就是公司客服相关的文件扔进去。

这里不需要做任何配置,默认效果就不错了。

Image

在如图这个位置拿到知识库ID

Image

MemOS 的接口文档在这里,基本上读写记忆等常规API 都有了,备用:

memos-docs.openmem.net/cn/api_docs…

Image

至此 MemOS 部分的设置就结束了,简单的令人发指。

2、n8n工作流

接下来就到n8n工作流的部分。主要是用它把 Gmail、MemOS 和 AI 连接起来。

Image

我把整个工作流拆解成了三个核心模块,大家跟着做就行。

模块一:监听邮件与智能识别

Image

避免一些垃圾邮件干扰我们处理了。

  1. 1. Gmail Trigger (监听):
  • 设置 Poll Times 为每分钟一次。
  • Filters 设置为 Label: INBOX 和 UNREAD(只看未读邮件)。
  1. Image
  2. 2. AI Agent:
  • 这里接一个小模型(比如 gpt-4o-mini 或 Qwen)就够了,省钱。
  • 核心任务:判断这封邮件是不是客户咨询。

Image

  • System Prompt:
我们是电商公司,你是邮件内容判断助手。
请判断当前邮件内容是否为客户的售前、售后咨询。
如果是,回复 {"客户邮件":"是"};否则回复 {"客户邮件":"否"}
  1. 3. If (分流):
  • 只有当 客户邮件 == 是 时,才进入后续流程。

模块二:知识库+记忆+上下文 —— 开启上帝视角

这是最核心的处理部分。

Image

  1. 1. Set Context Variables (清洗身份):
  • MemOS 需要一个 user_id 来认人。
  • 我们用正则表达式提取发件人邮箱:{{ json.from.match(/<(.+)>/)?.[1]json.from.match(/<(.+)>/)?.[1] || json.To }}。
  • 提取 threadId 作为 conversation_id,这是串联多轮对话的关键。

Image

  1. 2. 双路并行检索 (Parallel Retrieval):

通过 http请求节点跟 MemOS 交互。

  • 上路:检索记忆 (Search Memory)
  • 调用 MemOS /search/memory 接口。
  • 作用:查静态文档(尺码表、退货政策)+ 查长期记忆(用户身高体重、喜好)。

Image

  • 下路:获取上下文 (Get Context)
  • 调用 MemOS /get/message 接口。
  • 作用:查最近 10 条对话。比如用户说“那我就要这个了”,AI 必须通过历史记录知道“这个”指的是刚才推荐的牛仔裤。

Image

  1. 3. 合并上下文 (Merge):
  • 设置 Combine By 为 Position。
  • 这一步把“过去记忆”和“当下语境”合二为一,输送给最终的大脑。

模块三:注入灵魂回复 & 记忆闭环

最后一步,不仅是回复,更是为了让 AI 记住这次交互,这是越用越好用的关键。

Image

  1. 1. AI 回复生成器 (Injecting Soul):
  • 这是最关键的 Prompt。
# Role
你不是机器人,你是 **SHEIN 专属时尚顾问 (Style Bestie)**。
目标:用温暖、专业且带时尚感的语气解决问题。

# Context Data
1. 记忆与知识库: {{ $('检索记忆').item.json.data.memory_detail_list }}
2. 对话历史: {{ $('获取历史').item.json.data.message_detail_list }}

# Guidelines
- **拒绝机械感**:禁止说“根据数据库显示”。
- **显式记忆**:如果发现用户身高体重(如 170cm),必须在回复中显式提及("考虑到您 170cm 的高挑身材...")。
- **情绪价值**:适当夸赞用户眼光,使用 Emoji 😊。

# Output
必须输出 **HTML 格式** 的邮件正文,使用 <p><strong>标签排版。

注意这里我让 AI 返回的 HTML 格式,确保客户收到的邮件也是富文本格式的,提高阅读体验。这是简略版,完整版见文末原文。

  1. 2. 存入记忆 (Memory Loop):
  • 调用 /add/message 接口。
  • 关键操作:把用户的 User Query 和 AI 生成的 Output 一次性存回去。
  • 这样,MemOS 会自动分析这次对话,提取新的用户偏好(比如“用户觉得 M 码紧”),下次对话时 AI 就会自动避坑。
  1. 3. 发送邮件 (Gmail Send):
  • 记得开启 HTML 模式,把 AI 生成的漂亮排版发给客户。

这一套下来,你不仅拥有了一个能秒回邮件的客服,更拥有了一个能不断自我进化的用户数据资产库。

每一封邮件,都在让你的企业大脑更聪明一点。

从「回复邮件」到「经营关系」

这套 n8n + MemOS 的打法,直接把跨境电商的客服水平拉高了一个维度。

它不是在做“问答”,它是在做“关系管理”。

这套系统的核心价值,不在于它省了多少人工(虽然它确实省了),而在于它能留存客户资产。

以前,最有经验的客服离职了,他对客户的了解也就带走了。

现在,所有的记忆、偏好、习惯,全部沉淀在 MemOS 的记忆层里。哪怕你换了 10 批运营,AI 依然记得那个喜欢穿宽松牛仔裤、住在深圳、对运费敏感的老客户。

这就是数据资产。

这套逻辑还能怎么用?

既然 MemOS 能做大脑,n8n 能做手脚,那这个“超级销售”就不应该只活在邮箱里。

  1. 1. WhatsApp / Telegram 私域玩法:

对于做高客单价(如假发、珠宝、3D打印机)的卖家,私域是命脉。

把这套逻辑接入 WhatsApp Business API,AI 能记得客户上个月说了“想给女儿买生日礼物”,并在生日前一周自动推送新品。

这转化率,比群发广告高 100 倍。

  1. 2. 独立站 AI 导购 (Chatbot):

别再用那种只会弹优惠券的智障弹窗了。

把 MemOS 接入网站右下角的聊天窗,当用户浏览商品时,AI 能主动提示:“这件大衣和你上次买的靴子超搭哦!”

2026 年的红利,属于那些敢把 AI 塞进业务心脏里的人。

MemOS 2.0 现在的门槛极低,我已经把最难的“路”给探完了。

有兴趣的小伙伴可以去项目里面玩玩看

目前项目已经全面开源 github.com/MemTensor/MemOS

别观望了,去注册个账号,把你的文档扔进去试试。

哪怕只跑通一个场景,你的业务效率都能像滚雪球一样飞起来。

完整n8n工作流源码

关注公众号「饼干哥哥AGI」

后台回复「邮件Agent」即可

Harbeth:高性能Metal图像处理库,让你的图片处理速度飞起来!

🎯 项目简介

Harbeth是一个基于Metal的高性能图像处理库,为iOS和macOS开发者提供了一套简洁、高效的图像滤镜解决方案。它不仅支持传统的图像滤镜效果,还能处理HDR图像,让你的应用在图像处理方面如虎添翼。

用故障艺术美学建立动态RGB通道分离 实时检测边缘并添加霓虹灯发光效果
ShiftGlitch.gif EdgeGlow.gif

✨ 核心功能与优势

1. 高性能Metal渲染

  • 利用Metal GPU加速,处理速度比CPU实现快10-50倍
  • 支持命令缓冲区池管理,优化GPU资源使用
  • 双缓冲技术,进一步提升处理性能

2. 丰富的滤镜效果

  • 基础滤镜:亮度、对比度、饱和度、色相调整
  • 高级效果:高斯模糊、锐化、边缘检测、色调映射
  • 创意滤镜:复古、赛博朋克、电影效果、HDR增强
  • 自定义滤镜:支持自定义Metal着色器

超过 200+ 内置滤镜,组织成直观的类别,涵盖从基本颜色调整到高级艺术效果的各种功能

3. HDR图像处理

  • 支持rgba16Float和rgba32Float格式的HDR纹理
  • 内置HDR到SDR的色调映射算法
  • 保留HDR图像的细节和动态范围

4. 易用的API设计

  • 链式调用风格,代码简洁易读
  • 统一的输入输出接口,支持多种图像类型
  • 异步处理支持,避免主线程阻塞

5. 跨平台支持

  • 同时支持iOS、macOS和tvOS
  • 适配不同设备的Metal性能特性
  • 自动处理设备内存限制

🚀 快速开始

安装方式

// Swift Package Manager
.package(url: "https://github.com/yangKJ/Harbeth.git", from: "0.0.1")

// CocoaPods
pod 'Harbeth'

基础使用示例

import Harbeth

// 加载图像
let image = UIImage(named: "example")!

// 创建滤镜
let filter = C7Brightness(brightness: 0.2)

// 应用滤镜
let result = try? HarbethIO(element: image, filter: filter).output() as? UIImage

// 显示结果
imageView.image = result

链式滤镜示例

// 组合多个滤镜
let filters: [C7FilterProtocol] = [
    C7Brightness(brightness: 0.1),
    C7Contrast(contrast: 1.2),
    C7Saturation(saturation: 1.3),
    C7GaussianBlur(radius: 2.0)
]

// 应用滤镜链
let result = try? HarbethIO(element: image, filters: filters).output() as? UIImage

🎨 高级特性

1. 自定义Metal着色器

// 创建自定义滤镜
struct CustomFilter: C7FilterProtocol {
    var modifier: ModifierEnum {
        return .compute(kernel: "customKernel")
    }
    
    var factors: [Float] = [0.5, 0.5, 0.5]
}

// 应用自定义滤镜
let customFilter = CustomFilter()
let result = try? HarbethIO(element: image, filter: customFilter).output() as? UIImage

2. HDR图像处理

// 加载HDR图像
let hdrImage = UIImage(named: "hdr_example")!

// 应用HDR到SDR转换
let hdrFilter = HDRToSDR()
let result = try? HarbethIO(element: hdrImage, filter: hdrFilter).output() as? UIImage

3. 实时处理

// 实时处理相机捕获的图像
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
    // 异步处理
    HarbethIO(element: sampleBuffer, filter: C7Vibrance(vibrance: 0.5)).transmitOutput { result in
        switch result {
        case .success(let processedBuffer):
            // 处理成功,显示结果
            DispatchQueue.main.async {
                self.previewLayer.enqueue(processedBuffer)
            }
        case .failure(let error):
            print("处理失败: \(error)")
        }
    }
}

⚡ 性能优势

Harbeth在性能方面的表现令人印象深刻:

  • 处理速度:比Core Image快3-5倍,比CPU处理快10-50倍
  • 内存使用:智能纹理池管理,减少内存分配
  • 电池消耗:优化的GPU使用,降低能耗
  • 大图像处理:支持处理高分辨率图像和视频帧

📱 适用场景

Harbeth适用于各种需要图像处理的场景:

  1. 照片编辑应用:快速应用滤镜效果
  2. 相机应用:实时预览和处理
  3. 视频编辑:逐帧处理视频
  4. AR/VR应用:实时图像处理
  5. 社交媒体:快速滤镜效果
  6. HDR图像处理:专业图像处理

🖥️ macOS 支持

Harbeth 完全支持 macOS 平台,为桌面应用提供强大的图像处理能力,打造原生、优化的用户体验:

🎨 macOS 展示

探索 Harbeth 在 macOS 上的强大功能:

🌟 总结

Harbeth是一个功能强大、性能优异的Metal图像处理库,它不仅提供了丰富的滤镜效果,还支持HDR图像处理,为iOS和macOS开发者提供了一套完整的图像处理解决方案。

无论是快速原型开发还是生产环境应用,Harbeth都能满足你的需求。它的高性能特性让图像处理不再成为应用的性能瓶颈,而简洁的API设计则让开发过程更加愉快。

如果你正在寻找一个强大而灵活的图像处理库,Harbeth绝对值得尝试!

📁 项目链接

  • GitHub: github.com/yangKJ/Harb…
  • 文档: 详细的API文档和使用示例
  • 示例应用: 包含多种使用场景的示例代码

让Harbeth为你的应用添加绚丽的图像处理能力,让每一张图片都成为艺术品! 🎨✨

聊聊我最近都干了些什么,AI 时代的手动撸码人

前言

许久未更新内容了,除了被公司的项目倒腾、拉扯之外,其实最近几个月还是干了许多事情的。我就随便聊聊吧。


一、RxStudy 项目尝试同时集成Flutter模块与UniApp模块

其实这个尝试,没有使用AI的功能,完全就是我自己无聊做的一点尝试,我将自己的UniAppPlayAndroid打包成为wgt,然后把GetXStudy项目的Flutter模块全部都集成到RxStudy项目,做了一个超级大杂烩,并且尝试几个端的通信,大家看看效果。

项目截图

玩安卓 原生 Flutter UniApp.gif

二、RxStudy 项目从 CocoaPods 向 Tuist 迁移

CocoaPods 停止维护的消息,iOS 开发者应该都有所耳闻。趁着这个机会,我拿自己 2019 年就开始维护的 RxStudy 练手项目做了一次大迁移。

迁移方案:CocoaPods → Tuist + Swift Package Manager (SPM)

迁移耗时:前前后后大概 3天(从开始到项目能跑起来)。说实话,本来以为会花费更多时间,没想到有了 AI 的帮助,大概就花了这么点时间就搞定了,大大出乎我的意料。

迁移内容

  • Project.swift 定义项目结构、Targets、SPM 依赖引用
  • Tuist/Package.swift 管理 20+ 第三方库的 SPM 版本
  • 本地 Package 封装(HUD、网络请求封装、工具类、路由框架)
  • 双 Target 架构:RxStudy(UIKit + RxSwift) 和 SwiftUIApp(SwiftUI)

![Tuist + SPM 架构图]

AI 表现:大部分时间花在 Tuist 配置文件的编写上,AI 生成的代码基本可以直接使用,复盘时发现主要还是项目结构本身比较规范。


三、RxStudy 项目从 UIKit 向 SwiftUI 迁移

在完成 CocoaPods 向 Tuist 迁移后,我又给 AI 安排了一个新任务:把 RxSwift 里的 UIKit 代码向 SwiftUI 进行迁移。

迁移策略:采用双 Target 并行架构,而非一次性替换

Target 技术栈 说明
RxStudy UIKit + RxSwift 原有代码
SwiftUIApp SwiftUI + @Observable + async/await 新迁移代码

迁移结果:SwiftUIApp 这个 Target 里的代码,95% 都是 AI 写的,我只是给出了部分建议,以及尝试在两个 Target 中复用网络请求层代码。

迁移模块(共10个):

模块 功能
Home Banner + 文章列表
Project 项目分类
PublicNumber 公众号
Tree 体系结构(二级树形)
Mine 用户中心
Login 登录
Collect 收藏列表
Coin 积分明细
CoinRankList 积分排行榜
Search 热搜 + 搜索结果

技术栈变化

类型 迁移前 迁移后
状态管理 RxSwift (RxSwift 6.9.0) @Observable + async/await
状态绑定 RxCocoa SwiftUI 原生
网络层 Moya + RxSwift Moya + async/await

说明:SwiftUI 迁移没有使用 Combine,而是使用了 iOS 17+ 的 @Observable 宏和 Swift 的 async/await,代码更简洁。

项目截图

ScreenRecording_04-01-2026 09-38-01_1.gif

AI 表现:对迁移的功能表示满意,尤其是网络请求层的复用处理得不错。不过 SwiftUI 部分复杂的交互动画(比如下拉刷新 + 列表滚动 + 头部视差效果),还是需要自己动手调整。


四、UniAppPlayAndroid 小程序 Vue2 向 Vue3 升级

实际上我很久之前写过一个 UniApp 版本的玩安卓,只是很久没有维护了。由于我想把这个 UniApp 打包的 wgt 文件在 HarmonyOS Next 里通过小程序运行,但 uniCloud 环境仅支持 Vue3 版本的小程序。

想着 AI 不用白不用,于是让它帮我进行迁移。

迁移耗时:大约 2小时 完成全部迁移。

技术栈变化

类型 Vue2 Vue3
Vue 2.x 3.4.21
状态管理 Vuex Pinia 2.1.7
构建工具 webpack Vite 5.2.8
uni-app 旧版本 3.0.0-alpha
页面写法 Options API Composition API

支持平台

平台 状态 说明
H5 使用 Vite 代理解决跨域
微信小程序 完全支持
Android App 可编译 wgt 热更新包
iOS App 可编译 wgt 热更新包
HarmonyOS Next 存在 WebView bug,使用条件编译规避

典型问题与解决方案

问题 解决方案
根目录缺少 index.html 创建 Vue 3 入口 HTML
uview-plus 样式找不到 改用原生组件
可选链 ?. 不支持 替换为 && 短路求值
CORS 跨域 Vite devServer 代理
HarmonyOS WebView 崩溃 使用条件编译显示占位页

AI 表现:18个页面全部迁移完成,有完整的迁移文档和迁移指南。迁移过程中遇到的一些边界问题,AI 给出的解决方案都比较合理。


五、HarmonyStudy 项目 HarmonyOS Next 代码 5.0 向 6.0 迁移

让 AI 将项目从 5.0 向 6.0 迁移,它顺便把一些第三方库也帮我进行了迁移和升级。

路由系统 API 重大变更

// 5.0 (已废弃)
router.pushNamedRoute({ name: 'pageName', params: {} })
router.getParams()

// 6.0
router.push({ uri: 'pages/pageName', params: {} })

LoadingDialog 兼容性问题

  • 5.0:CustomDialogController 必须在正确的 UI 上下文中创建
  • 6.0 解决方案:引入 @jxt/xt_hud 库,通过全局 UIContext 初始化

第三方库依赖

版本 说明
@ohos/axios 2.2.7 HTTP 网络请求
@pura/harmony-utils 1.4.0 工具库
@jxt/xt_hud 3.4.0 Loading/Toast(6.0 新增)
@ohos/imageknife 3.2.8 图片加载缓存

项目截图: 配合上面UniAppPlayAndroid的Vue2到Vue3的升级,我终于可以在打包好的wgt文件在HarmonyOS Next正常运行起来了。

录屏2026-04-01 09.17.53.gif

AI 表现:路由迁移采用了最小改动方案,保留兼容性。AI 还顺便优化了 Router 类的实现,并完成了 Network HAR 模块的封装。


六、GetXStudy 项目优化代码

我个人觉得这个 Flutter 项目可以优化的地方有限,但 AI 还是给了一些不少的中肯意见,没事就让它跑跑,还是做了不少提交。

优化内容

优化项 详情 状态
修复废弃 API MaterialStateProperty → WidgetStateProperty ✅ 已完成
替换 print 8处 print → logger.d() ✅ 已完成
图片压缩 launchImage.png 4.9MB → 可压缩 70-90% ✅ 已完成
Git Hooks 添加 pre-commit 自动化检查脚本 ✅ 已完成
清理导入 5处未使用的 import 移除 ✅ 已完成
密码安全 明文存储 → flutter_secure_storage 加密 ✅ 已完成
网络缓存 减少约 60% 重复请求 ✅ 已完成
异常处理 统一 ErrorHandler 工具类 ✅ 已完成

AI 表现:提供了详细的优化报告(OPTIMIZATION_REPORT.md、ADDITIONAL_OPTIMIZATION.md),优化效果量化可查。


结论

AI 使用组合:Claude + MLG 4.7 和 Claude + MiniMax 2.5

实话实说

  • 对于 AI 的使用我并不算特别多,MLG 是试用了同事的,后来 MiniMax 因为个人原因买了 490 元的套餐
  • AI 确实解放了不少生产力,比如自己有的时候不太想写的代码,或者需要阅读理解的旧代码
  • 对于迁移、分析这种事情 AI 表现不错
  • 对于移动端开发,如果你写好一个模板,让它按照模板写一些功能与业务它也接得住
  • 不要期望它写过于复杂的交互就可以

几个项目的共同特点

  • 都是基于 WanAndroid 开放 API 的客户端
  • 都是一个人维护的个人项目
  • 都经历了较大的技术架构升级

个人感悟:时常在想,就这么付费上班,是不是也挺肉疼。后来想想,上班没那么累,下班可以正常走,也算行吧。


附录:项目地址

项目 GitHub 地址 相关分支
RxStudy (iOS) seasonZhu/RxStudy refactor/tuist-migration (CocoaPods→Tuist)
refactor/swiftui-migration (UIKit→SwiftUI)
develop_flutter (集成Flutter、UniApp模块)
UniAppPlayAndroid (跨平台) seasonZhu/UniAppPlayAndroid develop_vue3 (Vue2→Vue3)
HarmonyStudy (HarmonyOS) seasonZhu/HarmonyStudy develop_os6 (5.0→6.0)
GetXStudy (Flutter) seasonZhu/GetXStudy optimize-project (代码优化)

作者 GitHub@seasonZhu

LottieConverter:一键生成 .lottie 文件

现在的lottie有了自己的文件格式:.lottie,实际上就是json文件和多张图片的压缩包(可以执行unzip命令查看)。

由于现在项目的lottie资源基本都是「json文件+多张图片」的文件夹组合,存储和远程加载方面确实不太方便,既然新版本支持单文件形式,那肯定跟进啦。

只不过项目的lottie资源实在太多了,让设计重新出资源有点费劲,于是乎弄了个转换脚本,但我还是觉得不太方便,最后让GPT帮忙参考脚本的实现写了个Finder扩展:LottieConverter

example.gif

只需要对lottie文件夹点一下右键,选择"Convert to .lottie"就好了。

下载地址:LottieConverter

  • 支持.lottie和lottie文件夹的切换
  • 支持批量处理

Win11 抓包工具怎么选?网页请求与设备流量抓取

在 Windows 11 上抓包时,问题一般是当前任务应该用哪一类工具

同样是抓包,不同目标差异很大:

  • 想看浏览器接口
  • 想调试 App 请求
  • 想抓手机流量
  • 想分析 TCP 连接

如果一开始选错工具,就会在配置上面搞半天


一、只想看网页接口就先不用装工具

如果目标是查看网页请求和分析接口返回,直接用浏览器即可。


操作步骤(Chrome / Edge)

  1. 打开网页
  2. 按 F12
  3. 切换到 Network 面板
  4. 刷新页面

可以看到什么

  • 请求 URL
  • 请求方法
  • Header
  • Response

验证

点击某个按钮(例如登录),Network 面板会新增请求。

可以直接点进去查看参数。


二、需要修改请求或重放接口

浏览器工具只能查看,不能灵活修改。

这时需要代理抓包工具,例如:

  • Fiddler
  • Charles(Windows 版)
  • Sniffmaster

在 Win11 上配置 Fiddler

操作步骤:

  1. 启动 Fiddler
  2. 打开 Tools → Options
  3. 开启 HTTPS 解密
  4. 安装证书

让浏览器走代理

Fiddler 会自动设置系统代理。

打开网页后,可以在 Fiddler 中看到请求。


修改请求

  1. 开启断点(Breakpoints)
  2. 发送请求
  3. 修改参数
  4. 再发送

观察变化

修改参数后,服务器返回的数据会发生变化。


三、抓 Windows 本机程序流量

如果目标不是浏览器,而是:

  • 桌面软件
  • 本地客户端

仍然可以用 Fiddler、Charles 或 Sniffmaster。


验证方法

  1. 启动抓包工具
  2. 打开目标程序
  3. 触发网络请求

判断结果

  • 如果出现请求 → 程序走系统代理
  • 如果没有 → 程序未使用代理

四、抓 iPhone 流量(Win11 场景)

如果需要在 Windows 上抓 iPhone 的请求,可以先尝试代理方式。


配置步骤

  1. Win11 上启动 工具
  2. 查看端口(例如 8888)
  3. iPhone 连接同一 Wi-Fi
  4. 在 iPhone 设置代理
  5. 安装证书

端口


测试

用 Safari 打开网页:

  • 如果 Fiddler 有请求 → 配置成功

问题分支

如果 Safari 有请求,但 App 没有,说明 App 没走代理


五、使用数据线直接对设备进行抓包

在 Win11 上,如果代理抓不到移动端流量,可以使用 SniffMaster(抓包大师)


操作步骤

  1. 用 USB 连接 iPhone
  2. 解锁设备
  3. 点击“信任此电脑”
  4. 启动 SniffMaster
  5. 选择设备
  6. 安装驱动(Win11 会提示)
  7. 安装描述文件
  8. 进入 HTTPS 暴力抓包 / 数据流抓包模式
  9. 点击开始

------暴力

观察结果

在界面中可以看到:

  • iPhone 发起的请求
  • 包括未经过代理的流量

https


六、在 Win11 上减少抓包噪音

对设备抓包数据会很多,可以通过筛选降低复杂度。


按 App 筛选

在 SniffMaster 中:

  1. 点击 选择 App
  2. 勾选目标应用
  3. 再触发请求

app选择


再做一次控制变量

  1. 清空记录
  2. 点击开始
  3. 只触发一次操作

这样请求数量会明显减少。


七、分析 TCP / 网络问题

如果问题不是接口,而是:

  • 请求超时
  • 网络波动

可以结合 Wireshark。


操作方式

  1. 在 Win11 上启动 Wireshark
  2. 选择网卡
  3. 开始抓包
  4. 触发请求

可以看到

  • TCP 三次握手
  • 重传
  • 连接断开

在 Win11 上抓包,可以按任务选择工具:

  • 看网页 → 浏览器
  • 改接口 → Fiddler/Sniffmaster
  • 抓 App → 代理工具
  • 抓不到 → SniffMaster
  • 查连接 → Wireshark

《SWIFTER -Swift开发者必备Tips》学习笔记

一、函数与闭包核心特性

1. 柯里化 (Currying)

重点说明

  • 核心定义:将接收多个参数的函数,拆解为一系列单参数函数,每个函数返回下一个函数,实现分步传参、参数固化与函数复用,是Swift函数式编程的核心特性之一。
  • 注意:Swift 3+ 移除了原生柯里化语法糖,需通过函数嵌套手动实现。

代码示例

// 传统多参数函数
func addTwoNum(_ a: Int, _ b: Int) -> Int {
    a + b
}

// 柯里化实现:分步接收参数
func curryingAdd(_ a: Int) -> (Int) -> Int {
    return { b in a + b }
}

// 使用:固化参数生成新函数
let add10 = curryingAdd(10)
let result1 = add10(5)  // 15
let result2 = add10(20) // 30

// 多参数柯里化扩展
func curryingThreeParams(_ a: Int) -> (Int) -> (Int) -> Int {
    return { b in
        return { c in a + b + c }
    }
}
let finalResult = curryingThreeParams(5)(3)(2) // 10

2. @autoclosure 自动闭包

重点说明

  • 核心定义:将传入的表达式自动封装为无参闭包,实现延迟执行,仅当闭包被调用时才执行表达式,避免不必要的性能开销。
  • 核心场景:空合运算符??底层基于@autoclosure实现,适用于条件判断中的默认值处理。
  • 注意:仅支持无参闭包,禁止滥用导致可读性下降。

代码示例

// 传统闭包:需手动写闭包语法
func logIfFalse(_ condition: Bool, errorMsg: () -> String) {
    if !condition { print(errorMsg()) }
}
logIfFalse(1 > 2, errorMsg: { "条件错误" })

// @autoclosure 自动封装表达式
func autoLogIfFalse(_ condition: Bool, errorMsg: @autoclosure () -> String) {
    if !condition { print(errorMsg()) }
}
// 调用时直接写表达式,自动封装为闭包
autoLogIfFalse(1 > 2, errorMsg: "条件错误")

// 自定义??运算符(底层基于@autoclosure)
func customNilCoalescing<T>(optional: T?, defaultValue: @autoclosure () -> T) -> T {
    switch optional {
    case .some(let value): return value
    case .none: return defaultValue()
    }
}
let optionalStr: String? = nil
let finalStr = customNilCoalescing(optional: optionalStr, defaultValue: "默认值")

3. @escaping 逃逸闭包

重点说明

  • 核心定义:闭包作为函数参数,在函数返回后才会被执行,称为逃逸闭包,必须用@escaping标记。
  • 核心场景:异步网络请求、GCD延时操作、函数内属性存储闭包。
  • 注意:逃逸闭包内必须显式引用self,需警惕循环引用风险;非逃逸闭包默认无循环引用风险。

代码示例

import Foundation

// 非逃逸闭包:函数执行结束前完成执行
func syncTask(closure: () -> Void) {
    print("任务开始")
    closure()
    print("任务结束")
}

// 逃逸闭包:函数返回后才执行,必须标记@escaping
var globalClosure: (() -> Void)?
func asyncTask(closure: @escaping () -> Void) {
    print("异步任务开始")
    // 1. 存储到全局变量,函数返回后执行
    globalClosure = closure
    // 2. 异步延时执行
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        closure()
    }
    print("异步函数执行结束")
}

// 调用
asyncTask {
    print("逃逸闭包执行")
}

4. 函数基础增强特性

重点说明

  • 方法嵌套:函数内部可定义子函数,封装内部逻辑,实现作用域隔离。
  • 可变参数:用...标记,接收0个或多个同类型参数,函数内自动转为数组使用。
  • 默认参数:给参数设置默认值,调用时可省略该参数,提升灵活性。
  • inout参数:输入输出参数,可在函数内修改外部变量的值。

代码示例

// 1. 方法嵌套
func calculateTotalPrice(price: Double, count: Int) -> Double {
    // 嵌套函数:仅外层函数可访问
    func calculateDiscount(_ total: Double) -> Double {
        if total > 1000 { return total * 0.8 }
        else if total > 500 { return total * 0.9 }
        return total
    }
    let total = price * Double(count)
    return calculateDiscount(total)
}
let finalPrice = calculateTotalPrice(price: 200, count: 6) // 960

// 2. 可变参数
func sumNumbers(_ numbers: Int...) -> Int {
    numbers.reduce(0, +)
}
let sum = sumNumbers(1,2,3,4,5) // 15

// 3. 默认参数
func userInfo(name: String, age: Int = 18, city: String = "未知") {
    print("姓名:\(name),年龄:\(age),城市:\(city)")
}
userInfo(name: "张三") // 自动使用默认值

// 4. inout参数
func swapValue<T>(_ a: inout T, _ b: inout T) {
    (a, b) = (b, a)
}
var num1 = 10, num2 = 20
swapValue(&num1, &num2) // num1=20, num2=10

二、协议与面向协议编程

1. protocol 方法的 mutating 声明

重点说明

  • 核心定义:Swift的protocol可被class、struct、enum实现,struct和enum是值类型,默认无法在实例方法中修改自身属性,必须用mutating修饰方法。
  • 最佳实践:协议方法若会修改实例自身,必须声明mutating,保证struct/enum的实现兼容性;class实现时可忽略该关键字。

代码示例

// 正确协议定义:声明mutating
protocol Countable {
    mutating func addCount()
    mutating func resetCount()
}

// struct实现:必须保留mutating
struct Counter: Countable {
    private(set) var count: Int = 0
    mutating func addCount() { count += 1 }
    mutating func resetCount() { count = 0 }
}

// class实现:可忽略mutating
class ClassCounter: Countable {
    private(set) var count: Int = 0
    func addCount() { count += 1 }
    func resetCount() { count = 0 }
}

// 使用
var structCounter = Counter()
structCounter.addCount()
print(structCounter.count) // 1

2. Sequence 与 IteratorProtocol

重点说明

  • 核心定义:Swift中for...in循环的底层基于Sequence协议,实现Sequence必须先实现IteratorProtocol协议,定义元素的遍历规则。
  • 核心关系:IteratorProtocol负责生成元素,通过next()方法返回下一个元素,nil表示遍历结束;Sequence负责创建迭代器,提供遍历能力。

代码示例

// 1. 实现迭代器协议:反向遍历迭代器
struct ReverseIterator<T>: IteratorProtocol {
    typealias Element = T
    private var elements: [T]
    private var currentIndex: Int
    
    init(elements: [T]) {
        self.elements = elements
        self.currentIndex = elements.count - 1
    }
    
    mutating func next() -> T? {
        guard currentIndex >= 0 else { return nil }
        let element = elements[currentIndex]
        currentIndex -= 1
        return element
    }
}

// 2. 实现Sequence协议:提供遍历能力
struct ReverseSequence<T>: Sequence {
    private var elements: [T]
    
    init(elements: [T]) {
        self.elements = elements
    }
    
    func makeIterator() -> ReverseIterator<T> {
        ReverseIterator(elements: elements)
    }
}

// 使用:支持for...in循环
let numbers = [1,2,3,4,5]
let reverseSequence = ReverseSequence(elements: numbers)
for num in reverseSequence {
    print(num) // 输出:5 4 3 2 1
}

3. associatedtype 关联类型

重点说明

  • 核心定义:协议中的占位类型,协议实现时才指定具体类型,实现协议的泛型能力,是面向协议编程的核心特性。
  • 核心场景:协议中需要使用多个关联的泛型类型,保证类型安全,避免Any类型滥用。

代码示例

// 带关联类型的协议
protocol Stackable {
    // 定义关联类型,实现时指定具体类型
    associatedtype Element
    mutating func push(_ element: Element)
    mutating func pop() -> Element?
    var topElement: Element? { get }
}

// 泛型实现:自动推断关联类型
struct GenericStack<T>: Stackable {
    private var elements: [T] = []
    
    mutating func push(_ element: T) {
        elements.append(element)
    }
    
    mutating func pop() -> T? {
        elements.popLast()
    }
    
    var topElement: T? {
        elements.last
    }
}

// 使用
var stringStack = GenericStack<String>()
stringStack.push("Swift")
stringStack.push("iOS")
print(stringStack.topElement ?? "空") // iOS

4. Protocol Extension 协议扩展

重点说明

  • 核心定义:给协议添加扩展,提供方法、计算属性的默认实现,是Swift实现可选协议、功能横向复用、面向协议编程的核心。
  • 核心价值:无需继承即可给多个类型添加统一功能;实现协议可选方法,无需@objc限制,支持struct/enum。

代码示例

// 协议定义
protocol Runnable {
    func run()
    func stop()
}

// 协议扩展:提供默认实现
extension Runnable {
    func run() { print("默认的跑步行为") }
    func stop() { print("默认的停止行为") }
    // 扩展新增方法,所有遵循者自动获得
    func warmUp() { print("热身准备") }
}

// 遵循协议,无需实现任何方法,自动获得默认实现
struct Person: Runnable {}

// 重写默认实现
class Car: Runnable {
    func run() { print("汽车启动行驶") }
    func stop() { print("汽车刹车停止") }
}

// 使用
let person = Person()
person.warmUp() // 热身准备
person.run() // 默认的跑步行为

let car = Car()
car.run() // 汽车启动行驶
car.warmUp() // 热身准备

// 实现可选协议:替代@objc可选方案
protocol OptionalProtocol {
    func necessaryMethod() // 必须实现
    func optionalMethod()  // 可选,扩展提供默认实现
}

extension OptionalProtocol {
    func optionalMethod() {}
}

// 只需实现必须方法,可选方法自动获得默认实现
struct ProtocolImplementer: OptionalProtocol {
    func necessaryMethod() {
        print("必须实现的方法")
    }
}

三、可选类型与安全编程

1. 可选链 Optional Chaining

重点说明

  • 核心定义:通过?在可选值上链式调用属性、方法、下标,可选值为nil时,整个链式调用直接返回nil,不会崩溃,替代多层强制解包。
  • 注意:可选链的返回值始终是可选类型,即使调用的属性/方法返回非可选类型。

代码示例

// 定义嵌套模型
class Address {
    var city: String?
    var detail: String?
}

class User {
    var name: String
    var address: Address?
    
    init(name: String) {
        self.name = name
    }
}

// 可选链使用
var user: User? = User(name: "张三")
// 1. 链式访问属性
let city = user?.address?.city
// 类型:String?,任意一层为nil直接返回nil
print(city ?? "地址为空") // 地址为空

// 2. 安全赋值:可选值不为nil时才执行
user?.address = Address()
user?.address?.city = "成都"
let newCity = user?.address?.city
print(newCity ?? "地址为空") // 成都

// 3. 多层解包
if let detail = user?.address?.detail {
    print("详细地址:\(detail)")
}

2. 多重 Optional

重点说明

  • 核心定义:Optional类型本身可作为另一个Optional的包装值,形成嵌套可选类型(如String??),本质是Optional枚举的嵌套。
  • 核心坑点:直接判空!= nil只能判断外层是否为nil;字面量nil赋值给多重可选是外层为nil,把nil的可选值赋值给多重可选是外层.some、内层.none。

代码示例

// 多重可选的核心区别
var aNil: String? = nil // 单层nil
var anotherNil: String?? = aNil // 外层.some,内层.none
var literalNil: String?? = nil // 外层直接.none

// 判空区别
if anotherNil != nil {
    print("anotherNil 外层不为nil") // 会执行
}
if literalNil != nil {
    print("literalNil 外层不为nil") // 不会执行
}

// 正确解包方式
// 1. 多层if let
if let outer = anotherNil, let inner = outer {
    print("解包成功:\(inner)")
} else {
    print("内层为nil")
}

// 2. 空合运算符直接解包
let finalValue = anotherNil ?? "默认值"
print(finalValue) // 默认值

// 3. switch模式匹配
switch anotherNil {
case .some(.some(let value)):
    print("有值:\(value)")
case .some(.none):
    print("外层有值,内层nil")
case .none:
    print("外层直接nil")
}

3. Optional Map 与 flatMap

重点说明

  • map:对可选值的非nil值进行转换,nil则直接返回nil,闭包返回非可选类型。
  • flatMap:用于转换后仍是可选值的场景,自动解包一层,避免生成多重可选。

代码示例

// Optional Map
let optionalNum: Int? = 5
let mapResult = optionalNum.map { num in
    "数字:\(num)"
}
// 类型:String?,optionalNum为nil时直接返回nil
print(mapResult ?? "空") // 数字:5

// flatMap:避免多重可选
let strNum: String? = "123"
// map转换后生成Int??双重可选
let mapDoubleOptional = strNum.map { Int($0) }
// flatMap自动解包一层,返回Int?
let flatMapResult = strNum.flatMap { Int($0) }
print(flatMapResult ?? "转换失败") // 123

4. ?? 空合运算符

重点说明

  • 核心定义:可选值非nil则解包返回,nil则返回后面的默认值,底层基于@autoclosure实现,默认值延迟执行。
  • 核心价值:替代三目运算符,简化可选值的默认值处理,支持链式使用。

代码示例

// 基础使用
let optionalStr: String? = "Swift"
let result = optionalStr ?? "默认值"
// 类型:String,非可选
print(result) // Swift

// 链式使用
let level1: String?? = nil
let level2: String?? = "第二层值"
let finalResult = level1 ?? level2 ?? "最终默认值"
print(finalResult) // 第二层值

// 延迟执行特性:默认值仅在nil时执行
func getDefaultValue() -> String {
    print("执行了默认值方法")
    return "默认值"
}

let hasValue: String? = "有值"
let test1 = hasValue ?? getDefaultValue()
// 不会执行getDefaultValue,无打印

四、语法糖与基础类型增强

1. Tuple 多元组

重点说明

  • 核心定义:将多个不同类型的值组合成一个复合值,无需提前定义结构体/类,是轻量级数据组合方案。
  • 核心场景:多返回值、变量交换、多值绑定、函数参数简化。

代码示例

// 1. 基础定义
// 命名元素多元组
let userInfo = (name: "张三", age: 20, city: "成都")
print(userInfo.name) // 张三
print(userInfo.age) // 20

// 2. 变量交换:无需临时变量
var a = 10, b = 20
(a, b) = (b, a)
print(a, b) // 20 10

// 3. 函数多返回值
func calculateStats(_ numbers: [Int]) -> (sum: Int, average: Double, max: Int)? {
    guard !numbers.isEmpty else { return nil }
    let sum = numbers.reduce(0, +)
    let average = Double(sum) / Double(numbers.count)
    return (sum, average, numbers.max()!)
}

if let stats = calculateStats([1,2,3,4,5]) {
    print("总和:\(stats.sum),平均值:\(stats.average)")
}

2. 自定义操作符

重点说明

  • 核心定义:Swift支持自定义中缀、前缀、后缀操作符,扩展运算符功能,需先声明操作符,再实现对应的函数。
  • 注意:避免滥用导致代码可读性下降,自定义操作符需符合语义。

代码示例

import Foundation

// 1. 自定义正则匹配中缀操作符 =~
infix operator =~: ComparisonPrecedence
func =~ (input: String, pattern: String) -> Bool {
    guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else {
        return false
    }
    let range = NSRange(input.startIndex..., in: input)
    return regex.firstMatch(in: input, range: range) != nil
}

// 使用:邮箱校验
let mailPattern = "^([a-z0-9_\\.-]+)@([\\da-z\\.-]+)\\.([a-z\\.]{2,6})$"
let isMail = "test@example.com" =~ mailPattern
print(isMail) // true

// 2. 自定义前缀平方操作符 **
prefix operator **
prefix func ** (num: Int) -> Int {
    num * num
}
let square = **5 // 25

3. 下标 subscript

重点说明

  • 核心定义:给类、结构体、枚举定义下标,通过下标语法快速访问集合元素,支持多参数、重载、只读/读写。
  • 核心场景:自定义集合类型、安全数组访问、快捷模型属性访问。

代码示例

// 安全数组:避免下标越界崩溃
struct SafeArray<T> {
    private var elements: [T]
    
    init(elements: [T]) {
        self.elements = elements
    }
    
    // 基础下标:越界返回nil,不崩溃
    subscript(index: Int) -> T? {
        get {
            guard index >= 0, index < elements.count else { return nil }
            return elements[index]
        }
        set {
            guard let newValue = newValue, index >= 0, index < elements.count else { return }
            elements[index] = newValue
        }
    }
    
    // 重载下标:范围访问
    subscript(range: ClosedRange<Int>) -> [T] {
        let start = max(range.lowerBound, 0)
        let end = min(range.upperBound, elements.count - 1)
        guard start <= end else { return [] }
        return Array(elements[start...end])
    }
}

// 使用
let array = SafeArray(elements: [1,2,3,4,5])
print(array[2] ?? "越界") // 3
print(array[10] ?? "越界") // 越界,无崩溃
print(array[1...3]) // [2,3,4]

4. 区间运算符与模式匹配

重点说明

  • 区间运算符:...闭区间(包含首尾)、..<半开区间(包含首不包含尾),支持单侧区间,用于遍历、切片、范围判断。
  • 模式匹配:Swift强大的语法特性,支持元组、可选值、枚举、范围、类型匹配,核心基于switch,也可用于if case/for case

代码示例

// 区间运算符
let numbers = [1,2,3,4,5,6,7,8,9,10]
let slice1 = numbers[2...5] // [3,4,5,6]
let slice2 = numbers[2..<5] // [3,4,5]
let slice3 = numbers[5...] // [6,7,8,9,10]

// 模式匹配:switch范围判断
let score = 85
switch score {
case 0..<60: print("不及格")
case 60..<80: print("及格")
case 80...100: print("优秀")
default: print("无效分数")
}

// 可选值模式匹配
let optionalNum: Int? = 5
if case let num? = optionalNum, num > 0 {
    print("正数:\(num)")
}

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

五、类与结构体初始化规则

1. Designated、Convenience、Required 初始化

重点说明

  • 指定初始化(Designated):类的主初始化方法,必须初始化所有未赋值的存储属性,调用父类的指定初始化方法。
  • 便利初始化(Convenience):辅助初始化方法,必须调用同类的指定初始化方法,提供便捷的初始化入口。
  • 必须初始化(Required):强制子类必须重写的初始化方法,子类重写时必须加required,无需override
  • 核心规则:便利初始化必须横向调用,指定初始化必须纵向调用父类方法。

代码示例

// 父类
class Person {
    var name: String
    var age: Int
    
    // 指定初始化
    init(name: String, age: Int) {
        self.name = name
        self.age = age
        super.init()
    }
    
    // 便利初始化
    convenience init(name: String) {
        self.init(name: name, age: 18)
    }
    
    // 必须初始化
    required init() {
        self.name = "未知"
        self.age = 0
        super.init()
    }
}

// 子类
class Student: Person {
    var studentID: String
    
    // 子类指定初始化
    init(name: String, age: Int, studentID: String) {
        // 先初始化子类属性,再调用父类初始化
        self.studentID = studentID
        super.init(name: name, age: age)
    }
    
    // 重写必须初始化
    required init() {
        self.studentID = "000000"
        super.init()
    }
}

2. 可失败初始化(init?)

重点说明

  • 核心定义:初始化方法可返回nil,用init?()声明,在初始化过程中进行参数校验,校验失败返回nil,中断初始化。
  • 注意:Swift 5+ 支持在属性初始化完成前提前返回nil。

代码示例

class User {
    let name: String
    let age: Int
    
    init?(name: String, age: Int) {
        // 参数校验,失败返回nil
        guard !name.isEmpty else { return nil }
        guard age >= 0, age <= 150 else { return nil }
        // 校验通过,初始化属性
        self.name = name
        self.age = age
    }
}

// 使用
let validUser = User(name: "张三", age: 20) // 非nil
let invalidUser = User(name: "", age: 20) // nil

六、类型系统与核心关键字

1. static 与 class

重点说明

  • static:可用于class、struct、enum、protocol,定义的方法/属性不能被子类重写,静态绑定。
  • class:仅用于class类型,定义的方法/计算属性可被子类重写,动态绑定;不能用于存储属性。

代码示例

class Animal {
    // static存储属性:不可重写
    static let species = "动物"
    // class计算属性:可重写
    class var typeName: String { "Animal" }
    
    // static方法:不可重写
    static func breathe() { print("动物需要呼吸") }
    // class方法:可重写
    class func makeSound() { print("动物发出声音") }
}

class Cat: Animal {
    // 重写class计算属性
    override class var typeName: String { "Cat" }
    // 重写class方法
    override class func makeSound() { print("猫发出喵喵声") }
}

// 调用
print(Animal.typeName) // Animal
print(Cat.typeName) // Cat
Cat.makeSound() // 猫发出喵喵声

2. 属性观察 willSet / didSet

重点说明

  • 核心定义:监听存储属性的值变化,willSet在值变化前触发,didSet在值变化后触发,初始化时不会触发,仅赋值时触发。
  • 核心场景:数据绑定、值校验、UI更新、状态同步。

代码示例

class User {
    var name: String {
        willSet(newName) {
            print("即将把名字从\(name)改为\(newName)")
        }
        didSet(oldName) {
            print("已经把名字从\(oldName)改为\(name)")
            if name.isEmpty { name = oldName } // 空值恢复
        }
    }
    
    var age: Int {
        didSet {
            // 年龄自动修正
            if age < 0 { age = 0 }
            else if age > 150 { age = 150 }
        }
    }
    
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

// 使用
let user = User(name: "张三", age: 20)
user.name = "李四" // 触发属性观察
user.age = 200 // 自动修正为150

3. lazy 懒加载修饰符

重点说明

  • 核心定义:延迟存储属性的初始化,只有在属性第一次被访问时,才会执行初始化代码,仅执行一次。
  • 核心特性:只能用于var变量,初始化时可访问self,线程不安全。
  • 核心场景:耗时初始化、依赖其他属性的初始化、不常用的属性,提升初始化性能。

代码示例

class DataManager {
    // 普通属性:初始化时立即创建
    let createTime = Date()
    
    // lazy属性:第一次访问时才初始化
    lazy var fileManager: FileManager = {
        print("执行fileManager初始化")
        return FileManager.default
    }()
    
    // lazy属性:依赖其他属性
    lazy var documentPath: String = {
        print("执行documentPath初始化")
        return fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.path
    }()
    
    init() {
        print("DataManager初始化完成")
    }
}

// 使用
let manager = DataManager()
// 输出:DataManager初始化完成,lazy属性未初始化
_ = manager.fileManager
// 输出:执行fileManager初始化

4. 元类型、.self 与 AnyClass

重点说明

  • 元类型:类型的类型,用Type表示,如String.Type,代表类型本身。
  • AnyClassAnyObject.Type的别名,代表任意类的元类型。
  • .self:类型.self获取元类型,实例.self返回实例本身。
  • 核心场景:动态创建实例、工厂模式、runtime动态操作。

代码示例

// 获取元类型
let stringType: String.Type = String.self
print(stringType) // String

// AnyClass与动态创建实例
class Person {
    required init() {}
    func sayHello() { print("Hello") }
}

class Student: Person {
    override func sayHello() { print("Student Hello") }
}

let personClass: AnyClass = Person.self
let studentClass: AnyClass = Student.self

func createInstance(_ classType: AnyClass) -> Person? {
    guard let type = classType as? Person.Type else { return nil }
    return type.init()
}

let student = createInstance(studentClass)
student?.sayHello() // Student Hello

七、进阶特性

1. Reflection 与 Mirror

重点说明

  • 核心定义:Swift的反射机制,通过Mirror在运行时获取、遍历实例的所有属性信息,是Swift少有的运行时自省能力。
  • 核心特性:只读反射,支持struct、class、enum、tuple,无法修改属性值。
  • 核心场景:模型转字典、JSON序列化、对象属性打印。

代码示例

// 模型定义
struct User {
    let name: String
    let age: Int
    let isVip: Bool
}

// 模型转字典通用方法
func modelToDictionary(_ model: Any) -> [String: Any] {
    let mirror = Mirror(reflecting: model)
    var dict: [String: Any] = [:]
    for (propertyName, propertyValue) in mirror.children {
        guard let propertyName = propertyName else { continue }
        dict[propertyName] = propertyValue
    }
    return dict
}

// 使用
let user = User(name: "张三", age: 20, isVip: true)
let userDict = modelToDictionary(user)
print(userDict) // ["name": "张三", "age": 20, "isVip": true]

2. 正则表达式

重点说明

  • Swift语言层面未内置正则表达式,基于FoundationNSRegularExpression实现,可通过封装简化调用。
  • 注意:Swift字符串的range需基于utf16转换,避免中文/表情符号的range错误。

代码示例

import Foundation

// 正则工具类
struct RegexHelper {
    private let regex: NSRegularExpression
    
    init(pattern: String, options: NSRegularExpression.Options = .caseInsensitive) throws {
        self.regex = try NSRegularExpression(pattern: pattern, options: options)
    }
    
    // 匹配是否成功
    func isMatch(_ input: String) -> Bool {
        let range = NSRange(input.startIndex..., in: input)
        return regex.firstMatch(in: input, range: range) != nil
    }
    
    // 提取匹配结果
    func matches(_ input: String) -> [String] {
        let range = NSRange(input.startIndex..., in: input)
        let matches = regex.matches(in: input, range: range)
        return matches.compactMap {
            guard let range = Range($0.range, in: input) else { return nil }
            return String(input[range])
        }
    }
}

// 常用正则枚举
enum RegexPattern: String {
    case email = "^([a-z0-9_\\.-]+)@([\\da-z\\.-]+)\\.([a-z\\.]{2,6})$"
    case phone = "^1[3-9]\\d{9}$"
}

// 便捷扩展
extension String {
    func isMatch(pattern: RegexPattern) -> Bool {
        guard let helper = try? RegexHelper(pattern: pattern.rawValue) else {
            return false
        }
        return helper.isMatch(self)
    }
}

// 使用
let isEmail = "test@example.com".isMatch(pattern: .email) // true
let isPhone = "13800138000".isMatch(pattern: .phone) // true

3. 命名空间

重点说明

  • Swift无原生命名空间关键字,通过无case枚举+静态成员模拟命名空间,解决类名冲突,实现代码模块化,替代OC的前缀方案。

代码示例

// 模拟命名空间
enum MyApp {
    // 网络模块
    enum Network {
        static let baseURL = "https://api.myapp.com"
        static func request(url: String) {
            print("发起请求:\(url)")
        }
    }
    
    // UI模块
    enum UI {
        static let mainColor = "#FF0000"
        static func showToast(message: String) {
            print("显示Toast:\(message)")
        }
    }
}

// 使用
print(MyApp.Network.baseURL)
MyApp.UI.showToast(message: "操作成功")

// 解决类名冲突
enum ModuleA {
    class User { var name = "ModuleA User" }
}
enum ModuleB {
    class User { var name = "ModuleB User" }
}

let userA = ModuleA.User()
let userB = ModuleB.User()

八、OC混编与工程化特性

1. 条件编译

重点说明

  • 根据编译环境、平台、配置等条件选择性编译代码,是编译时执行,不符合条件的代码不会被编译到二进制中。
  • 常用条件:平台(os(iOS)/os(macOS))、模式(DEBUG/RELEASE)、Swift版本、自定义标记。

代码示例

// 调试/发布模式区分
#if DEBUG
let serverURL = "https://test-api.example.com"
#else
let serverURL = "https://api.example.com"
#endif

// 平台适配
#if os(iOS)
import UIKit
let screenWidth = UIScreen.main.bounds.width
#elseif os(macOS)
import AppKit
let screenWidth = NSScreen.main?.frame.width ?? 0
#endif

// Swift版本适配
#if swift(>=5.5)
print("支持async/await新特性")
#else
print("使用低版本兼容方案")
#endif

2. 编译标记 MARK / TODO / FIXME

重点说明

  • 代码标记注释,Xcode会在导航栏中显示,用于代码分块、标记待办事项、待修复问题,提升代码可维护性。
  • 核心类型:
    • // MARK::代码分块,加-生成分隔线
    • // TODO::待办事项
    • // FIXME::待修复的bug

代码示例

import UIKit

class ViewController: UIViewController {
    
    // MARK: - 生命周期
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }
    
    // MARK: - UI设置
    private func setupUI() {
        view.backgroundColor = .white
        // TODO: 添加导航栏按钮
        // FIXME: 适配iPhone SE布局
    }
    
    // MARK: - 事件处理
    @objc private func buttonClick() {
        // TODO: 实现按钮点击逻辑
    }
}

3. @objc、dynamic 与 Selector

重点说明

  • @objc:将Swift的类、方法、属性暴露给OC runtime,支持OC代码调用、runtime特性(KVO、target-action)。
  • dynamic:强制方法/属性使用OC runtime动态派发,支持方法交换、动态修改。
  • Selector:OC的方法选择器,Swift中用#selector()创建,用于动态方法调用、target-action。

代码示例

import UIKit

@objc class SwiftPerson: NSObject {
    @objc var name: String
    @objc var age: Int
    
    init(name: String, age: Int) {
        self.name = name
        self.age = age
        super.init()
    }
    
    @objc func sayHello() {
        print("Hello,我是\(name)")
    }
    
    // dynamic标记:动态派发
    @objc dynamic func dynamicMethod() {
        print("原始动态方法")
    }
}

// Selector动态调用
let person = SwiftPerson(name: "张三", age: 20)
let selector = #selector(SwiftPerson.sayHello)
person.perform(selector) // 输出:Hello,我是张三

// target-action使用
let button = UIButton()
button.addTarget(person, action: #selector(SwiftPerson.sayHello), for: .touchUpInside)

SwiftUI 如何使用 UIKit 组件

先理解问题是什么

现实情况是:SwiftUI 原生组件不够用。很多组件SwiftUI 自己没有直接提供,但 UIKit 里有。

那怎么办?苹果提供了一个"桥接协议":UIViewRepresentable


UIViewRepresentable 是什么

它是一个协议(Protocol) ,作用是:

把一个 UIKit 的 UIView包装成 SwiftUI 能认识的 View

你可以把它理解成一个翻译官,SwiftUI 和 UIKit 说的不是同一种语言,UIViewRepresentable 负责在中间翻译。

SwiftUI 世界          翻译官                    UIKit 世界
─────────────    ──────────────────────    ──────────────────
  some View  ←→  UIViewRepresentable  ←→   UIView(任意)

它要求你实现两个方法

protocol UIViewRepresentable {
    // 方法一:创建 UIKit 视图(只调用一次)
    func makeUIView(context: Context) -> 某种UIView
    
    // 方法二:更新 UIKit 视图(状态变化时调用)
    func updateUIView(_ uiView: 某种UIView, context: Context)
}

就这两个,不多(有没有想到什么,OC的NSProxy 是不是也是实现两个方法,虽然八杆子打不着,但是突然想到了)。

  • makeUIView → 负责初始化,相当于 viewDidLoad,只跑一次
  • updateUIView → 负责同步状态,SwiftUI 的数据变了,你要在这里手动更新 UIKit 视图

我写了一个BlurView,早期SwiftUI background不支持毛玻璃效果

struct BlurView: UIViewRepresentable {
    let style: UIBlurEffect.Style   // ← 从 SwiftUI 传进来的参数
    
    // 第一步:创建真实的 UIKit 视图
    func makeUIView(context: Context) -> some UIView {
        let view = UIView(frame: .zero)
        view.backgroundColor = .clear
        
        // 这才是核心:UIKit 的毛玻璃视图
        let blurEffect = UIBlurEffect(style: style)
        let blurView = UIVisualEffectView(effect: blurEffect)
        
        // 用 AutoLayout 让它撑满父视图
        blurView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(blurView)
        NSLayoutConstraint.activate([
            blurView.heightAnchor.constraint(equalTo: view.heightAnchor),
            blurView.widthAnchor.constraint(equalTo: view.widthAnchor)
        ])
        
        return view  // ← 把这个 UIKit 视图交给 SwiftUI 管理
    }
    
    // 第二步:状态更新时同步(这里暂时不需要做任何事)
    func updateUIView(_ uiView: UIViewType, context: Context) {
        // 如果 style 会动态变化,就在这里更新
    }
}

重点理解makeUIView 返回的那个 UIView,之后就由 SwiftUI 的布局系统接管了。你不需要手动设置 frame,SwiftUI 会帮你处理尺寸。


然后 View Extension 做了什么

extension View {
    func blurBackground(style: UIBlurEffect.Style) -> some View {
        ZStack {
            BlurView(style: style)  // ← UIKit 毛玻璃,铺在底层
            self                    // ← 原来的 SwiftUI 视图,叠在上层
        }
        //两个方法都行
        //self.background(BlurView(style: style))
    }
}

BlurView 在这里和任何 SwiftUI 原生 View 完全没有区别,可以直接放进 ZStack。这就是 UIViewRepresentable 的意义:让 UIKit 视图假装自己是 SwiftUI 视图


整体调用链是这样的

.blurBackground(style: .systemMaterial)
        ↓
    ZStack 叠加
   ┌────────────┐
   │  BlurView  │ ← UIViewRepresentable 在这里翻译
   │   (UIKit)  │   makeUIView() 被 SwiftUI 自动调用
   └────────────┘
        ↑
   self(原 SwiftUI 视图)叠在上面

什么时候用 UIViewRepresentable(有些SwfitUI 现在自己已经有了)

场景 推荐方案
毛玻璃、特效 UIVisualEffectViewUIViewRepresentable
地图 MKMapView → 或直接用 SwiftUI 的 Map
网页 WKWebViewUIViewRepresentable
富文本编辑 UITextViewUIViewRepresentable
相机预览 AVCaptureVideoPreviewLayerUIViewRepresentable
SwiftUI 能搞定的 直接用 SwiftUI,别绕弯子

还有一个兄弟协议:UIViewControllerRepresentable

如果你要包装的不是 UIView,而是整个 UIViewController(比如系统的图片选择器、分享弹窗),用这个:

struct ImagePickerView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIImagePickerController {
        return UIImagePickerController()
    }
    
    func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
        // 同上,同步状态用
    }
}

逻辑完全一样,只是把 UIView 换成了 UIViewController


总结

UIViewRepresentable 本质上就是:

实现两个方法(创建更新),让 SwiftUI 知道怎么驾驭一个 UIKit 视图

它解决的核心问题是:SwiftUI 和 UIKit 生命周期不同,这个协议负责在两套系统之间搭桥

BlurView 是一个非常标准的使用案例——SwiftUI 没有,UIKit 有,包一下,用上。

Swift 杀进 Android,Google 和 Apple 都要失眠了?

本文同步自微信公众号 “Android技术圈”

Swift 终于正式杀进 Android 了。 不是社区 Demo,不是民间魔改,而是 Swift 官方第一次亲自发布 Android SDK。 这件事现在看起来像一条技术新闻,再往后看,很可能会变成移动开发格局变化的起点。

先别急着喊 “Kotlin 要完了”。 但也别把它轻飘飘理解成 “Swift 又做了个跨平台实验”。 因为这次最狠的地方在于:Swift 官方第一次给出了进入 Android 工程体系的真实路径。

cover.png

官方到底宣布了什么

比“Apple 要不要抢 Android 地盘”更值得看的,其实是官方这次到底把支持范围推进到了哪一步。

Swift 官方博客对这件事的表述,其实非常克制,但也非常明确。核心就三点:

  • Swift 6.3 包含首个官方 Android Swift SDK
  • 可以开始用 Swift 开发原生 Android 程序
  • 可以通过 Swift JavaSwift Java JNI Core,把 Swift 代码集成进现有 Kotlin / Java Android 应用

这三句话加起来,真正说明的是:

Swift on Android,已经从社区探索,进入官方支持阶段。

这一步为什么重要?

因为过去你看到的很多 “Swift 跑 Android”,本质上都还是社区项目、实验性方案,或者少数团队自己打补丁维护的链路。

而这次不一样。 这次是 Swift 官方把 SDK、文档、安装方式、交叉编译路径和互操作方案一起摆上台面。

对开发者来说,差别非常大。

社区方案是“能折腾出来”。 官方方案才是“值得认真观察是否能进生产”。

02_ecosystem_bridge.png

别高估,也别低估

这类新闻最容易出现两个极端判断。

第一种是:
“Kotlin 要凉了,Android 以后可以全用 Swift 写。”

第二种是:
“没意义,不就是把命令行程序编到 Android 上吗?”

这两种都不准确。

从 Swift 官方文档来看,今天已经明确成立的是:

  • 你可以把 Swift 代码交叉编译到 Android
  • 你可以在 Android 设备或模拟器上运行 Swift 程序
  • 你可以把 Swift 模块构建成共享库
  • 你可以让现有 Kotlin / Java Android 应用去调用这些 Swift 代码

这意味着什么?

意味着 Swift 现在已经不是“只能在 Apple 生态里玩”的语言了。 但它也还远远没到“Android 主流团队明天集体切语言”的程度。

它真正有价值的地方,不是在口号层。 而是在工程层。

它第一次让 Swift 有机会进入 Android 的真实项目结构里。

真正值得关注的,是这条工程路径

Swift 官方的 Android 入门文档其实很务实,没有画大饼。

要跑起来,你得准备 3 样东西:

  • Swift 6.3 toolchain
  • Swift SDK for Android
  • Android NDK 27d 或更高版本

然后通过 swift build --swift-sdk ... 做 Android 目标的交叉编译。

官方演示的第一步,也不是完整 App,而是先把一个 Swift 可执行程序编译到 Android 上运行。

很多人看到这里会下意识地说:

“那不还是离真正 Android App 很远?”

但官方文档后面紧接着补了一句特别关键的话:

Swift 模块可以构建为共享库,并被打进 Android 应用,再由 Java / Kotlin 代码调用。

这句话,才是整件事的核心。

因为这说明 Swift on Android 的第一落点,并不是 UI 层,也不是整项目重写。 而是更现实、更符合团队采纳路径的地方:

  • 共享业务逻辑
  • 数据模型
  • 网络层
  • 算法模块
  • 某些性能敏感代码

说白了,Swift 现在不是先来抢 Compose 的饭碗。 它更像是先从“共享模块语言”这个位置切进去。

03_shared_logic_path.png

这件事为什么会让很多 iOS 团队心动

对纯 Android 团队来说,这条新闻现在更像观察项。 但对已经重度使用 Swift 的团队,这个信号就完全不一样了。

过去很多团队都会面临一个老问题:

“iOS 侧已经有一套成熟的 Swift 代码资产了,Android 还要不要重写一遍?”

如果这些资产只是 UI,那没办法,平台差异太大。 但如果是业务规则、网络协议、加解密逻辑、通用数据处理,重写本身就是重复劳动。

Swift 官方 Android SDK 的出现,至少让这类团队开始有了一个新的选项:

把 Swift 资产往 Android 端延伸,而不是永远困在 Apple 生态内部。

这点其实很关键。

因为它改变的不是某个 API,而是开发者对 Swift 的心理预期。

过去大家默认 Swift 是: “写 iPhone、写 Mac、写 Apple 设备。”

现在这个认知边界开始松了。

Swift 正在从 Apple 平台语言,继续往真正的跨平台语言走。

Kotlin 会不会被威胁

短期答案很直接:不会。

Kotlin 在 Android 的位置,不是一两条技术新闻能撼动的。 它背后是 Android 官方支持、Jetpack 生态、Android Studio、社区实践、招聘市场和海量线上项目。

Swift 6.3 现在拿出来的是“首个官方 Android SDK”。 这离大规模生产落地,中间还隔着很多现实问题:

  • 工具链稳定性怎么样
  • 桥接 Kotlin / Java 的成本高不高
  • 调试链路顺不顺
  • CI 能不能稳定接入
  • 团队愿不愿意为它付出学习和维护成本

所以更合理的判断不是“Swift 替代 Kotlin”。

而是:

  • Kotlin 仍然会是 Android 主语言
  • Swift 可能成为共享逻辑和多端模块的新候选
  • 真正决定它能不能站稳的,是工程成熟度,不是新闻热度

04_adoption_stages.png

这一步对 Swift 自己更重要

从更大的角度看,这次最受益的,也许不是 Android,而是 Swift 本身。

一门语言如果长期绑定单平台,它再强,外界对它的认知上限也很明确。

只有当它开始稳定支持更多平台,开发者才会真正把它当成一门独立的软件工程语言,而不是某家平台公司的附属工具。

Swift 6.3 这次最值得玩味的地方就在这里。

它不是一句空泛的“我们支持跨平台”。 而是给出了 SDK、文档、安装方式、构建路径、互操作方案。

这说明 Swift 官方现在回答的问题,已经不再只是:

“Swift 能不能把 Apple 生态服务好?”

而是:

Swift 能不能成为一门覆盖更多平台的软件工程语言?

Android,就是这个问题里最关键的一块拼图。

开发者现在该怎么看

如果你是 Android 开发者,不需要焦虑,也没必要跟风重写任何项目。

你更应该做的是持续观察这几个点:

  • Swift Android SDK 后续更新速度快不快
  • Swift Java 的接入体验到底顺不顺
  • 有没有团队先把 Swift 用到 Android 共享模块里
  • 工具链能不能稳定进入 CI 和生产流程

如果你是 iOS / Swift 开发者,这件事反而更值得持续盯住。

因为这可能是 Swift 在未来两三年里,最有战略意义的一次边界扩张。

它未必马上改变你的项目。 但它很可能会慢慢改变未来的技术选型。

写在最后

Swift 6.3 的重点,不是简单一句“Swift 能写安卓了”。

更准确的说法应该是:

Swift 官方第一次正式发布 Android SDK,并给出了进入现有 Android 工程的可执行路径。

这一步还远远谈不上改写 Android 生态。 但它已经足够说明一件事:

平台边界,还在继续变薄。

你看好 Swift 在 Android 上的发展吗? 你觉得它会先成为共享逻辑工具,还是最终走到更完整的 Android 开发场景?

欢迎评论区聊聊。 如果你身边正好有做 iOS、Android、跨平台架构的朋友,也欢迎把这篇文章转给他们,一起讨论。

参考资料:

  • Swift 官方博客:Swift 6.3 Released
  • Swift 官方文档:Getting Started with the Swift SDK for Android

一墙之隔,不同的时空 -- 肘子的 Swift 周报 #129

issue129.webp

一墙之隔,不同的时空

一年一度的 Let's Vision 大会在上海如期举行,今年的主题是:“Born to Create, Powered by AI”。除了与 Swift、空间计算相关的常规 Session,大会还邀请了许多开发者分享他们在工作中对 AI 的应用与理解。通过这些讲师对 AI 工作流的介绍,我也受益匪浅。原本只能容纳 300 人的 AI 主题会场,里三层外三层站满了热情高涨的观众。

然而,在众多优秀的 Session 中,一场由 YuChe Cheng 准备的、名为《Let's Create 1-liner Code in Swift》的演讲却将我的注意力引向了另一个会场。这究竟是一个怎样的话题?带着疑问我走了进去。作为一个 LeetCode 积分 2200+ 的开发者,YuChe Cheng 在演讲中展示了如何通过 Foundation 以及 Swift Algorithms 提供的大量高阶函数,将原本平淡无奇的 For-loop 代码,转换成更加优雅、美观、极具 Swift 风格的 Function Chaining(1-liner code),并在易读性与性能之间取得了很好的平衡。

看着幻灯片上的 Function Chaining 被一次又一次地优雅迭代,我有种茅塞顿开的畅快。整整 30 分钟的演讲,让我始终处于一种纯粹的兴奋之中——这种感觉,通常只在我绞尽脑汁最终攻克了一个难题,或是深刻理解了一个新概念后才会涌现。

尽管与主会场只有一墙之隔,但由于 AI 话题的绝对热度,本场演讲的听众明显偏少。与其说我为许多人错失了一场精彩演讲而感到遗憾,我真正担心的其实是:随着 AI 的进一步渗透,许多开发者原本在追求功能之外所赋予代码的那份“气质”,会不会就此消亡?

开发者不应该只关心编译后冷冰冰的二进制功能,代码本身也是个人风格的载体。它就像文章一样,在输出逻辑与结果之外,还承载着美学表达,体现着编写者的个人品味与巧思。

在今年的 Let's Vision 上,我感觉我们正站在一个时间的十字路口:我们是该一味追求 AI 带来的极致高效,还是在拥抱变化的同时,依然让属于开发者的那份骄傲与手艺,在 AI 时代得以保留?

本期内容 | 前一期内容 | 全部周报列表

本期推荐

Swift 6.3 Released

从 Swift 6 开始,语言演进已经稳定在半年一个 minor 版本的节奏,上周 Swift 6.3 如期发布。与前几个版本相比,这一版本并未引入明显的重磅特性,更多是对既有体系的打磨:并发模型在诊断准确性方面有所改进,新增的 @c 特性(attribute)进一步强化了 C/C++ 互操作能力,同时编译优化的控制粒度也变得更加细致。

尽管如此,这一版本也释放出一个清晰的信号:Swift 正在从“以 Apple 平台为中心的应用开发语言”,逐步向“具备跨平台与系统级能力的通用语言”演进。Embedded Swift、Android 支持的持续推进,以及 SwiftPM 构建体系的统一,都在指向这一方向。对多数 iOS 开发者而言,短期体感或许有限,但从更长的时间维度来看,这更像是一次为未来铺路的基础性更新。


如何在 Swift 中承接尚未稳定的 JSON (Designing a type-driven JSON in Swift)

当 API 契约尚未稳定、前后端对字段的理解又经常漂移时,Swift 的强类型系统反而会放大数据与 JSON 之间转换时的边界问题。Roman Niekipielov 在本文中介绍了一个刻意做小的 JSONValue 类型,用来承接这类过渡阶段的 JSON 数据。相比 [String: Any],它保留了更明确的类型结构;相比直接编写 Codable 模型,又更适合应对频繁变化的契约。这个实现并不试图替代正式模型,而是将不确定性暂时限制在边界层。


Swift 原生 AI Agent 开发实践系列

市面上有大量开发者使用 Python、TypeScript 开发 AI Agent,但 Chris Karani 认为,Swift 的并发模型天然更适合 Agent 的隔离与调度,强类型系统和宏功能也带来了额外的安全保证。他用 6 篇文章、从多个角度实践了这一观点——从统一多个 LLM Provider 的 SDK Conduit,到基于 Apple Foundation Models 的 Agent 运行时 Colony,再到用 Metal 加速的上下文记忆管理。如果你正在考虑在 Apple 平台上构建 AI 功能,这个系列是目前少见的完整原生方案。


Liquid Glass 设计工作坊 (Talking Liquid Glass with Apple)

Danny Bolella 在纽约参加了苹果举办的 Liquid Glass 设计工作坊,与设计团队和 SwiftUI 工程师进行了为期三天的深入交流。本次活动传递出非常明确的信号:Liquid Glass 并非过渡性尝试,而是苹果未来数年的设计方向,且将在后续工具链中成为默认前提。与此同时,苹果反复强调“层级(Hierarchy)”的重要性——界面应围绕内容构建,控件只是服务于内容的辅助元素,应尽量退居边缘,让信息本身成为视觉与交互的中心。除此之外,Danny 还在本文中记录了其他一些 SwiftUI 工程师给出的建议和技巧。本文记录的内容可以帮助你更早理解这场设计演进的节奏与方向。


App Store Connect 大更新 (Apple Dropped 100+ New Metrics. Your Competitors Are Already Using Them)

苹果对 App Store Connect 进行了近年来最大的一次更新,一口气引入了 100+ 官方指标、按来源划分的 cohort 分析、同行基准对比(转化率与单下载收益)以及可通过 API 导出的订阅数据。Jessica Chung 在本文中对这些关键变化进行了系统梳理。由于所有数据均来自苹果一手统计,这意味着开发者在 ASO 和增长决策中,将不再依赖第三方估算,而可以直接基于真实用户行为进行分析与优化。更重要的是,这次更新补齐了长期缺失的关键能力:你可以追踪不同关键词与渠道带来的用户质量,建立从曝光、下载到订阅与续费的完整转化链路,并通过同行基准明确自身所处位置。

本次更新对于开发者而言无疑是利好,但对于部分第三方 App Store 分析服务来说,也在一定程度上提高了竞争门槛,促使其提供更具附加值的能力。


Package Traits in Xcode

在创建 SPM 时,某些依赖可能只被特定 API 使用,但一旦用户引入该包,即便不使用这些 API,也需要一并引入相关依赖。Package Traits 正是为了解决这一问题而引入的,它为 SPM 提供了一种声明可选特性的方式,使使用者能够按需启用功能,从而避免引入不必要的依赖。遗憾的是,在该功能推出后,一直只能在社区版本的 Swift 工具链中使用。随着 Xcode 26.4 的发布,Package Traits 终于获得了苹果官方支持,有望迎来更广泛的应用。Matt Massicotte 在本文中对该特性进行了介绍,并展示了其基本用法。


优化感官性能,让用户感觉更快 (Why your SwiftUI app feels slow even though Instruments says it’s fine?)

用户投诉响应慢,一定是应用性能问题吗?Rafał Dubiel 将关注点从“实际性能”转向“感知性能(Perceived Performance)”,讨论如何通过界面反馈与交互节奏,让用户感觉应用“更快”。例如通过 skeleton view、延迟加载,以及合理的动画与状态过渡来掩盖等待时间。作者指出,在许多场景下,用户体验的关键并不在于减少毫秒级的计算时间,而在于是否及时提供反馈。相比单纯优化性能指标,这种从用户感知出发的思路,往往更直接地影响用户对应用流畅度的判断。


在 SwiftUI 中控制行高 (Adjusting line height in SwiftUI on iOS 26)

iOS 26 为 SwiftUI 新增了 lineHeight(_:) modifier,用于控制文本相邻两行基线之间的距离。Natalia Panferova 在本文中对各种配置方式进行了详细对比:内置预设(.loose.tight)、基于字号倍数的 .multiple(factor:)、固定增量的 .leading(increase:),以及绝对值控制的 .exact(points:)。此外,lineHeight(_:) 与已有的 lineSpacing(_:) 并不相同:前者控制基线间距,后者控制行底到下一行行顶的距离。

Natalia Panferova 曾是 Apple SwiftUI 核心团队成员,参与过多个关键 API 的设计与开发。本月她刚刚出版了新书 The SwiftUI Way,面向有一定 SwiftUI 经验的开发者,聚焦于生产环境中的模式选择、常见反模式识别,以及如何与框架“顺势而为”而非对抗。

工具

Cove:Swift 6 编写的 macOS 开源数据库客户端

Cove 是由 Emanuele Micheletti 开发的一款原生 macOS 数据库客户端,整个项目完全使用 Swift 6 构建,目前已经支持 PostgreSQL、MySQL、MariaDB、SQLite、MongoDB、Redis、ScyllaDB、Cassandra 和 Elasticsearch 等多种后端。它采用 SwiftUI 搭配 AppKit 原生控件实现,没有走 Electron 或 Web 技术栈,因此整体更轻量,也更符合 macOS 用户熟悉的交互体验。

相比“又一个数据库 GUI”,Cove 更值得关注的是它的实现思路。作者将所有数据库能力统一抽象为 DatabaseBackend 协议,UI 层不包含任何针对特定后端的分支逻辑。无论是 SQL 数据库、Redis 这类键值数据库,还是 MongoDB、Elasticsearch 这类非关系型后端,最终都会被整理为统一的表格模型交由界面渲染。项目目前仍处于 v0.1.0 的早期阶段,但已经具备查询、结构浏览、编辑、SSH 隧道和多标签等基础能力。即便你并不打算把它作为日常数据库工具,Cove 依然是一个很值得 Swift 开发者研究的桌面应用架构样本。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

❌