了解 NativeScript

NativeScript 是一个 JavaScript 运行时,它允许你完全使用 TypeScript 或 JavaScript 编写 iOS 或 Android 应用,并且不会牺牲任何原生能力。也就是说,它提供了与直接使用:iOS:Objective-C / Swift 以及 Android:Java / Kotlin 来开发应用时同等级别的原生访问能力。它不仅仅支持可以 JSON 序列化的数据类型,例如 NSString,而是支持所有原生数据类型

为了说明 NativeScript 的能力,下面是一个使用 NativeScript 获取手机电池电量的例子:

import { isIOS, isAndroid, Application } from "@nativescript/core";

/**
 * 返回电池电量
 * (至少在 iOS 上),范围是 0.0 到 1.0
 *
 * @platform iOS 或 Android API level 21+
 */
function getBatteryLevel(){
    if(isIOS){
        UIDevice.currentDevice.batteryMonitoringEnabled = true;
        const batteryLevel = UIDevice.currentDevice.batteryLevel;
        UIDevice.currentDevice.batteryMonitoringEnabled = false;

        return batteryLevel;
    } else if(isAndroid){
        const bm = Application.android.context.getSystemService(android.content.Context.BATTERY_SERVICE);

        return bm.getIntProperty(android.os.BatteryManager.BATTERY_PROPERTY_CAPACITY);
    }

    throw "Platform not yet supported!";
}

是不是感觉很神奇?🧙‍♂️ 你会发现:这些 JavaScript 调用实际上是一一对应到原生 API 的。并且它们是同步执行的,就像原生 API 本身一样。例如:

UIDevice.currentDevice.batteryLevel

对应 Objective-C:

[[UIDevice currentDevice] batteryLevel]

虽然上面的例子只是简单 API 调用,但 NativeScript 实际上可以完成 Objective-C 和 Java 运行时能做到的几乎所有事情,包括:

  • 继承原生类
  • 实现 delegate
  • 传递函数
  • 操作原生对象
  • 多线程
  • UI 管理 API
  • 应用生命周期管理
  • 更多原生能力

人人都可以访问 Native API!

能够直接在 JavaScript 环境中访问原生 API,对于开发者来说非常强大。这意味着你只需要掌握:一个 IDE、一个构建系统,然后通过热更新(Hot Reload)快速开发。显然 TypeScript / JavaScript 的开发体验更好,因为工具链更优秀。例如 TypeScript Language Service 类型检查速度快相比: Objective-C / Swift 的 SourceKit通常响应更快。

同时 NativeScript 的体验应该成为所有 App 开发框架努力的方向。事实上,NativeScript 技术委员会一直在尝试与其他框架结合。例如 Capacitor + NativeScript、React Native。项目 react-native-native-runtime尝试过 React Native 是否可以直接访问 Native API,作为概念验证让 React Native 调用 NativeScript。

NativeScript 的架构

首先,我们回顾一下 NativeScript 的整体架构。NativeScript 本质上由三个部分组成:

1、Metadata Generator(元数据生成器)

工具负责生成:描述所有可绑定 Native API 的元数据(metadata),包括:

  • iOS:ios-metadata-generator
  • Android:android-metadata-generator

这些工具会扫描原生 SDK,例如:UIKit、Foundation、Android SDK……然后生成描述信息。

2、iOS / Android Runtime

这是 NativeScript 的核心。它们是: V8/JavaScriptCore 修改而来的 JavaScript 引擎,其作用是根据 metadata:动态生成JavaScript ↔ Native之间的绑定。也就是说 metadata 告诉 Runtime:原生世界有哪些类、方法、属性。Runtime 根据这些信息在 JS 中创建对应对象。

3、NativeScript Core

这是一个高级 API 库,建立在上述绑定之上,提供跨平台 API。例如 Alert:

alert("hello")

创建文本框:

<TextField />

它的特点同一套代码可以运行在 iOS 或 Android 上。因此:如果你只是使用 NativeScript 作为完整跨平台框架,Core 就足够。但如果你只是想把 NativeScript 当作原生访问层那么 Core 可以不用。

小结

本文主要研究前两个部分:1. Metadata Generator;2. NativeScript Runtime,也就是:

Native API
     ↓
Metadata Generator
     ↓
metadata.bin
     ↓
NativeScript Runtime
     ↓
JavaScript Binding

JavaScript 到原生绑定

单独的 JavaScript 语言本身其实提供的能力并不多,因此,JavaScript 运行时通常会在 ECMAScript API 之外,额外提供一些来自平台的 API(即原生 API)。例如:

浏览器

浏览器为 JavaScript 增加了各种 Web API,例如 DOM、网络请求、文件访问等,其中 DOM:

document.querySelector();

实际上就是 JavaScript 调用了浏览器底层的原生能力。

Node.js

Node.js 增加了大量后端 API,例如:

process

文件系统:

fs.readFile();

很多时候,这些 API 都是在访问操作系统提供的能力。例如:

  • 浏览器可以访问文件系统
  • Node.js 可以访问文件系统

因此,在某个实现层面 JavaScript 引擎最终必须调用原生 API。而要让 JavaScript 环境调用原生 API 必须建立一种机制:

JavaScript
    |
    |
 Binding
    |
    |
Native API

这个机制就是:JS → Native Binding(JavaScript 到原生绑定)。不同 JavaScript Runtime / Engine 实现绑定的方式不同。

不同 JavaScript Runtime 如何实现 JS → Native Binding?

比较不同方案非常有价值,下面逐个分析。

Node.js

在 Node.js 中理论上用户代码可以通过:

child_process

访问任意原生能力。但是真正意义上的 Native Binding,例如让 JavaScript 可以表示原生对象:

const nativeObject = ...

通常通过: C++ Addons 实现。Node.js 提供 C++ Addons API 允许 C++ 代码注册到 JavaScript 环境。Node.js 有三种实现 C++ Addon 的方式。其中一种基于 V8 API 实现。这一点与 NativeScript 使用 V8 Runtime 有关联。

浏览器

浏览器中的情况,如果想增加新的 Native API 基本只能修改浏览器源码。例如 Chrome 想增加navigator.xxx这样的 API,必须修改 Chromium。普通用户无法动态添加。

JavaScriptCore(JSC)

JavaScriptCore 是 Apple 的 JavaScript 引擎。Safari 在使用它。它支持通过 Runtime API 从用户代码层建立绑定,当然也可以修改源码。

React Native

React Native 无论使用哪个:JavaScriptCore/Hermes/V8 都可以建立 Native Binding。传统方式使用 JSON Bridge,例如NativeModules.Camera.takePhoto()调用原生CameraModule.takePhoto()。但是这个方式有限制,只能传递 JSON 可序列化的数据。例如:

{
 "name":"test",
 "age":10
}

但是不能直接传递:

  • Native Object
  • 文件句柄
  • 原生类实例

后来 React Native 推出了: JSI(JavaScript Interface),JSI 可以让 JavaScript 直接表示 Native 数据类型,但是早期文档并不完善。

NativeScript

NativeScript 的方式不同。NativeScript 会自动生成所有公开 Native API 的 metadata,包括用户自己的 Native API 与 系统 SDK API,然后在运行时根据 metadata,自动创建绑定。

流程:

Native Header
      |
      |
Metadata Generator
      |
      |
metadata
      |
      |
NativeScript Runtime
      |
      |
JavaScript Object

如果你想在 JS 中创建一个方便调用原生 API 的函数有两种方式:

方式 1:直接用 JavaScript 写

例如之前的电池例子:UIDevice.currentDevice.batteryLevel完全由 JS 调用 Native。

方式 2:使用 NativeScript Plugin API

如果你希望用 Objective-C / Java 编写 Native 部分。例如已有一个原生 SDK:MyCameraSDK.framework你可以包装成 NativeScript Plugin。这样 JS:camera.takePhoto();底层调用 Objective-C:

[Camera takePhoto]

NativeScript 的独特之处

因此 NativeScript 最大的特点是:你可以在 JavaScript 中调用任意 Native API,并且可以表示任意 Native 数据类型,而不需要自己编写 Native 代码。更关键的是:你甚至不需要重新编译 App。因为 Webpack 监听代码变化,然后 Hot Reload。例如修改:

button.text = "Hello"

保存 App 立即更新。这就是 NativeScript 与传统跨平台方案的重要区别。

NativeScript Metadata Generator 工具如何工作?

下面进入 NativeScript 最核心的部分,以 iOS 为例子。简单来说 Metadata Generator 做的事情:读取所有进入 App 的公开 Header 文件,然后生成一个二进制 metadata 文件,它会被打包进入 NativeScript App。App 启动时读取 metadata,然后根据它建立 JS → Native Binding。

简单流程:

Objective-C Header
       ↓
Metadata Generator
       ↓
metadata.bin
       ↓
NativeScript Runtime
       ↓
JavaScript Classes

Metadata Generator 的具体流程

第一步:搜索 Header

通常它会第一步:搜索 Header,使用clang HeaderSearch API查找所有 Header。然后使用`clang RecursiveASTVisitor``遍历语法树。也就是说:它不是简单文本扫描,而是使用 Clang 编译器能力理解 Objective-C。例如遇到:

@interface UIDevice : NSObject
@property(nonatomic) float batteryLevel;
@end

它能够理解这是一个 Class UIDevice,有一个属性BatteryLevel,类型是float

第二步:创建 Meta 对象

对于每个 Clang 声明调用MetaFactory::create()判断它是什么类型,例如:

  • enum
  • class
  • interface
  • method
  • property

然后创建对应`Meta class instance``,例如:

@interface Person : NSObject
@property NSString *name;
@end

生成类似:

MetaClass:
    name = Person

MetaProperty:
    name
    type = NSString

第三步:清理数据

生成所有 Meta 对象之后进行 cleanup。例如删除重复成员、无效声明。

第四步:序列化

最后把 Meta 对象序列化成二进制,生成metadata.bin,同时它还可以生成 TypeScript 定义文件。例如:

declare class UIDevice {
    batteryLevel:number;
}

Metadata 长什么样?

实际进入 App 的 metadata 是一个:二进制文件,准确来说,它被格式化为 Mach-O 目标文件中的一个 section,然后交给 linker 处理。因此它不是人类可读的。但是,如果让 Metadata Generator 输出 YAML 格式,那么它会为每个模块生成一个 YAML 文件。例如ARKit.yaml这样的文件。

Metadata 中包含了 Header 文件中的所有详细信息,例如:

  • 方法签名(method signatures)
  • 类的实例方法(instance methods)
    -枚举值(enum values)
    -Header 文件位置
  • 类型信息

等等。例如假设 Objective-C 有:

@interface Person : NSObject

@property(nonatomic, strong)
NSString *name;
- (void)sayHello:(NSString *)message;

@end

Metadata 中会记录类似:

Class:
    Person

Property:
    name
    type: NSString

Method:
    sayHello:
    parameter:
        NSString

Runtime 后面就是利用这些信息动态生成 JavaScript 世界中的Person对象。

NativeScript Metadata Generator 在构建流程中的哪个阶段运行?

传统 NativeScript App 的构建流程大致包含四个步骤。

1. pre-build(预构建)

执行nativescript-pre-build脚本的作用是设置大量环境变量。例如:

BUILD_DIR
ARCH
CONFIGURATION

2. post-build(构建后)

执行nativescript-post-build脚本。它主要调用strip-dynamic-framework-architectures.sh,其作用是
从 App 的Frameworks目录中的动态库中删除无效架构。例如开发环境可能包含:arm64、x86_64、i386,但是最终设备只需要 arm64,于是删除多余部分。可能只是为了减少开发阶段磁盘空间占用。

3. pre-link(链接前)

这个步骤现在基本只是一个占位脚本。因为内容只有:

set -e

没有实际逻辑。

4. link(链接阶段)

这是最关键的一步。Xcode 的:

LD
LDPLUSPLUS

两个 Build Setting 被设置为$SRCROOT/internal/nsld.sh,它们不是 Xcode 官方公开文档中的标准设置。通常默认 linker:clang路径:

/Applications/Xcode.app/
Contents/Developer/
Toolchains/
XcodeDefault.xctoolchain/usr/bin/clang

NativeScript 做了一层包装:nsld.sh,它并不是替代 linker。它只是先生成 metadata,然后调用真正 linker。

流程:

Xcode Build
      |
      v
nsld.sh
      |
      |
      +---- Metadata Generator
      |
      |
      v
metadata-x86_64.bin
      |
      v

clang linker

metadata 如何被加入 App?

通过 Xcode 参数OTHER_LDFLAGS增加:

-sectcreate
__DATA
__TNSMetadata
metadata-x86_64.bin

意思是把metadata-x86_64.bin作为一个 section 写入最终 Mach-O 文件。最终 App 实际上包含:

Executable
 |
 |
 +-- __DATA
       |
       +-- __TNSMetadata
              |
              +-- metadata

所以 App 启动时 NativeScript Runtime 可以直接从内存读取 metadata。不需要:

  • 打开文件
  • 解析 JSON
  • 反序列化

这也是为什么 NativeScript 可以做到高性能。

NativeScript Runtime 如何根据 Metadata 建立 Binding?

现在假设 App 已经编译完成,Metadata 也已经链接进去。接下来看看运行时发生什么。整体流程:

App Launch
    |
    |
读取 metadata
    |
    |
NativeScript Runtime
    |
    |
创建 JS 类
    |
    |
绑定 Native 方法
    |
    |
JavaScript 调用 Native

第一步:获取 Metadata 地址

因为 metadata 已经作为 Mach-O section 编译进 App,所以 Runtime 需要从内存找到它。有两种方式。

方法一:Inline Assembly

例如:

extern char startOfMetadataSection
__asm("section$start$__DATA$__TNSMetadata");

void* metadataPtr =
    &startOfMetadataSection;

意思告诉编译器找到:

__DATA
    |
    +-- __TNSMetadata

这个 section 的开始地址。

方法二:Mach-O Section 查询

例如:

#import <mach-o/getsect.h>

static const struct mach_header_64 *
mhExecHeaderPtr =
&_mh_execute_header;


extern void* runtimeMeta(){

    NSString *sectname =
        @"__TNSMetadata";

    NSString *segname =
        @"__DATA";

    unsigned long size;


    void* meta =
        getsectiondata(
            &_mh_execute_header,
            [segname cStringUsingEncoding:
                NSUTF8StringEncoding],

            [sectname cStringUsingEncoding:
                NSUTF8StringEncoding],

            &size
        );

    return meta;
}

注意这里:

__DATA
__TNSMetadata

正是前面-sectcreate指定的 section 名称。

Metadata 是否需要反序列化?

由于 metadata 是直接作为目标文件 section 链接进去的。所以可能不需要传统意义上的:

读取文件
↓
解析二进制
↓
创建对象

过程中它可能已经以 Meta 对象列表形式存在于内存中。整个流程大致如下:

1. 设置 Metadata

AppDelegate 中创建 NativeScript:

NativeScript *script = [[NativeScript alloc] init];

同时传入 metadata 地址。NativeScript 内部把 metadata pointer 设置到RuntimeConfig,然后调用Runtime::Initialize()。内部进一步调用:

MetaFile::setInstance(
    RuntimeConfig.MetadataPtr
)

这个函数设置一个单例metaFileInstance


之后其他模块例如 ArgConverter/MetadataBuilder……都可以通过:

MetaFile::instance()

访问 metadata。

第二步:创建绑定

接下来调用Runtime::Init(),这个阶段大量类开始初始化。其中两个非常重要:

ArgConverter

负责 JavaScript 类型 ↔ Native 类型转换。例如:

123

转换成 Objective-C:

NSInteger

或者

{
 name:"Tom"
}

转换成 Native Object。

ClassBuilder

负责根据 metadata 动态创建 Native 类。ClassBuilder 使用了 Objective-C Runtime,例如:

class_addMethod()

这个函数可以动态给类增加方法。例如:

@interface Person

@end

运行时添加sayHello这个 objc 的方法。

然后 ClassBuilder 再通过 V8 API 把这些 Native 方法绑定到 JavaScript。最终:

person.sayHello()

会调用 Objective-C:

-[Person sayHello]

除此之外 Runtime 中还大量使用 C 语言的`dlsym()``,它是动态链接器 API,运行时查找函数地址。

总结(Conclusion)

NativeScript 传统上一直被作为一个端到端(end-to-end)的跨平台应用开发框架使用。但是实际上:它完全可以作为一个便捷的 JavaScript → Native Runtime(JS 到原生运行时) 使用。例如可以被集成到 React Native 应用,甚至完全原生开发的 App。为了让这种使用方式变得可行,NativeScript 团队正在改进它的 可嵌入性(embeddability),也就是说让 NativeScript 更容易作为一个底层能力被其他框架调用。而要做到这一点必须深入理解 NativeScript 底层运行机制。这样才能判断:

  • 哪些长期存在的设计模式可以优化;
  • 哪些构建步骤可以简化;
  • 哪些模板代码可以删除。

因此本文进行了这次底层机制深入研究,而且不夸张地说 NativeScript Runtime 实际上是一个非常复杂的 JavaScript ↔ Native 桥接系统。

与 React Native 的核心区别

我们不妨看看 NativeScript 和 React Native 的核心区别。

React Native 传统架构

JavaScript
    |
    |
 JSON Bridge
    |
    |
Native Module
    |
    |
iOS / Android API

特点:

  • 需要写 Native Module
  • 数据通常需要 JSON 化
  • 跨 JS / Native 有通信成本

NativeScript 架构

JavaScript
    |
    |
 Native Runtime
    |
    |
Metadata
    |
    |
iOS / Android API

特点:

  • 不需要为每个 API 写桥接代码
  • 可以直接访问系统 API
  • 支持 Native 类型
  • JS 可以直接创建 Native 对象

例如在 NativeScript 中调用 Android:

const vibrator =
    Application.android.context
    .getSystemService(
        android.content.Context.VIBRATOR_SERVICE
    );

vibrator.vibrate(500);

不需要创建:

VibratorModule.java

也不需要注册:

NativeModules.Vibrator

这也是为什么 NativeScript 可以做到:

“Write once, access everything native.”

对你目前使用 NativeScript Vue 的意义

结合你之前开发:

  • NativeScript Vue App
  • Android/iOS 双端
  • MQTT 通讯
  • 原生能力调用

这篇文章解释了为什么你可以直接:

import { Application } from "@nativescript/core";

然后访问 Android:

Application.android.context

甚至:

android.bluetooth.BluetoothAdapter

而不需要写 Java Plugin。但是也要注意 NativeScript 的强大来自 Runtime。代价是:

  • 首次启动需要初始化 Runtime
  • 包体积通常比纯原生大
  • 调试 Native 深层问题需要理解 JS Runtime + Native Runtime 两边

这也是 NativeScript 与 Flutter、React Native 最大的设计差异之一:

Flutter:

自己绘制 UI,隔离 Native。

React Native:

JS 控制 Native Component。

NativeScript:

JS 直接成为 Native Runtime 的第一等公民。

这篇文章实际上就是在解释 NativeScript 最核心的技术优势。

https://github.com/NativeScript/NativeScript/wiki/Deep-dive:-How-NativeScript’s-JS–native-bindings-work

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐