iOS 专注 App 架构实践:用 struct ViewModel 全面替代 ObservableObject,代码量砍掉三分之一

uni-app 和 Flutter 打包上架 iOS 频繁触发 4.3 (a):原因拆解与合规处理路径 跨端方案让 iOS 上架的门槛降低了,但也带来一个明显的副作用 ——4.3 (a) 的命中率显著高于原生项目。很多开发者不理解:业务代码明明是自己写的,为什么还会被判为重复应用。这篇专门讲跨端场景,把原因和可走的路径讲清楚。 阅读详情

前言:做了一个 iOS 专注计时工具,聊聊架构上的一次冒险

我在做一个叫「声境护照」的 iOS App——简单说就是「白噪音 + 番茄钟 + 游戏化成长」的专注计时工具。开发过程中,我做了一个比较大胆的决定:把项目里所有 ViewModel 从 class 换成 struct,彻底干掉了 @Published 和 Combine pipeline。

结果是代码量少了差不多三分之一,测试变得极其简单——但中间有个坑差点让我全部回滚。

这篇文章完整记录这次重构的动机、做法、踩坑和收获,希望对同样在用 SwiftUI 做项目的朋友有参考价值。

为什么要干掉 ObservableObject

项目初期我按 SwiftUI 社区最常规的套路写:每个页面一个 class ViewModel,继承 ObservableObject,里面塞一堆 @Published 属性,View 用 @StateObject 持有。

写到第三个页面的时候,我开始觉得不对劲。

问题很具体:

  1. 重复管道代码太多。 每个 ViewModel 的数据来源其实都是同一个全局 AppStore,但各自要写订阅、写转换,Combine pipeline 写了一堆,实际干的事无非是从 AppStore 读数据算一下。
    1. 测试成本高。 我只想验证「给定这组 FocusLog,算出来的统计数据对不对」,但 mock 一个 ObservableObject 需要处理 publisher、等异步回调,一个简单的断言被包在一堆样板代码里。
    1. 生命周期心智负担。 每个 class ViewModel 都要操心自己的生命周期——什么时候创建、什么时候销毁、订阅什么时候取消,而这些其实跟我的业务逻辑毫无关系。
      某天晚上我试着把一个 ViewModel 从 class 改成 struct,发现跑得通。然后花了一个周末,把全部 6 个页面的 ViewModel 都换掉了。

改完之后长什么样

整个 App 的数据架构变成了这样:

  • 一个 AppStore(class,作为唯一的全局状态源,这个没动)
    • 6 个 struct ViewModel,全部只读引用 AppStore,通过 computed property 做数据转换
      以统计页面的 ViewModel 为例:
struct StatsSheetViewModel {
    let store: AppStore
        let rangeKey: StatsRangeKey
    private var logs: [FocusLog] {
            store.focusLogs.isEmpty
                        ? StatsService.createDemoFocusLogs()
                                    : store.focusLogs
                                        }
    var stats: StatsData {
            StatsService.buildStatsData(
                        focusLogs: logs,
                                    streakDays: store.streakDays,
                                                rangeKey: rangeKey,
                                                            now: Date(),
                                                                        isDemo: store.focusLogs.isEmpty
                                                                                )
                                                                                    }
    var growth: GrowthProfile {
            GrowthService.buildGrowthProfile(
                        focusLogs: logs,
                                    streakDays: store.streakDays
                                            )
                                                }
                                                }
                                                ```
6 个 ViewModel 风格完全统一:持有 `store` 引用,暴露几个 computed property,没有可变状态,没有 init 里的副作用。

**测试的时候怎么写?** 塞一个预填好数据的 `AppStore` 进去,直接断言 `.stats` 的返回值。不需要等异步,不需要 mock publisher,一个测试函数五六行搞定。这种体验一旦尝过就回不去了。

## 差点让我全部回滚的坑

问题出在 **UI 临时状态**上。

统计页面有个时间范围切换器(7天 / 30天 / 全部),用户点了之后要记住当前选的是哪个 tab。我下意识就想在 ViewModel 里加一个 `var selectedRange: StatsRangeKey`。

编译没报错。但运行的时候发现:切换之后 View 刷新,struct 被重新创建,`selectedRange` 回到默认值。

原因很简单——**struct 是值类型,SwiftUI 的 View 重建会重新构造它**。class 不会有这个问题,因为引用类型的实例活在堆上,View 重建只是拿到同一个引用。

第一反应是「完了,这条路走不通」。冷静了一下想了想,这个 `selectedRange` 本质上是 **View 的 UI 状态**,跟业务数据没关系,它就不应该出现在 ViewModel 里。

最终方案是严格区分两种状态:

- **struct ViewModel 只负责「给定输入,算出输出」**,纯函数思维,零可变状态
- - **UI 临时状态全部用 View 层的 `@State` 管理**(选中的 tab、展开/折叠、输入框内容等)
- - **View 把 `@State` 的值作为参数传给 ViewModel**(通过 init 参数或 computed property 参数)
想清楚这个边界之后,再没遇到过类似问题。而且我发现这个约束反过来帮我理清了架构——以前 ViewModel 里混着业务逻辑和 UI 状态,现在泾渭分明。

## 远征系统中 Definition/State 分层的设计

再聊一个我觉得挺值得分享的设计。App 里有个远征系统——完成指定次数或时长的专注,就能推进到下一个城市章节,这是游戏化成长的核心玩法。

一开始任务定义和用户进度混在同一个 struct 里。加第三个城市的时候改得很痛苦,因为根本分不清哪些字段是静态配置、哪些是运行时数据。最后重构拆成了两套模型:

```swift
// 静态配置:描述「这个任务要求什么」
struct ExpeditionMissionDefinition: Identifiable, Codable {
    let id: String
        let title: String
            let kind: ExpeditionMissionKind // .sessionCount / .focusMinutes / .deepFocusCount
                let targetValue: Int
                    let rewardMiles: Int
                    }
// 运行时状态:记录「用户当前进度」
struct ExpeditionMissionState: Identifiable, Codable {
    let id: String
        var progress: Int
            var completedAt: Date?
                var completed: Bool { completedAt != nil }
                }
                ```
两者通过 `id` 关联。`Definition` 从 JSON 配置文件读取,全部是 `let`;`State` 存本地持久化,用 `var`。新增一个城市章节就是加一段 JSON,状态层零改动。

这个拆法跟 struct ViewModel 的思路是一脉相承的——**把不变的东西和会变的东西分开放**。Definition 像 ViewModel 一样是只读的数据转换层,State 才是真正需要持久化和变更的部分。`ExpeditionMissionKind` 用枚举区分任务类型,以后加新任务类型只需要加一个 case 和对应的进度计算逻辑,Definition 结构不用动。

## 这套方案适合什么项目

说几个前提条件,不满足的话建议慎用。

**适合的情况:**

- 有一个单一数据源(全局 Store 或 Redux 风格的状态管理)
- - ViewModel 的主要工作是「读数据 → 转换 → 给 View 展示」
- - 页面不需要在 ViewModel 层持有复杂的可变状态
**不太适合的情况:**

- ViewModel 需要管理网络请求的生命周期(取消、重试、loading 状态流转)
- - 页面有大量交互状态且互相依赖
- - 团队已经习惯 MVVM + Combine 那套,贸然切换反而增加认知负担
声境护照刚好属于前者:数据从 AppStore 流出来,ViewModel 做纯转换,View 负责展示和管理 UI 状态。没有复杂的异步编排,struct 完全够用。

## 总结

这个项目还在早期,很多东西在摸索。struct ViewModel 这套方案用了几个月,目前我自己还挺满意:

- 代码量实打实少了三分之一左右
- - 测试从「写起来烦」变成「写起来爽」
- - 架构上「业务数据 vs UI 状态」的边界被强制理清了
但我也不确定项目复杂度继续上升之后会不会撑不住——目前 6 个页面,如果扩展到 15-20 个页面,或者加入复杂的网络请求层,可能需要重新评估。

最后抛个问题:你在 SwiftUI 项目里用 struct 还是 class 做 ViewModel?如果用 class,有没有什么轻量化的写法可以避免 Combine 的模板代码?欢迎评论区交流。
基于STM32、HAL库的BMP585 气压传感器 驱动程序设计 define BMP585_I2C_ADDR_PRIMARY 0x76 << 1 /* 0x76地址左移一位 */#define BMP585_I2C_ADDR_SECONDARY 0x77 << 1 /* 0x77地址左移一位 *//* I2C句柄 *//* I2C地址 *//* 设置新的温度过采样率 *//* 设置新的滤波器系数 *//* 芯片ID *//* 清除温度过采样位 *//* 转换为hPa *//* 设置新的压力过采样率 *//* 设置新的电源模式 *//* 清除压力过采样位 */ 阅读详情

相关推荐

Qt中多线程的使用(一)

我们要实现的是读取大文件qtgui.index的内容加入文本框中。 很容易想到的方法: QFile* file = new QFile("E:\qtgui.index"); file-&gt;open(QIODevice::ReadOnly); QTextStream *stream = new QTextStream(file); while(!st...

yao5hed的专栏 1万+

Expenso-iOS项目架构演进:从MVC到MVVM的转型之路

Expenso-iOS是一款简洁的支出追踪应用,采用SwiftUI、CoreData、Charts等现代iOS技术栈构建,完整展示了从传统MVC到MVVM架构的转型实践。本文将深入剖析这一架构演进过程,揭示MVVM如何提升代码可维护性与测试效率。 ## 架构转型的核心驱动力 🚀 在移动应用开发中,架构选择直接影响项目的可扩展性和团队协作效率。Expenso-iOS最初采用MVC架构时,曾面临

gitblog_00500的博客 414

深入解析STM32F40x CAN控制器的配置与优化

本文深入解析STM32F40x CAN控制器的配置与优化,涵盖基础工作模式、波特率计算、筛选器配置及中断管理等关键技术。通过实战案例和代码示例,帮助开发者高效配置CAN通信,提升工业控制系统的稳定性和性能。

weixin_26834815的博客 223

[iOS]Runtime2

为了让你在父类中依然能访问当前对象(也就是子类的实例),编译器在底层会生成一个结构体,叫 : 当调用 时,底层调用的函数是 : 拿着(也就是当前的子类对象)作为消息接收者,但是把方法查找的起点定位在了 (父类)这里需要注意的就是仍然是调用者自己,即先讲一下上篇没讲过的,我们如果缓存未命中,那我们就会进入慢速查找,现在我们来详细的讲解在进入慢速查找的瞬间会再看一遍方法列表,然后立刻调用加锁,因为多线程下别人正在动态添加方法或者扩容,不加锁会崩溃类就绪OC的机制就是在编译期只生成了 结构,当类首次被发送消息时(

wzm20070304的博客 263

手把手教你用C++和NI-VISA写个简易仪器上位机(附QT工程配置)

本文详细介绍了如何使用C++和QT框架结合NI-VISA驱动开发跨平台仪器控制上位机软件。从Ubuntu环境配置、VISA通信核心架构设计到QT界面与业务逻辑解耦,提供了完整的实战指南和代码示例,帮助开发者快速构建专业级仪器控制程序。

weixin_42522374的博客 273

解密TMS320F28377D双核工程中的内存分配艺术:从CMD文件到实战优化

本文深入解析TMS320F28377D双核DSP的内存管理艺术,从CMD文件配置到实战优化技巧。详细介绍了SARAM、GSRAM等内存类型的特性与配置方法,提供双核协同、性能优化及高级调试的实用方案,帮助开发者提升系统实时性和稳定性。

weixin_29190651的博客 663

VS Code + KXApp 开发 iOS 报错排查指南,工程、签名、真机、补全、配置

在 VS Code 里用 KXApp 开发 iOS,报错常不在代码而在环境。本文按失败阶段分五类排查:工程没出现 Xcode 视图(查 .xcodeproj 与 xcode-select)、构建失败(No profiles、Team 签名、CocoaPods 用 .xcworkspace)、真机调试失败(信任、开发者模式、设备未准备好)、没补全或 No such module(等索引、清 clangd 冲突)、设置保存不了(装 xcodeproj gem),每类给现象、日志通道和修法。

2501_91590906的博客 209

Windows系统下LaTeX环境搭建与编辑器配置全攻略

本文详细介绍了在Windows系统下搭建LaTeX环境的完整流程,包括TeX Live的安装、TeXStudio编辑器的配置优化以及常见问题排查。特别针对中文用户推荐使用清华镜像源加速下载,并提供了XeLaTeX编译器配置、中文支持等实用技巧,帮助用户快速上手LaTeX文档编写。

weixin_42554408的博客 449

flutter ios 4.3.a被拒整改方案

本文分享了团队首次推进Flutter项目iOS上架时遭遇4.3.a审核被拒的全过程。历经四次提审失败,因未深入分析根本原因,仅做局部优化,导致问题反复。最终通过复盘、整理被拒文档、结合元数据与IPA-Diff工具分析,发现核心问题是二进制代码相似度过高,源于Flutter项目未深度重构。遂开展为期两周的系统性整改:重构Dart代码、清理冗余、升级Flutter版本、优化元数据与UI设计,降低二进制指纹至0.23,成功通过审核。强调海外上架需环境隔离、专用设备,并愿免费协助解决同类问题。

zdgdq的专栏 164

【定位教程2----旋转中心标定之方法一】

【定位教程2----旋转中心标定之方法一】

Quintin 3万+

【忍者算法】从拼积木到最长连续序列:一道别出心裁的数组题目|LeetCode 128 最长连续序列

本题核心:通过预处理数据结构来优化查找!

忍者算法的博客 738

Appuploader 创建 iOS 描述文件详解,Development / Ad Hoc / App Store

搞不清 iOS 描述文件(Provisioning Profile)该选 Development、Ad Hoc 还是 App Store?本文讲清四种类型各装到哪、哪些要绑设备、能不能上架,重点辨析最容易混的 Development 与 Ad Hoc,说明设备或证书变动后为什么要重建、重建后要重新导入签名,并给出不用 Mac 在 Windows/Linux 上用 Appuploader 新建与重建描述文件的实操步骤,附装机"描述文件不匹配"等常见问题排查。

2501_91373349的博客 237

SAP ABAP 实用小技巧合集 (一)

本合集包含了笔者从事 ABAP 开发 17 年以来积累的小技巧。持续更新。

2007 年 ~ 2025 年,深耕 SAP 技术 18 年 3224

VS Code 开发 iOS 工程,KXApp 与 Xcode 分工,双工具协同实操

想用 VS Code 写 iOS 又担心脱离 Xcode、搞乱团队工程?本文讲清 KXApp 和 Xcode 的并用关系:它是 Mac 上的 VS Code 扩展、必须装 Xcode 并调用它构建签名,两边共用编译结果(一边编过另一边不用重编)、共用同一份工程文件不迁移,补全用的 buildServer.json 可 gitignore 不影响同事,签名沿用 Xcode 登录的账户,Cmd+B/R/. 快捷键一致,Interface Builder 和 Instruments 仍回 Xcode。

2501_91592143的博客 72

假人有限元模型

假人模型,车辆碰撞假人有限元模型,适合做乘员安全的研究者利用

IEM模型,地表粗糙度模拟

利用微波反演土壤水分时候,利用IEM模型可以实现对随机地表的模拟

苹果的内核跑上了安卓手机:2,631 Star 的 HakiOS 让 XNU 在 Adreno GPU 上画出 118 帧

HakiOS 把苹果开源的 XNU 内核搬上 Android 手机:自研引导器与驱动、RAM 内运行重启即还原,Metal 在 Adreno GPU 上跑出 118 帧。本文拆解其分层架构、双路线性能对比与法律边界。

话梅糖の优快云 Blog 355

ET2012新版免狗免安装绿色软件

ET2012新版免狗免安祖昂绿色软件。,新界面,新视觉,

Windows 开发 iOS,Sign in with Apple 苹果登录,KXBuild 真机调试全流程

Sign in with Apple 接入分四步:App ID 开能力并重新生成描述文件,entitlements 加 com.apple.developer.applesignin,用 SignInWithAppleButton 拿 identityToken 交服务端验签,删账号时调 revoke。本文讲清审核指南 4.8 为什么要你提供它、姓名邮箱只给一次、用 user 当主键,并在 Windows 上用 KXBuild 真机测登录,附 1000 报错排查。

2501_91373349的博客 234

RU DOS版、EFI版32bit/64bit BIOS常用工具

DOS版、EFI版的RU工具,可用来查看IO、MEM、PCI、SMBIOS等资源。亲测在EFI SHELL下可用。

Appuploader 配置 iOS Bundle ID,开启推送、iCloud,解决描述文件失效问题

App 代码没动,去开发者后台取消勾选一个能力,再打包就签名失败、描述文件失效——为什么?本文讲清 iOS Bundle ID(App ID)的注册规矩(反向域名、创建后 Identifier 不可改、通配符、App Clip)、用 Appuploader 配置推送/iCloud/App Groups 等 Capabilities 的方法,重点说明能力配置与 Apple 门户实时同步、描述文件是能力的快照,以及取消能力为何导致描述文件失效、怎么重建修复,附改名、删除、通配符等常见问题。

2501_91591841的博客 156

iOS 深度链接配置教程,URL Scheme 与 Universal Links,KXBuild 真机验证

深度链接两种:URL Scheme(myapp://)只改 Info.plist 的 CFBundleURLTypes;Universal Links(https://)要开 Associated Domains、在 entitlements 写 applinks 域名、网站放 /.well-known/apple-app-site-association。讲清两者怎么选、onOpenURL 处理代码,在 Windows 上用 KXBuild 装 Debug 包真机验证,附 CDN 缓存和十个不跳转排查。

2501_91600747的博客 298

手机远程控制电脑哪家强?2026年iOS/安卓/鸿蒙横评实测,ToDesk/向日葵谁更流畅

手机远程控制电脑已成刚需,ToDesk凭借全平台覆盖、低延迟、无广告、免费不限速等优势,成为最佳选择。其iOS、安卓、鸿蒙及Mac系统均支持,实测连接快、操作流畅,虚拟键盘、外接键鼠、互动批注等功能体验出色。相较之下,向日葵存在限时踢线、传输慢、广告多等痛点。选购建议:无论用何种手机,首选ToDesk,实现高效便捷的远程办公与协助。

lx08113的博客 307

uni-app iOS 离线打包教程,Windows KXBuild 本地生成 IPA,页面秒推真机

uni-app 云打包老排队、次数受限、改一次 manifest 原生配置就得重打?用 KXBuild 的 HBuilderX 插件在 Windows 本地做 uni-app 离线打包 iOS:导入同版本离线 SDK、填好 AppKey 和证书,就能运行到 iPhone 真机,改页面用"推送"几秒生效,最后生成可上传 App Store Connect 的 ipa,全程不用云打包。附 AppKey 错误、版本不匹配白屏、相机闪退等 9 个常见问题排查。

2501_91510632的博客 207

go消息推送之极光推送 (JPush REST API + SDK,覆盖安卓与iOS)

使用 gorush有个绕不开的问题:国内安卓设备上 FCM 基本不可达,华为、小米、OPPO、vivo 各自为政——此时可用极光推送(JPush)作解决方案。它的思路和前面几家都不一样:App 集成极光 SDK 后拿到统一的 RegistrationID,服务端发一条 REST 请求,极光云端自动把消息路由到 APNs、各家厂商通道或极光自有长连接。这里给出了:一个用纯标准库直接调用极光的;另一个用社区维护的,类型化参数,接口注释与官方文档逐条对应。两个功能相同,可放在一起对比学习。

qq_40467670的博客 187

Cocos Creator3.8.8 + Xcode27.2 编译运行失败

Cocos Creator3.8构建IOS工程后,使用最新的Xcode27.2版本进行真机调试,遇到C++标准库冲突,如is_floating_point、is_arithmetic和is_signed失败,以及webp对齐失败等问题。

HYNN12的博客 421

高危工业场景人员定位系统设计:四类场景的架构选型与部署要点

本文由航飞光电一线工程团队整理,重点讨论高危工业场景下人员定位系统的架构选型思路。覆盖危化、矿山、港口、电力四类场景,拆解技术路线分工、融合引擎跨区切换实现,以及私有化/本地化部署中的工程难点,供架构师与技术负责人参考。

HF17513371855的博客 368

【STM32】 ADC 完全指南:从模拟信号采样量化到五大架构原理

本文系统介绍ADC核心概念与五大主流架构。模拟信号具时间与幅值连续性,需经采样(满足奈奎斯特准则防混叠)、量化(引入±1/2LSB误差,SNR≈6.02N+1.76dB)、编码三步转为数字信号。重点解析Flash(并行高速但受限于比较器数量)、SAR(逐次逼近,低功耗适合MCU如STM32)、双积分(高精度抗工频,用于万用表)、流水线(高速吞吐)及Σ-Δ(高分辨率,靠过采样与噪声整形)五类架构原理、优缺点与应用场景。强调分辨率≠精度,选型本质是速度、精度、功耗与面积的权衡。

专注后端与底层技术,分享实战干货与学习笔记,梦想进入字节跳动,步履不停,热爱不止。 508

热管理系列之八:终局判断 - 浸没式冷却、混合架构与技术路线图

动力电池热管理三条路径的终局判断:液冷极致优化守住基盘,直冷在中高端渗透,浸没式冷却从概念到量产。附2026-2030技术路线图与关键产业信号分析。

weixin_54917114的博客 1175

源网荷储分层控制架构:从设备级到系统级的协调优化

面对分布式光伏、储能与柔性负荷大规模接入带来的运行复杂性,传统粗放管控模式已难适应高比例新能源场景。源网荷储分层控制架构通过“设备级保安全、台区级调协同、系统级统全局”的多层级协同体系,实现单体精准执行、局部动态平衡与全域优化统筹,构建自上而下指令分解、自下而上反馈闭环的精细化调控新模式,有效破解设备冲突、局部最优与全局失衡难题,推动分布式能源从“各自为战”迈向“协同共赢”,为新型电力系统安全高效运行提供核心支撑。

zhuhai_xigedian的博客 182
上一篇: 独立开发毒舌版MBTI测试App:Swift数据模型版本迁移和多文案架构的实战踩坑
下一篇: 作为iOS独立开发者,我做了一个把城市探索变成「战争迷雾」的App——雁过留痕
SameX-4869
博客等级 码龄18年 3354粉丝 657原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值