feat(preferences): implement custom notifier for preference change management

- Introduced `PreferenceNotifier` class to replace `EventEmitter`, enhancing performance and memory efficiency for preference change notifications.
- Refactored `PreferenceService` to utilize the new notifier for managing subscriptions and notifications.
- Updated `SelectionService` to adopt the new subscription model, improving the handling of preference changes and ensuring proper cleanup of listeners.
- Enhanced subscription statistics and debugging capabilities within the notifier.
This commit is contained in:
fullex
2025-08-15 19:45:13 +08:00
parent 3dd2bc1a40
commit 087e825086
2 changed files with 190 additions and 57 deletions
+146 -27
View File
@@ -5,12 +5,124 @@ import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data
import { IpcChannel } from '@shared/IpcChannel'
import { and, eq } from 'drizzle-orm'
import { BrowserWindow, ipcMain } from 'electron'
import { EventEmitter } from 'events'
import { preferenceTable } from './db/schemas/preference'
const logger = loggerService.withContext('PreferenceService')
/**
* Custom observer pattern implementation for preference change notifications
* Replaces EventEmitter to avoid listener limits and improve performance
* Optimized for memory efficiency and this binding safety
*/
class PreferenceNotifier {
private subscriptions = new Map<string, Set<(key: string, newValue: any, oldValue?: any) => void>>()
/**
* Subscribe to preference changes for a specific key
* Uses arrow function to ensure proper this binding
* @param key - The preference key to watch
* @param callback - Function to call when the preference changes
* @param metadata - Optional metadata for debugging (unused but kept for API compatibility)
* @returns Unsubscribe function
*/
subscribe = (
key: string,
callback: (key: string, newValue: any, oldValue?: any) => void,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_metadata?: string
): (() => void) => {
if (!this.subscriptions.has(key)) {
this.subscriptions.set(key, new Set())
}
const keySubscriptions = this.subscriptions.get(key)!
keySubscriptions.add(callback)
logger.debug(`Added subscription for ${key}, total for this key: ${keySubscriptions.size}`)
// Return unsubscriber with proper this binding
return () => {
const currentKeySubscriptions = this.subscriptions.get(key)
if (currentKeySubscriptions) {
currentKeySubscriptions.delete(callback)
if (currentKeySubscriptions.size === 0) {
this.subscriptions.delete(key)
logger.debug(`Removed last subscription for ${key}, cleaned up key`)
} else {
logger.debug(`Removed subscription for ${key}, remaining: ${currentKeySubscriptions.size}`)
}
}
}
}
/**
* Notify all subscribers of a preference change
* Uses arrow function to ensure proper this binding
* @param key - The preference key that changed
* @param newValue - The new value
* @param oldValue - The previous value
*/
notify = (key: string, newValue: any, oldValue?: any): void => {
const keySubscriptions = this.subscriptions.get(key)
if (keySubscriptions && keySubscriptions.size > 0) {
logger.debug(`Notifying ${keySubscriptions.size} subscribers for preference ${key}`)
keySubscriptions.forEach((callback) => {
try {
callback(key, newValue, oldValue)
} catch (error) {
logger.error(`Error in preference subscription callback for ${key}:`, error as Error)
}
})
}
}
/**
* Get the total number of subscriptions across all keys
*/
getTotalSubscriptionCount = (): number => {
let total = 0
for (const keySubscriptions of this.subscriptions.values()) {
total += keySubscriptions.size
}
return total
}
/**
* Get the number of subscriptions for a specific key
*/
getKeySubscriptionCount = (key: string): number => {
return this.subscriptions.get(key)?.size || 0
}
/**
* Get all subscribed keys
*/
getSubscribedKeys = (): string[] => {
return Array.from(this.subscriptions.keys())
}
/**
* Remove all subscriptions for cleanup
*/
removeAllSubscriptions = (): void => {
const totalCount = this.getTotalSubscriptionCount()
this.subscriptions.clear()
logger.debug(`Removed all ${totalCount} preference subscriptions`)
}
/**
* Get subscription statistics for debugging
*/
getSubscriptionStats = (): Record<string, number> => {
const stats: Record<string, number> = {}
for (const [key, keySubscriptions] of this.subscriptions.entries()) {
stats[key] = keySubscriptions.size
}
return stats
}
}
type MultiPreferencesResultType<K extends PreferenceKeyType> = { [P in K]: PreferenceDefaultScopeType[P] | undefined }
const DefaultScope = 'default'
@@ -34,8 +146,8 @@ export class PreferenceService {
private static isIpcHandlerRegistered = false
// EventEmitter for main process change notifications
private mainEventEmitter = new EventEmitter()
// Custom notifier for main process change notifications
private notifier = new PreferenceNotifier()
private constructor() {
this.setupWindowCleanup()
@@ -94,20 +206,6 @@ export class PreferenceService {
return this.cache[key] ?? DefaultPreferences.default[key]
}
/**
* Get a single preference value from memory cache and subscribe to changes
* @param key - The preference key to get
* @param callback - The callback function to call when the preference changes
* @returns The current value of the preference
*/
public getAndSubscribeChange<K extends PreferenceKeyType>(
key: K,
callback: (newValue: PreferenceDefaultScopeType[K], oldValue?: PreferenceDefaultScopeType[K]) => void
): PreferenceDefaultScopeType[K] {
const value = this.get(key)
this.subscribeChange(key, callback)
return value
}
/**
* Set a single preference value
* Updates both database and memory cache, then broadcasts changes to all listeners
@@ -283,11 +381,7 @@ export class PreferenceService {
}
}
this.mainEventEmitter.on('preference-changed', listener)
return () => {
this.mainEventEmitter.off('preference-changed', listener)
}
return this.notifier.subscribe(key, listener, `subscribeChange-${key}`)
}
/**
@@ -304,10 +398,14 @@ export class PreferenceService {
}
}
this.mainEventEmitter.on('preference-changed', listener)
// Subscribe to all keys and collect unsubscribe functions
const unsubscribeFunctions = keys.map((key) =>
this.notifier.subscribe(key, listener, `subscribeMultipleChanges-${key}`)
)
// Return a function that unsubscribes from all keys
return () => {
this.mainEventEmitter.off('preference-changed', listener)
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe())
}
}
@@ -315,7 +413,7 @@ export class PreferenceService {
* Remove all main process listeners for cleanup
*/
public removeAllChangeListeners(): void {
this.mainEventEmitter.removeAllListeners('preference-changed')
this.notifier.removeAllSubscriptions()
logger.debug('Removed all main process preference listeners')
}
@@ -323,7 +421,28 @@ export class PreferenceService {
* Get main process listener count for debugging
*/
public getChangeListenerCount(): number {
return this.mainEventEmitter.listenerCount('preference-changed')
return this.notifier.getTotalSubscriptionCount()
}
/**
* Get subscription count for a specific preference key
*/
public getKeyListenerCount(key: PreferenceKeyType): number {
return this.notifier.getKeySubscriptionCount(key)
}
/**
* Get all subscribed preference keys
*/
public getSubscribedKeys(): string[] {
return this.notifier.getSubscribedKeys()
}
/**
* Get detailed subscription statistics for debugging
*/
public getSubscriptionStats(): Record<string, number> {
return this.notifier.getSubscriptionStats()
}
/**
@@ -332,7 +451,7 @@ export class PreferenceService {
*/
private async notifyChange(key: string, value: any, oldValue?: any): Promise<void> {
// 1. Notify main process listeners
this.mainEventEmitter.emit('preference-changed', key, value, oldValue)
this.notifier.notify(key, value, oldValue)
// 2. Notify renderer process windows
const affectedWindows: number[] = []