ネイティブコード
ガイドEpisode 13

第拾参話

ネイティブコード

ネイティブパッケージ。Go で宣言し、各プラットフォームのプロジェクトで実装する型付きのモジュールとビュー(iOS と macOS は Swift、Android は Kotlin、Web は JavaScript、Windows と Linux は Go)

Asuka がラップしていない機能には、ネイティブパッケージを通じてアクセスします。ネイティブパッケージは、モジュール(インターフェース)やビュー(その props)を Go で宣言する Go パッケージで、それを各プラットフォームが自身の言語で実装します。iOS と macOS では Swift、Android では Kotlin、Web では JavaScript、Windows と Linux では Go です。asuka native generate が、すべての側の型付きのグルーコードを生成します。Go からは、モジュールのメソッドを普通の Go のインターフェースと同じように呼び出し、ビューを普通のコンポーネントと同じように表示できます。値はコンパクトなバイナリ形式でやり取りされ、JSON もリフレクションも使いません。

Go
// Package battery はデバイスのバッテリーを扱います。
package battery

//asuka:native
type Battery interface {
	// Level は充電量で、0 から 1 の値です。
	Level() (float64, error)
	// Watch は、ctx が終了するまで、充電量が変わるたびに changed を呼びます。
	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
// Package 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 または整数の名前付き型 定数ごとに static を持つ RawRepresentable の構造体 定数ごとに定数を持つ value class 定数を収めた凍結(frozen)オブジェクト
//asuka:native のインターフェース(引数または戻り値) any T T オブジェクト
func(...)(引数) クロージャ 関数 関数

Asuka が動くすべてのプラットフォームで、Go の int と uint は 64 ビットです。構造体からは、エクスポートされたフィールドだけが宣言順に渡されます。オブジェクト(ネイティブのインターフェース)を渡せるのは引数か戻り値としてだけで、スライス、マップ、構造体の中には入れられません。それ以外の型を使うと、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 をラップしたエラーになります。
    • それ以外のもの。ドメイン(NSError のドメインや例外のクラス)、コード、メッセージを持つ *native.Error になります。

    戻り値に error を持たないメソッドでは、プラットフォーム側が例外を投げると Go 側でパニックが起きます。失敗する可能性のあるメソッドには、必ず error を宣言してください。

  • 最初の引数が context.Context のメソッドは非同期になります。Swift では async、Kotlin では suspend、JavaScript では最初の引数に AbortSignal を受け取る async 関数です。Go 側の呼び出しは、処理の結果が返るまで、呼び出したゴルーチンをブロックします。コンテキストが終了すると、プラットフォーム側のタスクはキャンセルされ(Swift のタスク、Kotlin のコルーチン、AbortSignal の abort)、Go は ctx.Err() を返します。ネットワーク、ファイル、ダイアログの応答待ちなど、時間がかかる処理に使ってください。

  • コールバック:func 型の引数には 2 種類あります。

    • 通知:何も返さない関数(progress func(done float64))。プラットフォーム側は、呼び出しの実行中でも、呼び出しが戻ったあとでも(ウォッチャーなど)、どのスレッドからでも、何度でも呼べます。Go 側ではメインスレッドで実行される(ui.Post)ので、シグナルに値を設定できます。
    • 問い合わせ:何かを返す関数(question func(q string) bool)。呼んだスレッドの上ですぐに実行され、その結果がプラットフォームに返ります。

    コールバックは、それを渡した先のオブジェクトが存在するあいだ有効です。

  • オブジェクト:メソッドは、ほかのネイティブインターフェースを返せます(Open は Oven を返します)。それぞれがプラットフォーム側では独立したオブジェクトです。Go がそれを保持しなくなったとき、またはクローズされた時点で解放されます。io.Closer を埋め込んだインターフェースの Close は、プラットフォーム側の close() を呼んでからオブジェクトを解放します。Close のあとの呼び出しは native.ErrClosed を返します。

  • スレッド:メソッドはプラットフォーム側で、呼び出したゴルーチンのスレッドの上で同期的に実行されます。スレッドの切り替えもキューもありません。そのため呼び出しは軽量ですが、メインスレッドから呼んだメソッドはメインスレッドをブロックします。時間のかかる処理は、コンテキストを受け取るメソッドで行うか、ゴルーチンから呼んでください。

    • メソッドやインターフェースに //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] のフィールドは、値が変わるたびに設定し直されるプロパティです。そのためのビューのエフェクトは、ほかのエフェクトと同じくオーナーを持ち、ビューがなくなると一緒に破棄されます。
  • 何も返さない func はイベントです。メインスレッドで実行されます。
  • 構造体に Style ui.Style があれば、それがビューのスタイルになります。

各プラットフォームは、ビューをプロトコルとして実装します。プロトコルには、ビュー本体、プロパティごとのセッター、そして作成時に受け取るイベントオブジェクトが含まれます。

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(後述)

固有のサイズを持たないビューは、スタイルからサイズを決めます。解放すべきものを持つビューは、AsukaDestroying に準拠させるか、JavaScript なら destroy を持たせるか、Go なら native.Destroyer を実装します。

examples/media/segmented は、すべてのプラットフォームで動く完全なビューの例です。

iOS と macOS:Swift

パッケージの Swift のコードは、Swift パッケージとして書きます。Go パッケージのルートに Package.swift を置き、ソースは 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"),
    ]
)

このパッケージは自分のもので、自由に書けます。依存関係、ターゲット、リソース、C ターゲット、バイナリターゲットは、ほかのパッケージと同じく SwiftPM の仕組みそのものです。CLI はこれを、アプリのほかのネイティブパッケージや Asuka のホストとまとめて xcodebuild でビルドし、その結果をアプリにリンクして、生成された動的フレームワークとリソースバンドルを埋め込みます。asuka(プロダクト Asuka)は、生成コードが土台にする Asuka のホストです。CLI はこれを CLI 自身が持つコピーに向けるので、常にアプリの Go のコードと一致します。パッケージが宣言する platforms も考慮されます。それより古い iOS や macOS 向けにアプリをビルドすると、パッケージが必要とするバージョンを示して失敗します。また、どちらか一方だけを挙げたパッケージ(platforms: [.iOS("16.0")])は、そのプラットフォーム向けにだけビルドされます。

Xcode で編集するには、Package.swift を開きます。Xcode は asuka をその URL から解決するので、生成コードも自分のコードも、ビルド時と同じように型チェックされます。

自分で書く 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)も、自分のコードで作ります。バックグラウンドで動くメソッドからメインアクターのオブジェクトを作るには、try AsukaNative.onMain { SwiftOven() } を使います。

Package.swift の隣に置いた Info.plist には、パッケージが Apple のアプリに求めるものを書きます。

  • 利用目的の説明文(NSBluetoothAlwaysUsageDescription)。アプリ自身の asuka.json にある説明文が優先されます。
  • 配列(UIBackgroundModes)。アプリやほかのパッケージの配列と結合されます。
  • そのほかのキー。アプリが設定していなければ、その値が設定されます。

エンタイトルメントは iOS.entitlements と macOS.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 ビルドに組み込み、そのビルドが次の設定を行います。そのため、モジュールに書く必要があるのは namespace と依存関係だけです。

  • 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 はアプリケーションのコンテキストで、どのスレッドからでも使えます。AsukaModules.activity は、アプリを表示しているアクティビティがあればそのアクティビティで、ダイアログや権限のリクエストなど、アクティビティが必要な処理のためにメインスレッドで使います。ビューのイベントオブジェクトは、そのビューが作られたテーマ付きのコンテキストを持っています(events.context)。AsukaModules.onMain { } は、Swift の AsukaNative.onMain と同じく、メインスレッドでオブジェクトを作ります。生成コードでは Kotlin の Unit を kotlin.Unit と書くので、Go 側に Unit という名前の型があっても問題ありません。

補完を使ってモジュールを編集するには、アプリの Android プロジェクト(asuka build --platform android が書き出します)を Android Studio で開きます。モジュールは、その中に :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 側でコンテキストを受け取らないメソッドでも同じです。パッケージで宣言したエラーは、生成されたクラスで投げます(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 による実装はインターフェースそのものなので、エンコードも境界越えもありません。ビューの実装は <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* を受け取ります。

Android のビルドも linux を満たすので、ビルド制約には windows と linux && !android を使ってください。Mac でも Go を使えます。darwin && !ios 向けにビルドされ、implement<T> を呼ぶファイルがあれば、Mac ではそれがパッケージの Swift より優先されます。その場合、AppKit は purego の objc 経由で使います。

デスクトップでは、メインスレッドから呼ばれた 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、マニフェストの変更も対象です。ネイティブパッケージを持たないアプリは、ホットリロードモードで開発用アプリの中で実行されます。開発用アプリに入っているのは、Asuka 自身のネイティブコードだけです。開発を参照してください。
  • macOS:パッケージの Swift は、アプリの Swift ライブラリにビルドされます。こちらも変更があったときだけビルドされ、アプリはシグナルの値を保ったまま再実行されます。
  • Windows と Linux:ネイティブコードは Go なので、アプリと一緒にビルドされます。
  • Web:パッケージの JavaScript が変わると、ページがリロードされます。

iOS や macOS でパッケージを初めてビルドするときに Swift の依存関係を解決し、以降のビルドではそれを再利用します。

制限

  • asuka build --platform ios --xcframework(自分の Xcode プロジェクトにアプリを埋め込む場合)は、Swift を含むネイティブパッケージを受け付けません。代わりに、それらの Swift パッケージをそのプロジェクトに追加してください。
  • asuka build --platform android --aar には、ネイティブパッケージのモジュールは含まれません。埋め込み先の Gradle プロジェクトに追加してください。
  • ディレクトリ名が同じネイティブパッケージが二つあると、両方に Swift を持たせることはできません。SwiftPM は、ローカルパッケージをディレクトリ名で識別するためです。