Tosainu Lab

[ネタ] QWebViewでGUIアプリケーションを作る

CocoaTwit

以前から何度かつぶやいていた自作Twitterクライアント, CocoaTwit-QWebViewを公開しました.
img

A Simple and Cute Twitter Clientをコンセプトに, シンプルなデザインかつ細かな拡張のしやすいTwitterクライアントを目指しています.
また, 僕が今まで使ってきたTwitterクライアントの手が届かなかったところを改善して実装していこうと思っています.
ちなみにCocoaTwitという名前に特別な意味は無いです. ココアさんかわいい.

バイナリ配布は今のところ予定していませんが, 気になるLinux使いの方はビルドしてみてください.
(Macでもイケると思ったけど, twitppがApple Clangでビルドできなかった)

この記事で紹介する手法はネタです

正月くらいからこのCocoaTwitをタイトル通りの手法で開発していました.
しかし, もっとよさ気な方法を見つけたため設計を大幅変更することにしました1.

この時点でQWebView版CocoaTwitも本記事もある程度出来上がっており, 消してしまうのがもったいなかったためネタ記事として公開することにしました.
**こんなブッ飛んだこともできるんだな〜**って感じで読んでもらえると良いかと思います.

C++でGUI is つらい

昨年からtwitppを書いていますが, これはC++でTwitterクライアントが作りたかったためです.
それに伴い以前からよさ気なGUIライブラリを探していたのですが, なかなか良いものが見つかりません.

C++には有名なQtをはじめwxWidgetsFLTK, gtkmmなどのGUIライブラリが存在2しますが, これらの中から

  • クセの少なく
  • マクロを多用していなくて
  • 他のC++コードとイイ感じに組み合わせられる

ものに絞っていくと, うーん…
悩んだ末, 最近よく耳にしていて個人的に使ってみたかったライブラリでもあったQt5を使うことにしました.

Qtの微妙だった点

STLと非互換なライブラリ

Qtは独自の文字列型であるQStringをはじめQVectorQListなどのSTLに代わるクラスを提供しており, そしてGUI周りのクラスがそれらに依存しています.
そのため, 今回のような非Qtなライブラリと組み合わせて使おうとすると独自型とのキャストが多発し, あまり気分の良いものではありません.

CocoaTwitでは特にstd::stringQStringの相互変換が多発しました.
僕はこんな感じに対処しましたが, もっと良い物があれば教えてもらえると嬉しいです.

// std::string -> QString
std::string foo("foo");
QString bar = QString::fromStdString(foo);

// QString -> std::string
QString hoge = "hoge";
std::string fuga(hoge.toUtf8().constData());

マクロの多用

Qtの代表的な機能であるSignal/Slotなどの実現には魔術(マクロ等)が多用されています.
別にただ単にマクロが使われている分には良いのですが, 実際に副作用的なものに遭遇したため許せませんでした.

特にQ_OBJECTマクロが曲者です.
まず, Q_OBJECTが書かれたクラス宣言のあるヘッダファイルでboost/asio.hppをincludeするとビルドが通らなくなることがある現象3に遭遇し1ヶ月悩みました.
そして, 2015年の目標に書いたようにtemplateを使ったプログラムに力を入れたかったのですが, Q_OBJECTの使われたクラス(Qt関連のすべてのクラス)ではClass Templateが使えない4と知って本当にショックでした.

命名規則の違い

QtはcamelCaseなクラス/関数を提供しています.
しかし, C++標準ライブラリやBoostに合わせてsnake_caseでコードを書いてきた僕にとっては物凄い違和感に苦しめられました.

命名規則は大体はその言語の標準ライブラリに合わせるのがBestかなと思っているのですが, どうしてこう外部C++のライブラリはcamelCaseのものが多いのか…
まぁコンパイラから見れば関係ないんだろうけどさぁ…

複雑なUIを組みたい

camelCaseな書き方を我慢するとしても, マクロの副作用に気をつけたとしても, Class Templateを諦めたとしても, 実際にQtでTwitterクライアント開発を始めて行くと大きな壁にぶつかりました. Widgetの拡張です.

単純にQPushButton等の用意されたWidgetを配置していく程度のアプリケーションなら何の問題もないのですが, 今回作成したいのはTwitterクライアントです.
例えばタイムライン表示を実装するとします. TweetDeckほど複雑な表示でなくとも, 最低限アイコンとscreen_name, tweet本文を独立して表示できるListViewが欲しいわけです.
tl

QtにこのようなWidgetは用意されていないので, ちょっと弄る必要があります.
QtのListViewであるQListViewを拡張するには, 必要に応じてQAbstractListModel等を継承した独自のModelを作成し, またQStyledItemDelegateを継承したクラスを適用させてQListViewのItemをPaintするときの動作を上書きしていくようです. 詳しくはこちら.

これらの情報を元に, 年末にいろいろ弄ってみました.
しかし, 僕のQt力が足りないことや, 雑に書いたのもありますがどうも不安定で, コードが予想以上に複雑に…
ここから表示する情報を増やしたり, Tweetの行数に合わせて高さを可変させるようにしたり, デザインよくしたりと考えていくともう…_(:3 」∠)_

ちなみに, QListViewQStandardItemModelを利用すると, アイコン付きのListは簡単に作れました.
しかし, 画面端での文字折り返しがどうしてもうまくいかず諦めました. (標準Widgetでは不可能…?)

QListView* l = new QListView();
QStandardItemModel* model = new QStandardItemModel();

l->setModel(model);
l->setEditTriggers(QAbstractItemView::NoEditTriggers);
l->setIconSize(QSize(64, 64));

model->insertRow(0, new QStandardItem(QIcon("icon.png"), "myon!!"));

HTMLでUIを書くという選択肢

さて, 長くなりましたが本題です.

なんとかいい感じに, 思い通りのUIを設計できる方法がないかと探っていると, たまたま開いたQtのドキュメントでQWebViewというクラスがあるのを見つけます.
このクラスは名前の通りWebドキュメントを表示したり編集したりするためのものです.

また, QWebViewに関連したクラスであるQWebFrameに, addToJavaScriptWindowObject()という何とも怪しいメンバ関数があるのを見つけます.
このメンバ関数は, なんとQObjectを継承しているObjectをQWebViewなどで表示しているページ内のJavaScript Objectとして追加できるという物凄いものでした.

そして最後に, 上記のメンバ関数の利用例を示したThe Qt WebKit Bridgeというドキュメントにたどり着きます.
このページには, QWebView内で動くJavaScriptとC++なコードを結びつけてアプリケーションを作る手法のあれこれが記されています.

あっ, これってつまり… GUI周りをすべてHTMLで実現できるってことじゃね!!??

ということで, 上記手法を用いたGUIアプリケーション作成方法について紹介していこうと思います.

Hello World!

とりあえずQWebViewを使ってみます.

$ qmake
$ make
$ ./QWebView_example

example

これだけです. 簡単ですね.
ちなみに, main.ccの8行目のR"( ... )"はC++11からのRaw String Literal5と呼ばれるものです.

C++からDOM

QWebViewの中でJavaScriptを動かすって方法もありますが, あくまで今回のメインはC++です.
DOM要素を扱うQWebElementを使うことでいろいろできます.
Sample: Tosainu / QWebView_dom.pro

dom

要素を取得する

JavaScriptでいうdocument.getElementById等に相当するようなものです.
例えばlistクラスのついた<ul>を取得するにはこんな感じに書きます.

auto list = webview->page()->mainFrame()->findFirstElement("ul.list");

そのリストの最初の子要素を取得するならこんな感じ.

auto item = webview->page()->mainFrame()->findFirstElement("ul.list li");

要素を挿入する

取得した要素に新しい要素を追加してみます.
以下のようなHTMLで, (a)の位置に挿入するにはQWebElement::prependInside(), (b)の位置に挿入するにはQWebElement::appendInside()を使います.

<ul class="list">
  <!-- (a) -->
  <li>もともと存在する要素</li>
  <!-- (b) -->
</ul>
auto list = webview->page()->mainFrame()->findFirstElement("ul.list");
list.prependInside("<li>" + QTime::currentTime().toString() + "</li>");
list.appendInside("<li>" + QTime::currentTime().toString() + "</li>");

ここで注意したいのが, QWebFrame::findFirstElement()が返す値はポインタではないということです.
周りと同じようにアロー演算子(->)を使ったりするとビルドできません.

取得した要素を削除する

取得した要素を削除するにはQWebElement::removeFromDocument()を使います.

auto item = webview->page()->mainFrame()->findFirstElement("ul.list li");
item.removeFromDocument();

クラスを追加/削除する

要素のクラスを追加するにはQWebElement::addClass(), 削除するにはQWebElement::removeClass()を使います. そのままですね.

auto list = webview->page()->mainFrame()->findFirstElement("ul.list");
list.addClass("hide");
list.removeClass("hide");

Signal/Slot

QtといったらSignal/Slotでしょう.
QWebFrame::addToJavaScriptWindowObject()の力を借りながらQWebView内のHTMLとC++のコードを繋いでみます.

HTML -> C++

表示しているHTMLからQObjectを継承したJsObjクラスのSlotであるhogeSlot()を呼び出してみます.
Sample: Tosainu / QWebView_html-to-cpp.pro

auto jo = new JsObj();
webview->page()->mainFrame()->addToJavaScriptWindowObject("jsobj", jo);

このように記述すると, jojsobjという名前のJavaScript contextとしてページに放り込まれます.
例えばページ内の<button>からhogeSlot()を呼び出すには, onclick=""にこのように記述してやります.

<button onclick="jsobj.hogeSlot();">Click!</button>

上に挙げたサンプルプログラムでは, ページに配置されたClick!ボタンをクリックすると, こんな感じで**QMessageBox::information()**が表示されたと思います.
htmlc++

C++ -> HTML(JavaScript)

今度はその逆, C++上のSignalが発火したらQWebView内のJavaScript関数が実行されるようにしてみます.
Sample: Tosainu / QWebView_cpp-to-html.pro

同様にjsobjを追加したうえで, このような文法でJavaScriptの関数fugaSlotをC++上のhogeSignalにconnectします.

var fugaSlot = function() {
  location.href = 'http://myon.info/';
};

jsobj.hogeSignal.connect(fugaSlot);

サンプルプログラムでは, 下部のQPushButtonをクリックするとhogeSignalが発火するようにしてありますので, ボタンをクリックするとhttp://myon.info/に飛んだと思います.
c++html

値の受け渡し

関数が相互に呼び出せるようになったので, 今度は値のやり取りをしてみます.
Sample: Tosainu / QWebView_exchange-value.pro

exchangeval

やり取りする相手がJavaScriptですが, なにか特別な書き方が必要というわけではありません.
サンプルでは文字列の受け渡ししかしていませんが, The Qt WebKit Bridge | Qt 5.4によると以下のような型が扱えると紹介されています. (面倒で全て試せてない)

形式Qtでの型
数値int, short, float, double等
文字列QString
日付/時刻QDate, QTime, QDateTime
正規表現QRegExp
配列QVariantList, QStringList, QObjectList, QList等
JSONQVariantMap等
その他QVariant, QObject, QWidget, QImage, QPixmap等

またSignal/SlotのOverloadも可能なほか, 以下のように呼び出すSlotを直接指定して呼び出すこともできるようです.
QStringを受け取るSlotに数値を渡したところ綺麗に変換(std::stoi()みたいな)されたのはおもしろかったです.

myQObject['myOverloadedSlot(int)']("10");
myQObject['myOverloadedSlot(QString)'](10);

まとめ

僕と似たような意見を持っている方を見つけたのでリンクを貼っておきます.
Why all GUI toolkits suck? - C++ Forum

とりあえず, 当分の間QtってるC++書きたくない.

Footnotes

  1. ただし作者は当分Qtってるコードを書きたくない模様

  2. List of platform-independent GUI libraries - Wikipedia

  3. https://svn.boost.org/trac/boost/ticket/10688 includeする場所をソースファイルに変えると解決する

  4. http://qt-project.org/forums/viewthread/14058

  5. C++11: Syntax and Feature - 2.8.4.3 生文字列リテラル(Raw String Literal)