Put your feet on the ground

主にプログラミングのお話。

Flutterで画像をローカルに保存して画面に表示

前回記事の延長。

  • ライブラリをインポート

アプリのストレージ領域にアクセスするためのライブラリを導入する。

pub.dartlang.org

これもFlutter Team製だから安心(何が)。

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2
  image_picker: ^0.4.10
  path_provider: ^0.4.1 //追加


公式ドキュメントをGoogle翻訳突っ込んで引用↓

Reading and Writing Files - Flutter

  • 一時ディレクトリ:システムがいつでも消去できる一時ディレクトリ(キャッシュ)。iOSでは、これはNSTemporaryDirectory()で返される値に対応します。Androidでは、getCacheDir()で返される値に対応します。
  • ドキュメントディレクトリ:アプリケーションだけがアクセスできるファイルを格納するためのディレクトリ。アプリが削除されたときにのみ、ディレクトリが消去されます。iOSでは、これはNSDocumentDirectoryに相当します。Androidでは、これは AppDataディレクトリに相当します。

今回はアプリのドキュメントディレクトリ(以下ドキュメント)に保存する。

  • ドキュメントへのパスを取得する

僕は役割ごとにクラスファイルを作るので、今回はドキュメント内のパスを取得・ファイルを操作するクラスを作る。

file_controller.dart


import 'dart:async';
import 'package:path_provider/path_provider.dart';

class FileController {
  // ドキュメントのパスを取得
  static Future get localPath async {
    final directory = await getApplicationDocumentsDirectory();
    return directory.path;
  }
}

localPathは

/var/mobile/Containers/Data/Application/816F158A-B23E-42BC-BF07-94A276C2D77E/Documents

みたいなのが出力されるはず。

  • 画像を保存するメソッドを作成する

次にドキュメントに画像を保存するメソッドを作成する。

import 'dart:async';
import 'dart:io'; // 追加
import 'package:path_provider/path_provider.dart';

class FileController {
  // ドキュメントのパスを取得
  static Future get localPath async {
    final directory = await getApplicationDocumentsDirectory();
    return directory.path;
  }

  // 画像をドキュメントへ保存する。
  // 引数にはカメラ撮影時にreturnされるFileオブジェクトを持たせる。
  static Future saveLocalImage(File image) async {
    final path = await localPath;
    final imagePath = '$path/image.png';
    File imageFile = File(imagePath);
    // カメラで撮影した画像は撮影時用の一時的フォルダパスに保存されるため、
    // その画像をドキュメントへ保存し直す。
    var savedFile = await imageFile.writeAsBytes(await image.readAsBytes());
    // もしくは
    // var savedFile = await image.copy(imagePath);
    // でもOK

    return savedFile;
  }
}

ドキュメントの画像を取得する場合は、以下のようなメソッドを作ればFileオブジェクトとして取得できます。

  // ドキュメントの画像を取得する。
  static Future loadLocalImage() async {
    final path = await localPath;
    final imagePath = '$path/image.png';
    return File(imagePath);
  }
  • カメラ撮影のメソッドを修正

image_picker_view.dart

// void _getImageFromDevice(ImageSource source) async { //メソッド名変更
 void _getAndSaveImageFromDevice(ImageSource source) async {
   // 撮影/選択したFileが返ってくる
   var imageFile = await ImagePicker.pickImage(source: source);
   // 撮影せずに閉じた場合はnullになる
   if (imageFile == null) {
     return;
   }

   var savedFile = await FileController.saveLocalImage(imageFile); //追加
   
   setState(() {
     // this.imageFile = imageFile;
     this.imageFile = savedFile; //変更
   });
 }

ここで注意!!

画像を表示するWidgetを以下のようにしていると、カメラ撮影を何回しても最初に撮影した画像しか表示されません。
(自分はこの現象に1日悩まされました…)

Image.file(
  imageFile,
  height: 100.0,
  width: 100.0,
),

そのため、以下のように書き換えましょう。

Image.memory( //変更
  imageFile.readAsBytesSync(), //変更
  height: 100.0,
  width: 100.0,
),
理由

Image.file()コンストラクタは、 「ファイルから取得したImageStreamを表示するウィジェットを作成」します。
ImageStreamクラスの公式ドキュメントによると、

ImageStream objects are backed by ImageStreamCompleter objects.

とあります。
どうやら、Image.file()コンストラクタで初期化した場合、何回カメラ撮影をしてもバックアップされた最初のImageStreamを読み込むため、最初の画像しか表示されなくなるようです。
(ふんわりとした理解なので、どなたか詳しい説明して頂けると大変助かります。)

というわけで、これで「カメラで撮影した画像をローカルに保存し、画面に表示させる」動作が可能になります。
f:id:karmactonics:20180902222158g:plain

というわけで最終的な画面ソースコードはこちら。

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:playground/controller/file_controller.dart';

class ImagePickerView extends StatefulWidget {
  @override
  State createState() {
    return ImagePickerViewState();
  }
}

class ImagePickerViewState extends State {
  File imageFile;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('ImagePicker'),
          backgroundColor: Theme.of(context).primaryColor,
        ),
        body: Container(
          alignment: Alignment.center,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              (imageFile == null)
                  ? Icon(Icons.no_sim)
                  : Image.memory(
                      imageFile.readAsBytesSync(),
                      height: 100.0,
                      width: 100.0,
                    ),
              Container(
                  padding: EdgeInsets.all(10.0),
                  child: RaisedButton(
                    child: Text('カメラで撮影'),
                    onPressed: () {
                      _getAndSaveImageFromDevice(ImageSource.camera);
                    },
                  )),
              Container(
                  padding: EdgeInsets.all(10.0),
                  child: RaisedButton(
                    child: Text('ライブラリから選択'),
                    onPressed: () {
                      _getAndSaveImageFromDevice(ImageSource.gallery);
                    },
                  )),
            ],
          ),
        ));
  }

// カメラまたはライブラリから画像を取得
// void _getImageFromDevice(ImageSource source) async { //メソッド名変更
  void _getAndSaveImageFromDevice(ImageSource source) async {
    // 撮影/選択したFileが返ってくる
    var imageFile = await ImagePicker.pickImage(source: source);
    // 撮影せずに閉じた場合はnullになる
    if (imageFile == null) {
      return;
    }

    var savedFile = await FileController.saveLocalImage(imageFile); //追加

    setState(() {
      // this.imageFile = imageFile;
      this.imageFile = savedFile; //変更
    });
  }
}