第拾陸話
原生代码
原生包——在 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,也不用反射。
// 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
}b, err := battery.NewBattery()
level, err := b.Level()// 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 的错误,视图则什么都不显示(宿主会在日志中提示缺少这个视图)。
创建原生包
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 方法,每一端都实现了它。你只需要重命名并补充内容。一个原生包的结构如下:
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,它就成为一个模块:
// 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,它就成为一个视图:
//asuka:view
type SegmentedProps struct {
Items []string // 只设置一次
Selected signal.Accessor[int] // 每次变化都会重新设置
Changed func(index int) // 一个事件
Style ui.Style
}生成的 func Segmented(p SegmentedProps) ui.Node 会显示这个视图,用法和任何组件一样:
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,以及创建时传入的事件对象:
@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-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 代码要为每个接口和每个视图各提供一个函数,生成文件的头部会列出它们:
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 库模块:
// 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(权限、服务、广播接收器、内容提供者)会合并到应用的清单中,和任何库一样。
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 所导入的那些函数:
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:build windows || (linux && !android)
package battery
func init() {
implementBattery(func() (Battery, error) { return desktopBattery{}, nil })
}Go 实现就是接口本身,没有编码,也不需要跨越边界。视图的 Go 实现是 <View>Native,由它的事件对象创建:
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 是按目录名识别本地包的。