RecycleView攻略 2日目 chat viewerを作る

今回はRVを使って以下のようなchat viewerを作ってみます。
![Screenshot at 2020-12-10 16-31-24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144673/5862203d-2b23-23dc-6cfe-3a03004deccb.png)
[前回][rv_day1]作ったものとは違って`view widget`の大きさはまちまちです(文章量に合わせて`view widget`の大きさを変えないといけない)。実はこのような物を普通に作ると`view widget`の大きさがズレてしまう事があって困っていたのですが、最近その対策に気付いたので今回取り上げました。

それでは実際に作っていきます。

# viewclassを実装

ここら辺は文字列を自動で折り返したい時のよくあるやり方になっています。

```yaml
<ChatMsg@Label>:
    # size_hint: 1, None
    height: self.texture_size[1]
    text_size: self.width, None
    canvas.before:
        Color:
            rgba: 1, 1, 1, .4
        Line:
            points: [self.x, self.y, self.right, self.y, ]
```

- `size_hint`の部分がcommentとなっているのはどうせRV側で`default_size_hint`を指定しないといけないため冗長だからです。
- canvasの部分は上の画像で見えている横線です。

# RV側を実装

```yaml
<ChatViewer>:
    orientation: 'vertical'
    RecycleView:
        id: rv
        viewclass: 'ChatMsg'
        always_overscroll: False
        RecycleBoxLayout:
            size_hint: 1, None
            height: self.minimum_height
            default_size_hint: 1, None
            spacing: 5
            padding: 5
            orientation: 'vertical'
```

```python
import itertools
from collections import defaultdict
from kivy.uix.boxlayout import BoxLayout
from kivy.utils import escape_markup

class ChatViewer(BoxLayout):
    COLORS = \
        '#00FF00 #FF00FF #008888 #44AA44 #808080 #FF7722 #6699FF'.split()

    def on_kv_post(self, *args, **kwargs):
        super().on_kv_post(*args, **kwargs)
        self._rv = self.ids.rv.__self__  # rvを何度も参照する事になると思うので直接参照を確保しておく
        self._user_color_map = defaultdict(
            lambda color_iter=itertools.cycle(self.COLORS): next(color_iter))

    def add_msg(self, *, msg:str, who:str):
        self._rv.data.append({
            'markup': True,
            'text': "[b][color={}]{}:[/color][/b] {}".format(
                self._user_color_map[who],
                who, escape_markup(msg),
            ),
        })
```

- `on_kv_post`はwidgetの初期化が終わった直後に行いたい処理を書くのに適した場所です(`__init__()`内で`super().__init__()`を呼んだ直後だと初期化が完了している保証が無い)。
- `_user_color_map`は発言者の名前に付ける色を取り出すための辞書です(鍵が発言者の名前で、値が色)。
- `add_msg()`は発言があったことを`ChatViewer`に伝えるmethodです。
- `ChatViewer`の基底classをRVではなく`BoxLayout`としたのは後で`Button`なり`TextInput`なりを置きたくなるだろうなという想定です。
- `always_overscroll`は`ScrollView`に加えられた新しい機能なのですが、**不具合が多い**ので常に無効(`False`)にしておくのがお薦めです(RVは`ScrollView`の派生class)。
- `escape_markup()`は発言内容にmarkup言語の制御文字(`[`, `]`, `&`)が含まれていた場合に制御文字として扱われないようにするために必要です。

# 実際に使ってみる

できたと思うので実際に動作を確かめてみます。chatなので本来はserverを作って接続なり認証なりをするものだと思いますが、それはRVとは全く関係無いので省き、代わりにrandomな発言を作って定期的に`add_msg()`を呼ぶ事にしました。

```python
def _test():
    import random
    from kivy.clock import Clock
    from kivy.app import App

    user_names = 'Alice Bob Chris David Elena Frank Georg Helen Ivy Jeff'.split()
    msgs = (
        "Hello", "What's up?", "All your base are belong to us",
        "Python is a programming language that lets you work quickly "
        "and integrate systems more effectively. Learn More <<<",
        "PEP 492 introduced support for native coroutines and async/await "
        "syntax to Python 3.5. It is proposed here to extend Python's "
        "asynchronous capabilities by adding support for asynchronous "
        "generators.",
        "special characters : [ & ]",
    )
    
    class SampleApp(App):
        def build(self):
            return ChatViewer()
        def on_start(self):
            # 0.1秒毎にadd_random_msg()を呼ぶ
            clock_event = Clock.schedule_interval(self.add_random_msg, .1)
            # 10秒経ったら呼ぶのを止める
            Clock.schedule_once(lambda __: clock_event.cancel(), 10)
        def add_random_msg(self, __):
            self.root.add_msg(
                msg=random.choice(msgs), who=random.choice(user_names))
    SampleApp().run()


if __name__ == '__main__':
    _test()
```

この様にあらかじめ用意しておいた名前と発言内容を無作為に組み合わせて発言を作るようにしました。やりたい事はGUI部分のtestなのでこれで十分でしょう。

# view widgetの大きさの崩れ

実行するとどうなったかというと
![Screenshot at 2020-12-11 14-45-33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144673/5b935d13-2d74-0bd4-f5f9-f3676010aca0.png)
という風に時折`view widget`の高さが崩れてしまいました。しかもこれはwindowの大きさを変えたりscrollしたりする事で正しく調整される事もあれば、逆にまた崩れてしまう事もあります。私の経験上こういった症状はやっかいです。何故ならこういったものはpropertyの評価順序やlayoutのtimingのような内部実装に強く依存している可能性が高いからです。なので諦めていたのですが...

# 対策

幸いにもGitHubで[似たような問題][issue7197]を訴えている人が居て、そこでRVの実装者であるmatham氏がかなり詳しく原因と対策を説明してくれていました。そしてそれを参考に以下の行を加える事で崩れを直す事ができました。

```yaml
<ChatViewer>:
    RecycleView:
        RecycleBoxLayout:
            default_size: None, None  # <= この行を加える
```

この`default_size`の初期値は`(100, 100)`になっていて、これが上の画像で`view widget`の高さを時々100pixelにしてしまう原因でした。今回のように`view widget`の

- 幅を`default_size_hint: 1, None`で
- 高さを`height: texture_size[1]`で

示している場合、つまり`view widget`の大きさの算出に必要な物を`default_size`とは別の所で全て示している場合は`default_size`は無効(`None, None`)の方が良いのかもしれません。

# おまけ

最後に少し雰囲気を出すために上部にbuttonを加えました。

```yaml
<ChatViewer>:
    orientation: 'vertical'
    BoxLayout:
        size_hint_y: None
        height: self.minimum_height
        padding: 5
        spacing: 5
        canvas.before:
            Color:
                rgb: .1, .1, .1
            Rectangle:
                pos: self.pos
                size: self.size
        Widget:
        Button:
            text: 'clear'
            size_hint_x: None
            width: self.texture_size[0] + 10
            size_hint_min_y: self.texture_size[1] + 10
            on_press: rv.data = []  # A
    RecycleView:
        # 略
```

![Screenshot at 2020-12-11 13-54-27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144673/03401c67-5a4f-0404-1532-6dabf58b7c86.png)
この"clear"buttonを押すことでそれまでに溜まった発言を全て消せます。A行は`on_press: rv.data.clear()`でも良さそうに見えますがそれだとうまくいきませんでした。はじめはその理由を`data`に変更があった事をRVが感知できないため[^1]だと思っていましたが、もしそうであれば

```yaml
            on_press:
                rv.data.clear()
                rv.refresh_from_data()  # dataに変更があった事を明示的に伝える
```

で動くはずです。でもこれもうまくいかなかったので理由は分からずじまいです。ともかく`rv.data`を初期化したい場合はA行のやり方を採る事にします。

# まとめ

というわけで`view widget`の大きさが揃っていない時も`default_size`を正しく設定してあげる事が鍵でした。

# 追記(20220318)

このChatViewerには幾つか問題が残っているので直しておきます。ひとつは実際にコードを実行するとわかるのですがchatの更新頻度が高いと画面が変わりっぱなしでとても読みづらい事です。なので更新頻度を抑える工夫をします。

## 画面の更新頻度を抑える

まずは`add_msg()`内で直ちにRVを更新する事はせず、いったんlistに蓄えます。

```python
    def on_kv_post(self, *args, **kwargs):
        ...
        self._queued_msgs = []  # ここに蓄える

    def add_msg(self, *, msg:str, who:str):
        self._queued_msgs.append((msg, who, ))
```

そして1秒後にRVを更新するよう予約します。

```python
    def on_kv_post(self, *args, **kwargs):
        ...
        self._queued_msgs = []
        self._trigger_update = Clock.create_trigger(self._update, 1)

    def add_msg(self, *, msg:str, who:str):
        self._queued_msgs.append((msg, who, ))
        self._trigger_update()  # 一秒後に _update() が呼ばれるよう予約

    def _update(self, dt):
        self._rv.data.extend(  # 溜まったmsgを一気に加える
            {
                'markup': True,
                'text': "[b][color={}]{}:[/color][/b] {}".format(
                    self._user_color_map[who],
                    who, escape_markup(msg),
                ),
            } for msg, who in self._queued_msgs
        )
        self._queued_msgs.clear()  # 加え終えたのでこっちは削除
```

これだけだと単に全体的に更新されるタイミングが一秒遅れるだけで頻度は下がらないように思えますが違います。`_trigger_update`に入っているのは`ClockEvent`という物なのですが、これの特徴として **既に予約されているときに次の予約が来ても無視する** というのがあります。なのでいくら頻りに`add_msg()`が呼ばれても`_update()`は最大で一秒に一回までしか呼ばれません。

## 安全性に関する心配

外部からやってきた文字列に対する検問が`escape_markup()`だけなのが気になるので念のために`str.format()`を`string.Template`に代えます。理由は`string.Template`が`str.format()`や`f文字列`とは違って単純な文字列の置き換えしか出来ないぶん安全だと何かの文献で見かけたからです。今回の場合は書式指定文字列自体は直書きで、外部から来た文字列は引数でしかないので無意味かもしれませんが一応。

```python
from string import Template

    def _update(self, dt, *, _template=Template(r"[b][color=${color}]${who}:[/color][/b] ${msg}")):
        self._rv.data.extend(
            {
                'markup': True,
                'text': _template.substitute(color=self._user_color_map[who], who=who, msg=escape_markup(msg)),
            } for msg, who in self._queued_msgs
        )
        self._queued_msgs.clear()
```

[source code](https://gist.github.com/gottadiveintopython/6fe590467cd62391084dce650097e757)

# 追記(20241203)

どうやら `default_size` を `None, None` にする事で[問題](https://ja.stackoverflow.com/q/100232/71318)が起こる事もあるようです。


[^1]: `ObservableList`の[実装][observablelist]を見て分かる通り`clear()`は上書きされていない

[rv_day1]:https://qiita.com/gotta_dive_into_python/items/486a79c8cbfb83e2f143
[observablelist]:https://github.com/nuigroup/kivy/blob/024fe8d7745dc881c54ccd39defc82eeb0153b2a/kivy/properties.pyx#L504
[youtube_izanai_label]:https://www.youtube.com/watch?v=rF0otl414ck
[issue7197]:https://github.com/kivy/kivy/issues/7197
[markup]:https://kivy.org/doc/master/api-kivy.core.text.markup.html