【Flutter】アプリ画面を画像として保存する方法

# はじめに
アプリの画面をキャプチャし、画像として保存する方法を調べましたので記事にします。

# パッケージの追加
下記の二つのパッケージをプロジェクトに追加してください

https://pub.dev/packages/path_provider

→ 一時的に画像を保存する必要があるため、上記のパッケージで一時フォルダのパスを取得します。

https://pub.dev/packages/gallery_saver

→ 画像を端末のアルバムに保存するため上記のパッケージを使用します。

# 準備
### iOS
Info.plstに下記を追記してください。
```
	<key>NSPhotoLibraryUsageDescription</key>
    <string>ライブラリに保存します</string>
```

### Android
`android/app/src/main/AndroidManifest.xml`に下記を追加。
(場所はapplicationタグの上でいいと思います)
```
 <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
```

`android/app/src/build.gradle`の`minSdkVersion`を２１に変更
```
defaultConfig {
　　...
　　minSdkVersion 21
 targetSdkVersion flutter.targetSdkVersion
 versionCode flutterVersionCode.toInteger()
 versionName flutterVersionName
}
```
# コード全文
最初に、画像を保存するためのコードを載っけておきます。
Flutterでプロジェクト作成時のコードを変更しただけなので、対象部分を差し替えてパッケージなどをimportすればお手元でも確認が簡単かと思います。

```dart
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final int _counter = 0;
  final _globalKey = GlobalKey();

  void _takeSnapshot() async {
    final boundary =
        _globalKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
    if (boundary.debugNeedsPaint) {
      await Future.delayed(const Duration(milliseconds: 300));
    }
    final image = await boundary.toImage();
    final byteData = await image.toByteData(format: ImageByteFormat.png);
    final pngBytes = byteData!.buffer.asUint8List();

    final directory = (await getTemporaryDirectory()).path;
    final imgFile = File('$directory/test_screen_shot.png');
    await imgFile.writeAsBytes(pngBytes);

    await GallerySaver.saveImage(imgFile.path, albumName: "Sample");

    await imgFile.delete();
  }

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      key: _globalKey,
      child: Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const Text(
                'You have pushed the button this many times:',
              ),
              Text(
                '$_counter',
                style: Theme.of(context).textTheme.headline4,
              ),
            ],
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: _takeSnapshot,
          tooltip: 'Snapshot',
          child: const Icon(Icons.photo_camera),
        ), // This trailing comma makes auto-formatting nicer for build methods.
      ),
    );
  }
}
```

# 画像を取得するための手順
実装を細かく見る前に、大まかな手順を説明します。
1. GlobalKeyを指定したRepaintBoundaryで画像化したいWidgetを囲う
1. 上で指定したGlobalKeyで、UIの描画を行なっているオブジェクトを取得
1. 上で取得したオブジェクトから画像を取得しバイトデータに変換
1. バイトデータをキャッシュに保存
1. キャッシュに保存したデータを端末のアルバムに保存

# 具体的な説明
## GlobalKeyを指定したRepaintBoundaryで画像化したいWidgetを囲う
下記の抜粋コードのように、`RepaintBoundary`で画像にしたいWidgetを囲います。
この時、GlobalKeyを指定することを忘れないでください。

```dart
class _MyHomePageState extends State<MyHomePage> {
  //　GlobalKeyを作成
  final _globalKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    //　画像として保存したいWidgetをRepaintBoundaryで囲み、GlobalKeyを設定
    return RepaintBoundary(
      key: _globalKey,
      child: Scaffold(
        ...
      ),
    );
  }
}
```

`RepaintBoundary`は再描画範囲を制限するために使用できるクラスのようです。
下記の動画がわかりやすかったですが、今回の使い方では画像にする反映を囲う使い方となります。

https://www.youtube.com/watch?v=cVAGLDuc2xE

## UIの描画を行なっているオブジェクトを取得
ここからの説明は以下のメソッドを抜粋して説明していきます。

```dart
  void _takeSnapshot() async {
    final boundary =
        _globalKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
    if (boundary.debugNeedsPaint) {
      await Future.delayed(const Duration(milliseconds: 300));
    }
    final image = await boundary.toImage();
    final byteData = await image.toByteData(format: ImageByteFormat.png);
    final pngBytes = byteData!.buffer.asUint8List();

    final directory = (await getTemporaryDirectory()).path;
    final imgFile = File('$directory/test_screen_shot.png');
    await imgFile.writeAsBytes(pngBytes);

    await GallerySaver.saveImage(imgFile.path, albumName: "Sample");

    await imgFile.delete();
  }
```

さて、この項の本題ですが、描画を行なっているオブジェクトは下記で取得しています。
ここでGlobalKeyが必要なため、buildメソッド内で指定しました。
```dart
    final boundary =
        _globalKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
```

## 描画オブジェクトから画像を取得しバイトデータに変換

ここは順番に変換しているだけだんですが、一つ注記すると、`if`文の中です。
`debugNeedsPaint`が`true`の状態だと、`toImage`メソッドを実行するとエラーになったので、trueなら少し待つように実装しています。

```dart
if (boundary.debugNeedsPaint) {
  await Future.delayed(const Duration(milliseconds: 300));
}
final image = await boundary.toImage();
final byteData = await image.toByteData(format: ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
```

## バイトデータをキャッシュに保存
一旦画像を保存しないと端末のアルバムに保存できないので、キャッシュに保存します。
`getTemporaryDirectory`メソッドは`path_provider`のメソッドで、キャッシュに使うフォルダパスを取得できます。

```dart
    final directory = (await getTemporaryDirectory()).path;
    final imgFile = File('$directory/test_screen_shot.png');
    await imgFile.writeAsBytes(pngBytes);
```

## キャッシュに保存したデータを端末のアルバムに保存
キャッシュに保存したファイルパスとアルバム名を指定し、ギャラリーに保存します。

```dart
    await GallerySaver.saveImage(imgFile.path, albumName: "Sample");

    await imgFile.delete();
```

AndroidだとGoogleフォトに保存され、
（Android詳しくないのでわかりませんが、端末や設定で違うかもです）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/56084862-7ebc-4017-9422-411eac44be34.png)

iOSは写真に保存されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/30963f47-67b3-b43e-0d95-58fdc3d41b2f.png)