指南
嵌入 iOS 应用
现有的 iOS 应用可以在自己的界面中显示 nsh 应用。运行时以 NativeShell.xcframework 的形式提供,这是一个带 Swift
模块的静态 framework:创建一个 NshAppView(一个 UIView),把它加到视图层级的任意位置,再往里加载一个应用即可。你的原生页面、导航和
nsh 应用共处同一个 UIKit 视图层级。
每个 NshAppView 都是一个独立的引擎,拥有自己的 JS VM;没有全局单例,所以一个应用可以同时显示多个 nsh 应用,比如每个标签页一个。
#获取 framework
发行包中包含两个版本:
dist/nsh/embed/ios/NativeShell.xcframework release: bundles and signed packages
dist/nsh/embed/ios/NativeShellDev.xcframework also connects to a `nsh dev` server
如果你有 nsh 的源码,可以只构建这两个 framework:
cargo xtask ios-embed # writes dist/nsh/embed/ios/{NativeShell,NativeShellDev}.xcframework
每个 XCFramework 包含三个 arm64 切片:iOS 真机、iOS 模拟器(Apple 芯片)和 Mac Catalyst。最低部署目标为 iOS 15.0。Swift 模块启用了 library evolution 编译,因此用其他版本 Swift 编译器构建的应用也能导入它;framework 已链接了它需要的一切,你无需再添加其他东西。
#加入 Xcode 项目
- 把
NativeShell.xcframework(开发构建则用NativeShellDev.xcframework)拖到应用 target 的 Frameworks, Libraries, and Embedded Content 中。它是静态 framework,所以选择 Do Not Embed。 - 在 Swift 中
import NativeShell。
#随应用附带 bundle
把 nsh 应用构建到一个文件夹里,再以名为 nsh 的文件夹引用(folder reference)把它加入应用资源:
nsh build App.tsx --release --out MyApp/nsh
nsh build 会写出:
| 文件 | 内容 |
|---|---|
bundle.qbc | 编译为 QuickJS 字节码的应用:加载最快。 |
bundle.js | 同一个应用的 JS 源码。 |
bundle.js.map | 它的 source map(堆栈跟踪会指向你的 .tsx 文件)。 |
capabilities.json | 应用声明的能力,仅当 nsh.json 中有声明时生成。 |
assets/ | 应用导入的图片和字体,路径相对于入口文件。 |
#显示应用
import NativeShell
import UIKit
final class EmbedController: UIViewController {
private let app = NshAppView()
override func viewDidLoad() {
super.viewDidLoad()
app.translatesAutoresizingMaskIntoConstraints = false
app.onError = { message in print("nsh error: \(message)") }
view.addSubview(app)
NSLayoutConstraint.activate([
app.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
app.leadingAnchor.constraint(equalTo: view.leadingAnchor),
app.trailingAnchor.constraint(equalTo: view.trailingAnchor),
app.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
guard let url = Bundle.main.url(forResource: "bundle", withExtension: "qbc", subdirectory: "nsh") else { return }
do {
try app.loadBundle(at: url)
} catch {
print("cannot load: \(error)")
}
}
// Let the embedded app's StatusBar decide the status bar.
override var preferredStatusBarStyle: UIStatusBarStyle { NshAppView.preferredStatusBarStyle }
override var prefersStatusBarHidden: Bool { NshAppView.prefersStatusBarHidden }
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
NshAppView.preferredStatusBarUpdateAnimation
}
}
视图的尺寸就是应用的窗口:nsh 按视图的 bounds 布局应用,bounds 变化时重新布局。再次加载会替换正在运行的应用;视图被释放时应用随之停止。
完整示例是 examples/embed-ios/main.swift:一个在自己的原生标题栏下方显示 nsh 应用的 UIKit 应用。
#NshAppView
NshAppView 是 UIView 的子类,隔离在 main actor 上。
| 成员 | 说明 |
|---|---|
init(frame: CGRect = .zero, trace: Bool = false) | 创建视图。trace 会记录每次提交的耗时。init?(coder:) 支持 storyboard 和 nib。 |
onError: ((String) -> Void)? | 在主线程上接收应用的致命错误(bundle 加载失败、未捕获的 JS 异常、VM 因触及限制而停止)。错误总会写入日志;开发构建还会把错误显示在应用上方。 |
limits: NshLimits | 此后加载的应用的 VM 限制。 |
grantCapabilities(_ json: String?) throws | 此后加载的应用最多能做什么,JSON 格式与 nsh.json 的 capabilities 相同;nil 表示授予每个应用所声明的能力。JSON 无效时抛出 NshError。 |
loadBundle(at url: URL) throws | 运行 nsh build 写出的 bundle。资源取自同目录下的 assets/;同目录下的 bundle.js.map 和 capabilities.json 也会被读取。 |
run(_ package: NshPackage) | 运行一个已验证的签名包(见下文)。一个包只能运行一次。 |
loadPackage(at:trust:) async throws -> NshPackage.Info | 在主线程之外验证指定 URL 的包,然后运行它。被拒绝时抛出原因;正在运行的应用保持不变。 |
connectDevServer(_ url: URL) throws | 开发用:从 nsh dev 服务器加载,每次修改后重新加载。使用不含开发支持的 NativeShell.xcframework 时会抛出错误。 |
register(component:_:) | 注册一个原生组件。 |
static preferredStatusBarStyle、prefersStatusBarHidden、preferredStatusBarUpdateAnimation | 正在运行的应用通过 StatusBar 请求的状态栏设置:在宿主视图控制器的对应重写中返回它们。 |
limits 和 grantCapabilities 从下一次加载开始生效:请在 loadBundle / run 之前设置。
#限制
app.limits = NshLimits(memoryMB: 128, callBudget: 1, maxNodes: 50_000)
NshLimits 字段 | 默认值 | 含义 |
|---|---|---|
memoryMB: UInt32? | 256 | JS 堆大小(MB)。耗尽时应用停止。 |
stackKB: UInt32? | 4096(也是上限) | 解释器栈大小(KB)。递归更深会抛出 RangeError。 |
loadBudget: TimeInterval? | 10 s | 执行 bundle 的时间。 |
callBudget: TimeInterval? | 2 s | 其他每次 JS 调用(一个事件、一个定时器、一帧)的时间。超时的调用会被中断。 |
maxNodes: UInt32? | 200 000 | 应用可保留的节点数。 |
nil 表示使用默认值。各项限制触发时会发生什么,见沙箱与能力。
#能力
应用在 nsh.json 中声明它可以访问什么;嵌入方应用可以授予更少的能力。一个请求必须同时被两者允许:
try app.grantCapabilities("""
{
"network": { "hosts": ["api.example.com", "*.cdn.example.com"] },
"webview": { "hosts": ["help.example.com"] }
}
""")
try app.loadBundle(at: url)
这里应用不能设置状态栏(没有 statusBar),即使它的 nsh.json 申请了也不行。详见沙箱与能力。
#签名包
要运行应用下载的代码(更新、第三方小程序),请使用签名包:nsh publish 会写出一个
.nshpkg,由经你的根密钥认证的发布密钥签名,应用在运行前对它进行验证。
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let trust = try NshTrust(stateDirectory: support.appendingPathComponent("nsh"))
try trust.addRoot(rootPublicKey) // the contents of root.pub from `nsh keygen`
Task {
do {
let info = try await app.loadPackage(at: downloadedFile, trust: trust)
print("running \(info.id) \(info.version) (build \(info.build))")
} catch {
print("refused: \(error)") // the running app stays
}
}
如果想先验证、稍后再运行,可以把两步拆开:
let package = try await trust.openPackage(at: downloadedFile) // verifies off the main thread
// ... decide, ask the user, ...
app.run(package)
| API | 说明 |
|---|---|
NshTrust(stateDirectory: URL) throws | 打开或创建信任库。它的状态(最新的吊销列表、每个应用已接受的最高构建号)持久化在该目录中,包的资源也解压到其下。在调用 addRoot 之前不信任任何密钥。线程安全。 |
addRoot(_ publicKey: String) throws | 信任一个根公钥(base64,即 .pub 文件的内容)。可以信任多个根。 |
updateRevocations(_ json: String) throws | 应用 nsh revoke 写出的吊销列表;该列表须由受信任的根签名,且比信任库现有的更新。 |
openPackage(at: URL) async throws -> NshPackage | 验证包、解压资源并接受该包(此后该应用更旧的构建都会被拒绝)。 |
NshPackage.info | 包内应用的 id、name、version、build。 |
NshError | 所有会抛出错误的调用抛出的错误类型;message 说明原因。 |
#开发
使用 NativeShellDev.xcframework 时,可以让视图连接开发服务器,而不是加载 bundle:
#if DEBUG
try app.connectDevServer(URL(string: "ws://127.0.0.1:8790/ws")!)
#else
try app.loadBundle(at: bundledURL)
#endif
在 Mac 上运行 nsh dev App.tsx。模拟器与 Mac 共享网络,所以 127.0.0.1 就能访问服务器;真机则使用 nsh dev
打印的局域网地址(devices on this network: ws://192.168.1.20:8790/ws)。每次保存都会就地更新应用,日志显示在 nsh dev
终端中,错误显示在应用上方。参见开发服务器与调试。
#注意事项
- 线程。
NshAppView隔离在 main actor 上。NshTrust、NshPackage和NshComponentContext是线程安全的。JS 引擎运行在自己的线程上;主线程从不等待它。 - 状态栏。 嵌入应用的
StatusBar会改变NshAppView.preferredStatusBarStyle等属性的返回值;宿主视图控制器必须像上面的示例那样转发它们。 - 安全区域。 应用照常使用
nsh/ui/mobile中的SafeAreaView或useSafeAreaInsets()。放在你自己标题栏下方的NshAppView拿到的是适用于它自身的 insets,而不是整个窗口的。 - Mac Catalyst。 同一个 framework 也可用于 Catalyst 应用。