💾

【Flutter】Androidで差し込み式のSDカードやUSBストレージにデータを保存する

に公開

はじめに

AndroidではアプリのデータをUSBストレージに保存する機能やデバイスにSDカードを差し込む形で内部ストレージを増量し、任意でそのSDカードにデータを保存する機能が備わっています。
反対にiOSでは特定の領域を指定してデータを保存することはできません。

Flutterはクロスプラットフォームなアプリ開発を行うフレームワークですが、一部のAndroidユーザーからはデバイスのストレージ以外の領域にデータを保存したいという要望が根強くあります。

この記事ではそういったエッジケースに対応する2つの手法をご紹介したいと思います。

記事の対象者

  • FlutterでAndroidアプリを開発しており、データをデバイスのSDカードやUSBストレージに保存したいと考えている方
  • パッケージを使ってストレージ操作を行いたいが、内部/外部/共有ストレージの違いに不安がある方
  • SAF(Storage Access Framework)をFlutterで活用する方法を知りたい方
  • Androidのストレージ仕様やFlutterでの実装方法を体系的に理解したい方

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.29.0, on macOS 15.3.1 24D70 darwin-arm64, localeja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.99.3)

サンプルプロジェクト

https://github.com/HaruhikoMotokawa/write_sdcard_sample/tree/main

Androidにおけるストレージの仕様

https://developer.android.com/training/data-storage?hl=ja

まず、アプリがデータを保存する場合、Androidのストレージには大きく分けて3種類あります。

  • 内部ストレージ
  • 外部ストレージ
  • 共有ストレージ

内部ストレージ

永続ファイルを保存するためのアプリ専用の場所です。
他のアプリはこれらの場所にアクセスできません。

アプリ自体のデータに加え、shared_preferencesなどもここに保存されます。
アプリがアンインストールされれば、一緒にデータは削除されます。

ただし、あまり大きいデータ、いわゆる画像や音声データを大量に置くことは推奨されていません。

また、ファイルアプリなどのアプリ以外のツールでデータの視認や操作をすることはできません。

外部ストレージ

基本的には内部ストレージと同じくデータを保存する場所ですが、内部ストレージとの相違点は他のアプリも適切な権限を持っていればこれらのディレクトリにアクセスできる点です。

アプリがアンインストールされれば、一緒にデータは削除されます。
よくあるローカルDBなどがデータを保存する先はここになります。

FlutterのローカルDB、isarなど

https://pub.dev/packages/isar

また、一部のデバイスにおいて差し込み式のmicroSDカードで容量を増加できるものがありますが、これはここに該当します。

共有ストレージ

https://developer.android.com/training/data-storage/shared?hl=ja

共有ストレージは、ユーザーがアプリをアンインストールした場合でも他のアプリからアクセスや保存を行うことができる、または行うべきユーザーデータに使用します。

ファイルアプリでユーザーはデータを操作することができます。
アプリがこのストレージを操作するにはデータの種類によって専用のAPIやフレームワークが必要です。
今回はその中で ドキュメントと他のファイル に焦点を当てたいと思います。

なお、USB経由で接続するいわゆる外付けストレージもこれに該当します。

共有ストレージのドキュメントと他のファイルの操作には専用のフレームワークが必要

https://developer.android.com/guide/topics/providers/document-provider?hl=ja

ストレージ アクセス フレームワーク(以後:SAF)が必要です。
SAFでは主に以下の機能が提供されています。

  • 共有ストレージへのアクセス
  • 専用の選択UI
  • ディレクトリの選択やパスの取得
  • ディレクトリの作成と削除
  • ファイルの保存、編集、削除

【Android限定】Flutterで差し込み式SDカードへデータを保存する

デモ動画
https://youtu.be/tlQd6cpxys0

path_providerとlocal_file_systemの組み合わせで保存します。

https://pub.dev/packages/path_provider

https://api.flutter.dev/flutter/package-file_local/LocalFileSystem-class.html

path_providerは保存場所のディレクトリを取得するAPIを提供するパッケージです。
これを使って取得したパスに対して、Flutter標準のAPIである LocalFileSystem でディレクトリを作成したり、ファイルを編集したりします。

詳細はサンプルの lib/screen/path_provider/screen.dart をご覧ください。

https://github.com/HaruhikoMotokawa/write_sdcard_sample/blob/main/lib/screen/path_provider/screen.dart

その中でいくつか主要な部分を解説します。

ディレクトリーの取得

// デバイスが認識している外部ストレージのディレクトリを取得
final directories = await getExternalStorageDirectories();

path_providerの上記のメソッドで現在認識できる外部ストレージのディレクトリーを取得します。
戻り値は Future<List<Directory>?> で複数取得できます。
例えば差し込み式SDカードに対応していないPixelシリーズなどでは基本は一つしか取得できません。
これは元々のデバイス内蔵のSSD内に作られた外部ストレージです。
これが差し込み式のSDカード対応のデバイスである場合は2つ以上取得できます。

  • デバイス内蔵SSDの外部ストレージのパス -> /storage/emulated/0/Android/data/{アプリのバンドルID}/files
  • 差し込み式SDの外部ストレージパス -> /storage/{SDカード名}/Android/data/{アプリのバンドルID}/files

SDカード名は 1234-1A2B のように記号形式になっているのが一般的です。
なお、当然ですがSDカードが挿入されていないまたは破損しているなどで認識できない場合は取得できません。

書き込み

LocalFileSystem をつかてディレクトリの作成や書き込みを行います。

/// サブディレクトリとファイルの作成
Future<void> _createSubDirAndFile(
  BuildContext context,
  // ローカルに保存しておいたディレクトリを引数で受け取る
  Directory directory,
) async {
  // 省略

  const localFileSystem = LocalFileSystem();

  // 選択されたディレクトリにサブディレクトリを作成
  final subDir = localFileSystem
      .directory(directory.path)
      .childDirectory('NewFolder')
    ..createSync(recursive: true);
  if (!subDir.existsSync()) {
    // 存在しない場合のハンドリング
  }

  // サブディレクトリにファイルを作成
  final file = subDir.childFile('hello.txt')
    ..writeAsStringSync('Hello, World!');

  // 必要に応じてUIで成功を表示
}

.directory(directory.path) で該当のディレクトリを取得し、 .childDirectory('NewFolder') を探しています。
..createSync(recursive: true); としていますが、もともと作成されていれば実行されず、なければ作成するというような挙動になります。

.childFile('hello.txt') でファイルを作成し、その中にテキストを ..writeAsStringSync('Hello, World!') で書き込んでいます。

ここで必要なのはあくまでディレクトリのパスなので、今回はメソッドの引数に Directory directory を受け取っていますが、String path にしても良いと思います。

読み取り、削除に関しては割愛しますが、基本は外部ストレージのパス(上記でいうdirectory.path)とその内部のパスNewFolderhello.txt を使ってアクセスします。

【Android限定】 FlutterでUSBストレージへデータを保存する

デモ動画
https://youtu.be/IUx3M7h7tnk

こちらは共有ストレージへの操作のため、SAFを内部実装したパッケージを利用する必要があります。
かなりエッジケースということもあり、これを実装したパッケージで尚且つ現時点でも運用できるパッケージは私が調査した限り現時点ではdocmanというパッケージのみです。

https://pub.dev/packages/docman

ライク数やダウンロード数は少ないものの、ドキュメントもしっかりと書かれており、実装したところ期待通りの挙動も実現できました。
ただ、Flutter標準の LocalFileSystem を使った読み書きではなくパッケージ独自の実装となっているので、その辺は注意が必要です。

詳細はサンプルの lib/screen/doc_man/screen.dart をご覧ください。

https://github.com/HaruhikoMotokawa/write_sdcard_sample/blob/main/lib/screen/doc_man/screen.dart

その中でいくつか主要な部分を解説します。

ディレクトリの取得

// DocumentFile からディレクトリを取得
final documentDirectory = await DocMan.pick.directory();

SAFが提供するディレクトリ選択UIを使ってユーザーが選択したディレクトリ情報をパッケージ独自の DocumentFile オブジェクトで受け取ります。
そして、この DocumentFile にはpathがなく、保存先はuriになります。ここがpath_providerとの大きな違いです。

書き込み

Future<void> _createSubDirAndFile(BuildContext context, String uri) async {
  // 省略
  try {
    // DocumentFile から既存ディレクトリを取得
    final parentDir = await DocumentFile.fromUri(uri);

    if (!context.mounted) return;
    if (parentDir == null || !parentDir.isDirectory) {
      await showAppDialog(context, title: 'エラー', content: '有効なディレクトリではありません');
      return;
    }

    // NewFolder を作成
    final newDir = await parentDir.createDirectory('NewFolder');

    if (newDir == null) {
      if (!context.mounted) return;
      await showAppDialog(context, title: '作成失敗', content: 'フォルダを作成できませんでした');
      return;
    }

    // example.txt を作成
    await newDir.createFile(
      name: 'example.txt',
      content: 'Hello, i write text from DocMan',
    );

    // 必要に応じてUIで成功を表示
  } catch (e) {
    // エラーハンドリング
  }
}

await DocumentFile.fromUri(uri); でまずはディレクトリを検索します。

await parentDir.createDirectory('NewFolder'); でディレクトリを作成。

その中に await newDir.createFile(...); でファイルとその中身を書き込んでいます。

大まかな流れは先ほどの LocalFileSystem と同じではあります。

おまけ: 【Android限定】USBストレージではない共有ストレージへデータを保存する

ファイル選択のパッケージとして有名なfile_pickerというパッケージと LocalFileSystem を使用します。

https://pub.dev/packages/file_picker

file_pickerも内部ではSAFを実装しているようなのですが、こちらはUSBストレージへの書き込みなどは対象外となっています。
ディレクトリを選択するUIはdocmanと同じになっています。(内部実装がSAFなので)

当初はfile_picker + LocalFileSystem の組み合わせでUSBストレージへの書き込みができると踏んでいたのですが、できませんでした。
恐らくこれはSAFによる書き込みAPIを内部実装していないのと、最後に選択したディレクトリのuriを取得できていないのが原因だと推測されます。

ただ、デバイス内限定ではありますが共有ストレージの取得とファイル操作はできます。

この記事では詳しく解説はしませんので、気になる方は lib/screen/file_picker/screen.dart をご覧ください。

https://github.com/HaruhikoMotokawa/write_sdcard_sample/blob/main/lib/screen/file_picker/screen.dart

終わりに

Androidにおけるストレージ操作は、内部・外部・共有ストレージの違いやSAFの導入など、やや複雑な側面があります。
特にSDカードやUSBストレージのような「物理的な外部ストレージ」への対応には、専用のフレームワークやパッケージの理解が不可欠です。

この記事では、Flutterでそういったストレージに対してデータを保存する方法を、path_providerdocmanfile_picker などのパッケージを通じて紹介しました。

実際のアプリでのニーズに合わせて、保存先の選択やユーザー体験の向上にぜひ役立てていただければ幸いです。

Discussion