第一版迁移完成得很快。Flutter 工程交给 AI,一个页面一个页面地"翻译"成 SwiftUI,两周左右,工程编译通过,每个页面都有了,截图和 Flutter 版放在一起几乎看不出差别。
装到手机上之后,问题一个一个冒出来:
- 首页切到别的 tab 再切回来,列表重新请求了一遍。Flutter 版不会。
- 首页的栏目名和顺序是写死在 Swift 里的。Flutter 版是服务端下发的。
- 底部 tab 用的是系统
TabView。Flutter 版是自绘的,带 Lottie 动效,滚动时会收起。 - 有几个图标明显比 Flutter 版大一圈------AI 是照着截图"调到看起来一样",不是按设计稿换算的。
- 首页只实现了"有数据"这一种状态。骨架屏、空态、接口失败、播放中,一个都没有。
每一条单独看都是小问题。合在一起,它意味着这个"完成"的工程其实只是一层壳:结构是偏的,数据流是错的,状态是缺的。而最麻烦的是,这些问题在编译期和截图对比里全部不可见。
这篇写的是一个音乐类 App 从 Flutter 迁到原生 SwiftUI 的过程:两个 iOS 开发加 AI,两个半月,原生工程最后是十万行量级的 Swift。我负责发起这个工程、做了第一批搬运、首页对齐和后面两期功能迭代;同事承担了大量模块实现。文章按踩坑顺序写------第一版为什么"看起来完成",我们后来怎么把它变成一个可以持续推进的工程,以及 SwiftUI 本身在这个过程里埋的几个坑。
文中示例代码由生产代码简化而来,未逐行编译验证。涉及公司内部的数字做了模糊处理。
一、先把约束讲死
动手之前,这几条约束决定了后面所有的决策:
- 不是重设计,是迁移还原。 产品不接受"换个 iOS 风格"。UI 结构、资源、文案、业务状态都要从 Flutter 源码溯源,最终用户不应该感知到底层换了技术栈。
- Flutter 工程不是纯 Dart。
ios/Runner里已经有大量原生 Swift:网络层、签名、积分、IAP、AI 任务请求,全部通过MethodChannel暴露给 Dart。这部分是资产,但带着FlutterMethodChannel/FlutterPlugin的壳。 - 设计稿是 1080px 基准。 Flutter 用
ScreenUtil的.w/.h/.sp按比例换算;到 375pt 的 iOS 基准,换算系数约2.88。这个数字后面会反复出现。 - 目标架构定死: SwiftUI + MVVM + 一个
AppEnvironment做依赖注入 + 自己的AppRouter。View 不直接碰网络、存储、支付。 - 每一轮改动必须过 workspace 编译。 工程依赖 CocoaPods,
xcodebuild -workspace ... -scheme Waazy -destination 'generic/platform=iOS' build输出** BUILD SUCCEEDED **才算数。
第 5 条看起来是废话,但它是后面整套流程的地基------因为 AI 生成的代码,"编译通过"是唯一一个能自动判定的硬标准。其他所有标准都需要人来定义。
二、第一版为什么"看起来完成"
把第一版的问题归类之后,我发现它们不是随机的 bug,而是同一类错误的六种表现:AI 按"页面"迁移,而 Flutter 的很多行为不在页面里。
坑 1:只迁移了子页面,没迁移父级壳
症状:首页看起来对了,但底部 tab、迷你播放器、悬浮的 Create 按钮要么没有,要么长得不对。
根因 :Flutter 的 HomePage 视觉上依赖 MainPage------底部 tab bar、BannerPlayer、Create 浮层都是 MainPage 的 Stack 里的层,HomePage 只是其中一层的内容。AI 读了 home_page.dart,忠实地迁移了它,但没有人告诉它要去读 main_page.dart。
解法:迁移任何页面之前先画结构树,把父级壳和全局浮层画进去:
text
MainPage
├─ HomePage
│ ├─ SliverAppBar
│ ├─ 状态层: Skeleton / NetworkRetry / 内容区
│ ├─ AutoPageView banner
│ └─ scroll listener: header 透明度、底部 overlay 隐藏、回顶
├─ TextToMusicPage
└─ BannerPlayer(mini player,跨 tab 存在)
对应到 iOS 侧,MainShellView 持有一个 MainBottomOverlay,mini player、Create 按钮、tab bar 全在这一层,由 Shell 的 ViewModel 根据当前 tab 和播放状态决定显示什么。首页的滚动偏移通过回调上报给 Shell,由 Shell 决定是否收起底部栏------这个阈值在 Flutter 里是裸的 offset > 10,不走 /2.88 换算,照搬即可;而回顶按钮的出现阈值 1200.w 是要换算的。这种"哪些数字要换算、哪些不要"的判断,AI 第一版全部猜错了。
坑 2:服务端驱动的栏目变成了硬编码
症状:首页栏目名、顺序、每个栏目的卡片样式,Swift 里写死了。
根因 :Flutter 的素材包列表来自接口,样式由 index 和 musicPackId 分发到四种 widget。AI 看到 mock 数据和截图,直接把当时的那一份数据固化了。
解法:在结构树之外再加一张"数据驱动关系表"------每个栏目的标题、顺序、样式分别来自哪里(接口字段 / JSON / index 映射 / 本地常量)。这张表填不出来,就不许写 View。
坑 3:缓存型页面变成了每次进入都请求
症状:切 tab 回到首页,列表闪一下骨架屏,重新加载。
根因 :SwiftUI 的 .task 跟随视图生命周期触发,tab 切换导致视图重建时会再跑一次。Flutter 这边 HomeLogic 是 GetX controller,生命周期比页面长,列表在 controller 里缓存。两边"页面出现"这个事件的语义不一样。
解法 :ViewModel 持有加载状态,.task 只调 loadIfNeeded:
swift
@MainActor
final class HomeViewModel: ObservableObject {
@Published private(set) var musicPacks: [HomeMusicPack] = []
private var hasLoadedMaterial = false
private var isLoadingMaterial = false
func loadIfNeeded() async {
// 运营位、专区等独立请求各自启动,不阻塞主素材的状态机
loadOperationsBannerIfNeeded()
guard !hasLoadedMaterial, !isLoadingMaterial else { return }
await loadMaterial(isRefresh: false)
}
func refresh() async {
await loadMaterial(isRefresh: true)
}
}
swift
struct HomeView: View {
@StateObject private var viewModel: HomeViewModel
var body: some View {
content
.task {
await viewModel.loadIfNeeded()
}
}
}
这里有个值得记一笔的细节:后来对照 Flutter 源码发现,Flutter 首页的 getList 其实在某些路径上会强制刷新。我们最终决定保留 iOS 的缓存保护,作为一个"产品取舍"记进缺口台账,而不是机械对齐。迁移还原不等于复制每一个行为,但偏离必须是显式决策,不能是 AI 顺手的结果。
坑 4:自绘控件被系统控件替代
症状 :底部 tab 是系统 TabView,顶部出现了系统导航栏,列表卡片变成灰底圆角。
根因 :AI 的"默认审美"是 iOS 系统控件。Flutter 工程里的 tab bar 是 Container + Row + Image/Text 自绘的,带两个 Lottie 动效(AIMV 未选中态、Library 合成中态),滚动时整条收起。系统 TabView 做不到这些,一换就是视觉上的断崖。
解法:规则写死------除非 Flutter 原本就用系统控件,否则一律自绘。顶部导航栏全局隐藏(这件事本身又是一个坑,第四节讲)。
坑 5:尺寸按截图"调到像",而不是按设计稿换算
症状:几个图标大了一圈,间距丢了。
根因:AI 拿着截图和自己的实现对比,视觉上"差不多"就停了。但截图是压缩过的,而且不同设备截图的缩放不一样。
解法 :所有尺寸先按 Flutter 值 / 2.88 算出来,再微调:
swift
HStack(spacing: 8 / 2.88) {
Image("ic_create")
.resizable()
.frame(width: 55 / 2.88, height: 55 / 2.88)
Text(L10n.string("home_create"))
.font(WaazyFont.bold(39 / 2.88))
}
代码里直接留着 / 2.88 看起来不优雅,但它有一个实际好处:review 的时候能一眼对回 Flutter 源码里的 55.w。等全部对齐之后再收敛成设计 token,顺序不能反。
坑 6:只实现了一张截图对应的状态
症状:首屏对了;滚动后 header 不变透明;接口失败没有重试视图;播放中 mini player 不出现。
根因:截图只能表达一个瞬间。AI 对着一张截图实现,自然只有一个状态。
解法 :每张参考截图都要标注它对应的是哪个状态(首屏 / 滚动后 / 播放中 / 空数据 / 接口失败 / 不同的栏目顺序),迁移计划里要列出全部状态,缺的标 Deferred,不能当完成。
这六个坑的共同点

回头看,六个坑指向同一件事:AI 迁移的失败模式不是"写错",而是"看起来完成"。 编译通过、截图像、每个页面都在------这三个信号全部是正向的,但结构是偏的。
这意味着传统的验收手段在这里失效了。截图对比恰恰是最差的验收方式,因为它只能验证 AI 最擅长的那一层。真正需要验收的是:父级壳有没有、数据从哪来、状态有几个、尺寸怎么算的。这些都要在"写代码之前"就定义清楚,让 AI 对着定义做,而不是对着截图做。
三、把迁移变成一个可以推进的工程
认清上面的问题之后,我们停下来重建了流程。核心思路是:AI 没有跨会话的记忆,那就把记忆放到仓库里。
3.1 四类文档
原生工程里多了一个 Migration/ 目录,分四类:
结构图(Structure Map) :每个模块一份,左边 Flutter 结构树,右边 iOS 当前结构树,下面一张"公共能力抽取表",标注每个能力是 core-public / page-local / service-needed / resource-needed,当前状态 fixed / partial / blocked。这是解决坑 1 和坑 2 的工具。
迁移矩阵(Migration Matrix):资源、本地化、路由、依赖、SPM 各一张。资源矩阵的一行长这样:
text
Flutter 引用 | Flutter 源文件 | Flutter 资源路径 | iOS 目标位置 | iOS 名称 | 状态 | 备注
状态只允许 existing / migrated / converted / missing / pending-dependency。规则是:WebP 必须转 PNG 再进 Asset Catalog;缺资源标 pending-dependency,禁止用 SF Symbols 或纯色块顶替后"以后再换";Lottie 没有 renderer 之前不许用静态图冒充。这些禁令每一条都对应第一版里真实发生过的事。
缺口台账(Parity Gap Log):每轮对比发现的差异一行,必须写清 Flutter 来源文件、iOS 目标文件、缺口类型、优先级、依赖、状态。光首页一个模块,台账跑了一百多轮、记了近两百条,其中纯 UI 类占不到三分之一------剩下的是交互、状态、业务、资源、埋点。如果只看截图,三分之二的缺口根本不会被发现。
轮次记录(Round):每一轮一个文件,固定格式:目标、Flutter 来源、iOS 修改、已完成、Deferred、验证命令和结果、下一步。"Deferred" 这一节是整个流程里最重要的------它强制把"这轮没做的"显式写下来,而不是让它消失在"完成"里。
3.2 一轮只落一个切片
第二个规则:并行审计可以,并行写代码不行。
我们试过让多个 agent 同时迁移不同模块。结果是 project.pbxproj 被改坏了:
text
The project 'Waazy' is damaged and cannot be opened due to a parse error.
Dictionary missing value for key "01A5C350269B4CA215798054", expected '=' and found ","
几个 PBXGroup 缺了 isa 和 children,GoogleService-Info.plist 被注册了两次,objectVersion 还被写成了 CocoaPods 的 xcodeproj gem 不认识的 70。修这一个文件花了一整轮。
之后的协议固定为:子 agent 只做只读审计(分析 Flutter 源码、审计 iOS 现状、盘点资源、盘点本地化),输出结构化报告;主流程负责方案收敛、改代码、注册工程文件、跑编译、更新台账。高冲突文件------project.pbxproj、AppEnvironment.swift、Core 服务、资源目录、Localizable.strings------只允许主流程单线改。
一轮的循环是:

text
读台账里的 pending 缺口
→ 选一个最小切片
→ 对照 Flutter 源码改 iOS
→ 注册到 Xcode target(新文件只进 app target,不进 Tests)
→ workspace build,看到 BUILD SUCCEEDED
→ 更新 Round 文件 + 台账状态
→ 写下一轮建议
切片要多小?小到一轮能编译验证完。比如"TextToMusic 的处理中任务上限"是一轮,"上限按 sourceType 过滤"是下一轮。看起来慢,但每一轮都是可验证、可回滚、可交接的。
3.3 用 Empty 服务让缺口显式
第三个工具是 AppEnvironment 里的 Empty 实现。每个服务协议都有一个 Empty* 版本:
swift
protocol AudioProcessingServicing {
func highlightStartTime(for request: AudioHighlightDetectionRequest) async -> AudioHighlightDetectionResult?
}
/// 未接入真实实现前的占位:静默失败,让调用方走降级路径
final class EmptyAudioProcessingService: AudioProcessingServicing {
func highlightStartTime(for request: AudioHighlightDetectionRequest) async -> AudioHighlightDetectionResult? {
nil
}
}
swift
final class AppEnvironment: ObservableObject {
let audioProcessingService: AudioProcessingServicing
let pointsService: PointsServicing
let iapService: IAPServicing
// ...
static func makeDefault() -> AppEnvironment {
AppEnvironment(
audioProcessingService: AVFoundationAudioProcessingService(),
pointsService: EmptyPointsService(), // 积分还没迁,先用 Empty
iapService: EmptyIAPService()
)
}
}
好处有三个:第一,工程从第一天起就能编译、能跑,不用等所有服务迁完;第二,哪些能力还没接是在 makeDefault() 里一眼能看到的,不用翻代码找 // TODO;第三,Empty 实现的行为是"静默降级"而不是 fatalError,页面在缺依赖的情况下也能走到 UI 层做 parity 对比。
这和坑 6 是一体的:UI 先按全部状态实现,依赖用 Empty 顶着,缺口在台账里而不是在代码里假装。
四、SwiftUI 自己埋的三个坑
流程解决的是"AI 怎么迁"的问题。但这个过程里还有几个坑和 AI 无关,是 SwiftUI 在一个"全部自绘顶栏、底部浮层跨 tab 存在"的 App 里本身就会踩的。
坑 7:.navigationBarHidden(true) 对根栏不稳定
症状:进入首页时顶栏位置是对的;等接口数据回来、骨架屏切换成可滚动列表之后,整个顶栏往下掉了一截。
根因 :App 的导航根是 NavigationStack(iOS 16+)或 NavigationView(iOS 15),所有页面都是自绘顶栏,不需要系统导航栏。但 .navigationBarHidden(true) 在 iOS 16 已废弃,对 NavigationStack 的根栏不一定生效------表现就是系统在内容出现后插了一条空导航栏,把安全区顶部撑大,跟随安全区的自绘顶栏就跟着下移。
解法:按版本分发到正确的 API,iOS 15 还要再压一层:
swift
extension View {
@ViewBuilder
func appHiddenNavigationBar() -> some View {
if #available(iOS 16.0, *) {
self.toolbar(.hidden, for: .navigationBar)
} else {
// iOS 15:.inline 防止大标题区在内容出现后展开,再叠 hidden
self.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
}
}
}
iOS 15 的 NavigationView 上这还不够稳------骨架屏切列表的瞬间系统仍可能重新插栏。最后的兜底是一个零尺寸的 UIViewControllerRepresentable,抓到底层 UINavigationController,在 viewWillAppear 和每次布局回调里强制 setNavigationBarHidden(true)。纯 SwiftUI 修饰符压不死的东西,只能从 UIKit 层断言。
坑 8:在 body 里读窗口安全区,会让页面永远不再刷新
这是全文最反直觉的一个。
症状 :iOS 26.5 上首页停在骨架屏,数据明明回来了,@Published 变了,视图就是不刷新。iOS 17 上完全正常。
根因 :顶栏的 top padding 用了一个 CGFloat.safeTop() 的 helper,它第一次调用时会扫 connectedScenes、找 key window、读 window.safeAreaInsets,然后缓存。这个调用发生在 SwiftUI body 求值过程中------也就是 AttributeGraph 的事务内部。读窗口触发了一次布局,布局又触发了 body 重算,于是成环。控制台会打一行 AttributeGraph: cycle detected。
不同系统版本对成环的处理策略不一样。17.x 的断环策略刚好让视图还能工作;26.5 的策略是把这个视图的全部更新边永久丢弃 ------@Published、@State、onChange 从此全部失效。页面不崩,就是死在那里。
解法 :窗口读取移到事务外,也就是 onAppear:
swift
private struct WindowSafeTopPadding: ViewModifier {
// 初值只读静态缓存,不碰窗口;冷启动首帧为 0,在黑底启动背景上不可见
@State private var topInset: CGFloat = CGFloat.safeTopCachedOnly()
func body(content: Content) -> some View {
content
.padding(.top, topInset)
.onAppear {
// onAppear 在 AttributeGraph 求值事务之外,这里才允许真读窗口
topInset = CGFloat.safeTop()
}
}
}
还有一个更阴的细节:safeTop() 有静态缓存,谁第一个调用,谁做那次 live 的窗口读取。所以只修首页一处是不够的------修完之后"首个 live 读取"会平移到下一个 tab 的 body 里,bug 跟着搬家。必须全局禁止在任何 body 里直接调它。
这个坑之所以反直觉,是因为它违反了"bug 在哪里修哪里"的直觉。表现在 A 页面的问题,根因是一个全局静态缓存的首次触发时机,修 A 只会让它出现在 B。
坑 9:嵌套 NavigationStack + 类型化 path 会崩
症状 :MV tab 里新加了一个三级导航(分类墙 → See All → 播放器),push 的时候崩溃:AnyNavigationPath.comparisonTypeMismatch。
根因 :App 级别已经有一个 NavigationStack 承载 AppRoute 的类型化 path,MV tab 内部又建了一个 NavigationStack 用自己的类型化 path。两个 path 的类型不一致,SwiftUI 在比较的时候直接 mismatch。
解法 :我第一次修只改了 iOS 16 分支,把值类型 path 换成布尔驱动的 navigationDestination(isPresented:)------但嵌套的 NavigationStack 还在,没解决。第二次才彻底:MV tab 内部全版本改用 NavigationView(.stack) + NavigationLink(isActive:),不再嵌套第二个 NavigationStack:
swift
struct MusicVideoTabRootView: View {
@State private var isShowingSeeAll = false
@State private var isShowingPlayer = false
var body: some View {
NavigationView {
MVCategoryWallView(onSeeAll: { isShowingSeeAll = true })
.background(
NavigationLink(isActive: $isShowingSeeAll) {
MVCategorySeeAllView(onOpenPlayer: { isShowingPlayer = true })
.background(
NavigationLink(isActive: $isShowingPlayer) {
MVPlayerHostView()
} label: { EmptyView() }
)
} label: { EmptyView() }
)
}
.navigationViewStyle(.stack)
}
}
NavigationLink(isActive:) 是被标记废弃的 API,用它不光彩。但在"外层已经有类型化 path 的 NavigationStack"这个前提下,它是最不折腾的稳定解。等整个 App 的导航统一收敛到一个 path 再回来重构,比现在在 tab 里硬塞一个 NavigationStack 划算。
五、什么时候不该这么干
诚实地说,这套流程的成本很高------文档比代码多,每一轮只推进一小步。它值得,只是因为满足了几个条件:
- Flutter 工程里已经有大量原生 Swift 能力。 网络、签名、积分、IAP、AI 任务这些
ios/Runner里的东西可以剥掉MethodChannel壳直接复用,迁移的"新代码"主要是 UI 和状态层。如果你的 Flutter 工程是纯 Dart,迁移成本要翻倍,先想清楚为什么要迁。 - 产品要求的是"无感"。 如果产品接受借迁移做一次重设计,那坑 1 到坑 6 大部分不存在,直接按 iOS 的方式重做反而更快。
- 不是所有模块都值得重写。 工具类模块(录音、变声、合并、分割)在旧
Runner里是成熟的 UIKit 实现,我们没有把它们翻成 SwiftUI,而是做了一层RunnerPort------用UIViewControllerRepresentable把旧的UIViewController宿主进 SwiftUI tab,只处理底部浮层的避让。SwiftUI 版本的壳保留着能编译但不走路由。这不是技术债,这是明确的"这一块不迁"的决策,记在模块状态表里。 - 团队得有人能判断 parity。 整个流程的前提是有人读得懂 Flutter 源码、读得懂 SwiftUI 生成的代码、能判断"这两个等不等价"。AI 负责搬运和审计,判断不能外包。
如果你的场景是"一个简单的 Flutter App,团队没有 iOS 经验,想换个技术栈"------别学我,先评估要不要迁。
六、可迁移的几条原则
从这个项目里拉出来、和 Flutter 或 SwiftUI 无关的几条:
1. AI 产出的验收标准要定义在"结构"上,不是"外观"上。 截图像不像是 AI 最擅长的事,验收它等于没验收。父级壳有没有、数据从哪来、状态有几个、尺寸怎么算------这些要在写代码之前写成清单。
2. 文档是 agent 的外部记忆,格式要固定。 结构图、矩阵、缺口台账、轮次记录各管一件事。"Deferred" 必须是显式的一节。没有这些,每一次新会话都是从零开始,而且会自信地把上次的 deferred 当成不存在。
3. 并行审计,串行写入。 只读的分析可以开多个 agent 跑,写代码的只能有一个主流程,高冲突文件只能单线改。一个 pbxproj 事故足够让你记住这条。
4. 用 Empty 实现让"没做"可编译、可见、可降级。 比 // TODO 好,比 fatalError 好,比假数据好。
5. 偏离原行为可以,但必须是显式决策。 iOS 保留缓存保护而不是照抄 Flutter 的强制刷新,这是一条写进台账的产品取舍。AI 顺手做的偏离和人决定的偏离,代码上看起来一样,性质完全不同。
6. 修 bug 先问"它为什么出现在这里"。 第 8 个坑如果只修首页,它会搬到下一个 tab。表现位置不等于根因位置,静态缓存和首次触发时机尤其如此。
两个半月之后,这个原生工程上线了,之后的版本迭代(分类曝光、运营活动配置)已经完全在原生工程里走正常的需求流程。回头看,最大的收获不是"Flutter 怎么迁 SwiftUI",而是搞清楚了 AI 在大工程里的失败模式长什么样,以及怎么用工程手段把它框住。