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

View File

@ -5,12 +5,124 @@ import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { BrowserWindow, ipcMain } from 'electron' import { BrowserWindow, ipcMain } from 'electron'
import { EventEmitter } from 'events'
import { preferenceTable } from './db/schemas/preference' import { preferenceTable } from './db/schemas/preference'
const logger = loggerService.withContext('PreferenceService') 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 } type MultiPreferencesResultType<K extends PreferenceKeyType> = { [P in K]: PreferenceDefaultScopeType[P] | undefined }
const DefaultScope = 'default' const DefaultScope = 'default'
@ -34,8 +146,8 @@ export class PreferenceService {
private static isIpcHandlerRegistered = false private static isIpcHandlerRegistered = false
// EventEmitter for main process change notifications // Custom notifier for main process change notifications
private mainEventEmitter = new EventEmitter() private notifier = new PreferenceNotifier()
private constructor() { private constructor() {
this.setupWindowCleanup() this.setupWindowCleanup()
@ -94,20 +206,6 @@ export class PreferenceService {
return this.cache[key] ?? DefaultPreferences.default[key] 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 * Set a single preference value
* Updates both database and memory cache, then broadcasts changes to all listeners * 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.notifier.subscribe(key, listener, `subscribeChange-${key}`)
return () => {
this.mainEventEmitter.off('preference-changed', listener)
}
} }
/** /**
@ -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 () => { 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 * Remove all main process listeners for cleanup
*/ */
public removeAllChangeListeners(): void { public removeAllChangeListeners(): void {
this.mainEventEmitter.removeAllListeners('preference-changed') this.notifier.removeAllSubscriptions()
logger.debug('Removed all main process preference listeners') logger.debug('Removed all main process preference listeners')
} }
@ -323,7 +421,28 @@ export class PreferenceService {
* Get main process listener count for debugging * Get main process listener count for debugging
*/ */
public getChangeListenerCount(): number { 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> { private async notifyChange(key: string, value: any, oldValue?: any): Promise<void> {
// 1. Notify main process listeners // 1. Notify main process listeners
this.mainEventEmitter.emit('preference-changed', key, value, oldValue) this.notifier.notify(key, value, oldValue)
// 2. Notify renderer process windows // 2. Notify renderer process windows
const affectedWindows: number[] = [] const affectedWindows: number[] = []

View File

@ -76,6 +76,8 @@ export class SelectionService {
private filterMode = 'default' private filterMode = 'default'
private filterList: string[] = [] private filterList: string[] = []
private unsubscriberForChangeListeners: (() => void)[] = []
private toolbarWindow: BrowserWindow | null = null private toolbarWindow: BrowserWindow | null = null
private actionWindows = new Set<BrowserWindow>() private actionWindows = new Set<BrowserWindow>()
private preloadedActionWindows: BrowserWindow[] = [] private preloadedActionWindows: BrowserWindow[] = []
@ -143,13 +145,15 @@ export class SelectionService {
* Ensures UI elements scale properly with system DPI settings * Ensures UI elements scale properly with system DPI settings
*/ */
private initZoomFactor(): void { private initZoomFactor(): void {
const zoomFactor = preferenceService.getAndSubscribeChange('app.zoom_factor', (zoomFactor: number) => { const zoomFactor = preferenceService.get('app.zoom_factor')
this.setZoomFactor(zoomFactor)
})
if (zoomFactor) { if (zoomFactor) {
this.setZoomFactor(zoomFactor) this.setZoomFactor(zoomFactor)
} }
preferenceService.subscribeChange('app.zoom_factor', (zoomFactor: number) => {
this.setZoomFactor(zoomFactor)
})
} }
public setZoomFactor = (zoomFactor: number) => { public setZoomFactor = (zoomFactor: number) => {
@ -157,9 +161,17 @@ export class SelectionService {
} }
private initConfig(): void { private initConfig(): void {
this.triggerMode = preferenceService.getAndSubscribeChange( this.triggerMode = preferenceService.get('feature.selection.trigger_mode') as TriggerMode
'feature.selection.trigger_mode', this.isFollowToolbar = preferenceService.get('feature.selection.follow_toolbar')
(triggerMode: string) => { this.isRemeberWinSize = preferenceService.get('feature.selection.remember_win_size')
this.filterMode = preferenceService.get('feature.selection.filter_mode')
this.filterList = preferenceService.get('feature.selection.filter_list')
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
this.setHookFineTunedList()
this.unsubscriberForChangeListeners.push(
preferenceService.subscribeChange('feature.selection.trigger_mode', (triggerMode: string) => {
const oldTriggerMode = this.triggerMode as TriggerMode const oldTriggerMode = this.triggerMode as TriggerMode
this.triggerMode = triggerMode as TriggerMode this.triggerMode = triggerMode as TriggerMode
@ -169,17 +181,15 @@ export class SelectionService {
if (oldTriggerMode !== triggerMode) { if (oldTriggerMode !== triggerMode) {
this.setHookGlobalFilterMode(this.filterMode, this.filterList) this.setHookGlobalFilterMode(this.filterMode, this.filterList)
} }
} })
) as TriggerMode
this.isFollowToolbar = preferenceService.getAndSubscribeChange(
'feature.selection.follow_toolbar',
(followToolbar: boolean) => {
this.isFollowToolbar = followToolbar
}
) )
this.isRemeberWinSize = preferenceService.getAndSubscribeChange( this.unsubscriberForChangeListeners.push(
'feature.selection.remember_win_size', preferenceService.subscribeChange('feature.selection.follow_toolbar', (followToolbar: boolean) => {
(rememberWinSize: boolean) => { this.isFollowToolbar = followToolbar
})
)
this.unsubscriberForChangeListeners.push(
preferenceService.subscribeChange('feature.selection.remember_win_size', (rememberWinSize: boolean) => {
this.isRemeberWinSize = rememberWinSize this.isRemeberWinSize = rememberWinSize
//when off, reset the last action window size to default //when off, reset the last action window size to default
if (!this.isRemeberWinSize) { if (!this.isRemeberWinSize) {
@ -188,22 +198,20 @@ export class SelectionService {
height: this.ACTION_WINDOW_HEIGHT height: this.ACTION_WINDOW_HEIGHT
} }
} }
} })
) )
this.filterMode = preferenceService.getAndSubscribeChange('feature.selection.filter_mode', (filterMode: string) => { this.unsubscriberForChangeListeners.push(
this.filterMode = filterMode preferenceService.subscribeChange('feature.selection.filter_mode', (filterMode: string) => {
this.setHookGlobalFilterMode(this.filterMode, this.filterList) this.filterMode = filterMode
}) this.setHookGlobalFilterMode(this.filterMode, this.filterList)
this.filterList = preferenceService.getAndSubscribeChange( })
'feature.selection.filter_list', )
(filterList: string[]) => { this.unsubscriberForChangeListeners.push(
preferenceService.subscribeChange('feature.selection.filter_list', (filterList: string[]) => {
this.filterList = filterList this.filterList = filterList
this.setHookGlobalFilterMode(this.filterMode, this.filterList) this.setHookGlobalFilterMode(this.filterMode, this.filterList)
} })
) )
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
this.setHookFineTunedList()
} }
/** /**
@ -342,9 +350,13 @@ export class SelectionService {
if (!this.selectionHook) return false if (!this.selectionHook) return false
this.selectionHook.stop() this.selectionHook.stop()
this.selectionHook.cleanup() //already remove all listeners this.selectionHook.cleanup() //already remove all listeners
for (const unsubscriber of this.unsubscriberForChangeListeners) {
unsubscriber()
}
this.unsubscriberForChangeListeners = []
//reset the listener states //reset the listener states
this.isCtrlkeyListenerActive = false this.isCtrlkeyListenerActive = false
this.isHideByMouseKeyListenerActive = false this.isHideByMouseKeyListenerActive = false
@ -1532,7 +1544,9 @@ export class SelectionService {
export function initSelectionService(): boolean { export function initSelectionService(): boolean {
if (!isSupportedOS) return false if (!isSupportedOS) return false
const enabled = preferenceService.getAndSubscribeChange('feature.selection.enabled', (enabled: boolean): void => { const enabled = preferenceService.get('feature.selection.enabled')
preferenceService.subscribeChange('feature.selection.enabled', (enabled: boolean): void => {
//avoid closure //avoid closure
const ss = SelectionService.getInstance() const ss = SelectionService.getInstance()
if (!ss) { if (!ss) {