Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion entry/src/main/ets/components/AppCard.ets
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,26 @@ const CARD_TITLE_HEIGHT = 40;
*/
@Component
export struct AppCard {
@ObjectLink app: ObservableApp;
@ObjectLink @Watch('onAppChanged') app: ObservableApp;
onTap: () => void = () => {};
onLongPress: () => void = () => {};
onSelect: () => void = () => {}; // 选中回调(用于更新背景图)

@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() {
Expand Down
60 changes: 52 additions & 8 deletions entry/src/main/ets/pages/AppListPageV2.ets
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ struct AppListPageV2 {
private loadAttempt: number = 0; // 当前加载尝试次数
private runningPollTimerId: number = -1; // 退游戏后运行状态轮询定时器
private runningPollAttempt: number = 0;
private refreshInFlight: Promise<void> | null = null; // 进行中的刷新,用于合并重复请求

// 空列表重试常量(匹配 Android EMPTY_LIST_THRESHOLD)
private static readonly EMPTY_LIST_THRESHOLD = 3;
Expand Down Expand Up @@ -463,9 +464,42 @@ struct AppListPageV2 {
}
}

/**
* 刷新应用列表
*
* 同一时刻只允许一次实际请求:连点刷新按钮、或后台轮询与手动刷新撞车时,
* 后来者搭上进行中的那次,而不是并发打多份 getServerInfo / getAppList。
* 后来者若是手动刷新,仍会显示下拉指示条并等到请求结束。
*/
private async refreshApps(silent: boolean = false): Promise<void> {
this.isRefreshing = true;

// 静默刷新不驱动 Refresh 组件:否则后台轮询每一轮都会让下拉指示条
// 展开又收起,列表被顶下去再弹回来
if (!silent) {
this.isRefreshing = true;
}

let pending: Promise<void>;
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<void> {
try {
const computer = this.computerManager.getComputer(this.computerId);
if (computer && computer.pairState !== PairState.PAIRED) {
Expand All @@ -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);

Expand All @@ -509,8 +553,6 @@ struct AppListPageV2 {
} else {
console.warn('AppListPageV2: 刷新失败(静默):', errMsg);
}
} finally {
this.isRefreshing = false;
}
}

Expand Down Expand Up @@ -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%')
Expand Down Expand Up @@ -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}`;
})
}
Expand Down
66 changes: 49 additions & 17 deletions entry/src/main/ets/viewmodel/AppViewModel.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, ObservableApp> = 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<void> {
console.info(`AppListViewModel: 开始加载 ${this.apps.length} 个应用图标`);
private async loadAllIcons(nvHttp: NvHttp, generation: number): Promise<void> {
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}`);
}
Expand All @@ -146,7 +178,7 @@ export class AppListViewModel {
}
}

if (!this.iconLoadingCancelled) {
if (this.iconLoadGeneration === generation) {
console.info(`AppListViewModel: 所有图标加载完成`);
}
}
Expand Down
Loading