原生代码
指南Episode 16

第拾陸話

原生代码

原生包——在 Go 里声明带类型的模块和视图,在各平台自己的项目里实现:iOS 和 macOS 用 Swift,Android 用 Kotlin,Web 用 JavaScript,Windows 和 Linux 用 Go。

Asuka 没有封装的功能,应用可以通过原生包来使用。原生包是一个 Go 包,在 Go 里声明一个模块(一个接口)或一个视图(它的属性),再由每个平台用自己的语言实现:iOS 和 macOS 上用 Swift,Android 上用 Kotlin,Web 上用 JavaScript,Windows 和 Linux 上用 Go。asuka native generate 会为每一端生成带类型的胶水代码。在 Go 里,调用模块的方法就像调用任何 Go 接口一样,显示视图也和显示任何组件一样。值以紧凑的二进制格式在两端之间传递,不经过 JSON,也不用反射。

Go
// battery 包提供设备的电池信息。
package battery

//asuka:native
type Battery interface {
	// Level 返回电量,范围是 0 到 1。
	Level() (float64, error)
	// Watch 在电量变化时调用 changed,直到 ctx 结束。
	Watch(ctx context.Context, changed func(level float64)) error
}
Go
b, err := battery.NewBattery()
level, err := b.Level()
Swift
// swift/Battery.swift
final class IOSBattery: Battery {
    func level() throws -> Double {
        UIDevice.current.isBatteryMonitoringEnabled = true
        return Double(UIDevice.current.batteryLevel)
    }
    ...
}
func makeBattery() throws -> any Battery { IOSBattery() }

原生包就是一个普通的 Go 包。它可以放在应用里,也可以由应用从任何模块导入,导入它的每个应用都会带上它的原生代码。每个平台的代码放在该平台自己的项目里(Swift 包、Gradle 模块),使用该平台自己的依赖管理工具:SwiftPM 和 Maven。

什么时候需要它

Asuka 的组件和包(asuka/sdk/...)已经覆盖了大多数应用需要显示和使用的功能,与 SwiftUI 和 Expo 的对比 中列出了它们。如果你需要某个还没有包封装的平台 API、某个厂商的 SDK,或者 Asuka 没有提供的视图(地图、图表、只有某个平台才有的控件),就可以写一个原生包。

原生包只需要为应用实际运行的平台提供代码。在没有代码的平台上,模块的构造函数会返回一个包装了 errors.ErrUnsupported 的错误,视图则什么都不显示(宿主会在日志中提示缺少这个视图)。

创建原生包

Shell
asuka native new ./battery                # Go、Swift、Kotlin、JavaScript,以及桌面端的 Go
asuka native new --projects swift,android ./battery
asuka native add web ./battery            # 之后再添加某个平台的项目

asuka native new 会生成一个在所有平台上都能编译和运行的包,其中有一个 Hello 方法,每一端都实现了它。你只需要重命名并补充内容。一个原生包的结构如下:

Text
battery/
  battery.go               声明://asuka:native、//asuka:view
  battery_native.go        生成的:NewBattery、客户端、视图
  battery_desktop.go       Windows 和 Linux 的 Go 实现(implementBattery)
  Package.swift            Swift 包,用于 iOS 和 macOS
  swift/
    Battery.swift          你写的:makeBattery
    Battery.asuka.swift    生成的:协议、类型、提供者
  android/
    build.gradle.kts       Gradle 库模块
    src/main/AndroidManifest.xml          (可选)权限、服务
    src/main/kotlin/com/example/battery/
      Battery.kt           你写的:makeBattery
      Battery.asuka.kt     生成的
  web/
    battery.js             你写的:export function makeBattery
    battery.asuka.js       生成的
  Info.plist               (可选)这个包对 Apple 应用的要求
  iOS.entitlements         (可选)
  macOS.entitlements       (可选)

生成的文件以 // Code generated by asuka native. DO NOT EDIT. 开头,并带有声明内容的哈希值。像提交 go generate 的输出一样,把它们提交到仓库。

  • asuka dev 和 asuka build 会在构建前重新生成应用自身模块中的原生包,所以修改声明后保存就会生效。
  • 来自其他模块(依赖)的原生包必须自带生成的代码。CLI 会检查这些代码是否与声明一致,不一致时会报错,并告诉你该运行什么命令。
  • asuka native generate [dir] 可以手动生成。asuka native check 在有任何内容过期时报错,适合在 CI 中使用。

声明模块

在接口上加 //asuka:native,它就成为一个模块:

Go
// kitchen 包提供厨房。
package kitchen

// ErrBusy 是厨房的错误:它正在做别的菜。
var ErrBusy = native.NewError("kitchen: busy")

type Unit string

const (
	UnitGram  Unit = "g"
	UnitOunce Unit = "oz"
)

type Item struct {
	Name   string
	Weight float64
	Unit   Unit
	Tags   []string
	Best   *time.Time // 可选
}

//asuka:native
type Kitchen interface {
	Name() string
	Items(filter string) ([]Item, error)
	Cook(ctx context.Context, recipe string, progress func(done float64)) (Item, error)
	Ask(question func(q string) bool) (bool, error)
	Open(name string) (Oven, error)
	//asuka:mainthread
	Show() error
}

//asuka:native
//asuka:mainthread
type Oven interface {
	io.Closer
	Heat(to float32, unit Unit) error
}

生成的代码会为包提供:

  • NewKitchen() (Kitchen, error):返回平台的实现。如果创建失败,返回的是平台给出的错误;如果包没有为当前平台提供代码,返回的是一个包装了 errors.ErrUnsupported 的错误。
  • implementKitchen(func() (Kitchen, error)):用于 Go 实现(见下文)。它没有导出,所以只有包自己能调用。

类型

Go Swift Kotlin JavaScript
bool Bool Boolean boolean
int、int64、int8...int32 Int、Int64、Int8...Int32 Long、Long、Byte...Int number;int64/uint64:bigint
uint...uint64 UInt...UInt64 ULong...UByte number;uint64:bigint
float32、float64 Float、Double Float、Double number
string String String string
[]byte Data ByteArray Uint8Array
time.Time Date java.time.Instant Date
time.Duration TimeInterval(秒) kotlin.time.Duration number(毫秒)
[]T [T] List<T> Array
map[string]T [String: T] Map<String, T> object
*T T? T? T 或 null
包中定义的结构体 结构体(包含自身时为 final class) data class 对象
带常量的具名 string 或整数类型 RawRepresentable 结构体,每个常量对应一个静态成员 value class,每个常量对应一个常量 由这些常量组成的冻结对象
//asuka:native 接口(作为参数或返回值) any T T 对象
func(...)(作为参数) 闭包 函数 函数

在 Asuka 支持的所有平台上,Go 的 int 和 uint 都是 64 位。结构体的字段就是它的导出字段,按声明顺序排列。对象(原生接口)只能作为参数或返回值传递,不能放在切片、map 或结构体里。其他不支持的用法,asuka native generate 都会在你 Go 代码中的对应位置报错,并告诉你应该改用什么。

方法

  • 错误。 最后一个返回值是 error 的方法可以失败。平台端抛出的错误会以下面几种形式返回:

    • 包中声明的某个错误(var ErrX = native.NewError("..."))。平台端这样抛出它:Swift 中是 KitchenError.busy,Kotlin 中是 KitchenException(KitchenError.BUSY),JavaScript 中是 new KitchenError("busy")。Go 拿到的就是声明的那个值本身,所以 errors.Is(err, kitchen.ErrBusy) 能正常工作。
    • AsukaNativeError.unsupported(msg)、AsukaUnsupportedException 或 asuka.native.UnsupportedError,它们会包装成 errors.ErrUnsupported。
    • 其他任何错误,都会变成一个 *native.Error,带有它的域(NSError 的域,或异常的类名)、错误码和消息。

    如果方法没有 error 返回值,而平台端抛出了错误,Go 中就会 panic,所以凡是可能失败的方法都要声明 error。

  • 第一个参数是 context.Context 的方法是异步的:在 Swift 中是 async,在 Kotlin 中是 suspend,在 JavaScript 中是一个第一个参数为 AbortSignal 的 async 函数。Go 中的调用会阻塞所在的 goroutine,直到平台端返回结果。context 结束时,平台端的任务会被取消(Swift 的 task、Kotlin 的协程、signal 的 abort),Go 则返回 ctx.Err()。凡是耗时的操作都应该这样写:网络、文件、等待对话框的回答。

  • 回调。 func 参数分为两种:

    • 通知:不返回任何值的函数(progress func(done float64))。平台端可以在任意线程上调用它任意多次,无论是在调用进行中还是返回之后(比如监听器)。它在 Go 中的主线程上运行(ui.Post),因此可以设置 signal。
    • 询问:有返回值的函数(question func(q string) bool)。它会在调用它的线程上立即运行,结果返回给平台端。

    回调的生命周期与接收它的对象相同。

  • 对象。 方法可以返回另一个原生接口(Open 返回一个 Oven)。每个返回的接口在平台端都是一个独立的对象。当 Go 不再持有它,或者它被关闭时,它就会被释放:嵌入了 io.Closer 的接口会有一个 Close 方法,它先调用平台端的 close(),再释放对象。Close 之后的调用会返回 native.ErrClosed。

  • 线程。 方法在平台端同步运行,所在线程就是调用它的 goroutine 所在的线程:中间没有线程切换,也没有队列。这让调用的开销很小,但也意味着在主线程上调用方法会阻塞主线程。耗时的工作应该放在接收 context 的方法里,或者从 goroutine 中调用。

    • 在方法或接口上加 //asuka:mainthread,它就会在平台的主线程上运行,UIKit、AppKit 和 Android 的视图都要求这样做。从主线程发起的调用会立即执行。这时 Swift 中的协议会标注为 @MainActor。
    • 其他方法在调用它的线程上运行,所以它们的实现必须是线程安全的。
    • JavaScript 是单线程的,所以这些在 Web 上不适用。

调用的开销

一次调用会把参数编码到一个池化的缓冲区中,然后只跨越一次边界:iOS 上经过 C,Android 上经过 JNI,Mac 上通过函数指针进入 Swift 库,Web 上经过 JavaScript 宿主。返回结果也以同样的方式解码。在稳定状态下,一次字符串方法调用在 Mac 上大约耗时 0.5µs,只有返回结果会分配内存。整个过程中没有任何反射。

声明视图

在名为 <View>Props 的结构体上加 //asuka:view,它就成为一个视图:

Go
//asuka:view
type SegmentedProps struct {
	Items    []string             // 只设置一次
	Selected signal.Accessor[int] // 每次变化都会重新设置
	Changed  func(index int)      // 一个事件
	Style    ui.Style
}

生成的 func Segmented(p SegmentedProps) ui.Node 会显示这个视图,用法和任何组件一样:

Go
segmented.Segmented(segmented.SegmentedProps{
	Items:    []string{"Day", "Week", "Month"},
	Selected: selected,
	Changed:  func(i int) { selected.Set(i) },
})
  • 类型在 类型 表中列出的字段,是只设置一次的属性。
  • signal.Accessor[T] 字段是每次变化都会重新设置的属性。视图的 effect 和其他 effect 一样有 owner,会随视图一起销毁。
  • 不返回值的 func 字段是事件,它在主线程上运行。
  • 如果结构体有 Style ui.Style 字段,它就是视图的样式;写成 Style ui.Styler 时也可以接受 StyleFunc。

每个平台都以协议的形式实现视图:包括视图本身、每个属性对应的 setter,以及创建时传入的事件对象:

Swift
@MainActor final class SwiftSegmented: Segmented {
    let control = UISegmentedControl()
    let events: SegmentedEvents
    var view: AsukaPlatformView { control }          // iOS 上是 UIView,macOS 上是 NSView
    init(_ events: SegmentedEvents) { self.events = events; ... }
    func setItems(_ value: [String]) { ... }
    func setSelected(_ value: Int) { ... }
    // 值变化时:events.changed(index: i)
}
@MainActor func makeSegmented(_ events: SegmentedEvents) -> any Segmented { SwiftSegmented(events) }

属性的设置顺序是不确定的,所以如果视图依赖其中两个属性(比如从若干项中选中一项),就要先把收到的值保存下来,等两者都到齐后再一起应用。宿主会像处理自己的视图一样确定它的尺寸:

  • Swift 中用 sizeThatFits(iOS)或 fittingSize(macOS),如果视图遵循 AsukaSizing,则用它的 sizeThatFits;
  • Android 上用视图自己的测量逻辑;
  • JavaScript 中用 measure(c);
  • Go 中用 Measure(见下文)。

没有固有尺寸的视图,尺寸由它的样式决定。如果视图持有需要释放的资源,在 Swift 中让它遵循 AsukaDestroying,在 JavaScript 中给它一个 destroy 方法,在 Go 中让它实现 native.Destroyer。

examples/media/segmented 是一个在所有平台上都完整实现的视图示例。

iOS 和 macOS:Swift

包中的 Swift 代码是一个 Swift 包:Package.swift 位于 Go 包的根目录,源码放在 swift/ 中。同一个包同时服务 iOS 和 macOS,两者有差异的地方用 #if canImport(UIKit) 区分:

Swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "BatteryNative",
    platforms: [.iOS("16.0"), .macOS("11.0")],
    products: [.library(name: "BatteryNative", targets: ["BatteryNative"])],
    dependencies: [
        .package(url: "https://github.com/egoist/asuka", from: "0.1.0"),
        .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0"),
    ],
    targets: [
        .target(name: "BatteryNative", dependencies: [
            .product(name: "Asuka", package: "asuka"),
            .product(name: "FirebaseAnalytics", package: "firebase-ios-sdk"),
        ], path: "swift"),
    ]
)

这个包完全由你掌控:它的依赖、target、资源、C target 和二进制 target 都是标准的 SwiftPM 写法,和任何 Swift 包一样。CLI 会用 xcodebuild 把它和应用的其他原生包以及 Asuka 的宿主一起构建,把结果链接进应用,并把生成的动态框架和资源包嵌入应用。asuka(产品名为 Asuka)是生成的代码所依赖的 Asuka 宿主;CLI 会把它指向 CLI 自带的那份副本,所以它总是与应用的 Go 代码版本一致。包中声明的 platforms 会真正起作用:如果应用的目标 iOS 或 macOS 版本更低,构建会失败并说明这个包的要求;只列出其中一个平台的包(platforms: [.iOS("16.0")])只会为那个平台构建。

要在 Xcode 中编辑它,打开 Package.swift 即可。Xcode 会从 URL 解析 asuka,所以生成的代码和你的代码在 Xcode 里的类型检查结果和构建时一致。

你的 Swift 代码要为每个接口和每个视图各提供一个函数,生成文件的头部会列出它们:

Swift
func makeKitchen() throws -> any Kitchen
@MainActor func makeOven() throws -> any Oven      // 一个 //asuka:mainthread 接口
@MainActor func makeDial(_ events: DialEvents) -> any Dial

在平台端创建、再交给 Go 的对象(比如 Open 返回的 Oven)由你的代码创建。如果要在后台方法中创建一个 main actor 对象,可以用 try AsukaNative.onMain { SwiftOven() }。

Package.swift 旁边的 Info.plist 声明这个包对 Apple 应用的要求:

  • 权限用途说明(如 NSBluetoothAlwaysUsageDescription):如果应用自己在 asuka.json 中提供了,以应用的为准;
  • 数组类型的键(如 UIBackgroundModes):会与应用和其他包的值合并;
  • 其他任何键:除非应用自己设置了,否则就使用包里的值。

iOS.entitlements 和 macOS.entitlements 声明它需要的授权(entitlements)。如果描述文件缺少其中某项,签名构建会失败,并指出是哪个包要求的。

Android:Kotlin

包中的 Kotlin 代码是 android/ 下的一个 Gradle 库模块:

Kotlin
// android/build.gradle.kts
plugins {
    id("com.android.library")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.example.battery"
}

dependencies {
    implementation("com.google.code.gson:gson:2.11.0")
}

Kotlin 源码放在 src/main/kotlin/ 下,包名与生成文件中的一致:也就是把 Go 包的导入路径中的主机名倒过来放在最前面(github.com/you/app/battery 对应 com.github.you.app.battery)。CLI 会把这个模块加入应用的 Gradle 构建,并统一设置以下内容,所以模块里只需要写命名空间和依赖:

  • SDK 版本、Java 17 和 Kotlin 工具链;
  • 对 Asuka 宿主库(dev.asuka)的依赖,它会带入 AndroidX、Material Components 和协程。

模块的 src/main/AndroidManifest.xml(权限、服务、广播接收器、内容提供者)会合并到应用的清单中,和任何库一样。

Kotlin
private class AndroidBattery(private val context: Context) : Battery {
    override fun level(): Double { ... }
    override suspend fun watch(changed: (Double) -> kotlin.Unit) { ... }
}
fun makeBattery(): Battery = AndroidBattery(AsukaModules.context)

AsukaModules.context 是应用的 application context,可以在任何线程上使用;AsukaModules.activity 是当前显示应用的 activity(如果有的话),供需要 activity 的操作使用(对话框、权限请求),要在主线程上使用。视图的事件对象中有创建该视图时使用的带主题的 context(events.context)。AsukaModules.onMain { } 会在主线程上创建对象,作用和 Swift 的 AsukaNative.onMain 相同。生成的代码里,Kotlin 的 Unit 写作 kotlin.Unit,所以 Go 中可以放心地定义名为 Unit 的类型。

想在编辑模块时获得代码补全,可以在 Android Studio 中打开应用的 Android 项目(由 asuka build --platform android 生成)。原生包的模块在其中以 :native-<package> 的形式出现。

Web:JavaScript

包中的 JavaScript 是 web/<package>.js,它是一个 ES 模块,导出生成的 web/<package>.asuka.js 所导入的那些函数:

JavaScript
export function makeBattery() {
  return {
    async level() { return (await navigator.getBattery()).level; },
    async watch(signal, changed) { ... },
  };
}

export function makeSegmented(events) {
  const el = document.createElement("div");
  return { el, setItems(v) { ... }, setSelected(v) { ... }, measure: () => [216, 32] };
}

方法可以返回 promise,即使它在 Go 端没有 context 参数。要抛出包中声明的错误,就使用生成的错误类(import { KitchenError } from "./kitchen.asuka.js")。web/ 中的每个文件都会随应用一起发布,所以模块可以导入自己的辅助脚本,也可以相对于 import.meta.url 获取自己的资源。其中任何文件变化时,asuka dev --platform web 都会重新加载页面。参见 Web。

Windows 和 Linux:Go

桌面端宿主在自己的进程中运行应用的 Go 代码,所以原生包在这些平台上的代码也是 Go:Windows 上通过 syscall 调用 Win32,Linux 上通过 purego 调用 GTK 4,都不需要 cgo。为这些平台构建的文件在 init 中把实现交给 implement<T>:

Go
//go:build windows || (linux && !android)

package battery

func init() {
	implementBattery(func() (Battery, error) { return desktopBattery{}, nil })
}

Go 实现就是接口本身,没有编码,也不需要跨越边界。视图的 Go 实现是 <View>Native,由它的事件对象创建:

Go
func init() {
	implementSegmented(func(events SegmentedEvents) SegmentedNative { ... })
}

type SegmentedNative interface {
	Handle() uintptr // Windows 上是 HWND,Linux 上是 GtkWidget
	SetItems(items []string)
	SetSelected(selected int)
}

events.Changed(i) 用来发送事件。events.Context.Parent() 是原生视图要放入的那个宿主视图:在 Windows 上,在其中创建一个子窗口(WS_CHILD);在 Linux 上,不要给控件设置父级,宿主会负责把它加进去。视图还可以实现:

  • Measure(width, height float32) (float32, float32):在布局给出的空间下返回它的固有尺寸(NaN 表示不受约束)。没有实现它时,GTK 会自己测量控件;Windows 上则没有人会测量,尺寸由样式决定。
  • Destroy():释放它持有的资源。
  • ParentMessage(msg uint32, wparam, lparam uintptr) (uintptr, bool)(Windows):接收它的控件发给父窗口的 WM_COMMAND、WM_NOTIFY、WM_*SCROLL 和 WM_CTLCOLOR* 消息。

构建约束要用 windows 和 linux && !android,因为 Android 构建也满足 linux。Mac 也可以使用 Go:为 darwin && !ios 构建、并调用了 implement<T> 的文件,在 Mac 上会优先于包中的 Swift 代码,通过 purego 的 objc 使用 AppKit。

在桌面端,只要 Go 实现是在主线程上被调用的,它做的所有事情都在主线程上运行,和宿主一样。GTK 信号需要一个 C 回调(purego.NewCallback),而这些回调永远不会被释放,所以只创建一个,再按控件分发。examples/media/segmented/segmented_linux.go 就是这样做的。

开发原生代码

  • iOS 和 Android:asuka dev 会构建包含 Go 代码和原生包的应用并安装,保存时重新构建,Swift、Kotlin、Package.swift、build.gradle.kts、Info.plist 和清单文件的修改都包括在内。没有原生包的应用则以热重载模式运行在开发 App 里,开发 App 只包含 Asuka 自己的原生代码。参见 开发。
  • macOS:原生包的 Swift 代码会构建进应用的 Swift 库,同样只在有变化时才重新构建,然后应用重新运行,signal 的值会保留下来。
  • Windows 和 Linux:原生代码就是 Go,和应用一起构建。
  • Web:包的 JavaScript 变化时,页面会重新加载。

一个包在 iOS 或 macOS 上第一次构建时会解析它的 Swift 依赖,之后的构建会复用它们。

限制

  • asuka build --platform ios --xcframework(把应用嵌入你自己的 Xcode 项目)不支持带 Swift 代码的原生包:请改为把它们的 Swift 包添加到那个项目中。
  • asuka build --platform android --aar 不包含原生包的模块:请把它们添加到嵌入该产物的 Gradle 项目中。
  • 如果两个原生包的目录同名,它们不能都带有 Swift 代码:SwiftPM 是按目录名识别本地包的。