multi_image_pickerとsqfliteを使って選択した画像をアプリに保存するFlutterアプリを作った

<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
# この記事の要点
* アプリ内の画像から複数の画像を選択して何かしたい時は、[multi_image_picker](https://pub.dev/packages/multi_image_picker)が便利
* アプリ内に画像など比較的大きいデータや同じ構造のデータを多数保存するには、 [sqflite](https://pub.dev/packages/sqflite)が便利
（追記: 画像はsqliteに入れずに、`path_provider`などを使ってアプリ内の領域に保存して、そのパスをsqliteに保存するのが良さそうです。)
* 写真などを格子状に並べるには、`GridView`Widgetを使う
* `ListView`や`GridView`などで`List`の値を動的に表示する際、元々ある`List`に`add`や`insert`などを実行して`List`の要素を変えても状態変化は反映されない(再描画されない)。かわりに、新しい`List`を作って代入する必要がある。

# 成果物
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">multi_image_picker x sqflite <a href="https://t.co/S6WUhd2X3Z">pic.twitter.com/S6WUhd2X3Z</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1272088913805336576?ref_src=twsrc%5Etfw">June 14, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

https://github.com/popy1017/flutter_package_samples/tree/master/multi_image_picker_sample

# 手順メモ

※ iOSでしか試してません。

まずは`flutter create appname`

## 流れ
1. パッケージのインストール
2. レイアウトの大枠を作る
3. multi_image_pickerを使う
4. sqliteに画像を保存する

## 1. パッケージのインストール
* multi_image_picker: 今回の主役。アプリ内の写真を複数選択するための便利UIを提供してくれる。
* sqflite: 今回の主役2。Flutterでsqliteを使うための便利パッケージ。
* path_provider: DB作るパスを探すか何かでsqflite使うのに必要。
* provider: Providerパターンで状態管理するときに必要な影の主役。

```yaml:pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  multi_image_picker: ^4.6.7

  sqflite: ^1.3.0
  path_provider: ^1.6.10

  provider: ^4.1.3
```

### 追加設定 for iOS
iOSで`multi_image_picker`を使うためには、追加の設定が必要。
`ios/Runner/Info.plist`に以下を追加。

```xml:Info.plist
<key>NSPhotoLibraryUsageDescription</key>
<string>Example usage description</string>
<key>NSCameraUsageDescription</key>
<string>Example usage description</string>
```
それぞれ写真・カメラへのアクセス権限を確認するときに表示されるダイアログに表示されるメッセージ。

続いて、`ios/Podfile`の先頭行に、`platform :ios, '9.0'`を追記。
たぶんデフォルトで全く同じコードがコメントアウトされているので、コメントアウトを外せばOK。

以上でパッケージのインストール・設定は終了。

## 2. レイアウトの大枠を作る
保存した画像を並べて表示するViewを`GridView`で、画像を選択するシートを表示するトリガーとなるボタンを`FloatingActionButton`で作成する。

まずは、`flutter create`でできるカウンターアプリの`MyHomePage`Widgetを`StatelessWidget`にし、余計な部分を取り除いて`FloatingActionButton`を設定する。

```dart:main.dart
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add_photo_alternate),
        onPressed: () {},
      ),
    );
  }
}
```

続いて、`Scaffold`の`body`となるWidgetを追加する。
`album.dart`を作って`StatelessWidget`の`Album`Widgetを作成する。
今の段階では、ネット上にある画像をランダムにいくつか表示する。
画像は [Free-Photos | Pixabay](https://pixabay.com/ja/users/free-photos-242387/) のものを拝借。

```dart:album.dart
import 'dart:math';

import 'package:flutter/material.dart';

class Album extends StatelessWidget {
  final List<Widget> _photos = getPhotos(20);
  @override
  Widget build(BuildContext context) {
    return GridView.builder(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
        ),
        itemCount: _photos.length,
        itemBuilder: (BuildContext context, int index) => Padding(
              padding: const EdgeInsets.all(1),
              child: _photos[index],
            ));
  }
}

final List<String> _photoList = <String>[
  'https://cdn.pixabay.com/photo/2015/03/26/09/47/sky-690293__340.jpg',
  'https://cdn.pixabay.com/photo/2015/09/09/16/05/forest-931706__340.jpg',
  'https://cdn.pixabay.com/photo/2016/03/09/09/22/workplace-1245776__340.jpg',
  'https://cdn.pixabay.com/photo/2016/02/19/11/19/office-1209640__340.jpg',
  'https://cdn.pixabay.com/photo/2014/12/15/17/16/pier-569314__340.jpg'
];

List<Widget> getPhotos(int count) {
  final Random _rnd = Random();
  return List<Widget>.generate(count, (int index) {
    final int _id = _rnd.nextInt(_photoList.length - 1);
    return Image.network(
      _photoList[_id],
      fit: BoxFit.cover,
    );
  });
}
```

```diff:MyHomePage()
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
+     body: Album(),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add_photo_alternate),
        onPressed: () {},
      ),
    );
  }
}
```
こんな感じ。
今後は`Album`widgetの`_photos`をsqliteから取ってきたり、`MyHomePage`widgetの`onPressed`に複数画像を選択するViewを表示する機構を用意していく。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/4ddeb1ce-2ce0-f407-cb29-2ccc9a88f645.png" width="300" />

## 3. multi_image_pickerを使う
次は、以下のように実装する。

* FloatingActionButtonを押すと、アプリ内の画像を選択するViewが表示される。
* 画像を選択すると、選択した画像がGridViewに表示される。

### multi_image_pickerについて
パッケージの説明に載っている例を見たり、いろいろ試すと、以下のようなことがわかった。

* `await MultiImagePicker.pickImages()`で画像選択UIを表示し、画像の選択を待ち受ける。
* 戻り値は`List<Asset>`で`Asset`はパッケージ固有のクラス
* `Asset`は、通常の`Image`widgetではなく、パッケージ固有の`AssetThumb`Widgetで表示する。

リファレンスによると、
>このメソッドは、Assetオブジェクトのリストを返します。これらは画像自体ではなく、画像への実際の識別子を含む単なるプレースホルダーであるため、一度に何千もの画像を選択でき、パフォーマンスを低下させることはありません。元の画像またはサムをリクエストする方法は、Assetクラスのドキュメントを参照できます。

らしい。

また、`Image`Widgetで表示できないか調査したところ、以下のようにして表示できることがわかった。
(`AssetThumb`widgetでも同様のことが行われているっぽい)

1. `Asset`クラスの`Future<ByteData> getByteData（）`で`ByteData`に変換する。
2. `ByteData().buffer.asUint8List()`で`Uint8List`に変換する。
3. `Uint8List`は`Image.memory(Uint8List bytes)`で表示できるので、`Image.memory()`で表示する。

表示するときは、`AssetThumb`で、sqliteに保存する時は、`Uint8List`で`BLOB`型として保存すると良さそう。

### 状態の追加
multi_image_pickerを使う前に、現在のアプリでは選んだ画像に応じて表示を切り替えることができないため、アプリに状態を持たせて動的にViewを表示できるようにする、
今回は、`MyHomePage`Widgetの`FloatingActionButton`と、`Album`Widgetから状態にアクセスする必要があるので、`StatefulWidget`による状態管理ではなく、Providerパターンで状態管理を行う。

#### 状態クラスの作成
`ChangeNotifier`クラスを拡張し、状態を保持するクラスを作成する。
`album_model.dart`を作成し、パッケージの例を参考に以下のように実装。

```dart:album_model.dart
import 'package:flutter/material.dart';
import 'package:multi_image_picker/multi_image_picker.dart';

class AlbumModel extends ChangeNotifier {
  List<Asset> _photos = <Asset>[];

  List<Asset> get photos => _photos;

  Future<void> loadAssets() async {
    List<Asset> _resultList;

    try {
      _resultList = await MultiImagePicker.pickImages(
        maxImages: 50,
        enableCamera: true,
        cupertinoOptions: const CupertinoOptions(
          selectionFillColor: '#ff11ab',
          selectionTextColor: '#ffffff',
        ),
        materialOptions: const MaterialOptions(
          actionBarColor: '#abcdef',
          actionBarTitle: 'Example App',
          allViewTitle: 'All Photos',
          useDetailsView: true,
          selectCircleStrokeColor: '#000000',
        ),
      );
    } on NoImagesSelectedException catch (e) {
      print(e);
      return;
    } on Exception catch (e) {
      print(e);
    }
    // 新しいリストを代入しないと再描画されない。
    // NG: _photos.add() or _photos.insert()
    _photos = _resultList;

    notifyListeners();
  }
}
```
`_photos`を`List<Asset>`にしたので、`Album`を書き換え。

```dart:Album
class Album extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final List<Asset> _photos =
        context.select((AlbumModel model) => model.photos);

    return GridView.builder(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
        ),
        itemCount: _photos.length,
        itemBuilder: (BuildContext context, int index) => Padding(
              padding: const EdgeInsets.all(1),
              child: AssetThumb(
                key: Key(_photos[index].identifier),
                asset: _photos[index],
                width: _photos[index].originalWidth,
                height: _photos[index].originalHeight,
              ),
            ));
  }
}
```

##### MultiImagePicker.pickImagesのオプション
| Key | Value type | 説明 |
|:---|:---|:---|  
| maxImages | int | 必須パラメータ。選択可能な画像の枚数 |
| enableCamera | bool | ギャラリー起動中にカメラ起動を有効にする。デフォルトはfalse |
| selectedAssets | List<Asset> | 選択中の画像を記憶するための配列を指定する。 |
| cupertinoOptions | CupertinoOptions | iOS用のカスタマイズオプション。背景色や選択中の画像につけるアイコンの色などを設定できる。 詳しくはこちら。([CupertinoOptions](https://sh1d0w.github.io/multi_image_picker/#/theming))|
| materialOptions | MaterialOptions | Android用のカスタマイズオプション。iOSより設定できる項目が多い。 |

※ `selectedAssets`は、選択した順番が変わってしまうバグがある。(Asset.identifierの昇順ソートになっているっぽい。)

#### Providerの設定
`MyApp`の`home`に`ChangeNotifierProvider`を設定する。

```dart:MyApp()
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: ChangeNotifierProvider<AlbumModel>(
          create: (_) => AlbumModel(), child: MyHomePage()),
    );
  }
}
```
#### FloatingActionButton押下時の設定
`MyHomePage`widgetの`FloatingActionButton`の`onPressed`を修正して、`AlbumModel`の`loadAssets()`を呼び出すようにする。

```dart:MyHomePage
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Album(),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add_photo_alternate),
        onPressed: () {
          context.read<AlbumModel>().loadAssets();
        },
      ),
    );
  }
}
```

こんな感じ。

<blockquote class="twitter-tweet"><p lang="et" dir="ltr">multi_image_picker <a href="https://t.co/YufimmH88C">pic.twitter.com/YufimmH88C</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1272056141749473280?ref_src=twsrc%5Etfw">June 14, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 4. sqliteに画像を保存する
次は、選択した画像をsqliteに保存して、アプリを再起動しても保存した画像が表示されるようにする。
最初は`Uint8List`を`BLOB`で保存していたが、Asset.identifierなどを保存した方が軽そうなのでAssetの情報を保存することにする。（アプリ内の画像が削除された場合への対処は必要になる。)

まずは、DB操作系の処理をまとめた`DBHelper`クラスを作る。

```dart:db_helper.dart
import 'dart:async';
import 'dart:io';

import 'package:multi_image_picker/multi_image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

// AssetクラスにtoMapメソッドを追加
// sqliteに保存する際、Map<String, dynamic>に変換する必要がある。
extension on Asset {
  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'identifier': identifier,
      'name': name,
      'originalWidth': originalWidth,
      'originalHeight': originalHeight
    };
  }
}

class DBHelper {
  static const String tableName = 'MultiImagePickerSample';
  Database _db;

  Future<Database> get database async {
    if (_db == null) {
      final Directory documentDirectory =
          await getApplicationDocumentsDirectory();
      final String path = join(documentDirectory.path, 'multi_photo_sample.db');

      _db = await openDatabase(path, version: 2,
          onCreate: (Database newDb, int version) {
        newDb.execute('''
          CREATE TABLE $tableName
            (
              identifier TEXT PRIMARY KEY,
              name TEXT,
              originalWidth INTEGER,
              originalHeight INTEGER
            )
        ''');
      });
    }
    return _db;
  }

  Future<void> insertAsset(Asset asset) async {
    final Database db = await database;
    await db.insert(
      tableName,
      asset.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<List<Asset>> assets() async {
    final Database db = await database;
    final List<Map<String, dynamic>> maps = await db.query(tableName);

    return List<Asset>.generate(maps.length, (int i) {
      final String identifier = maps[i]['identifier'] as String;
      final String name = maps[i]['name'] as String;
      final int originalWidth = maps[i]['originalWidth'] as int;
      final int originalHeight = maps[i]['originalHeight'] as int;

      return Asset(identifier, name, originalWidth, originalHeight);
    });
  }

  Future<void> close() async => _db.close();
}
```

次に、`AlbumModel`を以下のように変更する。

* 起動時にsqliteに保存してある画像を読み込む
* 画像選択が完了したら、選択した画像の情報をsqliteに保存する

```diff:album_model.dart
class AlbumModel extends ChangeNotifier {
  List<Asset> get photos => _photos;
  List<Asset> _photos = <Asset>[];


+ AlbumModel() {
+   loadAssetsFromDB();
+ }
+ final DBHelper _dbHelper = DBHelper();


+ Future<void> loadAssetsFromDB() async {
+   _photos = await _dbHelper.assets();
+   notifyListeners();
+ }

  Future<void> loadAssets() async {
    List<Asset> _resultList;

    try {
      _resultList = await MultiImagePicker.pickImages(
        maxImages: 50,
        enableCamera: true,
        cupertinoOptions: const CupertinoOptions(
          selectionFillColor: '#ff11ab',
          selectionTextColor: '#ffffff',
        ),
        materialOptions: const MaterialOptions(
          actionBarColor: '#abcdef',
          actionBarTitle: 'Example App',
          allViewTitle: 'All Photos',
          useDetailsView: true,
          selectCircleStrokeColor: '#000000',
        ),
      );
    } on NoImagesSelectedException catch (e) {
      print(e);
      return;
    } on Exception catch (e) {
      print(e);
    }


+   for (final Asset asset in _resultList) {
+     await _dbHelper.insertAsset(asset);
+   }
+   loadAssetsFromDB();
  }
}
```
とりあえず上記で画像情報を保存することができたが、以下のような課題がある。
* 保存されたAssetの情報に対する画像がデバイス上にないとエラーになる
* Sqliteの設定がIDが重複した際に置換する設定(`conflictAlgorithm: ConflictAlgorithm.replace`)になっているので、同じ画像を複数枚保存できない

1つ目の課題は、`AssetThumb`Widgetが内部的にエラーを投げているので、対処しづらい。さらにエラーを投げている関数が`Future`型の`getThumbByteData`関数なので、if文とかで条件分岐するのもめんどくさそう。
いっそ最初からsqliteに画像データを入れておいて、毎回それを表示するようにした方が良いかもしれない。(今回は断念)

2つ目の課題は、アプリの要件によってどうするべきか変わるので、今回は対処しない。

## （おまけ)画像選択Viewの日本語化
画像を選択する際のViewの上部が(キャンセル、カテゴリ、完了)が英語になっていたので日本語にする方法を調査。

ios/Runner.xcodeproj/ をXcodeで開き、

1. プロジェクトファイル(Runner)を選択
1. PROJECTのRunnerを選択
2. 上部のメニューからInfoを選択
3. ▼Localizations の ＋ を押して、Japaneseを追加する

※ 当然デバイスの設定は日本語である必要があります。