# 原生代码

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 的对比](https://asuka.egoist.dev/zh/docs/parity.md) 中列出了它们。如果你需要某个还没有包封装的平台 API、某个厂商的 SDK，或者 Asuka 没有提供的视图（地图、图表、只有某个平台才有的控件），就可以写一个原生包。

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

## 创建原生包

```sh
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`，它就成为一个模块：

```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` 所导入的那些函数：

```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](https://asuka.egoist.dev/zh/docs/web.md#%E5%8E%9F%E7%94%9F%E4%BB%A3%E7%A0%81)。

## 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 自己的原生代码。参见 [开发](https://asuka.egoist.dev/zh/docs/development.md#%E5%9C%A8%E6%89%8B%E6%9C%BA%E4%B8%8A%E7%83%AD%E9%87%8D%E8%BD%BD%E6%A8%A1%E5%BC%8F%E4%B8%8E%E6%9E%84%E5%BB%BA%E7%9A%84%E5%BA%94%E7%94%A8)。
* **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 是按目录名识别本地包的。
