Ⓜ️

【Flutter】Misskeyクライアントの作りかた

2023/12/16に公開

Misskeyのサードパーティクライアント「Miria」をもとに、FlutterにおけるMisskeyクライアントの実装の仕方や、Misskeyの仕様的な部分で実装する際の落とし所や注意点といったところを解説します。

なお、この記事の内容には、Miriaで現在そうなっていないが今後こうした方がよい、こうするべきであったといった反省点も多く含まれています。

ノートを表示する

何はともあれMisskeyクライアントではノートを表示する必要があります。ノートにはこのような情報を表示します。

ノートの例

  • ノートの内容(注釈、本文、投票、添付メディア)
    • そのユーザーがisCatであれば、本文に対しnyaize処理をする必要があります。
    • Misskeyのドライブはzipでもexeでも何でもアップロードできるという点を考慮して添付メディアの表示の実装をする必要があります。(いずれMiriaもちゃんとやらないといけないところ)
  • リノートであれば、誰がリノートしたか、リノートの対象のノートは何か
    • 注釈、本文、投票、添付メディアのないリノートは、単純なリノートとして扱います。
  • チャンネルに対するノートであれば、どのチャンネルに向けたノートか
  • そのノートの公開範囲はどこまでか、連合するかしないか
  • 投稿時刻
  • ノートにつけられたリアクション

また、ノートにはユーザーの情報が含まれます。Misskeyでは主に下記のようなものを表示します。

  • ユーザー名、スクリーンネーム
  • どのサーバーの人か(いわゆるInstance Ticker)
  • そのユーザーにわりあてられた公開ロール
  • そのユーザーが設定したアバターデコレーション
  • そのユーザーがisCatであれば、猫耳
  • そのユーザーがisBotであれば、ボットである表示

ノートの本文やユーザー名の表示には、MFM(Misskey Flavored Markdown)を含む表示が必要です。

Miria用に作ったMFM表示ウィジェットをpub.devに公開していますので、こちらを使うことでMFMの表示などをよしなにできるようにしています。

https://pub.dev/packages/mfm

アバターにはアバターデコレーションが含まれますが、これはクライアント側で画像を重ねる必要があります。アバターデコレーションはStackTransformウィジェットを活用することで、比較的簡単に実装することができます[1]。下記はMiriaにおける実装です。

https://github.com/shiosyakeyakini-info/miria/blob/ac69f7804f309a9fc6afb63c0d5c96aef741ee23/lib/view/common/avatar_icon.dart#L87-L186

カスタム絵文字の取り扱い

ユーザー名やノートの本文、リアクションにはカスタム絵文字が使われますが、注意しないといけない点が複数あります。

Misskey v13系では各ノートに対して自分のサーバーの絵文字の情報は含まれない

Misskey v13系では、各ノートに対して自分自身のサーバーのカスタム絵文字の情報は含まれません。いちいち全部につけていたらサーバー側のデータの転送量やクライアント側の通信量への影響が大きいということは至極もっともだと思います。

そのため、アカウントが所属するサーバーに登録されているカスタム絵文字の一覧を先にapi/emojisで取得する必要があります。アカウント毎やサーバー毎のProvider.familyを作成し、これらから絵文字を表示するウィジェットから参照するといったことで実現できます。

基本的にはそこで取得したURLを画像として表示すればよいのですが、いくつか注意しないといけないケースがあります。

カスタム絵文字やロールのアイコンなどがSVG形式のことがある

りんごぱいのロールのアイコンをはじめ、まれにSVGの画像が出現します。SVG形式はImageウィジェットやcached_network_imageでは対応できないので、パスから拡張子を推測してSVGの場合はflutter_svgを使うといった考慮が必要です。

ただし、後述するメディアプロキシを使用する場合は、メディアプロキシがSVG画像をwebpに加工することになっているので不要です。

カスタム絵文字の画像がドライブから欠落していることがある

そのサーバーのカスタム絵文字を担当していたモデレーターがアカウントを削除しただとか、カスタム絵文字を自由に登録できるサーバーで登録者がドライブからカスタム絵文字の画像を削除しただとか、Misskeyではさまざまなケースでカスタム絵文字の画像が消失することがあります。

しかしながら、こうした状況下においてカスタム絵文字の画像が即座にMisskey Webで表示されなくなるといったことはなく、メディアプロキシやCloudflareのCDNなどがキャッシュを効かしている間についてはカスタム絵文字がMisskey Web上ではカスタム絵文字の画像が表示されます。

api/emojisから取得できるURLはあくまでドライブの画像直リンなので、ドライブやアカウント削除の状況下でこのURLをそのまま使うと上記のキャッシュにはヒットせず、404が返却されてしまいカスタム絵文字の画像を表示できません。そこで、api/metaから取得できるメディアプロキシのURLを介して画像を取得する必要があります。あるいはフォールバックでもよいです。

また、これはリモートから送られてくるリアクションについても同様のことがいえるので、リモートの場合リモート先のメディアプロキシを介して画像を表示することを考えるのもよいでしょう。[2] Misskey Webでも特にこうしたことはしていないので、一時期Misskey.ioのMisskey Webから見るとにじみすのカスタム絵文字が見えない、といった現象が発生していました。

ノートの一覧を表示する

単純にリストでノートを表示することを考えてみましょう。Misskeyでこうしたリストでノートを表示するシーンは下記のようなものが考えられます。

  • クリップ
  • お気に入り
  • チャンネルのハイライト
  • ユーザーのノート、ハイライト
  • 検索
  • ハッシュタグ
  • サーバー全体のハイライト
  • タイムライン
    • リスト
    • アンテナ
    • チャンネルのノート
    • ホーム、ローカル、ハイブリッド、グローバルタイムライン
    • ロールタイムライン

物量がけっこうある上に、エンドポイントによってuntilIdを渡したりoffsetを渡したりと仕様が違ったりすることがあります。

APIについては、任意のサーバーのapi-docapi.jsonをリクエストすることで、どのようなAPIがあるかを確認することができます。(例えば、https://misskey.io/api-doc をリクエストすると、Misskey.ioの現在のバージョン&フォークとしてのAPIのドキュメントが確認できます。)

また、Misskeyでは速い変更に追いつく必要がありますが、現在では各プルリクエストに対してapi.jsonの差分を出力するようになっているので、Misskeyのリポジトリのプルリクエストを監視しておくことでこうした変更を察知することができます。

画面の話にもとに戻しましょう。こういったノートの一覧を表示するうえで、いずれも「最初に呼び出すFuture」「次以降のページネーションを呼び出すFuture」でリストの項目が決まるという点は変わりません。

そこで、Miriaでは、このようなウィジェットを作成し、これらのページネーションを含むリストの処理を共通化しています。

https://github.com/shiosyakeyakini-info/miria/blob/ac69f7804f309a9fc6afb63c0d5c96aef741ee23/lib/view/common/pushable_listview.dart

initializeFutureには最初に呼び出すFutureを、nextFutureにはページネーションのFutureを指定できるようにし、nextFutureには最後のアイテムと最後のアイテムのインデックスを渡すようにしています。こうすることで、untilIdoffsetといったパラメータにこれらのアイテムのIDを渡せるようにしています。itemBuilderはそのままリストの項目を作るビルダーです。

listKeyは、自身のキーを示すことができるようにしています。例えば、検索画面では検索の内容を変更するとリストの内容を更新する必要がありますが、検索内容の文字列をlistKeyにいれることで、検索の内容の変更と同期できるようにしているわけです。

ところで、FlutterにおけるListViewは、AndroidにおけるRecyclerViewと同様に、必要なときにビルドされるということを念頭に置く必要があります。これが数千ものノートのリストを表示しても問題ない理由となります。

ノートのウィジェット自体に何かしらのStateを持つと、スクロールして画面から消えると一緒に破棄されます。これを逆手に取ることもできますが、基本的にはProviderなどでうまく管理する必要があります。

また、これらの導線(アンテナ、リスト、クリップ、ハッシュタグ、チャンネルなど)も必要です。チャンネルは例外として、それ以外の導線となるリストは多くの情報を表示する必要がないため、ListTileなどを活用すればシンプルに実装することができます。

ワードミュート処理

ワードミュートはMisskey 2023.11.0以降ではクライアント側で実装する必要があります。

また、直近のリリースであるMisskey 2023.12.0以降ではハードミュートが「サーバー側でミュートする機能」から「クライアント側で何も表示しないミュート」としてやや仕様が変わって再び生まれ変わりました。

APIから取得したノートの一覧について、こうしたミュートを実装するには、api/iから予め設定されたミュートの情報を持っておくようにするのがよいです。

api/iに含まれる、Misskey公式のミュートでは正規表現を使用することができます。クライアントの処理としては正規表現のパースを何度も発生させないように、読み込み時にRegExクラスのインスタンスをあらかじめ生成しておき、ノートの取得時にはそのインスタンスを使用するといった工夫が必要になります。

api/iを用いず、自前にワードミュートの情報を持つことも当然できるでしょう。しかしながらワードミュートに関しては、Misskey.io9ineverseではその合計文字数や個数がサーバーに対する支援の特典になっていることもあるため、そうした点を尊重するのであればクライアントとして別個でワードミュートの情報を持たず、Misskey Webと同じミュート、すなわちapi/iで取得できるミュート情報を使用し、api/i/updateで更新するのがよいでしょう。

ユーザーの情報を実装する

ユーザーの情報は意外とたくさんの内容を表示する必要があり、「思っていたより難しい」点の一つです。

表示する内容が多く、また複雑な内容を描画する必要があります。

  • バナー画像
  • 名前、アイコン、スクリーンネーム、割当済みロール
  • 場所、登録日、誕生日
  • 追加情報の表
  • ノート数、フォロー、フォロワー数
  • ログイン済みユーザーが書いたそのユーザーに対するメモ

これらに加えてMisskey Webと同じようにするのであれば、ピン留め、ハイライト済みノートを下部に表示することになります。

これをColumnListView(shrinkWrap: true, physics: const NeverScrollableScrollPhysics())などで実装してしまうと、ユーザーのプロフィールやピン留めにMFMアートが含まれているときにパフォーマンスが大幅に悪化します。CustomScrollViewSliverListなどを駆使する必要があります。(Miriaがいずれ直さないといけないところのひとつです…)

タイムラインの自動更新を行う

タイムラインの表示はMiriaを作るにあたって正解を私が見つけられなかった機能のひとつです。おそらく、FlutterでMisskeyのサードパーティクライアントを(まともに)実装しようとしたときに最難関となる部分のひとつでした。

タイムラインでは、下記のような機能を実装します。

  • 初回表示時、今現在のタイムラインを取得して表示する(これは一般的なリストと同じ)
  • タイムラインの末端までスクロールしたとき、タイムラインの続きを取得して表示する(これは下方向条件付き無限スクロール)
  • WebSocketによって新着ノートを受信したとき、上方向にノートを追加する(上方向条件付き無限スクロール)
  • タイムラインの最上部にいるときのみ、WebSocketによって新着ノートを受信したときにノートの最上部に自動スクロールする

さらに、下記の制約があります。

  • ノートの本文やノートにつけられたリアクションのカスタム絵文字は外部のサーバーから送られてくることもあります。これらのカスタム絵文字は不等幅のため、ノート全体の高さがノートごとに可変であることはもちろん、ノートを表示したそのタイミングでは高さが確定せず、すべてのカスタム絵文字が読み込み終わったときにはじめて高さが確定します。
  • 注釈をタップしたり、ノートに対して新しいリアクションをしたときも、当然ノート全体の高さが変わります。

ほかのリストと比べてこれが難しい所以はいくつかありますが、

  • 一般的に無限スクロールとされるものは、片側に対する無限スクロールです。これの解法はいくつかあります。しかし、今回要求されている無限スクロールは上下方向です。
  • タイムラインはユーザーの操作によらず上方向に絶えず追加され続けます。さらにこのノートが上方向に追加される速度には少なくとも現在のMisskey.ioのLTLやGTLの約3倍(Xが落ちるなどしてオンラインユーザーが急増したときの流速、オンラインユーザー25,000人程度)を想定する必要があります。
  • タイムラインを上方向に自動スクロールする必要がありますが、自動スクロールが必要になった時点では高さが確定しないので、追加でスクロールが必要になる場合もあります。
  • タイムラインの最上部にいないときはスクロール位置をそのままの位置に留める必要があります。WebSocketで新しいノートを受信したときもそのままの位置でなければなりません。

Misskeyのカスタム絵文字が不等幅であるという点が、タイムラインを実装することを非常に難しくしています。おそらくTwitterやNostr、BlueSkyといったSNSの同様のタイムラインでは、このような考慮をする必要はないでしょう。

曲がりなりにも動いてるものの、Miriaの現状の動作も必ずしもよいともいえないのですが、一応Miriaにおける実装を解説します。

infinite_listviewをベースにしました。infinite_listviewは上下方向に無限のListViewを提供するものです。しかし、これをそのまま使うとリストがもう無いところにも追加されてしまうので、WidgetsBinding.instance.addPostFrameCallbackで下方向側のリストの最大幅を毎フレーム取得し、_InfiniteScrollPositionminScrollExtentにその最大幅の制約をつけるようにしました。上方向はタイムラインの自動更新のために上に向かう機構があるので、これが制約となっています。(たぶん… この箇所を実装したのはかなり前で、手探りで実装したのでうるおぼえです。)

リアクションピッカーの実装する

リアクションピッカー
リアクションピッカーの表示例

リアクションを行ったりノートの本文にカスタム絵文字を挿入するためには、リアクションピッカーを実装する必要があります。

Miriaでは、不等幅のカスタム絵文字を不等幅そのままの幅で配置しています。これもパフォーマンス的にはあまりよくないのですが、不等幅のカスタム絵文字をそのままの幅で配置することは(Miriaにおいては)必須の要件でしたので、あえてこのようにしています。

MiriaではWrapウィジェットを使い、各リアクションがフローで並ぶように表示しています。この場合、そのままの状態では同じカテゴリにある画像がカテゴリを開けた瞬間すべてリクエストされてしまいます。Misskey.ioなどサーバーによっては数百ものカスタム絵文字が同じカテゴリに存在するため、これがいっぺんにリクエストされることを防ぐ必要があります。

そこで、Miriaではリアクションピッカーをカテゴリで開いたとき、visibility_detectorを使用して表示されたものだけ画像の読み込みが発生するようにしました。遅延読み込みを自前で実装したということです。

https://github.com/shiosyakeyakini-info/miria/blob/ac69f7804f309a9fc6afb63c0d5c96aef741ee23/lib/view/reaction_picker_dialog/reaction_picker_content.dart#L144-L197

なお、不等幅のカスタム絵文字を不等幅そのままで表示することにこだわらない(Misskey Webのように縮小して表示する)のであれば、GridViewなどを使用することで遅延読み込みも行われますので、実装はもっと簡単になります。

ページを表示する

ページ機能を実装するにあたって、Misskeyにおける下記の仕様を理解する必要があります。

  • Misskey v12では高度なプログラミング機能、フォームなどを有する複雑な機能でした。
  • Misskey v13でページ機能の大部分はPlayに置き換えられました。
  • Misskey v13以降のMisskey Webでは、互換性のためにMisskey v12系で作成されたページが動作します。ただし新規にv12系にあったページの機能を使うことはできません。

サードパーティクライアント目線では、Misskey v12系から移行されたページもサポートするのかしないのかという選択をすることになります。

MiriaはMisskey v12系で作成されたページの機能は非対応としているので、私もこの点について知見を持ち合わせていません。Submarinえとねるんといった現在でもv12で動いているサーバーで作成されたページを表示するために、サードパーティクライアントとして実装するのであれば、Misskey v12のページ周りのソースを読んで理解する必要があるかと思います。

v13系で作成されるページに関してはそこまで難しい仕様をしておらず、テキストや画像、ノートを配置する、セクションでこれらが階層構造になっているという程度にとどまります。

MiriaのAPIラッパーであるmisskey_dartでは、これらのテキスト、画像といったオブジェクトをAbstractPageContentを継承するようにしています。(なんでsealed classにしなかったんだっけ…忘れてしまいました)

https://github.com/shiosyakeyakini-info/misskey_dart/blob/master/lib/src/data/base/page.dart

また、これらを表示する部分については、コンテンツを表示するウィジェットを作り、そのウィジェットを単純にColumnで表示しています。(ここもたぶんSliverのほうがいいかもしれませんね…)

セクションについても、コンテンツを表示するウィジェットを再帰的に作ることで実現しています。

https://github.com/shiosyakeyakini-info/miria/blob/ac69f7804f309a9fc6afb63c0d5c96aef741ee23/lib/view/misskey_page_page/misskey_page_page.dart#L64-L65

https://github.com/shiosyakeyakini-info/miria/blob/ac69f7804f309a9fc6afb63c0d5c96aef741ee23/lib/view/misskey_page_page/misskey_page_page.dart#L116-L220

Play、プラグインを実装する

Miriaも現状はPlayやプラグインの機能は実装しておらず、どのような課題が生じるかは半ば未知数です。ここでは、仮に実装するとしたらどのようなことが必要かを考察します。

まず第一に、AiScriptのパーサと処理系を自前で実装する必要があります。React NativeやNativeScriptといった、処理系が最終的にECMAScriptに準拠した環境であればこれらは必要ありません。Flutterで実装しようとすると、自前での実装が必要になります。

また、AiScriptは破壊的変更がMisskey本体以上に加わりやすく、直近ではletの再代入禁止が実装されたことは記憶に新しいかと思います。サードパーティクライアントとしてAiScriptを実装するのであれば、サーバー毎に動かすエンジンを変えるのか、Misskey本家やあるいはMisskey.ioに追従するのかといった方針をあらかじめ考えておく必要があります。

プラグインまでであれば、上記に気をつけながら各種UIをフックすることで実現できそうです。PlayになるとUI描画(フォームなど)もPlayの内容に応じて動的に作成できる環境が必要となります。

アカウント情報の保持を考える

少し話題を変えて、アカウントの情報の保持について考えてみます。

単一のアカウントだけを対象にしたアプリであれば、単純に簡単なProviderで保持するだけでよいでしょう。

また、複数のアカウントに対応したアプリでも、アカウントを明示的に切り替えることを必要とする仕様とするならば、どのアカウントを選択しているかという状態をStateProviderなどで管理するとよいでしょう。(ただしログイン済みアカウントを一覧で表示するような画面の実装は、気を使うことになる可能性があります)

Miriaの場合は、複数のアカウントをさまざまな場面で「ここはこのアカウントの領域」「こっちは別のアカウントの領域」といったケースが発生します。この場合、考えることが一気に増えます。

Scaffold
 - AppBar
   - Drawer
     - ListView
       [0] <- これより下のウィジェットはAのアカウント
       [1] <- これより下のウィジェットはBのアカウント
 - PageView
    [0] <- これより下のウィジェットはAのアカウント
    [1] <- これより下のウィジェットはBのアカウント
  ...

そして、カスタム絵文字やアバターアイコン、MFM中の各要素などこの下に配置されるさまざまなウィジェットが「何のアカウントの文脈でそのウィジェットが配置されているか」を知る必要があります。愚直にやると引数で渡すことになりますが、膨大な引数リレーが発生してしまいます。こういったケースではProviderを使っても解決する方法が思いつきませんでした。Provider.familyを使うにしてもキーが必要となり、そのキーが結局膨大な引数リレーになってしまいます。

そこで、画面間の遷移だけは引数で行い、それより下はInheritedWidgetを用いることでこれより下のウィジェットが引数リレーの必要なくアカウント情報を知ることができるようにしました。

https://github.com/shiosyakeyakini-info/miria/blob/ac69f7804f309a9fc6afb63c0d5c96aef741ee23/lib/view/common/account_scope.dart

このようなウィジェットを作成して、この文脈はこのアカウントであることを明示することで、

Scaffold
 - AppBar
   - Drawer
     - ListView
       [0]
         AccountScope(account: A)
	   - これより下のウィジェットはAccountScope.of(context)でAのアカウントが取得できる
       [1]
         AccountScope(account: B)
	   - これより下のウィジェットはAccountScope.of(context)でBのアカウントが取得できる
 - PageView
    [0]
      AccountScope(account: A)
    [1]
      AccountScope(account: B)
  ...

これより下のウィジェットがどのアカウントであるかわかるようになります。

ただし、いくつかの問題点もあります。

  • AccountScope.of(context)で取得できるかどうかは、ビルド時には分からず、実行時にしか分からない
    • 不具合の温床となります。実際これが原因の不具合がいくつかありました。 #129 #130 #478 #479
  • AccountScopeを配置するウィジェットそのものは、自分より上位のAccountScopeを見に行く(よく考えるとそれはそうですね)ため、そのウィジェット自体でアカウント情報を使うには引き渡された方のアカウントのオブジェクトを使わないといけません。このことが上記の不具合を起こしやすくもしています。

ローカルにMisskey開発用サーバーにつなぎに行けるようにする

これはまだMiriaも現状できていないことですが、ローカルにMisskeyの開発用サーバーを立てて、そこにもアクセスできるようにしておくとよいです。

アプリには下記のような機能を組み込む必要があります。

  • ログイン時にホスト名の他にポート番号を入力できるようにする
  • 開発用ビルドではHTTPも使えるようにしておく(AndroidManifest.xmlやInfo.plistなども修正が必要になります)

これが必要になる理由は主に下記によるものです。

  • Misskeyの更新が非常に速いので、それに追いつくために手元でdevelopブランチの最新のMisskeyを使えたほうがよいです。
  • Misskeyには多数のフォークが存在し、これらのフォークを考慮する必要がいずれ出てきます。こうしたフォークひとつひとつにクライアントアプリ開発者がアカウントを作成するのは現実的でないので、手元でそのフォークを動かすことができるとよいでしょう。

ローカルの開発環境の作り方は直近で変更が加えられています。私も結局いまどうなっているのか混乱していますが、とりあえずはmisskey本家のレポジトリのCONTRIBUTINGに記載がありますので、これを読むとよいでしょう。VSCodeとDockerが入っている環境であれば、devcontainerでコンテナを起動し、コンテナ内でpnpm install && pnpm devを行うと簡単に起動させることができます。

フォークのはなし

フォークについて少し触れたので、もう少し掘り下げてみます。Misskeyでは多くのフォークが存在し、各々のサーバー独自の機能が存在します。

マストドンの事情にはあまり私は明るくないので、fedibirdとkmyblue以外のフォークについては知らないのですが、Misskeyについてはとにかくたくさんのフォークが存在します。

一例として、下記のようなものが挙げられます。

  • 9inverseはTLに対して大幅な改修を行っており、名前も異なります。
  • Tanukey(ノベルスキーなど)は連合可能なチャンネルが存在します。
  • dream(ねむすぎーなど)はカスタム絵文字やロールの管理権限に大幅な変更を加えています。現在は機能が閉じられていますが、かつてリレーショナルタイムラインという独自のタイムラインが使われていました。
  • Ebisskey(シュリンピア帝国など)は数字引用といったサーバー独自の機能がよく使われています。
  • kakurega(隠れ家)ではファイル名のランダム化といったサーバー独自の機能が含まれます。
  • Misskey.ioではメディア投稿時に、BackspaceKeyでは注釈を含むノートの投稿時に注意事項が表示されます。
  • nadesskey(なですきー)ではローカルタイムラインに、ホームのみの投稿を含むオプションが存在します。
  • PrisMisskey、square(みすきーすくえあ)、dreamなどでは、$[mix]や数式構文の復活といった形でMFMの構文が拡張されています。

こうした大小さまざまなサーバー独自の機能が、作るMisskeyのサードパーティクライアントに対してどういった挙動を示すのかについて関心を払うとよいでしょう。

たとえば、Tanukeyにおける連合可能なチャンネルでは、チャンネルへのノートを明示して連合しないとして送らないと、意図せずチャンネルのノートを連合させてしまうことがあります(#261)。

こうしたサーバー独自の機能も考慮していくのかしないのか、それはどこまでするのかといった点はサードパーティクライアントによって方針も異なるでしょう。

さいごに

Misskeyのサードパーティクライアントを作る上で落とし穴になりうるところなどを何点か書いてみました。これらが今後Misskeyのサードパーティクライアントを作ろうとしている人の助けになれば幸いです。

また、Misskeyのみならず、ほかのSNSのクライアントをFlutterで作る場合にも参考になればと思います。

脚注
  1. つい直近のMisskeyの仕様変更でアバターデコレーションの位置を変更できるようになったため、修正が必要です ↩︎

  2. 実際には、リアクションの情報に現状どのソフトウェアなのかといった情報が無いため、相手先のサーバーにメディアプロキシがあるソフトウェアなのかどうかは不確かです。Miriaでは、リモートからのカスタム絵文字が404の場合のみ、相手がfedibirdであるなど関係なくメディアプロキシにフォールバックするようにしています。 ↩︎

Discussion