🤝

.NET for AndroidでLottieアニメーションを使えるようにしてみた(Native Library Interop)

2024/10/22に公開

はじめに

.NET for Android(※MAUI ではない)で書かれたアプリに Lottie アニメーションを実装することになりましたので、その実装手順になります。

素直に NuGet の Lottie ライブラリがあれば良かったのですが、nuget.org で検索してみても古くてメンテされてなさそうだったり、あっても MAUI 専用っぽかったりしてどうにも使えなさそうだったので(見落としているだけでしたらすみません)、ネイティブのライブラリを使った方が良さそうとなり、ネイティブライブラリの使い方を探していて見つかった Native Library Interop というアプローチ(元々は Slim Binding と呼ばれていたようです)が良さそうということで試してみました。

本記事の執筆にあたって作成したサンプルコードは以下になります。
https://github.com/tommy10344/NativeLibraryInteropSample-Android

Native Library Interop (Slim Binding)についてはこちらの記事に詳しく紹介されています。
https://www.frog-pod.com/FrogTechLog/2024/07/slim-binding-ios-android.html
https://www.frog-pod.com/FrogTechLog/2024/08/native-library-interop-slim-bindings.html

その他にも以下を参考にしています。

Microsoft 公式ドキュメント:
https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/maui/native-library-interop/

公式ドキュメントで紹介されているプロジェクトテンプレート:
https://github.com/CommunityToolkit/Maui.NativeLibraryInterop/tree/main/template/android

Lottie 本体(ネイティブライブラリ)の公式ドキュメント:
https://airbnb.io/lottie/#/android

これらの参考記事やドキュメントでは、いわゆるクロスプラットフォーム フレームワークであるところの.NET MAUIについて書かれていますが、基本的な考え方はそれほど変わりません。

ソリューション作成

まずはソリューションと必要なディレクトリを作成していきます。

mkdir NativeLibraryInteropSample-Android
cd NativeLibraryInteropSample-Android
dotnet new sln
mkdir -p App NativeLibraryInterop/Binding NativeLibraryInterop/native

ディレクトリ構成としては以下のようになります。

.
├── App
├── NativeLibraryInterop
│   ├── Binding
│   └── native
└── NativeLibraryInteropSample-Android.sln
  • NativeLibraryInterop/native
    ネイティブの Lottie ライブラリをラップする Wrapper ライブラリ を作成するための Android Studio プロジェクト置き場
  • NativeLibraryInterop/Binding
    作成した Wrapper ライブラリ をバインドするための .NET Binding Library プロジェクト置き場
  • App
    動作確認用の .NET for Android アプリ のプロジェクト置き場

Wrapper ライブラリの作成

ネイティブの Lottie ライブラリをラップする、ネイティブの Wrapper ライブラリ を作成していきます。

なお、参考記事や公式テンプレートでは、もう少し細かく設定ファイルが編集されていたり、テストモジュールを削除したりしていますが、ここでは最小限の手順に留めるためにそれらの作業は省略しています。(最終的にはこれらもやった方が良いと思います)

Android Studio プロジェクトの作成

Android Studio でnativeディレクトリ内に新しいプロジェクトを作成します。


ライブラリだけのプロジェクトは作れないようなので、Empty Activity で作成。(これも後で動作確認に使います)


native ディレクトリを指定。

Android Library モジュールの作成

プロジェクトを作った時に自動的に作成されるのはアプリモジュールなので、それとは別に Lottie ライブラリを実際にラップするためのライブラリモジュールを作成します。

New > Module を選択。

今回はモジュール名を wrapper とします。

libs.versions.toml に Lottie のバージョンを追記

現在の Android Studio だとデフォルトでバージョンカタログを使用していたため、それに合わせる形で設定。

libs.versions.toml
[versions]
...
+ lottie = "6.5.2"

[libraries]
...
+ lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" }

Android Library モジュールの build.gradle.kts を編集

wrapper/build.gradle.kts
- dependencies {
-     implementation(libs.androidx.core.ktx)
-     implementation(libs.androidx.appcompat)
-     implementation(libs.material)
-     testImplementation(libs.junit)
-     androidTestImplementation(libs.androidx.junit)
-     androidTestImplementation(libs.androidx.espresso.core)
- }

+ // Create configuration for copyDependencies
+ configurations {
+     create("copyDependencies")
+ }
+
+ dependencies {
+     implementation(libs.lottie)
+     "copyDependencies"(libs.lottie)
+ }
+
+ // Copy dependencies for binding library
+ project.afterEvaluate {
+     tasks.register<Copy>("copyDeps") {
+         from(configurations["copyDependencies"])
+         into("${layout.buildDirectory.get()}/outputs/deps")
+     }
+     tasks.named("preBuild") { finalizedBy("copyDeps") }
+ }
  • configurations, project.afterEvaluate の部分は新規追加、dependencies の部分は元々あった依存関係を全て削除し、lottie への依存を追記しています。
  • project.afterEvaluate の部分では、取り込んだサードパーティライブラリ(Lottie)の aar を、後に作成するアプリプロジェクトから参照出来る場所にコピーしているようです。
    参考記事では ${layout.buildDirectory.get()} の部分が元々 ${buildDir} だったのですが、非推奨になっていたため推奨と思われる書き方に直しています。

Lottie の Wrapper View を作成

Lottie ライブラリが提供するLottieAnimationViewを .NET 側に公開されないようにするため、これを FrameLayout で包んだカスタム View を作成します。


New > UiComponent > Custom View を選択。


ここでは名前を LottieWrapperView とします。

LottieWrapperView.kt
class LottieWrapperView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    private val lottieView: LottieAnimationView

    init {
        View.inflate(getContext(), R.layout.lottie_wrapper_view, this)
        lottieView = findViewById(R.id.lottie)
    }

    public fun setAnimation(rawRes: Int) {
        lottieView.setAnimation(rawRes)
    }

    public fun setLoop(enabled: Boolean) {
        lottieView.repeatCount = if (enabled) ValueAnimator.INFINITE else 0
    }

    public fun playAnimation() {
        lottieView.playAnimation()
    }
}

今回は単純にLottieAnimationViewの以下とほぼ等価のメソッドを定義しています。

  • setAnimation
  • loop(setRepeatCount)
  • playAnimation
lottie_wrapper_view.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.airbnb.lottie.LottieAnimationView
        android:id="@+id/lottie"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</FrameLayout>

一度ネイティブのサンプルアプリで動作確認

プロジェクト作成時に自動的に作成された app モジュールを使い、まずはネイティブの世界だけでライブラリの動作を確認しておきます。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<LottieWrapperView>(R.id.lottie_wrapper).apply {
            setAnimation(R.raw.bullseye)
            setLoop(true)
            playAnimation()
        }
    }
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.example.wrapper.LottieWrapperView
        android:id="@+id/lottie_wrapper"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.5" />

</androidx.constraintlayout.widget.ConstraintLayout>
  • res/raw ディレクトリに Lottie JSON ファイル(bullseye.json)を入れています。ファイル自体はLottie 公式のサンプルから拝借しました。

.NET Binding Library プロジェクトの作成

上記で作成した Wrapper ライブラリ をバインドするための .NET Binding Library を作成していきます。

.NET プロジェクトの作成

# slnがあるディレクトリから開始
cd NativeLibraryInterop/Binding
dotnet new android-bindinglib

# slnへの追加
cd ../..
dotnet sln add NativeLibraryInterop/Binding/Binding.csproj

csproj 編集

作成したプロジェクトの csproj(Binding.csproj)に以下を追加します。

Binding.csproj
  <!-- Reference to Android project -->
  <ItemGroup>
    <NLIGradleProjectReference Include="..\native">
      <ModuleName>wrapper</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" />
    <PackageReference Include="Xamarin.Kotlin.StdLib" Version="2.0.20" />
  </ItemGroup>
  • <NLIGradleProjectReference>Include 属性に先ほど作成した Android Studio プロジェクトのルートパスを指定します。
  • <NLIGradleProjectReference> 内の <ModuleName> に Android Studio プロジェクトのライブラリモジュール名を指定します。
  • CommunityToolkit.Maui.NativeLibraryInterop.BuildTasks を取り込んでおくことで、この Binding Library をビルドする際に <NLIGradleProjectReference> で指定した Android Studio プロジェクトも一緒にビルドしてくれるようです。
  • Wrapper ライブラリが Kotlin で書かれている場合、依存関係にXamarin.Kotlin.StdLibが必要なようなので追加しています。

SupportedOSPlatformVersion(minSdkVersion)の統一

確認の時点では Android Studio プロジェクトのminSdkのデフォルトが 24、.NET プロジェクトの<SupportedOSPlatformVersion>のデフォルトが 21 だったため、とりあえず後者を 24 に合わせます。

Binding.csproj
- <SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
+ <SupportedOSPlatformVersion>24</SupportedOSPlatformVersion>

NuGet.config の追加

CommunityToolkit.Maui.NativeLibraryInterop.BuildTasks をインストールするための NuGet.config も sln と同じ階層に追加します。

NuGet.config
<?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>

(必要に応じて)Metadata.xml の編集

今回は不要ですが、必要に応じて Metadata.xml でバインドの挙動を調整していきます。
サードパーティライブラリを直接バインドしてしまうと、大量の API をバインドしようとしてエラーが発生し、泣きながら Metadata.xml を編集することになったりするかと思うのですが、薄い Wrapper ライブラリを被せることで、これを回避することが出来ます。

動作確認用の .NET for Android サンプルアプリ作成

.NET プロジェクトの作成

# slnがあるディレクトリから開始
cd App
dotnet new android

# slnへの追加
cd ..
dotnet sln add App/App.csproj

依存関係の追加

Binding Library プロジェクトを追加

cd App
dotnet add reference ../NativeLibraryInterop/Binding/Binding.csproj

aar への参照の追加

アプリプロジェクトには、依存元となっているサードパーティ aar を含めないと実行時エラーになってしまうため、<AndroidLibrary> で依存している aar へのパスを追加します。
今回は lottie-6.5.2.aar(Lottie ライブラリ本体)とokio-1.17.6.jar(Lottie が内部で依存している)を追加しています。

App.csproj
  <ItemGroup>
      <AndroidLibrary Include="..\NativeLibraryInterop\native\wrapper\bin\Release\net8.0-android\outputs\deps\lottie-6.5.2.aar">
          <Bind>false</Bind>
          <Visible>false</Visible>
      </AndroidLibrary>
      <AndroidLibrary Include="..\NativeLibraryInterop\native\wrapper\bin\Release\net8.0-android\outputs\deps\okio-1.17.6.jar">
          <Bind>false</Bind>
          <Visible>false</Visible>
      </AndroidLibrary>
  </ItemGroup>

Include 属性に書かれているパスは、Wrapper ライブラリの build.gradle.kts に記載したパス(${layout.buildDirectory.get()}/outputs/deps)です。

NuGet パッケージの追加

また、AppCompat にも依存していたのでパッケージを追加しておきます。(究極的にはこれも aar にした方が良いのかもしれませんが、流石にちょっと依存関係が複雑すぎるのと、これほどコアなライブラリだったら公式が Binding Library を提供し続けてくれると思いたいということで NuGet パッケージ で良いかなと...)

dotnet add package Xamarin.AndroidX.AppCompat

SupportedOSPlatformVersion(minSdkVersion)の統一

Binding Library と同様に、<SupportedOSPlatformVersion>を 24 に合わせます。

App.csproj
- <SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
+ <SupportedOSPlatformVersion>24</SupportedOSPlatformVersion>

呼び出しコードの追加

MainActivity.cs
using Com.Example.Wrapper;

namespace App;

[Activity(Label = "@string/app_name", MainLauncher = true)]
public class MainActivity : Activity
{
    protected override void OnCreate(Bundle? savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        // Set our view from the "main" layout resource
        SetContentView(Resource.Layout.activity_main);

        var lottieView = FindViewById<LottieWrapperView>(Resource.Id.lottie_wrapper);
        lottieView.SetAnimation(Resource.Raw.bullseye);
        lottieView.SetLoop(true);
        lottieView.PlayAnimation();
    }
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.wrapper.LottieWrapperView
        android:id="@+id/lottie_wrapper"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true" />

</RelativeLayout>
  • ネイティブのサンプルアプリ同様、Resources/raw ディレクトリに Lottie JSON ファイル(bullseye.json)を入れています。

この状態でビルドに成功すると、ネイティブのサンプルアプリと同じようにアニメーションが表示されるかと思います。

終わりに

「ネイティブライブラリをラップした Wrapper ライブラリを作成し、その aar を.NET 側でバインディングすること」自体は元から出来ましたが、

  • ラッパー側で変更が生じた場合に、わざわざ Android Studio を立ち上げて手動で aar をビルドする
  • ビルドした aar を手動で.NET プロジェクト側にコピーする

というあたりを .NET のビルド時に自動でやってくれるという感じでしょうか。

まさにこれらを自動でやってくれているであろう CommunityToolkit.Maui.NativeLibraryInterop.BuildTasks がまだプレリリース版なのが気になるところですが、最悪これだけ外す事は簡単そうなので良しとしておきます。

あとこれは Native Library Interop に限らず Binding Library 全般に言える話ですが、<AndroidLibrary>で実行時の aar の依存関係を自力で解消する必要がある点はちょっとハードル高いなというのが正直な感想ですね。(私が慣れてないのもあるとは思いますが、今回だと okio の aar を追加する必要があるのが分からなくてハマった...)

株式会社ワンポイントファイブ

Discussion