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を作成します。
Android Studioで新規のAndoroidプロジェクトを作成しプロジェクト内で新規モジュールsampleLibraryを作成します。
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を利用することができました。
単純にプロジェクトに追加する場合は問題ありませんが、プラグインとして利用する場合に少し複雑になりますので この記事がお役に立てば幸いです。
お読みいただきありがとうございました。
《この公式ブロガーのおすすめ記事》
お問合せはお気軽に
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/