Flutter 迁 SwiftUI:AI 把页面"搬"过来只用了两周,我们补结构花了两个月

第一版迁移完成得很快。Flutter 工程交给 AI,一个页面一个页面地"翻译"成 SwiftUI,两周左右,工程编译通过,每个页面都有了,截图和 Flutter 版放在一起几乎看不出差别。

装到手机上之后,问题一个一个冒出来:

  • 首页切到别的 tab 再切回来,列表重新请求了一遍。Flutter 版不会。
  • 首页的栏目名和顺序是写死在 Swift 里的。Flutter 版是服务端下发的。
  • 底部 tab 用的是系统 TabView。Flutter 版是自绘的,带 Lottie 动效,滚动时会收起。
  • 有几个图标明显比 Flutter 版大一圈------AI 是照着截图"调到看起来一样",不是按设计稿换算的。
  • 首页只实现了"有数据"这一种状态。骨架屏、空态、接口失败、播放中,一个都没有。

每一条单独看都是小问题。合在一起,它意味着这个"完成"的工程其实只是一层壳:结构是偏的,数据流是错的,状态是缺的。而最麻烦的是,这些问题在编译期和截图对比里全部不可见。

这篇写的是一个音乐类 App 从 Flutter 迁到原生 SwiftUI 的过程:两个 iOS 开发加 AI,两个半月,原生工程最后是十万行量级的 Swift。我负责发起这个工程、做了第一批搬运、首页对齐和后面两期功能迭代;同事承担了大量模块实现。文章按踩坑顺序写------第一版为什么"看起来完成",我们后来怎么把它变成一个可以持续推进的工程,以及 SwiftUI 本身在这个过程里埋的几个坑。

文中示例代码由生产代码简化而来,未逐行编译验证。涉及公司内部的数字做了模糊处理。


一、先把约束讲死

动手之前,这几条约束决定了后面所有的决策:

  1. 不是重设计,是迁移还原。 产品不接受"换个 iOS 风格"。UI 结构、资源、文案、业务状态都要从 Flutter 源码溯源,最终用户不应该感知到底层换了技术栈。
  2. Flutter 工程不是纯 Dart。 ios/Runner 里已经有大量原生 Swift:网络层、签名、积分、IAP、AI 任务请求,全部通过 MethodChannel 暴露给 Dart。这部分是资产,但带着 FlutterMethodChannel / FlutterPlugin 的壳。
  3. 设计稿是 1080px 基准。 Flutter 用 ScreenUtil 的 .w/.h/.sp 按比例换算;到 375pt 的 iOS 基准,换算系数约 2.88。这个数字后面会反复出现。
  4. 目标架构定死: SwiftUI + MVVM + 一个 AppEnvironment 做依赖注入 + 自己的 AppRouter。View 不直接碰网络、存储、支付。
  5. 每一轮改动必须过 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。

症状 :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 在大工程里的失败模式长什么样,以及怎么用工程手段把它框住。

相关推荐
茶底世界之下5 小时前
四角变换后的最小包围视口,为什么不能只把宽高取整?
ios·swift
茶底世界之下5 小时前
羽毛颜色已经很鲜艳,还要继续加色吗?用 Awaking 试一次减饱和
前端·ios·运营
天神哥哥啊8 小时前
cocos联调注意事项-IOS
ios·cocoa·xcode
终端安全笔记8 小时前
配置锁状态那一栏查的是什么:报告里几项各自指向什么风险
android·前端·安全·ios
Carson带你学Android9 小时前
OpenAI Dots 登陆手机端:手机上派任务,AI 在云端写代码
android·ios
老李IT笔记21 小时前
设备指纹由哪几个字段拼出来:换一块主板会变成新设备
ios·智能手机
andyweike1 天前
iOS App 测试
ios·测试·ad hoc·分发
茶底世界之下1 天前
预乘 Alpha 图层合成为什么会越叠越暗?
架构·swift
茶底世界之下1 天前
花瓣要更柔和,还是更有反差?用 Awaking 做一次对比度取舍
ios