指南
原生组件
原生组件是由嵌入方应用按名称注册的视图:地图、相机预览、图表库,或者 iOS 上任意的 UIView、Android 上任意的
android.view.View。nsh 应用通过 nsh/native 提供的 nativeComponent() 声明它,然后像使用其他组件一样使用它。nsh
会把它和应用的其余部分一起布局,向它发送 props,把它的事件交给对应的处理函数,并转发通过 ref 发出的命令。
nsh 本身就用系统原生视图渲染,所以原生组件只是同一视图层级里多出来的一个子视图:不存在层级叠放问题,它会像 <View>
一样被裁剪、随内容滚动并接收触摸。
#整体流程
- 宿主应用在它的
NshAppView(iOS)或NshView(Android)上以某个名称(如"embed.badge")注册一个工厂。 每个视图都有自己的注册表,没有任何全局状态。 - nsh 应用为这个名称声明一个组件,列出它用到的 props 和事件。
- 元素创建时,nsh 先创建一个容器视图(和普通视图一样负责装饰、定位和命中测试),再向工厂请求一个组件,并把组件的视图放进容器、铺满容器。
- 组件创建后会立即收到所有 props,之后每当某个 prop 变化时再收到该 prop。
- 组件可以发出事件、上报自身尺寸;JS 通过
ref调用它的命令。
#在 JS 中声明组件
import { mount, signal, Text, type YNode } from "nsh";
import { nativeComponent } from "nsh/native";
import { SafeAreaView } from "nsh/ui/mobile";
/** The host app's badge: a label it draws, sized to its text. */
const Badge = nativeComponent<{ text: string; color?: string }, { tap: { count: number } }>("embed.badge", {
props: ["text", "color"],
events: ["tap"],
});
const colors = ["#8250df", "#0969da", "#1a7f37", "#cf222e"];
function App() {
const taps = signal(0);
let badge: YNode | undefined;
return (
<SafeAreaView style={{ flex: 1, padding: 20, gap: 16, backgroundColor: "#f6f8fa" }}>
<Text style={{ fontSize: 22, fontWeight: "700" }}>Rendered by nsh</Text>
<Badge
ref={(node) => (badge = node)}
text={taps() === 0 ? "Tap the native badge" : `Tapped ${taps()}×`}
color={colors[taps() % colors.length]}
onTap={(e) => {
taps.set(e.count);
badge?.command("pulse");
}}
style={{ alignSelf: "flex-start", borderRadius: 8, overflow: "hidden" }}
/>
<Text>{`JS saw ${taps()} taps`}</Text>
</SafeAreaView>
);
}
mount(App);
这就是 examples/embedded/App.tsx,仓库里的两个嵌入示例运行的都是它。
#nativeComponent(name, spec)
function nativeComponent<P extends object = {}, E extends object = {}>(
name: string,
spec: NativeComponentSpec<P, E>,
): (props: NativeComponentProps<P, E>) => YNode;
| 参数 | 类型 | 说明 |
|---|---|---|
name | string | 宿主应用注册时使用的名称。请加上自己的前缀("maps.view"、"acme.chart"),以保证名称唯一。 |
spec.props | readonly (keyof P & string)[] | 组件自己的 props。只有这里列出的 props 才会发送到原生端。 |
spec.events | readonly (keyof E & string)[] | 组件发出的事件。可选。 |
类型参数描述 props(P)以及每个事件的负载(E)。返回的组件接受:
- 组件自己的 props
P; - 每个事件对应一个处理函数:事件
tap由onTap处理,regionChange由onRegionChange处理(首字母大写并加上on前缀); <View>能接受的一切(children 除外):style、class/classList(Tailwind)、布局、 无障碍 props、testID、onLayout、ref。这些作用于 nsh 绘制的容器,而不是组件本身。
原生组件是叶子节点:它不接受 children。
#Props
每个自有 prop 都以 JSON 形式发送,所以它的值可以是任意 JSON 值:字符串、数字、布尔值、null、数组和普通对象。Props
和其他属性一样是响应式的:当 text={label()} 读取了一个 signal,signal 变化时(且仅在此时)新值才会被发送。
| JS 中发生了什么 | 组件收到什么 |
|---|---|
| 元素被创建 | 组件创建后立即收到所有已设置的自有 prop |
| 某个 prop 的值变化 | 该 prop 及其新值 |
某个 prop 变为 undefined | 该 prop,但没有值(Swift 中为 nil,Java 中为 null) |
非 JSON 值(函数、类实例、NativeValue)无法跨越边界;接口只传数据。
#事件
组件按名称发出事件,可附带一个 JSON 负载。nsh 把它投递到引擎线程,并用该负载调用对应的处理函数:
const MapView = nativeComponent<
{ latitude: number; longitude: number; zoom?: number },
{ regionChange: { latitude: number; longitude: number }; markerPress: { id: string } }
>("acme.map", {
props: ["latitude", "longitude", "zoom"],
events: ["regionChange", "markerPress"],
});
<MapView
latitude={52.52}
longitude={13.405}
onRegionChange={(region) => center.set(region)}
onMarkerPress={({ id }) => open(id)}
style={{ flex: 1 }}
/>;
事件是异步的:主线程从不等待 JS。如果组件必须当场做出决定(这个手势能否开始、这个区域能否改变),就只能在原生端根据之前收到的 props 来决定。
#命令
命令式调用(缩放到某个点、开始播放、播放一段动画)通过元素的 ref 发起:
let map: YNode | undefined;
<MapView ref={(node) => (map = node)} latitude={52.52} longitude={13.405} style={{ flex: 1 }} />;
map?.command("zoomTo", { level: 3 });
command(name, args?) 把名称和参数(任意 JSON 值)发送给组件的 command 方法。命令会和在它之前做出的变更按顺序应用,所以紧跟在
prop 变更之后发出的命令能看到新的 prop。
#尺寸
容器的布局方式与 <View> 相同:
- 样式给出了某个维度时(
width、height、flex: 1、alignSelf: "stretch"等),以样式为准。 - 样式未确定某个维度时,容器采用组件通过
setSize上报的尺寸,就像图片采用它的固有尺寸一样。从不上报尺寸的组件在未确定的维度上尺寸为 0。
每当内容变化时,以点(iOS)或 dp(Android)为单位上报尺寸。上面的 badge 每次 text 变化时都会测量标签,并上报标签尺寸加上内边距,因此
alignSelf: "flex-start" 能让 badge 恰好与文字等宽。
#在 iOS 上注册(Swift)
实现 NshComponent,并在 NshAppView 上注册一个工厂:
import NativeShell
import UIKit
/// `embed.badge`: props `text` and `color`; emits `tap`; command `pulse`.
final class Badge: NshComponent {
let view = UIView()
private let label = UILabel()
private let context: NshComponentContext
private var taps = 0
init(_ context: NshComponentContext) {
self.context = context
label.textColor = .white
label.font = .boldSystemFont(ofSize: 17)
label.textAlignment = .center
label.frame = view.bounds
label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(label)
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
}
func setProp(_ name: String, _ value: Any?) {
switch name {
case "text":
label.text = value as? String
let size = label.intrinsicContentSize
context.setSize(CGSize(width: size.width + 32, height: size.height + 20))
case "color":
view.backgroundColor = (value as? String).flatMap(UIColor.init(hex:)) ?? .systemPurple
default:
break
}
}
func command(_ name: String, _ args: Any?) {
guard name == "pulse" else { return }
view.alpha = 0.3
UIView.animate(withDuration: 0.6) { self.view.alpha = 1 }
}
@objc private func tapped() {
taps += 1
context.emit("tap", ["count": taps])
}
}
// Before loading the bundle:
app.register(component: "embed.badge") { context in Badge(context) }
(UIColor(hex:) 是示例里的一个小工具函数,不属于 NativeShell。)
| API | 说明 |
|---|---|
NshAppView.register(component:_:) | 以某个名称注册一个工厂 (NshComponentContext) -> NshComponent。此后每创建一个元素,它就生成一个组件。重复注册同一名称会替换工厂。 |
protocol NshComponent | var view: UIView(铺满容器)、setProp(_:_:)、command(_:_:)(可选:默认实现忽略命令)。隔离在 main actor 上。 |
setProp(_ name: String, _ value: Any?) | 值是 Foundation 的 JSON 对象:String、NSNumber、[Any]、[String: Any]、NSNull;prop 被移除时为 nil。 |
NshComponentContext.emit(_:_:) | 发出一个事件;数据必须是 JSONSerialization 能接受的值(或 nil)。线程安全。 |
NshComponentContext.setSize(_:) | 以点为单位上报组件自身的尺寸。线程安全。 |
#在 Android 上注册(Java / Kotlin)
实现 com.longbridge.nsh.NshComponent,并在 NshView 上注册一个工厂:
import com.longbridge.nsh.NshComponent;
import com.longbridge.nsh.NshComponentContext;
final class Badge implements NshComponent {
private final TextView label;
private final NshComponentContext context;
private final float density;
private int taps;
Badge(Context app, NshComponentContext context) {
this.context = context;
density = app.getResources().getDisplayMetrics().density;
label = new TextView(app);
label.setTextColor(Color.WHITE);
label.setTextSize(17);
label.setGravity(Gravity.CENTER);
label.setOnClickListener(v -> context.emit("tap", Collections.singletonMap("count", ++taps)));
}
@Override
public View view() {
return label;
}
@Override
public void setProp(String name, Object value) {
if (name.equals("text")) {
label.setText(value instanceof String ? (String) value : "");
label.measure(0, 0);
// Sizes are in dp.
context.setSize(label.getMeasuredWidth() / density + 32, label.getMeasuredHeight() / density + 12);
} else if (name.equals("color")) {
label.setBackgroundColor(value instanceof String ? Color.parseColor((String) value) : 0xff8250df);
}
}
@Override
public void command(String name, Object args) {
if (!name.equals("pulse")) return;
label.setAlpha(0.3f);
label.animate().alpha(1).setDuration(600).start();
}
}
// Before loading the bundle:
nsh.registerComponent("embed.badge", context -> new Badge(this, context));
用 Kotlin 写法相同:
nsh.registerComponent("embed.badge") { context -> Badge(this, context) }
| API | 说明 |
|---|---|
NshView.registerComponent(String name, NshComponent.Factory factory) | 以某个名称注册一个工厂;此后每创建一个元素,它就生成一个组件(UI 线程)。 |
interface NshComponent | View view()、void setProp(String, Object)、default void command(String, Object)、default void destroy()(元素已移除)。在 UI 线程上调用。 |
setProp 的值 | String、Number、Boolean、java.util.List、java.util.Map;被移除时(或 JSON null)为 null。 |
NshComponentContext.emit(String event, Object data) | 发出一个事件;data 使用同样的 Java 类型,或 null。可在任意线程调用。 |
NshComponentContext.setSize(float width, float height) | 以 dp 为单位上报组件自身的尺寸。可在任意线程调用。 |
#从 Rust 注册
从 Rust 嵌入 iOS 运行时(nsh-platform-ios)的应用,在 AppView 上注册一个 NativeComponent:
use std::rc::Rc;
use nsh_core::JsonValue;
use nsh_platform_ios::{AppView, ComponentContext, NativeComponent};
use objc2_ui_kit::UIView;
struct Badge {
view: objc2::rc::Retained<UIView>,
context: ComponentContext,
}
impl NativeComponent for Badge {
fn view(&self) -> &UIView {
&self.view
}
fn set_prop(&mut self, name: &str, value: Option<&JsonValue>) {
// ... update the view, then report its size in points:
self.context.set_size(120.0, 40.0);
}
fn command(&mut self, name: &str, args: &JsonValue) {
// optional
}
}
fn register(app: &AppView, make_view: impl Fn() -> objc2::rc::Retained<UIView> + 'static) {
app.register_component(
"embed.badge",
Rc::new(move |context: ComponentContext| -> Box<dyn NativeComponent> {
Box::new(Badge { view: make_view(), context })
}),
);
}
ComponentContext::emit(name, data) 发送一个事件(data 是 serde_json::Value),set_size(width, height)
上报尺寸;两者都可以在任意线程调用。
#规则与限制
- 先注册,再加载。 工厂只对注册之后创建的元素生效。请在
loadBundle/loadAssetBundle/run之前注册所有名称,这样第一帧就能找到它们。 - 装饰和触摸归容器管。 背景、边框、圆角、阴影、不透明度、变换以及
overflow: "hidden"都作用于 nsh 绘制的容器。你的视图铺满容器,并接收落在其中的触摸。 - 能力声明不适用。 注册的组件是宿主应用自己的代码;应用声明的能力限制不了它做什么。如果你嵌入的是不受信任的应用,就只注册那些可以放心让它们使用的组件。
- 接口只传数据。 Props、事件负载和命令参数都以 JSON 文本跨越边界;每次变更时传递标识符和小数据,而不是大块数据。
- 一个 bundle,两个平台。 在 iOS 和 Android 上用相同的 props 和事件注册相同的名称,同一个 bundle 就能在两个平台上运行。
#另请参阅
nsh/native参考- 嵌入 iOS 应用和嵌入 Android 应用
- 架构:props 和事件如何跨线程传递