Native Library Interop (旧称:Slim Bindings) 作り方編
前回、Slim BindingsもといNative Library Interopについての説明記事を書きました。 今回は作り方について説明していきます。
前回の記事を書いた後に名称が変更され、サンプルリポジトリが.NETのCommunityToolkit配下に移管されました。 これにより正式にコミュニティによって共有される設計パターンとなったらしく、MS Leanにもドキュメントが追加されました。
サンプルプロジェクトのリポジトリ
ドキュメント
- Native Library Interop - .NET MAUI Community Toolkit - Community Toolkits for .NET | Microsoft Learn
自力でプロジェクト一式を作る
Native Library Interopではtemplate
ディレクトリに置かれたプロジェクト一式のテンプレートを改造して作ることが推奨されています。
せっかくなので、今回は勉強のために自力でこのプロジェクト一式の再現に挑戦してみます。なお、手順はすべてMacで実行しています。
環境準備
少なくとも以下の開発ツールが必要となります。
- .NET SDK
- この記事では.NET 8.0 SDKを使用しています。
- .NET 8.0 (Linux、macOS、Windows) をダウンロードする
- .NET Workload
- この記事ではiOS, Android, MAUIを使用しています。
- コマンドラインで
dotnet workload install maui
を実行すると一度にインストールできます。
- コマンドラインで
- Objective-Sharpie
- iOSのBinding Libraryプロジェクトをビルドするときに必要となります。
- https://aka.ms/objective-sharpie からダウンロードできます。
アプリケーション本体のプロジェクトの作成
まずBinding Libraryを使うアプリケーション側のプロジェクトを作成します。 既存のアプリケーションを使う場合は省略出来ます。
まず最初にMAUIアプリのプロジェクトを作ります。
dotnet new maui --name NativeLibraryInteropSampleApp
今回はiOS、Androidだけを対象にしたいので、このタイミングでcsprojファイルを編集してTargetFrameworks
からmaccatalystとwindowsを削除します。
NuGet.configの追加
Native Library Interopで使うビルドタスクが独自のパッケージフィードで配布されているため、NuGet.config
をおいてパッケージソースを追加します。
以下の内容のNuGet.config
ファイルをslnファイルと同じ階層に作成して下さい。
<?xml version="1.0" encoding="utf-8"?> <configuration> <packageSources> <add key="maui-nativelibraryinterop" value="https://pkgs.dev.azure.com/xamarin/public/_packaging/maui-nativelibraryinterop/nuget/v3/index.json" /> </packageSources> </configuration>
Binding用プロジェクトの追加
Binding用のプロジェクトを追加していきます。
android
、ios
ディレクトリはslnファイルと同じ階層に作り、それぞれの中に.NETのBinding Libraryとネイティブのライブラリプロジェクトで1セットで配置します。
こんな感じのディレクトリ構造になります。
- android - native - Android Studioプロジェクト一式 - NativeLibraryInteropSample.Android.Binding - .NETのBidning Libraryプロジェクト一式(Android) - ios - native - Xcodeプロジェクト一式 - NativeLibraryInteropSample.iOS.Binding - .NETのBidning Libraryプロジェクト一式(iOS)
ネイティブプロジェクトの置き場所と途中のディレクトリを作っておきます。(slnファイルと同じ階層で実行します)
mkdir -p android/native mkdir -p ios/native
.NETのBinding Libraryプロジェクトを作り、slnファイルにも追加します。(slnファイルと同じ階層で実行します)
dotnet new android-bindinglib --name NativeLibraryInteropSample.Android.Binding --output android/NativeLibraryInteropSample.Android.Binding dotnet new iosbinding --name NativeLibraryInteropSample.iOS.Binding --output ios/NativeLibraryInteropSample.iOS.Binding
必要な.NETプロジェクトが揃ったので、ここでソリューションファイルを作ってプロジェクトを追加しておきます。
dotnet new sln --name NativeLibraryInteropSampleApp dotnet sln add **/*.csproj
アプリケーション本体のプロジェクトの設定を変更
最初に作ったMAUIアプリケーションのcsprojに以下の内容を追加して、各Binding用プロジェクトへの参照を追加します。
AndroidLibrary
の部分は、C#から呼び出す必要のない(JavaやKotolinで依存関係が閉じている).jar
や.aar
を取り込むための設定なようです。
実行時に依存ライブラリが見つからなくて動かない場合、Binding用プロジェクトのビルド結果から対象ファイルをアプリケーション本体のプロジェクトに取り込み直すために有効化することになると思います。
<!-- Reference to MaciOS Binding project --> <ItemGroup Condition="$(TargetFramework.Contains('ios'))"> <ProjectReference Include="..\macios\NativeLibraryInteropSample.iOS.Binding\NativeLibraryInteropSample.iOS.Binding.csproj" /> </ItemGroup> <!-- Reference to Android Binding project --> <ItemGroup Condition="$(TargetFramework.Contains('android'))"> <ProjectReference Include="..\android\NativeLibraryInteropSample.Android.Binding\NativeLibraryInteropSample.Android.Binding.csproj" /> </ItemGroup> <!-- Reference the Android binding dependencies --> <!-- Uncomment the code block below and update the AndroidLibrary path to point your dependency .aar --> <!-- <ItemGroup Condition="$(TargetFramework.Contains('android'))"> <AndroidLibrary Include="..\android\native\newbinding\bin\Release\net8.0-android\outputs\deps\{yourDependencyLibrary.aar}"> <Bind>false</Bind> <Visible>false</Visible> </AndroidLibrary> </ItemGroup> -->
Android ネイティブライブラリプロジェクトの作成
Androidのネイティブライブラリプロジェクトを作っていきます。 正直なところ自力で作ると結構面倒そうなのでサンプルリポジトリのテンプレートからコピーした方が良いかもしれません。
イチから作成する場合、以下のような手順が良さそうです。
- Android Studioで空のAndroidアプリプロジェクトを新規作成。
android/native
ディレクトリの直下に展開されるようにします。
- 「Android Library」のモジュールを追加。
- アプリケーション部分を削除。
app
ディレクトリを削除。.idea
ディレクトリもいったん削除。build.gradle.kts
からid("com.android.application")
の行を削除。settings.gradle.kts
からinclude(":app")
の行を削除。- .NETのBinding用プロジェクトから、Android Stuido側のライブラリモジュールを指定できるのでアプリケーション部分を残しても大丈夫かも。
- Android Studioプロジェクトのライブラリモジュールからユニットテストを削除
android/native/{ライブラリモジュール名}/build.gradle.kts
の設定を修正- 別途説明
空のAndroidアプリプロジェクトを新規作成
Androidライブラリモジュールを追加
android/native/{ライブラリモジュール名}/build.gradle.kts
の設定を修正
まず、defaultConfig
からminSdk
以外の項目を削除します。
次に、dependencies
部分を次のように置き換えます。
copyDependencies
はラップしたいライブラリのファイルを.NETのBinding Libraryに持ってくるために必要です。
// Create configuration for copyDependencies configurations { create("copyDependencies") } dependencies { // Add package dependency for binding library // Uncomment line below and replace {dependency.name.goes.here} with your dependency // implementation("{dependency.name.goes.here}") // Copy dependencies for binding library // Uncomment line below and replace {dependency.name.goes.here} with your dependency // "copyDependencies"("{dependency.name.goes.here}") } // Copy dependencies for binding library project.afterEvaluate { tasks.register<Copy>("copyDeps") { from(configurations["copyDependencies"]) into("${buildDir}/outputs/deps") } tasks.named("preBuild") { finalizedBy("copyDeps") } }
例えばfaccebook SDKをラップする場合、dependencies
には次のようにセットで記述すれば良いようです。
`implementation("com.facebook.android:facebook-android-sdk:latest.release")` "copyDependencies"("com.facebook.android:facebook-android-sdk:latest.release")
Bindingの動作確認用クラスを作成
モジュール作成時に一緒に作られていたサンプルファイルを改造して、HelloWorld的な動作確認用のクラスにします。
package com.example.nlisample; public class SampleBinding { public static String getString(String myString) { return myString + " from java!"; } }
iOS ネイティブライブラリプロジェクトの作成
iOSのネイティブライブラリプロジェクトを作っていきます。 こちらはAndroidと違って準備が簡単です。
Xcodeで新規プロジェクトの作成からFramework
のテンプレートを選択します。
作成場所にはios/native
の配下を指定します。
Frameworkプロジェクトを追加
Bindingの動作確認用クラスを作成
XcodeでSwiftファイルを追加して、HelloWorld的な動作確認用のクラスを作っておきます。
.NETのBinding LibraryはObjective-C経由でアクセスするため@objc
が必要です。
import Foundation @objc(SampleBinding) public class SampleBinding : NSObject { @objc public static func getString(myString: String) -> String { return myString + " from swift!" } }
.NETのBinding Libraryプロジェクトを手直し
Android
android
配下のcsprojに以下の内容を追加します。
ModuleName
部分はAndroid Studioで作ったライブラリモジュールの名前にして下さい。
NLIGradleProjectReference
部分がAndroid Studioのライブラリモジュールをビルドして、.NETのBinding用プロジェクトに取り込むための設定です。
NuGet.configを追加していたのは、ここに関連するビルドタスクをNuGetパッケージとして取得するためでした。
<!-- Reference to Android project --> <ItemGroup> <NLIGradleProjectReference Include="../native" > <ModuleName>{Android Studioのライブラリモジュール名}</ModuleName> <!-- Metadata applicable to @(AndroidLibrary) will be used if set, otherwise the following defaults will be used: <Bind>true</Bind> <Pack>true</Pack> --> </NLIGradleProjectReference> </ItemGroup> <!-- Reference to NuGet for building bindings --> <ItemGroup> <PackageReference Include="CommunityToolkit.Maui.NativeLibraryInterop.BuildTasks" Version="0.0.1-pre1" PrivateAssets="all" /> </ItemGroup>
iOS
iOSではcsprojの編集の他に、API定義も行います。
まず、ios
配下のcsprojに以下の内容を追加します。
NLIXcodeProjectReference
部分がXcodeのFrameworkをビルドして、.NETのBinding用プロジェクトに取り込むための設定です。
NuGet.configを追加していたのは、ここに関連するビルドタスクをNuGetパッケージとして取得するためでした。
Android側と異なり、xcodeprojまでのファイルパスを具体的に指定する必要があるため、実際のパスに合わせて修正して下さい。
<!-- Reference to Xcode project --> <ItemGroup> <NLIXcodeProjectReference Include="../native/{プロジェクト名}/{プロジェクト名}.xcodeproj"> <SchemeName>{プロジェクト名}</SchemeName> <SharpieNamespace>{プロジェクト名}</SharpieNamespace> <SharpieBind>true</SharpieBind> <!-- Metadata applicable to @(NativeReference) will be used if set, otherwise the following defaults will be used: <Kind>Framework</Kind> <SmartLink>true</SmartLink> --> </NLIXcodeProjectReference> </ItemGroup> <!-- Reference to NuGet for building bindings --> <ItemGroup> <PackageReference Include="CommunityToolkit.Maui.NativeLibraryInterop.BuildTasks" Version="0.0.1-pre1" PrivateAssets="all" /> </ItemGroup>
次にApiDefinition.cs
を以下のように編集して、Swiftで動作確認用に作ったクラスに対するAPI定義を追加します。
この辺りは従来のBinding Libraryの作り方となり、ここで楽をするためにSwiftで都合の良いAPIを定義するのがNative Library Interop(旧称:Slim Bindings)の要点と言えます。
using Foundation; namespace NativeLibraryInteropSample.iOS { // @interface SampleBinding : NSObject [BaseType (typeof(NSObject))] interface SampleBinding { // +(NSString * _Nonnull)getStringWithMyString:(NSString * _Nonnull)myString __attribute__((warn_unused_result(""))); [Static] [Export ("getStringWithMyString:")] string GetString (string myString); } }
Objective-C のバインディングに関するドキュメント
MAUIアプリで動作確認
最初に作成したMAUIアプリを改造してBindingの動作確認をします。
ちょうど良くMainPage.xaml.cs
にボタンを押した時の処理があるので、ここでネイティブライブラリのAPIを呼び出してみましょう。
private void OnCounterClicked(object sender, EventArgs e) { #if __IOS__ // XcodeのFrameworkで動作確認用に定義したメソッドを呼び出す。 string message = NativeLibraryInteropSample.iOS.SampleBinding.GetString("Hello");; #else // Android Studioのライブラリモジュールで動作確認用に定義したメソッドを呼び出す。 string message = Com.Example.Nlisample.SampleBinding.GetString("Hello");; #endif _ = this.DisplayAlert("Hello Native Library Interop", message, "OK"); }
上手くいくとこのような実行結果になります。
Slim Bindingアプローチについて
今回はSlim Bindingアプローチを題材に.NET iOS/Android, MAUIにおけるBinding Library事情についての話をします。(Slim Bindingアプローチの具体的な実装手順は割愛します)
Binding Libraryとは?
Slim Bindingアプローチは言ってしまえばBinding Libraryの作り方の一つです。ですので、まずはBinding Libraryについて簡単に説明します。
例えば、FirebaseやFacebookのSDKのようなiOS/Android向けのネイティブライブラリを使いたい時、これらをC#から呼べるようにラップしたものがBinding Libraryです。
技術的には.NET iOS/Androidが各OSのAPIを呼ぶのと同様の仕組みを利用しており、元ライブラリのAPIとC#を対応させるためにAPI定義やサポートクラスを用意する必要があります。 C#として使いやすいように工夫したり、自動生成された結果を調整することが必要で、元のライブラリの規模複雑さに比例して労力がかかります。
バインディング用のC#プロジェクト(以下、Bindingプロジェクト)を作成し、そこにAPI定義ファイルとネイティブライブラリを配置してBinding Libraryをビルドするというのが標準的な作り方です。
Slim Bindingと比較して、元のネイティブライブラリが備えるAPIを全てC#から使えるようにするアプローチは"フルバインディング"と呼ばれます。
Slim Bindingアプローチ
Slim BindingもBinding Libraryを作る方法の一つですが、API定義の作成を簡単にするために工夫されています。
まず、XcodeやAndroid Studioで目的のネイティブライブラリを使用するライブラリを作成します。そして、C#のBindingプロジェクトでは自作したネイティブライブラリをターゲットにします。
自作ネイティブライブラリで最小限のAPIを公開したり、C#に落とし込みやすい形にすることにより、Bindingプロジェクトでの作業がスムーズになります。
1つのアプリケーションがネイティブライブラリの全APIを使用することはまず無いため、このアプローチは理にかなっています。
Slim Bindingとそうでない作り方の違いはこの図のようになります。
Git Hub上の公式サンプルリポジトリ
Slim Bindingが上手くいくケース
なるべくライブラリの依存関係を浅くして、自分がコントロールできる部分を大きくしようとするとハマりやすくなります。
ネイティブライブラリのフル機能の提供するBinding Libraryとそれに依存するライブラリのエコシステムを活用するのは魅力的ですが、カバー範囲の広さ故にアップデートに時間がかかる傾向にあります。
例えば、Microsoftが提供するBinding Libraryを通してGoogle Firebaseの一部のコンポーネント(Cloud Messagingなど)を利用していた場合を考えてみてください。 元のFirebase SDKはサービスごとに細かく分かれていて、それを反映したBinding Libraryも複数に分かれています。そのため、一連のライブラリの更新作業が足並み揃えて完了するまで待たなければなりません。
もしも、Slim Bindingアプローチにより必要最小限の機能を自力でアップデートできるのであれば、ネイティブライブラリの更新をアプリケーションに素早く適用することも可能となるわけです。
Binding Libraryを取り巻く問題
特に「Binding Libraryに依存するライブラリ」を利用していたアプリケーションとって難しい状況が続きました。それにより、Slim Bindingによる自力解決を検討すべき時を迎えたと言えます。
Xamarin.iOSと.NET iOSのバイナリ非互換
.NET iOSでnint
やnuint
などの型がC#で正式にサポートされることになった影響で、Xamarin.iOS向けに作られたライブラリは.NET iOSで利用できないという問題が発生しました。
この件は、オーナーがモチベーションを失って更新が停止したライブラリの存在を浮き彫りにしました。
Xamarinのサポート終了における.NET(MAUI)化の際に対応が必須だったこともあり、ソースコードをフォークして自分でビルドし直して対処したプロジェクトもあるのではないでしょうか。
Microsoftによる保守が停止したBinding Library
Xamarin時代、Google、Facebookなど利用者の多いSDKライブラリについてはXamarin社がBinding Libraryを作ってくれていました。MicrosoftがXamarinを買収した後はMicrosoftがこれらの保守を引継ぎました。
困ったことにiOS版では2年ほど更新が停止状態にあります、Microsoftからのサポート終了宣言はありません。一方でSlim Bindingがフルバインディングより適切なアプローチであると説明されされています。つまりそういうことなのでしょう。
何故かAndroid版に関しては更新が継続されているのですが、何かしらの幸運(サポート可能な開発者が残っている、iOSよりもバインディングの負荷が低い?)に支えられた現状であると推測されます。もしこれが無くなった時にどうするべきかは一度検討しておいた方が良いでしょう。
AppleのPrivacy Manifest対応
Binding Library界隈で当たりがひときわ大きかった事件です。
Appleの新しいプライバシー要件でiOSアプリやライブラリにPrivacy Manifestという情報を含めることが必要となりました。2024年5月1日以降はこれに対応していないとアプリの新規登録やアップデートがリジェクトされる(かもしれない)という重大要件です。
Plugin.xxxのようなクロスプラットフォームライブラリに依存していた場合、Privacy Manifest対応には次のような作業が必要となります。
- ネイティブライブラリがPrivacy Manifest対応版をリリースする。
- 新しいネイティブライブラリを使って、新しいBinding Libraryをリリースする。
- 新しいBinding Libraryを使って、新しいクロスプラットフォームライブラリをリリース。
- 新しいクロスプラットフォームライブラリを使って.NET(MAUI)アプリをリリースする。
Binding Libraryが対応してくれないと後続が動き出せない訳ですが、先述の通り半公式と思われていたMicrosoftのBinding Libraryが更新停止状態だったのでissueが悲惨な状況でした。
(実は今のところライブラリのPrivacy Manifestは特に何も言われないおかげで何とかなっているアプリもあるのでは)
つまりどうするべきか?
身も蓋も無い言い方をすると、Microsoftが半公式的なBinding Libraryの維持に限界を迎え、Slim Bindingによる自力解決を提案している状況です。
まずはライブラリの依存関係を把握しましょう。
アプリが依存しているライブラリ、ライブラリが依存しているライブラリを調査します。Binding Libraryが含まれていなければひとまず気にすべきことはありません。
直接的にBinding Libraryを利用していた場合
ライブラリの保守状況を確認します。 更新が望めず代替ライブラリも無い場合はSlim Bindingで対処する必要があります。
間接的にBinding Libraryを利用していた場合
Plugin.xxxのようなライブラリがBinding Libraryに依存していて、Binding Libraryの代わりにSlim Bindingで対処する場合の想定です。
プラグインで利用していた機能を自力で実装できるならそれが一番シンプルな解決法でしょう。 プラグイン全体が必要な場合、ソースコードを取り込んだ上でBinding Libraryに依存していた部分をSlim Bindingで作るライブラリが提供するように変更する方法が考えられます。今のところ試す機会はありませんでしたが、自分ならそうすると思います。(それが許されるかどうかはライセンスによるので確認してください)
関連情報
Slim Bindingアプローチのサンプル
Xamarin.Firebase.iOS.*のサポートに関する議論
Xamarin.Firebase.iOS.*ライブラリ群を引き継いでフォーク版を公開している方がいます。
Binding Library関連ドキュメント(iOS)
- iOS ライブラリのバインド - Xamarin | Microsoft Learn
- iOS Swift ライブラリのバインド - Xamarin | Microsoft Learn
- Xamarin.iOS バインド プロジェクトの移行 - .NET MAUI | Microsoft Learn
Binding Library関連ドキュメント(Android)
【Xamarin → .NET 6+(MAUI)移行】 csprojファイルに書くと良いかも知れない設定 3選
Xamarin.FormsアプリをMAUIへ移行する際にプロジェクトファイル(csproj)を直接編集して設定変更することが度々ありました。 今回はその中で利用機会のありそう3点をご紹介します。
- プラットフォーム固有のソースファイル指定
- (MAUI向け) XamlC強制
- HttpHandler設定
プラットフォーム固有のソースファイル指定
1つのプロジェクトで複数プラットフォーム向けのアプリをビルドする場合におすすめの設定です。 MAUIで特に有効ですが、SDKスタイルのプロジェクト全般で利用できるためマルチプラットフォーム向けライブラリを作る際にも役立ちます。
csprojファイルを編集して<Project>
要素の直下に追加します。
<!-- ターゲットがAndroidの場合だけ .Android.cs をコンパイルするようにする --> <ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'android'"> <Compile Remove="**\**\*.Android.cs" /> <None Include="**\**\*.Android.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" /> </ItemGroup> <!-- ターゲットがiOSの場合だけ .iOS.cs をコンパイルするようにする --> <ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'ios'"> <Compile Remove="**\**\*.iOS.cs" /> <None Include="**\**\*.iOS.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" /> </ItemGroup>
説明
ファイルの命名規則を利用して、特定のプラットフォームの場合にのみコンパイルするファイルを制御できるようにします。
これにより、ソースコードが #if
だらけで読みにくくなることを防ぐことができます。
特にMAUIでカスタムViewやカスタムHandlerを書く場合、ネイティブコントロールを扱うためのプラットフォーム固有のコードが大きくなるため、ファイル分割の恩恵も大きくなります。
例えば、このようにファイル分割します。 *.cs
に共通なプロパティ定義などを書き、 *.<プラットフォーム名>.cs
でネイティブコントロールのプロパティに伝播させるイメージです。
- CustomViewHandler.cs
- CustomViewHandler.Android.cs
- CustomViewHandler.iOS.cs
余談
実は指定内容が「"◯◯以外"の場合、".◯◯.cs"を除外する」というややこしい物になっています。 これはデフォルト設定が「".cs"を全て含める」と指定しているのを打ち消すためです。
(MAUI向け) XamlC強制
MAUIプロジェクトの場合にのみ有効な設定です。 Xamarin.Formsの頃と比較してデバッグ実行時の画面遷移が遅くて困っている場合にお試しください。
csprojファイルを編集して<Project>
要素の直下に追加します。
<PropertyGroup> <!-- MAUIのデフォルト設定を変更して、Debugビルドでも事前コンパイル済みのXAML読み込みを利用するようにしてViewの初期化を高速化します。 --> <!-- 代わりにコンパイル時間が伸びるため、繰り返しビルドする場合や実行時の速度が重要でない場合はコメントアウトしてください。 --> <MauiXamlCValidateOnly>False</MauiXamlCValidateOnly> <_MauiForceXamlCForDebug>True</_MauiForceXamlCForDebug> </PropertyGroup>
説明
Xamarin.Formsには、XAMLの定義内容を事前コンパイルして実行時の初期化処理を速くする機能があります。 この機能はMAUIにも引き継がれましたがデバッグ時の挙動が変わっており、実行時にはXAMLの解析しながら初期化するため遅くなり、事前コンパイルはエラー検証のために使うようになりました。 この変更は、XAMLコンパイル結果をアプリに組み込む工程を省略することで、繰り返しビルドを速くするためのようです。
画面の初期化が遅すぎてデバッグ時の動作検証に支障がある場合、上の設定を使ってみてください。
HttpHandler設定
Xamarinで HttpClientHandler
を使っていたプロジェクト向けの設定です。必要な場合のみ利用してください。
csprojファイルを編集して<Project>
要素の直下に追加します。
<PropertyGroup> <UseNativeHttpHandler>false</UseNativeHttpHandler> <PropertyGroup>
説明
Android/iOSネイティブのHTTP通信実装ではなく.NET由来のHTTP通信実装を使いたい場合、Xamarinでは HttpClientHandler
を利用すると実現できました。
一方、.NET 6以降ではUseNativeHttpHandlerの設定によって HttpClientHandler
の挙動が変わるため同じようにはいきません。
true(デフォルト)の場合、内部でネイティブのHTTP通信実装が使われてしまうため、Xamarinの挙動に近づけるためにfalseに変更する必要があります。 falseの場合でも、Xamarin(Mono)と.NETではHttpClientHandlerの実装が異なるため、期待と異なる挙動をする可能性があります。 その場合はGitHubでそれぞれリポジトリを調べてみてください。
.NET 6+, MAUIで使えるデバイステストランナー
今回は.NET 6以降、MAUI世代のユニットテストに使えるデバイステストランナーについて紹介します。
動機
Xamarin.Forms世代の頃には、古い内容のままでしたが一応ユニットテスト用のプロジェクトテンプレートが存在していました。しかしこれは.NET 6以降のiOS, Androidアプリに対応していないため乗り換える必要がある、というお話です。 xUnit向け、NUnit向けと1つずつ紹介します。
デバイステストテストランナーとは?
ここではiOS, Androidアプリ上で実行するテストを"デバイステスト"(Device Tests)と呼びます。 テストランナーについては、xUnitやNUnitを使って書かれたテストコードを"収集して順番に実行する"存在を指します。 今回紹介するデバイステストランナーはiOS, Androidアプリとしてテストコードを実行するためのライブラリです。
なぜ必要なのか?
Visaul Studio組み込みで動くユニットテストランナーもありますが、あれらでは.NETコンソールアプリケーションとして実行するため、.NET for iOS/Androidのプログラムをテストできません。 iOS, Android固有の機能やクラスを利用するコードをテストするには、iOS, Androidアプリとして実行する必要があります。
[xUnit向け] Shiny.Xunit.Runners.Maui
- [GitHub] https://github.com/shinyorg/xunit-maui
- [NuGet] https://www.nuget.org/packages/Shiny.Xunit.Runners.Maui
GitHubのdotnet/mauiリポジトリの中にあるテストツールを切り出したものです。 元のテストツールはMAUIを使ってMAUIをテストするような作りになっているところを、ライブラリとして利用できるように工夫されています。
余談: MAUIプロジェクトではxharnessという実験的な内製ツールを使ってHeadless Testをしているようです。興味がある人は覗いてみてください。(dotnet/xharness: C# command line tool for running tests on Android / iOS / tvOS devices and simulators)
使い方
MAUIの新規アプリを作成して CreateMauiApp()
の部分に .ConfigureTests()
と .UseVisualRunner()
を追加します。
TestOptions.AssembliesにxNuitのテストが定義されているAssemblyを指定すると、自動的にテストを収集して実行してくれます。
テストランナー画面
必要最低限の機能を備えているという感じ。テキスト入力によるフィルタリングがありがたいです。
[NUnit向け] NUnit.Maui.Runner
Xamarin.Forms版のNUnitテストランナーをForkしてMAUI仕様に手直ししたライブラリのようです。
- [GitHub] https://github.com/JaneySprings/NUnit-MAUI-Runner/tree/master
- [NuGet] https://www.nuget.org/packages/NUnit.Maui.Runner
使い方
まずConfigクラスを用意します。ProvideAssemblies()
でNNuitのテストが定義されているAssemblyを返すようにしてください。
MAUIの新規アプリを作成して CreateMauiApp()
の部分で .UseMauiApp<T>()
に渡すApplicationクラスの型をテストランナーが用意したAppクラスに差し替えます。そして builder.Services
に上で用意したConfigを登録します。
テストランナー画面
こちらも必要最低限の機能を備えているという感じ。ただテキスト入力フィルタは付いていません。
おわり
アプリの規模やプロジェクトごとの判断によるところではありますが、共通な部分は標準のテストランナーで.NETコンソールアプリとしてユニットテストを実行して、iOS, Android固有な部分は実アプリでテストすると割り切る判断もアリだと思います。公式ドキュメントも割とそんな世界観のように見えます。
とはいえ、MAUIのプロジェクトテンプレートにも何かしらユニットテストプロジェクトを用意してほしいところですね。
なぜXamarinから.NET 6+, MAUIへ移行しなければならないのか
Xamarin、およびXamarin.Formsのサポート終了期日(2024年5月1日)まで半年を切りました。
今回はなぜ移行が必要なのか、どんな作業が発生するのかといった事について書いていきます。
なお、本記事では.NET 6とそれ移行のバージョンについて.NET 6+と表記します。
移行のモチベーション
なぜXamarinから.NET 6+へ移行が必要なの?
(ここはXamarin.Formsを使っていないアプリにも共通するお話です)
サポートが切れてもXamarinを使い続ければ良いのでは?
最大の動機は ストアにアプリを公開し続けるため です。これにつきます。
iOS, Androidどちらのアプリストアも提出要件に対象OS、SDKの最低バージョンを設けており、徐々にこれを引き上げています。 一方でXamarinのサポートポリシーでは「Android API 34 と Xcode 15 SDK (iOS および iPadOS 17、macOS 14)」を最後の対象バージョンと定めています。(2023年11月12日時点 Xamarin 公式サポートポリシー | .NET)
Xamarin SDKで開発を続けた場合、ストアの提出要件がXamarinのサポート範囲を超えた時に アプリの新規公開や、アップデートができなくなってしまいます。 だから新しいSDKを使ってストアにアプリを公開し続けるのために Xamarinから.NET 6+への移行が必要 なのです。
Xamarin.Formsから.NET MAUIへの移行は必須なの?
.NET 6+の新規プロジェクトに既存のXamarin.Formsアプリを移植すれば最低限の工数で対応できるのでは?
残念なことにこの試みは失敗します。
Xamarin.iOS向けのライブラリは.NET 6+とバイナリ互換がなくなってしまったため利用できません。 AndroidではXamarin.FormsのNuGetパッケージはインストールできるものの、ビルドツールが対応できないようでやはりビルド失敗となりました。
そのため、Xamarin.Formsアプリは.NET MAUIへの移行が必須となります。
.NET MAUIへの移行はどのくらい大変?
アプリの規模や作りによって変わってきますが、たいてい1か月以上かかる作業と考えてもらって良いと思います。 必然的に既存のXamarin.Forms版と並行開発する期間が発生しますので運用についても工夫しましょう。
いくつかMAUIへの移行に取り組んだ感触として、大きく次のような段階に分けられると思います。
- 新規MAUIプロジェクトへ既存Xamarin.Formsアプリのソースコードを移植、ビルドできるように修正する
- 動かしてみて実行時エラーを出しながら修正する
- レイアウト崩れ、破綻を修正する
特にライブラリ移行の影響が大きく、どうしてもMAUIで利用できない場合は代わりを自作する、もしくは仕様の方を変更して調整する必要が出てきます。 移行作業を始める前に調査して目処をつけておくことをオススメします。
新規MAUIプロジェクトへ既存Xamarin.Formsアプリのソースコードを移植、ビルドできるように修正する
移植したソースコードのエラーをひたすら解消しつつ、MAUIアプリとしてビルドできるようにする作業です。 なんだかんだ、これらの作業で2〜4週間くらいかかると思います。
- MAUI方式のプロジェクトへ組み換え
- MAUIで名前空間やクラスが変更された箇所への対応
- 今まで利用していたライブラリの互換性調査、移行
MAUI方式のプロジェクトへ組み換え
MAUIは1つのプロジェクト(csproj)から複数の成果物をビルドするSingle Projectというスタイルを採用しています。 Xamarin.Formsでは最低3プロジェクト構成が基本なので、1つにまとめる際にディレクトリ構造をどうまとめるかなど、検討ポイントがあります。 csprojで使用する設定も変わっているため、何をいじれば良いのか把握するのも大変だと思います。
MAUIで名前空間やクラスが変更された箇所への対応
主にXamarin.Forms, Xamarin.EssentialsからMAUI, MAUI Essentialsへの移行作業です。 公式ドキュメントに情報があって比較的取り組みやすい部分です。
今まで利用していたライブラリの互換性調査、移行
今まで利用していたライブラリのMAUI, .NET 6+対応版が出ているか調査して、あれば乗り換えます。 初期化方法MAUI向けになっていたり使い方が変わっていることが多いです。
後継ライブラリが存在しない場合、独自に同じ機能を実装できそうか、他に乗り換えられるライブラリがないか検討する必要があります。 あまりに労力が大きすぎる場合、MAUIへの移行そのものに影を落とすかもしれません。
一見MAUI対応版が出ていても一部の機能が削除されているケースもありますので根気よく取り組みましょう。
動かしてみて実行時エラーを出しながら修正する
ここまで全く動作確認できずにコードを修正しているので、ちょっと動かすとすぐに問題が出ます。 主要なシナリオや移行時に変更した箇所を重点的にチェックしましょう。
レイアウト崩れ、破綻を修正する
MAUIでレイアウト仕様に見直しが入っているため、Xamarin.Formsで問題ないXAML記述でもUIが崩れる場合があるため、最終的には全画面を確認したいです。 軽微なものは余白の大きさが変わっている程度ですが、Viewが重なってボタンが押せなくなってしまう場合もあります。
終わり
つまるところアプリをリリースし続けるならば.NET 6+, MAUIへ移行は避けられないというお話でした。 会社のお仕事でXamarin.FormsからMAUIへの移行サポートをやっていますので、ご入用でしたらいったん@ticktackmobileの方までご連絡ください。
MAUI移行時のDependencyServiceの対処
Xamarin.FormsアプリをMAUIへ移行する際に遭遇した問題から一つ。
問題
DependencyService.Get<T>()
でインタンスを取得できなくなる。
解決方法
型の登録方法を変更し、DependentyAttribute
で自動登録する方法から、明示的な手動登録に変更する。
解説
MAUIへ移行すると DependencyService.Get<T>()
した際にインスタンスを取得できなくなると思います。
型の自動登録は、DependencyService.Get<T>()
した際にDependencyServiceが未初期化なら、AssemblyからDepenedency属性のついた収集して初期化するという実装になっています。
しかし、MAUIではブートストラップ処理中にDependencyServiceを初期化済みとマークするため自動登録が働くなっています。 このため、従来の処理を移植すると未登録の型を要求することになりインスタンスが取得できなくなります。
おそらくMAUI標準のDependency Injectionを利用して欲しいという意図とパフォーマンス上の理由でこのような扱いになっているのだと思います。 (依存関係の挿入 | Microsoft Learn)
対策としては、こんな感じの型登録処理を用意して MauiProgram.CreateMauiApp()
の際に呼ぶと良いでしょう。
static void SetupDependencyService() { #if ANDROID DependencyService.Register<IService, MyApp.AndroidServiceImpl>(); #elif IOS DependencyService.Register<IService, MyApp.iOSServiceImpl>(); #endif }
余談
理屈としては UseMauiApp<T>()
より先に DependencyService.Get<T>()
すると型の自動登録が走るので、そういう解決方法もアリかも?
参考
DependencyService初期化済みとする部分
DependencyAttributeから型を自動登録する部分
HttpClient周りの仕様が変わってました
Xamarinから.NET 6+(MAUI含む)へ移行する際に注意すべきHTTPスタックのお話です。
HttpClientの挙動に影響するので、.NET 6+へ移行したらHTTP通信周りに異常が無いかテストしましょう!
HTTP Handlerの設定
.NET 6+になってHTTP Handler周りの仕様がXamarin.iOS / Androidの頃と変わりました。
Xamarin時代ではプロジェクト設定の↓こういうところにいたやつです。
Xamarinの場合
アプリプロジェクトでHttpClientが規定で使用するHttpMessageHandlerの実装を指定します。 Android/iOSのHTTPスタックのいずれか、Managed(.NET由来の実装)から選択可能です。
実装的には HttpClient
を引数無しで初期化する場合に影響します。
.NET 6+の場合
アプリプロジェクトの UseNativeHttpHandler
という設定が新しくできました。
- true: Android/iOS 用の HttpMessageHandler を使用します。
- false: Managed(.NET由来の実装)を使用します。
実装的には HttpClientHandler
を使用する場合に影響します。
HttpClientHandler
は.NET版とネイティブ版両方のHTTPスタックのラッパーのような立ち位置になり、UseNativeHttpHandler
の値によって挙動を切り替えます。
HttpClient
を引数無しで初期化した場合は HttpClientHandler
が使用されるようになっているため、こちらにも影響します。
HttpClient初期化方法による違い
Xamarinの場合
引数で任意のHttpMessageHandler実装を与えればHTTPスタックをコントロールできます。
.NET 6+の場合
UseNativeHttpHandlerがtrueの場合、基本的に.NETのHTTPスタックを使う道が無くなります。
ライブラリを作る場合の注意
UseNativeHttpHandler
はアプリビルド時の設定なのでライブラリでは制御する事が出来ません。
.NET版のHttpMessageHandler実装を期待して HttpClientHandler
を使用していた場合、ネイティブHttp Handlerが使われても動作するように修正するか、独自に.NET版のHttpMessageHandler実装を用意する必要があります。
例えば、iOSでUseNativeHttpHandlerがtrueの場合、HttpClientHandler.Proxyのセッターを呼び出すと例外が発生します。
実行時にUseNativeHttpHandlerの値を取得する
次のようなコードでアプリビルド時に設定されたUseNativeHttpHandlerの値を取得する事ができます。
System.AppContext.TryGetSwitch("System.Net.Http.UseNativeHttpHandler", out bool isNativeHttpHandlerEnabled);
参考
iOS, Androidで動作する場合のHttpClientHandlerクラスのソースコード - https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.AnyMobile.cs - https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpClientHandler.AnyMobile.InvokeNativeHandler.cs
iOSで使用されるネイティブ版HttpHandlerのソースコード - https://github.com/xamarin/xamarin-macios/blob/main/src/Foundation/NSUrlSessionHandler.cs
Androidで使用されるネイティブ版HttpHandlerのソースコード - https://github.com/xamarin/xamarin-android/blob/main/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs