Angular + Electronでアプリを作った話


この記事は [Angular #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/angular-2) の14日目の記事です。

Angular + Electronで、写真の撮影位置を地図上に表示するアプリを作りました。
選択したフォルダから、位置情報が付与されたJPGファイルを探し出し、[Open Street Map](https://www.openstreetmap.org/)上に表示するアプリです。

[![demo-gif]][demo-youtube]

[demo-gif]: https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185010/0e34593b-a129-f19f-d3e3-1d33eff9c196.gif
[demo-youtube]: https://youtu.be/n7hv_h1llKo "クリックするとYouTubeに移動します。"

ダウンロードは[こちら](https://tomoyukiaota.github.io/photo-location-map/)です。ソースコードは[TomoyukiAota/photo-location-map](https://github.com/TomoyukiAota/photo-location-map)にあります。

この記事では、開発を始めてからリリースするまでの経緯について、Angularに焦点を当てつつ説明します。


# 開発の動機

スマホからPCに保存した写真が数多くあり、特に旅行の際の写真については、これらの撮影位置を地図上に表示したいと思っていました。既存のアプリがいくつかありましたが、気に入るものがなかったので、自分で作ってみることにしました。

最初に開発したElectronアプリケーションは、素のHTML/CSS/JavaScriptで書かれており、Angularは使っていませんでしたが、目的を達成するアプリを開発することができました。しかし、ある程度の規模になってくると、TypeScriptによる静的型付けや、カプセル化されたcomponentなしには、継続的な開発が難しくなることが分かってきました。そこで、Angular + Electronの構成でアプリを作ることにしました。


# 開発開始、CIの設定

Angular + Electronで開発を開始するためのテンプレートとして、[maximegris/angular-electron](https://github.com/maximegris/angular-electron)があります。これをもとに発展させていくことに決めました。さっそくの難題として、ビルドシステムが複雑で、気づかないうちに壊してしまうかもしれないと思いました。複雑さの理由は、そもそもAngular、Electronそれぞれのビルドシステムがそれなりに複雑であることに加えて、[Angular + Electronで必要なwebpackの設定をするためにnode_modules内のファイルを書き換える](https://github.com/maximegris/angular-electron/blob/angular7/postinstall.js)ハックがあることや、アプリケーションのサイズを小さくするために[Renderer processでのみ必要なnpm packageは`devDependencies`に記述する](https://github.com/maximegris/angular-electron/issues/99)といったことをしているためです。

ですので、本格的に開発を開始する前に、CI上でアプリケーションのビルド・起動のテスト([スモークテスト](http://e-words.jp/w/%E3%82%B9%E3%83%A2%E3%83%BC%E3%82%AF%E3%83%86%E3%82%B9%E3%83%88.html))をすることにしました。具体的には、アプリケーションのexe, dmg, AppImageファイルを作成して起動するスクリプトを書き、Travis CI, AppVeyorで実行するようにしました。また、`ng lint`, `ng test`, e2eテストもCIで実行するようにしました。更に、CIをパスしたPRのみmaster branchにmergeできるように設定しました。（[Mergify](https://mergify.io/)によって、CIをパスしたPRが自動でmergeされるようにしています。）

思わぬ変更で壊れるということが何度かあり、CI上で気づいているので、早めに設定して良かったと思います。


# GUIのレイアウト

次に、GUIのレイアウトを作ることにしました。
GUIのレイアウトは次のような構成になっています。

 - 左ペイン
    - フォルダ選択ボタン
    - ディレクトリのツリー
 - スプリッター（ドラッグで移動可能）
 - 右ペイン
    - マップ


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185010/6836e0fa-33d0-7330-0f4d-81c9e91d25ae.png)


## 左ペイン（フォルダ選択ボタン、ディレクトリのツリー）

フォルダ選択ボタンとディレクトリのツリーは、[Angular Material](https://material.angular.io/)を使っています。

フォルダ選択ボタンは[mat-stroked-button](https://material.angular.io/components/button/overview)を使いました。

ディレクトリのツリーは次の機能が必要です。

 1. ファイル・フォルダをツリー状に並べる
 2. フォルダはキャレット(vまたは>のアイコン)をクリックすると展開・縮小する
 3. 選択可能・選択状態を表すチェックボックスがついている。

チェックボックスの状態をまとめると次の表のとおりになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185010/25878002-032c-d4c0-3925-232b595906b3.png)

これらの機能を満たすように、[mat-tree](https://material.angular.io/components/tree/overview), [mat-checkbox](https://material.angular.io/components/checkbox/overview), [mat-icon](https://material.angular.io/components/icon/overview)を組み合わせて実装しました。mat-checkboxは、checked, intermediate, uncheckedの状態については、もとからサポートしています。残りはグレーアウトですが、これはチェックボックスの領域に灰色の四角形を描画することによって実装しました。


## スプリッター

スプリッターは[angular-split](https://bertrandg.github.io/angular-split/#/)を使いました。Examplesが豊富なので、とっかかりやすいと思います。


## 右ペイン（マップ）

マップは[Leaflet](https://leafletjs.com/)と[Leaflet.markercluster](https://github.com/Leaflet/Leaflet.markercluster)を使いました。


# フォルダ選択からマップ表示までのルーチン

ユーザーがフォルダを選択した後、次のことが実行されます。

 1. フォルダに含まれるファイル・サブフォルダを再帰的に取得する（ディレクトリのツリーに必要な情報を取得する）
 2. JPGファイルについて、EXIFが含まれる先頭65635バイトを取得し、解析する
 2. EXIFにGPSの情報が含まれているJPGファイルに絞り込む
 3. GPSの情報をもとにマップにピンを打つ。ピンをクリックするとファイルのプレビューが表示されるようにする。

これらのルーチンは、Angularなしで開発していたバージョンとほぼ同じですので、TypeScriptに対応させた上で、移行しました。

また、（写真の情報などの）状態はAngular serviceに保存しておき、それを複数のAngular componentにinjectした上で参照するようにしました。状態が更新された際の処理の実行は、RxJSを使うように書き換えました。

# フォルダ選択時のローディングダイアログ

フォルダ選択後、選択したフォルダのロード中であることを示すダイアログを表示します。

[MatDialog](https://material.angular.io/components/dialog/overview) を使って、ダイアログ上にロード中であることを示すためのcomponentを表示するようにしました。このcomponent上では、[mat-progress-bar](https://material.angular.io/components/progress-bar/overview)を`mode="buffer"`で表示しています。`mode="buffer"` にした理由は、1) GUIとしては静止させたくないこと、2) 将来的に実際のプログレスの値をプログレスバーに反映させることを考えて、それらを両立できそうなためです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185010/fff4c402-d16f-3560-f9bc-62a4eb3bad6c.png)


# 設定ダイアログ

設定ダイアログについては、Menu barからSettings -> Manage Settingsをクリックすると表示するようにしました。

![2019-12-02_00-22-28.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185010/d72568a1-384c-3ab7-227e-7569bd09d046.gif)


[Menu item](https://electronjs.org/docs/api/menu-item)はElectronのmain processで動作します。ですので、ここからrenderer processに通知した上で、[MatDialog](https://material.angular.io/components/dialog/overview) を使ってdialog上に設定ダイアログ用のcomponentを表示するようにしました。

Menu itemのクリックがきっかけで始まる動作なので、Angularの外からAngular componentのfunctionを呼び出す必要があります。おおまかには、Angular componentのfunctionを`zone.run(() => { ... })`でラップし、wrapper functionを参照するグローバル変数を用意して、そのグローバル変数を頼りに呼び出すという方法をとっています。詳細は次のリンクを参考にしてください。

https://stackoverflow.com/a/39280942/7947548


About boxや、アプリケーションの初回起動時に表示するWelcome dialogについても、設定ダイアログと同様に実装しました。


# あとがき

今回開発したアプリケーションにおいて、Angularが特に関係する箇所は以上になります。

そのほか、ディレクトリツリー上のツールチップや、マップのピンをクリックした際に表示するプレビューなど、GUIであってもAngular関係ない箇所で苦労したり、EXIFやロガーなどの処理、ElectronでのMenuの構成、auto updateの設定、[Electronアプリケーションの配布](https://qiita.com/TomoyukiAota/items/6b81b4126058e7fd4099)など、様々な箇所で苦労しました・・・が長文になるので割愛させてください。

Angular + Electronで開発してみて、今後もメンテしていけそうな手応えは得られたと感じています。