エフアンダーバー

個人開発の記録

【Unity】 ネイティブプラグインのリロード

Unityにてネイティブプラグインのリロードに苦労したのでメモ。 Windowsを想定した記事ですが、おそらく各OSに対応したAPIを叩けば他のOSでも同様のことはできるはず。

基本的に次の記事の内容をやってみたよ、という記事です。

qiita.com

自力でやることに拘らなければ次の記事で紹介されているツールを使った方がいいかもしれません。

tips.hecomi.com

ネイティブプラグインのリロード問題

Unityでネイティブプラグインを書いてみようと調べていたところ、 ネイティブプラグインにはDLLの再読み込みにUnityエディタの再起動が必要となる問題があるとのこと。 なんでもエディタ拡張のためにゲーム再生時以外もUnityエディタがDLLを握ってしまうため、 新しいDLLに上書きすることができなくなるみたいです。 しかし、ネイティブプラグインのデバッグ時に、コードを少し書き換えるたびにエディタを再起動、 なんてことはやってられないのでなんとかしたいところです。

前掲、後者の記事のツールでは、DLLインジェクションによって、 旧DLLのエクスポートテーブルを無理やり新DLLの関数のアドレスに書き換えることで疑似的にリロードされたように見せかけるそうです。 ただ、ツールのREADMEにも書いてあるように、新たな関数の追加には対応できませんし、 DLLインジェクションという結構危うい*1技術によって成り立っているので、 少し不安が残ります。

そのため今回は前者の記事の方法、 DLLの読み込みと解放を自前で実装する方法を試してみることにしました。

実装

サンプルDLL

スクリプト側から呼び出す関数の定義は次の通りです(Rust)。

#[no_mangle]
pub extern "C" fn add(x: i32, y: i32) -> i32 {
    x + y
}

これをコンパイルして結果のDLLをUnityプロジェクトに配置します。 そのとき忘れずに対象プラットフォームからEditorを外しておきます。 こうしておかないとエディタが自動的にDLLを読んでしまうため上書きができなくなってしまいます。

DLLの動的ロード・アンロード

まずはDLLを動的にロード・アンロードするためのクラスを書きます。

Windowsを想定しているのでロードとアンロードにはそれぞれLoadLibraryFreeLibraryを使います。

#if UNITY_EDITOR

using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;
using System;

public sealed class NativeLibrary : IDisposable
{
    private static class NativeMethods
    {
        [DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
        internal static extern SafeLibraryHandle LoadLibrary(string lpLibFileName);

        [DllImport("kernel32", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        internal static extern bool FreeLibrary(IntPtr hLibModule);

        [DllImport("kernel32", SetLastError = true, CharSet = CharSet.Ansi)]
        internal static extern IntPtr GetProcAddress(SafeLibraryHandle hModule, string lpProcName);

        [DllImport("kernel32", SetLastError = true, CharSet = CharSet.Ansi)]
        [return: MarshalAs(UnmanagedType.Bool)]
        internal static extern bool SetDllDirectory(string lpPathName);
    }

    private sealed class SafeLibraryHandle : SafeHandleZeroOrMinusOneIsInvalid
    {
        public SafeLibraryHandle() : base(true) { }

        protected override bool ReleaseHandle()
        {
            return NativeMethods.FreeLibrary(this.handle);
        }
    }

    private readonly SafeLibraryHandle handle;

    private NativeLibrary(SafeLibraryHandle handle)
    {
        this.handle = handle;
    }

    public static NativeLibrary Load(string name)
    {
        SafeLibraryHandle handle = NativeMethods.LoadLibrary(name);
        if (handle.IsInvalid)
        {
            throw new ArgumentException($"Library '{name}' not found.", nameof(name));
        }

        return new NativeLibrary(handle);
    }

    public void Dispose() => this.handle.Dispose();

    public T GetFunction<T>(string name) where T : Delegate
    {
        IntPtr ptr = NativeMethods.GetProcAddress(this.handle, name);
        if (ptr == IntPtr.Zero)
        {
            throw new ArgumentException($"Function '{name}' not found.", nameof(name));
        }

        return Marshal.GetDelegateForFunctionPointer<T>(ptr);
    }

    public static bool SetDllDirectory(string path) => NativeMethods.SetDllDirectory(path);
}

#endif

#if UNITY_EDITOR#endifで囲っているのはエディタ以外でコードが残らないようにするためです。 後述しますが、エディタ以外では通常の方法で読み込むためこのクラスは使いません。

基本的な処理の流れは次の通り。

  1. `NativeLibrary.Load("DLL_NAME")でロード
    • LoadLibraryの呼び出し
  2. `GetFunction("FUNCTION_NAME")でデリゲートの取得
    • GetProcAddressで関数ポインタの取得
    • Marshal.GetDelegateForFunctionPointer<T>でデリゲート化
  3. Dispose()またはファイナライザでアンロード
    • FreeLibraryの呼び出し

自前でDLLを読み込むため、UnityはDLLの読み込みパスを設定してくれません。 そのため、自分でパスを設定するためにSetDllDirectoryも呼べるようにしています。

ネイティブAPIの定義

前述のクラスを使って、ネイティブプラグイン呼び出し用のAPIを定義します。

using System.Runtime.InteropServices;

public static class NativeAPI
{
#if UNITY_EDITOR
    private partial struct Cache { }

    private static NativeLibrary adder;
    private static Cache cache;

    static NativeAPI()
    {
        NativeLibrary.SetDllDirectory("Assets/Plugins/x86_64");
    }

    public static bool Loaded => adder != null;

    public static void Load()
    {
        adder = NativeLibrary.Load("adder");
    }

    public static void Free()
    {
        adder?.Dispose();
        adder = null;
        cache = new Cache();
    }
#endif

#if UNITY_EDITOR
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    private delegate int AddDelegate(int x, int y);
    private partial struct Cache { public AddDelegate Add; }
    public static int Add(int x, int y)
    {
        if (cache.Add == null) cache.Add = adder.GetFunction<AddDelegate>("add");
        return cache.Add(x, y);
    }
#else
    [DllImport("adder", CallingConvention = CallingConvention.Cdecl, EntryPoint = "add")]
    public static extern int Add(int x, int y);
#endif
}

前半部分はAPI呼び出しのための準備と後処理です。 静的コンストラクタではDLLの検索パスの設定をしています(Assets/Plugins/x86_64 の部分には自分がDLLを配置したパスを書きます)。 Loadメソッド、FreeメソッドはそれぞれDLLのロードとアンロードのためのメソッドで、 API利用時には適宜それぞれを呼び出す必要があります。

後半部分はAPIの定義です。 エディタでは自前ロードしたDLLによって、それ以外では通常通りDllImportによって関数を呼び出します。 各関数に対して毎回GetProcAddress呼び出しとデリゲート生成をするのもあれなので、 デリゲートはキャッシュして使用するようにしています。 API定義は手動で書くと禿げるので、コードスニペットかテンプレートエンジン(T4とか)あたりを使って書くのがいいと思います。

ネイティブAPIの使用

APIの定義だけで簡単に使えたらよかったのですが、 使用状況によってDLLの読み込みと解放のタイミングが異なるため、 どうしても使用時にもコードが必要になってきます。 状況別にいくつか例を書いておきます。

ゲーム再生時

RuntimeInitializeOnLoadMethodEditorApplication.playModeStateChangedを利用します。

#if UNITY_EDITOR

using UnityEngine;
using UnityEditor;

public class NativeAPISwitcher
{
    [InitializeOnLoadMethod]
    private static void Init()
    {
        EditorApplication.playModeStateChanged += state =>
        {
            if (state == PlayModeStateChange.ExitingPlayMode)
            {
                NativeAPI.Free();
            }
        };
    }

    [RuntimeInitializeOnLoadMethod]
    private static void RuntimeInit()
    {
        NativeAPI.Load();
    }
}

#endif

最初はEditorApplication.playModeStateChangedのみで制御しようと思ったのですが、 どうもEnteredPlayModeのタイミングが遅いらしく、 StartでのAPI呼び出しに間に合わなかったためこちらの方法にしました。

テスト実行時

OneTimeSetUpOneTimeTearDownを利用します。

using NUnit.Framework;

namespace Tests
{
    public class NativeAPITest
    {
#if UNITY_EDITOR
        [OneTimeSetUp]
        public void SetUp()
        {
            NativeAPI.Load();
        }

        [OneTimeTearDown]
        public void TearDown()
        {
            NativeAPI.Free();
        }
#endif

        [Test]
        public void AddTest()
        {
            Assert.AreEqual(3, NativeAPI.Add(1, 2));
        }
    }
}

手動でトグル

エディタ拡張として読み込みます。 Unityの再起動よりはマシ、くらいな感じです。

using UnityEditor;

public static class ToggleNativeAPI
{
    [InitializeOnLoadMethod]
    public static void Init()
    {
        EditorApplication.update += () => Menu.SetChecked("NativeAPI/Enabled", NativeAPI.Loaded);
    }

    [MenuItem("NativeAPI/Enabled")]
    public static void Toggle()
    {
        if (!NativeAPI.Loaded)
        {
            NativeAPI.Load();
        }
        else
        {
            NativeAPI.Free();
        }
    }
}

問題点

元記事の方にも書いてありますが、UnityPluginLoadUnityPluginUnloadは呼ばれません。 解決方法は元記事に書いてある通りで、IUnityInterfacesを取得するためだけのプラグインを作り、 それを使って直接呼び出してやることです。

自分は今のところ使わないのでやりません。 必要な方は元記事を参照してください。

おわりに

ネイティブプラグインは地味に面倒ですね。 最初にも書きましたが、自分の管理下で制御する必要がなければDLLインジェクションのツールを使った方がいいかもしれません。

余談ですが、.NET Core 3 にてNativeLibraryというネイティブライブラリの動的ロード・アンロード用のヘルパー関数を提供する静的クラスが追加されるらしいです。 ちょうど今回実装したNativeLibraryと似たような機能を持っているみたいです。



執筆時のUnityのバージョン:2018.3.5f1

*1:セキュリティ的な意味もありますがそれ以上にAPIの使い方が正統ではないという意味