本編「【Unity】iOSネイティブプラグイン開発を完全に理解する」の付録記事です。
記事中での用語や略称についてはそのまま本編に倣う形で記載していきます。
ここでは実際にネイティブプラグインを実装/保守していく上でのオススメの設計手法について触れていきます。
あくまで自分がよくやる手法の話なので、一例として受け取っていただけると幸いです。
題材自体はiOSネイティブプラグインに限らずに適用できる内容になるかと思います。
TL;DR
-
interfaceを切ってプラットフォームごとに実装を分けることをオススメ
- 可読性/保守性/拡張性が上がる
- DIフレームワークとの相性が良くなる
-
かと言って慣習的に必ず切る必要は無い
- サンプル実装や簡単な機能検証程度ならプリプロセス命令で雑に分岐して直接呼び出してしまうのも有りかも
最小構成のサンプルを振り返ってみる
本編側にある最小構成のサンプルでは以下のようにEditor実行時との挙動はプリプロセス命令で分岐した上でボタン押下時にP/Invokeの処理を直接呼び出していました。
このようなサンプル実装や簡単な機能検証程度ならこの書き方でも良いかと思います。
sealed class Example : MonoBehaviour
{
[SerializeField] Button _buttonHelloWorld = default;
void Start()
{
_buttonHelloWorld.onClick.AddListener(() =>
{
#if !UNITY_EDITOR && UNITY_IOS
// プラグインの呼び出し
var ret = PrintHelloWorld();
Debug.Log($"戻り値: {ret}");
#else
// それ以外のプラットフォームからの呼び出し (Editor含む)
Debug.Log("Hello World (iOS以外からの呼び出し)");
#endif
});
}
#region P/Invoke
[DllImport("__Internal", EntryPoint = "printHelloWorld")]
static extern int PrintHelloWorld();
#endregion P/Invoke
}
マルチプラットフォーム想定の機能だとどうなるか?
例としてネイティブプラグインを活用した 「iOS/Android標準のシェアUIからテキスト/画像/動画をシェアする機能」 を実装するとしましょう。
もし上述のサンプルと同じくプリプロセス命令の分岐ベースで愚直に実装すると、場合によっては以下のような感じの実装になってくるかと思います。
クラス図
※ExampleView
からシェア機能(ShareMedia
)を呼び出すイメージ 1
コードはこちら
ShareMedia.cs (クリックで展開)
namespace Examples.View
{
/// <summary>
/// 各プラットフォーム標準のシェアUIからテキスト/画像/動画をシェア
/// </summary>
/// <remarks>
/// NOTE: 全体的に参照透過性が高いのでstatic classとして纏めている
/// </remarks>
public static class ShareMedia
{
/// <summary>
/// テキストのシェア
/// </summary>
public static void ShareText(string text)
{
#if !UNITY_EDITOR || UNITY_IOS
ShareTextForIOS(text);
#elif !UNITY_EDITOR || UNITY_ANDROID
ShareTextForAndroid(text);
#else
Debug.Log("Editorからの呼び出し");
#endif
}
/// <summary>
/// 画像のシェア
/// </summary>
public static void ShareImage(byte[] image)
{
#if !UNITY_EDITOR || UNITY_IOS
ShareImageForIOS(image);
#elif !UNITY_EDITOR || UNITY_ANDROID
ShareImageForAndroid(image);
#else
Debug.Log("Editorからの呼び出し");
#endif
}
/// <summary>
/// 動画のシェア
/// </summary>
/// <param name="moviePath">動画のパス</param>
public static void ShareMovie(string moviePath)
{
#if !UNITY_EDITOR || UNITY_IOS
ShareMovieForIOS(moviePath);
#elif !UNITY_EDITOR || UNITY_ANDROID
ShareMovieForAndroid(image);
#else
Debug.Log("Editorからの呼び出し");
#endif
}
#region iOSのP/Invoke
#if UNITY_IOS
// iOSのシェアUIからテキストをシェア
[DllImport("__Internal", EntryPoint = "shareText")]
static extern void ShareTextForIOS(string text);
// iOSのシェアUIから画像をシェア
[DllImport("__Internal", EntryPoint = "shareImage")]
static extern void ShareImageForIOS(byte[] image);
// iOSのシェアUIから動画をシェア
[DllImport("__Internal", EntryPoint = "shareMovie")]
static extern void ShareMovieForIOS(string moviePath);
#endif
#endregion iOSのP/Invoke
#region AndroidのP/Invoke
#if UNITY_ANDROID
// AndroidのシェアUIからテキストをシェア
static void ShareTextForAndroid(string text)
{
// ネイティブのシェアUI呼び出し (省略)
}
// AndroidのシェアUIから画像をシェア
static void ShareImageForAndroid(byte[] image)
{
// ネイティブのシェアUI呼び出し (省略)
}
// AndroidのシェアUIから動画をシェア
static void ShareMovieForAndroid(string moviePath)
{
// ネイティブのシェアUI呼び出し (省略)
}
#endif
#endregion AndroidのP/Invoke
}
}
ExampleView.cs (クリックで展開)
namespace Examples.View
{
/// <summary>
/// シェア機能の呼び出しサンプル
/// </summary>
sealed class ExampleView : MonoBehaviour
{
[SerializeField] InputField _shareText = default;
void Start()
{
// 入力されたテキストのシェア
_shareText.onEndEdit.AddListener(text =>
{
ShareMedia.ShareText(text);
});
}
}
}
プリプロセス命令の分岐が増えてくると可読性が悪くなる
注目して欲しいのはShareMedia.cs
です。
至るところにプリプロセス命令による分岐が挟まってます。
例に示した機能程度であれば「これでも運用できなくはないかな...」と言う意見もあるかもしれませんが、ここから更に「対応プラットフォームにStandaloneとWebGLを追加」「テキスト/画像/動画以外にも、○○をシェアできるようにしたい」と言った要件が増えてくると、その分プリプロセス命令の分岐が増えてきて可読性や保守性が悪くなってくるかと思われます。
更に言うとサンプルコードのpublic methodはP/Invokeのメソッドを呼び出すだけと言った割とシンプルな構成で収まってますが、場合によってはプラットフォームごとに合わせたデータ変換と言ったロジックも挟まる可能性もあり、 そうなってくると更にコードの量も増えていく懸念があります。
interfaceを切ってプラットフォームごとに実装を分けていく
「じゃあどう実装していくのが良いのか?」について話していきます。
個人的には表題にある通り、interfaceを切ってプラットフォームごとに実装を分けていく手法をオススメします。
前の章のサンプルを分けるとしたら以下のような形になります。
先にクラス図/コード合わせて載せておきます。
クラス図
コードはこちら
IShareMedia.cs (クリックで展開)
namespace Examples.View
{
/// <summary>
/// シェアUIからテキスト/画像/動画をシェア
/// </summary>
interface IShareMedia
{
/// <summary>
/// テキストのシェア
/// </summary>
void ShareText(string text);
/// <summary>
/// 画像のシェア
/// </summary>
void ShareImage(byte[] image);
/// <summary>
/// 動画のシェア
/// </summary>
/// <param name="moviePath">動画のパス</param>
void ShareMovie(string moviePath);
}
}
ShareMediaForIOS.cs (クリックで展開)
#if UNITY_IOS
namespace Examples.View
{
/// <summary>
/// iOS用の`IShareMedia`の実装
/// </summary>
public sealed class ShareMediaForIOS : IShareMedia
{
public void ShareText(string text) => ShareTextForIOS(text);
public void ShareImage(byte[] image) => ShareImageForIOS(image);
public void ShareMovie(string moviePath) => ShareMovieForIOS(moviePath);
#region P/Invoke
// iOSのシェアUIからテキストをシェア
[DllImport("__Internal", EntryPoint = "shareText")]
static extern void ShareTextForIOS(string text);
// iOSのシェアUIから画像をシェア
[DllImport("__Internal", EntryPoint = "shareImage")]
static extern void ShareImageForIOS(byte[] image);
// iOSのシェアUIから動画をシェア
[DllImport("__Internal", EntryPoint = "shareMovie")]
static extern void ShareMovieForIOS(string moviePath);
#endregion P/Invoke
}
}
#endif
ShareMediaForAndroid.cs (クリックで展開)
#if UNITY_ANDROID
namespace Examples.View
{
/// <summary>
/// Android用の`IShareMedia`の実装
/// </summary>
public sealed class ShareMediaForAndroid : IShareMedia
{
public void ShareText(string text) => ShareTextForAndroid(text);
public void ShareImage(byte[] image) => ShareImageForAndroid(image);
public void ShareMovie(string moviePath) => ShareMovieForAndroid(moviePath);
#region P/Invoke
// AndroidのシェアUIからテキストをシェア
static void ShareTextForAndroid(string text)
{
// ネイティブのシェアUI呼び出し (省略)
}
// AndroidのシェアUIから画像をシェア
static void ShareImageForAndroid(byte[] image)
{
// ネイティブのシェアUI呼び出し (省略)
}
// AndroidのシェアUIから動画をシェア
static void ShareMovieForAndroid(string moviePath)
{
// ネイティブのシェアUI呼び出し (省略)
}
#endregion P/Invoke
}
}
#endif
ShareMediaForEditor.cs (クリックで展開)
#if UNITY_EDITOR
namespace Examples.View
{
/// <summary>
/// Editor用の`IShareMedia`の実装
/// </summary>
public sealed class ShareMediaForEditor : IShareMedia
{
public void ShareText(string text)
{
Debug.Log("Editorからの呼び出し");
}
public void ShareImage(byte[] image)
{
Debug.Log("Editorからの呼び出し");
}
public void ShareMovie(string moviePath)
{
Debug.Log("Editorからの呼び出し");
}
}
}
#endif
ExampleView.cs (クリックで展開)
namespace Examples.View
{
/// <summary>
/// シェア機能の呼び出しサンプル
/// </summary>
sealed class ExampleView : MonoBehaviour
{
[SerializeField] InputField _shareText = default;
IShareMedia _shareMedia;
void Start()
{
// プラットフォームに応じて実装を差し替える
// NOTE: サンプルなので雑に分岐しているが、実際にやるならDIフレームワーク経由で注入しても良いかもしれない
#if UNITY_EDITOR
_shareMedia = new ShareMediaForEditor();
#elif UNITY_IOS
_shareMedia = new ShareMediaForIOS();
#elif UNITY_ANDROID
_shareMedia = new ShareMediaForAndroid();
#else
// 非対応プラットフォームなら投げておく
// NOTE: 非対応プラットフォームでも動かしたいなら`IShareMedia`を実装したダミークラスを用意して入れておくのも手
throw new NotImplementedException();
#endif
// 入力されたテキストのシェア
_shareText.onEndEdit.AddListener(text =>
{
_shareMedia.ShareText(text);
});
}
}
}
利点
この形式にすると実装の詳細がプラットフォームごとの実装クラスに委譲されるので、仮に「プラットフォームごとに合わせたデータ変換」と言ったロジックが挟まることになったとしても、処理のスコープを限定できるようになります。
(「iOSで必要な処理はiOSの実装クラス内に」「Androidで必要な処理はAndroidの実装クラス内に」と言った感じにスコープを限定できる)
他にもプリプロセス命令による分岐が初期化タイミングのみとなっているために、全体的に見通しも良くなっているかと思います。
保守性と拡張性
もし対応プラットフォームが増減したとしても既存のコードに対する影響範囲を抑えられます。
(増えた際には実装クラスを追加して初期化時のプリプロセス命令の分岐に追加するだけで済む。減った際にはプリプロセス命令の分岐から消すだけで済む)
他にもIShareMedia
を実装したダミークラスを用意することで非対応プラットフォーム実行時/Editor実行時などの振る舞いを分けやすくなったり、ソフトウェアテスト用にMockクラスを実装して挟むと言ったことも対応しやすくなります。
DIフレームワークとの相性
今回の例ではプリプロセス命令でプラットフォームに応じた実装クラスを流し込む形になってますが、実装自体はinterfaceを切っているのでZenjectと言ったDIフレームワークとの親和性も上がります。
仮に他のViewでもシェア機能を呼び出したいとなったときに利便性が上がります。
サンプルプロジェクト
ここまでの流れのおさらい用にサンプルプロジェクトを用意しました。
内容としては 「端末のバッテリーレベル(容量)を取得して画面に表示する」 と言ったものであり、iOS/Android/Editorに対応してます。
(※Editor実行時は常に100%が表示される)
サンプルは「objCpp/designExanmple」ブランチにて管理してます。
詳細な実装内容は前の章にて解説した通りなので省きますが、宜しければ一例として御覧ください。
※備考
ちなみに..こちらのサンプルは説明のために意図的に車輪の再発明をしたものであり、実際にバッテリーレベルを取得するぐらいであれば以下のAPIから取得可能だったりします。
ここまでのまとめ
大事なポイントとしてはinterfaceを切ってプラットフォームごとに実装を分けることです。
可読性/保守性/拡張性が上がり、DIフレームワークとの相性が良くなっていくるかと思います。
ただ、interfaceを切ると言っても慣習的に必ず切る必要は無く、例えば冒頭にも記載したようにサンプル実装や簡単な機能検証程度ならプリプロセス命令で雑に分岐して直接呼び出してしまうのも有りかと思います。
-
ViewからViewを呼び出すような構成になっているが、このサンプル中ではそこに強い意図は無い。各プロジェクトごとに採用しているアーキテクチャに合わせて呼び出し元をViewなりPresenterなりと変えていけば良いと思う。 ↩