2016年02月20日

Making of 電波新聞社コンパス(QML入門)

今回は前回のブログ記事で制作した「電波新聞社コンパス」の技術的解説をしたいと思います。
筆者も忘れかけていますが、一応当ブログは「レトロPCをおかずにして現代のプログラム技術を磨く」ということをヘッダーで宣言しているのですが、宣言しているのですが、宣言…?
してねえ!
当初書いていたはずだけど、あまりに実態とかけ離れてきてたから消した?
すでにそんな経緯も忘却の彼方にすっ飛んでますが、このコンセプトは忘れずにブログは続けていく所存です。

そんなわけで今回は電波新聞社コンパスの実装に使用した言語、QMLの解説を主体に内部の解説をしたいと思います。

QML(Qt Meta-Object Language)はQtの新しいUIフレームワーク、Qt Quickでアプリケーションを構築するための宣言型言語です。
言語のタイプとしてはJavaScriptとCSSのチャンポンで、Web開発者のスタイルを取り込んでいると思われます。
従来単にQtと呼ばれていた、C++用のクロスプラットフォームGUIフレームワークは、今はQWidgetと呼ばれ区別されています。
どうしてこんなものが生まれたかというと、私の独断と偏見によれば旧来のQWidgetではスマホやタブレットのトレンドについていけなくなったから、と理解しています。
QWidgetで作られた、Android版のP6VXをお使いいただいた方なら解ると思いますが、UIの表示はひどいもんです。
P6VX_android1.pngP6VX_android2.png
そういう技術的な問題と、開発言語の人気のトレンドから、このままQWidget一本で行くのは諦めた、ということだと推察します。
現にQtの開発陣はAndroidではQWidgetは「とりあえず動く」レベル以上のメンテナンスはしないと明言しているようですし、今回QMLの勉強を始めたのも、これまでQtで作ったアプリをスマホやタブレットに持っていくために仕方なく、という側面が少なからずあります

これまで私は公私ともにほとんどC++のみでやって行けてしまっていたため、Javascriptはほぼ初挑戦ということになります。
そういった観点で、これまでC++プログラマーだった人がどう考え方を変えればよいのか、変えなければいけなかったのかを書いていきたいと思います。
ソースはそのまま公開すると色々まずい部分があるので、抜粋でというかたちになりますがご了承を。

QWidgetはEclipseやVisualStudioなど様々な開発環境で使えるようになっていましたが、QMLアプリケーションの開発はオフィシャルのQtCreatorを使うのが現状多分唯一の選択肢でしょう。
ここではQtCreatorを使った開発手順を解説します。

■プロジェクトの作成
QtCreatorを立ち上げたらファイルメニューから「ファイル/プロジェクトの新規作成」を選択します。
プロジェクトの選択画面が出たら、「Qt Quick Controls Application」を選択します。
その上に「Qt Quick Application」というのがありますが、こちらは本当にQtQuickの最低限の機能が提供されるのみで、画面部品は全て自作することになります。
今回は聖地の選択にコンボボックスを使いたいので、そうした部品が予め提供される「Qt Quick Controls Application」を選択します。
DPCompass_wizard1.png

DPCompass_wizard2.png
次の画面のチェックは両方共ONにしましょう。「With ui QML file」はUI定義とロジックのファイルを分離します。「Enable native styling」はターゲットOSの見た目に合わせたUIが提供されます。

プロジェクトを作成するとMainForm.ui.qmlとmain.qmlが生成されています。
MainForm.ui.qmlが画面の見た目の定義、main.qmlはそれを制御するロジックのスクリプトです。

■UIの定義
まずはMainForm.ui.qmlを画面左のモード選択メニューから「デザイン」モードで開きます。

ここでは画面のレイアウトを作っていきます。
左上のライブラリから、ドラッグアンドドロップでコンパスの盤面に相当する画像を2枚配置します。
レイアウトの仕組み自体はQWidgetのそれと大差ないので割愛しますが、ここで重要なのは部品同士の親子関係です。
このアプリは通常の方位磁針(compassBase)のと聖地を指す盤面(compass)の2枚重ねになっていますが、ここでcompassをcompassBaseの子として配置します。
DPCompass_layout.png
デザインモードでは画面の見た目までしか作れないので、main.qmlでその挙動の制御を記述する必要があります。そのために、ロジックから画面部品にアクセスできるようにするためにエクスポートという手順が必要になります。
これが非常にわかりにくいのですが、デザインモードの「ナビゲータ」にある各アイテムの横にある四角いアイコンをクリックするとアイコンが変化します。小さい四角が飛び出した状態がエクスポートされた状態です。ここでは聖地選択のコンボボックスとコンパスの盤面2つをエクスポートします。
DPCompass_export.png
■ロジックの記述
ここまで出来たら「編集」モードでmain.qmlを開きます。
こちらがソースリストですが、これくらいのアプリであれば100行に満たない行数で作成できます

import QtQuick 2.5
import QtQml.Models 2.2
import QtQuick.Controls 1.4
import QtQuick.Dialogs 1.2
import QtQuick.Window 2.0
import QtPositioning 5.3
import QtSensors 5.3

ApplicationWindow {
id: appWindow
visible: true
width: 320
height: 480
title: qsTr("DPCompass")

property var locNames: [
"電波新聞社(五反田)",
"マイコンソフト(大阪)",
"日本ファルコム(立川)",
"T&Eソフト跡地(名古屋)",
"ハドソンソフト跡地(札幌)",
"アスキー旧所在地(南青山)",
"工学社旧所在地(代々木ぜんらくビル)",
"Bit-Inn跡地(秋葉原)",
"サムソンソフト跡地(横浜)",
"M2(我孫子)",
"スマイルブーム(札幌)",
]
property var locCoords: [
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//電波新聞社
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//マイコンソフト
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//日本ファルコム
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//T&E
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//ハドソンソフト
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//アスキー
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//工学社
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//Bit-Inn
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//サムソンソフト
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//M2
QtPositioning.coordinate(XX.XXXXXX, YYY.YYYYYY),//スマイルブーム
]
property var locPictures: [
"res/dp.png",
"res/dp.png",
"res/falcom.png",
"res/T&E.png",
"res/hudson.png",
"res/ascii.png",
"res/kogakusha.png",
"res/bitinn.png",
"res/gaia.png",
"res/M2.png",
"res/smileboom.png",
]

property int coordIndex: 0

//目的地までの方角
function targetAzimuth(){
var currentCoord = positionSensor.position.coordinate;
var targetCoord = appWindow.locCoords[appWindow.coordIndex];
return currentCoord.azimuthTo(targetCoord);
}

//画面の向きによるコンパスの角度補正
function screenOrientationDeg(){
return Screen.angleBetween(Screen.orientation, Screen.primaryOrientation);
}

PositionSource {
id: positionSensor
active: Qt.application.state === Qt.ApplicationActive
}

Compass {
id: compassSensor
active: Qt.application.state === Qt.ApplicationActive
}

MainForm {
anchors.fill: parent
compassBase.rotation: -compassSensor.reading.azimuth + screenOrientationDeg()
compass.rotation: targetAzimuth()
selectLocation.model: locNames
selectLocation.onCurrentIndexChanged: {
appWindow.coordIndex = selectLocation.currentIndex
compass.source = locPictures[selectLocation.currentIndex]
}
}

}


このアプリではGPSとコンパスを使用します。
そのためにはスクリプトの先頭部分(6-7行目)に

import QtPositioning 5.3
import QtSensors 5.3

を追記します。
QtPositioningはGPS,QtSensorsはコンパスを利用するのに必要です。

次に、聖地の情報を格納するプロパティを定義します。(16-56行目)
locNamesがコンボボックスに表示される聖地名、
locCoordsが聖地の座標(緯度,経度)、
locPicturesが聖地を示すコンパスの盤面の画像です。
画像はQtのリソースシステムを用いてバイナリに埋め込みます。
聖地の座標は80年代当時の雑誌広告などから住所を調べてGoogleMapsで座標を調べました
殆どが大都市圏だったため、平成の大合併で消滅した自治体はなく、当時の住所でそのまま検索できました。また多くは建物もそのままだったようです。
(この記事のリスト上では聖地の具体的な座標は伏せて掲載しています)
56行目のcoordIndexは現在表示する聖地のインデックスを示すプロパティです。
初期値として0(五反田の電波新聞社)を入れておきます。

汎用関数として、関数を2つ定義しておきます。(58-68行目)
targetAzimuth():現在地から見た聖地の方角を取得します。
positionSensor.position.coordinateとlocCoordsはともにQtPositioning.coordinateという型です。この型にはazimuthTo()という、ある座標から見た別の座標の方角を取得するという今回のアプリにピッタリのメソッドがあります。

screenOrientationDeg():スマホやタブレットで画面を縦や横に向きを変えた場合に表示が回転しますが、その回転角はScreen.primaryOrientationというデバイス本来の向きからの角度となりますので、現在の画面の向きとの差をここで算出して補正に使います。

これでやっと準備が整いましたので、MainForm上の画面部品を制御する処理を記述していきます。(80-89行目)
QWidgetベースのアプリケーションではあるイベントが発生したらそれに対応するイベントハンドラーが呼び出されるという考え方でしたが、宣言型言語であるQMLではこのアプローチを変える必要があります。
オブジェクトの各プロパティは式で表現し、どうしても1つの式で表現しきれない処理だけイベントハンドラーや関数の形で記述すると考えたほうが良いでしょう。
プロパティがどのタイミングで更新されるのかということは意識しません。アプリが動いている間勝手に同期してくれているものと理解しています。(この理解が間違っているとまずい)

このアプリで制御する対象はコンパスの盤面の回転角です。
2枚の盤面のオブジェクト(compassBase,compass)はUIからエクスポート済みですので、
これらの回転角を定義する式をここで記述します。(82-83行目)

compassBase.rotation: -compassSensor.reading.azimuth + screenOrientationDeg()
compass.rotation: targetAzimuth()

compassSensor.reading.azimuthはコンパスがから取得した端末が向いている方角です。
ここでは北の方角を指したいので、符号を逆にして回転します。
screenOrientationDeg()で画面の向きによる補正もここでかけておきます。
聖地の方角を指すcompass.rotationは先ほど定義したtargetAzimuth()でOKです。こちらはやけにシンプルですが、ここでオブジェクトの親子関係が聞いてきます。
オブジェクトの移動、回転は子オブジェクトを含めて適用されます。つまり子オブジェクトは親オブジェクトにくっついていき、小オブジェクトに移動、回転を適用すると親オブジェクトからの相対位置として適用されます。ここでは親オブジェクトのcompassBaseがすでに北を向いているのでそこからの相対的な回転角だけ設定してやればよいのです。

次に本アプリで唯一のイベントハンドラ、selectLocation.onCurrentIndexChangedで
コンボボックスの選択を変更した際の処理を記述します。

selectLocation.onCurrentIndexChanged: {
appWindow.coordIndex = selectLocation.currentIndex
compass.source = locPictures[selectLocation.currentIndex]
}

イベントハンドラはどうやって登録するのかという疑問ですが、(オブジェクト名).on(プロパティ名)Changedという名前を記述すればたいていのイベントは拾えるようです。

■その他の作り込み
これで基本的な機能は動作するようになりました。ところが、このアプリはプロセスがOSに殺されるまでGPSとコンパスを動かしっぱなしにするため、バッテリを必要以上に消費するという問題がありました。
そこでアプリがバックグラウンドに回ったらセンサーを停止したいのですが、ここでもバックグラウンド化というイベントを拾うのではなく、センサーの有効化状態がアプリケーションのアクティブ状態に連動すると考えたほうが良いでしょう。(筆者はここでハマりました)(70-78行目)

PositionSource {
id: positionSensor
active: Qt.application.state === Qt.ApplicationActive
}


もうひとつ、細かいところですが、QtでAndroidアプリを作った場合、デフォルトフォントがDroid Sans Fallbackになるらしく、一部の字体が中華フォントになってしまいます。
これを回避するため、強引にモトヤフォントに表示を変更します。

QGuiApplication app(argc, argv);
app.setFont(QFont("MotoyaLMaru"));


■デプロイ
ここまで出来たらデバイスにデプロイすれば完成です。
筆者はAndroidでしか動作確認していませんが、コンパスとGPSがあれば(Surfaceのようなマシンなら)Windowsでも動くのではないかと思われます。
デプロイの手順はここで説明するより、以下のページで紹介されている電子書籍にまとまっていますので、そちらを参照するのが近道です。
http://relog.xii.jp/mt5r/2014/08/qt-quick-3.html

■終わりに
今回のアプリではコードの記述量自体は非常に少なく済んでいますが、これはQMLでほぼすべて完結しているからという要因が大きいと思います。
QMLはQObjectの機構を通じてC++のネイティブ層と連携することができ、一定以上の規模のアプリではこの機能の利用が必須となるでしょう。
その際にネイティブとQMLの機能分担の設計を誤ると、その後のメンテナンスに苦労しそうという印象を受けました。
今後はスマホ向けのアプリも作っていこうと考えているので、そうした設計ノウハウはこれから体得していこうと考えています。
posted by eighttails at 20:44| Comment(0) | その他 | このブログの読者になる | 更新情報をチェックする
この記事へのコメント
コメントを書く
お名前:

メールアドレス:

ホームページアドレス:

コメント:

※ブログオーナーが承認したコメントのみ表示されます。