阅读视图

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

开发者必看,全面解析应用更新策略,让用户无法拒绝你的应用更新!

这里每天分享一个 iOS 的新知识,快来关注我吧


前言


在移动应用的开发过程中,版本更新是必不可少的一环。

无论是修复漏洞、功能迭代,还是提升用户体验,更新都扮演着至关重要的角色。那么,如何给用户有效地传递更新信息,并在必要时执行强制更新呢?本文将为你详细解析这一过程。

为什么需要更新?

随着时间的推移,应用需要不断迭代,以适应用户的需求和市场的变化。而有的功能也随之废弃,此时就需要让用户更新到最新版本。

还有的时候,应用需要进行安全更新,以修复已知的安全漏洞和优化用户体验。

作为开发者,我们有责任在代码中传达更新的重要性。

请在微信客户端打开

非侵入性通知策略

应用内通知

首先我们需要有个服务端接口来获取应用的最新版本,客户端开启时调用接口检查,把当前的版本号传给服务端,服务端返回是否需要更新,以及更新内容。

如果你没有服务端,那么可以利用 iTunes Lookup API 来获取应用的最新版本,然后进行版本对比。具体的 URL 规则为https://itunes.apple.com/br/lookup?bundleId={你的bundleId},它会返回一个 JSON 对象,其中包含应用的最新版本号。

通过应用内通知,我们可以在用户打开应用时,温和地提示他们进行更新。以下是一个简单的 Swift 实现示例:

func showInAppNotification(message: String) {
    let alertController = UIAlertController(
        title: "有更新可用啦",
        message: message,
        preferredStyle: .alert
    )

    let updateAction = UIAlertAction(title: "更新", style: .default) { _in
        iflet appStoreURL = URL(string: "https://apps.apple.com/cn/app/%E6%8A%96%E9%9F%B3/id1142110895") {
            UIApplication.shared.open(appStoreURL, options: [:], completionHandler: nil)
        }
    }

    alertController.addAction(updateAction)

    iflet viewController = UIApplication.shared.keyWindow?.rootViewController {
        viewController.present(alertController, animated: true, completion: nil)
    }
}

// 示例使用
let updateMessage = "应用程序的新版本已可用。立即更新以获取最新功能和改进。"
showInAppNotification(message: updateMessage)

这个示例中,我们通过UIAlertController 创建了一个弹窗,并在用户点击更新按钮时,打开应用在 App Store 的链接,Alert 也会被关闭。

image.png

理论上,这种更新方式可以被用户跳过。

推送通知

如果用户没有打开应用,那么就需要通过推送通知来提醒用户更新了,服务端存储用户的版本,然后根据版本号来判断给哪些用户发送更新推送。

利用推送通知是一种在用户不打开应用的情况下,提醒用户的有效方式。结合苹果的 APNS,可以在 AppDelegate 中处理收到的推送通知,进而弹出更新弹窗:

func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
    if let updateMessage = userInfo["updateMessage"] as? String {
        showInAppNotification(message: updateMessage)
    }
}

强制更新的情况

强制更新侵入性比较强,也比较容易引起用户的反感,通常用于特定场景,比如发现安全漏洞、修复严重 bug、服务器端的重大变更或旧版本的弃用等等。

在制定强制更新方案时,需要考虑用户的不同选择、可能的中断、网络情况等。

实现方案

利用 iTunes Lookup API,我们可以获取应用的最新元数据。我们以抖音 app 为例,写一个获取最新版本号的并唤起强更的示例。

抖音 app 的 iTunes Lookup API 地址为https://itunes.apple.com/cn/lookup?bundleId=com.ss.iphone.ugc.Aweme

请求 iTunes Lookup API

简单的代码如下:

guard let url = URL(string: "https://itunes.apple.com/cn/lookup?bundleId=com.ss.iphone.ugc.Aweme") else {
    return
}

URLSession.shared.dataTask(with: url) { data, response, error in
    iflet data = data {
        do {
            let json = tryJSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
            iflet results = json?["results"] as? [[String: Any]], let version = results.first?["version"] as? String {
                let localVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
                iflet localVersion = localVersion, version > localVersion {
                    DispatchQueue.main.async {
                        // 弹出更新弹窗
                        let updateMessage = "应用程序的新版本已可用。立即更新以获取最新功能和改进。"
                        self.showInAppNotification(message: updateMessage)
                    }
                }
            }
        } catch {
            print("Error parsing JSON: \(error)")
        }
    }
}.resume()

处理异常情况

首先,既然是强更,就不让让用户跳过了,最简单的方法是在用户点击“更新”按钮后,重新将 Alert 弹出:

func showInAppNotification(message: String) {
    let alertController = UIAlertController(
        title: "有更新可用啦",
        message: message,
        preferredStyle: .alert
    )

    let updateAction = UIAlertAction(title: "更新", style: .default) { _in
        iflet appStoreURL = URL(string: "https://apps.apple.com/cn/app/%E6%8A%96%E9%9F%B3/id1142110895") {
            UIApplication.shared.open(appStoreURL, options: [:], completionHandler: nil)
        }
        
        // 重新弹出更新弹窗
        self.showInAppNotification(message: message)
    }

    alertController.addAction(updateAction)

    iflet viewController = UIApplication.shared.keyWindow?.rootViewController {
        viewController.present(alertController, animated: true, completion: nil)
    }
}

另外还需要考虑用户断网、请求失败等情况,最好的方法是在本地记录一个是否检查过版本更新的值,然后监听设备有网络时重新检查一下更新状态,保证一次冷启动周期内检查成功一次,进而保证用户无法跳过强制更新。

结语

应用的更新与用户体验息息相关。通过合理的策略,我们可以在尽量不打扰用户的情况下,及时有效地推动更新,确保应用的安全性和功能性。希望本文能为你的应用更新策略提供一些启发。

这里每天分享一个 iOS 的新知识,快来关注我吧

本文同步自微信公众号 “iOS新知”,每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!

我们什么时候应该使用协议继承?——Swift 协议继承的应用与思

在 Swift 中,协议(Protocol)是一种强大的抽象工具,它让我们可以只关注「行为」而不是具体「实现」。协议不仅可以定义方法和属性要求,还可以继承其他协议,从而在代码中实现更清晰的分层设计。

但很多同学常常会问:什么时候应该使用协议继承?
今天,我们就一起来聊聊协议继承的应用场景、最佳实践,以及在实际项目中应该如何优雅使用。


💡 什么是协议继承?

协议继承其实非常简单:一个协议可以继承一个或多个其他协议,除了继承它们的要求之外,还可以添加自己的新要求。

protocol Vehicle {
    func start()
}

protocol ElectricVehicle: Vehicle {
    func charge()
}

上面,ElectricVehicle 继承了 Vehicle 协议,这意味着任何遵循 ElectricVehicle 的类型,必须同时实现 start()charge()


🟢 协议继承的应用场景

✅ 1. 按功能分解,组合更小的协议

当你的协议太大时,直接把所有要求都堆在一个协议里会非常笨重、难以维护。这时候可以将功能拆解成小协议,然后再通过继承组合成一个大协议。

protocol Drivable {
    func drive()
}

protocol Flyable {
    func fly()
}

protocol AmphibiousVehicle: Drivable, Flyable {}

这样,AmphibiousVehicle 就是「既能开也能飞」的交通工具,实现者必须同时满足两个功能。


✅ 2. 为不同层次的抽象设计协议

有时我们需要定义一些「更抽象」的行为,后面再细化。例如:

protocol Shape {
    func area() -> Double
}

protocol ColoredShape: Shape {
    var color: String { get set }
}
  • Shape 定义了基本形状的行为(计算面积)。
  • ColoredShape 继承 Shape 并增加颜色属性。

当我们需要「只关心形状」时,只使用 Shape;需要「带颜色的形状」时,使用 ColoredShape,这种分层设计思路更加清晰、灵活。


✅ 3. 给默认实现做准备

如果你有多个协议需要提供相似的默认实现,使用协议继承可以帮助你只写一次。

protocol Printable {
    func printInfo()
}

extension Printable {
    func printInfo() {
        print("This is a printable object.")
    }
}

protocol DetailedPrintable: Printable {
    func detailedDescription() -> String
}

extension DetailedPrintable {
    func printInfo() {
        print(detailedDescription())
    }
}

这样,DetailedPrintable 可以重写 printInfo(),但仍然保留 Printable 的默认实现。


⚡️ 使用协议继承的好处

可组合:小协议组合大协议,灵活扩展。
更好的抽象层次:让设计更符合「接口分离原则」。
减少耦合:调用方只依赖需要的协议,避免过度依赖具体实现。


❗️ 使用协议继承要注意什么?

  • ❌ 不要为了「好看」而滥用继承,把很多无关行为硬塞进一个协议里。
  • ✅ 遵循「单一职责原则」,一个协议最好只关心一件事,后续可以通过继承组合。
  • ✅ 谨慎考虑是否需要在协议中定义默认实现,如果太多默认实现,可能隐藏了实现者必须注意的逻辑。

🟢 总结

什么时候应该使用协议继承?

当你需要:

  • 分层抽象
  • 组合小协议
  • 为可选功能提供可重用的默认实现

这时候协议继承就是一个非常优雅、灵活的解决方案。

Swift 方法调用机制揭秘:从虚表到性能优化

前言

在 Swift 代码中,一句简单的 object.method() 背后,藏着编译器与运行时的精密协作。方法调用看似只是 “执行一段代码”,但 Swift 为了平衡性能与灵活性,设计了多套派发机制 —— 从编译时就能锁定地址的 “静态派发”,到运行时动态查找的 “虚表舞蹈”,再到与 Objective-C 兼容的消息发送魔法。

理解这些机制,不仅能帮你看透代码的执行效率瓶颈,更能在设计数据结构、选择类型(结构体 vs 类)、优化性能时做出更理性的决策。今天,我们就拆开这层 “黑箱”,从底层原理讲到实战技巧,带你掌握 Swift 方法调用的 “底层密码”。

一、Swift 的方法派发艺术:静态与动态的平衡术

方法派发(Method Dispatch)指的是 “如何找到并执行方法对应的代码”。Swift 不像 Objective-C 那样依赖单一的消息发送机制,而是根据场景灵活切换策略 —— 这正是它既能保持高性能,又能支持面向对象特性的核心原因。

1. 静态派发:编译时的 “精准定位”

静态派发的核心是 “编译时确定”:编译器在编译阶段就明确知道方法的具体实现地址,调用时直接跳转到该地址执行,无需任何运行时查找。这种机制就像快递员提前知道收件人的精确地址,直接上门投递,效率极高。

适用场景

  • 结构体(struct)、枚举(enum)等值类型的方法(默认静态派发);

  • 被 final 修饰的类或方法(禁止重写,编译器可确定不会有动态变化);

  • 私有方法(private 修饰,仅限当前文件可见,无法被外部重写)。

代码示例

struct Point {
    var x: Int, y: Int
    func distance(to other: Point) -> Int {  // 静态派发:编译时确定地址
        return abs(x - other.x) + abs(y - other.y)
    }
}

final class MathUtil {  // final 类:所有方法默认静态派发
    func calculate() -> Int { return 42 }
}

class Logger {
    private func log() { print("调试日志") }  // private 方法:静态派发
}

性能优势
静态派发避免了运行时的查找开销,执行速度接近原生函数调用。对于高频调用的工具方法(如数学计算、数据转换),静态派发能显著提升性能。

2. 动态派发:虚表(VTable)的 “动态舞蹈”

当需要支持类的继承与方法重写时,静态派发就无法满足需求了 —— 编译器无法在编译时确定 “到底调用父类还是子类的方法”。这时,Swift 会启用虚表(Virtual Table,简称 VTable)  机制,让方法调用在运行时动态决策。

虚表的本质:函数指针数组

每个类在编译时都会生成一张虚表,本质是一个 “函数指针数组”,其中存储了该类所有可被重写的方法的实现地址。具体规则如下:

  • 父类的方法会按顺序排在虚表前面;
  • 子类新增的方法追加在虚表末尾;
  • 子类重写父类的方法时,会替换虚表中对应位置的函数指针(保持与父类虚表的索引对齐)。

动态调用的流程

当调用一个类的方法时,执行步骤如下:

  1. 从对象实例中取出 “类指针”(指向该对象的实际类型信息);

  2. 通过类指针找到对应的虚表;

  3. 根据方法在虚表中的索引,找到具体的函数指针并执行。

代码示例

class Animal {
    func speak() { print("动物叫声") }  // 虚表索引 0
    func move() { print("移动中") }     // 虚表索引 1
}

class Dog: Animal {
    override func speak() { print("汪汪!") }  // 替换索引 0 的指针
    func fetch() { print("捡球") }             // 新增索引 2 的指针
}

// 调用时的动态决策
let pet: Animal = Dog()  // 编译时类型是 Animal,运行时类型是 Dog
pet.speak()  // 运行时找到 Dog 的虚表,执行索引 0 的方法 → 输出“汪汪!”

动态派发的优势与代价

优势:支持灵活的继承与多态,让子类可以 “无缝替换” 父类的方法实现,是面向对象编程的核心基础。
代价:相比静态派发,虚表查找增加了 “取类指针→查虚表→取函数指针” 的三步操作,虽比 Objective-C 的消息发送快,但仍慢于静态派发。

3. 消息派发:与 Objective-C 的 “跨语言桥梁”

Swift 为了兼容 Objective-C 的运行时特性(如 KVO、动态方法交换),提供了 @objc dynamic 修饰符,强制方法使用 Objective-C 的消息发送(Message Sending)  机制。

这种机制与虚表派发完全不同:调用方法时,运行时会通过 objc_msgSend 函数动态查找方法(先查缓存,再查类的方法列表,最后触发消息转发),灵活性极高,但性能开销也最大。

代码示例

class Player: NSObject {
    @objc dynamic func attack() { print("攻击!") }  // 启用消息发送
}

// Objective-C 可通过 runtime 动态替换方法实现
let player = Player()
player.attack()  // 实际执行由 runtime 动态决定

二、Swift 与 Objective-C 派发机制的深度对比

Swift 和 Objective-C 的方法调用机制,本质上是 “编译时优化” 与 “运行时灵活” 的取舍。下表从底层原理到实际表现做详细对比:

特性 Swift 机制 Objective-C 机制
核心原理 静态派发(值类型 /final)+ 虚表派发(类继承) 消息发送(objc_msgSend 动态查找)
调用流程 编译时确定地址(静态)/ 虚表索引查找(动态) 运行时逐级查找方法缓存→类列表→父类
性能 静态派发极快,虚表派发较快 消息发送较慢(查找成本高)
灵活性 需提前声明(override/dynamic 支持运行时动态添加 / 替换方法
适用场景 性能敏感的业务逻辑(如游戏、算法) 依赖运行时魔法的场景(如 KVO、AOP)

关键差异
Swift 更倾向于 “编译时做决定”,通过静态派发和虚表派发平衡性能与继承需求;Objective-C 则完全依赖 “运行时动态查找”,牺牲部分性能换取极致灵活。

三、性能优化实战:从派发机制入手

理解了派发机制后,我们可以通过以下技巧优化 Swift 代码性能:

1. 用 final 锁定静态派发

为不需要被继承的类或方法添加 final 修饰符,告诉编译器 “该方法不会被重写”,从而将动态派发优化为静态派发。

示例

// 无需继承的工具类,直接标记为 final
final class DateFormatter {
    func format() -> String { ... }  // 静态派发,性能提升
}

class NetworkClient {
    // 核心方法不允许重写
    final func sendRequest() { ... }  // 静态派发,避免虚表开销
}

2. 优先选择值类型(结构体 / 枚举)

结构体(struct)和枚举(enum)默认使用静态派发,且存储在栈上(相比堆上的类实例,内存分配 / 释放更快)。对于无继承需求的数据模型(如坐标、颜色、配置项),值类型是更优选择。

示例

// 用结构体替代类,提升性能
struct User {
    let id: Int
    let name: String
    func display() { print(name) }  // 静态派发,栈存储
}

3. 避免过度继承,控制虚表规模

类的继承层级越深,虚表越长,查找索引的成本越高(虽微乎其微,但高频调用会累积)。建议:

  • 能用组合(has-a)替代继承(is-a)时,优先用组合;
  • 非必要不设计超过 3 层的继承体系。

4. 慎用 @objc dynamic

@objc dynamic 会强制方法使用 Objective-C 的消息发送机制,性能比虚表派发慢 2-3 倍。仅在必须依赖 Objective-C 运行时的场景(如 KVO、与 OC 动态交互)时使用。

四、虚表的底层细节:从内存布局到多态实现

虚表的设计是 Swift 支持多态的核心,以下从内存角度进一步解析:

1. 类实例的内存布局

每个类的实例在内存中分为两部分:

  • 成员变量区:存储实例的属性值;
  • 类指针(isa 指针) :指向该实例对应的类元数据(包含虚表地址、类信息等)。

2. 虚表的继承与重写示例

以 Animal 和 Dog 为例,它们的虚表结构如下:

Animal 虚表(父类) 索引 Dog 虚表(子类) 索引
speak()(动物叫声) 0 speak()(汪汪!) 0 (重写)
move()(移动中) 1 move()(移动中) 1 (继承)
- - fetch()(捡球) 2 (新增)

当通过父类指针(let pet: Animal = Dog())调用 pet.speak() 时,运行时会通过 pet 的类指针找到 Dog 的虚表,再通过索引 0 找到重写后的 speak() 实现 —— 这就是多态的底层逻辑。

结语

Swift 的方法调用机制,是 “性能” 与 “灵活” 的精妙平衡:静态派发为值类型和固定逻辑提供极致速度,虚表派发为类的继承与多态提供动态支持,而消息派发则架起与 Objective-C 生态的桥梁。

作为开发者,理解这些机制后,我们能更理性地选择类型(struct 还是 class)、设计继承体系(是否需要重写)、优化性能(final 修饰或避免过度动态)。毕竟,写出高效的代码,不仅需要掌握语法,更要看透语言的底层逻辑。

希望这篇文章能帮你揭开 Swift 方法调用的神秘面纱,让你的代码在优雅与性能之间找到完美平衡!

高温与奇怪的天象 | 肘子的 Swift 周报 #092

issue92.webp

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

高温与奇怪的天象

从 6 月中开始,我所在的城市也迎来了罕见的高温天气。尽管相较于动辄 35-40 多摄氏度的其他地区,30-31 摄氏度在数字上看起来并不夸张,但对于习惯了 20 几度的我来说,这种温度已经很难熬了。

与高温相伴的还有一些奇怪的天象。6 月 19 日,大连上空出现了一种罕见的云——糙面云。天空呈现出强烈的压迫感,给人一种"末世"将近的错觉,幸好仅持续了半个小时。几天前的夜间又出现了高频闪电现象。一夜之间,据气象部门设备检测共发生了 7649 次闪电。我当时看向窗外的感觉就像有人在用氩弧焊进行焊接,整夜不停。有趣的是,尽管闪电极多,却几乎听不到雷声。

这些局部现象其实反映了全球性的趋势。至少从最近两个月的天气表现来看,今年仍会是气温极高的一年。全球很多地方的气温从春季开始就在不断刷新着纪录。从气象历史的角度看,气候带迁移本是一个正常的周期性趋势,但这种变化通常以百年、千年为尺度。而现在的发展速度远超理论上的自然演进——已经缩短到以几十年为单位了。

工业革命以来,大气中二氧化碳浓度急剧上升,目前的平均水平为过去至少 70 万年内所未见。几乎可以肯定,人类活动改变并加速了气候变化的自然进程。

人类文明要进步、要发展,就需要更多的创造和活动,而这些又势必会改变延续已久的生态平衡。在地球的演化史上,气候发生巨大而剧烈的变化并不罕见,地球总能用时间来抹平这些创伤,让一切恢复如初。只是,当这一切归于平静之时,人类是否还会是今天的模样呢?

前一期内容全部周报列表

原创

与 AI 共舞:我的 Claude Code 一月谈

转眼间,我使用 Claude Code 已经整整一个月了。这段时间里,它迅速成为了开发者们的新宠,关于 Claude Code 的讨论充斥着我的社交媒体时间线。最近有网友在 Discord 上问我对 Claude Code 的看法,正好借这个机会分享一下这段时间的使用感受,以及过去两年中 AI 为我的开发工作带来的便利与思考。

近期推荐

深入 Attribute Graph

在 SwiftUI 中,Attribute Graph 既重要又神秘。它是 SwiftUI 的核心运行时机制之一,用于追踪视图状态和依赖,驱动高效、最小化的 UI 更新。Chris Eidhof 在这段视频(附文字稿)里分享了他对其的洞察,并推荐了相关资料和工具,帮助开发者深入探索 SwiftUI 的内部原理。


用 AlarmKit 实现倒计时定时器 (Schedule a Countdown Timer with AlarmKit)

在 WWDC 2025,苹果发布了开发者期待已久的 AlarmKit,支持创建一次性闹钟、每周重复闹钟和即时启动的倒计时计时器。与 UserNotifications 在静音或专注模式下“悄无声息”不同,AlarmKit 的警报能突破限制,在关键时刻响起并显示横幅。在本文中,Natalia Panferova 详解了其 API 的使用方法,并演示了如何在主应用、Widget 和 Live Activity 等场景下进行配置。


为什么我不再“万物皆 struct” (Why I Stopped Using Structs for Everything in Swift)

Swift 社区一直推崇"优先使用 struct"的开发理念。值类型的不可变性和线程安全性看似完美,但在大型项目中,这种“struct 万能论”却暴露出不少问题。在这篇文章中,Koti Avula 结合实战经验,深入剖析了引用类型嵌入 struct 的陷阱、闭包捕获 self 的意外行为、以及关于性能的常见误区,并分享了他为何不再坚持“struct 至上”。


SwiftUI 如何实现渐进式模糊效果?

在 iOS 26,Apple Music 等系统应用引入了更细腻的渐变模糊效果。不过,这些功能尚未完全开放给开发者,也难以兼容较低系统版本。Justin Yan 在本文中分享了两种实现思路:

  • 使用 UIVisualEffectView 复用系统级模糊渐变
  • 结合 QuartzCore 和私有 CAFilter API 自定义渐进式模糊

虽然与 Liquid Glass 的原生效果仍有差距,但足以实现接近的视觉体验。


我用 Claude Code 独立开发了一款 macOS 应用 (I Shipped a macOS App Built Entirely by Claude Code)

在这篇极长文中,Indragie Karunaratne 分享了他用 Claude Code ,从 0 到 1 开发并上线一款原生 macOS 应用的经历。作者认为 Claude 的 Swift 6 和 SwiftUI 生成能力已相当实用,并通过 priming agent、自动化反馈循环等技巧,几乎实现了全部代码的自动编写(20,000 行代码中 95% 由 AI 生成)。文章还探讨了未来 IDE 形态和构建高质量自动化流程的思路,以及“人机协作式编程”的可能性。


Swift Android SDK 快速上手体验 (Using the Swift Android SDK)

虽然 Android Workgroup 已经成立,但在 Swift.org 上仍找不到关于安装和使用 Swift Android SDK 的官方指南。Abe White 在这段视频中演示了如何通过 skip 安装 Swift Android SDK,并在安卓设备上运行和测试 Swift 代码。

看完视频后,我马上按照 Abe 介绍的方法安装了 SDK,只用了很短时间就让我原本仅支持 macOS/Linux 的两个库顺利跑在 Android 上,过程非常顺畅。

工具

Objects2XLSX - 一键将数据集转换成 xlsx

一个类型安全、声明式的 Swift 库,让你用几行代码将对象数组导出为专业级 Excel (.xlsx) 文件。支持多工作表、完整样式、异步数据源和实时进度跟踪。

  • ✅ Swift 6 并发支持
  • ✅ KeyPath 映射 + 泛型 Sheet
  • ✅ 多平台支持,macOS/Linux/Android
  • ✅ 生成文件 100% Excel 兼容

Equatable Macro – 改善 SwiftUI 视图性能

SwiftUI 默认的 diff 机制在遇到闭包或需要忽略某些状态时容易误判,导致视图不必要的重绘。常见的优化方式是让视图符合 Equatable,用自定义逻辑控制刷新,但手动实现既繁琐又容易遗漏。为此,Mirza UčanbarlićCal StephensUnderstanding and Improving SwiftUI Performance 一文中的思路启发,打造了 @Equatable 宏,自动生成 Equatable 实现,极大简化开发流程。

往期内容

THANK YOU

如果你觉得这份周报或者我的文章对你有所帮助,欢迎 点赞 并将其 转发 给更多的朋友。

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

【HarmonyOS】鸿蒙蓝牙连接与通信技术

在鸿蒙系统中,蓝牙功能的实现依赖于 HarmonyOS 提供的ConnectivityKit蓝牙模块、AbilityKit权限控制模块和ArkTS工具模块。本文详细讲解蓝牙连接、数据传输等核心流程。



一、蓝牙权限申请

  • 在使用蓝牙功能之前,须在module.json5中配置蓝牙权限。
"requestPermissions": [
  {
    "name": "ohos.permission.ACCESS_BLUETOOTH",
    "reason": "$string:open_bluetooth",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]
  • 同时向用户申请蓝牙权限
async getBluetoothRequestion(callback: (res: abilityAccessCtrl.GrantStatus) => void) {
  const ctx = AppStorage.get<Context>(CONTEXT_GLOB) // 获取全局上下文
  const ctrl = abilityAccessCtrl.createAtManager() // 创建权限管理器
  const res = (ctrl.requestPermissionsFromUser(ctx, ['ohos.permission.ACCESS_BLUETOOTH'])) // 请求蓝牙权限

  res.then(async (promise) => {
    if (!promise.authResults.every(t => t == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)) {
      const res = await ctrl.requestPermissionOnSetting(ctx, ['ohos.permission.ACCESS_BLUETOOTH'])
      callback(res[0])
    } else {
      callback(promise.authResults[0]) // 调用回调函数,返回权限状态
    }
  })
}
  • 引入权限控制模块:使用 abilityAccessCtrl.createAtManager() 创建权限管理器。
  • 请求权限:调用 requestPermissionsFromUser 向用户请求 ohos.permission.ACCESS_BLUETOOTH 权限。
  • 权限未授予处理:若用户拒绝权限,则尝试跳转到设置页面进行手动授权,调用 requestPermissionOnSetting
  • 回调返回结果:最终通过 callback(res[0])callback(promise.authResults[0]) 返回权限状态。

二、蓝牙开关状态判断与监听

1. 判断蓝牙是否开启

isBluetoothEnabled(): boolean {
  const state: access.BluetoothState = access.getState(); // 获取蓝牙当前状态
  // 判断是否为开启或正在开启状态
  return state === access.BluetoothState.STATE_ON; // 返回蓝牙是否已开启的布尔值
}
  • 调用 access.getState() 获取当前蓝牙状态。
  • 检查状态是否为 access.BluetoothState.STATE_ON,如果是则表示蓝牙已打开。

2. 监听蓝牙状态变化

//监听蓝牙变化
onBluetoothState(callback: (state: boolean) => void) {
  // 监听蓝牙状态变化事件
  access.on('stateChange', (status) => {
    // 判断蓝牙是否关闭,若关闭则调用回调函数并传入false
    status == access.BluetoothState.STATE_OFF && callback(false)
    // 判断蓝牙是否开启,若开启则调用回调函数并传入true
    status == access.BluetoothState.STATE_ON && callback(true)
  })
}
  • 使用 access.on('stateChange') 监听蓝牙开关状态变化事件。
  • 当状态变为 STATE_OFF 时,调用 callback(false)
  • 当状态变为 STATE_ON 时,调用 callback(true)

三、蓝牙设备扫描

// 蓝牙设备扫描
findBluetoothDecice(callback: (device: ble.ScanResult[]) => void) {
  try {
    ble.startBLEScan(null, {
      interval: 500, // 设置扫描间隔为500ms
      dutyMode: ble.ScanDuty.SCAN_MODE_LOW_POWER, // 设置扫描模式为低功耗模式
      matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE // 设置匹配模式为积极模式
    })

    // 初始化设备列表数组
    let deviceList: ble.ScanResult[] = []

    ble.on("BLEDeviceFind", (res: ble.ScanResult[]) => { // 监听蓝牙设备发现事件
      res.forEach((dev: ble.ScanResult) => { // 遍历所有发现的设备
        // 如果设备有名称
        if (dev.deviceName) {
          // 将新设备添加到列表中,避免重复
          deviceList.push(...res.filter(v => !deviceList.some(vv => vv.deviceId === v.deviceId)))
        }
        // 调用回调函数,返回当前设备列表
        callback(deviceList)
      })
    })
  } catch (err) { // 捕获异常
    logger.info(err) // 记录错误信息
  }
}
  • 启动蓝牙扫描:调用 ble.startBLEScan 开始低功耗扫描,参数如下:

    • interval: 扫描间隔(500ms)
    • dutyMode: 扫描模式(低功耗模式 SCAN_MODE_LOW_POWER
    • matchMode: 匹配模式(积极匹配 MATCH_MODE_AGGRESSIVE
  • 设备发现监听:注册 BLEDeviceFind 事件,每次发现新设备后,遍历并去重添加至 deviceList

  • 回调返回设备列表:通过 callback(deviceList) 返回当前扫描到的所有设备。


四、蓝牙连接与断开

1. 连接设备

// 蓝牙设备连接
connectDevice(device: ble.ScanResult, callback: (clientDevice: ble.ScanResult) => void) {
  try {
    // 创建GATT客户端设备实例,传入目标设备的ID
    this.currentClient = ble.createGattClientDevice(device.deviceId)
    // 建立与设备的蓝牙连接
    this.currentClient.connect()
    // 监听蓝牙连接状态变化事件
    this.currentClient.on("BLEConnectionStateChange", (result) => {
      // 判断连接状态是否为已连接
      if (result.state === constant.ProfileConnectionState.STATE_CONNECTED) {
        // 调用回调函数,传入已连接的设备
        callback(device)
      }
    })
  } catch (err) {
    logger.info(err)
  }
}
  • 创建 GATT 客户端:调用 ble.createGattClientDevice(device.deviceId) 创建客户端实例。
  • 建立连接:调用 connect() 方法发起连接。
  • 监听连接状态变化:注册 BLEConnectionStateChange 事件,当连接状态为 STATE_CONNECTED 时,调用 callback(device) 返回连接成功的设备对象。

2. 断开设备连接

//断开蓝牙设备
disconnectDevice(callBack: () => void) {
  if (this.currentClient) { // 检查当前是否已连接蓝牙设备
    this.currentClient.disconnect() // 断开与蓝牙设备的连接
    this.currentClient.close(); // 关闭蓝牙客户端资源
    this.currentClient = null // 将当前客户端设为null,表示已断开连接
    callBack() // 调用回调函数,通知断开完成
  }
}
  • 检查连接状态:判断 this.currentClient 是否存在。
  • 断开连接:调用 disconnect() 关闭连接,并调用 close() 清理资源。
  • 清空引用:将 currentClient 设为 null
  • 回调通知完成:调用 callBack() 告知上层断开操作已完成。

五、蓝牙数据发送与监听

1. 发送数据

//数据发送
async bluetoothSendMsg(data: BlueData) {
  try {
    // 检查当前是否存在已连接的蓝牙设备
    if (this.currentClient) {
      // 获取蓝牙设备的所有服务列表
      const list = await this.currentClient.getServices()
      // 查找服务UUID以0000AE30开头的服务
      const doorService = list.find(v => v.serviceUuid.startsWith("0000AE30"))
      // 查找特征UUID以0000AE10开头的特征
      const message = doorService?.characteristics.find(v => v.characteristicUuid.startsWith("0000AE10"))
      // 创建文本编码器实例
      const encoder = new util.TextEncoder()
      // 将数据对象编码为Uint8Array
      const u8a = encoder.encodeInto(JSON.stringify(data))

      // 向蓝牙设备发送特征值数据
      await this.currentClient.writeCharacteristicValue({
        serviceUuid: message?.serviceUuid, // 服务UUID
        characteristicUuid: message?.characteristicUuid, // 特征UUID
        characteristicValue: u8a.buffer, // 特征值数据
        descriptors: [], // 描述符列表
      }, ble.GattWriteType.WRITE) // 设置写入类型为WRITE
    }
  } catch (err) {
    logger.info(err)
  }
}
  • 检查连接状态:确保 currentClient 不为空。
  • 获取服务列表:调用 getServices() 获取设备支持的服务。
  • 查找目标服务和特征
    • 服务 UUID 以 0000AE30 开头
    • 特征 UUID 以 0000AE10 开头
  • 编码数据:使用 util.TextEncoder 将 JSON 对象转换为 Uint8Array
  • 写入特征值:调用 writeCharacteristicValue 发送数据,参数包括:
    • serviceUuid
    • characteristicUuid
    • characteristicValue(即编码后的数据)
    • descriptors
    • writeType(指定为 WRITE

2. 监听特征值变化

//监听特征值变化
async listenInDeviceDataChange(callBack: (message: number | void) => void) {
  // 检查当前是否存在已连接的蓝牙设备
  if (this.currentClient) {
    // 监听蓝牙特征值变化事件
    this.currentClient?.on("BLECharacteristicChange", (res) => {
      // 创建文本解码器实例
      const decoder = util.TextDecoder.create()
      // 将特征值数据转换为Uint8Array
      const buffer = new Uint8Array(res.characteristicValue)
      // 将二进制数据解码并解析为BlueData对象
      const result = JSON.parse(decoder.decodeToString(buffer)) as BlueData
      // 如果命令类型为'wifi'
      if (result.command === 'wifi') {
        // 再次调用回调函数,传递状态码
        callBack(result.status)
      }
    })
    // 获取蓝牙设备的所有服务列表
    const serviceList = await this.currentClient?.getServices()
    // 查找服务UUID以0000AE30开头的服务
    const doorService = serviceList?.find(v => v.serviceUuid.startsWith("0000AE30"))
    // 查找特征UUID以0000AE04开头的特征
    const message = doorService?.characteristics.find(v => v.characteristicUuid.startsWith("0000AE04"))
    // 设置特征值变化通知,启用通知功能
    await this.currentClient?.setCharacteristicChangeNotification(message, true)
  }
}
  • 监听特征值变化:注册 BLECharacteristicChange 事件,每当设备有数据更新时触发。
  • 解码数据:使用 util.TextDecoder.create() 创建解码器,将 characteristicValue 转换为字符串并解析为 BlueData 对象。
  • 特殊命令处理:如果命令是 'wifi',调用 callBack(result.status)

六、接口定义说明

interface BlueData {
  status?: 200 | 400 // 200 表示成功,400 表示失败
  msg?: string // 状态消息
  command?: 'open' | 'wifi' // 命令类型:开门或配置Wi-Fi
  data?: string[] // 数据内容,如 Wi-Fi 的 [ssid, pwd]
}

该接口用于封装蓝牙通信过程中发送和接收的数据结构。


七、完整代码

import { abilityAccessCtrl } from "@kit.AbilityKit" // 导入AbilityKit中的权限控制模块
import { logger } from "../../../../Index" // 导入自定义日志工具
import { CONTEXT_GLOB } from "../constants/ConstantEvent" // 导入全局上下文常量
import { access, ble, constant } from "@kit.ConnectivityKit" // 导入蓝牙相关模块
import { util } from "@kit.ArkTS" // 导入ArkTS工具模块,用于文本编码解码等操作


// 1. 蓝牙开门 2. 配置设备 wifi 连网
interface BlueData {
  status?: 200 | 400 //  200 成功  400 失败
  msg?: string // 消息提示
  command?: 'open' | 'wifi' // 命令类型:开门或配置Wi-Fi
  data?: string[] // 例如配置Wi-Fi时的数据:[ssid, pwd]
}

class BluetoothManager {
  // 当前已连接的设备
  currentClient: ble.GattClientDevice | null = null

  // 获取蓝牙权限
  async getBluetoothRequestion(callback: (res: abilityAccessCtrl.GrantStatus) => void) {
    const ctx = AppStorage.get<Context>(CONTEXT_GLOB) // 获取全局上下文
    const ctrl = abilityAccessCtrl.createAtManager() // 创建权限管理器
    const res = (ctrl.requestPermissionsFromUser(ctx, ['ohos.permission.ACCESS_BLUETOOTH'])) // 请求蓝牙权限

    res.then(async (promise) => {
      if (!promise.authResults.every(t => t == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)) {
        const res = await ctrl.requestPermissionOnSetting(ctx, ['ohos.permission.ACCESS_BLUETOOTH'])
        callback(res[0])
      } else {
        callback(promise.authResults[0]) // 调用回调函数,返回权限状态
      }
    })
  }

  // 判断本机蓝牙是否打开
  isBluetoothEnabled(): boolean {
    const state: access.BluetoothState = access.getState(); // 获取蓝牙当前状态
    // 判断是否为开启或正在开启状态
    return state === access.BluetoothState.STATE_ON; // 返回蓝牙是否已开启的布尔值
  }

  //监听蓝牙变化
  onBluetoothState(callback: (state: boolean) => void) {
    // 监听蓝牙状态变化事件
    access.on('stateChange', (status) => {
      // 判断蓝牙是否关闭,若关闭则调用回调函数并传入false
      status == access.BluetoothState.STATE_OFF && callback(false)
      // 判断蓝牙是否开启,若开启则调用回调函数并传入true
      status == access.BluetoothState.STATE_ON && callback(true)
    })
  }

  // 蓝牙设备扫描
  findBluetoothDecice(callback: (device: ble.ScanResult[]) => void) {
    try {
      ble.startBLEScan(null, {
        interval: 500, // 设置扫描间隔为500ms
        dutyMode: ble.ScanDuty.SCAN_MODE_LOW_POWER, // 设置扫描模式为低功耗模式
        matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE // 设置匹配模式为积极模式
      })

      // 初始化设备列表数组
      let deviceList: ble.ScanResult[] = []

      ble.on("BLEDeviceFind", (res: ble.ScanResult[]) => { // 监听蓝牙设备发现事件
        res.forEach((dev: ble.ScanResult) => { // 遍历所有发现的设备
          // 如果设备有名称
          if (dev.deviceName) {
            // 将新设备添加到列表中,避免重复
            deviceList.push(...res.filter(v => !deviceList.some(vv => vv.deviceId === v.deviceId)))
          }
          // 调用回调函数,返回当前设备列表
          callback(deviceList)
        })
      })
    } catch (err) { // 捕获异常
      logger.info(err) // 记录错误信息
    }
  }

  // 蓝牙设备连接
  connectDevice(device: ble.ScanResult, callback: (clientDevice: ble.ScanResult) => void) {
    try {
      // 创建GATT客户端设备实例,传入目标设备的ID
      this.currentClient = ble.createGattClientDevice(device.deviceId)
      // 建立与设备的蓝牙连接
      this.currentClient.connect()
      // 监听蓝牙连接状态变化事件
      this.currentClient.on("BLEConnectionStateChange", (result) => {
        // 判断连接状态是否为已连接
        if (result.state === constant.ProfileConnectionState.STATE_CONNECTED) {
          // 调用回调函数,传入已连接的设备
          callback(device)
        }
      })
    } catch (err) {
      logger.info(err)
    }
  }

  //断开蓝牙设备
  disconnectDevice(callBack: () => void) {
    if (this.currentClient) { // 检查当前是否已连接蓝牙设备
      this.currentClient.disconnect() // 断开与蓝牙设备的连接
      this.currentClient.close(); // 关闭蓝牙客户端资源
      this.currentClient = null // 将当前客户端设为null,表示已断开连接
      callBack() // 调用回调函数,通知断开完成
    }
  }

  //数据发送
  async bluetoothSendMsg(data: BlueData) {
    try {
      // 检查当前是否存在已连接的蓝牙设备
      if (this.currentClient) {
        // 获取蓝牙设备的所有服务列表
        const list = await this.currentClient.getServices()
        // 查找服务UUID以0000AE30开头的服务
        const doorService = list.find(v => v.serviceUuid.startsWith("0000AE30"))
        // 查找特征UUID以0000AE10开头的特征
        const message = doorService?.characteristics.find(v => v.characteristicUuid.startsWith("0000AE10"))
        // 创建文本编码器实例
        const encoder = new util.TextEncoder()
        // 将数据对象编码为Uint8Array
        const u8a = encoder.encodeInto(JSON.stringify(data))

        // 向蓝牙设备发送特征值数据
        await this.currentClient.writeCharacteristicValue({
          serviceUuid: message?.serviceUuid, // 服务UUID
          characteristicUuid: message?.characteristicUuid, // 特征UUID
          characteristicValue: u8a.buffer, // 特征值数据
          descriptors: [], // 描述符列表
        }, ble.GattWriteType.WRITE) // 设置写入类型为WRITE
      }
    } catch (err) {
      logger.info(err)
    }
  }

  //监听特征值变化
  async listenInDeviceDataChange(callBack: (message: number | void) => void) {
    // 检查当前是否存在已连接的蓝牙设备
    if (this.currentClient) {
      // 监听蓝牙特征值变化事件
      this.currentClient?.on("BLECharacteristicChange", (res) => {
        // 创建文本解码器实例
        const decoder = util.TextDecoder.create()
        // 将特征值数据转换为Uint8Array
        const buffer = new Uint8Array(res.characteristicValue)
        // 将二进制数据解码并解析为BlueData对象
        const result = JSON.parse(decoder.decodeToString(buffer)) as BlueData
        // 调用回调函数,传递状态码
        callBack(result.status)
        // 如果命令类型为'wifi'
        if (result.command === 'wifi') {
          // 再次调用回调函数,传递状态码
          callBack(result.status)
        }
      })
      // 获取蓝牙设备的所有服务列表
      const serviceList = await this.currentClient?.getServices()
      // 查找服务UUID以0000AE30开头的服务
      const doorService = serviceList?.find(v => v.serviceUuid.startsWith("0000AE30"))
      // 查找特征UUID以0000AE04开头的特征
      const message = doorService?.characteristics.find(v => v.characteristicUuid.startsWith("0000AE04"))
      // 设置特征值变化通知,启用通知功能
      await this.currentClient?.setCharacteristicChangeNotification(message, true)
    }
  }
}

export const bluetoothManager = new BluetoothManager()

开发者必看:如何在 iOS 应用中完美实现动态自定义字体!

这里每天分享一个 iOS 的新知识,快来关注我吧

前言

作为 App 开发来说,字体在应用中扮演着至关重要的角色。一个精心选择的字体能够让你的应用在众多竞争者中脱颖而出。

image.png

但是,无论你选择哪种字体,都必须确保它的核心功能——可读性。

在以前,只有苹果自带的系统字体支持动态调整大小,而自定义字体则不支持。但自从 iOS 11 以来,这种情况已经改变。现在,你可以轻松地在动态字体中使用你的自定义字体。

今天就来讲讲如何在动态字体中使用自定义字体。

什么是动态字体?

苹果早在 iOS 7 时就引入了动态字体,旨在让用户选择他们偏好的文本大小以满足自身需求。

在较大的文本尺寸下,各种文本样式(如 .headline, .subheadline, .body, .footnote, .caption1, .caption2, .largeTitle, .title1, .title2, .title3.callout)的权重、大小和行距值可以参考苹果的人机界面指南 - 动态字体尺寸[1]。

image.png

动态字体的实现

动态字体与文本样式一起起作用,文本样式用于为每种文本大小设定缩放因子。例如,.caption2 是最小的文本样式,不会缩小到小于 11 号的大小,因为那样会很难阅读。在最小、小、中和大文本大小下,.caption2 文本样式的大小将保持在 11pt。

要获取动态字体,我们可以使用 UIFont 类方法 preferredFont(forTextStyle:) 来初始化字体。

let label = UILabel()
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true 

设置 adjustsFontForContentSizeCategory 为 true,可以在设备的内容大小类别更改时自动更新字体。

上面的代码将返回一个苹果 San Francisco 常规字体,大小为 17(大文本尺寸下的 body 样式),以下是大文本大小上的所有文本样式的示例。

image.png

调整字体大小

可以通过以下方式更改字体大小:

  1. 打开系统设置 - 显示与亮度 - 文字大小。

  2. 通过拖动滑块调整字体大小。

image.png

调整到更大的字体

通过上边的方法调整字体到一定大小就不能再大了,其实还有办法可以调到更大:

  1. 打开设置 - 辅助功能 - 显示与文字大小 - 更大字体。

  2. 打开更大字体开关。

  3. 通过拖动滑块调整字体大小。

image.png

调试阶段修改文本大小

在开发阶段,还可以直接从 Xcode 调整字体。

  1. 点击调试栏中的图标 Environment Overrides 按钮.

  2. 打开 Dynamic Type 开关.

  3. 通过拖动滑块调整字体大小。

image.png

使用自定义字体

在 iOS 11 中,苹果引入了 UIFontMetrics,使我们的代码更简单。通过它,我们可以创建指定文本样式的 UIFontMetrics,然后将自定义字体传递给 scaledFont(for:) 方法,以获得基于自定义字体的字体对象,具有适当的样式信息,并自动缩放以匹配当前动态字体设置。

let customFont = UIFont(name: "Merriweather-Regular", size: 17)! // <1>
let label = UILabel()
label.adjustsFontForContentSizeCategory = true
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont) // <2>

<1> 我们初始化了自定义字体。在这个例子中,我使用了 Google 字体的 Merriweather 字体。
<2> 我们定义了 UIFontMetrics 的 body 文本样式,然后用它来缩放我们的自定义字体。

支持自定义字体的动态类型

虽然 UIFontMetrics 可以减少我们在支持动态类型的自定义字体上的工作量,但它并不是万能的。有时候我们仍然需要做一些额外的工作。

scaledFont(for:) 方法会根据大文本尺寸的基础字体大小应用缩放因子。苹果在 iOS 人机界面指南中说明了系统字体的字体度量标准。你可以用它作为为每种文本样式定义自定义字体的起始。

以下是我基于苹果度量的简单实现:

let customFonts: [UIFont.TextStyle: UIFont] = [
    .largeTitle: UIFont(name"Merriweather-Regular", size34)!,
    .title1: UIFont(name"Merriweather-Regular", size28)!,
    .title2: UIFont(name"Merriweather-Regular", size22)!,
    .title3: UIFont(name"Merriweather-Regular", size20)!,
    .headline: UIFont(name"Merriweather-Bold", size17)!,
    .body: UIFont(name"Merriweather-Regular", size17)!,
    .callout: UIFont(name"Merriweather-Regular", size16)!,
    .subheadline: UIFont(name"Merriweather-Regular", size15)!,
    .footnote: UIFont(name"Merriweather-Regular", size13)!,
    .caption1: UIFont(name"Merriweather-Regular", size12)!,
    .caption2: UIFont(name"Merriweather-Regular", size11)!
]

extension UIFont {
    class func customFont(forTextStyle styleUIFont.TextStyle) -> UIFont {
        let customFont = customFonts[style]!
        let metrics = UIFontMetrics(forTextStyle: style)
        let scaledFont = metrics.scaledFont(for: customFont)
        
        return scaledFont
    }
}

UIFontMetrics(forTextStyle: style).scaledFont(for: customFont) 替换为 UIFont.customFont(forTextStyle: style) 并再次运行即可。

let styles: [UIFont.TextStyle] = [.largeTitle, .title1, .title2, .title3, .headline, .subheadline, .body, .callout, .footnote, .caption1, .caption2]
for style in styles {
    ...
    let label = UILabel()
    label.adjustsFontForContentSizeCategory = true
    label.text = String(describing: style)
    label.font = UIFont.customFont(forTextStyle: style)    
    ...
}

最后看下效果:

image.png

image.png

image.png

结论

UIFontMetrics 可以减少我们在让自定义字体支持动态类型时所需的工作量。另外我们可能需要花一些时间来微调基础字体以确保其在所有变体中都适合,在 UIFontMetrics 的帮助下,这个过程不算负责。

希望这能帮助你更好地在应用中运用自定义字体。关于自定义动态字体,你有什么看法吗?欢迎在评论区中留言交流。

参考资料

[1]

苹果的人机界面指南: developer.apple.com/design/huma…

这里每天分享一个 iOS 的新知识,快来关注我吧

本文同步自微信公众号 “iOS新知”,每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!

关于openGL的一些学习记录

OpenGL 学习总结

目录

  1. 基础概念

  2. 渲染管线

  3. 着色器编程

  4. 纹理与采样

  5. iOS OpenGL ES

  6. 实际应用

  7. 性能优化

  8. 常见问题


基础概念

什么是 OpenGL?

OpenGL(Open Graphics Library)是一个跨语言、跨平台的图形渲染 API,用于渲染 2D 和 3D 图形。它提供了一套硬件无关的接口,让开发者能够利用 GPU 进行高效的图形渲染。

核心特点

  • 硬件抽象层:屏蔽不同 GPU 的差异

  • 状态机:通过设置状态来控制渲染行为

  • 立即模式 vs 保留模式:现代 OpenGL 使用保留模式

  • 可编程管线:通过着色器程序控制渲染过程

坐标系系统


// OpenGL 使用右手坐标系

// X轴:向右为正

// Y轴:向上为正  

// Z轴:向外为正(屏幕外)

  


// 顶点坐标通常在 [-1, 1] 范围内

let vertices: [Float] = [

    -1.0, -1.0, 0.0// 左下

     1.0, -1.0, 0.0// 右下

     0.01.0, 0.0   // 顶部

]


渲染管线

1. 顶点着色器阶段


// 顶点着色器

attribute vec4 position;

attribute vec2 texCoord;

varying vec2 vTexCoord;

  


void main() {

    gl_Position = position;

    vTexCoord = texCoord;

}

2. 图元装配

  • 将顶点连接成图元(点、线、三角形)

  • 进行视锥体裁剪

  • 背面剔除

3. 光栅化

  • 将图元转换为像素

  • 插值计算片段的属性

4. 片段着色器阶段


// 片段着色器

precision mediump float;

varying vec2 vTexCoord;

uniform sampler2D uTexture;

  


void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    gl_FragColor = color;

}

5. 逐片段操作

  • 深度测试

  • 模板测试

  • 混合


着色器编程

顶点着色器


// 基础顶点着色器

attribute vec4 aPosition;

attribute vec2 aTexCoord;

uniform mat4 uModelViewProjectionMatrix;

  


varying vec2 vTexCoord;

  


void main() {

    gl_Position = uModelViewProjectionMatrix * aPosition;

    vTexCoord = aTexCoord;

}

片段着色器


// 基础片段着色器

precision mediump float;

  


varying vec2 vTexCoord;

uniform sampler2D uTexture;

uniform float uAlpha;

  


void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    gl_FragColor = vec4(color.rgb, color.a * uAlpha);

}

常用内置变量

  • gl_Position:顶点位置(顶点着色器输出)

  • gl_FragColor:片段颜色(片段着色器输出)

  • gl_PointSize:点大小

  • gl_FragCoord:片段坐标

数据类型


// 标量类型

float, int, bool

  


// 向量类型

vec2, vec3, vec4

ivec2, ivec3, ivec4

bvec2, bvec3, bvec4

  


// 矩阵类型

mat2, mat3, mat4

  


// 采样器类型

sampler2D, samplerCube


纹理与采样

纹理创建


func createTexture(from image: UIImage) -> GLuint {

    guard let cgImage = image.cgImage else { return 0 }

    

    var textureName: GLuint = 0

    glGenTextures(1, &textureName)

    glBindTexture(GLenum(GL_TEXTURE_2D), textureName)

    

    // 设置纹理参数

    glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR)

    glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR)

    glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_S), GL_CLAMP_TO_EDGE)

    glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_T), GL_CLAMP_TO_EDGE)

    

    // 上传纹理数据

    let width = cgImage.width

    let height = cgImage.height

    let colorSpace = CGColorSpaceCreateDeviceRGB()

    let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)

    

    let context = CGContext(data: nil,

                           width: width,

                           height: height,

                           bitsPerComponent: 8,

                           bytesPerRow: width * 4,

                           space: colorSpace,

                           bitmapInfo: bitmapInfo.rawValue)!

    

    context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))

    

    let data = context.data!

    glTexImage2D(GLenum(GL_TEXTURE_2D), 0, GL_RGBA, GLsizei(width), GLsizei(height), 0, GLenum(GL_RGBA), GLenum(GL_UNSIGNED_BYTE), data)

    

    return textureName

}

纹理坐标


// 纹理坐标 (0,0) 在左下角,(1,1) 在右上角

let texCoords: [Float] = [

    0.0, 0.0// 左下

    1.0, 0.0// 右下

    0.0, 1.0// 左上

    1.0, 1.0   // 右上

]

纹理过滤


// 最近邻过滤

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_NEAREST)

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_NEAREST)

  


// 线性过滤

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR)

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR)

  


// 多级纹理过滤

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR_MIPMAP_LINEAR)

glGenerateMipmap(GLenum(GL_TEXTURE_2D))


iOS OpenGL ES

初始化


import GLKit

  


class OpenGLView: GLKView {

    private var context: EAGLContext!

    private var program: GLuint = 0

    

    override init(frame: CGRect) {

        // 创建 OpenGL ES 2.0 上下文

        context = EAGLContext(api: .openGLES2)!

        super.init(frame: frame, context: context)

        

        // 设置代理

        self.delegate = self

        

        // 设置像素格式

        self.drawableColorFormat = .RGBA8888

        self.drawableDepthFormat = .format24

        

        // 设置内容缩放因子

        self.contentScaleFactor = UIScreen.main.scale

        

        // 设置当前上下文

        EAGLContext.setCurrent(context)

        

        // 初始化 OpenGL 状态

        setupOpenGL()

    }

    

    private func setupOpenGL() {

        // 启用深度测试

        glEnable(GLenum(GL_DEPTH_TEST))

        

        // 设置清除颜色

        glClearColor(0.0, 0.0, 0.0, 1.0)

        

        // 创建着色器程序

        program = createShaderProgram()

    }

}

渲染循环


extension OpenGLView: GLKViewDelegate {

    func glkView(_ view: GLKView, drawIn rect: CGRect) {

        // 清除缓冲区

        glClear(GLbitfield(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT))

        

        // 使用着色器程序

        glUseProgram(program)

        

        // 设置顶点数据

        setupVertexData()

        

        // 设置纹理

        setupTexture()

        

        // 绘制

        glDrawArrays(GLenum(GL_TRIANGLES), 0, 6)

    }

}

着色器编译


func createShaderProgram() -> GLuint {

    let vertexShaderSource = """

    attribute vec4 position;

    attribute vec2 texCoord;

    varying vec2 vTexCoord;

    

    void main() {

        gl_Position = position;

        vTexCoord = texCoord;

    }

    """

    

    let fragmentShaderSource = """

    precision mediump float;

    varying vec2 vTexCoord;

    uniform sampler2D uTexture;

    

    void main() {

        gl_FragColor = texture2D(uTexture, vTexCoord);

    }

    """

    

    // 编译顶点着色器

    let vertexShader = compileShader(type: GLenum(GL_VERTEX_SHADER), source: vertexShaderSource)

    

    // 编译片段着色器

    let fragmentShader = compileShader(type: GLenum(GL_FRAGMENT_SHADER), source: fragmentShaderSource)

    

    // 创建程序

    let program = glCreateProgram()

    glAttachShader(program, vertexShader)

    glAttachShader(program, fragmentShader)

    glLinkProgram(program)

    

    // 检查链接状态

    var linkStatus: GLint = 0

    glGetProgramiv(program, GLenum(GL_LINK_STATUS), &linkStatus)

    if linkStatus == GL_FALSE {

        print("Program link failed")

        glDeleteProgram(program)

        return 0

    }

    

    // 清理着色器

    glDeleteShader(vertexShader)

    glDeleteShader(fragmentShader)

    

    return program

}

  


func compileShader(type: GLenum, source: String) -> GLuint {

    let shader = glCreateShader(type)

    var cSource = (source as NSString).utf8String

    var length = GLint(source.utf8.count)

    glShaderSource(shader, 1, &cSource, &length)

    glCompileShader(shader)

    

    // 检查编译状态

    var compileStatus: GLint = 0

    glGetShaderiv(shader, GLenum(GL_COMPILE_STATUS), &compileStatus)

    if compileStatus == GL_FALSE {

        var infoLength: GLint = 0

        glGetShaderiv(shader, GLenum(GL_INFO_LOG_LENGTH), &infoLength)

        var infoLog = [GLchar](repeating: 0, count: Int(infoLength))

        glGetShaderInfoLog(shader, infoLength, nil, &infoLog)

        print("Shader compilation failed: \(String(cString: infoLog))")

        glDeleteShader(shader)

        return 0

    }

    

    return shader

}


实际应用

图片渲染


class ImageRenderer {

    private var program: GLuint = 0

    private var vertexBuffer: GLuint = 0

    private var texture: GLuint = 0

    

    func setup() {

        // 创建顶点缓冲区

        let vertices: [Float] = [

            // 位置        // 纹理坐标

            -1.0, -1.0, 0.00.0, 0.0,

             1.0, -1.0, 0.01.0, 0.0,

            -1.01.0, 0.00.0, 1.0,

             1.0, -1.0, 0.01.0, 0.0,

             1.01.0, 0.01.0, 1.0,

            -1.01.0, 0.00.0, 1.0

        ]

        

        glGenBuffers(1, &vertexBuffer)

        glBindBuffer(GLenum(GL_ARRAY_BUFFER), vertexBuffer)

        glBufferData(GLenum(GL_ARRAY_BUFFER), MemoryLayout<Float>.size * vertices.count, vertices, GLenum(GL_STATIC_DRAW))

    }

    

    func render() {

        glUseProgram(program)

        

        // 设置顶点属性

        glBindBuffer(GLenum(GL_ARRAY_BUFFER), vertexBuffer)

        

        let positionLocation = glGetAttribLocation(program, "position")

        let texCoordLocation = glGetAttribLocation(program, "texCoord")

        

        glEnableVertexAttribArray(GLuint(positionLocation))

        glEnableVertexAttribArray(GLuint(texCoordLocation))

        

        let stride = GLsizei(MemoryLayout<Float>.size * 5)

        glVertexAttribPointer(GLuint(positionLocation), 3, GLenum(GL_FLOAT), GLboolean(GL_FALSE), stride, nil)

        glVertexAttribPointer(GLuint(texCoordLocation), 2, GLenum(GL_FLOAT), GLboolean(GL_FALSE), stride, UnsafeRawPointer(bitPattern: 3 * MemoryLayout<Float>.size))

        

        // 设置纹理

        glActiveTexture(GLenum(GL_TEXTURE0))

        glBindTexture(GLenum(GL_TEXTURE_2D), texture)

        glUniform1i(glGetUniformLocation(program, "uTexture"), 0)

        

        // 绘制

        glDrawArrays(GLenum(GL_TRIANGLES), 0, 6)

        

        glDisableVertexAttribArray(GLuint(positionLocation))

        glDisableVertexAttribArray(GLuint(texCoordLocation))

    }

}

滤镜效果


// 灰度滤镜

void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));

    gl_FragColor = vec4(vec3(gray), color.a);

}

  


// 反色滤镜

void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    gl_FragColor = vec4(1.0 - color.rgb, color.a);

}

  


// 模糊滤镜

void main() {

    vec2 texelSize = 1.0 / textureSize(uTexture, 0);

    vec4 sum = vec4(0.0);

    

    for(int i = -2; i <= 2; i++) {

        for(int j = -2; j <= 2; j++) {

            vec2 offset = vec2(float(i), float(j)) * texelSize;

            sum += texture2D(uTexture, vTexCoord + offset);

        }

    }

    

    gl_FragColor = sum / 25.0;

}


性能优化

1. 批处理


// 合并多个绘制调用

func batchRender(objects: [GameObject]) {

    // 按材质分组

    let groupedObjects = Dictionary(grouping: objects) { $0.material }

    

    for (material, objects) in groupedObjects {

        // 绑定材质

        bindMaterial(material)

        

        // 批量绘制

        for object in objects {

            updateTransform(object.transform)

            drawObject(object)

        }

    }

}

2. 顶点缓冲区优化


// 使用 VBO 存储顶点数据

func createVertexBuffer() {

    let vertices: [Float] = [/* 顶点数据 */]

    

    glGenBuffers(1, &vertexBuffer)

    glBindBuffer(GLenum(GL_ARRAY_BUFFER), vertexBuffer)

    glBufferData(GLenum(GL_ARRAY_BUFFER), MemoryLayout<Float>.size * vertices.count, vertices, GLenum(GL_STATIC_DRAW))

}

3. 纹理优化


// 使用纹理图集

func createTextureAtlas() {

    // 将多个小纹理合并到一个大纹理中

    // 减少纹理切换次数

}

  


// 使用压缩纹理

func loadCompressedTexture() {

    // 使用 PVRTC 或 ASTC 格式

    // 减少内存占用和带宽

}

4. 着色器优化


// 避免分支语句

// 不好的做法

if (condition) {

    color = texture2D(tex1, coord);

} else {

    color = texture2D(tex2, coord);

}

  


// 好的做法

color = mix(texture2D(tex1, coord), texture2D(tex2, coord), condition ? 1.0 : 0.0);

  


// 使用内置函数

// 不好的做法

float length = sqrt(x * x + y * y);

  


// 好的做法

float length = length(vec2(x, y));


常见问题

1. 纹理显示问题

问题:纹理显示为黑色或白色

原因

  • 纹理数据格式不匹配

  • 纹理坐标错误

  • 采样器设置问题

解决


// 检查纹理格式

glTexImage2D(GLenum(GL_TEXTURE_2D), 0, GL_RGBA, width, height, 0, GLenum(GL_RGBA), GLenum(GL_UNSIGNED_BYTE), data)

  


// 检查纹理坐标

let texCoords: [Float] = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]

  


// 设置正确的采样器

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR)

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR)

2. 深度测试问题

问题:物体渲染顺序错误

解决


// 启用深度测试

glEnable(GLenum(GL_DEPTH_TEST))

glDepthFunc(GLenum(GL_LESS))

  


// 清除深度缓冲区

glClear(GLbitfield(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT))

3. 内存泄漏

问题:OpenGL 资源未正确释放

解决


deinit {

    // 释放纹理

    if texture != 0 {

        glDeleteTextures(1, &texture)

    }

    

    // 释放缓冲区

    if vertexBuffer != 0 {

        glDeleteBuffers(1, &vertexBuffer)

    }

    

    // 释放着色器程序

    if program != 0 {

        glDeleteProgram(program)

    }

}

4. 性能问题

问题:渲染性能低下

解决

  • 减少绘制调用次数

  • 使用批处理

  • 优化着色器

  • 使用 LOD(细节层次)

  • 启用背面剔除


调试技巧

1. 着色器调试


// 在片段着色器中输出调试信息

void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    

    // 输出红色通道作为调试

    gl_FragColor = vec4(color.r, 0.0, 0.0, 1.0);

}

2. 状态检查


func checkOpenGLState() {

    // 检查帧缓冲区状态

    let status = glCheckFramebufferStatus(GLenum(GL_FRAMEBUFFER))

    if status != GLenum(GL_FRAMEBUFFER_COMPLETE) {

        print("Framebuffer not complete: \(status)")

    }

    

    // 检查着色器编译状态

    var compileStatus: GLint = 0

    glGetShaderiv(shader, GLenum(GL_COMPILE_STATUS), &compileStatus)

    if compileStatus == GL_FALSE {

        // 获取错误信息

        var infoLength: GLint = 0

        glGetShaderiv(shader, GLenum(GL_INFO_LOG_LENGTH), &infoLength)

        var infoLog = [GLchar](repeating: 0, count: Int(infoLength))

        glGetShaderInfoLog(shader, infoLength, nil, &infoLog)

        print("Shader compilation failed: \(String(cString: infoLog))")

    }

}

3. 性能分析


// 使用 Instruments 进行性能分析

// 关注以下指标:

// - GPU 使用率

// - 绘制调用次数

// - 纹理内存使用

// - 顶点处理数量


总结

OpenGL 是一个强大的图形渲染 API,掌握它需要:

  1. 理解渲染管线:从顶点到像素的完整流程

  2. 掌握着色器编程:GLSL 语言和 GPU 编程

  3. 熟悉纹理系统:纹理创建、采样和过滤

  4. 学会性能优化:批处理、内存管理、算法优化

  5. 掌握调试技巧:状态检查、错误处理、性能分析

通过持续学习和实践,OpenGL 将成为你图形编程的强大工具。记住:

  • 从简单开始,逐步增加复杂度

  • 重视性能优化

  • 养成良好的调试习惯

  • 关注最新的 OpenGL 特性和最佳实践

AppStore教你一招免备案的骚操作!

前言

书接上文AppStore的肃清了没有备案的产品,后台很多留言询问怎么样可以不备案?

好好好,想卡Bug,又不想花钱,还不想有风险

行吧,参考了很多资料和证明咨询了AppStore,终于找出来一条免备案的骚操作

如何操作?

No1. 需要从AppStore开发者后台,向苹果审核团队发起审核相关的疑问。

联系技术支持.png

No2. 耐心等待审核团队邮件,如实提供资料。

苹果回复

No3. 免责声明。

尊敬的审核团队:

   你好,非常感谢您给我提供这样一个回复的机会。
   兹证明Apple ID:xxxxxx@qq.com,持有人为xxx,我申请的免备案AppleID为:xxxxxx,
   Bundle ID:com.xxxxxx.xxxx.xxx
身份证号码为:xxxxxxxxxxx,居住地址为:xxx省xxxx市xxx区xxx路xxx号xxx小区xxx栋xxxx-xxx。
   
   具体参考资料,请查看身份证正面和背面的照片。为了证明我是账号持有人,我还将额外提供户籍信息、居住地缴费清单,来确保我的身份真实有效。
   正面:【附图】
   背面:【附图】
   户口本:【附图】
   水费、电费:【附图】
   
   本人郑重承诺,以上所有信息真实有效,如有任何欺瞒审核团队或虚假资料本人愿意承担任何法律责任,承担一切法律后果。
   
   持有签字:xxxx 【手印】
   签字日期:xxxx年xx月xx日

因为对于国内开发者信上海是苹果的话事人,所以不需要担心语言沟通问题。

⚠️关于免责声明的内容,仅供参考。总之,要尽可能多得向苹果提供有效资料。提交之后,就是耐心等待结果。

最终效果

BYPASS

如果开发者邮箱收到苹果新消息,那么恭喜你已经成功跳过来备案要求。

特别说明

因为本文示例产品为单机应用,类目属于工具类。只是用了AppStore内购相关的API,其他不需要任何网络请求。所以,需要联网的产品未必适用本文内容

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

相关推荐

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

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

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

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

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

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

知识星球

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

一步到位:用 Very Good CLI × Bloc × go_router 打好 Flutter 工程地基

目录

  1. 创建flutter项目
  2. 国际化:l10n 带来的不仅是翻译
  3. 路由:为什么选 go_router?
  4. 状态管理:Cubit + Bloc + Repository 分层模型
  5. 错误提示策略:SnackbarMessage
  6. 资源管理:flutter_gen 的价值
  7. 调试与可观测性

在开发一个 Flutter 应用时,通常需要考虑以下几个方面:

  • 国际化(i18n):支持多语言,提升用户体验;
  • 状态管理:管理页面之间或组件之间的数据状态;
  • 资源引入:如图片、字体等静态资源的管理与使用;
  • 路由管理:实现页面跳转和导航逻辑;
  • 用户状态切换:处理未登录到已登录状态的转变;
  • 错误提示机制:如登录失败时的错误反馈展示。

本文将以一个 Instagram 登录页为例,从 0 开始搭建一个 Flutter 项目,逐步实现上述功能,构建一个可持续开发的项目架构。本文完整代码:github.com/wutao23yzd/… 中的Demo5,效果如下:

1.创建flutter项目

创建flutter项目,采用very_good_cli,(pub.dev/packages/ve… 创建 Flutter 项目的好处在于,它提供了规范化的项目结构、严格的代码分析规则、内建测试和 CI 支持,帮助开发者快速搭建高质量、可维护的应用,特别适合团队协作和企业级项目开发。通过如下指令,可以创建一个指定组织名和包名的flutter应用

very_good create flutter_app flutter_instagram_clone_app --org "com.flutter--application-id "com.flutter.futter_instagram_clone"


2. 国际化 (l10n)

l10n 是 “localization” 的缩写(l + 10个字母 + n),即“本地化/国际化”。very_good_cli创建好项目后,会自动生成国际化相关文件,但没有中文,可以这样子添加:

  • 在 pubspec.yaml 中确保已经添加了 flutter_localizations 依赖。
  • 在 l10n.yaml 配置文件中添加中文(zh)支持。
# 添加中文支持
preferred-supported-locales:
  - en
  - zh
  • 在 arb 目录下添加中文的 ARB 文件(如 app_localizations_zh.arb),并翻译内容
  • 添加新的文案时,使用flutter gen-l10n 重新生成本地化代码。

3. 路由:为什么选 go_router

路由使用go_routterpub.dev/packages/go… 是 Flutter 官方推荐的路由管理库,,支持嵌套路由、参数传递、URL 同步、重定向和导航守卫等高级功能。相比传统的 Navigator,它结构更清晰、代码更简洁,而且支持基于用户登录状态的路由重定向逻辑,比如登录、登出,可以通过如下代码跳转:

  redirect: (context, state) {
      final authenticated = appBloc.state.status == AppStatus.authenticated;
      final authenticating = state.matchedLocation == AppRoutes.auth.route;

      if (!authenticated) return AppRoutes.auth.route;
      if (authenticating && authenticated) return AppRoutes.home.route;

      return null;
  }


4. 状态管理:Cubit + Bloc + Repository 分层模型

在 Flutter 项目中,采用 Cubit + Bloc + Repository 的分层模型 是一种清晰、可维护性强的架构设计。它将业务逻辑、状态管理和数据访问进行职责分离(pub.dev/packages/bl…

  • Repository 层负责与数据源(如 API、数据库、缓存)交互,提供统一的数据获取接口。
  • Bloc/Cubit 层负责管理状态和业务逻辑,从 Repository 获取数据并根据用户行为更新状态。
  • **UI 层(Widget)**只关心状态展示,通过监听 Bloc/Cubit 提供的状态流进行响应式更新。

在提供的Demo中,AuthRepository 统一产出 用户身份流,任何需要身份信息的层(AppBloc)都只订阅这一个来源。

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

  @override
  Widget build(BuildContext context) {
    return RepositoryProvider(
      // 全局唯一的数据层
      create: (_) => AuthRepository(),
      child: BlocProvider(
        create: (context) => AppBloc(
          authRepository: context.read<AuthRepository>(),
        ),
        child: const AppView(),
      ),
    );
  }
}
  • app.dart 顶层依赖注入,暴露一个Stream表示全局身份状态。
  • LoginCubit 只负责表单状态与调用 AuthRepository.login
  • AppBloc 只订阅 AuthRepository.user,再映射成 authenticated / unauthenticated

这样 UI ↔︎ 业务 ↔︎ 数据 的依赖方向清晰且单向。


5. 错误提示策略:SnackbarMessage 队列化

使用BlocListener<LoginCubit, LoginState>监听当前状态,如果出现错误,则顶部弹出提示;使用clearIfQueue 清除旧消息 。

BlocListener<LoginCubit, LoginState>(
  listenWhen: (p, c) => p.status != c.status && c.status.isError,
  listener: (_, s) => openSnackbar(
    SnackbarMessage.error(title: '错误', description: s.message!),
    clearIfQueue: true,
  ),
  child: const LoginForm(),
);

6. 资源管理:flutter_gen 的价值

首先要要安装flutter_gen 依赖,同时在dev_dependencies中,需要安装

build_runner: ^2.5.4
flutter_gen_runner: ^5.10.0

对于svg的文件,还需要安装依赖flutter_svg;然后按照demo中所示,在pubspec.yaml中提供资源所示路径;最后,执行dart run build_runner build自动生成assets.gen.dart 文件,引用资源文件如下所示:

Image.asset(Assets.images.logoPng.path);
FontFamily(Assets.fonts.montserrat);  // 类型安全 + IDE 自动补全
  • 不再担心路径拼写错误
  • 对 Lottie / SVG 同样适用

7. 调试与可观测性

class AppBlocObserver extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) =>
      log('[Bloc] ${bloc.runtimeType} $change');
}
  • 实时跟踪 Bloc / Cubit 状态变迁
  • Flutter DevTools:开启 “Enhance tracing for user widgets”

写在最后
Demo大量代码使用了www.youtube.com/watch?v=xr5…

autorelease pool

  1. 有两个observer会监听runloop两个事件,一个observer监听runloop要进入的是时候entry,会调用pool push方法创建一个autorelease pool

  2. 另一个observer监听runloop的状态,当runloop要进入休眠状态时beforewaiting,会pop一个自动释放池,同时push创建一个新的自动释放池。

  3. AutoreleasePoolPage 结构

    class AutoreleasePoolPage
    {
    const magic
    id *next 指向下一个可以存放被释放对象的地址
    pthread_t const thread 当前所在的线程
    AutoreleasePoolPage *const parent 当前page的父节点
    AutoreleasePoolPage *child
    
    
    }
    
  4. 每个page占4096个字节也就是4kb,自身成员变量只占56个字节,也就是7个成员变量,每个成员变量占8个字节。其他四千多个字节都是用来存放被autorelease修饰的对象内存地址。

  5. pool_boundary的作用是区分不同自动释放,调用push时,会传入一个pool_boundary并返回一个地址,这个地址不存储@autorelease对象的地址,起到一个标识作用,用来分隔不同的autoreleasepool

  6. 调用pop的时候,会传入end地址,从后到前调用对象的release方法,直到pool_boundary为止。

  7. 如果存在多个page,会从child的page最末尾开始调用,直到pool_boundary

  8. page是一个栈结构,释放是从栈顶开始

  9. 多层嵌套会共用一个page,通过pool_boundary来分隔,优先释放在里层的pool,因为最里层的pool中的对象被放倒了栈顶,优先释放栈顶对象。

    @autoreleasepool {
         NSObject *p1 = [[NSObject alloc] init]
         NSObject *p2 = [[NSObject alloc] init]
              @autoreleasepool {
                     NSObject *p3 = [[NSObject alloc] init]
                            @autoreleasepool{
                                   NSObject *p4 = [[NSObject alloc] init]
    }
    }
    }
    

16476988032851.jpg

  1. 释放时机:如果通过代码添加一个autoreleasepool,在作用域结束时,随着pool的释放,就会释放pool中的对象,这种情况是几十释放的,并不依赖于runloop。另一个就是系统自动释放的,系统会在runloop开始的时候创建一个pool,结束的时候会对pool中对象执行release操作。
  2. autoreleasepool 和 runloop的关系

16509481525421.jpg

Xcode16报错: SDK does not contain 'libarclite' at the path '/Applicati

xcode 16运行项目报如下错误:

SDK does not contain 'libarclite' at the path '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/arc/libarclite_iphonesimulator.a'; try increasing the minimum deployment target

解决方案:

  • 一、错误原因是在这个路径下边缺少一个libarclite_iphonesimulator.a文件,那就command + G打开这个路径看一下,结果发现这个目录下边没有arc这个文件夹。如下图所示:
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/

Snip20250705_1.png

  • 二、点击这里下载arc文件下载下来放到这个路径下边再次运行就不报错了。这里需要注意下,就是必须是command + C + command + V复制粘贴arc这个目录下,不能拖拽,拖拽的是快捷方式不是真实的文件。

Snip20250705_2.png

下载地址我已经放到github了,需要的可以自行下载。

谈一谈iOS线程管理

前言

iOS 线程管理是一个老生常谈的话题,也是新人绕不过去的一道难关。用好多线程不容易,下面我简单谈一谈我的体会,希望对屏幕前的你有所帮助。

一、什么时候需要多线程

首先,要知道线程不是越多越好,创建线程和切换线程都有一定的开销,线程使用的不当也容易造成崩溃。那么什么时候需要使用多线程呢?一个主要的衡量标准是这个操作是否耗时,比如读写文件、网络请求、加解密等。特别是IO密集的操作,一定是要多线程的,否则会阻塞当前线程。

其次,线程和队列有着紧密的联系(ios里面特指GCD队列),如果某些操作需要按照一定的时序来执行并且对执行的时间不是那么敏感的话,那么最好就是放在一个串行队列里,比如写缓存。如果这些操作对执行时间敏感,且不是很讲究顺序的话,那么放在并行队列里比较合适,比如从分批下载视频片段(例如dash和hls)。如果是对执行时间敏感,并且又有一定的执行顺序,那么可以考虑NSOperationQueue,或者用dispatch_group、dispatch_semaphore来管理多个线程及其依赖关系。如果对这些都不讲究,那就用不着多线程了。

二、同步还是异步

一般情况下,能用异步还是用异步,除非是需要等待结果返回的才用同步。这主要是因为同步操作会阻塞线程,弄的不好还会导致死锁。编写同步代码的话,主要是用在同步读取某些属性这种场景,比如以下这个方法

- (BOOL)hasSubscribeTopic:(NSString*)topic {

        __block BOOL subscribed = NO;

        dispatch_sync(self.syncQueue, ^{

            subscribed = [self.subscribedTopics containsObject:topic];

        });

        return subscribed;


}

但是这样写有一个问题,就是如果别的方法在syncQueue对应的线程上调用了hasSubscribeTopic这个方法,就会导致死锁,所以正确的方式应该是这样

static const void * kDispatchQueueSpecificKey = &kDispatchQueueSpecificKey;
//init方法中调用
dispatch_queue_set_specific(_syncQueue, kDispatchQueueSpecificKey, (__bridge void *)self, NULL);

- (BOOL)hasSubscribeTopic:(NSString*)topic {

    void* value = dispatch_get_specific(kDispatchQueueSpecificKey);

    if (value == (__bridge void *)(self)) {

        return [self.subscribedTopics containsObject:topic];

    }else{

        __block BOOL subscribed = NO;

        dispatch_sync(self.syncQueue, ^{

            subscribed = [self.subscribedTopics containsObject:topic];

        });

        return subscribed;

    }

}

有些第三方库没有注意这方面,比如SDWebImage的SDImageCache,使用的时候就需要尤其注意

- (void)storeImageDataToDisk:(nullable NSData *)imageData

                      forKey:(nullable NSString *)key {

    if (!imageData || !key) {

        return;

    }

    

    dispatch_sync(self.ioQueue, ^{

        [self _storeImageDataToDisk:imageData forKey:key];

    });

}

三、串行还是并行

这个如前所述,主要看对执行时间的敏感程度和有无顺序要求。一般使用dispatch_create创建的队列以串行为主(swift的dispatchQueue默认就是串行的)。并行队列使用global_queue就可以了,但是有一个需要特别注意的是,不管是dispatch_get_global_queue还是dispatch_create分配的线程都是有上限的,如果超出上限,系统要么就是等待前面的线程执行完成(iOS模拟器),要么就会因为分配资源过多而导致崩溃(iOS真机)。通过下面这段代码,可以测试出系统最多能分配多少个线程,在iphone 15的模拟器上我测试得到的是global_queue能分配64个左右线程,而dispatch_create相对多一点,100多不到200个。

dispatch_queue_t syncQueue = dispatch_queue_create("test", DISPATCH_QUEUE_SERIAL);

    for (int i=0;i<1000;i++) {

        dispatch_async(dispatch_get_global_queue(0, 0), ^{

            pthread_t tid = pthread_self();

            printf("1 tid=%lu\n",(unsigned long)tid);

            dispatch_sync(syncQueue, ^{

                NSLog(@"2");

            });

            NSLog(@"3");

        });

    }

还有一个问题就是,使用dispatch_get_global_queue创建的线程看似64个也够用了,但如果在这些线程里面使用了同步操作等待串行队列执行完成的话,就会造成阻塞,最终超出线程数量上限而崩溃。比如将上面代码中的NSLog(@"2") 改为一个写缓存之类的耗时操作。

四、线程池

由于线程数量是有上限的,并且线程切换比较耗时,所以对于性能要求较高的程序需要有线程池来管理多线程。iOS是没有系统自带的线程池的,一般都是自己实现(推荐使用dispatch_semaphore或NSOperationQueue,具体实现可以参考java的executor相关代码。需要注意的是,什么时候切换到线程池是有讲究的,一般规则是逻辑层的代码尽早切换到线程池,特别是有些逻辑可能会创建多个线程的时候,比如多个图片的下载和缓存。

五、线程的同步

线程的同步也是一个比较经典的话题了,我在这里就不想赘述了,大家可以在网上随便搜一搜,我只提一下,一般线程间同步就几种方式:

  1. 加锁
  2. 条件变量 3.信号量
  3. 串行队列+同步读异步写
  4. 内存屏障
  5. CAS原子操作

个人比较推荐的是加锁(性能要求没那么高)和条件变量(性能要求较高,逻辑相对简单的场景)。串行队列如果管理不当可能会创建多个线程,因此不做推荐。内存屏障和CAS原子操作比较底层,使用起来也没那么方便,除非是对时序和性能要求极高。

六、线程间通信

除了使用C语言的pthrea_create和pthread_join来进行线程创建和销毁时的通信外,iOS还可以使用NSMachPort和NSThread的performSelectorOnThread来做线程间通信。前者跟runloop结合,在runloop的生命周期内注册一个特定的事件来定期检查并执行,后者类似于pthread_create,在创建线程时传递一个参数。 除了这种系统提供的方法外,还有一种通用的方式,就是在线程内维护一个事件队列,外部需要给这个线程发消息时,就往队列插入一个事件,然后该线程在一个循环内定时去取事件执行。有点类似runloop的感觉,如果要跨平台的话可以考虑使用libevent(一般用来做网络通信)来实现。

结语

不管是在iOS还是其他的平台上,多线程管理都是一个复杂的话题。要用好多线程,除了要掌握一些常见的方法外,最主要还是平时编程的时候多思考,什么时候应该用多线程,以及怎么样做好线程同步和队列的选择,在追求高性能的同时保证安全性。

iOS断点下载

断点下载:是指在下载一个较大文件过程中因网络中断等主动暂停下载,当重启任下载任务时,能够从上次停止的位置继续下载,而不用重新下载。

知识点:

1.URLSession及其任务管理

URLSessionDownloadTask:是实现断点下载的核心类,专门用于下载文件到临时位置,并原生支持断点续传:

相关代码:

let configuration = URLSessionConfiguration.default

var downloadTask : URLSessionDownloadTask?

let fileURL = URL(string: "http://vjs.zencdn.net/v/oceans.mp4")

任务下载

let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

downloadTask = session.downloadTask(with: URLRequest(url: fileURL!))
downloadTask?.resume()

继续下载

let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

downloadTask = session.downloadTask(withResumeData: data)
downloadTask?.resume()

取消下载

downloadTask?.cancel(byProducingResumeData: { [weak self] resumeData in
   self?.downloadTask = nil
   // 其他操作
}

2.数据持久化

下载的过程本身是不处理相关数据的存储的,需要我们自己来实现。数据持久化的方式很多但支持断点下载功能的多半都是比较大型的文件。因此选择沙盒(SandBox)来存储下载的文件是十分合适的。

获取文件目录:一般都是把文件存储到documentDirectoryuserDomainMask目录

let fileManager = FileManager.default

let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!

创建写入文件路径:这里表示把文件写入MyVideos文件,文件名为:oceans.mp4

let folderName = documentDaiectory.appendingPathComponent("MyVideos")

let videoURL = folderName.appendingPathComponent("oceans.mp4")

在上一步获取文件目录已经指定了一个根目录这个会沙盒系统的根目录下再创建一个MyVideos文件

// 创建需要的文件目录
do {
   try fileManager.createDirectory(at: folderName, withIntermediateDirectories: true, attributes: nil)
   // 写入文件
} catch {
   print("创建目录失败:\(error)")
}

写入文件

do {
    try data.write(to: videoURL)
    print("写入成功")
} catch {
    print("写入失败:\(error)")
}

下次继续下载时要去做一个判断,查看是否已经存储之前下载的内容,返回TRUE则是进行继续下载,返回FALSE则是重新开始下载

let fileManager = FileManager.default

guard let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
   return false
}

let documentsFileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")

if fileManager.fileExists(atPath: documentsFileURL.path) {
  do {
      // 存在 
      // 同时获取当一已下载文件的Data
      self.currentDownloadData = try Data(contentsOf: documentsFileURL)
      return true
  } catch {
      return false
  }
} else {
  return true
}

在对返回的状态做相应的处理

if isFileExist() == true {
   // 继续下载
} else {
  // 重新下载
}

3.URLSessionDownloadDelegate

除了相关下载存储操作外还要实现 URLSessionDownloadDelegate 相关代理方法

下载完成:通过URLSessionDownloadTask下载完成的文件并不会存储到指定的文件夹,而是存储在sandbox的tmp目录下的临时文件夹内。该文件夹内的数据随时都会被系统清理,因此要在适当的时候把文件转移到我们需要的文件下。

这里我们把文件存储到""MyVideos"文件下并使用"oceans.mp4"为文件名

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
   let fileManager = FileManager.default
   let documentDirectory = FileManager.default.urls(for:.documentDirectory, in: .userDomainMask).first!
   let fileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")

   do {
      if isFileExist() == true {
         // 还是对文件是否存在做一个判断并做一个删除处理,因为沙盒系统本身不会自动覆盖同名文件的处理
         try fileManager.removeItem(at: fileURL)
      }
      
      // 移动到指定目录
      try fileManager.moveItem(at: location, to: fileURL)
   } catch {
      print("删除文件出错:\(error)")
   }
}

下载过程中方法:可以从该方法获取到下载的进度

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
   self.currentProgressValue = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
}

核心代码

import UIKit
import Foundation

typealias DownloadProgressBlock = (_ progreee : Float) -> ()
typealias DownloadFileBlock = (_ fileURL : URL) -> ()

class WZGVideoDownloader : NSObject {
    static var shard = WZGVideoDownloader()

    var progressBlock : DownloadProgressBlock?
  
    var fileBlock : DownloadFileBlock?

    let configuration = URLSessionConfiguration.default
    var downloadTask : URLSessionDownloadTask?
    let fileURL = URL(string: "http://vjs.zencdn.net/v/oceans.mp4")
    
    // 存储已下载data
    var currentDownloadData : Data?

    // 当前文件大小
    var currentProgressValue : Float = 0.0
    
    func startDownload(_ fileSize : Data) {
        let fileManager = FileManager.default
        let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
        let documentFileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")

        let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

        // 判断是继续下载还是重新下载
        if isFileExist() == true {
            if let data = self.currentDownloadData {
                if data == fileSize {
                    self.progressBlock?(1)
                    self.fileBlock?(documentFileURL)
                    return
                }
                self.progressBlock?(self.currentProgressValue)

                // 继续下载
                print("继续下载")
                downloadTask = session.downloadTask(withResumeData: data)
                downloadTask?.resume()
            }

        } else {
            // 重新下载
            print("重新下载")
            downloadTask = session.downloadTask(with: URLRequest(url: fileURL!))
            downloadTask?.resume()
        }
    }
    
    func stopDownload() {
        downloadTask?.cancel(byProducingResumeData: { [weak self] resumeData in
            guard let resumeData = resumeData else {
                return
            }
            self?.writeSandBox(resumeData)
            self?.downloadTask = nil
        })
    }
    
    // 判断是否有下载的文件

    func isFileExist() -> Bool {
        let fileManager = FileManager.default
        guard let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            return false
        }

        let documentsFileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")

        if fileManager.fileExists(atPath: documentsFileURL.path) {
            do {
                self.currentDownloadData = try Data(contentsOf: documentsFileURL)
                print("currentDownloadData:\(currentDownloadData)")
                return true
            } catch {
                return false
            }
        } else {
            return false
        }
    }
    
    // 写入sandbox

    func writeSandBox(_ data : Data) {
        let fileManager = FileManager.default
        let documentDaiectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!

        //创建目录几写入文件名
        let folderName = documentDaiectory.appendingPathComponent("MyVideos")

        //设置写入文件名称
        let videoURL = folderName.appendingPathComponent("oceans.mp4")

        // 创建目录
        do {
            try fileManager.createDirectory(at: folderName, withIntermediateDirectories: true, attributes: nil)
            // 写入文件
            do {
                try data.write(to: videoURL)
                print("写入成功")
            } catch {
                print("写入失败:\(error)")
            }
        } catch {
            print("创建目录失败:\(error)")
        }
    }
}

extension WZGVideoDownloader : URLSessionDownloadDelegate {
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        let fileManager = FileManager.default
        let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        let fileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")
        do {
            if isFileExist() == true {
                // 文件存在则删除
                try fileManager.removeItem(at: fileURL)
            }
            // 下载完会保存在temp零食文具目录 转移至需要的目录
            try fileManager.moveItem(at: location, to: fileURL)
            self.fileBlock?(fileURL)
        } catch {
            print("删除文件出错:\(error)")
        }
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        self.currentProgressValue = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
        DispatchQueue.main.async {
            self.progressBlock?(self.currentProgressValue)
        }
    }
}

关于OC与Swift内存管理的解惑

  • 在Swift中,如何解决闭包的循环引用?
myClosure = { [weak self] in
    guard let self = self else { return }
}
  • 那么如果是多个闭包嵌套呢?
// ✅ 只需要在最外层写一次 
myClosure1 = { [weak self] in [weak self]
    // 在顶部进行一次安全解包
    guard let self = self else { return }
    // 在这个作用域里,`self` 是一个临时的强引用,可以安全使用
    // 不用担心它被提前释放
    myClosure2 = {
        // 它会捕获上面 guard let 创建好的、非可选的强引用 self
        myClosure3 = {
            // 你可以直接、安全地调用 self 的方法或属性
        }
    }
}
  • 那么又有一个问题:在OC中的block,就需要每一层都要弱引用,第二层要先强引用再弱引用,第三层再先强引用再弱引用吧?为什么Swift不是这个逻辑? 这是因为Swift 的逻辑确实和 OC 不一样,它在这方面做了极大的简化和安全性提升。

OC 的核心问题在于:   @strongify(self) 创建的那个临时强引用 strongSelf 的生命周期会持续到包含它的那个 block 执行完毕。如果这个 block 内部又启动了一个长时间的异步任务(第二个 block),那么 strongSelf 会被第二个 block 捕获,导致 self 实例的生命周期被不必要地延长。因此,为了追求最精细的内存管理,开发者才会在每一层异步调用前都重复进行“弱化 -> 强化”的操作。

Swift 的优势在于:  guard let self = self 创建的强引用 self 是一个全新的、仅存在于外层闭包作用域内的局部变量。当 anotherAsyncTask 的闭包(内层闭包)创建时,它捕获的是这个局部的、新的 self。一旦外层闭包执行完毕(someAsyncTask 的回调结束),这个局部的 self 变量就会被销毁。anotherAsyncTask 的闭包对它的持有也就自然解除了,完全不会影响到原始 self 实例的生命周期。

特性 / 行为 Objective-C (block) Swift (closure)
弱引用声明 @weakify(self) 或 __weak typeof(self) weakSelf = self; 在捕获列表 [weak self]
临时强引用 @strongify(self) 或 __strong typeof(weakSelf) strongSelf = weakSelf; guard let self = self else { return }
嵌套捕获 内层 block 捕获由 @strongify 创建的 strongSelf,其生命周期可能过长,导致需要“再次弱化”。 内层闭包捕获由 guard let 创建的局部强引用 self。该局部变量生命周期很短,因此无需再次弱化
开发者操作 需要警惕并可能在每一层异步调用前都重复“弱化-强化”的模式。 只需要在最外层做一次“弱化-强化” ,内部可以完全放心使用。
  • 但是在实际开发中,swift代码提示引用报错,往往xcode是这样解决的,为什么?
someClosure = { [self] in }

那是因为Xcode 的首要任务是解决编译错误,而不是帮你分析内存管理。  而 [self] in 正是解决这个特定编译错误的“最直接”的语法。它解决了语法问题,但它没有解决循环引用的问题。它只是把一个隐式的强引用,变成了一个显式的强引用。所以解决循环引用问题还是需要[weak self]

  • 那么在实际开发中[self]对于内存泄露来说是错误的呗?

这个说法不完全准确,但您的警惕性非常对!更精确的说法是: 在【会】产生循环引用的场景下使用 [self],是绝对错误的,它会直接导致内存泄漏。 但是,在【不会】产生循环引用的场景下,[self] 则是安全、甚至是被推荐的写法。 所以,[self] 本身不是“错误”,它只是一个工具。错误的是在不合适的场景下使用了这个工具。

比如下列两种情况

// 这个闭包被传递给 UIView.animate,执行完动画后就会被销毁。 
// self 并没有一个属性来持有这个闭包。 
// 所以 self -> 闭包 这条强引用链不存在。 
UIView.animate(withDuration: 0.5) { [self] in 
    // 在这里使用 [self] in 是【完全正确】的。 
    // 它明确地告诉编译器:“我知道我在强引用 self,且我确定这是安全的。” 
}

// DispatchQueue.main.asyncAfter 的闭包同样是执行完就销毁。 
DispatchQueue.main.asyncAfter(deadline: .now() +1.0) { [self] in 
    // 这里使用 [self] in 也是【完全正确】的。 
}

简单的可以理解为“一次性”的工作 (用 [self] 是安全的),“长期”的规则 (必须用 [weak self])。

  • 那么又有一个场景,在对网络请求进行二次封装的情况下,在调用网络请求时,是否需要弱引用?

答案是不需要的。为什么呢?因为Alamofire临时持有了您的闭包,由于 ViewController 没有持有任何东西,所以闭包无论如何强引用 ViewController,都构不成一个闭环。因此,这里使用 [self] 来显式强引用是完全安全的。

  • 为什么感觉 OC 的 AFNetworking 封装调用时不需要弱引用?

无论是在 OC 的 AFNetworking 还是 Swift 的 Alamofire,它们【内部都没有,也不可能】自动处理您在闭包中捕获 self 导致的循环引用。防止循环引用的责任始终在调用者(也就是您)身上。

  • 那为什么您会有“AFN不需要弱引用”的印象呢?

原因和我们上面分析的完全一样:因为您在 OC 中调用 AFN 封装的场景,很可能也属于“一次性”的调用,本身就不会产生循环引用。由于AFHTTPSessionManager的实例 manager 是一个局部变量,方法执行完就释放了,success block 被 manager 临时持有,执行完也就释放了。所以,当时您在 OC 里不写弱引用是正确的,不是因为 AFNetworking 内部处理了,而是因为您的【用法】决定了它根本没有循环引用!

[MyOCNetworkManager requestWithURL:@"" parameters:nil success:^(id responseObject) { 
    // 您在这里直接使用 self,比如 [self.tableView reloadData]; 
    // 并没有写 __weak typeof(self) weakSelf = self; 
} failure:^(NSError *error) { 
    // ... 
}];
  • 那么在OC+AFNetworking的二次封装回调时,如果我依然写弱引用的话,会有问题吗?

完全没有问题,这样做在内存上是绝对安全的,但同样,它也是不必要的,并且有轻微的副作用。 在那个场景下,编写弱引用代码(即 weak/strong dance)是“安全但多余的”。

我们来对比一下两种写法:

  1. 在您调用一个“一次性”的网络请求封装时,闭包不会被您的 ViewController 持有,因此不存在循环引用。
[MyOCNetworkManager requestWithURL:@"" 
                        parameters:nil 
                           success:^(id responseObject) {
    // 直接使用 self,闭包会强引用 self。
    // 因为没有循环引用,self 会在闭包执行完后被正常释放,这是安全的。
    [self.tableView reloadData];
} 
                           failure:nil];

这种写法简洁、清晰,并且正确地表达了意图:“这是一个一次性的任务,我需要 self 在任务执行时是存在的。”

  1. 如果您坚持使用弱引用,代码会是这样:
__weak typeof(self) weakSelf = self;
[MyOCNetworkManager requestWithURL:@"/some/path" 
                        parameters:nil 
                           success:^(id responseObject) {
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        return;
    }
    // 使用 strongSelf 来确保在闭包执行期间 self 不会被释放
    [strongSelf.tableView reloadData];
} 
                           failure:nil];
  1. 内存安全吗?

是的,100%安全。弱引用永远不会增加引用计数,所以它绝不会“创造”出一个循环引用。从这个角度看,它没有任何“问题”。

  1. 有什么副作用或缺点吗?  

有,和 Swift 的情况完全相同:

代码变得冗余:为了一个没有必要的安全措施,您多写了三行样板代码 (__weak__strongif)。这降低了代码的简洁性。

意图变得模糊:当其他开发者读到这段代码时,他们会看到 weak/strong dance,这通常是一个强烈的信号,表示“这里有循环引用的风险”。他们可能会因此花时间去寻找一个实际并不存在的风险点,增加了维护成本。

极端情况下的功能差异:如果 self (比如一个 UIViewController) 在网络请求发出后、但在回调执行前被释放了(例如用户快速返回上一个页面),那么:

写法一(强引用) :self 会被闭包“续命”,直到闭包执行完毕。[self.tableView reloadData] 会被执行。

写法二(弱引用) :self 会被立即释放。当回调执行时,strongSelf 会是 nil,代码会直接 return[strongSelf.tableView reloadData] 不会被执行。

[MyOCNetworkManager requestWithURL:@"" 
                        parameters:nil 
                           success:^(id responseObject) {
    // 直接使用 self,闭包会强引用 self。
    // 因为没有循环引用,self 会在闭包执行完后被正常释放,这是安全的。
    [self.tableView reloadData];
} 
                           failure:nil];
 __weak typeof(self) weakSelf = self;
[MyOCNetworkManager requestWithURL:@"" 
                        parameters:nil 
                           success:^(id responseObject) {
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        return;
    }
    // 使用 strongSelf 来确保在闭包执行期间 self 不会被释放
    [strongSelf.tableView reloadData];
} 
                           failure:nil];
  • 极端情况下的功能差异:如果 self (比如一个 UIViewController) 在网络请求发出后、但在回调执行前被释放了(例如用户快速返回上一个页面),写法二(弱引用):self 会被立即释放。当回调执行时,strongSelf 会是 nil,代码会直接 return,[strongSelf.tableView reloadData] 不会被执行。 但是实际中的这种写法很常见,我以前就是这么写,但是没有发现什么问题啊?返回页面还是会执行dealloc‌,为什么?
  1. 大多数情况下,行为差异“无关紧要”

我们回到最常见的场景:网络请求回来后,更新界面。

Objective-C

// 弱引用写法
__weak typeof(self) weakSelf = self;
[MyNetworkManager request:^{
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        return; // 如果 self 销毁了,就直接返回
    }
    [strongSelf.tableView reloadData]; // 更新界面
}];

设想一下用户快速返回,self (也就是 ViewController) 被销毁了。这时网络回调回来了,因为 strongSelf 是 nil,所以 [strongSelf.tableView reloadData] 这行代码没有被执行**。

这对用户来说是问题吗?完全不是。  因为界面都已经消失了,tableView 也不存在了,去刷新一个不存在的界面本来就是一件没有意义的事情。代码不执行,反而更干净利落。

所以,在 99% 的 UI 更新场景中,弱引用导致的“代码不执行”这个行为差异,不仅不是问题,反而是我们期望的、最合理的结果。

  1. “快速返回”的极端情况发生概率低

要触发这个“功能差异”,需要满足一个条件:self 的销毁发生在“网络请求发出后”和“回调执行前”这个短暂的时间窗口内。

对于大多数响应速度很快的 API 来说,这个窗口可能只有几百毫秒。用户需要操作得非常快才能正好卡在这个时间点上。因此,在日常测试和使用中,这个情况本身就不容易遇到。

  1. 即使代码不执行,也无可见负面影响

假设回调里做的事情是 [self hideLoadingIndicator]。如果用户已经返回了上一个页面,那个加载指示器 loadingIndicator 本来就已经随着页面消失了,所以 hideLoadingIndicator 这行代码执不执行,用户根本感知不到任何区别。

❌