一套代码搬上手机
从网页到手机端的代码迁移
系列上线后,一个朋友问我:"手机上能用吗?"
她不是说"用手机浏览器打开网页"。她的意思是:点桌面图标,从相册选张照片,生成完直接分享到朋友圈。全程不跳出去。
我说用网页版也行。她说好的回头试试。
然后就没有然后了。
手机不是加分项,是必选项
其实我一直知道这件事。第一篇里写过,É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——它只发一个 museCardId 和 outputStyle 给 API,剩下全是服务器的事。整个 prompt 流水线、VANITY_DESIGN_INSTRUCTIONS、卡片定义,全在服务端。手机白嫖。
API 请求/响应接口:~95%。 手机的 lib/types.ts 就是网页版 types/generation.ts、types/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 接口设计、状态流动方向。手机有 CardPicker、RefImagePicker、StyleToggle、PhotoCountSlider,名字一样,职责一样,渲染代码完全不同。
导航:~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 的。服务端逐张发送事件——started、photo_completed、photo_failed、completed。网页版用 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-picker、expo-media-library、expo-sharing、expo-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.