NextJS App 改造原生 App 的完美指南

Geekits 是我开发了五年多的开源项目,也是我维护最久的开源项目。在经过不断的迭代之后,在 Next 技术栈稳定了下来。

本文将介绍我如何把一个纯 Web App 改造为优雅的原生 App,让这个产品支持多终端运行。

其实改造要做的工作不仅仅是把 App 放入 Webview,而是要移除原生app不需要的功能,用原生 API 替换某些 JS 实现的功能,样式的适配等等。

为什么要做原生 App

我个人并不完全赞同“Web 才是未来”的说法。但我很喜欢 Local first 的 web app,所以即使在开发 Web App 时,我都尽力让 Geekits 离线可用。

而迁移到原生让 local first 的实现更加方便,资源加载更快,大的数据也无需联网加载(例如成语查询功能)。

不仅如此,可操作的窗口尺寸也更大了,不用担心标签页会被不小心关掉,响应速度也更快。

最后,系统提供了更多浏览器不便获取(或者版本受限)的信息,例如加速度传感器,电池信息等。

Step 1 - 安装配置 Capacitor

得益于 Capacitor 不错的 DX,一切顺利的话,你只需要几分钟即可得到一个“能运行的”原生版本。

首先,安装 Capacitor:

1npm i @capacitor/core
2npm i -D @capacitor/cli
3npx cap init
4npm i @capacitor/android @capacitor/ios

修改配置文件 capacitor.config.js :

1import { CapacitorConfig } from "@capacitor/cli";
2
3const config: CapacitorConfig = {
4	appId: "com.ygeeker.geekits",
5	appName: "Geekits",
6	webDir: "out",
7	server: {
8		androidScheme: "https",
9		errorPath: "/500.html",
10	},
11};
12
13export default config;

如果你的 Next APP 一直是纯静态导出,那么恭喜你,大功告成。运行此命令可将你的 Next App 编译为静态文件:

1next export

接着将编译出的代码同步到 Capacitor:

1npx cap sync

大功告成!

但如果你的 Next App 使用了混合构建模式(例如使用了服务端功能),你可能无法直接执行 next export。你需要将一些依赖服务器的逻辑移动到客户端处理。例如:

  • 客户端使用 device API 获取 locale(而不是 Next 的 Locale API)
  • 客户端使用 hook 获取数据(而不是从 getServerProps 等方法)

从客户端获取 Locale 的示例如下:

1async function getDeviceLanguage() {
2	let { value } = await Device.getLanguageCode();
3
4	if (value === "en") {
5		value = "en-US";
6	}
7	if (value === "zh") {
8		value = "zh";
9	}
10
11	return value;
12}
13
14function MainApp({ Component, pageProps }: AppProps) {
15	// The auto-detected locale from NextJS
16	// If user has no preferred locale, use auto
17	const [preferredLocale, setPreferredLocale] = useState(pageProps.locale);
18
19	useEffect(() => {
20		const readLocaleConfig = async () => {
21			// https://en.wikipedia.org/wiki/IETF_language_tag
22			let preferredSet = localStorage.getItem("locale");
23			if (preferredSet) {
24				if (preferredSet === "auto" && !isWeb()) {
25					setPreferredLocale(await getDeviceLanguage());
26				} else if (preferredSet !== "auto") {
27					// If user has spicified the language
28					setPreferredLocale(preferredSet);
29				}
30			}
31		};
32
33		readLocaleConfig();
34	}, []);
35	
36}

Step 2 - 添加屏幕安全区域

至此我们已经拥有一个基本的原生 App,但仍有很多小问题需要适配。

不同于浏览器环境,移动设备异形屏会影响显示,例如刘海和打孔。为了避免出血,可以在全局 CSS 文件加如下全局变量:

1html {
2	--ion-safe-area-top: env(safe-area-inset-top);
3	--ion-safe-area-bottom: env(safe-area-inset-bottom);
4	--ion-safe-area-left: env(safe-area-inset-left);
5	--ion-safe-area-right: env(safe-area-inset-right);
6}

然后在需要预留 Bleeding Area 的地方使用该属性,例如 Appbar(头部):

1padding-top: var(--ion-safe-area-top),

Step 3 - 运行时条件处理

尽管 Capacitor 提供了检测运行平台的 API,但在 Next 静态导出的过程中,其API总是会检测为浏览器环境。

例如,有时候我们希望某些 React 组件仅在原生平台中出现,在导出时就需要告知 Next 我们是否在为原生平台打包。

为了在 Next 导出过程中检测指定打包平台,我们可以手动添加一个环境变量指定导出目标。方便起见,你可以在 package.json 中写入以下脚本:

1"scripts": {
2		"build": "next build",
3		"build:cap": "CAPACITOR_BUILD=true next build && CAPACITOR_BUILD=true next export && npx cap sync",
4		"dev:cap": "CAPACITOR_BUILD=true next",
5 },

接着封装一个方法检测打包平台:

1const isCapacitor = () => process.env.CAPACITOR_BUILD === "true";

Step 4 - 添加原生图标

Capacitor 并不会为你生成各种尺寸的原生 App 图标,你需要自己手动制作替换。

你只需准备一张 1024 * 1024 的图标原图,接着你可以利用这个工具一键生成 iOS 和 Android 平台的图标。

将下载下来的文件替换项目中的文件即可。

P.S. 此工具无法生成 Adaptive Icon,你需要自己制作前景和背景。

后续流程

Capacitor 的功能远不止本文所写,你可以从官方文档探索更多可能。