普通视图

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

碰到一个不听劝的老板,喜提4.3a!

作者 iOS研究院
2025年12月3日 00:45

背景

之前公司的老板,突然找到我想把之前的老包迭代一手。

说实话看在之前老板劝退给赔偿的份上,迭代一下赚点维护费也不是不行。但是迭代之前,特别强调了本身是兄弟来砍我类型的马甲包(没错,就是那个渣渣辉代言的游戏),而且还是AB的骚操作

如果不是必须要迭代,建议不动最安全。毕竟AB马甲包迭代 = 渡劫

再三确认,要迭代就谈好了费用直接开动。

内购端倪

之前也知道,老板对上架这块规则不懂。但是没有想到开局就暴击!

说实话内购定价能到2w和5w,我是万万没想到的。激进派看了都得说激进!

1.jpg

本着对迭代负责的态度,建议了定价不要这么离谱!毕竟我这边主动拿出来阴阳师王者荣耀晓之以理,动之以情。摆事实,讲道理!

2.jpg

讲了半天,没办法。还是觉得定价低了。最低也得来个5000元的充值档次。

拿人钱财替人消灾,既然老板愿意承担风险。倒也无所谓了。

e0c4c6e7ff8d5fddb19db201843e44ed.jpg

首轮被拒

不出意外的话,肯定是要出意外了! 苹果直接打回了4999元,定价的合理性。

于是又开始了新一轮的劝解,没办法。经过协商同意将4999元档次删除。其他的充值档次保留。

b5817f26b861a15b4495c8ae32e995df.jpg

第二轮被拒

其实提交的时候,已经预想到这种结局了。果然又是被拒了。苹果质疑1999元充值档次的合理性。

本来以为这时候的老板能够及时醒悟,奈何永远叫不醒一个装睡的人! 宁可撞南墙也不知悔改,`主打一个

不撞南墙不回头,不见棺材不掉泪!` 最后,劝不动。按照老板的意思,回复尝试。

3.jpg

5.jpg

4.3a 判定

好消息:金额苹果觉得没有问题了。

坏消息:没得玩,4.3a了。

其实遭遇4.3a,已经是意料之中的事情了。 这套马甲包代码,分别上架了海外和国内。(因为都是我上的,所以我门清。)

6.jpg

7.jpg

总结

所以,专业的人做专业的事儿。 外行的人,要多听劝。

之所以金额是1999元和4999元,是因为苹果充值档次没有2000元和5000元的档次。

4.3a 垃圾应用 和 2.3.1 隐藏功能,都是因为不懂规则的人过度自以为是。没有吃过合规化上架的苦,把偶尔一两次的侥幸,当作区区AppStore不过如此的谈资。

截至发稿,已将产品通过审核。重新规划功能模块,顺利解决。

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

相关推荐

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

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

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

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

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

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

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

iOS MMKV原理整理总结:比UserDefaults快100倍的存储方案是如何炼成的?

作者 sweet丶
2025年12月2日 23:41

作为iOS开发者,你一定体验过NSUserDefaults在频繁读写时的性能瓶颈,它在卡顿列表和WatchDog导致的0x8badf0d类型的abort列表中经常看到,而今天要介绍的MMKV,以其独特的mmap内存映射和增量更新策略,实现了令人惊艳的存储性能提升,且替代NSUserDefaults后能解决导致的卡顿和abort问题。

一、传统存储方案的痛点

在介绍MMKV之前,让我们先看看iOS开发者常用的几种本地存储方案:

  • NSUserDefaults:适合简单键值对,但性能瓶颈明显,每次写入都需同步到文件
  • SQLite:功能强大但使用复杂,对简单键值存储来说太重了
  • Core Data:面向对象但学习曲线陡峭,性能调优困难
  • 直接文件操作:灵活但需要处理并发、安全、序列化等各种问题

这些方案在存储频繁读写的小数据时,要么性能不足,要么使用过于复杂。那么,有没有一种既简单又高效的键值存储方案呢?

二、MMKV的惊艳表现

MMKV是腾讯开源的一款高性能键值存储组件,最初为微信开发,用于解决跨平台、高性能的本地存储需求。让我们通过一个简单测试来看它的性能优势:

// 性能对比测试
let testCount = 1000

// NSUserDefaults测试
let defaults = UserDefaults.standard
let startTime1 = CACurrentMediaTime()
for i in 0..<testCount {
    defaults.set("value\(i)", forKey: "key\(i)")
    defaults.synchronize() // 强制同步到文件
}
let userDefaultsTime = CACurrentMediaTime() - startTime1

// MMKV测试
let mmkv = MMKV.default()!
let startTime2 = CACurrentMediaTime()
for i in 0..<testCount {
    mmkv.set("value\(i)", forKey: "key\(i)")
}
let mmkvTime = CACurrentMediaTime() - startTime2

print("NSUserDefaults耗时:\(userDefaultsTime)秒")
print("MMKV耗时:\(mmkvTime)秒")
print("MMKV比NSUserDefaults快\(userDefaultsTime/mmkvTime)倍")

测试结果显示,在1000次连续写入的场景下,MMKV可以比NSUserDefaults快几十到上百倍。那么,这种惊人性能是如何实现的呢?

三、MMKV核心技术原理揭秘

1. 内存映射(mmap)技术

MMKV性能的核心秘诀在于使用了mmap(memory mapping) 技术。传统的文件写入流程是这样的:

  1. 数据写入应用层缓冲区
  2. 数据拷贝到内核缓冲区
  3. 内核将数据写入磁盘

这个过程涉及两次数据拷贝和多次用户态/内核态切换,效率较低。

而mmap的工作方式完全不同:

// mmap的基本使用方式
int fd = open(filePath, O_RDWR|O_CREAT, S_IRWXU);
void *memory = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);

mmap将磁盘文件直接映射到进程的虚拟内存空间,使应用可以像操作内存一样操作文件。对映射内存的读写操作,操作系统会在后台自动同步到磁盘文件。

mmap的优势

  • 零拷贝:数据无需在用户空间和内核空间之间来回拷贝
  • 延迟写入:修改操作先写入内存,由操作系统异步刷盘
  • 崩溃安全:即使应用崩溃,操作系统也能保证数据持久化

2. 增量更新与追加写入策略

传统键值存储(如NSUserDefaults)每次写入时,都会重新序列化整个字典并写入文件。而MMKV采用了一种聪明的增量更新策略:

初始文件内容:[KV1, KV2, KV3]

更新key1的值: 传统方式:重新写入[KV1_new, KV2, KV3]

MMKV方式:直接在文件末尾追加[KV1_new]

文件变为:[KV1, KV2, KV3, KV1_new]

读取时,MMKV会从后向前扫描,最后出现的键值对就是最新值

3. Protocol Buffers序列化

MMKV使用Google的Protocol Buffers作为序列化协议,相比传统的JSON或Property List格式:

  • 编码更紧凑:二进制格式,体积更小
  • 解析更快:无需复杂的词法分析
  • 向后兼容:支持字段增减而不破坏现有数据
// Protobuf的消息定义
message KVItem {
    optional string key = 1;
    optional bytes value = 2;
}

4. 空间重整与扩容机制

追加写入策略的一个明显问题是文件会无限增长。MMKV通过空间重整解决这个问题:

class MMKV {
    func fullWriteback() {
        // 1. 收集所有键值对,每个key只保留最新值
        var newData = [String: Data]()
        for (key, value) in allKeyValues() {
            newData[key] = value.last // 只保留最新值
        }
        
        // 2. 重新序列化所有数据
        let serializedData = serialize(newData)
        
        // 3. 如果空间不足,执行扩容
        if serializedData.count > currentSize {
            expand(calculateNewSize(serializedData.count))
        }
        
        // 4. 将整理后的数据写入文件开头
        writeToBeginning(serializedData)
        
        // 5. 截断文件,丢弃旧数据
        truncateFile(serializedData.count)
    }
}

当文件剩余空间不足时,MMKV会触发空间重整,如果重整后仍空间不足,则按两倍大小扩容。

5. 多进程与线程安全

MMKV通过文件锁和内存重映射实现多进程访问数据同步,这在iOS扩展(如Today Extension、Share Extension等)中特别有用:

// 多进程初始化
MMKV *mmkv = [MMKV mmkvWithID:@"shared_mmkv" 
                  cryptKey:cryptKey
                  mode:MMKVMultiProcess];

// 进程A写入数据
[mmkv setObject:@(42) forKey:@"shared_key"];

// 进程B读取数据(立即生效)
NSNumber *value = [mmkv getObjectOfClass:NSNumber.class 
                                  forKey:@"shared_key"];

内部通过文件锁(fcntl)读写锁(pthread_rwlock) 保证数据一致性:

  • 进程间同步:使用文件锁
  • 线程间同步:使用读写锁(读共享,写独占)

四、MMKV架构全貌

为了更好地理解MMKV各组件如何协同工作,让我们看一下其整体架构图:

graph TD
    A[应用层调用] --> B[MMKV C++ Core]
    
    B --> C{操作类型}
    C -->|读操作| D[Memory Map<br/>内存映射]
    C -->|写操作| E[Protobuf序列化]
    
    D --> F[查找算法]
    F --> G[从后向前扫描<br/>获取最新值]
    G --> H[返回数据给应用层]
    
    E --> I[追加写入]
    I --> J{空间检查}
    J -->|空间足够| K[写入mmap内存]
    J -->|空间不足| L[触发空间重整]
    
    L --> M[Key排重<br/>保留最新值]
    M --> N{重整后空间是否足够}
    N -->|是| O[写入文件头部]
    N -->|否| P[文件扩容<br/>2倍增长]
    P --> O
    
    K --> Q[操作系统异步刷盘]
    O --> Q
    
    R[CRC校验] --> S[数据完整性保证]
    Q --> S
    

从这个架构图可以看出,MMKV的设计哲学是:

  1. 读操作优先:通过内存映射和高效查找,实现O(n)复杂度读取
  2. 写操作优化:通过追加写入避免全量重写
  3. 空间智能管理:自动重整和扩容,平衡空间和性能
  4. 数据安全保障:CRC-32算法校验确保数据完整性,MMKV在文件末尾存储了一个循环冗余校验码。这个值是文件所有有效数据通过CRC算法计算出的一个摘要。

五、实际应用场景与最佳实践

1. 何时使用MMKV?

  • 用户偏好设置:主题、字体大小等配置
  • 登录状态与Token:需要快速读写的认证信息
  • 应用标记位:首次启动、功能引导完成状态
  • 轻量缓存:搜索历史、浏览记录等

2. 何时避免使用MMKV?

  • 大量结构化数据:考虑使用SQLite或Core Data
  • 大型二进制文件:如图片、视频,应使用文件系统直接存储
  • 需要复杂查询的数据:MMKV只支持键值查询

3. 使用示例

import MMKV

class SettingsManager {
    static let shared = SettingsManager()
    private let mmkv: MMKV
    
    private init() {
        // 初始化MMKV
        MMKV.initialize(rootDir: nil)
        mmkv = MMKV.default()!
        
        // 多进程共享(用于App Groups)
        // let groupDir = MMKV.initialize(rootDir: "your_app_group_path")
        // mmkv = MMKV(mmapID: "shared_settings", mode: .multiProcess)
    }
    
    // 存储用户设置
    func saveUserSettings(_ settings: UserSettings) {
        do {
            let data = try JSONEncoder().encode(settings)
            mmkv.set(data, forKey: "user_settings")
        } catch {
            print("保存设置失败: \(error)")
        }
    }
    
    // 读取用户设置
    func loadUserSettings() -> UserSettings? {
        guard let data = mmkv.data(forKey: "user_settings") else {
            return nil
        }
        
        do {
            return try JSONDecoder().decode(UserSettings.self, from: data)
        } catch {
            print("读取设置失败: \(error)")
            return nil
        }
    }
    
    // 存储简单值
    var darkModeEnabled: Bool {
        get { mmkv.bool(forKey: "dark_mode", defaultValue: false) }
        set { mmkv.set(newValue, forKey: "dark_mode") }
    }
    
    // 加密存储敏感数据
    func saveSecureToken(_ token: String) {
        if let cryptMMKV = MMKV(mmapID: "secure_storage", cryptKey: "your_encryption_key".data(using: .utf8)) {
            cryptMMKV.set(token, forKey: "auth_token")
        }
    }

    // 及时释放不用的MMKV实例
    func cleanupUnusedMMKV() {
        MMKV.close(mmapID: "temporary_storage")
    }
}

4. 性能优化技巧

  1. 批量写入:虽然MMKV单次写入很快,但批量操作仍可进一步优化
// 不好的做法:多次单独写入
for item in items {
    mmkv.set(item.value, forKey: item.key)
}

// 好的做法:批量处理
mmkv.beginTransaction()
defer { mmkv.commitTransaction() }
for item in items {
    mmkv.set(item.value, forKey: item.key)
}
  1. 合理选择存储类型
  • 简单类型直接存储(Bool、Int、String等)
  • 复杂对象使用JSON或Protobuf序列化
  • 大对象考虑拆分为多个键值存储

六、MMKV的局限性

虽然MMKV性能卓越,但也存在一些限制:

  1. 数据大小限制:单个MMKV实例文件不宜过大(建议不超过几MB)
  2. 无查询功能:只能按键查找,不支持条件查询
  3. iOS版本要求:需要iOS 9.0+
  4. 增加包体积:增加约200KB左右的二进制大小

七、未来展望

MMKV仍在持续演进,未来可能的发展方向包括:

  1. 压缩支持:对存储内容进行透明压缩
  2. 更智能的淘汰策略:自动清理过期数据
  3. 云同步集成:与iCloud等云服务无缝集成
  4. Swift原生API:提供更符合Swift习惯的接口

总结

MMKV通过巧妙结合mmap内存映射、Protobuf序列化和增量更新策略,实现了远超传统方案的存储性能。它的设计哲学是:为频繁读写的小数据场景提供极致优化

对于大多数iOS应用,MMKV是替代NSUserDefaults的理想选择,特别是在需要高性能、多进程共享或简单加密存储的场景。当然,选择存储方案时,还是要根据具体需求:小数据、高频读写选MMKV;结构化、复杂查询选SQLite;大文件选文件系统

希望这篇文章能帮助你深入理解MMKV的工作原理,并在实际项目中合理使用这一强大工具。如果你有任何问题或使用经验分享,欢迎在评论区留言交流。

昨天 — 2025年12月2日掘金 iOS

Swift ——详解Any、AnyObject、 AnyClass

作者 Haha_bj
2025年12月2日 17:36

在Swift 中,AnyAnyObjectAnyClass 是三个不同的类型,它们用于不同的场景,代表了不同的类型和用途。

一、Any

Any 是 Swift 中可以表示任何类型的类型,包含所有类型的实例。它可以是一个普通的类型、结构体、类、元组、函数、枚举,甚至是一个 Optional 类型的值。

  • Any 可以表示任何类型的实例。
  • 它是一个广泛的类型,几乎可以存放任何类型的值。
  • 使用时需要进行类型转换(Type Casting)才能访问原本的类型。
// 例
     var a : Any = 20
     a = true
     a = 3.2
     a = [2,3,4]
     a = NSObject()
     print(a)

二、AnyObject

AnyObject 是Any的子集, AnyObject是Swift 中表示所有类类型(Class Types)的类型。它只允许存储对象引用类型的值。它不能存储结构体、枚举或其他非类类型的值。

  • AnyObject 可以表示任何类类型的实例。
  • 它只能用于类实例,不能用于结构体、枚举或其他值类型。
  • AnyObject 类型的值进行操作时,通常需要进行类型转换才能使用其特定的属性和方法。
class Persion{
    
}
     var p : AnyObject
     p = Persion()
     if let p = p as? Persion{
     print(p)
 

AnyObject 用于存储类实例,例如 NSObject 或任何继承自类的对象。它不支持值类型或非类类型。

三、AnyClass

AnyClass 是一个表示类类型(Class Type)的特殊类型。它用于引用类类型本身,而不是类的实例。通过 AnyClass 可以访问类的元信息(比如元类)。

  • AnyClass 代表类类型本身,而不是类的实例。
  • 用来存储类类型(如 MyClass.self)的引用。
  • 通常用于动态地操作类类型或元类的元信息。
let classType: AnyClass = Persion.self
print(classType) // Persion

AnyClass 表示类的类型,不是类的实例。它通常用于处理类类型的元信息(如反射、类检查等)。

总结

  • Any 是最通用的类型,可以表示任何类型的实例。
  • AnyObject 仅用于类类型的实例,不能存储值类型。
  • AnyClass 用于表示类类型本身,用于获取类的元信息。

Swift 中的async和await

作者 Haha_bj
2025年12月2日 17:10

异步编程是指程序在执行任务时,不需要等待任务完成才能继续执行其他任务。传统的同步编程方式会导致程序等待某个操作完成(比如网络请求、磁盘读写等),直到任务完成后才会继续执行,可能会造成性能瓶颈。异步编程允许程序在等待某个操作时去执行其他任务,从而提高效率。

asyncawait 是 Swift 5.5 引入的用于处理异步编程的关键字,它们使得处理异步任务变得更加简单和直观。它们提供了一种新的方式来管理异步操作,相比传统的回调函数或闭包,async/await 更接近同步代码的写法,让代码更加易读和可维护。

一、asyncawait

  • async: 用于标记一个函数为异步函数。异步函数会返回一个 Task 类型,可以在执行时暂停,直到结果准备好。
  • await: 用于暂停函数的执行,直到异步操作完成并返回结果。

1、标记异步函数

使用 async 关键字来定义一个异步函数,表示这个函数包含异步操作,并且可能需要一些时间来执行。

func handleData() async ->String{
        
        // 模拟网络请求,慢处理等
        
        return "handle data"
    }

handleData()是一个异步函数,它返回一个字符串。函数内部的操作可能会是一个耗时操作,虽然这里没有具体的异步代码,但它表示这段代码可以用异步方式进行处理。

2、 调用异步函数

调用一个异步函数,必须在一个异步上下文中使用 await 关键字。await 会暂停当前的代码执行,直到异步函数返回结果。

/// 去调用异步任务
    func callAsync() async{
        /// 等待异常函数返回结果
        let result = await handleData()
        print(result)
    }

    func handleData() async ->String{
        // 模拟网络请求,慢处理等
        return "handle data"
    }

3 异步任务的创建

使用 Task 来创建异步任务。Task 允许你在异步上下文之外执行异步代码。

override func viewDidLoad() {
        super.viewDidLoad()
        print("1")
        Task {
            /// 等待异常函数返回结果
            let result = await handleData()
            print(result)
        }
        print("2")
    }

    func handleData() async ->String{
        // 模拟网络请求,慢处理等
        return "handle data"
    }
    
    // 打印信息 1 、 2 、 handle data

Task 是一个异步任务,它会自动创建一个新的异步上下文来执行异步代码。当你需要在不直接处于异步函数内部的地方执行异步代码时。

二、asyncawait 与传统的闭包回调对比

传统的异步编程中,我们可能会使用闭包来处理异步操作的回调

override func viewDidLoad() {
        super.viewDidLoad()
        
        handleData { data in
            print(data)
        }
       
    }
    func handleData(completion: @escaping(String) -> Void){
        DispatchQueue.main.async {
            let data = "handle data"
            completion(data)
        }
    }

使用 asyncawait 后,你可以这样写

 override func viewDidLoad() {
         super.viewDidLoad()
         Task {
             /// 等待异常函数返回结果
             let result = await handleData()
             print(result)
         }
     }

     func handleData() async ->String{
         // 模拟网络请求,慢处理等
         return "handle data"
     }

相比使用闭包,asyncawait 更加简洁、直观。

三、错误处理

异步函数中,错误处理通常使用 do-catch 语句来处理。你可以在异步函数中抛出错误,并使用 await 来捕获和处理它们

enum jlDataError: Error{
    case invalidData
}

 class ViewController: UIViewController {

     override func viewDidLoad() {
         super.viewDidLoad()
         Task {
             do {
                 let result = try await handleData()
                 print(result)
             } catch{
                 print("Error:\(error)")
             }
             
         }

     }

     func handleData() async throws ->String{
         
         let success = false
         if success == false{
             throw jlDataError.invalidData
         }
         // 模拟网络请求,慢处理等
         return "handle data"
     }
     
 }

上述代码中,handleData() 可能会抛出 DataError.invalidData 错误,调用它时需要使用 try await 来捕获和处理错误。

四、 并发执行多个异步任务

可以使用 async let 来并行执行多个异步任务,并且在最后获取它们的结果。这是一个非常强大的功能,尤其是当你需要同时处理多个异步操作时。

override func viewDidLoad() {
         super.viewDidLoad()
         Task {
             // 异步并发任务
             async let handleData1 = handleData1()
             // 异步并发任务
             async let handleData2 = handleData2()
             // 等待结果
             let r1 = await handleData1
             let r2 = await handleData2
             print(r1,r2)
             
         }

     }

     func handleData1() async  ->String{
        
         // 模拟网络请求,慢处理等
         return "handle data1"
     }
     
     func handleData2() async  ->String{
        
         // 模拟网络请求,慢处理等
         return "handle data2"
     }

在上面的代码中,handleData1handleData2 会并行执行,最终我们使用 await 来获取它们的结果。

总结

asyncawait 是处理异步操作的核心工具,它们通过提供一种类似同步代码的结构,使得异步编程更加简单和清晰。使用这些特性,你可以:

  • 简化代码,使其更加易读和维护。
  • 避免回调地狱(callback hell)和嵌套的闭包。
  • 更容易进行错误处理。
  • 使并发执行变得简单,减少手动管理异步任务的复杂度。

《Flutter全栈开发实战指南:从零到高级》- 20 -主题与国际化

2025年12月2日 09:44

引言

在移动应用开发中,用户体验个性化已成为基本要求,这里将深入探讨Flutter应用中的主题切换和国际化实现。掌握这些技能,让你的应用能够适应不同用户群体的视觉偏好和语言需求。 在这里插入图片描述

为什么需要主题与国际化?

Flutter提供了强大的主题和国际化支持,但很多开发者在实际项目中会遇到以下问题:

  • 如何实现丝滑的主题切换?
  • 如何管理动态主题配置?
  • 如何处理多语言资源?以及如何实现运行时语言切换?

本文将带着以上疑惑一一解答这些问题

一、主题系统

1.1 架构原理

Flutter的主题系统基于继承(Inheritance) 设计模式构建。让我们通过一个架构图来加深理解:

graph TB
    A[MaterialApp] --> B[ThemeData]
    B --> C[ColorScheme]
    B --> D[TextTheme]
    B --> E[Other Themes]
    C --> F[primaryColor]
    C --> G[secondaryColor]
    C --> H[surfaceColor]
    D --> I[headline1]
    D --> J[bodyText1]
    D --> K[caption]
    
    style1[Widget] -.-> B
    style2[Widget] -.-> B
    style3[Widget] -.-> B
    
    subgraph "Theme Scope"
        B
    end

核心原理ThemeData对象通过Theme widget在整个widget树中向下传递,任何子widget都可以通过Theme.of(context)获取当前主题数据。

1.2 主题配置

亮色主题
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '主题与国际化',
      // 默认亮色主题
      theme: ThemeData(
        // 使用ColorScheme定义颜色
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.blue, 
          brightness: Brightness.light, 
        ),
        // 主题配置
        textTheme: const TextTheme(
          displayLarge: TextStyle(
            fontSize: 32,
            fontWeight: FontWeight.bold,
            color: Colors.black87,
          ),
          bodyLarge: TextStyle(
            fontSize: 16,
            color: Colors.black87,
          ),
        ),
        // 组件主题配置
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
            padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(8),
            ),
          ),
        ),
        // 应用栏主题
        appBarTheme: const AppBarTheme(
          centerTitle: true,
          elevation: 2,
        ),
      ),
      home: const HomePage(),
    );
  }
}
暗色主题
// 暗色主题配置
ThemeData darkTheme = ThemeData(
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.blue,
    brightness: Brightness.dark, 
  ),
  // 暗色模式下的文本颜色需要调整
  textTheme: const TextTheme(
    displayLarge: TextStyle(
      fontSize: 32,
      fontWeight: FontWeight.bold,
      color: Colors.white70, 
    ),
    bodyLarge: TextStyle(
      fontSize: 16,
      color: Colors.white70,
    ),
  ),
  scaffoldBackgroundColor: Colors.grey[900], 
);

1.3 使用主题

在Widget中使用主题
class ThemedWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 获取当前主题
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;
    final textTheme = theme.textTheme;
    
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: colorScheme.primaryContainer, // 使用主题颜色
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '标题文本',
            style: textTheme.headlineMedium?.copyWith(
              color: colorScheme.onPrimaryContainer,
            ),
          ),
          const SizedBox(height: 8),
          Text(
            '老师的会计法律束带结发拉卡萨电极法啦束带结发。',
            style: textTheme.bodyLarge?.copyWith(
              color: colorScheme.onPrimaryContainer.withOpacity(0.8),
            ),
          ),
          const SizedBox(height: 16),
          // 使用主题化的按钮
          ElevatedButton(
            onPressed: () {},
            child: Text('主题按钮'),
          ),
        ],
      ),
    );
  }
}
自定义主题扩展

有时我们需要在主题中添加自定义属性,可以通过扩展ThemeExtension来实现:

// 1. 创建主题扩展类
@immutable
class CustomColors extends ThemeExtension<CustomColors> {
  const CustomColors({
    required this.success,
    required this.warning,
    required this.danger,
    required this.info,
  });

  final Color success;
  final Color warning;
  final Color danger;
  final Color info;

  @override
  ThemeExtension<CustomColors> copyWith({
    Color? success,
    Color? warning,
    Color? danger,
    Color? info,
  }) {
    return CustomColors(
      success: success ?? this.success,
      warning: warning ?? this.warning,
      danger: danger ?? this.danger,
      info: info ?? this.info,
    );
  }

  @override
  ThemeExtension<CustomColors> lerp(
    ThemeExtension<CustomColors>? other, 
    double t,
  ) {
    if (other is! CustomColors) {
      return this;
    }
    return CustomColors(
      success: Color.lerp(success, other.success, t)!,
      warning: Color.lerp(warning, other.warning, t)!,
      danger: Color.lerp(danger, other.danger, t)!,
      info: Color.lerp(info, other.info, t)!,
    );
  }
}

// 2. 在主题中使用
ThemeData(
  extensions: const <ThemeExtension<dynamic>>[
    CustomColors(
      success: Colors.green,
      warning: Colors.orange,
      danger: Colors.red,
      info: Colors.blue,
    ),
  ],
);

// 3. 在Widget中使用
final customColors = Theme.of(context).extension<CustomColors>()!;
Container(
  color: customColors.success,
  child: Text('成功状态', style: TextStyle(color: Colors.white)),
);

二、动态主题切换

2.1 状态管理方案选择

对于主题切换,我们需要一个全局状态管理方案。以下是几种常见方案的对比:

方案 优点 缺点 适用场景
Provider 官方推荐 需要一定学习成本 中小型应用
Riverpod 类型安全,编译时检查 概念较多,有一定学习成本 中大型应用
Bloc 状态管理规范 模板代码多 大型复杂应用
GetX 简单快捷 耦合度较高 快速开发

这里我们使用Provider,因为它是Flutter官方推荐且学习起来相对简单。

2.2 主题管理实现

步骤1:创建主题状态管理类
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

// 主题模式枚举
enum ThemeModeType {
  light,    // 亮色模式
  dark,     // 暗色模式
  system,   // 跟随系统
  custom,   // 自定义
}

// 主题管理器
class ThemeManager with ChangeNotifier {
  ThemeModeType _themeMode = ThemeModeType.system;
  ThemeData _lightTheme = _defaultLightTheme;
  ThemeData _darkTheme = _defaultDarkTheme;
  ThemeData? _customTheme;
  
  // 亮色主题
  static final ThemeData _defaultLightTheme = ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.blue,
      brightness: Brightness.light,
    ),
    useMaterial3: true,
  );
  
  // 暗色主题
  static final ThemeData _defaultDarkTheme = ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.blue,
      brightness: Brightness.dark,
    ),
    useMaterial3: true,
  );
  
  // 获取当前主题模式
  ThemeModeType get themeMode => _themeMode;
  
  // 获取当前主题数据
  ThemeData get currentTheme {
    switch (_themeMode) {
      case ThemeModeType.light:
        return _lightTheme;
      case ThemeModeType.dark:
        return _darkTheme;
      case ThemeModeType.custom:
        return _customTheme ?? _defaultLightTheme;
      case ThemeModeType.system:
      default:
        // 根据系统设置决定
        final platformBrightness = WidgetsBinding.instance.window.platformBrightness;
        return platformBrightness == Brightness.dark ? _darkTheme : _lightTheme;
    }
  }
  
  // 切换主题
  Future<void> switchTheme(ThemeModeType newMode) async {
    _themeMode = newMode;
    
    // 保存到本地存储
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt('theme_mode', newMode.index);
    
    // 通知监听者
    notifyListeners();
  }
  
  // 自定义主题
  Future<void> setCustomTheme(ThemeData theme) async {
    _customTheme = theme;
    _themeMode = ThemeModeType.custom;
    
    // 保存配置
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt('theme_mode', ThemeModeType.custom.index);
    
    notifyListeners();
  }
  
  // 缓存中获取主题设置
  Future<void> loadThemeFromPrefs() async {
    final prefs = await SharedPreferences.getInstance();
    final savedModeIndex = prefs.getInt('theme_mode');
    
    if (savedModeIndex != null) {
      final savedMode = ThemeModeType.values[savedModeIndex];
      _themeMode = savedMode;
      notifyListeners();
    }
  }
  
  // 更新亮色
  void updateLightTheme(ThemeData newTheme) {
    _lightTheme = newTheme;
    if (_themeMode == ThemeModeType.light) {
      notifyListeners();
    }
  }
  
  // 更新暗色
  void updateDarkTheme(ThemeData newTheme) {
    _darkTheme = newTheme;
    if (_themeMode == ThemeModeType.dark) {
      notifyListeners();
    }
  }
}
步骤2:在应用入口配置Provider
void main() async {
  // 确保WidgetsFlutterBinding初始化
  WidgetsFlutterBinding.ensureInitialized();
  
  // 创建主题实例
  final themeManager = ThemeManager();
  
  // 加载保存的主题设置
  await themeManager.loadThemeFromPrefs();
  
  runApp(
    // 使用MultiProvider包装应用
    MultiProvider(
      providers: [
        ChangeNotifierProvider.value(value: themeManager),
        // 其他Provider...
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // 使用Consumer监听主题变化
    return Consumer<ThemeManager>(
      builder: (context, themeManager, child) {
        return MaterialApp(
          title: '主题与国际化',
          // 使用主题管理器中的当前主题
          theme: themeManager.currentTheme,
          darkTheme: themeManager.currentTheme, 
          themeMode: ThemeMode.system, 
          home: const HomePage(),
        );
      },
    );
  }
}
步骤3:创建主题切换界面
class ThemeSettingsPage extends StatelessWidget {
  const ThemeSettingsPage({super.key});

  @override
  Widget build(BuildContext context) {
    final themeManager = Provider.of<ThemeManager>(context);
    
    return Scaffold(
      appBar: AppBar(
        title: const Text('主题设置'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // 主题模式选择卡片
          _buildThemeModeCard(themeManager),
          const SizedBox(height: 24),
          
          // 亮色主题
          _buildThemeCustomizationCard(
            themeManager,
            isDark: false,
          ),
          const SizedBox(height: 24),
          
          // 暗色主题
          _buildThemeCustomizationCard(
            themeManager,
            isDark: true,
          ),
          const SizedBox(height: 24),
          
          // 主题预览
          _buildThemePreviewCard(context),
        ],
      ),
    );
  }
  
  Widget _buildThemeModeCard(ThemeManager themeManager) {
    return Card(
      elevation: 2,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              '主题模式',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 12),
            // 主题模式选项
            ...ThemeModeType.values.map((mode) {
              return RadioListTile<ThemeModeType>(
                title: Text(_getThemeModeName(mode)),
                value: mode,
                groupValue: themeManager.themeMode,
                onChanged: (value) {
                  if (value != null) {
                    themeManager.switchTheme(value);
                  }
                },
              );
            }).toList(),
          ],
        ),
      ),
    );
  }
  
  String _getThemeModeName(ThemeModeType mode) {
    switch (mode) {
      case ThemeModeType.light:
        return '亮色模式';
      case ThemeModeType.dark:
        return '暗色模式';
      case ThemeModeType.system:
        return '跟随系统';
      case ThemeModeType.custom:
        return '自定义主题';
    }
  }
  
  Widget _buildThemeCustomizationCard(
    ThemeManager themeManager, {
    required bool isDark,
  }) {
    return Card(
      elevation: 2,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              isDark ? '暗色主题' : '亮色主题',
              style: const TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 12),
            
            // 主题色选择
            const Text('主题色'),
            const SizedBox(height: 8),
            
            // 颜色选择器
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _buildColorOption(
                  color: Colors.blue,
                  isSelected: true,
                  onTap: () => _updateThemeColor(themeManager, Colors.blue, isDark),
                ),
                _buildColorOption(
                  color: Colors.green,
                  isSelected: false,
                  onTap: () => _updateThemeColor(themeManager, Colors.green, isDark),
                ),
                _buildColorOption(
                  color: Colors.red,
                  isSelected: false,
                  onTap: () => _updateThemeColor(themeManager, Colors.red, isDark),
                ),
                _buildColorOption(
                  color: Colors.purple,
                  isSelected: false,
                  onTap: () => _updateThemeColor(themeManager, Colors.purple, isDark),
                ),
                _buildColorOption(
                  color: Colors.orange,
                  isSelected: false,
                  onTap: () => _updateThemeColor(themeManager, Colors.orange, isDark),
                ),
                _buildColorOption(
                  color: Colors.teal,
                  isSelected: false,
                  onTap: () => _updateThemeColor(themeManager, Colors.teal, isDark),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
  
  Widget _buildColorOption({
    required Color color,
    required bool isSelected,
    required VoidCallback onTap,
  }) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        width: 40,
        height: 40,
        decoration: BoxDecoration(
          color: color,
          shape: BoxShape.circle,
          border: isSelected
              ? Border.all(color: Colors.white, width: 3)
              : null,
          boxShadow: [
            BoxShadow(
              color: Colors.black.withOpacity(0.1),
              blurRadius: 4,
              offset: const Offset(0, 2),
            ),
          ],
        ),
        child: isSelected
            ? const Icon(Icons.check, color: Colors.white, size: 20)
            : null,
      ),
    );
  }
  
  void _updateThemeColor(
    ThemeManager themeManager, 
    Color color, 
    bool isDark,
  ) {
    final newTheme = ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: color,
        brightness: isDark ? Brightness.dark : Brightness.light,
      ),
      useMaterial3: true,
    );
    
    if (isDark) {
      themeManager.updateDarkTheme(newTheme);
    } else {
      themeManager.updateLightTheme(newTheme);
    }
  }
  
  Widget _buildThemePreviewCard(BuildContext context) {
    final theme = Theme.of(context);
    
    return Card(
      elevation: 2,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              '主题预览',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 12),
            
            // 组件预览
            Column(
              children: [
                // 按钮
                Wrap(
                  spacing: 8,
                  children: [
                    ElevatedButton(
                      onPressed: () {},
                      child: const Text('主要按钮'),
                    ),
                    OutlinedButton(
                      onPressed: () {},
                      child: const Text('轮廓按钮'),
                    ),
                    TextButton(
                      onPressed: () {},
                      child: const Text('文本按钮'),
                    ),
                  ],
                ),
                const SizedBox(height: 16),
                
                // 卡片预览
                Card(
                  child: Padding(
                    padding: const EdgeInsets.all(12),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          '卡片标题',
                          style: theme.textTheme.titleLarge,
                        ),
                        const SizedBox(height: 8),
                        Text(
                          '这是一个卡片内容的预览,这是一个卡片内容的预览,这是一个卡片内容的预览。',
                          style: theme.textTheme.bodyMedium,
                        ),
                      ],
                    ),
                  ),
                ),
                const SizedBox(height: 16),
                
                // 颜色预览
                Row(
                  children: [
                    _buildColorPreview('主色', theme.colorScheme.primary),
                    const SizedBox(width: 8),
                    _buildColorPreview('辅色', theme.colorScheme.secondary),
                    const SizedBox(width: 8),
                    _buildColorPreview('背景', theme.colorScheme.background),
                  ],
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
  
  Widget _buildColorPreview(String label, Color color) {
    return Expanded(
      child: Column(
        children: [
          Container(
            height: 40,
            decoration: BoxDecoration(
              color: color,
              borderRadius: BorderRadius.circular(8),
              border: Border.all(color: Colors.grey.shade300),
            ),
          ),
          const SizedBox(height: 4),
          Text(
            label,
            style: const TextStyle(fontSize: 12),
          ),
        ],
      ),
    );
  }
}

2.3 渐变主题与动画切换

// 渐变主题切换
class AnimatedThemeSwitcher extends StatefulWidget {
  final Widget child;
  
  const AnimatedThemeSwitcher({super.key, required this.child});
  
  @override
  State<AnimatedThemeSwitcher> createState() => _AnimatedThemeSwitcherState();
}

class _AnimatedThemeSwitcherState extends State<AnimatedThemeSwitcher> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  late Animation<double> _animation;
  
  @override
  void initState() {
    super.initState();
    
    // 创建动画控制器
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );
    
    // 创建缓动动画
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    );
  }
  
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    
    // 监听主题变化,开始动画
    final themeManager = Provider.of<ThemeManager>(context, listen: true);
    
    // 每次主题变化时重新启动动画
    _controller.forward(from: 0);
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Opacity(
          opacity: _animation.value,
          child: Transform.scale(
            scale: 0.95 + 0.05 * _animation.value,
            child: child,
          ),
        );
      },
      child: widget.child,
    );
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

三、国际化

3.1 Flutter国际化架构

Flutter国际化系统基于Localizations机制,其工作流程如下:

sequenceDiagram
    participant App as 应用程序
    participant MaterialApp as MaterialApp
    participant Localizations as Localizations
    participant Delegate as 本地化代理
    participant Resource as 资源文件
    participant Widget as Widget
    
    App->>MaterialApp: 提供localizationsDelegates
    MaterialApp->>Localizations: 加载本地化配置
    Localizations->>Delegate: 请求本地化资源
    Delegate->>Resource: 加载对应语言资源
    Resource-->>Delegate: 返回资源数据
    Delegate-->>Localizations: 返回Localizations类
    Localizations-->>MaterialApp: 建立本地化上下文
    Widget->>Localizations: 通过Localizations.of获取文本
    Localizations-->>Widget: 返回本地化文本

3.2 国际化配置

步骤1:添加依赖
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: ^0.18.1 # 用于日期、数字格式化
  shared_preferences: ^2.2.2 
步骤2:创建国际化支持类
// lib/l10n/app_localizations.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

// 代理
class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
  const AppLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) {
    // 支持的语言列表
    return ['en', 'zh', 'ja', 'ko'].contains(locale.languageCode);
  }

  @override
  Future<AppLocalizations> load(Locale locale) {
    // 加载对应的本地化资源
    return SynchronousFuture<AppLocalizations>(AppLocalizations(locale));
  }

  @override
  bool shouldReload(AppLocalizationsDelegate old) => false;
}

// 本地化类
class AppLocalizations {
  final Locale locale;

  AppLocalizations(this.locale);

  // 静态方法
  static AppLocalizations? of(BuildContext context) {
    return Localizations.of<AppLocalizations>(context, AppLocalizations);
  }

  // 资源文件映射
  static final Map<String, Map<String, String>> _localizedValues = {
    'en': {
      'appTitle': 'Flutter Internationalization Demo',
      'welcome': 'Welcome to Flutter!',
      'login': 'Login',
      'logout': 'Logout',
      'settings': 'Settings',
      'language': 'Language',
      'theme': 'Theme',
      'darkMode': 'Dark Mode',
      'lightMode': 'Light Mode',
      'systemMode': 'System Mode',
      'changeLanguage': 'Change Language',
      'currentLanguage': 'Current Language',
      'english': 'English',
      'chinese': 'Chinese',
      'japanese': 'Japanese',
      'korean': 'Korean',
      'home': 'Home',
      'profile': 'Profile',
      'messages': 'Messages',
      'notifications': 'Notifications',
      'search': 'Search',
      'submit': 'Submit',
      'cancel': 'Cancel',
      'save': 'Save',
      'delete': 'Delete',
      'edit': 'Edit',
      'view': 'View',
      'loading': 'Loading...',
      'error': 'An error occurred',
      'success': 'Operation successful',
      'warning': 'Warning',
      'info': 'Information',
      'confirm': 'Confirm',
      'back': 'Back',
      'next': 'Next',
      'previous': 'Previous',
      'close': 'Close',
      'open': 'Open',
      'yes': 'Yes',
      'no': 'No',
      'ok': 'OK',
      'retry': 'Retry',
      'skip': 'Skip',
      'continue': 'Continue',
      'finished': 'Finished',
      'start': 'Start',
      'stop': 'Stop',
      'pause': 'Pause',
      'resume': 'Resume',
    },
    'zh': {
      'appTitle': 'Flutter国际化示例',
      'welcome': '欢迎使用Flutter!',
      'login': '登录',
      'logout': '退出登录',
      'settings': '设置',
      'language': '语言',
      'theme': '主题',
      'darkMode': '暗色模式',
      'lightMode': '亮色模式',
      'systemMode': '系统模式',
      'changeLanguage': '切换语言',
      'currentLanguage': '当前语言',
      'english': '英语',
      'chinese': '中文',
      'japanese': '日语',
      'korean': '韩语',
      'home': '首页',
      'profile': '个人资料',
      'messages': '消息',
      'notifications': '通知',
      'search': '搜索',
      'submit': '提交',
      'cancel': '取消',
      'save': '保存',
      'delete': '删除',
      'edit': '编辑',
      'view': '查看',
      'loading': '加载中...',
      'error': '发生错误',
      'success': '操作成功',
      'warning': '警告',
      'info': '信息',
      'confirm': '确认',
      'back': '返回',
      'next': '下一步',
      'previous': '上一步',
      'close': '关闭',
      'open': '打开',
      'yes': '是',
      'no': '否',
      'ok': '确定',
      'retry': '重试',
      'skip': '跳过',
      'continue': '继续',
      'finished': '完成',
      'start': '开始',
      'stop': '停止',
      'pause': '暂停',
      'resume': '恢复',
    },
    'ja': {
      'appTitle': 'Flutter国際化デモ',
      'welcome': 'Flutterへようこそ!',
      'login': 'ログイン',
      'logout': 'ログアウト',
      'settings': '設定',
      'language': '言語',
      'theme': 'テーマ',
      'darkMode': 'ダークモード',
      'lightMode': 'ライトモード',
      'systemMode': 'システムモード',
      'changeLanguage': '言語を切り替える',
      'currentLanguage': '現在の言語',
      'english': '英語',
      'chinese': '中国語',
      'japanese': '日本語',
      'korean': '韓国語',
      'home': 'ホーム',
      'profile': 'プロフィール',
      'messages': 'メッセージ',
      'notifications': '通知',
      'search': '検索',
      'submit': '送信',
      'cancel': 'キャンセル',
      'save': '保存',
      'delete': '削除',
      'edit': '編集',
      'view': '表示',
      'loading': '読み込み中...',
      'error': 'エラーが発生しました',
      'success': '操作が成功しました',
      'warning': '警告',
      'info': '情報',
      'confirm': '確認',
      'back': '戻る',
      'next': '次へ',
      'previous': '前へ',
      'close': '閉じる',
      'open': '開く',
      'yes': 'はい',
      'no': 'いいえ',
      'ok': 'OK',
      'retry': '再試行',
      'skip': 'スキップ',
      'continue': '続行',
      'finished': '完了',
      'start': '開始',
      'stop': '停止',
      'pause': '一時停止',
      'resume': '再開',
    },
    'ko': {
      'appTitle': 'Flutter 국제화 데모',
      'welcome': 'Flutter에 오신 것을 환영합니다!',
      'login': '로그인',
      'logout': '로그아웃',
      'settings': '설정',
      'language': '언어',
      'theme': '테마',
      'darkMode': '다크 모드',
      'lightMode': '라이트 모드',
      'systemMode': '시스템 모드',
      'changeLanguage': '언어 변경',
      'currentLanguage': '현재 언어',
      'english': '영어',
      'chinese': '중국어',
      'japanese': '일본어',
      'korean': '한국어',
      'home': '홈',
      'profile': '프로필',
      'messages': '메시지',
      'notifications': '알림',
      'search': '검색',
      'submit': '제출',
      'cancel': '취소',
      'save': '저장',
      'delete': '삭제',
      'edit': '편집',
      'view': '보기',
      'loading': '로딩 중...',
      'error': '오류가 발생했습니다',
      'success': '작업이 성공했습니다',
      'warning': '경고',
      'info': '정보',
      'confirm': '확인',
      'back': '뒤로',
      'next': '다음',
      'previous': '이전',
      'close': '닫기',
      'open': '열기',
      'yes': '예',
      'no': '아니오',
      'ok': '확인',
      'retry': '재시도',
      'skip': '건너뛰기',
      'continue': '계속',
      'finished': '완료',
      'start': '시작',
      'stop': '중지',
      'pause': '일시 정지',
      'resume': '재개',
    },
  };

  // 获取本地化文本
  String? _getText(String key) {
    if (_localizedValues.containsKey(locale.toString())) {
      return _localizedValues[locale.toString()]![key];
    }
    
    if (_localizedValues.containsKey(locale.languageCode)) {
      return _localizedValues[locale.languageCode]![key];
    }
    
    // 兜底
    return _localizedValues['en']![key];
  }

  // getter方法
  String get appTitle => _getText('appTitle')!;
  String get welcome => _getText('welcome')!;
  String get login => _getText('login')!;
  String get logout => _getText('logout')!;
  String get settings => _getText('settings')!;
  String get language => _getText('language')!;
  String get theme => _getText('theme')!;
  String get darkMode => _getText('darkMode')!;
  String get lightMode => _getText('lightMode')!;
  String get systemMode => _getText('systemMode')!;
  String get changeLanguage => _getText('changeLanguage')!;
  String get currentLanguage => _getText('currentLanguage')!;
  String get english => _getText('english')!;
  String get chinese => _getText('chinese')!;
  String get japanese => _getText('japanese')!;
  String get korean => _getText('korean')!;
  String get home => _getText('home')!;
  String get profile => _getText('profile')!;
  String get messages => _getText('messages')!;
  String get notifications => _getText('notifications')!;
  String get search => _getText('search')!;
  String get submit => _getText('submit')!;
  String get cancel => _getText('cancel')!;
  String get save => _getText('save')!;
  String get delete => _getText('delete')!;
  String get edit => _getText('edit')!;
  String get view => _getText('view')!;
  String get loading => _getText('loading')!;
  String get error => _getText('error')!;
  String get success => _getText('success')!;
  String get warning => _getText('warning')!;
  String get info => _getText('info')!;
  String get confirm => _getText('confirm')!;
  String get back => _getText('back')!;
  String get next => _getText('next')!;
  String get previous => _getText('previous')!;
  String get close => _getText('close')!;
  String get open => _getText('open')!;
  String get yes => _getText('yes')!;
  String get no => _getText('no')!;
  String get ok => _getText('ok')!;
  String get retry => _getText('retry')!;
  String get skip => _getText('skip')!;
  String get continueText => _getText('continue')!;
  String get finished => _getText('finished')!;
  String get start => _getText('start')!;
  String get stop => _getText('stop')!;
  String get pause => _getText('pause')!;
  String get resume => _getText('resume')!;

  // 携带参数
  String welcomeUser(String username) {
    // 根据不同语言调整格式
    switch (locale.languageCode) {
      case 'zh':
        return '欢迎, $username!';
      case 'ja':
        return 'ようこそ、$usernameさん!';
      case 'ko':
        return '환영합니다, $username님!';
      default:
        return 'Welcome, $username!';
    }
  }

  // 数字格式化
  String formatNumber(int number) {
    final formatter = NumberFormat.decimalPattern(locale.toString());
    return formatter.format(number);
  }

  // 货币格式化
  String formatCurrency(double amount) {
    final formatter = NumberFormat.currency(
      locale: locale.toString(),
      symbol: _getCurrencySymbol(locale.languageCode),
    );
    return formatter.format(amount);
  }

  String _getCurrencySymbol(String languageCode) {
    switch (languageCode) {
      case 'zh':
        return '¥';
      case 'ja':
        return '¥';
      case 'ko':
        return '₩';
      default:
        return '\$';
    }
  }

  // 日期格式化
  String formatDate(DateTime date) {
    final formatter = DateFormat.yMMMMd(locale.toString());
    return formatter.format(date);
  }

  // 时间格式化
  String formatRelativeTime(DateTime date) {
    final now = DateTime.now();
    final difference = now.difference(date);
    
    if (difference.inDays > 0) {
      return _pluralize(difference.inDays, 'day', 'days');
    } else if (difference.inHours > 0) {
      return _pluralize(difference.inHours, 'hour', 'hours');
    } else if (difference.inMinutes > 0) {
      return _pluralize(difference.inMinutes, 'minute', 'minutes');
    } else {
      return _getText('justNow') ?? 'Just now';
    }
  }

  String _pluralize(int count, String singular, String plural) {
    // 简单处理
    if (count == 1) {
      return '$count $singular';
    } else {
      return '$count $plural';
    }
  }
}
步骤3:配置MaterialApp
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '国际化',
      // 支持的语言列表
      supportedLocales: const [
        Locale('en', 'US'), // 英文
        Locale('zh', 'CN'), // 中文
        Locale('ja', 'JP'), // 日文
        Locale('ko', 'KR'), // 韩文
      ],
      // 本地化代理
      localizationsDelegates: const [
        AppLocalizationsDelegate(), // 自定义代理
        GlobalMaterialLocalizations.delegate,  // Material组件本地化
        GlobalWidgetsLocalizations.delegate,   // Widget文本本地化
        GlobalCupertinoLocalizations.delegate, // iOS风格组件本地化
      ],
      // 找不到对应语言时的回退语言
      localeResolutionCallback: (locale, supportedLocales) {
        // 检查支持的语言
        for (var supportedLocale in supportedLocales) {
          if (supportedLocale.languageCode == locale?.languageCode) {
            return supportedLocale;
          }
        }
        // 默认英语
        return const Locale('en', 'US');
      },
      home: const HomePage(),
    );
  }
}

3.3 语言管理器

// lib/providers/language_manager.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

// 语言管理器
class LanguageManager with ChangeNotifier {
  Locale _locale = const Locale('zh', 'CN');
  
  Locale get locale => _locale;
  
  // 切换语言
  Future<void> switchLanguage(Locale newLocale) async {
    _locale = newLocale;
    
    // 保存到本地存储
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('language_code', newLocale.languageCode);
    if (newLocale.countryCode != null) {
      await prefs.setString('country_code', newLocale.countryCode!);
    }
    
    // 通知监听者
    notifyListeners();
  }
  
  // 从本地存储加载语言设置
  Future<void> loadLanguageFromPrefs() async {
    final prefs = await SharedPreferences.getInstance();
    final languageCode = prefs.getString('language_code');
    final countryCode = prefs.getString('country_code');
    
    if (languageCode != null) {
      _locale = Locale(languageCode, countryCode);
      notifyListeners();
    }
  }
  
  // 获取支持的语言列表
  List<Map<String, dynamic>> get supportedLanguages => [
    {
      'code': 'zh',
      'country': 'CN',
      'name': '中文',
      'nativeName': '中文',
      'flag': '🇨🇳',
    },
    {
      'code': 'en',
      'country': 'US',
      'name': 'English',
      'nativeName': 'English',
      'flag': '🇺🇸',
    },
    {
      'code': 'ja',
      'country': 'JP',
      'name': 'Japanese',
      'nativeName': '日本語',
      'flag': '🇯🇵',
    },
    {
      'code': 'ko',
      'country': 'KR',
      'name': 'Korean',
      'nativeName': '한국어',
      'flag': '🇰🇷',
    },
  ];
  
  // 获取当前语言的显示名称
  String get currentLanguageName {
    final lang = supportedLanguages.firstWhere(
      (lang) => lang['code'] == _locale.languageCode,
      orElse: () => supportedLanguages.first,
    );
    return lang['name'];
  }
}

// 在应用入口配置
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  final languageManager = LanguageManager();
  final themeManager = ThemeManager();
  
  // 加载保存的设置
  await Future.wait([
    languageManager.loadLanguageFromPrefs(),
    themeManager.loadThemeFromPrefs(),
  ]);
  
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider.value(value: languageManager),
        ChangeNotifierProvider.value(value: themeManager),
      ],
      child: const MyApp(),
    ),
  );
}

// 更新MyApp以支持动态语言切换
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final languageManager = Provider.of<LanguageManager>(context);
    final themeManager = Provider.of<ThemeManager>(context);
    
    return MaterialApp(
      title: 'Flutter国际化',
      theme: themeManager.currentTheme,
      // 使用动态locale
      locale: languageManager.locale,
      supportedLocales: const [
        Locale('en', 'US'),
        Locale('zh', 'CN'),
        Locale('ja', 'JP'),
        Locale('ko', 'KR'),
      ],
      localizationsDelegates: const [
        AppLocalizationsDelegate(),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      localeResolutionCallback: (locale, supportedLocales) {
        return languageManager.locale;
      },
      home: const HomePage(),
    );
  }
}

3.4 语言切换界面

class LanguageSettingsPage extends StatelessWidget {
  const LanguageSettingsPage({super.key});

  @override
  Widget build(BuildContext context) {
    final languageManager = Provider.of<LanguageManager>(context);
    final localizations = AppLocalizations.of(context)!;
    
    return Scaffold(
      appBar: AppBar(
        title: Text(localizations.language),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // 当前语言显示
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    localizations.currentLanguage,
                    style: const TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    languageManager.currentLanguageName,
                    style: const TextStyle(fontSize: 18),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 24),
          
          // 语言选择列表
          Card(
            child: Padding(
              padding: const EdgeInsets.all(8),
              child: Column(
                children: languageManager.supportedLanguages.map((language) {
                  return ListTile(
                    leading: Text(
                      language['flag'],
                      style: const TextStyle(fontSize: 24),
                    ),
                    title: Text(language['nativeName']),
                    subtitle: Text(language['name']),
                    trailing: language['code'] == languageManager.locale.languageCode
                        ? const Icon(Icons.check, color: Colors.blue)
                        : null,
                    onTap: () {
                      languageManager.switchLanguage(
                        Locale(language['code'], language['country']),
                      );
                    },
                  );
                }).toList(),
              ),
            ),
          ),
          
          const SizedBox(height: 32),
          
          // 国际化功能
          _buildLocalizationDemo(context),
        ],
      ),
    );
  }
  
  Widget _buildLocalizationDemo(BuildContext context) {
    final localizations = AppLocalizations.of(context)!;
    final now = DateTime.now();
    final yesterday = now.subtract(const Duration(days: 1));
    
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '国际化功能',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 16),
            
            // 文本
            _buildDemoItem('普通文本', localizations.welcome),
            _buildDemoItem('带参数文本', localizations.welcomeUser('xx')),
            _buildDemoItem('按钮文本', localizations.submit),
            
            const SizedBox(height: 16),
            
            // 数字格式化
            _buildDemoItem('数字格式化', localizations.formatNumber(1234567)),
            _buildDemoItem('货币格式化', localizations.formatCurrency(1234.56)),
            
            const SizedBox(height: 16),
            
            // 日期格式化
            _buildDemoItem('日期格式化', localizations.formatDate(now)),
            _buildDemoItem('相对时间', localizations.formatRelativeTime(yesterday)),
          ],
        ),
      ),
    );
  }
  
  Widget _buildDemoItem(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            label,
            style: const TextStyle(
              fontSize: 12,
              color: Colors.grey,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            value,
            style: const TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.w500,
            ),
          ),
        ],
      ),
    );
  }
}

3.5 在Widget中使用国际化

class InternationalizedWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 获取本地化实例
    final localizations = AppLocalizations.of(context)!;
    
    return Scaffold(
      appBar: AppBar(
        title: Text(localizations.appTitle),
        actions: [
          IconButton(
            icon: const Icon(Icons.language),
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => const LanguageSettingsPage(),
                ),
              );
            },
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              localizations.welcome,
              style: const TextStyle(fontSize: 24),
            ),
            const SizedBox(height: 20),
            
            // 使用本地化文本
            ElevatedButton(
              onPressed: () {},
              child: Text(localizations.login),
            ),
            const SizedBox(height: 10),
            
            OutlinedButton(
              onPressed: () {},
              child: Text(localizations.settings),
            ),
            const SizedBox(height: 10),
            
            TextButton(
              onPressed: () {
                // 显示本地化提示
                _showLocalizedDialog(context);
              },
              child: Text(localizations.info),
            ),
            
            const SizedBox(height: 30),
            
            // 显示格式化数据
            Text(
              '${localizations.currentLanguage}: ${localizations.formatNumber(1234)}',
              style: const TextStyle(fontSize: 16),
            ),
          ],
        ),
      ),
    );
  }
  
  void _showLocalizedDialog(BuildContext context) {
    final localizations = AppLocalizations.of(context)!;
    
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(localizations.info),
        content: Text(localizations.welcomeUser('Flutter开发者')),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text(localizations.close),
          ),
          TextButton(
            onPressed: () {
              Navigator.pop(context);
              // ...
            },
            child: Text(localizations.confirm),
          ),
        ],
      ),
    );
  }
}

四、使用技巧

4.1 主题与国际化结合实践

创建配置管理器
// 统一的应用配置管理器
class AppConfigManager with ChangeNotifier {
  final ThemeManager _themeManager;
  final LanguageManager _languageManager;
  
  AppConfigManager({
    required ThemeManager themeManager,
    required LanguageManager languageManager,
  }) : _themeManager = themeManager,
       _languageManager = languageManager;
  
  // 同时切换主题和语言
  Future<void> switchToPreset(String presetName) async {
    switch (presetName) {
      case 'light_chinese':
        await _themeManager.switchTheme(ThemeModeType.light);
        await _languageManager.switchLanguage(const Locale('zh', 'CN'));
        break;
      case 'dark_english':
        await _themeManager.switchTheme(ThemeModeType.dark);
        await _languageManager.switchLanguage(const Locale('en', 'US'));
        break;
      // 这里还可以添加其他设置...
    }
    notifyListeners();
  }
  
  // 导出当前配置
  Map<String, dynamic> exportConfig() {
    return {
      'theme': _themeManager.themeMode.name,
      'language': _languageManager.locale.toString(),
      'timestamp': DateTime.now().toIso8601String(),
    };
  }
  
  // 导入配置
  Future<void> importConfig(Map<String, dynamic> config) async {
    // 解析并应用配置...
    notifyListeners();
  }
}

4.2 性能优化

按需加载语言资源
// 懒加载
class LazyAppLocalizations {
  static final Map<String, Future<Map<String, String>>> _resourceCache = {};
  
  static Future<Map<String, String>> _loadResources(String languageCode) async {
    // 接口调用
    await Future.delayed(const Duration(milliseconds: 100));
    return {
      'welcome': _getWelcomeText(languageCode),
      // ... 
    };
  }
  
  static Future<Map<String, String>> getResources(String languageCode) {
    if (!_resourceCache.containsKey(languageCode)) {
      _resourceCache[languageCode] = _loadResources(languageCode);
    }
    return _resourceCache[languageCode]!;
  }
  
  static String _getWelcomeText(String languageCode) {
    switch (languageCode) {
      case 'zh': return '欢迎';
      case 'en': return 'Welcome';
      default: return 'Welcome';
    }
  }
}
主题缓存
// 缓存
class ThemeCache {
  static final Map<String, ThemeData> _themeCache = {};
  
  static ThemeData getOrCreateTheme({
    required Color primaryColor,
    required Brightness brightness,
  }) {
    final key = '${primaryColor.value}_${brightness.name}';
    
    if (!_themeCache.containsKey(key)) {
      _themeCache[key] = ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: primaryColor,
          brightness: brightness,
        ),
        useMaterial3: true,
      );
    }
    
    return _themeCache[key]!;
  }
}

4.3 测试策略

主题测试
// 主题
void testThemeSwitching() {
  final themeManager = ThemeManager();
  
  // 测试初始状态
  assert(themeManager.themeMode == ThemeModeType.system);
  
  // 测试切换主题
  themeManager.switchTheme(ThemeModeType.dark);
  assert(themeManager.themeMode == ThemeModeType.dark);
  
  // 测试自定义主题
  final customTheme = ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Colors.red),
  );
  themeManager.setCustomTheme(customTheme);
  assert(themeManager.themeMode == ThemeModeType.custom);
}

// Widget测试
void testThemedWidget() {
  testWidgets('Widget使用正确的主题颜色', (WidgetTester tester) async {
    await tester.pumpWidget(
      MaterialApp(
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        ),
        home: ThemedWidget(),
      ),
    );
    
    // 验证Widget使用了主题颜色
    final container = tester.widget<Container>(
      find.byType(Container).first,
    );
    
    final boxDecoration = container.decoration as BoxDecoration;
  });
}

五、创建设置页面案例

// 整合主题和国际化的设置页面
class SettingsPage extends StatefulWidget {
  const SettingsPage({super.key});

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> {
  @override
  Widget build(BuildContext context) {
    final localizations = AppLocalizations.of(context)!;
    
    return Scaffold(
      appBar: AppBar(
        title: Text(localizations.settings),
      ),
      body: ListView(
        children: [
          // 用户信息
          _buildUserSection(context),
          
          // 主题设置
          _buildThemeSection(context),
          
          // 语言设置
          _buildLanguageSection(context),
          
          // 其他设置
          _buildOtherSettings(context),
          
          // 导出/导入配置
          _buildConfigManagement(context),
        ],
      ),
    );
  }
  
  Widget _buildUserSection(BuildContext context) {
    final localizations = AppLocalizations.of(context)!;
    
    return Card(
      margin: const EdgeInsets.all(16),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            const CircleAvatar(
              radius: 30,
              backgroundImage: NetworkImage('https://via.placeholder.com/150'),
            ),
            const SizedBox(width: 16),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    '祁厅长',
                    style: Theme.of(context).textTheme.titleLarge,
                  ),
                  const SizedBox(height: 4),
                  Text(
                    'developer@example.com',
                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                      color: Colors.grey,
                    ),
                  ),
                ],
              ),
            ),
            IconButton(
              icon: const Icon(Icons.edit),
              onPressed: () {
                // 编辑资料
              },
            ),
          ],
        ),
      ),
    );
  }
  
  Widget _buildThemeSection(BuildContext context) {
    final themeManager = Provider.of<ThemeManager>(context);
    final localizations = AppLocalizations.of(context)!;
    
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16),
      child: ExpansionTile(
        leading: const Icon(Icons.color_lens),
        title: Text(localizations.theme),
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                // 主题模式选择
                _buildThemeModeSelector(themeManager, localizations),
                const SizedBox(height: 16),
                
                // 颜色选择器
                _buildColorSelector(themeManager),
                const SizedBox(height: 16),
                
                // 高级设置
                _buildAdvancedThemeSettings(themeManager, localizations),
              ],
            ),
          ),
        ],
      ),
    );
  }
  
  Widget _buildThemeModeSelector(
    ThemeManager themeManager, 
    AppLocalizations localizations,
  ) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          '主题模式',
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),
        SegmentedButton<ThemeModeType>(
          segments: [
            ButtonSegment(
              value: ThemeModeType.light,
              label: Text(localizations.lightMode),
              icon: const Icon(Icons.light_mode),
            ),
            ButtonSegment(
              value: ThemeModeType.dark,
              label: Text(localizations.darkMode),
              icon: const Icon(Icons.dark_mode),
            ),
            ButtonSegment(
              value: ThemeModeType.system,
              label: Text(localizations.systemMode),
              icon: const Icon(Icons.settings),
            ),
          ],
          selected: {themeManager.themeMode},
          onSelectionChanged: (Set<ThemeModeType> newSelection) {
            themeManager.switchTheme(newSelection.first);
          },
        ),
      ],
    );
  }
  
  Widget _buildColorSelector(ThemeManager themeManager) {
    final colors = [
      Colors.blue,
      Colors.green,
      Colors.red,
      Colors.purple,
      Colors.orange,
      Colors.teal,
    ];
    
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          '主题色',
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),
        Wrap(
          spacing: 12,
          runSpacing: 12,
          children: colors.map((color) {
            return GestureDetector(
              onTap: () {
                final isDark = themeManager.themeMode == ThemeModeType.dark;
                if (isDark) {
                  themeManager.updateDarkTheme(
                    ThemeData(
                      colorScheme: ColorScheme.fromSeed(
                        seedColor: color,
                        brightness: Brightness.dark,
                      ),
                    ),
                  );
                } else {
                  themeManager.updateLightTheme(
                    ThemeData(
                      colorScheme: ColorScheme.fromSeed(
                        seedColor: color,
                        brightness: Brightness.light,
                      ),
                    ),
                  );
                }
              },
              child: Container(
                width: 40,
                height: 40,
                decoration: BoxDecoration(
                  color: color,
                  shape: BoxShape.circle,
                  border: Border.all(
                    color: Theme.of(context).colorScheme.outline,
                    width: 2,
                  ),
                ),
              ),
            );
          }).toList(),
        ),
      ],
    );
  }
  
  Widget _buildAdvancedThemeSettings(
    ThemeManager themeManager,
    AppLocalizations localizations,
  ) {
    return ExpansionTile(
      title: const Text('高级设置'),
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          child: Column(
            children: [
              // 圆角设置
              _buildSliderSetting(
                '圆角大小',
                0.0,
                24.0,
                (value) {
                  // 更新主题圆角
                },
              ),
              
              // 阴影强度
              _buildSliderSetting(
                '阴影强度',
                0.0,
                10.0,
                (value) {
                  // 更新阴影
                },
              ),
              
              // 动画速度
              _buildSliderSetting(
                '动画速度',
                0.5,
                2.0,
                (value) {
                  // 更新动画速度
                },
              ),
            ],
          ),
        ),
      ],
    );
  }
  
  Widget _buildSliderSetting(
    String label,
    double min,
    double max,
    ValueChanged<double> onChanged,
  ) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label),
          Slider(
            value: (min + max) / 2,
            min: min,
            max: max,
            onChanged: onChanged,
          ),
        ],
      ),
    );
  }
  
  Widget _buildLanguageSection(BuildContext context) {
    final languageManager = Provider.of<LanguageManager>(context);
    final localizations = AppLocalizations.of(context)!;
    
    return Card(
      margin: const EdgeInsets.all(16),
      child: ListTile(
        leading: const Icon(Icons.language),
        title: Text(localizations.language),
        subtitle: Text(languageManager.currentLanguageName),
        trailing: const Icon(Icons.chevron_right),
        onTap: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => const LanguageSettingsPage(),
            ),
          );
        },
      ),
    );
  }
  
  Widget _buildOtherSettings(BuildContext context) {
    final localizations = AppLocalizations.of(context)!;
    
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16),
      child: Column(
        children: [
          ListTile(
            leading: const Icon(Icons.notifications),
            title: Text(localizations.notifications),
            trailing: Switch(
              value: true,
              onChanged: (value) {},
            ),
          ),
          const Divider(height: 1),
          ListTile(
            leading: const Icon(Icons.security),
            title: const Text('隐私设置'),
            trailing: const Icon(Icons.chevron_right),
            onTap: () {},
          ),
          const Divider(height: 1),
          ListTile(
            leading: const Icon(Icons.help),
            title: const Text('帮助与反馈'),
            trailing: const Icon(Icons.chevron_right),
            onTap: () {},
          ),
          const Divider(height: 1),
          ListTile(
            leading: const Icon(Icons.info),
            title: const Text('关于我们'),
            trailing: const Icon(Icons.chevron_right),
            onTap: () {},
          ),
        ],
      ),
    );
  }
  
  Widget _buildConfigManagement(BuildContext context) {
    return Card(
      margin: const EdgeInsets.all(16),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            const Text(
              '配置管理',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 16),
            Row(
              children: [
                Expanded(
                  child: OutlinedButton.icon(
                    icon: const Icon(Icons.upload),
                    label: const Text('导出配置'),
                    onPressed: () {
                      _exportConfig(context);
                    },
                  ),
                ),
                const SizedBox(width: 16),
                Expanded(
                  child: OutlinedButton.icon(
                    icon: const Icon(Icons.download),
                    label: const Text('导入配置'),
                    onPressed: () {
                      _importConfig(context);
                    },
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            OutlinedButton.icon(
              icon: const Icon(Icons.restore),
              label: const Text('恢复默认设置'),
              onPressed: () {
                _resetToDefaults(context);
              },
            ),
          ],
        ),
      ),
    );
  }
  
  void _exportConfig(BuildContext context) {
    // 导出配置逻辑
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('导出配置'),
        content: const Text('配置已复制到剪贴板'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }
  
  void _importConfig(BuildContext context) {
    // 导入配置逻辑
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('导入配置'),
        content: const Text('请粘贴配置JSON'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('取消'),
          ),
          TextButton(
            onPressed: () {
              // 导入配置
              Navigator.pop(context);
            },
            child: const Text('导入'),
          ),
        ],
      ),
    );
  }
  
  void _resetToDefaults(BuildContext context) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('恢复默认设置'),
        content: const Text('确定要恢复所有设置为默认值吗?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('取消'),
          ),
          TextButton(
            onPressed: () {
              // 恢复
              Navigator.pop(context);
            },
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }
}

总结

至此主题与国际化相关知识点就介绍完了,通过本节内容的学习,我们掌握了以下核心知识点:主题系统动态主题切换国际化

实际开发建议

  1. 渐进式实现:如果项目已经开发到一半,可以从基础的主题配置开始,逐步添加国际化支持。

  2. 设计系统先行:在项目初期就建立完整的设计系统(Design System),包括颜色、字体、间距等规范。

  3. 保持一致性:确保整个应用使用统一的主题和国际化方案,避免混合使用不同方案。

  4. 用户体验优先:主题切换和语言切换应该流畅自然,提供良好的视觉效果。

OK!有任何问题或建议,欢迎在评论区留言讨论!让我们一起在Flutter全栈开发的道路上不断进步!

Textture 生命周期

2025年12月1日 20:26

Texture (AsyncDisplayKit) 节点生命周期完全指南

本文详解 Texture 框架中 ASDisplayNode 的完整生命周期,包括线程安全陷阱和官方最佳实践。

📋 生命周期流程图

1. init()                       // 节点创建(⚠️ 可能在后台线程)2. didLoad()                    // view/layer 已创建(✅ 主线程)3. layoutSpecThatFits(_:)       // 布局测量(⚠️ 可能在后台线程)4. didEnterPreloadState()       // 即将接近屏幕(✅ 主线程)5. didEnterDisplayState()       // 即将显示(✅ 主线程)6. didEnterVisibleState()       // 完全可见(✅ 主线程)7. didExitVisibleState()        // 离开可见区(✅ 主线程)8. didExitDisplayState()        // 离开显示区(✅ 主线程)9. didExitPreloadState()        // 离开预加载区(✅ 主线程)10. clearContents()             // 释放资源(✅ 主线程)11. deinit                      // 节点销毁

1️⃣ init() - 节点初始化

🧵 线程特性

⚠️ 关键:可能在主线程或后台线程执行!

根据创建方式不同:

  • 直接创建let node = MyNode() → 在调用线程执行
  • Block 创建ASCellNode { MyNode() } → 在后台线程执行

📖 官方文档引用

来自 ASDisplayNode.h:

"This method can be called on a background thread.
You MUST ensure that no UIKit objects are accessed."

✅ 应该做的事

override init() {
    super.init()
    
    // ✅ 初始化子节点
    let avatarNode = ASNetworkImageNode()
    let textNode = ASTextNode()
    
    // ✅ 设置 Node 层级属性(线程安全)
    avatarNode.cornerRadius = 18
    avatarNode.style.preferredSize = CGSize(width: 36, height: 36)
    
    textNode.maximumNumberOfLines = 0
    textNode.truncationMode = .byWordWrapping
    
    // ✅ 添加子节点
    automaticallyManagesSubnodes = true
    addSubnode(avatarNode)
    addSubnode(textNode)
}

❌ 绝对禁止的操作

override init() {
    super.init()
    
    // ❌ 访问 view/layer(后台线程会崩溃)
    self.view.backgroundColor = .white  // 💥 Crash!
    imageNode.view.layer.cornerRadius = 10  // 💥 Crash!
    
    // ❌ 创建 UIKit 对象
    let image = UIImage(named: "icon")  // ⚠️ 可能有问题
    
    // ❌ 调用 UIKit API
    let color = UIColor.red.cgColor  // ⚠️ 不推荐
}

🔧 替代方案对比

需求 ❌ 错误写法 (init) ✅ 正确写法
设置圆角 node.view.layer.cornerRadius = 10 node.cornerRadius = 10
设置背景色 node.view.backgroundColor = .red node.backgroundColor = .red
添加手势 node.view.addGestureRecognizer(...) didLoad() 中添加
设置阴影 node.view.layer.shadowRadius = 5 didLoad() 中设置

2️⃣ didLoad() - 视图已创建

🧵 线程特性

✅ 始终在主线程执行

📖 官方文档引用

"Called on the main thread after the node's view or layer has been created.
This is the earliest time to safely access node.view or node.layer."

触发时机

node.viewnode.layer 首次被访问时触发(懒加载),而不是创建后自动触发。

✅ 应该做的事

override func didLoad() {
    super.didLoad()
    
    // ✅ 访问 UIKit 视图层级
    textNode.view.isUserInteractionEnabled = true
    
    // ✅ 添加手势
    let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
    view.addGestureRecognizer(tapGesture)
    
    // ✅ 设置 layer 属性
    imageNode.view.layer.shadowColor = UIColor.black.cgColor
    imageNode.view.layer.shadowOffset = CGSize(width: 0, height: 2)
    imageNode.view.layer.shadowRadius = 4
    imageNode.view.layer.shadowOpacity = 0.3
    
    // ✅ 设置 delegate
    scrollNode.view.delegate = self
}

❌ 不应该做的事

override func didLoad() {
    super.didLoad()
    
    // ❌ 不要做布局计算(应该在 layoutSpecThatFits 中)
    textNode.frame = CGRect(x: 10, y: 10, width: 200, height: 40)
    
    // ❌ 不要做数据加载(应该在 didEnterPreloadState 中)
    fetchRemoteData()
}

3️⃣ layoutSpecThatFits(_:) - 布局测量

🧵 线程特性

⚠️ 可能在主线程或后台线程执行

根据调用场景:

  • 异步测量(滚动时)→ 后台线程
  • 同步布局(主动调用)→ 主线程

📖 官方文档引用

来自 ASDisplayNode.h:

"This method is called off the main thread.
It is called on the main thread only when being used synchronously.
Node subclasses should NEVER access their view or layer properties."

✅ 应该做的事

override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
    // ✅ 设置 Node 的布局属性
    avatarNode.style.preferredSize = CGSize(width: 36, height: 36)
    
    // ✅ 创建布局规范
    let textInset = ASInsetLayoutSpec(
        insets: UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10),
        child: textNode
    )
    
    let hStack = ASStackLayoutSpec.horizontal()
    hStack.spacing = 8
    hStack.alignItems = .start
    hStack.children = [avatarNode, textInset]
    
    return ASInsetLayoutSpec(
        insets: UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12),
        child: hStack
    )
}

❌ 绝对禁止的操作

override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
    // ❌ 访问 view(后台线程会崩溃)
    self.view.backgroundColor = .white  // 💥 Crash!
    textNode.view.frame = CGRect(...)  // 💥 Crash!
    
    // ❌ 调用 UIKit API
    let color = UIColor.red  // ⚠️ 危险
    
    // ❌ 修改状态变量(可能导致线程竞争)
    self.isLoading = false  // ⚠️ 需要加锁
    
    return ASLayoutSpec()
}

💡 关键原则

layoutSpecThatFits 是纯函数风格

  • 输入:size 约束
  • 输出:布局描述(ASLayoutSpec)
  • 禁止:访问 UIKit、修改状态、产生副作用

4️⃣ Interface State 系统

Texture 提供了三级渐进式状态管理:

状态 距离屏幕 触发时机 典型用途
Preload 1-2 屏 即将进入可视区 网络请求、解码图片
Display 即将显示 准备渲染 文本光栅化、layer 内容
Visible 完全可见 出现在屏幕内 播放动画/视频

📖 官方文档引用

来自 Intelligent Preloading

"Texture provides granular callbacks for when content enters different stages of the pipeline.
Use Preload for network fetching, Display for rendering, and Visible for animations."

完整示例

class VideoCardNode: ASDisplayNode {
    private let videoNode = ASVideoNode()
    private let titleNode = ASTextNode()
    
    // Preload: 距离屏幕 1-2 屏时触发
    override func didEnterPreloadState() {
        super.didEnterPreloadState()
        
        // ✅ 开始网络请求
        fetchVideoMetadata()
        
        // ✅ 预加载视频
        videoNode.asset = AVAsset(url: videoURL)
    }
    
    // Display: 即将显示但还没完全可见
    override func didEnterDisplayState() {
        super.didEnterDisplayState()
        
        // ✅ 确保内容已渲染
        print("内容已准备好显示")
    }
    
    // Visible: 完全出现在屏幕内
    override func didEnterVisibleState() {
        super.didEnterVisibleState()
        
        // ✅ 启动动画
        startPulseAnimation()
        
        // ✅ 播放视频
        videoNode.play()
        
        // ✅ 曝光打点
        Analytics.trackImpression(itemId: videoId)
    }
    
    // 退出可见状态
    override func didExitVisibleState() {
        super.didExitVisibleState()
        
        // ✅ 立即停止动画
        stopPulseAnimation()
        
        // ✅ 暂停视频
        videoNode.pause()
    }
    
    // 退出显示状态
    override func didExitDisplayState() {
        super.didExitDisplayState()
        print("已退出显示区域")
    }
    
    // 退出预加载状态
    override func didExitPreloadState() {
        super.didExitPreloadState()
        
        // ✅ 取消网络请求
        cancelNetworkTasks()
    }
}

🗑️ clearContents() - 资源释放

🧵 线程特性

✅ 主线程执行

📖 官方文档引用

来自 Texture Best Practices:

"Override to clear any cached or calculated content when the node is no longer visible.
This is called automatically by the framework to manage memory."

触发时机

节点完全退出预加载范围后,系统自动调用

✅ 应该做的事

override func clearContents() {
    super.clearContents()
    
    // ✅ 释放大图(可重建的资源)
    imageNode.image = nil
    
    // ✅ 停止动画
    lottieAnimationNode.stop()
    lottieAnimationNode.animationView = nil
    
    // ✅ 清除缓存数据
    cachedRenderData = nil
    
    // ✅ 释放视频资源
    videoNode.asset = nil
}

⚠️ 注意事项

  • clearContents() 会在节点完全退出预加载范围后自动调用
  • 不需要手动调用
  • 主要用于释放可重建的资源(如解码后的图片、渲染缓存)
  • 不要释放配置数据(如 URL、ID、样式设置等)

🎯 最佳实践总结

线程安全检查表

生命周期方法 线程 可访问 view? 可访问 UIKit?
init() ⚠️ 后台/主 ❌ 否 ❌ 否
didLoad() ✅ 主线程 ✅ 是 ✅ 是
layoutSpecThatFits() ⚠️ 后台/主 ❌ 否 ❌ 否
didEnter*State() ✅ 主线程 ✅ 是 ✅ 是
didExit*State() ✅ 主线程 ✅ 是 ✅ 是
clearContents() ✅ 主线程 ✅ 是 ✅ 是

记忆口诀

"初建布局在后台,装载显示回主干"

  • (init) (layoutSpec) 可能在后台线程 → 禁止访问 UIKit
  • (didLoad) 载显示(Interface State) 都在主线程 → 可以访问 UIKit

职责分离原则

class MyNode: ASDisplayNode {
    // ✅ init: 创建节点树 + 设置静态属性
    override init() {
        super.init()
        addSubnode(avatarNode)
        avatarNode.cornerRadius = 10
    }
    
    // ✅ didLoad: UIKit 交互
    override func didLoad() {
        super.didLoad()
        view.addGestureRecognizer(...)
    }
    
    // ✅ layoutSpec: 纯布局描述
    override func layoutSpecThatFits(_ size: ASSizeRange) -> ASLayoutSpec {
        return ASStackLayoutSpec(...)
    }
    
    // ✅ Preload: 数据加载
    override func didEnterPreloadState() {
        super.didEnterPreloadState()
        fetchData()
    }
    
    // ✅ Visible: 动画/视频
    override func didEnterVisibleState() {
        super.didEnterVisibleState()
        videoNode.play()
    }
    
    // ✅ clearContents: 释放可重建资源
    override func clearContents() {
        super.clearContents()
        imageNode.image = nil
    }
}

⚠️ 常见崩溃场景

场景 1:在 init 中访问 view

// ❌ 当使用 ASCellNode(block:) 时会崩溃
override init() {
    super.init()
    self.view.backgroundColor = .white  // 💥 后台线程访问 UIKit
}

解决方案:

override init() {
    super.init()
    self.backgroundColor = .white  // ✅ 使用 Node 的属性
}

override func didLoad() {
    super.didLoad()
    self.view.layer.shadowRadius = 5  // ✅ 在 didLoad 中访问 layer
}

场景 2:在 layoutSpec 中修改 view

// ❌ 后台线程调用时会崩溃
override func layoutSpecThatFits(_ size: ASSizeRange) -> ASLayoutSpec {
    textNode.view.numberOfLines = 2  // 💥 访问了 UILabel
    return ASLayoutSpec()
}

解决方案:

override func layoutSpecThatFits(_ size: ASSizeRange) -> ASLayoutSpec {
    textNode.maximumNumberOfLines = 2  // ✅ 使用 Node 的属性
    return ASLayoutSpec()
}

场景 3:在 layoutSpec 中调用 UIColor

// ⚠️ 后台线程可能有问题
override func layoutSpecThatFits(_ size: ASSizeRange) -> ASLayoutSpec {
    let color = UIColor.systemBlue  // 危险!
    textNode.backgroundColor = color
    return ASLayoutSpec()
}

解决方案:

// ✅ 在 init 中设置
override init() {
    super.init()
    textNode.backgroundColor = UIColor.systemBlue
}

// 或在 didLoad 中设置
override func didLoad() {
    super.didLoad()
    textNode.view.backgroundColor = UIColor.systemBlue
}

📊 完整生命周期示例

class CompleteExampleNode: ASDisplayNode {
    private let avatarNode = ASNetworkImageNode()
    private let titleNode = ASTextNode()
    private let videoNode = ASVideoNode()
    
    // 1️⃣ 初始化(可能在后台线程)
    override init() {
        super.init()
        print("✅ 1. init - 可能在后台线程")
        
        // 只做线程安全操作
        automaticallyManagesSubnodes = true
        avatarNode.cornerRadius = 20
        titleNode.maximumNumberOfLines = 2
    }
    
    // 2️⃣ 视图已创建(主线程)
    override func didLoad() {
        super.didLoad()
        print("✅ 2. didLoad - 主线程")
        
        // 访问 UIKit
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
        view.addGestureRecognizer(tapGesture)
        
        videoNode.view.layer.cornerRadius = 8
    }
    
    // 3️⃣ 布局测量(可能在后台线程)
    override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
        print("✅ 3. layoutSpecThatFits - 可能在后台线程")
        
        // 只做布局描述
        avatarNode.style.preferredSize = CGSize(width: 40, height: 40)
        
        let vStack = ASStackLayoutSpec.vertical()
        vStack.spacing = 8
        vStack.children = [avatarNode, titleNode, videoNode]
        
        return ASInsetLayoutSpec(insets: UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16), child: vStack)
    }
    
    // 4️⃣ 进入预加载状态(主线程)
    override func didEnterPreloadState() {
        super.didEnterPreloadState()
        print("✅ 4. didEnterPreloadState - 主线程")
        
        // 开始网络请求
        fetchVideoData()
    }
    
    // 5️⃣ 进入显示状态(主线程)
    override func didEnterDisplayState() {
        super.didEnterDisplayState()
        print("✅ 5. didEnterDisplayState - 主线程")
    }
    
    // 6️⃣ 进入可见状态(主线程)
    override func didEnterVisibleState() {
        super.didEnterVisibleState()
        print("✅ 6. didEnterVisibleState - 主线程")
        
        // 播放视频和动画
        videoNode.play()
        startAnimation()
    }
    
    // 7️⃣ 退出可见状态(主线程)
    override func didExitVisibleState() {
        super.didExitVisibleState()
        print("✅ 7. didExitVisibleState - 主线程")
        
        // 停止视频和动画
        videoNode.pause()
        stopAnimation()
    }
    
    // 8️⃣ 退出显示状态(主线程)
    override func didExitDisplayState() {
        super.didExitDisplayState()
        print("✅ 8. didExitDisplayState - 主线程")
    }
    
    // 9️⃣ 退出预加载状态(主线程)
    override func didExitPreloadState() {
        super.didExitPreloadState()
        print("✅ 9. didExitPreloadState - 主线程")
        
        // 取消网络请求
        cancelNetworkTasks()
    }
    
    // 🔟 清除内容(主线程)
    override func clearContents() {
        super.clearContents()
        print("✅ 10. clearContents - 主线程")
        
        // 释放资源
        avatarNode.image = nil
        videoNode.asset = nil
    }
    
    // 1️⃣1️⃣ 销毁
    deinit {
        print("✅ 11. deinit")
    }
    
    @objc private func handleTap() {
        print("节点被点击")
    }
    
    private func fetchVideoData() {}
    private func cancelNetworkTasks() {}
    private func startAnimation() {}
    private func stopAnimation() {}
}

📚 官方资源

  1. 源码注释ASDisplayNode.h
  2. 官方文档Node Lifecycle
  3. 智能预加载Intelligent Preloading
  4. 线程安全指南Thread Safety
  5. 容器节点文档ASViewController

🎓 进阶主题

Interface State 的精确控制

你可以通过 interfaceState 属性手动检查节点状态:

if interfaceState.contains(.visible) {
    print("节点当前可见")
}

if interfaceState.contains(.preload) {
    print("节点在预加载范围内")
}

自定义预加载距离

// 在容器节点(如 ASTableNode)中设置
tableNode.leadingScreensForBatching = 2.0  // 提前 2 屏开始预加载

性能优化技巧

  1. 合理使用 Interface State

    • Preload: 网络请求(距离远,提前加载)
    • Display: 文本渲染(即将显示)
    • Visible: 动画/视频(只在可见时播放)
  2. 及时释放资源

   override func didExitVisibleState() {
       super.didExitVisibleState()
       videoNode.pause()  // 立即暂停,节省电量
   }
   
   override func clearContents() {
       super.clearContents()
       imageNode.image = nil  // 释放内存
   }
  1. 避免在 layoutSpec 中做重计算
   // ❌ 每次布局都重新计算
   override func layoutSpecThatFits(_ size: ASSizeRange) -> ASLayoutSpec {
       let processedText = heavyTextProcessing(rawText)  // 耗时操作
       textNode.attributedText = processedText
       return ASLayoutSpec()
   }
   
   // ✅ 在数据更新时计算一次
   func updateData(_ newText: String) {
       let processedText = heavyTextProcessing(newText)
       textNode.attributedText = processedText
       setNeedsLayout()
   }

💡 总结

三条黄金法则

  1. 线程安全第一

    • init()layoutSpecThatFits() 可能在后台线程
    • 绝对不要在这两个方法中访问 UIKit
  2. 职责分离

    • init: 创建节点树
    • didLoad: UIKit 交互
    • layoutSpec: 纯布局描述
    • Interface State: 生命周期响应
  3. 性能优先

    • 使用 Preload 提前加载
    • 使用 clearContents 释放资源
    • 及时停止动画和视频

调试技巧

开启 Texture 的调试日志:

// 在 AppDelegate 中
#if DEBUG
ASDisplayNode.shouldShowRangeDebugOverlay = true
#endif

这会在屏幕上显示每个节点的 Interface State,帮助你理解生命周期。


希望这份指南能帮助你避开 Texture 的常见陷阱,写出高性能的代码! 🚀

如有问题欢迎评论讨论,也欢迎补充更多实战经验!


作者:[你的昵称]
链接:[文章链接]
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

当 Android 手机『强行兼容』AirDrop -- 肘子的 Swift 周报 #113

作者 东坡肘子
2025年12月2日 08:01

issue113.webp

当 Android 手机『强行兼容』AirDrop

AirDrop 让使用者可以在各种不同类型的苹果设备上高效、无损的数据传输,它一直是苹果生态的专属且核心功能。但,这种情况现在出现了“奇怪”的变化。几天前,谷歌宣布在 Pixel 10 中,在没有苹果的参与下,为 Quick Share 提供了 AirDrop 的兼容机制,实现了安卓手机与苹果手机基于 AirDrop 的无线互通。

随后,高通也宣布其搭载 Snapdragon 的 Android 设备“很快就会”支持这一路线,也就是说这不再是 Pixel 的专属功能,而有望扩展到更广泛的 Android 手机阵营。

除了谷歌的技术能力外,本次互通的最大推手或许正是 DMA(欧盟《数字市场法案》)。AirDrop 依赖的技术是 AWDL (Apple Wireless Direct Link),即便到现在也是私有的。但是 DMA 的要求下,苹果从 iOS 26 开始引入了对 Wi-Fi Aware 支持,这大幅降低了本次“强行兼容”的难度。安卓手机可以直接发出标准的 Wi-Fi Aware 信号去寻找 iPhone,并且由于走的是官方标准协议,连接极其稳定,发现速度极快,而且苹果很难有理由去封杀。

从厂商提供跨端应用实现无线互联,到部分厂商主动适配苹果的 livePhoto,这些年从安卓阵营发起的对苹果的主动兼容屡见不鲜。这一方面表现出了苹果的很多实现和体验确有过人之处,另一方面也展现出安卓厂商更愿意为了获取苹果生态的用户而主动出击,在体验上对齐。DMA 这种在某些方面看起来过分苛刻的法规,又恰如其分的促使了苹果的“开放”,从而创造出更多的跨平台无缝体验,满足了相当一部分消费者的需求。

对于苹果来说,在法律攻防战外,只有不断地推出更具吸引力的新功能才能保持苹果生态的“优势”。一旦某一天,这种“强行兼容”不再有需求,那么就意味着苹果的“独特性”衰落了。相比起现在的情况来说,我想苹果更不想看到这样的场景出现。

本期内容 | 前一期内容 | 全部周报列表

🚀 《肘子的 Swift 周报》

每周为你精选最值得关注的 Swift、SwiftUI 技术动态


近期推荐

当我决定同时做 iOS 和 Android:独立开发者的真实双平台之路

在不少苹果生态开发者眼中,Android 既熟悉又遥远:用户规模巨大,但生态碎片化;潜在回报可观,但投入成本不确定。许多人因此对“要不要做 Android 版本”始终犹豫不决。资深 iOS 开发者道哥采用“双平台并进”策略多年。在本文中,他分享了双平台开发的实战经验:双端功能如何对齐、遇到系统差异时的权衡、两边的运营表现差异、收入结构的变化等。


Skip 框架的跨平台实践 (Skip Framework: A Cross-Platform Journey for Native iOS Developer)

Maxim Ermolaev 分享了他将 SwiftUI 应用迁移到 Android 的实践经验,呈现了 Skip 在真实项目中的表现与边界。对 Skip 已支持的 SwiftUI 功能,迁移过程相对顺畅;而对于尚未覆盖的高级特性,作者则通过 ComposeView 在同一 Swift 文件中直接嵌入 Jetpack Compose 代码,为 Android 侧提供定制实现。Maxim 的结论相当务实:Skip 足以让 iOS-first 团队快速获得一个“可用且一致”的 Android 客户端。但如果目标是两端达到完全一致的视觉与交互体验,则仍需在 Android 侧做更多平台特化,或采用 Skip Lite 共享业务逻辑、将 UI 保持为原生实现。

随着 Swift Android SDK 的成熟与 Skip 等工具的不断完善,Swift 在 Android 世界的可能性正迅速从“实验性”迈向“可落地”。两位作者从不同角度呈现了当前的真实路径,也为正在考虑“跨到另一边”的 Swift 开发者提供了难得的参考。


Mac 原生 AI 客户端:聚合 GPT、Claude、Gemini 及本地最新模型

受够了浏览器吃光内存?试试 BoltAI

它将 GPT、Claude、Gemini 以及 Ollama 本地模型无缝集成到你的开发工作流中。无论模型如何迭代,你都能第一时间在原生界面中调用最强能力。支持屏幕上下文感知代码解释与重构,是真正属于开发者的 Mac 原生 AI 神器。

🎉 周报读者限时福利:凭代码 BFCM25 可享 51% OFF

🚀 立即试用 BoltAI!


打造每天跑 2000+ 条流水线的 Mac 机器农场 (Building Mac Farm: Running 2000+ iOS Pipelines Daily)

在本文中,Yusuf Özgül 详述了 Trendyol 团队如何从零搭建一套由 130 台设备组成的 macOS Farm,以从容支撑每天 2000+ 条 iOS 流水线的实战经验。整套系统采用基于 Apple Virtualization Framework 自研的 VM 管理体系、通过 Authorization Plug-ins 解决批量设备的安全自动登录、定位并修复 VM 在 P/E Core 识别上的性能瓶颈,并构建 Grafana 监控与告警系统,实现自愈式 Runner 集群。在流水线上,通过配合 Tuist Cache(构建提速约 70%)与选择性测试(测试提速约 80%)进一步提高了性能。

少见的 macOS Farm 落地全景案例:从虚拟化架构到性能调优,从日志与监控到流水线设计,几乎覆盖了企业级 iOS CI/CD 所需的全部关键环节。


在 Zed 中实现 SwiftUI 预览的小技巧 (Building iOS and Mac apps in Zed: SwiftUI Previews)

尽管目前开发者已经可以在 Zed 中开发调试 iOS 应用了,但仍无法实现 Xcode 中的杀手级功能:Preview。通常的替代方案是在 Zed 旁边再开一个 Xcode 预览窗口,但切换编辑的 SwiftUI 页面后,Xcode 并不会自动跳转到对应的 Preview。Adrian Ross 分享了一个小技巧:通过一个脚本配合 Zed Task,实现 Xcode 与 Zed 的编辑页面同步,从而在外部预览窗口中自动同步展示对应的 Preview,基本复刻了“在 Zed 中使用 Preview”的体验。

这一思路不仅适用于 Zed,任何在 macOS 上的编辑器都可以用类似方式与 Xcode 的 Preview 功能协同工作。


在 macOS 的 SwiftUI 列表中启用选择、双击和右键菜单 (Enabling Selection, Double-Click and Context Menus in SwiftUI List Rows on macOS)

macOS 的 SwiftUI List 在行选择、双击和右键菜单等桌面端特有交互上,与 iOS 存在显著差异。开发者需要使用带 selection 参数的 List 初始化器来启用选择功能,并通过 contextMenu(forSelectionType:menu:primaryAction:) 修饰器同时实现双击操作和右键菜单。相比 iOS,macOS 版的 List 更接近 AppKit 的表格式交互模型。在本文中,Gabriel Theodoropoulos 以一套简洁的示例展示了如何在 macOS 的 SwiftUI 列表中正确组合这些 API 以实现桌面端标准交互。


开发阶段使用 Associated Domains 的替代模式 (Using Associated Domains Alternate Mode during Development)

在开发涉及 Associated Domains 的功能(如 Universal Links、Shared Web Credentials 或 App Clips)时,iOS 默认通过 Apple CDN 获取 apple-app-site-association (AASA) 文件,而非直接从服务器获取。这在生产环境中运行良好,但在开发阶段会带来不便:文件更改需要等待 CDN 传播,本地或测试服务器可能根本无法公开访问。Natascha Fadeeva 介绍了苹果提供的 Alternate Mode(替代模式):通过在 Associated Domains 条目中添加 ?mode=developer 等后缀,让 iOS 绕过 CDN,直接从服务器拉取对应的 AASA 文件。借助此机制,开发者可以让配置即时生效、在本地环境调试,而不必等待 CDN 缓存刷新,大幅提升开发效率。


用可视化方式理解 Swift 中的数据竞争 (Understanding Data Races: A Visual Guide for Swift Developers)

数据竞争是并发编程中难以理解的核心概念。Krishna 通过一系列图片:几个 ToddlerBot(幼儿机器人)一起给同一张涂色页上色,以直观方式展示了共享可变状态在并发环境中如何引发混乱:从轻微的结果错乱,到读写交错导致的逻辑失效甚至崩溃。

本文最大的特色在于“图文并茂”。ToddlerBot 的视觉化叙事成功把传统上枯燥严肃的技术主题变得生动易懂。Krishna 表示后续文章将继续沿用 ToddlerBot 这一角色,构建一套连贯的 Swift 并发心智模型。


教 AI 读懂 Xcode 构建 (Teaching AI to Read Xcode Builds)

如果说当下 AI 在“写代码”这件事上未必优于经验丰富的开发者,那么在“看数据、拆问题”上,它几乎一定强过大多数人类,而且输入的信息越多、越结构化,优势越明显。苹果开源 swift-build 之后,Tuist 团队得以直接从构建服务中获取详尽的构建事件数据,并将其以结构化的方式写入 SQLite,让 AI 代理能够真正“理解”一次构建,而不只是被动解析 xcodebuild 的文本输出。在本文中,Pedro Piñera 详细介绍了这一尝试的实现路径,并通过在 Wikipedia iOS、Tuist 等真实项目上的实测,展示了 AI 如何基于这些结构化数据做出远超“读日志”的诊断和优化建议,为未来的实时构建可观测性以及真正“懂构建”的 AI 助手描绘出一条相当清晰的技术路线。

工具

SwiftUI-Popover: 支持 watchOS 的气泡提示库

尽管 SwiftUI 提供了 .popover 修饰器,但它在不同平台上的表现并不一致:iPhone 上会降级为 sheet,watchOS 则完全不支持。Quirin Schweigert 开发的 SwiftUI-Popover 是一个轻量级、纯 SwiftUI 实现的 Popover 库,提供跨平台一致的气泡提示功能,支持包括 watchOS 在内的所有 SwiftUI 平台。该库的特色在于箭头会自动跟随附着点位置,且可以灵活嵌入到任何视图层级中。

// 1. 附加 popover
  Image(systemName: "globe")
      .swiftUIPopover(
          isPresented: $showPopover,
          isDismissible: true,        // 可点击背景关闭
          isExclusive: true,           // 独占显示
          preferredAttachmentEdge: .top // 优先附着在顶部
      ) {
          Text("气泡内容")
      }

  // 2. 在容器视图上启用 popover 渲染
  .presentPopovers()

SwiftIR: Swift 的现代 ML 编译基础设施

目前 Swift 中可用的 ML 路径主要包括 Foundation 的 _Differentiation、手写 Accelerate/Metal,以及已经停更的 Swift for TensorFlow。但它们分别面临性能瓶颈、开发成本高或缺乏维护等问题。由 Pedro N. Rodriguez 开发的 SwiftIR,正是在这种背景下出现的解决方案。

SwiftIR 通过 DifferentiableTracer 拦截 Swift 原生自动微分(@differentiable)的运算过程,自动构建完整计算图,并编译到与 JAX/TensorFlow 相同的运行时(XLA/PJRT),最终在 CPU/GPU/TPU 上执行。项目最大的突破在于:While 循环编译时间保持常数(~43ms,传统展开需要数十分钟),梯度开销仅 ~1.0x(标准 Swift 为 2.5-4.3x),在大规模计算时性能显著优于标准 Swift。为 Swift 带来了真正现代化的 ML 编译基础设施。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

昨天以前掘金 iOS

iOS深入理解事件传递及响应

作者 Haha_bj
2025年12月1日 13:59

一、事件传递

事件传递相关的两个方法

// 哪个视图响应事件返回哪个  
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;     
// 点击位置是否在当前视图范围  
-(BOOL)pointInside(CGPoint)point withEvent:(UIEvent *)event; 

图片.png 如图,View A中包含View B1、View B2,View B2中包含View C1,View C2既包含View C1的一部分,又包含View B2的一部分,View C1中包含View D。当点击View C2的空白区域时,系统如何找到事件响应者为View C2?

(1)事件传递流程

当用户点击屏幕的某个位置,该事件会被传递给UIApplicationUIApplication又传递给当前的UIWindow,UIWindow会通过hitTest:WithEvent:方法返回响应的视图。hitTest:WithEvent:方法内部通过pointInside:withEvent:方法判断点击point是否在当前UIWindow范围内,如果在,则会遍历其中的所有子视图SubViews来查找最终响应此事件的视图,遍历方式为倒序遍历,即最后添加到UIWindow的视图最优先被遍历到,依次遍历,可以看作是递归调用。每个UIView中又都会调用其对应hitTest:WithEvent:方法,最终返回响应视图hit,如果hit有值,则hit视图就作为该事件的响应视图被返回,如果hit没有值,但在当前UIWindow范围内,则当前UIWindow作为事件的响应视图。

图片.png

(2)hitTest:WithEvent:系统内部实现

首先在hitTest:WithEvent:方法内部先判断当前视图的hidden属性、是否可交互、透明度是否大于0.01。如果该视图不同时满足上述3个条件,则返回nil,当前视图不作为事件的响应视图,当前视图的父视图继续遍历其他的子视图;如果该视图没有隐藏、用户可交互、透明度大于0.01,则会通过pointInside:WithEvent:方法判断点击的点是否在当前视图范围内,如果不在,则同样返回nil,当前视图仍不作为事件的响应者;如果在,则会通过倒序遍历当前视图的子视图,调用其子视图对应的hitTest:WithEvent:方法,如果某个视图返回了事件响应视图,则该返回的视图被作为事件的响应者,反之则继续遍历判断。如果遍历完后没有任何视图响应此事件,因为此事件点击的范围在当前视图范围内,则将当前视图作为事件响应者返回。

图片.png

二、视图事件响应

上述讲述了视图事件的传递流程,当视图事件传递后,最终事件由谁来响应呢,这就涉及视图的响应链、响应链的机制和流程。 如图,页面存在一个UILabel一个UITextField、一个UIButton,实线箭头表示下一个响应者。

图片.png

视图事件响应链相关的方法

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event

例如,当点击View C2的空白处时,事件由谁来响应呢?首先由View C2接收事件,如果它不处理,就会把事件传递给View B2,如果View B2还不响应这个事件,View B2会通过响应链将事件传递给它的父视图View A,如果还不响应,则会沿着响应链一直向上传递,直到传递到UIApplicationDelegate仍然不对事件进行处理,则会忽略此事件

图片.png

SwiftUI 最新数据模型完整解析:@Observable、@State、@Bindable(iOS17+ 全新范式)

作者 汉秋
2025年12月1日 11:22

自 iOS 17 起,SwiftUI 引入了 全新的 Observation 模型

它用三个核心工具彻底重塑了数据管理方式:

  • @Observable —— 定义可观察的状态模型

  • @State —— 持有模型实例,等价于旧时代的 @StateObject

  • @Bindable —— 在视图中实现对 Observable 模型的双向绑定

如果你还在用 ObservableObject、@Published、@StateObject、@ObservedObject、@EnvironmentObject,是时候升级了:新范式更简单、更 Swift、更高性能。

本文将系统梳理 SwiftUI 最新的数据管理体系。


🧱 一、旧数据体系的问题

iOS 16 及以前,我们管理状态基本依赖:

  • ObservableObject

  • @Published

  • @StateObject

  • @ObservedObject

  • @EnvironmentObject

这些机制的问题:

  • 装饰器太多,容易混乱

  • 生命周期容易搞错(尤其是 @StateObject vs @ObservedObject)

  • @Published 对属性执行全局广播,性能不够优雅

  • 环境写法不够类型安全

新模型的目标:让 SwiftUI 更简单、更自动、更智能。


🚀 二、@Observable:新时代核心

新系统中的任何可观察模型,只要声明:

@Observable
class UserModel {
    var name = "HanQiu"
    var age = 23
}

不再需要:

  • ObservableObject

  • @Published

  • 手动发布变更

所有存储属性都是可观察的,SwiftUI 会精确追踪变化来源。


🧩 三、@State取代@StateObject

在旧时代,创建页面级别持久的模型需要:

@StateObject var vm = UserModel()

在新系统中:

@State var vm = UserModel()

是的, @State 自动完成以前 @StateObject 的作用

  • 保持引用类型实例生命周期

  • 在视图重建中保持稳定

  • 触发视图刷新

只要你的模型是 @Observable 的,就可以用 @State 持有。


🧠 四、那@ObservedObject呢?—— 不需要了

旧写法(子视图):

struct ProfileView: View {
    @ObservedObject var vm: UserModel
}

新写法:

struct ProfileView: View {
    var vm: UserModel
}

SwiftUI 会自动观察视图中“被使用的属性”。

你不需要告诉它“这个对象可观察”,它本身就知道(因为模型是 @Observable)。


🌿 五、环境注入方式的升级

旧写法:

@EnvironmentObject var settings: SettingsModel

新写法更强、更明确:

注入

struct AppRoot: View {
    @State var settings = SettingsModel()

    var body: some View {
        MainView()
            .environment(settings)
    }
}

获取

@Environment(SettingsModel.self) var settings

减少误用,也更符合 Swift 语言本身的表达。


⭐ 六、重点:@Bindable的出现解决了什么?

@Observable 模型虽然自动可观察,但 UI 控件(如 TextField)需要 双向绑定

TextField("Name", text: $vm.name)

新模型中,属性只是普通 stored property,不是 Published,不具备 Binding 能力。

于是 Swift 引入:

✔@Bindable

为 View 提供 绑定视角的模型访问


🧲 七、@Bindable的标准用法

模型

@Observable
class UserModel {
    var name = ""
    var age = 18
}

视图(可编辑 UI)

struct EditUserView: View {
    @Bindable var user: UserModel

    var body: some View {
        Form {
            TextField("Name", text: $user.name)
            Stepper("Age: \(user.age)", value: $user.age)
        }
    }
}

只需标记 @Bindable,模型属性即可自动得到 $binding。


🧩 八、为什么不是所有时候都用@Bindable?

是否需要取决于:

情况 是否需要 @Bindable
仅用于展示,不会修改模型 ❌ No
需要用 TextField / Toggle / Stepper 修改模型 ✔ Yes
子视图要修改父模型 ✔ Yes
完全只读视图 ❌ No

越“表单”风格的页面,越需要 @Bindable。


🚦 九、@Bindable的局部绑定写法(推荐技巧)

你也可以只在 body 内使用 Bindable:

var body: some View {
    @Bindable var b = user   // 局部绑定

    VStack {
        TextField("Name", text: $b.name)
        Stepper("Age: \(b.age)", value: $b.age)
    }
}

不会污染结构体属性定义,适合仅局部可编辑的 UI。


🧭 十、三者关系总结(最重要)

@Observable   —— 使模型可观察
@State        —— 在 View 中持有模型(生命周期 = 旧 @StateObject@Bindable     —— 提供绑定能力,允许 UI 修改模型

一个“完整数据流”的表达式:

@Observable 定义状态 → @State 持有 → @Bindable 编辑 → SwiftUI 自动刷新


🧪 十一、完整示例:新 Paradigm 最佳实践

@Observable
class ProfileModel {
    var name = "HanQiu"
    var level = 1
}

struct ProfileView: View {
    @State var profile = ProfileModel()

    var body: some View {
        VStack {
            Text("Name: \(profile.name)")
            Text("Level: \(profile.level)")

            EditSection(profile: profile)
        }
    }
}

struct EditSection: View {
    @Bindable var profile: ProfileModel

    var body: some View {
        VStack {
            TextField("Name", text: $profile.name)
            Stepper("Level: \(profile.level)", value: $profile.level)
        }
        .padding()
    }
}

无需 @Published,不用 @StateObject,不需要 @ObservedObject。

SwiftUI 的数据管理彻底简化。


🧾 十二、迁移指南(旧 → 新)

旧 API 新 API
ObservableObject @Observable
@Published 不需要
@StateObject @State
@ObservedObject 删除,直接传模型
@EnvironmentObject .environment(model) + @Environment(Model.self)
双向绑定属性 使用 @Bindable

🎉 总结

SwiftUI 从 iOS17 开始进入 Observation 时代

  • @Observable → 自动观察

  • @State → 管理模型生命周期

  • @Bindable → 构建表单/编辑 UI 的关键

  • 更少的装饰器

  • 更精准的性能优化

  • 更符合 Swift 语言设计哲学

如果你写 SwiftUI,这套新范式未来几年都会是主流。


【ASO数据科学】拒绝盲猜:基于竞品归因模型的 App Store 关键词逆向工程实战

作者 goldenpig
2025年11月30日 19:47

【ASO数据科学】拒绝盲猜:基于竞品归因模型的 App Store 关键词逆向工程实战

摘要:在流量红利见顶的 2025 年,ASO(应用商店优化)已从单纯的运营手段演变为一门数据科学。本文提出一套基于“竞品归因模型”的增长方法论:通过监控版本迭代(Input)与榜单波动(Output)的时序相关性,逆向推导高权重关键词。文章包含数据采集逻辑、归因分析实战及 JSON 化的元数据管理方案。

关键词ASO优化 数据驱动增长 App Store算法 竞品分析 逆向工程 数据分析


一、 背景:当 ASO 遇见数据科学

在移动应用增长(Mobile Growth)领域,传统的关键词研究方法往往存在严重的“黑盒效应”:

  1. 依赖主观假设:仅凭直觉(Gut feeling)筛选关键词。
  2. 缺乏归因闭环:修改了 Metadata,却无法量化具体是哪个词带来了 DAU 增长。
  3. 数据滞后:第三方工具的“热度指数”往往滞后于真实的用户搜索行为。

作为开发者或增长黑客(Growth Hacker),我们需要将 ASO 视为一个工程问题。最高效的策略并非“重新发明轮子”,而是利用 Competitive Intelligence(竞品情报)

本文将基于专业数据分析工具 Appark.ai 的情报能力,拆解一套 4步逆向分析框架,帮助你建立数据驱动的增长引擎。


二、 步骤一:构建高维度的竞品画像库 (Competitor Mapping)

在 App Store 的推荐算法(Collaborative Filtering)中,竞争对手的定义早已超越了“功能相似”。凡是抢占了你目标流量入口(Keywords & User Time)的 App,皆为竞品。

为了获取具有统计学意义的样本,我们需要进行多维度的降维扫描

1.1 基于分类与地区的广度扫描

利用数据工具的“高级搜索”接口思维,通过参数筛选发现“隐形冠军”。

  • 逻辑:跨类别打击。
  • 案例:户外应用 AllTrails 虽然属于“导航”类需求,但被归类在 Health & Fitness。如果你是健身 App 开发者,忽略它就意味着失去了一大块“户外运动”场景的流量。
  • Action:筛选目标 Category 下 Top 50-100 的应用。这部分 App 通常没有大厂的品牌加持,能上榜全靠硬核的 ASO 策略,参考价值极高。

工具参考:Appark Advanced Search (高级搜索筛选器)

appark-advanced-search-filters.webp图 1:通过多维度过滤器发现潜在竞品

1.2 基于算法推荐的关联挖掘

利用 Apple/Google 的 Similar Apps 算法进行关联挖掘。

  • 技术原理:Item-based Collaborative Filtering。如果算法判定 App A 和 App B 相似,意味着它们的 元数据向量(Metadata Vector) 高度重合。
  • 应用:直接提取竞品详情页的关联 App 列表,作为关键词挖掘的种子库。

三、 步骤二:搭建自动化情报监控系统 (Event Monitoring)

数据分析的核心在于捕捉变化(Delta)。静态的关键词快照价值极低,我们需要构建一个基于时间序列的监控系统。

监控核心公式:

ΔMetadata (Input)+ΔRank (Output)High Confidence Strategy\Delta \text{Metadata (Input)} + \Delta \text{Rank (Output)} \Rightarrow \text{High Confidence Strategy}

2.1 配置 Webhook 级别的监控思维

我们需要建立类似 Webhook 的自动化机制。建议对筛选出的 5-10 个核心竞品开启以下 Alert:

  1. Version Updates:监控 Title, Subtitle, Description 的文本变更(Text Diff)。
  2. Rank Fluctuations:监控 Category Rank 和 Keyword Rank 的跳变。

配置入口:Appark Monitoring Dashboard

appark-monitoring-settings.webp图 2:建立自动化监控流


四、 步骤三:归因分析——逆向推导关键词策略

这是本指南最核心的**数据归因(Attribution)**环节。我们需要建立“动作”与“结果”的因果关系。

3.1 实战案例:AllTrails 的增长黑客战术复盘

场景复现: 通过监控系统,我们捕捉到竞品 AllTrails2025 年 6 月初 的一次异常信号。

Phase 1: 输入端分析 (Input Analysis)
  • Event:发布版本 v15.2
  • Diff Log
    • Added Feature: "AllTrails Peak" (高级会员)。
    • New Keywords Detected: Plan ahead (提前规划), Heatmaps (热力图), Offline maps (离线地图)。
Phase 2: 输出端验证 (Output Verification)

调取竞品的时间序列趋势图,观察窗口期内的 Downloads 曲线。

appark-competitor-trend-chart.png图 3:版本更新与下载量激增的时序关联

  • Observation:版本发布后 3 天内,下载量曲线出现明显的 Spike (尖峰),并稳定在新的 Baseline (基线)(由 70w/月 \uparrow 90w/月)。
  • Conclusion:该增长与“高级路线规划”相关关键词的覆盖呈强正相关

五、 步骤四:工程化落地——关键词 Backlog 管理

基于上述分析,我们不再是“猜词”,而是进行“词库移植”。建议使用 JSON 结构或数据库思维来管理你的 ASO 关键词资产,以便后续进行 A/B Test。

4.1 关键词意图提取 (Intent Extraction)

从竞品的成功中提取用户的高意图(High Intent)需求:

  • User Story: "我想规划徒步路线" \rightarrow Keywords: Hiking route planner, Trail map.
  • User Story: "我怕山里没信号" \rightarrow Keywords: Offline trail maps, GPS tracker.

4.2 建立结构化的元数据 JSON

为了方便版本管理,建议建立如下的关键词 backlog 结构:

{
  "aso_strategy_v1": {
    "target_audience": "Advanced Hikers",
    "source_competitor": "AllTrails",
    "validation_data": "Appark_Trend_June_2025",
    "metadata_structure": {
      "title": {
        "content": "Hiking & Trail Maps",
        "weight": "High",
        "keywords": ["Hiking", "Trail", "Maps"]
      },
      "subtitle": {
        "content": "Offline Route Planner & GPS",
        "weight": "Medium",
        "keywords": ["Offline", "Route Planner", "GPS"]
      },
      "keyword_field": [
        "trekking", "topo maps", "custom routes", "heatmaps", "outdoor navigation"
      ]
    }
  }
}

在实际操作中,将上述 JSON 中的 keywords 填入 App Store Connect 的对应字段即可。


六、 总结

ASO 本质上是一场信息不对称的博弈。通过数据可视化能力,我们可以将 ASO 流程标准化为一个科学闭环:

  1. Discover:利用高级搜索进行全域扫描。
  2. Monitor:自动化追踪版本迭代与榜单变化。
  3. Analyze:通过时序分析进行增长归因。
  4. Implement:基于验证策略进行工程化落地。

拒绝盲猜,让数据成为你增长引擎的燃料。


附录:参考资源与工具箱

为了方便读者复现文中的分析流程,以下整理了相关的数据源与官方文档:

安康记1.1.x版本发布

2025年11月30日 19:27

安康记新版本已经上线App Store。目前已经更新了独立用药功能,方便一些简单的小毛病可能会自己用药,可以跳过就诊直接进行记录。

图片

原本这个功能已经上线了有几天了,但因为一直测试发现有一些新的问题,这两周也提交了多次修复版本,目前算是相对稳定了,希望各位能更新体验♪(・ω・)ノ

swift的inout的用法

2025年11月30日 18:01

基础用法底层原理高级特性注意事项四个方面详细讲解。

1. 基础概念:为什么要用 inout?

在 Swift 中,函数的参数默认是常量(Constant/let) 。这意味着你不能在函数内部修改参数的值。

错误示例:

func doubleValue(value: Int) {
    value *= 2 // ❌ 报错:Left side of mutating operator isn't mutable: 'value' is a 'let' constant
}

如果你希望函数能修改外部传进来的变量,就需要使用 inout

正确示例:

func doubleValue(value: inout Int) {
    value *= 2
}

var myNumber = 10
// 调用时必须在变量前加 '&' 符号,显式表明这个值会被修改
doubleValue(value: &myNumber) 

print(myNumber) // 输出:20

2. 核心原理:输入输出模型 (Copy-In Copy-Out)

这是面试或深入理解时最重要的部分。虽然 inout 看起来像“引用传递”,但 Swift 官方将其描述为 Copy-In Copy-Out(输入复制,输出复制) ,也就是“值结果模式(Call by Value Result)”。

完整过程如下:

  1. Copy In(输入复制): 当函数被调用时,参数的值被复制一份传入函数内部。
  2. Modification(修改): 函数内部修改的是这个副本
  3. Copy Out(输出复制): 当函数返回时,修改后的副本值被**赋值(写回)**给原本的变量。

底层优化:

  • 对于物理内存中的变量:编译器通常会进行优化,直接传递内存地址(也就是真正的引用传递),避免不必要的复制开销。
  • 对于计算属性(Computed Properties) :必须严格执行 Copy-In Copy-Out 流程(因为计算属性没有物理内存地址,只有 getter 和 setter)。

代码证明(计算属性也能用 inout):

struct Rect {
    var width = 0
    var height = 0
    
    // 计算属性:面积
    var area: Int {
        get { width * height }
        set { 
            // 简单逻辑:假设保持 width 不变,调整 height
            height = newValue / width 
        }
    }
}

func triple(number: inout Int) {
    number *= 3
}

var square = Rect(width: 10, height: 10) // area = 100

// 这里传入的是计算属性 area
// 流程:
// 1. 调用 area 的 get,得到 100,Copy In 给 triple
// 2. triple 将 100 * 3 = 300
// 3. 函数结束,将 300 Copy Out,调用 area 的 set(300)
triple(number: &square.area)

print(square.height) // 输出:30 (因为 300 / 10 = 30)

3. inout 的常见应用场景

A. 交换值 (Standard Swap)

Swift 标准库的 swap 就是用 inout 实现的。

func mySwap<T>(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}

var x = 1
var y = 2
mySwap(&x, &y)
print("x: (x), y: (y)") // x: 2, y: 1

B. 修改复杂的结构体 (Mutating Structs)

当结构体嵌套很深时,使用 inout 可以避免冗长的赋值代码。

struct Color {
    var r: Int, g: Int, b: Int
}

struct Settings {
    var themeColor: Color
}

var appSettings = Settings(themeColor: Color(r: 0, g: 0, b: 0))

// 能够直接修改嵌套深处的属性
func updateBlueComponent(color: inout Color) {
    color.b = 255
}

// 传入路径
updateBlueComponent(color: &appSettings.themeColor)

print(appSettings.themeColor.b) // 255

4. 关键规则与内存安全 (Memory Safety)

这是 Swift 相比 C++ 指针更先进的地方。Swift 编译器会强制执行独占访问权限(Law of Exclusivity) ,防止内存冲突。

规则 1:同一个变量不能同时作为两个 inout 参数传递

如果两个 inout 参数指向同一个变量,会发生“别名(Aliasing)”问题,导致行为不可预测。

var step = 1

func increment(_ number: inout Int, by amount: inout Int) {
    number += amount
}

// ❌ 运行时崩溃或编译错误:Simultaneous accesses to 0x...
// increment(&step, by: &step) 

规则 2:不能将 let 常量或字面量作为 inout 参数

因为它们本质上不可写。

Swift

func change(val: inout Int) {}

// change(val: &5) // ❌ 错误:字面量不可变
let num = 10
// change(val: &num) // ❌ 错误:常量不可变

规则 3:inout 参数在闭包中的捕获(Capture)

inout 参数在逃逸闭包(Escaping Closure)中是不能被捕获的,因为逃逸闭包可能在函数返回后才执行,而那时 inout 的生命周期(Copy-In Copy-Out 过程)已经结束了。

func performAsync(action: @escaping () -> Void) {
    // 异步执行...
}

func badFunction(x: inout Int) {
    // ❌ 错误:Escaping closure captures 'inout' parameter 'x'
    /*
    performAsync {
        x += 1 
    }
    */
}

解决办法: 使用非逃逸闭包,或者显式地捕获变量的副本(如果逻辑允许)。


5. inout vs 类 (Reference Types)

这是一个常见的误区: “类本来就是引用类型,还需要 inout 吗?”

  • 不需要 inout 如果你只想修改类实例内部的属性。
  • 需要 inout 如果你想替换掉整个类实例本身(即改变指针的指向)。

代码对比:

class Hero {
    var name: String
    init(name: String) { self.name = name }
}

// 情况 1:修改内部属性(不需要 inout)
func renameHero(hero: Hero) {
    hero.name = "Batman" // 合法,因为 hero 引用本身没变,变的是堆内存里的数据
}

var h1 = Hero(name: "Superman")
renameHero(hero: h1)
print(h1.name) // Batman

// 情况 2:替换整个实例(需要 inout)
func switchHero(hero: inout Hero) {
    hero = Hero(name: "Iron Man") // 将外部变量指向全新的内存地址
}

var h2 = Hero(name: "Spiderman")
switchHero(hero: &h2)
print(h2.name) // Iron Man

总结

  1. 语法: 定义用 inout,调用用 &
  2. 本质: Copy-In Copy-Out(值结果模式),但在物理内存操作上通常优化为引用传递。
  3. 使用场景: 需要在函数内部修改外部值类型(Struct/Enum)状态,或交换数据。
  4. 限制: 遵守独占访问原则(Exclusivity),不可在逃逸闭包中捕获。

iOS 实现微信读书的仿真翻页

2025年11月30日 15:35

先看效果

仿真翻页效果: 在这里插入图片描述

普通翻页效果:

在这里插入图片描述

实现方案

iOS 中实现翻页效果比较简单,直接使用系统提供的 UIPageViewController 即可做到。

UIPageViewController 是 UIKit 中的分页控制器,它允许用户通过横向或纵向滑动手势在多个页面(ViewController)之间切换,主要配置的两个属性如下:

1)UIPageViewControllerTransitionStyle

  • .pageCurl:仿真翻页
  • .scroll:类似 UIScrollView 自然滑动

2)UIPageViewControllerNavigationOrientation

  • .horizontal:左右翻页
  • .vertical:上下翻页

以仿真翻页配置为例子:

class BookReaderViewController: UIViewController {

    // 模拟书籍数据
    private let bookPages = [
        "第一章:Swift 的起源\n\nSwift 是一种由 Apple 开发的强大且直观的编程语言...",
        "第二章:UIKit 基础\n\nUIKit 提供了构建 iOS 应用程序所需的关键对象...",
        "第三章:动画艺术\n\n核心动画 (Core Animation) 是 iOS 界面流畅的关键...",
        "第四章:高级翻页\n\nUIPageViewController 是实现仿真翻页的神器...",
        "终章:未来展望\n\n随着 SwiftUI 的普及,声明式 UI 正在改变世界..."
    ]

    private var pageViewController: UIPageViewController!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupPageViewController()
    }

    private func setupPageViewController() {
        // 关键设置:transitionStyle = .pageCurl (仿真翻页效果)
        // navigationOrientation = .horizontal (水平翻页)
        pageViewController = UIPageViewController(transitionStyle: .pageCurl,
                                                  navigationOrientation: .horizontal,
                                                  options: nil)

        pageViewController.dataSource = self
        pageViewController.delegate = self

        // 设置初始页面
        if let firstPage = getViewController(at: 0) {
            pageViewController.setViewControllers([firstPage], direction: .forward, animated: false, completion: nil)
        }

        // 将 PageVC 添加到当前 VC
        addChild(pageViewController)
        view.addSubview(pageViewController.view)
        pageViewController.view.frame = view.bounds
        pageViewController.didMove(toParent: self)

        // 解决仿真翻页背面颜色问题 (让背面也是纸张色,而不是默认的半透明或白色)
        // 注意:这是一个比较 Hack 的方法,更完美的做法是自定义背面的 Layer
        pageViewController.view.backgroundColor = UIColor(red: 248/255, green: 241/255, blue: 227/255, alpha: 1.0)
    }

    // 辅助方法:根据索引获取 VC
    private func getViewController(at index: Int) -> BookPageViewController? {
        guard index >= 0 && index < bookPages.count else { return nil }
        return BookPageViewController(index: index, totalPage: bookPages.count, content: bookPages[index])
    }
}

// MARK: - 3. DataSource 实现 (核心逻辑)
extension BookReaderViewController: UIPageViewControllerDataSource {

    // 获取"上一页"
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let currentVC = viewController as? BookPageViewController else { return nil }
        let previousIndex = currentVC.pageIndex - 1
        return getViewController(at: previousIndex)
    }

    // 获取"下一页"
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let currentVC = viewController as? BookPageViewController else { return nil }
        let nextIndex = currentVC.pageIndex + 1
        return getViewController(at: nextIndex)
    }
}

// MARK: - 4. Delegate (可选,用于处理翻页后的状态)
extension BookReaderViewController: UIPageViewControllerDelegate {
    // 这里可以处理 spineLocation,例如横屏时显示双页
    func pageViewController(_ pageViewController: UIPageViewController, spineLocationFor orientation: UIInterfaceOrientation) -> UIPageViewController.SpineLocation {
        // 手机竖屏通常是单页 (.min)
        return .min
    }
}

如上不到百行的代码即可实现仿真翻页效果,手势在 UIPageViewController 中会自动处理,外部不用感知。

和 UITableView 使用类似,需要通过 UIPageViewControllerDataSource 来提供上一页和下一页的数据源,通过 UIPageViewControllerDelegate 来感知翻页时机。

UIPageViewControllerDelegate 主要提供三个时机:

1)willTransitionTo:当用户开始滑动翻页的时候触发,系统已经准备好目标页,通过该回调来告诉你将要显示哪个页面(pendingViewControllers)

/// pendingViewControllers: 将要显示的页面
func pageViewController(_ pageViewController: UIPageViewController,
                        willTransitionTo pendingViewControllers: [UIViewController])

2)didFinishAnimating:当用户的翻页动画结束时回调

/// finished: 动画是否完成
/// previousViewControllers: 原来显示的ViewController
/// completed: 最终是否翻页成功;比如滑一半又拖回去,不会真正翻页
func pageViewController(_ pageViewController: UIPageViewController,
                        didFinishAnimating finished: Bool,
                        previousViewControllers: [UIViewController],
                        transitionCompleted completed: Bool)

3)spineLocationFor:控制仿真书本翻页时 “书脊” 的位置,只在 UIPageViewControllerTransitionStyle 为 pageCurl 时有效

/// SpineLocation:
/// - none: 没书脊
/// - min: 书脊在左边(单页模式)
/// - mid: 书脊在中间(双页模式)
/// - max: 书脊在右边(单页模式)
func pageViewController(_ pageViewController: UIPageViewController,
                        spineLocationFor orientation: UIInterfaceOrientation)
-> UIPageViewController.SpineLocation

如果要配置普通翻页效果,只需要修改 UIPageViewController 的配置即可:

// Options: 设置页面之间的间距 (微信读书一般有 10-20pt 的间距)
let options: [UIPageViewController.OptionsKey: Any] = [
    .interPageSpacing: 20
]

// 核心修改 1: transitionStyle 改为 .scroll
pageViewController = UIPageViewController(transitionStyle: .scroll,
                                          navigationOrientation: .horizontal,
                                          options: options)

另外翻页手势通常和系统的侧滑返回手势有冲突,可以手动禁用手势来解决;类似微信读书一样,在导航栏出现时才开启侧滑返回手势,否则禁用侧滑返回:

private func updateGesture() {
    if isNaviBarHidden { // 导航栏隐藏:禁用侧滑,开启翻页手势
        navigationController?.interactivePopGestureRecognizer?.isEnabled = false
        for gesture in pageViewController.gestureRecognizers {
            gesture.isEnabled = true
        }
    } else { // 导航栏显示:开启侧滑,禁用翻页手势
        navigationController?.interactivePopGestureRecognizer?.isEnabled = true
        for gesture in pageViewController.gestureRecognizers {
            gesture.isEnabled = false
        }
    }
}

WKWebView的重定向(objective_c)

2025年11月29日 18:12

背景

第三方支付回调时需要重定向到app的某个页面,比如支付完成后回到原生订单详情页,这个时间会有两种情况:

1、直接在web页面重定向到app的订单详情页,这个时候只需要实现 WKNavigationDelegate 中的一个核心方法webView:decidePolicyForNavigationAction:decisionHandler: 方法。

2、在支付中心跳转到第三方app然后支付完成后需要跳转回自己的app的订单详情页,这个时候可以采用Scheme方式或者是通用链接的方式解决

wkWebView重定向实现

实现这一目标,您需要让您的 WKWebView 所在的控制器遵循 WKNavigationDelegate 协议,并实现 webView:decidePolicyForNavigationAction:decisionHandler: 方法。

self.webView.navigationDelegate = self; // 设置代理

#pragma mark - WKNavigationDelegate 
- (**void**)webView:(WKWebView *)webView

decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction

decisionHandler:(**void** (^)(WKNavigationActionPolicy))decisionHandler {
    NSURL *url = navigationAction.request.URL;
    NSString *scheme = url.scheme;
    // 1. 检查 URL Scheme 是否是我们的自定义 Scheme
    if ([scheme isEqualToString:@"coolpet"]) {
        // 1.1. 阻止 WKWebView 加载这个 URL
        decisionHandler(WKNavigationActionPolicyCancel);
        // 1.2. 实现了 handleCoolPetURL: 方法
        [self handleCoolPetURL:url];
        // 1.3. 跳转后关闭当前的 WebView 页面
        [self.navigationController popViewControllerAnimated:YES];
        return;
    }
    // 2. 对于其他 HTTP/HTTPS 链接,允许正常加载
    // 特别检查 navigationType 是否是新的主框架加载,例如用户点击了链接
//    if (navigationAction.navigationType == WKNavigationTypeLinkActivated && ![scheme hasPrefix:@"http"]) {
//        // 如果是点击了非 HTTP/HTTPS 的链接(但不是我们自定义的 Scheme),可以根据需要处理,
//        // 比如打开 App Store 或其他应用。这里我们通常允许其他系统 Scheme
//        // 允许继续,但更安全的做法是只允许 http(s)
//        // decisionHandler(WKNavigationActionPolicyAllow);
//    }
    // 3. 默认允许其他所有导航行为(如页内跳转、HTTP/HTTPS 加载等)
    decisionHandler(WKNavigationActionPolicyAllow);
}

// 通过URL跳转对应页面
- (void)handleCoolPetURL:(NSURL *)url {
    NSString *host = url.host;
    NSString *path = url.path;      // 路径: /order/detail
    NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
    NSMutableDictionary *queryParams = [NSMutableDictionary dictionary];
    for (NSURLQueryItem *item in components.queryItems) {
        queryParams[item.name] = item.value;
    }
    // 根据路径判断是否是订单详情页
    if ([host isEqualToString:kAPPUniversalTypeOrderDetailsHost] && [path isEqualToString:kAPPUniversalTypeOrderDetailsPath]) {
        // 获取我们需要的订单号
        NSString *tradeNo = [queryParams[@"tradeNo"] stringValue];
        // 执行跳转

        if (tradeNo.length > 0) {
            dispatch_async(dispatch_get_main_queue(), ^{
                /// 做跳转
            });
        }
    }
}

Scheme方式

第三方支付平台完成支付后,是通过你App的 URL Scheme 来唤醒你的App并携带支付结果的。

  1. 配置 App URL Scheme
  • 操作: 在 Xcode 项目的 Info.plist 或项目设置的 Info 选项卡下的 URL Types 中添加你的 App 的 Scheme。

    • 例如,你可以设置一个 Scheme 叫 myscheme
  1. 处理 App Delegate 中的回调

App 被第三方支付应用唤醒后,系统会调用 AppDelegate 中的特定方法。你需要在这里接收并处理回调 URL。

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, **id**> *)options {
    // 1. 检查是否是你的支付回调 Scheme
    if ([url.scheme isEqualToString:@"myappscheme"]) {
        [self handleCoolPetURL:url];
    }

    // 如果是其他URL(如通用链接),也在这里处理
    // ...
    return NO;
}

通用链接方式

当用户点击一个配置了通用链接的 HTTPS 链接时:

  1. 如果 App 已经安装,系统会直接调用 AppDelegate 中的这个方法。
  2. 如果 App 未安装,该链接会直接在 Safari 中打开。

这个机制的主要优点是安全(基于 HTTPS)和用户体验更好(避免了 URL Scheme 引起的跳转确认和安全问题)。

🔗 通用链接(Universal Links)实现指南

步骤 1: 服务器端配置(Association File)

这是通用链接能够工作的基础。您需要在您的 Web 服务器上创建一个特殊的 JSON 文件,告诉 iOS 系统哪些路径应该由您的 App 处理。

1. 创建 apple-app-site-association 文件
  • 文件名: 必须是 apple-app-site-association(注意,没有 .json 扩展名)。

  • 内容格式(JSON):

    {
        "applinks": {
            "apps": [],
            "details": [
                {
                    "appID": "TeamID.BundleID",
                    "paths": [
                        "/orders/*",    // 匹配所有 /orders/ 下的路径
                        "/products/*",  // 匹配所有 /products/ 下的路径
                        "NOT /account/login/*" // 排除某些路径
                    ]
                }
            ]
        }
    }
    
    • TeamID 您的 Apple Developer Team ID。
    • BundleID 您的 App 的 Bundle Identifier。
    • paths 定义您希望 App 能够处理的 URL 路径。
2. 部署文件
  • 部署位置: 将此文件上传到您的域名根目录或 .well-known/ 目录下。

    • 例如:https://yourdomain.com/apple-app-site-association
    • 或者:https://yourdomain.com/.well-known/apple-app-site-association
  • 内容类型: 确保服务器以正确的 MIME 类型提供此文件:application/jsontext/plain

  • HTTPS: 您的整个网站必须使用 HTTPS

步骤 2: App 端配置(Xcode & Objective-C)

1. 开启 Associated Domains Capability

在 Xcode 中为您的 App 开启 Associated Domains 功能。

  • 路径: Xcode -> 项目设置 -> 目标 (Target) -> Signing & Capabilities 选项卡

  • 操作: 点击 + Capability,添加 Associated Domains

  • 添加域名: 在列表中添加您的域名,格式为:

    applinks:yourdomain.com
    

    注意: 不带 https://http://

2. 在 AppDelegate 中接收回调

当用户点击一个通用链接并唤醒 App 时,系统会调用 AppDelegate 中的 continueUserActivity 方法。您需要在此方法中解析 URL 并进行页面跳转。

// AppDelegate.m

#import "OrderViewController.h" // 假设您的订单处理页面

// ...

- (BOOL)application:(UIApplication *)application 
continueUserActivity:(NSUserActivity *)userActivity 
  restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
    
    // 1. 检查活动类型是否为 Universal Link
    if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
        
        // 2. 获取用户点击的 HTTPS URL
        NSURL *webpageURL = userActivity.webpageURL;
        
        if (webpageURL) {
            NSLog(@"Received Universal Link: %@", webpageURL.absoluteString);
            
            // 3. 将 URL 转发给路由处理方法
            [self handleUniversalLinkURL:webpageURL];
            
            return YES;
        }
    }
    
    return NO;
}

// 通用链接路由处理方法
- (void)handleUniversalLinkURL:(NSURL *)url {
    
    // 示例:解析路径并跳转到订单详情
    if ([url.path hasPrefix:@"/orders/detail"]) {
        
        // 解析查询参数,例如 order_id=12345
        NSString *orderID = [self extractParameter:@"order_id" fromURL:url];
        
        if (orderID.length > 0) {
            dispatch_async(dispatch_get_main_queue(), ^{
                // 执行跳转逻辑
                UINavigationController *nav = (UINavigationController *)self.window.rootViewController;
                OrderViewController *orderVC = [[OrderViewController alloc] init];
                orderVC.orderID = orderID;
                [nav pushViewController:orderVC animated:YES];
            });
        }
    }
}

// 辅助方法 (需要您自行实现,或使用前文提到的 dictionaryWithQueryString: 方法)
- (NSString *)extractParameter:(NSString *)paramName fromURL:(NSURL *)url {
    // ... 解析 url.query 字符串,提取指定参数 ...
    return nil; 
}

iOS 语音房(拍卖房)开发实践

作者 KangJX
2025年11月29日 17:49

本文基于一个真实的iOS语音房项目案例,详细讲解如何使用状态模式来管理复杂的业务流程,以及如何与权限中心协同工作,因为在拍卖房间中不只有不同的房间阶段变化(状态)还有不同角色拥有不同的权限(权限中心)。

业务场景

拍拍房是一个实时拍卖房间系统,类似于语音房间+拍卖的结合体。用户可以在房间内:

  • 作为房主主持拍卖
  • 作为拍卖人上传物品并介绍
  • 作为竞拍者出价竞拍
  • 作为观众观看拍卖过程

核心业务流程

一个完整的拍卖流程需要经历4个明确的阶段:

准备阶段 → 上拍 → 拍卖中 → 定拍 → (循环)准备阶段

每个阶段都有:

  • 不同的允许操作(如只能在准备阶段上传物品)
  • 不同的状态转换规则(如只能从拍卖中进入定拍)
  • 不同的业务逻辑(如只有拍卖中才能出价)

技术挑战

  1. 状态多:4个主要状态,每个状态行为差异大
  2. 转换复杂:状态之间的转换有严格的规则
  3. 权限交织:每个操作还需要考虑用户角色权限
  4. 易扩展性:未来可能增加新的拍卖模式

为什么选择状态模式

❌ 不使用状态模式的问题

如果使用传统的 if-elseswitch-case 来处理:

// 反例:所有逻辑堆砌在一起
func placeBid(amount: Decimal) {
    if currentState == .preparing {
        print("拍卖还未开始")
        return
    } else if currentState == .listing {
        print("拍卖还未正式开始")
        return
    } else if currentState == .auctioning {
        // 执行出价逻辑
        if user.role == .viewer {
            print("观众不能出价")
            return
        }
        if user.id == auctioneer.id {
            print("拍卖人不能给自己出价")
            return
        }
        if amount < currentPrice + incrementStep {
            print("出价金额不足")
            return
        }
        // 终于可以出价了...
    } else if currentState == .closed {
        print("拍卖已结束")
        return
    }
}

问题显而易见

  1. 🔴 代码臃肿:所有状态的逻辑混在一起
  2. 🔴 难以维护:修改一个状态可能影响其他状态
  3. 🔴 不易扩展:增加新状态需要修改多处代码
  4. 🔴 权限混乱:业务逻辑和权限判断纠缠在一起
  5. 🔴 测试困难:无法单独测试某个状态的逻辑

✅ 使用状态模式的优势

// 状态模式:每个状态独立处理
class AuctioningState: RoomStateProtocol {
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        // 只关注拍卖中状态的出价逻辑
        let bid = Bid(...)
        room.addBid(bid)
        return true
    }
}

class PreparingState: RoomStateProtocol {
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        // 准备阶段直接拒绝
        print("拍卖还未开始")
        return false
    }
}

优势明显

  1. 职责单一:每个状态类只关注自己的逻辑
  2. 易于维护:修改某个状态不影响其他状态
  3. 开闭原则:新增状态只需添加新类,不修改现有代码
  4. 清晰直观:状态转换一目了然
  5. 便于测试:可以单独测试每个状态

状态模式设计

整体架构

┌─────────────────────────────────────────┐
│           Room(房间上下文)             │
│  - currentState: RoomStateProtocol      │
│  - changeState(to: RoomState)           │
└──────────────┬──────────────────────────┘
               │ 持有
               ↓
┌─────────────────────────────────────────┐
│      RoomStateProtocol(状态协议)       │
│  + startAuction(room: Room) -> Bool     │
│  + placeBid(room: Room, ...) -> Bool    │
│  + endAuction(room: Room) -> Bool       │
│  + uploadItem(room: Room, ...) -> Bool  │
└─────────────┬───────────────────────────┘
              │ 实现
    ┌─────────┼─────────┬─────────┐
    ↓         ↓         ↓         ↓
┌──────┐ ┌────────┐ ┌────────┐ ┌────────┐
│准备  │ │上拍    │ │拍卖中  │ │定拍    │
│State │ │State   │ │State   │ │State   │
└──────┘ └────────┘ └────────┘ └────────┘

核心组件

1. 状态枚举

enum RoomState: String {
    case preparing      // 准备阶段
    case listing        // 上拍
    case auctioning     // 拍卖中
    case closed         // 定拍
}

2. 状态协议

protocol RoomStateProtocol {
    var stateName: RoomState { get }
    
    // 状态转换
    func startAuction(room: Room) -> Bool
    func endAuction(room: Room) -> Bool
    
    // 业务操作
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool
    
    // 状态描述
    func getStateDescription() -> String
}

状态转换图

┌─────────────┐
│  准备阶段    │ 拍卖人上传物品、设置规则
│  Preparing  │ 房主可以开始拍卖
└──────┬──────┘
       │ startAuction()
       ↓
┌─────────────┐
│    上拍     │ 展示物品信息
│   Listing   │ 倒计时准备(3秒)
└──────┬──────┘
       │ 自动转换 / 房主提前开始
       ↓
┌─────────────┐
│   拍卖中    │ 用户可以出价
│ Auctioning  │ 倒计时重置机制
└──────┬──────┘
       │ endAuction() / 倒计时归零
       ↓
┌─────────────┐
│    定拍     │ 展示成交结果
│   Closed    │ 可以开启下一轮
└──────┬──────┘
       │ startAuction() (开启下一轮)
       ↓
┌─────────────┐
│  准备阶段    │ 回到初始状态
│  Preparing  │
└─────────────┘

具体实现

1. 准备阶段(Preparing)

class PreparingState: RoomStateProtocol {
    var stateName: RoomState { return .preparing }
    
    // ✅ 允许:开始拍卖
    func startAuction(room: Room) -> Bool {
        guard room.currentItem != nil else {
            print("⚠️ 没有拍卖物品,无法开始")
            return false
        }
        
        // 状态转换:准备 → 上拍
        room.changeState(to: .listing)
        
        // 3秒后自动进入拍卖中
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            room.changeState(to: .auctioning)
        }
        
        return true
    }
    
    // ❌ 不允许:出价
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        print("⚠️ 拍卖还未开始,无法出价")
        return false
    }
    
    // ✅ 允许:上传物品
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool {
        room.setAuctionItem(item, rules: rules)
        return true
    }
    
    // ❌ 不允许:结束拍卖
    func endAuction(room: Room) -> Bool {
        print("⚠️ 拍卖还未开始")
        return false
    }
    
    func getStateDescription() -> String {
        return "准备阶段:拍卖人可以上传物品并设置规则"
    }
}

关键点

  • ✅ 只允许上传物品和开始拍卖
  • ✅ 自动触发状态转换(准备 → 上拍 → 拍卖中)
  • ✅ 逻辑清晰,职责单一

2. 上拍阶段(Listing)

class ListingState: RoomStateProtocol {
    var stateName: RoomState { return .listing }
    
    // ✅ 允许:房主提前开始
    func startAuction(room: Room) -> Bool {
        room.changeState(to: .auctioning)
        return true
    }
    
    // ❌ 不允许:出价
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        print("⚠️ 拍卖还未正式开始,无法出价")
        return false
    }
    
    // ❌ 不允许:修改物品
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool {
        print("⚠️ 上拍阶段无法修改物品")
        return false
    }
    
    // ❌ 不允许:结束拍卖
    func endAuction(room: Room) -> Bool {
        print("⚠️ 拍卖还未正式开始")
        return false
    }
    
    func getStateDescription() -> String {
        return "上拍中:展示拍卖物品,倒计时后自动开始"
    }
}

关键点

  • 🎯 过渡状态:用于展示物品信息
  • ✅ 房主可以提前开始
  • ❌ 大部分操作被禁止,保证流程的严谨性

3. 拍卖中(Auctioning)⭐ 核心状态

class AuctioningState: RoomStateProtocol {
    var stateName: RoomState { return .auctioning }
    
    // ❌ 不允许:重复开始
    func startAuction(room: Room) -> Bool {
        print("⚠️ 拍卖已经在进行中")
        return false
    }
    
    // ✅ 允许:结束拍卖
    func endAuction(room: Room) -> Bool {
        room.changeState(to: .closed)
        
        if let winner = room.currentBid {
            room.addSystemMessage("🎉 成交!恭喜 (winner.bidderName) 以 ¥(winner.price) 拍得")
        } else {
            room.addSystemMessage("流拍:没有人出价")
        }
        
        return true
    }
    
    // ✅ 允许:出价(核心逻辑)
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        // 创建出价记录
        let bid = Bid(
            id: UUID().uuidString,
            price: amount,
            bidderId: user.id,
            bidderName: user.nickname,
            timestamp: Date()
        )
        
        // 记录出价
        room.addBid(bid)
        
        print("💰 (user.nickname) 出价 ¥(amount)")
        
        // 这里可以重置倒计时(简化版省略)
        // resetCountdown()
        
        return true
    }
    
    // ❌ 不允许:修改物品
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool {
        print("⚠️ 拍卖进行中,无法修改物品")
        return false
    }
    
    func getStateDescription() -> String {
        return "拍卖中:竞拍者可以出价,倒计时结束后定拍"
    }
}

关键点

  • 💰 核心业务逻辑:处理出价
  • 📊 实时更新:记录每次出价
  • ⏱️ 倒计时机制:有出价时重置(可扩展)
  • 🔄 状态转换:可以结束进入定拍

4. 定拍阶段(Closed)

class ClosedState: RoomStateProtocol {
    var stateName: RoomState { return .closed }
    
    // ✅ 允许:开启下一轮
    func startAuction(room: Room) -> Bool {
        // 重置房间状态
        room.changeState(to: .preparing)
        room.currentItem = nil
        room.currentBid = nil
        room.addSystemMessage("🔄 准备下一轮拍卖")
        return true
    }
    
    // ❌ 不允许:出价
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        print("⚠️ 拍卖已经结束,无法出价")
        return false
    }
    
    // ❌ 不允许:重复结束
    func endAuction(room: Room) -> Bool {
        print("⚠️ 拍卖已经结束")
        return false
    }
    
    // ❌ 不允许:上传物品
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool {
        print("⚠️ 拍卖已结束,请开启下一轮")
        return false
    }
    
    func getStateDescription() -> String {
        return "已定拍:拍卖结束,可以开启下一轮"
    }
}

关键点

  • 🎉 展示成交结果
  • 🔄 支持循环拍卖:可以开启下一轮
  • 🔒 所有拍卖操作被锁定

与权限中心协作

设计哲学:分离关注点

┌─────────────────────────────────────┐
│         用户发起操作                 │
└──────────────┬──────────────────────┘
               ↓
┌─────────────────────────────────────┐
│      RoomManager(协调层)           │
└──────────────┬──────────────────────┘
               ↓
        ┌──────┴──────┐
        ↓             ↓
┌──────────────┐ ┌──────────────┐
│ 权限中心      │ │ 状态对象      │
│"能不能做"    │ │"怎么做"      │
└──────────────┘ └──────────────┘

协作流程

class RoomManager {
    func placeBid(user: User, room: Room, amount: Decimal) -> Result<Void, RoomError> {
        // 第一步:权限中心检查"能不能做"
        let result = permissionCenter.checkPermission(
            action: .placeBid,
            user: user,
            room: room,
            metadata: ["amount": amount]
        )
        
        guard result.isAllowed else {
            return .failure(.permissionDenied(result.deniedReason ?? "权限不足"))
        }
        
        // 第二步:状态对象执行"怎么做"
        let success = room.stateObject.placeBid(room: room, user: user, amount: amount)
        
        if success {
            return .success(())
        } else {
            return .failure(.operationFailed("出价失败"))
        }
    }
}

权限规则示例

// 权限中心:检查"能不能做"
PermissionRule(
    action: .placeBid,
    priority: 100,
    description: "只能在拍卖中状态出价"
) { context in
    guard context.room.state == .auctioning else {
        return .denied(reason: "❌ 当前不在拍卖阶段,无法出价")
    }
    return .allowed
}

PermissionRule(
    action: .placeBid,
    priority: 90,
    description: "拍卖人不能给自己出价"
) { context in
    if context.user.role == .auctioneer,
       context.user.id == context.room.currentItem?.auctioneerId {
        return .denied(reason: "❌ 您是拍卖人,不能对自己的物品出价")
    }
    return .allowed
}

为什么要分离?

如果不分离

// ❌ 反例:状态和权限混在一起
class AuctioningState {
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        // 权限判断
        if user.role == .viewer {
            return false
        }
        if user.role == .auctioneer && user.id == auctioneer.id {
            return false
        }
        if amount < currentPrice + increment {
            return false
        }
        
        // 业务逻辑
        room.addBid(...)
        return true
    }
}

分离后

// ✅ 状态对象:只关心业务逻辑
class AuctioningState {
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        room.addBid(...)  // 纯粹的业务逻辑
        return true
    }
}

// ✅ 权限中心:只关心权限验证
PermissionCenter.check(.placeBid, user, room)

优势

  1. 单一职责:状态对象不关心权限
  2. 易于扩展:新增权限规则不影响状态
  3. 易于测试:可以独立测试权限和状态
  4. 灵活配置:权限规则可以动态调整

实际应用场景

场景1:完整拍卖流程

// 1. 创建房间(自动进入准备阶段)
let room = Room(name: "今晚靓号专场", owner: host)
print("房间状态:(room.state.displayName)") // 准备中

// 2. 拍卖人上传物品
let item = AuctionItem(name: "手机号 13888888888", ...)
room.stateObject.uploadItem(room: room, item: item, rules: rules)
// ✅ 准备阶段允许上传物品

// 3. 房主开始拍卖
room.stateObject.startAuction(room: room)
// 状态转换:准备 → 上拍
print("房间状态:(room.state.displayName)") // 上拍中

// 4. 3秒后自动进入拍卖中
// 状态转换:上拍 → 拍卖中
print("房间状态:(room.state.displayName)") // 拍卖中

// 5. 竞拍者出价
room.stateObject.placeBid(room: room, user: bidder1, amount: 120)
// ✅ 拍卖中状态允许出价
print("当前价格:¥(room.currentPrice)") // ¥120

room.stateObject.placeBid(room: room, user: bidder2, amount: 150)
print("当前价格:¥(room.currentPrice)") // ¥150

// 6. 房主结束拍卖
room.stateObject.endAuction(room: room)
// 状态转换:拍卖中 → 定拍
print("房间状态:(room.state.displayName)") // 已定拍
print("成交:(room.currentLeader) - ¥(room.currentPrice)")

// 7. 开启下一轮
room.stateObject.startAuction(room: room)
// 状态转换:定拍 → 准备
print("房间状态:(room.state.displayName)") // 准备中

场景2:错误的操作被拒绝

// 尝试在准备阶段出价
let room = Room(...)
room.stateObject.placeBid(room: room, user: bidder, amount: 200)
// ❌ 输出:"拍卖还未开始,无法出价"
// 返回:false

// 尝试在拍卖中上传物品
room.stateObject.startAuction(room: room)  // 进入拍卖中
room.stateObject.uploadItem(room: room, item: item, rules: rules)
// ❌ 输出:"拍卖进行中,无法修改物品"
// 返回:false

// 尝试在定拍后出价
room.stateObject.endAuction(room: room)  // 进入定拍
room.stateObject.placeBid(room: room, user: bidder, amount: 300)
// ❌ 输出:"拍卖已经结束,无法出价"
// 返回:false

场景3:状态转换的严格性

let room = Room(...)

// ✅ 正确的转换
room.state  // .preparing
room.stateObject.startAuction(room: room)
room.state  // .listing → .auctioning

// ❌ 不允许跳过状态
room.state  // .preparing
room.stateObject.endAuction(room: room)
// 输出:"拍卖还未开始"
// 状态不变,仍然是 .preparing

优势与挑战

✅ 优势

1. 代码组织清晰

对比

传统方式(500行的switch):

func handleOperation() {
    switch currentState {
    case .preparing:
        // 100行代码
    case .listing:
        // 100行代码
    case .auctioning:
        // 200行代码
    case .closed:
        // 100行代码
    }
}

状态模式(每个文件<100行):

PreparingState.swift    // 80行
ListingState.swift      // 60行
AuctioningState.swift   // 100行
ClosedState.swift       // 60行

2. 易于维护

修改"拍卖中"的逻辑:

  • ❌ 传统方式:在500行代码中找到对应的case,小心翼翼地修改
  • ✅ 状态模式:直接打开AuctioningState.swift,放心修改

3. 符合开闭原则

新增"暂停"状态:

  • ❌ 传统方式:修改所有的switch语句,增加新的case
  • ✅ 状态模式:创建PausedState.swift,不修改现有代码

4. 便于测试

// 可以单独测试某个状态
func testAuctioningState() {
    let state = AuctioningState()
    let room = MockRoom()
    let result = state.placeBid(room: room, user: mockUser, amount: 100)
    XCTAssertTrue(result)
}

5. 团队协作友好

多人开发时:

  • 小明负责 PreparingState
  • 小红负责 AuctioningState
  • 小刚负责 ClosedState

互不干扰,Git冲突少。

⚠️ 挑战

1. 类的数量增加

  • 4个状态 = 4个类文件
  • 如果有10个状态,就需要10个文件

应对:合理的文件组织和命名规范

2. 状态转换的复杂性

需要仔细设计状态转换图,避免:

  • 死锁状态
  • 循环转换
  • 无法到达的状态

应对

  • 绘制状态图
  • 编写状态转换测试
  • 文档化转换规则

3. 状态间的数据共享

状态对象是无状态的,数据存储在Room对象中:

class Room {
    var stateObject: RoomStateProtocol  // 当前状态对象
    var currentItem: AuctionItem?       // 状态间共享的数据
    var currentBid: Bid?                // 状态间共享的数据
}

应对

  • 明确哪些数据属于上下文(Room)
  • 哪些数据属于状态对象

4. 调试可能更困难

调用链变长:

ViewController → RoomManager → PermissionCenter → StateObject

应对

  • 添加详细的日志
  • 使用断点调试
  • 编写单元测试

最佳实践

1. 状态对象应该是无状态的

// ❌ 错误:状态对象持有数据
class AuctioningState {
    var currentPrice: Decimal = 0  // 不应该在这里
    var bidHistory: [Bid] = []     // 不应该在这里
}

// ✅ 正确:数据存储在上下文中
class Room {
    var currentPrice: Decimal
    var bidHistory: [Bid]
    var stateObject: RoomStateProtocol
}

2. 使用工厂方法创建状态

class Room {
    func changeState(to newState: RoomState) {
        self.state = newState
        
        // 工厂方法
        switch newState {
        case .preparing:
            self.stateObject = PreparingState()
        case .listing:
            self.stateObject = ListingState()
        case .auctioning:
            self.stateObject = AuctioningState()
        case .closed:
            self.stateObject = ClosedState()
        }
        
        addSystemMessage("房间状态变更为:(newState.displayName)")
    }
}

3. 记录状态转换日志

func changeState(to newState: RoomState) {
    let oldState = self.state
    self.state = newState
    
    // 记录状态转换
    print("🔄 状态转换:(oldState.displayName) → (newState.displayName)")
    
    // 可以添加到数据库或分析系统
    Analytics.trackStateChange(from: oldState, to: newState)
}

4. 验证状态转换的合法性

func changeState(to newState: RoomState) {
    // 验证转换是否合法
    guard isValidTransition(from: self.state, to: newState) else {
        print("⚠️ 非法的状态转换:(self.state) → (newState)")
        return
    }
    
    // 执行转换
    self.state = newState
    self.stateObject = createState(newState)
}

private func isValidTransition(from: RoomState, to: RoomState) -> Bool {
    let validTransitions: [RoomState: [RoomState]] = [
        .preparing: [.listing],
        .listing: [.auctioning],
        .auctioning: [.closed],
        .closed: [.preparing]
    ]
    
    return validTransitions[from]?.contains(to) ?? false
}

5. 提供状态查询接口

extension Room {
    var canStartAuction: Bool {
        return stateObject.startAuction(room: self)
    }
    
    var canPlaceBid: Bool {
        return state == .auctioning
    }
    
    var canUploadItem: Bool {
        return state == .preparing
    }
}

// 使用
if room.canPlaceBid {
    room.stateObject.placeBid(...)
}

6. 编写完整的单元测试

class StatePatternTests: XCTestCase {
    func testStateTransitions() {
        let room = Room(...)
        
        // 测试初始状态
        XCTAssertEqual(room.state, .preparing)
        
        // 测试状态转换
        room.stateObject.startAuction(room: room)
        XCTAssertEqual(room.state, .listing)
        
        // 等待自动转换
        wait(for: 3)
        XCTAssertEqual(room.state, .auctioning)
    }
    
    func testInvalidOperations() {
        let room = Room(...)
        
        // 在准备阶段不能出价
        let result = room.stateObject.placeBid(...)
        XCTAssertFalse(result)
    }
}

总结

何时使用状态模式

适合使用的场景

  1. 对象行为随状态改变而改变
  2. 有明确的状态转换规则
  3. 状态相关的代码较多
  4. 需要避免大量的条件判断

不适合使用的场景

  1. 状态很少(2-3个)
  2. 状态间没有明确的转换规则
  3. 状态逻辑非常简单
  4. 性能要求极高的场景

状态模式的价值

在拍拍房项目中,状态模式:

  1. 将复杂的业务流程结构化
    • 4个状态,4个类,清晰明了
    • 每个状态独立,互不干扰
  1. 提高代码质量
    • 避免了数百行的switch语句
    • 符合单一职责原则
    • 符合开闭原则
  1. 增强可维护性
    • 修改某个状态不影响其他状态
    • 新增状态只需添加新类
    • 状态转换一目了然
  1. 改善团队协作
    • 不同开发者可以独立开发不同状态
    • 减少Git冲突
    • 代码审查更容易
  1. 与权限中心完美配合
    • 状态负责"怎么做"
    • 权限负责"能不能做"
    • 职责清晰,耦合度低

最后的建议

  1. 不要过度设计:如果只有2-3个简单状态,可能不需要状态模式
  2. 绘制状态图:在实现之前先画出状态转换图
  3. 编写测试:为每个状态编写单元测试
  4. 文档化:记录每个状态的职责和转换规则
  5. 逐步重构:可以先用简单方式实现,再重构为状态模式

参考资源

设计模式相关

  • 《设计模式:可复用面向对象软件的基础》- GoF
  • 《Head First 设计模式》

本项目相关

#3 Creating Shapes in SwiftUI

作者 Neo_Arsaka
2025年11月29日 13:44

示例程序

struct ShapesBootcamp: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 4)
            .stroke(
                Color.purple,
                style: StrokeStyle(lineWidth: 4, dash: [10, 5])
            )
            .frame(width: 200, height: 100)
    }
}

形状类型

类型 初始化 几何描述
Circle() 无参 外接最小圆
Ellipse() 无参 外接椭圆
Capsule(style:) .circular / .continuous 两端半圆
Rectangle() 无参 无圆角
RoundedRectangle(cornerRadius:style:) 半径 + 风格 四角等半径

所有形状默认撑满父视图提案尺寸;使用 .frame() 可强制固定宽高。

视觉修饰符

修饰符 功能 示例 备注
.fill(_:) 内部填充 .fill(Color.blue) 支持纯色、渐变
.stroke(_:lineWidth:) 等宽描边 .stroke(.red, lineWidth: 2) 默认线帽 butt
.stroke(_:style:) 高级描边 .stroke(.orange, style: StrokeStyle(...)) 虚线、线帽、线连接
.trim(from:to:) 路径裁剪 .trim(from: 0.2, to: 0.8) 0–1 比例
.frame(width:height:alignment:) 固定尺寸 .frame(200, 100) 形状无固有尺寸
.scale(_:anchor:) 缩放 .scale(1.2) 锚点默认 center
.rotation(_:anchor:) 旋转 .rotation(.degrees(45)) 同上
.offset(x:y:) 平移 .offset(x: 10) 仅视觉偏移
.opacity(_:) 透明度 .opacity(0.5) 0–1
.blendMode(_:) 混合模式 .blendMode(.multiply) 需同级 ZStack
.mask(_:) 遮罩 .mask(Circle()) 支持任意 View
.shadow(color:radius:x:y:) 阴影 .shadow(.black, 4, x: 2, y: 2) 先阴影后形状
.accessibilityHidden(true) 隐藏朗读 见上 纯装饰时推荐

任务速查表

需求 片段
圆角按钮背景 RoundedRectangle(cornerRadius: 12).fill(.accent)
环形进度 Circle().trim(from: 0, to: progress).stroke(.blue, lineWidth: 4)
虚线边框 Rectangle().stroke(style: StrokeStyle(lineWidth: 1, dash: [5]))
胶囊标签 Capsule().fill(Color.gray.opacity(0.2))

性能与可访问性

  1. 矢量路径自动适配 @2x/@3x,无位图失真。
  2. 支持动态颜色与「降低透明度」辅助选项。
  3. 动画复杂时启用 .drawingGroup() 以 Metal 合成,降低 CPU 负担。
  4. 纯装饰形状请附加 .accessibilityHidden(true),避免 VoiceOver 读出「图像」。

#2 Adding Text in SwiftUI

作者 Neo_Arsaka
2025年11月29日 13:13

示例

struct TextBootcampView: View {
    var body: some View {
        Text("Hello, World!".capitalized)   // 格式化字符串
            .multilineTextAlignment(.leading)
            .foregroundColor(.red)
            .frame(width: 200, height: 100, alignment: .leading)
            .minimumScaleFactor(0.1)        // 极限压缩
    }
}

修饰符行为

修饰符 作用 备注 / 坑
.capitalized 先「单词首字母大写」再显示 这是 String 的 Foundation 方法,不是 Text 的修饰符;对中文无效果
.font(.body) 系统动态字体「正文」级别 会随用户「设置-显示与文字大小」变化,无障碍友好
.fontWeight(.semibold) / .bold() 字重 两者可叠加,后写的覆盖前面的
.underline(true, color: .red) 下划线 + 自定义颜色 false 可取消;颜色缺省用 foregroundColor
.italic() 斜体 只对支持斜体的字体有效;中文一般无斜体轮廓
.strikethrough(true, color: .green) 删除线 与 underline 可同时存在
.font(.system(size:24, weight:.semibold, design:.default)) 完全自定义字体 不会响应动态类型,除非自己再包 UIFontMetrics;苹果官方推荐优先用 Font 语义化 API
.baselineOffset(50) 基线偏移 正值上移,负值下移;可做「上标/下标」效果,但别用于整行,会炸行高
.kerning(1) 字符间距 对中文同样生效;负值会让字贴得更紧
.multilineTextAlignment(.leading) 多行文字水平对齐 只在「宽度被限制且文字折行」时生效
.foregroundColor(.red) 文字颜色 iOS 17 起新增 foregroundStyle 支持渐变/材质,旧项目注意版本
.frame(width:200, height:100, alignment:.leading) 给 Text 套固定尺寸 Text 默认是「尺寸自适应」;一旦加 frame,多余文字会被截断除非搭配 minimumScaleFactor
.minimumScaleFactor(0.1) 超长时等比缩小 范围 0.01–1.0;与 lineLimit(nil) 配合可实现「先缩再放」效果

#1 How to use Xcode in SwiftUI project

作者 Neo_Arsaka
2025年11月29日 12:59

Bundle Identifier

在 Xcode 中,Bundle Identifier(包标识符) 是一个唯一标识你 App 的字符串,它在整个 Apple 生态系统中用于区分你的应用

截屏2025-11-29 12.49.26.png

注意事项

  • 必须唯一:Bundle ID 在 Apple 生态系统中必须唯一,不能与其他已上架或未上架的 App 冲突
  • 区分大小写:虽然系统不区分大小写,但建议保持一致
  • 不可更改:一旦上传到 App Store Connect 或使用某些功能(如推送通知、iCloud),Bundle ID 就不能更改
  • 与 App ID 对应:在 Apple Developer 后台,Bundle ID 对应一个 App ID,用于配置证书、推送、iCloud 等功能

程序入口

@main 标识标明这是程序的入口

//
//  SwiftfulThinkingBootcampApp.swift
//  SwiftfulThinkingBootcamp
//
//  Created by Lancoff Allen on 2025/10/23.
//

import SwiftUI

@main
struct SwiftfulThinkingBootcampApp: App {
    var body: some Scene {
        WindowGroup {
//            ContentView()
            AppStorageBootcamp()
        }
    }
}

程序设置界面

如果点击左侧 Navigator 中的第一级目录(SwiftfulThinkingBootcamp),就会进入程序信息设置

其中 Identity -> DisplayName 就是程序显示给用户的名称

截屏2025-11-29 12.55.06.png

flutter 集成flutter_Boost

2025年11月28日 17:22

flutter_Boots 是咸鱼开源的三方框架,主要是为原生和flutter之间混合跳转提供的解决方案,下面说一下集成flutter_Boots的步骤和如何在项目中使用flutter_Boots。

  1. 创建原生工程和flutter module

    1. 使用xcode创建iOS app原生工程,这个比较简单,这里面就不去贴图了。
    2. 创建flutter module,执行命令 flutter create -t module my_flutter_module。
    3. 这样在本地就把iOS工程和flutter module创建好了,如下图: image.png
  2. flutter安装flutter_Boots依赖

    1. 需要注意的是,flutter_boost的高版本需要使用git这种方式去安装依赖。
    2. 安装截图配置依赖,然后执行命令 flutter pub get按钮依赖。

    image.png

  3. ios 配置pod

    1. cd my_ios_app
    2. pod init
    3. 修改podfile文件
    4. pod install
    # Uncomment the next line to define a global platform for your project
    platform :ios, '13.0'
    
    flutter_application_path = '../my_flutter_module'
    load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
    
    target 'my_ios_app' do
      # Comment the next line if you don't want to use dynamic frameworks
      use_frameworks!
    
      install_all_flutter_pods(flutter_application_path)
    
      # Pods for my_ios_app
    
    end
    
    post_install do |installer|
      flutter_post_install(installer) if defined?(flutter_post_install)
    end
    
  4. flutter 编写flutter_boost集成代码

    1. 导入flutter_boost

      import 'package:flutter_boost/flutter_boost.dart';
      
    2. 创建CustomFlutterBinding

      class CustomFlutterBinding extends WidgetsFlutterBinding
          with BoostFlutterBinding {}
      
    3. 测试页面

      class DefaultPage extends StatelessWidget {
        const DefaultPage({super.key});
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(title: const Text('Flutter Boost')),
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/one',
                          arguments: {'msg': 'hello from default page 1'});
                    },
                    child: const Text('go to page one'),
                  ),
                  ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/two',
                          arguments: {'msg': 'hello from default page 2'});
                    },
                    child: const Text('go to page two'),
                  ),
                  ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/home',
                          arguments: {'msg': 'hello from default page 2'});
                    },
                    child: const Text('go to page native home'),
                  )
                ],
              ),
            ),
          );
        }
      }
      
      class OnePage extends StatelessWidget {
        const OnePage({super.key, required this.pramas});
        final Map pramas;
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(title: const Text('page one')),
            body: Text('page one, 参数: ${pramas['msg']}'),
          );
        }
      }
      
      class TwoPage extends StatelessWidget {
        const TwoPage({super.key, required this.pramas});
        final Map pramas;
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(title: const Text('page two')),
            body: Text('page two, 参数: ${pramas['msg']}'),
          );
        }
      }
      
    4. 编写widget和路由代码

      void main() {
        CustomFlutterBinding();
        runApp(const MyApp());
      }
      
      class MyApp extends StatefulWidget {
        const MyApp({super.key});
      
        @override
        State<StatefulWidget> createState() => _MyAppState();
      }
      
      class _MyAppState extends State<MyApp> {
        @override
        Widget build(BuildContext context) {
          return FlutterBoostApp(routeFactory);
        }
      
        Widget appBuilder(Widget home) {
          return MaterialApp(
            home: home,
            debugShowCheckedModeBanner: true,
            builder: (_, __) {
              return home;
            },
          );
        }
      }
      
      Route<dynamic>? routeFactory(
          RouteSettings settings, bool isContainerPage, String? uniqueId) {
        final pramas = (settings.arguments as Map?) ?? {};
        switch (settings.name) {
          case '/':
            return MaterialPageRoute(
                settings: settings, builder: (_) => const DefaultPage());
          case '/one':
            return MaterialPageRoute(
                settings: settings, builder: (_) => OnePage(pramas: pramas));
          case '/two':
            return MaterialPageRoute(
                settings: settings, builder: (_) => TwoPage(pramas: pramas));
          default:
            return null;
        }
      }
      

      flutter端代码集成完毕。

  5. iOS端代码集成

    1. 先创建一个BoostDelegate继承FlutterBoostDelegate,里面主要的逻辑就是实现push原生、push flutter、pop的方法.

      import Foundation
      import flutter_boost
      
      class BoostDelegate: NSObject, FlutterBoostDelegate {
          
          //push导航栏
          var navigationController: UINavigationController?
          
          //记录返回flutter侧返回结果列表
          var resultTable: Dictionary<String, ([AnyHashable: Any]?) -> Void> = [:]
          
          func pushNativeRoute(_ pageName: String!, arguments: [AnyHashable : Any]!) {
              let isPresent = arguments["isPresent"] as? Bool ?? false
              let isAnimated = arguments["isAnimated"] as? Bool ?? true
              var targetViewController = UIViewController()
              if pageName == "/home" {
                  targetViewController = HomeViewController()
              }
              if isPresent {
                  navigationController?.present(targetViewController, animated: isAnimated)
              } else {
                  navigationController?.pushViewController(targetViewController, animated: isAnimated)
              }
          }
          
          func pushFlutterRoute(_ options: FlutterBoostRouteOptions!) {
              let vc: FBFlutterViewContainer = FBFlutterViewContainer()
              vc.setName(options.pageName,
                         uniqueId:options.uniqueId,
                         params: options.arguments,
                         opaque: options.opaque)
              let isPresent = options.arguments["isPresent"] as? Bool ?? false
              let isAnimated = options.arguments["isAnimated"] as? Bool ?? true
              
              //对这个页面设置结果
              resultTable[options.pageName] = options.onPageFinished
              
              if (isPresent || !options.opaque) {
                  navigationController?.present(vc, animated: isAnimated)
              } else {
                  navigationController?.pushViewController(vc, animated: isAnimated)
              }
          }
          
          func popRoute(_ options: FlutterBoostRouteOptions!) {
              //如果当前被present的vc是container,那么就执行dismiss逻辑
              if let vc = self.navigationController?.presentedViewController as? FBFlutterViewContainer,vc.uniqueIDString() == options.uniqueId{
                  
                  //这里分为两种情况,由于UIModalPresentationOverFullScreen下,生命周期显示会有问题
                  //所以需要手动调用的场景,从而使下面底部的vc调用viewAppear相关逻辑
                  if vc.modalPresentationStyle == .overFullScreen {
                      
                      //这里手动beginAppearanceTransition触发页面生命周期
                      self.navigationController?.topViewController?.beginAppearanceTransition(true, animated: false)
                      
                      vc.dismiss(animated: true) {
                          self.navigationController?.topViewController?.endAppearanceTransition()
                      }
                  }else{
                      //正常场景,直接dismiss
                      vc.dismiss(animated: true, completion: nil)
                  }
              }else{
                  self.navigationController?.popViewController(animated: true)
              }
              //否则直接执行pop逻辑
              //这里在pop的时候将参数带出,并且从结果表中移除
              if let onPageFinshed = resultTable[options.pageName] {
                  onPageFinshed(options.arguments)
                  resultTable.removeValue(forKey: options.pageName)
              }
          }
      }
      
  6. 修改Appdelegate文件

     var boostDelegate = BoostDelegate() 
        
        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
            // Override point for customization after application launch.
            FlutterBoost.instance().setup(application, delegate: boostDelegate, callback: { engine in
            })
            return true
        }
    
  7. 添加跳转交互

    1. 跳转flutter

       if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
                  appDelegate.boostDelegate.navigationController = self.navigationController
              }
              let ops = FlutterBoostRouteOptions()
              ops.pageName = "/"
              ops.arguments = ["msg":"app"]
              FlutterBoost.instance().open(ops)
      
    2. 跳转原生

       ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/home',
                          arguments: {'msg': 'hello from default page 2'});
                    },
                    child: const Text('go to page native home'),
                  )
      

通过以上的集成步骤和代码编写,我们就可以流畅的在flutter和原生之间互相跳转了。

❌
❌