(2023/1/28更新)
以前、端末内でユーザーがアクセス可能な場所にファイルを保存(バックアップ)する方法について記事を書きました。
今回は、バックアップ機能とセットで必ず必要になるインポート機能(外部ストレージからファイルを読み込んでくる機能)についても、実装方法を共有したいと思います。
結論は、バックアップ機能と比べると非常にシンプルで、
File Pickerというパッケージを使う
ことで実現できます。
ただ、実装する上で、つまづいた点などもありましたので、留意点などを共有したいと思います。
40代からプログラミング(Flutter)を始めて、GooglePlayとAppStoreにアプリを公開しているhalzo appdevです。
作成したアプリはこちら↓ 全てFlutterで開発したアプリです。
File Pickerの導入
File Pickerは、端末に標準搭載されているファイル管理アプリを起動して、選択したファイルをアプリ内に読み込む機能を提供してくれるパッケージです。
上記サイトからパッケージを入手し、「pubspec.yaml」ファイルに以下を追記して、「pub get」します。 ※バージョン番号は、適宜、その時点の最新のものをご利用ください。
dependencies: file_picker: ^4.0.3
この他、後段で説明する実装例では、「path_provider」、「external_path」、「permission_handler」のパッケージも使っています(保存先のパスを取得するために、最低でも「path_provider」は必要になります)。
※以前採用していた「ext_storage」は、バージョン不適合の警告が出るため、「external_path」に変更しました。
これらのパッケージの使用方法については、バックアップ方法について書いた記事をご参照ください。
File Picker実装上の留意点
Androidの事前設定
パッケージの公式説明に記載がありますが、Androidについては、特に設定は必要ありませんでした。
公式説明には、リリースビルド時にファイルの追加が必要になる旨の記載がありますが、自分の環境では特にエラーは発生しませんでした。
iOSの事前設定
こちらもパッケージの公式説明に記載がありますが、読み込むファイルタイプ(拡張子の種類)に応じて設定が異なります。
ここでは、よく使われそうなケースについて説明します。
全てのファイルタイプ、もしくは特定の拡張子だけ(カスタム)を読み込みたい場合
全てのファイルタイプ、もしくは、カスタム設定で指定した拡張子だけ読み込みたい場合には、「Info.plist」に以下を追記する必要があります。※ファイルタイプ(拡張子)の指定については、後述します。
<key>UIBackgroundModes</key> <array> <string>fetch</string> <string>remote-notification</string> </array>
追記する場所は、「info.plist」の上から4行目あたり(何行目かは人によって若干違うかもしれません)にある<dict>と、下から2行目にある</dict>の間であれば、どこでも大丈夫です。
実は、上記を設定しなくても、シミュレーターや実機では動作できてしまったのですが、恐らく設定していないとAppleの審査でリジェクトされるので(試していないですが、、)、設定した方が良いと思います。
フォトライブラリから読み込みたい場合
フォトライブラリの画像(「写真」アプリからアクセスできる画像)を読み込みたい場合は、下記を設定する必要があります。
<key>NSPhotoLibraryUsageDescription</key> <string> XXXXXXXXXXXXXXXXXXX </string>
「XXXXXXXXXX」の部分には、なぜフォトライブラリにアクセスする必要があるか、の理由を記載します(文言自体は、意味が通じる内容であれば自由です)。
ただ、若干ハマるポイントだったのですが、読み込み可能なファイルタイプ(ファイルタイプの設定方法については後述します)を、「type: FileType.any」(全てのファイルタイプ)にした場合、上記を設定しても、フォトライブラリにアクセスできませんでした。
結局、アクセスするには、ファイルタイプを「FileType.image」か「FileType.video」にする必要がありました。確かに公式説明にそう書いてあるのですが、大は小を兼ねると思い、「.any」でも大丈夫だろうと考えましたが、そうはいきませんでした。
アプリ専用フォルダから読み込みたい場合
アプリ専用フォルダ内に保存されたファイルを読み込みたい場合には、アプリ専用フォルダをユーザーがアクセスできる状態にする必要があるため、下記を設定します。
<key>UISupportsDocumentBrowser</key> <true/> <key>LSSupportsOpeningDocumentsInPlace</key> <true/>
但し、この設定は、端末内へのバックアップ機能の実装時に必要になるため、設定済のことも多いかもしれません。
その他
上記の他に、公式説明では、iosフォルダにある「Podfile」内に「use_frameworks!」の記述が必要と書かれています。
・・・略・・・ flutter_ios_podfile_setup target 'Runner' do use_frameworks! # ← この部分です。 use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end ・・・略・・・
自分の環境では、元々Podfileが作成された時点で、上記の記載がありましたが、無い方は追記が必要になります。
File Pickerを使う部分のコード
Future型(非同期)で定義したメソッドの中に、下記1行のコードを書くだけで、
「ファイル」アプリ起動
→ ユーザーがファイルを選択
→ データをインスタンス(下記例では、「importFileDataO」)に格納
という処理を実現できます。
FilePickerResult? importFileDataO = await FilePicker.platform.pickFiles();
下記によりファイルのパスを取得し、File型のインスタンスとして取得することができます。
File fileO = File(importFileDataO.files.single.path!);
とても簡単で驚きです。
アクセス可能なファイル形式(拡張子)を限定する
全てのファイルをアクセス可能にすると、ユーザーの操作次第で、想定と異なる形式のファイルを読み込まれる可能性があります。
その後の処理次第では、アプリが動かなくなってしまいます。
自分の経験では、テキストファイルを読み込み、ファイル内容をテキストとして抽出するコードを書いたのですが、誤って画像ファイルを読み込んだら、アプリがフリーズしてしまいました。
そのため、「pickFiles()」の中に引数を設定し、事前に読み込み可能なファイル形式を限定しておく(フィルタリングする)のがお勧めです。
具体的には、以下のように書きます。これは、拡張子が「.txt」のファイルだけ読み込み可能にした場合の例です。
FilePickerResult? importFileDataO = await FilePicker.platform.pickFiles( type: FileType.custom, // 拡張子のピリオドは付けずに指定する。付けるとエラーになる allowedExtensions: ['txt'], );
こうすると、下図のように、テキストファイル以外はグレーアウトして選択できなくなります。
これもとても便利な機能です。
ファイル形式(拡張子)を限定するとエラーになる場合の対処法
但し、フィルタリングできる拡張子は決められています。
自分は、SQLiteを使ったデータベースのバックアップ・インポートをやりたかったので、拡張子に「db」を指定したところ、以下のようなエラーが出てしまいました。
I/flutter ( 7506): [MethodChannelFilePicker] Platform exception: PlatformException(FilePicker, Unsupported filter. Make sure that you are only using the extension without the dot, (ie., jpg instead of .jpg). This could also have happened because you are using an unsupported file extension. If the problem persists, you may want to consider using FileType.all instead., null, null)
「db」はサポートしていない拡張子だということです。
そのため、自分は以下の方法で対処しました。
まず、引数に「type: FileType.any」を設定し、全てのファイル形式を取得可能に設定します。
FilePickerResult? importFileDataO = await FilePicker.platform.pickFiles( type: FileType.any, );
次に、下記のように「.files.first」→「.name」のプロパティに順にアクセスして、選択したファイルのファイル名を取得します。
PlatformFile platformFileO = importFileDataO.files.first; String platformFileNameO = platformFileO.name;
あとは、このファイル名に、特定の拡張子が含まれているかどうかを条件分岐で処理して(「platformFileNameO.endsWith('.db')」などを使って判定)、フィルタリングを自作しました。
ただ、そもそも、
「type: FileType.」の引数でフィルタリングに対応している拡張子はどれなのか?
を知りたい所です。
パッケージの公式説明にも情報がなく、探すのに苦労しましたが、GitHubのIssuesに、下記ページに記載があるとの情報がありました。
このページに記載のある拡張子なら、フィルタリングの引数として設定が可能なようです(実際、いくつか試しましたが、大丈夫でした。ただ、GitHubの報告を見るといくつか使用できない拡張子もあるようですが、、、)。
iOSの場合、1分間しか一時保存されないため注意が必要
iOSの場合、「type: FileType.any」や「type: FileType.custom」を設定すると、iOSに標準搭載されている「ファイル」アプリからファイルを取得する形になります。「写真」アプリからは取得できません。
一方、「type: FileType.image」や「type: FileType.media」を設定すると、「写真」アプリからの取得になりますが、「ファイル」アプリからは取得できなくなります。
「ファイル」アプリから取得する場合、取得したファイルは、下記のようなパスに一時的に保存されます。
[iOSシミュレーターの例]
/Users/######/Library/Developer/CoreSimulator/Devices/####デバイスの識別番号####/data/Containers/Data/Application/####デバイスの識別番号####/tmp/com.example.studyapp-Inbox/画像ファイル名.jpg
※アプリのbundle identifierが「com.example.studyapp」の場合
しかし、このフォルダに保存されたファイルは、1分経つと自動的に削除されてしまいます。
そのため、ファイルを取得したら、すぐに別のフォルダにファイルを移動させる必要がある点に注意が必要です。
後述のサンプルコードでは、この点を考慮した設計になっています。
この問題について、もう少し詳しく書いた記事がありますので、よろしければ下記をご参照ください。
実装例(サンプルアプリ)のアウトプットイメージ
端末内へのバックアップ方法について整理した記事の「方法(2)」のコードに、インポートの機能を単純に追加する形で、実装例(サンプリアプリ)を作成しました。
UIとしては、
- 「テスト⑤」のボタン
- 端末内に保存したテキストファイルの内容を表示する部分(画面下部)
を追加しただけになります。 ※今回は「テスト④Step2」のボタンは使いません。
本サンプルアプリでは、以下の流れでインポートの挙動を確認できます。
- 「テスト④Step1」のボタンを押すと、テキストファイル「AppFolderTest.txt」が生成され、アプリ専用フォルダ内に保存される。同時に、ファイルの内容(テキスト)が画面下部に表示される。
- 「テスト③」のボタンを押すと、別のテキストファイル「SaveTest.txt」が生成され、ダウンロードフォルダ(iOSの場合はアプリ専用フォルダ)に保存される。
- 手動で、2.で保存した「SaveTest.txt」を外部ストレージ(AndroidはGoogle Drive、iOSはiCloud Driveなど)に移動する。
※機種変更時に行う、データ引き継ぎのためのインポートを想定しているため、いったん端末外のストレージにファイルを移動させる形でテストしています。 - 「テスト⑤」のボタンを押すと、ファイル選択画面が表示されるので、3.で保存した外部ストレージから「SaveTest.txt」を選択すると、インポートが実行される。
これで、1.で作成したテキストファイル「AppFolderTest.txt」の内容が、「SaveTest.txt」の内容に書き換えられ、画面下部の表示も更新されるため、インポート完了を確認できる。
■実行例(Androidの画面遷移イメージ) ※iOSも基本的に同じ流れです
実装例(サンプルアプリ)のコード
前述のとおり、バックアップ方法の記事の「方法(2)」のコードに、インポート機能のコードを単純に追加しただけになります。全体をコピーペーストして実行いただけば、動作確認いただけます。
※追加した部分には、できるだけコード中にコメントを記載しましたが、バックアップ部分の説明は割愛していますので、ご必要の際は下記記事をご参照ください。
import 'dart:io'; import 'dart:typed_data'; import 'package:external_path/external_path.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; // File Pickerパッケージの導入 import 'package:file_picker/file_picker.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: "Test", theme: ThemeData.light(), home: SampleScreenO(), ); } } class SampleScreenO extends StatefulWidget { @override _SampleScreenOState createState() => _SampleScreenOState(); } class _SampleScreenOState extends State<SampleScreenO> { final String savedFileNameO = "SaveTest.txt"; final String inAppFolderFileNameO = "AppFolderTest.txt"; // ### 本記事ではこれは使用しない final String backUppedFileNameO = "BackUpTest.txt"; // ファイル内のテキストを表示するためのプロパティ String displayTextO = ""; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("ファイル保存テスト"), ), // 画面表示の縦方向オーバーを防ぐためSingleChildScrollViewを設定 body: SingleChildScrollView( child: Center( child: Column( children: <Widget>[ SizedBox( height: 20, ), ElevatedButton( onPressed: () => _saveNewFileO(), child: Text( "テスト③\n新規ファイルを、\nダウンロードフォルダに保存", textAlign: TextAlign.center, ), ), SizedBox( height: 60, ), ElevatedButton( onPressed: () async{ await _makeFileInAppFolderO(); setState(() {}); }, child: Text( "テスト④ Step1\n新規ファイルを、いったんアプリ専用フォルダ内の\nユーザーがアクセスできない場所に保存\n(iOSの場合はアクセスできる場所に保存)", textAlign: TextAlign.center, ), ), SizedBox( height: 20, ), // ### 本記事ではこのボタンは使用しない ElevatedButton( onPressed: () => _backUpExistingFileO(), child: Text( "テスト④ Step2\nStep1で保存したファイルを\nダウンロードフォルダ\n(iOSの場合はアプリ専用フォルダ)に\n再保存(バックアップ)", textAlign: TextAlign.center, ), ), SizedBox( height: 20, ), // インポートの実行ボタン ElevatedButton( onPressed: () async{ // インポート実行メソッドの呼び出し await _importFileO(); // ファイル内容の再描画 setState(() {}); }, child: Text( "テスト⑤\n外部ストレージからファイルをインポートして、\nテスト④Step1で保存したアプリ専用フォルダ内の\nファイルを上書き", textAlign: TextAlign.center, ), ), SizedBox( height: 20, ), // テスト④ Step1で生成したファイルの内容を表示 // テスト⑤のインポートにより内容が更新される Text("ファイル内容↓"), Text(displayTextO), ], ), ), ), ); } // テスト③ Future<void> _saveNewFileO() async { final String savedContentO = '''テキストファイルです。 このファイルを端末内のダウンロードフォルダ (iOSの場合はアプリ専用フォルダ)に保存します。'''; await [Permission.storage].request(); String savedPathO = ""; if (Platform.isAndroid) { savedPathO = await ExternalPath.getExternalStoragePublicDirectory( ExternalPath.DIRECTORY_DOWNLOADS); } else { final savedDocumentDirectoryO = await getApplicationDocumentsDirectory(); savedPathO = savedDocumentDirectoryO.path; } String savedFullPathO = "$savedPathO/$savedFileNameO"; try { File savedFileO = File(savedFullPathO); print("$savedFullPathO"); await savedFileO.writeAsString(savedContentO); } catch (e) { print(e); } } // テスト④ Step1 Future<void> _makeFileInAppFolderO() async { // 「後でダウンロードフォルダに再保存する」というテキストの内容自体は、バックアップ機能に関する記事用の内容のため、本記事では関係ない final String inAppFolderContentO = '''テキストファイルです。 このファイルをいったん端末内のアクセスできない場所 (iOSの場合はアクセスできる場所)に保存し、 後でダウンロードフォルダ(iOSの場合はアプリ専用フォルダ) に再保存(バックアップ)します。'''; displayTextO = inAppFolderContentO; final inAppFolderDocumentDirectoryO = await getApplicationDocumentsDirectory(); final inAppFolderPathO = inAppFolderDocumentDirectoryO.path; String inAppFolderFullPathO = "$inAppFolderPathO/$inAppFolderFileNameO"; print("$inAppFolderFullPathO"); try { File inAppFolderFileO = File(inAppFolderFullPathO); print("$inAppFolderFullPathO"); await inAppFolderFileO.writeAsString(inAppFolderContentO); } catch (e) { print(e); } } // テスト④ Step2 ### 本記事ではこのメソッドは使用しない Future<void> _backUpExistingFileO() async { await [Permission.storage].request(); final inAppFolderDocumentDirectoryO = await getApplicationDocumentsDirectory(); final inAppFolderPathO = inAppFolderDocumentDirectoryO.path; String inAppFolderFullPathO = "$inAppFolderPathO/$inAppFolderFileNameO"; final backUppedFileO = File(inAppFolderFullPathO); Uint8List convertedBuckUppedFileO = await backUppedFileO.readAsBytes(); String backUppedPathO = ""; if (Platform.isAndroid) { backUppedPathO = await ExternalPath.getExternalStoragePublicDirectory( ExternalPath.DIRECTORY_DOWNLOADS); } else { final backUppedDocumentDirectoryO = await getApplicationDocumentsDirectory(); backUppedPathO = backUppedDocumentDirectoryO.path; } String backUppedFullPathO = "$backUppedPathO/$backUppedFileNameO"; try { File backUppedFileO = File(backUppedFullPathO); print("$backUppedFullPathO"); await backUppedFileO.writeAsBytes(convertedBuckUppedFileO); } catch (e) { print(e); } } // テスト⑤ インポートするメソッド Future<void> _importFileO() async { // file_pickerのメソッドを呼び、外部ストレージからファイルを読み込む(ファイル選択画面を起動) FilePickerResult? importFileDataO = await FilePicker.platform.pickFiles( // 読み込み可能なファイル形式を指定 ※下記は「.txt」形式に限定した例 type: FileType.custom, allowedExtensions: ['txt'], // サポート対象外の拡張子を使う場合は以下を指定し、別途ファイル名からフィルター処理を自作する必要あり // type: FileType.any, ); if (importFileDataO != null) { // 以下2行のコードは参考 // これでインポートするファイルの名前を取得できる // FileTypeでサポートしていない拡張子('db'など)をフィルターする際に使用できる // // PlatformFile platformFileO = importFileDataO.files.first; // String platformFileNameO = platformFileO.name; // file_pickerで読み込んだファイルをFile型に格納 File fileO = File(importFileDataO.files.single.path!); // どんなファイル形式でも対応できるよう、数字の羅列で表現されるバイト型データに変換 Uint8List convertedFileO = await fileO.readAsBytes(); // テスト④ Step1で保存したアプリ内専用フォルダのパスを再取得し、File型を生成 final inAppFolderDocumentDirectoryO = await getApplicationDocumentsDirectory(); final inAppFolderPathO = inAppFolderDocumentDirectoryO.path; String inAppFolderFullPathO = "$inAppFolderPathO/$inAppFolderFileNameO"; final reSavedFileO = File(inAppFolderFullPathO); // 上記パスのファイルに、外部ストレージから読み込んだファイルを上書き(ここでインポートが完了) await reSavedFileO.writeAsBytes(convertedFileO); // 上書きされたファイルの内容を抽出して表示テキストを更新 displayTextO = await reSavedFileO.readAsString(); } else { print("インポートできませんでした。"); } } }
以上になります。
まとめると、この方法では、
- 端末内のユーザーがアクセスできる場所へバックアップ
- ユーザーが自分で外部ストレージにバックアップファイルを移動
- 機種変更時に、外部ストレージからファイルをインポートしてデータを引き継ぎ
という流れで、バックアップ・インポートが可能になります。
ユーザーが手動でバックアップファイルを移動しなければならないため、ユーザビリティに課題はありますが、アプリから直接外部ストレージにアクセスしないため、ユーザーの個人情報(アカウント情報)を取得する必要がない点はメリットかと思います。
以上、ご参考になれば幸いです。
最後までお読みいただき、ありがとうございました。
個人アプリ開発で役立ったもの
おすすめの学習教材
\超初心者向けでオススメな元Udemyの講座/
\キャンペーン時を狙えば安価で網羅的な内容が学べる(日本語訳あり)/
\Gitの基礎について無料で学べる/
おすすめの学習書籍
\実用的。image_pickerに関してかなり助けられた/
\Dartの基礎文法を素早くインプットできる/
コメント