FlutterのCamera Pluginのサンプルソースを読んでみる

# はじめに

Flutterでカメラを使ってみたいと思い、[camera](https://pub.dev/packages/camera/)を見ていたんですが、サンプルのコードが結構長いので、その辺を見ながらカメラの使い方を理解したいなと思います。
おそらく、どんなメソッドがどんな役割を担っているのかはわかると思います。

flutter周りのバージョンはこんな感じ
```shell
> flutter --version
Flutter 3.7.8 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 90c64ed42b (3 months ago) • 2023-03-21 11:27:08 -0500
Engine • revision 9aa7816315
Tools • Dart 2.19.5 • DevTools 2.20.1
```

まずはAndroidで、iOSも確認予定(たぶんそのうち)

# 公式のコードをコピペして実行してみる

まず、コピーしただけではだめなので、以下のコマンドで必要なライブラリを導入する。

```shell
> flutter pub add camera
> flutter pub add video_player
```

次に、iOSようにios/Runner/Info.plistに以下を追記
```
    <key>NSCameraUsageDescription</key>
    <string>your usage description here</string>
    <key>NSMicrophoneUsageDescription</key>
    <string>your usage description here</string>
```

Androidの対応として android/app/build.gradle の minSdkVersion を変更
```
        //minSdkVersion flutter.minSdkVersion
        minSdkVersion 21
```



main.dartに[サンプル](https://pub.dev/packages/camera/example/)をコピペしてビルドしたら、以下のエラーが発生。

```shell
Performing hot restart...
Syncing files to device A104SH...
Restarted application in 1,604ms.
E/flutter (16513): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: MissingPluginException(No implementation found for method availableCameras on channel plugins.flutter.io/camera)
E/flutter (16513): #0      MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:313:7)
E/flutter (16513): <asynchronous suspension>
E/flutter (16513): #1      MethodChannel.invokeListMethod (package:flutter/src/services/platform_channel.dart:504:35)
E/flutter (16513): <asynchronous suspension>
E/flutter (16513): #2      MethodChannelCamera.availableCameras (package:camera_platform_interface/src/method_channel/method_channel_camera.dart:66:52)
E/flutter (16513): <asynchronous suspension>
E/flutter (16513): #3      main (package:cameratest/main.dart:1068:16)
E/flutter (16513): <asynchronous suspension>
E/flutter (16513): 
```

flutter cleanしてビルドしたら動作した。

# コードを見ていく

## main()メソッド

```dart
List<CameraDescription> _cameras = <CameraDescription>[];

Future<void> main() async {
  // Fetch the available cameras before initializing the app.
  try {
    WidgetsFlutterBinding.ensureInitialized();
    _cameras = await availableCameras();
  } on CameraException catch (e) {
    _logError(e.code, e.description);
  }
  runApp(const CameraApp());
}
```

[availableCameras function](https://pub.dev/documentation/camera/latest/camera/availableCameras.html) を使ってカメラの情報を取得します。
おそらくですが、一度取得すればよいという考えでmain()メソッドで行っていると思います。
カメラの情報は[CameraDescriptionクラス](https://pub.dev/documentation/camera_platform_interface/latest/camera_platform_interface/CameraDescription-class.html)に格納されます。

見てみると、このように前面カメラと背面カメラの情報が取れていることがわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277984/962aa429-f3cb-7409-4e6a-9cf519074bc5.png)


## build()メソッド
UIを生成するbuildメソッドを見ていきます。
とはいえ、ここの処理自体は大したことはないですが、UIを生成するためのメンバメソッドが紐づけられています
どのようなメソッドが紐づけられているかを確認します。
```dart
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Camera example'),
      ),
      body: Column(
        children: <Widget>[
          Expanded(
            child: Container(
              decoration: BoxDecoration(
                color: Colors.black,
                border: Border.all(
                  color:
                  controller != null && controller!.value.isRecordingVideo
                      ? Colors.redAccent
                      : Colors.grey,
                  width: 3.0,
                ),
              ),
              child: Padding(
                padding: const EdgeInsets.all(1.0),
                child: Center(
                  child: _cameraPreviewWidget(),
                ),
              ),
            ),
          ),
          _captureControlRowWidget(),
          _modeControlRowWidget(),
          Padding(
            padding: const EdgeInsets.all(5.0),
            child: Row(
              children: <Widget>[
                _cameraTogglesRowWidget(),
                _thumbnailWidget(),
              ],
            ),
          ),
        ],
      ),
    );
  }
```
紐づけられているメソッドは以下の５メソッドになります。
|メソッド|概要|
|--|--|
|_cameraPreviewWidget()    |カメラのプレビューのUIを提供|
|_captureControlRowWidget()|写真やビデオ撮影するためのUIを提供|
|_modeControlRowWidget()   |フラッシュや露光のモードの変更のUIを提供|
|_cameraTogglesRowWidget() |カメラの選択を行うUIを提供|
|_thumbnailWidget()        |撮影した写真、ビデオのサムネイル表示|

多機能ですね^^;

## カメラのプレビューのUIを提供する_cameraPreviewWidget()メソッド

カメラのプレビューを行うUIを作成しているメソッドですね。

```dart
 Widget _cameraPreviewWidget() {
    final CameraController? cameraController = controller;

    if (cameraController == null || !cameraController.value.isInitialized) {
      return const Text(
        'Tap a camera',
        style: TextStyle(
          color: Colors.white,
          fontSize: 24.0,
          fontWeight: FontWeight.w900,
        ),
      );
    } else {
      return Listener(
        onPointerDown: (_) => _pointers++,
        onPointerUp: (_) => _pointers--,
        child: CameraPreview(
          controller!,
          child: LayoutBuilder(
              builder: (BuildContext context, BoxConstraints constraints) {
                return GestureDetector(
                  behavior: HitTestBehavior.opaque,
                  onScaleStart: _handleScaleStart,
                  onScaleUpdate: _handleScaleUpdate,
                  onTapDown: (TapDownDetails details) =>
                      onViewFinderTap(details, constraints),
                );
              }),
        ),
      );
    }
  }
```
カメラのコントローラーが生成されていない場合はメッセージを表示。
生成されている場合は、Listenerを追加しています。
Listenerの子のWidgetとしてCameraPreviewインスタンスを指定している感じです。
CameraPreviewにはGestureDetectorを紐づけて、動作を定義しているようです。
onScaleStart,onScaleUpdateがスケール操作、要はピンズームの操作のイベントになります。
onTapDownはタップ時の挙動です。


### プレビュー画像を拡大・縮小する操作を提供する_handleScaleStart()メソッド、_handleScaleUpdate()メソッド

カメラのスケール(拡大縮小)の処理になります。

```dart
  void _handleScaleStart(ScaleStartDetails details) {
    _baseScale = _currentScale;
  }

  Future<void> _handleScaleUpdate(ScaleUpdateDetails details) async {
    // When there are not exactly two fingers on screen don't scale
    if (controller == null || _pointers != 2) {
      return;
    }

    _currentScale = (_baseScale * details.scale)
        .clamp(_minAvailableZoom, _maxAvailableZoom);

    await controller!.setZoomLevel(_currentScale);
  }
```

_handleScaleStart()メソッドで、スケール操作開始時点のスケールをベースとするために変数に格納しています。
(計算式はよくわからない・・・)

_handleScaleUpdate()メソッドでスケール動作中の処理を定義しています。
最初のifで、コントローラーが生成されていない、または2点の操作ではない場合は何もせず終了しています。
コントローラーが生成されており、2点のスケール操作の場合、現在のスケールを計算し、setZoomLevel()メソッドにスケールを設定しています。

### タップ時に自動で露光調整、フォーカス調整を行うためのonViewFinderTap()メソッド
カメラのプレビューがタップされた場合の処理です。

```dart
  void onViewFinderTap(TapDownDetails details, BoxConstraints constraints) {
    if (controller == null) {
      return;
    }

    final CameraController cameraController = controller!;

    final Offset offset = Offset(
      details.localPosition.dx / constraints.maxWidth,
      details.localPosition.dy / constraints.maxHeight,
    );
    cameraController.setExposurePoint(offset);
    cameraController.setFocusPoint(offset);
  }
```

setExposurePoint()メソッドで露出値を自動決定するためのポイントを設定しています。
setFocusPoint()メソッドでフォーカス値を自動決定するためのポイントを設定しています。
(位置の計算はよくわからない・・・)


## 写真やビデオ撮影するためのUIを提供する _captureControlRowWidget()
メソッドが長いのでコードは割愛します。
アイコンをタップしたイベントにメソッドが紐づけられているのでそれを見ていきます。

|操作|メソッド|
|--|--|
|写真を撮影  |onTakePictureButtonPressed()メソッド|
|動画を撮影  |onVideoRecordButtonPressed()メソッド|
|録画再開    |onResumeButtonPressed()|
|録画一時停止|onPauseButtonPressed()メソッド|
|録画終了    |onStopButtonPressed()メソッド|
|プレビュー表示の一次停止/再開|onPausePreviewButtonPressed()メソッド|

### 写真を撮影するonTakePictureButtonPressed()メソッド

```dart
  void onTakePictureButtonPressed() {
    takePicture().then((XFile? file) {
      if (mounted) {
        setState(() {
          imageFile = file;
          videoController?.dispose();
          videoController = null;
        });
        if (file != null) {
          showInSnackBar('Picture saved to ${file.path}');
        }
      }
    });
  }
```

takePicture()メソッドを呼び出して、撮影した写真を保存しています。
takePicture()メソッドが成功した場合に保存の処理をしています。
mountedはStateオブジェクトがBuildContextに関連付けられているか(マウントされている)かどうかが設定されています。(知らなかった・・・)
なので、成功時にはマウントされている場合のみ処理を行う構造になっています。
showInSnackBar()メソッドはスナックバーにメッセージを表示するメソッドになります。

takePicture()メソッドはカメラロールに保存するわけではなく、アプリケーションのcasheディレクトリに保存します。
そのため、通常のカメラのような保存をしたい場合は、別途処理が必要になります。

### 動画を撮影を開始するonVideoRecordButtonPressed()メソッド
今回は動画撮影は範囲外なので割愛
とはいえ、処理は単純なのでコード見ればわかると思います。

### 録画再開するonResumeButtonPressed()
今回は動画撮影は範囲外なので割愛
とはいえ、処理は単純なのでコード見ればわかると思います。

### 録画一時停止するonPauseButtonPressed()メソッド
今回は動画撮影は範囲外なので割愛
とはいえ、処理は単純なのでコード見ればわかると思います。

### 録画終了するonStopButtonPressed()メソッド
今回は動画撮影は範囲外なので割愛
とはいえ、処理は単純なのでコード見ればわかると思います。

### プレビュー表示の一次停止/再開させるonPausePreviewButtonPressed()メソッド
カメラのプレビューの一時停止/再開を行います。

```dart
  Future<void> onPausePreviewButtonPressed() async {
    final CameraController? cameraController = controller;

    if (cameraController == null || !cameraController.value.isInitialized) {
      showInSnackBar('Error: select a camera first.');
      return;
    }

    if (cameraController.value.isPreviewPaused) {
      await cameraController.resumePreview();
    } else {
      await cameraController.pausePreview();
    }

    if (mounted) {
      setState(() {});
    }
  }
```

コントローラーのpausePreview()メソッドで一時停止します。
コントローラーのresumePreview()メソッドで再開します。

## フラッシュや露光のモードの変更のUIを提供する _modeControlRowWidget() 
メソッドが長いのでコードは割愛します。
アイコンをタップしたイベントにメソッドが紐づけられているのでそれを見ていきます。

|操作|メソッド|
|--|--|
|フラッシュの設定    |onFlashModeButtonPressed()メソッド|
|露光調整           |onExposureModeButtonPressed()メソッド|
|フォーカスの設定    |onFocusModeButtonPressed()|
|オーディオモード設定|onAudioModeButtonPressed()メソッド|
|写真の向きの固定方法の設定 |onCaptureOrientationLockButtonPressed()メソッド|

### フラッシュの設定を行う onFlashModeButtonPressed()メソッド

フラッシュは使える機種と使えない機種があるっぽい

|機種|使用の可否|
|--|--|
|AQUOS Wish   |NG|
|初代ROG Phone|OK|
|Zenfone8 Flip|OK|
|iPhone 8     |未調査|

Andoidはフラッシュ使えない！って言われる可能性ありそう。
これ、地味に辛いけどどうにもならないだろうなぁ・・・

onFlashModeButtonPressed()メソッドはフラッシュモードを設定するUIの表示非表示を行います。
実際にフラッシュモードを設定するメソッドは onSetFlashModeButtonPressed()メソッドになります。

```dart
  void onSetFlashModeButtonPressed(FlashMode mode) {
    setFlashMode(mode).then((_) {
      if (mounted) {
        setState(() {});
      }
      showInSnackBar('Flash mode set to ${mode.toString().split('.').last}');
    });
  }
```

onSetFlashModeButtonPressed()メソッドは引数のFlashMode列挙型の値で挙動が決定します。
onSetFlashModeButtonPressed()メソッドは_flashModeControlRowWidget()メソッドで生成されたUIから呼び出されます。
このUIのタップ時のイベントでonSetFlashModeButtonPressed()メソッドの引数が決定します。
フラッシュのモードは setFlashMode() メソッドを呼び出すことで決定されます。

### 露光調整を行う onExposureModeButtonPressed()メソッド
このメソッド自体はUI操作をしているのですが、紐づいているUI側で露光を調整するメソッドが呼び出されています。
_exposureModeControlRowWidget()メソッドの戻り値で生成しているSizeTransitionがそれにあたります。
そのクラスからメソッドが呼ばれており、最終的にコントローラーの以下2つのメソッドを呼び出しています。

|メソッド|説明|
|----|--|
|setExposureMode()  |露光モードを設定する。<br>ExposureMode.auto:露出設定を自動的に決定<br> ExposureMode.locked:現在決定されている露出設定をロックする。|
|setExposureOffset()|露光オフセットを設定する。サンプルではスライダーの値を設定している。|

### フォーカスの設定を行う onFocusModeButtonPressed()
このメソッド自体はUI操作をしているのですが、紐づいているUI側でフォーカスを調整するメソッドが呼び出されています。
_focusModeControlRowWidget()メソッドの戻り値で生成しているSizeTransitionがそれにあたります。
そのクラスからメソッドが呼ばれており、最終的にコントローラーの以下のメソッドを呼び出しています。

|メソッド|説明|
|----|--|
|setFocusMode()  |露光モードを設定する。<br>FocusMode.auto:フォーカス設定を自動的に決定<br>FocusMode.locked:現在決定されているフォーカス設定をロックする。|

### オーディオモード設定を行う onAudioModeButtonPressed()メソッド
このメソッドが謎で、最終的に読んでるメソッドがコントローラのsetDescription()メソッドが呼び出されています。
このメソッドはカメラの説明を設定するらしいのですが、なんでしょうね？

### 写真の向きの固定方法の設定を行う onCaptureOrientationLockButtonPressed()メソッド

ここではコントローラの２つのメソッドを呼んでいます。

|メソッド|説明|
|--|--|
|unlockCaptureOrientation()|キャプチャの方向のロックを解除する|
|lockCaptureOrientation()  |キャプチャの方向の方向をロックする|

この説明だけだとわかりづらいのですが、例えばポートレート(要はスマホを縦にして使っている)の時にロックすると、
スマホを横にしてもプレビューに表示されるのはランドスケープの状態が維持されます。
これ実際に使うと結構気持ちが悪いです。(縦に動かしているのにプレビューは横にスクロールしたりします)

## カメラの選択を行うUIを提供する _cameraTogglesRowWidget()
簡単に言えば、フロントカメラと背面カメラの切り替えです。
機種によっては、フロント、背面1,背面2,背面3という機種もあります(Zenfone8Flipがそうだった)

呼び出しているコントローラのメソッドは setDescription() イメージとしては、起動時に取得したCameraDescription を引数に渡し、利用するカメラを選ぶ感じです。


# おわりに
どんなメソッド使うと何ができるのかは読み取れました。
[Camera PluginのAPI Refarence](https://pub.dev/documentation/camera/latest/)をだけではやはりわからないこともあるので、サンプルソースを動かしつつ、ドキュメントを参照することで理解も深まるかと思います。
あと、端末によって挙動が違う場合があるので注意が必要ですね。