🤖

[Flutter/Stream]ChatGPTの文字がスラスラ出てくるあれの実装方法

2024/12/02に公開

はじめに

ChatGPTのようなチャットボットでは、回答が一気に表示されるのではなく、部分的に順次表示される仕組みがありますよね。これに似た動きをFlutterでどのように実現するのか気になったことはありませんか?

この記事では、Flutterを使ってテキストをリアルタイムに順次表示する方法を解説します。
この記事を読むことで、Dartの非同期プログラミング(asynchronous programming)の重要な概念の一つであるStreamの基礎を学ぶことが出来ます!

環境

  • FVM: 3.2.1
  • Flutter: 3.24.5
  • Dart: 3.5.4

実装

アプリの完成形

コーディングを始める前に、最終形なアプリのイメージを見てみましょう。
アプリは最終的に以下のようにします。

右下のボタンを押すと、仮想AIアシスタントからの回答が開始され、準備出来たテキストから順番に表示されています。
ここでは、順番に表示されるという部分がStreamを用いることで効果的に実装出来ることを頭の片隅に置いておいてください。

初期値のテキストを表示する

アプリの完成形を見たので、コーディングをはじめて行きましょう!
まずは、以下のような初期値のテキストを表示するだけのコードから始めます。

main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

/// アプリ
class MyApp extends StatelessWidget {
  /// コンストラクタ
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flow Text Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

/// ホーム画面
class MyHomePage extends StatefulWidget {
  /// コンストラクタ
  const MyHomePage({super.key});

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _text = 'ワタシと楽しい会話をしましょう⚡';

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(
          'Display the text using a Stream',
          style: TextStyle(
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 32),
          child: Text(
            _text,
            style: const TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.w600,
              height: 2,
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.play_arrow, size: 32),
      ),
    );
  }
}

ダメな実装

次に、ボタンを押すと、仮想AIアシスタントから回答が来る機能を追加します。
ChatGPTの動作を参考にして、私たちの仮想AIアシスタントのサーバーサイドの動きを実装してみましょう!

ChatGPTの応答が時折途切れることがあるので、テキストを生成するのに結構時間がかかっているようです。また、テキストは順次表示されるので、最終的に出力されるテキストの部分部分を生成するのにもそこそこの時間がかかっているみたいですね。
これをシミュレートするために、仮想AIアシスタントは1文字を生成するのに、一定時間をかかるようにします。
例えば以下のようで良いでしょう。

main.dart
/// 最終的に表示するテキスト
const fullText =
    '''こんにちは!ワタシは仮想AIアシスタントです。さまざまなトピックについて情報を提供したり、質問に答えたり、アイデアをサポートしたりすることは一切ありません。技術的なアドバイス、文章作成、プログラミングサポート、キャリア相談など、幅広いリクエストに対応できませんし、あなたの現在の状況やご希望に合わせて最適なサポートを提供する能力は一切ありませんが、何でも気軽に聞いてください!''';

/// テキストを取得する
Future<String> getFullText() async {
  var text = '';
  for (var i = 0; i < fullText.length; i++) {
    await Future<void>.delayed(const Duration(milliseconds: 100));
    text += fullText[i];
  }
  return text;
}

仮想AIアシスタントの計算結果を利用できるようにしたので、仮想AIアシスタントの回答を表示していきます。
ボタンを押す → 初期テキストを非表示にする → 仮想AIアシスタントに回答をリクエストする → 回答が返ってくる → 回答内容を表示する、という流れで実装します。

_MyHomePageStateクラスを以下のようにすると、上記の実装が出来ます。

main.dart
class _MyHomePageState extends State<MyHomePage> {
  // 初期テキストから変更するので再代入できるようにする
  var _text = 'ワタシと楽しい会話をしましょう⚡';

  // 初期テキストを空文字にする
  void _clearText() {
    setState(() {
      _text = '';
    });
  }

  // テキストを表示する
  Future<void> _showText() async {
    _clearText();
    final text = await getFullText();
    setState(() {
      _text = text;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(
          'Display the text using a Stream',
          style: TextStyle(
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 32),
          child: Text(
            _text,
            style: const TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.w600,
              height: 2,
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // ボタンを押したら、仮想AIアシスタントからの回答を表示する
        onPressed: _showText,
        child: const Icon(Icons.play_arrow, size: 32),
      ),
    );
  }
}

完成🎉🎉🎉

とはいかず、ボタンを押してから画面が真っ白な状態が続きます。
仮想AIアシスタントは1文字生成するのに100ミリ秒かけるので、だいたい20秒程経たないと今回用意した回答結果は表示されません。
ユーザーがアクションを起こしてから、何も動きが無い時間が20秒もあっては、誰も回答を見てくれません。(私はしょうがなく眺めてました☀️)
準備が出来た回答から順にテキストを表示するよう改良する必要があります。

Streamを使った実装

計算を全部終えてから結果を返してもらっている点が最悪のUXを引き起こしていました。
計算を全て待つことなく、計算が終わった部分からデータを返してもらう良い方法はないでしょうか?

DartのStreamという概念がこのユースケースに有用です。

Streamとは一体なんでしょうか?
StreamはDartの非同期プログラミングで使用する概念の一つです。
公式ドキュメントによると、Dartの非同期プログラミングは、FutureとStreamによって特徴づけられるようです。
FutureはStreamよりも馴染み深いものだと思います。
それでは、FutureとStreamの違いはなんでしょうか?
本記事では、下記の違いがあるという認識だけで問題ありません。
Futureは「すぐに完了しない単一の計算結果」が欲しい時に使用でき、Streamは「すぐに完了しない連続的な計算結果」が欲しい時に使用できる。

https://dart.dev/libraries/async/using-streams

先ほどのダメな実装では、仮想AIチャットボットが回答を全て生成する計算全体を単一の計算として扱いました。
しかし、別の見方をすると、仮想AIチャットボットが回答を生成する計算全体は、100ミリ秒毎に1文字を生成する計算の積み重ねだということが分かります。
この見方をStreamを用いて実装に反映していきます。

まずは、仮想AIチャットボットが回答を生成するgetFullTextの代わりに、textStreamというStreamを定義していきます。
textStreamは下記のようになります。

main.dart
/// テキストを一定間隔で1文字ずつ出力するStream
Stream<String> textStream() async* {
  for (var i = 0; i < fullText.length; i++) {
    await Future<void>.delayed(const Duration(milliseconds: 100));
    yield fullText[i];
  }
}

注目する点は以下の3つです。

  1. Futureではなく、Streamであること。
  2. asyncではなく、async*であること。
  3. returnではなく、yieldであること。

今回は100ミリ秒毎に1文字を生成する計算結果を用意出来た順から利用したいので、Streamを導入しました。
Streamを使用する場合は、型をStreamに指定する必要があり、Streamが返すデータの型も指定します。
async*は非同期ジェネレーター(asynchronous generator)と呼ばれるもので、Streamを定義する時に使用されます。
yieldはStreamが逐次データを出力する時に使用されます。returnと似ているようですが、returnが関数を終了させるのに、対してyieldは関数を終了させません。

回答を1文字毎に出力するStreamが定義出来たので、Streamを使って、回答をUIに表示していきましょう。

_MyHomePageStateクラスを以下のように修正し、Streamを初期化して、回答を順次UIに反映します。

main.dart
class _MyHomePageState extends State<MyHomePage> {
  var _text = 'ワタシと楽しい会話をしましょう⚡';
  // Streamの初期化
  final _stream = textStream();

  void _clearText() {
    setState(() {
      _text = '';
    });
  }

  Future<void> _showText() async {
    _clearText();
    // Streamから出力されてくる回答をUIに反映する
    await for (final currentChar in _stream) {
      setState(() {
        _text += currentChar;
      });
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(
          'Display the text using a Stream',
          style: TextStyle(
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 32),
          child: Text(
            _text,
            style: const TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.w600,
              height: 2,
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _showText,
        child: const Icon(Icons.play_arrow, size: 32),
      ),
    );
  }
}

ここで注目する点は、await forの部分です。
await forを使うことで、Streamからの出力を反復処理できます。
ちなみに、await forは非同期forループ(asynchronous for loop)とも言います。

ボタンを押してみると、仮想AIチャットボットがまさに会話しているかのように、スラスラとテキストを表示するようになりました🎉🎉🎉

最終的なソースコードは以下です。

main.dart
import 'package:flutter/material.dart';

/// 最終的に表示するテキスト
const fullText =
    '''こんにちは!ワタシは仮想AIアシスタントです。さまざまなトピックについて情報を提供したり、質問に答えたり、アイデアをサポートしたりすることは一切ありません。技術的なアドバイス、文章作成、プログラミングサポート、キャリア相談など、幅広いリクエストに対応できませんし、あなたの現在の状況やご希望に合わせて最適なサポートを提供する能力は一切ありませんが、何でも気軽に聞いてください!''';

/// テキストを一定間隔で1文字ずつ出力するStream
Stream<String> textStream() async* {
  for (var i = 0; i < fullText.length; i++) {
    await Future<void>.delayed(const Duration(milliseconds: 100));
    yield fullText[i];
  }
}

void main() {
  runApp(const MyApp());
}

/// アプリ
class MyApp extends StatelessWidget {
  /// コンストラクタ
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flow Text Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

/// ホーム画面
class MyHomePage extends StatefulWidget {
  /// コンストラクタ
  const MyHomePage({super.key});

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var _text = 'ワタシと楽しい会話をしましょう⚡';
  final _stream = textStream();

  void _clearText() {
    setState(() {
      _text = '';
    });
  }

  Future<void> _showText() async {
    _clearText();
    await for (final currentChar in _stream) {
      setState(() {
        _text += currentChar;
      });
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(
          'Display the text using a Stream',
          style: TextStyle(
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 32),
          child: Text(
            _text,
            style: const TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.w600,
              height: 2,
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _showText,
        child: const Icon(Icons.play_arrow, size: 32),
      ),
    );
  }
}

おわり

Streamにあまり触れたことの無かった方には、よいStreamの導入になったのではないでしょうか。
しかし、今回紹介したStreamの使い方だと、Streamを途中で止められなかったり、初期化することが出来ません。
次回は、もっと深くStreamを探求したいと思います。
最後まで記事を読んでくださり、ありがとうございました!

参考元

https://dart.dev/libraries/async/using-streams
https://dart.dev/libraries/async/creating-streams
https://www.freecodecamp.org/news/how-to-use-and-create-streams-in-dart-and-flutter/

Discussion