iOS・macOS

SwiftUIの画面遷移でメインスレッドが20秒ハングしたのでsampleで実測して直した

  • SwiftUI
  • macOS
  • Swift
  • Vigilare
  • パフォーマンス

タスク管理アプリ Vigilare で、タスク詳細画面から一覧へ「戻る」操作をすると、メインスレッドが20秒以上ハングする不具合が起きました。macOSのハングレポートとライブサンプリングで原因のコールスタックを実測し、LazyVStackに渡すForEachのデータソースが描画のたびに新しい配列で作り直されていたことを特定して直すまでの調査記録です。

VigilareFloating Reminders for macOSKeep your Apple Reminders always on top with a floating window for macOS. Works in fullscreen apps, with quick actions, list filtering, and a built-in Markdown editor.

戻るボタンを押すと固まる症状

Vigilareはメニューバーに常駐して、Apple Remindersのタスクをフローティングウィンドウで操作するmacOSアプリです。タスクを複数追加してから詳細画面を開き、戻るボタンで一覧に戻ると、まれにメインスレッドが完全に固まりました。

再現条件を絞り込むと次の手順で起きていました。

  1. 下部の追加ボタンから複数のタスクを続けて追加する
  2. タスク詳細画面を開く
  3. 戻るボタンを押す

100%の確率では再現しませんが、日常的な操作の範囲で発生します。データが壊れることはなく、体感できるほど長いハングだけが起きるのが厄介なところでした。

sampleコマンドでメインスレッドを実測する

固まっている最中に何が起きているかを、macOSのハングレポート機能とsampleコマンドのライブサンプリングの両方で取りました。sampleはInstrumentsの一部ではなく、実行中のプロセスの挙動を一定間隔で記録する単体のCLIツールです(man sample参照)。結果は一致していて、サンプリングした区間のほぼ全てで、メインスレッドがSwiftUIのレイアウト再計算でビジー状態になっていました。

ホットパスはこの並びです。

NSHostingView.beginTransaction
  → GraphHost.flushTransactions
    → AG::Subgraph::update
      → SafeAreaInsets.resolve / ViewTransform.convert(異常に深い再帰)

StackLayoutUnaryLayoutEnginesizeThatFitsexplicitAlignmentが大量に呼ばれており、自社コードのシンボルとしてはタスクをグループ表示するビュー(GroupedReminderListView)だけがスタックに現れていました。レイアウト計算そのものが暴走している状態で、ネットワークやディスクI/Oの待ちではありません。

LazyVStackのForEachに渡す配列が毎回作り直されていた

コールスタックだけでは「なぜ」までは分かりません。SwiftUIのLazyVStackForEachの組み合わせで似た症状の事例を調べたところ、cmuxのGitHub Issueが同じ構図を報告していました。ForEachenumerated()経由で毎回新しく生成した配列を渡すコードがLazyVStackのdiffingを壊し、レイアウトの再計算が終わらないフィードバックループに陥っていたという事例です。データソースが評価のたびに違う配列だと、行の同一性を追うdiffingが機能せず、レイアウトの再計算が連鎖するという構図は、自社コードの状況と一致していました。

自社コードを見直すと、まさに同じ形になっていました。タスクをグループ分けするgroupsがcomputed propertyで、body評価のたびに毎回呼び出されていたのです。

private var groups: [ReminderGroup] {
  ReminderGroupingService.group(viewModel.filteredReminders)
}

groupsはコンテンツが変わっていなくても、bodyが評価されるたびに新しい配列を返します。LazyVStackForEach(groups)は、その新しい配列を「前回と別物」として毎回扱うため、画面内の行すべてでレイアウトの再計算が起きます。詳細画面から戻る操作は、ビューの破棄・再構築・保存処理の通知がまとまって発生するタイミングで、この再計算が連鎖的に積み重なっていました。

@Stateとテストの追加で直す

修正の方針は、groupsをcomputed propertyから@Stateに変えて、データが実際に変化したときだけ再グルーピングすることです。

@State private var groups: [ReminderGroup]

var body: some View {
  Group {
    if groups.isEmpty {
      emptyStateView
    } else {
      listContent
    }
  }
  .onChange(of: viewModel.filteredReminders, initial: true) { _, _ in
    groups = viewModel.groupedReminders()
  }
}

これだけでは1つ見落としがありました。グループの内訳(期限切れ・今日・明日、といった分類)は日付をまたぐと意味が変わりますが、onChangeはデータそのものが変わらないと発火しません。深夜をまたいでも起動しっぱなしのメニューバーアプリでは、日が変わった瞬間に再グルーピングされない問題が残ります。ここはレビューで指摘されて、NSCalendarDayChanged通知を購読して日をまたいだタイミングでも再グルーピングするよう追加しました。

.task {
  for await _ in NotificationCenter.default.notifications(named: .NSCalendarDayChanged) {
    groups = viewModel.groupedReminders()
  }
}

グルーピングのロジック自体もView層からViewModelに移し、日をまたいだ場合の挙動を含めてテストを追加しました。修正後、同じ手順で操作を繰り返してもハングは再現せず、体感できるほど動作が速くなったことを確認しています。

効果を確認したあと、他の画面も点検した

修正が効いたことを確認したところで終わらせず、同じ設計ミスが他の画面にも残っていないかを点検しました。狙いは明確です。groupsが抱えていた欠陥は「computed propertyをLazyVStackのForEachに直接渡している」という形そのものにあるので、同じ形のコードを探せば同じ欠陥が見つかるはずです。

案の定、リスト一覧を検索・絞り込みするための画面で、filteredItemsという別のcomputed propertyが同じ形をしていました。フィルター処理を2回とソートを1回、画面のbody評価のたびに実行していて、画面内の7箇所から参照されている状態です。

private var filteredItems: [ListItem] {
  let source = viewModel.availableListItems.filter { $0.filterType == nil }
  let trimmed = navigationController.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
  let matched = trimmed.isEmpty ? source : source.filter { $0.title.localizedCaseInsensitiveContains(trimmed) }
  return ReminderListSortService.sortByTitle(matched)
}

こちらは実際にハングが報告される前に見つかった段階だったので、同じ@State化とデータ変化時のみ再計算するパターンで先回りして直しました。参照箇所が7つあったため、ハイライト位置の取得やスクロール先の特定など、filteredItemsを参照していた箇所を一つ残らず新しい状態変数に置き換える必要がありました。取りこぼすとインデックスがずれて誤った項目を選択する事故につながるため、参照箇所を洗い出してから一括で置き換えています。

原因を全部特定しきれなくても直せることはある

この調査と並行して、以前から「コメントが長い、または件数が多い状態でスクロールするとハングする」という別の不具合が1か月ほど再現条件のつかめないまま残っていました。

メインスレッドの応答性を一定間隔で計測してみると、コメントの内容が長い場合に数百ミリ秒単位でメインスレッドがブロックされていることが分かりました。ブロックの長さは本文の長さにおおむね比例していて、長文のコメントが並ぶと数秒単位のハングに発展しうる規模でした。

原因の候補は2つ見えていました。1つは、コメント行を表示するコンポーネントが、無関係な状態変化のたびにURLを検出する処理(NSDataDetectorベースの文字列変換)をコメント全行ぶん再計算していたことです。メモ化がなく、呼び出されるたびにゼロから計算し直します。もう1つは、エディタを開いただけで内容の正規化が走り、それが自動保存の引き金になって状態変化の連鎖を起こしていたことです。

自動保存が余計に走る側の原因は特定できたので直しましたが、URL検出のメモ化不足という根本原因のほうは手を付けられていません。それでも自動保存の引き金を止めたことで、体感できる頻度でのハングは起きなくなりました。原因を100%解消したわけではなく、ハングを引き起こしていたきっかけの一つを塞いだことで実用上困らなくなった、という状態です。原因が全部わかっていなくても、何が引き金だったかを言語化できていれば、いったんクローズして次に何かあったときの手がかりを残しておく判断はできます。

残っている課題

URL検出処理のメモ化は未着手のままです。コメントの内容が変わらない限り同じ結果になる処理なので、内容をキーにしたキャッシュを持たせれば、無関係な状態変化のたびに再計算する必要はなくなります。頻度は下がったとはいえ、根本原因ではないので、コメントの表示件数が多いユーザーではまだ発生しうる状態です。

今回のcomputed property問題は、LazyVStackListに配列を渡すコードすべてに当てはまる形です。今回直した2箇所以外にも同じ形が残っていないか、コードレビューの観点として明文化します。今は目視での洗い出しに頼っているので、静的解析での検出も次に試します。