見出し画像

FlutterプロジェクトとプラグインでローカルAARを利用する際の注意点


はじめに


みなさんお元気ですか。 SHIFT DAAEグループ所属のsakuraiです。

既存のAndroid、iOSアプリで利用しているAAR(Android ライブラリ)とXCFramework(iOS)を
Flutterの新規アプリでも利用したいという要件があり、対応方法を調査しました。

無事対応できたのですが AARを取り込む時に少し工夫が必要になりましたので、今回はその手順についてブログを書こうと思います。

導入前提

環境は以下の通りです。

% flutter doctor
[✓] Flutter (Channel stable, 3.27.1, on macOS 14.7.1 23H222 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.0)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.1)
[✓] VS Code (version 1.96.1)
[✓] Connected device (6 available)
[✓] Network resources

サンプルAARの準備

AAR取り込み確認のため、以下の手順で確認用のAARを作成します。

  1. Android Studioで新規のAndoroidプロジェクトを作成しプロジェクト内で新規モジュールsampleLibraryを作成します。

  2. SampleApi.ktファイルを作成し、バッテリー情報を取り出すシンプルなAndroidの処理を記載します。

package com.example.sakurai.sampleLibrary

import android.content.Context
import android.os.BatteryManager

class SampleApi(private val context: Context) {
    fun getBatteryLevel(): Int {
        val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
    }
}

3.sampleLibraryモジュールをAARとしてビルドして、sampleLibrary.aarを作成します。

FlutterプロジェクトにローカルAARを導入する

それでは作成したAARをFlutterに取り込んでいきましょう。
まずはFlutterプロジェクトにそのままAARを導入します。

以下のコマンドで新規プロジェクトを作成します。

% cd ~
% flutter create --org com.example.sakurai --platforms=android,ios -a kotlin aar_sample_project

プロジェクト作成後、androidディレクトリにlibsディレクトリを作成し、aarファイルを追加します。

aar_sample_project
└── android
    └── libs
        └── sampleLibrary.aar ← aarファイルを追加する

次にandroid/app/build.gradleに以下を追記します。
今回はローカルのsampleLibrary.aarは外部ライブラリに依存しておらず、
単体で参照できればOKですのでファイルパスで指定しています。

//  以下を追記
dependencies {
    implementation files('../libs/sampleLibrary.aar')
}

これでaarの読み込み設定は完了です。
android/app/src/main/kotlin/com/example/sakurai/aar_sample_project/MainActivity.kt
でaar内のAPIが呼び出せることを確認します。

package com.example.sakurai.aar_sample_project

import android.content.Context
import androidx.annotation.NonNull
import com.example.sakurai.sampleLibrary.SampleApi // sampleLibrary.aarが利用できる
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel 

class MainActivity : FlutterActivity() {
    private val channel = "com.example.sakurai.aar_sample/sample"

    private val context: Context = this

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger, channel
        ).setMethodCallHandler { call, result ->
            when (call.method) {
                "getBatteryLevel" -> {
                    result.success("BatteryLevel ${SampleApi(context).getBatteryLevel()}")                }

                else -> {
                    result.notImplemented()
                }
            }
        }
    }
}

最後にlib/main.dartを変更し、
Fluuter側からMethodChannnel経由でAAR内の処理が実行できることを確認します。

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const platform =
      MethodChannel('com.example.sakurai.aar_sample/sample');

  String _message = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              _message,
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            ElevatedButton(
              onPressed: _getBatteryLevel,
              child: Text('Get Battery Level'),
            ),
          ],
        ),
      ),
    );
  }

  Future<void> _getBatteryLevel() async {
    final String result = await platform.invokeMethod('getBatteryLevel');
    setState(() {
      _message = result;
    });
  }
}

アプリを起動すると、無事AAR経由でバッテリーの状態が確認できました。

これでAARを利用できるようになりました。(終)
としたいところですが次はFlutterプラグインでAARを利用しようと思います。こちらでは少し工夫が必要になりますのでその辺りを中心に説明いたします。

FlutterプラグインにローカルAARを導入する

既存アプリのAARを利用する場合、処理を直接プロジェクトに取り込んでも良いですが、プラグインとして独立した形式で管理すると関心の分離や再利用性といった点でメリットがありそうです。

そのようなケースのために、次はFlutterプラグインでローカルAARを取り込みたいと思います。

以下のコマンドで新規プラグインを作成します。

% cd ~
% flutter create --org com.example.sakurai --template=plugin --platforms=android,ios -a kotlin aar_sample_plugin

プラグイン作成後、androidディレクトリにlibsディレクトリを作成しaarファイルを追加します。

aar_sample_plugin
└── android
    └── libs
        └── sampleLibrary.aar ← aarファイルを追加する

Flutterプロジェクトの時と同様にandroid/app/build.gradleに以下を追記します。

//  以下を追記
dependencies {
    implementation files('../libs/sampleLibrary.aar')
}

先ほどと同様であればこれでAARの読み取りができるはずなので、
example/lib/main.dartからアプリを起動してみます。
すると以下のエラーが表示されました。

> Error while evaluating property 'hasLocalAarDeps' of task ':aar_sample_plugin:bundleDebugAar'.
   > Direct local .aar file dependencies are not supported when building an AAR. The resulting AAR would be broken because the classes and Android resources from any local .aar file dependencies would not be packaged in the resulting AAR. Previous versions of the Android Gradle Plugin produce broken AARs in this case too (despite not throwing this error). The following direct local .aar file dependencies of the :aar_sample_plugin project caused this error: /Users/sakurai/aar_sample_plugin/android/libs/sampleLibrary.aar

Flutterで作成したプラグインをAndroidで利用する場合はAARでビルドされるようなのですが、その際にローカルの.aarファイルを直接依存関係として使用することはサポートされておらずビルドエラーとなるようです。

対応方法を探していたところ、プラグインのビルドのgradleタスクでローカルAARファイルをMavenリポジトリのような構造に変換し、それをプロジェクトの依存関係として参照する対応を行なっている記事を見つけました。

Demystifying Local AAR Usage in Flutter: Step-by-Step Guide for Flutter Plugins and Projects
https://itnext.io/working-with-local-aar-files-in-flutter-6028bb289124

こちらの方法を参考に引き続き設定を行います。
android/build.gradleに追記した設定を削除し、
ローカルAARファイルをMavenリポジトリのような構造に変換するタスクを追記します。

// ファイルの先頭にimportを追記
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException

// !!!以下を削除!!!
// dependencies {
//     implementation files('libs/sampleLibrary.aar')
// }

// 以下を追記
// 参考:
// https://github.com/flutter/flutter/issues/28195#issuecomment-1686952301
// https://itnext.io/working-with-local-aar-files-in-flutter-6028bb289124
// https://gist.github.com/Mastersam07/5df9bb44d3ac48550d57fdfdbdc3700f
String localMavenPath = project.mkdir("build").absolutePath
String aarPath = localMavenPath
task useAar {
    File file = project.file("libs")
    if (file.exists() && file.isDirectory()) {
        file.listFiles(new FileFilter() {
            @Override
            boolean accept(File pathname) {
                return pathname.name.endsWith(".aar")
            }
        }).each { item ->
            String aarName = item.name.substring(0, item.name.length() - 4)
            String[] aarInfo = aarName.split("-")
            String sha1 = getFileSha1(item)
            String md5 = getFileMD5(item)
            String fromStr = item.path
            String intoStr = aarPath + "/" + aarInfo[0].replace(".", "/") + "/" + aarInfo[1] + "/" + aarInfo[2]
            String newName = aarInfo[1] + "-" + aarInfo[2] + ".aar"
            println("localMavenPath: " + localMavenPath)
            println("aar: " + aarInfo + " file sha1:" + sha1 + " md5:" + md5)
            println("aarPath: " + aarPath)
            println("intoStr: " + intoStr)
            println("newName: " + newName)
            println("fromStr: " + fromStr)
            println("intoStr: " + intoStr)

            project.copy {
                from fromStr
                into intoStr
                rename(item.name, newName)
            }

            project.file(intoStr + "/" + newName + ".md5").write(md5)
            project.file(intoStr + "/" + newName + ".sha1").write(sha1)

            String pomPath = intoStr + "/" + newName.substring(0, newName.length() - 4) + ".pom"
            project.file(pomPath).write(createPomStr(aarInfo[0], aarInfo[1], aarInfo[2]))
            project.file(pomPath + ".md5").write(getFileMD5(project.file(pomPath)))
            project.file(pomPath + ".sha1").write(getFileSha1(project.file(pomPath)))

            String metadataPath = project.file(intoStr).getParentFile().path + "/maven-metadata.xml"
            project.file(metadataPath).write(createMetadataStr(aarInfo[0], aarInfo[1], aarInfo[2]))
            project.file(metadataPath + ".md5").write(getFileMD5(project.file(metadataPath)))
            project.file(metadataPath + ".sha1").write(getFileSha1(project.file(metadataPath)))
            dependencies {
                implementation "${aarInfo[0]}:${aarInfo[1]}:${aarInfo[2]}"
            }
        }
    }
}

public static String createMetadataStr(String groupId, String artifactId, String version) {
    return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
            "<metadata>\n" +
            "  <groupId>$groupId</groupId>\n" +
            "  <artifactId>$artifactId</artifactId>\n" +
            "  <versioning>\n" +
            "    <release>$version</release>\n" +
            "    <versions>\n" +
            "      <version>$version</version>\n" +
            "    </versions>\n" +
            "    <lastUpdated>${new Date().format('yyyyMMdd')}000000</lastUpdated>\n" +
            "  </versioning>\n" +
            "</metadata>\n"
}

public static String createPomStr(String groupId, String artifactId, String version) {
    return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
            "<project xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\" xmlns=\"http://maven.apache.org/POM/4.0.0\"\n" +
            "    xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">\n" +
            "  <modelVersion>4.0.0</modelVersion>\n" +
            "  <groupId>$groupId</groupId>\n" +
            "  <artifactId>$artifactId</artifactId>\n" +
            "  <version>$version</version>\n" +
            "  <packaging>aar</packaging>\n" +
            "</project>\n"
}

public static String getFileSha1(File file) {
    FileInputStream input = null;
    try {
        input = new FileInputStream(file);
        MessageDigest digest = MessageDigest.getInstance("SHA-1");
        byte[] buffer = new byte[1024 * 1024 * 10];

        int len = 0;
        while ((len = input.read(buffer)) > 0) {
            digest.update(buffer, 0, len);
        }
        String sha1 = new BigInteger(1, digest.digest()).toString(16);
        int length = 40 - sha1.length();
        if (length > 0) {
            for (int i = 0; i < length; i++) {
                sha1 = "0" + sha1;
            }
        }
        return sha1;
    }
    catch (IOException e) {
        System.out.println(e);
    }
    catch (NoSuchAlgorithmException e) {
        System.out.println(e);
    }
    finally {
        try {
            if (input != null) {
                input.close();
            }
        }
        catch (IOException e) {
            System.out.println(e);
        }
    }
}

public static String getFileMD5(File file) {
    FileInputStream input = null;
    try {
        input = new FileInputStream(file);
        MessageDigest digest = MessageDigest.getInstance("MD5");
        byte[] buffer = new byte[1024 * 1024 * 10];

        int len = 0;
        while ((len = input.read(buffer)) > 0) {
            digest.update(buffer, 0, len);
        }
        String md5 = new BigInteger(1, digest.digest()).toString(16);
        int length = 32 - md5.length();
        if (length > 0) {
            for (int i = 0; i < length; i++) {
                md5 = "0" + md5;
            }
        }
        return md5;
    }
    catch (IOException e) {
        System.out.println(e);
    }
    catch (NoSuchAlgorithmException e) {
        System.out.println(e);
    }
    finally {
        try {
            if (input != null) {
                input.close();
            }
        }
        catch (IOException e) {
            System.out.println(e);
        }
    }
}

gradleタスクにあわせて、aarファイル名を変更します。

aar_sample_project
└── android
    └── libs
        └── com.example.sakurai-sampleLibrary-0.0.1.aar ← sampleLibrary.aarをリネームする

次にプラグインを呼び出すexampleの設定を変更します。
example/android/build.gradleにローカルへの依存関係を追記します。

allprojects {
    repositories {
        google()
        mavenCentral()
        maven {
            // 以下を追記
            url "${project(':aar_sample_plugin').projectDir}/build"
        }          
    }
}

ここでexample/lib/main.dartからアプリが起動できるようになりましたのでFlutterプロジェクトと同様にFlutterアプリからAARの呼び出しを確認します。

プラグインの処理を以下のように変更してください。

Android側の処理の変更

package com.example.sakurai.aar_sample_plugin

import android.content.Context
import com.example.sakurai.sampleLibrary.SampleApi

import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result

class AarSamplePlugin: FlutterPlugin, MethodCallHandler {
  private lateinit var channel : MethodChannel
  private lateinit var context : Context

  override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
    channel = MethodChannel(flutterPluginBinding.binaryMessenger, "aar_sample_plugin")
    channel.setMethodCallHandler(this)
    context = flutterPluginBinding.applicationContext
  }

  override fun onMethodCall(call: MethodCall, result: Result) {
    if (call.method == "getBatteryLevel") {
      result.success("BatteryLevel ${SampleApi(context).getBatteryLevel()}")
    } else {
      result.notImplemented()
    }
  }

  override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
    channel.setMethodCallHandler(null)
  }
}

Flutterプラグインの処理の変更

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

import 'aar_sample_plugin_platform_interface.dart';

/// An implementation of [AarSamplePluginPlatform] that uses method channels.
class MethodChannelAarSamplePlugin extends AarSamplePluginPlatform {
  /// The method channel used to interact with the native platform.
  @visibleForTesting
  final methodChannel = const MethodChannel('aar_sample_plugin');

  @override
  Future<String?> getBatteryLevel() async {
    final version = await methodChannel.invokeMethod<String>('getBatteryLevel');
    return version;
  }
}
import 'package:plugin_platform_interface/plugin_platform_interface.dart';

import 'aar_sample_plugin_method_channel.dart';

abstract class AarSamplePluginPlatform extends PlatformInterface {
  /// Constructs a AarSamplePluginPlatform.
  AarSamplePluginPlatform() : super(token: _token);

  static final Object _token = Object();

  static AarSamplePluginPlatform _instance = MethodChannelAarSamplePlugin();

  /// The default instance of [AarSamplePluginPlatform] to use.
  ///
  /// Defaults to [MethodChannelAarSamplePlugin].
  static AarSamplePluginPlatform get instance => _instance;

  /// Platform-specific implementations should set this with their own
  /// platform-specific class that extends [AarSamplePluginPlatform] when
  /// they register themselves.
  static set instance(AarSamplePluginPlatform instance) {
    PlatformInterface.verifyToken(instance, _token);
    _instance = instance;
  }

  Future<String?> getBatteryLevel() {
    throw UnimplementedError('getBatteryLevel() has not been implemented.');
  }
}
import 'aar_sample_plugin_platform_interface.dart';

class AarSamplePlugin {
  Future<String?> getBatteryLevel() {
    return AarSamplePluginPlatform.instance.getBatteryLevel();
  }
}

今回利用しないtestファイルの削除

% rm -r example/integration_test/plugin_integration_test.dart
% rm -r test/aar_sample_plugin_method_channel_test.dart
% rm -f test/aar_sample_plugin_test.dart

最後にexample/lib/main.dartを書き換えてAAR経由でバッテリーレベルが取得できることを確認します。

import 'package:flutter/material.dart';
import 'dart:async';

import 'package:aar_sample_plugin/aar_sample_plugin.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String _message = '';
  final _aarSamplePlugin = AarSamplePlugin();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text(
                _message,
                style: Theme.of(context).textTheme.headlineMedium,
              ),
              ElevatedButton(
                onPressed: _getBatteryLevel,
                child: Text('Get Battery Level'),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Future<void> _getBatteryLevel() async {
    final String result = await _aarSamplePlugin.getBatteryLevel() ?? '';
    setState(() {
      _message = result;
    });
  }
}

実際にプラグインとして利用する場合はmelosまたはPub workspacesを利用して、アプリ本体側からプラグインを参照することになると思います。

その場合はアプリ本体のpubspec.yamlにaar_sample_pluginを追加した上で
アプリ本体のandroid/build.gradleに以下を追記することで利用可能となります。

maven {
    url "${project(':aar_sample_plugin').projectDir}/build"
}

おわりに


ということで、無事FlutterでローカルのAARを利用することができました。
単純にプロジェクトに追加する場合は問題ありませんが、プラグインとして利用する場合に少し複雑になりますので この記事がお役に立てば幸いです。
お読みいただきありがとうございました。


執筆者プロフィール:sakurai
SHIFT DAAEグループ所属の開発エンジニアです。

《この公式ブロガーのおすすめ記事》

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/