mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-01 01:30:51 +08:00
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:
parent
3dd2bc1a40
commit
087e825086
@ -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[] = []
|
||||
|
||||
@ -76,6 +76,8 @@ export class SelectionService {
|
||||
private filterMode = 'default'
|
||||
private filterList: string[] = []
|
||||
|
||||
private unsubscriberForChangeListeners: (() => void)[] = []
|
||||
|
||||
private toolbarWindow: BrowserWindow | null = null
|
||||
private actionWindows = new Set<BrowserWindow>()
|
||||
private preloadedActionWindows: BrowserWindow[] = []
|
||||
@ -143,13 +145,15 @@ export class SelectionService {
|
||||
* Ensures UI elements scale properly with system DPI settings
|
||||
*/
|
||||
private initZoomFactor(): void {
|
||||
const zoomFactor = preferenceService.getAndSubscribeChange('app.zoom_factor', (zoomFactor: number) => {
|
||||
this.setZoomFactor(zoomFactor)
|
||||
})
|
||||
const zoomFactor = preferenceService.get('app.zoom_factor')
|
||||
|
||||
if (zoomFactor) {
|
||||
this.setZoomFactor(zoomFactor)
|
||||
}
|
||||
|
||||
preferenceService.subscribeChange('app.zoom_factor', (zoomFactor: number) => {
|
||||
this.setZoomFactor(zoomFactor)
|
||||
})
|
||||
}
|
||||
|
||||
public setZoomFactor = (zoomFactor: number) => {
|
||||
@ -157,9 +161,17 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
private initConfig(): void {
|
||||
this.triggerMode = preferenceService.getAndSubscribeChange(
|
||||
'feature.selection.trigger_mode',
|
||||
(triggerMode: string) => {
|
||||
this.triggerMode = preferenceService.get('feature.selection.trigger_mode') as TriggerMode
|
||||
this.isFollowToolbar = preferenceService.get('feature.selection.follow_toolbar')
|
||||
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
|
||||
|
||||
this.triggerMode = triggerMode as TriggerMode
|
||||
@ -169,17 +181,15 @@ export class SelectionService {
|
||||
if (oldTriggerMode !== triggerMode) {
|
||||
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(
|
||||
'feature.selection.remember_win_size',
|
||||
(rememberWinSize: boolean) => {
|
||||
this.unsubscriberForChangeListeners.push(
|
||||
preferenceService.subscribeChange('feature.selection.follow_toolbar', (followToolbar: boolean) => {
|
||||
this.isFollowToolbar = followToolbar
|
||||
})
|
||||
)
|
||||
this.unsubscriberForChangeListeners.push(
|
||||
preferenceService.subscribeChange('feature.selection.remember_win_size', (rememberWinSize: boolean) => {
|
||||
this.isRemeberWinSize = rememberWinSize
|
||||
//when off, reset the last action window size to default
|
||||
if (!this.isRemeberWinSize) {
|
||||
@ -188,22 +198,20 @@ export class SelectionService {
|
||||
height: this.ACTION_WINDOW_HEIGHT
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
this.filterMode = preferenceService.getAndSubscribeChange('feature.selection.filter_mode', (filterMode: string) => {
|
||||
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_mode', (filterMode: string) => {
|
||||
this.filterMode = filterMode
|
||||
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
|
||||
})
|
||||
)
|
||||
this.unsubscriberForChangeListeners.push(
|
||||
preferenceService.subscribeChange('feature.selection.filter_list', (filterList: string[]) => {
|
||||
this.filterList = 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
|
||||
|
||||
this.selectionHook.stop()
|
||||
|
||||
this.selectionHook.cleanup() //already remove all listeners
|
||||
|
||||
for (const unsubscriber of this.unsubscriberForChangeListeners) {
|
||||
unsubscriber()
|
||||
}
|
||||
this.unsubscriberForChangeListeners = []
|
||||
|
||||
//reset the listener states
|
||||
this.isCtrlkeyListenerActive = false
|
||||
this.isHideByMouseKeyListenerActive = false
|
||||
@ -1532,7 +1544,9 @@ export class SelectionService {
|
||||
export function initSelectionService(): boolean {
|
||||
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
|
||||
const ss = SelectionService.getInstance()
|
||||
if (!ss) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user