普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月23日iOS

Ioser 铭(iOS开发2015-2025)

作者 戴铭
2026年2月23日 11:18

春节期间我在整理笔记本的一些资料,感叹这些年真是一转眼就溜过去了。如果不记录点什么,就会感觉有点空。于是一口气整理了下这十年来的一些零散记录,主要是些看过、学过和研究过的东西,就是记录些经历。如果有些类似的体验,希望能有些共鸣。想看看擦肩过啥,也可以读读。

关于标题的 Ioser 实际是谐形梗,是一个 iOS 开发者自嘲的老梗了。铭既有铭文体的意思也有代表我(戴铭)的意思,一语双关。我现在在京东端基础技术组,有兴趣的,对端技术热爱的朋友可以联系我,目前业务、架构、性能和跨端动态化都有需要的。

小学的时候,父亲从学校借回一台 Apple II。那时候我对它没什么兴趣,只觉得这个米色的大盒子挺占地方。谁能想到,二十多年后我会因为乔布斯说的那段

成长过程中,总有人会告诉你,这个世界是不会改变的,而你呢,只要学会去适应这个世界,尽量少把自己撞的头破血流。但生活完全可以更广阔的,只要你认清一个简单的事实,那就是,你身边被称之为生活的这一切,都是由人创造的,但这些人并不比你更聪明。而你可以改变这一切,影响这一切,甚至亲手创造出能造福他人的东西。

而走上 iOS 开发这条路。

这些年开发中我发现苹果技术在性能方面有很多很棒的优化手段。例如,在 64 位架构下,指针其实用不了 64 位(通常只用 30-40 位),Apple 将剩下的位用来直接存储数据,比如一些小对象如 NSNumber @42 不在堆上分配内存,直接把数据编码在指针值里。iOS 14 将 ARM64 的 Tag 位从高位移到低位,利用 ARM64 的 Top Byte Ignore (TBI) 特性。使用 ldp 指令一次加载两个寄存器,减少内存访问次数,使用 br 而非 blr,直接跳转而非函数调用,保留所有参数寄存器。dyld3 将符号解析、依赖分析等工作从运行时移到编译时。iOS 14 Runtime 内存重构,只有 约 10% 的类会在运行时动态修改(Category、Method Swizzling 等),90% 的类只需要精简版的 class_rw_t,扩展数据 class_rw_ext_t 按需分配。iOS 16 时为了用户体验不惜修改 c 的 ABI,使用自定义的 call convention 根据指针位置,适时使用正确变量而不用移动它,从而摆脱了调用里的多余 retain/release 函数调用。在内存紧张时通过高效的 WKDM 算法压缩,而不是使用磁盘,这个做法也是相当聪明了。等等吧,实在很多,这些不拘一格,敢于突破的优化手段和思路给我了很大的启发。

学习 iOS 开发以来我认识的朋友比以前多上好多倍。特别是近两年,除了欧美日本外,在首尔还认识了一些韩国朋友,聊聊 App 开发和 kpop 等我感兴趣的话题。

下面就让我们一起拉开这段历史的帷幕吧。

站在巨人肩膀上

这十年正是中国移动互联网的黄金时代。那时候孙源(sunnyxx)在微博和博客分享底层技术,郭耀源(ibireme)YYKit横空出世,唐巧的《iOS开发进阶》成为必读书目,喵神王巍(onevcat)的OneV’s Den的博客是每周必看,Casa Taloyum(casatwy)的组件化架构方案影响深远,Bang的 JSPatch 改变了热修复格局,雷纯锋分享的 MVVM 和 RAC 实践,limboy的技术思考,小虾等前辈在技术会议上分享实践经验。他们的文章和开源项目,影响了一代 iOS 开发者。

如今巧哥、喵神、Bang 和 limboy 还在输出着,虽然内容和 iOS 不相关了,但也是与时俱进的跨入了新时代,新时代也出现了很多 iOS 技术极客,比如 SwiftUI 和 Core Data 专家东坡肘子、weak self 主理人13pofat和硬核独立开发者Lakr233

当然还有国际上的 NSHipster 创始人 Mattt、objc.io 创始人 Chris Eidhof、Swift 社区重要贡献者 John Sundell 和 Hacking with Swift 教程创始人 Paul Hudson

这些前辈和同行,用他们的代码、文章和开源项目,照亮了无数 iOS 开发者的成长之路。

这篇文章不仅记录技术成长,也记录这个行业的变迁——那些激动人心的 WWDC,那些改变行业的框架,以及我们一起经历的技术浪潮。接下来,正片开始:

2015年:移动互联网的黄金起点

2015 年,我反复刷了电影《夏洛特烦恼》,胡歌主演的《琅琊榜》成为现象级高分剧;《王者荣耀》在 11 月上线后迅速崛起。4 月 Apple Watch 开售,苹果终于把穿戴设备这块拼图补上。应用侧也很精彩:音乐流媒体 Apple Music 把“订阅听歌”带进更多人的日常;照片管理上 Google Photos 以“无限量(当时)+ 智能整理”迅速出圈;社交/内容消费里 Periscope 把直播推向大众;效率工具 Workflow 用“拖拽拼积木”的方式让普通人也能做自动化(后来成为系统的 Shortcuts/快捷指令);邮件客户端 Spark 以“智能收件箱”俘获了不少重度用户;国内健身应用 Keep 也在这一年走红。Mac 端设计工具方面,Sketch 逐渐成为 UI 设计师标配;而像 Paste 这样的剪贴板管理工具,也开始进入“装机必备”的清单。

技术大背景同样剧烈变动。ES6(ES2015) 标准正式发布,箭头函数、Promise、let/const 等现代特性让 JavaScript 进入“可工程化”阶段。Visual Studio Code 凭借“轻量 + 插件生态”迅速出圈,TensorFlow 开源则把深度学习框架带入更广泛的工程实践——后来 AI 基础设施有不少都能追溯到这波浪潮。中国还解除了长达 14 年的游戏主机禁令,PS4、Xbox One 终于可以光明正大地买了。

盘古团队 10 月发布面向 iOS 9 的越狱工具(代号“伏羲琴”)。

苹果官方层面,9 月 iPhone 6s 与 iOS 9 发布,带来 3D Touch;iPad 多任务(Split View / Slide Over)让适配复杂度陡增——尺寸、旋转、约束与交互策略都要重新梳理。WWDC 2015 的工程关键词是:App Thinning(应用瘦身)、Bitcode 以及 UIStackViewwatchOS 2 开放了更多原生能力,让 Apple Watch 从“通知屏幕”走向真正的应用平台——我也是在这一年买了第一块 Apple Watch。

WWDC15 里有几场 Session 很“提效率”。性能调优方面,《Profiling in Depth》(Session 412,slides)把 Time Profiler 热点定位流程讲得更体系化;并发与响应性方面,《Building Responsive and Efficient Apps with GCD》(Session 718,slides)把 QoS、优先级反转以及队列/RunLoop 边界讲透。图形与计算这条线更底层:iOS 9 引入 Metal Performance Shaders(MPS),把卷积、模糊、直方图等常用图像算法下沉到 GPU(《What’s New in Metal, Part 2》Session 607,slides)。

工具链方面,Xcode 7 引入 UI Testing 并支持 Swift 2.0;更底层的改进是把 Address Sanitizer 带进日常调试,让内存越界、Use-After-Free 等问题能在开发期更早暴露。Swift 2.0 带来的错误处理(do/try/catch)与协议扩展(protocol extensions),让我第一次明确感受到“语言特性正在推动工程化”。12 月 3 日,Apple 正式开源 Swift 并发布 Swift.org Blog 首篇文章,Swift 项目从此进入社区协作节奏。其实我入门 iOS 用的是 Swift 1.x,甚至用它写过一个完整 App;但进入业务开发后,因团队技术栈与交付节奏,我又回到了更“有年头”的 Objective-C。

对我个人而言,2015 是第一次把所学 iOS 技术真正用于业务的一年。美团和大众点评在 10 月合并,滴滴快的在 2 月合并,我在这一年加入滴滴做顺风车业务。刚入职时还是新手,为补齐基础,我重刷了斯坦福 CS107 公开课,周末带娃去游乐场时也在手机上看视频。那时顺风车正处于高速增长期,需求排得满满的,但我还是抽时间把 Auto Layout 和 Masonry 吃透了——毕竟适配工作量实在太大。

围绕 Auto Layout,我做了一份内部分享,后来整理成《深入剖析 Auto Layout 的幻灯片》放到博客上,算是对那段“被约束支配”日子的总结(关键词:Auto Layout/Cassowary(约束求解)、约束优先级、intrinsicContentSize、Content Hugging / Compression Resistance、layout pass(updateConstraints/layoutSubviews)、systemLayoutSizeFittingSize)。为了更深入理解自动布局,我围绕“给谁做约束、如何设置约束、设置完后如何处理”三个问题,对比了 SnapKit(Swift)与 Masonry(Objective-C)两套 DSL 的设计取舍,阅读了源码并整理成笔记:读 SnapKit 和 Masonry 自动布局框架源码(关键词:DSL/Builder、ConstraintMaker(SnapKit)/MASConstraintMaker(Masonry)、NSLayoutConstraint.activateConstraints、更新/重建约束策略、可读性与性能取舍)。

再看当年的工程生态。大厂“标配技术栈”大致如下:网络层 AFNetworking 仍是首选(基于 NSURLSession 封装,支持请求/响应序列化、Reachability 网络状态监测、SSL Pinning 证书校验);Auto Layout 逐渐普及,Masonry / SnapKit 成为写约束的主流(链式 DSL 让约束代码更可读,底层封装 NSLayoutConstraint);数据存储常见 FMDB(轻量 SQLite 封装,FMDatabaseQueue 保证线程安全,支持事务与批量操作)或 Realm(零拷贝设计、对象-数据库直接映射、跨平台支持、实时数据同步);图片加载几乎离不开 SDWebImage(异步下载、内存/磁盘双缓存、渐进式解码、图片处理 pipeline)。ReactiveCocoa 把函数响应式编程(FRP)带入 iOS(Signal/SignalProducer、冷热信号、操作符组合),与 MVVM、路由、组件化等关键词一起高频出现,很多团队把“可测试性/解耦/模块边界”写进工程规范。依赖管理上 CocoaPods 仍是绝对主流,Carthage 也开始被认真讨论(去中心化设计、直接构建 framework、Cartfile 版本锁定);构建、签名、发布自动化方面,fastlane 在不少团队里开始萌芽(gym 打包、match 证书同步、deliver 上架、pilot TestFlight 分发)。

开源社区这年非常活跃。UIStackView 仅支持 iOS 9+,老项目没法直接用;当时社区里很实用的“兼容层”之一是 FDStackView,把类似的布局能力带到更低版本(API 与原生 UIStackView 几乎一致,支持 iOS 6+,通过 Auto Layout 约束实现 axis/distribution/alignment/spacing 等核心属性)。除此之外,FDFullscreenPopGesture 解决了”全屏跟手返回”(一行代码集成,通过 method swizzling 扩展 UINavigationController,支持自定义手势识别区域与交互式 pop 动画),UITableView-FDTemplateLayoutCell 则把“动态算高”这件事变得简单可控(内部维护模板 Cell,通过 systemLayoutSizeFittingSize 自动计算并基于 IndexPath 缓存高度,hook 刷新 API 自动失效)——是那种一用就回不去的痛点级工具。同一时期,Facebook Paper 团队开源的 AsyncDisplayKit(介绍文:Introducing AsyncDisplayKit for smooth and responsive apps on iOS)把异步布局/异步渲染做成框架能力:引入 Node 线程安全抽象层(对应 UIView/CALayer),将图片解码、布局计算(绕开 Auto Layout 的 Cassowary 算法开销)、文本渲染等耗时操作移到后台线程并行执行,主线程只做轻量 setFrame,成为当时讨论“列表流畅度”的代表性方案。那几年,微博/博客上也有大量高质量技术输出,比如 sunnyxx 关于 Runtime、Block 内部实现、Category 原理的文章就非常深入浅出,是当时学习底层技术的必读材料。

同一时期,唐巧的《iOS 开发进阶》出版,几乎成了圈内必读(关键词:Instruments/Time Profiler、RunLoop/GCD、内存管理、性能与工程实践)。ibireme 的 YYKit 系列也在那几年逐步成型,后来成为性能优化与工程实践的标杆(关键词:YYModel、YYCache、YYImage、YYText、异步绘制、图片解码与缓存)。SwiftGG 翻译组也在这一阶段逐渐形成,通过系统翻译 Swift 官方文档,显著降低了国内开发者学习 Swift 的门槛(关键词:Swift Language Guide、Standard Library、do/try/catchguard/defer、protocol extensions)。国际上,NSHipster 持续更新 Swift 2.0 新特性解读,围绕 Protocol Extensions、Error Handling 等话题给了很多“能直接用”的实践建议(关键词:Foundation、NSOperation/GCD、NSURLSession、KVC/KVO、Runtime 小技巧)。objc.io 的文章依旧高密度高质量,涵盖函数式编程、Core Data 等主题(关键词:Functional Programming、Value Semantics、Core Data 并发(MOC/PSC)、架构/测试)。Ray Wenderlich(现 Kodeco) 团队也发布了《iOS 9 by Tutorials》,项目驱动的学习方式很受欢迎(关键词:UIStackView、UI Testing/XCTest、3D Touch、iPad 多任务(Split View/Slide Over)、App Thinning/Bitcode)。中文圈里,InfoQ 中文站的徐川总结了 WWDC 2015 十大看点,喵神王巍也发表了 《WWDC15 召开,细数新 SDK 带来的全新变化》,帮助大家快速抓住 iOS 9 与 Swift 2.0 的核心变化(关键词:iOS 9 SDK、UIStackView、3D Touch、iPad 多任务、do/try/catch、protocol extensions)。

如果说“工具与框架”解决的是当下效率,那些“原理与架构文章”则在塑造接下来几年的共同语言。ibireme 的 深入理解 RunLoop 从 CFRunLoop 源码出发,把 Mode/Source0/Source1/Observer/Timer 五大核心类、mach_msg() 休眠唤醒机制、线程与 RunLoop 一对一关系,以及事件分发、AutoreleasePool、界面刷新这些机制串在一起;后来很多“卡顿监控/常驻线程/异步任务调度”的方案都会引用它。还有唐巧的 被误解的 MVC 和被神化的 MVVM:把架构讨论从“站队/玄学”拉回到职责边界、可测试性与可维护性——指出 Controller 臃肿的根源是复用性问题,建议通过抽取网络请求层/ViewModel层/Service层/Storage层改进 MVC,同时澄清用 ReactiveCocoa 不等于 MVVM 的误区,既给当年的 MVVM 热降了温,也推动了更理性的工程化讨论。Casa 的 iOS 应用架构谈:组件化方案:在“业务爆炸/多人协作”的背景下,系统对比了 URL 注册方案与 Target-Action/Mediator 中间件方案,提出通过 OC Runtime 动态调用实现无需注册的服务发现,把组件拆分、路由、依赖治理等问题放到同一张图里讲清楚,影响了后来很多大厂的工程组织方式。bang 的 JSPatch - 动态更新 iOS APP:基于 JavaScriptCore 与 OC Runtime 反射能力,通过 class_replaceMethod 实现方法替换,用正则转换 JS 调用为消息桥接,把“线上热修复”从概念变成可落地的工程实践,显著拓宽了团队对“动态化/发布效率”的想象边界。

同年 3 月,React Native 也通过官方博客 React Native: Bringing modern web techniques to mobile 正式亮相,成为 2015 年跨端/动态化讨论的另一个引爆点,把“热更新/工程效率/多端复用”拉进了移动端主流视野(关键词:JavaScriptCore、Bridge、Native Modules、JS bundle、Flexbox 布局;国内后来也迅速跟进)。

回看 2015,这年更像是把“基础补齐并能稳定交付”:适配、交付、工程化萌芽一起发生;很多后来会变成“标配”的理念与工具,也是在这一年开始进入视野。当业务规模和协作复杂度继续上升时,光靠堆需求一定会失控——你必须开始做“拆职责、治依赖、控边界”的系统性工作,而这也正是 2016 会加速的方向。

2016年:组件化与技术深耕

回到 2016,随着顺风车业务增长与协作规模扩大,“把需求做出来”不再是唯一目标,更重要的是把工程体系搭稳:边界要清晰、依赖要可控、性能要可量化。

我这一年玩得最多的游戏是皇室战争(Clash Royale),算是高压项目之余的碎片化放松。

2016 年的大时代背景是英国脱欧、美国大选、里约奥运。科技圈同样风起云涌:AlphaGo 击败李世石让 AI 破圈,Pokémon GO 把 LBS 与 AR 游戏做成全球现象级爆款。移动支付继续渗透,网约车新政落地,Apple Pay 也在中国上线

技术趋势层面,前端框架 Vue 2 / Angular 2 发布,TypeScript 2.0 让类型安全成为主流选择;Kotlin 1.0 正式发布,为 Android 开发埋下伏笔。
与此同时,Serverless 架构开始被讨论——“工程化、平台化、跨端化”逐渐成为长期命题。跨端方面,React Native 在国内快速扩散,阿里开源的 Weex 也频繁出现在技术分享中。

2016 年是我在滴滴技术成长最快的一年。如果说 2015 更像“把基础补齐并能稳定交付”,那 2016 则是在规模化协作里把工程体系搭起来:滴滴顺风车业务高速发展,订单量翻了好几倍,我也从写业务代码逐步参与到架构讨论中。

这一年我深入学习了 Runtime 的消息转发机制,研究了 RunLoop 在卡顿监控中的应用,也第一次真正理解了“组件化不是拆代码,而是拆职责”这句话。而苹果生态的几次大版本更新,也成为这一年技术讨论的另一条主线。

硬件方面,9 月 iPhone 7 发布,取消 3.5mm 耳机孔;12 月 AirPods 正式开售,“真无线”从此成为主流体验。新款 MacBook Pro 引入触控条(Touch Bar),开发者一度期待很高,但市场反响平平。

WWDC 与 iOS 10。WWDC 2016 带来了 iOS 10,系统开放程度大幅提升:通知体系重构,UserNotifications 成为统一入口;SiriKit 首次向第三方开放;CallKit 让 VoIP 应用能以原生来电界面呈现;Speech 框架提供语音识别能力;iMessage 也支持了扩展(Messages)。性能与调试层面,UITableView/UICollectionView 引入数据预取(UITableViewDataSourcePrefetching),可在 cell 上屏前预加载数据;网络侧新增 URLSessionTaskMetrics,DNS/TCP/TLS/请求响应各阶段耗时终于有了结构化数据。

Swift 3.0 与 Xcode 8。9 月 13 日 Swift 3.0(swift-3.0-RELEASE)正式发布,带来全新的 API 命名规范与大规模重命名(如去除 NS 前缀),几乎所有 Swift 项目都需要迁移。Xcode 8 随之发布,提供迁移工具与新的签名流程。虽然升级成本不低,但 Swift 的采用热情依然高涨。Xcode 8 还把内存排查前移到日常调试:Memory Graph Debugger 能一键抓取对象引用图,配合 Runtime Issues Navigator 直接定位 retain cycle 与泄漏点,详见 Visual Debugging with Xcode(WWDC16 410)

配合 iOS 10 / Swift 3 的更新,大厂的“标配技术栈”在 2015 年基础上继续演进:网络层 AFNetworking 仍是主力,Alamofire(Swift 原生网络库,链式请求构建、请求/响应拦截器、参数编码、Combine/async-await 支持)在 Swift 项目中逐渐普及;图片加载 SDWebImage 依然坚挺,Kingfisher(Swift 图片下载与缓存库,Processor 图片处理、Prefetcher 预取、SwiftUI 原生支持)开始被 Swift 项目采用;数据存储 FMDB/Realm 继续使用,Core Data 因 iOS 10 的改进重新进入讨论;异步编程 PromiseKit(链式异步、then/catch/finally、bridge 回调转 Promise)与 RxSwift(Observable 序列、Operator 操作符、Scheduler 调度器、MVVM 绑定)把 Promise/FRP 推到风口。

列表性能优化成为这一年的热词。AsyncDisplayKit(后更名为 Texture)与 IGListKit(数据驱动的 UICollectionView 框架,ListDiffable 协议、O(n) 的 diff 算法、SectionController 架构、增量更新)把”异步渲染 + diff 更新”从大厂内部实践带到了可复用的开源形态。Facebook 开源了 iOS 内存检测工具集 Automatic memory leak detection on iOS(含 FBRetainCycleDetector,通过运行时遍历对象引用图、检测强引用环),把 retain cycle 检测做成可直接集成的库;LinkedIn 开源 LayoutKit(声明式布局 API、后台线程异步计算、布局结果缓存),用异步计算与缓存将布局从 Auto Layout 性能开销中解耦(Open-Sourcing LayoutKit);年底 Facebook 又以 Yoga 的形式开源跨平台 Flexbox 布局引擎(C 实现、高性能布局计算、支持 iOS/Android/Web、flexDirection/justifyContent/alignItems 等属性),至今仍是 React Native 的布局底座(Yoga: A cross-platform layout engine)。这些基础设施的演进,让“组件化的工程落地”更顺滑了。

2016 年是组件化真正“从概念走向标配”的一年。6 月极客邦在北京举办了首届 GMTC 全球移动技术大会,滴滴的李贤辉在会上分享了大规模团队协作与组件化建设。5 月 CocoaPods 1.0 发布,依赖管理工具趋于成熟,“依赖可控”终于成为可落地的工程实践。

组件化架构讨论在这一年达到高潮。limboy 基于蘑菇街实践发表了 蘑菇街 App 的组件化之路,系统阐述了 URL 路由式组件化(MGJRouter)的收益与落地经验:通过 openURL: 实现页面跳转解耦,通过「协议-类绑定」(Protocol <-> Class)解决组件间同步数据调用,借助 ModuleManager 统一管理组件生命周期与 AppDelegate 回调,并配套壳工程、CocoaPods 私有库与 CI 持续集成来保障多团队协作效率,后来成为许多团队的参考范本。与此同时,Casa 在落地层面给出了 Target-Action / 中间件(CTMediator)的另一条路径。两种思路的碰撞,让组件化讨论从“要不要拆”走向“怎么拆、怎么治依赖、怎么控边界”。

在组件化成势的同时,热修复也持续火热,JSPatch 这类动态化方案把 JavaScriptCore 与 Objective-C Runtime 拼到一起:通过消息转发与方法替换(method swizzling / class_replaceMethod)把线上崩溃与逻辑 bug 的修复下沉为脚本发布,配合灰度与开关把迭代节奏从“等发版”变成“分钟级止血”;但也因此更容易踩到签名不匹配、类型桥接、线程与生命周期边界等坑,风险与合规压力在暗处累积。

国内社区产出了多篇高质量技术文章。YY 大神的 不再安全的 OSSpinLock,从一个真实 bug 出发,系统讲解了自旋锁在 iOS 新版 QoS 线程调度下的优先级反转(Priority Inversion)问题——高优先级线程忙等导致低优先级线程无法释放锁,并对比了 dispatch_semaphorepthread_mutexos_unfair_lock 等替代方案的性能基准,是当年传播最广的 iOS 底层文章之一。巧哥的 猿题库从 Objective-C 到 Swift 的迁移,分享了 15 万行 OC 代码渐进迁移的真实经验:新功能用 Swift、旧功能按需重写,重点探讨了泛型编程与面向协议编程(POP)如何替代 ObjC Runtime 的动态能力,以及混编接口设计的注意事项,适合“准备迁移但担心风险”的团队参考。喵神的 Swift 性能探索和优化分析,从测量方法(CACurrentMediaTime/mach_absolute_time()、Time Profiler、XCTest measureBlock)到优化路径(GCD 多线程、NSCache 缓存、Accelerate.framework 并发计算、-whole-module-optimization 编译优化、避免 Swift/NSObject 隐式转换开销、@autoclosure 延迟求值),把 Swift 早期性能问题与“先定位再优化”的方法论讲得清晰实用。

国际社区同样活跃:NSHipster 围绕 Swift 3 的 API Design Guidelines 讨论了不少“写给调用方看”的细节——参数标签/默认参数如何让调用点读起来像英文、值语义与可变性(mutating)的约定、以及 Swift 与 Foundation(URL/Data/Date 等)的桥接在迁移期最常见的坑;objc.io 推出了 Swift Talk 视频系列,用小案例拆解泛型、protocol extensions、不可变数据与函数式组合(map/filter/reduce),把 Swift 的抽象能力与可测试写法讲得很“能抄”;Ray Wenderlich 团队的《iOS 10 by Tutorials》则以项目驱动串起 UserNotifications、SiriKit/CallKit、iMessage App Extension 以及 Xcode 8 调试工作流,适合在业务里按需对照落地。

2016 年的应用生态同样精彩。前文提到的 Pokémon GO 让“AR + 地图”首次真正破圈;AI 滤镜应用 Prisma 把神经风格迁移带入大众视野;国内短视频赛道开始升温,抖音 在这一年上线;知识付费领域 得到 让“订阅专栏/音频课”成为新的内容形态。

Mac 端上,Readdle 把 Spark 带到 macOS,把邮箱做成“可管理的任务流”:Smart Inbox/规则分流、Snooze、Send Later、模板与团队协作(Shared Inbox)让邮件更像待办而不是噪音;Setapp 以订阅制挑战传统买断模式,也在某种程度上推动了“工具链按需组合”的使用习惯;Docker Desktop for Mac 则让容器化开发更顺手:基于 HyperKit/Hypervisor.framework 的轻量虚拟化跑 Docker Engine,配合 docker-compose 一键拉起 Redis/MySQL/ELK 等依赖,让“本地=线上”的环境一致性更易维护;写作工具方面,Typora 的 Live Preview 把 Markdown 的语法与排版合到一个视图里,配合导出(HTML/PDF)让“写-发”链路更顺滑。

2017年:热修落幕与 AR/ML,底座升级

2017 年我花时间最多刷的剧是 《三生三世十里桃花》,后来入职阿里时的花名“夜桦”也来自“夜华”的谐音。

17年共享单车 成了街头风景,摩拜ofo 的颜色大战让城市多了几分戏剧性——我们在滴滴大厦楼下吃完饭还会围观摩拜单车,讨论它怎么联网、怎么供电。

说回 iOS 开发本身,2017 年最直接的政策冲击是动态热修被全面收紧:App Store Review Guidelines 明确禁止下载、安装或执行会改变 App 行为的代码(常被引用的是 2.5.2 条款)。JSPatch、Rollout 等方案逐渐降温,热修复的黄金时代基本落幕。

工程侧的应对也随之“前移”。这一变化直接推动了组件化、模块化与发布流程治理——既然线上“修不了”,就把质量关口前移。CI/CD、灰度发布、功能开关逐渐变成大型 App 的标配。

WWDC 2017 与 iOS 11。WWDC 2017 堪称“未来技术预演”。台前,ARKitCore ML 同时亮相,加上 Vision 框架,苹果为移动端 AI 与 AR 铺好了路——ARKit 通过 Visual Inertial Odometry(VIO)融合相机与 CoreMotion 数据实现世界追踪(world tracking)、平面检测(plane detection)与环境光估算(lighting estimation),让虚拟物体能稳定”贴”在真实表面上;Core ML 把训练好的 .mlmodel 模型带到端侧推理,自动在 CPU/GPU/Neural Engine 间调度以平衡性能与功耗,配合 Xcode 自动生成 Swift/OC 包装类让集成只需两行代码;Vision 则用 VNImageRequestHandler 统一图片分析入口,内置人脸检测(VNDetectFaceRectanglesRequest)、条码识别、矩形检测与文字识别(OCR)等请求类型。当时我们还觉得 AR 是噱头,没想到几年后 Vision Pro 真的来了。系统层面,iOS 11 带来了全新的文件管理器、改进的 iPad 多任务处理、可自定义的控制中心,以及 Drag and Drop 交互。硬件方面,苹果发布了 HomePodiMac Pro,在智能家居和专业市场同时发力。

底座升级与工具链。台下同样有大动作。3 月 iOS 10.3 把默认文件系统切换到 APFS,Copy-on-write、快照、加密模型为存储底座全面升级。Xcode 9 新增的 Main Thread Checker 让“UIKit 在后台线程被调用”这类玄学 bug 变成可复现的 Runtime Issue(详见 WWDC17 Session 406)。图形层面,Metal 2 把更多工作交给 GPU;iOS 11 则全面拥抱 HEVC/HEIF,大幅降低媒体体积(相关 session:503 讲为什么切换到这套标准、511 讲 API 层面如何落地——HEVC 相机采集比 H.264 压缩率提升约 2 倍、默认采集已启用、513 则从容器结构到辅助图像(alpha/depth/Live Photo)逐一拆解)。这些底层变化看似无感,却决定了后续几年性能治理能走到哪里。

硬件拐点:iPhone X。9 月,iPhone X 发布,“刘海屏”与 Face ID 让全行业跟进,也让 iOS 开发者迎来一轮 Safe Area 适配的噩梦。我在第一时间入手了一台,Face ID 解锁确实方便不少。

同期 Swift 4.0 发布,带来了更强大的字符串处理(String 重新成为 Collection)、Codable 协议(JSON 序列化终于有了原生方案),以及改进的泛型和编译速度。ABI 稳定性路线继续推进,开源社区开始明显地往 Swift 迁移。

这一年社区涌现了大量 Swift 原生库:Hero(声明式转场动画,heroID 匹配、自定义动画曲线、交互式手势)、Moya(Alamofire 上层抽象,TargetType 协议、Plugin 拦截器、RxSwift/Combine 扩展)、R.swift(资源类型安全,编译时生成强类型引用、避免字符串硬编码、支持图片/字体/Storyboard/本地化)、SwiftLint(代码规范检查,基于 SourceKit、可配置规则集、Xcode 集成)、Lottie(After Effects 动画跑在移动端,JSON 动画描述、矢量渲染、动态修改属性)。SnapKit 成了 Swift Auto Layout 首选,RxSwift 响应式编程也更加普及。不过这些库在国内大厂落地并不快——Objective-C 依然是主流。

同年,Pinterest 宣布 AsyncDisplayKit 更名为 Texture,异步渲染从“经验”变成“可复用的框架”;腾讯开源 WCDB(高性能 SQLite ORM,WINQ 类型安全查询语言、全文搜索 FTS、数据库损坏修复、加密支持),把高并发下的 SQLite 工程化做成基础设施(报道:InfoQ);滴滴也在 GMTC 分享了 iOS 端瘦身实践。这些内容后来都成了大厂性能专项的“常见母题”。

跨端与外部技术的风向同样清晰。跨平台方面,1 月 微信小程序 正式上线,让“一次开发多端运行”的想法更加深入人心。React Native(JavaScript Bridge 架构、原生组件渲染、Flexbox 布局、热更新支持)和 Weex(阿里开源、Vue 语法、JS Engine + Native 渲染)成了各大公司争相接入的方案。桌面端则是 Electron 应用爆发式增长,VS Code、Slack、Discord 都基于它构建——虽然被吐槽“内存杀手”,但确实改变了桌面应用开发格局。

移动端之外,Google 宣布 Kotlin 成为 Android 官方语言,iOS 开发者隔岸观望,感叹 Swift 和 Kotlin 的相似度之高。深度学习框架 TensorFlow 1.0PyTorch 相继发布,两大框架的竞争就此拉开。前端圈 Vue.js 持续走红,9 月 React 16 引入 Fiber 架构,后续几年前端渲染话题几乎都绑定在它身上。

这一年 iOS 和 macOS 上涌现了不少优秀应用。Bear 凭借优雅的 Markdown 写作体验获得 Apple Design Award 2017Things 3 以全新设计亮相,把 GTD 做到极致优雅;Affinity PhotoAffinity Designer 以买断制挑战 Adobe。

效率工具方面,iStat Menus 6 让状态栏变成性能仪表盘,Bartender 3 解决菜单栏图标过多的烦恼,Magnet 几乎成了“装完 Mac 必装”的清单项。开发者工具中,Paw 是当时最漂亮的 API 调试工具,Dash 是查阅文档的必备神器。Notion 开始在硅谷圈子里流行,虽然国内用户还不多,但已有先行者在尝试这个“All-in-One”的新物种。

喵神王巍在 objccn.io 上推出《Swift 进阶》,把 Swift 高级特性讲得深入浅出,成为进阶必读。SwiftGG 持续输出高质量翻译。国际上,John Sundell 创办 Swift by Sundell 博客,以高产、高质迅速成为社区明星;Ray Wenderlich(现 Kodeco)发布《iOS 11 by Tutorials》;NSHipsterobjc.io 也持续更新 Swift 4、ARKit、Core ML 等主题的深度解析。

我自己在这一年写了三篇「深入剖析」系列,把注意力放到更底层的三块:

  • 深入剖析 iOS 编译 Clang / LLVM(3 月):从词法分析(Lexer)生成 Token、语法分析(Parser)构建 AST 抽象语法树,到 IR 中间代码与 LLVM Pass 优化,再到 Mach-O 可执行文件格式、dyld 动态链接;还覆盖了 Swift 的 SIL 中间语言、Bitcode 以及 Clang Static Analyzer 与 CFG 控制流图等静态分析机制——串起 iOS 编译工具链的全链路。
  • 深入剖析 iOS 性能优化(6 月):从集合操作的时间复杂度(O(1)/O(n)/O(log n))、GCD 的 QoS 服务质量与 dispatch_semaphore 并发控制,到 I/O 批量写入、NSCache 自动清理策略与线程爆炸防范——尝试把性能问题量化到方法调用链路与频次。
  • 深入剖析 WebKit(10 月):沿着 HTML Tokenize → DOM Tree、CSS 解析 → CSS Rule Tree/CSSOM → Render Object → Layout 的主链路,深入 WebCore 排版引擎与 JavaScriptCore 脚本引擎的协作机制,把 WebKit 架构拆开来看。

当年我反复翻的几篇资料也值得一提:

  • SwiftGG 的 Swift 4.0 Released!:发布说明的中文精读版:除了 Codable(面向 JSON/plist 的类型安全序列化)之外,还把 String/Substring 体系重做(多行字符串、Index overhaul、性能与 Unicode 正确性)、Dictionary/Set 增强,以及 Swift 3.2/4.0 兼容模式与 Swift Package Manager 新 API 等迁移要点串到一起。
  • 一篇文章 get 微信开源移动端数据库组件 WCDB 的一切!:系统梳理 WCDB 基于 SQLCipher 的加密机制、WINQ 语法抽象层与 ORM 映射、数据库损坏修复三板斧、全文搜索分词器以及内建反注入保护等核心能力。
  • Build More Intelligent Apps with Core ML:Apple 官方 news 的“入门引子”,强调用 Core ML 这套端侧机器学习基础能力把智能特性带进 App:人脸跟踪(face tracking)、文字检测(text detection)、语言识别(language identification)等,并直接引导继续阅读 Core ML 文档完成从模型到功能的落地。
  • Hacking with Swift 的 New App Store Review guidelines:把审核条款变化翻译成开发者 checklist——Face ID 必须通过 LocalAuthentication 而非 ARKit 实现、ARKit 面部数据禁止用于用户画像挖掘、AR 应用须提供完整沉浸体验等。
  • Michael Tsai 的 Swift 4.0 Released:引用式聚合:一边记录“迁移很顺滑,但 Xcode/文档/跳转定义等工具链仍粗糙”的一线反馈,一边把 Swift 4 的关键议题(Codable/Encoder、Key-Value Observation、错误处理实现、as 转型的 bridging peephole、Equatable/Hashable 自动合成、String 大改、Dictionary Keys/Values 集合等)集中串起来,适合按图索骥深挖。

回头看 2017,这一年是“热修落幕、AR/ML 起航、底座升级”的分水岭:苹果封堵了动态化的“野路子”,同时把 AR/ML 推向台前;而 APFS、Main Thread Checker、Metal 2 与 HEIF/HEVC 则在台下把性能与系统底座悄悄换了一遍。

对我个人而言,这也是在滴滴的最后一年:共享单车的疯狂、热修复的终结、AR 的萌芽、WannaCry 的惊恐,都成了记忆里无法抹去的标签。下一年 9 月我就会入职高德,成为阿里人,也和很多如雷贯耳的 iOS 前辈成为了同事。

2018年:内功修炼与性能

3 月 斯蒂芬·霍金 去世,10 月 金庸 先生辞世,“飞雪连天射白鹿,笑书神侠倚碧鸳”成了朋友圈的主旋律。

游戏 《旅行青蛙》 意外走红,佛系养蛙刷屏了一阵。这一年滴滴顺风车安全事件也让整个行业反思。

技术与开源同样热闹。前端圈 React 16.3 带来新的 Context API(createContext)与一批为 async rendering 铺路的生命周期替换(如 getDerivedStateFromProps/getSnapshotBeforeUpdate,配合 StrictMode 做迁移提醒),Vue CLI 3.0 则把脚手架做成基于 webpack 的插件化体系(preset/mode、多页面、vue ui 可视化配置),Prettier 通过“基于 AST 的再打印”把格式化变成确定性规则,配合 husky/lint-staged 很快成了团队协作的默认选项。GitHub 被微软以 75 亿美元收购,开源社区一片哗然,后来证明担心是多余的。

APM(应用性能监控)系统在各大厂普及,自动化测试、持续集成趋于成熟,组件二进制化被用来提升编译速度,性能优化开始成为团队级议题。

海外生态里,Google 在 I/O 2018 宣布 Firebase Performance Monitoring 从 beta 走向 GA,把启动/自定义 trace、网络请求等指标采集做成 SDK 能力;Facebook 则在同年开源跨 iOS/Android 的调试平台 Flipper(仓库:facebook/flipper),用插件体系把 Layout、Network、Logs、Performance markers 等可视化调试能力收口到一个桌面端里——性能问题的定位开始从“会用 Instruments 的少数人”向“团队共享的工具平台”迁移。各大厂技术博客也开始活跃起来——美团技术团队滴滴技术和字节跳动的文章内容系统且深入。跨端方向也迎来新节点:12 月 4 日 Flutter 1.0 正式发布(GA),以 Dart 语言、Skia 自绘渲染、Widget 声明式 UI、热重载等特性在国内引发了新一轮的技术讨论。

在苹果这一侧,2018 的主线也很明确:把“性能”写进系统升级的主题。9 月 iPhone XS / XS Max / XR 发布,XS Max 成为史上最大屏幕的 iPhone;同期发布的 Apple Watch Series 4 首次支持 ECG(心电图)功能和跌倒检测,健康监测进入新阶段。10 月的发布会也很有记忆点:新 iPad Pro 全面屏 + USB‑C + Apple Pencil 2 让 iPad 更像生产力设备;MacBook Air 换上 Retina + Touch ID;还有“多年不更新”的 Mac mini 终于回归,开发者们一边感动一边开始算预算。

WWDC 2018 的主题是“让老设备也能跑得飞快”:iOS 12 主打性能优化,iPhone 6 Plus 上 App 启动速度提升 40%,键盘响应速度提升 50%,相机打开速度提升 70%——苹果终于对老设备友好了一把。新特性方面也有几项“立刻能用”的系统能力:

  • ARKit 2:把多人共享 AR 体验真正落到“共享世界坐标”上(如 ARWorldMap/anchor 同步),并加入 3D Object Detection(Reference Objects)让现实物体能被识别并作为锚点。
  • Create ML:把端侧模型训练塞进 Xcode 工作流里,用 MLDataTable 等抽象支持表格数据/图像/文本分类等常见任务,一键导出为 Core ML 模型用于 App 侧推理。
  • Siri Shortcuts(快捷指令):通过 NSUserActivity/Intents 的 donation(INInteraction)让系统学会“用户常做的事”,再把它变成可被 Siri/Spotlight/Shortcuts 调用的入口——语音只是表象,本质是把关键动作做成可组合的 Intent。
  • Screen Time:以按 App/分类的使用统计为基础,配套 App Limits 与 Downtime 等“可执行的限制”,把数字健康从口号变成系统级的可用功能。

macOS Mojave 则带来了系统级 Dark Mode(深色模式),开发者们纷纷适配,那段时间打开 App Store 全是“支持深色模式”的更新日志。

在更偏“系统底层”的 API 上,WWDC 2018 还发布了 Introducing Network.framework: A modern alternative to Sockets(715):把连接(NWConnection)、监听(NWListener)、服务发现(NWBrowser)和网络路径监测(NWPathMonitor)统一到一套状态机里,很多团队后来用它替换 Reachability + Socket 的旧组合,把“网络切换/弱网/省流量”这类问题尽量往系统能力上靠。

语言与工具链也在同步打底。Swift 4.2 在 9 月 17 日正式发布,带来了条件遵循(Conditional Conformance)、统一的随机数 API、改进的 Hashable 协议,以及显著提升的编译速度;Xcode 10 引入全新的构建系统(New Build System),支持并行化编译,大项目的构建时间明显缩短。ABI 稳定性继续推进,为 Swift 5 的到来铺路。

国内团队的“标配技术栈”也进一步成熟:

  • 数据存储:WCDB(基于 SQLite/SQLCipher 的加密存储,配合 WINQ/ORM 抽象,并提供损坏修复等工程能力)开始被更多团队采用。
  • 研发效率工具:8 月 14 日滴滴在 GitHub 建仓并开源了 DoKit(DoraemonKit),一款面向移动端的研发效率工具,用“悬浮窗入口 + 插件化面板”把数据 Mock、网络/性能监控、UI 检查等能力收口到一个工具箱里,后来成为滴滴首个 GitHub 破万 Stars 的开源项目。

性能治理的核心命题是:如何把“救火式优化”升级成可复用、可度量、可协作的工程能力?苹果在 WWDC 2018 通过 Measuring Performance Using LoggingPractical Approaches to Great App Performance 把方法论讲成流程:先统一日志,再用 signpost 对关键路径做埋点,最后让问题能被 Instruments 的时间线直接“读出来”。中文圈里也很快出现了对这套体系的落地实践,比如 os_signpost API Introduction 详细演示了 os_log_create(用反向 DNS 格式创建 Category)、os_signpost_id_generate(生成唯一 ID 匹配 begin/end)以及 os_signpost_interval_begin/end(标记耗时区间)这套完整流程,把 os_signpost 从”看过视频”推进到”能在 Instruments 的 Points of Interest 里可视化调试”;而像 《深入浅出-iOS程序性能优化(转载)》 这种按 CPU/内存/渲染/启动拆维度的文章,则更像团队治理时随手可用的“检查表”。

当团队把关键路径用 signpost 统一标记后,下一步就是把“方法”做成“工具”:

当“量化”成为共识后,讨论自然会收敛到最影响体验的那几条链路上:启动、链接与渲染。

  • 启动治理:比如美团的 《美团外卖 iOS App 冷启动治理》 将冷启动拆为 T1(main 前的 dyld 加载与链接)、T2(main 到 didFinishLaunching)、T3(首页渲染完成)三阶段,通过 Kylin 组件实现启动项自注册(利用 Clang 的 __attribute__((section())) 将函数指针写入 __DATA 段,运行时按阶段触发),并结合 Time Profiler 与火焰图可视化定位”平顶山”式瓶颈,同时采用闪屏页并行首页构建、缓存定位 + 首页预请求等策略把收益落到数据上。
  • 链接与交付:Static linking vs dyld3 从 dyld3/链接策略切入,实测将 26 个动态库改为静态链接后 iPhone 5c 启动时间缩短约 2 秒,并通过 MACH_O_TYPE=staticlib 构建设置与 framework 转 resource bundle 的两步方案渐进落地,把”静态链接到底能省多少启动时间、边界在哪里”讲清楚。更底层一点看,dyld3 代表苹果把大量动态链接工作尽量前移到安装/更新阶段(最初主要用于系统 App):用 launch closure 把 Mach-O 解析、依赖解析与绑定信息预计算出来,让启动阶段更多做校验与映射(dyld 开源代码:apple-oss-distributions/dyld;概念来源:WWDC 2017 App Startup Time: Past, Present, and Future(413))。与此同时,围绕“如何交付与复用”的工程现实也被拿到台面上讨论:在 Swift ABI 尚未稳定的时代,Binary Frameworks in Swift 直面二进制分发的痛点与取舍——这也与后来业内热议的二进制化/构建速度治理形成了前奏。
  • 渲染机制:渲染这条线同样在补课中变得体系化,比如 《深入理解 iOS Rendering Process》 从 OpenGL ES/Metal 渲染管线角度剖析 UIKit/CoreAnimation 的离屏渲染触发条件(如 cornerRadius + masksToBounds、shadow、group opacity)与 GPU 瓶颈成因,帮助把”为什么卡”从经验判断升级成对 Core Animation 提交事务、图层树遍历与 GPU Tile-Based Rendering 机制的理解。

性能一旦成为主线,很多原本“看起来像基础设施”的能力也会被重新定义:日志不再是简单的 print,而要高性能、可加密、可可靠上传,于是有了美团对 Logan 的系统化总结与开源历程(《美团移动端基础日志库——Logan》 / 《Logan:美团开源移动端基础日志库》);存储要更快、更稳,就有了 MMKV 的工程化实现思路(《微信自用高性能通用key-value组件MMKV已开源!》),用 mmap 内存映射避免传统文件 I/O 的系统调用开销,结合 protobuf 二进制序列化压缩存储体积,并通过 CRC 校验 + 增量写入实现写入失败后的数据恢复,把”系统能力榨性能”变成可复用的组件化方案。

上述这些实践——无论是 signpost、APM 还是高性能日志与存储——都指向同一个方向:把性能治理做成可沉淀的工程能力。而要让这些能力长期发挥作用,代码本身的可维护性就成了另一条暗线。Swift by Sundell 系列里,导航/解耦的 Navigation in Swift(介绍 Coordinator pattern 和 Navigator protocol + associated type 的实现,以及 URL Scheme 深度链接的落地方式)、接口协作的 Delegation in Swift(详解 weak delegate 防循环引用、protocol 可选方法的 @objc optional 与 extension 默认实现两种方案)、复杂页面组织的 Custom container view controllers in Swift,以及质量保障的 Unit testing asynchronous Swift code(介绍 XCTestExpectation、async/await 测试模式及 mock 注入策略),本质都在回答“团队如何在变更中不失控”。语言与工具层面也在同步补齐这套工程表达:在 Result 还没进入标准库前,The power of Result types in Swift 用类型系统提前“演练”了错误处理的可组合性;围绕集合与函数式写法的性能细节(Performance, functional programming and collections in SwiftcompactMap vs flatMap: The differences explained)提醒我们,性能往往就藏在这些日常习惯里。

应用生态方面,Apple Design Award 获奖者中 FlorenceAgendaAlto’s Odyssey 都很有代表性。效率工具领域 Notion 开始流行,开发工具方面 TowerProxymanTablePlus 被更多开发者采用;游戏方面 FortnitePUBG Mobile 霸榜,手机吃鸡成为现象级。

2018 “性能元年”对我来说最直接压力来自一个很具体的目标:App 包体积瘦身。它是一个自上而下的目标,于是大家都在想办法“抠体积”——资源侧能做的无非是去无用/去重;再往下,就只能动二进制本身:调编译参数、尝试缩短符号/命名长度……但现实是,收益非常有限,而且这些“挤出来的一点点”很快会随着版本迭代又涨回去。

为了把目标真正做实,只能回到最朴素也最有效的路径:清理无用代码。但当时现成工具无法保证“删了就一定不会在运行时被调用”,我只能靠人工逐个排查与验证,效率极低。于是我干脆把这套手动检查与验证流程,在周末写成了工具——让“无用函数/无用类”的识别尽可能自动化,同时把误删风险降到可控范围。

同事看到效果后问我原理,我随口解释了实现思路,他一句“这不就是编译原理吗?”把我点醒了:很多所谓的“工程治理”,底层其实就是编译器视角下的依赖与可达性分析。后来我专门去补了 Xcode 背后的 LLVM/Clang 工具链资料;也差不多在同一时期,滴滴杭州团队用 Clang 插件技术做了类似的瘦身工具,并在大会上分享了实践经验(GMTC 上分享滴滴出行 iOS 端瘦身实践的 Slides)。

静态手段做到极致,大概也就到“基于编译与链接信息,尽量准确地找出不可达代码”这个程度;要更进一步,就必须引入运行时证据:线上到底有没有用到它。

粗粒度上,可以通过类里记录初始化信息的字段判断“这个类在运行时是否真的被触发”;细粒度则是函数级别——我后来更倾向用函数级覆盖率来做闭环:线下跑主流程拿到覆盖数据,再只在非主流程上做小范围灰度,以最小的性能损失去识别真正的无用函数/代码路径。

相关的静态检查工具我做了两个开源项目:SMCheckProjectsmck;而关于“用 LLVM 做函数级覆盖率/利用覆盖率找无用代码”的更系统总结,我也写在了这篇文章里:使用 LLVM

2018 年是我从滴滴到阿里的过渡期。9 月我正式入职高德,成了一名阿里人。因为开始做 JS 解析与处理相关工作,我把 JavaScriptCore 从 Lexer/Parser/AST、字节码,到 LLInt/分层 JIT(Baseline/DFG/FTL/B3),再到类型分析、指令集架构等主链路梳理了一遍,并写了篇《深入剖析 JavaScriptCore》为后面做动态化方案打底。

第三届 @swift 大会分享“用 Swift 写解释器”的思路,开源了一个支持少量 OC 语法的解释器 demo(HTN),以及一个扩展 CTMediator 并引入 AOP/状态管理的架构 demo(ArchitectureDemo);幻灯片则尝试用“漫画分镜”的方式来表达技术内容,见 这次 Swift 大会分享准备的幻灯片和 demo

回头看 2018,这一年是“修炼内功”的一年:苹果把性能优化做成了 iOS 12 的主打卖点,各大厂把 APM、启动优化、包体积治理当成工程标配。对我而言,包体积瘦身几乎贯穿全年——从资源去无用/去重,到把无用代码清理工具化,再到补编译原理与 LLVM/Clang 工具链,甚至思考如何用覆盖率与灰度在运行时做最终验证:这些都是后来很多“性能治理”工作的底层思维训练。

2019年:SwiftUI 与 Swift ABI 稳定

3 月 996.ICU 项目在 GitHub 横空出世,短短几天 Stars 破 10 万,“工作 996,生病 ICU”成了程序员的年度自嘲。与此同时,李子柒 的田园视频在 YouTube 爆火,年底订阅数突破 700 万,成为中国文化输出的现象级案例。

3 月 25 日 Swift 5.0 发布,最重要的里程碑是 ABI 稳定:Swift 运行时库被嵌入操作系统,App 不再需要打包 Swift 标准库,包体积显著减小,跨版本兼容性大幅提升。语言与生态正式从“快速演进”迈向“可长期维护”。Swift 5.0 还带来了 UTF-8 编码的 String 重构、Result 类型、原始字符串字面量、改进的字符串插值等实用特性。

9 月随 iOS 13 发布的 Swift 5.1 则为 SwiftUI 铺路:some 关键字(Opaque Result Types)、Property WrappersFunction Builders / Result Builders 等新特性,让声明式 UI 的 API 设计成为可能。John Sundell 在 The Swift 5.1 features that power SwiftUI’s API 里把这条因果关系解释得非常清楚:some View 如何替代繁琐的类型擦除(AnyView)、@functionBuilder/@ViewBuilder 如何让多视图组合获得 DSL 般的声明式体验、以及 @propertyWrapper 如何把 @State/@Binding 这类状态管理代码简化到一行注解。

WWDC 2019 是近年来最令人兴奋的一届:SwiftUICombine 的发布让整个社区沸腾。SwiftUI 采用声明式语法,配合实时预览(Live Preview),让 UI 开发体验焕然一新;Combine 则为 iOS 带来了官方的响应式编程框架,虽然很多团队已经在用 RxSwift,但官方方案的到来意味着响应式编程正式成为苹果推荐的范式。

同一波发布里,iOS 13 带来了系统级 Dark Mode(深色模式),适配深色模式成了那几个月开发者的“必修课”;Sign in with Apple 为用户隐私提供了新选择,也给使用第三方登录的 App 带来了新的审核要求。ARKit 3 引入了人体遮挡(People Occlusion,让虚拟物体能正确地被真人遮挡或遮挡真人)和动作捕捉(Motion Capture,实时追踪人体关节位置),同时带来多人脸追踪与协作式 AR 会话,配套的 RealityKit 框架把光照渲染、物理模拟与动画打包成更高层抽象;Core ML 3 支持设备端模型训练——通过 MLUpdateTask + MLBatchProvider 在不重新编译的情况下做个性化微调,新增 70+ 神经网络层与 MLModelConfiguration 让 CPU/GPU/Neural Engine 切换更可控,苹果在 AR 和 ML 上的布局越来越清晰。iPadOS 首次从 iOS 中独立出来,iPad 终于有了自己的身份,多任务、文件管理、鼠标支持都有了显著改进。Xcode 11Swift Package Manager 集成进来,开发者终于可以在 IDE 里直接管理 Swift 包依赖,不再必须依赖 CocoaPods 或 Carthage。

同时 iOS 13 也给 UIKit 带来了几项“立刻能用”的工程级新能力:比如 Diffable Data SourceUITableViewDiffableDataSource / UICollectionViewDiffableDataSource)让列表更新从“手写 insert/delete/move”变成“应用快照”;UICollectionViewCompositionalLayout 让复杂布局更易组合;而 UIScene / UIWindowSceneDelegate(我们口语里常叫 SceneDelegate)则带来了新的生命周期适配工作。Mac Catalyst 也在这一年开始被更多人认真尝试。

9 月 iPhone 11 / iPhone 11 Pro 系列发布,“浴霸”三摄成为新的视觉标志,夜景模式让手机摄影再上台阶;同场发布的 Apple Watch Series 5 以 Always‑On 屏幕成了那年最“顺眼”的升级之一。10 月 AirPods Pro 发布,主动降噪让真无线耳机体验飞跃。

10 月 macOS Catalina 上线,彻底告别 32 位应用,让不少老软件“一夜变灰”,也让很多人升级前第一次认真做了兼容性清单。11 月 16 英寸 MacBook Pro 发布,剪刀脚键盘回归,把不少人从 2016-2018 款的阴影里拉出来;11 月 1 日 Apple TV+ 流媒体服务上线,苹果正式进军内容领域;WWDC 2019 上亮相的“奶酪刨丝器”造型 Mac Pro(2019) 在年底开售,专业用户终于迎来性能怪兽;同期 Apple Arcade 游戏订阅服务也正式上线,为移动游戏带来新的商业模式。

2019 年 iOS 圈涌现了大量高质量的技术文章。

第一条主线是语言层面“终于定型”。Swift 团队用 ABI Stability and More 把 Swift 5 的关键意义讲透,随后 Swift 5 Released! 宣告 ABI 稳定真正落地:语言与生态从“快迭代”迈向“可长期维护”。也正因为底座稳定了,Swift 5.1 那批为声明式 API 铺路的特性(some View@State 等)才显得更“像一套设计”。

第二条主线是框架层面的“范式迁移”:从 SwiftUI 的声明式 UI 与预览驱动开发,到 Combine 这套官方响应式框架把异步与数据流“写进类型系统”。宏观上,Mattt 在 NSHipster 的 WWDC 2019 不只把 SwiftUI/Catalyst 放进“平台长期可行性”的叙事里,还给了更偏落地的选择题:存量 App 先把 iOS 13 的 Dark Mode、Sign in with Apple、SF Symbols 等必修项做成 checklist;iPad App 则评估通过 Catalyst 迁移到 macOS Catalina。微观上,中文圈几乎是同一时间把“上手路线”补齐:喵神的《SwiftUI 的一些初步探索》系列( / )让人能快速把核心概念跑通,而淘宝技术的《系列文章深度解读|SwiftUI 背后那些事儿》则更偏机制与实现视角,适合想弄懂“为什么这样设计”的读者。

当 SwiftUI/Combine 真的进入真实工程,大家关心的就不再是“会不会写”,而是“怎么和存量共存、边界在哪里”。例如 objc.io 的 SwiftUI: Loading Data Asynchronously 用 “Endpoint(URL + parse)+ 泛型 Resource” 的抽象把按需请求、解析与加载态(loading/loaded)到驱动 UI 更新的链路串了起来(当时还要靠 BindableObjectdidChange 来触发刷新),同时也直面 function builder 早期不支持 if let 等写法的限制;而 Swift by Sundell 的 Functional networking 则代表另一条常青线:把网络层收敛为可注入的函数/闭包(而不是巨型 manager),让“请求 + 解析”更可组合、更可测试、更易 mock——哪怕 UI 框架在变,这类“把依赖变成参数”的思路依然能长期复用。中文圈里也有很多“拿项目说话”的复盘,比如 InfoQ 的《历时五天用 SwiftUI 做了一款 APP,如何做到?》,把 SwiftUI 在原型/小工具上的效率红利与现实边界讲得很具体。

第三条主线更“工程化”。如前面 iOS 13 的变化所示,真正让团队都“动起来”的,往往不是某个新 API,而是一组必须落地成 checklist 的工程关键词:比如 Dark Mode 对外观与资源体系的重塑、Sign in with Apple 把登录方案和隐私合规绑定、以及 UIScene / SceneDelegate 带来的新生命周期与多窗口语义(适配工作量也随之上升)。这类问题最需要“可执行的检查表”,比如 AvanderLee 的 Dark Mode: Adding support to your app in Swift 就很适合用来对照扫坑:从 UIUserInterfaceStyleInfo.plist 里一键 opt-out,到按 view/view controller/window 覆写 userInterfaceStyle,再到语义/自适应颜色与 UIColor(dynamicProvider:) 的兼容策略、traitCollectionDidChange 修复 CALayer 边框色不动态更新,以及在 Asset Catalog 里为图片配置 light/dark 变体或用 template+tint 控制资源膨胀;而 NSHipster 的 iOS 13 则更像“你这一版不该漏掉什么”的总览清单。

更有意思的是,UIKit 并没有被新范式抛下,反而在列表与布局上给了更现代的工程解法:快照驱动的 Diffable Data Source(可配合 Modern table views with diffable data sources 快速上手——文章详解了 NSDiffableDataSourceSnapshotappendSections/appendItems/apply 更新流程,以及如何用 Hashable 标识符让 diff 算法正确识别变更),以及用 UICollectionViewCompositionalLayout 描述复杂布局(参考 Using compositional collection view layouts in iOS 13)。工具链与生态也在同步补齐:Xcode 11 把 Swift Package Manager 集成进 IDE,很多团队会像 AvanderLee 这篇 Using Xcode Previews with existing UIKit views without using SwiftUI 那样,用 canImport(SwiftUI) + #if DEBUG 把 SwiftUI 只留在调试构建里,再用 UIViewRepresentable/UIViewControllerRepresentable + PreviewProvider 给 UIKit 视图“套壳”,甚至开发期临时把 deployment target 调到 iOS 13 来解锁 Canvas 预览,从而在不真正迁移 SwiftUI 的前提下先吃到实时预览的效率红利;而想把 Combine 用进 UIKit 的存量体系,也可以参考 Creating a custom Combine Publisher to extend UIKit 这类桥接实践——文章演示了如何从 Subscription 处理 demand/cancel,到实现自定义 Publisher,再通过 Subscriber.receiveUIControl 事件转化为响应式数据流。

但 2019 还有一条更“底层”的暗线:性能治理开始从“靠经验抓一把”走向“可观测 + 可回归”。WWDC 2019 的 Improving Battery Life and Performance(417) 把这条链路讲得很完整:开发阶段用 XCTest 的性能度量把关键路径写成可自动回归的测试,上线后再用 iOS 13 新引入的 MetricKit 收集真实用户设备上的能耗与性能指标(CPU/内存/磁盘写入/启动等),最后在 Xcode Organizer 的 Metrics 面板里看趋势与回归。

而当视角再往下,Optimizing App Launch(423) 把启动成本拆到 dyld 动态链接、ObjC runtime 类注册与 +load、静态初始化、主线程阻塞这些“看不见的开销”,几乎成了后来做启动专项的官方教科书;后台侧 iOS 13 的 BackgroundTasksAdvances in App Background Execution(707))把后台刷新/处理从“碰运气”变成更符合系统调度与省电策略的 API:以 BGTaskScheduler 注册/调度任务,用 BGAppRefreshTaskRequest/BGProcessingTaskRequest 声明是否需要网络/外接电源等约束,并在过期回调里做好取消与收尾。配合 os_signpost + Instruments(Getting Started with Instruments(411))把关键路径打点,这一年官方工具链对“性能与底层”的表达其实前所未有地清晰。

这条线在大厂输出上也很明显:微信团队的 Matrix 把 APM 做成插件体系,WCCrashBlockMonitorPlugin(基于 KSCrash)把 crash 与卡顿的堆栈抓取打通,配合 RunLoop 状态判断卡死;WCFPSMonitorPlugin 在滑动等场景采集主线程 call stack;WCMemoryStatPlugin 记录内存分配与调用栈,为 OOM 归因补齐证据链。更轻量的泄漏排查上,腾讯的 MLeaksFinder 几乎是“装上就生效”:默认盯 UIView/UIViewControllerdealloc,泄漏时直接弹窗给出 View-ViewController 栈,还支持通过 willDealloc 排除单例/常驻对象并扩展到更大的对象图。美图的 MTHawkeye 走的是“in-app profiling”路线:UITimeProfiler(VC Life Trace + ObjC CallTrace)、ANRTrace(卡死时采样主线程栈)、NetworkMonitor(带 waterfall/重复请求检测)等插件直接挂在浮窗面板里;滴滴的 DoraemonKit/DoKit 则把接口 Mock(网络拦截)、沙盒浏览/文件导出、Mock GPS、Crash/卡顿堆栈、CPU/内存曲线、UI 层级检查、+load 耗时分析等调试能力统一收口到一个入口。

国际圈里,Facebook 的 Flipper(配套的工程博客:Introducing Flipper)把插件化调试平台做成标准形态:桌面端 client + 移动端 SDK,通过双向 socket 通道把数据送到 React 驱动的 UI;Layout Inspector、Network(甚至 GraphQL 请求流)、日志等能力都可以按插件组合扩展。它们共同指向一个趋势:性能优化开始从“某个专家会用 Instruments”迁移到“团队日常工程流程可复用”,而当你要继续把启动/加载成本压到 dyld/Mach-O 这一层时,Apple 的开源代码也能作为很好的旁证(例如 dyld)。

信息获取层面,周报能很好地把“当周必读”过滤出来(比如《老司机 iOS 周报 #73 | 2019-07-01》这种把资讯、文章与开源资源打包投喂的 weekly);而在“框架之外”的工程实践上,像 InfoQ 的《0.3 秒完成渲染!信息流内容页“闪开”优化总结和思考》也很典型:通过浏览器内核、客户端外壳、服务端、前端的多端协作,结合离线缓存策略突破 PWA/SSR 的局限,在日均亿级 PV 的场景下实现 100% 消除白屏——新框架再热,最终还是要落到渲染、链路与体验这些硬指标上。年末回看趋势时,再读一遍 The Decade of Swift 这种复盘文章(从 Swift 3 的标准库 API 重设计、Swift 4 的 Codable/key paths,到 2019 的 Combine/SwiftUI,以及编译器诊断/并发愿景),也能把“热点”重新拉回到“长期方向”。

2019 年 Apple Design Award 的获奖者很有代表性:Flow by Moleskine 是一款精致的数字笔记应用,与 Apple Pencil 配合得天衣无缝;Pixelmator Photo(现 Photomator) 把机器学习驱动的图片编辑带到 iPad,简单易用却功能强大;The Gardens Between 是一款关于时间、记忆与友谊的解谜游戏,画面和叙事都很治愈;Asphalt 9: Legends 把主机级画质带到了移动端;Butterfly iQ 让手持超声波设备成为现实,是医疗科技的标杆应用;HomeCourt 用 AI 追踪篮球投篮数据,是运动科技的创新典范。年度最佳应用和游戏方面,Spectre Camera(长曝光相机)和 Sky: Children of the Light(陈星汉团队新作)都是当年的亮点——Sky 的画风让人想起《风之旅人》,而 Spectre 则用计算摄影把长曝光变得简单优雅。

年底 Apple 还发布了 Best of 2019 官方榜单:iPhone 年度 App 是 Spectre Camera,iPad 年度 App 是 Flow by Moleskine,Mac 年度 App 是 Affinity Publisher;游戏方面 iPhone 年度游戏是 Sky,iPad 年度游戏是 Hyper Light Drifter,Mac 年度游戏是 GRIS。现在回头看,这份榜单其实把 2019 的几条主线(计算摄影、手写创作、独立游戏审美)抓得很准。

iOS 上还有不少现象级应用:剪映 凭借简单易用的剪辑功能迅速走红,成为短视频创作者的标配;飞书(字节跳动出品)开始在企业协作市场发力;滴答清单Sorted³ 在 GTD 工具圈很受欢迎。

macOS 上,Bear(熊掌记)继续是 Markdown 笔记的首选;Things 3 依然是 GTD 工具的标杆;Notion 在国内也开始流行起来,“All-in-One Workspace”的理念吸引了很多效率爱好者;Craft 虽然要等到 2020 年才正式发布,但相关理念已经在酝酿。开发工具方面,Paw(API 调试)、Proxyman(网络抓包,这年正式发布 1.0)、TablePlus(数据库客户端)继续是很多开发者的首选;Alfred 4 发布,效率党狂喜;CleanMyMac X 依然是清理 Mac 的不二之选。设计工具方面,Figma 持续蚕食 Sketch 的市场份额,“协作设计”成为新的关键词。

这一年我的工作重心已逐渐转向更基础的技术工作:解决疑难杂症、对工程做性能优化。我更多地关注一码多端的动态化技术——除了 JavaScriptCore 外,还深入了解了 React NativeWeex 的具体实现,以及字节跳动的 Lynx(后来在 2025 年重新发布)。除了引擎层面外,Bundle 版本发布管理平台也是很重要的一环。

2019 年末的时候,谁也没想到接下来的一年会如此不同。

2020年:疫情与 Apple Silicon 革命

2020 像一次“强制重排”:疫情把生活与工作方式拉进不确定性,Apple Silicon 的落地则让苹果生态按下新的加速键。

2020 年新冠疫情席卷全球。年初武汉封城让所有人猝不及防,远程办公、在线教育、直播电商一夜之间成了刚需;东京奥运会也史上首次被迫延期,很多人的“2020 计划”就这样被按下暂停键。1 月 26 日,科比·布莱恩特 在直升机事故中不幸离世,整个体育界为之震动——“凌晨四点的洛杉矶”成为永恒的记忆。3 月美股经历了史无前例的 四次熔断,巴菲特也感叹“活了 89 年没见过这场面”。

直播带货在这一年彻底爆发——薇娅李佳琦 成为现象级主播,“OMG,买它!”成为年度金句;罗永浩 4 月进军直播带货还债,“真还传”开始上演。

内容层面,《隐秘的角落》的“爬山”梗刷屏全网,秦昊、张颂文的演技让人直呼过瘾;《安家》以房产中介视角讲述都市百态,孙俪的演绎深入人心。B 站跨年晚会《最美的夜》在 2019 年底播出后口碑爆棚,也让更多人意识到 B 站不只是二次元。

居家期间,Switch 的《动物森友会:新地平线》在 3 月发售后爆红,“上岛”“大头菜”成了社交媒体的共同语言;《健身环大冒险》则把客厅变成健身房,缺货涨价也成了那年的另类记忆。而《Among Us》这款 2018 年上线的“狼人杀”风格游戏,在 2020 年因主播推广意外翻红,10 月全球下载量达到 7480 万次,“There is 1 Impostor among us”也成了年度经典台词。

Zoom 从一家视频会议公司变成了“疫情基建”,每日会议参与者(daily meeting participants)从年初的 1000 万跃升到 3 亿量级;钉钉、飞书、企业微信在国内竞争异常激烈。GPT-3 在 6 月由 OpenAI 发布(若该链接不可访问,可参考 GPT-3 论文:Language Models are Few-Shot Learners),1750 亿参数的规模让业界震惊,虽然普通开发者当时还用不上,但“大模型”的种子已经埋下。产业侧,年末 特斯拉 被纳入 S&P 500 指数,市值一度超过丰田成为全球最有价值的汽车公司;小鹏理想 在美股上市,蔚来 则在交付与股价上迎来高光时刻,中国新能源汽车“造车新势力”被推到聚光灯下。

同一年 10 月 20 日 React 17 发布,主打“更易渐进升级”(对大型项目而言,这比新特性更重要);Node.js 14 在 4 月发布并于 10 月进入 LTS,让一大批工具链与生产项目更稳地站上现代 JavaScript 语法与运行时。

开源社区这一年也很活跃。前端方面,Next.js 10 带来图片优化与国际化路由;Tailwind CSS 2.0 让原子化 CSS 成为主流。后端方面,FastAPI 凭借类型提示和自动生成 OpenAPI 文档迅速走红。iOS/Swift 生态方面,TCA(单向数据流、State/Action/Reducer/Effect、可测试 SwiftUI 架构)正式发布,SwiftFormat(自动代码格式化、可配置规则)、Pulse(网络请求日志、持久化与可视化调试)等工具开始被更多团队采用。疫情也带火了远程协作工具:Excalidraw(手绘白板)在疫情期间大受欢迎;Foam(VS Code 上的 Roam 替代品)让双向链接笔记在开发者圈子里流行起来。远程协作的另一面是“能自建就自建”:开源视频会议 Jitsi Meet 和直播推流神器 OBS Studio 进入更多人的工具箱;AI/NLP 领域 Transformers 进一步出圈,让“预训练模型 + 微调”成为工程常识;可观测性方向 OpenTelemetry 逐步成为 tracing/metrics/logs 统一语义的事实标准。

硬件方面,2020 年是苹果“改朝换代”的开始。11 月 10 日,苹果发布自研的 Apple Silicon M1 芯片,这颗 5nm 制程的 SoC 把 CPU、GPU、神经引擎和统一内存集成在一起,性能与能效双双跃升:搭载 M1 的 MacBook Air 无风扇设计却依然能大幅超越上一代,续航达到 18 小时;13 英寸 MacBook Pro 续航更是长达 20 小时,创下 Mac 历史之最;Mac mini 也不再只是性能“入门款”,CPU 性能提升 3 倍、GPU 提升 6 倍。苹果用实际行动证明:ARM 架构不仅能做手机,也能做电脑。对 Intel 的这次“断舍离”,也让整个 PC 行业重新审视“架构”这件事。

10 月 13 日,iPhone 12 系列发布,苹果终于拥抱 5G——A14 仿生芯片(业界首款 5nm 手机芯片)、超瓷晶面板、全新的直角边框设计(致敬 iPhone 4/5),以及 MagSafe 磁吸充电的回归,都让人眼前一亮。同时发布的还有 iPhone 12 mini(5.4 英寸小屏党狂喜)、iPhone 12 Pro 和 iPhone 12 Pro Max(LiDAR 扫描仪加持)。9 月的发布会上,Apple Watch Series 6 带来了血氧监测功能,入门款 Apple Watch SE 让更多人能买得起苹果表;iPad Air(第四代) 换上全面屏设计,首次把 Touch ID 塞进电源键,A14 芯片也让它性能直逼 iPad Pro。

WWDC 2020 首次完全线上举办,没有现场观众的 Keynote 反而更加紧凑高效;更关键的是,苹果也在同一届 WWDC 正式宣布 Mac 向 Apple Silicon 迁移,年末 M1 的发布就是第一批量产落地。iOS 14 带来了呼声最高的 桌面小组件(Widgets)——终于可以在主屏幕上放天气、日历、快捷方式了;App Library 自动整理所有 App,强迫症患者狂喜;App Clips 让用户无需下载完整 App 就能体验核心功能,扫码即用的体验非常丝滑;画中画 功能从 iPad 来到了 iPhone;Siri 和来电界面也终于不再全屏霸占了。macOS Big Sur 带来了近十年来最大的视觉重设计,圆角、半透明、新图标,整体更接近 iOS/iPadOS 的设计语言,也为 M1 Mac 上运行 iOS App 做好了准备。

SwiftUI 2.0 是这届 WWDC 的另一个亮点:新增了 WidgetKit(小组件开发框架)、LazyVStack/LazyHStack(懒加载布局)、LazyVGrid / LazyHGrid(网格布局)、App 协议@main 入口点)、@StateObject@AppStorage@SceneStorage 等新特性,让 SwiftUI 终于可以不依赖 AppDelegateSceneDelegate 独立构建完整 App 了。Swift 5.3 在 9 月 16 日随 Xcode 12 发布,带来了 @main 属性、多尾随闭包、Float16、更简洁的 #file 字符串、Swift Package Manager 对资源和本地化的支持,以及官方 Windows 支持。编译后二进制体积也显著减小——一个 SwiftUI App 的代码大小比 Swift 5.1 时代减少了 40% 以上。

WWDC20 的 What’s New in Swift(10170) 把 clean/dirty memory 的视角摆到台前,并展示了在 iOS 14 的 Swift 运行时上如何把一部分原本会变成堆上 dirty memory 的开销(比如元数据与缓存)压下去,示例里甚至能让某些场景的运行时堆开销降到“更早版本的三分之一以下”。同一届的 Advancements in the Objective-C Runtime(10163) 则从 ObjC runtime 的角度继续“把可不变的东西留在只读页、把可变部分延迟/隔离”,核心目的同样是减少 iOS 上更昂贵的 dirty pages。中文圈也有人把这场分享拆解得很细,比如这篇《优化 Objective-C runtime(WWDC2020)》就按 clean/dirty memory 把 class_ro_t/class_rw_t 的拆分与 class_rw_ext_t 的按需分配讲清楚,也解释了 relative method list(方法列表用 32 位偏移)与 swizzling mapping table 的取舍,最后落到一个很实用的提醒:别直接摸私有结构,尽量通过 class_copyMethodList/method_getImplementation 这类官方 API 来访问。

作为产品形态的变化,iOS 14 把主屏幕变成新的产品入口,小组件成为年度最大增量——从官方的 Widgets / WidgetKit,到 WWDC 的 Build SwiftUI views for widgets(WWDC20 10033),再到 @samwize 写的 Guide to WidgetKit(从 Widget Extension target 的签名/Provisioning,到 TimelineProviderplaceholder/getSnapshot/getTimeline 与 reload policy,再到 widgetFamily 适配、widgetURL/Link 的点击限制(小尺寸只能用前者)等细节整理成 checklist),你几乎能完整看到“新入口”如何被迅速工程化。与小组件一起出现的还有系统级的“更轻、更快”的体验取向:App Clips 让用户“先用再装”,App Library 则把主屏幕管理交给系统,产品形态在 2020 明显更强调低摩擦的触达与收纳。

开发细节层面,肘子的两篇 HowTo(SwiftUI2.0 使用 ToolBar 替代 navigationbarItems / 使用 ScrollViewReader 定位滚动位置)属于那种“拿来就能用”的小而美补丁:前者把 .toolbar + ToolbarItem(placement:) 如何覆盖 navigationBarItems(尤其是 .automatic 这类多平台 placement 的行为差异,以及 ToolbarContentBuilder 还不支持逻辑判断这类限制)讲清楚;后者则用 ScrollViewReader/ScrollViewProxy.scrollTo(_:anchor:) 演示基于 .id 的滚动定位,并点明“想反向记录当前滚动位置”仍然不太优雅。

工具链这边,Swift 5.3 + Xcode 12 把 Swift Package Manager 推到“生产可用”的关键一档:官方的 Swift 5.3 Release ProcessSwift 5.3 Released!(以及 SwiftGG 的对应译文:Swift 5.3 发布流程 / Swift 5.3 released!)把发布节奏与工程向特性讲得很完整;当你真的要把包“用进项目”时,最容易翻车的往往不是写 Swift,而是“包里带资源/做本地化/引入二进制依赖”。这时配合官方文档 Bundling resources with a Swift packageBundle.module)和提案 SE-0272 Package Manager Binary Dependencies,再读 Vincent Tourraine 的 Notes WWDC 2020 : Swift packages - Resources and localization 与 @samwize 的 How to localize resources in Swift packages,就能把“宿主 App 也要声明 Localizations”这类隐蔽坑点一次性扫掉。

在体验红线上,2020 仍然绕不开启动优化:年初那两篇被高频转发的文章——《一行代码解决!iOS二进制重排启动优化》与《抖音研发实践:基于二进制文件重排的解决方案,APP启动速度提升超15%》——把“二进制重排(order file)如何落进构建链路”以及“Page Fault 视角下为什么重排有效”的因果链路从原理到数据都讲透了:cold launch 时 dyld 把 Mach-O mmap 到虚拟内存后,随着 pc 寄存器不断跳转取指执行,会频繁触发缺页中断来加载物理页(A9+ 处理器 16KB/页),一次 page fault 耗时 0.3~1ms,启动期间可能发生 2000+ 次、累计 300ms 以上;通过 hook objc_msgSend、插桩 +load/C++ constructor,或借助 SanitizerCoverage 编译时插桩拿到真实执行顺序,把启动期调用的函数符号紧凑排列进 order file,就能大幅减少 page fault 与 disk thrashing 风险。

如果你想把 pre-main 再往下拆到 dyld/Mach-O/initializers 的具体成本,美团外卖那篇《美团外卖 iOS 冷启动治理》至今仍是绕不过的底层教材:文章把启动划分为 T1(main 前的加载链接)、T2(main 到 didFinishLaunching)、T3(首页渲染完成)三个阶段,用 DYLD_PRINT_STATISTICS 与火焰图做精准定位,再通过减少动态库、减少 +load、清理无用类/方法等手段压缩 T1;更有价值的是它提出的”分阶段启动 + 启动项自注册”机制——借助 Clang 的 __attribute__((section())) 把函数指针写入 __DATA 段、运行时按阶段触发,既实现解耦又能跨端复用;闪屏页的并行构建、缓存定位 + 首页预请求等”变串行为并行”的策略,同样是可直接落地的工程抓手。

而启动之外,2020 另一个在大厂里被反复打磨的主题是”卡顿/无响应”的线上归因:iOS 没有 Android 那样的 ANR 系统回调,工程上通常走”RunLoop 状态机 + 子线程采样堆栈”这条路——在主线程 RunLoop 的 kCFRunLoopBeforeSources/kCFRunLoopAfterWaiting 等状态切换时打点计时,超时则通过 Mach 线程接口(task_threads/thread_get_state)抓取主线程调用栈。微信团队把这套方案在 Matrix 的 iOS 端落成可复用的插件体系(卡顿检测、内存监控、耗电归因等模块可独立接入);美图在 2020 开源的 MTHawkeye 则把 ANRTrace/UITimeProfiler/FPS 等能力做进 App 内的 profiling 面板,还支持网络请求监控与 GPU 过度绘制检测(发布介绍:MTHawkeye 重磅发布);滴滴的 DoKit(DoraemonKit) 也把 FPS/CPU/内存/卡顿等”开发期必备仪表盘”统一收口到一套工具入口里,并支持 mock 数据、H5 任意门、视觉对比等便捷调试功能。

内存这条线在 2020 也明显”从泄漏排查走向 OOM 归因”:除了 Instruments 的 memgraph/leaks 这些老牌工具,腾讯开源的 OOMDetector 把”大内存分配监控(通过 hook malloc/vm_allocate 等接口记录分配调用栈)、泄漏扫描、爆内存前 dump 调用栈”做成组件,特别适合拿来解释”为什么触发了 jetsam 被系统杀掉但看不到 crash 日志”这种线上 FOOM(Foreground Out Of Memory)疑难问题;而当它与 Apple 在 WWDC20 强调的 clean/dirty memory、ObjC runtime 只读化/延迟化策略放在同一张图里,你会更容易把内存优化从”省点内存”升级为”降低 dirty pages、减少可变页写入、把高成本工作推迟到真正需要的时候”。

如果说启动优化偏“工程内功”,那么 2020 也是 Apple 把“线上真实用户的性能问题”正式工程化的一年:Xcode 12 的 Organizer 能直接看到按版本聚合的滚动卡顿(scroll hitches)与磁盘写入异常等指标(Diagnose performance issues with the Xcode Organizer(WWDC20 10076));而 MetricKit 2.0(What’s New in MetricKit,WWDC20 10081) 则把这些能力下沉到 API 层面:除了新增 CPU instructions、scroll hitches、app exit reasons 等指标外,还补齐了 MXDiagnosticPayload 这类诊断 payload(hang/crash/CPU exception/disk write exception),让“发现问题”不止停留在曲线上,而是能拿到 call stack tree 去定位(文档入口:MetricKit,例如 MXDiagnosticPayloadhangDiagnostics)。与之配套的是“把卡顿写进测试”:在 Eliminate animation hitches with XCTest(WWDC20 10077) 里,Apple 把 hitches 指标(ms/sec)和 XCTOSSignpostMetric(比如滚动拖拽/减速)交到 XCTest 的性能测试里,让“顺不顺”第一次更像一条可回归的 CI 指标;社区里也有不少好笔记/翻译,比如《Eliminate animation hitches with XCTest》与基于 Organizer 的实战拆解《Diagnose Performance Issue》,以及 Donny Wals 的《Measuring Performance with os_signpost》(从 OSLog/signpostID 的写法,到 Instruments 里添加 os_signpost + Points of Interest 两条 track、按 begin/end 自动聚合区间并统计耗时,基本把 signpost→Instruments 的链路讲透)。

图形/渲染这条线在 2020 也有一套更“硬”的官方教材:Apple GPU 的 TBDR 架构、GPU counters 的定位方法,以及 Xcode 12 的 Metal Debugger/Metal System Trace(含 summary insights)如何落到“找瓶颈→改一处→复测”的闭环,基本都在这几场里讲透了——Harness Apple GPUs with Metal(WWDC20 10602)Optimize Metal apps and games with GPU counters(WWDC20 10603)Gain insights into your Metal app with Xcode 12(WWDC20 10605)

对我个人而言,2020 几乎是围着“启动”在转。年初受前滴滴同事邀请去快手做分享时,我把启动阶段耗时分析的思路与工具链梳理成 Slides,并把方法耗时分析工具 MethodTraceAnalyze 开源出来(记录在这篇文章里:在快手做分享、无用类检查、在广州做 SwiftUI 学习笔记分享、InfoQ二叉树视频)。同一时期我也在广州做了一次 SwiftUI 学习笔记分享,但“为了讲清楚”而反复梳理的过程,对做性能优化同样重要。

那次整理对我最大的价值,是把“启动优化”从经验活变成工程活:不再泛泛地说“启动慢”,而是把链路拆到 dyld / ObjC runtime / +load / C/C++ 静态初始化这些具体环节,再用数据说话。也因此我开始更认真地做无用类/无用代码治理——它表面上在做包体积,最后却会落到启动加载成本上;光靠静态扫描远远不够,还需要结合运行时证据做灰度验证。

年底我把高德 App 启动优化专项里用到的手段与一些想法系统写成了《App 启动提速实践和一些想法》:从唤端与 H5 启动页体验,到 pre-main 阶段的加载与链接开销,再到用 SanitizerCoverage/objc_msgSend 采样生成 order file,以及线程调度与任务编排,最后落到“把指标做成自动化管控,才能长期守住收益”。

SwiftUI 的兴起也带火了更可组合、可测试、可观测的架构范式:Point-Free 用《Composable Architecture, the library》把 TCA 作为库正式推出,又在《Instrumenting features built in the Composable Architecture》里把 os_signpost 这类性能度量做成一行可插拔的 .signpost() higher-order reducer——既能统计 action 的发送频次与 reducer 的执行耗时,也能把 effect 的开始/完成/取消纳入同一套 Instruments 观测;而年底 Swift by Sundell 的《The lifecycle and semantics of a SwiftUI view》则从“View 是值类型、body 只是描述而不是生命周期回调”的角度,提醒大家别在 body 里触发副作用,而应使用 onAppear/onReceive 等修饰符承载时序,并在 UIViewRepresentablemakeUIView/updateUIView 里正确复用 UIKit 视图,避免把 UIKit 的生命周期直觉硬套进去。

系统化学习资源方面,国际上 objc.io 在这一年出版了 《Thinking in SwiftUI》——更强调用 SwiftUI 的数据流与语义来组织界面(状态、绑定、环境值这些概念该怎么“顺着框架”去用);喵神在 objccn.io 出版的《SwiftUI 与 Combine 编程》则用实践把 SwiftUI 的状态驱动和 Combine 的响应式链路(Publisher/Operator/Scheduler)串起来;而追踪资讯最省力的入口之一依旧是《老司机 iOS 周报》。

2020 年 Apple Design Award 的获奖作品很有代表性:Sayonara Wild Hearts(Simogo/Annapurna Interactive)把复古街机与音乐 MV 融为一体,视觉和听觉都是顶级享受;Sky: Children of the Light(thatgamecompany)延续了《风之旅人》的治愈风格,强调合作与分享;Song of Bloom(Philipp Stollenmayer)是一款脑洞大开的解谜游戏;Where Cards Fall(The Game Band/Snowman)用纸牌屋构建梦境,美术风格独树一帜。年底 Apple 发布的 Best of 2020 榜单 中,iPhone 年度 App 是 Wakeout!(居家健身)、iPad 年度 App 是 Zoom(疫情刚需)、Mac 年度 App 是 Fantastical(日历神器);游戏方面,iPhone 年度游戏是《原神》、iPad 年度游戏是《Legends of Runeterra》(拳头的卡牌游戏)、Mac 年度游戏是《Disco Elysium》(神作 RPG)。

国内 App 方面,腾讯会议 在疫情期间迅速崛起,成为国内在线会议的首选;钉钉 被“网课”带火,虽然收获了大量一星差评(学生党的怨念),但 MAU 突破 3 亿;学习通 也因网课需求下载量暴涨,虽然用户体验一言难尽,但确实成了很多学生的“噩梦”和日常。小宇宙 播客 App 在 3 月上线,凭借简洁的设计和优秀的收听体验,成为中文播客圈的现象级产品。飞书 凭借字节跳动内部沉淀的协作理念,在企业市场持续发力。美团外卖饿了么 在疫情期间成为刚需,骑手们成了城市里最忙碌的人。

疫情也让一批“基础设施型 App/服务”从“装不装无所谓”变成了“不装不行”:各地的健康码/通行码几乎成了出门标配;在线教育方面,从“停课不停学”到“直播上课”,腾讯课堂、雨课堂、ClassIn 等把课堂搬进手机;文档协作方面,腾讯文档 / 石墨文档 这种多人实时编辑工具也变成了远程协作的日常。

iOS 上小组件的开放带火了一批定制化应用:Widgetsmith 让用户自定义各种风格的桌面小组件,一度登顶 App Store 免费榜;Color Widgets 也凭借精美的预设设计吸引了大量用户;主屏幕美化一时成为社交媒体上的热门话题,“iOS 14 美化教程”刷屏各大平台。健身应用方面,Keep 在居家期间用户激增,“自律给我自由”成为很多人的桌面壁纸;Nike Training Club 宣布免费开放高级功能,也收获了一波好感。

Mac 端,Notion 在国内的使用者越来越多,“All-in-One”的知识管理理念开始深入人心;Craft 在年底发布,以原生 Swift 开发、精美的设计和流畅的体验,成为 Notion 的有力竞争者;Obsidian 以本地 Markdown 文件 + 双向链接的方式,吸引了一批注重数据所有权的用户。效率工具方面,Raycast(年底公开测试)开始挑战 Alfred 的地位,凭借现代化的设计和丰富的扩展生态迅速积累口碑;Rectangle(开源的窗口管理工具)成为很多人 Spectacle 的替代品;CleanShot X 让截图和录屏变得更加优雅。远程协作工具也几乎成了“上班必备”:ZoomMicrosoft TeamsSlackFigmaMiro 这些软件在 2020 频繁出现在屏幕共享里。开发工具方面,Fig(终端自动补全,后被 AWS 收购)开始在开发者圈子里流行;TablePlusProxyman 继续是数据库和网络调试的首选。

2021年:并发与元宇宙

如果用两个关键词概括 2021:一个是“元宇宙”点燃的想象力与泡沫;另一个是 Swift 5.5 让并发从“写法问题”升级为“语言级范式切换”。

2021 年我最喜欢的电视剧是 《觉醒年代》,豆瓣 9.3 分几乎是年度口碑之王。《你好,李焕英》 作为贾玲的导演处女作,票房冲到 54 亿,母女情催泪无数;《山海情》 则用扶贫题材打动人心。

2 月 Clubhouse 音频社交横空出世,马斯克在 Clubhouse 上聊天带火了这款应用,邀请码一度被炒到几百块。虽然热度只持续了几个月,但“音频社交”的概念从此进入主流视野。

同样被马斯克带火的还有加密货币。年初 狗狗币(Dogecoin) 在他推特带货下暴涨,一度冲进加密货币市值前十;3 月数字艺术家 Beeple 的 NFT 作品在佳士得拍出 6934 万美元,“NFT”一夜之间成为热词,Bored Ape Yacht Club(无聊猿) 等 PFP 项目爆火——虽然后来泡沫破裂,但 2021 的确称得上“NFT 元年”。

体育与航天方面同样有高光时刻。7 月 23 日延期一年的 东京奥运会 终于开幕,空场比赛的画面成了史无前例的奥运记忆,苏炳添 9 秒 83 闯入百米决赛让无数人热泪盈眶。航天领域,4 月 29 日 空间站天和核心舱发射升空,中国空间站建设正式拉开序幕;5 月 15 日 天问一号着陆巡视器成功着陆火星(祝融号火星车着陆火星乌托邦平原),中国成为继美国之后第二个成功让火星车着陆的国家。

10 月 28 日,Facebook 宣布更名为 Meta,扎克伯格押注“元宇宙”成为下一代互联网入口——这一动作点燃了全球对元宇宙的狂热讨论,“元宇宙元年”成为年度关键词。国内科技巨头也迅速跟进:8 月 字节跳动以 90 亿元收购 VR 厂商 Pico百度 12 月推出元宇宙产品“希壤”;腾讯网易 也纷纷申请元宇宙相关商标。3 月,被称为“元宇宙第一股”的 Roblox 在纽交所上市,首日市值突破 400 亿美元,让资本市场看到了这个赛道的想象空间。

与元宇宙的喧嚣形成对比的,是中国互联网行业的监管风暴。4 月 10 日,阿里巴巴因“二选一”垄断行为被罚 182.28 亿元,创下中国反垄断罚款纪录。7 月 滴滴 赴美上市后旋即遭遇网络安全审查,App 下架整改。7 月 24 日 “双减” 政策落地,教培行业一夜入冬——新东方好未来 等巨头股价腰斩,很多程序员朋友也在这一年经历了“教育行业大逃离”。这一年也被称为中国平台经济的“反垄断元年”,《国务院反垄断委员会关于平台经济领域的反垄断指南》 在 2 月正式发布,全年反垄断处罚案例超过 120 起。

GitHub Copilot 在 6 月以技术预览版亮相,第一次让“AI 写代码”从概念变成了能用的工具——虽然当时只支持 VS Code,但已经让很多人惊呼“这才是未来”。Rust 继续蝉联 Stack Overflow 最受喜爱编程语言,Rust Foundation 也在 2 月正式成立,让 Rust 的治理更加正规化。Tailwind CSS 持续流行,“原子化 CSS”成为新的设计范式;Vite 2.0 在 2 月正式发布,成为前端构建工具的新宠。

前端与全栈方向,Next.js 12Remix 把“全栈框架”的叙事推到台前;Astro(静态站点生成器,”岛屿架构”、零 JS 默认、多框架组件)在这一年开始崭露头角;pnpm(硬链接共享 node_modules、严格依赖隔离、workspace 支持)凭借高效的硬链接和严格的依赖管理迅速流行,很多项目开始从 npm/yarn 迁移;Playwright(微软出品的 E2E 测试框架,跨浏览器、Auto-wait、Codegen 录制)成为 Cypress 的有力竞争者;Tauri(Rust + Web 构建桌面应用)作为 Electron 的轻量替代开始被更多人关注。后端方面,Prisma(TypeScript ORM)在 Node.js 项目中越来越流行;NocoDB(开源 Airtable 替代)、Appwrite(开源 BaaS)也在这一年获得大量 Stars。DevOps/工具方向,Fig(终端自动补全)开始在开发者圈子里流行;GitHub CLI 持续完善,gh 命令成为很多人的日常。

对 iOS 开发者来说,2021 还有一条“业务强相关”的暗线:隐私合规。4 月 iOS 14.5 正式启用 App Tracking Transparency(ATT)(框架文档:AppTrackingTransparency,官方时间点见:Upcoming requirements: App Tracking Transparency),IDFA(ASIdentifierManager)不再是“默认可用”,广告归因更多要转向 SKAdNetwork。这不是纯技术话题,却直接决定了很多 App 的增长、投放与变现策略,也让“上线前对照清单做适配”变成了当年的新常态。

9 月 14 日,苹果发布 iPhone 13 系列:A15 仿生芯片、电影效果模式(Cinematic Mode)、更小的刘海、更长的续航,以及 Pro 系列的 ProMotion 120Hz 自适应刷新率——那段时间朋友圈被“丝滑”刷屏。但真正让开发者沸腾的是 10 月 18 日发布的 MacBook Pro(14/16 英寸)M1 Pro 和 M1 Max 芯片把性能拉到新高度,统一内存最高 64GB,MagSafe 充电回归,HDMI 和 SD 卡槽回归,刘海屏设计虽然争议不断但 Liquid Retina XDR 显示效果确实惊艳。这一代 MacBook Pro 成为很多开发者“等了五年终于换机”的答案——Touch Bar 终于退场,剪刀脚键盘和实体功能键回归,编译速度提升肉眼可见。4 月还发布了搭载 M1 芯片的 iMac(24 英寸),七彩配色让桌面焕然一新。

WWDC 2021 依旧以线上形式举办,但内容密度丝毫不减。这届 WWDC 最重磅的发布当属 Swift 5.5 带来的全新并发模型:async/await 让异步代码终于能像同步代码一样写,告别回调地狱;Actor 用类型系统保护共享可变状态,让并发安全成为编译期保证;Structured ConcurrencyTaskTaskGroupasync let)让并发任务的生命周期管理更可控。这套并发系统从提案(SE-0296SE-0306 等)到落地,经历了数年讨论,终于在 2021 年正式交付——这也是 Swift 自 2014 年发布以来最重大的语言特性变化之一。

iOS 15 / iPadOS 15 带来了 SharePlay(同播共享,可以和朋友一起看视频、听音乐)、Focus Modes(专注模式,按场景过滤通知)、Live Text(实况文本,图片里的文字可以直接复制)、重新设计的通知系统和天气 App。macOS Monterey 则带来了 Universal Control(通用控制,一套键鼠控制多台设备)、AirPlay to Mac、以及 Safari 的标签页组等功能。开发者工具方面,Xcode Cloud 作为苹果官方的 CI/CD 服务首次亮相,Object Capture(3D 扫描 API)让大家开始畅想苹果眼镜的未来。

SwiftUI 3.0 这一版补齐了很多实用能力:AsyncImage(异步加载图片)、refreshable(下拉刷新)、searchable(搜索框)、swipeActions(滑动操作)、listRowSeparator(分隔线控制)、confirmationDialog(确认对话框)等修饰符让很多以前需要 UIKit 桥接才能实现的功能变得原生可用。AttributedString 也在这一版正式落地,配合 Text 可以直接渲染富文本。同时,SwiftUI 与 async/await 的集成也非常自然——task 修饰符让在视图里发起异步请求变得优雅。Swift Playgrounds 4 也在年底发布,第一次可以在 iPad 上直接开发并提交 SwiftUI App 到 App Store。

同届 WWDC 还带来了 StoreKit 2(更现代的订阅与交易 API)、以及更完善的订阅状态与校验链路(配合 StoreKit Transaction / VerificationResult 这类类型安全的表达)。对做会员/订阅的 App 来说,这是 2021 另一个“看起来是框架升级,实际是商业底座升级”的变化。

2021 年下面几篇值得一读的文章:

  • 官方的 Swift 5.5 Released! 把 Swift Concurrency 的主线(async/await、structured concurrency、Actors)以及一系列配套提案(Objective-C 并发互操作、continuations、Task Local Values、async let)串在一起,读完能对“哪些写法是语言保证、哪些仍是约定俗成”有清晰边界;中文圈里喵神的 Swift 结构化并发 从 goto 语句与结构化编程的历史切入,延伸到任务树的父子关系、withUnsafeCurrentTask 的取消/优先级语义,以及 withTaskGroup + for await 的收束方式,把取消传播(cancellation propagation)和错误传播如何沿任务树自动级联讲得很透;avanderlee 的 Actors in Swift 则以 data race 为切入口,解释 actor 的隔离状态(isolated state)为什么要求 await 跨边界访问、如何用 nonisolated 放开只读成员,以及如何减少不必要的 suspension point(比如避免重复 await)来兼顾安全与性能。
  • Majid 的 Mastering AsyncImage in SwiftUI 基本是最常被抄作业的一篇,从多个 initializer 入手讲清楚怎么拿到下载后的 Image 去做 resizable/contentMode/clipShape 等定制,再用 AsyncImagePhase 组织 loading/success/failure 的状态机,配合 placeholderTransaction 把过渡动画也一并管起来;中文圈里喵神的 TCA - SwiftUI 的救星? 从 Elm 架构启示讲起,把 Feature 拆解为 State/Action/Reducer/Store(以及 Environment、Effect)、ViewStore/WithViewStore 的观测方式、TestStore 的断言式测试与 .scope 的切分策略串成一条线,让“单向数据流 + 副作用 + 可测试”在 SwiftUI 里有了更可落地的形态;肘子的 用 Async-Await 重建 SwiftUI 的 Redux-like 状态容器 则把 Store 提升到 @MainActor 的语义边界,用即发即弃的 Task 管副作用生命周期,并在副作用方法中返回 Task<Action, Error>,把原本需要 Combine Publisher/订阅管理的样板代码收敛到更简洁的 async/await 写法。
  • ATT 适配踩坑看 @samwize 的 Pitfall: ATT prompt not showing,它指出 iOS 15 起如果在 launch 阶段、applicationState == .inactive 时调用 ATTrackingManager.requestTrackingAuthorization,可能导致系统弹窗不出现并引发审核问题;更稳妥的做法是把触发点放到 applicationDidBecomeActive(或延迟一拍)再请求;StoreKit 2 看 wwdcbysundell 的 Working with in-app purchases in StoreKit 2,除了 Product/Transaction/VerificationResult 的类型安全链路,还覆盖了 Xcode 的 StoreKit configuration file 测试、purchase() 的多状态结果处理、Transaction 的 async sequence 监听(pending/更新)、currentEntitlements + revocationDate 的权益判断,以及应用内发起退款流程的 API。
  • WWDC21 的 Ultimate Application Performance Survival Guide 把 MetricKit、Instruments、Xcode Organizer 等性能指标和工具链串成闭环。国内大厂的输出同样值得关注:字节的《抖音 iOS 启动优化实战》从分阶段埋点监控讲起,详解 +load/initializer 调用顺序与链接顺序的关系、Runloop 回调时机(kCFRunLoopBeforeTimers)、CA::Transaction::commit 首帧检测等无侵入方案;《OOM 崩溃率下降 50%+》则从 iOS 的 Jetsam 内存管控机制切入,把 FOOM/BOOM 区分、pageSize/rpages 内存页计算、XNU 内核清理策略讲透;腾讯的 Matrix 以插件化 APM 覆盖卡顿、FPS、内存等维度;滴滴的 DoKit 提供端内调试面板;美团的 cocoapods-hmap 编译提速 用 Header Map 技术替代传统 Header Search Path,通过 .hmap 头文件映射表减少文件 IO、解决 400+ 组件工程的编译瓶颈——这些都把”性能问题”做成了可工程化落地的方案。
  • 更多阅读可参考 老司机 iOS 周报 的 2021 年归档,以及 SwiftGG 的中文翻译。

2021 年开源社区也很活跃。并发相关的项目开始涌现,比如 Apple 官方的 swift-async-algorithms(异步序列算法库,提供 merge/zip/combineLatest/debounce/throttle 等操作符)开始酝酿;底层与系统编程方向,Apple 也把 swift-atomics 推到 1.0(原子操作与无锁并发基石),并在 Swift Forums 宣布 Swift System 1.0swift-system)发布,让 Swift 在“系统接口/文件描述符/路径”这层也开始有了更标准的抽象。工程化工具方面,SwiftFormatSwiftLint 持续是代码规范的首选;XcodeGen(YAML 定义项目结构、生成 .xcodeproj)和 Tuist(Swift DSL 项目描述、依赖图可视化、模块化缓存加速)在项目配置管理上各有拥趸;Kingfisher 5.15 / 6.0 版本持续更新,适配 async/await;Alamofire 也在这一年开始适配 Swift Concurrency。SPM(Swift Package Manager)的使用也在这一年显著增长,越来越多的库开始优先甚至只支持 SPM。

文档与基础设施也在补齐:WWDC21 推出的 DocC 文档系统(基于 Markdown、支持教程与 API 文档、Xcode 集成)以及随后开源的 swift-docc / swift-docc-render 让”文档即代码”更像官方正统;同年 Apple 还开源了 swift-collections(Deque/OrderedSet/OrderedDictionary 等)与 swift-markdown(Markdown 解析),Swift 的基础设施越来越厚。

Apple Design Award 2021 的获奖者很有代表性:Voice Dream Reader(文字转语音,无障碍设计典范)、Pok Pok Playroom(儿童创意应用)、CARROT Weather(毒舌天气,年度 Apple Watch App)、原神(视觉与图形奖)、NaadSadhana(AI 音乐创作)、英雄联盟手游(创新奖)。年底 Apple 发布的 App Store Awards 2021:iPhone 年度 App 是 Toca Life World(儿童创意游戏)、iPad 年度 App 是 LumaFusion(专业级视频剪辑)、Mac 年度 App 是 Craft(原生文档工具,凭借丝滑体验成为 Notion 的有力竞争者)、Apple Watch 年度 App 是 CARROT Weather。游戏方面,iPhone 年度游戏是英雄联盟手游、Apple Arcade 年度游戏是 Fantasian(坂口博信新作)。Apple 还把“年度趋势”定为“Connection”(连接),致敬那些在疫情期间把人们连接在一起的应用,比如 Among Us!BumbleCanva 等。

iOS 上值得关注的应用还有:Widgetsmith 延续上一年的热度,小组件定制依然火爆;Day One(日记应用)获得了更多用户;Craft 凭借年度 Mac App 的声誉在 iOS 上也收获了大量用户;GoodNotes 5Notability 继续是 iPad 手写笔记的双子星(虽然 Notability 年底宣布转为订阅制引发争议);Telegram 在隐私意识觉醒的背景下用户量持续增长;Discord 从游戏社区扩展到更广泛的兴趣社群。

Mac 端,Raycast 在这一年持续迭代,扩展生态越来越丰富,正式成为 Alfred 的强力竞争者——很多人是在这一年完成了从 Alfred 到 Raycast 的迁移;Obsidian 的双向链接笔记在开发者圈子里持续流行,成为知识管理的新范式,社区插件生态也在这一年爆发式增长;Craft 凭借原生 Swift 开发、精美设计和流畅体验获得年度 Mac App,也让很多人开始重新审视”原生 vs Electron”的选择;Logseq(开源双向链接笔记)作为 Obsidian/Roam 的开源替代也开始被更多人关注。

还有一类“低调但装机率极高”的小工具:视频播放器 IINA;键盘改键神器 Karabiner-Elements;外接显示器亮度控制 MonitorControl;以及菜单栏系统监控 Stats 等——它们不一定上榜,但很多人的 Mac 没它们会少点顺手。

效率工具方面,CleanShot X 在截图和录屏领域越来越强大;Bartender 4 继续是菜单栏管理的不二之选;Rectangle(开源窗口管理)已经基本取代了停更的 Spectacle;Paste(剪贴板管理)和 PopClip(文本增强)仍是很多人的装机必备;Fantastical 继续是日历应用的标杆;Things 3OmniFocus 3 依然是 GTD 工具的两大顶流;Linear(项目管理)凭借流畅的体验和现代化的设计在科技公司中迅速流行。

开发工具方面,Proxyman 持续是网络调试的首选,2.0 版本带来了更多专业功能;TablePlus 在数据库管理上越来越成熟;Warp(基于 Rust 的现代终端)开始内测,虽然要等到 2022 年才公开发布,但已经在开发者圈子里引发期待;iTerm2 依然是很多老用户的选择;VS Code 在 M1 Mac 上的原生支持让体验更加流畅,Remote Development 功能也让远程开发成为日常;JetBrains 全家桶在这一年也完成了 Apple Silicon 原生适配。设计工具方面,Figma 持续蚕食 Sketch 的市场份额,“协作设计”成为主流;Framer 转型为无代码建站工具后也获得了新生。

这年我研读了下 QuickJS 代码,写了篇 深入剖析 JavaScript 编译器/解释器引擎 QuickJS - 多了解些 JavaScript 语言 从 JavaScript 与作者 Fabrice Bellard 的背景切入,解释 QuickJS 如何用约 210KB 的体量覆盖最新 ECMA-262,并通过 Test262 把兼容性“跑出来”;再从工程视角补上 makefile、qjsc、字节码、以及如何用 Xcode 编译安装和调试源码。

另外我还写了篇 我写技术文章的一点心得:用“熵增/逆熵”的视角,把写作拆成“独特性、真实感、故事性、新意”四个维度,并给出从记录→归类→搭骨架→完善与包装的四步法,以及一套写完后的自检问题清单与常用工具链。

2021 这一年是“并发元年”——Swift 5.5 带来的 async/await 和 Actor 模型,从根本上改变了 iOS/macOS 异步编程的范式,影响将在接下来几年持续发酵。M1 Pro/Max 的发布让 Apple Silicon 从“够用”变成了“真香”,很多开发者在这一年完成了设备换代。

这一年我也开始更认真地关注 SwiftUI 的演进——虽然业务代码还是以 UIKit 为主,但 SwiftUI 明显在补齐短板,感觉“下一个项目可以试试”的时机越来越近了。年末的时候,大家还在讨论元宇宙能不能成,没人预料到一年后的 ChatGPT 会让整个行业的话题彻底转向。

2022年:AI 元年与 SwiftUI 工程化

2022 年是新旧时代的分水岭:一边是生成式 AI 的点火,把“写代码/写内容”的方式推向新范式;另一边是 SwiftUI 4、Swift Concurrency 与 Xcode 14 把苹果生态的工程化底座补齐。沿着这两条主线回看,会更容易理解这一年为什么重要。

11 月 30 日,ChatGPT 发布,两个月内用户数突破 1 亿,成为互联网历史上增长最快的应用。我此前一直在使用 GitHub Copilot,但 ChatGPT 带来的震撼更强——它像一个随叫随到的“知识型同事”,能即时回答各种技术问题,也能帮你把思路梳理成可执行的路径。

把时间线往前拨,图像生成领域迎来三巨头:3 月 Midjourney 面世、4 月 DALL·E 2 发布、8 月 Stable Diffusion 开源。Stable Diffusion 作为 latent diffusion(LDM)路线的代表,通过 CLIP 文本编码、U-Net 去噪与 VAE/autoencoder 的潜空间压缩组合把“文生图”落到可运行的工程形态;围绕它的生态(如 AUTOMATIC1111/stable-diffusion-webui)也在几个月内迅速成熟,把 txt2img/img2img、inpainting/outpainting 这类生成模式,以及 negative prompt、seed、采样器与 guidance scale(引导强度)等常用调参入口做成可复用的本地工作流,让“人人都能本地跑图”成为现实。

语音领域同样取得突破:9 月 OpenAI 开源 Whisper,它是基于 Transformer 的 sequence-to-sequence 多任务模型,不只覆盖多语言语音识别/转写,也支持语音翻译(speech translation)和语种识别等任务;配合 ffmpeg 等基础依赖,用命令行或脚本就能在本地按滑动的 30 秒窗口做推理,把“语音转文字”真正接进内容生产、检索与质检等产品工作流。

科技行业迎来一轮 互联网寒冬。10 月,马斯克以 440 亿美元完成对 Twitter 的收购后,立即裁员 50%;随后 Meta、亚马逊、Salesforce 等巨头相继宣布裁员或冻结招聘,国内互联网公司也在“降本增效”。11 月 FTX 交易所暴雷,加密货币市场一夜入冬。

监管层面,欧盟 9 月正式通过 Digital Markets Act(DMA),“iOS 是否要开放侧载和第三方应用商店”开始从讨论变成实打实的时间表。

12 月国内疫情政策调整,刘畊宏毽子操和“羊了个羊”小游戏成为居家期间的集体记忆。

系统编程领域,Rust 持续崛起,Linux 内核开始接受 Rust 代码,Rust 连续第七年蝉联 Stack Overflow 最受喜爱编程语言。

JavaScript 生态迎来多个重要发布:6 月 Bun(Zig 编写的 JS 运行时,内置打包器/测试/包管理)以极快的启动速度震动社区;10 月 Next.js 13 引入号称比 Webpack 快 700 倍的 Turbopack(Rust 编写的增量打包器)。桌面应用框架方面,6 月 Tauri 1.0 正式发布(Rust 后端 + Web 前端、调用系统 WebView、极小体积),以极小体积和原生性能成为 Electron 的有力替代;8 月 Astro 1.0 带来“岛屿架构”,让静态站点也能拥有局部交互。

几个“生态级版本号”也在这一年集体换挡:3 月 React 18 将并发渲染带入主流;5 月 Flutter 3 完成“六端稳定”拼图;10 月 Python 3.11 以解释器性能提升出圈;12 月 SvelteKit 1.0 落地,“更轻的框架 + 更快的构建”成为前端热议话题。

这些变化发生在整个技术世界,而苹果生态在 2022 的关键词则更集中:iOS 16 把“入口”推到锁屏与灵动岛,SwiftUI 4 把“导航”重写,Xcode 14 把“诊断”下沉成可闭环的工具链。

9 月 7 日,苹果发布 iPhone 14 系列:Pro 系列首次采用“灵动岛”(Dynamic Island)取代刘海,把“挖孔”变成“交互入口”;同时首次支持 48MP 主摄、Always-On 显示,以及卫星紧急求救(SOS)功能。同期发布的 Apple Watch Series 8 新增体温感应和车祸检测,Apple Watch Ultra 则瞄准户外极限运动市场;AirPods Pro(第二代) 以两倍降噪和自适应通透模式再次刷新真无线耳机体验。

10 月,苹果更新 iPad 线:搭载 M2 芯片iPad Pro 与全新设计的 iPad(第十代) 一起发布——iPad 终于告别了 Lightning 接口。

WWDC 2022 对开发者来说干货满满。iOS 16 最大的亮点是 锁屏小组件(Lock Screen Widgets)和高度可定制的锁屏界面——开发者可以通过 .accessoryInline.accessoryCircular.accessoryRectangular 三种新 widget family 把信息直接展示在锁屏上。macOS Ventura 带来了 台前调度(Stage Manager)和 接力相机(Continuity Camera)。

同样在 WWDC22 上被推到台前的还有 Passkeys:这套基于 WebAuthn/FIDO 的公钥凭证体系,把“登录”从共享口令变成每个账号一对公私钥(公钥上送服务端、私钥留在设备/iCloud Keychain,解锁依赖 Face ID/Touch ID 等生物识别)。落到 iOS 工程实现时,通常会通过 AuthenticationServices 的 ASAuthorizationPlatformPublicKeyCredentialProvider 发起 registration/assertion(challenge → 注册/断言)的闭环,并且必须配置 associated domains(webcredentials)才能在 App 与 WKWebView 场景里跑通——因此很多团队也第一次认真评估“短信验证码/传统密码”之外的身份体系。

Swift 5.7 在并发和类型系统上继续演进:正则表达式字面量和 RegexBuilder 让字符串处理更加直观(配合 Swift Regex);分布式 Actor(Distributed Actors)简化了分布式系统开发;if let 简写、不透明参数类型、泛型改进等让日常代码更简洁。配合 iOS 16 在 dyld/Swift runtime 上的优化(见 WWDC22 110363),大型 Swift App 的启动时间也能获得显著改善。

SwiftUI 4.0 是这一版最大的亮点:NavigationStackNavigationSplitView 取代了旧的 NavigationView,带来数据驱动、可编程的导航体验,终于解决了困扰开发者多年的导航难题;GridLayout 协议让自定义布局更加灵活;ShareLinkPhotosPickerGaugeMultiDatePicker 等新组件补齐了很多业务场景。

2022 年苹果还首次开放了几个“业务级”框架:Swift Charts 让绑定数据画图表变得和写 SwiftUI 视图一样简单,UI 与苹果自家 App(如股票、健康)一模一样;WeatherKit 提供了与系统天气 App 相同的高精度天气数据(每月 50 万次免费调用);Live ActivitiesActivityKit 则让动态数据能够出现在锁屏和灵动岛上。这些框架的出现,让开发者真正能够“站在巨人肩膀上进行创作”。

如果想从官方口径弄清“这一年到底变了什么”,语言层面 Swift 团队用 Swift language updates from WWDC22 把 WWDC22 的语言演进讲成一条清晰的脉络,随后 Swift 5.7 Released! 再把并发、类型系统与正则等变化落到版本发布说明上;同时大家也会借助“总览型复盘”来快速补课,比如 WWDC 2022 Platform State of the Union Recap 或中文圈的 WWDC2022-iOS 篇完全解析,用更低的时间成本把 session 重点串起来。工具链与工程侧的关键词也因此浮出水面:从 SPM 的依赖治理,到 Xcode 14 的日常升级,再到 dyld 相关的启动优化(dyld/启动优化(WWDC22 110363)),都在提醒团队:新系统版本的价值,很多时候是“性能与工具链”这种看不见但长期收益很高的底座改进。

同样在工程与工具链侧,我也把之前偏“原理向”的 LLVM/Clang 学习,整理成更偏“怎么用”的实践笔记《使用 LLVM》:从 llvm-cov/llvm-profdata 的 source-based coverage,到 SanitizerCoverage 的插桩回调,再到自制 Pass 控制插桩范围,把“函数/基本块级执行证据”真正跑出来,用来做代码新陈代谢(识别无用函数/路径、辅助瘦身与启动治理)并形成可灰度验证的闭环。

另外一个偏工程化但很实用的变化是 Swift 5.6 把 Swift Package Manager 的可扩展性补齐:通过 SwiftPM Plugins(Build Tool / Command Plugins,对应 SE-0303/SE-0332)把代码生成、格式化、Lint、文档构建(比如 Swift-DocC plugin)这类任务收敛到 swift package/IDE/CI 的同一条流水线上,很多团队也因此把原来散落在脚本里的工具链变成可复用、可审计的插件。

2022 其实是 Apple 也很重视“卡顿/无响应(hang)”:

并发性能也在 2022 被官方“扶正”。Instruments 14 新增的 Swift Concurrency template 能把 Swift Tasks/Actors、Task Forest、Actor queue contention 等信息直接可视化,定位“为什么 UI 不响应”“为什么并行没跑起来”这类问题的效率大幅提升(Visualize and optimize Swift concurrency(WWDC22 110350);Swift 团队在 Swift language updates from WWDC22 里也专门提到这套新模板)。从这个角度看,Swift Concurrency 的落地不仅是语法迁移,更是工具链把“并发正确性/并发性能”纳入日常工程的一次升级。

字节跳动的 DanceCC 工具链系列把“调试性能”这种经常被忽视但极影响效率的痛点掰开揉碎:一方面给出《Swift 调试性能的优化方案》(2022-05-07),从 po/p/v 命令的工作流程(IR 编译、JIT 执行、Dynamic Type Resolve 通过 remoteAST 或 Swift Reflection 解析内存布局)讲起,针对大型 Swift 项目”厚主二进制”(主二进制超 1GB、数百个 Swift Module)的场景,通过关闭 swift-typeref-system / swift-dwarfimporter 开关、修复静态链接库误用 dlopen 的 O(N×M) 搜索问题、用 fstat 前置判断优化 External Module 查找路径、增加 Symbol File 共享缓存等手段,把断点陷入后变量显示耗时从分钟级别压缩到秒级;另一方面又补上《Xcode LLDB 耗时监控统计方案》(2022-09-07),通过 LLDB Plugin 机制注入动态库、用 Fishhook 拦截 lldb-rpc-server 对 LLDB.framework 的 Script Bridge API 调用(如 SBFrame::GetVariables、SBValue::GetChildAtIndex),在调用前后打点计时,配合 OverrideCallback 拦截 expr/po/p 命令、log timers dump 采集极端耗时场景的堆栈,形成”怎么把耗时测准、测全、持续测”的完整监控体系。另一条更偏线上治理的路径则由微信团队的 Matrix 代表:把卡顿、内存(FOOM)、FPS 等问题做成插件化 APM,形成“监控→取证→归因→修复→回归”的标准链路(例如他们对 RunLoop 卡顿监控与耗时堆栈提取的工程细节公开得非常彻底:Matrix-iOS 卡顿监控;内存/FOOM 体系同样是工业级长文:Matrix-iOS 内存监控)。

把“新范式”落进真实工程,而 2022 最典型的落点就是 SwiftUI 4 的导航体系。围绕 NavigationStack / NavigationPath / NavigationSplitView,大家关心的不再是“能不能写页面”,而是“复杂业务流如何组织、怎么做程序化导航、怎么适配 iPad 与 Stage Manager”。国际圈里,What is new in SwiftUI after WWDC22What’s New in SwiftUI 4 for iOS 16 负责把新增 API 的覆盖面铺开;当讨论进入“怎么做”的层面,Essential Developer 的两篇文章(Programmatic iOS App Navigation with SwiftUI NavigationLink (like in UIKit!) / How senior iOS devs do SwiftUI navigation programmatically (Patterns & Flows))把复杂导航抽象成可复用的 patterns/flows。中文圈也几乎同步给出了“能直接抄作业、还能讲清取舍”的落地版本,比如 SwiftUI 4.0 的全新导航系统 把概念和 API 迁移讲透,肘子的 在 SwiftUI 中创建自适应的程序化导航方案 则更贴近真实项目里 Stack/SplitView 的决策过程——从 NavigationPath(对 Hashable 数组的包装)如何在”栈”中推送和弹出数据,到 NavigationSplitView 与 List(selection:) 的深度绑定(只有用 List 修改状态才能在自动转换后保持程序化导航能力),再到如何用 horizontalSizeClass 判断当前视觉模式、在台前调度(Stage Manager)场景下根据 onAppear 时机在 NavigationStack 与 NavigationSplitView 之间平滑切换状态,把”一次编写适配多设备”这个 SwiftUI 承诺落到了可执行的工程方案里。年末再用 Point-Free 的 2022 Year-in-Review 做一次回看,很多团队会更容易判断:哪些写法该沉淀成规范,哪些只是阶段性过渡。

而在 SwiftUI 侧,我在 3 月受 Apple 加速器 SwiftUI 活动邀请做了一次分享《在苹果加速器活动做的 SwiftUI 开发分享》,并把幻灯片做成可交互 demo(支持解释执行代码片段)。内容既包含 iOS 常用组件的系统梳理,也把视角扩展到了 macOS:多栏导航(Sidebar/Detail)、Toolbar 的语义化布局、树形目录(List(children:)/DisclosureGroup)、文本/代码编辑器(NSTextView + NSViewRepresentable)、Grid 与一些视觉效果。对我自己而言,这次“把内容讲清楚”的过程也像一次工程化演练:把零散的 API 经验抽成可复用的组件清单和 demo,便于后续在真实业务里评估与落地。

与 SwiftUI 4 并行推进的,是并发体系继续从“语言特性”变成“工程规范”。Swift Concurrency 相关的讨论不再停留在语法层,而是围绕 Actor / Sendable 这些约束,以及“任务取消”这种容易被忽略的工程细节(在 async 流程里及时检查取消信号),去建立团队层面的并发边界与 code review 共识;avanderlee 的 The 5 Biggest Mistakes iOS Developers Make with Async/Await 之所以被反复引用,本质上是它把迁移期最容易翻车的细节讲得很“可对照”:把 for await 异步序列当普通循环、误以为 async 默认在后台执行导致 UI 卡死、忽略取消检查让请求/计算白跑、手工迁移旧代码导致行为与原来不同,以及在 onAppear 里随手创建 Task 造成重复请求等。与此同时,Swift 5.7 把 RegexBuilder 推到标准库能力里,也让不少“原来靠三方/靠手写”的字符串处理场景有了更可维护的表达方式。

iOS 16 把新的交互入口推到所有团队面前:锁屏与灵动岛。围绕 锁屏小组件ActivityKit / Live Activities,要做的事既包括“把展示形态跑起来”,也包括“把数据模型与生命周期管起来”。国际圈里,Lock screen widgets in SwiftUIDisplaying live activities in iOS16 把关键机制讲得很透——从 ActivityAttributes 协议如何把静态数据(如订单 ID)与动态数据(ContentState,如配送进度)分离,到 ActivityConfiguration 如何在 WidgetKit 中定义锁屏与灵动岛的展示视图,再到 8 小时自动超时 / 12 小时最大显示时长的系统限制、Info.plist 需添加”Supports Live Activities”键、前台启动后可用 BackgroundTasks 或 Push Notification 更新状态等工程细节;想走最短路径上手时,Dynamic Island (and Live Activities): Quick Start Tutorial 基本就是从配置到 ActivityConfiguration 的直达路线。中文圈对应的“落地链路”也很快补齐:从 Live Activities - Dynamic Island Dev 的工程配置与展示区域拆分,到 Live Activity - 创建你自己的灵动岛 App 的可抄作业实践,再到 iOS16适配指南之Live Activity 这种偏适配/排雷的总结,基本覆盖了从 demo 到业务上线会遇到的主要坑点。

2022 年 iOS/Swift 开源社区热度依旧:AlamofireKingfisherSnapKitRxSwift 等“老牌”库持续更新并逐步拥抱 Swift Concurrency;动画场景里 Lottie 依然是首选;在需要兼容 iOS 15 及以下系统时,Charts 仍是很多团队的现实选择(即使 Swift Charts 已发布);网络抽象层的 Moya 继续流行。工程侧,SwiftLintSwiftFormat 仍是团队标配;架构侧,swift-composable-architecture(TCA) 在 SwiftUI 项目中越来越受欢迎,Point-Free 团队持续迭代,swift-dependencies 也开始被独立使用,TCA 的 Navigation 支持在这一年趋于成熟。Swift Package Manager 的使用继续增长,越来越多的库开始优先甚至只支持 SPM。

Apple Design Awards 2022 的获奖者很有代表性:Procreate(多元包容)、Slopes(优越互动)、Halide Mark II(视觉图像)、(Not Boring) Habits(乐趣横生)、Rebel Girls(社会影响)、Odio(创新思维)。年底 Apple 发布的 App Store Awards 2022 中,iPhone 年度 App 是 BeReal——这款主打“真实社交”的 App 在年轻人中迅速走红,每天随机时间提醒你拍一张“此刻”照片的设计颇有新意;iPhone 年度游戏是 Apex Legends Mobile;iPad 年度 App 是 GoodNotes 5;Mac 年度 App 是 MacFamilyTree 9。中国区的年度 iPad App 同样是 GoodNotes 5,年度游戏是 《英雄联盟电竞经理》喝水羊驼 因帮助用户养成饮水习惯获得“文化影响力”表彰。

Mac 端应用,Raycast 在这一年持续迭代,扩展生态越来越丰富,成为很多人替代 Alfred 的首选;Arc 浏览器 在 4 月发布,垂直侧边栏和“万维网操作系统”的理念让人耳目一新,成为 Chrome 的有力竞争者;Warp 终端在 4 月公开发布,基于 Rust 构建、AI 辅助命令提示的设计让终端体验焕然一新;Figma 在 9 月被 Adobe 宣布以 200 亿美元收购,震动整个设计圈(虽然最终因监管问题未能完成)。效率工具方面,Obsidian 的双向链接笔记生态继续繁荣,10 月发布了 1.0 正式版,标签面板和 Canvas 画布功能让它更像一个完整的知识管理系统;Logseq 作为开源替代也在这一年获得更多关注;CraftNotion 在知识管理领域各有拥趸;CleanShot X 成为截图录屏的标杆;ProxymanTablePlus 在开发工具领域持续流行。开发者工具方面,GitHub DesktopForkTower 在 Git 客户端中各有用户群;Cursor 在这一年开始进入早期开发者的视野,AI 辅助编程的理念已经萌芽。菜单栏工具方面,Bartender 4Hidden Bar(开源)继续是管理菜单栏图标的首选;iStat Menus 在系统监控领域依然强大;PasteMaccy(开源)在剪贴板管理上各有拥趸。

微信 在这一年也开始尝试在搜一搜、视频号等场景融入更多 AI 能力。随着 Stable Diffusion 等 AI 图像生成模型的开源,国内也涌现了一批 AI 绘图 App,如 意间AI绘画6pen 等,“AI 作画”成为年度热词。居家期间,Keep 配合刘畊宏直播再次迎来用户增长高峰;得物(毒 App)在年轻人中持续流行;得到 在知识付费领域仍有忠实用户群。Locket Widget 把朋友的即时照片直接挂在桌面/小组件上,简单到极致,却又足够上头。

Bear 发布了 Panda 编辑器(Bear 2.0 预览版),新的表格和代码块支持让人期待。1Password 8 在这一年发布,虽然争议不断(Electron 化),但密码管理的核心功能依然强大;Drafts 继续是“文字处理的起点”;Carrot Weather 的毒舌天气依然有趣;Overcast小宇宙 在播客领域各有拥趸。

2023年:AI 常态化与空间计算登场

科技行业这一年的大事件不少:7 月 Twitter 正式更名为 X,小蓝鸟图标成为历史,API 政策的收紧也让不少第三方客户端开发者黯然离场;10 月微软完成了对 动视暴雪的 687 亿美元收购

影视方面,《漫长的季节》 写尽时代洪流下的普通人命运,《繁花》 在年末开播;《长安三万里》 用动画写李白与盛唐。生活层面,“淄博烧烤”和“酱香拿铁”先后出圈,年末董宇辉与东方甄选的“小作文”风波引爆舆论。

这些更像 2023 的背景噪声;真正改写这一年底色的,还是 AI。

3 月 14 日,GPT-4 发布(可参考其技术报告:arXiv:2303.08774)。多模态能力让它在律师资格考试中的成绩从 GPT-3.5 的“垫底”跃升到前 10%,“AI 能做什么”的边界被重新定义。

ChatGPT 从 2022 年底的现象级产品,在这一年彻底渗透进开发者的日常:写代码、查文档、头脑风暴、改 Bug,很多人发现“先问 GPT”已经成了新的肌肉记忆。12 月,GitHub Copilot Chat 正式面向所有订阅用户开放,AI 辅助编程从“尝鲜”变成了“标配”。

AI 图像生成领域,Midjourney V5 在 3 月发布,画面质量再上一个台阶;Stable Diffusion XL 也在这一年推出,开源社区围绕它构建了更成熟的工具链(如 ComfyUI,节点式可视化工作流)。语音领域,基于 Whisper 的本地化方案开始普及,“语音转文字”变得前所未有地简单。

AI 领域的竞争格局在这一年急剧变化:

  • 7 月,Anthropic 发布 Claude 2,100K 上下文窗口让它在处理长文档时大放异彩。
  • 同月,Meta 开源了 LLaMA 2,从 7B 到 70B 参数的全系列模型让开源社区沸腾——第一次有顶级大厂把这种级别的模型以更可复用的形式交到开发者手里。
  • 9 月,Mistral AI 发布 Mistral 7B,用更小的参数量达到了 LLaMA 2 13B 的水平,“小模型大智慧”的路线开始被认真对待。
  • 11 月 6 日,OpenAI 在首届开发者大会 DevDay 上发布 GPT-4 Turbo(128K 上下文、更便宜的价格),并推出“自定义 ChatGPT”的 GPTs 功能。
  • 11 月的 OpenAI 董事会风波也让全球科技圈目瞪口呆——Sam Altman 被董事会解雇、员工集体威胁离职、微软抛出橄榄枝,最后 Sam Altman 在短短 5 天内回归并重组董事会。
  • 12 月,Google 发布 Gemini,声称在某些基准测试上超越 GPT-4,AI “三国杀”正式开打。

AI 相关的开源项目也迎来井喷式爆发,很多“做 LLM 应用需要的基础积木”在这一年被迅速补齐:

  • 应用框架:LangChain(Python,链式调用、Prompt 模板、Memory 上下文、Agent 工具调用)和 LangChain.js(JavaScript)成为构建 LLM 应用的首选框架,“链式调用”的思路让复杂的 AI 工作流变得可组合、可调试。
  • Agent 概念:AutoGPT 在 4 月横空出世,让 AI 自主完成任务链的概念一夜爆火,虽然实用性有限,但它打开了“AI Agent”的想象空间。
  • 本地运行:llama.cpp 让大模型可以在普通 CPU 上运行,配合 GGUF 格式和量化技术,“本地部署 LLM”从奢望变成现实;基于它的 Ollama 更是把本地运行大模型做到了“一行命令启动”的简洁。text-generation-webui 提供了开箱即用的 Web 界面,让非技术用户也能体验本地 AI。
  • 私有数据与 RAG:PrivateGPT 让“本地知识库问答”成为可能——把文档丢进去,AI 就能基于你的私有数据回答问题。向量数据库领域,ChromaMilvusQdrant 都在这一年获得大量关注,RAG(检索增强生成)架构成为 LLM 应用的标配。
  • 图像工作流:ComfyUI 的节点式工作流让 Stable Diffusion 的使用更加灵活,Fooocus 则走简化路线,号称“Midjourney 开源平替”。

前端与全栈领域同样热闹。9 月,Bun 1.0 正式发布,这个用 Zig 写的 JavaScript 运行时号称比 Node.js 快数倍,内置打包器、测试运行器、包管理器,“all-in-one”的野心让人侧目。Next.js 14 带来了 Server Actions,让服务端函数可以直接在组件里调用,全栈开发的边界再次模糊。shadcn/ui 火遍前端圈,它不是一个组件库,而是“组件代码的集合”:把代码复制到你的项目里、随便改——这种“反框架”的理念让很多人眼前一亮。

6 月 5 日的 WWDC 2023 注定载入苹果史册——不是因为 iOS 17,而是因为 Apple Vision Pro 的发布。它把 AR/VR 的讨论从概念拉进了苹果生态,visionOS 成为苹果第六大操作系统。虽然 3499 美元的售价和 2024 年初才发售的时间表让很多人只能“云体验”,但苹果用 Reality Composer ProRealityKitARKit 的组合告诉开发者:空间计算的开发工具已经准备好了。WWDC 上发布的 visionOS SDK 在 6 月下旬向开发者开放,全球多地的 Vision Pro Labs 也让开发者提前上手真机。

对 macOS 开发者/玩家来说,WWDC 2023 还有一个被低估的惊喜:Game Porting Toolkit 让很多 Windows 游戏可以在 Apple Silicon 上更快跑起来(配合 Wine/CrossOver 社区),一度出现“Mac 也能玩 3A”的小高潮;这也为后续《死亡搁浅》《生化危机》等原生移植的讨论提前预热。

9 月 12 日,iPhone 15 系列发布,最大的变化是全系换装 USB-C 接口——Lightning 时代正式落幕。iPhone 15 Pro 和 Pro Max 搭载 A17 Pro 芯片(业界首款 3nm 手机芯片),USB 3 传输速度提升到 10Gbps,还新增了可自定义的 Action Button(动作按钮)取代静音开关。灵动岛(Dynamic Island)也从 Pro 系列下放到全系,成为 iPhone 的标志性交互。同期发布的 Apple Watch Series 9Apple Watch Ultra 2 带来了双指互点(Double Tap)手势,让单手操作更方便。

10 月,Apple 发布 M3 芯片家族,同时更新了 MacBook Pro 与 iMac。3nm 工艺、硬件光线追踪和 Dynamic Caching 让“Mac 图形性能”第一次有了更具体的工程指标,也直接影响到 Metal/渲染/游戏相关开发者的关注点。

iOS 17 带来了不少“小而美”的更新:StandBy 模式让横置充电的 iPhone 变成床头时钟;NameDrop 让交换联系方式只需碰一碰;Journal App 在年底上线,主打“记录生活中的小美好”。开发者侧,交互式小组件(Interactive Widgets)终于来了——用户可以直接在小组件上操作,不用打开 App;TipKit 让“功能引导”有了官方方案。macOS Sonoma 则把交互式小组件带到了桌面,还加入了屏幕保护壁纸和 Game Mode。

产品层面,苹果也在 3 月推出了 Apple Music Classical,把古典乐的曲库与元数据做成独立 App;5 月,Final Cut Pro for iPadLogic Pro for iPad 上线,“iPad 能不能当生产力主力机”又被讨论了一整年。

SwiftUI 5.0 的更新围绕“让现有能力更好用”展开:ScrollView 获得了大量新修饰符(如 scrollPositionscrollTargetBehaviorscrollTransition),终于能精细控制滚动行为;新的 Inspector API 简化了侧边栏检查器的实现;新的 Observation 框架配合 @Bindable@Observable 让状态管理更清晰;动画方面,弹簧动画(spring animation)成为默认,.animation 修饰符也支持了更细粒度的控制。MapKit for SwiftUI 也在这一年变得真正可用,不再只是“能跑”而是“能用到业务里”。

这届 WWDC 最让人兴奋的框架级更新当属 SwiftData——苹果用纯 Swift 重新设计的数据持久化框架。和 Core Data 相比,SwiftData 用 @Model 宏声明模型、用 @Query 宏获取数据、用 ModelContainerModelContext 管理存储上下文,语法更贴近 SwiftUI 的声明式风格,学习曲线也更平缓。官方的 Meet SwiftDataBuild an App with SwiftDataDive Deeper into SwiftData 三个 Session 基本覆盖了从入门到进阶的完整链路。当然,SwiftData 1.0 也有不少坑点(比如复杂关系的性能、CloudKit 同步的稳定性),但作为“Core Data 接班人”的第一版,它已经展示出了足够的潜力。

Swift 5.9 在 9 月随 Xcode 15 发布,带来了两个重磅语言特性:宏(Macros) 和 参数包(Parameter Packs)。宏分为独立宏(# 前缀)和附着宏(@ 前缀),让开发者可以在编译期生成代码,减少样板代码的同时保持类型安全。@Observable 宏就是最典型的应用——它让类自动获得观察能力,彻底取代了 ObservableObject + @Published 的组合。参数包则让泛型函数可以接受任意数量的类型参数,解决了之前需要写多个重载的尴尬。社区也迅速跟进,swift-power-assertswift-spyableMetaCodable 等基于宏的工具开始涌现。除了宏/参数包,Swift 5.9 也把“所有权模型”往前推了一步(borrowing/consumingconsume~Copyable 等),让性能敏感与系统级代码能更少拷贝、更少 ARC,虽然这些能力在 5.9 仍有不少限制,但方向已经很明确。

数据层的 SwiftData,状态层的 Observation 与语言层的 Swift Macros,界面层围绕 SwiftUI ScrollView 的“滚动补齐”、iOS 17 的 交互式小组件TipKit,再加上平台叙事的 visionOS;所有这些要落到工程里,最终又会回到 Xcode 15 Release Notes 这类“版本事实”。沿着这条主线回看社区文章,会读出一条很清晰的节奏:先在 WWDC 后快速建立全景与共识,再把新能力拆解到可抄作业的落地细节,最后回到工程化与架构稳定性。

WWDC23 的 Demystify SwiftUI Performance 把 SwiftUI 的依赖追踪、identity 与刷新成本讲清楚;Analyze Hangs with Instruments 则把“卡顿”拆成 Busy Main Thread / Blocked Main Thread / Async Hang 三类,用 Instruments 的 Time Profiler、Thread State Trace、Hangs 轨道(以及 SwiftUI 的 View Body 轨道)把证据链拉直。并发这条线也终于能被观察:在 Beyond the Basics of Structured Concurrency 里,task tree、取消与优先级不再只是语法,而是可被工具捕捉的运行时行为。

开发期用 Instruments 把点状问题定位清楚,上线后则要有“面状”的指标闭环:Apple 的 MetricKit 提供 MXMetricPayload / MXDiagnosticPayload 等系统聚合数据,把启动、CPU、内存、hang、崩溃等问题从“个案”变成“分布”;配合 iOS 15+ 的 OSSignposter(signpost)把关键路径打点,才有可能把优化变成可量化的迭代。社区侧也有不少踩坑贴把“性能陷阱”具体化,比如 iOS 17 新引入的 Observation 框架与 @Observable 宏在 SwiftUI 中的内存泄漏问题——Observation 为了追踪属性变化会对被追踪的引用对象维持强引用,而 SwiftUI 在 .sheet/.fullScreenCover dismiss 后的释放时机 bug 会让这些对象“该放不放”,尤其当被捕获对象较大时,内存占用很容易被滚到数 GB。bafford 在 2023-10-12 的 SwiftUI memory leak workaround 记录了该问题在 iOS 17.0..17.1b3 期间的表现,并给出一个 UIKit 兜底:通过在 environment 注入带 UIViewController 的 coordinator,把 sheet 的呈现交给 UIKit,并提供 leak_workaround_sheet / leak_workaround_fullScreenCover 这类 view modifier 来触发(该 bug 在 iOS 17.1 之后被修复)。另外,抖音/字节的“二进制重排(order file)做启动优化”也长期被工程团队复用:用 System Trace 在 VM Activity 里看 File Backed Page In(Page Fault)把“启动期缺页”量化出来,再结合 LinkMap 的 __TEXT,__text / __DATA,__mod_init_func 静态扫描与启动期 trace 生成 ld -order_file 的符号顺序,把热路径函数聚拢以减少 Page Fault;落地时还会用 os_signpost 给 Mach-O load / C++ 静态初始化等阶段打点,并用 -order_file_statistics 校验命中(可参考 InfoQ 的整理:抖音研发实践:基于二进制文件重排的解决方案,APP 启动速度提升超15%)。

社区围绕这些新能力产出了大量高质量内容。WWDC 后快速补课,Majid 的 What is new in SwiftUI after WWDC 23 用一篇把 SwiftUI 5 的重点更新串起来:数据流从 Combine 走向 Observation(@Observable 宏、@Bindable、environment 注入),动画补齐了 PhaseAnimator 与带 completion 的 withAnimation,ScrollView 也终于能通过 scrollPosition/scrollTargetBehavior 做更可控的滚动定位与分页行为;同时像 ContentUnavailableView、List 间距控制、#Preview 宏等“小但常用”的改动也都点到。中文圈可以先看 fatbobman 的 WWDC 23,SwiftUI 5 和 SwiftData 的初印象 来校准预期:他一方面从 @Observable 的“按属性粒度响应变化”切入,解释这套新数据流对过渡计算等性能痛点的意义;另一方面也把 SwiftData 明确为“基于 Swift 5.9 新特性实现的 Core Data Swift 封装”,并提前标出 ModelContext 合并、Sendable/线程限制、CloudKit 同步与 .unique 约束等第一波坑。ScrollView 新 API 的落地细节,fatbobman 的 深入了解 SwiftUI 中 ScrollView 的新功能 覆盖了 contentMarginsscrollPosition(id:)scrollTargetBehavior(分页与视图对齐)、scrollTransition 过渡动画等新 API,基本可以直接“抄作业”。宏系统入门,SwiftLee 的 Swift Macros: Extend Swift with New Kinds of Expressions 详细介绍了 freestanding 宏(# 前缀)与 attached 宏(@ 前缀)两种类型,以及七种宏角色的用法,并演示如何通过 AST 操作实现编译期代码生成与验证。想判断 SwiftUI 该押多重,timac 的 Apple’s use of Swift and SwiftUI in iOS 17 通过逆向分析 dyld shared cache 给出了可量化数据:iOS 17 包含 385 个使用 SwiftUI 的二进制文件,Swift 代码占比较 iOS 16 增长 50%,使用 SwiftUI app lifecycle 的 App 从 4 个增至 14 个,Settings、Health、Home、Calendar、Reminders 等系统应用已大规模采用 SwiftUI。年末 SwiftLee 2023: A Year in Review 复盘时也给了一个很直观的信号:年度热门依然是 async/await 的落地、@MainActor 的主线程调度,以及“如何在 iOS Simulator 测推送”这类工程实操——从侧面印证了社区重心正从“看新能力”转向“做迁移、补工具、提效率”。

2023 年 iOS/Swift 开源社区依然活跃。swift-composable-architecture(TCA) 发布了 1.0 版本,围绕 Reducer/Store/Effect 与 TestStore 把“可组合状态管理 + 可测试副作用”沉淀成一套工程化打法,甚至用 @Reducer 宏把样板代码再压一层;Point-Free 团队用 Modern SwiftUI 系列视频把这套理念讲得更完整。Kingfisher 7.x 版本持续适配新系统,依旧是“异步图片下载 + memory/disk hybrid cache + processor + prefetch”的事实标准,并把 SwiftUI 侧的 KFImage 做得足够顺滑。新兴项目方面,swift-dependencies 把 SwiftUI “environment” 风格的依赖注入抽象成可控依赖(DependencyValues 注册、withDependencies 覆盖),让测试与 Preview 的依赖替换更系统;swift-navigation 则把“用状态驱动导航”的模式从 SwiftUI 扩展到 UIKit/AppKit,尤其强调用 enum destination 获得编译期约束,避免同时 push/sheet/alert 的无效组合。宏相关的工具开始涌现:swift-macro-testing 把宏展开(expansion)做成可记录/可回归的测试,并能校验 warnings/errors/fix-its;swift-spyable@Spyable 自动为协议生成 spy,追踪调用/参数/返回值;swift-power-assert 则用 #assert() 这种 diagrammed assertions 把失败信息“图解化”,连 async/await 表达式也能直接断言。

把视角从框架与工程落地拉回生态与产品侧,会更直观看到这些变化如何进入日常。

Apple Design Awards 2023 的获奖者很有代表性:Universe — Website Builder(多元包容)让建站变得触手可及;Duolingo(乐趣横生)以游戏化设计让语言学习上瘾;Flighty(绝佳互动)把航班追踪做到极致;Headspace(社会影响)让冥想走进大众;SwingVision(创新思维)用 AI 分析网球动作;Any Distance(视觉图像)把运动追踪与 Live Activities 完美结合。年底 App Store Awards 2023 的获奖名单中,iPhone 年度 App 是 AllTrails(户外徒步)、iPad 年度 App 是 Prêt-à-Makeup(妆容模板)、Mac 年度 App 是 Photomator(照片编辑);游戏方面,iPhone 年度游戏是 《崩坏:星穹铁道》、iPad 年度游戏是 Lost in Play、Apple Arcade 年度游戏是 Hello Kitty Island Adventure。文化影响力获奖者包括 Pok Pok(儿童创意)、Proloquo(辅助沟通)、Too Good To Go(减少食物浪费)。

Mac 端应用也有不少亮点。Arc 浏览器 在这一年彻底出圈,垂直标签栏、Spaces 工作区、Boosts 自定义样式让它成为很多人的主力浏览器,年底还推出了 Windows 版。Raycast 继续进化,Raycast AI 功能让启动器也能调用大模型,扩展生态越来越丰富。Warp 终端凭借 AI 命令提示和现代化设计吸引了大量开发者。Obsidian 发布了 1.4 版本,Properties 功能让元数据管理更规范。效率工具方面,CleanShot X 在截图录屏领域依然是标杆;Bartender 虽然被收购后引发争议,但功能上依然强大;ProxymanTablePlus 继续是开发者的网络调试和数据库管理首选。AI 相关工具方面,MacGPTMacWhisper 等本地化工具开始流行。写作工具领域,iA Writer 持续迭代、Ulysses 依然是长文写作的标杆、Notion 推出了 Notion AI 功能让笔记工具也能调用大模型、Craft 在文档协作上越发成熟。设计工具方面,Figma 依然是 UI 设计的事实标准,虽然 Adobe 收购案被欧盟否决,但也让更多人开始关注它。Linear 以极简的设计和流畅的体验成为很多团队的项目管理首选。

iOS 平台上 Perplexity AIApp Store)以“AI 搜索引擎”的定位吸引了大量用户,搜索结果附带来源引用,让人耳目一新。Claude(Anthropic 的官方 App)在 iOS 上提供了流畅的对话体验。Character.AI 让用户可以和各种虚拟角色聊天,成为 Z 世代的社交新宠。Luma AI 用 NeRF 技术把照片变成 3D 模型,“扫一圈就能建模”的体验让人惊叹。Remini 用 AI 修复老照片,让很多人翻出了压箱底的旧照“修复青春”。健康领域,Gentler Streak 以“温和健身”的理念赢得了 Apple Design Award,强调休息和恢复同样重要。Copilot(个人财务) 不是 GitHub 那个,而是一款精美的记账 App,把账单追踪做得赏心悦目。Timery 配合 Toggl 让时间追踪变得更顺手。播客领域,小宇宙 继续深耕中文播客生态,Snipd 用 AI 提取播客精华片段,让“听后整理”变得轻松。

文心一言通义千问讯飞星火智谱清言Kimi(月之暗面) 等国产大模型相继发布 App 或网页版,虽然与 GPT-4 仍有差距,但进步速度令人欣喜,“国产大模型”成为投资热词。Poe(平台本身可能需要翻墙)作为多模型聚合平台也吸引了不少用户。移动端,ChatGPT 官方 App 在 5 月上架 App Store,让 iOS 用户终于有了原生体验。《蛋仔派对》 在年轻人中流行,社交玩法让它成为又一款现象级手游。

看过这些工具和应用的繁荣,回头想想 AI 对日常开发的影响,其实更值得细说。ChatGPT 的到来确实改变了很多开发者的工作方式:写代码时先问 GPT、改 Bug 时先让 AI 分析、写文档时用 AI 辅助——这些已经成了新的日常。但与此同时,AI 更像是“放大器”:基础扎实的人用 AI 效率飙升,基础薄弱的人可能只是得到了“看起来对但跑不通”的代码。Vision Pro 的发布让人兴奋,但 3499 美元的售价和“开发者先行”的策略也说明,空间计算离普及还有距离。苹果在发布会上对 AI 的沉默,可能不是没准备好,而是在等一个“苹果式”的答案。

4 月我把用 SwiftUI 做 RSS 阅读器的实践写成《使用 SwiftUI 开发 RSS 阅读器》:从订阅源识别(mimeType + SwiftSoup 兜底解析)、多种 RSS 规范解析,到 Core Data 建模/批量写入、Core Spotlight 索引,以及 CloudKit 同步订阅、已读与收藏,算是一次把“界面 + 数据 + 同步”串成完整产品链路的练习。

5 月在我家孩子小学的家长讲堂,我做了一次《给孩子小学的家长讲堂做了一个计算机科普分享》,用图灵与 Enigma 破译的故事引出“计算机是什么/能做什么/不能做什么”,顺带从 DES/AES、Diffie-Hellman 密钥交换讲到 HTTPS 与 iPhone 的数据保护——这类“把基础讲清楚”的输出也提醒我:越是 AI 时代,越要把底层概念讲透、做实。

在这些实践之外,我也继续关注 SwiftUI 的演进,SwiftData 的出现让我开始认真考虑在新项目里用纯 Swift 方案替代 Core Data。宏系统的引入让 Swift 的元编程能力上了一个台阶,虽然学习曲线陡峭,但带来的收益是实打实的。AI 工具的普及也让我重新思考“开发者的核心竞争力”——代码生成可以交给 AI,但架构设计、性能优化、问题定位,这些还是需要扎实的基本功。

2024年:Apple Intelligence 与空间计算落地

2024 年年初,《繁花》 收官,王家卫镜头下的上海让人感叹“原来那个年代那么美”;8 月,《黑神话:悟空》 发售,首周销量破 1000 万份,成为中国游戏史的里程碑。电影方面,《热辣滚烫》《飞驰人生2》 承包了春节档的笑与泪。

7 月底至 8 月,巴黎奥运会 在塞纳河畔开幕,霹雳舞 首次入奥。中国代表团以 40 金与美国并列榜首,郑钦文 网球女单夺冠创造历史,潘展乐 成为男子 100 米自由泳首位“破 47 秒”的人。闭幕式上,汤姆·克鲁斯 从体育场屋顶滑降,为 2028 洛杉矶奥运会 造势。

11 月,特朗普 再度当选美国总统,马斯克 全程站台——科技与政治的交织愈发明显。

这些更像 2024 的背景噪声;真正改写工作方式的,还是 AI 的落地速度。

OpenAI 动作频频:5 月发布原生多模态的 GPT-4o;9 月推出专注推理的 o1 模型;12 月正式发布文生视频模型 Sora,标志着这一能力从实验室走向产品。

国产大模型展现惊人的追赶速度:Kimi 以 20 万字上下文和出色的中文理解迅速走红;智谱清言文心一言通义千问讯飞星火 纷纷推出更强版本;豆包 凭借免费策略吸引大量用户。“国产大模型可堪一用”成为共识。

AI 模型与生态的竞争也更白热化:

与此同时,科技行业的裁员潮并未停止,全球裁员总数接近 28 万人。3 月,Reddit 上市(股票代码 RDDT),对于经历了 2023 年 API 收费风波(导致 Apollo 等第三方客户端关闭)的用户来说,颇具讽刺意味。

前端生态持续演进。React 19 带来了 Actions、use() Hook 和 Server Components。Python 生态掀起“更快工具链”浪潮:Astral 的 uv 把 pip/venv/lockfile 压缩成一个极速二进制,Ruff 让 lint + format 收敛为一套默认选项。

AI 编程工具方面:

  • Cursor(VS Code fork、AI 代码补全、多文件编辑、Codebase 上下文理解)以 AI-first 的编辑器体验迅速走红,成为很多开发者的 VS Code 替代品。
  • GitHub Copilot 的 Workspace 功能让 AI 辅助从“代码补全”扩展到“项目理解”。
  • Devin(号称“首个 AI 软件工程师”)在 3 月发布,虽然实际能力有限,但打开了“AI Agent 做完整任务”的想象空间。

开源工具方面,LocalSend(Flutter 跨平台、局域网 P2P 传输、端到端加密)凭借跨平台、免费、无需互联网的特点获得超过 40k Star,成为 AirDrop 的最佳替代品;Stirling-PDF 提供本地部署的 PDF 处理工具集;Open WebUI 为本地 LLM 提供类似 ChatGPT 的 Web 界面。AI 应用开发平台 Dify(可视化 Prompt 编排、RAG 流水线、Agent 工作流)和 FastGPT(知识库问答、工作流编排)在国内外都获得大量关注。

本地运行大语言模型变得前所未有的简单。Ollama 凭借“一条命令跑 Llama”的极简体验迅速走红,ollama run llama3 成为很多开发者接触本地 LLM 的第一步;LM Studio 则提供了更友好的 GUI。RAG 应用构建方面,LangChainLlamaIndex 持续主导;Vercel 的 v0.dev 用 AI 生成 UI 组件,让“描述即开发”更近一步。

前端 UI 库方面,shadcn/ui 以“复制粘贴而非安装依赖”的理念成为 React 社区新宠;HTMX 的复兴让“返璞归真”的开发方式重新获得关注。编辑器领域,Zed 在 1 月正式发布 1.0 并开源,以极致性能和原生 AI 集成挑战 VS Code。代码质量工具方面,Biome(原 Rome)作为 ESLint + Prettier 的 Rust 替代品持续获得关注。

把视角从通用工具链拉回 Apple 生态,2024 的平台侧关键词也很明确:Apple Intelligence 与空间计算落地。

6 月 10 日的 WWDC 2024,苹果发布了 Apple Intelligence——一套深度集成到 iOS 18、iPadOS 18 和 macOS Sequoia 的个人智能系统,涵盖写作工具、Image PlaygroundGenmoji、Siri 升级,以及与 ChatGPT 的集成。苹果没有追逐“最强模型”的军备竞赛,而是把重心放在隐私保护(Private Cloud Compute)和端侧智能上。

2 月 2 日,Apple Vision Pro 在美国发售,3499 美元的售价和“空间计算”定位让它成为年度最受关注的硬件。配备双微 OLED 显示屏、M2 主芯片和 R1 芯片,通过眼动追踪、手势和语音交互——这是苹果十年来的首款全新品类。上市时已有超过 600 个专为 visionOS 设计的空间应用。6 月发布的 visionOS 2 引入空间照片、新手势、Mac 虚拟显示等功能。不过,Vision Pro 在销量和佩戴舒适性方面也面临挑战。

9 月 9 日,苹果发布 iPhone 16 系列,全系搭载 A18 芯片,专为 Apple Intelligence 优化。iPhone 16 Pro/Pro Max 采用更大显示屏(6.3/6.9 英寸),新增 相机控制按钮
同期更新的还有 Apple Watch Series 10(更薄、屏幕更大,新增睡眠呼吸暂停检测)和 AirPods 4(入门款首次提供带主动降噪的版本)。
10 月,搭载 M4 芯片 的新款 MacBook ProMac miniiMac 发布。

iOS 18 围绕“个性化”和“AI 能力”展开:主屏幕图标可自由排列并自定义颜色;控制中心完全重新设计,支持多页面和第三方控件;Photos 应用迎来最大改版;Messages 支持 RCS 协议;Safari 新增 Highlights 功能。开发者侧,Controls 让第三方 App 也能把动作放进控制中心;App Intents 框架的扩展让 App 与 Siri 和 Apple Intelligence 的集成更深入。

SwiftUI 6.0 迎来多项重要更新:

Swift 5.10 在 3 月发布,完全支持数据隔离(Data Isolation),为 Swift 6 做最后铺垫。新增 nonisolated(unsafe) 关键字;启用 -strict-concurrency=complete 后,编译器会检测潜在的数据竞争。

9 月 17 日,Swift.org 发布 Announcing Swift 6,数据竞争安全成为语言级能力——Swift 6 模式下,编译器将数据竞争视为编译错误。非可复制类型(Noncopyable Types)获得泛型和标准库支持;Typed throws 让错误处理可指定具体类型;C++ 互操作性进一步增强。配套发布的 Swift Testing 框架用更现代的语法(@Test#expect)替代传统 XCTest。

SwiftData 在 iOS 18 中迎来重要更新:ModelContext.didSave 通知终于正常工作;#Index 宏让数据库索引声明更直观;CloudKit 同步稳定性改善。根据 timac 的统计,iOS 18 中使用 SwiftUI 的系统二进制文件数量再创新高,SwiftUI 在系统级应用中已成“标配”。

社区对 WWDC 2024 / iOS 18 / Swift 6 的讨论热度非常高。

  • Majid:What Is New in SwiftUI after WWDC24——梳理了 App/Scene/View 协议的 @MainActor 隔离变化、View collections(Group/ForEach 新重载与 SubviewsCollection)、新的 Tab 类型与侧边栏流畅过渡、Hero 动画(matchedTransitionSource/navigationTransition)、ScrollPosition 精确滚动控制,以及 @Entry/@Previewable 宏的简洁用法
  • AppCoda:What’s New in SwiftUI for iOS 18——用可运行示例串起 iOS 18/SwiftUI 6 的新能力:TabView 通过 .tabViewStyle(.sidebarAdaptable) 实现浮动 Tab Bar 与侧边栏的自适应切换,Sheet 用 .presentationSizing 统一尺寸行为,视觉上有 MeshGradientmatchedTransitionSource 的 Zoom transition,滚动交互补上 onScrollGeometryChange,控制中心则用 ControlWidget 把控件小组件变成系统入口
  • 官方入口:WWDC24 SwiftUI 与 UI 框架指南——把 WWDC24 的 SwiftUI/AppKit/UIKit 相关 session 按主题编排:先从 SwiftUI/UIKit 的新能力入手,再延伸到容器与布局、动画与过渡、iPad 标签页与边栏体验和辅助功能,最后覆盖 visionOS 的空间容器与沉浸式空间,适合当作“查新 API 的索引页”
  • 肘子:WWDC 2024 观后感——从开发者视角评价 Apple Intelligence、Swift 6 严格并发、SwiftData 改进等年度重点变化
  • Swift.org:Swift 5.10 Released(完全数据隔离支持、nonisolated(unsafe) 关键字)、Ready for Swift 6Announcing Swift 6(数据竞争编译时检测、Typed throws、Noncopyable Types 泛型支持)
  • SwiftGG 中文版:Swift 5.10 ReleasedAnnouncing Swift 6——官方文档的高质量中文翻译
  • onevcat:Swift 6——详细记录迁移体会,包括 @MainActor 后向兼容策略(assumeIsolated/assertIsolated)、用 OSAllocatedUnfairLock 实现线程安全的 Sendable class、@unchecked Sendable 的合理使用、@Sendable 闭包的”传染”问题、以及 deinit 隔离的社区讨论与变通方案
  • Medium:Mastering Modern Swift Concurrency——结合 Swift 5.10/6 讲解 actor hopping、Sendable 约束、async/await 最佳实践
  • 官方:Concurrency Adoption Guidelines——面向库作者的并发“迁移清单”:从 API 设计和 #if canImport(_Concurrency) 的兼容守护开始,逐步引入 Sendable/@Sendable 并用 -warn-concurrency 观察风险,再到 Task cancellation/Task Local 的工程化落地,以及对 Swift 6 全量检查时代的预期与破坏性变化
  • SwiftLee:Swift Testing: Writing a Modern Unit Test@Test 宏与测试函数命名)、Using the #expect macro(用布尔表达式替代 40+ 种 XCTest 断言、异常捕获的多种形式)、MVVM in SwiftUI(结合 @Observable 宏的现代架构实践)
  • Majid:Introducing Swift Testing. Basics.——从 @Test/@Suite 宏、#expect/#require 断言到参数化测试与并行执行,提供完整入门指南
  • 肘子:Mastering the Swift Testing Framework(深入剖析 Swift Testing 与 XCTest 的差异、Xcode 16 集成方式、命令行 --enable-swift-testing 选项)、理解 SwiftUI 的视图刷新机制(探讨视图依赖追踪与 body 重算时机)、SwiftUI onAppear 异常调用的陷阱(分析生命周期回调的时序问题与规避策略)、写在 WWDC 2024 之前:SwiftData 的未来潜力与现实挑战(讨论 #Index 宏、CloudKit 同步稳定性、ModelContext.didSave 通知等痛点)
  • timac:State of Swift and SwiftUI in iOS 18——通过二进制分析得出硬数据:iOS 18 含 6800 个二进制,592 个使用 SwiftUI(较 iOS 17 增长超 50%);19 个 App 采用纯 SwiftUI 生命周期(含 Calculator/Passwords);Apple Intelligence 框架(ProactiveSummarization/PrivateCloudCompute)均用 Swift 编写;新增 SwiftUICore 供 UIKitCore 底层调用
  • SwiftLee:SwiftLee in 2024——年度复盘里主要讲“独立开发者如何把产出做成系统”:全职独立后的节奏与优先级取舍、Newsletter 周更习惯,以及 RocketSim(含 Teams 版)、课程与 Podcast 的推进路径

iOS/Swift 开源社区持续活跃。swift-composable-architecture(TCA) 在大型 SwiftUI 项目中的采用率持续上升,Point-Free 团队也发布了 swift-navigationswift-perceptionExyte 团队的 SwiftUI 库(ChatPopupViewAnimatedTabBar)因设计精美、接口简洁而广受欢迎。SwiftLintSwiftFormat 依然是团队标配;SwiftUI-Introspect 在访问底层 UIKit/AppKit 视图时仍不可或缺。

Apple Design Awards 2024 获奖者:Procreate Dreams(创新)、oko(多元包容)、Bears Gratitude(乐趣横生)、Crouton(绝佳互动)、Gentler Streak(社会影响)、Rooms(视觉图像)。首次设立的“空间计算”类别由 djayBlackbox 获得。

App Store Awards 2024:iPhone 年度 App 是 Kino(把 iPhone 变成专业摄影机);iPad 年度 App 是 Moises(AI 分离音轨);Apple Vision Pro 年度 App 是 What If…? An Immersive Story。Apple Arcade 年度游戏是 Balatro+——扑克牌 + Roguelike 的“构建卡组”游戏,在独立游戏圈爆火。

Mac 端,Arc 浏览器 推出 Windows 版和 Arc Search(iOS),但创始团队宣布不再投入桌面版引发争议;Raycast 的 AI 功能和扩展生态持续繁荣;OpenAI 推出 ChatGPT for macOSWarp 终端发布 Linux 版本;Obsidian 的 1.5/1.6 版本带来全新表格编辑器和 PDF 标注体验。Bear 2.0 终于发布,Markdown 表格、绘图和全新编辑器让老用户等得值。Bartender 被收购后争议不断,很多用户转向开源替代 Ice

iOS 平台上,AI 应用继续爆发:ChatGPT 支持语音对话和图像理解;Perplexity AI 以“AI 搜索引擎”定位获得青睐;国产 Kimi豆包通义千问 纷纷推出移动端 App。苹果在 2 月发布了 Apple Sports——更轻更快的比分/赛程 App。小红书 在海外以“RedNote”名义走红,TikTok 禁令风波让它意外收获大量美国用户。

4 月苹果更新 App Store Review Guidelines,允许“复古游戏主机模拟器”上架,Delta 等模拟器迅速走红——很多人第一次在 iPhone 上用更“官方”的方式重温 GBA/FC/SFC 的童年。

对 iOS 工程师来说,2024 另一个实感很强的变化是:工具链把“取证”前移到了日常。

Xcode 16 把一批“本来需要资深工程师才能搭出来的诊断链路”前移到日常工作流:Thread Performance Checker 能直接提示主线程 hang、优先级反转、过量磁盘写入;配合 Instruments 的 Flame Graph,Time Profiler 更像“一眼能看懂的热点地形图”(What’s New in Xcode 16)。

Apple 在 Analyze Heap Memory(WWDC24 10173) 里把堆内存问题写成可复用流程:先把现象分成 transient growth / persistent growth / leaks,再用 Instruments 标定时间段、找持有链,必要时下沉到 vmmap / malloc_history

Explore Swift Performance(WWDC24 10217) 用函数调用、内存布局、值拷贝这些底层成本解释泛型、协议类型、闭包与 async 何时会变贵——提醒我们别靠直觉做微优化,而要用 profiling 证明。

Swift 6 把“底层约束”写进语言:Noncopyable Types 用 ~Copyableborrowing/consuming 把资源所有权和“少拷贝/少 ARC”变成语义的一部分(What’s New in Swift);Strict Concurrency / Sendable / @MainActor 的迁移会直接影响模块边界与性能折中——onevcat 在《Swift 6》把迁移成本与渐进策略讲得很落地。

值得注意的是,工具链本身也会“改变运行时”。Nutrient 在《Investigating a dynamic linking crash with Xcode 16》记录了一次 Xcode 16 下的动态链接崩溃排查:他们用 dlsym/dladdr 校验 SQLite 符号是否都来自同一 libsqlite3.dylib,却发现 Dl_info.dli_fname 有时指向 libRPAC.dylib;进一步定位到 Thread Performance Checker 会通过 interpose 介入部分 SQLite 符号,最终让 sqlite3_threadsafe 成为触发点——排查这类问题时要把“诊断开关/环境变量(如 DYLD_PRINT_LINKS_WITH)”也当成变量。

美团在《Recce in Meituan:大前端·如何突破动态化容器的天花板?》以 iOS “无 JIT” 为前提重做动态化容器:运行时采用 “Wasm 为主(Wasm3)+ JavaScript 为辅(QuickJS)” 的解释执行策略,业务侧用 Rust 编译到 Wasm;渲染层复用 React Native 的原生渲染与组件封装、保留 UIManager/Yoga 布局,并把优化重点放在“属性设置/渲染通信”(避开 RN 属性转换瓶颈)与平台抽象接口(借鉴 WebIDL/LLVM 的分层思路)上,目标是在动态化能力和接近原生的渲染管线效率之间重新找平衡。

10 月我在韩国首尔 KWDC24 做了《我在韩国首尔 KWDC24 做的技术分享》,把 iOS 性能优化的演进拆成三块:内存问题(leak/peak/thrashing)的定位与取证、启动阶段(Pre-main/Post-main)的度量与治理、以及通过识别未执行代码/未使用资源来压缩包体。文章里也把常用抓手串了一遍:Memory Graph、MallocStackLogging / leaks / malloc_history、fishhook / malloc_logger、MetricKit 的 os_signpostobjc_msgSend hook、LLVM Pass 等。

这是我第一次出国做分享,认识了很多朋友,收获颇丰。

2025年:Liquid Glass 与 AI Agent

2025 对我而言像一条“交叉路口”:一边是 AI 从“对话”走向“执行”,Agent 把任务拆解、工具调用、验证迭代串成流水线;另一边是苹果用 Liquid Glass 重写平台视觉语言,把“像系统”推到跨设备的一套统一标准。对 iOS 工程师来说,它们分别改变了“怎么做事”和“做出来长什么样”。

AI Agent:从“被动工具”到“主动执行者”

2025 年是 AI Agent 全面落地的一年。根据 SuperAI PULSE 的报告,约 65% 的 AI 从业者认为 AI Agent 将是最具影响力的发展趋势——AI 正从“被动工具”变成“主动执行者”。2 月 AI Action Summit 在巴黎召开,汇聚超过 100 个国家的代表讨论 AI 政策与伦理;3 月 Nvidia GTC 大会 上,黄仁勋发布全新 Rubin AI 芯片架构,宣布 AI 已进入“拐点时代”。

国产大模型迎来“国运级”时刻。DeepSeek 凭借 V3 模型在数学、编码与中文任务上对标 GPT-4o/Claude-3.5-Sonnet,在 App Store 下载榜登顶,被外媒称为“中国 AI 的 Sputnik 时刻”;Kimi(月之暗面) 发布 K2 模型——1 万亿参数的 MoE 架构,激活仅 320 亿参数,在 SWE-Bench、LiveCodeBench 等基准上达到开源 SOTA,训练成本约 460 万美元,被评价为“又一个 DeepSeek 时刻”。豆包通义千问文心一言智谱清言 等国产大模型持续迭代,“国产大模型可堪大用”逐渐成为共识。

这一年模型与基础设施仍在加速迭代。OpenAI 在年末推出了 o3 推理模型,在数学、编程、科学推理上取得重大突破;Google 发布了 Gemini 2.0,原生多模态能力让它在视觉理解上达到新高度。AI 硬件方面,Nvidia 的 Blackwell 架构 GPU 和 AMD 的 MI300X 加速卡在数据中心和 AI 训练市场展开激烈竞争;苹果的 M4 系列芯片 在端侧 AI 推理上表现亮眼,Mac mini M4 成为性价比之选。特斯拉 的人形机器人 Optimus 开始在工厂进行测试部署,Elon Musk 宣称 2025 年底前将有数千台 Optimus 投入使用。

开源社区在 2025 年迎来了 AI 基础设施工具的爆发:

  • LangChain(~100k Stars)成为构建 LLM 应用的事实标准框架,从 RAG 到 Agent 几乎无所不能
  • LlamaIndex(~38k Stars)专注于数据索引和检索增强生成
  • Ollama(~105k Stars)让在本地运行开源大模型变得简单,“一行命令跑大模型”成为日常
  • vLLM(~35k Stars)的 PagedAttention 技术大幅提升了 LLM 推理吞吐量,成为生产环境部署的首选

同一时间,通用开发工具链也在加速演进:

  • 前端与运行时: Next.js 15(Turbopack 让冷启动与热更新更快)、Bun 1.x(“all-in-one JavaScript runtime”)
  • 内容与组件化: Astro(内容驱动架构)、shadcn/ui(“复制粘贴”而非安装依赖)
  • Rust 生态与跨端桌面: Tauri 2.0(支持移动端)、Zed(极致性能 + AI 集成)
  • Python 工具链: Ruff(Rust 重写的 linter/formatter)
  • 基础设施: Deno 2.0(npm 兼容)、Podman(Docker 的无守护进程替代)

如果把“AI Agent 元年”拆到开源栈,你会发现 2025 年大家补齐的不只是模型能力,而是“连接”和“编排”:像 Model Context Protocol(MCP)(Anthropic 开源、标准化工具与资源接入、JSON-RPC 通信)把工具/数据源接入抽象成开放协议;LangGraph(状态机图、循环与分支、持久化检查点)把 Agent 的控制流变成可视化、可回放的图;CrewAI(角色定义、任务分配、协作流程)让多 Agent 的角色分工更像“团队协作”;微软也推出了 Microsoft Agent Framework文档),把 AutoGen/Semantic Kernel 的经验往“可治理、可观测”的企业方向收敛。

AI 编程工具在这一年深度融入开发流程。GitHub CopilotCursorClaude Code 成为很多开发者的标配。Claude Code 支持代码库全局理解、多文件编辑、自动运行测试,可以直接把 Issue 变成 Pull Request。Anthropic 在这一年发布了 Claude 4.5 系列模型(Opus 4.5Sonnet 4.5Haiku 4.5),在代码理解和生成上达到新高度。写简单的逻辑、生成测试用例、重构代码,AI 都能帮上忙。但 AI 不能替代思考——架构设计、性能优化、问题定位,这些还是需要深厚的基础知识。AI 是工具,不是替代品。正如 SwiftLee 在 SwiftLee in 2025 中总结的:“AI 能帮你写代码,但维护代码质量仍需要 SwiftLint、SwiftFormat 等工具来保障。”

6 月 9 日的 WWDC 2025 是苹果十年来最大的设计语言变革。苹果发布了全新的 Liquid Glass 设计语言——一套跨 iOS、iPadOS、macOS Tahoe、watchOS、tvOS、visionOS 的统一视觉体系。Liquid Glass 以半透明、类玻璃的材质为核心,能反射和折射周围环境,并随设备运动、光照和内容动态变化。按钮、导航栏、工具栏、小组件、图标、锁屏、主屏幕——几乎所有界面元素都采用了这种新材质。iOS 26 于 9 月 15 日正式推送,支持 iPhone 11 及更新机型。

Liquid Glass 带来的变化是全方位的:锁屏时间显示会根据壁纸空间动态缩放,照片壁纸可以变成“空间场景”随设备移动呈现 3D 效果;App 图标变成多层结构并带有玻璃质感,新增“透明”图标风格;工具栏和导航控件采用更圆润、流动的形态,滚动时自动收缩以突出内容。不过,这套新设计也引发了争议——部分用户反映 眼睛疲劳和眩晕感,尤其是在深色壁纸和透明图标模式下。苹果在 iOS 26.1/26.2 中回应了这些反馈,增加了“玻璃透明度”滑块和回退选项。

SwiftUI 7 围绕 Liquid Glass 进行了全面适配:新增 .buttonStyle(.glass) 玻璃按钮样式、glassEffect 修饰符、更新的 Tab 和 Toolbar API。原生 WebView 组件首次内置 SwiftUI,无需桥接即可加载网页;TextEditor 新增富文本编辑支持;macOS 上的滚动列表性能显著改善,配合新的 Instruments 模板让 SwiftUI 性能调优更直观。Swift 6.2 在并发模型上继续演进,编译器功能增强。

开发者工具层面,苹果在 WWDC 上发布了 Xcode 26,并在 Coding Intelligence 上把 AI 集成推到新高度:可以直接连接 LLM(ChatGPT 或其他)来编写代码、生成测试、修复错误,还支持在 Apple Silicon 上本地运行模型。全新的 Foundation Models 框架 让开发者可以在 App 中嵌入本地 LLM,支持离线运行并保护隐私。Human Interface Guidelines 也随 Liquid Glass 全面修订。

也许是因为 AI 让开发者从繁杂的手敲代码中抽身出来,那些“不那么简洁但性能和稳定性更好”的框架又重回到开发者身边——很多开发者重新发现了 UIKit 的价值。SwiftUI 虽然声明式很优雅,但在复杂交互、性能优化方面 UIKit 更可控。早在 iOS 18,Apple 就为 UIKit 带来了 @Observable 支持,配合 updateProperties() 方法,让“状态驱动 UI”在 UIKit 里也更顺滑。社区里关于“UIKit 回归”的讨论热度很高,SwiftUI vs UIKit in 2025 等文章会把两者的性能基准、迁移成本和混合方案讲得更量化。

如果说 Liquid Glass 是表层的 UI 语言,2025 年对 iOS 工程师更“底层”的变化,是性能工具链把“调优”从 CPU/内存进一步推进到了 SwiftUI 更新因果、能耗预算与硬件级瓶颈分析。Instruments 26 强化了 SwiftUI 专用模板,Optimize SwiftUI Performance with Instruments(WWDC25 306) 把 view body 更新、Representable 更新、以及 “Cause & Effect Graph(为什么会更新)” 做成了可视化证据链;能耗层面,苹果用 Profile and optimize power usage in your app(WWDC25 226) 把 Power Profiler 与“真机离线采集 Performance Trace”带进日常工作流——终于可以把“省电”从口号变成 trace 对比。

更硬核的是 CPU 微架构调优的入口:Optimize CPU performance with Instruments(WWDC25 308) 把 Processor Trace(记录每一条分支/指令的执行路径)和 CPU Counters 的 Bottleneck Analysis(Useful/Instruction Delivery/Instruction Processing/Discarded)做成了面向开发者的工具;配合 Xcode Organizer 的指标推荐与趋势洞察(WWDC25 247),性能问题开始更像“可以被持续治理的工程债”,而不是事后背锅的玄学。

系统底层这边,苹果在 9 月发布了 Apple Security Research 的 Memory Integrity Enforcement:把硬件级内存标记(EMTE)与 typed allocator 等机制组合成更完整的内存安全体系——对我们这些“写 App 的人”来说,它既是安全范式的变化,也意味着未来在性能/稳定性问题定位上会遇到更多“系统帮你兜底”与“系统更严格”并存的新边界。

  • 总览/速览:Majid 的 What’s new in SwiftUI after WWDC25 与 SwiftProgramming.com 的 What’s new in SwiftUI and iOS 26 at WWDC25——前者梳理了 Liquid Glass 如何让 Xcode 26 构建的 App 自动适配新设计,并逐一讲解 Tab/Toolbar 新 API、GlassButtonStyleglassEffect() 修饰符、AttributedString 富文本编辑与原生 WebView 支持;后者补充了 @Animatable 宏、List section index、场景感知 padding、SF Symbols “draw on” 动画等更细粒度的 API 变化
  • 可运行示例:Exploring WebView and WebPage in SwiftUI for iOS 26(AppCoda)——演示了 WebView 一行代码加载网页(告别 UIViewRepresentable 包装),以及 WebPage 的程序化控制方式,包括 title/url/estimatedProgress 等属性获取与加载进度追踪
  • 中文“校准取舍”:fatbobman 的 WWDC 2025 初印象:意料之中,预想之外 与 CSDN 的 WWDC25开发新秘技揭秘——前者指出实机动效远胜静态截图、Liquid Glass 需要重新设计背景而非简单开关,还提到 Apple 给了一年过渡期可在 Xcode 26 中关闭新设计;此外点评了 TabView 大改、macOS SwiftUI-AppKit 互操作增强、以及 AttributedString 富文本编辑是今年最惊喜的 API 补齐;后者更像一组“按主题拆解”的 WWDC25 合集:从 Liquid Glass/SwiftUI 7 的视觉与组件实现,到 @Animatable 宏、SwiftData3 的 @Model 与迁移/并发安全,再到 Chart3D 与 SnippetIntent 这类新能力,适合先扫一遍技术地图再按需深挖
  • 玻璃效果实操:Majid 的 Glassifying custom SwiftUI viewsGlassifying custom SwiftUI views. Groups——第一篇介绍 glassEffect 三种预设(regular/clear/identity)及 tint() 着色、interactive() 手势响应;第二篇讲解 GlassEffectContainer 将多个玻璃视图合并渲染以提升性能,spacing 参数控制形变距离,glassEffectUnion 则用于跨视图统一玻璃效果
  • 迁移与架构:Vlad Khambir 的 The Most Valuable WWDC 2025 Sessions for iOS EngineersHow to Migrate from UIKit to SwiftUI in Large Projects——前者精选了 10 个高价值 Session(含 Foundation Models、SwiftUI 性能优化等);后者提出”从叶到根”渐进迁移策略:先迁移子视图、再迁移页面、最后处理根导航,通过 UIHostingController/UIViewRepresentable 双向桥接实现混合架构
  • 并发变化与迁移:SwiftLee 的 Default Actor Isolation in Swift 6.2 与 Donny Wals 的 Exploring concurrency changes in Swift 6.2(对照 Swift 6.2 Released)——核心变化是 nonisolated(nonsending) 让异步函数默认在调用者执行器上运行、不再强制 Sendable;新项目默认启用 @MainActor 隔离,老项目可通过 Xcode 26 的”Approachable Concurrency”设置开启;@concurrent 属性则用于显式切回全局执行器
  • 端侧 AI 落地:SwiftCafe 的 iOS 26 开发者可以调用 Apple Intelligence 本地模型,Foundation Models 框架、Superwall 的 An Introduction to Apple’s Foundation Model Framework、Artem Novichkov 的 Foundation Models profiling with Xcode Instruments——三篇分别覆盖 SystemLanguageModel 基础调用、Guided Generation 结构化输出与 Instructed Prompts 系统指令、以及 Instruments 性能剖析;模型内置系统不增加包体积,支持 Streaming API 与 Tool Calling
  • 端侧 AI(业务视角):Majid 的 Building AI features using Foundation Models——从业务场景出发演示 LanguageModelSession 初始化、instructions 系统指令配置、GenerationOptions 生成参数调优,以及如何用 respond() 实现多轮对话
  • 团队知识底稿:信逆云科技的 iOS Swift开发实战:从UIKit到SwiftUI现代化应用(2025)——更像一份“现代化改造目录”,把落地拆成可复用的模块:架构侧覆盖 MVVM 与 Clean Architecture(分层、依赖注入、状态管理),工程侧给出 CI/CD(GitHub Actions/GitLab CI、Fastlane、自动签名打包与发布)与测试体系(TDD、Mock、UI/集成测试、测试金字塔)的组织方式,同时补齐安全(HTTPS、本地存储、混淆/逆向、防护与证书 Pinning)与性能(启动、内存、流畅度、网络指标与监控)这些“最后一公里”的清单
  • 工具链与验证:SwiftLee 的 #Playground Macro: Running Code Snippets in Xcode’s canvas 与 MJ Tsai 的 Xcode 26——前者介绍 #Playground 宏:import Playgrounds 后可在源码中直接运行代码片段并在 Canvas 预览结果,无需单独创建 .playground 文件,实现”所写即所得”的即时反馈;后者记录了 Xcode 26 正式版与 RC 的 build number / .xip 包差异与下载细节,评论区还补充了可通过 Info.plist UIDesignRequiresCompatibility 临时保持旧视觉样式的兼容开关,适合做过渡期的风险兜底
  • 上线闭环:Majid 的 Monitoring app performance with MetricKit——讲解如何通过 MXMetricManagerSubscriber 订阅 MXMetricPayload(性能指标)与 MXDiagnosticPayload(崩溃诊断),捕获传统 Crash Reporter 遗漏的 OOM 与后台终止,applicationExitMetrics 可追踪内存压力退出,支持 JSON 导出便于接入后端

迁移与架构讨论也是 2025 的高频话题:一类是“从 UIKit 渐进迁移到 SwiftUI”的工程方法论(桥接、灰度、边界);另一类是“SwiftUI + Swift Concurrency”在大项目里的分层与可测试性——Vlad 的两篇与信逆云科技那篇基本覆盖了这两条路线。

周报类内容在 2025 年仍然是快速补课的最佳入口。国际圈里,Majid 的 SwiftUI Weekly 每周精选社区好文,近期讨论了 Feature Flags、玻璃效果的自动应用时机、zIndex 等实用技巧;SwiftLee Weekly 每周二发送,精选五篇文章配合 Swift Evolution 动态,订阅者已超 3 万。中文圈里,肘子的 Swift 周报(fatbobman)持续输出高质量原创,第 116 期《Swift、SwiftUI 与 SwiftData:走向成熟的 2025》回顾了这些技术的年度演进,第 109 期包含 iOS 性能优化问答汇总;老司机 iOS 周报 第 358 期讨论了 objc4-950 源码更新、App Store Mini App Partner Program 等话题。两份周报都是中文开发者“每周必读”的存在。

年末回顾类文章也很有价值。SwiftLee 的 SwiftLee in 2025: A full year as an indie developer 记录了 Antoine 全职独立开发的第一年:RocketSim for Teams 达到 250+ 团队用户,Swift Concurrency 课程 被 Airbnb、Garmin、Monzo 等公司采购,Newsletter 订阅者突破 3 万。他也坦言博客流量因 AI 内容聚合而增长放缓,转而加大 YouTube 视频投入。Majid 在 2025-12-09 的 Monitoring app performance with MetricKit 中深入探讨了 MetricKit 的崩溃、启动时间、内存监控能力;在 2025-07-23 的 Glassifying custom SwiftUI views. Groups 则演示了如何为自定义视图组应用玻璃效果。

2025 年 iOS/Swift 开源社区持续活跃:

同一个名单里还有不少“看一眼就知道今年大家在用什么”的 finalists。Apple 在 6 月的 winners and finalists 公告 里把它们完整列了出来:效率与写作类有 iA Writer,信息消费里有 Ground News,专注力工具里有 Opal;游戏侧除了大热的 Balatro,还能看到 Prince of Persia: The Lost Crown、Skate City: New York、Control Ultimate Edition 这类更偏“主机级体验”的作品——很能代表 2025 年 Apple 平台生态一边追求更精致的个人生产力、一边追求更沉浸的内容消费。

说完行业和社区,聊聊我自己这一年。2025 对我而言像一条“二分线”:一头是继续把性能问题量化到每一毫秒,另一头是把 AI 变成可以共同产出的搭档。

1 月我在新加坡的 iOS Conf SG 25(大会 10 周年)做了《我在 iOS Conf SG 25 的演讲》分享,主题仍然是启动优化:先把 Launch Time 拆成 Pre-main/Post-main,再分别讲怎么度量和治理——用 Instruments 的 App Launch 模板做快速定位,用 os_signpostsysctl、MetricKit 把“手工分析”推进到“可日常化采集”;最后通过 hook objc_msgSend(fishhook)与 Swift 函数插桩,把耗时归因到函数与调用链。原文里也放了视频以及三个 Demo/工具(AppLaunchDemoDevToolsRSSReader)。

3 月在上海的 Let’s Vision 25,我分享了《使用 AI 突破 iOS 开发者能力边界》。AI 带来的改变很特殊——它第一次让很多场景接近“所想即所得”:过去需要翻文档、试参数、读源码的部分,现在可以先快速生成一个可运行的骨架,再用验证把它推到可维护。在我的实践里,SwiftUI 动画开发场景中,AI 工具能减少约 70% 的 API 查阅时间。工具上我常用 Cursor + Claude 做多文件理解与重构;提示词上更依赖一个稳定骨架:角色设定 → 任务描述 → 约束条件 → 输出格式,再配合“逆向 Prompt”(先让 AI 给实现,再让它解释风险/边界)把结果从“能跑”推进到“可维护”。甚至可以把 Keynote 动画导出成视频,用 Vision 框架分析帧差异反推 CAAnimation 参数,再让 AI 转成可编辑的 SwiftUI 代码。我最大的感触是:用 AI 去学习喜欢却不擅长的,用 AI 去做必要却枯燥繁琐的,把时间留给更少但更重要的事。

我越来越确信:当 UI 语言在表层被重写、当工作流在工具侧被重写,真正不变的是工程能力本身——把问题拆到可度量、可定位、可回归,把新工具当成放大器而不是拐杖。带着这种确定感,回头看这十年,会更清楚什么值得长期投入。

下一个十年已经开始,好看的剧《太平年》开始了,好的模型Opus4.6、GPT5.3 Codex也来了,好使的工具 OpenClaw 开始浮现,好用的方法论 Skill 也用了起来。对后面的日子更加期待起来了。

十年回望

写到最后,想起周末带娃去游乐场,孩子在玩,我在旁边用手机看斯坦福公开课的日子。那时候 iOS 9 刚发布,3D Touch 还新鲜,适配和约束把人折腾得够呛。

十年过去,iOS 已经到 26 了,SwiftUI 逐渐成熟,Vision Pro 开辟了新的战场,Liquid Glass 带来了十年来最大的视觉变革。技术在变,但学习的方法没变——保持好奇,解决问题,持续输出。

回头看这十年,有几个感悟:

  • 底层原理永远不过时。新框架层出不穷,但内存管理、多线程、渲染原理这些底层知识是不变的。理解它们,才能在新框架里游刃有余。
  • 不要被新技术焦虑绑架。SwiftUI 很好,但 UIKit 也不过时;Swift 很好,但 Objective-C 也还能用。关键是能解决实际问题,而不是追逐热点。
  • 输出是最好的学习。每次深入研究一个问题,写下来,分享出去。”教”是最好的”学”,这些年写博客、做分享,受益最大的其实是我自己。
  • 把问题拆到可验证的闭环。十年前看 Auto Layout 头疼,现在看并发模型一样头疼——但“测量 → 定位 → 改动 → 回归”这条路径一直有效。

十年前在游乐场看公开课的那个新手,现在还是会为新技术兴奋、为难题头疼。也许这就是做开发者最好的状态吧。

春晚、机器人、AI 与 LLM - 肘子的 Swift 周报 #124

作者 Fatbobman
2026年2月23日 22:00

作为一个观众数量超十亿的电视节目,央视春晚无疑是极佳的展示平台。今年春晚中,多家中国机器人厂商在不同节目中展示了其产品,其中讨论度最高的当属宇树(Unitree)的人形机器人。在表演环节,多款型号的人形机器人完成了大量较为复杂的武术与动态动作展示。

第三十二章 接下来我们开始做`灭菌整板`页面

作者 君赏
2026年2月23日 13:50

image-20211222183930334

新建 SterilizeWholeBoardPage 空页面

class SterilizeWholeBoardPageViewModel: BaseViewModel {
    
}
struct SterilizeWholeBoardPage: View {
    @StateObject private var viewModel = SterilizeWholeBoardPageViewModel()
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            EmptyView()
        }
        .makeToDetailPage()
    }
}

添加 【灭菌批号】【栈版号】【箱号】

image-20211222185405845

class SterilizeWholeBoardPageViewModel: BaseViewModel {
    /// 灭菌批号
    @Published var sterilizationLotNumber:String = ""
    /// 栈版号
    @Published var stackVersionNumber:String = ""
    /// 箱号
    @Published var caseNumber:String = ""
}
struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                Spacer()
                    .frame(height: 5)
                VStack(spacing: 0) {
                    ScanTextView(title: "灭菌批号",
                                 prompt: "请输入灭菌批号",
                                 text: $viewModel.sterilizationLotNumber)
                    Divider()
                        .padding(.leading, 10)
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.stackVersionNumber)
                    Divider()
                        .padding(.leading, 10)
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.caseNumber)
                }
                .background(.white)
                
                Spacer()
            }
        }
        ...
    }
}

image-20211222192408026

添加 【栈板序号】【物料总体积】【箱数】

image-20211222192919873

struct SterilizeWholeBoardPage: View {
   ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                ...
                VStack {
                    HStack(spacing: 0) {
                        Text("栈板序号")
                            .frame(width: 100, alignment: .leading)
                        Text("1")
                    }
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(EdgeInsets(top: 15, leading: 22, bottom: 15, trailing: 22))
                    Divider()
                        .padding(.leading, 10)
                    VStack(spacing: 10) {
                        HStack(spacing: 0) {
                            Text("物料总体积")
                                .frame(width: 100, alignment: .leading)
                            Text("120.86 m³")
                        }
                        .frame(maxWidth: .infinity, alignment: .leading)

                        HStack(spacing: 0) {
                            Text("箱数")
                                .frame(width: 100, alignment: .leading)
                            Text("12")
                        }
                        .frame(maxWidth: .infinity, alignment: .leading)
                    }
                    .padding(EdgeInsets(top: 15, leading: 22, bottom: 15, trailing: 22))
                }
                ...
            }
        }
        ...
    }
}

image-20211223082530347

使用 environment 规范 Title 文本的宽度

.frame(width: 100, alignment: .leading)

大量这种代码我们实在受够了,一个页面如果很多元素,或者其他界面一样的这种对齐呢?不过我们可以通过 environment进行设置。

新增 TitleWidthEnvironmentKey

struct TitleWidthEnvironmentKey: EnvironmentKey {
  /// 设置默认为100 
    static var defaultValue: CGFloat = 100
}

给 EnvironemtValues 扩展属性 titleWidth

extension EnvironmentValues {
    var titleWidth: CGFloat {
        get { self[TitleWidthEnvironmentKey.self] }
        set { self[TitleWidthEnvironmentKey.self] = newValue }
    }
}

将 ScanTextView 中的宽度限制 修改为 titleWidth

struct ScanTextView: View {
   ...
    /// 默认为 100
    @Environment(\.titleWidth) private var titleWidth:CGFloat
    
    init(title:String, prompt:String, text:Binding<String>) {
        ...
    }
    ...
}

封装组件 LimitLeadingWidthView

struct LimitLeadingWidthView<Leading:View, Treading:View>: View {
    @Environment(\.titleWidth) private var leadingLimitWidth:CGFloat
    private let leading:Leading
    private let treading:Treading
    init(@ViewBuilder leading:() -> Leading, @ViewBuilder treading:() -> Treading) {
        self.leading = leading()
        self.treading = treading()
    }
    var body: some View {
        HStack(spacing: 0) {
            leading
                .frame(width: leadingLimitWidth, alignment: .leading)
            treading
                .frame(maxWidth: .infinity, alignment: .leading)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}
struct LimitLeadingWidthView_Previews: PreviewProvider {
    static var previews: some View {
        LimitLeadingWidthView(leading: {
            Text("我是左侧文本")
        }, treading: {
            Text("我是右侧文本")
        })
            .previewLayout(.sizeThatFits)
    }
}

image-20211223100900788

将【栈板序号】【物料总体积】【箱数】更换为 LimitLeadingWidthView 组件

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
               ...
                VStack {
                    LimitLeadingWidthView(leading: {
                        Text("栈板序号")
                    }, treading: {
                        Text("1")
                    })
                    ...
                    VStack(spacing: 10) {
                        LimitLeadingWidthView {
                            Text("物料总体积")
                        } treading: {
                            Text("120.86 m³")
                        }
                        LimitLeadingWidthView {
                            Text("箱数")
                        } treading: {
                            Text("12")
                        }
                    }
                    ...
                }
                ...
            }
        }
        ...
    }
}

将 ScanTextView 内部使用 LimitLeadingWidthView 组件

struct ScanTextView: View {
    ...
    var body: some View {
        LimitLeadingWidthView {
            HStack {
                Text("*")
                    .foregroundColor(Color(uiColor: appColor.c_e68181))
                Text(title)
                Spacer()
            }
        } treading: {
            HStack {
                TextField(prompt, text: $text)
                    .frame(height:33)
                Image("scan_icon", bundle: .main)
            }
        }
        ...
    }
}

扩展 View 新增 limitLeadingWidth 方法

虽然默认值100已经在当前页面足够的展示左侧的内容,我们想要在当前页面根本修改全部LimitLeadingWidthView左侧宽度为110

z

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            ...
        }
        .environment(\.titleWidth, 110)
    }
}

对于.environment(\.titleWidth, 110)这样的方式不是很优雅,使用者还要关心对应Key是什么?我们可以给View做一下扩展。

extension View {
    func limitLeadingWidth(_ width:CGFloat) -> some View {
        self.environment(\.titleWidth, width)
    }
}

此时我们上面的代码就可以变成下面

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            ...
        }
        .limitLeadingWidth(110)
    }
}

这样的写法可以明确用意。

获取栈板序号

image-20211223113058626

栈板序号的值来源于通过灭菌批号查询

新增 @Published 栈板序号用于更新页面

class SterilizeWholeBoardPageViewModel: BaseViewModel {
    ...
    /// 栈板序号
    @Published var palletSerialNumber:String = ""
}

根据【灭菌批号】获取【栈板序号】

class SterilizeWholeBoardPageViewModel: BaseViewModel {
    ...
    
    /// 请求栈板序号
    func requestPalletNumber() async {
        guard !sterilizationLotNumber.isEmpty else {
            showHUDMessage(message: "灭菌批号为空!")
            return
        }
        let api = GetSterilizationSequenceApi(sterilizeBatch: sterilizationLotNumber)
        let model:BaseModel<Int> = await request(api: api)
        guard model._isSuccess, let data = model.data else {
            return
        }
        palletNumber = "\(data)"
    }
}

输入完毕【灭菌批号】获取【栈板序号】

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                VStack(spacing: 0) {
                    ScanTextView(title: "灭菌批号",
                                 prompt: "请输入灭菌批号",
                                 text: $viewModel.sterilizationLotNumber)
                        .onSubmit {
                            Task {
                                await viewModel.requestPalletNumber()
                            }
                        }
                    ...
                }
                ...
            }
        }
        ...
    }
}

设置 TabBar 的背景颜色

image-20211223115539590

突然发现,我们的TabrBar变成了这个样子,应该是我们修改SafeArea导致的。

struct TabPage: View {
    ...
    
    init() {
        ...
        UITabBar.appearance().backgroundColor = .white
    }
    ...
}

获取【物料总体积】【箱数】

继承 PalletBindBoxNumberPageViewModel

物料总体积箱数来源于根据栈版号获取的箱子列表拿到的数据。这个页面输入栈版号箱号是一样的逻辑,我们不如将SterilizeWholeBoardPageViewModel继承于PalletBindBoxNumberPageViewModel

class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
    /// 灭菌批号
    @Published var sterilizationLotNumber:String = ""
    /// 栈板序号
    @Published var palletSerialNumber:String = ""
    
    ....
}
struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                VStack(spacing: 0) {
                    ...
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.palletNumber)
                    ...
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.boxNumber)
                }
                ...
                VStack {
                    LimitLeadingWidthView(leading: {
                        Text("栈板序号")
                    }, treading: {
                        Text(viewModel.palletSerialNumber)
                    })
                    ...
            }
        }
        ...
    }
}

新增 @Published 变量显示【箱号总体积】【箱数】

class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
    ...
  /// 总体积
    @Published var totalCapacity:String = ""
    /// 箱数
    @Published var totalBox:String = ""
    ...
}

因为箱子总体积箱数的数据来源于单条的BoxDetailModel里面的数据,我们监听boxDetailModels的变化,获取第一条元素进行获取。

class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
    ...
    /// 存储 Publisher 取消
    private var cancellabels:Set<AnyCancellable> = []
    
    override init() {
        super.init()
        $boxDetailModels.sink {[weak self] models in
            guard let self = self else {return}
            self.totalCapacity = models.first.flatMap({$0.volume}).map({"\($0)"}) ?? ""
            self.totalBox = models.first.flatMap({$0.total}).map({"\($0)"}) ?? ""
        }
        .store(in: &cancellabels)
    }
    
    ...
}

查询栈板箱子列表和新增删除箱号

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                VStack(spacing: 0) {
                    ...
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.palletNumber)
                        .onSubmit {
                            Task {
                                await viewModel.requestBoxDetailList()
                            }
                        }
                    ...
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.boxNumber)
                        .onSubmit {
                            Task {
                                await viewModel.addOrRemoveBox()
                            }
                        }
                }
                ...
            }
        }
        ...
    }
}

提炼箱号列表

灭菌整板箱子列表和托盘绑定箱号的箱子列表是一样的,所以,我们可以将灭菌整板的箱子列表进行提炼。

struct BoxListView: View {
    private let models:[BoxDetailModel]
    init(models:[BoxDetailModel]) {
        self.models = models
    }
    var body: some View {
        List {
            ForEach(models) { model in
                BoxDetailView(model: model)
                    .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 5, trailing: 0))
                    .listRowBackground(Color.clear)
                    .listRowSeparator(.hidden)
            }
        }
        .listStyle(.plain)
    }
}

修改托盘绑定箱号页面

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                BoxListView(models: viewModel.boxDetailModels)
            }
        }
        ...
    }
}

灭菌整板页面新增BoxListView

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
               ...
                Spacer()
                    .frame(height: 10)
                BoxListView(models: viewModel.boxDetailModels)
            }
        }
        ...
    }
}

image-20211224105912683

添加 【重置】 【提交】按钮

image-20211224110123490

打印需要调用蓝牙和硬件交互,我们就把打印替换成重置

封装 TextButton

在登录页面,我们有一个类似的登录按钮,决定按照登录按钮的样式封装按钮,方便后面的使用。

struct TextButton: View {
    @StateObject private var appColor = AppColor.share
    private let title:String
    private let action:() -> Void
    init(title:String, action:@escaping () -> Void) {
        self.title = title
        self.action = action
    }
    var body: some View {
        Button(action: action) {
            Text(title)
                .font(.system(size: 16))
                .frame(maxWidth:.infinity)
                .frame(height: 45)
                .background(Color(uiColor: appColor.c_209090))
                .foregroundColor(.white)
                .cornerRadius(5)
        }
        
    }
}

struct TextButton_Previews: PreviewProvider {
    static var previews: some View {
        TextButton(title: "登录", action: {})
            .previewLayout(.sizeThatFits)
    }
}

通过 TextButton 添加 【重置】【提交】

通过 Stack 进行叠加布局

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            ZStack {
                ...
                VStack {
                    Spacer()
                    HStack {
                        TextButton(title: "重置") {
                            
                        }
                        TextButton(title: "提交") {
                            
                        }
                    }
                    .padding()
                }
            }
        }
        ...
    }
}

重置界面数据

点击重置按钮需要将界面所有的数据清空,界面恢复到刚打开的状态。

class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
    ...
    func reset() {
        sterilizationLotNumber = ""
        palletSerialNumber = ""
        palletNumber = ""
        boxNumber = ""
        totalCapacity = ""
        totalBox = ""
        boxDetailModels = []
    }
}
struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            ZStack {
                ...
                VStack {
                    ...
                    HStack {
                        TextButton(title: "重置") {
                            viewModel.reset()
                        }
                        ...
                    }
                    ...
                }
            }
        }
        ...
    }
}

【提交】灭菌整板

image-20211226172444260

第三十一章 完善箱号列表

作者 君赏
2026年2月23日 13:49

我们已经通过栈版号获取到了箱子列表数据,那么我们用List将数据展示出来。

BoxDetailModel 实现 Identifiable 协议

extension BoxDetailModel: Identifiable {
    var id: String { boxCode ?? "" }
}

List + ForEach 实现列表

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        ... {
            ... {
               ...
                List {
                    ForEach(viewModel.boxDetailModels) { model in
                        BoxDetailView()
                    }
                }
            }
        }
        ...
    }
}

image-20211222112442651

List 构建的是否存在性能问题?

image-20211222113027064

看了视图,核心还是利用UITableView重用的机制,所以使用List展示很多数据,是会走重用机制的。

设置 List 的 Style

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        ... {
            ... {
                ... {
                List {
                ...
                }
                .listStyle(.plain)
            }
        }
        ...
    }
}

image-20211222143008277

通过 listRowInsets 设置 Row 的间隙

显示出来的间隙,明显和我们BoxDetailView的间隙大很多,为了看一下差距,我们给BoxDetailView设置一个红色背景色。

image-20211222143722283

看起来左右留白多一些,上下留白很少。

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(0 ..< 10) { model in
                        BoxDetailView()
                            ...
                            .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))

                    }

                }
                ...
            }
        }
        ...
    }
}

image-20211222155658090

通过 listRowInsets 增加 Cell 之间的间隙

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(0 ..< 10) { model in
                        BoxDetailView()
                            .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 5, trailing: 0))
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222163931659

通过 listRowBackground 设置背景颜色

上图完全看不到Cell之间的坚决,我们可以通过listRowBackground进行设置颜色,来区分Cell

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(0 ..< 10) { model in
                        BoxDetailView()
                            ...
                            .listRowBackground(Color.clear)
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222165502211

通过 listRowSeparator 隐藏 Cell 的 Separator

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(0 ..< 10) { model in
                        BoxDetailView()
                            ...
                            .listRowSeparator(.hidden)
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222170038600

传递 Model 到 BoxDetailView 赋值

struct BoxDetailView: View {
    private let model:BoxDetailModel
    init(model:BoxDetailModel) {
        self.model = model
    }
    var body: some View {
        HStack {
            VStack {
                TitleValueView(...,
                               value: model.skuCode ?? "")
                ...
                TitleValueView(...,
                               value: model.skuBatch ?? "")
            }
            VStack {
                TitleValueView(...,
                               value: model.paperCode ?? "",
                               ...)
                ...
                TitleValueView(...,
                               value: model.boxCode ?? "",
                               ...)
            }
        }
        ...
    }
}
struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(viewModel.boxDetailModels) { model in
                        BoxDetailView(model: model)
                            ...
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222183243829

第三十章 接下来我们写首页的功能,首先是我们的`托盘绑定箱号`。

作者 君赏
2026年2月23日 13:49

托盘绑定箱号

创建托盘绑定箱号界面

新建 ViewModel

class PalletBindBoxNumberPageViewModel: BaseViewModel {   
}

新建 Page

struct PalletBindBoxNumberPage: View {
    @StateObject private var viewModel = PalletBindBoxNumberPageViewModel()
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            EmptyView()
        }
    }
}

新增首页跳转 PalletBindBoxNumberPage

NavigationLink

对于导航的跳转,我们需要用到NavigationLink.

struct HomePage: View {
    ...
    var body: some View {
        ... {
            ... {
                ActionCardView(
                    title: "生产执行",
                    actions: [
                    /// ActionItem
                        ...
                ])
                 ...
            }
        } 
        ...
}
struct ActionItem: Hashable {
    ...
}

ActionItem不是一个View,因此不能够使用NavigationLink

Function方法体内部执行 NavigationLink跳转。

HomePageViewModel 新增一个控制 NavigationLink 激活的变量

class HomePageViewModel: BaseViewModel {
    ...
    /// 是否允许跳转界面
    @Published var isAllowPushPage:Bool = false
    ...
}

HomePage 新增一个不可见的 NavigationLink

struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            VStack {
                ...
                VStack(spacing:0) {
                    NavigationLink(isActive: $viewModel.isAllowPushPage) {
                        
                    } label: {
                        EmptyView()
                    }
                    Spacer()
                }
            }
        } leadingBuilder: {
            ...
        } trailingBuildeder: {
            ...
        }
        ...
    }
}

获取点击首页按钮 ActionItem

HomePageViewModel 新增记录选中 ActionItem的变量。
class HomePageViewModel: BaseViewModel {
    ...
    /// 当前点击按钮的 `ActionItem`
    @Published var currentClickActionItem:ActionItem?
    ...
}
ActionCardView
struct ActionCardView: View {
    ...
    @Binding var currentClickActionItem:ActionItem?
    var body: some View {
        VStack {
            ...
            HStack(alignment:.top) {
                HStack {
                    ActionView(actionItems: actions(index: .left),
                               currentClickActionItem: $currentClickActionItem)
                    ...
                }
                ...
                HStack {
                    ActionView(actionItems: actions(index: .center),
                               currentClickActionItem: $currentClickActionItem)
                }
                ...
                HStack {
                    ...
                    ActionView(actionItems: actions(index: .right),
                               currentClickActionItem: $currentClickActionItem)
                }
                ...
            }
            ...
        }
        ...
    }
...
}
ActionView
struct ActionView: View {
    ...
    @Binding var currentClickActionItem:ActionItem?
    var body: some View {
        VStack {
            ForEach(actionItems, id: \.self) { item in
                ...
                    .onTapGesture {
                        currentClickActionItem = item
                    }
            }
        }
    }
}

HomePage
struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            VStack {
                ActionCardView(
                    title: "生产执行",
                    actions: [
                        ....
                    ], currentClickActionItem: $viewModel.currentClickActionItem)
                    ...
                }
            }
        } leadingBuilder: {
            ...
        } trailingBuildeder: {
            ...
        }
        .onAppear {
            ...
        }
    }
}

监听 currentClickActionItem 值的改变,执行跳转。

struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            ...
        } leadingBuilder: {
            ...
        } trailingBuildeder: {
            ...
        }
        ...
        .onChange(of: viewModel.currentClickActionItem) { newValue in
            viewModel.isAllowPushPage = true
        }
    }
}

根据 ActionItem 返回对应的 Page

extension HomePageViewModel {
    var actionPage: some View {
        return currentClickActionItem.map { item in
            Group {
                if item.title == "托盘绑定箱号" {
                    PalletBindBoxNumberPage()
                } else {
                    EmptyView()
                }
            }
        }
    }
}

HomePage

struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            VStack {
                ...
                VStack(spacing:0) {
                    NavigationLink(isActive: $viewModel.isAllowPushPage,
                                   destination: {viewModel.actionPage}) {
                        EmptyView()
                    }
                    ...
                }
            }
        } leadingBuilder: {
            ...
        } trailingBuildeder: {
            ...
        }
        ...
    }
}

37102565-781C-4B7A-A024-6E871B4AF579-12013-00001DCBE6C95C10

修复返回按钮样式不对

image-20211217083137276

隐藏返回按钮文本

let backButtonAppearance = UIBarButtonItemAppearance()
backButtonAppearance.normal.titleTextAttributes = [
    .font : UIFont.systemFont(ofSize: 0),
]
appearance.backButtonAppearance = backButtonAppearance

修改 SwiftUI 返回按钮的颜色

NavigationView {
...
}
.accentColor(.black)

需要注意的是官方说accentColor已经要废弃了,Use the asset catalog's accent color or View.tint(_:) instead."

但是替换为 tint不起作用。

没有隐藏底部的 Tab

image-20211217104349972

目前在 SwiftUI中暂时没有任何方便的方法可以在 NavigationView 进行 Push 跳转隐藏底部的 Tabbar。我们只能在需要隐藏的界面的 onAppearonDisappear去隐藏。

/// ❌ 这样设置是不起作用的
UITabBar.appearance().isHidden = true

我们在运行时候,看一下布局。

image-20211217110816676

我们按照结构找出 UITabbar

if let appBar = App.keyWindow?.rootViewController
    .flatMap({$0.view})
    .flatMap({$0.subviews.first})
    .flatMap({$0.subviews.first})
    .map({$0.subviews})
    .map({$0.compactMap({$0 as? UITabBar})})
    .flatMap({$0.first}) {
    print(appBar)
}

App 获取当前 Tabbar 的方法

struct App {
    ...
    
    static var tabBar:UITabBar? {
        return keyWindow?.rootViewController
            .flatMap({$0.view})
            .flatMap({$0.subviews.first})
            .flatMap({$0.subviews.first})
            .map({$0.subviews})
            .map({$0.compactMap({$0 as? UITabBar})})
            .flatMap({$0.first})
    }
}

隐藏和显示当前 UITabbar

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            ...
        }
        .onAppear {
            App.tabBar?.isHidden = false
        }
        .onDisappear {
            App.tabBar?.isHidden = true
        }
    }
}

image-20211217114659348

隐藏UITabar之后多出了很多空白的区域,我们设置忽略安全距离。

/// 页面的基础试图
struct PageContentView<Content:View,
                        Leading:View,
                        Trailing:View,
                        ViewModel:BaseViewModel>: View {
    ...
    var body: some View {
        navigationBar {
            ZStack {
                content
                    .background {
                        Color(uiColor: appColor.c_efefef)
                            .ignoresSafeArea()
                    }
            }
            ...
        }
    }
    ...
}

封装 Detail 页面

为了让后面的界面一样拥有 隐藏UITabBar我们需要进行封装成DetailView,方便后续的使用。

新建一个 DetailPageViewModify

struct DetailPageViewModify: ViewModifier {
    func body(content: Content) -> some View {
        content
            .onAppear {
                App.tabBar?.isHidden = true
            }
            .onDisappear {
                App.tabBar?.isHidden = false
            }
    }
}

extension View {
    func makeToDetailPage() -> some View {
        self.modifier(DetailPageViewModify())
    }
}

将 PalletBindBoxNumberPage 页面使用 DetailPageViewModify

struct PalletBindBoxNumberPage: View {
    @StateObject private var viewModel = PalletBindBoxNumberPageViewModel()
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            EmptyView()
        }
        .makeToDetailPage()
    }
}

修复第二次相同页面无法 Push 问题

E30B5E5D-03E3-4AB4-9B9E-F1D57042B2AC-6170-0000086B21AA549E

从上面的掩饰发现,第一次是可以正常的进入,点击返回,第二次无法Push进入。只有点击其他页面返回之后,才能正常的返回。

打印点击 Push 对应的 ActionItem

newValue = Optional(Win_.ActionItem(icon: "托盘绑定箱号", iconColor: UIExtendedSRGBColorSpace 0.945098 0.564706 0.215686 1, title: "托盘绑定箱号"))
newValue = Optional(Win_.ActionItem(icon: "灭菌整板(有箱号)", iconColor: UIExtendedSRGBColorSpace 0.945098 0.564706 0.215686 1, title: "灭菌整板(有箱号)"))
newValue = Optional(Win_.ActionItem(icon: "托盘绑定箱号", iconColor: UIExtendedSRGBColorSpace 0.945098 0.564706 0.215686 1, title: "托盘绑定箱号"))

发现在整个过程中,点击第二次是没有走 onChange,是因为检测到值相同,是因为ActionItem实现了Hashable协议。

在 HomePage 的 onAppear 方法重置 currentClickActionItem

struct HomePage: View {
    ...
    var body: some View {
        ...
        .onAppear {
            ...
            viewModel.currentClickActionItem = nil
        }
        ...
    }
}

2DD48351-20FB-4E98-A12F-93D5DE903641-6170-00000B685153E499

经过重置,第二次Push无法跳转问题解决了。

封装扫描输入组件

image-20211217144859246

接下来我们封装上面的组件,大致的界面构造如下。

image-20211217145319468

新建一个 ScanTextView

struct ScanTextView: View {
    @StateObject private var appColor = AppColor.share
    /// 前面的标题
    private let title:String
    /// 输入框的提示文本
    private let prompt:String
    /// 输入框输入的内容
    @Binding private var text:String
    init(title:String, prompt:String, text:Binding<String>) {
        self.title = title
        self.prompt = prompt
        self._text = text
    }
    var body: some View {
        HStack {
            HStack {
                Text("*")
                    .foregroundColor(Color(uiColor: appColor.c_e68181))
                Text(title)
              Spacer()
            }
            TextField(prompt, text: $text)
                .frame(height:33)
            Image("scan_icon", bundle: .main)
        }
        .font(.system(size: 14))
        .padding()
    }
}

image-20211217152532232

添加栈版号和箱号

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack {
                VStack {
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.palletNumber)
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.boxNumber)
                }
                .background(.white)
                Spacer()
            }
        }
        ...
    }
}
class PalletBindBoxNumberPageViewModel: BaseViewModel {
    /// 输入的栈版号
    @Published var palletNumber:String = ""
    /// 箱号
    @Published var boxNumber:String = ""
}

image-20211217170131720

固定 ScanTextView 的 Title 的宽度

image-20211217170240820

提示语是没有对齐的,因为是自动布局,很难会让自动的对齐,我们需要设置左侧标题固定长度。

struct ScanTextView: View {
    ...
    /// 默认为 100
    private let titleWidth:CGFloat
    init(title:String, prompt:String, text:Binding<String>, titleWidth:CGFloat = 100) {
        ...
        self.titleWidth = titleWidth
    }
    var body: some View {
        HStack {
            HStack {
                ...
            }
            .frame(width: titleWidth)
            ...
        }
        ...
    }
}

栈版号和箱号中间添加分割线

struct PalletBindBoxNumberPage: View {
    @StateObject private var viewModel = PalletBindBoxNumberPageViewModel()
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                VStack(spacing:0) {
                    ...
                    Divider()
                        .padding(.leading)
                   ...
                }
                ...
            }
        }
        ...
    }
}

箱号详情组件

image-20211217171227535

分析布局如下。

image-20211217173615566

struct BoxDetailView: View {
    var body: some View {
        HStack {
            VStack {
                HStack {
                    Text("物料编号:")
                    Text("A")
                }
                HStack {
                    Text("物料批号:")
                    Text("120211217A")
                }
            }
            VStack {
                HStack {
                    Text("工单号:")
                    Text("WO-201425")
                }
                HStack {
                    Text("箱号:")
                    Text("BOX-01")
                }
            }
        }
        .padding(15)
        .frame(maxWidth: .infinity)
        .background(.white)
        .cornerRadius(10)
    }
}

image-20211217175610405

制作标题信息组件

我们需要标题和信息上对齐,类似下面的排版方案。

image-20211220090538999

struct TitleValueView: View {
    @StateObject private var appColor = AppColor.share
    private let title:String
    private let value:String
    init(title:String, value:String) {
        self.title = title
        self.value = value
    }
    var body: some View {
        HStack(alignment:.firstTextBaseline) {
            Text(title)
                .foregroundColor(Color(uiColor: appColor.c_999999))
            Text(value)
                .foregroundColor(Color(uiColor: appColor.c_333333))
        }
        .font(.system(size: 14))
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

image-20211220091532902

将箱号详情标题和描述替换为 TitleValueView 组件

struct BoxDetailView: View {
    var body: some View {
        HStack {
            VStack {
                TitleValueView(title: "物料编号:",
                               value: "A")
                TitleValueView(title: "物料批号:",
                               value: "120211217A")
            }
            VStack {
                TitleValueView(title: "工单号:",
                               value: "WO-201425")
                TitleValueView(title: "箱号:",
                               value: "BOX-01")
            }
        }
        ...
    }
}

image-20211220092135131

调整上下组件的间距

struct BoxDetailView: View {
    var body: some View {
        HStack {
            VStack {
                ...
                Spacer()
                    .frame(height: 7.5)
                ...
            }
            VStack {
                ...
                Spacer()
                    .frame(height: 7.5)
                ...
            }
        }
        ...
    }
}

image-20211220092422575

可手动控制 Title 的宽度

我们给TitleValueView新增一个可以手动控制Title宽度的参数,如果不为0则手动控制高度。

struct TitleValueView: View {
    ...
    private let titleWidth:CGFloat
    init(title:String, value:String, titleWidth:CGFloat = 0) {
        ...
        self.titleWidth = titleWidth
    }
    var body: some View {
        HStack(alignment:.firstTextBaseline) {
            if titleWidth == 0 {
                titleText
            } else {
                titleText
                    .frame(width: titleWidth, alignment: .leading)
            }
            ...
        }
        ...
    }
    
    private var titleText: some View {
        Text(title)
            .foregroundColor(Color(uiColor: appColor.c_999999))
    }
}

我们将工单号和箱号宽度保持一致

struct BoxDetailView: View {
    var body: some View {
        HStack {
            VStack {
                ...
            }
            VStack {
                TitleValueView(title: "工单号:",
                               value: "WO-201425",
                               titleWidth: 50)
                ...
                TitleValueView(title: "箱号:",
                               value: "BOX-01",
                               titleWidth: 50)
            }
        }
        ...
    }
}

image-20211221105603227

固定 ScanTextView的高度

image-20211221110011577

经过自动布局之后的ScanTextView的高度达到了65的高度,超出了设计图50的高度,主要是输入框固定了高度,我们将去掉Padding,给ScanTextView设置固定高度为50

struct ScanTextView: View {
    ...
    var body: some View {
        HStack {
            ...
        }
        ...
        .frame(height:50)
    }
}

image-20211221110351933

只增加左右间距

高度50设置完毕,但是左右靠边,我们只设置边距左右为10

struct ScanTextView: View {
    ...
    var body: some View {
        HStack {
            ...
        }
        ...
        .padding(.leading, 10)
        .padding(.trailing, 10)
      /// 或者
      /// .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
    }
}

image-20211221111955278

获取箱号列表

新增 @Published 参数箱号列表 用于更新列表

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    /// 箱子列表
    @Published var boxDetailModels:[BoxDetailModel] = []
}

新增根据栈版号获取箱号列表方法

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    /// 请求获取箱子列表
    func requestBoxDetailList() async {
        let api = PalletQueryApi(palletCode: palletNumber)
        let model:BaseModel<[BoxDetailModel]> = await request(api: api)
        guard model._isSuccess else { return }
        boxDetailModels = model.data ?? []
    }
}

当输入栈版号结束之后请求箱号列表

怎么才能监听到输入完毕呢?我们可以使用onSubmit这个扩展获取。

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                VStack(spacing:0) {
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.palletNumber)
                        .onSubmit {
                            Task {
                                await viewModel.requestBoxDetailList()
                            }
                        }
                    ...
                }
                ...
            }
        }
        ...
    }
}

添加或者删除箱号

此时我们的栈板上是没有数据的,需要我们输入箱号进行新增和删除操作。

image-20211221142928191

上图的逻辑都封装在接口里面,所以我们只需要关心输入箱号之后,调用接口即可。

添加新增或者删除箱号逻辑方法

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    /// 添加或者移除箱号
    func addOrRemoveBox() async {
        let api = BoxAddApi(palletCode: palletNumber, boxCode: boxNumber)
        let model:BaseModel<String> = await request(api: api)
        guard model._isSuccess else {return}
        /// 重新获取列表 刷新界面
        await requestBoxDetailList()
    }
}

给箱号输入框添加onSubmit方法

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                VStack(spacing:0) {
                   ...
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.boxNumber)
                        .onSubmit {
                            Task {
                                await viewModel.addOrRemoveBox()
                            }
                        }
                }
                ...
            }
        }
        ...
    }
}

给请求添加HUD

此时添加箱号成功了

{"code":200,"data":"箱号绑定栈板成功!!!","message":"success","objectType":null,"success":true}

在日志也看不出来乱码显示,我们希望提示给用户。

给获取箱子列表添加HUD

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    /// 请求获取箱子列表
    func requestBoxDetailList() async {
        ...
        let model:BaseModel<[BoxDetailModel]> = await request(api: api, showHUD: true)
        ...
    }
    ...
}

06AD995C-44B3-4F97-85FA-EB546715CFE9-32874-0000111BEBD404CE

此时我们已经获取到列表了,但是HUD没有消失,主要是逻辑中没有调用隐藏HUD。

给BaseViewModel新增Hidden HUD方法

@MainActor
class BaseViewModel: ObservableObject {
    ...
    func hiddenHUD() {
        self.isLoadingHUD = false
    }
    ...
}

给查询箱号和新增和删除箱号添加HUD和移除HUD

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    
    /// 请求获取箱子列表
    func requestBoxDetailList() async {
        ...
        hiddenHUD()
        ...
    }
    
    /// 添加或者移除箱号
    func addOrRemoveBox() async {
       ...
        let model:BaseModel<String> = await request(api: api, showHUD: true)
        ...
        hiddenHUD()
        ...
    }
}

添加或者删除成功提示

上面的代码我们还是无法成功显示提示语,到底是添加成功还是删除成功。当我们请求完毕,展示获取的Data字符串。

8E0091D0-4423-4821-A20E-60F1D087D5AF-32874-000011FEBD5094D8

但是展示和隐藏十分的快,在显示没有结束之前,被后面获取箱子列表接口在请求完毕之后隐藏了。

image-20211221164303927

class PalletBindBoxNumberPageViewModel: BaseViewModel {
    ...
    
    /// 添加或者移除箱号
    func addOrRemoveBox() async {
        ...
        if let message = model.data {
            showHUDMessage(message: message)
        }
    }
}

A25D7D14-4057-4781-80AC-049B8CC6DFD8-32874-0000127C741D4A5D

修复HUD开始显示之前内容的问题

HUD展示逻辑

image-20211221170320889

HUD Message展示逻辑

image-20211221194016155

我们看到在展示文本延时两秒之后,文本没有清空,导致下次请求进行Loading时候因为文本不为空,展示不是一个Loading HUD而是上一个提示的文本。

清空上一个展示的文本

修复这个问题,大概有两种方案

方案1 在延时两秒隐藏时候 清空文本

@MainActor
class BaseViewModel: ObservableObject {
    ...
    
    /// 展示 HUD 文本
    /// - Parameter message: 提示的信息
    func showHUDMessage(message:String) {
        ...
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            ...
            self.hudMessage = ""
        }
    }
    
    ...
}

方案2 在展示HUD的时候 清空之前的文本

@MainActor
class BaseViewModel: ObservableObject {
    ...
    func request<T:Codable, API:APIConfig>(api:API, showHUD:Bool = false) async -> BaseModel<T> {
        if (showHUD) {
            hudMessage = ""
            ...
        }
        ...
    }
}

展示HUD Message的文本内容只是一个临时的展示内容,应该在展示完毕重置,所以第一种方案比较好。

6FB09227-0EC0-4CE3-9B88-C3E71BE1A47A-32874-00001AAF68A2AB4A

第二十九章 修复首页 PopMenuView 显示问题

作者 君赏
2026年2月23日 13:48

在首页切换工厂的时候,我们发现了一处严重的UI问题。

image-20211215154246752

本来我们做的PopMenuButton竟然被导航栏遮挡在最下面。出现的原因在于,我们无法确保我们的PopMenuView一定在最外面,因此可能被其他外层遮挡。为了确保PopMenuView一定会在最外层弹出,我们只能弹出一个 UIViewController,这样保证一定出现在最外层。

image-20211215160233695

我们只需要获取到offset Y的高度即可,这个值也是PopMenuButton对应的在Golbal对应的offset y

对于获取视图在对应视图的位置,我们可以使用GeometryReader。今天在测试通过PreferenceKey传递获取的偏移量时候,意外试验出一个BUG

通过 PreferenceKey 获取指定视图的偏移量

1 创建 PreferenceKey

struct TextPointKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
        value = nextValue()
    }
}

2 组件获取 Point

Text("Hello World!")
/// 一般使用 `background`其实也可以使用 `overlay`
    .background {
        /// 使用  `GeometryReader` 获取父试图的大小
        GeometryReader { geometry in
            /// 使用 透明颜色 是为了不污染界面
            Color.clear
                /// 通过`GrometryProxy`的`frame`方法可以获取对应的位置
                /// 保存在 `Preference`中
                .preference(key: TextPointKey.self,
                            value: geometry.frame(in: .global).origin)
        }
    }

3 通过 onPreferenceChange 获取刚才设置的值

Text("Hello World!")
/// 一般使用 `background`其实也可以使用 `overlay`
    .background {
        ...
    }
    .onPreferenceChange(TextPointKey.self) { point in
        print(point.debugDescription)
    }

此时我们运行可以看到有下面打印信息。

(148.5, 408.1666666666667)

上述的方法进行使用会可能引起获取不到的Bug,关于这个Bug的研究可以看下面的文章。

[]: xiaozhuanlan.com/topic/74531…"关于 SwiftUI 通过 Preference 获取视图 Frame 的隐藏 BUG 探索"

获取PopMenuButton对应globalpoint

1 新增PreferenceKey

struct PopMenuPointKey: PreferenceKey {
    static var defaultValue: [CGPoint] = []
    static func reduce(value: inout [CGPoint], nextValue: () -> [CGPoint]) {
        value.append(contentsOf: nextValue())
    }
}

2 获取选择工厂组件的Point

struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            ...
        } leadingBuilder: {
            HStack(spacing:6) {
                ...
            }
            .background(content: {
                GeometryReader { geometry in
                    Color.clear
                        .preference(key: PopMenuPointKey.self, value: [geometry.frame(in: .global).origin])
                }
            })
            .onPreferenceChange(PopMenuPointKey.self, perform: { points in
                print(points.debugDescription)
            })
            ...
        } trailingBuildeder: {
            ...
        }
        ...
    }
}
[(16.0, 60.0)]
[(16.000000000000007, 60.0)]
[(16.0, 60.0)]

打印了三次,打印多次,这就是使用数组的弊端吧。

保存获取到的Point

为了能够让我们弹出一个UIViewController可以定位到,我们需要将这个Point保存下来,我们需要新增一个@State变量存起来。

struct HomePage: View {
...
    @State private var popMenuButtonOffset:CGPoint = .zero
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            ...
        } leadingBuilder: {
            HStack(spacing:6) {
                ...
            }
            ...
            .onPreferenceChange(PopMenuPointKey.self, perform: { points in
                guard let point = points.first else { return }
                popMenuButtonOffset = point
            })
            ...
        } trailingBuildeder: {
            EmptyView()
        }
        ...
    }
}

新增 View 展示 PopMenuView

struct PopMenuContentView<T:PopMenuItem>: View {
    /// 数据源
    private let items:[T]
    /// `PopMenuButton`的`Offset`
    private let offset:CGPoint
    /// 当前选中的数据源
    @Binding private var currentItem:T
    init(items:[T],
         offset:CGPoint,
         currentItem:Binding<T>) {
        self.items = items
        self.offset = offset
        self._currentItem = currentItem
    }
    var body: some View {
        GeometryReader { geometry in
            popMenuButton
                .offset(x: 0, y: offset.y)
        }
    }
    
    private var popMenuButton: some View {
        PopMenuButton(items: items, currentItem: $currentItem) {item in
            currentItem = item
        }
    }
}

使用UIHostingController展示工厂列表

struct HomePage: View {
    ...
    @State private var popMenuButtonOffset:CGPoint = .zero
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            ...
        } leadingBuilder: {
            HStack(spacing:6) {
                ...
            }
            ...
            .onTapGesture {
                let rootView = PopMenuContentView(items: viewModel.factoryList,
                                                     offset: popMenuButtonOffset,
                                                     currentItem: $viewModel.currentFactory)
                let controller = UIHostingController(rootView: rootView)
                controller.modalPresentationStyle = .overFullScreen
                controller.view.backgroundColor = .clear
                let rootWindow:UIWindow?
                if #available(iOS 13.0, *) {
                    rootWindow = UIApplication.shared.connectedScenes
                        .filter({$0.activationState == .foregroundActive})
                        .compactMap({$0 as? UIWindowScene})
                        .first?.windows
                        .filter({$0.isKeyWindow})
                        .first
                } else {
                    rootWindow = UIApplication.shared.windows.filter({$0.isKeyWindow}).first
                }
                rootWindow?.rootViewController?.present(controller, animated: false, completion: nil)
            }
        } trailingBuildeder: {
            ...
        }
        ...
    }
}

封装获取Key Window的获取方法

我们在弹出了UIHostingController代码的时候,我们再次写了获取Key Window的代码,这是我们第二次用到,我们可以将获取Key Window进行封装,方便我们后续的使用。

struct App {
    static var keyWindow:UIWindow? {
        if #available(iOS 13.0, *) {
            return UIApplication.shared.connectedScenes
                .filter({$0.activationState == .foregroundActive})
                .compactMap({$0 as? UIWindowScene})
                .first?.windows
                .filter({$0.isKeyWindow})
                .first
        } else {
            return UIApplication.shared.windows
                .filter({$0.isKeyWindow})
                .first
        }
    }
}

替换掉工程现有获取Key Window的方法

DataPickerManager

class DataPickerManager {
    ...
    /// show 方法采用 @ViewBuilder 获取自定义的视图
    func show<Content:View>(@ViewBuilder _ content:() -> Content) {
        /...
        guard let rootViewController = App.keyWindow?.rootViewController else {return}
        ...
    }
    ...
}
struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            ...
        } leadingBuilder: {
            HStack(spacing:6) {
                ...
            }
            ...
            .onTapGesture {
               ...
                App.keyWindow?.rootViewController?.present(controller, animated: false, completion: nil)
            }
        } trailingBuildeder: {
            ...
        }
        ...
    }
}

修复偏移问题

image-20211216112926889

修改完毕,我们运行之后发现,这偏移的位置和也太远了。为了探明原因,我们修改一下PopMenuContentView背景颜色,看一下问题所在。

struct PopMenuContentView<T:PopMenuItem>: View {
    ...
    var body: some View {
        GeometryReader { geometry in
            ...
        }
        .background(.blue)
    }
    
    ...
}

image-20211216115621948

发现PopMenuContentView是完全铺满的,不是因为安全距离造成的。那是不是偏移量导致的吗?我们去掉offset.

struct PopMenuContentView<T:PopMenuItem>: View {
    ...
    var body: some View {
        GeometryReader { geometry in
            popMenuButton
        }
        .background(.blue)
    }
    
    ...
}

image-20211216133502604

我们去掉offset之后,竟然布局好像从安全距离开始的。我们就忽略掉安全距离,再次试一下。

struct PopMenuContentView<T:PopMenuItem>: View {
    ...
    var body: some View {
        GeometryReader { geometry in
            popMenuButton
        }
        .ignoresSafeArea()
        .background(.blue)
    }
    ...
}

image-20211216133756258

这个就符合我们的预期了。我们将代码恢复,运行,我们的组件已经布局正常了。

image-20211216134027691

此时凭空出现的PopMenuView显得十分的突兀,我们不妨让PopMenuView显示在PopMenuButton的下来会好的多。

修改PopMenuPointKey值为[CGRect]

PopMenuPointKey

struct PopMenuPointKey: PreferenceKey {
    static var defaultValue: [CGRect] = []
    static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
        value.append(contentsOf: nextValue())
    }
}

HomePage

struct HomePage: View {
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            ...
        } leadingBuilder: {
            ...
            .background(content: {
                GeometryReader { geometry in
                    Color.clear
                        .preference(key: PopMenuPointKey.self, value: [geometry.frame(in: .global)])
                }
            })
            .onPreferenceChange(PopMenuPointKey.self, perform: { rects in
                guard let rect = rects.first else { return }
                popMenuButtonOffset = CGPoint(x: rect.minX, y: rect.maxY)
            })
            ...
        } trailingBuildeder: {
            EmptyView()
        }
        ...
    }
}

image-20211216134857931

此时我们的界面看起来好一些,但是还是很丑。

封装PopMenu

刚才经过我们一阵的修改,功能实现了,但是需要实现这个功能,需要改很多的东西。最好体验就是封装一个组件,可以自定义PopMenuButton和自定义PopMenuView。通过一个变量控制UIHostingController显示和隐藏。

类似这样伪代码

Button("show Menu")
.popMenu(isShow:$isShow) {
PopMenuView
}

改造PopMenuContentView

我们要做的让PopMenuContentView现实的内容可以用户自定义,参数offset保持不变。

struct PopMenuContentView<Content:View>: View {
    ...
    /// 内容视图
    private let content:Content
    init(offset:CGPoint, @ViewBuilder content:() -> Content) {
        ...
        self.content = content()
    }
    var body: some View {
        GeometryReader { geometry in
            content
                ...
        }
        ...
    }
}

封装.popMenu方法

struct PopMenuViewModify: ViewModifier {
    @Binding private var isShow:Bool
    init(isShow:Binding<Bool>) {
        _isShow = isShow
    }
    func body(content: Content) -> some View {
        content
            .background(content: {
                GeometryReader { geometry in
                    Color.clear
                        .preference(key: PopMenuPointKey.self, value: [geometry.frame(in: .global)])
                }
            })
    }
}

保存获取到的Frame

struct PopMenuViewModify: ViewModifier {
    ...
    /// `PopMenuButton`的`Frame`
    @State private var contentFrame:CGRect = .zero
    ...
    func body(content: Content) -> some View {
        content
            ...
            .onPreferenceChange(PopMenuPointKey.self) { rects in
                guard let rect = rects.first else {return}
                contentFrame = rect
            }
    }
}

通过onChange监听isShow值的变动

struct PopMenuViewModify: ViewModifier {
    ...
    func body(content: Content) -> some View {
        content
            ...
            .onChange(of: isShow) { newValue in
                if newValue {
                    /// 展示 `UIHostingController`
                } else {
                    /// 隐藏 `UIHostingController`
                }
            }
    }
}

新增一个 @ViewBuilder设置 PopMenuView

struct PopMenuViewModify<PopMenuView:View>: ViewModifier {
    ...
    /// 自定义 `PopMenuView`的闭包
    private let contentBlock:() -> PopMenuView
    init(isShow:Binding<Bool>,
         @ViewBuilder content:@escaping () -> PopMenuView) {
        ...
        contentBlock = content
    }
    func body(content: Content) -> some View {
        ...
    }
}

展示 UIHostingController

struct PopMenuViewModify<PopMenuView:View>: ViewModifier {
    ...
    func body(content: Content) -> some View {
        content
            ...
            .onChange(of: isShow) { newValue in
                if newValue {
                    /// 展示 `UIHostingController`
                    show()
                } else {
                    /// 隐藏 `UIHostingController`
                }
            }
    }
    
    private func show() {
        let offset = CGPoint(x: contentFrame.minX, y: contentFrame.maxY)
        let rootView = PopMenuContentView(offset: offset, content: {
            contentBlock()
        })
        let controller = UIHostingController(rootView: rootView)
        controller.modalPresentationStyle = .overFullScreen
        controller.view.backgroundColor = .clear
        App.keyWindow?.rootViewController?.present(controller,
                                                   animated: false,
                                                   completion: nil)
    }
}

隐藏 UIHostingController

当我们进行隐藏时候发现,我们此时已经拿不到当前弹出的视图。

通过 presentedViewController获取当前弹出的 UIHostingController

var presentedViewController: UIViewController? { get }

当您使用 present(_:animated:completion:) 方法以模态方式(显式或隐式)呈现视图控制器时,调用该方法的视图控制器将此属性设置为它呈现的视图控制器。 如果当前视图控制器没有以模态方式呈现另一个视图控制器,则此属性中的值为 nil。

struct PopMenuViewModify<PopMenuView:View>: ViewModifier {
    ...
    private func dismiss() {
        let controller = App.keyWindow?.rootViewController?.presentedViewController
        controller?.dismiss(animated: false, completion: nil)
    }
}

封装 View 的扩展

extension View {
    func popMenu<PopMenuView:View>(isShow:Binding<Bool>,
                                   @ViewBuilder content:@escaping () -> PopMenuView) -> some View {
        let modify = PopMenuViewModify(isShow: isShow, content: content)
        return self.modifier(modify)
    }
}

将封装好的PopMenu组件替换首页工厂功能

struct HomePage: View {
    ...
    @State private var isShowFactoryMenu:Bool = false
    ...
    var body: some View {
        PageContentView(title: "首页",
                        viewModel: viewModel) {
            ...
        } leadingBuilder: {
            HStack(spacing:6) {
                ...
            }
            .popMenu(isShow: $isShowFactoryMenu, content: {
                PopMenuButton(items: viewModel.factoryList,
                              currentItem: $viewModel.currentFactory) { item in
                    viewModel.currentFactory = item
                    isShowFactoryMenu = false
                }
            })
            .onTapGesture {
                isShowFactoryMenu = true
            }
        } trailingBuildeder: {
            ...)
        }
        ...
    }
}

发现我们使用起来更加的简单方便。

0D07AB75-0887-484F-8BF7-684E23D93833-12013-000015F807E392BE

修改登录页面选择服务器组件

struct LoginPage: View {
    ...
    @StateObject private var appConfig:AppConfig = AppConfig.share

    var body: some View {
        ... {
            ... {
                ...
                ... {
                    ServerSelectMenuView()
                        ...
                        .popMenu(isShow: $viewModel.isShowServerMenu) {
                            PopMenuButton(items: viewModel.supportServerUrls,
                                          currentItem: $appConfig.currentAppServer) { item in
                                appConfig.currentAppServer = item
                                viewModel.isShowServerMenu = false
                            }
                        }
                        .onTapGesture {
                            viewModel.isShowServerMenu = true
                        }
                    ...
                }
                ...
            }
            ...
        }
        ...
    }
}

7727EAFC-70E9-4001-AA74-A241C40B098A-12013-000016214B3C2D07

第二十八章 重置 ObservableObject 模型数据

作者 君赏
2026年2月23日 13:47

经过通过Demo工程不停的测试,终于尝试出来两种版本可以解决问题,一种通过@ObservedObject的方式可以解决问题,另外通过@StateObject解决问题。但是不管通过@ObservedObject还是@StateObject方式,都需要将需要修改的对象用@Published声明。

class RootModel: ObservableObject {
    static let root = RootModel()
    @Published var model:Model = Model()
}

class Model: ObservableObject {
    @Published var text:String = ""
    @Published var isOpen:Bool = true {
        didSet {
            text = "你能发现我了,恭喜你!"
        }
    }
}

下面讲述一下通过@ObservedObject的方式来实现。

@main
struct ExampleApp: App {
    @StateObject var model = RootModel()
    var body: some Scene {
        WindowGroup {
            VStack {
                ContentView(model: model.model)
                Button("tap") {
                    model.model = Model()
                }
            }
        }
    }
}
struct ContentView: View {
    @ObservedObject var model:Model
    var body: some View {
        VStack {
            Text(model.text)
            Toggle("是否显示", isOn: $model.isOpen)
        }
    }
}

6FDE33D6-DF4B-4834-9416-70881C78607D-2852-0000052D9D15DAC7

当是我运行看到效果达到的时候,我并没有满足当前的解决方案,我觉得通过传递参数这一种有点复杂,并不是我们想要的,后来经过尝试了很多次,终于发现了另外的一种。

通过@StateObject达成效果

struct ExampleApp: App {
    var body: some Scene {
        WindowGroup {
            VStack {
                ContentView()
                Button("tap") {
                    RootModel.root.model = Model()
                }
            }
        }
    }
}
struct ContentView: View {
    @StateObject var model = RootModel.root
    var body: some View {
        VStack {
            Text(model.model.text)
            Toggle("是否显示", isOn: $model.model.isOpen)
        }
    }
}

通过上述的代码,我们一样完成了功能。不过的是我们的RootModel需要做成单例模式,不过这个没关系,正好符合我们的需求。

不过我觉得第二种实现起来更加的方便,不需要将参数传来传去的。那么我们就用第二种方法改造上一章节的问题。

改造 UserConfig 的生成规则

第一步 修改 AppConfig 中的 userConfig 从 Optional 修改为 No Optional

var userConfig:UserConfig

这样我们在使用UserConfig中的参数@Published时候不会因为语法报错,当用户没有登录使用默认的值,也是符合正常的业务逻辑。

第二步 修改 getUserConfig 方法的逻辑

image-20211215101153452

private func getUserConfig() -> UserConfig {
    /// 流程图地址 ![](https://gitee.com/joser_zhang/upic/raw/master/uPic/202112151011488.png)
    /// 如果服务器地址为空 或者 当前登录用户不存在 则返回 [server = "" user = 0]的默认配置
    let defaultUserConfig = UserConfig(server: "", user: "0")
    guard !currentAppServer.isEmpty else { return defaultUserConfig }
    guard let currentUserId = try? UserManager.EmployeeNo(currentUserId).value else { return defaultUserConfig }
    return UserConfig(server: currentAppServer, user: currentUserId)
}

但是这个方式还是存在一些问题,可能还存在下面的情况

image-20211215104451184

既然是用户配置,自然和用户强相关的,没有服务器地址用户就不能登录,没有登录就不存在用户ID,所以获取不到统一用一套新的用户配置是可以的,当在已经登录情况下,不存在服务器地址和用户ID是错误的,是不允许存在的。

为了保障我们获取UserConfig的逻辑的严谨性,我们按照最新的逻辑图进行修改代码。

class AppConfig: ObservableObject {
    ...
    /// 流程图地址 https://gitee.com/joser_zhang/upic/raw/master/uPic/202112151044218.png
    private func getUserConfig() -> UserConfig {
        guard !currentAppServer.isEmpty else {
            /// 如果服务器为空 则创建 [server = ""] [user = 0]的用户配置
            return UserConfig(server: "", user: "0")
        }
        guard let currentUserId = try? UserManager.EmployeeNo(currentUserId).value else {
            /// 如果服务器不为空 当前不存在登录用户的ID 则创建 [server = "xxx"] [user = "0"]的用户配置
            return UserConfig(server: currentAppServer, user: "0")
        }
        /// 如果服务器存在 存在登录用户的ID 就返回[server = "xxx"][user = "xxx"]的用户配置
        /// 里面是否重新创建配置还是读取本地已经存在配置 交给 `UserConfig`处理
        return UserConfig(server: currentAppServer, user: currentUserId)
    }
}

第三步 修改 AppConfig 初始化

fileprivate extension Notification.Name {
...
    static let currentServerChanged = Notification.Name("currentServerChanged")
}
class AppConfig: ObservableObject {
    ...
    
    /// 当前 App 的服务器地址
    @AppStorage("currentAppServer")
    var currentAppServer:String = "" {
        didSet {
            NotificationCenter.default.post(name: .currentServerChanged, object: nil)
        }
    }
    
    ...
    
    private var cancellabelSet:Set<AnyCancellable> = []
    
    init() {
        ...
        /// 监听  `currentAppServer` 的变化重新生成 `UserConfig`
        NotificationCenter.default.publisher(for: .currentServerChanged, object: nil)
            .sink { [weak self] no in
                /// 监听到`currentAppServer`改变,重新生成`UserConfig`
                guard let self = self else {return}
                self.userConfig = self.getUserConfig()
            }
            .store(in: &cancellabelSet)
    }
    ...
}

修改是否登录逻辑

为了可以确保可以在用户登录之后拿到UserConfig没有问题,我们需要修改一下isLogin的逻辑。我们假设一下我们我们不修改会造成什么的危害?

image-20211215113925183

红色箭头的逻辑是有问题的,因为覆盖安装,旧版本已经登录情况下,用户操作的所有配置都保存在默认配置下面,是存在问题的。

为了解决旧版本已经登录的情况,我们就修改isLogin的逻辑,让旧版本已经登录的用户保持未登录的状态。

image-20211215134408761

我们将之前通过判断gatewayUserName换成了employeeNO,不但兼容了老版本,而且新版本后续登录也不会出问题。

1 新增 employeeNo 是否来源于缓存字段替换 isGatewayUserNameFromCache

class AppConfig: ObservableObject {
    ...
    
    /// `employeeNo` 是否来源于缓存 默认来源于缓存
    var isEmplyeeNoFromCache:Bool = true
    
    ...
}

2 用户主动登录之后 修改 isEmplyeeNoFromCache = false

struct UserManager {
    ...
    
    /// 进行登录
    func login() {
        AppConfig.share.isEmplyeeNoFromCache = false
        ...
    }
}

3 修改入口 isNeedLogin 代码

struct Win_App: App {
    @StateObject private var appConfig:AppConfig = AppConfig.share
    ...

    private var isNeedLogin:Bool {
        /// 如果 `employeeNo` 不存在 则需要进行登录
        guard isExitUserId else { return true}
        /// 如果 `employeeNo` 存在 并且 `isEmplyeeNoFromCache = false` 代表是刚刚登录的 则不需要登录
        guard appConfig.isEmplyeeNoFromCache else { return false }
        /// 此时 `employeeNo`已经存在 假设是全新创建`UserConfig`默认`isAutoLogin = false`也是需要进行登录操作的
        return !appConfig.userConfig.isAutoLogin
    }
    
    private var isExitUserId:Bool {
        guard let _ = try? UserManager.EmployeeNo(appConfig.currentUserId).value else {
            return false
        }
        return true
    }
}

修复工程报错

因为我们将所有存放在AppConfig的信息转移到UserConfig中,很多地方出现了报错,经过上面修改逻辑,我们拿着AppConfig.UserConfig修复工程中出现的错误。

修复 Api

class Api: API {
    ...
    
    static var defaultHeadersConfig: ((inout HTTPHeaders) -> Void)? {
        return { headers in
            if let gatewayUserName = AppConfig.share.userConfig.gatewayUserName {
               ...
            }
            if let currentFactoryCode = AppConfig.share.userConfig.currentFactoryCode {
                ...
            }
        }
    }
}

修复 HomePageViewModel

class HomePageViewModel: BaseViewModel {
    ...
    @Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
                                                                                      factoryName: nil) {
        didSet {
            AppConfig.share.userConfig.currentFactoryCode = currentFactory.factoryCode
        }
    }
    
    ...
    /// 查找保存的工厂代码对应最新工厂列表的模型
    private func findFactory() -> FactoryListResponseModel? {
        return factoryList.first { model in
            guard let currentFactoryCode = AppConfig.share.userConfig.currentFactoryCode else {return false}
            ...
        }
    }
}

修复 MyPage

struct MyPage: View {
    ...
    @StateObject private var appConfig = AppConfig.share
    ...
    
    private func userNameCell() -> some View {
        MyDetailStyle1CellContentView(title: "姓名",
                                      detail: AppConfig.share.userConfig.userInfoModel?.userName ?? "")
    }
    
...
    
    private func autoLoginCell() -> some View {
        MyCellContentView(title: "自动登录") {
            Toggle("", isOn: $appConfig.userConfig.isAutoLogin)
        }
    }
    
    ...
    
    private func logoutButton() -> some View {
        Button {
            appConfig.userConfig.gatewayUserName = nil
            ...
        } label: {
            ...
        }

    }
    
    /// 点击了产线
    private func didClickProductLine() {
        guard let _ = appConfig.userConfig.workShopCode else {
            ...
            return
        }
        ...
    }
    
    ...
}

修复 AppConfig 报错

class AppConfig: ObservableObject {
    ...
    /// 当前 App 的服务器地址
    @AppStorage("currentAppServer")
    var currentAppServer:String = "" {
        ...
    }
    ...
    var userConfig:UserConfig
    ...
    init() {
      /// ❌ 'self' used in property access 'currentAppServer' before all stored properties are initialized
        if currentAppServer.isEmpty {
            ...
        }
      /// ❌ 'self' used in method call 'getUserConfig' before all stored properties are initialized
        /// 初始化 UserConfig
        self.userConfig = getUserConfig()
        ...
    }
    /// 流程图地址 https://gitee.com/joser_zhang/upic/raw/master/uPic/202112151044218.png
    private func getUserConfig() -> UserConfig {
        ...
    }
}

分别存在两个错误

  • currentAppServeruserConfig之前使用,因为调用方法和变量默认省去了self.,所以我们需要在AppConfig初始化完毕才能使用currentAppServer
  • 我们在AppConfig初始化之前调用了方法getUserConfig

1 将 getUserConfig 方法修改为类方法

/// 流程图地址 https://gitee.com/joser_zhang/upic/raw/master/uPic/202112151044218.png
/// 根据提供的服务器地址 和当前登录用户的唯一ID 查询本地已经存在的用户配置 或者重新生成新的用户配置
/// - Parameters:
///   - server: 服务器地址
///   - userId: 当前登录用户的唯一ID
/// - Returns: 当前用户的配置
private static func getUserConfig(from server:String, userId:String?) -> UserConfig {
    guard !server.isEmpty else {
        /// 如果服务器为空 则创建 [server = ""] [user = 0]的用户配置
        return UserConfig(server: "", user: "0")
    }
    guard let currentUserId = try? UserManager.EmployeeNo(userId).value else {
        /// 如果服务器不为空 当前不存在登录用户的ID 则创建 [server = "xxx"] [user = "0"]的用户配置
        return UserConfig(server: server, user: "0")
    }
    /// 如果服务器存在 存在登录用户的ID 就返回[server = "xxx"][user = "xxx"]的用户配置
    /// 里面是否重新创建配置还是读取本地已经存在配置 交给 `UserConfig`处理
    return UserConfig(server: server, user: currentUserId)
}

2 在 AppConfig 初始化之前获取 server 和 userId 的值

let server = _currentAppServer.wrappedValue
let userId = _currentUserId.wrappedValue

3 调整 currentAppServer 赋值和 userConfig 初始化的位置

class AppConfig: ObservableObject {
    ...
    
    init() {
        ...
        /// 初始化 UserConfig
        self.userConfig = AppConfig.getUserConfig(from: server, userId: userId)
        if currentAppServer.isEmpty {
            ...
        }
        ...
    }
    ...
}

4 修复 AppConfig 其他报错

class AppConfig: ObservableObject {
    ...
    
    init() {
        ...
        /// 监听 currentUserId 的变化
        /// `@AppStorage` 是无法进行监听的 因此这里采用 `Notification`
        NotificationCenter.default.publisher(for: .currentUserIdChanged, object: nil)
            .sink {[weak self] no in
                ...
                self.userConfig = AppConfig.getUserConfig(from: self.currentAppServer, userId: self.currentUserId)
            }
            ...
        /// 监听  `currentAppServer` 的变化重新生成 `UserConfig`
        NotificationCenter.default.publisher(for: .currentServerChanged, object: nil)
            .sink { [weak self] no in
                ...
                self.userConfig = AppConfig.getUserConfig(from: self.currentAppServer, userId: self.currentUserId)
            }
            ...
    }
    ...
}

修复 MyPageViewModel

class MyPageViewModel: BaseViewModel {
    ...
    /// 当前选中的车间
    @Published var currentWorkshop:GetAllWorkshopResponse? {
        didSet {
            AppConfig.share.userConfig.workShopCode = currentWorkshop?.workshopCode
        }
    }
    ...
    
    /// 当前选中产线的模型
    @Published var currentProductLine:GetAllProductLineApiResponse? {
        didSet {
            AppConfig.share.userConfig.productLineCode = currentProductLine?.code
        }
    }
    ...
    
    /// 当前选中的仓库
    @Published var currentStoreHouse:GetAllStoreHouseApiResponse? {
        didSet {
            AppConfig.share.userConfig.storeHouseCode = currentStoreHouse?.code
        }
    }
    
    override init() {
        ...
        workshopCancellabel = AppConfig.share.userConfig.$workShopCode.sink {[weak self] value in
            ...
        }
    }
    
    ...
    
    private func getAllWorkShop() async {
        ...
        if let workShopCode = AppConfig.share.userConfig.workShopCode {
            ...
            guard let _ = index else {
                /// 如果查询不到 意味着之前选中的数据已经不存在 则默认第一个
                AppConfig.share.userConfig.workShopCode = workShops.first?.workshopCode
                return
            }
            AppConfig.share.userConfig.workShopCode = workShopCode
        } else {
            /// 如果之前没有选中的车间 则默认第一个
            AppConfig.share.userConfig.workShopCode = workShops.first?.workshopCode
        }
        currentWorkshop = data.first(where: { response in
            guard let configCode = AppConfig.share.userConfig.workShopCode else {return false}
            ...
        })
    }
    
    /// 获取车间下面的所有产线
    private func getAllProductLine() async {
        guard let workShopCode = AppConfig.share.userConfig.workShopCode else {
            return
        }
        ...
        /// 是否存在之前选中保存的产线code
        if let productLineCode = AppConfig.share.userConfig.productLineCode {
            ...
        } else {
            ...
        }
    }
    
    func getAllStoreHouse() async {
       ...
        /// 是否存在之前保存过的仓库
        if let storeHouseCode = AppConfig.share.userConfig.storeHouseCode {
            ...
        } else {
            ...
        }
    }
    
    
    ...
        
    /// 当前选中车间的名称
    func currentWorkShopName() -> String? {
        return workShops.first { response in
            guard let workShopCode = AppConfig.share.userConfig.workShopCode else {return false}
            ...
        }?.name
    }
    
    ...
}

修复 UserManager

1 修复报错

struct UserManager {
    ...
    
    /// 进行登录
    func login() {
        ...
        AppConfig.share.userConfig.gatewayUserName = gatewayUserName
        AppConfig.share.userConfig.userInfoModel = user
        ...
    }
}

在修复上述代码的时候,我们发现了一个问题。

/// 进行登录
func login() {
    AppConfig.share.isEmplyeeNoFromCache = false
    /// 设置`gatewayUserName`和`userInfoModel`值
    ...
    /// 设置`currentUserId`重新创建一个新的`UserConfig`
    AppConfig.share.currentUserId = employeeNo
}

系统监听到currentUserId变动,重新创建新的UserConfig

2 调整 设置 gatewayUserName 和 userInfoModel 值位置

/// 进行登录
func login() {
    ...
    AppConfig.share.currentUserId = employeeNo
    
    AppConfig.share.userConfig.gatewayUserName = gatewayUserName
    AppConfig.share.userConfig.userInfoModel = user
}

75AB18D4-F91F-4C66-8988-39546467BB58-2852-0000160BA34BBE50

运行登录,看起来十分的正常。但是我们操作退出的时候发现了竟然无法退出。

A173D48C-59DA-4048-BEF6-9F4DB40921A5-2852-000016289EB2DBED

修复退出登录异常

struct MyPage: View {
    ...
    
    private func logoutButton() -> some View {
        Button {
            appConfig.userConfig.gatewayUserName = nil
            appConfig.currentTabIndex = 0
        } label: {
            ...
        }

    }
    
    ...
}

无法退出的原因在于我们判断登录调整为employeeNo,但是退出登录清空的是gatewayUserName

1 修复退出失败

struct MyPage: View {
    ...
    
    private func logoutButton() -> some View {
        Button {
            appConfig.currentUserId = nil
            ...
        } label: {
            ...
        }

    }
    
    ...
}

2 UserManager 新增退出方法

struct UserManager {
    ...
    
    /// 退出登录
    static func logout() {
        AppConfig.share.currentUserId = nil
        AppConfig.share.currentTabIndex = 0
    }
}
struct MyPage: View {
    ...
    private func logoutButton() -> some View {
        Button {
            UserManager.logout()
        } label: {
            ...
        }

    }
    ...
}

C8D72444-4FAB-4D5F-9FB6-5DE21768F4A5-2852-00001836F3D406E7

第二十七章 UINavigationBarAppearance|Divider

作者 君赏
2026年2月23日 13:46

在我的界面,导航栏和内容视图已经融合在一起了,我们没有办法分清楚。

image-20211210180515722

我们准备让导航条和内容分开,不然这样看起来的UI太丑了。

/// 页面的基础试图
struct PageContentView<Content:View,
                        Leading:View,
                        Trailing:View,
                        ViewModel:BaseViewModel>: View {
    
    ...
    
    /// 初始化页面试图
    /// - Parameters:
    ///   - title: 导航标题
    ///   - contentBuilder: 内容
    ///   - leadingBuilder: 导航左侧按钮
    ///   - trailingBuildeder: 导航右侧按钮
    init(title:String,
         viewModel:ViewModel,
         @ViewBuilder contentBuilder:() -> Content,
         @ViewBuilder leadingBuilder:() -> Leading,
         @ViewBuilder trailingBuildeder:() -> Trailing) {
        ...
        
        let appearance = UINavigationBarAppearance()
        UINavigationBar.appearance().standardAppearance = appearance
        UINavigationBar.appearance().compactAppearance = appearance
        UINavigationBar.appearance().scrollEdgeAppearance = appearance
    }
    
    ...
}

image-20211210180650420

此时我们创建一个默认导航条的配置,可以轻松和内容是如区分。我们设置一下导航条的背景颜色为白色,和我们底部的颜色保持一致。

let appearance = UINavigationBarAppearance()
appearance.backgroundColor = .white

image-20211213105741257

如图所示,我们在最后一行也显示了线,导致界面上十分的丑,我们将界面可以进行配置这条线。

struct MyCellContentView<Right:View>: View {
    ...
    private let isShowBottomLine:Bool
    ...
    
    init(title:String,
         isShowBottomLine:Bool = true,
         @ViewBuilder rightBuilder:() -> Right) {
        ...
        self.isShowBottomLine = isShowBottomLine
        ...
    }
    
    var body: some View {
        VStack(spacing: 0) {
            ...
            if isShowBottomLine {
                ...
            } else {
              /// 是为了填充让控件一样的高度
                Color.clear
                    .frame(height: 0.5)
            }
        }
        ...
    }
}
struct MyDetailStyle1CellContentView: View {
    ...
    private let isShowBottomLine:Bool
    init(title:String,
         detail:String,
         isShowBottomLine:Bool = true) {
        ...
        self.isShowBottomLine = isShowBottomLine
    }
    var body: some View {
        MyCellContentView(title: title,
                          isShowBottomLine: isShowBottomLine) {
            ...
        }
    }
}
struct MyDetailCellContentView: View {
    ...
    private let isShowBottomLine:Bool
    
    init(title:String,
         detail:String,
         isShowBottomLine:Bool = true) {
        ...
        self.isShowBottomLine = isShowBottomLine
    }
    
    var body: some View {
        MyCellContentView(title: title,
                          isShowBottomLine: isShowBottomLine) {
            ...
        }
    }
}

突然我们发现有 Divider 这个组件,就是分割 UI元素用的,我们可以替换我们之前自定义的线。

struct MyCellContentView<Right:View>: View {
    ...
    var body: some View {
        VStack(spacing: 0) {
            ...
            if isShowBottomLine {
                Divider()
                    .padding(.leading, 15)
            } else {
                ...
            }
        }
        ...
    }
}

image-20211213112110861

我们自动登录的高度明显要高于其他,主要原因我们设置自动布局,并且设置外边距是 15。这就导致 Switch组件默认高度比较高,加上15的Padding之后,整体放入高度会比较高。

我们将组件限制为50 高度,其余的元素全部居中对齐。

struct MyCellContentView<Right:View>: View {
    ...
    var body: some View {
        ZStack {
            VStack(spacing: 0) {
                HStack {
                    ...
                }
                ...
                .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
            }
            VStack {
                Spacer()
                if isShowBottomLine {
                    ...
                } else {
                    ...
                }
            }
        }
        ...
        .frame(height:50)
    }
}

image-20211213113045069

此时我们的界面已经优化的和设计图差不多了,接下来我们开始优化我们功能。

class AppConfig: ObservableObject {
    ...
    
    @AppStorage("gatewayUserName")
    var gatewayUserName:String?
    
    /// 当前选中的工厂代码
    @AppStorage("currentFactoryCode")
    var currentFactoryCode:String?
    
    @AppStorage("userInfo")
    private var userInfo:String?
    ...
    
    /// 是否自动登录
    @AppStorage("isAutoLogin")
    var isAutoLogin = false
    
    /// 选中的车间代码
    @AppStorage("workShopCode")
    /// 因为 _workShopCode 已经被系统使用 我们用workShopCode_
    private var workShopCode_:String?
    
    ...
    /// 选中的产线 code
    @AppStorage("productLineCode")
    var productLineCode:String?
    
    /// 选中仓库 code
    @AppStorage("storeHouseCode")
    var storeHouseCode:String?
    
    ...
}

观察上述的代码,我们的本地存储是有问题的。因为服务器地址,用户发生了改变,这些值就要跟着发生改变。那就意味着,我们不能用这些不变的作为Key,需要加入服务器地址和用户的唯一ID作为条件。

但是我们在 AppConfig 初始化Key又拿不到当前保存的服务器地址和用户的唯一ID,我们不妨把用户相关的配置分离出来。

/// 用户配置
class UserConfig: ObservableObject {
    private let server:String
    private let user:String
    
    @AppStorage(gatewayUserNameKey)
    var gatewayUserName:String?
    
    private var gatewayUserNameKey:String {
        return "gatewayUserName_\(server)_\(user)"
    }
    
    init(server:String, user:String) {
        self.server = server
        self.user = user
    }
}

但是上面代码报了错误

Cannot use instance member 'gatewayUserNameKey' within property initializer; property initializers run before 'self' is available

这样我们无法进行初始化,我们就按照之前我们做的,自己自定义进行初始化。

/// 用户配置
class UserConfig: ObservableObject {
    ...
    @AppStorage
    var gatewayUserName:String?
    
    init(server:String, user:String) {
        ...
        self._gatewayUserName = AppStorage("gatewayUserName_\(server)_\(user)")
    }
}

我们将所有和用户有关的配置都转移到 UserConfig 里面。

/// 用户配置
class UserConfig: ObservableObject {
    ...
    init(server:String, user:String) {
        self.server = server
        self.user = user
        let userKey = "\(server)_\(user)"
        self._gatewayUserName = AppStorage("gatewayUserName_\(userKey)")
        self._currentFactoryCode = AppStorage("currentFactoryCode_\(userKey)")
        self._userInfo = AppStorage("userInfo_\(userKey)")
        self._isAutoLogin = AppStorage(wrappedValue: false, "isAutoLogin_\(userKey)")
        self._workShopCode_ = AppStorage("workShopCode_\(userKey)")
        self._productLineCode = AppStorage("productLineCode_\(userKey)")
        self._storeHouseCode = AppStorage("storeHouseCode_\(userKey)")
        
        self.workShopCode = workShopCode_
    }
}

image-20211213171441348

我们要获取用户的配置的时候必须要拿到用户ID,获取用户ID的时候必须拿到用户配置。这个似乎陷入了死循环中,我们看下面的流程。

image-20211213171843676

我们在整个流程中发现,只有当用户没有登录,重新登录可以拿到用户ID获取到用户配置,才能打破这个死循环。但是在已经登录的流程,想要获取到用户配置就是一个死循环。

想要打破这个循环,就要改变上面的逻辑。

image-20211213172240351

我们将判断是否登录换成了判断本地是否有用户ID,有了用户ID就可以获取到用户配置,从而打破循环。

class AppConfig: ObservableObject {
    ...
    /// 当前登录的用户ID
    @AppStorage("currentUserId")
    var currentUserId:String?
    ...
}

字段 currentUserId 来源于我们用户信息中的 employeeNo 字段,我们在用户登录的时候进行保存employeeNo字段到本地。

class LoginPageViewModel: BaseViewModel {    
    ...
    func login() async {
        ...
        AppConfig.share.currentUserId = model.data?.user?.employeeNo
    }
}

此时我们看一下我们当前用户登录之后的设置代码。

if let gatewayUserName = model.data?.gatewayUserName {
    /// 放在 [AppConfig.share.gatewayUserName] 赋值的前面 这样 gatewayUserName 通知时候才能获取 isGatewayUserNameFromCache 最新值
    AppConfig.share.isGatewayUserNameFromCache = false
    AppConfig.share.userConfig?.gatewayUserName = gatewayUserName
}
AppConfig.share.userConfig?.userInfoModel = model.data?.user
AppConfig.share.currentUserId = model.data?.user?.employeeNo

我们此时只有一处地方可以登录,我们后续可能还有手机号/微信/微博/苹果等等登录方式,可能登录地方就要写很多这种逻辑。我们不如将登录之后的逻辑放在一个统一的方法里面,以后在其他登录方法或者页面登录之后进行调用。

struct UserManager {
    /// 登录时候 类似于JWT的值
    private let gatewayUserName:String
    /// 用户唯一的 ID 当前值代表员工的工号
    private let employeeNo:String
    /// 用户的信息
    private let user:UserInfoModel
    /// 初始化用户管理中心 如果初始化失败 则返回异常
    /// - Parameter response: 用户登录的返回内容
    init(userLogin response:UserLoginResponse) throws {
        guard let gatewayUserName = response.gatewayUserName, !gatewayUserName.isEmpty else {
            throw "[gatewayUserName]返回为空"
        }
        self.gatewayUserName = gatewayUserName
        guard let user = response.user else {
            throw "[user]返回为空"
        }
        self.user = user
        guard let employeeNo = response.user?.employeeNo, !employeeNo.isEmpty else {
            throw "[employeeNo]返回为空"
        }
        guard let _ = Int(employeeNo) else { throw "[employeeNo]必须是纯数字" }
        self.employeeNo = employeeNo
    }
    
    /// 进行登录
    func login() {
        AppConfig.share.isGatewayUserNameFromCache = false
        AppConfig.share.userConfig?.gatewayUserName = gatewayUserName
        AppConfig.share.userConfig?.userInfoModel = user
        AppConfig.share.currentUserId = employeeNo
    }
}

我们在 UserManager 初始化的时候做了验证并可能抛出异常,我们初始化这么多验证,如果后续的字段更多,岂不是初始化逻辑就很复杂了。我们修改一下上面初始化方法,将验证进行一次简化。

struct UserManager {
    ...
    init(userLogin response:UserLoginResponse) throws {
        self.gatewayUserName = try UserManager.verify(gatewayUserName: response.gatewayUserName)
        self.user = try UserManager.verify(user: response.user)
        self.employeeNo = try UserManager.verify(employeeNo: self.user.employeeNo)
    }
    
    /// 验证 gatewayUserName 的值
    /// - Parameter name: gatewayUserName 值
    /// - Returns: 验证通过的 gatewayUserName 值
    private static func verify(gatewayUserName name:String?) throws -> String {
        guard let gatewayUserName = name, !gatewayUserName.isEmpty else {
            throw "[gatewayUserName]返回为空"
        }
        return gatewayUserName
    }
    
    /// 验证用户信息
    /// - Parameter user: 用户信息
    /// - Returns: 验证通过的用户信息
    private static func verify(user model:UserInfoModel?) throws -> UserInfoModel {
        guard let user = model else {
            throw "[user]返回为空"
        }
        return user
    }
    
    /// 验证 employeeNo 的值
    /// - Parameter no: employeeNo 值
    /// - Returns: 验证通过的 employeeNo 值
    private static func verify(employeeNo no:String?) throws -> String {
        guard let employeeNo = no, !employeeNo.isEmpty else {
            throw "[employeeNo]返回为空"
        }
        guard let _ = Int(employeeNo) else { throw "[employeeNo]必须是纯数字" }
        return employeeNo
    }
    
    ...
}

此时我们将验证提炼出来,可以给 UserManager的其他的初始化方法进行调用。我们还可以对于代码进行提炼进行修改,我们修改成下面的样子。

struct UserManager {
    ...
    init(userLogin response:UserLoginResponse) throws {
        self.gatewayUserName = try GatewayUserName(response.gatewayUserName).value
        self.user = try User(response.user).value
        self.employeeNo = try EmployeeNo(response.user?.employeeNo).value
    }
    
    ...
}

fileprivate protocol UserResponseVerify {
    associatedtype T
    var value:T { get }
    init(_ value:T?) throws
}

extension UserManager {
    struct GatewayUserName: UserResponseVerify {
        let value: String
        init(_ value: String?) throws {
            ... 验证过程
            self.value = gatewayUserName
        }
    }
    
    struct User: UserResponseVerify {
        let value: UserInfoModel
        init(_ value: UserInfoModel?) throws {
            ... 验证过程
            self.value = user
        }
    }
    
    struct EmployeeNo: UserResponseVerify {
        let value: String
        init(_ value: String?) throws {
            ... 验证过程
            self.value = employeeNo
        }
    }
}

我们修改成这个样子之后,已经渐渐的和 DDD(领域驱动)沾点边了。

class LoginPageViewModel: BaseViewModel {    
    ...    
    func login() async {
        ...
        if let response = model.data, let userManager = try? UserManager(userLogin: response) {
            userManager.login()
        }
    }
...
}

我们修改了逻辑,已经在登录完毕完成了保存 employeeNo的值,此时我们就要写一下UserConfig的逻辑。

image-20211214162930988

class AppConfig: ObservableObject {
    ...
    var userConfig:UserConfig?
    
    /// 当前登录的用户ID
    @AppStorage("currentUserId")
    var currentUserId:String?
    
    init() {
        ...
        /// 初始化 UserConfig
        self.userConfig = getUserConfig()
        /// 监听 currentUserId 的变化
        /// @AppStorage是无法进行监听的
    }
    
    private func getUserConfig() -> UserConfig? {
        guard !currentAppServer.isEmpty else { return nil }
        guard let currentUserId = try? UserManager.EmployeeNo(currentUserId).value else { return nil }
        return UserConfig(server: currentAppServer, user: currentUserId)
    }
}

我们使用 @AppStorage 是无法通过 sink监听值更新的。我们可以在 currentUserIddidSet中去操作设置新的UserConfig,但是我们上面的逻辑就显得有点中断。

我们可以通过Notification进行实现,让流程连贯起来,方便阅读和维护。

class AppConfig: ObservableObject {
    ...
    
    private var cancellabelSet:Set<AnyCancellable> = []
    
    init() {
        ...
        /// 初始化 UserConfig
        self.userConfig = getUserConfig()
        /// 监听 currentUserId 的变化
        /// `@AppStorage` 是无法进行监听的 因此这里采用 `Notification`
        NotificationCenter.default.publisher(for: .currentUserIdChanged, object: nil)
            .sink {[weak self] no in
                /// 监听到 `currentUserId` 改变的时候 更新 `UserConfig`
                guard let self = self else { return }
                self.userConfig = self.getUserConfig()
            }
            .store(in: &cancellabelSet)
    }
    
    ...
}

fileprivate extension Notification.Name {
    static let currentUserIdChanged = Notification.Name("currentUserIdChanged")
}

写到这里我们发现了userConfig是一个Optional可选值,是无法通过@StateObject初始化的。但是UserConfig如果用户没有登录则无法进行初始化。

/// ❌ Cannot convert value of type 'UserConfig?' to specified type 'UserConfig'
@StateObject private var useConfig:UserConfig = AppConfig.share.userConfig

我想通过用户没有登录就创建一个空的UserConfig,当登录或者重新登录就对当前的UserConfig进行重新的赋值,但是这样的操作十分的麻烦。

就当我绝望,觉得只能通过通过一个个更新才能实现的时候,我想到了在Flutter中可以监听整个对象,如果对象变动,则会更新使用此对象属性所有的Widget

那么这个思路是否可以通过SwiftUI中实现吗,我们下一章接下来说。

第二十六章 Focused

作者 君赏
2026年2月23日 13:46

新增 Profile 环境

到此我们已经做完了登录页面 首页 我的页面,但是还是存在一些问题需要进行优化,比如登录页面在第一次安装App的时候,默认没有服务器地址,需要用户手动的选择一个,这样就让用户可能多一次操作,体验不是很好。

为了可以优化体验,我们觉得第一次安装App的时候给服务器地址增加一项默认值,但是默认值设置为那一个?

image-20211208172545636

我们目前可以获取是否是 DEBUG编译还是 RELEASE编译,但是无法区分是PROFILE编译。那么我们基于DEBUG环境新增一套环境作为我们PROFILE环境。

image-20211208173338173

这样我们已经可以区分是否是Profile环境,我们给服务器一个默认的值。

class AppConfig: ObservableObject {
    ...
    
    init() {
        ...
        if currentAppServer.isEmpty {
            #if DEBUG
            currentAppServer = AppServer.debug.rawValue
            #elseif PROFILE
            currentAppServer = AppServer.profile.rawValue
            #else
            currentAppServer = AppServer.release.rawValue
            #endif
        }
    }
}

登陆页面,记住密码的选项默认也是关闭的,我们修改默认为打开,可以方便用户下次登陆页面不需要重复输入账号和密码。

class LoginPageViewModel: BaseViewModel {    
    ...
    
    ///是否记住密码
    @AppStorage("isRememberPassword")
    var isRememberPassword:Bool = true
    ...
}

focused 获取输入框是否获取焦点

我们下次启动,用户名和密码已经自动填写,但是我们更换用户名的时候需要一个个的进行删除,如果我们编辑的时候展示删除按钮岂不是可以一键的进行删除。

但是在 SwiftUI 中给 TextField 中添加 ClearMode十分的困难。不过我们可以通过 focusedModify获取到输入框获取到焦点。

func focused(_ condition: FocusState<Bool>.Binding) -> some View

那么我们就封装一下,当获取焦点的时候并且内容不为空时候,显示Clear按钮。

struct ClearTextField: View {
    private let title:String
    @Binding private var text:String
    /// 获取当前输入框是否获取焦点
    @FocusState private var isFocus:Bool
    
    /// 保持和 TextField 一致 好替换
    init(_ title:String, text:Binding<String>) {
        self.title = title
        self._text = text
    }
    
    var body: some View {
        HStack {
            TextField(title, text: $text)
                .focused($isFocus)
            if isShowClearButton {
                /// `X`按钮
                Image(systemName: "xmark")
                    .padding()
                    .onTapGesture {
                        /// 点击清空文本
                        text = ""
                    }
            }
        }
    }
    
    private var isShowClearButton:Bool {
        /// 当获取焦点并且文本不为空才显示清空的按钮
        isFocus && !text.isEmpty
    }
}

在测试过程中 ClearTextField 组件预览输入文本,无法正常显示 Clear 按钮。但是用在登陆页面,就可以正常显示,这一点很奇怪。

我们的密码不能直接使用上述组件,因为密码需要用到 SecureField 组件。我们给 ClearTextField 新增一个属性,控制使用 TextFiled 还是 ClearTextField

struct ClearTextField: View {
    ...
    private var isSecure:Bool
    
    /// 保持和 TextField 一致 好替换
    init(_ title:String, text:Binding<String>, isSecure:Bool = false) {
        ...
        self.isSecure = isSecure
    }
    
    var body: some View {
        HStack {
            if !isSecure {
                TextField(title, text: $text)
                    .focused($isFocus)
            } else {
                SecureField(title, text: $text)
                  .focused($isFocus)
            }
            ...
        }
    }
    
    ...
}

我们登陆页面的用户名和密码输入框换成我们封装的输入框。

struct UserNameValueContentView: View {
    ...
    private var userNameField:some View {
        ClearTextField("请输入用户名", text: $viewModel.userName)
    }
}
struct PasswordValueContentView: View {
    ...
    private var passwordField:some View {
        ClearTextField("请输入密码",
                       text: $viewModel.password,
                       isSecure: true)
    }
}

553E5A26-8F29-42E9-AE0A-CD5323313394-13561-00000AFEB2A5D84B

我们运行看了一下效果,发现输入框的高度在获取焦点和失去焦点相互跳跃。原来在于我们给最后 Clear 按钮添加 Padding的时候,上下也添加了 Padding,导致 Clear出现的时候比 输入框的高度还搞 就自动拉伸了整个控件的高度。

为了解决高度跳动的问题,我们将输入框的高度固定在33,因为在UIKitUITextField的默认高度就是33。之后设置Clear按钮的高度也是33。

struct ClearTextField: View {
    ...
    var body: some View {
        HStack {
            ...
            if isShowClearButton {
                /// `X`按钮
                Image(systemName: "xmark")
                    .frame(maxHeight: .infinity)
                    .padding(.leading, 10)
                    .padding(.trailing, 10)
                    ...
            }
        }
        .frame(height:33)
    }
    ...
}

5796E81B-65F3-4BF2-B432-85B1575E39ED-13561-00000B6F570E1035

此时我们的输入框的高度不会来回的跳跃了。

虽然我们用户名和密码可以一键的清空,但是我们我们修改用户名的时候,密码按道理说应该被清空。我们不考虑不同账号同一个密码的情况,这种极少存在。

那么我们监听到用户名输入框内容修改的时候,我们就清空密码输入框。

class LoginPageViewModel: BaseViewModel {    
    ...
    /// 监听用户名输入
    private var userNameCancellabel:AnyCancellable?
    
    override init() {
        ...
        userNameCancellabel = $userName.sink(receiveValue: {[weak self] _ in
            guard let self = self else {return}
            self.password = ""
        })
    }
    
    ...
}

image-20220104150820876

我们运行发现,我们本来记住密码的功能失效了,密码被清空了。不明白为什么sink之后立马得到回掉,这个是导致密码被清空的原因所在。

不过我在研究的过程中,发现了一个规律可以解决这个问题。

sink value = admin username = admin /// 相等不需要进行操作
sink value = admin1 username = admin /// 不想等需要进行操作
sink value = admin1 username = admin1 /// 相等不需要进行操作

虽然我们初始化和执行一次用户名更改调用了三次,但是需要执行的只有一次。我们只需要在判断 sink value != userName的情况下去操作我们的密码。

class LoginPageViewModel: BaseViewModel {    
    ...
    /// 监听用户名输入
    private var userNameCancellabel:AnyCancellable?
    override init() {
        ...
        userNameCancellabel = self.$userName.sink(receiveValue: {[weak self] name in
            guard let self = self else {return}
            /// 如果更新的用户名发生了变动,则清空密码输入框
            guard name != self.userName else {return}
            self.password = ""
        })
    }
    ...
}

CEE35567-50CF-4C8B-8076-E51B41E28BDD-6495-00000700DF581C6D

我们输入框在被用户变更之后已经可以正常的删除密码,但是我们的登录按钮在用户名和密码都为空的情况下,竟然依然可以点击操作,这是不合理的。

我们希望在用户名和密码没有输入的情况下,按钮的背景颜色灰色看起来不可点击,当用户输入用户名和密码之后,登录按钮变亮可以点击。

image-20211209161725362

在我们封装登录按钮的时候我们无法感知外界对于按钮的因素变化,现在只需要判断用户名和密码是否存在,后面可能会需要判断用户名的组成格式是否正确。

我们修改一下流程图。

这样我们登录按钮只需要关心外面传入的是否可以点击控制自己的状态。

struct LoginButton: View {
    @StateObject private var appColor = AppColor.share
    /// 是否激活按钮
    @Binding private var isActive:Bool
    private let action:() -> Void
    
    init(isActive:Binding<Bool>, action:@escaping () -> Void) {
        self._isActive = isActive
        self.action = action
    }
    
    var body: some View {
        Button(action:action) {
            Text("登录")
                .frame(maxWidth:.infinity)
                .frame(height: 45)
                .background(background)
                .foregroundColor(.white)
                .cornerRadius(5)
        }
        .disabled(!isActive)
    }
    
    @ViewBuilder private var background:some View {
        /// 按钮激活 背景色 #209090 按钮禁用 背景色 #cccccc
        if isActive {
            Color(uiColor: appColor.c_209090)
        } else {
            Color(uiColor: appColor.c_cccccc)
        }
    }
}

为了可以在登录页面根据输入的用户名和密码变化,更改登录按钮的激活状态。

struct LoginPage: View {
    @StateObject private var viewModel:LoginPageViewModel = LoginPageViewModel()
    ...
    var body: some View {
        PageContentView(title: "登陆", viewModel: viewModel) {
            VStack {
                ...
                VStack(spacing:30) {
                    ...
                    LoginButton(isActive: $viewModel.isLoginButtonActive) {
                        Task {
                            await viewModel.login()
                        }
                    }
                }
                ...
            }
            ...
        }
        ...
    }
}
class LoginPageViewModel: BaseViewModel {    
    ...
    private var cancellabel:Set<AnyCancellable> = []
    /// 登录按钮是否激活
    @Published var isLoginButtonActive:Bool = false
    
    override init() {
        ...
        self.$userName.sink(receiveValue: {[weak self] value in
            guard let self = self else {return}
            self.updateLoginButtonActive()
            ...
        }).store(in: &cancellabel)
        
        self.$password.sink {[weak self] value in
            guard let self = self else {return}
            self.updateLoginButtonActive()
        }.store(in: &cancellabel)
    }
    
    ...
    
    /// 更新登录按钮的状态
    private func updateLoginButtonActive() {
        /// 只有用户名和密码通知不为空的时候才可以激活登录按钮
        isLoginButtonActive = !userName.isEmpty && !password.isEmpty
    }
}

B66B5806-C0B3-4C13-AD26-B5FD3495B9D0-6495-00000B49C1511169

第二十五章 完善登录逻辑

作者 君赏
2026年2月23日 13:45

实现自动登录

接下来我们需要做 `自动登陆功能,自动登陆就是登陆之后,下次启动开启状态下,直接进入首页。关闭情况下,则进入登陆页面。

image-20211208090638855

@main
struct Win_App: App {
    @StateObject private var appConfig:AppConfig = AppConfig.share
    var body: some Scene {
        WindowGroup {
            if isLogin {
                if appConfig.isAutoLogin {
                    TabPage()
                } else {
                    LoginPage()
                }
            } else {
                LoginPage()
            }
        }
    }
    
    ...
}

我们需要两处需要初始化LoginPage的地方,这个玩意需要参数,或者其他设置,就比较麻烦了,虽然我们可以提炼代码。

@main
struct Win_App: App {
    @StateObject private var appConfig:AppConfig = AppConfig.share
    var body: some Scene {
        WindowGroup {
            if isLogin {
                if appConfig.isAutoLogin {
                    TabPage()
                } else {
                    loginPage()
                }
            } else {
                loginPage()
            }
        }
    }
    
    ...
    
    private func loginPage() -> some View {
        LoginPage()
    }
}

但是我们的判断逻辑依然十分的复杂,我们可以变更一下流程图。

image-20211208091618692

我们将判断的逻辑封装成一个方法,这样虽然看起来没啥变化,但是对于页面处理逻辑清晰。

@main
struct Win_App: App {
    @StateObject private var appConfig:AppConfig = AppConfig.share
    var body: some Scene {
        WindowGroup {
            if isNeedLogin {
                LoginPage()
            } else {
                TabPage()
            }
        }
    }
    
    ...
    
    private var isNeedLogin:Bool {
        return !isLogin || !appConfig.isAutoLogin
    }
    
}

但是,经过测试,我们登陆成功也是无法进入首页的,因为 isAutoLogin 默认关闭的。经过思考,我们上面的逻辑是有问题的,需要修改一些逻辑。

image-20211208112523686

1 当App全新未安装的时候(红线代表逻辑走向)

image-20211208112559886

2 当执行登陆完毕之后

image-20211208112735217

3 第二次启动 App已经登录过 但是没有开启自动登录

image-20211208112839580

4 App启动 App已经登录过,开启了自动登录

image-20211208113106569

我们按照流程图写一下代码

@main
struct Win_App: App {
    @StateObject private var appConfig:AppConfig = AppConfig.share
    ...

    private var isNeedLogin:Bool {
        /// 如果 gatewayUserName 不存在 则需要进行登录
        guard isExitGatewayUserName else { return true}
        /// 如果 gatewayUserName 存在 并且 isGatewayUserNameFromCache = false 代表是刚刚登录的 则不需要登录
        guard appConfig.isGatewayUserNameFromCache else { return false }
        /// 如果 gatewayUserName 存在 并且 isGatewayUserNameFromCache = true 代表登录是之前运行操作的 如果没开启自动登录就需要前往重新登录
        return !appConfig.isAutoLogin
    }
    
    /// 是否存在 gatewayUserName
    private var isExitGatewayUserName:Bool {
        guard let gatewayUserName = appConfig.gatewayUserName else { return false }
        return !gatewayUserName.isEmpty
    }
}
class AppConfig: ObservableObject {
    ...
    
    /// gatewayUserName 是否来源于缓存 默认来源于缓存
    var isGatewayUserNameFromCache:Bool = true
    
    ...
}
class LoginPageViewModel: BaseViewModel {    
    ...
    func login() async {
        ...
        if let gatewayUserName = model.data?.gatewayUserName {
            /// 放在 [AppConfig.share.gatewayUserName] 赋值的前面 这样 gatewayUserName 通知时候才能获取 isGatewayUserNameFromCache 最新值
            AppConfig.share.isGatewayUserNameFromCache = false
            AppConfig.share.gatewayUserName = gatewayUserName
        }
        ...
    }
}

image-20211208115656854

接下来我们需要获取版本号和 build号显示出来,这个简单一些。

struct MyPage: View {
    ...
    
    private func appVersionCell() -> some View {
        MyDetailStyle1CellContentView(title: "版本",
                                      detail: viewModel.versionValue)
    }
    
    ...
}
class MyPageViewModel: BaseViewModel {
    ...
    
    var versionValue:String {
        guard let infoDictionary = Bundle.main.infoDictionary else { return "" }
        guard let version = infoDictionary["CFBundleShortVersionString"] else { return "" }
        guard let buildNumber = infoDictionary["CFBundleVersion"] else { return "" }
        return "\(version)(\(buildNumber))"
    }
}

我的页面接下来就只剩下退出登录功能了,我们按照我们上方登录流程图来看,只需要将 gatewayUserName 设置为 nil即可实现退出登录,回到登录界面。

struct MyPage: View {
    ...
    @StateObject private var appConfig = AppConfig.share
    ...
    
    private func logoutButton() -> some View {
        Button {
            appConfig.gatewayUserName = nil
        } label: {
            ...
        }

    }
    
    ...
}

9DA7C7E9-5D85-4341-94BF-99C27531E814-2354-00000163CADE1649

研究界面的初始化和重建

但是我们重新进来还是在我的界面,既然重新登录,我认为就应该回到首页。我们在研究生命周期时候发现下面的打印。

struct TabPage: View {
    
    ...
    
    init() {
        print("-> TabPage init")
        ...
    }
    
    var body: some View {
        TabView(selection:$currentTabIndex) {
            ...
        }
        ...
        .onAppear {
            print("-> currentTabIndex = \(currentTabIndex)")
        }
    }
}
-> LoginPage init // 未登录展示登录界面
-> TabPage init // 登陆 初始化TabPage
-> HomePage init // 初始化HomePage
-> MyPage init // 初始化 MyPage
-> currentTabIndex = 0 // TabPage onAppear
-> TabPage init // ??? 为啥再次初始化一次
-> HomePage init /// 点击currentTabIndex = 1 重新初始化HomePage
-> MyPage init // 重新初始化 MyPage
-> TabPage init // ??? 不理解为啥再次初始化
-> TabPage init // ??? 不理解为啥再次初始化
-> TabPage init // ??? 不理解为啥再次初始化
-> TabPage init // ??? 不理解为啥再次初始化

TabPage init打印了很多次,但是 currentTabIndex 只打印了一次,那就是 onAppear只执行了一次。我们简单绘制一下渲染树结构,按照Page为单位。

image-20211208145421464

我们通过 @State 将数结构细化一点

image-20211208151019693

从首页切换到我的页面,为啥切换到我的页面会打印这么多次 TabPage init?我的页面和首页的不同就是,首页初始化了工厂列表,我的页面在onAppear方法里面执行了初始化车间和产线还有仓库数据的操作。

难道和这个有关系,我们屏蔽一下初始化的代码。

struct MyPage: View {
    ...
    var body: some View {
        ...
        return PageContentView(title: "我的", viewModel: viewModel) {
            ...
            }
        }
        .onAppear {
//            Task {
//                await viewModel.initData()
//            }
        }
    }
    
    ...
}
struct HomePage: View {
    ...
    var body: some View {
        return NavigationView {
            ...
        }
        ...
        .onAppear {
//            Task {
//                await viewModel.requestFactoryList()
//            }
        }
    }
}

我们再次看一下日志输出。

-> LoginPage init // 未登录展示登录界面
-> TabPage init // 登陆 初始化TabPage
-> HomePage init // 初始化HomePage
-> MyPage init // 初始化 MyPage
-> currentTabIndex = 0 // TabPage onAppear
-> HomePage init /// 点击currentTabIndex = 1 重新初始化HomePage
-> MyPage init // 重新初始化 MyPage
-> TabPage init // ??? 不理解为啥再次初始化

少了四次 TabPage init,这四次应该就是刷新我的界面的 车间/产线/仓库显示和刷新首页工厂操作引起的。但是这样依然 View 初始化的很多次,按照我们的操作。

/// 下面是理想状态下的输出
-> LoginPage init // 未登录展示登录界面
-> TabPage init // 登陆 初始化TabPage
-> HomePage init // 初始化HomePage
-> MyPage init // 初始化 MyPage
-> currentTabIndex = 0 // TabPage onAppear

上面应该就是在 UIKit系统下面正常的数据,但是SwiftUI不同于UIKit的生命周期,但是和Flutter有类似的作用。我们打印一下Body执行的过程,这个才是真正设计到调用绘制。

-> LoginPage init /// 未登录 初始化 LoginPage
-> LoginPage Body /// 绘制 LoginPage
-> LoginPage Body /// 展示 Loading 绘制 LoginPage
-> LoginPage Body /// 展示登陆成功提示 绘制 LoginPage
-> TabPage init /// 登陆成功 初始化 TabPage
->Tab Page Body /// 绘制 TabPage
->HomePage init /// 初始化 HomePage
->MyPage init /// 初始化 MyPage 因为都没展示我的页面 所以后续不需要绘制
-> currentTabIndex = 0 /// TabPage onAppear
-> HomePage Body /// 绘制 HomePage
->Tab Page Body /// 点击 tab = 1 重新绘制 TabPage
->HomePage init /// 重新初始化 HomePage 因为首页已经绘制 所以不需要重新绘制
->MyPage init /// 重新初始化 MyPage
->MyPage Body /// 绘制 MyPage 页面
->MyPage Body /// 重新绘制 MyPage 页面
-> TabPage init /// 初始化 TabPage

从输出上面看绘制首页一次是正常的,虽然多次初始化,多次初始化对于性能影响不大。但是我的页面绘制了两次?经过不停的调试,发现我的页面比首页多执行一次的原因在于 在 HomePage中添加了 NavigationView, 而 MyPageNavigationView 是加在 TabPage里面的。

我们都将 NavigationView 转移到 TabPage,再次看一下输出。

-> LoginPage init
-> LoginPage Body
-> LoginPage Body
-> LoginPage Body
-> TabPage init
->Tab Page Body
->HomePage init
->MyPage init
-> currentTabIndex = 0
-> HomePage Body
->Tab Page Body
->HomePage init
->MyPage init
->MyPage Body
->MyPage Body
-> TabPage init

都转移出来之后,发现刚开始进入的时候就开始初始化了 我的页面了。

我们能够通过树形结构局部刷新数来优化呢?答案是肯定的,但是目前来说也没必要研究那么深入,并且现在的页面就算优化,也没有大的意义。

从上面的输入看,当页面重新初始化和绘制的时候,@State不会随着初始化的,导致我们重新登陆完毕,展示给我们的是我的界面的问题。

因为 @StateTabPage私有的,所以我们在我的页面退出登录也无法操作 TabPagecurrentTabIndex。目前想到了两种方案,第一种采用通知的形式,第二种采用@Binding。对于Struct,我猜测通知的方式可能不生效,或者麻烦,没有@Binding 方便。

class AppConfig: ObservableObject {
...
    /// 当前 Tab 的索引
    @Published var currentTabIndex:Int = 0
    
    ...
}
struct TabPage: View {
    ...
    @StateObject private var appConfig = AppConfig.share
    ...
    var body: some View {
        TabView(selection:$appConfig.currentTabIndex) {
            ...
        }
        ...
    }
}

我们采用在AppConfig中新增一个@Published标识当前选中的Tab,因为AppConfig对象随时可以访问。为了修复重新登录无法重新定位到首页,我们在退出登录重置一下 currentTabIndex

struct MyPage: View {
    ...
    @StateObject private var appConfig = AppConfig.share
    ...
    private func logoutButton() -> some View {
        Button {
            ...
            appConfig.currentTabIndex = 0
        } label: {
            ...
        }

    }
}

第二十四章 init 方法初始化 State

作者 君赏
2026年2月23日 13:44

选择车间功能做完之后,我们接下来开始做产线的功能。

image-20211206160005636转存失败,建议直接上传图片文件

但是产线的功能来源于车间,意思当车间更换之后,我们的产线就要发生变更。那么我们就要监听AppConfigworkShopCode 值发生改变,我们请求产线的数据。

但是不幸的是,我们的 workShopCode 不是通过 @Published修饰的,所以我们无法通过sink方式监听值的变更。既然我们只能通过 @Published 进行监听,我们将之前值设置为中间代码。

class AppConfig: ObservableObject {
    ...
    /// 选中的车间代码
    @AppStorage("workShopCode")
    /// 因为 _workShopCode 已经被系统使用 我们用workShopCode_
    private var workShopCode_:String?
    
    /// 用于外部监听 workShopCode 值的变更
    @Published var workShopCode:String? {
        didSet {
        /// 车间切换 更新本地缓存
            workShopCode_ = workShopCode
        }
    }
...
    
    init() {
        workShopCode = workShopCode_
    }
}
class MyPageViewModel: BaseViewModel {
    ...
    /// 保存 AnyCancellable 不然会导致后续的车间变更获取不到通知
    private var workshopCancellabel:AnyCancellable?
    override init() {
        super.init()
        workshopCancellabel = AppConfig.share.$workShopCode.sink {[weak self] value in
            /// 监听到 车间变更
            print(value ?? "")
        }
    }
    ...
}

但是在运行测试过程中,发现了一个问题。就是我们来回切换选择器值的时候,我们也收到了值更新的打印。那么我们确定的按钮根本没有起到作用。

我们功能是只有当用户点击了确定按钮的时候,我们才需要更改外部值。

struct PickerSheet<Item:DataPickerItem>: View {
    ...
    /// 只存在 PickerSheet 运行期间缓存的值 解决只有点击确定按钮才更新外部的值的问题
    @State private var cacheSelectItem:Item?
    ...
    var body: some View {
        VStack {
            ...
            DataPickerView(items: items, selectItem: $cacheSelectItem)
            ...
        }
        ...
    }
    
    private func confirmClick() {
        selectItem = cacheSelectItem
        confirmHandle()
    }
}

但是我们测试的过程中发现,每次点击弹出 PickerSheet 组件的时候,我们默认选择的第一项都是第一个。看来之前写的代码不生效?这个是什么原因导致的呢?

通过断点查看,原来 selectItem 传进来为空,想起来了,这个值默认为空,之后我们就没管了。我们需要获取到 workShops 数据之后,将之前选中的车间简码转换成对应的模型。

class MyPageViewModel: BaseViewModel {
    ...
    private func getAllWorkShop() async {
        ...
        currentWorkshop = data.first(where: { response in
            guard let configCode = AppConfig.share.workShopCode else {return false}
            guard let code = response.workshopCode else {return false}
            return configCode == code
        })
    }
    ...
}

发现上述代码改完之后,我们在测试过程中,发现还是空值,后来一想,我们使用了 cacheSelectItem 作为中间值,是因为中间值没有初始化。

struct PickerSheet<Item:DataPickerItem>: View {
    ...
    init(...) {
        ...
        self.cacheSelectItem = selectItem.wrappedValue
    }
    ...
}

在 Init 方法初始化 State

这样改后依然没有任何的效果,难道是因为在init中还没有初始化State,我们在init 自己初始化试一下。

struct PickerSheet<Item:DataPickerItem>: View {
    ...
    init(...) {
        ...
        self._cacheSelectItem = State(initialValue: selectItem.wrappedValue)
    }
    ...
}

通过上述的代码,我们 PickerSheet 弹出之后选中对应不会选中对应行的问题解决了。对于属性包装器在 init 方法完毕才初始化,这个确实是不注意就坑的地方。

接下来,我们来写获取车间下面所有产线的方法。

class MyPageViewModel: BaseViewModel {
    ...
    override init() {
        ...
        workshopCancellabel = AppConfig.share.$workShopCode.sink {[weak self] value in
            /// 监听到 车间变更
            Task {[weak self] in
                guard let self = self else {return}
                await self.getAllProductLine()
            }
        }
    }
    
    ...
    
    /// 获取车间下面的所有产线
    private func getAllProductLine() async {
        guard let workShopCode = AppConfig.share.workShopCode else {
            showHUDMessage(message: "请选选择车间!");
            return
        }
        let api = GetAllProductLineApi(workshopCode: workShopCode)
        let model:BaseModel<[GetAllProductLineApiResponse]> = await request(api: api, showHUD: false)
        guard model._isSuccess else {return}
        guard let data = model.data else {return}
        productLines = data
    }
    ...
}

我们根据切换车间,就重新获取新的产线,设置新的产线。

image-20211207140305370转存失败,建议直接上传图片文件

为了可以获取到之前保存的产线code,那么我们需要在 AppConfig新增一个变量。

class AppConfig: ObservableObject {
    ...
    /// 选中的产线 code
    @AppStorage("productLineCode")
    var productLineCode:String?
    
...
}

我们在获取产线列表方法,实现上面的流程图。

class MyPageViewModel: BaseViewModel {
    ...
    
    /// 车间下面所有的产线列表
    var productLines:[GetAllProductLineApiResponse] = []
    ...
    /// 获取车间下面的所有产线
    private func getAllProductLine() async {
        ...
        /// 是否存在之前选中保存的产线code
        if let productLineCode = AppConfig.share.productLineCode {
            /// 存在 判断在最新产线列表是否存在 就将之前的 code 进行更新
            if isExit(productLine: productLineCode, in: productLines) {
                AppConfig.share.productLineCode = productLineCode
            } else {
                /// 否则就默认第一个产线
                AppConfig.share.productLineCode = productLines.first?.code
            }
        } else {
            /// 如果不存在之前选中保存的产线 code 则直接默认第一个产线
            AppConfig.share.productLineCode = productLines.first?.code
        }
    }
    
    
    /// 是否指定产线的 code 在列表存在
    /// - Parameters:
    ///   - code: 产线的 code
    ///   - list: 产线列表
    private func isExit(productLine code:String, in list:[GetAllProductLineApiResponse]) -> Bool {
        /// 查找出指定产线 code 在列表位置
        let index = list.firstIndex(where: { response in
            guard let _code = response.code else {return false}
            return code == _code
        })
        /// 如果查找出来索引不为空,则代表存在于列表中
        return index != nil
    }
    
    ...
}

为了可以拿到当前选中产线的模型,用于显示当先选中产线的名字,我们修改一下代码。

class MyPageViewModel: BaseViewModel {
    ...
    
    /// 当前选中产线的模型
    @Published var currentProductLine:GetAllProductLineApiResponse? {
        didSet {
            AppConfig.share.productLineCode = currentProductLine?.code
        }
    }
    
    ...
    
    /// 获取车间下面的所有产线
    private func getAllProductLine() async {
        ...
        
        /// 获取列表之后 找到当前选中的模型
        currentProductLine = productLines.first(where: { response in
            guard let code = AppConfig.share.productLineCode, let _code = response.code else {return false}
            return code == _code
        })
    }
    ...
}

我们这样写逻辑也没有问题,但是自信的一想,最后一个获取当前选中模型赋值操作之后再次更新了 AppConfigproductLineCode的值。

image-20211207144826949转存失败,建议直接上传图片文件

此时我们有四条线可以更新产线的code,灰色区域三种只存在一种可能,加上外面可以更新产线code。整个流程下面会因为设置当前产线模型多了一次更新。

虽然性能方面不会产生大的影响,但是后续如果其他地方监听产线变更做一些逻辑,就会导致问题出现。我们修改一下逻辑图如下所示。

image-20211207151724845转存失败,建议直接上传图片文件

这样我们就还是三种只存在一种可能设置产线 Code,我们修改一下逻辑代码。

class MyPageViewModel: BaseViewModel {
    ...
    
    /// 获取车间下面的所有产线
    private func getAllProductLine() async {
        ...
        /// 是否存在之前选中保存的产线code
        if let productLineCode = AppConfig.share.productLineCode {
            /// 存在 判断在最新产线列表是否存在 就将之前的 code 进行更新
            if let response = find(productLine: productLineCode, in: productLines) {
                currentProductLine = response
            } else {
                /// 否则就默认第一个产线
                currentProductLine = productLines.first
            }
        } else {
            /// 如果不存在之前选中保存的产线 code 则直接默认第一个产线
            currentProductLine = productLines.first
        }
    }
    
    
    /// 根据产线 code 从产线列表查找对应模型
    /// - Parameters:
    ///   - code: 产线 code
    ///   - list: 产线列表
    /// - Returns: 对应模型
    private func find(productLine code:String, in list:[GetAllProductLineApiResponse]) -> GetAllProductLineApiResponse? {
        return list.first { response in
            guard let _code = response.code else { return false }
            return code == _code
        }
    }
    
    ...
}

我们已经拿到了当前选中的产线,我们将显示在当前页面上面。

struct MyPage: View {
    ...
    private func productLineCell() -> some View {
        MyDetailCellContentView(title: "产线",
                                detail: viewModel.currentProductLine?.name ?? "请选择产线")
    }
    
    ...
}

转存失败,建议直接上传图片文件

我们切换车间的时候,产线也随之发生了改变。我们此时产线无法进行手动选择,我们添加一下功能。

struct MyPage: View {
    @StateObject private var viewModel = MyPageViewModel()
    ...
    @StateObject private var appConfig = AppConfig.share
    var body: some View {
        PageContentView(title: "我的", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                productLineCell()
                    .onTapGesture(perform: didClickProductLine)
                ...
            }
        }
        ...
    }
    
    ...
    
    /// 点击了产线
    private func didClickProductLine() {
        guard let _ = appConfig.workShopCode else {
            viewModel.showHUDMessage(message: "请先选择车间!");
            return
        }
        DataPickerManager.manager.show {
            PickerSheet(title: "产线",
                        items: viewModel.productLines,
                        selectItem: $viewModel.currentProductLine) {
                DataPickerManager.manager.dismiss()
            } confirmHandle: {
                DataPickerManager.manager.dismiss()
            }
        }
    }
}

为了不让调用设置背景色,我们将设置白色背景设置在 PickerSheet 里面。

struct PickerSheet<Item:DataPickerItem>: View {
    ...
    
    var body: some View {
        VStack {
            ...
        }
        ...
        .background(.white)
    }
    
    ...
}

转存失败,建议直接上传图片文件

得意于我们之前封装,产线功能做起来才会这么顺手和快速。接下来我们来做选择仓库的功能,我们只需要获取仓库列表,之后设置仓库对应的 code

image-20211207161639977转存失败,建议直接上传图片文件

struct MyPage: View {
    @StateObject private var viewModel = MyPageViewModel()
    ...
    var body: some View {
        PageContentView(title: "我的", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                storeHourseCell()
                    .onTapGesture(perform: didClickStoreHourse)
                ...
            }
        }
        ...
    }
    
    ...
    private func storeHourseCell() -> some View {
        MyDetailCellContentView(title: "仓库",
                                detail: viewModel.currentStoreHouse?.name ?? "请选择仓库")
    }
    
    ...
    private func didClickStoreHourse() {
        DataPickerManager.manager.show {
            PickerSheet(title: "仓库",
                        items: viewModel.storeHouses,
                        selectItem: $viewModel.currentStoreHouse) {
                DataPickerManager.manager.dismiss()
            } confirmHandle: {
                DataPickerManager.manager.dismiss()
            }

        }
    }
}
class MyPageViewModel: BaseViewModel {
    ...
    /// 仓库列表
    var storeHouses:[GetAllStoreHouseApiResponse] = []
    
    /// 当前选中的仓库
    @Published var currentStoreHouse:GetAllStoreHouseApiResponse? {
        didSet {
            AppConfig.share.storeHouseCode = currentStoreHouse?.code
        }
    }
    
    ...
    
    public func initData() async {
        ...
        /// 仓库列表的数据只和工厂有关系 所以可以放在初始化进行请求
        await getAllStoreHouse()
    }
    
    ...
    
    func getAllStoreHouse() async {
        let api = GetAllStoreHouseApi()
        let model:BaseModel<[GetAllStoreHouseApiResponse]> = await request(api: api, showHUD: false)
        guard model._isSuccess, let data = model.data else {return}
        storeHouses = data
        /// 是否存在之前保存过的仓库
        if let storeHouseCode = AppConfig.share.storeHouseCode {
            if let response = find(storeHouse: storeHouseCode, in: storeHouses) {
                currentStoreHouse = response
            } else {
                /// 如果最新的仓库列表已经不包含之前选中的仓库 则默认第一个仓库
                currentStoreHouse = storeHouses.first
            }
        } else {
            /// 如果不存在 则设置默认第一个仓库
            currentStoreHouse = storeHouses.first
        }
    }
    
    ...
    /// 根据仓库 code 从仓库列表查找对应模型
    /// - Parameters:
    ///   - code: 仓库 code
    ///   - list: 仓库列表
    /// - Returns: 查找的模型
    private func find(storeHouse code:String, in list:[GetAllStoreHouseApiResponse]) -> GetAllStoreHouseApiResponse? {
        return list.first { response in
            guard let _code = response.code else { return false }
            return code == _code
        }
    }
    
    ...
}
struct GetAllStoreHouseApi {}

extension GetAllStoreHouseApi: APIConfig {
    var path: String { "/api/winplus/bm/store/search" }
}
struct GetAllStoreHouseApiResponse: Codable {
    /// 仓库名称
    let name:String?
    /// 仓库 code
    let code:String?
}

extension GetAllStoreHouseApiResponse: DataPickerItem {
    var pickerItemTitle: String { name ?? "" }
    
    static func ==(lhs:GetAllStoreHouseApiResponse, rhs:GetAllStoreHouseApiResponse) -> Bool {
        guard let code = lhs.code, let _code = rhs.code else { return false }
        return code == _code
    }
}

转存失败,建议直接上传图片文件

第二十三章 UIHostingController|withAnimation|SwiftUI 默认动画时间

作者 君赏
2026年2月23日 13:43

UIViewController 自定义 Sheet

struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的", viewModel: viewModel) {
            VStack(spacing: 0) {
               ...
                workshopCell()
                    .onTapGesture {
                        DataPickerManager.manager.show {
                            PickerSheet(title: "工厂",
                                        items: ["1","2"],
                                        isShow: $viewModel.isShowDataPicker)
                                .background(.white)
                        }
                    }
                ...
            }
        }
        ...
    }
    ...
}

UIHostingController 调用 SwiftUI 视图 withAnimation 默认动画

我们将使用 UIViewController 弹出封装在 DataPickerManager 里面调用。

class DataPickerManager {
    /// 做成单利对象是为了记录当前弹出的 UIViewController 方便随意的调用消失
    static let manager = DataPickerManager()
    /// 当前展示 Data Picker 的控制器
    private var currentShowDataPickerController:UIViewController?
    
    /// show 方法采用 @ViewBuilder 获取自定义的视图
    func show<Content:View>(@ViewBuilder _ content:() -> Content) {
        /// 将自定义的视图封装为 DataPickerContentView 为了封装动画的弹出和消失
        let contentView  = DataPickerContentView(content: content)
        /// 使用 UIHostingController 来展示 SwiftUI 的试图
        let controller = UIHostingController(rootView: contentView)
        /// 设置界面弹出方式为 overFullScreen 是支持设置界面半透明
        controller.modalPresentationStyle = .overFullScreen
        /// 设置背景为黑色半透明
        controller.view.backgroundColor = .black.withAlphaComponent(0.6)
        guard let rootViewController = keyWindow()?.rootViewController else {return}
        /// 保存当前正在展示的模态试图 方便进行消失
        currentShowDataPickerController = controller
        rootViewController.present(controller, animated: false, completion: nil)
    }
    
    func dismiss() {
        guard let currentShowDataPickerController = self.currentShowDataPickerController else {
            return
        }
        currentShowDataPickerController.dismiss(animated: false, completion: nil)
        self.currentShowDataPickerController = nil
    }
    
    /// 获取当前的 KeyWindow 在 iOS15上面 采用 UIWindowScene 获取
    private func keyWindow() -> UIWindow? {
        if #available(iOS 15.0, *) {
            return UIApplication.shared.connectedScenes
                .filter({$0.activationState == .foregroundActive})
                .map({$0 as? UIWindowScene})
                .compactMap({$0})
                .first?.windows
                .filter({$0.isKeyWindow})
                .first
        } else {
            return UIApplication.shared.windows
                .filter({$0.isKeyWindow})
                .first
        }
    }
}

/// 采用 PreferenceKey 获取 自定义视图的高度
fileprivate struct DataPickerSizeKey: PreferenceKey {
    static var defaultValue: [CGSize] = []
    static func reduce(value: inout [CGSize], nextValue: () -> [CGSize]) {
        /// 为什么要通过数组合并? 尝试不通过这种方式 有的视图在外层获取不到大小
        value.append(contentsOf: nextValue())
    }
}

/// 封装自定义视图的弹出和消失
struct DataPickerContentView<Content:View>: View {
    private let content:Content
    /// 自定义视图的大小 需要消失的时候用到 所以需要进行保存
    @State private var size:CGSize = .zero
    /// 当前将自定义视图进行弹出的偏移量 通过偏移量的变更 来执行动画
    @State private var offsetY:CGFloat = .zero
    /// 初始化 Content 用户需要展示的自定义视图
    init(@ViewBuilder content:() -> Content) {
        self.content = content()
    }
    
    var body: some View {
        VStack {
            Spacer()
            content
                .background {
                    /// 可以通过封装在 background 或者 overlay 里面通过 geometry 获取试图的大小
                    GeometryReader { geometry in
                        Color.clear
                        /// 将获取的大小保存在 Preference 里面 ,向上进行传递
                            .preference(key: DataPickerSizeKey.self, value: [geometry.size])
                    }
                }
                /// 监听获取的视图的大小的变更
                .onPreferenceChange(DataPickerSizeKey.self, perform: { value in
                    /// 可能获取不到 就直接中断执行
                    guard let size = value.first else {return}
                    /// 将获取的大小保存下来
                    self.size = size
                    /// 更改偏移量 用于 .offset 设置偏移量
                    offsetY = size.height
                })
                /// 偏移自定义视图高度 这样让初始化的位置在屏幕以下位置
                .offset(x: 0, y: offsetY)
                .onAppear {
                    /// 当界面展示的时候 设置 offsetY = 0 为了做到界面出现就进行弹出动画
                    withAnimation(.linear) {
                        offsetY = 0
                    }
                }
        }
    }
}

Kapture 2021-12-06 at 11.32.36

我们通过 UIViewController 进行弹出,我们的动画终于正常了。但是消失呢?因为通过 取消和确定的按钮都能进行取消。我们简单的画一下功能流程图。

image-20211206115250607

对于消失我们就需要先让 DataPickerContentView 执行消失动画,之后再让当前的 UIHostController 移除。但是外界的操作怎么通知到

DataPickerContentView 之后做消失动画呢?

为了做到信息互通,我们采用 ViewModel 的方式。

class DataPickerManager {
    ...
    /// 通过 DataPickerContentViewModel 进行控制动画的弹出和消失
    private let viewModel:DataPickerContentViewModel = DataPickerContentViewModel()
    
    ...
    
    func dismiss() {
        /// 当 DataPickerContentView 动画消失之后 再让界面消失
        viewModel.endAnimation {[weak self] in
            ...
        }
    }
    
    ...
}
class DataPickerContentViewModel: ObservableObject {
    /// 将 offsetY 转移到 DataPickerContentViewModel @Published 用于外界属性观察
    @Published var offsetY:CGFloat = 0
    /// 自定义视图大小 因为不需要观察 只需要进行存储 就设置为私有属性
    private var contentSize:CGSize = .zero
    
    /// 外界不需要进行读取 contentSize 我们就只写了更新 contentSize 方法
    func updateContentSize(size:CGSize) {
        contentSize = size
        offsetY = size.height
    }
    
    /// 执行动画就只需要 offsetY = 0
    func startAnimation() {
        withAnimation(.linear) {
            offsetY = 0
        }
    }
    
    /// 结束动画就需要将 offsetY = contentSize.height
    func endAnimation(completion:@escaping () -> Void) {
        withAnimation(.linear) {
            offsetY = contentSize.height
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
            completion()
        }
    }
}
/// 封装自定义视图的弹出和消失
struct DataPickerContentView<Content:View>: View {
    ...
    @ObservedObject private var viewModel:DataPickerContentViewModel
    
    /// 初始化 Content 用户需要展示的自定义视图
    init(viewModel:DataPickerContentViewModel, @ViewBuilder content:() -> Content) {
        self.viewModel = viewModel
        ...
    }
    
    var body: some View {
        VStack {
            Spacer()
            content
                ...
                /// 监听获取的视图的大小的变更
                .onPreferenceChange(DataPickerSizeKey.self, perform: { value in
                    /// 可能获取不到 就直接中断执行
                    guard let size = value.first else {return}
                    viewModel.updateContentSize(size: size)
                })
                /// 偏移自定义视图高度 这样让初始化的位置在屏幕以下位置
                .offset(x: 0, y: viewModel.offsetY)
                .onAppear {
                    /// 当界面展示的时候 设置 offsetY = 0 为了做到界面出现就进行弹出动画
                    viewModel.startAnimation()
                }
        }
    }
}

我们此前的 PickerSheet 组件没有暴露出,取消按钮事件和确定按钮事件。我们调整代码暴露出来,我们觉得既然是事件还是通过闭包代理出来设计比较好。

struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                workshopCell()
                    .onTapGesture {
                        DataPickerManager.manager.show {
                            PickerSheet(title: "工厂",
                                        items: ["1","2"],
                                        cancelHandle: {
                                DataPickerManager.manager.dismiss()
                            },
                                        confirmHandle: {
                                DataPickerManager.manager.dismiss()
                            })
                                .background(.white)
                        }
                    }
                ...
            }
        }
        ...
    }
    
    ...
}
struct PickerSheet<Item:DataPickerItem>: View {
    ...
    
    /// 点击取消按钮回掉
    typealias CancelHandle = () -> Void
    private let cancelHandle:CancelHandle
    
    /// 点击确定按钮回掉
    typealias ConfirmHandle = () -> Void
    private let confirmHandle:ConfirmHandle
    
    init(title:String,
         items:[Item],
         cancelHandle:@escaping CancelHandle,
         confirmHandle:@escaping ConfirmHandle) {
        ...
        self.cancelHandle = cancelHandle
        self.confirmHandle = confirmHandle
    }
    
    var body: some View {
        VStack {
            ...
            HStack {
                Button(action:cancelHandle) {
                    ...
                }
                Button(action: confirmHandle) {
                    ...
                }
            }
            ...
        }
        ...
    }
}

Kapture 2021-12-06 at 14.37.31

默认动画时间从 0.25 变为 0.35

但是看起来,消失动画感觉还没有消失,这个界面就消失了?那么可能0.25秒默认时间不对,我们看了一下API。

    public static func timingCurve(_ c0x: Double, _ c0y: Double, _ c1x: Double, _ c1y: Double, duration: Double = 0.35) -> Animation

果然默认的动画时间变成了0.35秒。

class DataPickerContentViewModel: ObservableObject {
    ...
    
    /// 结束动画就需要将 offsetY = contentSize.height
    func endAnimation(completion:@escaping () -> Void) {
        ...
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
            completion()
        }
    }
}

Kapture 2021-12-06 at 14.44.34

这样看起来动画正常了,偷偷改了默认动画时间,有点坑。

此时我们封装的Modal的弹出和消失已经封装完毕,但是我们选择工厂点击确定,我们 却无法拿到数据,我们通过传入 @Binding可以让 PickerSheet 组件内部进行设置。

struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                workshopCell()
                    .onTapGesture {
                        DataPickerManager.manager.show {
                            PickerSheet(title: "工厂",
                                        items: viewModel.workShops,
                                        selectItem: $viewModel.currentWorkshop,
                                        cancelHandle: {
                                ...
                            },
                                        confirmHandle: {
                                ...
                            })
                                ...
                        }
                    }
                ...
            }
        }
        ...
    }
    
    ...
}
class MyPageViewModel: BaseViewModel {
    ...
    /// 当前选中的车间
    @Published var currentWorkshop:GetAllWorkshopResponse?
    ...
}
struct PickerSheet<Item:DataPickerItem>: View {
    ...
    /// 当前选中的 Item
    @Binding private var selectItem:Item?
    ...
    init(title:String,
         items:[Item],
         selectItem:Binding<Item?>,
         cancelHandle:@escaping CancelHandle,
         confirmHandle:@escaping ConfirmHandle) {
        ...
        self._selectItem = selectItem
        ...
    }
    
    var body: some View {
        VStack {
            ...
            DataPickerView(items: items, selectItem: $selectItem)
            ...
        }
        ...
    }
}
struct DataPickerView<Item:DataPickerItem>: UIViewRepresentable {
    ...
    @Binding private var selectItem:Item?
    
    init(items:[Item], selectItem:Binding<Item?>) {
        ...
        self._selectItem = selectItem
    }
    
    func makeCoordinator() -> DataPickerViewCoordinator<Item> {
        return DataPickerViewCoordinator(items: items, selectItem: $selectItem)
    }
    func makeUIView(context: Context) -> UIPickerView {
        ...
        if let selectItem = selectItem,
            let index = items.firstIndex(where: {$0 == selectItem }) {
            picker.selectRow(index, inComponent: 0, animated: false)
        }
        ...
    }
    ...
}
class DataPickerViewCoordinator<Item:DataPickerItem>: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
    
    ...
    @Binding private var selectItem:Item?
        
    init(items:[Item], selectItem:Binding<Item?>) {
        ...
        self._selectItem = selectItem
    }
    ...
    func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
        selectItem = items[row]
    }
    ...
}

Kapture 2021-12-06 at 15.30.45

但是选择完毕之后,我们的界面没有更新,因为我们开始用到的是 AppConfig的缓存的数据。当 currentWorkshop 值改变的时候,我们改变一下 AppConfig.share.workShopCode 的值。

class MyPageViewModel: BaseViewModel {
    ...
    /// 当前选中的车间
    @Published var currentWorkshop:GetAllWorkshopResponse? {
        didSet {
            AppConfig.share.workShopCode = currentWorkshop?.workshopCode
        }
    }
    ...
}

这样我们选择车间功能就封装完毕了。

第二十二章 onAppear|DataPickerView

作者 君赏
2026年2月23日 13:42

获取当前工厂车间列表

这一章我们来给我的界面的数据写数据获取的实现和界面的交互。

image-20211201095644549

对于显示当前选择的生产车间的,我们先是要获取到当前工厂可用的车间列表。

class Api: API {
    ...
    static var defaultHeadersConfig: ((inout HTTPHeaders) -> Void)? {
        return { headers in
            ...
            if let currentFactoryCode = AppConfig.share.currentFactoryCode {
                headers.add(name: "x-winplus-factory-code", value: currentFactoryCode)
            }
        }
    }
}
class AppConfig: ObservableObject {
    ...
    /// 选中的车间代码
    @AppStorage("workShopCode")
    var workShopCode:String?
}
class MyPageViewModel: BaseViewModel {
    /// 工厂所有的车间列表
    private var workShops:[GetAllWorkshopResponse] = []
    
    override init() {
        super.init()
        Task {
            await getAllWorkShop()
        }
    }
    
    private func getAllWorkShop() async {
        let api = GetAllWorkshopApi()
        let model:BaseModel<[GetAllWorkshopResponse]> = await request(api: api, showHUD: false)
        guard model._isSuccess else {return}
        guard let data = model.data else {return}
        workShops = data
        if let workShopCode = AppConfig.share.workShopCode {
            let index = workShops.firstIndex { response in
                guard let _workshopCode  = response.workshopCode else {return false}
                return workShopCode == _workshopCode
            }
            guard let _ = index else {
                /// 如果查询不到 意味着之前选中的数据已经不存在 则默认第一个
                AppConfig.share.workShopCode = workShops.first?.workshopCode
                return
            }
            
        } else {
            /// 如果之前没有选中的车间 则默认第一个
            AppConfig.share.workShopCode = workShops.first?.workshopCode
        }
    }
    
    /// 当前选中车间的名称
    func currentWorkShopName() -> String? {
        return workShops.first { response in
            guard let workShopCode = AppConfig.share.workShopCode else {return false}
            guard let _workShopCode = response.workshopCode else {return false}
            return workShopCode == _workShopCode
        }?.name
    }
}

我们将车间的名称设置到界面上去。

struct MyPage: View {
    ...
    private func workshopCell() -> some View {
        MyDetailCellContentView(title: "车间", detail: viewModel.currentWorkShopName() ?? "请选择车间")
    }
    
   ...
}
{"success":false,"code":400,"message":"请选择工厂\n","data":null}

onAppear 请求数据

但是运行起来接口报错了,那说明我们Headers新增加的工厂的字段不存在。我们明明已经在我的页面获取工厂列表进行自动设置了,为啥还出现这种情况,经过分析问题处在下面代码。

class MyPageViewModel: BaseViewModel {
    ...
    override init() {
        super.init()
        Task {
            await getAllWorkShop()
        }
    }
    
    ...
}

我们MyPageViewModel初始化的时候就开始请求数据了,但是 MyPageViewModel 初始化和 MyPage一起初始化的,以为和首页刚初始化,就调用这个接口了。

我们修改一下调用的顺序。

class MyPageViewModel: BaseViewModel {
    ...
    public func initData() async {
        await getAllWorkShop()
    }
    
    ...
}
struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的", viewModel: viewModel) {
            ...
        }
        .onAppear {
            Task {
                await viewModel.initData()
            }
        }
    }
    
    ...
}

在我的页面出现的时候再请求,我们就可以不会因为还没有设置工厂,导致接口报错。

刚才我们发现获取车间列表是默默请求的,为什么错误信息会被提示出来?而且提示信息还没完整的提示出来?

extension APIConfig {
    func request<M:Codable>(model:M.Type) async -> BaseModel<M> {
        do {
            ...
        } catch(let e) {
            ...
            return BaseModel(message: error.domain,
                             ...
        }
    }
}

错误提示没有是因为忘记把错误信息赋值导致的。

@MainActor
class BaseViewModel: ObservableObject {
    ...
    
    func request<T:Codable, API:APIConfig>(api:API, showHUD:Bool = false) async -> BaseModel<T> {
        ...
        if (!model._isSuccess && showHUD) {
            ...
        }
        ...
    }
}

设置不展示HUD依然展示,是没有添加HUD的判断逻辑。

image-20211201115230300

我们运行发现我的页面没有导航栏了,我们添加一个导航栏。

struct TabPage: View {
    ...
    var body: some View {
        TabView(selection:$currentTabIndex) {
            ...
            NavigationView {
                MyPage()
            }
               ...
        }
        ...
    }
}

image-20211201115532461

有了导航条,但是和下面的内容串起来很难受,不过我们暂时先不管。我们发现一个大问题,就是我们的车间显示是下面的样子。

image-20211201115708387

我们车间列表已经有数据,最少也是显示一条默认的,不可能存在请选择车间提示。研究了一下逻辑发现,当之前选择的车间在最新列表存在,就不会重新赋值,导致就无法通知进行更新。

class MyPageViewModel: BaseViewModel {
    ...
    private func getAllWorkShop() async {
        ...
        if let workShopCode = AppConfig.share.workShopCode {
            ...
            AppConfig.share.workShopCode = workShopCode
        } else {
            ...
        }
    }
    
    ...
}

image-20211201135659955

Sheet 弹窗

我们默认选择的车间已经可以显示出来了,但是人工没法操作,接下来我们封装操作的弹框。

image-20211201140241449

我们可以将视图分成下面组成方式。

image-20211201144151134

我们只需要将蓝色区域底部对齐即可。

struct PickerSheet: View {
    @StateObject private var appColor = AppColor.share
    var body: some View {
        VStack {
            Text("车间选择")
                .foregroundColor(Color(uiColor: appColor.c_333333))
                .font(.system(size: 16))
                .padding(EdgeInsets(top: 20, leading: 0, bottom: 20, trailing: 0))
            Rectangle()
                .frame(height:0.5)
                .foregroundColor(Color(appColor.c_d8d8d8))
            Picker(selection: .constant(1), label: Text("Picker")) {
                Text("1").tag(1)
                Text("2").tag(2)
            }
            HStack {
                Button {
                    
                } label: {
                    Text("取消")
                        .font(.system(size: 14))
                        .foregroundColor(Color(uiColor: appColor.c_999999))
                        .padding(EdgeInsets(top: 10, leading: 50, bottom: 10, trailing: 50))
                        .overlay {
                            RoundedRectangle(cornerSize: CGSize(width: 5, height: 5))
                                .stroke(Color(uiColor: appColor.c_999999),lineWidth: 0.5)
                        }

                }
                Button {
                    
                } label: {
                    Text("确定")
                        .font(.system(size: 14))
                        .foregroundColor(.white)
                        .padding(EdgeInsets(top: 10, leading: 50, bottom: 10, trailing: 50))
                        .background(Color(uiColor:appColor.c_209090))
                        .cornerRadius(5)
                }
            }
        }
        .frame(maxWidth: .infinity)
    }
}

image-20211201150507004

但是SwiftUIiOS的表现已经不是 UIDataPicker的样式了,可能为了为了支持全平台做了改变。那么Picker这个组件我们就不能用了,我们通过创建 UIPickerView进行转换。

struct DataPickerView<Item:DataPickerItem>: UIViewRepresentable {
    typealias UIViewType = UIPickerView
    
    private let items:[Item]
    
    init(items:[Item]) {
        self.items = items
    }
    
    func makeCoordinator() -> DataPickerViewCoordinator<Item> {
        return DataPickerViewCoordinator(items: items)
    }
    func makeUIView(context: Context) -> UIPickerView {
        let picker = UIPickerView()
        picker.dataSource = context.coordinator
        picker.delegate = context.coordinator
        let v = UIView()
        v.backgroundColor = .red
        picker.addSubview(v)
        return picker
    }
    
    func updateUIView(_ uiView: UIPickerView, context: Context) {
        
    }
}

class DataPickerViewCoordinator<Item:DataPickerItem>: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
    
    private let items:[Item]
        
    init(items:[Item]) {
        self.items = items
    }
    
    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        1
    }
    
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        items.count
    }
    
    func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
        let contentView = rootView(row: row)
        return UIHostingController(rootView: contentView).view
    }
    
    func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
        50
    }
        
    private func rootView(row:Int) -> some View {
        VStack {
            Rectangle()
                .frame(height:0.5)
                .foregroundColor(Color(uiColor: AppColor.share.c_209090))
            Spacer()
            Text(items[row].pickerItemTitle)
                .font(.system(size: 12))
                .foregroundColor(Color(uiColor: AppColor.share.c_209090))
            Spacer()
            Rectangle()
                .frame(height:0.5)
                .foregroundColor(Color(uiColor: AppColor.share.c_209090))
        }
    }
}

protocol DataPickerItem {
    var pickerItemTitle:String {get}
}

extension String: DataPickerItem {
    var pickerItemTitle: String {self}
}

image-20211201163205763

选中默认的灰色的遮罩暂时没有找到可以修改的方法,对于DataPicker暂时就这样。

struct PickerSheet: View {
    ...
    var body: some View {
        VStack {
            ...
            DataPickerView(items: [
                "1",
                "2"
            ])
            ...
          Spacer()
                .frame(height:20)
        }
        ...
    }
}

image-20211201163637849

我们将 PickerSheet 组件进行提炼。

struct PickerSheet<Item:DataPickerItem>: View {
    ...
    private let title:String
    private let items:[Item]
    
    init(title:String, items:[Item]) {
        self.title = title
        self.items = items
    }
    
    var body: some View {
        VStack {
            Text(title)
                ...
            ...
            DataPickerView(items: items)
            ...
        }
        ...
    }
}

为了可以方便调用,我们封装一个 ViewModify

struct DataPickerViewModify: ViewModifier {
    @Binding var isShow:Bool
    func body(content: Content) -> some View {
        ZStack {
            content
            if isShow {
                GeometryReader { geometry in
                    VStack {
                        Spacer()
                        PickerSheet(title: "仓库",
                                    items: [
                                        "1",
                                        "2"
                                    ])
                            .background(.white)
                    }
                }
                .background(Color(uiColor: UIColor.black.withAlphaComponent(0.6)))
            }
        }
    }
}

extension View {
    func dataPicker(isShow:Binding<Bool>) -> some View {
        self.modifier(DataPickerViewModify(isShow: isShow))
    }
}

image-20211201171608227

界面突然的出现有点不自然,我们添加一个从底部弹出的动画。

struct DataPickerViewModify: ViewModifier {
    ...
    func body(content: Content) -> some View {
        ZStack {
            content
            if isShow {
                Color(uiColor: UIColor.black.withAlphaComponent(0.6))
                    .edgesIgnoringSafeArea(.all)
                VStack {
                    Spacer()
                    PickerSheet(...)
                }
                .transition(.move(edge: .bottom))
                .animation(.linear)
            }
        }
    }
}

我们将 DataPickerViewModify 的标题和内容进行提炼。

struct DataPickerViewModify<Item:DataPickerItem>: ViewModifier {
    ...
    private var title:String
    private var items:[Item]
    
    init(title:String, items:[Item], isShow:Binding<Bool>) {
        self.title = title
        self.items = items
        self._isShow = isShow
    }
    
    func body(content: Content) -> some View {
        ZStack {
            ...
            if isShow {
                ...
                VStack {
                    ...
                    PickerSheet(title: title,
                                items: items)
                        ...
                        
                }
                ...
            }
        }
    }
}

此时我们的 DataPicker 弹出之后,没有办法进行消失。我们新增可以消失的方法。

struct DataPickerViewModify<Item:DataPickerItem>: ViewModifier {
    ...
    func body(content: Content) -> some View {
        ZStack {
            ...
            if isShow {
                Color(uiColor: UIColor.black.withAlphaComponent(0.6))
                    ...
                    .onTapGesture {
                        isShow = false
                    }
                VStack {
                    ...
                    PickerSheet(title: title,
                                items: items,
                                isShow: $isShow)
                        .background(.white)
                        
                }
                ...
            }
        }
    }
}
struct PickerSheet<Item:DataPickerItem>: View {
    ...
    @Binding private var isShow:Bool
    ...
    
    init(title:String, items:[Item], isShow:Binding<Bool>) {
        ...
        self._isShow = isShow
    }
    
    var body: some View {
        VStack {
            ...
            HStack {
                Button {
                   isShow = false
                } label: {
                    ...
                }
                Button {
                   isShow = false
                } label: {
                   ...
                }
            }
            ...
        }
        ...
    }
}

Kapture 2021-12-02 at 17.55.18

我们来实现一下点击车间,弹出所有可以选择的车间列表。

extension GetAllWorkshopResponse: DataPickerItem {
    var pickerItemTitle: String {name ?? ""}
}
struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                workshopCell()
                    .onTapGesture {
                        viewModel.isShowDataPicker.toggle()
                    }
                ...
            }
        }
        ...
        .dataPicker(title: "车间",
                    items: viewModel.workShops,
                    isShow: $viewModel.isShowDataPicker)
    }
    
    ...
}

Kapture 2021-12-02 at 18.09.34

我们发现我们的弹出试图被上层的TabPage遮挡了。如果想要在最外层。那么这个控件就要注入在最外层才可以。但是数据怎么传递到最外层呢?

这个方案实现起来难度很大,我们用系统的 fullScreenCover 来实现,但是最终的效果是下面的效果。

Kapture 2021-12-03 at 11.06.19

黑色透明背景也是一起跟着动的,就这一点不满足我们的需求。我们能否通过 UIViewController之前的一套做出来呢?

第二十一章 @ViewBuilder默认实现|Toggle|我的页面封装

作者 君赏
2026年2月23日 13:41

首页的界面基本做完了,功能也挺简单,跳转到对应界面即可。我们就先做一下我的页面的内容,内容也不是很多。

image-20220104142656114

我的页面是一个配置和显示的功能也不是很复杂,但是界面也需要标题栏和灰色的背景试图。但是我们就需要将首页的代码复制一份过来吗?在UIKit的时代,因为是继承关系,我们可以在父类进行设置,但是现在我们在SwiftUI里面。Struct是不能继承的,我们只能封装,使用的时候使用封装的组件来达到效果。

在封装的过程中,遇到了一些困难,差一点就放弃了封装,幸亏找到了解决思路。

遇到的困难就是对于 @ViewBuilder 怎么在初始化提供默认的实现,因为有一些有一些封装不是必须实现的,下面的链接提供了解决的方法。

stackoverflow.com/questions/6…

/// 页面的基础试图
struct PageContentView<Content:View, Leading:View, Trailing:View>: View {
    
    private let title:String
    private let content:Content
    private let leading:Leading
    private let trailing:Trailing
    
    @StateObject private var appColor:AppColor = AppColor.share
    
    /// 初始化页面试图
    /// - Parameters:
    ///   - title: 导航标题
    ///   - contentBuilder: 内容
    ///   - leadingBuilder: 导航左侧按钮
    ///   - trailingBuildeder: 导航右侧按钮
    init(title:String,
         @ViewBuilder contentBuilder:() -> Content,
         @ViewBuilder leadingBuilder:() -> Leading,
         @ViewBuilder trailingBuildeder:() -> Trailing) {
        self.title = title
        self.content = contentBuilder()
        self.leading = leadingBuilder()
        self.trailing = trailingBuildeder()
    }
    
    var body: some View {
        navigationBar {
            ZStack {
                Color(uiColor: appColor.c_efefef)
                content
            }
        }
    }
    
    private func navigationBar<Content:View>(@ViewBuilder content:() -> Content) -> some View {
        content()
            .navigationTitle(Text(title))
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement:.navigationBarLeading) {leading}
                ToolbarItem(placement:.navigationBarTrailing) {trailing}
            }
    }
}

extension PageContentView where Leading == EmptyView, Trailing == EmptyView {
    init(title:String, contentBuilder:() -> Content) {
        self.init(title: title,
                  contentBuilder: contentBuilder,
                  leadingBuilder: {EmptyView()},
                  trailingBuildeder: {EmptyView()})
    }
}

我们将刚才封装 PageContentView 用到我们的首页。

struct HomePage: View {
    ...
    var body: some View {
        NavigationView {
            PageContentView(title: "首页") {
                ...
            } leadingBuilder: {
                ...
            } trailingBuildeder: {
                EmptyView()
            }

        }
        ...
    }
}

我们封装完毕 PageContentView 完毕之后,我们想让登录页面也统一走这个封装试图,我们尝试的修改一下登录界面。

struct LoginPage: View {
...
    var body: some View {
        PageContentView(title: "登陆") {
            VStack {
                ...
            }
            .background(.white)
        }
        ...
    }
}

我们登陆页面使用 PageContentView 之后,背景颜色会变成灰色,我们重新设置一下内容区域颜色即可。

之前在封装登陆页面的时候,对于外部 HUDView 一致无法封装,我们现在能否封装在 PageContentView 里面呢?

/// 页面的基础试图
struct PageContentView<Content:View,
                        Leading:View,
                        Trailing:View,
                        ViewModel:BaseViewModel>: View {
    
    ...
    @ObservedObject private var viewModel:ViewModel
    ...
    
    /// 初始化页面试图
    /// - Parameters:
    ///   - title: 导航标题
    ///   - contentBuilder: 内容
    ///   - leadingBuilder: 导航左侧按钮
    ///   - trailingBuildeder: 导航右侧按钮
    init(title:String,
         viewModel:ViewModel,
         @ViewBuilder contentBuilder:() -> Content,
         @ViewBuilder leadingBuilder:() -> Leading,
         @ViewBuilder trailingBuildeder:() -> Trailing) {
        ...
        self.viewModel = viewModel
        ...
    }
    
    var body: some View {
        navigationBar {
            ZStack {
                Color(uiColor: appColor.c_efefef)
                content
            }
            .hud(viewModel: $viewModel)
        }
    }
    
    ...
}

extension PageContentView where Leading == EmptyView, Trailing == EmptyView {
    init(title:String,
         viewModel:ViewModel,
         contentBuilder:() -> Content) {
        self.init(title: title,
                  viewModel: viewModel,
                  contentBuilder: contentBuilder,
                  leadingBuilder: {EmptyView()},
                  trailingBuildeder: {EmptyView()})
    }
}

我们测试将HUD封装在 PageContentView内部,通过登陆页面调试一切正常。现在我们可以开始做我的页面了。

struct MyPage: View {
    @StateObject private var viewModel = MyPageViewModel()
    var body: some View {
        PageContentView(title: "我的",
                        viewModel: viewModel) {
            Text("Hello, World!")
        }
    }
}

struct MyPage_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            MyPage()
        }
    }
}

image-20211130162551419

我的页面基本全是这种左右对齐的控件,我们先制作这个控件。

struct MyCellContentView: View {
    @StateObject private var appColor = AppColor.share
    var body: some View {
        HStack {
            Text("车间")
                .font(.system(size: 14))
                .foregroundColor(Color(uiColor: appColor.c_333333))
            Spacer()
            HStack {
                Text("深圳车间")
                    .font(.system(size: 14))
                    .foregroundColor(Color(uiColor: appColor.c_333333))
                Image(systemName: "chevron.right")
                    .foregroundColor(Color(uiColor: appColor.c_cccccc))
            }
        }
        .frame(maxWidth: .infinity)
        .padding(EdgeInsets(top: 15, leading: 10, bottom: 15, trailing: 10))
      .background(.white)
    }
}

image-20211130173858393

我们进行提炼封装,方便后面使用。

struct MyCellContentView<Right:View>: View {
    ...
    private let right:Right
    ...
    
    init(title:String,
         @ViewBuilder rightBuilder:() -> Right) {
        self.title = title
        self.right = rightBuilder()
    }
    
    var body: some View {
        HStack {
            Text(title)
                ...
            ...
            right
        }
        ...
    }
}

我们使用上面的组件来给我的页面绘制下面的界面

image-20220104142759957

private func userNameCell() -> some View {
    MyCellContentView(title: "姓名") {
        Text("我的名字")
            .font(.system(size: 14))
            .foregroundColor(Color(uiColor: appColor.c_cccccc))
    }
}

我们这里需要展示用户的昵称,但是我们在用户登录的时候没有保存用户的昵称,我们在登录页面写一下保存的逻辑。

class AppConfig: ObservableObject {
    ...
    @AppStorage("userInfo")
    private var userInfo:String?
    /// 用户信息
    var userInfoModel:UserInfoModel? {
        get {
            guard let userInfo = userInfo, let jsonData = userInfo.data(using: .utf8) else {
                return nil
            }
            return try? CleanJSONDecoder().decode(UserInfoModel.self, from: jsonData)
        }
        set {
            guard let value = newValue, let jsonData = try? JSONEncoder().encode(value) else {
                userInfo = nil
                return
            }
            userInfo = String(data: jsonData, encoding: .utf8)
        }
    }
    
}
class LoginPageViewModel: BaseViewModel {    
    ...
    
    func login() async {
        ...
        AppConfig.share.userInfoModel = model.data?.user
    }
}

我们已经可以拿到保存的用户名了,我们更新一下刚才视图的代码。

struct MyPage: View {
    ...
    private func userNameCell() -> some View {
        MyCellContentView(title: "姓名") {
            Text(AppConfig.share.userInfoModel?.userName ?? "")
                ...
        }
    }
}

接下来我们制作下面的视图

image-20211130193208936

import SwiftUI

struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的",
                        viewModel: viewModel) {
            VStack {
                ...
                workshopCell()
                ...
            }
        }
    }
    
    ...
    
    private func workshopCell() -> some View {
        MyCellContentView(title: "车间") {
            HStack {
                Text("深圳车间")
                    .font(.system(size: 14))
                    .foregroundColor(Color(uiColor: appColor.c_333333))
                Image(systemName: "chevron.right")
                    .foregroundColor(Color(uiColor: appColor.c_cccccc))
            }
        }
    }
}

image-20211130193636926

我们发现 MyCellContentView 组件的下面没有显示横线,这个和我们界面不太一样,我们就修改 MyCellContentView 新增一条线。

struct MyCellContentView<Right:View>: View {
    ...
    
    var body: some View {
        VStack(spacing: 0) {
            HStack {
                ...
            }
            .frame(maxWidth: .infinity)
            .padding(EdgeInsets(top: 15, leading: 10, bottom: 15, trailing: 10))
            Rectangle()
                .foregroundColor(Color(uiColor: appColor.c_cccccc))
                .frame(height: 0.5)
                .padding(.leading, 15)
        }
        .background(.white)
    }
}

接下来我们做产线界面

image-20211130195751061

我们发现和刚才做的车间的界面一模一样,我们可以先将车间的进行提炼。

struct MyDetailCellContentView: View {
    @StateObject private var appColor = AppColor.share
    private let title:String
    private let detail:String
    
    init(title:String,
         detail:String) {
        self.title = title
        self.detail = detail
    }
    
    var body: some View {
        MyCellContentView(title: title) {
            HStack {
                Text(detail)
                    .font(.system(size: 14))
                    .foregroundColor(Color(uiColor: appColor.c_333333))
                Image(systemName: "chevron.right")
                    .foregroundColor(Color(uiColor: appColor.c_cccccc))
            }
        }
    }
}
struct MyPage: View {
    ...
    private func workshopCell() -> some View {
        MyDetailCellContentView(title: "车间", detail: "深圳车间")
    }
}

image-20211201084936232

struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的",
                        viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                productLineCell()
                ...
            }
        }
    }
    
    ...
    private func productLineCell() -> some View {
        MyDetailCellContentView(title: "产线", detail: "生产线_yk")
    }
}

image-20211201085424077

struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的",
                        viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                Spacer()
                    .frame(height:10)
                storeHourseCell()
                ...
            }
        }
    }
    
    ...
    private func storeHourseCell() -> some View {
        MyDetailCellContentView(title: "仓库", detail: "123")
    }
}

image-20211201085803791

Toggle 开关

我们自动登录模块稍微有一些不同,我们右侧是一个开关按钮,我们需要用到Toggle

class AppConfig: ObservableObject {
    ...
    /// 是否自动登录
    @AppStorage("isAutoLogin")
    var isAutoLogin = false
}
struct MyPage: View {
    ...
    @StateObject private var appConfig = AppConfig.share
    var body: some View {
        PageContentView(title: "我的",
                        viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                autoLoginCell()
                ...
            }
        }
    }
    
    ...
    private func autoLoginCell() -> some View {
        MyCellContentView(title: "自动登录") {
            Toggle("", isOn: $appConfig.isAutoLogin)
        }
    }
}

image-20211201090524227

显示当前版本,这个可以将显示名称的提炼共用一套。

struct MyDetailStyle1CellContentView: View {
    @StateObject private var appColor = AppColor.share
    private let title:String
    private let detail:String
    init(title:String,
         detail:String) {
        self.title = title
        self.detail = detail
    }
    var body: some View {
        MyCellContentView(title: title) {
            Text(detail)
                .font(.system(size: 14))
                .foregroundColor(Color(uiColor: appColor.c_cccccc))
        }
    }
}
struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的",
                        viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                appVersionCell()
                ...
            }
        }
    }
    
    private func userNameCell() -> some View {
        MyDetailStyle1CellContentView(title: "姓名",
                                      detail: AppConfig.share.userInfoModel?.userName ?? "")
    }
    
    ...
    private func appVersionCell() -> some View {
        MyDetailStyle1CellContentView(title: "版本", detail: "1.2.0(1638264135)")
    }
}

image-20211201091936577

struct MyPage: View {
    ...
    var body: some View {
        PageContentView(title: "我的",
                        viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                logoutButton()
                ...
            }
        }
    }
    
    ...
    private func logoutButton() -> some View {
        Button {
            
        } label: {
            Text("退出登录")
                .frame(maxWidth:.infinity)
                .frame(height:45)
                .background(Color(uiColor: appColor.c_209090))
                .foregroundColor(.white)
                .font(.system(size: 16))
                .cornerRadius(5)
                .padding(30)
        }

    }
}

至此,我的界面算是全部做出来了。但是界面的数据和交互算是我的页面复杂的部分,虽然显示文本很简单,但是数据的获取十分的麻烦。

我们将我的页面添加到 TabPage里面。

struct TabPage: View {
    ...
    var body: some View {
        TabView(selection:$currentTabIndex) {
            ...
            MyPage()
                .tabItem {
                    VStack {
                        if currentTabIndex == 1 {
                            Image("我的1")
                        } else {
                            Image("我的2")
                        }
                        Text("我的")
                    }
                }
                .tag(1)
        }
        ...
    }
}

第 二十章 @Published sink

作者 君赏
2026年2月23日 13:40

为了让选中工厂之后可以显示我们工厂的名称,我们修改代码如下。

HomePage

/// old
Text("请选择工厂")
/// new
Text(viewModel.currentFactory.factoryName ?? "请选择工厂")

@Published sink监听值的变化

但是我们想把选中的工厂编码保存到本地,用于下次启动可以显示上次选中的工厂。我们直接使用 @AppStorage吗?但是我们是一个模型呀,不行,我们怎么能够坚挺到值的变化进行操作呢?。

我们直接通过操作 @Published sink进行值更新的监听。

class HomePageViewModel: BaseViewModel {
    ....
    @Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
                                                                                      factoryName: nil) 
    private var factorySink:AnyCancellable?
    
    override init() {
        super.init()
        factorySink = $currentFactory.sink { model in
            print("sink \(model.factoryName)")
        }
    }
    
    ....
}

这里有一个坑,不要直接进行这样的操作。

$currentFactory.sink { model in
    print("sink \(model.factoryName)")
}

通过 didSet 监听值更新

没有强保留返回结果,是不能够监听后续值更新操作的。使用起来这么麻烦吗?其实不然,我们可以通过 Swift中对于值更新的 didSet 方法进行监听值更新。

class HomePageViewModel: BaseViewModel {
    ...
    @Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
                                                                                      factoryName: nil) {
        didSet {
            print("didset \(currentFactory.factoryName)")
        }
    }
   ....
}

这种使用起来十分的方便,我推荐使用这一种方式。我们已经可以拿到选中工厂的代码了,那么我们就可以新增一个属性用于保存。

class AppConfig: ObservableObject {
...    
    /// 当前选中的工厂代码
    @AppStorage("currentFactoryCode")
    var currentFactoryCode:String?
    
}

我们接收到用户选中工厂之后,将最新选中的工厂代码进行保存。

class HomePageViewModel: BaseViewModel {
    ...
    @Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
                                                                                      factoryName: nil) {
        didSet {
            AppConfig.share.currentFactoryCode = currentFactory.factoryCode
        }
    }
    ...
}

我们将选中的工厂代码保存到了本地,下次启动我们需要在最新的工厂代码寻找,如果找到,就用对应模型,否则就用第一个模型。

class HomePageViewModel: BaseViewModel {
    /// 工厂列表
    @Published var factoryList:[FactoryListResponseModel] = []
    @Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
                                                                                      factoryName: nil) {
        didSet {
            AppConfig.share.currentFactoryCode = currentFactory.factoryCode
        }
    }
    
    /// 请求工厂列表
    func requestFactoryList() async {
        ...
        factoryList = model.data ?? []
        if let factoryModel = findFactory() {
            currentFactory = factoryModel
        } else if let firstModel = factoryList.first {
            currentFactory = firstModel
        }
    }
    /// 查找保存的工厂代码对应最新工厂列表的模型
    private func findFactory() -> FactoryListResponseModel? {
        return factoryList.first { model in
            guard let currentFactoryCode = AppConfig.share.currentFactoryCode else {return false}
            guard let factoryCode = model.factoryCode else {return false}
            return currentFactoryCode == factoryCode
        }
    }
}

接下来我们就要编写首页功能组件了。

image-20211126154503612

首页布局

我们发现一个功能组件大概有这样的特征。

  • 高度随着组件数量变化
  • 周围有圆角
  • 左侧按钮垂直居左并各自居中对齐
  • 中间居中
  • 右侧按钮垂直巨右并且各自居中对齐

我们画一下模板就清楚了。

image-20211126160425834

我们讲一个功能模块按照左侧功能区域,中间功能区域,和右侧功能区域进行布局。如果按照一行一行的布局按钮,会导致和下面的组件无法对齐。

如果直接使用 GridView,感觉也是不行,他们又不是均匀分布的,我觉得目前可行的布局方案就是按照模板进行布局,后续遇到问题再解决。

我们先来制作首页功能按钮

image-20211126162706988

struct ActionButton: View {
    var body: some View {
        VStack {
            Image("物料绑定托盘")
                .frame(width:40, height: 40)
                .background(Color(uiColor: UIColor("#209090")))
                .cornerRadius(8.5)
            Text("物料绑定托盘")
                .foregroundColor(Color(uiColor: UIColor("#666666")))
        }
    }
}

image-20211126163703347

因为图标和文本是动态,我们修改代码支持动态生成。

struct ActionButton: View {
    let icon:String
    let iconColor:UIColor
    let title:String
    var body: some View {
        VStack {
            Image(icon)
                ...
                .background(Color(uiColor: iconColor))
                ...
            Text(title)
                ...
        }
    }
}

我们功能组件封装完毕,接下来我们封装功能视图组件。

struct ActionView: View {
    var body: some View {
        VStack {
            ActionButton(icon: "物料绑定托盘",
                         iconColor: UIColor("#209090"),
                         title: "物料绑定托盘")
            ActionButton(icon: "托盘绑定箱号",
                         iconColor: UIColor("#F19037"),
                         title: "托盘绑定箱号")
            ActionButton(icon: "灭菌",
                         iconColor: UIColor("#0EA1DA"),
                         title: "灭菌")
        }
    }
}

image-20211126165217186

我们不确定我们一列到底显示多少个,所以我们需要动态的进行配置。

struct ActionItem: Hashable {
    /// 图标名称
    let icon:String
    /// 图标背景色
    let iconColor:UIColor
    /// 按钮文本
    let title:String
}

struct ActionView: View {
    let actionItems:[ActionItem]
    var body: some View {
        VStack {
            ForEach(actionItems, id: \.self) { item in
                ActionButton(icon: item.icon,
                             iconColor: item.iconColor,
                             title: item.title)
            }
        }
    }
}

我们一列按钮视图做好之后,我们封装一整块的功能。

struct ActionCardView: View {
    var body: some View {
        VStack {
            HStack {
                Text("生产执行")
                    .foregroundColor(Color(uiColor: UIColor("#333333")))
                    .fontWeight(.medium)
                    .font(.system(size: 14))
                Spacer()
            }
            Spacer()
                .frame(height:15)
            HStack {
                ActionView(actionItems: [
                    ...
                ])
                ActionView(actionItems: [
                    ...
                ])
                ActionView(actionItems: [
                    ...
                ])
            }
        }
        .frame(maxWidth:.infinity)
        .padding(15)
        .background(.white)
        .cornerRadius(10)
    }
}

image-20211126171837270

总是感觉这界面有点乖乖的,和我们设计图一点都不搭。我们给 ActionView 添加一个背景颜色看一下。

struct ActionView: View {
    ...
    var body: some View {
        VStack {
            ...
        }
        .background(.red)
    }
}

image-20211126172229456

我们中间功能区域没有宽度没有完全的充满,我们先设置一下。

struct ActionCardView: View {
    var body: some View {
        VStack {
            ...
            HStack() {
                ...
            }
            .frame(maxWidth:.infinity)
        }
        ...
    }
}

image-20211126172511729

组件最大宽度已经发生了变化,但是三个没有充满,我们需要在组件的中间添加Spacer

struct ActionCardView: View {
    var body: some View {
        VStack {
            ...
            HStack() {
                ActionView(actionItems: [
                    ...
                ])
                Spacer()
                ActionView(actionItems: [
                    ...
                ])
                Spacer()
                ActionView(actionItems: [
                    ...
                ])
            }
            ...
        }
        ...
    }
}

image-20211126172758955

此时看起来好多了,但是中间的间隙是平分的,按照中间视图居中原则,当左侧和右侧视图宽度一致,那么间隙才可能宽度相等。

此时左侧和右侧的宽度不等,那么此时平分的话,中间视图一定偏右侧了。

那么我们就需要计算 左侧视图宽度,中间视图宽度,右侧视图宽度,总宽度。

struct ActionCardView: View {
    @State private var leftViewWidth:CGFloat = 0
    @State private var centerViewWidth:CGFloat = 0
    @State private var rightViewWidth:CGFloat = 0
    @State private var contentViewWidth:CGFloat = 0
    var body: some View {
        VStack {
            ...
            HStack() {
                ActionView(actionItems: [
                    ...
                ])
                    .getWidth(width: $leftViewWidth)
                Spacer()
                    .frame(width:spacer1Width)
                ActionView(actionItems: [
                    ...
                ])
                    .getWidth(width: $centerViewWidth)
                Spacer()
                    .frame(width:spacer2Width)
                ActionView(actionItems: [
                    ...
                ])
                    .getWidth(width: $rightViewWidth)
            }
            ...
            .getWidth(width: $contentViewWidth)
        }
        ...
    }
    
    private var spacer1Width:CGFloat {
        let width = contentViewWidth / 2 - leftViewWidth - centerViewWidth / 2
        return max(width, 0)
    }
    
    private var spacer2Width:CGFloat {
        let width = contentViewWidth / 2 - rightViewWidth - centerViewWidth / 2
        return max(width, 0)
    }
}

fileprivate extension View {
    func getWidth(width:Binding<CGFloat>) -> some View {
        self.background {
            GeometryReader { geometry in
                _getWidth(width: width, geometry: geometry)
            }
        }
    }
    
    private func _getWidth(width:Binding<CGFloat>, geometry:GeometryProxy) -> some View {
        width.wrappedValue = geometry.size.width
        return Color.clear
    }
}

我们通过设置计算出当第二个试图居中显示,第一个和第三个分别居左和居右的时候,Spacer1Spacer2的宽度,来达到居中的目的。间隙不可能存在负数,如果存在就是重叠了,这在显示上面是不允许的。

image-20211129105113162

此时布局已经分别居左 居中和居右显示了。从目前来看,的确没什么问题,但是我们如果按钮的标题十分的长,是怎么样的一个显示呢?

image-20211129105408292

虽然按钮分组项目没有影响,但是按钮标题的环境导致横向的没有对齐,十分的难看,我们设置一下按钮的标题最大智能显示一行。

struct ActionButton: View {
...
    var body: some View {
        VStack {
            ...
            Text(title)
                ...
                .lineLimit(1)
        }
    }
}

image-20211129105646864

这样看来感觉正常了。为了将功能模块可以一自定义的新增和删除,我们需要对于 ActionCardView进行提炼和封装。

struct ActionCardView: View {
    let title:String
    let actions:[ActionItem]
    ...
    var body: some View {
        VStack {
            HStack {
                Text(title)
                    ...
            }
            ...
            HStack() {
                ActionView(actionItems: actions(index: .left))
                    ...
                ActionView(actionItems: actions(index: .center))
                    ...
                ActionView(actionItems: actions(index: .right))
                    ...
            }
            ...
        }
...
    }
    
...
    
    /// 根据索引获取对应的功能列表
    /// - Parameter index: 功能索引
    /// - Returns: 功能分组
    private func actions(index:ActionIndex) -> [ActionItem] {
        var actionItems:[ActionItem] = []
        var itemInex = index.rawValue
        while itemInex < actions.count {
            actionItems.append(actions[itemInex])
            itemInex += 3
        }
        return actionItems
    }
    
    /// 功能索引
    private enum ActionIndex:Int {
        /// 左侧功能区域
        case left
        /// 中间功能区域
        case center
        /// 右侧功能区域
        case right
    }
}

image-20211130091041655

看起来我们已经提炼完毕了,但是目前我们的数据是对称的,因为是配置的,所以存在多多稍稍的情况。我们去掉两个看一下情况。

image-20211130091220706

缺少之后我们的按钮瞬间就乱了顺序,我们设置顶部对齐。

struct ActionCardView: View {
    ...
    var body: some View {
        VStack {
            ...
            HStack(alignment:.top) {
                ...
            }
            ...
        }
        ...
    }
    
    ...
}

image-20211130091442627

当我只剩下三四个功能的时候,竟然之前的布局不工作了,我干脆就让三等分,左侧就设置居左,中间的就居中,右侧就居右显示。

struct ActionCardView: View {
    ...
    var body: some View {
        VStack {
            ...
            HStack(alignment:.top) {
                HStack {
                    ActionView(actionItems: actions(index: .left))
                    Spacer()
                }
                .frame(maxWidth:.infinity)
                HStack {
                    ActionView(actionItems: actions(index: .center))
                }
                .frame(maxWidth:.infinity)
                HStack {
                    Spacer()
                    ActionView(actionItems: actions(index: .right))
                }
                .frame(maxWidth:.infinity)
            }
            ...
        }
        ...
    }
    
    ...
}

image-20211130100905583

我们把生产执行的功能添加到首页里面。

struct HomePage: View {
...
    var body: some View {
        NavigationView {
            navigationBar {
                ZStack {
                    Color(uiColor: appColor.c_efefef)
                    VStack {
                        ActionCardView(
                            title: "生产执行",
                            actions: [
                                ...
                            ])
                            .padding(EdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 10))
                        Spacer()
                    }
                }
            }
        }
        ...
    }
    
    ...
}

image-20211130102607315

第十九章 TabView|accentColor|AnyView|NavigationView|navigationTitle|navigationBarTit

作者 君赏
2026年2月23日 13:36

用户登录之后,就可以进入首页了,我们看一下首页的 UI的样子。

image-20211124173333111

我们先创建一个 HomePage

image-20211124173511480

我们在入口修改逻辑,支持登录完毕进入首页。

image-20211124174225448

image-20211124174240112

TabView 创建 TabBar

我们登录完毕,或者下次启动就进入了首页了。我们首页底部是有 Tab 的,我们需要用到 TabView

我们创建一个 TabPage,用户显示我们首页底部的 Tab

image-20211124181149736

我们修改一下代码,将我们的 HomePage 添加进去。

image-20211125080109642

image-20211125080220365

这显示的效果明显不是我们需要的效果,而且文本怎么变成蓝色了?我们需要的是下面的效果

image-20211125080508984

accentColor设置 TabBar 选中颜色

我们尝试一下设置一下文本的前景色。

image-20211125080800417

但是没有任何的效果,这时候我们需要谷歌一下资料是什么原因了。发现网上一些文章答案已经不能用了,但是 accentColor 这个还是有效果的,但就是废弃了。

image-20211125082732340

image-20211125082750634

image-20211125082457016

需要用最新的 tint(_:),如果想更改默认未选中 item 的颜色,需要通过下面代码设置。

image-20211125083803216

尝试封装 TabView

为了我们的 TabItem 可以方便的进行设置,我们决定封装一下我们 TabView

image-20211125084535863

我们如果封装,需要用户提供试图内容,未选中的图标,选中图标,选中的颜色,未选中的颜色,还有当前选中的索引。

image-20211125085531525

我们需要用户传入一个 TabItem 的数组,我们通过数组进行创建 TabViewitem

image-20211125085908959

但是我们的范型的结构体无法放在数组里面。那么,我们可以将范型设置为 AnyView,这样报错解决了,但是在SwiftUI中最好不要用到 AnyView,这会导致系统无法推断最外层结构,从而无法优化Diff算法,优化性能。

走到这一步,我们发现还是不要封装为好,毕竟超过5个的tabItem就已经少之又少。

我们重新修改一下 TabPage的代码。

image-20211125093235601

image-20211125093332975

image-20211125093436760

image-20211125093459780

⚠️我们使用最新的 .tint(\_:) 会经常不起效果,但是换成 .accentColor(_:)就可以。

对于 TabView 我就先到此为止了,目前也是达到我们的效果,接下来我们开始做我们首页的逻辑。

image-20211125094058101

NavigationView 使用导航

首页的头部是一个导航条,并且左侧有一个进行选择的选择框。对于导航,我们需要用到 NavigationView

image-20211125094315054

.navigationTitle 设置导航标题

但是我们怎么设置导航标题呢?我们可以在任何子组件通过 .navigationTitle进行设置。

image-20211125094511501

image-20211125094547006

.navigationBarTitleDisplayMode 设置导航样式

但是我们的导航显示是默认的大标题,是符合 iOS新版本的系统风格一样。不过我们可以通过.navigationBarTitleDisplayMode进行设置导航标题的显示模式。

image-20211125094912042

image-20211125094929459

.toolbar 添加导航按钮

此时我们的导航的标题已经显示正常了。但是我们工厂选择的组件怎么添加到首页左侧的位置呢?经过谷歌之后,我们发现可以通过.toolbar的方法轻松的添加左侧和右侧的视图。

image-20211125112305287

image-20211125112323176

我们将添加导航的代码提炼出来,并且设置页面背景颜色为淡灰色。

struct HomePage: View {
    @StateObject private var appColor:AppColor = AppColor.share
    var body: some View {
        NavigationView {
            navigationBar {
                Color(uiColor: appColor.c_fefefe)
            }
        }
    }
    
    private func navigationBar<Content:View>(@ViewBuilder content:() -> Content) -> some View {
        content()
            .navigationTitle(Text("首页"))
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement:.navigationBarLeading) {
                    HStack(spacing:6) {
                        Text("请选择工厂")
                            .foregroundColor(Color(uiColor: appColor.c_999999))
                            .font(.system(size: 15))
                        Image("drop_icon")
                    }
                }
            }
    }
}

首页进来需要先获取工厂列表,如果之前设置过并且不在工厂列表,或者都没设置过工厂,则默认为列表的第一个工厂。

我们不管怎么样的逻辑,第一步就是获取工厂列表,之后才能做剩下的逻辑。

class HomePageViewModel: BaseViewModel {
    
    /// 请求工厂列表
    func requestFactoryList() async {
        let api = FactoryListApi()
        let model:BaseModel<FactoryListResponseModel> = await request(api: api)
        // 下面逻辑
    }
}

现在我们就拿到了全部的工厂列表,我们判断请求成功就保存工厂列表到当前页面。

// 新建一个 @Published 可以接受请求的工厂列表并通知更新
/// 工厂列表
@Published var factoryList:[FactoryListResponseModel] = []
// 请求成功就将工厂数据更新
guard model._isSuccess else {return}
factoryList = model.data ?? []

Hashable 解决 ForEach 可能 Sting 相同报错

有了工厂列表我们就可以点击 PopMenuButton 展示所有的工厂列表了。但是我们渲染的时候出现了报错,提示下面的报错。

ForEach<Array<String>, String, ModifiedContent<ModifiedContent<PopMenuButtonItem, _BackgroundStyleModifier<BackgroundStyle>>, AddGestureModifier<_EndedGesture<TapGesture>>>>: the ID 111111 occurs multiple times within the collection, this will give undefined results!

提示我们多次出现了数据111111,可能会导致找不到唯一 ID的结果。这样一看,确实是我们当初封装的时候考虑的太浅,没有想到展示的数据可能名字一样,虽然不合理,但是存在。

为了解决这个问题,我们必须对 PopMenuButton 进行重构,我们需要将数据假设成一个协议。

protocol PopMenuItem:Hashable {
    /// 显示在 Menu Item 的文字
    var menuTitle:String {get}
}
/// old
struct PopMenuButton: View {
    let items:[String]
    @Binding var currentItem:String
    
/// new
struct PopMenuButton<T:PopMenuItem>: View {
    let items:[T]
    @Binding var currentItem:T
/// old
typealias ItemValueChanged = (String) -> Void

/// new
typealias ItemValueChanged = (T) -> Void
/// old
PopMenuButtonItem(title: item,

/// new
PopMenuButtonItem(title: item.menuTitle,

我们将PopMenButton 的代码修改成如上。但是之前的示例和引用都会报错,对于名字重复出现几率很小,不可能不允许纯文本数组支持。

String 实现 PopMenuItem 实现兼容

为了兼容和支持纯文本数组的支持,我们新增String的扩展。

extension String: PopMenuItem {
    var menuTitle: String {self}
}

为了修复我们工厂数据源,因为工厂名字存在重复,我们将 FactoryListResponseModel 实现我们 PopMenuItem 协议。

extension FactoryListResponseModel: PopMenuItem {
    var menuTitle: String { factoryName ?? "" }
    /// 重写 == 方法 为了自定义实现两个模型是否一样
    static func ==(lhs:FactoryListResponseModel, rhs:FactoryListResponseModel) -> Bool {
        guard let leftCode = lhs.factoryCode, let rightCode = rhs.factoryCode else {return false}
        return leftCode == rightCode
    }
}

我们调整一下首页的代码,来支持 FactoryListResponseModel 模型。

/// HomePageViewMode
/// old
@Published var currentFactoryName:String = ""

/// new
@Published var currentFactory:FactoryListResponseModel = FactoryListResponseModel(factoryCode: nil,
                                                                                      factoryName: nil)

PopMenuButtonModify

/// old
struct PopMenuButtonModify: ViewModifier {
    let items:[String]
    @Binding var currentItem:String
    
/// new
struct PopMenuButtonModify<T:PopMenuItem>: ViewModifier {
    let items:[T]
    @Binding var currentItem:T

View+popMenuButton

/// old
func popMenuButton(items:[String],
                   currentItem:Binding<String>,
                   isShowPopMenuButton:Binding<Bool>) -> some View{
                   
/// new
func popMenuButton<T:PopMenuItem>(items:[T],
                                  currentItem:Binding<T>,
                                  isShowPopMenuButton:Binding<Bool>) -> some View{

HomePage

/// old
.popMenuButton(items: factoryNames,
               currentItem: $viewModel.currentFactoryName,
/// new
.popMenuButton(items: viewModel.factoryList,
                       currentItem: $viewModel.currentFactory,

此时我们工厂选择再也不报错误了,可以正常的显示出来了。

image-20211126140408654

第十八章 封装HUD和完善登录界面逻辑

作者 君赏
2026年2月23日 13:36

我们几乎在 LoginPageViewModel 添加了大量的代码,才实现了请求展示 HUD,请求完毕展示信息之后 2 秒自动消失。

我们需要每个界面都要写这么多的代码吗?我们可以考虑进行封装,那么我们可以将 @MainActor 和 相关的 HUD的属性和逻辑转移到 BaseViewModel 里面。

image-20211124144747582

image-20211124144901361

我们进行登陆的方法的代码依然没有减少,我觉得这样的代码量还是很多。我们可以精简一下,请求的时候可以根据我们传入的参数自动显示 HUD,和自动的展示 错误的提示。

正确的提示交给使用者,这样精简之后,使用起来岂不是更加的简单。

image-20211124145535053

image-20211124145706324

这样我们使用起来,代码量就变得很简单了,而且如果中间多次请求,只需要在最后关闭 HUD 即可。

我们思考一下,我们每一个界面都需要设置下面代码来支持 HUD的显示吗?

image-20211124145905712

我们可以使用继承方式,但是遗憾的是 Struct 不支持继承。我们用协议实现怎么样呢?

image-20211124152311934

我们将所有单独的页面需要实现我们新增的扩展方法。

image-20211124152413428

虽然也代码量也没有省多少,但是不需要传递那么多的参数,从而后面的改动不会影响外层的使用。

我们登陆不可能这么的逻辑代码不可能这么的简单,如果用户没有输入用户名和密码,自然不需要进行请求,浪费网络资源。

那么对于没有用户名和密码输入,我们则进行提示用户。

image-20211124153247867

我们发现当我们直接点击登录按钮的时候,界面没有任何的提示。这是因为我们当时设置了只有 isAnimating = true 才会展示 HUDView,我们修改一下代码。

image-20211124154457551

这样就完成了如果用户名和密码没有输入就提示用户输入,之后进行登录就加载 HUD,请求完毕展示信息。

我们登陆完毕之后,如果用户开启了记住密码,我们需要将用户的用户名和密码保存下来,下次不需要用户输入用户名和密码。

那么我们需要用到 @AppStorage,但是我们直接修改成下面代码,会有什么问题呢?

image-20211124155239898

这样就算我们开启记住密码,系统已经将用户名和密码保存下来了。我们只能放在AppConfig里面,当开启就设置 AppConfig 的用户名和密码,进入登陆页面就读取 AppConfig 之前保存的用户名和密码。

image-20211124155545184

我们在 LoginPageViewModel 的登录方法里面,当登录成功并且开启保存将用户名和密码进行保存。

image-20211124155821597

我们在 LoginPageViewModel 的初始化方法里面,将之前保存的 用户名和密码进行设置。

image-20211124155950487

我们登录完毕获取到的 gatewayUserName 相当于 JWT,用于后续需要用户的接口,那么我们也保存在 AppConfig 里面。

image-20211124160557975

image-20211124160641694

到此为止,我们终于完成了 LoginPage 的交互和逻辑。

第十七章 @MainActor

作者 君赏
2026年2月23日 13:35

HUDViewModify 封装完毕,我们添加在 LoginPage 主页面上面,首先需要在 LoginViewModel 新增一个 isLoadingHUD 的参数。

image-20211124100041628

LoginPageHUD 添加在最外层。

image-20211124111539600

那么我们在 LoginPageViewModel 中的 login 方法在请求之前开始 HUD,和在请求完毕结束 HUD

image-20211124111841823

但是我们发现一个问题,是我们不注意导致的,我们竟然没有在初始化状态隐藏底部的背景,导致初始化状态就看到一个黑色的背景。

image-20211124112007686

我们修改一个 HUDViewModify 的代码。

image-20211124112120463

@MainActor 在 async 方法更新 UI

这样初始化状态不显示,当需要隐藏 HUD 的时候一起隐藏。我们修改完毕之后,我们测试一下登录功能。

从表面上一切都正常,但是我们看到打印的 Log,发现我们更新UI不在主线程,那么在Release就会导致出问题。

[SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

我们在 async 的方法内部怎么回到主线程更新 UI,我们还是用之前的方式更新吗?

image-20211124134144368

也是可以的 ,但是我们出来了 async/await 还这么麻烦吗?大难是否定的,我们有一个 @MainActor 的修饰符。关于 @MainActor 大家可以参考下面的文章了解一下。

hicc.pro/p/swift/the…

image-20211124135406200

image-20211124135534335

通过 @MainActor 装饰我们的 LoginPageViewModel 之后,我们就可以正常通过设置 @Published 值进行更新 SwiftUI 了。

但是我们执行登录请求,虽然也看到加动画和消失动画。对于用户来说,也不知道这次登录时是成功还是失败。虽然可以通过登录成功进入首页做到这一点,但是请求接口完毕做一个提示还是好的。

修改支持提示纯文本

我们修改一下 HUDView 允许可以显示提示的文本,修改的代码如下。

image-20211124141127976

但是我们显示 UI 发生了变化,结果成为了下面显示的样子。

image-20211124141227926

调整 HUD 大小等于内容大小

这和我们预想的不一样,我们预想的不管组件多大,我们都会在最外层添加一个 Padding,并且设置大小为 Fit 的大小。

我们想到我们设置地步黑色区域为宽度和高度相等,现在中间的文件是宽度大于高度,所以按照等比。那么背景大小宽度就等于高度,导致这个样子的出现。

image-20211124141715968

我们删掉了上面的代码,界面效果恢复了。

image-20211124141750241

image-20211124141952750

现在是我们的文本比较长,但是当我们显示一个特别短的文本会是怎么样的 UI 呢?

image-20211124142052985

设置最短显示宽度

这个也说的过去,但是总觉得显示宽度太短了,我们想设置最短的宽度为 50。

image-20211124142311548

image-20211124142323980

此时看起来还差不多。

我们就让 LoginPage 的登录操作,报错就提示错误,否则就提示成功。

image-20211124143027508

image-20220104140049059

延时 2 秒消失

但是我们的提示不会消失了,我们设置提示完毕之后延时 2 秒消失。

image-20211124143750584

image-20211124143809099

此时我们已经做到请求完毕提示 2 秒之后消失。

第十六章 RoundedRectangle|aspectRatio|UIViewRepresentable

作者 君赏
2026年2月23日 13:34

RoundedRectangle 自定义 HUD

在我们进行登陆请求的时候,界面上毫无反馈,我们想加上 Loading 动画,等接口完毕就提示登录成功或者登录失败。

虽然有很多优秀的第三方 HUD,但是我觉得这么简单的也不需要用第三方了,我们尝试自己写一下试一下。

我们在 View 文件夹,新建一个 HUDView.swift文件。

struct HUDView: View {
    var body: some View {
        Text("Hello World!")
    }
}

我们想做一个效果就是,大概是下面的效果。

image-20211123172106317

那么我们就需要用到 ZStack 垂直布局,最下面 我们需要用到 一个黑色圆角的试图,那么就需要 RoundedRectangle 。

var body: some View {
    ZStack {
        RoundedRectangle(cornerRadius: 20)
    }
}
image-20211123091323479

aspectRatio 填充模式

因为 RoundedRectangle 没有任何内容可计算自己的大小,所以会按照最大的宽度和最大的高度进行充满。

我们需要一个正方形,那么就需要宽度和长度进行一比一的大小。

.aspectRatio(contentMode: .fit)

目前宽度和高度都是一样的大小,但是宽度铺满不符合,那么我们设置一下 宽度。

.frame(width:150)
image-20211123140447982

UIViewRepresentable 封装 UIView 为 View

我们希望控件的中间放一个系统的 ActivityIndicatorView,来表示当前正在请求。但是在 SwiftUI 里面,系统并没有提供这样的控件可以用。

那么我们需要将 UIKit中的 UIActivityIndicatorView 转换给 SwiftUI 用。

我们将在 View的文件夹新建一个 ActivityIndicatorView.swift文件。

struct ActivityIndicatorView: UIViewRepresentable {
    typealias UIViewType = UIActivityIndicatorView
    func makeUIView(context: Context) -> UIActivityIndicatorView {
        let activityIndicatorView = UIActivityIndicatorView()
        return activityIndicatorView
    }
    
    func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
        
    }
}

我们遇到了一个 全新的 View 的协议 UIViewRepresentable,这个允许我们可以将 UIView 的试图轻松的转换成 SwiftUI 的类型。

主要需要实现 makeUIView 函数和 updateUIView 函数。

因为 ActivityIndicatorView 是结构体,ActivityIndicatorView初始化时候会调用 makeUIView 生成对应的试图,当 @State值变化时候,会调用 updateUIView 更新试图。

关于 UIViewRepresentable 详细的介绍可以查看文章

betterprogramming.pub/how-to-use-…

此时我们 ActivityIndicatorView 试图预览看不出任何内容。

image-20211123143541364

那是因为默认 isAnimating = false,会自动隐藏控件。我们设置一个 @Binding 的组件控制 UIActivityIndicatorView 的开始动画和结束动画。

struct ActivityIndicatorView: UIViewRepresentable {
    
    @Binding var isAnimating:Bool
    
    typealias UIViewType = UIActivityIndicatorView
    func makeUIView(context: Context) -> UIActivityIndicatorView {
        let activityIndicatorView = UIActivityIndicatorView()
        return activityIndicatorView
    }
    
    func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
        if isAnimating {
            uiView.startAnimating()
        } else {
            uiView.stopAnimating()
        }
    }
}
image-20211123144143640

我们设置外面传进来 @Binding的值为true,此时我们可以预览看到小小的菊花。但是觉得 UIActivityIndicatorView 的组件有点小,我们设置为样式为 large

activityIndicatorView.style = .large
image-20211123144519862

这个灰色自然不能满足我们控件的需求,你想我们在黑色背景下面显示灰色,估计眼睛都可以看瞎了。我们新建一个变量,可以设置UIActivityIndicatorView 的颜色。

/// ActivityIndicatorView
let color:UIColor?
@Binding var isAnimating:Bool

init(isAnimating:Binding<Bool>,
     color:UIColor? = nil) {
    _isAnimating = isAnimating
    self.color = color
}

/// makeUIView
if let color = color {
    activityIndicatorView.color = color
}

现在我们就可以将 ActivityIndicatorView 组件用在我们的 HUDView上面了。

image-20211123153149927image-20211123155630534

这么看起来,效果还不错。但是背景颜色太黑了,我们设置一下透明度,让可以隐约的看到下面的试图,并且看上去没有那么黑。

image-20211123155932310

image-20211123160528094

但是我们感觉我们的 HUDView 试图还是太大了,我们觉得如果按照里面的内容设置 Padding 岂不是最好了。

image-20211123161913297

我们将 RoundedRectangle 设置为 ActivityIndicatorView Background 试图,并且设置 Padding = 20

image-20211123162107815

为了外界可以控制 HUDView 的开始动画和结束动画,我们将修改代码如下。

image-20211123162446279

控件我们完成了,我们新建一个 HUDViewModify 来让其他界面方便的进行调用。

image-20211123171221420

我们再新增一个 View 的扩展。

image-20211123171431298

到目前为止,我们简单版本的 HUD 就封装完毕了。对于大家日常开发过程中,优先可以选择第三方的控件使用,但是要理解他们是怎样实现的。

不能只作为一个只会使用工具的工具人。

❌
❌