LoginSignup
11
0

More than 3 years have passed since last update.

Kotlin MultiplatformでUnity向けネイティブプラグインを作ってみる

Last updated at Posted at 2019-12-16

本記事は、サムザップ 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 ファイルを編集して、プロジェクトを構成します。

build.gradle.kts
plugins {
    kotlin("multiplatform") version("1.3.61")
}
repositories {
    mavenCentral()
}

今回はUnityから使うプラグインを作成しますので、
MacOS / Windows向けに動的ライブラリを、iOS向けに静的ライブラリを出力するように設定します。
また、iOS向けプラグインは実機ではなくシミュレータで試しますので、iosArm32iosArm64ではなくiosX64を選択します。

build.gradle.kts
kotlin {
    macosX64 { binaries { sharedLib { } } }
    mingwX64 { binaries { sharedLib { } } }
    iosX64 { binaries { staticLib { } } }
}

build.gradle.kts ファイルの完成形はこちらになります。

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フォルダ以下に作成します。
本来の趣旨では出来る限り実装をここに集めて、各プラットフォーム毎にメンテナンスしなければならないコードを減らすのですが、
今回は特に共通化出来る実装がないので単なるメソッドの宣言に留まっています。

src/commonMain/kotlin/Clipboard.kt
package clipboard

expect fun setString(value: String)

続いて各プラットフォーム向けの実装を、それぞれのソースフォルダに作成していきます。

src/macosX64Main/kotlin/Clipboard.kt
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)
}
src/mingwX64Main/kotlin/Clipboard.kt
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)
}
src/iosX64Main/kotlin/Clipboard.kt
package clipboard

import platform.UIKit.UIPasteboard

actual fun setString(value: String) {
    UIPasteboard.generalPasteboard.string = value
}

プラグインのビルド

準備が整いましたので、gradle assembleでビルドします。
ビルドが完了したらbuildフォルダ以下に、各プラットフォーム向けのプラグインが出力されています。
ただしホストOSによってビルド出来るプラットフォームが限定されていますので、
PCがWindowsであればmingwX64のみが、MacOSの場合はiosX64macosX64が作られていると思います。

./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プロジェクトでの作業

プラグインがビルド出来ましたので、それを使用するクライアントの実装します。

クライアントでは、抽象化されたクリップボードのインタフェースを用意して、
これを各ネイティブプラグインを呼び出す形で実装していきます。

Assets/Scripts/Clipboard/IClipboard.cs
public interface IClipboard {
    void SetString(string value);
}

MacOS / Windows向けプラグイン呼び出し側実装

ライブラリと一緒に出力されたヘッダファイルを見ますと、
ずらずらと型定義が並んだ後に、下記のような構造体が定義されています。

Assets/Plugins/MacOS/x64/libClipboard_api.h
...
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#で呼べるようにします。

Assets/Scripts/Clipboard/Platforms/MacOSClipboard.cs
#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に変更します。

Assets/Scripts/Clipboard/Platforms/iOSClipboard.cs
...
[DllImport("__Internal", EntryPoint = "libClipboard_symbols")]
private static extern IntPtr GetSymbols();
...

実行

プラグインを呼び出すコードが出来ましたので、iOSのシミュレータを立ち上げて実行してみます。
Buttonを押すと、InputFieldの文字列でネイティブプラグインを呼び出して、クリップボードにコピーされるようにしたのですが、
実際にそのように動作していることが分かります。
output.gif

まとめ

個人的にはプラットフォームのAPIがkotlinで定義されているかのような使い心地で、非常にコーディングがしやすいと感じました。
プラットフォーム固有の知識は必要ですが、実装言語を統一できるのは大きなメリットだと思います。
まだまだ試験的な段階ではありますが、バージョンを重ねるごとに安定してきていますので、
kotlin-multiplatformの今後に期待したいと思います。

12/16は、@norimatsu_yusuke さんの記事です。

11
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
0