ENZH

一套代码搬上手机

从网页到手机端的代码迁移从网页到手机端的代码迁移

系列上线后,一个朋友问我:"手机上能用吗?"

她不是说"用手机浏览器打开网页"。她的意思是:点桌面图标,从相册选张照片,生成完直接分享到朋友圈。全程不跳出去。

我说用网页版也行。她说好的回头试试。

然后就没有然后了。


手机不是加分项,是必选项

其实我一直知道这件事。第一篇里写过,ÉLAN 的目标用户是 18-35 岁、想要零成本美照的女性。

她们的全部流程在手机上:拍照在手机上,修图在手机上,发小红书在手机上,发朋友圈在手机上。

网页版再怎么打磨,也是一个绕路。你得打开浏览器,找到网址,上传照片(iOS Safari 的上传体验你懂的),生成完了再长按保存图片,再切到微信去发。

每多一步,转化率就掉一截。

所以问题不是"要不要做手机版",而是"为什么还没做"。


PWA?在 iOS 上基本是废的

PWA 对内容消费类产品够用了。但对任何需要碰相册、调用分享、本地存储的产品,PWA 在 iOS 上基本是废的。

ÉLAN 的核心循环是:从相册选照片 → 生成结果 → 存到相册 → 分享到微信。

这四步,PWA 每一步都比原生差。相册访问受限,存图只能长按保存,分享菜单不存在,推送通知勉强能用。

说白了,PWA 不是不能用,是体验差到用户会放弃。


三条路,选哪条

我已经有一个 Next.js 网页版,用 Tailwind 做样式,Zustand 管状态,SSE 流式做图片生成。问题是:怎么最快做到手机端,同时最大化复用现有代码?

Flutter。 跨平台,性能好,生态成熟。但问题是——跟我现有的 TypeScript 代码库零复用。等于用 Dart 重写一遍。这不是"扩展",是"另起炉灶"。

原生(Swift + Kotlin)。 最好的性能,最好的平台集成。但一个人维护三套代码库?想想就头大。

React Native + Expo。 同一种语言(TypeScript),同一个状态管理库(Zustand),同样的组件思维。Expo SDK 55 已经很成熟了——文件系统、图片选择、相册访问、分享功能,全都内置。赌注是:业务逻辑能复用,只重写 UI 层。

我选了第三条路。


项目结构:monorepo?算了

我评估过标准的 monorepo 拆分——packages/logic/packages/presets/packages/types/。最后放弃了。

原因很简单:目前只有一个消费者(手机 App 调网页版的 API)。为了这一个消费者去维护跨包导入、tsconfig alias、workspace 配置,投入产出不成比例。

实际结构更直接:

ai-daipai/
  src/              ← Next.js 网页版
    stores/         ← Zustand 状态(Web)
    types/          ← TypeScript 类型(Web)
    lib/            ← 业务逻辑、灵感卡、预设
  mobile/           ← Expo 手机版(独立目录)
    src/
      app/          ← Expo Router 页面
      stores/       ← Zustand 状态(Mobile,适配版)
      lib/          ← API 客户端、类型、主题、图片缓存
      components/   ← React Native 组件
      hooks/        ← 自定义 hooks

手机版通过 API 跟网页版通信。不直接 import src/ 的代码。类型定义和 store 模式是手动复制过来的——不优雅,但实用。等真的有第二个移动端平台或者需要 SDK 的时候,再拆不迟。


到底复用了多少——说实话

说实话,我见过太多"我们做到了 90% 代码复用!"的说法,其中一半把复制粘贴的类型定义也算进去了。

这是我的真实数据。

几乎全部复用(90-100%)

Prompt 构造和灵感卡数据:100%。 这些全在服务端。手机 App 不构造 prompt——它只发一个 museCardIdoutputStyle 给 API,剩下全是服务器的事。整个 prompt 流水线、VANITY_DESIGN_INSTRUCTIONS、卡片定义,全在服务端。手机白嫖。

API 请求/响应接口:~95%。 手机的 lib/types.ts 就是网页版 types/generation.tstypes/upload.ts 的镜像。同样的接口,同样的字段名,同样的枚举值。我花了大概半小时仔细复制过来,加了更严格的 readonly 标注。

Zustand store 模式:~85%。 网页有 creation-store.ts,手机有 generation-store.ts。概念一样——步骤追踪、参考图管理、生成状态。但实现有差异,因为手机版的 store 内部集成了"上传完再生成"的流程(网页版把上传委托给了独立组件)。状态形状相似,action 相似,但代码不能直接复制粘贴。

部分复用(40-50%)

UI 组件:~40%。 这是"一套代码"的梦想撞上现实的地方。

网页用 <div><button>className、CSS Grid、Tailwind 工具类。手机用 <View><Pressable>StyleSheet.create()、纯 Flexbox。一行 JSX 都没法共享。

能复用的是结构——组件拆分方式、props 接口设计、状态流动方向。手机有 CardPickerRefImagePickerStyleTogglePhotoCountSlider,名字一样,职责一样,渲染代码完全不同。

导航:~30%。 网页用 Next.js 路由。手机用 Expo Router 的文件路由 + Tab 布局。概念对得上——创作页、结果页、个人页——但机制完全不同。

完全没法复用

样式。 网页:Tailwind。手机:StyleSheet.create() + 自定义主题系统。我考虑过 NativeWind(React Native 版 Tailwind),最终没用。手机端需要精确控制触摸目标尺寸(44px 底线)、安全区域适配、平台差异的阴影——用显式 StyleSheet 比工具类抽象更可控。

登录流程。 网页用 httpOnly cookie,服务端设置。React Native 没法直接用 httpOnly cookie。手机版同时发 Cookie header 和 x-invite-code header,后端两个都检查。改动不大,但不是显而易见的坑。


SSE 流式传输:卡了整整一天的坑

这是手机版开发中唯一让我卡了一整天的问题。

ÉLAN 的生成流程是基于 SSE 的。服务端逐张发送事件——startedphoto_completedphoto_failedcompleted。网页版用 fetch API 的 ReadableStream 解析响应体。标准、干净、到处能跑。

React Native 上呢?fetch 返回的 Response,它的 body(ReadableStream)在 iOS 上是 null

不是报错,不是缺失——运行时直接不支持流式响应体。这是 React Native 一个存在多年的已知限制。

我试了三条路:

react-native-sse EventSource 的 polyfill 库。但我们的 API 用 POST + JSON body(不是 GET),EventSource 规范只支持 GET。要改后端协议才行。

react-native-fetch-api 支持流式的 fetch polyfill。但依赖原生模块补丁,跟 Expo SDK 55 的新架构有兼容问题。

XMLHttpRequest 全世界最老的 API。但它有个特性:onprogress 事件会随着数据到达增量触发,xhr.responseText 会累积完整响应。通过渐进式文本累积,你能得到流式效果。

最后我用了 XHR,自己写了一个 SSE 解析器:

xhr.onprogress = () => {
  const newData = xhr.responseText.substring(lastIndex);
  lastIndex = xhr.responseText.length;
  const events = parser.feed(newData);
  for (const event of events) {
    onEvent(event);
  }
};

解析器的核心逻辑:按双换行符(SSE 协议分隔符)切分,只处理完整消息,不完整的留在缓冲区等下一次 onprogress

老派但靠谱。不依赖原生模块,不需要 polyfill,iOS 和 Android 都能跑。超时设了 10 分钟,够生成 8-9 张高清照片。


同一个流程,完全不同的手感

网页版的三步流程(选张美照 → 选个灵感 → 光影创作)直接映射到手机创作页——但交互方式完全变了。

选照片。 网页:拖拽上传区。手机:expo-image-picker 调用相册,紧凑的预览行显示正脸和可选的身材照位。

选灵感卡。 网页:响应式网格 + hover 预览。手机:可滚动卡片网格,点击选择,长按弹出预览弹窗。弹窗从底部滑上来,显示完整的场景描述、服饰、氛围、示例图——桌面上 hover 能看到的信息。

灵感匹配。 两个平台都有:上传一张照片(比如小红书截图),AI 分析风格自动匹配最合适的灵感卡,给出匹配百分比。手机上匹配完会自动选中卡片,卡片选择器折叠成一行摘要。少一次点击。

结果页。 网页:照片网格 + 下载按钮。手机:双列网格 + 骨架屏 shimmer 动画(用 react-native-reanimated),生成中轮换显示等待文案——"好照片值得等一等""光正在寻找最好的角度""你的光,刚刚好"。完成后:保存到相册、通过系统分享菜单分享、或者"换灵感"返回重新生成但保留参考照片。

图片持久化。 这是网页版没有的东西。手机上,生成完成后照片自动缓存到设备本地文件系统。服务器 2 小时后删除 blob URL,但本地副本还在。个人页有缓存管理——显示总大小,一键清除。


暗色模式:白送的

我没刻意做暗色模式。但 Expo SDK 55 的 userInterfaceStyle: "automatic" 加上 React Navigation 的 ThemeProvider,基本是白送的。

主题系统就是一张查找表:亮色模式用暖奶油底色 + 暖炭灰文字,暗色模式用深棕底色 + 浅奶油文字。香槟金强调色两个模式都一样——品牌锚点不能变。

每个组件从 useTheme() hook 读颜色,直接往 style 对象上贴。不用条件 className,不用 CSS 变量。

整个暗色模式大概花了 2 小时。网页版用 Tailwind 的 dark: 前缀做暗色模式反而花了更久。


哪些比想象的简单,哪些比想象的难

比想象中简单的事:

Expo 的原生 API。 expo-image-pickerexpo-media-libraryexpo-sharingexpo-file-system——装上就能用。不用 link 原生模块,不用调试 pod install,不用碰 Xcode 工程文件。从零到"生成的 AI 照片存到相册并分享到微信",一个下午搞定。

Zustand 在 React Native 上。 API 完全一样。不需要适配器。Store 形状不同(因为手机有 local URI vs blob URL、上传阶段追踪等特有问题),但 Zustand 的 create() 模式原封不动地能用。

EAS Build。 Expo Application Services 在云端构建 iOS 和 Android。推代码,跑 eas build,出 .ipa.apk,全程没碰 Xcode 和 Android Studio。第一次构建 15 分钟,后面更快。

比想象中难的事:

SSE 流式传输。 上面详细写了。React Native 的 ReadableStream 缺失让我花了整整一天研究和试错,才找到 XHR 方案。

触摸目标。 Apple HIG 要求最小 44px。网页组件的 py-1.5 大概只有 28px 高。每个可交互元素都要调——文案风格选择器、平台切换标签、分类过滤器。不难,但很烦,每个组件都要过一遍。

安全区域。 iPhone 的 Home 指示条、状态栏、刘海——都会吃掉你的布局。每个页面都要 SafeAreaView 或者手动 useSafeAreaInsets() 加 padding。结果页底部的操作栏需要 paddingBottom: insets.bottom,不然按钮会被 Home 指示条挡住。小事,但忘一次就有 bug。


数字

既然是造灵颜系列的技术日志,放一些真实数据:

  • 网页代码量: ~12,000 行(Next.js + Tailwind + API 路由)
  • 手机代码量: ~4,200 行(Expo + React Native)
  • 比例: 手机约为网页的 35%
  • 手机 MVP 用时: 2 周(包括 SSE 踩坑的那一天)
  • 引用复用: 全部服务端逻辑(prompt、卡片数据、生成流水线、文案生成)
  • 模式复用: Zustand store、API 契约、类型定义
  • 从头重写: UI 组件、导航、样式、登录流程

35% 的代码量比例说明了一件事:手机端之所以简单,是因为所有复杂度都在服务端。手机 App 就是一个薄客户端——上传照片,选个卡,看结果。

prompt 构造、Gemini API 调用、人脸漂移质检、成本追踪——全在服务端,全是白嫖。


重来的话我会怎么做

先做手机版。 我先做网页版是因为迭代更快。但手机版才是更好的产品——相册集成、系统分享、自动缓存、原生体验。如果今天从头开始,我会先做 Expo,再加一个网页管理后台。

第一天就拆共享类型包。 src/types/mobile/src/lib/types.ts 之间的手动复制,在这个规模(6 个类型文件)还能忍。但每次 API 改动都要改两个地方。packages/types/ 的 workspace 拆分大概第二周就能回本。

这就是从网页到手机的全程记录。架构上最大的收获:把智能放在服务端,客户端只是窗口。新功能上线的时候,网页和手机同时生效,因为改的是后端,不是前端。


This post is also available in English.


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0