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つ:
- フレームスキップ: 16ms間隔(60fps相当)で処理を間引く
- 処理中フラグ: 前のフレームの処理が終わっていなければスキップ
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アプリの実装で学んだこと:
- Isolate + フレームスキップでUIスムーズ性を確保
- 安定化フィルタで認識結果のちらつきを排除
- sealed classで型安全なアクション管理
- Riverpodでリアクティブなデータフロー
- オンデバイス処理でプライバシー保護
「ボタンを押さないUI」は、技術的には複雑だが、ユーザー体験としてはシンプルになる。そのギャップを埋めるのがエンジニアリングの醍醐味だと感じた。
リンク
- Google Play: ScanDo - AIスキャン&アクション
- 公式サイト: https://scando.joinclass.jp/
Discussion