この記事は Mastodon Advent Calendar 2018 の10日目です.
普段はMastodonのデスクトップ向けクライアントWhalebird を開発しています.
あと,Pleromaのインスタンスを運用してます.全然人が少ないし特に何も運営らしきことはしていないんだけど.
というわけで今日はマストドンクライアントの話です. Whalebirdの実装の中でも,結構重かった(いろんな意味で)部分の話をします.
フロントは全部自分で書く必要がある
WhalebirdはElectronベースのMastodonクライアントですが,ただWebページを表示するだけのガワアプリではなく,フロントを全部自前実装してAPIを叩いているアプリです. そのため,前提として,Mastodon本体のフロントエンドの資産はほぼ使えません.
また基本的にAPIが用意されていない機能も使うことができません. そのため,これから書くような「Webの再実装に近いんだけど,全部自分で作らないといけない」状態になってます.
サジェスト
Whalebirdには
- アカウント名
- 絵文字
- ハッシュタグ
のサジェスト機能があります.
これらのサジェストを実装していく上で,考慮点がいくつかありました.
こういうのに迷ったときにはMastodonのWebを参考にするんですが,Webでは
という実装でした. というわけで,Whalebirdでも検索は都度APIを叩くことにしました. ただ,流石にサードパーティー製のアプリケーションで,APIコールしまくるのはどうかと思ったので.キー入力イベントは間引きならがAPIを叩いています.
そのため,基本的にここの部分の速さはインターネットの速度によって制限されます.まぁPC向けなのでそこまで遅い環境で使われることは想定しなくていいかなと思ってます.
キー入力イベントを間引いたとしても,キー入力のたびに
- 入力文字列のパーサ
- パースされた結果に応じて検索APIの呼び出し
という処理が裏で走るので,ここはかなりCPUを食う場所です.
2.5.0あたりでやたら入力がもっさりしたバージョンをリリースしましたが,あれの原因はほぼこいつらでした.
あそこで,汚かった実装をいろいろとリファクタリングして,現状はこんな感じ.
const textAtCursorMatch = (str, cursorPosition, separators = ['@', '#', ':']) => { let word let left = str.slice(0, cursorPosition).search(/\S+$/) let right = str.slice(cursorPosition).search(/\s/) if (right < 0) { word = str.slice(left) } else { word = str.slice(left, right + cursorPosition) } if (!word || word.trim().length < 3 || separators.indexOf(word[0]) === -1) { return [null, null] } word = word.trim().toLowerCase() if (word.length > 0) { return [left + 1, word] } else { return [null, null] } } export default textAtCursorMatch
ここは本家のソースをだいぶ参考にしました.
で,現状は普通に使っててストレスないくらいにはなっていますが,やはり多少CPUは使いますね…….
絵文字
もう一つ2.5.0で重くなった要因があります. それが,EmojiMartの絵文字パレットの追加です.
これはMastodon本家に合わせて,EmojiMartのvue版を使っていました.
ただ,こいつのロードになかなか時間がかかるため, v-show
で制御していました.これが重くなる原因で,この重さのコンポーネントを常に裏側に用意しておくと,またメモリを500MB以上食うようになりました.
というわけで最近こいつは v-if
で制御するようにしています.
そうすると絵文字パレットの表示には時間がかかるんですけどね…….
パーサ
Whalebirdのトゥートにはパーサが仕込まれています. これは,クリック時に以下の判定を行い,それぞれの動作に遷移させるために必要な処理です.
- クリックされた文字列がアカウント名であれば,アカウントの詳細ページを開く
- クリックされた文字列がハッシュタグなら,ハッシュタグタイムラインを開く
- クリックされた文字列が上記に該当しないリンクであれば,OSのデフォルトブラウザで開く
また,これに関してはweb側の実装が一切参考にできません(Reactなので,おそらくパースなんかしなくてもRouterさえちゃんと設定してあれば画面遷移できるので).
というわけで,
export function findAccount (target, parentClass = 'toot') { if (target.getAttribute('class') && target.getAttribute('class').includes('u-url')) { return parseMastodonAccount(target.href) } // In Pleroma, link does not have class. // So we have to check URL. if (target.href && target.href.match(/^https:\/\/[a-zA-Z0-9-.]+\/@[a-zA-Z0-9-_.]+/)) { return parseMastodonAccount(target.href) } // Toot URL of Pleroma does not contain @. if (target.href && target.href.match(/^https:\/\/[a-zA-Z0-9-.]+\/users\/[a-zA-Z0-9-_.]+/)) { return parsePleromaAccount(target.href) } if (target.parentNode === undefined || target.parentNode === null) { return null } if (target.parentNode.getAttribute('class') === parentClass) { return null } return findAccount(target.parentNode, parentClass) } export function parseMastodonAccount (accountURL) { const res = accountURL.match(/^https:\/\/([a-zA-Z0-9-.]+)\/(@[a-zA-Z0-9-_.]+)/) const domainName = res[1] const accountName = res[2] return { username: accountName, acct: `${accountName}@${domainName}` } } export function parsePleromaAccount (accountURL) { const res = accountURL.match(/^https:\/\/([a-zA-Z0-9-.]+)\/users\/([a-zA-Z0-9-_.]+)/) const domainName = res[1] const accountName = res[2] return { username: `@${accountName}`, acct: `@${accountName}@${domainName}` } }
こういうややこしいパーサをいくつか書いています.
リンクを外部ブラウザで開くだけなら,そこまで苦労するものでもないんですが,その手前にハッシュタグとアカウントのパーサを挟もうと思うと,これしかありませんでした.
こいつが重い要因は,要素の親を再帰的に辿るからです. これには理由があって,MastodonのAPIが返してくるstatusはhtml構造となっており,
<p> <span> <a href="https://social.mikutter.hachune.net/@h3_poteto"> @<span>h3_poteto</span> </a> </span> hogehoge </p>
こんな感じで,必ずしもクリックした要素が<a>
タグになっているわけではないからです.
また,MastodonとPleromaでこのhtml構造が微妙に違う場合があって,それらの差異もこのパーサですべて吸収しています.
まとめ
めちゃくちゃフロントの話になってしまいましたが,そのくらいバックエンドでボトルネックになるところは少ないです. 今のところMastodonのAPIはよくできているので,裏側でストリーミング更新したりする部分は,ほとんど重くならずに実装できています.
実は最近の更新で,裏でストリーミングするタイムラインを選べるようになっているんだけど,そこでストリーミング数を増やしても,メモリ消費的にはそこまで上がりません. CPUは少し食うかもしれない,それはWebSocketなので仕方がないのだけれど…….
というわけで,実装的にも動作的にも重い部分を紹介しました. この辺はできればもっとスマートに高速化していきたいとは思っているので,今後にご期待ください.