🔬

【詳解】Flutter の画像切り抜きパッケージ crop_your_image のアーキテクチャ

2024/12/13に公開

crop_your_image は Flutter アプリに利用できる画像切り抜きパッケージです。

https://pub.dev/packages/crop_your_image

3, 4 年ほど前に開発をスタートしてから細々とメンテナンスを続けていたのですが、ようやく最近 2024.12.13 にバージョン 2.0.0 をリリースし、さまざまな不具合修正やリクエストのあった機能追加などを行いました。

この 2.0.0 では内部的にも大きく設計の見直しとリファクタリングを行いましたので、この記事で紹介したいと思います。

パッケージ開発の参考になればと思うと共に、crop_your_image のコードを読んで動作を理解したり機能追加のプルリクを送ったりしたい方の参考になればと思います。

crop_your_image について

その前に crop_your_image がどんなパッケージなのかの紹介を簡単にさせてください。

Flutter アプリ開発で使える画像切り抜きパッケージとして、有名なところでは image_cropper が挙げられるのではないかと思います。

https://pub.dev/packages/image_cropper

この image_cropper はほんの 1 ステップのコードで画像の切り抜きを可能にしてくれますが、とはいえその機能はネイティブで用意された画面に遷移することによって実現するため、Flutter の Widget と混ぜて使うことはできません。

一方 crop_your_image は Crop という Widget を配置することによってすべてが Widget で構築された切り抜きの UI を提供します

Crop は Flutter のレイアウトシステムを尊重し、与えられた Constraints に応じてそのサイズを決定し必要な座標を計算します。つまり、Crop は配置する場所を選びません。画面いっぱいに広げても良いし、フォーム画面の一部分に小さく配置しても良いし、ボトムシートやダイアログ内でも問題なく動作します。

アプリのデザインに合わせて UI を自在に設定でき、ユーザーにとってシームレスな切り抜き機能を提供する、というのが crop_your_image のコンセプトです。

使い方も TextTextEditingController のように Crop とそれを操作する CropController によって実現するため、新しい概念を覚えることなく使えることを意識しています。

// CropController を State のフィールドなどに準備
final _controller = CropController();

Column(
  children: [
    Expanded(
    // Crop を配置
      child: Crop(
        controller: _controller, // CropController を設定
        image: _imageData,
      ),
    ),
    ElevatedButton(
      onPressed: () => controller.crop(), // 切り抜き実行
      child: Text('Crop!'),
    ),
  ],
),

このような Flutter の考え方に沿った作りのおかげで image_cropper との使い分けも明確になり、それなりに使ってもらえるパッケージになったのではないかなと自画自賛しています。[1]

そんなパッケージです。

パッケージにおけるアーキテクチャの重要性

crop_your_image のアーキテクチャについて書く前に、「パッケージ」にアーキテクチャの検討なんてものが必要なのか という点について書いておきたいと思います。

みなさんの想像の通り、パッケージはアプリに比べて非常に少ないコード量でできています。普段のアプリ開発で作る何かしらの共通 Widget やロジックに名前をつけて公開しているだけのイメージです。[2]

さらにパッケージ開発はほとんどが個人開発だと思います。複数人のチームメンバーと開発するお仕事のプロジェクトと違い、究極的には「自分がわかればそれで良い」と言えると思います。

それでも crop_your_image はバージョン 1.0.0、 2.0.0 とメジャーバージョンを上げるたびにアーキテクチャの見直しとリファクタリングを行っています。その理由は プルリクを送ってくれる人がいるから に尽きます。

パッケージ開発は個人開発と見せかけて個人開発ではありません。GitHub にコードを公開している限り、それを読んで 機能追加や不具合修正のプルリクを送ってくれる方々が世界中にいます 。そしてそんな方々のプルリクのおかげで自分ひとりでは手が回らないような機能も実現できる可能性が生まれるわけです。

そんな方々に対し、直接話してコードの説明をしている暇はありません。どうしても勝手に読んで勝手に修正してもらうことがほとんです。そう考えた時、なるべく読みやすく修正しやすいコードを保っておくというのは プルリクのハードルを下げてパッケージを盛り上げるためにも重要である と考えられるでしょう。

あとは単純に考えたことを実際に形にしてみたいとか、それをひとつのアウトプットとしてアピールしたいとか、そんな考えもあるにはありますが、そういった個人の動機に依らないメリットとしては 「プルリクの出しやすさ」 が挙がるのではないかなと思います。

以上がパッケージ開発におけるアーキテクチャの重要性に対する私の考えです。

crop_your_image のアーキテクチャ

では crop_your_image のアーキテクチャについて見ていきましょう。

とは言っても先述の通りコードの総量はそれほど多くありません。そのため複数の層にたくさんのクラスがあるようなものではなく、「どのようにクラス分けをしたのか」程度 にみていただければ大丈夫です。「アーキテクチャ」という言葉も仰々しい気がしますので、ここからは単に「クラス設計」という言葉を使おうと思います。

クラス設計のコンセプト

crop_your_image のクラス設計において重視しているのは以下の 2 点です。

  • 画像処理ロジックを UI から明確に分離し、差し替え可能にする
  • UI の元になる座標計算ロジックは可能な限り Widget クラスから独立させる

画像切り抜きパッケージである crop_your_image では、ユーザーが自由に切り抜き範囲を選択するための座標やズームの計算であったり、ディスプレイ上で調整した切り抜き範囲を実際の画像サイズに基づく座標に変換するような処理も必要になったりと、とても多くの計算処理が必要 になります。

このような処理をひとつの StatefulWidget クラスにまとめてしまっていては当然コードが読みづらくなり、どこからどこまでが計算処理でどこが UI 構築のコードなのかわからなくなってしまいます。

また、ユーザーが決定した切り抜き範囲の座標が計算できた後、それを使って目的の画像処理をするコードも必要です。自分は画像処理の専門家ではないため、「自分があまり詳しくない分野」についてのコードを切り分けておく ことは誰かの助けを得る上でとても大事なことだと思っています。

ここまでをまとめると、crop_your_image は以下のように 3 つの役割でクラスが分けられています。

  • UI を構築するクラス
  • 座標計算やズームの計算などのロジックを実装するクラス
  • 画像処理を実現するクラス

crop_your_image概要

下から順番に、それぞれ詳しくみていきましょう。

画像処理を実現するクラス

画像処理は ImageCropper というクラスが担当しています。

ただしこれは abstract がついた抽象クラスで、「切り抜きの流れ」は書かれていますがその具体的な切り抜きの実装はサブクラスで行う形になっています。いろいろ省略すると以下のような構造です。

abstract class ImageCropper<T> {
  const ImageCropper();

  // 切り抜き処理
  FutureOr<Uint8List> call() async {
    // 切り抜き範囲のバリデーション
    final error = rectValidator(original, topLeft, bottomRight);
    if (error != null) {
      throw error;
    }

    // 切り抜き処理を実行。ただし、四角形か円形かで処理が異なる。
    return switch (shape) {
      ImageShape.rectangle => rectCropper(),
      ImageShape.circle => circleCropper(),
    };
  }

  // バリデーションや切り抜き処理の具体的な実装はサブクラスで行う。
  RectValidator<T> get rectValidator;
  RectCropper<T> get rectCropper;
  CircleCropper<T> get circleCropper;
}

先述の通り自分は画像処理については全くの素人です。そのため、crop_your_image では画像処理の一切を image というパッケージを利用して実現しています。

https://pub.dev/packages/image

しかしながら、世の中には 画像処理を専門とするが Flutter の UI には詳しくない という方々もいると思います。もしかしたらネイティブ等で使いまわせる画像処理の資産やライブラリがあって、そちらを使いたいというプロジェクトもあるかもしれません。

そのような場合を想定して、crop_your_image ではこの ImageCropper を差し替え可能にしています。

Crop(
  controller: _controller,
  image: _imageData,
  // 画像処理の具体的な実装を差し替える
  cropper: MyCropper(),
),

デフォルト実装で利用している image パッケージはすべての処理が Dart で書かれているおかげで Web やデスクトップを含むすべてのプラットフォームで利用できるメリットがある一方で、パフォーマンス面ではネイティブの方が良いということもあると思います。「crop_your_image の UI は良いけど画像処理の質が問題で使えない」という場合に備えて、この差し替えの仕組みを用意している というわけです。

その際、差し替える側のコードを少しでも減らしつつ自由度を確保するために、「親クラスである ImageCropperimage パッケージに依存しない」「どの実装でも共通で必要になる画像と座標の情報だけを親クラスで整理する」という工夫も入れたりしています。

座標を計算するクラス

crop_your_image にはたくさんの座標計算が登場します。それらを Widget クラスで一緒に実装してしまうと、以下のような不都合が発生します。

  • 読みづらい
  • テストしづらい

「読みづらい」については説明するまでもないと思います。ひとつのファイルに Widget の構築処理から座標計算処理までまとめてかかれてしまうと、コードのどこに何が書かれているのか把握するのが自分でも困難になってしまいます。

「テストしづらい」についてですが、パッケージはコード量が少ない一方、「アプリ内の共通クラス」とは比べものにならない頻度やパターンで利用されます。そのため、ひとつひとつのコード修正が意図しない箇所に影響していないかをいろんなパターンで確認することが大事になる わけですが、それを手作業で行うのはかなり困難です。

また、機能追加等のプルリクを送ってくれる人も全パターンの動作確認をしてくれるわけではありません。こちらも仕事ではないので確保できる時間に限りがあります。そうなると、テストコードで品質を担保する(正確には「挙動に変化がないことを確認する」)重要性はお仕事におけるアプリ開発よりも高くなる と考えられます。

だからと闇雲にテストコードを書こうとすると、UI がからむとテストコードを書くのが難しくコストがかさむことについては 以前に書いた 通りです。

座標計算を UI から独立することで テストのハードルを下げ、それによってカバーできるテストのパターンを増やし、手作業では確認しきれないパターンを自動テストで確認できるようにする、というのが座標計算を別クラスに分離する理由です。

座標計算と状態管理

さて、座標計算のクラスを分離することが決まったわけですが、どのようにクラスを分ければ良いでしょうか。

crop_your_image のバージョン 1.0.0 では計算するための関数を集めた Calculator というクラスを用意し、それを Widget から適宜呼び出すだけで対応していたのですが、それだけでは その計算をどのタイミングで呼び出し、結果をどのように扱うかという「ロジック」がまだ Widget に残ってしまっている 状態でした。

そのため Widget から完全にロジックを切り離すことができず、また Crop はズームや切り抜き範囲の移動にともなって再計算しなければならない値も多く、それらをすべて Widget で行っていてはまだ「読みづらい」「テストしづらい」状態でした。

そこで、「状態」と「宣言的」に着目して切り分けた クラスが CropEditorViewState です。

CropEditorViewState は UI の構築に必要なすべての値を保持する immutable なクラスで、以下のような特徴があります。

  • 計算の元になる値をコンストラクタで受け取る
  • そこから計算で求められる値を late final で提供する

パッケージに限らず Flutter アプリ開発において、最終的に Widget の build() メソッドで必要になる値は以下の 4 パターンで整理されます。

    1. Widget のコンストラクタで受け取った値
    1. その Widget 内部で発生した状態
    1. 外部から取得したグローバルな状態
    1. それらから計算された値

アプリ開発において、だいたいの Widget はこれらを適宜受け取って適宜使うだけでコードが汚くなることはありません。

しかし Crop のように、画像のズームや移動、切り抜き範囲の調整や設定変更など、ユーザーやプログラマーにさまざまな操作を提供している Widget にとって、これらの交通整理ができていないとすぐに「あちらの変更がこちらに影響する」コードで複雑になってしまいます。それを整理するのが CropEditorViewState です。

CropEditorViewState は、先述の 1, 2, 3 のすべてをコンストラクタで受け取ります。[3]

その後、それらの値から計算される別の値を late final で宣言することで、Widget が必要なタイミングでそのフィールドにアクセスすればそこで一度だけ計算されて結果が保持される仕組みになっています。

class CropEditorViewState {
  ReadyCropEditorViewState({
    required super.viewportSize,
    required this.imageSize,
    required this.cropRect,
    required this.imageRect,
    required this.scale,
    required this.offset,
  });

  /// コンストラクタで受け取る各種の値
  final Size viewportSize;
  final Size imageSize;
  final ViewportBasedRect cropRect;
  final ViewportBasedRect imageRect;
  final double scale;
  final Offset offset;

  /// 以下、あとから計算で求められる値
  /// 与えられた枠に対して画像が縦方向にフィットしているかどうか
  late final isFitVertically = imageSize.aspectRatio < viewportSize.aspectRatio;

  /// 横か縦かで変わる計算クラス
  late final calculator =
      isFitVertically ? VerticalCalculator() : HorizontalCalculator();

  /// 枠にちょうどフィットするズーム率
  late final scaleToCover = calculator.scaleToCover(viewportSize, imageRect);
}

このようにしておくことで、Cropbuild() メソッドでは CropEditorViewState から必要な値を必要なタイミングで指定するだけでよくなるので、「この計算結果はここに適用して、、」のような判断が不要になり、「状態が変わったらとにかく CropEditorViewState を作り直す」ことだけを考えればよくなります。

また、状態を表すたくさんの値も State クラスに build() と一緒に記述されず CropEditorViewState にまとめられるので、Crop の中も Widget の構築とイベントハンドリングだけに集中できます。

Flutter の基本的な考え方は「宣言的」です。

たとえば「X の状態が変わったから別の Y を再計算したい」のであれば、「X が変わったから Y を再計算する処理を『忘れずに』呼び出す」ではなく、「X が変わったら Y も勝手に再計算される」 という仕組みを検討することで build() メソッドのコードをシンプルにできると思います。

UI を構築するクラス

ここまでの工夫を入れることで、UI については特に特筆することはなくなります。

CropEditorViewState を適宜更新し、そこから得られる値を使って 「この状態の時はどんな UI であるべきか」をコーディングするだけ です。面倒な座標計算処理はもう終わっている前提で利用すれば良いため、Widget の配置とイベントが起きた際の適切なコールバックの呼び出しや CropEditorViewState の作り直しだけ注意すれば良いでしょう。[4]

crop_your_image パッケージにおいてこの Crop が記述されたクラスは「Crop の仕様」を確認するためにも読まれます。

そのため、長々した複雑な計算処理よりも 使い方のドキュメントや各種の引数がどのように扱われるのか、どんな場合にどんな見た目になるのかを概観できることが重要 です。

そんな意味でも、「具体的」な座標計算や画像処理は別に追いやっておくと良いといえるでしょう。

まとめ

以上です。ところどころ単純化した説明になってしまった部分はあるものの、これを読んでから crop_your_image のコードを読むことで、どのように画像の切り抜き機能が実現しているかが理解しやすくなったのではないかと思います。

とはいえすべてのパッケージがこのように設計されるべきという話ではありません。ここまで読んでいただければわかる通り、説明したクラス分けはすべて crop_your_image 特有の事情を基に考えたもの です。

それを踏まえた上で、何か別のパッケージを作る際の参考になっていれば嬉しいです。

この記事を読んで crop_your_image に興味が出てきた方は、ぜひ使ってみて機能追加や不具合修正のプルリクを送ってみてください!(そして私の反応を気長に待っていてください。すみません。)

脚注
  1. pub.dev によると、1ヶ月で 45,000 ほどのダウンロードがされているそうです。ありがとうございます。 ↩︎

  2. もちろんパッケージの内容によってその規模は様々ですが、とはいえアプリ開発以上の規模になるパッケージというのは稀だと思います。 ↩︎

  3. とはいえアプリの性質上「3. 外部から取得したグローバルな状態」はありませんが。 ↩︎

  4. それでもまだいろいろ必要なコードはありますが。 ↩︎

GitHubで編集を提案

Discussion