🐒

Zero-UIアプリ「ScanDo」を作った話 ―カメラを向けるだけで動くAIアプリの設計思想

に公開

Zero-UIアプリ「ScanDo」を作った話 ― カメラを向けるだけで動くAIアプリの設計思想

はじめに

「ボタンを押す」という行為は、本当に必要なのだろうか?

スマホアプリを使っていると、毎回同じ操作を繰り返していることに気づく。QRコードを読み取るときはカメラを起動して、フォーカスを合わせて、シャッターを押す。外国語のメニューを翻訳するときも同じ。カレンダーに予定を追加するときも、手動で入力している。

ScanDoは、この「当たり前」を疑うところから始まった。

カメラを向けるだけでAIが対象を認識し、「次にやりたいこと」を推論して、アクションボタンを1つだけ表示する。ユーザーはそのボタンを押すだけ。それ以外のUIは存在しない。

この記事では、このZero-UIアプリをFlutterで実装した際の技術的なチャレンジと設計判断について共有する。


アーキテクチャ概要

Zero-UIとは何か

Zero-UIとは、明示的な操作を最小限に抑えたインターフェース設計のことだ。ScanDoの場合、以下の原則を採用した:

  • シャッターボタンなし: カメラプレビューは常時アクティブ
  • 自動認識: 毎フレーム(30fps)で画像を解析
  • 単一アクション: 画面に表示されるボタンは常に1つだけ
  • 0.5秒以内のレスポンス: スキャンからアクション提示まで

技術スタック

Flutter 3.x + Dart
├── 状態管理: Riverpod
├── カメラ: camera パッケージ
├── AI/ML: google_mlkit_text_recognition, google_mlkit_barcode_scanning
└── アーキテクチャ: Clean Architecture (Feature-first)

ディレクトリ構成

lib/
├── core/
│   └── di/providers.dart      # Riverpod プロバイダー定義
├── features/
│   ├── camera/                # カメラ機能
│   │   ├── presentation/      # CameraView, PermissionView
│   │   ├── domain/            # CameraState, CameraRepository
│   │   └── data/              # CameraRepositoryImpl
│   ├── analysis/              # 画像解析
│   │   ├── domain/            # ActionIntent, AnalysisStabilizer
│   │   └── data/              # ImageProcessor, MLKitAdapter
│   └── action/                # アクション実行
│       ├── presentation/      # ActionButtonWidget
│       └── domain/            # ActionExecutor
└── main.dart

技術的チャレンジ① : UIをブロックしない画像処理

問題: 30fps × ML処理 = カクカク

カメラから毎秒30フレームの画像が流れてくる。各フレームでML Kit(OCR + バーコード検出)を実行すると、UIスレッドがブロックされてプレビューがカクつく。

解決: Isolate + フレームスキップ

DartのIsolateを使って、画像処理を別スレッドで実行する設計にした。

/// Isolate内で画像処理を行うプロセッサ
class ImageProcessorIsolate {
  Isolate? _isolate;
  bool _isProcessing = false;
  DateTime? _lastProcessTime;
  static const Duration _minInterval = Duration(milliseconds: 16); // 60fps上限

  /// フレームスキップ判定
  bool shouldProcess() {
    final now = DateTime.now();
    if (_lastProcessTime == null ||
        now.difference(_lastProcessTime!) > _minInterval) {
      _lastProcessTime = now;
      return true;
    }
    return false;
  }
}

ポイントは2つ:

  1. フレームスキップ: 16ms間隔(60fps相当)で処理を間引く
  2. 処理中フラグ: 前のフレームの処理が終わっていなければスキップ
Future<ScanResult> processWithResult(CameraImage image) async {
  // フレームスキップ
  if (!_isolate.shouldProcess()) {
    return ScanResult.empty();
  }

  // 処理中の場合はスキップ
  if (_isolate.isProcessing) {
    return ScanResult.empty();
  }

  _isolate.setProcessing(true);

  try {
    final detection = await _mlKitAdapter.process(image);
    // ... インテント推論
    return ScanResult(intent: intent, ...);
  } finally {
    _isolate.setProcessing(false);
  }
}

これにより、UIスレッドは常にスムーズな30fpsを維持しつつ、バックグラウンドで解析が走る。


技術的チャレンジ② : 認識結果のちらつき排除

問題: フレームごとに結果が変わる

カメラを支払票に向けると、あるフレームでは「QRコード検出」、次のフレームでは「テキスト検出」、その次は「何も検出されない」...というように、結果が不安定になる。

そのままUIに反映すると、アクションボタンがチカチカと点滅してしまう。

解決: 安定化フィルタ(AnalysisStabilizer)

高信頼度の結果のみを通知し、同じ結果の重複通知を防ぐシンプルなフィルタを実装した。

/// 解析結果を安定化するサービス
class AnalysisStabilizer {
  final _stabilizedController = StreamController<ActionIntent>.broadcast();
  ActionIntent? _lastEmittedIntent;

  Stream<ActionIntent> get stabilizedStream => _stabilizedController.stream;

  void addResult(ActionIntent intent) {
    // NoActionIntentは即座にクリア
    if (intent is NoActionIntent) {
      if (_lastEmittedIntent != null && _lastEmittedIntent is! NoActionIntent) {
        _lastEmittedIntent = intent;
        _stabilizedController.add(intent);
      }
      return;
    }

    // 信頼度が低い場合は無視(0.7未満)
    if (!intent.isHighConfidence) {
      return;
    }

    // 前回と同じ結果は通知しない
    if (!_isSameIntent(_lastEmittedIntent, intent)) {
      _lastEmittedIntent = intent;
      _stabilizedController.add(intent);
    }
  }
}

信頼度(confidence)による閾値判定:

sealed class ActionIntent {
  final double confidence;

  /// 高い信頼度かどうか(0.7以上)
  bool get isHighConfidence => confidence >= 0.7;
}

これにより、確信度の高い結果だけがUIに伝播し、ちらつきが排除される。


技術的チャレンジ③ : sealed classによるアクション型安全

問題: アクションの種類が増えると管理が大変

ScanDoは6種類のアクションをサポートする:

検出対象 アクション
QRコード/支払票 決済アプリ起動
日時情報 カレンダー登録
外国語テキスト 翻訳
URL ブラウザで開く
名刺 連絡先追加
長文 要約

各アクションは異なるデータを持つ。enumで管理すると、データの型安全性が失われる。

解決: Dart 3のsealed class

/// ユーザーが次に行いたいアクションを表すsealed class
sealed class ActionIntent {
  final double confidence;
  final String displayLabelKey;

  const ActionIntent({
    required this.confidence,
    required this.displayLabelKey,
  });
}

/// 支払いアクション
class PaymentIntent extends ActionIntent {
  final String deepLink;
  final String paymentCode;
  final String? paymentProvider;

  const PaymentIntent({
    required super.confidence,
    required this.deepLink,
    required this.paymentCode,
    this.paymentProvider,
  }) : super(displayLabelKey: 'actionPay');
}

/// カレンダー登録アクション
class CalendarIntent extends ActionIntent {
  final DateTime? startTime;
  final DateTime? endTime;
  final String title;
  final String? location;

  const CalendarIntent({
    required super.confidence,
    required this.title,
    this.startTime,
    this.endTime,
    this.location,
  }) : super(displayLabelKey: 'actionCalendar');
}

// ... 他のIntent

sealed classを使うことで、パターンマッチングで網羅性チェックが効く:

bool _isSameIntent(ActionIntent? a, ActionIntent? b) {
  if (a == null || b == null) return false;
  if (a.runtimeType != b.runtimeType) return false;

  return switch ((a, b)) {
    (PaymentIntent a, PaymentIntent b) => a.paymentCode == b.paymentCode,
    (UrlIntent a, UrlIntent b) => a.url == b.url,
    (CalendarIntent a, CalendarIntent b) => a.title == b.title,
    (ContactIntent a, ContactIntent b) => a.name == b.name,
    (TranslationIntent a, TranslationIntent b) => a.sourceText == b.sourceText,
    (SummarizeIntent a, SummarizeIntent b) => a.sourceText == b.sourceText,
    (NoActionIntent _, NoActionIntent _) => true,
    // コンパイラが漏れを検出してくれる
  };
}

技術的チャレンジ④ : Riverpodによるリアクティブな状態管理

全体の流れ

CameraImage → ImageProcessor → AnalysisStabilizer → UI
     ↓              ↓                  ↓
  30fps入力     ML Kit解析      安定化フィルタ

これをRiverpodで宣言的に接続する:

/// イメージプロセッサプロバイダー
final imageProcessorProvider = Provider<ImageProcessor>((ref) {
  final processor = ImageProcessor();
  ref.onDispose(() => processor.dispose());
  return processor;
});

/// 解析スタビライザープロバイダー
final analysisStabilizerProvider = Provider<AnalysisStabilizer>((ref) {
  final stabilizer = AnalysisStabilizer();
  ref.onDispose(() => stabilizer.dispose());
  return stabilizer;
});

/// 安定化されたインテントストリームプロバイダー
final stabilizedIntentProvider = StreamProvider<ActionIntent>((ref) {
  final stabilizer = ref.watch(analysisStabilizerProvider);
  return stabilizer.stabilizedStream;
});

UIはこのストリームを購読するだけ:

class CameraView extends ConsumerStatefulWidget {
  @override
  Widget build(BuildContext context) {
    final stabilizedIntent = ref.watch(stabilizedIntentProvider);

    return Stack(
      children: [
        // カメラプレビュー
        _buildCameraPreview(),

        // アクションボタン(安定化されたインテントに応じて表示)
        Positioned(
          bottom: 0,
          child: stabilizedIntent.when(
            data: (intent) => ActionButtonWidget(intent: intent),
            loading: () => const SizedBox.shrink(),
            error: (_, __) => const SizedBox.shrink(),
          ),
        ),
      ],
    );
  }
}

ref.onDisposeを使ってリソースの解放を自動化しているのもポイント。


プライバシーへのこだわり: 全てオンデバイス

ScanDoは、カメラ画像を一切外部に送信しない。

  • OCR: Google ML Kit(オンデバイス)
  • バーコード検出: Google ML Kit(オンデバイス)
  • インテント推論: ルールベース(将来的にはGemma 3 270Mを検討)

ネットワーク接続なしで動作するため、機内モードでも使える。


Spec-Driven Development の実践

このプロジェクトでは、Kiro風のSpec-Driven Developmentを採用した。

ワークフロー

Requirements(要件定義)
    ↓
Design(技術設計)
    ↓
Tasks(実装タスク)
    ↓
Implementation(実装)

各フェーズでドキュメントを作成し、AIエージェント(Claude Code)と協調して開発を進めた。

要件定義の例(EARS形式)

#### REQ-2.5 Isolate処理
The AIEngine shall AI推論処理を必ずIsolate(別スレッド)で実行し、
UIスレッドをブロックしない

#### REQ-2.6 安定化フィルタ
While 解析結果が変動している間, the AnalysisStabilizer shall
3フレーム連続で同一結果が得られた場合のみUIに通知する

このように要件を形式的に記述することで、実装の抜け漏れを防ぎ、AIエージェントとの認識齟齬を減らせた。


パフォーマンス結果

指標 目標 実測
起動速度 1秒以内 0.8秒
解析レスポンス 0.5秒以内 0.3秒
メモリ使用量 500MB以下 約350MB
フレームレート 30fps以上 30fps維持

今後の展望

Gemma 3 / Gemini Nano の統合

現在のインテント推論はルールベースだが、将来的にはオンデバイスLLMを活用したい:

  • flutter_gemma: Gemma 3 270M(実験的)
  • AICore: Android 14以降のGemini Nano

より複雑な文脈理解(「この支払票は〇〇の料金だから、△△アプリで払いたい」など)が可能になる。

対応アクションの拡充

  • 食品パッケージ → 栄養情報表示
  • 薬のパッケージ → 服用履歴記録
  • 植物 → 品種同定

まとめ

Zero-UIアプリの実装で学んだこと:

  1. Isolate + フレームスキップでUIスムーズ性を確保
  2. 安定化フィルタで認識結果のちらつきを排除
  3. sealed classで型安全なアクション管理
  4. Riverpodでリアクティブなデータフロー
  5. オンデバイス処理でプライバシー保護

「ボタンを押さないUI」は、技術的には複雑だが、ユーザー体験としてはシンプルになる。そのギャップを埋めるのがエンジニアリングの醍醐味だと感じた。


リンク

Discussion