macOS sonomaのWidgetを真面目に作ることにした
概要
んで資料がAppleの公式資料とかなのでチラ見しつつ大半はChatGPTに聞く。
ざっくり
widgetとAppの間のやり取りはsを使うと楽で、それ以外をやるとすごいしんどい。
widgetの要素で個々にインタラクティブな要素を入れようとすると、Link要素に操作したいUI要素を入れると大体何とかなる。
やりたいこと
widget上にスイッチボタンを置いて、押すたびに切り替わる。付随してエアコン制御APIとかを叩きたい。
実例
https://github.com/sassembla/Timer2
はまりどころ
ライフタイムとデータ伝達とエラーハンドリングが難しい。特に難しかったところを列挙する。
Widget Appの作り方
Projectを新規作成し、さらにnew→Target→Widget Extensionを作成する。
この時macOSだけにしてAppを作るとまずは簡単で、そうしないと「macOS sonoma上だと、何しても表示がこまった状態で固定される」プロジェクトになる。
たぶん後で切り替える方式だとどこかにバグが残るのか、延々と更新されない。
Link要素がXcodeでのplay時に動作しない
これは実際にデスクトップに置くと動作するが、Play時に表示される画面ではなんとどれだけ正しく作っても動作しない気がする。
これによって、userDefaultsを用いたWidget-App間の疎通 以外の手段は極端に難しくなっている。
面倒くさくなったのと、XcodeでPlay時に出るWidgetKit自体が気に食わなかったので、独自にログシステムを構築して対応した。
、、のが、Timer2の一部としてgithubに上がっている。
ファイルのI/Oがめちゃくちゃ厳しく制御されている
Sandbox関係なく、AppGroupで指定したドメインにuserDefaultsを置く以外にはファイルを置けない/書けないと思っていい。
いろいろ回避策はあるけどほんとガッチガチだ。
OSが用意している正しい手続きを踏めば色々できそうなのでいいと思うけど、この制約を外部から知るのは骨が折れた。
Widgetの不安定性について
ぶっちゃけ稼働し出したWidgetはとても安定するが、開発中はXcodeでbuildコマンドを実行しただけで結構な回数の各種ハンドラが呼ばれており、カオス。
把握するにはpythonで簡易なログサーバを立てて見てみるのが一番良かった。で、AppやWidget側からHTTPを飛ばすコードは適当に挟んで実行できるので、ログが残って良い。
これ以外の方法でまともにログを見ることはできなかった。
Widgetがいつのまにか画面から消えていて、一覧から追加しようにも出なくなる、ということがあり得て、そうなったらclean buildをするとしれっと復活する。
壊れ方にも何段階かあるようで、見えてないだけで存在するのかその場所に新たなwidgetが置けなくなる、とかがあった。
こまったらclean build folder でもう一度ビルドすると良い。
エラーハンドリング
ミスるとよく無音で即死する。
widgetの更新とかは特に、 OSが管理してくれているEntryの更新などの完全に見えないところで即死するので、ログを出すようにしないとマジできつかった。
WidgetへのインプットがAppに届くのをアップストリーム、逆をダウンストリームとすると、
アップストリームは次のパターンがある
なんでもいいから触った
Linkが関わっているボタンを押した
userDefaultsの中身を書き換えるボタンを押した
ダウンストリームは次のパターンがある
(URL Schemeが来たとか何かがあったので)reloadAllTimelines関数をぶっ叩く→WidgetのgetTimeline関数がぶっ叩かれる
ダウンが厄介で、Entry更新メソッドはreloadしかしてくれないので、特に情報を付与できない。
App側でuserDefaultsに何かを書き込み、書き込んだ後でEntry更新メソッドをぶっ叩く、という形にすると何とかなる。
というかこれ以外の方法がないのでは、、、?
userDefaultsが読めるけど取得できる値が間違っている
例えばbool値でtrueを書き込んだはずが、falseしか取得できない。
ファイル読み込みに対してエラーが出ているわけでもない。
こういう時に疑うといいのは、「userDefaultsに干渉したいすべてのTargetに対して、AppGroupが個別に登録されているかどうか」
なんと、どれか一つにだけついてない、とかが発生すると、アクセスはでき、Readもエラーなくできるが、正しい値を読めないという特殊な状態がある。
AppとWidgetを横断するデータに対して、AppからuserDefaultsに書き込んで、読めてるはずだし書き込めてないのか?と思ったら、書いた側でreadしたら正しい値が入っている。
で、Widget側でreadすると、read時のファイルアクセスエラーは出ていないし、キーもあるから値が読めるが、肝心の値が常にfalseになる。
ということで、もしかして?ってなって確認したら、ビンゴだった。
いやー、油断した。
getTimelineが発生するタイミングがカオス
これはまだ規則性の全てを見切っているわけではないが、どうやらAppでreloadAllTimelinesを実行した後、即座にwidget側のgetTimelineが呼ばれる、というのは確定していない。
App側の起動のトリガーになった場合はreloadAllTimelines→ getTimelineの時間差がほぼないが、
そうでない場合=Appが開いてからApp側でreloadAllTimelinesをやってから、widget側のgetTimelineの実行までにはかなり個性的な時間差がある。2秒からそれ以上になることもある。
おそらくgetTimeline関数の実行はOS側によってスケジュールされていて、reloadAllTimelinesが予約に過ぎないんだろうと思う。
で、これは「Appを起動する初回については常に最速」なのだけれど、実際にはAppを「アクティブにした」実績があれば最速っぽく、
Appをhideしてから再度widgetからAppを呼び出し、reloadAllTimelinesをじっこうすると、最速でgetTimelineが発生するのを確認した。面白い。
とりあえずざっくりと。これで自由にデスクトップに家のスイッチが置ける。