指南
快速开始
nsh 的主要用途是在原生应用里承载模块(见简介)。本指南沿着这条路线走:编写一个模块,借助热重载开发,在你的 iOS 或 Android 应用里显示它,再以签名包下发一次更新。前提是已经安装 nsh 并加入 PATH(安装);在源码仓库中请改用 ./target/debug/nsh。
import { mount, signal, Text, View } from "nsh";
// The app `nsh init` creates, with a reset button and a short history.
function App() {
const count = signal(0);
const history = signal<number[]>([]);
const change = (delta: number) => {
const next = count() + delta;
count.set(next);
history.update((h) => [next, ...h].slice(0, 4));
};
return (
<View style={{ flex: 1, backgroundColor: "#f6f8fa", padding: 20, paddingTop: 24, alignItems: "center", justifyContent: "center", gap: 16 }}>
<Text style={{ fontSize: 15, color: "#6e7781" }}>You tapped</Text>
<Text style={{ fontSize: 56, fontWeight: "700", color: count() < 0 ? "#cf222e" : "#1f2328" }}>{count()}</Text>
<View style={{ flexDirection: "row", gap: 12 }}>
<View
onPress={() => change(-1)}
activeOpacity={0.6}
accessibilityRole="button"
style={{ paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, backgroundColor: "#ffffff", borderWidth: 1, borderColor: "#d0d7de" }}
>
<Text style={{ color: "#1f2328", fontSize: 17 }}>−1</Text>
</View>
<View
onPress={() => change(1)}
activeOpacity={0.6}
accessibilityRole="button"
style={{ paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, backgroundColor: "#0969da" }}
>
<Text style={{ color: "white", fontSize: 17 }}>Tap</Text>
</View>
</View>
<If when={history().length > 0}>
<Text style={{ fontSize: 13, color: "#6e7781" }}>Recent: {history().join(", ")}</Text>
</If>
<View onPress={() => (count.set(0), history.set([]))} hitSlop={8}>
<Text style={{ fontSize: 15, color: "#0969da" }}>Reset</Text>
</View>
</View>
);
}
mount(App);#创建模块
nsh init my-app
模块就是宿主应用加载的东西:一个或多个带入口的 .tsx 文件。nsh init 会在目录不存在时创建它,并写入:
| 文件 | 作用 |
|---|---|
App.tsx | 入口模块:用 mount 渲染应用 |
nsh.json | 应用的名称、id、版本和构建号(项目结构) |
tsconfig.json | TypeScript 设置,把 nsh 解析到 .nsh/types 中的声明 |
.gitignore | 忽略 .nsh/ 和 out/ |
.nsh/types/ | 生成的所有 nsh 模块的声明,供编辑器使用 |
显示名称默认是目录名;可以用 --name "My App" 指定其他名称。id 会是 com.example.<name>:发布前请在 nsh.json 中修改。已存在的文件会被保留,所以你可以在已经有部分文件的目录里运行 nsh init。
#入口模块
import { mount, signal, Text, View } from "nsh";
import { SafeAreaView } from "nsh/ui/mobile";
function App() {
const count = signal(0);
return (
<SafeAreaView style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}>
<Text style={{ fontSize: 28, fontWeight: "600" }}>{count()}</Text>
<View
onPress={() => count.update((c) => c + 1)}
activeOpacity={0.6}
accessibilityRole="button"
style={{ paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, backgroundColor: "#0969da" }}
>
<Text style={{ color: "white", fontSize: 17 }}>Tap</Text>
</View>
</SafeAreaView>
);
}
mount(App);
逐行来看:
import { mount, signal, Text, View } from "nsh":nsh是核心模块,提供 signals、mount、其他响应式原语,以及宿主元素View和Text。它由原生运行时提供,不会被打包。import { SafeAreaView } from "nsh/ui/mobile":移动端 UI 组件集。SafeAreaView会为内容加上内边距,使其避开刘海、状态栏和 Home 指示条。function App()是一个组件:返回 JSX 的函数。它只运行一次。signal(0)创建一个会随时间变化的值。{count()}在 JSX 中读取 signal。编译器看到这是一次调用,就让这段文本跟随该 signal:count变化时,运行时只更新这一段文本,其他什么都不动。<View onPress>是一个响应按压的原生视图;activeOpacity让它在按下时以原生方式变淡。View、Text、Image、Scroll、Input等宿主元素和其他组件一样从 nsh 的模块导入;只有If和Each无需导入。style={{ … }}接受由 camelCase 样式属性组成的对象,数字的单位是点。布局是 flexbox,子元素默认按列排列(布局)。mount(App)把组件渲染到应用的界面上。
#开发
#运行
nsh run 会启动开发服务器、构建应用、启动加载该应用的开发宿主,并在你编辑时持续重新构建。应用的日志(console.log、错误)会显示在终端中。
#Mac Catalyst(默认)
nsh run my-app/App.tsx
应用会在 Mac 上一个 iPhone 尺寸(390×844 点)的窗口中打开。这是最快的开发循环:不需要启动模拟器。它和 iOS 一样使用 UIKit,所以看到的效果就是 iPhone 上的效果,只有 Mac 特有的行为除外,例如用鼠标滚轮滚动。
#iOS 模拟器
nsh run my-app/App.tsx --target ios
nsh run my-app/App.tsx --target ios --device "iPhone 16 Pro"
nsh 会使用已启动的模拟器;如果没有正在运行的模拟器,就启动最新的 iPhone,并把开发宿主安装进去。--device 按名称或 UDID 选择模拟器。
#Android
nsh run my-app/App.tsx --target android
先启动模拟器或连接设备(adb devices 会列出它们;nsh 使用第一个)。nsh 会安装一个开发宿主 APK(只在运行时变化时重新构建),并通过 adb reverse 把开发服务器的端口转发到设备上,因此通过 USB 即可工作,无需共享网络。
#用热重载编辑
保持 nsh run 运行,修改 App.tsx 中的按钮文字:
<Text style={{ color: "white", fontSize: 17 }}>Add one</Text>
保存。CLI 重新构建并发送补丁;宿主重新运行变化的模块,并只重新渲染代码有变化的组件。这里变化的是 App,它持有 count signal,所以计数会归零。当你编辑的组件并不持有状态时,状态会被保留。把按钮移到单独的组件里:
function TapButton(props: { onPress: () => void }) {
return (
<View onPress={props.onPress} activeOpacity={0.6} style={{ padding: 12, borderRadius: 10, backgroundColor: "#0969da" }}>
<Text style={{ color: "white", fontSize: 17 }}>Tap</Text>
</View>
);
}
在 App 中使用 <TapButton onPress={() => count.update((c) => c + 1)} />,点几下,然后修改 TapButton 的颜色:按钮会更新,计数保持不变。
热替换以顶层组件为单位:名称首字母大写、函数体中包含 JSX 的函数。组件在自身代码变化时,或模块中组件之外的代码(它用到的常量、辅助函数)变化时重新渲染。如果某次编辑无法原地应用(例如导入了运行中的应用尚未导入的 nsh 模块,或修改了 nsh.json 中的 capabilities),宿主会重新加载整个应用。
#错误
构建错误会打印在终端中,并在设备上以红色浮层显示文件、行号和列号;应用继续运行上一次成功的构建。下次构建成功后浮层会消失(也可以点击提前关闭)。运行时抛出的异常以同样方式显示,调用栈会映射回你的 .tsx 源码。
#承载到你的应用里
应用用一个专门的视图显示模块:iOS 上是 NshAppView(一个 UIView),Android 上是 NshView(一个 Android View)。像其他视图一样摆放即可,可以放在任何页面,数量不限。开发时让它连接开发服务器;发布时加载构建好的 bundle 或下载的包。
#iOS
在 Debug 配置中链接 NativeShellDev.xcframework,在 Release 中链接 NativeShell.xcframework(在 iOS 应用中嵌入),然后:
import NativeShell
let app = NshAppView()
app.onError = { message in print("nsh error: \(message)") }
view.addSubview(app) // 再像普通视图一样设置约束
#if DEBUG
try app.connectDevServer(URL(string: "ws://127.0.0.1:8790/ws")!) // 在 Mac 上运行 `nsh dev my-app`
#else
try app.loadBundle(at: Bundle.main.url(forResource: "bundle", withExtension: "qbc", subdirectory: "nsh")!)
#endif
nsh dev my-app/App.tsx 把模块提供给应用,并在每次保存后推送更新,就像 nsh run 对它自带的宿主所做的那样。发布版用 nsh build my-app/App.tsx --release --out MyApp/nsh 把 bundle 写进一个文件夹,再把它加入应用的资源。
#Android
调试版添加 nsh-dev.aar,发布版添加 nsh.aar(在 Android 应用中嵌入),然后:
val nsh = NshView(this)
nsh.setErrorListener { message -> Log.e("nsh", message) }
setContentView(nsh) // 或者加入任意布局
if (BuildConfig.DEBUG) {
nsh.connectDevServer("ws://127.0.0.1:8790/ws") // 配合 `adb reverse tcp:8790 tcp:8790`
} else {
nsh.loadAssetBundle("nsh") // assets/nsh,由 `nsh build my-app/App.tsx --release --out app/src/main/assets/nsh` 生成
}
加载之前,由宿主决定模块能做什么:grantCapabilities 设置它能访问的网络主机和其他能力,各项限制约束它的内存、调用时长和节点数(沙箱与能力)。要让模块使用你自己的原生视图(地图、图表、设计系统组件),把它们注册为原生组件。
#以签名包下发更新
应用在发布之后下载的模块,以签名包的形式提供。先创建一个根密钥并离线保管,用它认证一个发布密钥,然后发布:
nsh keygen keys/root && nsh keygen keys/release
nsh certify --root keys/root.secret --key keys/release.pub --apps 'com.example.*' -o keys/release.cert
nsh publish my-app/App.tsx --key keys/release.secret --cert keys/release.cert --build 2
应用信任根公钥,并加载下载到的包;nsh 在运行任何代码之前校验签名、证书和吊销列表:
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let trust = try NshTrust(stateDirectory: support.appendingPathComponent("nsh"))
try trust.addRoot(rootPublicKey) // keys/root.pub 的内容
let info = try await app.loadPackage(at: downloadedFile, trust: trust)
Android 上用 NshTrust 和 NshPackage,流程相同。承载、下发和吊销的细节见签名包。
#或者作为独立应用发布
同一个模块也可以组成一个完整的应用。nsh package 用预编译的运行时把它打成可安装的应用,不需要你自己的 Xcode 工程或 Gradle 构建:
nsh package my-app --target ios-simulator --run
nsh package my-app --target android
这是受支持的用法,也是把模块装到设备上最快的方式,但 nsh 的设计出发点是在原生应用中承载模块。选项、签名和图标见打包。
#在终端中试运行
nsh trace 以无界面方式(不需要设备)运行应用,打印它产生的原生视图操作及耗时,然后点击第一个可按压的视图两次:
nsh trace my-app/App.tsx
这是检查应用能否正常启动、查看一次交互引起多少工作量的快捷方式(测试与 trace)。
#下一步
- 在 iOS 应用中嵌入和在 Android 应用中嵌入:完整的宿主 API。
- 签名包和沙箱与能力:下发方式,以及模块能做什么。
- JSX 与组件和响应式:编写模块。
- 项目结构:
nsh.json、资源和文件。