diff --git a/entry/src/main/ets/components/AppCard.ets b/entry/src/main/ets/components/AppCard.ets index cea304d..d4160bd 100644 --- a/entry/src/main/ets/components/AppCard.ets +++ b/entry/src/main/ets/components/AppCard.ets @@ -22,7 +22,7 @@ const CARD_TITLE_HEIGHT = 40; */ @Component export struct AppCard { - @ObjectLink app: ObservableApp; + @ObjectLink @Watch('onAppChanged') app: ObservableApp; onTap: () => void = () => {}; onLongPress: () => void = () => {}; onSelect: () => void = () => {}; // 选中回调(用于更新背景图) @@ -30,6 +30,18 @@ export struct AppCard { @State private isPressed: boolean = false; @State private isFocused: boolean = false; @State private imageLoadFailed: boolean = false; + private lastIconPath: string = ''; + + /** + * 卡片实例在刷新时会被复用(ForEach key 只含 id), + * 图标路径换了就清掉上一次的加载失败标记,否则会一直显示占位图。 + */ + private onAppChanged(): void { + if (this.app.localIconPath !== this.lastIconPath) { + this.lastIconPath = this.app.localIconPath; + this.imageLoadFailed = false; + } + } build() { Column() { diff --git a/entry/src/main/ets/pages/AppListPageV2.ets b/entry/src/main/ets/pages/AppListPageV2.ets index 498cd08..012d923 100644 --- a/entry/src/main/ets/pages/AppListPageV2.ets +++ b/entry/src/main/ets/pages/AppListPageV2.ets @@ -71,6 +71,7 @@ struct AppListPageV2 { private loadAttempt: number = 0; // 当前加载尝试次数 private runningPollTimerId: number = -1; // 退游戏后运行状态轮询定时器 private runningPollAttempt: number = 0; + private refreshInFlight: Promise | null = null; // 进行中的刷新,用于合并重复请求 // 空列表重试常量(匹配 Android EMPTY_LIST_THRESHOLD) private static readonly EMPTY_LIST_THRESHOLD = 3; @@ -463,9 +464,42 @@ struct AppListPageV2 { } } + /** + * 刷新应用列表 + * + * 同一时刻只允许一次实际请求:连点刷新按钮、或后台轮询与手动刷新撞车时, + * 后来者搭上进行中的那次,而不是并发打多份 getServerInfo / getAppList。 + * 后来者若是手动刷新,仍会显示下拉指示条并等到请求结束。 + */ private async refreshApps(silent: boolean = false): Promise { - this.isRefreshing = true; - + // 静默刷新不驱动 Refresh 组件:否则后台轮询每一轮都会让下拉指示条 + // 展开又收起,列表被顶下去再弹回来 + if (!silent) { + this.isRefreshing = true; + } + + let pending: Promise; + if (this.refreshInFlight) { + pending = this.refreshInFlight; + } else { + pending = this.doRefreshApps(silent); + this.refreshInFlight = pending; + } + + try { + await pending; + } finally { + if (this.refreshInFlight === pending) { + this.refreshInFlight = null; + } + if (!silent) { + this.isRefreshing = false; + } + } + } + + /** refreshApps 的实际实现,不要直接调用——走 refreshApps 才有防重入 */ + private async doRefreshApps(silent: boolean): Promise { try { const computer = this.computerManager.getComputer(this.computerId); if (computer && computer.pairState !== PairState.PAIRED) { @@ -489,6 +523,16 @@ struct AppListPageV2 { const apps = await this.getAppListWithRetry('刷新'); + // 静默刷新拿到空列表多半是服务端瞬时故障,保留现有列表, + // 否则后台轮询会把整页闪成"没有可用的应用"。 + // 但运行状态要按刚拿到的 serverInfo 同步——否则退游戏后的收敛轮询 + // 靠 hasRunningApp 判断,会一直等不到收敛。 + if (apps.length === 0 && silent && this.viewModel.appCount > 0) { + console.warn('AppListPageV2: 静默刷新返回空列表,保留现有数据'); + this.viewModel.syncRunningState(computer?.runningGameId || 0); + return; + } + // 标记正在运行的应用 this.markRunningApps(apps, computer?.runningGameId || 0); @@ -509,8 +553,6 @@ struct AppListPageV2 { } else { console.warn('AppListPageV2: 刷新失败(静默):', errMsg); } - } finally { - this.isRefreshing = false; } } @@ -1007,7 +1049,9 @@ struct AppListPageV2 { onMoreAction: () => this.openAppMenu(app) }) } - }, (app: ObservableApp) => `${app.id}_${app.isRunning}_${app.iconLoaded}`) + // key 只用 id:isRunning / iconLoaded 变化由 @ObjectLink 就地更新, + // 放进 key 会让每次状态变化都销毁重建列表项,表现为刷新时整列跳动 + }, (app: ObservableApp) => `${app.id}`) } .width('100%') .height('100%') @@ -1041,12 +1085,12 @@ struct AppListPageV2 { this.updateBackgroundForApp(app); } }) - }, (app: ObservableApp) => `${app.id}_${app.isRunning}_${app.iconLoaded}`) + }, (app: ObservableApp) => `${app.id}`) } .margin({ bottom: rowIndex < this.rowCount - 1 ? AppSpacing.Medium : 0 }) }, (rowApps: ObservableApp[], rowIndex: number) => { - // 行的 key 需要包含该行所有应用的 ID 和运行状态 - const appIds = rowApps.map(a => `${a.id}:${a.isRunning ? 1 : 0}`).join(','); + // 行 key 只反映"这一行有哪些应用",不含 isRunning 等易变状态 + const appIds = rowApps.map(a => a.id).join(','); return `row_${rowIndex}_apps_${appIds}`; }) } diff --git a/entry/src/main/ets/viewmodel/AppViewModel.ets b/entry/src/main/ets/viewmodel/AppViewModel.ets index 62bb1f9..f5bd309 100644 --- a/entry/src/main/ets/viewmodel/AppViewModel.ets +++ b/entry/src/main/ets/viewmodel/AppViewModel.ets @@ -83,61 +83,93 @@ export class AppListViewModel { // 排序方式 sortBy: 'name' | 'recent' = 'name'; - // 图标加载取消标志 - private iconLoadingCancelled: boolean = false; - + // 图标加载代际标记:每次 setApps / cancelIconLoading 自增, + // 旧的串行加载循环在 await 恢复后发现代际已变即自行退出(避免多个循环并发跑) + private iconLoadGeneration: number = 0; + /** * 设置应用列表(带图标 URL) + * + * 增量合并:按 id 复用已有的 ObservableApp 实例,只更新变化的字段。 + * 不能全量重建——那会把 localIconPath/iconLoaded 清空,导致每次刷新 + * 整个列表先闪回占位符再逐个恢复图标(视觉上的"跳动")。 + * * @param entries 应用列表 * @param nvHttp NvHttp 实例(用于生成图标 URL 和加载图标) */ setApps(entries: AppEntry[], nvHttp?: NvHttp): void { - // 取消之前的图标加载任务 - this.iconLoadingCancelled = true; - // 查找正在运行的应用 const runningApps = entries.filter(e => e.isRunning); console.info(`AppListViewModel: 加载 ${entries.length} 个应用, 正在运行: ${runningApps.map(a => `${a.name}(${a.id})`).join(', ') || '无'}`); + const existing: Map = new Map(); + for (const app of this.apps) { + existing.set(app.id, app); + } + this.apps = entries.map(entry => { const iconUrl = nvHttp ? nvHttp.getBoxArtUrl(entry.id) : ''; + const reused = existing.get(entry.id); + if (reused) { + // 复用实例:@ObjectLink 会把变化推给组件,无需重建 UI 节点 + reused.updateFrom(entry, iconUrl); + return reused; + } return new ObservableApp(entry, iconUrl); }); this.updateFilteredApps(); this.updateRunningApp(); - // 异步加载所有图标 + // 异步加载尚未拿到图标的应用 if (nvHttp) { - this.iconLoadingCancelled = false; - this.loadAllIcons(nvHttp); + this.loadAllIcons(nvHttp, ++this.iconLoadGeneration); + } + } + + /** + * 按最新的 runningGameId 同步现有列表的运行状态 + * + * 用于拿不到新 applist、但已知服务端运行状态的场合(如静默刷新返回空列表时 + * 保留旧数据):不同步的话 hasRunningApp 会一直停在旧值,退游戏后的收敛轮询 + * 就永远等不到收敛。 + */ + syncRunningState(runningGameId: number): void { + for (const app of this.apps) { + app.isRunning = runningGameId > 0 && app.id === runningGameId; } + this.updateRunningApp(); } /** * 取消正在进行的图标加载 */ cancelIconLoading(): void { - this.iconLoadingCancelled = true; + this.iconLoadGeneration++; console.info(`AppListViewModel: 图标加载已取消`); } /** * 异步加载所有应用图标(串行加载,避免并发过多) + * 已加载成功的图标会被跳过,刷新时不会重新拉取。 */ - private async loadAllIcons(nvHttp: NvHttp): Promise { - console.info(`AppListViewModel: 开始加载 ${this.apps.length} 个应用图标`); + private async loadAllIcons(nvHttp: NvHttp, generation: number): Promise { + const pending = this.apps.filter(app => !app.iconLoaded); + if (pending.length === 0) { + return; + } + console.info(`AppListViewModel: 开始加载 ${pending.length} 个应用图标`); // 串行加载图标,避免并发过多导致卡顿 - for (const app of this.apps) { - // 检查是否已取消 - if (this.iconLoadingCancelled) { + for (const app of pending) { + // 检查是否已被更新的一轮加载取代 + if (this.iconLoadGeneration !== generation) { console.info(`AppListViewModel: 图标加载被取消,停止加载`); return; } try { const localPath = await nvHttp.getAppAssetCached(app.id); - if (localPath && !this.iconLoadingCancelled) { + if (localPath && this.iconLoadGeneration === generation) { app.setLocalIconPath(localPath); console.info(`AppListViewModel: 图标加载成功 ${app.name}`); } @@ -146,7 +178,7 @@ export class AppListViewModel { } } - if (!this.iconLoadingCancelled) { + if (this.iconLoadGeneration === generation) { console.info(`AppListViewModel: 所有图标加载完成`); } }