指南

快速开始

nsh 的主要用途是在原生应用里承载模块(见简介)。本指南沿着这条路线走:编写一个模块,借助热重载开发,在你的 iOS 或 Android 应用里显示它,再以签名包下发一次更新。前提是已经安装 nsh 并加入 PATH(安装);在源码仓库中请改用 ./target/debug/nsh。

guide-getting-started.tsx 在线编辑 在新标签页打开
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.jsonTypeScript 设置,把 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)。

#下一步