阅读视图

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

设计一种机制检测UIViewController的内存泄漏

一、核心设计思路

  1. 基于对象释放的延迟检测

    • 原理:在 UIViewController 被销毁(如 pop 或 dismiss)时,延迟一定时间(如 2-3 秒)后检查其是否仍存在强引用。若存在,则判定为内存泄漏。
    • 实现:通过 Hook UIViewController 的 viewDidDisappear: 方法触发检测逻辑,利用 weak 弱指针观察对象是否存活。
  2. 循环引用链分析

    • 若检测到泄漏,进一步通过工具(如 FBRetainCycleDetector)分析对象间的强引用关系,定位循环引用链条。
  3. 白名单与误判处理

    • 支持白名单机制,排除单例、缓存对象等无需释放的场景。

二、具体实现步骤

1. Hook 生命周期方法

使用 Method Swizzling 替换 UIViewController 的 viewDidDisappear: 方法,在视图消失时启动检测:

    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        swizzleMethod([self class], @selector(viewDidDisappear:), @selector(swizzled_viewDidDisappear:));
    });
}

- (void)swizzled_viewDidDisappear:(BOOL)animated {
    [self swizzled_viewDidDisappear:animated];
    if (self.isMovingFromParentViewController || self.isBeingDismissed) {
        [self willDeallocCheck]; // 触发泄漏检测
    }
}

2. 延迟检测存活状态

通过 dispatch_after 延迟检查对象是否释放:

    if ([self isInWhitelist]) return NO; // 跳过白名单对象
    __weak id weakSelf = self;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
        __strong id strongSelf = weakSelf;
        if (strongSelf) {
            [strongSelf assertNotDealloc]; // 触发断言或弹窗报警
        }
    });
    return YES;
}

- (void)assertNotDealloc {
    NSAssert(NO, @"%@ 发生内存泄漏!", NSStringFromClass([self class]));
}

3. 遍历视图树检测子对象

检查 UIViewController 的视图及其子视图是否泄漏:

    [self.view.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if (![obj willDealloc]) {
            NSLog(@"子视图 %@ 可能泄漏", obj);
        }
    }];
}

4. 结合循环引用检测工具

集成 FBRetainCycleDetector,在断言触发时自动分析引用链:

    FBRetainCycleDetector *detector = [FBRetainCycleDetector new];
    [detector addCandidate:self];
    NSSet *retainCycles = [detector findRetainCycles];
    NSLog(@"循环引用链:%@", retainCycles);
}

三、优化与注意事项

  1. 性能优化

    • 仅在 Debug 模式 启用检测,避免影响线上性能。
    • 使用缓存机制减少重复检测。
  2. 误判处理

    • 区分延迟释放真实泄漏:某些对象可能因异步任务延迟释放,需多次检测确认。
    • 支持动态白名单,允许开发者标记无需检测的类。
  3. 扩展性

    • 支持自定义检测时间阈值(如从 2 秒调整为 5 秒)。
    • 可扩展至检测其他对象(如 UIView、自定义模型)。

四、验证与工具对比

方法 优点 缺点
手动检测 dealloc 简单直接,无侵入性 需反复操作,无法自动化
Instruments 全面分析内存分配与泄漏 操作复杂,需主动触发,实时性差
MLeaksFinder 自动报警,精准定位泄漏对象 需结合其他工具分析循环引用
本方案 自动化 + 循环引用分析 + 低侵入性 需集成第三方库(如 FBRetainCycleDetector)

五、实践建议

  1. 开发阶段集成:通过 CocoaPods 引入检测库(如 MLeaksFinder),仅启用 Debug 配置。
  2. CI/CD 流程:在自动化测试中增加内存泄漏检测步骤,结合日志分析工具统计泄漏点。
  3. 团队规范:在代码 Review 中强制要求修复泄漏报警,避免技术债务累积。

通过上述机制,开发者可高效定位并修复 UIViewController 内存泄漏问题,提升应用稳定性。

Swift 中重要特性——逃逸闭包@escaping

@escaping 是 Swift 中一个非常重要的特性,通常用于闭包(closure)的参数,尤其是在处理异步操作或回调时。它用于标记闭包参数“逃逸”出了函数的作用域,即闭包的生命周期超出了函数的执行范围。

为什么需要 @escaping

在 Swift 中,闭包默认是非逃逸(non-escaping)的,也就是说,闭包只能在函数调用过程中执行,并且不会保存到外部的变量或常量中。这样做的目的是提高性能,因为闭包不需要被保持,编译器可以进行优化。

然而,在处理异步操作时,比如网络请求或定时器,我们需要将闭包传递出去,让它在函数执行完毕后(甚至在函数退出后)继续执行。这时,闭包需要逃逸出函数的作用域,这时就需要使用 @escaping 来显式标记这个闭包参数。

@escaping 的作用

@escaping 表示闭包会逃逸出函数的作用域,可以在函数返回后被执行。这通常用于处理异步回调或者其他延迟执行的场景。

关键点

  • 非逃逸闭包(non-escaping closure):闭包只能在函数内部执行,并且会在函数返回前执行完毕。默认情况下,函数的闭包参数是非逃逸的。
  • 逃逸闭包(escaping closure):闭包可以在函数返回之后仍然执行,通常用于异步回调。

例子:非逃逸闭包

如果没有使用 @escaping,闭包是非逃逸的,不能存储到函数外部。

func performTask(task: () -> Void) {
    task()  // 这里闭包被执行并且在函数内完成
}

例子:逃逸闭包

当闭包需要在函数执行完毕后仍然执行,通常会标记为 @escaping。最典型的例子是异步操作,例如网络请求或定时器。

func fetchData(completion: @escaping (Data?) -> Void) {
    DispatchQueue.global().async {
        // 模拟网络请求
        let data = Data()
        completion(data)  // 闭包会在函数返回后执行
    }
}

在上面的例子中,completion 闭包会逃逸出 fetchData 函数,因为它是在一个异步线程中执行的,函数返回后闭包才会被调用。

逃逸闭包与内存管理

由于逃逸闭包的生命周期可能超过函数的执行时间,它可能会导致内存管理问题。逃逸闭包会被持有到函数执行完成后,因此需要特别小心避免强引用循环(retain cycles)。

通常,为了避免强引用循环,我们会将闭包声明为 weakunowned,从而防止闭包持有对象的强引用。

使用 weakunowned 避免循环引用

func fetchData(completion: @escaping (Data?) -> Void) {
    DispatchQueue.global().async { [weak self] in
        // 使用 weak 或 unowned 防止循环引用
        guard let self = self else { return }
        let data = Data()
        completion(data)
    }
}

使用 @escaping 的实际场景

@escaping 主要用于异步操作或回调函数,它的作用是使闭包可以在函数执行完毕后,甚至在函数返回后继续执行。

  1. 网络请求回调:在网络请求成功或失败后执行回调操作。
  2. 定时器回调:在定时器触发时执行闭包操作。
  3. UI 更新回调:例如,在多线程中更新 UI,闭包可能需要在主线程执行。

总结

  • @escaping 标记闭包为逃逸闭包,即它可能在函数返回后被调用。
  • 逃逸闭包通常用于处理异步操作、回调等情况。
  • 逃逸闭包的生命周期可能会超过函数的作用域,因此需要注意内存管理,避免出现强引用循环。

微软收紧插件、谷歌发力云端,Xcode 何去何从? | 肘子的 Swift 周报 #079

issue79.webp

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

微软收紧插件、谷歌发力云端,Xcode 何去何从?

不久前,微软对 Github Copilot 进行了相当程度的功能增强,除了添加了对 MCP 的支持外,在 AI 交互模式上也提供了与 Cursor 对齐的 Agent 模式,至此,Github Copilot 大大缩小了与其他 AI 代码工具领先者之间的差距。考虑到其更低的定价策略( $10/月 ),明显微软已做好了全力进入商业 AI 辅助市场/服务的准备。

对于开发者来说,良性的竞争意味着会带来更好的产品和更有性价比的服务。但由于目前不少 AI 编程工具与 VSCode 都使用了相同或接近的底层实现,并且选用了类似的大模型组合,这意味着过段时间,这些工具之间的差异会越来越小,提供更有性价比、更具独特价值的功能就变成了这些工具获取用户的主要手段。

或许是为了保证 VSCode 的独特性,在 VSCode 生态中一些非常重要的,由微软开发的插件(Remote SSH、Pylance、Python Debugger、C/C++ 等)在最新的更新版中已经无法在 VSCode 之外的编辑器中使用了。这些插件虽然闭源但一直允许第三方编辑器使用,现在却突然弹出“只能在微软产品中使用”的提示。尽管这并没有违法微软在开发这些插件时的授权,但这种突然的屏蔽行为还是引起了很多其他编辑器使用者的不便。考虑到在整个 VSCode 生态中,微软提供了大量的优秀插件,如果未来其中相当一部分只能运行在 VSCode 中,那么会明显提高 VSCode 在这些编辑器工具中的竞争力,并影响不少开发者的选择。

面对这一挑战,Cursor 团队已迅速推出应急修复,并计划开发长期解决方案,逐步转向支持社区开源替代品。这一事件实际上反映了微软已将 Cursor 视为真正的竞争对手——一个凭借 AI 能力,在某些场景下体验超越 VSCode 的挑战者。

与此同时,谷歌在上周推出的 Firebase Studio 又将 AI 编辑器推向了新的领域。这款融合了 Project IDX、Genkit 和 Gemini 的平台不仅支持通过自然语言快速生成原型,还能通过 AI 聊天方式迭代应用,并提供一键部署到 Firebase App Hosting 的能力。通过与谷歌云端服务的深入捆绑,不仅加速了开发、调试的过程,也大幅降低了部署的难度。可以预见,同样具备云端服务优势的微软也很快会在 VSCode 上提供类似的体验。就像当前的浏览器、搜索引擎一样,AI 开发工具将逐步从开发者的桌面工具过渡成各个大公司绑定开发者的入口。

单纯从商业角度来说这并没有什么问题,但这意味着仍在开发 AI 编辑器的小公司、小团队的机会窗口将更加的小了。他们不得不在两条路中选择:要么在 AI 编程体验上做出真正的差异化,要么依靠社区力量维持生态,成为一个真正独立的开发工具。一个逐渐缺少了活力的市场将扼杀创意,尽管从历史上来看几乎每个领域都会走向类似的结局,只是没想到在 AI 时代,大公司的行动会如此的迅速,如此的果决。

在这场开发工具的变革浪潮中,苹果的 Xcode 似乎还未展现出清晰的 AI 集成战略。作为苹果生态的核心开发工具,Xcode 长期以来依靠其与平台的无缝集成成为苹果开发者的不二之选。然而,当 AI 正以前所未有的速度重塑开发体验时,静观其变已不再是明智之举。如果不能在未来的一两年中有重要突破,不仅开发者体验会滞后,创新生态也可能流失。苹果会在 WWDC 2025 上交出怎样的答案?这将决定其平台在下一代开发范式中的地位。

前一期内容全部周报列表

本期助力

需要在 iPhone 上调试 HTTPS?

试试 Proxyman!这是一款顶级的 macOS 应用,只需点击几下,即可轻松捕获和调试 HTTP(s) 流量。支持 iOS设备和模拟器。

🚀 立即试用 →

原创

用 Swift 构建 WASM 应用

随着 Swift 6.1 版本的正式发布,SwiftWasm 也迎来了重大升级。这一里程碑式的更新标志着 SwiftWasm 首次实现了完全基于官方 Swift 开源工具链的构建——告别了自定义补丁的时代,不仅显著简化了开发者的安装流程,大幅节省了系统存储空间,更为重要的是,这种纯正构建方式极大降低了平台的维护成本,为 Swift 生态系统注入了新的活力。在本文中,我们将探索如何利用 Swift 构建 WebAssembly 应用,带你领略 Swift 跨平台开发的无限可能。

近期推荐

结构化并发的行为准则 (Rules of Structured Concurrency)

Swift 并发中的任务可分为结构化(Structured)与非结构化(Unstructured)两类,核心差异在于是否具备父子任务关系,以及是否能自动管理生命周期、错误传播与取消逻辑。在这篇文章中,Vitaly Batrakov 基于“任务树”模型,总结出结构化并发的三大核心规则(Error、Group Completion、Group Cancellation,简称 EGG 🥚),为 Swift 并发机制提供了清晰、系统的理解路径,推荐给并发初学者与进阶开发者阅读。

swift-markdown 的自定义能力边界 (The Limits of swift-markdown Customization)

swift-markdown 是苹果最初开发的 Markdown 解析与构建库,提供了优雅的访问结构和基于 Visitor 模式的遍历机制。Christian Tietze 在文章中分享了他在构建 Markdown 处理管线过程中遇到的诸多限制:虽然读取和遍历功能完善,但在插入节点、修改结构、扩展元素等方面受限严重。无法新增节点类型、缺乏插入/组合能力、formatter 不可扩展,让这个库成为“看起来灵活、实际封闭”的典型代表。吐槽之余,Christian 也对 AST 工具的设计边界提出了不少值得参考的思考。

如何发布你的 macOS Swift 命令行工具 (Distribute Your Swift CLIs for macOS)

目前,许多 Swift CLI 工具仍依赖 Mint 或 Mise 实现“安装即编译”,虽然简化了维护流程,但对用户而言存在编译慢、易失败等问题。Pedro Piñera 认为,更理想的方式是发布预编译二进制文件,并结合 GitHub Releases + UBI 实现一键安装。再配合 Mise,可提供更完善的版本管理体验。在本文中,Pedro 提供了一套可复用的构建与发布脚本流程,覆盖 fat binary 构建、压缩、签名上传至 GitHub,并适配 CI 与本地环境。

你的项目适合用 SwiftData 吗?(Should You SwiftData?)

SwiftData 自发布以来一直颇具争议,很多开发者仍在观望是否应该投入使用。在本文中,Leo G Dion 结合自己开发 Bushel 的经验,分享了他的思考。他认为,对于倾向于使用 Apple 官方框架、喜欢 ORM 编程范式、重视长期维护成本与新技术支持(如 SwiftUI、Swift 6、宏等)的开发者来说,SwiftData 是值得考虑的方案。此外,文中还列出了一些替代方案,如 GRDB、CoreStore、Boutique 等,供不同需求的开发者参考。

我为什么不再子类化 UITableViewCell / UICollectionViewCell (Why I Never Subclass UITableViewCell or UICollectionViewCell)

在开发购物类 App 的过程中,Srđan Stanić 起初沿用了常规模式:为 UITableViewUICollectionView 分别子类化 UITableViewCell / UICollectionViewCell,用以构建商品列表 UI。但随着产品设计不断演进,他逐渐遇到以下问题:相同 UI 需在多个上下文中复用;每次复用都必须重新实现布局逻辑;为适配某种 UI 承载方式,不得不引入不必要的复杂容器。于是他选择反向而行:不再子类化 Cell,而是将布局独立为一个纯 UIView,根据使用场景再嵌入到不同的 Cell 或容器中。这不仅提升了复用性和可维护性,也能轻松适配 SwiftUI,通过泛型 Cell 容器进一步简化样板代码。

WWDC25 的愿望单 (WWDC25 Wishes)

又到了每年喜闻乐见的 WWDC 愿望清单时节,Majid Jabrayilov 的关注点主要聚焦在开发工具的改进:

  • Swift Assist 与 AI 工具链:呼吁 Apple 尽快补上 AI 辅助开发的短板,并建议推出 Xcode 专属的 MCP server,构建 Copilot 式开发体验;
  • Project.swift 项目配置文件:希望能像 Package.swift 一样,用纯 Swift 描述项目配置,提升版本控制友好性,同时让 VS Code 等非 Xcode 工具更具可操作性;
  • SwiftUI 的 Recycling View 支持:期待 SwiftUI 引入像 UITableView/UICollectionView 一样的视图重用机制,解决复杂列表场景中的性能瓶颈。

Majid 提出的每一项都非常戳我,尤其是项目配置的现代化 —— 在当前多 IDE 并用的开发环境中,已经显得越来越迫切。

TextRenderer 演示合集 —— Prismic

从 iOS 18 开始,开发者可以通过自定义 TextRenderer 协议的实现,在 SwiftUI 的 Text 渲染前对文本进行变换,从而打造更具视觉冲击力的动态文字效果。Paul Hudson 创建了 Prismic,这是一个收录了多种 TextRenderer 示例效果的开源项目,既包含纯 Swift 实现的样式,也包含基于 Metal Shader 的高级扭曲与颜色特效。Paul 鼓励开发者基于该项目进行扩展,贡献更多创意实现。

想了解如何构建这些效果?推荐阅读:用 TextRenderer 构建绚丽动感的文字效果

活动

AdventureX 2025 解除报名的年龄限制

在 2024 年,AdventureX 曾规定参赛者年龄需在 26 岁以下。而在 2025 年,我们决定面向所有人开放报名。虽然 26 岁以下的青年创作者与学生依然将构成参赛者的主要群体,但我们也将开放至多 100 个不限年龄名额,欢迎那些虽不在年龄范围内、却依然怀抱年轻心态与创作热情的朋友加入。

我们希望借此机会,促成跨代创作者的深度交流,在同一个现场分享经验、碰撞灵感。

往期内容

THANK YOU

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

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

使用渲染管线渲染图元

渲染一个简单的 2D 三角形。 代码传送门

概述

此示例展示了如何配置渲染管线并将其作为渲染通道的一部分,用于在视图中绘制一个简单的二维彩色三角形。示例为每个顶点提供了位置和颜色信息,渲染管线使用这些数据来渲染三角形,并在三角形顶点指定的颜色之间进行插值。

image.png


Metal 渲染管线理解

渲染管线负责处理绘图指令,并将数据写入渲染通道的目标中。此管线包含多个阶段,其中一些阶段可以通过着色器编程控制,而其他阶段则具有固定或可配置的行为。这个示例主要关注管线的三个关键阶段:顶点阶段、光栅化阶段和片段阶段。顶点阶段和片段阶段是可编程的,因此你可以使用 Metal 着色语言 (MSL) 为它们编写函数。而光栅化阶段的行为是固定的。

image.png

渲染开始于一个绘图指令,该指令包括顶点数量以及要渲染的图元类型。例如,这是本示例中的绘图指令:

// 绘制三角形。
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
                  vertexStart:0
                  vertexCount:3];

顶点阶段为每个顶点提供数据。当处理足够的顶点后,渲染管线对图元进行光栅化,确定在渲染目标中哪些像素位于图元边界内。片段阶段决定这些像素在渲染目标中要写入的值。

本示例的其余部分演示了如何编写顶点和片段函数,如何创建渲染管线状态对象,以及最后如何编码一条使用此管线的绘图指令。这提供了对如何利用 Metal 构建高效的图形渲染流程的基本了解,特别是针对那些希望深入理解图形编程底层细节的人。通过这种方式,开发者可以更好地控制图形渲染过程,实现高性能和高质量的视觉效果。


自定义渲染管线如何处理数据

顶点函数为单个顶点生成数据,片段函数为单个片段生成数据,但您需要决定它们的工作方式。您需根据目标配置管线的各个阶段,这意味着您知道希望管线生成什么结果以及它如何生成这些结果。

决定将哪些数据传递到渲染管线中,并将哪些数据传递到管线的后续阶段。通常有三个地方可以实现这一点:

  • 管线的输入数据:由您的应用程序提供并传递给顶点阶段。
  • 顶点阶段的输出数据:传递给光栅化阶段。
  • 片段阶段的输入数据:由您的应用程序提供或由光栅化阶段生成。

在此示例中,管线的输入数据是顶点的位置及其颜色。为了演示顶点函数中通常执行的变换类型,输入坐标被定义在一个自定义坐标空间中,以视图中心为原点用像素表示。这些坐标需要转换为 Metal 的坐标系。

声明一个 AAPLVertex 结构体,使用 SIMD 向量类型存储位置和颜色数据。为了在内存布局中共享单一定义,在通用头文件中声明该结构体,并在 Metal 着色器和应用程序中导入它。

typedef struct
{
    vector_float2 position;
    vector_float4 color;
} AAPLVertex;

SIMD 类型在 Metal 着色语言中非常常见,您还应该在应用程序中使用 simd 库来使用它们。SIMD 类型包含多个特定数据类型的通道,因此将位置声明为 vector_float2 意味着它包含两个 32 位浮点值(即 x 和 y 坐标)。颜色则使用 vector_float4 存储,因此它们具有四个通道——红、绿、蓝和透明度 (RGBA)。

在应用程序中,输入数据通过一个常量数组指定:

static const AAPLVertex triangleVertices[] =
{
    // 2D 位置, RGBA 颜色
    { {  250,  -250 }, { 1, 0, 0, 1 } },
    { { -250,  -250 }, { 0, 1, 0, 1 } },
    { {    0,   250 }, { 0, 0, 1, 1 } },
};

顶点阶段为每个顶点生成数据,因此需要提供颜色和变换后的位置。声明一个 RasterizerData 结构体,包含位置和颜色值,同样使用 SIMD 类型。

struct RasterizerData
{
    // 此成员的 [[position]] 属性表明,当此结构体从顶点函数返回时,
    // 此值是顶点的裁剪空间位置。
    float4 position [[position]];

    // 由于此成员没有特殊属性,光栅化阶段会将其值与三角形其他顶点的值
    // 进行插值,然后将插值后的值传递给每个片段的片段着色器。
    float4 color;
};

输出位置(详见下文)必须定义为 vector_float4。颜色的声明方式与输入数据结构中相同。

您需要告诉 Metal 哪个字段在光栅化数据中提供位置数据,因为 Metal 不会对结构体中的字段强制任何特定的命名约定。使用 [[position]] 属性限定符注解位置字段,以声明该字段保存输出位置。

片段函数只需将光栅化阶段的数据传递给后续阶段,因此不需要任何额外的参数。

声明顶点函数

声明顶点函数,包括其输入参数和输出数据。类似于使用 kernel 关键字声明计算函数,您可以使用 vertex 关键字声明顶点函数。

vertex RasterizerData
vertexShader(uint vertexID [[vertex_id]],
             constant AAPLVertex *vertices [[buffer(AAPLVertexInputIndexVertices)]],
             constant vector_uint2 *viewportSizePointer [[buffer(AAPLVertexInputIndexViewportSize)]])

第一个参数 vertexID 使用 [[vertex_id]] 属性限定符,这是另一个 Metal 关键字。当执行渲染命令时,GPU 会多次调用您的顶点函数,为每个顶点生成唯一值。

第二个参数 vertices 是一个包含顶点数据的数组,使用之前定义的 AAPLVertex 结构体。

为了将位置转换为 Metal 的坐标系,函数需要绘制三角形的目标视口大小(以像素为单位),因此将其存储在 viewportSizePointer 参数中。

第二个和第三个参数具有 [[buffer(n)]] 属性限定符。默认情况下,Metal 会自动为每个参数分配参数表中的槽位。当您为缓冲区参数添加 [[buffer(n)]] 限定符时,您明确告诉 Metal 使用哪个槽位。显式声明槽位可以使您更轻松地修改着色器,而无需同时更改应用程序代码。在共享头文件中声明这两个索引的常量。

函数的输出是一个 RasterizerData 结构体。

编写顶点函数

您的顶点函数必须生成输出结构体的所有字段。使用 vertexID 参数索引到 vertices 数组中,读取该顶点的输入数据。同时,获取视口的尺寸。

float2 pixelSpacePosition = vertices[vertexID].position.xy;

// 获取视口大小并转换为浮点类型。
vector_float2 viewportSize = vector_float2(*viewportSizePointer);

顶点函数必须以裁剪空间坐标(clip-space coordinates)的形式提供位置数据,这些坐标是通过四维齐次向量 (x, y, z, w) 表示的 3D 点。光栅化阶段会将输出位置的 xyz 坐标除以 w,以生成归一化设备坐标(normalized device coordinates)。归一化设备坐标与视口大小无关。

image.png

归一化设备坐标使用左手坐标系,并映射到视口中的位置。图元会被裁剪到此坐标系中的一个盒子内,然后进行光栅化。裁剪盒子的左下角坐标为 (-1.0, -1.0),右上角坐标为 (1.0, 1.0)。正的 z 值指向远离摄像机的方向(进入屏幕)。z 坐标的可见部分在 0.0(近裁剪平面)和 1.0(远裁剪平面)之间。

将输入坐标系转换为归一化设备坐标系。

image.png

由于这是一个 2D 应用程序,不需要齐次坐标,因此首先为输出坐标写入一个默认值,其中 w 值设置为 1.0,其他坐标设置为 0.0。这意味着坐标已经在归一化设备坐标空间中,顶点函数应在该坐标空间中生成 (x, y) 坐标。将输入位置除以视口大小的一半以生成归一化设备坐标。由于此计算使用 SIMD 类型,两个通道可以同时除以一个操作完成。执行除法并将结果放入输出位置的 xy 通道中。

out.position = vector_float4(0.0, 0.0, 0.0, 1.0);
out.position.xy = pixelSpacePosition / (viewportSize / 2.0);

最后,将颜色值复制到 out.color 返回值中。

out.color = vertices[vertexID].color;

编写片元函数

片元是渲染目标可能发生的更改。光栅化器确定渲染目标中哪些像素被图元覆盖。只有像素中心位于三角形内部的片元才会被渲染。

image.png

片元函数处理来自光栅化器的单个位置的传入信息,并为每个渲染目标计算输出值。这些片元值由管线的后续阶段处理,最终写入渲染目标。

注意
片元被称为“可能的更改”,是因为片元之后的管线阶段可以配置为拒绝某些片元或更改写入渲染目标的内容。在此示例中,片元阶段计算的所有值都会直接写入渲染目标。

此示例中的片元着色器接收与顶点着色器输出相同的参数。使用 fragment 关键字声明片元函数。它接受一个参数,即顶点阶段提供的相同 RasterizerData 结构体。添加 [[stage_in]] 属性限定符以表明此参数由光栅化器生成。

objc
深色版本
fragment float4 fragmentShader(RasterizerData in [[stage_in]])

如果您的片元函数写入多个渲染目标,则必须声明一个包含每个渲染目标字段的结构体。由于此示例只有一个渲染目标,因此直接指定一个浮点向量作为函数的输出。该输出是要写入渲染目标的颜色。

光栅化阶段为每个片元的参数计算值,并使用这些值调用片元函数。光栅化阶段将其颜色参数计算为三角形顶点颜色的混合值。片元越接近某个顶点,该顶点对最终颜色的贡献越大。

image.png

返回插值后的颜色作为函数的输出。

return in.color;

创建渲染管线状态对象

现在函数已完成,您可以创建一个使用它们的渲染管线。首先,获取默认库并为每个函数获取一个 MTLFunction 对象。

id<MTLLibrary> defaultLibrary = [_device newDefaultLibrary];

id<MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
id<MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"];

接下来,创建一个 MTLRenderPipelineState 对象。渲染管线有更多阶段需要配置,因此使用 MTLRenderPipelineDescriptor 配置管线。

MTLRenderPipelineDescriptor *pipelineStateDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipelineStateDescriptor.label = @"Simple Pipeline";
pipelineStateDescriptor.vertexFunction = vertexFunction;
pipelineStateDescriptor.fragmentFunction = fragmentFunction;
pipelineStateDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat;

_pipelineState = [_device newRenderPipelineStateWithDescriptor:pipelineStateDescriptor
                                                         error:&error];

除了指定顶点和片段函数外,还需要声明管线绘制的所有渲染目标的像素格式。像素格式 (MTLPixelFormat) 定义了像素数据的内存布局。对于简单格式,此定义包括每像素的字节数、存储在像素中的通道数以及这些通道的位布局。由于此示例只有一个渲染目标并且由视图提供,因此将视图的像素格式复制到渲染管线描述符中。您的渲染管线状态必须使用与渲染通道兼容的像素格式。在此示例中,渲染通道和管线状态对象都使用视图的像素格式,因此它们始终相同。

当 Metal 创建渲染管线状态对象时,管线会配置为将片段函数的输出转换为渲染目标的像素格式。如果您想针对不同的像素格式进行渲染,则需要创建不同的管线状态对象。您可以在多个管线中重用相同的着色器,以针对不同的像素格式进行渲染。


设置视口

现在您已经有了渲染管线状态对象,可以使用渲染命令编码器渲染三角形。首先设置视口,以便 Metal 知道要绘制到渲染目标的哪个部分。

// 设置绘制区域。
[renderEncoder setViewport:(MTLViewport){0.0, 0.0, _viewportSize.x, _viewportSize.y, 0.0, 1.0}];

设置渲染管线状态

设置要使用的渲染管线状态。

[renderEncoder setRenderPipelineState:_pipelineState];

将参数数据传递到顶点函数

通常,您使用缓冲区 (MTLBuffer) 将数据传递到着色器。然而,当只需要向顶点函数传递少量数据(如本示例)时,可以直接将数据复制到命令缓冲区中。

示例将两个参数的数据都复制到命令缓冲区中。顶点数据从示例中定义的数组中复制,而视口数据则从用于设置视口的同一变量中复制。

在此示例中,片段函数仅使用从光栅化器接收到的数据,因此没有需要设置的参数。

[renderEncoder setVertexBytes:triangleVertices
                       length:sizeof(triangleVertices)
                      atIndex:AAPLVertexInputIndexVertices];

[renderEncoder setVertexBytes:&_viewportSize
                       length:sizeof(_viewportSize)
                      atIndex:AAPLVertexInputIndexViewportSize];

编码绘制命令

指定图元类型、起始索引和顶点数量。当三角形被渲染时,顶点函数会分别使用 012vertexID 值调用。

// 绘制三角形。
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
                  vertexStart:0
                  vertexCount:3];

与《使用 Metal 绘制到屏幕》类似,结束编码过程并提交命令缓冲区。不过,您可以使用相同的步骤编码更多的渲染命令。最终图像会按命令指定的顺序渲染。(为了性能,GPU 可以并行处理命令甚至部分命令,只要最终结果看起来是按顺序渲染即可。)


实验颜色插值

在此示例中,颜色值在三角形上进行了插值。这通常是您想要的效果,但有时您希望某个顶点生成的值在整个图元上保持恒定。为此,可以在顶点函数的输出上指定 flat 属性限定符。现在尝试一下。在示例项目中找到 RasterizerData 的定义,并为其颜色字段添加 [[flat]] 限定符。

float4 color [[flat]];

再次运行示例。渲染管线会在整个三角形上统一使用第一个顶点(称为引发顶点)的颜色值,并忽略其他两个顶点的颜色。您可以通过在顶点函数的输出上添加或省略 flat 限定符来混合使用平面着色和插值值。Metal 着色语言规范还定义了其他属性限定符,可用于修改光栅化行为。

CocoaPods 私有库Spec Repo搭建与使用指南

一、创建私有 Spec Repo

  1. 创建 Git 仓库
    在 Git 服务器(如 GitHub、GitLab)上新建一个空仓库,例如 PrivateSpecs,用于存放私有库的 podspec 文件。
  2. 添加 Spec Repo 到本地
    前往文件夹 ~/.cocoapods/repos
    打开终端,在终端切换到当前目录下,然后进行pod repo add操作 在终端执行以下命令,将私有仓库添加到 CocoaPods 的仓库列表:
#
pod repo add PrivateSpecs git@github.com:your-username/PrivateSpecs.git

替换 your-username 和仓库地址为实际信息,注意使用SSH或者HTTPS方式获取代码。~/.cocoapods/repos的目录下新增加PrivateSpecs文件夹。

二、创建私有库

  1. 生成模板项目
    在 Git 服务器(如 GitHub、GitLab)上新建一个空仓库,例如 DLYCenterModule。存放私有库代码 使用 CocoaPods 模板生成私有库:

    pod lib create DLYCenterModule
    

    按提示选择配置(语言、Demo 等)。

截屏2025-04-14 11.14.51.png

  1. 配置项目 cd 到Example文件下,然后pod install下,更新Example项目的pod。如图项目中的podspec文件,更改spec。修改s.homepage和s.source为自己git项目内容。新增加的源码放到DLYCenterModule/Classes/目录下。

截屏2025-04-14 11.28.22.png

3.推送代码到 Git 仓库

cd DLYCenterModule
git add .
git commit -m "Initial commit"
git remote add origin git@github.com:your-username/DLYCenterModule.git
git push -u origin master

4.打Tag并推送

    #注意 tag和s.version = '0.1.0' 的保持一致
    git tag 0.1.0
    git push --tags

三、验证与发布私有库

  1. 本地验证 podspec

    pod lib lint --allow-warnings
    

    若存在警告但可忽略,使用 --allow-warnings

  2. 推送 podspec 到私有 Spec Repo

    pod repo push PrivateSpecs DLYCenterModule.podspec --allow-warnings
    

3.搜索 私有库是否成功

#更新PrivateSpecs
pod repo update PrivateSpecs
#搜索 DLYCenterModule
pod search DLYCenterModule

四、使用私有库

  1. 配置 Podfile
    在项目的 Podfile 中添加私有源和依赖:

    # 公有源(可选)
    source 'https://github.com/CocoaPods/Specs.git'
    # 私有源
    source 'https://github.com/your-username/PrivateSpecs.git'
    
    target 'YourProject' do
      pod 'DLYCenterModule'
    end
    
  2. 安装依赖

    pod install
    

截止目前已有15.6w应用惨遭下架,你的应用还好么?

前言

正常人问候一般都是问一句:你吃么?

但是在iOSer之间问候一般都是提一句:你过了么?

跟很多同行聊天,每每提及Appstore审核都如坐针毡,头皮发麻!

当然了对于那些合规化的产品完全不慌,提审如同喝水一般简单

毕竟合不合规,要不要合规,能不能合规,都不是一个iOSer开发所能决定的。之有余前关于3.2f封号的严重性可能不够直观,于是今天统计了一把Appstore第一季度肃清的应用让更多的开发者意识到产品合规的重要性

备注:以下数据均来自第三方数据统计,仅供参考。

2025年1月

累计下架:44574

wechat_2025-04-14_104006_336.png

2025年2月

累计下架:37139

wechat_2025-04-14_104043_221.png

2025年3月

累计下架:55150

wechat_2025-04-14_104112_705.png

2025年4月

累计下架:20819

wechat_2025-04-14_104209_605.png

总结

在这些被下架15.6w应用中,游戏占比仅为10%。所以客观来说游戏里无论是代码,还是原创度都相对于普通的应用类产品更安全,也更遵守规则。

对于常规应用来讲依旧是重灾区,如果你的应用没有这种感觉,那么恭喜你!请你继续保持良好的开发习惯,做一个遵循苹果开发者指南的良民!

细心的读者也发现了在截图中存在了重新上架的产品,那么这些产品是什么情况呢?

  • 开发者账号到期,重新续费
  • 触发苹果调查,解除误会后恢复上架(比如:封号倒计时)

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

iOS开发:关于导航控制器

使用系统控件的困境

如果去学习Android、Flutter或者前端的代码,就会发现其他App的导航栏都是跟着独立页面走。

但是iOS却不同,你会发现NavigationController更像一个全局的单例,每个页面的NavigationControlle都是一个样的。

而往往业务侧对导航栏的需求又是多样的,渐变、一屏到顶、Web页面全屏接管等等,都会让你在导航栏的配置上焦头烂额,效果达不到也就算了,甚至会引出bug。

而通过隐藏系统导航栏,全部自己写自定义导航栏的时候,NavigationController又会阴魂不散,时不时给你一点意外惊喜。

我这里没有特别多的好策略,就是给出一个我喜欢用的库——RTRootNavigationController。

这里,我先给出完全使用系统UINavigationController的方案,这个方案在RxStudy上面已经实践过,其中push时的假死bug也是网友斧正帮我解决的。这个项目中,完全使用系统导航栏,只用自定义leftBarButtonItem就可以了。

但是需要注意的是,这个思路只适合架构简单,没有太多自定义需求的项目。

class BaseViewController: UIViewController {
    
    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        /// 最简单的设置统一返回按钮的方法,所有的控制器继承该基类即可
        let leftBarButtonItem = UIBarButtonItem(image: R.image.back(), style: .plain, target: self, action: #selector(leftBarButtonItemAction(_:)))
        navigationItem.leftBarButtonItem = (navigationController?.viewControllers.count ?? 0) > 1 ? leftBarButtonItem : nil
        navigationItem.hidesBackButton = true
    }
     
    /// 将此方法从private改成对外暴露,让子类能有能力重新这个返回的方法,一般情况这个返回的方法会与侧滑返回的逻辑绑定,比如MyJueJinController就是例子
    /// - Parameter item: UIBarButtonItem
    @objc
    func leftBarButtonItemAction(_ item: UIBarButtonItem) {
        navigationController?.popViewController(animated: true)
    }
}
class BaseNavigationController: UINavigationController {

    override func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = self
        delegate = self
    }

}

extension BaseNavigationController: UIGestureRecognizerDelegate, UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        interactivePopGestureRecognizer?.isEnabled = true
        /// 解决某些情况下push时的假死bug,防止把根控制器pop掉
        if navigationController.viewControllers.count == 1 {
            interactivePopGestureRecognizer?.isEnabled = false
        }
    }
}

谁包裹谁?

再来就是有关于RTNavigationController,其中的包裹方案有以下几种:

  • 方案一:使用 RTRootNavigationController 包裹整个 UITabBarController,适用于需要全局统一管理导航控制器的情况。这种方案的优点是结构简单,适合需要全局统一管理导航控制器的情况。缺点是每个选项卡中的视图控制器共享同一个导航控制器,可能会导致导航堆栈管理复杂。

画板

  • 方案二:使用 RTRootNavigationController 包裹每个 UIViewController,然后用 UITabBarController 包裹这些 RTRootNavigationController,适用于需要独立管理每个选项卡中的导航堆栈的情况。这种方案的优点是每个选项卡中的视图控制器都有独立的导航控制器,导航堆栈管理更清晰。缺点是结构稍微复杂一些。

画板

其实这种结构不仅适用于RTRootNavigationController,其实对于原生UINavigationController也一样适用,我一般使用方案二。

我个人的理解是,每一个Tab包含的都是一种相关的业务,独立为每一种tab业务用一个Navigation进行管理比较合适,这样就算是路由业务,也能更好的解耦。

RTRootNavigationController的基本使用

页面包裹相关的思路其实和使用UINavigationController一致,这里就不再展开,说一些需要注意的

  • 页面隐藏导航栏
class ExampleController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    private func setupUI() {
        /// 隐藏导航栏 返回手势可用
        navigationController?.isNavigationBarHidden = true
        /// 注意使用RT,一定要把这个API设置,并设置为false,否则页面无法侧滑
        rt_disableInteractivePop = false
    }
}

RT默认当页面导航栏隐藏时,系统的侧滑失效,于是这里rt_disableInteractivePop必须手动设置一下。

RT相关逻辑代码如下:

- (void)viewDidLayoutSubviews
{
    [super viewDidLayoutSubviews];
    
    UIViewController *viewController = self.topViewController;
    if (!viewController.rt_hasSetInteractivePop) {
        BOOL hasSetLeftItem = viewController.navigationItem.leftBarButtonItem != nil;
        if (self.navigationBarHidden) {
            viewController.rt_disableInteractivePop = YES;
        } else if (hasSetLeftItem) {
            viewController.rt_disableInteractivePop = YES;
        } else {
            viewController.rt_disableInteractivePop = NO;
        }
        
    }
    if ([self.parentViewController isKindOfClass:[RTContainerController class]] &&
        [self.parentViewController.parentViewController isKindOfClass:[RTRootNavigationController class]]) {
        [self.rt_navigationController _installsLeftBarButtonItemIfNeededForViewController:viewController];
    }
}

- (void)setNavigationBarHidden:(BOOL)hidden animated:(BOOL)animated
{
    [super setNavigationBarHidden:hidden animated:animated];
    if (!self.visibleViewController.rt_hasSetInteractivePop) {
        self.visibleViewController.rt_disableInteractivePop = hidden;
    }
}
  • 页面查找:

我们看一下一个被RTNavigationController包裹的页面层级:

画板

/// 切换到发现页面的社区页面
if let rt = Tools.currentVC()?.tabBarController?.viewControllers?.first as? RTRootNavigationController,
   let containerController = rt.topViewController as? RTContainerController,
   let viewController = containerController.contentViewController as? UIViewController {
}

我们需要拿到RTContainerController,接着拿到RTContainerController里面的contentViewController属性获取真正我们构建的控制器,而使用UINavigationController,在topViewController我们就可以直接转我们构建的控制器了。

push与pop有完成回调:


- (void)pushViewController:(UIViewController *)viewController
                  animated:(BOOL)animated
                  complete:(void(^)(BOOL finished))block;

- (UIViewController *)popViewControllerAnimated:(BOOL)animated complete:(void(^)(BOOL finished))block;

但使用rt_navigationController时,可以使用这个改造后的push与pop,它能更好的控制push与pop之后的控制。

删除栈

通过remove可以删除指定的栈内的控制器,特别是当push到某一个页面之后,需要对栈做优化,值得注意的是,使用RT的API会比系统的舒适一点,比如下面这个例子就是push完成后,将当前的页面给删除掉:

self.rt_navigationController?.pushViewController(ChangePhoneController(type: .bindNewPhone), animated: true, complete: { _ in
    self.rt_navigationController.removeViewController(self)
})

当然这里使用通过对象删除栈内的方式,并不通用,因为有的时候我们无法拿到栈内的对象,于是通过控制器名称等标签的查找与删除,更符合使用,下面是我封装的一个方法:

// MARK: - 这两个方法是基于RTRootNavigationController的封装
extension BaseViewController {
    
    /// push到目标控制器,并通过类名进行定向移除导航控制器中的栈内控制器
    /// - Parameters:
    ///   - viewController: 目标控制器
    ///   - animated: 是否有动画效果
    ///   - removeViewControllerClassNameList: 需要移除控制器名称的数组
    ///   - isRemoveSelf: 是否移除触发push方法的当前控制器
    func pushViewController(_ viewController: UIViewController, animated: Bool, removeViewControllerClassNameList: [String] = [], isRemoveSelf: Bool = true) {
        rt_navigationController?.pushViewController(viewController, animated: animated) { [weak self] _ in
            guard let self else { return }
            
            if let viewControllers = self.navigationController?.viewControllers {
                for vc in viewControllers where removeViewControllerClassNameList.contains(vc.className) {
                    self.rt_navigationController?.removeViewController(vc)
                }
            }
            
            if isRemoveSelf {
                self.rt_navigationController?.removeViewController(self)
            }
        }
    }
    
    /// 用于通过类名进行定向pop
    /// - Parameters:
    ///   - className: pop回退到的控制器名称
    ///   - animated: 是否有动画效果
    ///   - completion: pop完成后的回调
    func popToViewController(className: String, animated: Bool, completion: ((Bool) -> Void)? = nil) {
        
        var isPoped = false
        
        for vc in self.navigationController?.viewControllers ?? [] where vc.className == className {
            rt_navigationController.pop(to: vc, animated: animated, complete: completion)
            isPoped = true
            break
        }
        
        if !isPoped {
            navigationController?.popViewController(animated: true)
        }
    }
}

这里的className可以认为是NSStringForClass的一种封装,简单而言就是通过类名去识别栈内是否有符合控制器实例,当然这个方法还是有一个缺陷,就是如果栈内有2个或者2个以上同名的控制器实例如何解决?

方法还是有的,只是没有封装到这个方法中,我们在构建控制器的时候,可以给控制器打tag,通过区分tag就可以更好的进行颗粒度细致的控制,这里就讲到这里,扩展我想大家都会了。

禁止侧滑,点击返回按钮的逻辑需要自定义:

如果看RT的官方Demo,在页面中自定义返回按钮的代码,就是下面这种:

- (UIBarButtonItem *)rt_customBackItemWithTarget:(id)target action:(SEL)action
{
    UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
    [button setImage:[UIImage imageNamed:@"back"] forState:UIControlStateNormal];
    [button sizeToFit];
    [button addTarget:target
     action:action
     forControlEvents:UIControlEventTouchUpInside];
    return [[UIBarButtonItem alloc] initWithCustomView:button];
}

这里的action直接调用了super的action,如果在当前页面传一个自己写的方法,那么一定会抛出崩溃出来,堆栈信息如下:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[DCZLApp.BaseNavigationController backAction]: unrecognized selector sent to instance 0x109490000'

你会发现,没有找到这个方法,为什么?我们可以跟踪到rt_customBackItemWithTarget在RT框架中的实现:

- (void)_installsLeftBarButtonItemIfNeededForViewController:(UIViewController *)viewController
{
    BOOL isRootVC = viewController == RTSafeUnwrapViewController(self.viewControllers.firstObject);
    BOOL hasSetLeftItem = viewController.navigationItem.leftBarButtonItem != nil;
    if (!isRootVC && !self.useSystemBackBarButtonItem && !hasSetLeftItem) {
        if ([viewController respondsToSelector:@selector(rt_customBackItemWithTarget:action:)]) {
            viewController.navigationItem.leftBarButtonItem = [viewController rt_customBackItemWithTarget:self
                                                               action:@selector(onBack:)];
        }
        else if ([viewController respondsToSelector:@selector(customBackItemWithTarget:action:)]) {
            #pragma clang diagnostic push
            #pragma clang diagnostic ignored "-Wdeprecated-declarations"
            viewController.navigationItem.leftBarButtonItem = [viewController customBackItemWithTarget:self
                                                               action:@selector(onBack:)];
            #pragma clang diagnostic pop
        }
        else {
            viewController.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Back", nil)
                                                               style:UIBarButtonItemStylePlain
                                                               target:self
                                                               action:@selector(onBack:)];
        }
    }
}

它最后回去调用RTRootNavigationController中的onBack方法,如果我们想要在控制器中重写action,那么可以有两个实现思路:

  • 在RTRootNavigationController中新增分类,添加自定义方法,并在当前页面进行调用实现
  • 重写RTRootNavigationController中的onBack方法

我们先看第一个思路:

extension BaseViewController {
    override func rt_customBackItem(withTarget target: Any!, action: Selector!) -> UIBarButtonItem! {
        let button = ExtendTouchButton()
        button.frame = CGRect(x: 0, y: 0, width: 44, height: 44)
        button.addTarget(target, action: #selector(self.rt_navigationController.backAction), for: .touchUpInside)
        button.contentHorizontalAlignment = .left
        button.setImage(UIImage(named: R.image.dczl_back_black_icon.name), for: .normal)
        return UIBarButtonItem(customView: button)
    }
}

extension RTRootNavigationController {
    @objc func backAction() {
        print("我是新写的RTRootNavigationController里面的backAction")
        popToRootViewController(animated: true)
    }
}

执行如下:

这种思路是可行的,也就是说针对不同的页面与页面逻辑,我们只需要写实现一个RTRootNavigationController分类来处理即可。

第二种方案:

import UIKit
import RTRootNavigationController

class BaseNavigationController: RTRootNavigationController {
    
    var onBackCallback: (() -> Void)?
    
    @objc
    override func onBack(_ sender: Any) {
        if 伪代码,有什么条件 {
            onBackCallback?()
        } else {
            super.onBack(sender)
        }
    }
    
}

/// 看了OC RTRootNavigationController的源码,onBack其实在.m文件中,没有声明在.h中
/// 这里添加分类的同时,其实相当于将RTRootNavigationController的onBack方法给重写了
/// 如果在OC中写,其实就是为RTRootNavigationController创建分类,并在分类的.h将此方法
extension RTRootNavigationController {
    @objc
    func onBack(_ sender: Any) {
        popViewController(animated: true)
    }
}

我们的重点是onBack方法的重写,OC中RTRootNavigationController的源码,onBack其实在.m文件中,没有声明在.h中,这里添加分类的同时,其实相当于将RTRootNavigationController的onBack方法暴露出来了,如果在OC中写,其实就是为RTRootNavigationController创建分类,并在分类的.h将此方法。

接着我们通过继承RTRootNavigationController的方式,可以对onBack方法进行重写,通过条件拦截,比如BaseNavigationController里面的当前控制器是哪一个控制,我们就怎么怎么样,亦或者自己定义一个onBackCallback属性去做属性回调即可:

注意的是onBackCallback是全局存在与RTRootNavigationController中的,不同的业务页面可能会对onBackCallback做不同的实现,可能导致的问题就是onBackCallback不断被复写而达不到预期效果,可以考虑onBackCallback实现完成后置为nil,后续有使用再实现。

方案一,直接在是RTRootNavigationController的分类中进行新增方法从而改变action方法,适合于返回事件与当前页面没什么耦合的情况,在新的方法中处理逻辑即可;

方案二,因为可以将回调实现写在当前页面中,所以对于那种业务逻辑比较复杂的情况,使用方案二会比较好一点,当然使用方法二的成本也会高一点,需要对RT框架做对外暴露改造,继承RTRootNavigationController,以及对应的逻辑修改。

当然也有更简单的方法,就是直接更改RT代码,我这里写了一个分类,以减少对代码的入侵:

#import <RTRootNavigationController/RTRootNavigationController.h>

NS_ASSUME_NONNULL_BEGIN

typedef void (^OnBackCallback)(void);

@interface RTRootNavigationController (OnBack)

@property (nonatomic, copy) OnBackCallback onBackCallback;

- (void)onBack:(id)sender;

@end

NS_ASSUME_NONNULL_END
#import <objc/runtime.h>

#import "RTRootNavigationController+OnBack.h"

@implementation RTRootNavigationController (OnBack)

// 关联对象的key
static void *OnBackCallbackKey = &OnBackCallbackKey;

// 闭包属性的getter方法
- (OnBackCallback)onBackCallback {
    return objc_getAssociatedObject(self, OnBackCallbackKey);
}

// 闭包属性的setter方法
- (void)setOnBackCallback:(OnBackCallback)onBackCallback {
    objc_setAssociatedObject(self, OnBackCallbackKey, onBackCallback, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (void)onBack:(id)sender
{
    if (self.onBackCallback) {
        self.onBackCallback();
    } else {
        [self popViewControllerAnimated:YES];
    }
}

@end

以前在使用RT框架的过程中,遇到这种禁掉侧滑,返回按钮需要自定义的时候,只能直接将系统导航栏都隐藏了,去写一个高保真的系统导航来完成功能,通过以上思路,可以让代码与逻辑更加简单。

包含WebView页面的返回上一页,侧滑返回上一页,返回pop与侧滑pop:

其实可以认为第4点其实就是对于第3点扩充与应用。

在因为iOS里面WebView点击跳转,我们可以认为是在同一个WebView不停的做Web内部的路由,而这种路由动画就“好像”push,于是什么时候是返回Web的上一个路由还是返回上一个控制器,就成为了判断的关键。可能文字说不明白,直接上个动图:

因为自己的开发过程中使用了Rx,所以代码逻辑如下:

        (rt_navigationController as? BaseNavigationController)?.onBackCallback = { [weak self] in
            if self?.webView.canGoBack == true {
                self?.webView.goBack()
            } else {
                self?.navigationController?.popViewController(animated: true)
            }
        }
        
        /// iOS 如何让WKWebView侧滑返回时html逐级返回,而不是直接返回到上级控制器?
        /// https://www.imooc.com/article/26158
        webView.rx.observeWeakly(Bool.self, "canGoBack")
            .subscribe(onNext: { [weak self] newValue in
                print("新的值: \(newValue)")
                
                if let canGoBack = newValue {
                    self?.rt_disableInteractivePop = canGoBack
                }
            })
            .disposed(by: rx.disposeBag)

如果不使用RxCocoa与RTNavigationController,可以参考下面这段代码:

private let canGoBackKeyPath = "canGoBack"

webView.addObserver(self, forKeyPath: canGoBackKeyPath, options: .new, context: nil)

open override func observeValue(forKeyPath keyPath: String?,
                                of object: Any?,
                                change: [NSKeyValueChangeKey: Any]?,
                                context: UnsafeMutableRawPointer?) {
    guard let theKeyPath = keyPath, object as? WKWebView == webView else {
        super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
        return
    }
    
    if theKeyPath == canGoBackKeyPath{
        if let newValue = change?[NSKeyValueChangeKey.newKey]{
            let newV = newValue as! Bool
            if newV == true {
                self.navigationController?.interactivePopGestureRecognizer?.isEnabled = false;
            }else{
                self.navigationController?.interactivePopGestureRecognizer?.isEnabled = true;
            }
        }
    }
}


deinit {
    webView.removeObserver(self, forKeyPath: canGoBackKeyPath, context: nil)
}

整体的逻辑就是通过监听WebView的canGoBack属性,将其值与侧滑使能绑定,自定义重写的返回按钮事件,也与canGoBack做逻辑判断,canGoBack为true,就做WebView的返回上一页,canGoBack为false,就返回上一页。

总结

最近因为看TheRoute的源码,里面没有push跳转的过程中删除栈的功能,于是我想到了RTRootNavigationController,它是我个人用的比较多的一个库,在它原有的基础功能上,我结合自己使用过程中的业务场景与问题做了总结,希望可以帮助到大家。

使用 Metal 绘制视图的内容

创建一个 MetalKit 视图和渲染通道以绘制视图的内容。

概述

在这个示例中,您将学习使用 Metal 渲染图形内容的基础知识。您将使用 MetalKit 框架创建一个利用 Metal 绘制视图内容的视图。然后,您将编码一个渲染通道的命令,以背景色擦除视图。

示例代码传送门

注意

MetalKit 自动化了窗口系统任务、加载纹理和处理 3D 模型数据。更多信息,请参见 MetalKit。

准备一个 MetalKit 视图以进行绘制

MetalKit 提供了一个名为 MTKView 的类,它是 NSView(在 macOS 中)或 UIView(在 iOS 和 tvOS 中)的子类。MTKView 处理了许多与将您使用 Metal 绘制的内容显示到屏幕上的相关细节。

MTKView 需要引用一个 Metal 设备对象,以便内部创建资源,因此您的第一步是将视图的 device 属性设置为现有的 MTLDevice。

_view.device = MTLCreateSystemDefaultDevice();

MTKView 上的其他属性允许您控制其行为。要将视图的内容擦除为纯背景色,您需要设置其 clearColor 属性。您可以通过指定红、绿、蓝和 alpha 值使用 MTLClearColorMake(::::) 函数创建颜色。

_view.clearColor = MTLClearColorMake(0.0, 0.5, 1.0, 1.0);

由于在此示例中您不会绘制动画内容,请配置视图,使其仅在需要更新内容时(例如当视图形状改变时)才进行绘制:

_view.enableSetNeedsDisplay = YES;

委托绘制职责

MTKView 依赖您的应用程序向 Metal 发出命令以生成视觉内容。MTKView 使用委托模式在应该进行绘制时通知您的应用程序。要接收委托回调,请将视图的 delegate 属性设置为符合 MTKViewDelegate 协议的对象。

_view.delegate = _renderer;

委托实现两个方法:

  • 当内容大小发生变化时,视图会调用 mtkView(_:drawableSizeWillChange:) 方法。这发生在包含视图的窗口被调整大小时,或在设备方向改变时(在 iOS 上)。这允许您的应用程序适应渲染分辨率以匹配视图的大小。
  • 当需要更新视图的内容时,视图会调用 draw(in:) 方法。在此方法中,您创建一个命令缓冲区,编码告诉 GPU 绘制什么内容以及何时在屏幕上显示它的命令,并将该命令缓冲区排队以供 GPU 执行。这有时被称为绘制一帧。您可以将帧视为生成显示在屏幕上的单个图像所需完成的所有工作。在交互式应用程序(如游戏)中,您每秒可能会绘制多帧。

在这个示例中,一个名为 AAPLRenderer 的类实现了委托方法并承担了绘制的责任。视图控制器创建此类的一个实例,并将其设置为视图的委托。

创建渲染通道描述符

当您绘制时,GPU 会将结果存储到纹理中,这些纹理是包含图像数据并可由 GPU 访问的内存块。在此示例中,MTKView 创建了绘制视图所需的所有纹理。它创建了多个纹理,以便能够在显示一个纹理的内容的同时向另一个纹理进行渲染。

要进行绘制,您需要创建一个渲染通道,即绘制到一组纹理的一系列渲染命令。在渲染通道中使用的纹理也称为渲染目标。要创建渲染通道,您需要一个渲染通道描述符,即 MTLRenderPassDescriptor 的实例。在此示例中,不是配置自己的渲染通道描述符,而是请求 MetalKit 视图为您的应用创建一个。

MTLRenderPassDescriptor *renderPassDescriptor = view.currentRenderPassDescriptor;
if (renderPassDescriptor == nil)
{
    return;
}

渲染通道描述符描述了一组渲染目标及其在渲染通道开始和结束时应如何处理。渲染通道还定义了一些不属于此示例的其他渲染方面。视图返回具有指向视图纹理之一的颜色附件的渲染通道描述符,并根据视图属性配置渲染通道。默认情况下,这意味着在渲染通道开始时,渲染目标会被擦除为与视图的 clearColor 属性匹配的纯色,在渲染通道结束时,所有更改都会保存回纹理。

因为视图的渲染通道描述符可能为 nil,所以在创建渲染通道之前,应该测试确保渲染通道描述符对象是非 nil 的。

创建渲染通道

通过使用 MTLRenderCommandEncoder 对象将其编码到命令缓冲区中来创建渲染通道。调用命令缓冲区的 makeRenderCommandEncoder(descriptor:) 方法并传入渲染通道描述符。

id<MTLRenderCommandEncoder> commandEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];

在此示例中,您不编码任何绘制命令,因此渲染通道唯一做的事情就是擦除纹理。调用编码器的 endEncoding 方法以指示该通道已完成。

[commandEncoder endEncoding];

将可绘制对象呈现到屏幕

绘制到纹理不会自动将新内容显示在屏幕上。实际上,只有某些纹理可以呈现在屏幕上。在 Metal 中,能够显示在屏幕上的纹理由可绘制对象管理,要显示内容,您需要呈现可绘制对象。

MTKView 自动创建可绘制对象来管理其纹理。读取 currentDrawable 属性以获取拥有渲染通道目标纹理的可绘制对象。视图返回一个 CAMetalDrawable 对象,这是一个连接到 Core Animation 的对象。

id<MTLDrawable> drawable = view.currentDrawable;

调用命令缓冲区上的 present(_:) 方法,并传入可绘制对象。

[commandBuffer presentDrawable:drawable];

此方法告诉 Metal 在命令缓冲区计划执行时,Metal 应与 Core Animation 协作在渲染完成后显示纹理。当 Core Animation 呈现纹理时,它将成为视图的新内容。在此示例中,这意味着被擦除的纹理将成为视图的新背景。这一变化与其他 Core Animation 为屏幕上用户界面元素所做的视觉更新同步发生。

提交命令缓冲区

既然已经发出了帧的所有命令,提交命令缓冲区。

[commandBuffer commit];

SwiftUI-国际化(二)

介绍

SwiftUI-国际化一文中,我们详细介绍了国际化的内容。在 Xcode 15 之后,Apple 提供了一种新的国际化方式,通过引入String Catalog,使得处理国际化更加高效与便捷。

特点

  • Info.plist 文件国际化需要新建一个String Catelog,必须命名为InfoPlist.xcstrings
  • 文本国际化需要新建一个String Catelog,必须命名为Localizable.xcstrings
  • Xcode 为xcstrings文件提供了可视化的编辑界面,并且会显示每一种语言的国际化完成比例。
  • 编译时可以自动提取需要国际化的内容到xcstrings文件。

案例

  1. 配置国际化语言。
  2. 新建Localizable.xcstrings
  3. SwiftUI 代码。
import SwiftUI

struct ContentView: View {
    let temperature = 10

    var body: some View {
        VStack {
            // 纯文本
            Text(String(localized: "title", defaultValue: "Kindly Reminder"))

            // 自定义View
            MessageView(String(localized: "message", defaultValue: "Weather Information"))

            // 插值
            Text(String(localized: "weather",
                        defaultValue: "Weather is \(String(localized: "localizedWeather", defaultValue: "Sunny"))"))

            Text(String(localized: "temperature",
                        defaultValue: "Temperature is \(temperature) ℃"))
        }
        .padding()
    }
}

struct MessageView: View {
    let message: String

    init(_ message: String) {
        self.message = message
    }

    var body: some View {
        Text(message)
    }
}
  1. 编译项目,可以自动提取需要国际化的内容到xcstrings文件。
  2. 在 Xcode 提供的可视化的界面进行国际化内容的编辑,并且会显示每一种语言的国际化完成比例。

编辑英文.png编辑中文.png

  1. 运行并且测试。

效果

  • 英文。 英文效果.png

  • 中文。 中文效果.png

widget重建

Flutter Widget重建(Rebuild)全面深度解析

下面是一个全面且深入的分析:

1. 显式触发的重建

1.1 setState() 调用

void incrementCounter() {
  setState(() {
    counter++;
  });
}

机制:标记Element为dirty,注册框架重建请求。

1.2 markNeedsBuild() 直接调用

// 在自定义RenderObjectWidget中
element.markNeedsBuild();

机制:较底层API,setState()内部实际调用此方法。

2. 配置/数据驱动的重建

2.1 父Widget重建

class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  int counter = 0;
  
  @override
  Widget build(BuildContext context) {
    print("Parent rebuild");
    return Column(
      children: [
        Text("Counter: $counter"),
        ChildWidget(),  // ChildWidget会随Parent重建
        ElevatedButton(
          onPressed: () => setState(() => counter++),
          child: Text("Increment"),
        ),
      ],
    );
  }
}

机制:父Widget重建导致子Widget树重新创建。

2.2 InheritedWidget变化

class MyInheritedWidget extends InheritedWidget {
  final int data;
  
  MyInheritedWidget({required this.data, required Widget child})
      : super(child: child);
  
  @override
  bool updateShouldNotify(MyInheritedWidget oldWidget) {
    return data != oldWidget.data;
  }
  
  static MyInheritedWidget of(BuildContext context) {
    return context.dependOnInheritedWidget<MyInheritedWidget>();
  }
}

// 使用方
Widget build(BuildContext context) {
  final myData = MyInheritedWidget.of(context).data;  // 建立依赖
  return Text('Data: $myData');
}

机制:当InheritedWidget数据变化,所有依赖它的Widget都会重建。这是Provider、Theme、MediaQuery等的工作原理。

2.3 didUpdateWidget触发

class MyStatefulWidget extends StatefulWidget {
  final int data;
  MyStatefulWidget({required this.data});
  
  @override
  _MyStatefulWidgetState createState() => _MyStatefulWidgetState();
}

class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  @override
  void didUpdateWidget(MyStatefulWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.data != oldWidget.data) {
      // 响应配置变化
      setState(() {
        // 更新内部状态
      });
    }
  }
}

机制:父Widget传入的配置变化,可在didUpdateWidget中处理。

3. 系统/环境变化触发的重建

3.1 屏幕旋转(方向变化)

class OrientationAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final orientation = MediaQuery.of(context).orientation;
    print("Orientation: $orientation");
    
    return orientation == Orientation.portrait
        ? Column(children: [RedBox(), BlueBox()])
        : Row(children: [RedBox(), BlueBox()]);
  }
}

机制

  • 旋转屏幕时,系统更新MediaQuery
  • MediaQuery是InheritedWidget
  • 依赖MediaQuery的Widget会重建

3.2 键盘显示/隐藏

class KeyboardAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final bottomInset = MediaQuery.of(context).viewInsets.bottom;
    print("Keyboard height: $bottomInset");
    
    return Container(
      padding: EdgeInsets.only(bottom: bottomInset),
      child: TextField(),
    );
  }
}

机制

  • 键盘弹出时,viewInsets.bottom增加
  • MediaQuery更新,依赖它的Widget重建

3.3 系统设置变化

3.3.1 字体大小变化
class FontScaleAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final textScaleFactor = MediaQuery.of(context).textScaleFactor;
    print("Text scale factor: $textScaleFactor");
    
    return Text(
      "This text adapts to system font size",
      style: TextStyle(fontSize: 16 * textScaleFactor),
    );
  }
}
3.3.2 深色模式切换
class ThemeAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final isDarkMode = Theme.of(context).brightness == Brightness.dark;
    print("Dark mode: $isDarkMode");
    
    return Container(
      color: isDarkMode ? Colors.grey[800] : Colors.white,
      child: Text(
        "Theme adaptive text",
        style: TextStyle(
          color: isDarkMode ? Colors.white : Colors.black,
        ),
      ),
    );
  }
}

机制

  • 系统设置变化时,Flutter框架更新MediaQuery/Theme
  • 作为InheritedWidget,依赖它们的Widget重建

3.4 语言/区域设置变化

class LocaleAwareWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);
    print("Current locale: $locale");
    
    return Text(
      locale.languageCode == 'zh' ? "你好" : "Hello",
    );
  }
}

机制

  • 系统语言变更时,Localizations Widget更新
  • 依赖Localizations的Widget重建

4. 应用状态变化触发的重建

4.1 应用生命周期变化

class AppLifecycleAwareWidget extends StatefulWidget {
  @override
  _AppLifecycleAwareWidgetState createState() => _AppLifecycleAwareWidgetState();
}

class _AppLifecycleAwareWidgetState extends State<AppLifecycleAwareWidget> with WidgetsBindingObserver {
  AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
  
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }
  
  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
  
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    setState(() {
      _lifecycleState = state;
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("App lifecycle: $_lifecycleState");
    
    return Text(
      "Current state: $_lifecycleState",
      style: TextStyle(
        color: _lifecycleState == AppLifecycleState.resumed
            ? Colors.green
            : Colors.red,
      ),
    );
  }
}

机制

  • 应用进入前台/后台时,通过WidgetsBindingObserver回调
  • 手动调用setState触发重建

4.2 内存压力事件

class MemoryPressureAwareWidget extends StatefulWidget {
  @override
  _MemoryPressureAwareWidgetState createState() => _MemoryPressureAwareWidgetState();
}

class _MemoryPressureAwareWidgetState extends State<MemoryPressureAwareWidget> with WidgetsBindingObserver {
  bool _isUnderMemoryPressure = false;
  
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }
  
  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
  
  @override
  void didHaveMemoryPressure() {
    setState(() {
      _isUnderMemoryPressure = true;
    });
    
    // 释放一些资源
    Future.delayed(Duration(seconds: 5), () {
      if (mounted) {
        setState(() {
          _isUnderMemoryPressure = false;
        });
      }
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return _isUnderMemoryPressure
        ? SimpleImageWidget()  // 低内存模式,简化显示
        : HighQualityImageWidget();  // 正常模式,高质量显示
  }
}

机制

  • 系统内存不足时触发didHaveMemoryPressure
  • 手动调用setState降级UI

5. 路由和导航触发的重建

5.1 路由变化

class RouterAwareWidget extends StatefulWidget {
  @override
  _RouterAwareWidgetState createState() => _RouterAwareWidgetState();
}

class _RouterAwareWidgetState extends State<RouterAwareWidget> with RouteAware {
  String _routeStatus = "Active";
  RouteObserver<PageRoute>? _routeObserver;
  
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    
    _routeObserver = Router.of(context).routeObserver as RouteObserver<PageRoute>;
    _routeObserver?.subscribe(this, ModalRoute.of(context) as PageRoute);
  }
  
  @override
  void dispose() {
    _routeObserver?.unsubscribe(this);
    super.dispose();
  }
  
  @override
  void didPush() {
    setState(() {
      _routeStatus = "Pushed";
    });
  }
  
  @override
  void didPop() {
    setState(() {
      _routeStatus = "Popped";
    });
  }
  
  @override
  void didPushNext() {
    setState(() {
      _routeStatus = "Inactive (new route pushed)";
    });
  }
  
  @override
  void didPopNext() {
    setState(() {
      _routeStatus = "Active (returned to this route)";
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Text("Route status: $_routeStatus");
  }
}

机制

  • 路由变化时,RouteObserver提供回调
  • 手动调用setState响应路由事件

5.2 Focus变化

class FocusAwareWidget extends StatefulWidget {
  @override
  _FocusAwareWidgetState createState() => _FocusAwareWidgetState();
}

class _FocusAwareWidgetState extends State<FocusAwareWidget> {
  late FocusNode _focusNode;
  bool _hasFocus = false;
  
  @override
  void initState() {
    super.initState();
    _focusNode = FocusNode();
    _focusNode.addListener(_onFocusChange);
  }
  
  void _onFocusChange() {
    setState(() {
      _hasFocus = _focusNode.hasFocus;
    });
  }
  
  @override
  void dispose() {
    _focusNode.removeListener(_onFocusChange);
    _focusNode.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return TextField(
      focusNode: _focusNode,
      decoration: InputDecoration(
        labelText: "Input",
        border: OutlineInputBorder(),
        fillColor: _hasFocus ? Colors.blue.withOpacity(0.1) : null,
        filled: _hasFocus,
      ),
    );
  }
}

机制

  • Focus变化时,FocusNode触发监听器
  • 手动调用setState更新UI

6. 开发相关的重建触发

6.1 热重载(Hot Reload)

机制

  • 热重载时,Flutter重新运行build方法
  • 保留现有的State对象状态
  • Widget树从修改的地方开始重建

6.2 热重启(Hot Restart)

机制

  • 热重启时,整个应用重新初始化
  • 所有State都被重置
  • 完整的Widget树重建

7. 特殊重建场景

7.1 AnimationBuilder触发的重建

class PulsatingCircle extends StatefulWidget {
  @override
  _PulsatingCircleState createState() => _PulsatingCircleState();
}

class _PulsatingCircleState extends State<PulsatingCircle> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 1),
    )..repeat(reverse: true);
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        print("AnimatedBuilder rebuilding"); // 每帧都会打印
        return Container(
          width: 100 + 50 * _controller.value,
          height: 100 + 50 * _controller.value,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: Colors.blue.withOpacity(0.5 + 0.5 * _controller.value),
          ),
        );
      },
    );
  }
}

机制

  • 动画每帧触发AnimatedBuilder重建
  • 重建限制在AnimatedBuilder范围内

7.2 FutureBuilder/StreamBuilder触发的重建

class DataLoadingWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: fetchData(),
      builder: (context, snapshot) {
        print("FutureBuilder rebuilding, state: ${snapshot.connectionState}");
        
        if (snapshot.connectionState == ConnectionState.waiting) {
          return CircularProgressIndicator();
        } else if (snapshot.hasError) {
          return Text("Error: ${snapshot.error}");
        } else {
          return Text("Data: ${snapshot.data}");
        }
      },
    );
  }
  
  Future<String> fetchData() async {
    await Future.delayed(Duration(seconds: 2));
    return "Hello from the future!";
  }
}

机制

  • Future/Stream状态变化时自动触发重建
  • 重建限制在Builder范围内

7.3 LayoutBuilder触发的重建

class SizeResponsiveWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        print("LayoutBuilder: width=${constraints.maxWidth}, height=${constraints.maxHeight}");
        
        // 基于可用空间切换布局
        if (constraints.maxWidth > 600) {
          return WideLayout();
        } else {
          return NarrowLayout();
        }
      },
    );
  }
}

机制

  • 父级尺寸变化时LayoutBuilder重建
  • 可检测组件自身尺寸变化

8. 重建优化策略

8.1 使用const构造器

// 优化前
IconButton(
  icon: Icon(Icons.add),
  onPressed: () => setState(() => counter++),
)

// 优化后
IconButton(
  icon: const Icon(Icons.add), // 不会重建
  onPressed: () => setState(() => counter++),
)

8.2 使用RepaintBoundary隔离重绘

class OptimizedListItem extends StatelessWidget {
  final int index;
  
  const OptimizedListItem({Key? key, required this.index}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    print("Building item $index");
    
    return RepaintBoundary(
      child: ListTile(
        title: Text("Item $index"),
        // 复杂内容
        trailing: ComplexWidget(),
      ),
    );
  }
}

8.3 使用缓存和记忆化

class MemoizedWidget extends StatelessWidget {
  final int id;
  final String data;
  
  // 使用缓存
  static final Map<int, Widget> _cache = {};
  
  const MemoizedWidget({Key? key, required this.id, required this.data}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    // 检查缓存
    if (!_cache.containsKey(id)) {
      print("Cache miss for id $id");
      _cache[id] = _buildExpensiveWidget(id, data);
    } else {
      print("Cache hit for id $id");
    }
    
    return _cache[id]!;
  }
  
  Widget _buildExpensiveWidget(int id, String data) {
    // 假设这是一个计算密集型组件
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Text("Data: $data for ID $id"),
      ),
    );
  }
}

8.4 细粒度状态管理

// 不好的实践 - 整个列表重建
class IneffectiveListWidget extends StatefulWidget {
  @override
  _IneffectiveListWidgetState createState() => _IneffectiveListWidgetState();
}

class _IneffectiveListWidgetState extends State<IneffectiveListWidget> {
  List<bool> itemStates = List.generate(100, (_) => false);
  
  void toggleItem(int index) {
    setState(() {
      itemStates[index] = !itemStates[index];
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("Building entire list");
    return ListView.builder(
      itemCount: 100,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text("Item $index"),
          trailing: Checkbox(
            value: itemStates[index],
            onChanged: (_) => toggleItem(index),
          ),
        );
      },
    );
  }
}

// 好的实践 - 只重建单个项
class EfficientListWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print("Building list container once");
    return ListView.builder(
      itemCount: 100,
      itemBuilder: (context, index) {
        return ItemWidget(index: index);
      },
    );
  }
}

class ItemWidget extends StatefulWidget {
  final int index;
  
  const ItemWidget({Key? key, required this.index}) : super(key: key);
  
  @override
  _ItemWidgetState createState() => _ItemWidgetState();
}

class _ItemWidgetState extends State<ItemWidget> {
  bool checked = false;
  
  @override
  Widget build(BuildContext context) {
    print("Building just item ${widget.index}");
    return ListTile(
      title: Text("Item ${widget.index}"),
      trailing: Checkbox(
        value: checked,
        onChanged: (value) {
          setState(() {
            checked = value!;
          });
        },
      ),
    );
  }
}

总结

Flutter中Widget重建的触发机制非常丰富,了解这些可以帮助你更好地诊断性能问题并优化应用。关键是要意识到:

  1. 不仅是setState:重建可能来自多种系统级别事件
  2. 重建不等于绘制:Element和RenderObject层有自己的优化
  3. 重建成本因Widget而异:大多数重建很轻量,但仍要注意避免不必要重建
  4. 控制重建范围:通过拆分StatefulWidget和使用缓存机制优化

掌握这些重建机制,可以构建既响应用户操作又流畅高效的Flutter应用。

Flutter中从setState()到屏幕更新的完整流程

Flutter中从setState()到屏幕更新的完整流程

当在Flutter应用中调用setState()时,会触发一系列精确的步骤,最终导致UI更新。以下是这个过程的详细分解,配合具体示例:

1. setState()调用与标记阶段

步骤详解

  • 调用setState()并执行其回调函数
  • 标记当前State对象为"dirty"
  • 向引擎注册一个新帧的请求

源码层次的工作

@protected
void setState(VoidCallback fn) {
  // 断言确保不在build过程中调用setState
  assert(_debugLifecycleState != _StateLifecycle.defunct);
  
  // 执行回调函数,通常是更新一些状态变量
  fn();
  
  // 核心:标记当前State需要重建
  _element!.markNeedsBuild();
}

其中_element.markNeedsBuild()会:

  1. 将Element标记为dirty
  2. 将Element添加到BuildOwner的_dirtyElements列表
  3. 调用SchedulerBinding.instance.scheduleFrame()请求新帧

例子 - 计数器状态更新

class CounterState extends State<Counter> {
  int count = 0;
  
  void incrementCounter() {
    // 调用setState并提供匿名回调
    setState(() {
      count++; // 状态变更
      print("状态已更新为: $count"); // 立即执行
    });
    
    // 此时Element已被标记为dirty,但屏幕尚未更新
    print("setState已调用完毕,但屏幕尚未更新");
  }
  
  @override
  Widget build(BuildContext context) {
    print("build方法被调用,count = $count");
    return Text('Count: $count');
  }
}

incrementCounter()被调用时,输出顺序是:

状态已更新为: 1
setState已调用完毕,但屏幕尚未更新
...等待引擎触发新帧...
build方法被调用,count = 1

2. 调度帧阶段

步骤详解

  • scheduleFrame()通知Flutter引擎有工作要做
  • 引擎会等待下一个vsync信号(通常是16.67ms,对应60fps)
  • vsync信号到达时,引擎调用handleBeginFramehandleDrawFrame

源码工作流程

void scheduleFrame() {
  if (_hasScheduledFrame || !_framesEnabled)
    return;
  
  // 通知引擎需要在下一个vsync信号处理新帧
  window.scheduleFrame();
  _hasScheduledFrame = true;
}

然后在vsync时:

// 简化的流程
void _handleBeginFrame(Duration rawTimeStamp) {
  // 处理动画和各种回调
}

void _handleDrawFrame() {
  // 1. 运行所有微任务
  // 2. 构建所有dirty Elements
  // 3. 布局和绘制
  buildOwner.buildScope(renderViewElement);
  pipelineOwner.flushLayout();
  pipelineOwner.flushCompositingBits();
  pipelineOwner.flushPaint();
  // 4. 合成并发送到GPU
  renderView.compositeFrame();
}

例子 - 动画状态更新

class AnimatedBoxState extends State<AnimatedBox> with SingleTickerProviderStateMixin {
  late AnimationController controller;
  double width = 100.0;
  
  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: Duration(seconds: 1),
      vsync: this,
    )..addListener(() {
      setState(() {
        // 这会在每一帧被调用,随着动画值变化
        width = 100.0 + controller.value * 100.0;
        print("内部状态更新: width = $width");
      });
    });
    
    controller.forward();
  }
  
  @override
  Widget build(BuildContext context) {
    print("绘制宽度为 $width 的box");
    return Container(width: width, height: 100, color: Colors.blue);
  }
}

这个例子中,动画控制器在每一帧触发setState:

  1. 动画开始,请求首帧
  2. vsync信号到达,运行动画tick
  3. 动画tick更新值并调用setState
  4. 标记Element为dirty
  5. 在同一帧内完成构建和渲染
  6. 重复步骤2-5直到动画完成

3. 构建阶段(Build Phase)

步骤详解

  • BuildOwner遍历所有dirty elements
  • 调用每个dirty element的rebuild()方法
  • Element调用关联State的build()方法
  • 与之前的Widget tree进行比较,更新Element树

源码关键部分

void buildScope(Element context) {
  // ... 
  
  try {
    // 按深度排序,确保父元素先于子元素重建
    _dirtyElements.sort(Element._sort);
    
    // 处理所有需要重建的元素
    int dirtyCount = _dirtyElements.length;
    int index = 0;
    
    while (index < dirtyCount) {
      // 获取并重建Element
      final Element element = _dirtyElements[index];
      element.rebuild(); // 这里会调用widget.build()
      
      // ...处理潜在新增的dirty elements
    }
  } finally {
    // ...
  }
}

例子 - 层级嵌套重建

class ParentWidget extends StatefulWidget {
  @override
  ParentWidgetState createState() => ParentWidgetState();
}

class ParentWidgetState extends State<ParentWidget> {
  bool showDetails = false;
  
  void toggleDetails() {
    setState(() {
      showDetails = !showDetails;
      print("父级状态变更: showDetails = $showDetails");
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("父级build开始");
    return Column(
      children: [
        ElevatedButton(
          onPressed: toggleDetails,
          child: Text('Toggle Details'),
        ),
        ChildWidget(showDetails: showDetails),
      ],
    );
  }
}

class ChildWidget extends StatefulWidget {
  final bool showDetails;
  
  ChildWidget({required this.showDetails});
  
  @override
  ChildWidgetState createState() => ChildWidgetState();
}

class ChildWidgetState extends State<ChildWidget> {
  @override
  Widget build(BuildContext context) {
    print("子级build开始, showDetails=${widget.showDetails}");
    return widget.showDetails 
        ? Card(child: Text('详细信息...'))
        : SizedBox.shrink();
  }
}

当点击按钮时,重建顺序是:

父级状态变更: showDetails = true
父级build开始
子级build开始, showDetails=true

关键流程是:

  1. 点击按钮触发toggleDetails()
  2. setState()标记ParentWidgetState的Element为dirty
  3. 下一帧开始构建阶段
  4. 按深度排序后先重建父Element
  5. 父Element的重建导致子Widget重新创建
  6. 子Element检测到Widget配置(showDetails)变化,并更新

4. 布局阶段(Layout Phase)

步骤详解

  • RenderObject树接收到Element的更新
  • 标记需要重新布局的RenderObject
  • 从上到下计算约束(constraints)
  • 从下到上确定尺寸(sizes)

工作流程

void flushLayout() {
  // 确保不在布局过程中再次触发布局
  try {
    while (_nodesNeedingLayout.isNotEmpty) {
      final List<RenderObject> dirtyNodes = _nodesNeedingLayout.toList();
      _nodesNeedingLayout.clear();
      
      // 按深度排序,确保父节点先于子节点布局
      dirtyNodes.sort((a, b) => a.depth - b.depth);
      
      // 依次对每个节点进行布局
      for (final RenderObject node in dirtyNodes) {
        if (node._needsLayout && node.owner == this)
          node._layoutWithoutResize();
      }
    }
  } finally {
    // ...
  }
}

例子 - 复杂布局计算

class ResponsiveContainer extends StatefulWidget {
  @override
  ResponsiveContainerState createState() => ResponsiveContainerState();
}

class ResponsiveContainerState extends State<ResponsiveContainer> {
  bool useWideLayout = false;
  
  void toggleLayout() {
    setState(() {
      useWideLayout = !useWideLayout;
      print("布局状态变更: useWideLayout = $useWideLayout");
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("构建响应式容器");
    return Column(
      children: [
        ElevatedButton(
          onPressed: toggleLayout,
          child: Text('切换布局'),
        ),
        useWideLayout
            ? Row(
                children: [
                  Expanded(child: ColoredBox(color: Colors.red, child: SizedBox(height: 100))),
                  Expanded(child: ColoredBox(color: Colors.blue, child: SizedBox(height: 100))),
                ],
              )
            : Column(
                children: [
                  ColoredBox(color: Colors.red, child: SizedBox(height: 100, width: double.infinity)),
                  ColoredBox(color: Colors.blue, child: SizedBox(height: 100, width: double.infinity)),
                ],
              ),
      ],
    );
  }
}

布局流程:

  1. 点击按钮更改useWideLayout并触发setState
  2. 重建Widget树,现在结构从Column变为Row(或反之)
  3. Element树更新,创建/复用子Elements
  4. RenderObject树收到更新,标记需要重新布局
  5. 布局引擎从根部开始,向下传递约束:
    • 父RenderObject向Row/Column传递约束
    • Row/Column向其子项传递约束
  6. 子RenderObject确定自己的尺寸并向上报告:
    • 颜色块根据约束确定尺寸
    • Row/Column收集子项尺寸并确定自己的最终尺寸

5. 绘制阶段(Paint Phase)

步骤详解

  • 遍历标记为需要重绘的RenderObject
  • 创建或更新绘制记录(Layer)
  • 将绘制命令记录到Layer中

源码工作流程

void flushPaint() {
  try {
    final List<RenderObject> dirtyNodes = _nodesNeedingPaint.toList();
    _nodesNeedingPaint.clear();
    
    // 按深度排序,确保父节点先于子节点绘制
    dirtyNodes.sort((a, b) => a.depth - b.depth);
    
    // 依次对每个节点进行绘制
    for (final RenderObject node in dirtyNodes) {
      if (node._needsPaint && node.owner == this) {
        if (node._layer == null)
          node._repaintBoundary = null;
        node._paint();
      }
    }
  } finally {
    // ...
  }
}

例子 - 自定义绘制与动画

class AnimatedCircleState extends State<AnimatedCircle> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  Color _color = Colors.blue;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    )..addListener(() {
      setState(() {
        // 空的setState,只是为了触发重绘
        print("请求重绘,动画值: ${_controller.value}");
      });
    });
    
    _controller.repeat(reverse: true);
  }
  
  void changeColor() {
    setState(() {
      _color = _color == Colors.blue ? Colors.red : Colors.blue;
      print("颜色更改为: $_color");
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("构建自定义绘制组件");
    return Column(
      children: [
        ElevatedButton(
          onPressed: changeColor,
          child: Text('更改颜色'),
        ),
        CustomPaint(
          painter: CirclePainter(
            color: _color,
            progress: _controller.value,
          ),
          size: Size(200, 200),
        ),
      ],
    );
  }
}

class CirclePainter extends CustomPainter {
  final Color color;
  final double progress;
  
  CirclePainter({required this.color, required this.progress});
  
  @override
  void paint(Canvas canvas, Size size) {
    print("执行paint方法, progress=$progress");
    final center = Offset(size.width / 2, size.height / 2);
    final radius = 50.0 + 30.0 * progress;
    
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill;
      
    canvas.drawCircle(center, radius, paint);
  }
  
  @override
  bool shouldRepaint(CirclePainter oldDelegate) {
    return oldDelegate.color != color || oldDelegate.progress != progress;
  }
}

绘制流程:

  1. 动画tick或按钮点击触发setState
  2. Element重建并更新RenderObject属性
  3. RenderObject标记为需要重绘
  4. 绘制阶段,CustomPaint的RenderObject调用CirclePainter的paint方法
  5. CirclePainter根据当前color和progress绘制圆形
  6. 绘制命令被收集到对应的Layer中

6. 合成与渲染阶段

步骤详解

  • 构建完整的Layer树
  • 将Layer树提交给Flutter引擎
  • 引擎将Layer树转换为Skia(GPU渲染库)命令
  • GPU执行命令进行光栅化,产生像素
  • 显示器在下一次刷新时显示这些像素

流程简化

// 在绘制完成后,Flutter框架调用以下方法
ui.SceneBuilder _sceneBuilder = ui.SceneBuilder();

// 每个Layer都会添加到场景中
void addToScene(ui.SceneBuilder builder) {
  // ... Layer特定的添加逻辑
}

// 最后提交整个场景
ui.Scene scene = _sceneBuilder.build();
window.render(scene);

例子 - RepaintBoundary与Layer优化

class OptimizedUIState extends State<OptimizedUI> {
  int topCounter = 0;
  int bottomCounter = 0;
  
  void incrementTop() {
    setState(() {
      topCounter++;
      print("顶部计数器更新: $topCounter");
    });
  }
  
  void incrementBottom() {
    setState(() {
      bottomCounter++;
      print("底部计数器更新: $bottomCounter");
    });
  }
  
  @override
  Widget build(BuildContext context) {
    print("主UI构建");
    return Column(
      children: [
        ElevatedButton(
          onPressed: incrementTop,
          child: Text('更新顶部'),
        ),
        // 使用RepaintBoundary创建独立的Layer
        RepaintBoundary(
          child: Builder(builder: (context) {
            print("顶部区域构建: $topCounter");
            return Container(
              height: 100,
              color: Colors.amber,
              alignment: Alignment.center,
              child: Text('顶部计数: $topCounter', style: TextStyle(fontSize: 24)),
            );
          }),
        ),
        ElevatedButton(
          onPressed: incrementBottom,
          child: Text('更新底部'),
        ),
        // 另一个独立Layer
        RepaintBoundary(
          child: Builder(builder: (context) {
            print("底部区域构建: $bottomCounter");
            return Container(
              height: 100,
              color: Colors.lightBlue,
              alignment: Alignment.center,
              child: Text('底部计数: $bottomCounter', style: TextStyle(fontSize: 24)),
            );
          }),
        ),
      ],
    );
  }
}

渲染优化流程:

  1. 点击"更新顶部"按钮时:

    顶部计数器更新: 1
    主UI构建
    顶部区域构建: 1
    底部区域构建: 0
    
  2. 在绘制阶段:

    • 两个RepaintBoundary各自创建独立的PictureLayer
    • 顶部容器的绘制命令记录到第一个Layer
    • 底部容器的绘制命令记录到第二个Layer
  3. 再次点击"更新顶部"按钮:

    顶部计数器更新: 2
    主UI构建
    顶部区域构建: 2
    底部区域构建: 0
    
  4. 这次的绘制过程:

    • 只有顶部的PictureLayer需要重绘
    • 底部的Layer可以被复用,因为其内容没有变化
    • 这就是Layer树的合成优化
  5. 最后,所有Layer组合成一个Scene:

    • 根ContainerLayer包含所有子Layer
    • Scene被提交给Flutter引擎
    • 引擎将Scene转换为GPU命令
    • 显示器在下一个vsync显示结果

7. 总结:完整流程图

setState()到屏幕更新的完整路径:

  1. 触发阶段

    • setState()被调用
    • 更新State中的数据
    • 标记Element为dirty
    • 请求新帧
  2. 调度阶段

    • 等待下一个vsync信号
    • 处理开始帧回调
  3. 构建阶段

    • 遍历dirty elements
    • 调用build()方法
    • 更新Element树
  4. 布局阶段

    • 计算尺寸和位置
    • 自上而下传递约束
    • 自下而上确定尺寸
  5. 绘制阶段

    • 记录绘制命令
    • 创建或更新Layer
  6. 合成与渲染阶段

    • 构建Layer树
    • 提交给引擎
    • GPU渲染
    • 显示器展示

这个过程是Flutter实现流畅60fps动画的核心机制,通过精确控制每个阶段的工作,确保高效的UI渲染。

在H5页面的SSR中,客户端需要做哪些工作?

1、前言

作为前端开发,对于H5页面的SSR,我们一般只关心webview启动之后的工作,如数据请求、水合、渲染等。实际上,H5页面的SSR,需要和客户端高度配合,才能实现所需效果。比如在笔者的上一篇文章【 手把手带你实现 Vite+React 的简易 SSR 改造【含部分原理讲解】】中,简要提到了流式SSR+FCC优化这一工作便主要是借助客户端的缓存实现相应优化:
20250413105055.png

笔者除了前端开发外,对客户端开发也有一定了解(主要是iOS开发、flutter开发等),因此本文从iOS客户端的角度讲解所需要关注的细节,让读者对整个SSR流程更加了解。

2、客户端核心逻辑梳理

20250413115312.png

2-1 入口启动注册

在App启动时进行SSR模块的初始化(主要是注册自定义的 URL 协议类 SSRURLProtocol):

[RequestSSRHandler setup];

2-2 URL拦截和处理

  • SSRURLProtocol
    • 使用 NSURLProtocol 来拦截特定的网络请求。这些拦截基于请求 URL 的某些标志位或头字段。
    • canInitWithRequest: 方法用于判断是否对请求进行拦截,避免重复拦截,并进行必要的日志记录和异常处理。

2-3 请求发送与响应处理

  • RequestSSRHandler:
    • setup 方法设置与应用相关的请求参数,如 appkeyappverutdid 等,并进行统一管理。
    • sendRequest: 方法将请求发送给后端,可选择缓存命中的内容直接响应,优化响应时间。
    • ssrRequest:didReceiveResponse: 处理响应,检查缓存数据是否可用并适用的判断逻辑。
    • 对于特定错误进行降级处理(如网络错误),通过 requestOnline:receiver: 方法发起普通网络请求。

2-4 缓存管理

  • 缓存数据 ( SSRCacheData ) :

    • 负责序列化缓存的 HTML 数据,并保存相关的版本、过期时间等,用于快速响应请求。
    • 提供 getCacheHtmlsetCacheHtml 方法来管理缓存数据的存取,加速处理过程。
  • 缓存存储 ( FCCStorage ) :

    • 管理缓存的持久化。
    • 提供方法来保存、获取和移除缓存数据,以确保缓存的有效利用。
    • 使用特定查询参数和用户标识决定缓存的唯一键值,使缓存管理更具灵活性。

2-5 响应版本和开关校验

  • 在处理请求和响应时,对版本和开关的启用状态进行检查,确保缓存的正确性和适用性。
  • 确保在版本不匹配或开关关闭的情况下替代渲染方法,以保障应用的稳定性。

2-6 请求上下文管理

  • SSRRequestContext:
    • 管理请求的状态和数据,包括是否启用首 chunk 缓存、是否命中缓存、是否复用完缓存等。
    • produceResponse:produceData: 负责处理接收到的响应和数据,根据缓存状态进行处理,包括使用缓存、替代渲染、保存缓存等。
    • 提供 matchFirstChunkCache, saveFirstChunkCache, 和 matchFCC:cacheData:response: 等方法来管理首 chunk 缓存数据的匹配和存取。

2-7 错误处理与降级策略

  • 在请求失败的情况下,如果满足特定条件,会自动降级请求为普通在线请求,以保证系统的稳定性和用户体验。

3、附上WebViewController应有的一些逻辑

  1. WebView初始化与配置

    • 使用WKWebView,进行URL拦截。
    • 支持下拉刷新、自定义导航栏、状态栏样式等。
  2. 请求处理与拦截

    • 登录拦截。
    • URL安全校验,防止恶意链接。
    • 路由拦截,处理本地协议跳转。
  3. 性能监控与埋点

    • 页面加载时间统计。
    • 错误监控与上报。
    • 应用启动阶段H5页面加载的性能追踪。
  4. UI交互

    • 显示加载状态(自定义Loading动画)。
    • 处理横竖屏切换。
    • 导航栏返回按钮和关闭按钮的逻辑。
  5. 其他功能

    • Cookie同步、第三方验证、字体注入等。

了解客户端处理的这些逻辑之后,可以考虑h5页面首屏进一步的性能优化:webview预热和文档预请求,具体的实现逻辑需要笔者和读者一起去学习探索:
7CE0DEC0-5FBB-41A3-AC06-487735635733.png

Trae + SwiftUI 1 小时实现一个单词本 Mac App

前言

在 AI 发展越来越好的现在,它的应用已经不仅仅限制于帮我们生成问题的答案,还可以直接通过对自然语言的理解帮助我们直接生成对象的代码。对于某些简单的场景,如模版代码实现、结构简单的 UI 绘制等,它现在已经做得很好,这对于程序员的生产力提升还是非常有帮助的。

接下来,我通过一个简单的单词本应用,来给大家展示一下 Trae 的真实体验。

应用功能

首先,我们需要将应用的功能通过自然语言去描述出来,比如这个单词本 App,主要包含三个功能,单词本、错词和已掌握三个模块,每个模块都是以列表的形式进行展示。单词本中的单词如果不熟悉可以添加到错词中,如果很熟悉就添加到已掌握中,且支持 SwiftData 。

下面是我梳理的需求描述:

  • 新建一个 Swift 文件,文件名为 Word,并在里面实现一个 Word 类,包含 title 字符串类型、isError 布尔类型、isMaster 布尔类型,需要支持 SwiftData
  • 生成一个长度为 50 的数组 words,元素为 Word 类型,title 为随机的英文单词,10 个元素 isErrortrue,5 个元素 isMastertrue,其余的 isErrorisMasterfalse
  • 在侧边栏实现三个按钮,标题分别为单词本,错题,已掌握,点击按钮切换右侧视图。
  • 单词本、错词、已掌握三个 detail 都为列表形式。
  • 单词本列表内容为 words 中的所有元素,表格样式包含一个文本展示单词,两个按钮,一个按钮是添加到错词,若该模型的 isErrortrue 隐藏该按钮,若为 false 才显示。点击该按钮,将该条数据模型的 isError 赋值为 true。一个按钮是已掌握,若该模型的 isMastertrue 隐藏该按钮,若为 false 才显示。点击该按钮,将该条数据模型的 isMaster 赋值为 true
  • 错词列表内容为 words 中 isErrortrue 的所有元素,表格样式包含一个文本展示单词,一个按钮已掌握,点击该按钮,将该条数据模型的 isMaster 赋值为 trueisError 赋值为 false
  • 已掌握列表内容为 wordsisMastertrue 的所有元素,表格样式包含一个文本展示单词,一个按钮移除,点击该按钮,将该条数据模型的 isMaster 赋值为 false

梳理完,我们就可以通过 Trae 进行代码创建了。

Trae

首先,我们创建一个 SwiftUI 的 macOS app,然后通过 Trae 打开该项目。接着在 AI 对话流中,通过 #Folder 来选定当前文件夹,将第一条需求复制进去点击回车即可生成。

截屏2025-04-13 10.15.14.png

对话流中会生成代码的详细解释,右侧是代码实现,头部有拒绝和接受的选项,点击接受,代码就会自动写入项目中。

其余的需求描述我们需要 #File 选定相应的文件进行需求转代码实现。这里就不一一举例赘述了。

下面让我们来看下 Trae 实现的效果:

录屏2025-04-13 10.18.01.gif

小瑕疵

在代码实现过程中,虽然大部分代码都是正确可编译通过的,但还是碰到了下面的两个小问题:

  • if words.isEmpty { generateWords() } 直接写在了 View 中,代码视图如下:
var body: some View {
NavigationSplitView {
    VStack {}
} detail: {
    if words.isEmpty { generateWords() } // 这里会编译报错
    List(words, id: \.self) { word in
    ...
    }
}

正确的代码:

var body: some View {
NavigationSplitView {
    VStack {}
} detail: {
    if words.isEmpty { generateWords() } // 这里会编译报错
    List(words, id: \.self) { word in
    ...
    }.onAppear {
        if words.isEmpty {
            generateWords()
        }
    }
}
  • if !word.isMaster 写成了 if!word.isMaster,这个错误感觉有点不应该...

总结

从这个小例子的使用感受上来说,对开发者的帮助肯定是正大于负的,比我想象中的要聪明很多。希望大家能够拥抱变化,早早的享受到 AI 的红利。

音视频基础能力之 iOS 视频篇(六):使用Metal进行视频渲染

涉及硬件的音视频能力,比如采集、渲染、硬件编码、硬件解码,通常是与客户端操作系统强相关的,就算是跨平台的多媒体框架也必须使用平台原生语言的模块来支持这些功能

本系列文章将详细讲述移动端音视频的采集、渲染、硬件编码、硬件解码这些涉及硬件的能力该如何实现,其中的示例代码,在我们的 Github 仓库 MediaPlayground 中都能找到,与文章结合着一起理解,效果更好

本文为该系列文章的第 6 篇,将详细讲述在 iOS 平台下如何使用 Metal 实现视频画面的渲染,对应了我们 MediaPlayground 项目中 SceneVCVideoRenderMetal.m 文件涉及到的内容

往期精彩内容,可参考

音视频基础能力之 iOS 视频篇(一):视频采集

音视频基础能力之 iOS 视频篇(二):视频硬件编码

音视频基础能力之 iOS 视频篇(三):视频硬件解码

音视频基础能力之 iOS 视频篇(四):使用OpenGL进行视频渲染(上)

音视频基础能力之 iOS 视频篇(五):使用OpenGL进行视频渲染(下)

前言

之前 2 期文章讲了如何使用 OpenGL 做视频渲染,这次总算轮到 Metal 了。从开发的经验来看,兜兜转转这么多年,Metal 还是凭借自身过硬的实力证明了自己的,它的普及率在不断的提高,随着老旧 Apple 设备的逐渐淘汰,新设备对于 Metal 的支持也做的越来越好,可以说 Apple 生态下的渲染引擎,Metal 是当之无愧的老大

简单介绍Metal

Metal 渲染引擎是 Apple 为旗下操作系统专门打造的一套底层图形和计算编程接口,它能让开发者充分利用设备 GPU的强大性能,实现高性能的图形渲染和并行计算,目标就是代替原本的 OpenGL,为 Apple 生态提供更强大、更高效的图形能力。Metal 的高性能,来源于与 Apple 生态的强绑定,不像 OpenGL 标准要兼顾跨平台的通用性,因此更能发挥出 Apple 硬件设备的实力

宏观流程

在 OpenGL 的文章中,我们详细介绍了图形渲染的思路和流程,其实渲染引擎换成 Metal 之后,整体思路和流程是不变的,关键的渲染要素也都是不变的,只不过在 Metal 中换了一种写法。因此我们来快速回顾下图形渲染的流程

1.jpg

微观细节

下面用一个例子展开讲 Metal 渲染的细节

场景:将视频采集之后得到的 NV12 图像数据渲染在屏幕上

注意:因为原始图像数据的格式是 NV12,根据 NV12 格式的特点,数据会分为 Y 和 UV 两个平面,因此要有 2 个输入图像才能正常进行渲染,宏观流程会变成这个样子

2.jpg

系统框架

要在 iOS 上调用 Metal 的接口,需要引入头文件

#import <Metal/Metal.h>

必要资源

MTLDevice

MTLDevice 顾名思义,就是渲染设备,可以理解为 GPU 的抽象体现,与 Metal 渲染引擎相关的资源都由它来分配

id<MTLDevice> device = MTLCreateSystemDefaultDevice();

MTLLibrary

MTLLibrary 用于访问着色器程序,通过 MTLDevice 来创建

id<MTLLibrary> default_library = [device newDefaultLibrary];

MTLCommandQueue

MTLCommandQueue 用于操作渲染管线,通过 MTLDevice 来创建

id<MTLCommandQueue> command_queue = [device newCommandQueue];

采样纹理

与 OpenGL 一样,纹理的数据来源分为 2 种

  1. 如果原始图像数据被包装在 CVPixelBuffer 中,也就是我们例子中的场景,那么这块纹理是由系统创建并管理生命周期的,我们只需要拿到它就好。为了能拿到它,我们需要创建 CVMetalTextureCacheRef。注意:这个 cache 的生命周期需要我们自行管理
CVMetalTextureCacheCreate(kCFAllocatorDefault, NULL, device, NULL, &texture_cache_);

2. 如果原始图像数据本身已经在内存中(有可能是读取本地图片得到的,也有可能是通过软件解码得到的),就需要自行创建纹理并维护其生命周期 (本文中的例子用不到,建议放在一起对比着看,加深理解)

MTLTextureDescriptor* descriptor = [[MTLTextureDescriptor alloc] init];
descriptor.width = pixel_width;
descriptor.height = pixel_height;
descriptor.pixelFormat = MTLPixelFormatR8Unorm;// 对于 YUV 的 Y 分量
texture_ = [metal_device_ newTextureWithDescriptor:descriptor];
MTLRegion region = MTLRegionMake2D(0, 0, pixel_width, pixel_height);
[texture_ replaceRegion:region mipmapLevel:0 withBytes:pixel_data bytesPerRow:pixel_width];

渲染目标

Metal 中没有 OpenGL 那样 frame buffer 的概念,或者就算是有,存在感也不是那么强。涉及渲染目标的内容就是 MTLRenderPassDescriptor,但它其实就像是个携带了一堆参数的 config 而已,因此我们把渲染目标约等于 MTLTexture 也是问题不大的。

根据渲染目标的不同用途,有 2 种创建方式

  1. 想要渲染的结果能在屏幕上展示,也就是我们例子中的场景,就需要从 CAMetalLayer 中去拿到 MTLTexture。在 iOS 中需要自定义 UIView,修改 layerClass 为 CAMetalLayer
+ (Class)layerClass {
    return [CAMetalLayer class];
}

2. 如果渲染目标并不需要在屏幕上展示,只做离屏渲染,只需要创建 MTLRenderPassDescriptor 和 MTLTexture (本文中的例子用不到,建议放在一起对比着看,加深理解)

MTLTextureDescriptor* texture_descriptor = [[MTLTextureDescriptor alloc] init];
texture_descriptor.width = width;
texture_descriptor.height = height;
texture_descriptor.pixelFormat = MTLPixelFormatBGRA8Unorm;
texture_descriptor.usage = MTLTextureUsageShaderRead | MTLTextureUsageRenderTarget;

id<MTLTexture> texture = [metal_device_ newTextureWithDescriptor:texture_descriptor];

render_pass_descriptor_ = [MTLRenderPassDescriptor renderPassDescriptor];
render_pass_descriptor_.colorAttachments[0].clearColor = MTLClearColorMake(0.0f, 0.0f, 0.0f, 1.0f);
render_pass_descriptor_.colorAttachments[0].texture = texture;
render_pass_descriptor_.colorAttachments[0].loadAction = MTLLoadActionClear;
render_pass_descriptor_.colorAttachments[0].storeAction = MTLStoreActionStore;

着色器程序

着色器的作用在 OpenGL 的篇章里已经讲过,这里直接上代码。Metal 的着色器代码会单独放在 .metal 文件中,写好着色器程序,通过名称获取到 MTLFunction,再关联到 MTLRenderPipelineState。MTLRenderPipelineState 顾名思义就是跟渲染管线有关的资源

id<MTLFunction> vertex_function = [default_library newFunctionWithName:@"BasicVertexShader"];// from MetalBaseShader.metal
id<MTLFunction> fragment_function = [default_library newFunctionWithName:@"NV12FragmentShader"];// from MetalBaseShader.metal
MTLRenderPipelineDescriptor* pipeline_descriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipeline_descriptor.vertexFunction = vertex_function;
pipeline_descriptor.fragmentFunction = fragment_function;
pipeline_descriptor.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;
NSError* error = nil;
self.render_pipeline_state = [device newRenderPipelineStateWithDescriptor:pipeline_descriptor error:&error];
if (error) {
  NSAssert(!error, @"init shader failed, error:%@", error);
}

单次渲染流程

前期准备工作都做完后,就可以让渲染管线真正跑起来了,分为以下几个步骤

第 1 步:将输入的图像 A1 和 A2 的数据,关联到用于采样的纹理

本例中,由于原始图像数据的格式是 NV12,因此需要准备 2 个用于采样的纹理,宏观流程图中的 A1 和 A2 分别对应 Y 平面和 UV 平面。根据图像来源的不同,分为 2 种方式,跟之前准备采样纹理时一样

  1. 如果原始图像数据被包装在 CVPixelBuffer 中,也就是我们例子中的场景,那么从 CVPixelBuffer 中就能拿到纹理
// NV12 的 Y 分量
CVMetalTextureRef cv_texture = nullptr;
CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, texture_cache_, pixel_buffer, NULL, MTLPixelFormatR8Unorm, pixel_width, pixel_height, 0, &cv_texture);
id<MTLTexture> texture = CVMetalTextureGetTexture(cv_texture);
CFRelease(cv_texture);

// NV12 的 UV 分量
CVMetalTextureRef cv_texture = nullptr;
CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, texture_cache_, pixel_buffer, NULL, MTLPixelFormatRG8Unorm, pixel_width/2, pixel_height/2, 1, &cv_texture);
id<MTLTexture> texture = CVMetalTextureGetTexture(cv_texture);
CFRelease(cv_texture);

2. 如果原始图像数据本身已经在内存中,就需要将内存中的数据传递到之前创建好的纹理中 (本文中的例子用不到,建议放在一起对比着看,加深理解)

MTLTextureDescriptor* descriptor = [[MTLTextureDescriptor alloc] init];
descriptor.width = pixel_width;
descriptor.height = pixel_height;
descriptor.pixelFormat = MTLPixelFormatR8Unorm;// 双平面 YUV 的 Y 分量

id<MTLTexture> texture = [device newTextureWithDescriptor:descriptor];
MTLRegion region = MTLRegionMake2D(0, 0, pixel_width, pixel_height);
[texture replaceRegion:region mipmapLevel:0 withBytes:pixel_data bytesPerRow:pixel_width];
MTLTextureDescriptor* descriptor = [[MTLTextureDescriptor alloc] init];
descriptor.width = pixel_width/2;
descriptor.height = pixel_height/2;
descriptor.pixelFormat = MTLPixelFormatRG8Unorm;// 双平面 YUV 的 UV 分量

id<MTLTexture> texture = [device newTextureWithDescriptor:descriptor];
MTLRegion region = MTLRegionMake2D(0, 0, pixel_width/2, pixel_height/2);
[texture replaceRegion:region mipmapLevel:0 withBytes:pixel_data bytesPerRow:pixel_width];

第 2 步:准备渲染目标。本案例做屏上渲染,需要从 CAMetalLayer 拿到目标纹理

// 屏上渲染
id<CAMetalDrawable> layer_drawable = [[self.display_view getMetalLayer] nextDrawable];
id<MTLTexture> target_texture = layer_drawable.texture;

//
MTLRenderPassDescriptor* render_pass_descriptor = [MTLRenderPassDescriptor renderPassDescriptor];
render_pass_descriptor.colorAttachments[0].clearColor = MTLClearColorMake(0.0f, 0.0f, 0.0f, 1.0f);
render_pass_descriptor.colorAttachments[0].texture = target_texture;
render_pass_descriptor.colorAttachments[0].loadAction = MTLLoadActionClear;
render_pass_descriptor.colorAttachments[0].storeAction = MTLStoreActionStore;

第 3 步:创建 MTLRenderCommandEncoder,这个对象很重要,用于配置渲染管线中的各个要素

// 创建 render command encoder
id<MTLRenderCommandEncoder> render_command_encoder = [command_buffer renderCommandEncoderWithDescriptor:render_pass_descriptor];
// 设置渲染区域
[render_command_encoder setViewport:(MTLViewport){0.0f, 0.0f, [self.display_view getMetalLayer].drawableSize.width, [self.display_view getMetalLayer].drawableSize.height, 0.0f, 1.0f}];

第 4 步:关联着色器与当前的渲染操作

[render_command_encoder setRenderPipelineState:self.render_pipeline_state];

第 5 步:关联采样纹理和片段着色器

[render_command_encoder setFragmentTexture:texture_list_[0]->GetTexture() atIndex:MetalShaderTextureIndex0];
[render_command_encoder setFragmentTexture:texture_list_[1]->GetTexture() atIndex:MetalShaderTextureIndex1];

第 6 步:将顶点坐标传递给顶点着色器

id<MTLBuffer> vertex_coordinates_buffer = [device newBufferWithBytes:MetalDefaultVertexCoordinates length:sizeof(MetalDefaultVertexCoordinates) options:MTLResourceStorageModeShared];
[render_command_encoder setVertexBuffer:vertex_coordinates_buffer offset:0 atIndex:MetalShaderIndexVertexCoordinates];

第 7 步:将纹理坐标传递给顶点着色器,然后会由顶点着色器透传给片段着色器

id<MTLBuffer> texture_coordinates_buffer = [device newBufferWithBytes:MetalDefaultTextureCoordinates length:sizeof(MetalDefaultTextureCoordinates) options:MTLResourceStorageModeShared];
[render_command_encoder setVertexBuffer:texture_coordinates_buffer offset:0 atIndex:MetalShaderIndexTextureCoordinates];

第 8 步:调用绘制方法;渲染结果需要上屏显示的话,需要在 command buffer 执行 commit 之前,调用 command buffer 的 presentDrawable 方法,离屏渲染则不需要

可以看到 Metal 所体现的思想就是把渲染操作的指令批量进行打包,放在 command buffer 中,然后批量进行处理,其实 OpenGL 也是类似的,调用 OpenGL 接口只是把指令发给了 GPU,GPU 什么时候执行其实并不那么明确,Metal 在流程控制上也更方便,代码中使用了 waitUntilCompleted 来等待当前渲染操作完成,这在链式渲染操作中是很常见的手段

// render
[render_command_encoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4];
[render_command_encoder endEncoding];
  
// show render result on screen
[command_buffer presentDrawable:layer_drawable];
  
// commit render commands
[command_buffer commit];
[command_buffer waitUntilCompleted];

到此为止,单次的渲染流程就走完了,在本例中,对应着视频采集单次回调的图像数据从开始渲染直到在屏幕上展示的过程

释放资源

相比 OpenGL,Metal 的资源回收就方便很多了,唯一要注意的是采样纹理,如果纹理的数据来自 CVPixelBuffer,需要手动释放 CVMetalTextureCacheRef,其他的资源都可以由 ARC 机制进行内存管理,释放资源无需进行额外操作,解除强引用即可

CFRelease(texture_cache_);

写在最后

以上就是本文的所有内容了,详细介绍了在 iOS 平台下如何使用 Metal 实现视频画面的渲染

本文为音视频基础能力系列文章的第 6 篇

往期精彩内容,可参考

音视频基础能力之 iOS 视频篇(一):视频采集

音视频基础能力之 iOS 视频篇(二):视频硬件编码

音视频基础能力之 iOS 视频篇(三):视频硬件解码

音视频基础能力之 iOS 视频篇(四):使用OpenGL进行视频渲染(上)

音视频基础能力之 iOS 视频篇(五):使用OpenGL进行视频渲染(下)

后续精彩内容,敬请期待

音视频基础能力系列文章的示例代码,在我们的 Github 仓库 MediaPlayground 中都能找到,与文章结合着一起理解,效果更好

如果您觉得以上内容对您有所帮助的话,欢迎关注我们运营的公众号声知视界,会定期的推送音视频技术、移动端技术为主轴的科普类、基础知识类、行业资讯类等文章

音视频学习笔记十六——图像处理之OpenCV基础一

题记:前文介绍GPUImage滤镜链的原理,但实际上要写出效果,还需要理解其中图片处理的过程,所以本章开始会介绍一些OpenCV基础相关。图像处理需要用到很多专业的算法,本人业余学习略知皮毛,只是庶竭驽钝叙其所得,在音视频学习Demo有一些的示例。文章或代码若有错误,也希望大佬不吝赐教。

opencv绘图.jpg

一、OpenCV简介

OpenCV(Open Source Computer Vision Library)是一个开源的计算机视觉和机器学习库,广泛应用于人脸识别与生物识别、自动驾驶、工业检测等,核心功能包括:

  • 图像处理:滤波、边缘检测、几何变换、颜色空间转换、直方图均衡化等。
  • 视频分析:运动检测、光流检测等。
  • 特征提取与匹配:SIFT、SURF、ORB、角点检测等。
  • 目标检测与识别:Haar级联分类器(人脸检测)、HOG+SVM(行人检测)、深度学习模型(YOLO、SSD)。
  • 机器学习:支持向量机(SVM)、神经网络等算法。

二、基础操作

2.1. 输入/输出

// 读取图像
cv::Mat img = cv::imread("xxx/xxx.jpg", cv::IMREAD_COLOR);
// 保存图像
cv::imwrite("xxx/xxx.jpg", img);

cv::imshow(winname, img)创建窗口显示,移动端没有实现,iOS端转换为UIImage:

- (UIImage *)matToUIImage:(const cv::Mat&)mat {
    NSData *data = [NSData dataWithBytes:mat.data length:mat.elemSize() * mat.total()];
    CGColorSpaceRef colorSpace = mat.channels() == 1 ? CGColorSpaceCreateDeviceGray() : CGColorSpaceCreateDeviceRGB();
    
    CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)data);
    CGImageRef imageRef = CGImageCreate(mat.cols, mat.rows, 8, 8 * mat.channels(), mat.step[0], colorSpace, kCGImageAlphaNone|kCGBitmapByteOrderDefault, provider, NULL, false, kCGRenderingIntentDefault);
    UIImage *image = [UIImage imageWithCGImage:imageRef];
    CGImageRelease(imageRef);
    CGDataProviderRelease(provider);
    CGColorSpaceRelease(colorSpace);
    
    return image;
}

2.2. Mat对象

Mat基本是OpenCV中基本操作单元,可以从图片中读取(channels为BGR注意与移动端常用的RGB区别),也可以创建空矩阵。

// 空矩阵
cv::Mat emptyMat;

// 指定尺寸和类型(行,列,数据类型)
cv::Mat mat(480, 640, CV_8UC3);          // 3通道 8位无符号(BGR图像)
cv::Mat floatMat(100, 100, CV_32FC1);    // 单通道浮点矩阵

// 初始化值
cv::Mat redMat(100, 100, CV_8UC3, cv::Scalar(0, 0, 255)); // 全红色图像
cv::Mat ones = cv::Mat::ones(3, 3, CV_32F); // 全1矩阵

Mat的数据实际存储在u(UMatData)中,而data的内存管理,使用引用计数,可以使用mat.u->refcount查看引用的计数。

  • 浅拷贝:默认赋值或传参,共享数据内存

    cv::Mat shallow = mat;
    
  • 深拷贝:独立内存

    cv::Mat deep = mat.clone();
    // 或
    mat.copyTo(deep);
    

2.2.1. 访问和修改像素

  • 单通道(灰度):
uchar pixel = mat.at<uchar>(y, x); // 读取 (y,x) 处的值(注意行列顺序!)
mat.at<uchar>(y, x) = 255;         // 修改
  • 多通道(如 BGR 图像):
cv::Vec3b& pixel = mat.at<cv::Vec3b>(y, x); 
pixel[0] = 255; // 蓝色通道
pixel[1] = 0;   // 绿色通道
pixel[2] = 0;   // 红色通道
  • 使用指针高效遍历:
for (int i = 0; i < mat.rows; i++) {
    uchar* row = mat.ptr<uchar>(i);
    for (int j = 0; j < mat.cols; j++) {
        row[j] = ...; // 修改像素
    }
}

2.2.2. 图像处理操作

  • 调整大小:
cv::Mat resized;
cv::resize(inputMat, resized, cv::Size(newWidth, newHeight));
  • 颜色空间转换:
cv::Mat gray;
cv::cvtColor(colorMat, gray, cv::COLOR_BGR2GRAY);
  • 旋转:
cv::Mat rotated;
cv::rotate(inputMat, rotated, cv::ROTATE_90_CLOCKWISE);
旋转.jpg
  • 裁剪 ROI(Region of Interest):
int x = (cols - 200) / 2;
int y = (rows - 200) / 2;
cv::Rect roi_rect(x, y, 200, 200);
roi显示.jpg

矩阵运算

cv::Mat A = ... , B = ... , C;
cv::add(A, B, C);           // 矩阵加法
cv::multiply(A, B, C);      // 逐元素乘法
C = A * B;                  // 矩阵乘法(非逐元素)
cv::transpose(A, C);        // 转置

数据类型转换

cv::Mat floatMat;
mat.convertTo(floatMat, CV_32F, 1.0/255.0); // 转为浮点并归一化
  • 单通道显示

cv::split分离通道操作,注意是按照BGR的顺序,所以R通道为channels[2]

std::vector<cv::Mat> channels;
cv::split(mat, channels);

// 创建零矩阵并合并三通道
cv::Mat zeroMat = cv::Mat::zeros(mat.size(), CV_8UC1);
std::vector<cv::Mat> mergedChannels{zeroMat, zeroMat, channels[2]};
cv::Mat des;
cv::merge(mergedChannels, des);
红色通道.jpg

Blend效果

// 调整img2尺寸与输入图像匹配
cv::resize(img2, img2, mat.size(), 0, 0, cv::INTER_LINEAR);
// 使用addWeighted进行混合
cv::addWeighted(mat, 0.6, img2, 0.4, 0.0, blended);
融合.jpg

二值操作

cv::Mat gray, dst;
// 转换为灰度图像
cv::cvtColor(mat, gray, cv::COLOR_BGR2GRAY);

// 应用Otsu二值化
cv::threshold(gray, dst, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);
return dst;
二值效果.jpg

三、形态学处理

形态学是一类基于图像形状的图像处理技术,以下图为例,看一下形态学的变化。形态学是对实际上会对各个通道进行独立操作,默认是对单通道图像(如灰度图或二值图)操作,所以一般使用二值图看效果。

burr.jpg

3.1. 腐蚀

腐蚀操作原理是取邻域最小值,如下图,处理像素点1时,检查周围像素点(MORPH_RECT),取色值最小的点(右下),所以当前的点变成黑色。换个角度,黑色像素点会把周围点都变成黑色,像黑色来腐蚀了白色。

腐蚀原理.jpg

代码如下:

cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::morphologyEx(mat, mat, cv::MORPH_ERODE, kernel);

效果如图,整体变小了,毛刺少了很多。

腐蚀.jpg

3.2. 膨胀

膨胀操作原理是取邻域最大值,就是和腐蚀相反的操作。如下图,处理像素点1时,检查周围像素点(MORPH_RECT),取色值最大的点(右下),所以当前的点变成白色。换个角度,白色像素点会把周围点都染白,像白色像素进行了膨胀。

膨胀原理.jpg

代码如下:

cv::Mat dilated;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::dilate(mat, dilated, kernel);

效果如图,整体变大了,毛刺变得更粗壮了。

膨胀.jpg

3.3. 开运算

上述两种运算都会原来的形状(变大或缩小),而先腐蚀后膨胀就是开运算。开运算一般用于去噪,如下图,先腐蚀会让黑色区域变大,从而中间的白色噪点消失,再膨胀白色区域恢复(原来的噪点消失)。

开运算.jpg

代码如下:

cv::Mat opened;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5,5));
cv::morphologyEx(mat, opened, cv::MORPH_OPEN, kernel);

效果如图,可以通过改变kernel大小调整效果(5x5效果):

开运算效果.jpg

3.4. 闭运算

闭运算是开运算相反的操作先膨胀再腐蚀,运用孔洞填充,如下图字母T,由于打印或拍摄问题,有些像素点缺失。先膨胀就可以把区域连通,再腐蚀恢复成原来大小。

闭运算.jpg

代码如下:

cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5,5));
cv::morphologyEx(mat, mat, cv::MORPH_CLOSE, kernel);

效果如图,可以通过改变kernel大小调整效果(5x5效果),连通了毛刺中间的区域:

闭操作效果.jpg

3.5. 礼帽

礼帽操作是用原图-开运算,开运算作用是去毛刺,那么礼帽的作用就是获取图片中的毛刺,提取亮细节。

代码如下:

cv::Mat result;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::morphologyEx(mat, result, cv::MORPH_BLACKHAT, kernel);

效果如图,获取到毛刺:

礼帽.jpg

3.6. 黑帽

黑帽操作是用闭运算-原图,闭运算作用是连通,那么黑帽的作用就是提取暗细节。

黑帽原理.jpg

代码如下:

cv::Mat result;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::morphologyEx(mat, result, cv::MORPH_BLACKHAT, kernel);

Socket 与 WebSocket 的区别是啥?

一、基础概念

1. Socket

  • 定义
    Socket 是操作系统提供的 网络通信接口,是应用程序与网络协议(如 TCP/UDP)之间的桥梁。它通过底层协议直接与网络交互,提供 全双工通信能力
    • 协议类型
      • TCP Socket:基于 TCP 协议,保证数据可靠传输(有序、无丢失)。
      • UDP Socket:基于 UDP 协议,提供低延迟传输(但不保证可靠性)。
    • 特点
      • 灵活性:可完全控制数据格式、连接状态和传输逻辑。
      • 低层控制:适合需要精细控制网络行为的场景(如游戏、物联网设备)。

2. WebSocket

  • 定义
    WebSocket 是一种 应用层协议,基于 TCP,通过 HTTP 协议升级为持久化双向连接。它通过一次握手后,建立长期稳定的通信通道。
    • 特点
      • 全双工通信:客户端和服务器可随时主动发送数据。
      • 轻量高效:数据帧头部仅 2-10 字节,传输效率高。
      • 兼容性:原生支持浏览器和移动设备,适合实时 Web 应用。

二、核心区别对比

1. 协议与连接方式

维度 Socket WebSocket
协议层级 传输层(TCP/UDP)或应用层(自定义) 应用层(基于 HTTP/TCP)
连接建立 通过 TCP 三次握手直接建立连接 通过 HTTP 协议升级(如 GET /ws
连接模式 短连接(需手动维护长连接) 长连接(持久化,一次建立长期有效)
数据格式 纯字节流(需自行解析) 帧格式(自动分隔和重组数据)

2. 数据传输与性能

维度 Socket WebSocket
传输效率 低延迟(直接 TCP) 实时性高,但略高于纯 TCP(因握手开销)
头部开销 无额外 HTTP 头 初始握手有 HTTP 头,后续数据头小
可靠性 TCP 保证可靠传输 基于 TCP,但需处理协议层错误

3. 开发复杂度与维护

维度 Socket WebSocket
连接管理 需手动处理心跳、重连、错误恢复 自动管理连接(内置心跳机制)
数据处理 需自行解析分包、处理编码/解码 自动处理数据分帧(如文本/二进制)
安全性 需手动实现加密(如 SSL/TLS) 支持 WSS(WebSocket over TLS)
防火墙穿透 可能被拦截(需开放特定端口) 通过 HTTP/80 或 HTTPS/443 穿透

三、iOS 开发实践

1. Socket 实现

  • 常用框架
    • GCDAsyncSocket(TCP):支持异步操作,适合复杂协议控制。
    • SwiftSocket(TCP/UDP):轻量级,适合简单通信。
  • 开发流程
    1. 建立连接:通过 connect(toHost:onPort:) 建立 TCP 连接。
    2. 数据收发:手动发送字节流(需协议解析)。
    3. 错误处理:监听 didDisconnectWithError 等回调。
  • 示例代码(TCP)
    let socket = GCDAsyncSocket()
    do {
        try socket.connect(toHost: "example.com", onPort: 8080, withTimeout: 10)
        socket.readData(withTimeout: -1, tag: 0)
    } catch {
        print("连接失败:\(error)")
    }
    

2. WebSocket 实现

  • 常用框架
    • Starscream:轻量级,支持文本/二进制消息。
    • Socket.IO:兼容多种传输协议(WebSocket 优先)。
  • 开发流程
    1. 建立连接:通过 connect() 发送 HTTP 升级请求。
    2. 消息监听:通过 onTextonData 等回调接收数据。
    3. 自动重连:支持配置重连策略(如指数退避)。
  • 示例代码(Starscream)
    import Starscream
    
    let socket = WebSocket(url: URL(string: "wss://example.com/ws")!)
    socket.onConnect = { print("连接成功") }
    socket.onText = { text in print("收到文本消息:\(text)") }
    socket.connect()
    

四、场景选择与性能优化

1. 适用场景

场景类型 Socket WebSocket
实时性要求极高 游戏、物联网设备控制(毫秒级响应) 聊天、股票行情(秒级响应)
低延迟传输 传感器数据流、在线游戏同步 实时消息推送、音视频同步
复杂协议控制 自定义二进制协议(如金融交易) 标准化协议(如聊天协议)
浏览器/移动端混合开发 需额外适配(如 WebSockets 桥接) 原生支持,适合跨平台实时通信

2. 性能优化

Socket 优化建议

  • 复用连接:避免频繁建立/关闭连接,复用长连接。
  • 数据压缩:使用二进制协议(如 Protobuf)减少传输体积。
  • 心跳机制:定期发送心跳包检测连接状态(如每 30 秒一次)。

WebSocket 优化建议

  • 消息队列:批量发送消息以减少网络开销。
  • 协议压缩:启用 Permessage-Deflate 压缩(需服务器支持)。
  • 服务器选择:使用高性能 WebSocket 服务器(如 Nginx、Socket.IO 服务端)。

五、总结:如何选择?

  • 选 Socket 的场景

    • 需要 底层协议控制(如自定义二进制协议)。
    • 高实时性要求(如游戏、传感器数据)。
    • 局域网或封闭环境(防火墙限制较少)。
  • 选 WebSocket 的场景

    • 快速开发:标准化协议,开发效率高。
    • 实时 Web 应用:聊天、在线协作、股票推送。
    • 混合开发:需要与浏览器或跨平台应用通信。

六、关键术语表

术语 解释
TCP 可靠传输协议,保证数据顺序和完整性。
UDP 无连接协议,低延迟但不保证可靠性。
HTTP 升级 WebSocket 通过 HTTP 请求升级连接。
Permessage-Deflate WebSocket 的数据压缩扩展协议。

SwiftUI 国际化

已经2025年了,不要再用Strings File这种过时的文件来实现多语。

Xcode15(2023年9月)推出了新的多语格式String Catalog。

比起Strings File用key = value这样的简陋的形式来实现多语,String Catalog简直就是一把瑞士军刀。

下面我们来看看String Catalog有哪些优点。

  • 能够清晰地知道多语翻译的进度,可以渐进地翻译。 90dfb497-5867-48c2-b97c-a03fc4bf4d8f.jpg.png

  • 能够轻松实现英语中的复数。 image.png

image.png

而这在之前用Strings File是没法实现的。需要借助Stringsdict File来实现。 image.png

可以看得出来,繁琐复杂。以前开发App时因为英语国家的用户不是主要目标对象,我都选择忽略。

而用String Catalog就是顺带手的事,那就给翻译了吧。

  • 每次编译可以自动抓取需要翻译的key,并且把代码中删除的key标记出来。

  • 根据设备提供不一样的翻译。 image.png

Xcode还非常贴心地提供了从老版的Strings File迁移到String Catalog的功能。 image.png

String Catalog这么强大,那么拦在大家面前的障碍就只剩一点了吧。

String Catalog是随Xcode15推出的,那是不是只有iOS 17+才能用?

并不是。String Catalog是开发功能,而不是系统功能。在旧的系统上一样可以使用。

为什么可以确定? image.png

这个是用String Catalog的工程打包出来的ipa里面看到的。可以看到,和旧的格式是一样的,是.strings和.stringsdict。打包出来的格式是一样的,没有理由旧的系统不能用。

实际上经测试也是如此。

iOS开发:关于路由

在iOS开发中引入路由框架一直是一个有争议的话题。

因为即使不使用路由框架,似乎也不会有太大的影响。那么我们先来回顾一下几个典型的跳转场景:

从外部跳转到App

  1. Safari浏览器网页点击事件跳转到App的页面

  2. App在挂起或者杀死状态,收到推送跳转到App的页面

  3. 收到短信,短信里面有短链接跳转到App的页面

  4. 从邮件中的链接跳转到App的页面

  5. 从社交媒体应用(如微信、微博)中的链接跳转到App的页面

  6. 从App的小组件跳转到App的页面

  7. Siri、ShotCut进行跳转

App内部跳转

  1. 单一主工程,无业务模块依赖,页面间进行跳转

  2. 多模块工程,多个模块之间可以随意跳转

需要注意的是,对于多模块工程,模块可能是第三方开发的,并不遵守内部开发标准,这种情况不在考虑范围内。

路由中心

  1. 跳转到微信小程序:其实跳转到其他App的小程序也可以认为是这种业务场景。目前已经无法从微信小程序跳转到App了。点击查看
  2. 跳转到其他App:这种情况跳转API相对固定,传值也有规则,无需路由框架,不在我们的讨论之中。
  3. 跳转到Safari浏览器:这种情况跳转API也相对固定,传值也有规则,无需路由框架,不在我们的讨论之中。

image.png

上图展示了非常复杂的跳转场景。在日常开发过程中,比如推送跳转到不同页面,实际上是通过推送信息中的字符串创建一个枚举映射判断,不同的字段跳转到不同的页面。浏览器和短信消息以及App开屏广告跳转也是使用枚举映射的逻辑。

维护一个枚举映射表可以完成这个功能,但随着业务量的增大,这种方式虽然可行,但不够友好,下面是伪代码示例:


func pushToAppPage(model: LaunchAdModel,
                   tabbarController: UITabBarController,
                   navigationController: UINavigationController) {
        switch model.appPage {
        case "app_home_page":
            break
        case "app_message_center":/// 消息中心
            navigationController.pushViewController(MessageCenterController(), animated: true)
        case "app_message_center_detail":/// 公告详情
            let vc = AppMessageDetailController()
            vc.messageId = model.appItemId
            navigationController.pushViewController(vc, animated: true)
        case "community_topics":// 资讯
            break
        case "activity":
            break
        /// 业务增加会case也逐渐增加,如果入参规则不同,还需要不同的构建器,初始化方法和赋值

        }
}

如果使用路由框架将页面和路由表提前绑定,此时外部跳转进来,只需一行代码即可搞定。可以认为是将集中的枚举映射分散到了路由框架中。

其实我完全可以把上面的伪代码封装成一个路由中心,然后制定一系列入参传参规则来保证一致性,但是我也可以直接使用现有的框架来避免我重复造轮子,我只用了解框架的使用与传参规则就可以。 另外,良好的跳转逻辑,不仅需要移动端制定规则,还需要后端配合,完成数据下发的格式的对应。 可以想象一下再App中增加了一个路由中心,所有的跳转情况逻辑与跳转Action都由路由管理,然后再从路由中心发出去: image.png

TheRouter

我最近研究了一下相关框架,目前觉得TheRouter的功能和业务场景符合要求,因为它同时兼容OC,所以在某些语法上看起来很怪异。

其实所有的路由这种从前端借鉴过来的舶来物,总需要这样个过程:

  • 注册路由

  • 保证注册之后再使用路由

  • 异常路由侧进去了定义好的错误页面

比如Flutter中使用GetX的路由,我们会这样:


abstract class Routes {

  Routes._();

  static const coinRink = '/coinRink';

  static const unknown = "/unknown";

  ///页面合集

  static final routePage = [

    GetPage(
      name: coinRink,
      page: () => const CoinRankPage(),
      binding: CoinRankBinding(),
      middlewares: [LoginMiddleware()],
    ),

   GetPage(
      name: unknown,
      page: () => const UnknownPage(),
    ),
  ];

  static final unknownPage = GetPage(
    name: Routes.unknown,
    page: () => const UnknownPage(),
  );
}

TheRouter对比这种思路,手动注册之外,有一个我觉得很有特色功能就是通过runtime遍历进行路由的自动注册,减少了手动注册的不舒适度。


  let beginRegisterTime = CFAbsoluteTimeGetCurrent()

  var resultXLClass = [AnyClass]()

  let bundles = CFBundleGetAllBundles() as? [CFBundle]

  for bundle in bundles ?? [] {
      let identifier = CFBundleGetIdentifier(bundle);
      if let id = identifier as? String {
          if excludeCocoapods {
              if  id.hasPrefix(kSAppleSuffix) || id.hasPrefix(kSCocoaPodsSuffix) {
                  continue
              }
          } else {
              if  id.hasPrefix(kSAppleSuffix) {
                  continue
              }
          }
      }

      guard let execURL = CFBundleCopyExecutableURL(bundle) as NSURL? else { continue }
      let imageURL = execURL.fileSystemRepresentation
      let classCount = UnsafeMutablePointer<UInt32>.allocate(capacity: MemoryLayout<UInt32>.stride)
      guard let classNames = objc_copyClassNamesForImage(imageURL, classCount) else {
          continue
      }

      for idx in 0..<classCount.pointee {
          let currentClassName = String(cString: classNames[Int(idx)])
          guard let currentClass = NSClassFromString(currentClassName) else {
              continue
          }

          if class_getInstanceMethod(currentClass, NSSelectorFromString("methodSignatureForSelector:")) != nil,
             class_getInstanceMethod(currentClass, NSSelectorFromString("doesNotRecognizeSelector:")) != nil {

              if let cls =  currentClass as? UIViewController.Type {
                  resultXLClass.append(cls)
              }
          }
#if DEBUG
          if let clss = currentClass as? CustomRouterInfo.Type {
              apiArray.append(clss.patternString)
              classMapArray.append(clss.routerClass)
          }
#endif
      }
  }


  for i in 0 ..< resultXLClass.count {
      let currentClass: AnyClass = resultXLClass[i]
      if let cls = currentClass as? TheRouterable.Type {
          let fullName: String = NSStringFromClass(currentClass.self)
          if fullName.contains(kSADelegateClassSensorsSuffix)  {
              break
          }

          for s in 0 ..< cls.patternString.count {

              if fullName.contains(NSKVONotifyingPrefix) {
                  let range = fullName.index(fullName.startIndex, offsetBy: NSKVONotifyingPrefix.count)..<fullName.endIndex
                  let subString = fullName[range]
                  registerRouterList.append([TheRouterPath: cls.patternString[s], TheRouterClassName: "\(subString)", TheRouterPriority: "\(cls.priority)"])
              } else {
                  registerRouterList.append([TheRouterPath: cls.patternString[s], TheRouterClassName: fullName, TheRouterPriority: "\(cls.priority)"])
              }
          }

      } else if currentClass.self.conforms(to: TheRouterableProxy.self) {
          let fullName: String = NSStringFromClass(currentClass.self)
          if fullName.contains(kSADelegateClassSensorsSuffix)  {
              break
          }

          for s in 0 ..< currentClass.patternString().count {
              if fullName.contains(NSKVONotifyingPrefix) {
                  let range = fullName.index(fullName.startIndex, offsetBy: NSKVONotifyingPrefix.count)..<fullName.endIndex
                  let subString = fullName[range]
                  registerRouterList.append([TheRouterPath: currentClass.patternString()[s], TheRouterClassName: "\(subString)", TheRouterPriority: "\(String(describing: currentClass.priority()))"])
              } else {
                  registerRouterList.append([TheRouterPath: currentClass.patternString()[s], TheRouterClassName: fullName, TheRouterPriority: "\(String(describing: currentClass.priority()))"])
              }
          }
      }
  }
  let endRegisterTime = CFAbsoluteTimeGetCurrent()

另外需要注意,在最新的Xcode16下面,Debug模式下面自动注册runtime不起作用,需要修改一下工程配置:

Xcode16 下 Debug 模式 ENABLE_DEBUG_DYLIB 选项默认开启,开启之后 objc_copyClassNamesForImage 主工程 image 调用失败,Debug 模式下会使用 **.debug.dylib文件,所以会有点问题。可以先将ENABLE_DEBUG_DYLIB 关闭

image.png

github.com/HuolalaTech…

同时TheRouter会有一个强制校验过程,也就是必须在工程中手动维护一张路由表,来保证自动注册的路由表和手动注册的路由表一致,这种措施是为了保证在Debug环境下的一致性,当然如果工程不那么复杂,这个功能不用也罢:


/// - Parameters:
///   - excludeCocoapods: 排除一些非业务注册类,这里一般会将 "com.apple", "org.cocoapods" 进行过滤,但是如果组件化形式的,创建的BundleIdentifier也是
///   org.cocoapods,这里需要手动改下,否则组件内的类将不会被获取。
///   - urlPath: 将要打开的路由path
///   - userInfo: 路由传递的参数
///   - forceCheckEnable: 是否支持强制校验,强制校验要求Api声明与对应的类必须实现TheRouterAble协议
///   - forceCheckEnable 强制打开TheRouterApi定义的便捷类与实现TheRouterAble协议类是否相同,打开的话,debug环境会自动检测,避免线上出问题,建议打开
return TheRouterManager.addGloableRouter(true, url, userInfo, forceCheckEnable: false)

TheRouter

如果项目是多模块组成,传统的push与pop可能需要模块对外暴露Controller,以保证可以构建控制器与页面跳转。使用路由框架可以抹掉这些细节与传参、构造器方法,对外暴露跳转路径即可。

同时我也在思考,如果一个Flutter项目也是多模块的情况下,主工程无法知道子模块的暴露的Page,是如何维护路由表的呢?

截止我发文的时候,掘金的货拉拉又发了一篇文章,《iOS货运用户App组件路由器设计与实践》,不过目前被删除了,也不知道是个啥情况,反正就是说他们还有一套与TheRouter的不同的路由框架,嗯,好吧~

结论

  1. 路由框架并不是iOS开发的必备工具。如果外部跳转到App场景少,App内部跳转简单,单一工程,或者多模块但模块间跳转场景少、不复杂,可以不用。

  2. 路由带来方便的同时,可能会导致页面切换转场动画的固定化,因为路由的目的是打开页面,而页面相关的动画等,如果放在路由框架中,显然又不太合适,所以当存在路由框架时,在需要使用转场动画时,可能无法尽善尽美。

  3. 如果外部跳转和App内部跳转复杂,可以考虑使用路由框架,以减轻维护逻辑的编写。同时如果考虑双端一致性,甚至可以一次配置,双端可行。

❌