mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-08 22:39:36 +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 { 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[] = []
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user