はじめに
こんにちは。趣味でmacOSアプリ開発をしている学生デベロッパのKyomeと申します。今回は、CPU負荷に合わせた速度でメニューバー上で猫が走るだけのmacOS向けユーティリティ(?)アプリ、RunCatをリリースしてから約一周年ということで、RunCatの開発裏話を記しておこうと思います。
RunCat
「なんか面白いアプリ作りたいな〜。」
「この前はカスタムキーボードでピアノ作ったし、マニアックなとこ攻めたいな。メニューバーとか...。」
Mac Bookを開き、ふとメニューバーを見ると、Wi-Fiの接続チェック中のアニメーションが目に入る
「へぇ...メニューバーってアニメーションできるのか。」
「...猫でも走らせてみようか。」
すべてはこんな思いつきから始まりました。
RunCatはCPU負荷に合わせてRunnerと呼ばれるキャラクター(あるいはモチーフ)のアニメーション再生速度が変化する、システム・インフォメーション・インジケーターです。
↑基本的にはこんな感じで猫が走るアニメーションが無限に再生されるだけのアプリです。
現在では、CPUの負荷に加え、メモリパフォーマンス、ディスク容量、ネットワークスピードの情報を閲覧できる機能も加わり、ちょっと本格的なStats系アプリに成長しています。機能縮小版に関してはオープンソース化されています。→menubar_runcat
機能がミニマムなアプリだし、さぞ簡単に実装もできるだろうと一見思えるのですが、どっこいこれがそうでもなかったのです...。
前編:リリースまでの歩み
そもそも常駐型のメニューバーアプリってどう作るのよ?
macOSアプリエンジニアの数は非常に少なく、iOSと違って知見もなかなか見つからない界隈です。しかも、常駐型のメニューバーアプリなんてマニアックな分野になったらなおさら文献は少なく、やっと見つけたいくつかの記事もみんな俺実装をしていて何が正しいのかわからないなんてことはザラでした。かくして猫を走らせようという軽い思いつきは、いきなり壁にぶち当たりました。今思えば、なぜここで引き下がらなかったのかが不思議です。暇じゃなかったはずなのに...。でも諦めない!だってこのアイデアは形にしたいから!
ということで、常駐型メニューバーアプリ作成の基本の要点だけまとめます。
-
TARGET
のinfo.plist
にApplication is agent (UIElement)
またはApplication is background only
のKeyを追加して、ValueをYESにする -
Main.storyboard
のWindowController
およびViewController
を削除 -
Menu
にMenu Item
を2つ追加し,タイトルをAboutとQuitにして、それぞれFirstResponder
のorderFrontStandardAboutPanel
とterminate
に繋ぐ
-
AppDelegate
にて最小限の設定
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
@IBOutlet weak var menu: NSMenu!
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
var button: NSStatusBarButton!
func applicationDidFinishLaunching(_ aNotification: Notification) {
statusItem.menu = menu
button = statusItem.button!
button.title = "Moji" // アイコンの方がベター
button.image = NSImage(named: "アイコン画像") // アイコン画像の仕様については後述
}
}
こんな感じで基礎の基礎はできます。
メニューバーで猫が走った!でもつまらない...
とりあえずメニューバーアプリの基礎を組むことができた私は、走る猫の5コマアニメを用意してTimer
を使ってNSStatusBarButton
のimageを0.2秒ごとにループで切り替えるという単純実装をしてみたのでした。
...猫が走ってる! ...でもなんか面白くない。
そう。思っていたよりも面白くなかったのです。
永遠と一定の速度で走る猫は見ていてすぐに飽きることに気づいた私は、何かに基づいて走る速度が変わるようにしようと思いました。
マウスカーソルの移動に合わせて猫が走るようにしてみた
まずやってみたことは、マウスカーソルの移動量に応じて、コマ送りをするという案です。当然ですが、常駐型アプリなので、アプリがバックグラウンドになっている時も動いてくれないと面白くありません。そこでグローバルな環境でマウスイベントを取る必要が出てきました。マウスのムーブイベントを取るのはNSEvent
を使えば難しくありません。
var monitors = [Any?]()
func setMonitor() {
monitors.append(NSEvent.addLocalMonitorForEvents(matching: .mouseMoved, handler: { (event) -> NSEvent? in
let location = NSEvent.mouseLocation
return event
}))
monitors.append(NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved, handler: { (event) in
let location = NSEvent.mouseLocation
}))
}
func removeMonitor() {
for monitor in monitors {
NSEvent.removeMonitor(monitor!)
}
monitors.removeAll()
}
このようにして、ある閾値以上マウスが動いた場合に1フレームコマが送られるようにしたところ、素早くマウスを動かせば猫が必死に走り、ゆっくりマウスを動かせば猫がゆっくり走るようになりました。かなりよくなりました。ただ、常にマウスを動かしている訳でもありません。何かいい方法はないでしょうか...
キーボードの入力ごとに猫が走るようにしてみた(かった...)
ということで、執筆中にも猫を走らせたいと思い、キー入力イベントごとに猫が走るようにしようとしました。調子よくタイピングしているときは猫が軽快に走り、そうでないときは止まる感じですね。これも基本的にはNSEvent.addGlobalMonitorForEvents
にNSEvent.EventTypeMask.keyDown
を使えばできるようになるはずなのですが、アクセシビリティ制限の壁があったのです。環境設定の「セキュリティーとプライバシー」の中のアクセシビリティでアプリによるキー入力の取得の許可をユーザから得ないといけませんでした。また、この手法はAppStoreでリリースするアプリでは使えないものです。このアイデアについては仕方なく引き下がりました。トホホ
App Rejected: 4. 2 Design: Minimum Functionality
とにもかくにも、猫が走るようになったのですからひとまずリリースしてみようと思い、アップデート申請をしたところ、審査結果はリジェクト:Minimum Functionalityでした。知らない方もいるかもしれませんが、要は「**お前のアプリクソすぎてAppStoreには載せらんないわ,出直してきな!」**ということでした。そりゃそうです。猫がマウスの動きに合わせて走ったところでなんの役に立つというのか。AppStoreの審査員はそのバージョンに関しては不変らしいので、審査員をどうにかして納得させないといけません。しかし課題は一度出たらもうリリースは諦めた方が良いとまで恐れられるMinimum Functionalityです。私は匙を投げました。
とりあえずオープンソースにしてQiitaの記事を雑に投げた
リリースできなさそうだと踏んだ私は、早々に諦めてGitHubにオープンソースとして公開することにしました。そして、そのリンクを貼ったQiitaの記事を書いてこのネタは終わりにしようと思ったのです(なぜか記事を消してしまいました...)。
そして2ヶ月ほど過ぎたある日
Qiitaの記事に「猫をCPUに合わせて走らせてみては?」とコメントが届いていることに気づきました。
「なるほどそれは面白そうだし機能的にも意味がありそうだ!」
再びリリースへの挑戦心が復活しました。
CPU負荷に応じて猫が走るようにしたい!
早速開発作業に取り掛かった私は、CPU負荷を取ってくる方法を調査し始めました。
「...そんな文献全然出てこないんだけど!!」
そう、ただでさえ前例や文献の少ないmacOSアプリ開発界隈、CPU負荷を取得するなんて文献がホイホイ出てくるわけがないのです。しかし諦めすにネットを彷徨うこと1ヶ月強、SystemKitという強そうなソースコードを発見し、それを参考にしながら無事にCPU負荷を取得することができたのでした。
リリース開始!
その後も何度かリジェクトを食らうなど、紆余曲折あったものの、無事に2018年11月21日にリリースをすることができました。
猫だけではダメだったようで、犬やうさぎやドラゴンなどの初期メンバーを足すことになりました。
恐るべきバズり!!
なんとたった約一週間で2600ダウンロードされました。iOS市場はよく分かりませんが、macOS市場でしかも個人でとなるとこれは異常でした!!(私にとっては) これまでコツコツ育てて2年ほどかけてようやく1000ダウンロード行ったアプリ達がバカみたいでした(ユーザの役に立っているかどうかは別として)。
そこで私は悟ったのです。
「時代は猫なんだな...」
「とりあえずバズらせたいなら猫をモチーフに使えばいいんじゃないかな?」
後編:アップデートとバグフィックス
Runner有料化からのクレームの嵐
猫のバズりが落ち着いたある日、研究室の後輩に言われました
「なんで稼がないんですか?勿体無い」
確かにそうです。言われるまで、あまり欲がなかったのですが、なんか勿体無い気がしてきたのと、App内課金の実装に興味があったことから、何かしら有料機能をつけることを考え始めました。そして、後輩から様々なアドバイスを受けて、Runner(アニメーションのキャラクター)を小売やパック売りにするというのをやってみることにしたのです。
macOS向けのApp内課金の実装例、全然見つからないんだが
またしても文献が少ない問題が発生!iOSの例はいっぱいあるけどmacOSのはない...てか無知すぎてそもそも同じAPIでできるのかもわからない!という状態でした。そんな感じでも、ものは試しだとiOSの例を真似っこしながら実装したところ、コードは動いていそうな感じ。あとはApp Store Connectの設定だ!
参考にした記事:Swift4.0 非消耗型課金を簡単に行うIAPマネージャクラスを作ってみた
App Store Connectの設定とか、審査とか、SandBoxテストとかわからん
わからないことだらけですが、めげている場合ではありません。iTunes ConnectからApp Store Connectに変わったり、UIや仕組みが変わったりしていましたが、先行文献と照らし合わせながらのトライ&エラーの繰り返し。
参考になる記事:iOSアプリ開発辞典/アプリ内課金の実装方法
やった!App内課金ができたぞ!! ...ん?
何を思ったのか、私は無償で使えていたRunner達のいくつか(というかほとんど)を有料にして、その代わりに少数の新規無料Runnerを追加するという暴挙に出ていました。それからの一ヶ月は**クレームの嵐!**もともと無料のアプリだったというのにも関わらず、目を覆いたくなるような罵詈雑言が私に刺さりました(汗
以下、レビューの抜粋(翻訳含む)
- アプリの楽しさが取り除かれたので、アンインストール必須!
- 金の亡者
- 有料になったのは一部じゃなくて全部じゃねぇか!
- 失望しました
- はめられた!
- 恐ろしいことが起きた
- アップデートしなかったら良かったです
- インストールすることをオススメしない
- アプリを削除しました
- 課金を説明に書かないクズ、詐欺アップデート、アンインストール推奨、最低最悪
ちなみにクレームっぽいレビューの8割は中国語で書かれていました。単純に人口が多いからなのか、無料アプリに対する評価がシビアなお国柄なのかはわかりません。
大反省...でも一度内課金にしたら戻せないじゃん
多くのファンをがっかりさせてしまい、大反省した私にできることといえば、アップデートによる機能の充実と、無料Runnerの追加でした。暇があればひたすらコマ送りアニメーションのネタを考えてはドット絵をポチポチし、メニューバーで実際に動かしては、修正したりボツにしたりの繰り返し。そうして徐々に増やして行った結果、今ではRunnerが有料・無料合わせて60種類!!(バカなのか?)でも、アップデートを重ねるうちにクレーマーの熱も冷め、今では全世界での評価は星4つ★★★★☆です。なんとか(精神的に)耐えましたね。
アップデート頻発からのクレーム
当時何も考えずにSierra、High Sierra、Mojaveの3バージョンにてリリースしていたのですが、なんとデバッグをMojaveでしかしていなかったのです。メニューバーはかなり特殊なので、当然OSのバージョンごとに仕様がかなり異なっており、不具合の報告が相次ぎました。にも関わらず、「ゆうてSierraとHigh Sierraはあんまり変わらんだろ」と思ってSierraとHigh Sierraの実装を分けずにバグフィックスしたのが最悪で、あっちを直せばこっちがダメになり、こっちを直せばあっちがダメでといった現象が起きました(テスト版ではなくリリース版で、です)。その結果、10日間に4度のアップデートという仏もブチ切れるほどのアップデートを強行してしまったのです。そして届くクレーム!
- もうアップデート無くて良いです
- アップデート多すぎ糞
- もう使いませんのでご自分の都合でご自分が満足するまでアップデートしまくってください
本当に申し訳ありませんでしたっ! m(_ _)m
速度の調整可能な軽量なアニメーションを実装したかっただけなのに...
RunCatは常駐型アプリなので常に動いています。しかも、CPU負荷を表示する存在。そんなRunCat自身がCPU負荷を圧迫していたら大変です!
Timerでの実装:メニューバーにした途端処理重くなるのなんでなん?
一番最初はTimerを二重に使ってコマ送りアニメーションを実装していました。片方は5秒間隔で動くタイマーで、処理ごとにCPU負荷を取得して猫のスピード(コマ送りのインターバル)を計算します。もう片方のタイマーは先の処理に合わせて一時停止し、インターバールを更新した上でコマ送りを再開すると行った感じ。これは、普通のViewで動かす分には軽量(0.1%)で、CPU負荷を全然食わないのですが、メニューバーとなると話が変わってきます。急にCPU負荷が4%〜8%になってしまいます。
マルチディスプレイの壁1
色々検証した結果、Timerの処理が重くなってしまうのは、マルチディスプレイにしている時だと判明しました。マルチディスプレイでは、ディスプレイのそれぞれにメニューバーがあり、アイコンが並ぶため、簡単にいえば、ディスプレイの枚数だけ猫が走ることになるわけです。で、重大なのが、メインのディスプレイ(フォーカスが当たっているもの)上のオブジェクトのコピーを作ってサブのディスプレイにレンダリングされる仕組みの都合上、余計にCPUに負荷がかかるみたいです。ディスプレイを複数使うユーザは稀かもしれませんが、自分自身がそのユーザなので見逃せない負の仕様でした。
CAKeyframeAnimationで軽量なアニメーション!
そこで、使うことにしたのがCAKeyframeAnimationです。簡単に説明すると、使い方を工夫すれば複数の画像とそのインターバルを設定すると自動でコマ送りアニメーションしてくれるやつです。しかも、途中でアニメーションのスピードを変更することもできます。また、こいつはかなり軽量にアニメーションをすることができるもので、RunCatの場合はアニメーションにかかるCPU負荷は基本0.1%です。なぜ、軽量かというと、Core Animationを使ったアニメーションはGPUを用いるためらしいです。
マルチディスプレイの壁2
しかし、またしてもマルチディスプレイに障害発生です!なんと、CAKeyframeAnimationはフォーカスの当たっている方のディスプレイでしか動かなかったのです(原因不明でたぶん仕様)。そのあと、軽量なアニメーションを求めて、Stats系アプリ最強MenuBar Statsの開発者のseenseさんにFaceBookで英語凸してヒントをもらい(結局アプリの仕様が違うので役には立たなかったけれど)、結局うまく動かせなかったので、しかたなく眠っている猫の画像を配置することにしました(意外と見つけた人からは高評価)。
なんでdGPUくん勝手に仕事するん?
アニメーションは解決しただろうと思っていた矢先にこんなレビューがきました。
「MacBook Pro 15インチだとdGPU使って発熱するんだけど。」
まじか...MacBook Pro 13インチマンだから気づけなかった...
ということで、端末がない限り再現もできない負の仕様をどうやって解決するか途方にくれながら、dGPUを使わない方法を模索すること3,4ヶ月...ソースコードではなくinfo.plist
から解決する方法を発見しました。Supports Automatic Graphics Switching
というKeyを追加して、ValueにYES
を入れると、勝手にdGPUを使わずに、CPU内GPUを使うようになるらしいです。これで発熱問題が解決したかはわかりませんが、クレームが来なくなったので多分大丈夫なのでしょう。
ライトモードとダークモードの対応むっず
macOS(のメニューバー)にはiOSよりも早くからダークモード的な概念がありました。
ここでの課題は
- モードの切り替わりを正確に検出しないと猫が見えなくなってしまう
- ただ白黒反転すれば良いってもんじゃない!キャラクターによってはモードによって別の画像が必要!
という2点でした。
特に1個目のやつは、Catalinaからオートモードという自動でライトモードとダークモードを切り替えるやつが出てきてめちゃくちゃ面倒なことになりました。これについてはTwitterでぼやいたところ、CotEditorの現開発者の@1024jpさんからアドバイスをいただき解決しました。NSStatusBarButton
のeffectiveAppearance
をobserveすることで、モードの切り替わりを正確に検出できます。
また、2つ目については普通にそれぞれのモード用の画像を用意して使い分けるようにしました。基本的にRunnerは5枚の画像で構成されるので、モード毎に画像が必要なRunnerは倍の10枚の画像リソースを必要としていることになりますね。
↑こんな感じ
解像度問題:画像サイズとピクセルサイズって違う概念なのね
RunCatのRunnerはRetinaディスプレイ用の解像度2倍サイズを基準として、縦36px、横10px~200pxの画像を用いることになっているのですが、色の反転や左右の反転などそのまま使うのではなく、間に加工を挟んでからディスプレイに表示しています。そこで色々厄介な問題が発生するのですが、詳しくは下記の記事を読んでみてください。
Swift/Xcode:Image Assetsの@2x,@3x画像をコードでリサイズするときは注意しろよな☺️
コンピュータのシステムインフォメーション取ってくるの難しくない??
「僕はただアクティビティモニタに出ている情報を取ってきたかっただけなんだ...」
本当は猫がメニューバーで走っているだけで満足だったのですが、CPU負荷以外にも色々見られるようにしてほしいという要望が多数あったので、メモリパフォーマンスやディスク容量を表示できるようにすることにしました。しかし、まぁ、本当に前例が少ない。しかも見つけたと思ったらiOSのアプリ単体に関する情報の取得だったり、App SandBoxの範囲内でできない手法だったり... 結局執念でコツコツ探しては試して情報を取得できるようになりました。こんな苦労は僕だけで良いという思いで、もしかするとコピーアプリを作られてしまうリスクはありますが、システム情報を取得する手法を記事にまとめて公開しています!
- Swift:macのCPU使用率を取得する
- Swift:macのディスク使用量を取得する
- Swift:macのメモリパフォーマンスを取得する
- Swift:macのネットワーク上り下りスピードを取得する
- Swift:macのIP Addressを取得する
- ↑これらの集大成
プライマリーキーを2つ持たせちゃだめよ
RunCatはもともと無料だった頃、Runner一体ずつ数字のIDを割り振っていたのですが、有料化した時にApp内課金側のIDが紐づくようになりました。そのせいで、プライマリーキーが2つある状態が長らく続いており、Runnerオブジェクトを初期化する方法を2通り用意する必要があり、Runnerを管理するのが面倒でした。今では、App内課金側のIDで統一してプライマリーキーを1本化しました。
あれ?自作フォントが読み込めない...
RunCatのCPUやメモリ情報を表示しているところのアイコンは、実はフォントで表示しています。メニューバー上で頻繁に更新される表示は文字列を用いたほうが楽だと判断したからです。そこで、専用のフォントを作成して使っています。ただ、iOSでの自作フォントの読み込みは前例記事がいっぱいあるのですが、macOSはありませんでした。これも記事まとめてあります。 現在のバージョンではフォントは使用していません。
macOS向けアプリでカスタムフォントを扱う方法
最後に
RunCatを愛用してくださっているユーザの方々、本当にありがとうございます。皆様の応援に励まされてRunCatのメンテナンスは成り立っております。私も末長く愛されるアプリケーションになるよう尽力しますので、今後もRunCatをよろしくお願いいたします。
参考
RunCat開発に基づく知見
↑ここにある情報を集約すれば、あなたにもきっとRunCatが0から作れます。