本記事は、サムザップ Advent Calendar 2019 #1 の12/15の記事です。
12/14の記事は@shoichi1023さんのEventを使用したEloquentの水平分割でした。
Kotlin Multiplatformとは、複数のプラットフォーム向けに単一の言語(Kotlin)でアプリケーションやライブラリを作成出来る仕組みです。
これを活用するとコードの重複を排しつつ、ネイティブの機能を十分に活用することが出来るのではないかと思い、実際どんなものなのか試してみました。
お題はシンプルに、与えられた文字列をクリップボードに設定するプラグインを作ってみます。
環境情報
この記事は以下の環境のもと作成いたしました。
- MacOS Mojave 10.14.6
- Unity 2019.2.5f1
- Gradle 6.0.1
- kotlin-multiplatform 1.3.61
Kotlinプロジェクトの作成
まずgradle init
コマンドでプロジェクトを作成します。
いくつか設定を聞かれますので、それぞれ
Select type of project to generate:
1: basic
2: application
3: library
4: Gradle plugin
Enter selection (default: basic) [1..4]
プロジェクトタイプは1: basic
を、
Select build script DSL:
1: Groovy
2: Kotlin
Enter selection (default: Groovy) [1..2]
ビルドスクリプトは2: Kotlin
を、
Project name (default: Plugin):
プロジェクト名にはClipboard
を設定します。
次に、生成された build.gradle.kts ファイルを編集して、プロジェクトを構成します。
plugins {
kotlin("multiplatform") version("1.3.61")
}
repositories {
mavenCentral()
}
今回はUnityから使うプラグインを作成しますので、
MacOS / Windows向けに動的ライブラリを、iOS向けに静的ライブラリを出力するように設定します。
また、iOS向けプラグインは実機ではなくシミュレータで試しますので、iosArm32
やiosArm64
ではなくiosX64
を選択します。
kotlin {
macosX64 { binaries { sharedLib { } } }
mingwX64 { binaries { sharedLib { } } }
iosX64 { binaries { staticLib { } } }
}
build.gradle.kts ファイルの完成形はこちらになります。
plugins {
kotlin("multiplatform") version("1.3.61")
}
repositories {
mavenCentral()
}
kotlin {
macosX64 { binaries { sharedLib { } } }
mingwX64 { binaries { sharedLib { } } }
iosX64 { binaries { staticLib { } } }
}
プラグインの実装
全プラットフォームで共通の実装を src/commonMain/kotlinフォルダ以下に作成します。
本来の趣旨では出来る限り実装をここに集めて、各プラットフォーム毎にメンテナンスしなければならないコードを減らすのですが、
今回は特に共通化出来る実装がないので単なるメソッドの宣言に留まっています。
package clipboard
expect fun setString(value: String)
続いて各プラットフォーム向けの実装を、それぞれのソースフォルダに作成していきます。
package clipboard
import platform.AppKit.NSPasteboard
import platform.AppKit.NSPasteboardTypeString
actual fun setString(value: String) {
val pasteboard = NSPasteboard.generalPasteboard
pasteboard.clearContents()
pasteboard.setString(value, NSPasteboardTypeString)
}
package clipboard
import kotlinx.cinterop.*
import platform.windows.*
import platform.posix.*
actual fun setString(value: String) {
val len = wcslen(value.wcstr) + 1.toULong();
val hMem = GlobalAlloc(GHND, len * wchar_t.SIZE_BYTES.toULong())
val ptr = GlobalLock(hMem)
if (ptr != null) {
wcscpy_s(ptr.reinterpret(), len, value.wcstr)
}
GlobalUnlock(hMem)
val clipboard = OpenClipboard(null)
if (clipboard != 0) {
EmptyClipboard()
SetClipboardData(CF_UNICODETEXT, hMem)
CloseClipboard()
}
GlobalFree(hMem)
}
package clipboard
import platform.UIKit.UIPasteboard
actual fun setString(value: String) {
UIPasteboard.generalPasteboard.string = value
}
プラグインのビルド
準備が整いましたので、gradle assemble
でビルドします。
ビルドが完了したらbuildフォルダ以下に、各プラットフォーム向けのプラグインが出力されています。
ただしホストOSによってビルド出来るプラットフォームが限定されていますので、
PCがWindowsであればmingwX64
のみが、MacOSの場合はiosX64
とmacosX64
が作られていると思います。
./build
├── bin
│ ├── iosX64
│ │ ├── debugStatic
│ │ │ └── ...
│ │ └── releaseStatic
│ │ ├── libclipboard.a
│ │ └── libclipboard_api.h
│ └── macosX64
│ ├── debugShared
│ │ └── ...
│ └── releaseShared
│ ├── libclipboard.dylib
│ └── libclipboard_api.h
...
これらのReleaseビルドされたプラグインをUnityプロジェクトの、Assets/Plugins
配下にコピーします。
./Assets/Plugins
├── MacOS
│ ├── x64
│ │ ├── libClipboard.dylib
│ │ ├── libClipboard.dylib.meta
│ │ ├── libClipboard_api.h
│ │ └── libClipboard_api.h.meta
│ └── x64.meta
├── MacOS.meta
├── iOS
│ ├── x64
│ │ ├── libClipboard.a
│ │ ├── libClipboard.a.meta
│ │ ├── libClipboard_api.h
│ │ └── libClipboard_api.h.meta
│ └── x64.meta
├── iOS.meta
...
ビルド毎に手動でコピーするのは手間ですので、
実際には build.gradle.kts にコピーするタスクを定義して、ビルド後自動的に実行されるようにするといいでしょう。
Unityプロジェクトでの作業
プラグインがビルド出来ましたので、それを使用するクライアントの実装します。
クライアントでは、抽象化されたクリップボードのインタフェースを用意して、
これを各ネイティブプラグインを呼び出す形で実装していきます。
public interface IClipboard {
void SetString(string value);
}
MacOS / Windows向けプラグイン呼び出し側実装
ライブラリと一緒に出力されたヘッダファイルを見ますと、
ずらずらと型定義が並んだ後に、下記のような構造体が定義されています。
...
typedef struct {
/* Service functions. */
void (*DisposeStablePointer)(libClipboard_KNativePtr ptr);
void (*DisposeString)(const char* string);
libClipboard_KBoolean (*IsInstance)(libClipboard_KNativePtr ref, const libClipboard_KType* type);
libClipboard_kref_kotlin_Byte (*createNullableByte)(libClipboard_KByte);
libClipboard_kref_kotlin_Short (*createNullableShort)(libClipboard_KShort);
libClipboard_kref_kotlin_Int (*createNullableInt)(libClipboard_KInt);
libClipboard_kref_kotlin_Long (*createNullableLong)(libClipboard_KLong);
libClipboard_kref_kotlin_Float (*createNullableFloat)(libClipboard_KFloat);
libClipboard_kref_kotlin_Double (*createNullableDouble)(libClipboard_KDouble);
libClipboard_kref_kotlin_Char (*createNullableChar)(libClipboard_KChar);
libClipboard_kref_kotlin_Boolean (*createNullableBoolean)(libClipboard_KBoolean);
libClipboard_kref_kotlin_Unit (*createNullableUnit)(void);
/* User functions. */
struct {
struct {
struct {
void (*setString)(const char* value);
} clipboard;
} root;
} kotlin;
} libClipboard_ExportedSymbols;
extern libClipboard_ExportedSymbols* libClipboard_symbols(void);
...
どうやら公開されたシンボルをまとめた構造体を取得して、その構造体から個別のAPIを呼び出す作りになっているようです。
ちょっと横着して目当てのAPIのみC#で呼べるようにします。
#if UNITY_STANDALONE_OSX
using System;
using System.Runtime.InteropServices;
public class MacOSClipboard : IClipboard
{
[StructLayout(LayoutKind.Explicit)]
private struct Symbols
{
[FieldOffset(8 * 12)] // 8bytes * 12
public IntPtr setString;
}
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate void SetStringDelegate([MarshalAs(UnmanagedType.LPStr)]string value);
[DllImport("libClipboard", EntryPoint = "libClipboard_symbols")]
private static extern IntPtr GetSymbols();
private Symbols _symbols;
private SetStringDelegate _setString;
public MacOSClipboard()
{
var ptr = GetSymbols();
_symbols = Marshal.PtrToStructure<Symbols>(ptr);
_setString = Marshal.GetDelegateForFunctionPointer<SetStringDelegate>(_symbols.setString);
}
public void SetString(string value) {
_setString(value);
}
}
#endif
これはMacOS向けの実装ですが、シンボルの並びに変更がない限りWindows向けでもほぼ同一の実装になるでしょう。
なお今回はDLLの読み込みをDllImport
属性で簡単に済ませましたが、実際のプラグイン開発時は頻繁にDLLをビルドし直すと思いますので
Unity でネイティブプラグインを動的にロード/アンロードする (とはまりポイントを回避する)の記事のように、
動的にDLLをロード/アンロードする仕組みを用意されるといいと思います。
iOS向けプラグイン呼び出し側実装
実装はほぼ MacOS / Windows と同一です。
一点、DllImportのdllNameのみ__Internalに変更します。
...
[DllImport("__Internal", EntryPoint = "libClipboard_symbols")]
private static extern IntPtr GetSymbols();
...
実行
プラグインを呼び出すコードが出来ましたので、iOSのシミュレータを立ち上げて実行してみます。
Buttonを押すと、InputFieldの文字列でネイティブプラグインを呼び出して、クリップボードにコピーされるようにしたのですが、
実際にそのように動作していることが分かります。
まとめ
個人的にはプラットフォームのAPIがkotlinで定義されているかのような使い心地で、非常にコーディングがしやすいと感じました。
プラットフォーム固有の知識は必要ですが、実装言語を統一できるのは大きなメリットだと思います。
まだまだ試験的な段階ではありますが、バージョンを重ねるごとに安定してきていますので、
kotlin-multiplatformの今後に期待したいと思います。
12/16は、@norimatsu_yusuke さんの記事です。