Flutterでカメラアプリを作る〜写真保存編〜

前置き
FlutterAdventCalender2018 #2 に参加させていただきました。
この記事の前と、そのさらに前の合計3記事でワンセットになります。
blog.aftercider.com blog.aftercider.com
前に投稿した2つの記事は他の方も紹介していたり、ライブラリのExampleでだいたい理解できるところではあるのですが、今回のテーマとした「写真の保存」については個人的にも苦戦したため、AdventCalendarのエントリとしてまとめてみました。
環境
- Flutter (Channel stable, v1.0.0, on Mac OS X 10.14.1 18B75, locale ja-JP)
- Android toolchain - develop for Android devices (Android SDK 28.0.3)
- iOS toolchain - develop for iOS devices (Xcode 10.1)
- Android Studio (version 3.2)
記事書いてる途中でFlutter 1.0.0が出て嬉しいかぎりです!
想定する読者
- iOS開発/Android開発がなんとなくわかる
- Flutterは初心者(なので、クラス分割は行わず、ワンソースで処理を追えるようにします)
やりたいこと
以前の記事で、カメラプレビューの表示・撮影、そしてアプリケーション領域への撮影画像の保存をまとめました。
今回は撮影画像の保存先を他のアプリやPCからアクセスできるようにしていきます。
Androidであればシステム外部のストレージ領域、iOSであればPhotoライブラリ領域(別名 写真領域)に保存していきます。
ファイル保存のパーミッション設定
アプリケーション領域と異なり、Android・iOSそれぞれアプリケーション管轄外の保存領域に書き込むため、Permissionの取得が必要です。
パーミッション管理モジュールは、simple_permissionモジュールを使います。
(ちなみに最初、permission_handlerモジュールを最初使ったのですが、iOS環境でビルドするのに難儀したため、使用するのを断念しました。)
https://pub.dartlang.org/packages/permission_handler
Androidの設定
まずは設定の簡単なAndroidの方から進めます。
しばらく色々触った感想なのですが、Androidではサクッと動くモジュールも、iOSになると何か手を入れないと動かない、みたいなことが多いような気がしています。 それもあって、まずはAndroidから作るようなプロセスを今のところやっています。
以下のように、android/app/src/main/AndroidManufest/xmlに WRITE_EXTERNAL_STORAGE のパーミッションを要求する宣言を追加します。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.aftercider.cameraapp">
<!-- The INTERNET permission is required for development. Specifically,
flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method.
iOSの設定
iOSではPhotoLibraryにアクセスするためには、Info.plistに用途について記述することが必要です。
以下のように項目を追加します。詳しくはこちらの記事を。
ちなみに私の環境だと、simple_permissionを導入するとpodでエラーが発生するようになったため、ios/podfileの2行目を以下のように変更を入れる必要がありました。 Issueにも上がってるみたいです。
# Uncomment this line to define a global platform for your project platform :ios, '10.0' use_frameworks!
コードの追加
AndroidであればWRITE_EXTERNAL_STORAGE、iOSであればPhotoLibraryへのアクセスについて、パーミッションの確認・要求を行う処理を追加します。
// カメラを準備する
void setUpCamera(CameraDescription cameraDescription) async {
if (controller != null) {
await controller.dispose();
}
controller = CameraController(cameraDescription, ResolutionPreset.high);
// カメラの情報が更新されたら呼ばれるリスナー設定
controller.addListener(() {
if (mounted) setState(() {}); // 準備終わったらbuildし直す。
if (controller.value.hasError) {
showInSnackBar('Camera error ${controller.value.errorDescription}');
}
});
await controller.initialize();
// パーミッションの確認・要求
if (Platform.isAndroid &&
!await SimplePermissions.checkPermission(Permission.WriteExternalStorage)) {
SimplePermissions.requestPermission(Permission.WriteExternalStorage);
} else if (Platform.isIOS &&
!await SimplePermissions.checkPermission(Permission.PhotoLibrary)) {
SimplePermissions.requestPermission(Permission.PhotoLibrary);
}
if (mounted) {
setState(() {});
}
}
これで、アプリケーションが起動して、setUpCameraが呼ばれるタイミングでパーミッションの要求が実行されるようになります。
撮影画像の保存
次は、許可が取れた領域に撮影した画像を保存する処理を追加していきます。
Androidについては非常に簡単で、 path_provider モジュールがほとんどよしなにやってくれます。
getApplicationDocumentsDirectoryで取得していたディレクトリを、getExternalStorageDirectoryで取得するように変更すればOKです。
この際、WRITE_EXTERNAL_STORAGEのpermissionが取得できていない場合、保存に失敗するのでご注意ください。
// Before
final Directory extDir = await getApplicationDocumentsDirectory(); // アプリケーション領域
final String dirPath = '${extDir.path}/Pictures/flutter_test';
await Directory(dirPath).create(recursive: true);
final String filePath = '$dirPath/${timestamp()}.jpg';
// After
final Directory extDir = await getExternalStorageDirectory(); // 外部領域
final String dirPath = '${extDir.path}/Pictures/flutter_test';
await Directory(dirPath).create(recursive: true);
final String filePath = '$dirPath/${timestamp()}.jpg';
iOSでの処理
Androidで使用したgetExternalStorageDirectoryの関数は、残念ながらiOSでは使うことができません。
そこで、iOSのPhotoLibrary領域に写真を保存するために、 image_picker_saver モジュールを追加します。
pubspec.yamlにimage_picker_saverを以下のように追加します。
environment:
sdk: ">=2.0.0-dev.68.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
camera: ^0.2.6
path_provider: ^0.4.1
simple_permissions: ^0.1.9
image_picker_saver: ^0.1.0
image_picker_saverは、保存したいバイト配列を渡すと、PhotoLibrary領域に画像を保存してくれる機能を持っています。
ということで、iOSでは、写真撮影した画像をtemporary領域に一時画像として保存し、image_picker_saverを使って、PhotoLibrary領域にコピーするといった流れで、撮影画像を保存していきます。
// 画像撮影・保存処理
Future<String> takePicture() async {
if (!controller.value.isInitialized) {
return null;
}
Directory dir;
if (Platform.isAndroid) {
dir = await getExternalStorageDirectory(); // 外部ストレージに保存
} else if (Platform.isIOS) {
dir = await getTemporaryDirectory(); // 一時ディレクトリに保存
} else {
return null;
}
final String dirPath = '${dir.path}/Pictures/flutter_test';
await Directory(dirPath).create(recursive: true);
String filePath = '$dirPath/${timestamp()}.jpg';
if (controller.value.isTakingPicture) {
return null;
}
await controller.takePicture(filePath);
// filePathに保存されたデータをiOSならPhotoLibrary領域にコピーする
if (Platform.isIOS) {
String tmpPath = filePath;
var savedFile = File.fromUri(Uri.file(tmpPath));
filePath = await ImagePickerSaver.saveFile(
fileData: savedFile.readAsBytesSync());
}
return filePath;
}
動作確認
Android
AndroidEmulatorで動作させるとこんな感じの動作画面です。画面下部に表示された撮影先ファイルパスもちゃんと外部領域になっています。

iOS
iOSはシミュレーターだとカメラが動かせないので、手持ちのiPad miniで動作させました。
Androidと同じく、撮影されたファイルパスがきちんとPhotoLibrary領域になっています。撮影された画像も写真アプリからちゃんと閲覧することができています。

まとめ
コード量としては10行ちょっとの追加で、保存先を外部領域/PhotoLibrary領域に保存することができました。 Flutter 1.0.0が出たばっかりとはいえ、ライブラリ群がかなり準備されているおかげです。
コードについても、Dartがそこまで癖がある言語ではないこともあり、コード補完で大体出来上がった感じでした。 ただ、Podfileの変更や、AndroidManufest・Info.plistなど、各プラットフォームの開発経験がないと結構つまづきやすいところもあるので、その辺の知識はFlutter開発するにあたっても必須になるなと感じました。
また、今回コードをワンソースで見れるっていう形で作ったこともありOSでの分岐がどうしても発生してしまいましたが、クラス・モジュール設計をきちんとやるとロジック側ではあまりOS感の違いを意識しない作りにすることも可能だと思います。
もちろん、プロダクションレベルではモジュールとして隠蔽できるようにした作りにするのがいいかと思います。
書いたコード
作ったプロジェクトについてはGithubにおいておきました。ここには書ききれなかったコードの細かい変更等はそちらでご確認いただけると幸いです。