graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

PySide でエクスプローラー系モジュールを実装してみた。

この記事の続編みたいなもの。
graphics.hatenablog.com

基本設計

 モジュールの性質上それなりに多くの子オブジェクトを扱うことになる ので、QTreeWidget や QListWidget ではなく、View/Model で実装した上でそれらをラップする Widget をつくることにする。その上で、Widget が持つ View と Model を公開して、必要に応じたカスタムができるように。

 基本的には、Item/View/Model/Widget を DirectoryTree と FileList の両方に実装していく流れにした。設計自体はとてもシンプルになっているかなと。

f:id:hal1932:20200430072344p:plain
全体のクラス図

ViewModel なウィジェットのベースクラス

短いからコード全部載せる。

class _ViewModelWidgetBase(QWidget):
    def __init__(self, parent, defaultViewType, defaultModelType):
        # type: (QObject, type, type) -> NoReturn
        super(_ViewModelWidgetBase, self).__init__(parent)
        self.__defaultViewType = defaultViewType
        self.__defaultModelType = defaultModelType
        self._view = self.viewType()(self)  # type: QAbstractItemView
        self._view.setModel(self.modelType()(self))

    def viewType(self):
        # type: () -> type
        return self.__defaultViewType

    def modelType(self):
        # type: () -> type
        return self.__defaultModelType

    def view(self):
        # type: () -> QAbstractItemView
        return self._view

    def model(self):
        # type: () -> QAbstractItemModel
        return self.view().model()

    def _sourceModel(self):
        # type: () -> QAbstractItemModel()
        model = self.model()
        if isinstance(model, QAbstractProxyModel):
            model = model.sourceModel()
        return model

 今回つくるのは「ツール」ではなく「モジュール」*1なので、それなりに 細かいとこまでカスタムできるように しなきゃいけない。となるとどうしても View と Model を露出する必要が出てくるので、そのための共通実装がこれ。たとえば View の場合だと子クラスからは self.view() と self._view の両方が使えるんだけど、まぁ好きなほうで。でも self._model は定義してないから、とりあえず self.view() と self.model() を使うのが無難かな。*2

 コンストラクタの引数がちょっとややこしい。これは、子クラスが指定した View/Model の型を、その外部から更に指定しなおす状況を想定してる。コードで書くとだいたいこんなかんじ。

# ここからライブラリ
class CommonView(QAbstractItemView):
    ...

class CommonModel(QStandardItemModel):
    ...

class CommonWidget(_ViewModelWidgetBase):
    def __init__(self, parent):
        super().__init__(CommonView, CommonModel)
# ここまでライブラリ

# ここからツール実装
class UserModel(CommonModel):
    def __init__(self, parent):
        super().__init__(parent)
    ...

class UserWidget(CommonWidget):
    def modelType(self):
        return UserModel
# ここまでツール実装

 こうしておくと、ツール実装者は CommonView/CommonModel が実装済みの CommonWidget をそのまま利用することもできるし、View や Model の一部だけを書き換えて利用することも比較的手軽にできるようになる。ぱっと見では分かりにくいから、会社でこういうことやる場合はドキュメントが必須だろうけど。*3

 View/Model それぞれの型を TypeVar にするかどうかはちょっと悩んだけど、まぁいいや、そのうちまた考える。

DirectoryTree

 ディレクトリ構造をツリー状に表示するための UI コントロール

f:id:hal1932:20200429144400p:plain

 そんなにトリッキーなこともなく、基本に忠実に QTreeView と QStandardItemModel を継承しつつ実装してみた。あえていうなら「self.data() の実装が Qt.DecorationRole を想定していない」ってあたり。なのでこれをそのまま使うとアイコンが一切表示されない。

f:id:hal1932:20200429150849p:plain
アイコンが表示されていない

 このあたりは価値観レイヤーの話にもなってくるのだけど、個人的には、アイコンみたいに「抽象的な視覚情報をユーザーに与えうるもの」ってのは、その GUI が利用される文化圏 で定義されるべきものだと思ってる。なので、今回つくったみたいな「ツールをつくるためのモジュール」のレイヤーでは定義すべきではないかなという判断。たとえば「フォルダアイコン」ひとつとっても これだけのバリエーション が想定されてしまうわけで。*4

 まぁとはいえアイコンくらいはふつうにみんな欲しいはずなので、Qt 標準アイコンを使う実装 をサンプルに付けておいた。なんだかんだいってユースケースの大半はこれで賄われることになるんだろうなとは思ってる。

FileList

 ファイルをリストやアイコンで表示するための UI コントロール

f:id:hal1932:20200429154714p:plain

 こっちも基本的な発想は DirectoryTree とほぼ同じだけど、アイコンとか、場合によってはサムネイルとかあるからもうちょっとだけややこしい。

 Model に関しては以下の CommonItemModel というのを継承した FileModel を使っていて、View はわりと素直に QListView を継承してる。CommonItemModel 自体は、QStandardItemModel にちょっとした便利機能をつけただけのラッパーみたいなもの。複数アイテムの一括追加とか GUI のリフレッシュ周りは Qt のインターフェイスだとちょっと分かりにくいような気がしてる。

TCommonItem = TypeVar('TListItem', bound=QStandardItemModel)

class CommonItemModel(QStandardItemModel, Generic[TCommonItem]):
    def __init__(self, parent):
        # type: (QObject) -> NoReturn
        super(QCommonItemModel, self).__init__(parent)

    def replaceRows(self, items):
        # type: (List[TListItem]) -> NoReturn
        self.clear()
        self.appendColumn(items)

    def refresh(self):
        # type: () -> NoReturn
        self.dataChanged.emit(self.index(0, 0), self.index(self.rowCount(), self.columnCount()))

    def isIndexValid(self, index):
        # type: (QModelIndex) -> bool
        if not index.isValid():
            return False
        if not 0 <= index.row() < self.rowCount():
            return False
        if not 0 <= index.column() < self.columnCount():
            return False
        return True

 View に関してはただ QListView を継承してるだけなんだけど、QListView はイベント系が全部関数になっててオーバーライドしないと使えないから、とりあえず必要そうなのだけ Signal 化してある。*5 Model 側もこれといった拡張は特になし。

 まぁあえていうなら、View/Model を実装してからそれらを使う Widget を実装するんじゃないくて、まず Widget を実装してからそれに必要な I/F を View/Model に揃えていくほうが、このくらいの規模のものには向いてるのかも。というか、そもそもトップダウンとかボトムアップの真面目な検討が必要な規模の開発案件だったらいきなりコード書いたりはしないだろうし。今回のこの程度ならトップダウンでコード書いてくほうが一度に考えることが少なくなるような気がしてる。ただし異論は認める。

ファイルアイコン

 地味に面倒なのがここ。ファイルに応じた QIcon をつくるだけなら QFileIconProvider.icon() を叩くだけでいいんだけど、数 100 ファイルとかのアイコンを一気に処理すると結構な重さになる。ディレクトリ切り替えるたびに WaitCursor が何秒もぐるぐるするのはさすがに避けたい、ので、並列化する。ドキュメントによると A QIcon can generate smaller, larger, active, and disabled pixmaps from the set of pixmaps it is given. とのことなので QPixmap みたいに生成をサブスレッド化できないのかな?と思いきや、ふつうにできてしまうので問題なし。

 というわけで、並列化しつつ LRU キャッシュもつけたのが これ。基本的には、reset/append/extend を使ってファイルパスを追加していって、追加しおわったら loadAsync を呼ぶ。1 枚のロードがおわるごとに loaded シグナルが、全部おわったら completed シグナルが emit される仕組み。

QIcon の非同期バッチ生成

 自前で Producer-Consumer を実装してもまぁいいっちゃいいんだけど、PySide なら Qt が代替してくれる。このあたりの記事 でも書いたけど、基本的には multiprocessing.pool.ThreadPool と Signal.emit を使えばいい。*6

 QIcon 自体はメインスレッド以外でも生成できるんだけど、生成済みの QIcon をそのまま非メインスレッドで扱えるかどうかは状況による。というか GUI が絡むとだいたい無理。で、PySide(というか Qt)の場合、Signal がどのスレッドから emit された場合でも、Slot に connect された関数はメインスレッドで呼ばれることになる。

class Emitter(QObject):
    signal = Signal()

    def __init__(self):
        super().__init__(parent=None)
    
    def start(self):
        def _thread_func():
            self.signal.emit()  # 非メインスレッドから emit
        th = threading.Thread(target=_thread_func)
        th.start()

class Widget(QObject):
    def __init__(self):
        super().__init__(parent=None)
        em = Emitter()
        em.signal.connect(self.__receive)
    
    def __receive(self):
        ...  # メインスレッドで実行される

 なので、基本的には loaded みたいな Signal をロードスレッドで emit しておいて、ロードした QIcon を 使う側がそこに connect する のが自然な流れになりやすい。ただ、場合によってはロードスレッドの処理の流れの上でそのままコールバックが欲しいこともあるので、モジュールとしては そのままコールバックしてくれるインターフェイス を用意しておくと親切かもしれない。*7

サムネイルビュー

 FileList は View 側が QListView を継承してる ので、setViewMode(QListView.IconMode) すればそのままサムネ表示っぽくなる。あとは View 自体も露出もしてるので、setIconSize とかはツール実装側でやりやすいようにやればいいかなと。

class FileList(FileListWidget):
    def __init__(self):
        super().__init__(parent=None)
        view = self.view()
        view.setViewMode(QListView.IconMode)
        view.setIconSize(QSize(100, 100))

 ただいわゆる「サムネイル」表示をやりたい場合は如何に画像をロードするか?って話がどうしてもついてまわるので、そのためのツールキット は別途用意することにした。やってることはただ画像をロードしてるだけなんだけど、数 10 個とか、場合によっては数 100 個以上の画像をメインスレッドで同期ロードするのはさすがにアレなので。

 とはいえ実装と使い方は QIcon のときとあまり変わらないので、とりあえず使い方だけ挙げておく。

loader = BatchImageLoader()

# QImage を生成したら
# 同じスレッドでその QImage を縮小する
loader.addCallback(ImageLoadingCallback.LOADED, lambda img: img.scaled(100, 100))

# QImage を生成して縮小まで終わったら
# メインスレッドで _on_load_image を呼び出す
loader.loaded.connect(_on_load_image)

# すべての QImage の生成と縮小が終わったら
# メインスレッドで _on_load_complete を呼び出す
loader.completed.connect(_on_load_complete)

# ロードしたい画像のファイルパスを追加
for filePath in glob.iglob('C:/tmp/test_images/*.png'):
    loader.addFile(filePath)

# 非同期ロード開始
loader.loadAsync()

 ImageLoadingCallback ってのが上で挙げた(メインスレッドに処理を戻さずに)"そのままコールバックしてくれるインターフェイス" になってて、ここに登録した処理はロードスレッドでそのまま実行される。ロードスレッド内の処理はだいたい以下のとおり。

  1. 画像をロードして QImage にする
  2. ImageLoadingCallback.LOADED に登録された処理に 1 を渡す
  3. ImageLoader.loaded.emit
  4. すべての画像をロードし終わるまで 1-3 を繰り返し
  5. ImageLoadingCallback.COMPLETED に登録された処理を実行する
  6. ImageLoader.completed.emit

 もしかしたら、画像の縮小もメインスレッドでやればいいじゃないかって話があるかもしれない。まぁなんというか、ツール実装者がそう考えるのであれば、そうすればいい。ただ今回は「サムネ用画像のロード」を意図しているので、てことは、サムネとして利用できる状態にある、つまり適切に縮小された QImage を返すところまでを、ローダー側の責任範囲にしたかった。

 addCallback() した処理はローダーが作成したスレッドから呼び出されているので、基本的には、ツール実装から介入できる場所ではない。概念的な話ではあるんだけど、"処理の責任範囲" ってのを考えるんであれば、その処理が実際にどこのスレッドで実行されているかはひとつの目安にしてもいいのかなと思ってるところ。*8

 あとはまぁ、単純にメインスレッド上で行われる処理を少しでも減らしたかった。だってメインスレッドで処理が走ってる間は GUI 含めてツール全体がフリーズしてるわけで、その時間は少しでも短くすべき。Python で処理速度気にしても仕方ないってのもあるにはあるんだけど、ただどんな言語を使ったところで、"大量の画像サムネを一括表示する GUI ツール" のボトルネック箇所なんてだいたい同じ。どこまでいっても "ファイル I/O" と "GUI 更新" なわけで。C++/Qt や C#/WPF で書いていてもそれはあまり変わらない。*9

 逆にいえば、Python でもちゃんと書けばちゃんと速くなる し、GUI 系にはそういう話題が意外と多い。だったらちゃんと書こう。

*1:今回の実装は、View/Model からアイコンまで一事が万事その発想でつくられてる。

*2:self._model は別に持たせてもよかったんだけど、Python で protected の概念ってあんまり広まってないし、あえてフィールドを増やしてまでやることじゃないかなと。

*3:まぁ今回はこの記事がドキュメント代わりってことで……。

*4:PySide で書かれているんだから "Qt 標準のアイコン" であるべきかってのも考えはしたけど、そもそもそんな実装都合の事柄なんてユーザーには関係ないよねと。組織文化として "Qt の GUI" に馴染んでる場所は多々あるだろうけど、それはあくまで結果的に Qt っぽくなった "GUI" に馴染んでいるのであって、"実装" に馴染んでいるわけではなかろ。

*5:itemClicked はちょっと微妙だった気がするから消すかもしれないけど。

*6:PySide かつ Signal が使えない状況なら、仕方ないから 自前でイベントを投げる

*7:でも今回はやってない。アイコン使うのはだいたい GUI 絡みだし。

*8:もちろんクラス設計がどうとかって話もたくさんあるし、明確な正解ってのは特になさげ。

*9:というかそもそも、これ系の処理の根っこは、ふつうのフレームワークならネイティブ実装された DLL の中身を呼ぶためのラッパーになってる。なので Invoke のコストはあれど結局は C と OS の領域。