Skip to content

bug: ユーザ辞書編集後に設定画面の「N件のアイテム」表示が更新されない#328

Open
itouuuuuuuuu wants to merge 1 commit into
azooKey:mainfrom
itouuuuuuuuu:fix/user-dictionary-count-update
Open

bug: ユーザ辞書編集後に設定画面の「N件のアイテム」表示が更新されない#328
itouuuuuuuuu wants to merge 1 commit into
azooKey:mainfrom
itouuuuuuuuu:fix/user-dictionary-count-update

Conversation

@itouuuuuuuuu
Copy link
Copy Markdown
Contributor

問題

Closes #327

azooKey 設定画面の「azooKeyユーザ辞書」セクションに表示される 「N件のアイテム」 のカウントが、ユーザ辞書編集ウィンドウで項目を追加・削除しても更新されない不具合の修正です。
設定画面で別の操作をして view が再描画されると最新の件数になるため、データの保存自体は行われていることが確認できます。

原因

@ConfigState プロパティラッパーの内部実装と、ユーザ辞書編集ウィンドウ側の書き込みパターンの組み合わせが原因でした。

修正前の @ConfigState は、内部に @State private var underlyingState: Item.Value を保持し、projectedValue(Binding)経由の書き込みでのみ underlyingState を更新する設計になっていました。

// 修正前
@propertyWrapper
struct ConfigState<Item: ConfigItem>: DynamicProperty {
    @State private var underlyingState: Item.Value

    init(wrappedValue: Item) {
        self._underlyingState = .init(initialValue: wrappedValue.value)
        self.wrappedValue = wrappedValue
    }

    var wrappedValue: Item
    var projectedValue: Binding<Item.Value> {
        Binding(
            get: { self.underlyingState },
            set: {
                self.underlyingState = $0
                self.wrappedValue.value = $0   // UserDefaults 書き込み
            }
        )
    }
}

設定画面 (ConfigWindow) と編集ウィンドウ (UserDictionaryEditorWindow) は AppDelegate でそれぞれ独立した NSHostingController として生成されるため、同じ Config.UserDictionary を参照していてもそれぞれ別々の @ConfigState インスタンス(= 別々の @State)を持ちます。

一方、編集ウィンドウ側は Binding を経由せず 直接 mutation していました:

// 修正前(UserDictionaryEditorWindow.swift)
self.userDictionary.value.items.append(newItem)

これは Item.value の computed property setter 経由で UserDefaults だけを更新する経路です。設定画面側の @State は更新されないうえ、設定画面には body 再評価のトリガーが何も発生しないため、Text("\(self.userDictionary.value.items.count)件のアイテム") は古い @State の世代のまま再描画されず、件数表示が更新されない症状が発生していました。

なお、編集ウィンドウ自身は同じハンドラ内で editTargetID / undoItem (@State) を更新していたため、たまたま body が再評価されて表示が更新される副次的挙動になっており、「自分の画面では更新されるのに、設定画面では更新されない」という挙動になっていました。

修正内容

1. @ConfigState@StateObject + ConfigStateStore ベースに変更

azooKeyMac/Windows/ConfigState.swift の内部実装を、@State ベースから @StateObject で所有する ObservableObject ストアに置き換えました。

@MainActor
private final class ConfigStateStore<Item: ConfigItem>: ObservableObject {
    @Published private(set) var value: Item.Value

    private let item: Item
    private var observer: NSObjectProtocol?

    init(item: Item) {
        self.item = item
        self.value = item.value

        self.observer = NotificationCenter.default.addObserver(
            forName: UserDefaults.didChangeNotification,
            object: UserDefaults.standard,
            queue: .main
        ) { [weak self] _ in
            Task { @MainActor in
                self?.reload()
            }
        }
    }
    // ...
}
  • UserDefaults.didChangeNotification を購読することで、別ウィンドウからの書き込み・直 value mutation を含むプロセス内のあらゆる UserDefaults 更新store.value を reload します。@Published 経由で objectWillChange が発火し、subscribe 側の view が再評価されるため、別ウィンドウで行われた変更も即時反映されます。
  • ConfigStateStore は SwiftUI-facing な UI state を持つため @MainActor で隔離。queue: .main で main thread に乗りますが、型レベルの isolation のため Task { @MainActor in } で hop しています。MainActor.assumeIsolated を使えば 1 hop 減らせますが macOS 14+ なので採用していません。
  • observer は [weak self] で循環参照を回避し、@StateObject の lifetime に追従して deinit で removeObserver します。

2. ユーザ辞書編集ウィンドウの mutation を updateUserDictionary ヘルパに集約

azooKeyMac/Windows/UserDictionaryEditorWindow.swift の追加・削除・編集・undo の各処理を、$userDictionary (Binding) 経由の read-modify-write に統一しました。

private func updateUserDictionary(_ transform: (inout Config.UserDictionary.Value) -> Void) {
    var value = self.$userDictionary.wrappedValue
    transform(&value)
    self.$userDictionary.wrappedValue = value
}

// 使い方
Button("追加", systemImage: "plus") {
    let newItem = Config.UserDictionaryEntry(word: "", reading: "", hint: nil)
    self.updateUserDictionary { value in
        value.items.append(newItem)
    }
    // ...
}

Binding setter 経由で書き込むことで store.set@Published value 更新 → UserDefaults 書き込み が同期的に行われ、通知の reload を待たずに自 view の即時反映も担保されます。

3. read 経路を userDictionaryValue (= $userDictionary.wrappedValue) に統一

editor 内の isAdditionDisabled / itemBinding.get / Table(...) の参照も in-memory な store 経由に揃えました。

private var userDictionaryValue: Config.UserDictionary.Value {
    self.$userDictionary.wrappedValue
}

private var isAdditionDisabled: Bool {
    self.userDictionaryValue.items.count >= 50
}

これまで self.userDictionary.value.items.count のように Item.value の computed property(毎回 UserDefaults を JSON decode)経由で読んでいた箇所を、store.value を直接読む形に変更。「表示は store、保存は Binding setter」という構図が明確になり、body 評価ごとのデコードコストも軽減されます。

動作確認

  • xcodebuild -scheme azooKeyMac -configuration Debug build CODE_SIGNING_ALLOWED=NOBUILD SUCCEEDED
  • 設定画面と編集ウィンドウを開いた状態で、編集ウィンドウから項目を追加すると設定画面の件数表示が即時に増えることを目視確認
  • 編集ウィンドウから項目を削除すると設定画面の件数表示が即時に減ることを目視確認
  • 削除直後の 「元に戻す」 ボタンで件数表示が元に戻ることを目視確認
  • 既存項目の編集(単語・読み・ヒントの変更)が反映され、件数が変わらないことを確認

設定画面に表示される "N件のアイテム" のカウントが、ユーザ辞書編集ウィンドウで
項目を追加・削除しても古いまま残るバグを修正する。

原因:
@ConfigState は内部で @State (underlyingState) を保持していたが、ConfigWindow と
UserDictionaryEditorWindow はそれぞれ独立した @ConfigState インスタンスを持つ。
編集ウィンドウは self.userDictionary.value.items.append(...) のように Binding を
経由しない直接 mutation で UserDefaults だけを更新しており、別ウィンドウである
ConfigWindow の @State は同期されない。ConfigWindow には body 再評価のトリガーが
ないため、件数表示が更新されなかった。

修正:
1. ConfigState を @StateObject + ConfigStateStore (ObservableObject) に置き換え、
   UserDefaults.didChangeNotification を購読してプロセス内のあらゆる UserDefaults
   更新で store.value を reload する。これにより別ウィンドウからの書き込みでも
   View の再描画がトリガーされる。
2. ConfigStateStore は SwiftUI-facing な UI state を持つため @mainactor で隔離。
   通知 callback は queue: .main で main thread に乗るが、型レベル isolation の
   ため Task { @mainactor in ... } で hop する (MainActor.assumeIsolated は
   macOS 14+ のため未採用、deployment target は macOS 12/13)。
3. UserDictionaryEditorWindow の追加・削除・編集処理を updateUserDictionary
   ヘルパに集約し、必ず $userDictionary (Binding) 経由で書き込むようにした。
   store と UserDefaults が同期更新され、同一 view 内の即時反映も担保される。
4. 同 view の read 経路も userDictionaryValue computed property
   (= $userDictionary.wrappedValue) に統一し、表示は store / 保存は Binding
   setter という構図を明確化。Item.value (毎回 UserDefaults を JSON decode する
   computed property) 経由の参照を排除した。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@itouuuuuuuuu
Copy link
Copy Markdown
Contributor Author

すみません、こちらまだベータ版の機能だったんですね。
もし修正不要ならIssueとPRはクローズしてください。

@ensan-hcl
Copy link
Copy Markdown
Member

ありがとうございます、簡単に治るならmergeしてたんですが、解決される課題に対して変更が結構デカいのでちょっと悩み中です

@itouuuuuuuuu
Copy link
Copy Markdown
Contributor Author

ご確認ありがとうございます…!

@ConfigState は他の設定項目でも使われている共有プロパティラッパーで、別ウィンドウから UserDefaults を書き換えても元ウィンドウが更新されないという問題はユーザ辞書以外でも起こり得るため、同種バグの再発防止のため変更が大きくなってしまったかもしれません 🙇‍♂️
一旦ユーザ辞書に絞った修正も可能です。
または本機能はベータ版ということで、今後全体的に修正する予定などあればIssue/PR自体クローズでもOKです。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: ユーザ辞書編集後に設定画面の「N件のアイテム」表示が更新されない

2 participants