前言

在完成了鸿小易以及NowInOpenHarmony这两个项目的开发之后我们,子安学长将我引荐给了陈若愚老师,陈老师联系到中软国际,想要让我们去开发应用并协助我们将应用上架,这对于我来说可谓是千载难逢的机会了,毕竟我一直渴望能真正上架一个应用,但一致被后端服务器,备案,资质,内容过于简单等等一系列的问题所卡住,这次有了中软的协助我们应该就能更加专注于开发了。

而且这一次,我不再是独立开发,而是有了孙妈以及bqf,zxjc,hyx的协作,五个人的力量,再加上Codex,Claude Code的协助我相信我们一定能成功上架的。

选题

中软的老师确实是给了我们很多可选的选题,都是很标准的两个主功能的小应用,很轻量化,但也确实是没什么实际用途做出来也只是在应用市场上充数罢了,所以不如说是去圆一下之前的梦,把鸿小易和NowInOpenHarmony这两个应用给融合为一个新应用,于是“鸿易讯”诞生了。

核心功能

功能层级 功能名称 子功能/具体内容 说明
一级功能 鸿蒙新闻资讯 二级功能1:鸿蒙新闻 展示鸿蒙系统相关的最新行业新闻、官方动态、技术更新等内容
二级功能2:开源鸿蒙新闻 聚焦开源鸿蒙项目的进展、社区动态、代码提交、开源合作等信息
一级功能 鸿蒙智能问答AI助手 二级功能1:快速问答模式 针对鸿蒙开发相关的基础问题、常见疑问,提供快速精准的解答
二级功能2:DeepResearch模式 针对复杂的鸿蒙开发技术难题、深度研究需求,进行多维度分析与详细解答
一级功能 设置 隐私政策 展示应用数据收集、使用、存储及保护相关的隐私条款内容
版本号 显示当前应用的版本信息(示例:V1.0.0)

核心问题记录

这一次我不准备再像过去一样事无巨细的去记录全部流程,而是只去分析记录核心问题的解决方案以及一些试错方案,并且对于AI生成的代码去进行更进一步的解读。

AppInit

在我开发NowInOpenHarmony的时候,我参考子安学长曾经项目中的模式,将全部需要进行初始化的模块功能统一封装到了Product模块的AppInit类中,这也是可以针对于不同形态的设备配置不同的AppInit流程。这一块我此前并没有很多实践经验,对于具体的初始化流程规划以及日志的打印格式都是以实用主义的模式去进行编写的,一切以能排查出Bug为目标,就没有很规范。所以我决定要用AI去帮我进行一下重构。

在此前的经验中我们可以知道Claude对于ArkTS的开发有一定的基础认知但是对于接口的版本以及TS于ArkTS的语法临界区没有很好的把握,所以我选择使用详尽的描述以及在现有代码结构上举例说明的形式,来让Claude把其他应用开发中的通识性经验迁移到我的项目中,我也可以趁机学习一下对于多Promise的管理以及对于日志的格式化输出。

首先我先放一下在NowInOpenHarmony中AppInit的代码作为对比。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* Copyright (c) 2025 XBXyftx
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
GET_USER_CONFIG, logger, preferenceDB, UserConfigViewModel
} from "common";
import { common } from "@kit.AbilityKit";
import { AppStorageV2, promptAction } from "@kit.ArkUI";
import { colorModManager, newsManager, userConfigManager } from "feature";
import { lvCode, lvText } from "@luvi/lv-markdown-in";


const AppInit_LOG_TAG = 'AppInit: '

/**
* 应用初始化接口
*/
export class AppInit {
/**
* 用户配置项初始化
*/
configInit(context: common.UIAbilityContext) {
const isPreferenceDBInitSuccess: boolean = preferenceDB.init(context)
if (isPreferenceDBInitSuccess) {
logger.info(`${AppInit_LOG_TAG}首选项数据对象初始化成功`)
if (userConfigManager.syncDataToAppStorage()) {
return true
}
return false
} else {
promptAction.openToast({ message: `${AppInit_LOG_TAG}首选项数据对象初始化错误` })
logger.error(`${AppInit_LOG_TAG}首选项数据对象初始化错误`)
return false
}
}

async initAll(uiAbilityContext: common.UIAbilityContext, applicationContext: common.ApplicationContext) {
await newsManager.init(uiAbilityContext)
this.configInit(uiAbilityContext)
colorModManager.init(applicationContext)
await newsManager.updateNewsListToDB()
await newsManager.updateNewsSwiperToDB()
}

markDownConfigInit() {
let baseFontSize =
AppStorageV2.connect(UserConfigViewModel, GET_USER_CONFIG, () => new UserConfigViewModel())!.fontSize
lvCode.setIndexState(true)
lvText.setTextSize(baseFontSize)
logger.info(`${AppInit_LOG_TAG}Markdown初始化成功`)
}
}

export const appInit = new AppInit()

我原本的手搓版本只是在一位的滥用await去进行异步的处理,并没用到很多Promise原生的对于多异步任务的管理方法。

接下来让我们来看一下Claude给出的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
/**
* Copyright (c) 2025 XBXyftx
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
APP_STORAGE_KEYS, logger, preferenceDB, UserConfigViewModel, LOG_TAG, kvDatabase
} from "common";
import { common } from "@kit.AbilityKit";
import { colorModManager, newsManager, userConfigManager } from "feature";
import { AppStorageV2, promptAction } from "@kit.ArkUI";
import { lvCode, lvText } from "@luvi/lv-markdown-in";
import { InitStatus } from "../modules/AppInit/InitStatus";


/**
* 应用初始化管理类
*
* 负责管理应用启动时各个模块的初始化流程,采用分阶段初始化策略
*
* @remarks
* 初始化阶段设计:
*
* **阶段 1 - 基础模块初始化(onCreate)**
* - 数据库初始化:KVDatabase、PreferenceDB
* - 用户配置加载:从持久化存储恢复配置
* - 业务管理器初始化:NewsManager 等
*
* **阶段 2 - 窗口相关初始化(onWindowStageCreate)**
* - AppStorageV2 初始化:窗口宽度等全局状态
* - 主题管理器初始化:ColorModManager(依赖 ApplicationContext)
*
* **阶段 3 - UI 依赖初始化(页面加载后)**
* - Markdown 配置:依赖 AppStorageV2 中的用户配置
* - 其他 UI 相关配置
*
* 初始化顺序的重要性:
* - AppStorageV2 必须在 Window 创建后才能使用
* - 用户配置需要先从 PreferenceDB 加载再同步到 AppStorageV2
* - Markdown 配置依赖 AppStorageV2 中的字体大小设置
*
* @example
* ```typescript
* // 在 EntryAbility 中使用
* class EntryAbility extends UIAbility {
* // 阶段 1:基础初始化
* onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
* appInit.initPhase1_BaseModules(this.context)
* }
*
* // 阶段 2:窗口初始化
* onWindowStageCreate(windowStage: window.WindowStage): void {
* appInit.initPhase2_WindowRelated(this.context.getApplicationContext())
*
* windowStage.loadContent('pages/StartPage', (err) => {
* if (!err.code) {
* // 阶段 3:UI 依赖初始化
* appInit.initPhase3_UIDependent()
* }
* })
* }
* }
* ```
*
* @see EntryAbility 应用入口,协调初始化流程
*/
export class AppInit {
/**
* 初始化状态追踪
*
* 记录各个模块的初始化状态,便于调试和错误定位
*
* @private
*/
private initStatus: InitStatus = {
databases: false, // 数据库初始化状态
userConfig: false, // 用户配置初始化状态
managers: false, // 管理器初始化状态
appStorageV2: false, // AppStorageV2 初始化状态
markdown: false // Markdown 初始化状态
}

// ==================== 阶段 1:基础模块初始化 ====================

/**
* 阶段 1:基础模块初始化
*
* 在 EntryAbility.onCreate() 中调用,初始化不依赖窗口的基础模块
*
* @param uiAbilityContext - UIAbility 上下文对象
* @returns Promise<boolean> - 初始化是否成功
*
* @remarks
* 初始化内容:
* 1. 数据库模块(KV数据库、偏好设置数据库)
* 2. 用户配置加载(从持久化存储恢复)
* 3. 业务管理器(新闻管理器等)
* 4. 数据预加载(新闻列表、轮播图)
*
* 调用时机:
* - 应用启动的最早阶段
* - 在创建窗口之前
* - 在访问 UI 之前
*
* 注意事项:
* - 此阶段不能访问 AppStorageV2
* - 不能进行 UI 操作
* - 应尽快完成,避免阻塞启动
*
* @example
* ```typescript
* onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
* appInit.initPhase1_BaseModules(this.context).then(success => {
* if (success) {
* logger.info('基础模块初始化成功')
* } else {
* logger.error('基础模块初始化失败,应用可能无法正常运行')
* }
* })
* }
* ```
*/
async initPhase1_BaseModules(uiAbilityContext: common.UIAbilityContext): Promise<boolean> {
logger.info(`${LOG_TAG.APP_INIT}========== 开始阶段 1:基础模块初始化 ==========`)

try {
// 步骤 1:初始化数据库
logger.info(`${LOG_TAG.APP_INIT}步骤 1/4:初始化数据库模块...`)
const dbInitSuccess = await this.initDatabases(uiAbilityContext)
if (!dbInitSuccess) {
logger.error(`${LOG_TAG.APP_INIT}✗ 数据库初始化失败,终止初始化流程 - 请检查应用权限和存储空间`)
return false
}

// 步骤 2:加载用户配置
logger.info(`${LOG_TAG.APP_INIT}步骤 2/4:加载用户配置...`)
const configInitSuccess = this.initUserConfig(uiAbilityContext)
if (!configInitSuccess) {
logger.warn(`${LOG_TAG.APP_INIT}用户配置初始化失败,将使用默认配置`)
// 配置加载失败不影响应用运行,继续执行
}

// 步骤 3:初始化业务管理器
logger.info(`${LOG_TAG.APP_INIT}步骤 3/4:初始化业务管理器...`)
const managersInitSuccess = await this.initManagers(uiAbilityContext)
if (!managersInitSuccess) {
logger.error(`${LOG_TAG.APP_INIT}✗ 管理器初始化失败 - 应用可继续运行但新闻功能不可用`)
// 管理器初始化失败不终止流程,允许应用继续启动
// return false
}

// 步骤 4:预加载数据
logger.info(`${LOG_TAG.APP_INIT}步骤 4/4:预加载应用数据...`)
await this.preloadData()

logger.info(`${LOG_TAG.APP_INIT}========== 阶段 1 完成:基础模块初始化成功 ==========`)
return true

} catch (error) {
logger.error(`${LOG_TAG.APP_INIT}✗ 阶段 1 初始化异常 - 详细信息:${JSON.stringify(error)}`)
promptAction.openToast({ message: '应用初始化失败,请重启应用' })
return false
}
}

/**
* 初始化数据库模块
*
* 初始化 KV 数据库和偏好设置数据库
*
* @param context - UIAbility 上下文
* @returns Promise<boolean> - 是否初始化成功
*
* @remarks
* 初始化顺序:
* 1. KV 数据库:用于缓存业务数据(新闻列表等)
* 2. 偏好设置数据库:用于存储用户配置
*
* 失败处理:
* - KV 数据库失败:可能影响离线功能
* - 偏好设置失败:会使用默认配置
*
* @private
*/
private async initDatabases(context: common.UIAbilityContext): Promise<boolean> {
// 初始化 KV 数据库
const kvInitSuccess = kvDatabase.init(context)
if (kvInitSuccess) {
logger.info(`${LOG_TAG.APP_INIT}✓ KV 数据库初始化成功`)
this.initStatus.databases = true
} else {
logger.error(`${LOG_TAG.APP_INIT}✗ KV 数据库初始化失败 - 原因:KVManager 创建失败或 context 无效`)
return false
}

// 初始化偏好设置数据库
const preferenceInitSuccess = preferenceDB.init(context)
if (preferenceInitSuccess) {
logger.info(`${LOG_TAG.APP_INIT}✓ 偏好设置数据库初始化成功`)
} else {
logger.error(`${LOG_TAG.APP_INIT}✗ 偏好设置数据库初始化失败 - 原因:Preferences 实例创建失败`)
return false
}

return true
}

/**
* 初始化用户配置
*
* 从偏好设置数据库加载用户配置,恢复用户的个性化设置
*
* @param context - UIAbility 上下文
* @returns boolean - 是否初始化成功
*
* @remarks
* 配置内容:
* - 字体大小(FONT_SIZE)
* - 颜色模式(COLOR_MODE)
*
* 数据流:
* PreferenceDB → UserConfigManager → 内存状态
*
* 注意:此阶段还不能同步到 AppStorageV2,因为窗口还未创建
*
* @private
*/
private initUserConfig(context: common.UIAbilityContext): boolean {
try {
const syncSuccess = userConfigManager.syncDataToAppStorage()
if (syncSuccess) {
logger.info(`${LOG_TAG.APP_INIT}✓ 用户配置加载成功`)
this.initStatus.userConfig = true
return true
} else {
logger.warn(`${LOG_TAG.APP_INIT}⚠ 用户配置加载失败 - 原因:PreferenceDB 数据读取失败,将使用默认配置`)
return false
}
} catch (error) {
logger.error(`${LOG_TAG.APP_INIT}✗ 用户配置加载异常 - 原因:${JSON.stringify(error)}`)
return false
}
}

/**
* 初始化业务管理器
*
* 初始化各个业务模块的管理器
*
* @param context - UIAbility 上下文
* @returns Promise<boolean> - 是否初始化成功
*
* @remarks
* 管理器列表:
* - NewsManager:新闻数据管理
* - 其他业务管理器...
*
* 注意:ColorModManager 依赖 ApplicationContext,在阶段 2 初始化
*
* @private
*/
private async initManagers(context: common.UIAbilityContext): Promise<boolean> {
try {
// 初始化新闻管理器
const newsManagerInitSuccess = await newsManager.init(context)
if (newsManagerInitSuccess) {
logger.info(`${LOG_TAG.APP_INIT}✓ 新闻管理器初始化成功`)
this.initStatus.managers = true
return true
} else {
logger.error(`${LOG_TAG.APP_INIT}✗ 新闻管理器初始化失败 - 原因:无法获取 KV 数据库实例`)
return false
}
} catch (error) {
logger.error(`${LOG_TAG.APP_INIT}✗ 管理器初始化异常 - 原因:${JSON.stringify(error)}`)
return false
}
}

/**
* 预加载应用数据
*
* 在后台预加载必要的数据,提升用户体验
*
* @remarks
* 预加载策略:
* - 异步执行,不阻塞主流程
* - 失败不影响应用启动
* - 优先加载用户可能立即需要的数据
*
* 预加载内容:
* - 新闻文章列表
* - 轮播图数据
*
* @private
*/
private async preloadData(): Promise<void> {
try {
// 异步更新新闻数据,不阻塞启动流程
Promise.all([
newsManager.updateNewsListToDB(),
newsManager.updateNewsSwiperToDB()
]).then(() => {
logger.info(`${LOG_TAG.APP_INIT}数据预加载完成`)
}).catch((error: Error) => {
logger.warn(`${LOG_TAG.APP_INIT}数据预加载失败: ${JSON.stringify(error)},将使用缓存数据`)
})
} catch (error) {
logger.warn(`${LOG_TAG.APP_INIT}数据预加载异常: ${JSON.stringify(error)}`)
}
}

// ==================== 阶段 2:窗口相关初始化 ====================

/**
* 阶段 2:窗口相关初始化
*
* 在 EntryAbility.onWindowStageCreate() 中调用,初始化依赖窗口的模块
*
* @param applicationContext - 应用上下文对象
* @returns boolean - 初始化是否成功
*
* @remarks
* 初始化内容:
* 1. 颜色模式管理器(需要 ApplicationContext 来设置系统主题)
* 2. 其他依赖窗口的初始化
*
* 调用时机:
* - 在窗口创建后
* - 在加载页面之前
* - AppStorageV2 可用之后
*
* 前置条件:
* - 阶段 1 必须成功完成
* - Window 对象已创建
*
* @example
* ```typescript
* onWindowStageCreate(windowStage: window.WindowStage): void {
* // 初始化窗口相关模块
* appInit.initPhase2_WindowRelated(this.context.getApplicationContext())
*
* // 加载页面
* windowStage.loadContent('pages/StartPage', ...)
* }
* ```
*/
initPhase2_WindowRelated(applicationContext: common.ApplicationContext): boolean {
logger.info(`${LOG_TAG.APP_INIT}========== 开始阶段 2:窗口相关初始化 ==========`)

try {
// 初始化颜色模式管理器
logger.info(`${LOG_TAG.APP_INIT}初始化颜色模式管理器...`)
const colorModInitSuccess = colorModManager.init(applicationContext)
if (colorModInitSuccess) {
logger.info(`${LOG_TAG.APP_INIT}✓ 颜色模式管理器初始化成功`)
this.initStatus.appStorageV2 = true
} else {
logger.warn(`${LOG_TAG.APP_INIT}⚠ 颜色模式管理器初始化失败 - 原因:ApplicationContext 无效,将使用默认主题`)
// 不影响应用运行
}

logger.info(`${LOG_TAG.APP_INIT}========== 阶段 2 完成:窗口相关初始化成功 ==========`)
return true

} catch (error) {
logger.error(`${LOG_TAG.APP_INIT}阶段 2 初始化异常: ${JSON.stringify(error)}`)
return false
}
}

// ==================== 阶段 3:UI 依赖初始化 ====================

/**
* 阶段 3:UI 依赖模块初始化
*
* 在页面加载完成后调用,初始化依赖 AppStorageV2 的模块
*
* @remarks
* 初始化内容:
* - Markdown 配置:需要从 AppStorageV2 获取用户字体大小设置
* - 其他 UI 相关配置
*
* 调用时机:
* - 在 windowStage.loadContent() 的回调中
* - AppStorageV2 已完全可用
* - 在显示首页之前
*
* 独立性:
* - 此方法保持独立,方便单独调用
* - 可在需要时重新配置 Markdown
* - 不影响其他初始化流程
*
* 前置条件:
* - 阶段 1 和阶段 2 必须完成
* - AppStorageV2 中的 USER_CONFIG 必须已初始化
*
* @example
* ```typescript
* windowStage.loadContent('pages/StartPage', (err) => {
* if (err.code) {
* hilog.error(DOMAIN, 'testTag', '页面加载失败: %{public}s', JSON.stringify(err))
* return
* }
*
* // 页面加载成功,初始化 UI 依赖模块
* appInit.initPhase3_UIDependent()
* })
* ```
*/
initPhase3_UIDependent(): boolean {
logger.info(`${LOG_TAG.APP_INIT}========== 开始阶段 3:UI 依赖模块初始化 ==========`)

try {
// 初始化 Markdown 配置
logger.info(`${LOG_TAG.APP_INIT}初始化 Markdown 配置...`)
const markdownInitSuccess = this.markDownConfigInit()
if (markdownInitSuccess) {
logger.info(`${LOG_TAG.APP_INIT}✓ Markdown 配置初始化成功`)
} else {
logger.warn(`${LOG_TAG.APP_INIT}⚠ Markdown 配置初始化失败 - 原因:无法从 AppStorageV2 获取用户配置,将使用默认配置`)
}

logger.info(`${LOG_TAG.APP_INIT}========== 阶段 3 完成:UI 依赖模块初始化成功 ==========`)

return true

} catch (error) {
logger.error(`${LOG_TAG.APP_INIT}阶段 3 初始化异常: ${JSON.stringify(error)}`)
return false
}
}

/**
* 配置 Markdown 渲染引擎
*
* 根据用户的字体大小设置配置 Markdown 渲染参数
*
* @returns boolean - 配置是否成功
*
* @remarks
* 配置内容:
* - 代码块行号显示:启用行号
* - 文本基础字体大小:使用用户设置的字体大小
*
* 依赖关系:
* - 依赖 AppStorageV2 中的 USER_CONFIG
* - 必须在 AppStorageV2 初始化后调用
*
* 独立性说明:
* - 此方法可以独立调用
* - 用户修改字体大小后可重新调用此方法
* - 不影响其他初始化流程
*
* 使用的第三方库:
* - @luvi/lv-markdown-in: Markdown 渲染引擎
*
* @example
* ```typescript
* // 应用启动时初始化
* appInit.markDownConfigInit()
*
* // 用户修改字体大小后重新配置
* userConfig.fontSize = 18
* appInit.markDownConfigInit() // 重新应用配置
* ```
*/
markDownConfigInit(): boolean {
try {
// 从 AppStorageV2 获取用户字体大小设置
const userConfig = AppStorageV2.connect(
UserConfigViewModel,
APP_STORAGE_KEYS.USER_CONFIG,
() => new UserConfigViewModel()
)

if (!userConfig) {
logger.error(`${LOG_TAG.APP_INIT}✗ 无法获取用户配置 - 原因:AppStorageV2 中不存在 USER_CONFIG,Markdown 使用默认字体大小`)
return false
}

const baseFontSize = userConfig.fontSize

// 配置 Markdown 渲染引擎
lvCode.setIndexState(true) // 启用代码块行号
lvText.setTextSize(baseFontSize) // 设置文本基础字体大小

logger.info(`${LOG_TAG.APP_INIT}✓ Markdown 配置成功,字体大小: ${baseFontSize}`)
this.initStatus.markdown = true

return true

} catch (error) {
logger.error(`${LOG_TAG.APP_INIT}✗ Markdown 配置异常 - 原因:${JSON.stringify(error)}`)
return false
}
}

// ==================== 工具方法 ====================

/**
* 打印初始化状态
*
* 用于调试和问题诊断,输出各模块的初始化状态
*
* @remarks
* - 成功的模块使用 info 级别
* - 失败的模块使用 warn 级别,便于快速定位问题
*
* @public
*/
printInitStatus(): void {
logger.info(`${LOG_TAG.APP_INIT}========== 初始化状态报告 ==========`)

// 数据库模块
if (this.initStatus.databases) {
logger.info(`${LOG_TAG.APP_INIT}[数据库模块] ✓ 初始化成功`)
} else {
logger.warn(`${LOG_TAG.APP_INIT}[数据库模块] ✗ 初始化失败 - 影响:离线功能可能不可用`)
}

// 用户配置
if (this.initStatus.userConfig) {
logger.info(`${LOG_TAG.APP_INIT}[用户配置] ✓ 初始化成功`)
} else {
logger.warn(`${LOG_TAG.APP_INIT}[用户配置] ✗ 初始化失败 - 影响:将使用默认配置(字体16、跟随系统主题)`)
}

// 业务管理器
if (this.initStatus.managers) {
logger.info(`${LOG_TAG.APP_INIT}[业务管理器] ✓ 初始化成功`)
} else {
logger.warn(`${LOG_TAG.APP_INIT}[业务管理器] ✗ 初始化失败 - 影响:新闻数据功能不可用,请检查网络或数据库`)
}

// AppStorageV2
if (this.initStatus.appStorageV2) {
logger.info(`${LOG_TAG.APP_INIT}[AppStorageV2] ✓ 初始化成功`)
} else {
logger.warn(`${LOG_TAG.APP_INIT}[AppStorageV2] ✗ 初始化失败 - 影响:主题切换功能可能不可用`)
}

// Markdown 配置
if (this.initStatus.markdown) {
logger.info(`${LOG_TAG.APP_INIT}[Markdown配置] ✓ 初始化成功`)
} else {
logger.warn(`${LOG_TAG.APP_INIT}[Markdown配置] ✗ 初始化失败 - 影响:文章渲染使用默认字体`)
}

logger.info(`${LOG_TAG.APP_INIT}======================================`)
}

/**
* 获取初始化状态
*
* 供外部查询初始化状态
*
* @returns 初始化状态对象的只读副本
*
* @remarks
* 返回初始化状态的深拷贝,避免外部修改内部状态
*/
getInitStatus(): InitStatus {
return {
databases: this.initStatus.databases,
userConfig: this.initStatus.userConfig,
managers: this.initStatus.managers,
appStorageV2: this.initStatus.appStorageV2,
markdown: this.initStatus.markdown
}
}

/**
* 检查是否完全初始化
*
* @returns boolean - 所有模块是否都初始化成功
*
* @remarks
* 检查所有初始化状态是否都为 true
*/
isFullyInitialized(): boolean {
return this.initStatus.databases &&
this.initStatus.userConfig &&
this.initStatus.managers &&
this.initStatus.appStorageV2 &&
this.initStatus.markdown
}
}

/**
* 应用初始化管理器单例
*
* 全局唯一的 AppInit 实例,在 EntryAbility 中使用
*
* @remarks
* 使用示例:
* ```typescript
* import { appInit } from '../init/AppInit'
*
* // 在 EntryAbility 中按阶段调用
* onCreate() { appInit.initPhase1_BaseModules(this.context) }
* onWindowStageCreate() { appInit.initPhase2_WindowRelated(...) }
* // 页面加载后 { appInit.initPhase3_UIDependent() }
* ```
*/
export const appInit = new AppInit()

上面是新版的全部源码,由于这一次我是带着学习的心态去编写代码的,所以我让Claude给出了较为详细的注释也方便我们学习。接下来让我们分段拆解一下这个代码。

分段初始化

首先Claude对于整体初始化的流程进行了阶段的划分,它将初始化的过程切分成了三个阶段,分别是:

  • 基础模块初始化
  • 窗口相关模块初始化
  • UI 依赖模块初始化

这三个阶段分别对应了三个方法:

  • initPhase1_BaseModules()
  • initPhase2_WindowRelated()
  • initPhase3_UIDependent()

这个阶段的划分是依据于模块的依赖关系和初始化的时间点。

  • 基础模块初始化:包括数据库、用户配置、业务管理器、AppStorageV2 和 Markdown 配置。这些模块是其他模块的基础,必须在应用启动时就初始化完成。
  • 窗口相关模块初始化:包括窗口管理器、窗口装饰器等。这些模块依赖于基础模块,必须在窗口创建时初始化。
  • UI 依赖模块初始化:包括界面元素、事件处理等。这些模块依赖于窗口相关模块,必须在界面加载完成后初始化。

异步任务执行顺序管理

在我单独花了一段时间品读了一下Claude的代码之后才发现一段好的代码是真的可以赏心悦目,可以被称之为艺术品了。

这里我们需要结合着EntryAbility的代码来理解讲解。

首先我们要清楚的一点在于我们不同阶段之间以及不同阶段内部存在着一定量的彼此依存,需要严格依照正确的顺序执行,就比如说是我们的新闻数据Manager模块都要依赖于键值数据库的初始化,所有的配置数据Manager模块都依赖于用户首选项数据库的初始化。所以初始化的顺序至关重要。

而当前我们的初始化过程中包含了大量的异步任务,这些异步任务的实际执行时长各不相同,我们所需要的是利用Promise类内置的一系列静态方法来去控制多个异步任务的执行顺序,通过在上一个异步任务的then回调函数中去拉起下一个与之存在依赖关系的异步任务进入任务队列,从而实现异步任务的有序执行。这里不禁让我联想到了当初数据结构所学过的拓扑结构,只有完成全部的前置节点才能达到下一个节点,其应用真的很广,可以说是在日常生活中无处不在的了。

这里我先去放一下EntryAbility的源码然后咱们参照着源码逐一讲解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
/**
* Copyright (c) 2025 XBXyftx
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { AppStorageV2, window } from '@kit.ArkUI';
import { appInit } from '../init/AppInit';
import { userConfigManager } from 'feature';
import { APP_STORAGE_KEYS, WinWidth, logger, LOG_TAG } from 'common';

/**
* 日志域标识
*
* 用于 hilog 日志输出,标识日志来源
*/
const DOMAIN = 0x0000;

/**
* hilog 日志标签
*
* 用于过滤和搜索日志
*/
const TAG = 'EntryAbility';

/**
* 应用入口 Ability
*
* 作为应用的主入口,负责:
* - 应用生命周期管理
* - 应用初始化流程协调
* - 窗口创建和页面加载
* - 全局状态管理
*
* @remarks
* 生命周期方法调用顺序:
* 1. onCreate(): 应用创建时调用,进行基础初始化
* 2. onWindowStageCreate(): 窗口创建时调用,初始化 UI 相关模块
* 3. onForeground(): 应用进入前台
* 4. onBackground(): 应用进入后台,保存数据
* 5. onWindowStageDestroy(): 窗口销毁
* 6. onDestroy(): 应用销毁
*
* 初始化策略:
* - 分阶段初始化:基础模块 → 窗口模块 → UI 依赖模块
* - 异步处理:不阻塞主线程
* - 错误容错:关键模块失败时有降级方案
*
* @see AppInit 应用初始化管理器
*/
export default class EntryAbility extends UIAbility {
/**
* 阶段 1 初始化 Promise
*
* 用于在窗口创建时等待阶段 1 完成,确保初始化顺序正确
*
* @private
*/
private phase1Promise: Promise<boolean> | null = null

/**
* 应用创建生命周期回调
*
* 在应用启动时调用,是初始化的第一个阶段
*
* @param want - 启动意图,包含启动参数
* @param launchParam - 启动参数,包含启动原因等信息
*
* @remarks
* 初始化内容(阶段 1):
* - 数据库初始化(KV数据库、偏好设置数据库)
* - 用户配置加载
* - 业务管理器初始化
* - 数据预加载
*
* 注意事项:
* - 此时窗口还未创建,不能访问 UI
* - 不能使用 AppStorageV2
* - 应快速完成,避免阻塞启动
*
* 错误处理:
* - 初始化失败会记录日志
* - 关键模块失败可能影响应用功能
* - 非关键模块失败应用仍可运行
*/
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onCreate');
logger.info(`${LOG_TAG.ENTRY_ABILITY}应用启动,开始初始化流程`)

// 阶段 1:基础模块初始化(异步执行,保存 Promise 供后续等待)
this.phase1Promise = appInit.initPhase1_BaseModules(this.context)
}

/**
* 应用销毁生命周期回调
*
* 在应用退出时调用,进行资源清理
*
* @remarks
* 清理内容:
* - 释放数据库连接
* - 取消事件监听
* - 清理临时数据
*/
onDestroy(): void {
hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onDestroy');
logger.info(`${LOG_TAG.ENTRY_ABILITY}应用销毁`)
}

/**
* 窗口阶段创建生命周期回调
*
* 在窗口创建后调用,是初始化的第二和第三阶段
*
* @param windowStage - 窗口舞台对象,用于管理窗口和加载页面
*
* @remarks
* 初始化内容:
* - 等待阶段 1 完成(确保初始化顺序)
* - 阶段 2:窗口相关初始化(颜色模式管理器等)
* - AppStorageV2 初始化(窗口宽度)
* - 页面加载(StartPage)
* - 阶段 3:UI 依赖初始化(Markdown 配置等)
*
* AppStorageV2 初始化时机:
* - 窗口创建后,页面加载前
* - 必须在访问 AppStorageV2 之前完成
*
* 页面加载策略:
* - 首先加载启动页(StartPage)
* - 启动页会自动跳转到主页(Index)
* - 使用路由动画提升用户体验
*/
onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onWindowStageCreate');
logger.info(`${LOG_TAG.ENTRY_ABILITY}窗口创建,等待阶段 1 完成...`)

// 等待阶段 1 完成(如果还在进行中)
const phase1Promise = this.phase1Promise || Promise.resolve(false)

phase1Promise.then((phase1Success) => {
// 记录阶段 1 结果
if (phase1Success) {
logger.info(`${LOG_TAG.ENTRY_ABILITY}✓ 阶段 1 初始化成功`)
} else {
logger.error(`${LOG_TAG.ENTRY_ABILITY}✗ 阶段 1 初始化失败,应用可能无法正常运行`)
}

// 阶段 2:窗口相关模块初始化
logger.info(`${LOG_TAG.ENTRY_ABILITY}开始阶段 2 初始化...`)
const phase2Success = appInit.initPhase2_WindowRelated(this.context.getApplicationContext())
if (phase2Success) {
logger.info(`${LOG_TAG.ENTRY_ABILITY}✓ 阶段 2 初始化成功`)
} else {
logger.warn(`${LOG_TAG.ENTRY_ABILITY}⚠ 阶段 2 初始化失败,部分功能可能受影响`)
}

// 初始化 AppStorageV2:存储窗口宽度
window.getLastWindow(this.context).then((win) => {
const winWidth = win.getWindowProperties().windowRect.width
AppStorageV2.connect(WinWidth, APP_STORAGE_KEYS.WINDOW_WIDTH, () => new WinWidth(winWidth))
logger.info(`${LOG_TAG.ENTRY_ABILITY}窗口宽度已存储到 AppStorageV2: ${winWidth}px`)
}).catch((error: Error) => {
logger.error(`${LOG_TAG.ENTRY_ABILITY}获取窗口宽度失败: ${JSON.stringify(error)}`)
})

// 加载启动页面
windowStage.loadContent('pages/StartPage', (err) => {
if (err.code) {
hilog.error(DOMAIN, TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
logger.error(`${LOG_TAG.ENTRY_ABILITY}页面加载失败: ${JSON.stringify(err)}`)
return;
}

hilog.info(DOMAIN, TAG, 'Succeeded in loading the content.');
logger.info(`${LOG_TAG.ENTRY_ABILITY}启动页加载成功`)

// 阶段 3:UI 依赖模块初始化
logger.info(`${LOG_TAG.ENTRY_ABILITY}开始阶段 3 初始化...`)
const phase3Success = appInit.initPhase3_UIDependent()
if (phase3Success) {
logger.info(`${LOG_TAG.ENTRY_ABILITY}✓ 阶段 3 初始化成功`)
} else {
logger.warn(`${LOG_TAG.ENTRY_ABILITY}⚠ 阶段 3 初始化失败,部分功能可能受影响`)
}

// 等待所有异步操作完成后,打印最终状态报告
setTimeout(() => {
this.printFinalInitStatus()
}, 100) // 给异步操作留出完成时间
});
}).catch((error: Error) => {
logger.error(`${LOG_TAG.ENTRY_ABILITY}✗ 阶段 1 初始化异常: ${JSON.stringify(error)}`)
logger.error(`${LOG_TAG.ENTRY_ABILITY}应用启动失败,请重启应用`)
})
}

/**
* 打印最终初始化状态报告
*
* 在所有初始化阶段完成后调用,输出完整的状态报告
*
* @private
*/
private printFinalInitStatus(): void {
logger.info(`${LOG_TAG.ENTRY_ABILITY}========================================`)
logger.info(`${LOG_TAG.ENTRY_ABILITY} 应用初始化完成状态报告`)
logger.info(`${LOG_TAG.ENTRY_ABILITY}========================================`)

// 打印详细状态
appInit.printInitStatus()

// 检查完整初始化状态
if (appInit.isFullyInitialized()) {
logger.info(`${LOG_TAG.ENTRY_ABILITY}`)
logger.info(`${LOG_TAG.ENTRY_ABILITY}🎉 应用完全初始化成功,所有功能可用`)
logger.info(`${LOG_TAG.ENTRY_ABILITY}`)
} else {
logger.warn(`${LOG_TAG.ENTRY_ABILITY}`)
logger.warn(`${LOG_TAG.ENTRY_ABILITY}⚠️ 应用初始化不完整,部分功能可能受限`)
const status = appInit.getInitStatus()
logger.warn(`${LOG_TAG.ENTRY_ABILITY}详细状态: ${JSON.stringify(status)}`)
logger.warn(`${LOG_TAG.ENTRY_ABILITY}`)
}

logger.info(`${LOG_TAG.ENTRY_ABILITY}========================================`)
}

/**
* 窗口阶段销毁生命周期回调
*
* 在窗口销毁时调用,释放 UI 相关资源
*
* @remarks
* 清理内容:
* - 释放 UI 资源
* - 取消窗口监听
* - 清理临时 UI 状态
*/
onWindowStageDestroy(): void {
hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onWindowStageDestroy');
logger.info(`${LOG_TAG.ENTRY_ABILITY}窗口销毁`)
}

/**
* 应用进入前台生命周期回调
*
* 当应用从后台切换到前台时调用
*
* @remarks
* 可能的操作:
* - 刷新数据:检查是否有新内容
* - 恢复状态:恢复用户操作状态
* - 重新连接:重新建立网络连接
*/
onForeground(): void {
hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onForeground');
logger.info(`${LOG_TAG.ENTRY_ABILITY}应用进入前台`)
}

/**
* 应用进入后台生命周期回调
*
* 当应用从前台切换到后台时调用
*
* @remarks
* 数据保存:
* - 自动保存用户配置到持久化存储
* - 确保用户数据不丢失
* - 为下次启动做准备
*
* 保存内容:
* - 用户字体大小设置
* - 用户颜色模式偏好
* - 其他用户配置项
*
* 执行时机:
* - 用户按Home键退出
* - 切换到其他应用
* - 系统内存不足时被挂起
*/
onBackground(): void {
hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onBackground');
logger.info(`${LOG_TAG.ENTRY_ABILITY}应用进入后台,开始保存用户数据`)

// 同步用户配置到持久化存储
const syncSuccess = userConfigManager.syncDataToPreference()
if (syncSuccess) {
logger.info(`${LOG_TAG.ENTRY_ABILITY}用户配置保存成功`)
} else {
logger.error(`${LOG_TAG.ENTRY_ABILITY}用户配置保存失败,设置可能丢失`)
}
}
}

通过AppInit的源代码我们可以看到,我们所有的异步操作其实是全部被包裹在了initPhase1中,initPhase1是在AbilityonCreate中调用的,所以我们所有的异步操作都是在AbilityonCreate中完成的。但是问题在于后面的onWindowStageCreate窗口创建阶段与我们的onCreate函数是两个独立的代码块,彼此之间的局部变量并不互通,我们在onCreate的函数中创建的Promise实例对象无法在onWindowStageCreate中访问,所以为了保证阶段二的执行顺序,我们要将appInit.initPhase1_BaseModules对象的可见区域扩大,扩大至当前EntryAbility类的局部变量中。

1
2
3
4
5
6
7
8
/**
* 阶段 1 初始化 Promise
*
* 用于在窗口创建时等待阶段 1 完成,确保初始化顺序正确
*
* @private
*/
private phase1Promise: Promise<boolean> | null = null

将作用域提升之后,我们在onCreate函数中去进行promise对象的启动,将启动后的对象的引用赋值给this.phase1Promise,然后在onWindowStageCreate函数中去等待这个promise对象的完成。在完成后去调用appInit.initPhase2_UI函数进行阶段二的初始化。二阶段之所以是没有被单独提升作用域,这是因为二阶段和三阶段都是同步的。三阶段会很自然的排在二阶段的后面,无需额外进行更多操作。

在三个阶段的初始化过程中,每一步的初始化成功之后都会在initStatus这个对象中去记录其初始化状态,如果成功就会在对应的键值中去记录为true,失败就会记录为false。随后在三个初始化阶段的最后会统一进行结果的输出。

而在这个过程中,可能会出现同步进程连续执行,持续到应用准备阶段的最后也没有流出空闲去处理异步函数,导致最后输出的结果为失败是因为还没有执行(在早期版本时,我们的的确确遇到了这个问题。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
11-18 13:28:07.652   8088-8088     A00000/com.xbxy...EntryAbility  apppool               I     Ability onCreate
11-18 13:28:07.652 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I EntryAbility: 应用启动,开始初始化流程
11-18 13:28:07.652 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: ========== 开始阶段 1:基础模块初始化 ==========
11-18 13:28:07.652 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: 步骤 1/4:初始化数据库模块...
11-18 13:28:07.653 8088-8088 A03D00/com.xbx...ngYiXun/JSAPP apppool I KVDatabase: Succeeded in creating KVManager.
11-18 13:28:07.653 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I KVDatabase: 数据库管理对象创建成功。
11-18 13:28:07.653 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: ✓ KV 数据库初始化成功
11-18 13:28:07.653 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: ✓ 偏好设置数据库初始化成功
11-18 13:28:07.653 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: 步骤 2/4:加载用户配置...
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I PreferenceDB: Has ColorMode data: true
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool W PreferenceDB: Get data ColorMode: 0
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I UserConfigManager: 检测到COLOR_MODE = 0
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool W PreferenceDB: Get data ColorMode: 0
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I PreferenceDB: Has FontSize data: true
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool W PreferenceDB: Get data FontSize: 18
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I UserConfigManager: 检测到FONT_SIZE = 18
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool W PreferenceDB: Get data FontSize: 18
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I PreferenceDB: Has FontSize data: true
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I PreferenceDB: Has ColorMode data: true
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool W PreferenceDB: Get data ColorMode: 0
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool W PreferenceDB: Get data FontSize: 18
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool W UserConfigManager: 用户首选项持久化数据读取成功,colorMode=0,fontSize=18
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: ✓ 用户配置加载成功
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: 步骤 3/4:初始化业务管理器...
11-18 13:28:07.655 8088-8088 A03D00/com.xbx...ngYiXun/JSAPP apppool I KVDatabase: Succeeded in creating KVManager.
11-18 13:28:07.655 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I KVDatabase: 数据库管理对象创建成功。
11-18 13:28:07.665 8088-8088 A00000/com.xbxy...EntryAbility apppool I Ability onWindowStageCreate
11-18 13:28:07.665 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I EntryAbility: 窗口创建,开始窗口相关初始化
11-18 13:28:07.666 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: ========== 开始阶段 2:窗口相关初始化 ==========
11-18 13:28:07.666 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: 初始化颜色模式管理器...
11-18 13:28:07.666 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I ColorModManager: applicationContext初始化成功
11-18 13:28:07.666 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I ColorModManager: initColoModSetting 0: AppStorageV2colorModel = 0
11-18 13:28:07.666 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: ✓ 颜色模式管理器初始化成功
11-18 13:28:07.666 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I AppInit: ========== 阶段 2 完成:窗口相关初始化成功 ==========
11-18 13:28:07.666 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I EntryAbility: 阶段 2 初始化成功
11-18 13:28:07.669 8088-8088 A00000/com.xbxy...EntryAbility apppool I Ability onForeground
11-18 13:28:07.669 8088-8088 A01234/com.xbx...Xun/XBXLogger apppool I EntryAbility: 应用进入前台
11-18 13:28:07.707 8088-8088 A00000/com.xbxy...EntryAbility com.xbxyf...ongYiXun I Succeeded in loading the content.
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I EntryAbility: 启动页加载成功
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: ========== 开始阶段 3:UI 依赖模块初始化 ==========
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: 初始化 Markdown 配置...
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: ✓ Markdown 配置成功,字体大小: 18
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: ✓ Markdown 配置初始化成功
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: ========== 阶段 3 完成:UI 依赖模块初始化成功 ==========
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: ========== 应用初始化全部完成 ==========
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: ========== 初始化状态报告 ==========
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: [数据库模块] ✓ 初始化成功
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: [用户配置] ✓ 初始化成功
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun W AppInit: [业务管理器] ✗ 初始化失败 - 影响:新闻数据功能不可用,请检查网络或数据库
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: [AppStorageV2] ✓ 初始化成功
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: [Markdown配置] ✓ 初始化成功
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: ======================================
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I EntryAbility: 阶段 3 初始化成功
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun W EntryAbility: 应用初始化不完整,部分功能可能受限
11-18 13:28:07.707 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun W EntryAbility: 初始化状态: {"databases":true,"userConfig":true,"managers":false,"appStorageV2":true,"markdown":true}
11-18 13:28:07.708 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I KVDatabase: 成功获取storeId:HongYiXunKVDB数据库实例对象
11-18 13:28:07.708 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I NewsManager: init: 获取appKVDb成功
11-18 13:28:07.708 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: ✓ 新闻管理器初始化成功
11-18 13:28:07.708 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: 步骤 4/4:预加载应用数据...
11-18 13:28:07.708 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun D AxiosHttp: 进入AxiosHttp.request URL = /api/health
11-18 13:28:07.721 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun D AxiosHttp: 进入AxiosHttp.request URL = /api/banner/status
11-18 13:28:07.722 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I AppInit: ========== 阶段 1 完成:基础模块初始化成功 ==========
11-18 13:28:07.722 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I EntryAbility: 阶段 1 初始化成功
11-18 13:28:07.723 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun I EntryAbility: 窗口宽度已存储到 AppStorageV2: 1320px
11-18 13:28:07.725 8088-8088 A01234/com.xbx...Xun/XBXLogger com.xbxyf...ongYiXun D StartPage: winWidth: 440
1
AppInit: [业务管理器] ✗ 初始化失败 - 影响:新闻数据功能不可用,请检查网络或数据库

从这一条和

1
2
AppInit: ✓ 新闻管理器初始化成功
AppInit: 步骤 4/4:预加载应用数据...

这一条的输出顺序可以看出,异步函数的执行顺序问题是确实存在的,是需要解决的问题。

所以为了程序的稳定性,我们需要手动留出一段强制空闲时间去给异步操作进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 加载启动页面
windowStage.loadContent('pages/StartPage', (err) => {
if (err.code) {
hilog.error(DOMAIN, TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
logger.error(`${LOG_TAG.ENTRY_ABILITY}页面加载失败: ${JSON.stringify(err)}`)
return;
}

hilog.info(DOMAIN, TAG, 'Succeeded in loading the content.');
logger.info(`${LOG_TAG.ENTRY_ABILITY}启动页加载成功`)

// 阶段 3:UI 依赖模块初始化
logger.info(`${LOG_TAG.ENTRY_ABILITY}开始阶段 3 初始化...`)
const phase3Success = appInit.initPhase3_UIDependent()
if (phase3Success) {
logger.info(`${LOG_TAG.ENTRY_ABILITY}✓ 阶段 3 初始化成功`)
} else {
logger.warn(`${LOG_TAG.ENTRY_ABILITY}⚠ 阶段 3 初始化失败,部分功能可能受影响`)
}

// 等待所有异步操作完成后,打印最终状态报告
setTimeout(() => {
this.printFinalInitStatus()
}, 100) // 给异步操作留出完成时间
});

js和ts的异步逻辑是任务队列,我们通过定时器,将打印函数的调用塞在全部异步操作塞在打印之前,强制将打印函数的执行塞到任务队列的最后。这里设置为100ms是因为在正常情况下这些操作的执行总时长应该是远远低于100ms,若是高于100ms则说明他的执行过程中大概率发生了异常。

随后,对于printFinalInitStatus,这个函数会负责统一的输出三个阶段的初始化状态报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 打印最终初始化状态报告
*
* 在所有初始化阶段完成后调用,输出完整的状态报告
*
* @private
*/
private printFinalInitStatus(): void {
logger.info(`${LOG_TAG.ENTRY_ABILITY}========================================`)
logger.info(`${LOG_TAG.ENTRY_ABILITY} 应用初始化完成状态报告`)
logger.info(`${LOG_TAG.ENTRY_ABILITY}========================================`)

// 打印详细状态
appInit.printInitStatus()

// 检查完整初始化状态
if (appInit.isFullyInitialized()) {
logger.info(`${LOG_TAG.ENTRY_ABILITY}`)
logger.info(`${LOG_TAG.ENTRY_ABILITY}🎉 应用完全初始化成功,所有功能可用`)
logger.info(`${LOG_TAG.ENTRY_ABILITY}`)
} else {
logger.warn(`${LOG_TAG.ENTRY_ABILITY}`)
logger.warn(`${LOG_TAG.ENTRY_ABILITY}⚠️ 应用初始化不完整,部分功能可能受限`)
const status = appInit.getInitStatus()
logger.warn(`${LOG_TAG.ENTRY_ABILITY}详细状态: ${JSON.stringify(status)}`)
logger.warn(`${LOG_TAG.ENTRY_ABILITY}`)
}

logger.info(`${LOG_TAG.ENTRY_ABILITY}========================================`)
}

printInitStatus()打印的是每个小模块的细则,而isFullyInitialized()则是检查是否所有模块都初始化成功,打印的是整体的初始化情况,两者并不一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
Ability onCreate
EntryAbility: 应用启动,开始初始化流程
AppInit: ========== 开始阶段 1:基础模块初始化 ==========
AppInit: 步骤 1/4:初始化数据库模块...
KVDatabase: Succeeded in creating KVManager.
KVDatabase: 数据库管理对象创建成功。
AppInit: ✓ KV 数据库初始化成功
AppInit: ✓ 偏好设置数据库初始化成功
AppInit: 步骤 2/4:加载用户配置...
PreferenceDB: Has ColorMode data: false
PreferenceDB: Has FontSize data: false
PreferenceDB: Has FontSize data: false
UserConfigManager: 无用户配置持久化数据,执行默认配置设置
PreferenceDB: Has ColorMode data: false
UserConfigManager: preferenceDB.hasData(PreferenceEnum.COLOR_MODE)=false
PreferenceDB: push data: key=ColorMode,value=2
PreferenceDB: Has FontSize data: false
UserConfigManager: preferenceDB.hasData(PreferenceEnum.FONT_SIZE)=false
PreferenceDB: push data: key=FontSize,value=16
PreferenceDB: Get data ColorMode: 2
PreferenceDB: Get data FontSize: 16
UserConfigManager: 用户首选项持久化数据读取成功,colorMode=2,fontSize=16
AppInit: ✓ 用户配置加载成功
AppInit: 步骤 3/4:初始化业务管理器...
KVDatabase: Succeeded in creating KVManager.
KVDatabase: 数据库管理对象创建成功。
Ability onWindowStageCreate
EntryAbility: 窗口创建,等待阶段 1 完成...
Ability onForeground
EntryAbility: 应用进入前台
PreferenceDB: The key FontSize changed
PreferenceDB: The key ColorMode changed
KVDatabase: 成功获取storeId:HongYiXunKVDB数据库实例对象
NewsManager: init: 获取appKVDb成功
AppInit: ✓ 新闻管理器初始化成功
AppInit: 步骤 4/4:预加载应用数据...
AxiosHttp: 进入AxiosHttp.request URL = /api/health
AxiosHttp: 进入AxiosHttp.request URL = /api/banner/status
AppInit: ========== 阶段 1 完成:基础模块初始化成功 ==========
EntryAbility: ✓ 阶段 1 初始化成功
EntryAbility: 开始阶段 2 初始化...
AppInit: ========== 开始阶段 2:窗口相关初始化 ==========
AppInit: 初始化颜色模式管理器...
ColorModManager: applicationContext初始化成功
ColorModManager: initColoModSetting 2: AppStorageV2colorModel = 2
AppInit: ✓ 颜色模式管理器初始化成功
AppInit: ========== 阶段 2 完成:窗口相关初始化成功 ==========
EntryAbility: ✓ 阶段 2 初始化成功
Succeeded in loading the content.
EntryAbility: 启动页加载成功
EntryAbility: 开始阶段 3 初始化...
AppInit: ========== 开始阶段 3:UI 依赖模块初始化 ==========
AppInit: 初始化 Markdown 配置...
AppInit: ✓ Markdown 配置成功,字体大小: 16
AppInit: ✓ Markdown 配置初始化成功
AppInit: ========== 阶段 3 完成:UI 依赖模块初始化成功 ==========
EntryAbility: ✓ 阶段 3 初始化成功
EntryAbility: 窗口宽度已存储到 AppStorageV2: 1320px

在经过如此处理之后,输出结果的稳定性得到了大幅提升,经过20次的启动测试均未再出现异步函数执行顺序导致的初始化状态错误。

异步任务管理的关键函数

在我们AppInit的异步函数控制中使用了大量的Promise类内置的静态方法,同时也使用了async/await的处理方式,接下来我们来着重解析一下这些方法的作用。

async/await与Promise

首先我们要明确async/await与Promise的关系,async/await是Promise的语法糖,它可以让我们在异步函数中使用同步的代码风格,而不需要使用回调函数或者then方法。

这里我们可以从函数的返回值类型来看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 初始化业务管理器
*
* 初始化各个业务模块的管理器
*
* @param context - UIAbility 上下文
* @returns Promise<boolean> - 是否初始化成功
*
* @remarks
* 管理器列表:
* - NewsManager:新闻数据管理
* - 其他业务管理器...
*
* 注意:ColorModManager 依赖 ApplicationContext,在阶段 2 初始化
*
* @private
*/
private async initManagers(context: common.UIAbilityContext): Promise<boolean> {
try {
// 初始化新闻管理器
const newsManagerInitSuccess = await newsManager.init(context)
if (newsManagerInitSuccess) {
logger.info(`${LOG_TAG.APP_INIT}✓ 新闻管理器初始化成功`)
this.initStatus.managers = true
return true
} else {
logger.error(`${LOG_TAG.APP_INIT}✗ 新闻管理器初始化失败 - 原因:无法获取 KV 数据库实例`)
return false
}
} catch (error) {
logger.error(`${LOG_TAG.APP_INIT}✗ 管理器初始化异常 - 原因:${JSON.stringify(error)}`)
return false
}
}

这个函数中只包含了一个异步的耗时操作,同时我们的boolean类型的返回值表示的含义是初始化管理器是否成功,是强依赖于新闻管理器的初始化结果的,所以我们需要等待新闻管理器的初始化完成之后才能返回结果。对于这种单一的异步操作函数我们直接使用async/await的方式来处理,和使用then方法的方式没有区别,同时可以使代码风格更加简洁。

我们如果直接调用initManagers这个函数,获取到的是一个Promise对象,而并不是boolean类型的结果。只有等待其操作完成后,通过await或者.then()才能获取到真正的boolean值。

这里需要注意的是,当一个函数被标记为async时,它会自动返回一个Promise对象。这意味着调用者必须使用异步方式(await.then())来处理结果。这种设计虽然简化了异步代码的编写,但也意味着任何调用async函数的代码也都变成了异步的,形成了异步调用的链条效应。在复杂的初始化流程中,这种异步传播需要谨慎管理,以避免出现执行顺序不确定的问题。

接下来我们来更进一步的解析一下所谓的异步调用链效应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
/**
* 初始化业务管理器
*
* 初始化各个业务模块的管理器
*
* @param context - UIAbility 上下文
* @returns Promise<boolean> - 是否初始化成功
*
* @remarks
* 管理器列表:
* - NewsManager:新闻数据管理
* - 其他业务管理器...
*
* 注意:ColorModManager 依赖 ApplicationContext,在阶段 2 初始化
*
* @private
*/
private async initManagers(context: common.UIAbilityContext): Promise<boolean> {
try {
// 初始化新闻管理器
const newsManagerInitSuccess = await newsManager.init(context)
if (newsManagerInitSuccess) {
logger.info(`${LOG_TAG.APP_INIT}✓ 新闻管理器初始化成功`)
this.initStatus.managers = true
return true
} else {
logger.error(`${LOG_TAG.APP_INIT}✗ 新闻管理器初始化失败 - 原因:无法获取 KV 数据库实例`)
return false
}
} catch (error) {
logger.error(`${LOG_TAG.APP_INIT}✗ 管理器初始化异常 - 原因:${JSON.stringify(error)}`)
return false
}
}

/**
* 初始化函数,获取当前应用的键值对数据库实例。
* @param context
* @returns
*/
async init(context: common.UIAbilityContext): Promise<boolean> {
kvDatabase.init(context)
const res = await kvDatabase.getKVStoreById(APP_KV_DB_ID)
if (res) {
this.appKVDb = res
logger.info(`${LOG_TAG.NEWS_MANAGER}init: 获取appKVDb成功`)
return true
}
logger.error(`${LOG_TAG.NEWS_MANAGER}初始化失败`)
return false
}
/**
* 通过 ID 获取数据库实例对象
*
* 根据指定的数据库 ID 获取或创建一个 KV 数据库实例
*
* @param storeId - 数据库实例的唯一标识符
* @returns Promise<SingleKVStore | null> - 数据库实例对象,失败时返回 null
*
* @remarks
* 前置条件:
* - 必须先调用 init() 方法初始化 KVManager
* - 如果 kvManager 未初始化,将直接返回 null
*
* 数据库配置:
* - createIfMissing: true - 数据库不存在时自动创建
* - securityLevel: S1 - 安全级别(S1 为最低级别,适合公开数据)
* - kvStoreType: SINGLE_VERSION - 单版本数据库(不支持分布式同步)
*
* 监听机制:
* - 自动监听数据库服务状态变化
* - 当数据库服务异常时会记录警告日志
*
* 使用建议:
* - 建议为不同类型的数据创建不同的数据库实例
* - 本应用中使用 APP_KV_DB 常量作为统一的 storeId
* - 数据库实例可以被缓存复用,无需每次都重新获取
*
* @example
* ```typescript
* // 获取数据库实例
* const store = await kvDatabase.getKVStoreById(APP_KV_DB)
* if (store) {
* // 存储数据
* await store.put(KV_DB_KEYS.NEWS_ARTICLE_LIST, JSON.stringify(newsList))
*
* // 读取数据
* const data = await store.get(KV_DB_KEYS.NEWS_ARTICLE_LIST)
* const articles = JSON.parse(data as string)
* }
* ```
*
* @throws 不会抛出异常,所有错误都会被捕获并记录日志
*/
async getKVStoreById(storeId:string):Promise<distributedKVStore.SingleKVStore|null>{
if (this.kvManager) {
try {
const options:distributedKVStore.Options = {
createIfMissing: true,
securityLevel: distributedKVStore.SecurityLevel.S1,
kvStoreType:distributedKVStore.KVStoreType.SINGLE_VERSION
}
const kVStore:distributedKVStore.SingleKVStore = await this.kvManager.getKVStore(storeId,options)
if (kVStore) {
logger.info(`${LOG_TAG.KV_DATABASE}成功获取storeId:${storeId}数据库实例对象`)
this.kvManager.on('distributedDataServiceDie',()=>{
logger.warn(`${LOG_TAG.KV_DATABASE}数据库服务订阅发生变更`)
})
return kVStore
}
}catch (e){
let err = e as BusinessError
logger.error(`${LOG_TAG.KV_DATABASE}获取KV数据库实例对象异常,异常信息: ${err.message}`)
}
}
return null
}

/**
* Creates and obtains a KVStore database by specifying {@code Options} and {@code storeId}.
*
* @param { string } storeId - Identifies the KVStore database. The value of this parameter must be unique
* for the same application, and different applications can share the same value. The storeId can consist
* of only letters, digits, and underscores (_), and cannot exceed 128 characters.
* @param { Options } options - Indicates the {@code Options} object used for creating and
* obtaining the KVStore database.
* @returns { Promise<T> } {T}: the {@code SingleKVStore} or {@code DeviceKVStore} instance.
* @throws { BusinessError } 401 - Parameter error.Possible causes:1.Mandatory parameters are left unspecified;
* <br>2.Incorrect parameters types;
* <br>3.Parameter verification failed.
* @throws { BusinessError } 15100002 - Open existed database with changed options.
* @throws { BusinessError } 15100003 - Database corrupted.
* @syscap SystemCapability.DistributedDataManager.KVStore.Core
* @since 9
*/
getKVStore<T>(storeId: string, options: Options): Promise<T>;

我将整个调用链条所涉及到的全部函数都列出来了,其实可以看出整个异步调用链条的根源是来自getKVStore函数,这是我们作为应用开发者所能接触到的最底层的一个系统接口,更深层的实现就与开发者无关了,就如同计算机网络中下层协议对上层透明一样。为了方便应用的数据管理我们封装了KVDatabase类、NewsManager类、AppInit类。

层层封装的结构分析

让我们先梳理一下整个调用链条的层级关系:

1
2
3
4
5
6
7
8
9
第1层(系统接口): kvManager.getKVStore() -> Promise<SingleKVStore>

第2层(数据库封装): kvDatabase.getKVStoreById() -> Promise<SingleKVStore | null>

第3层(业务管理器): newsManager.init() -> Promise<boolean>

第4层(初始化管理器): appInit.initManagers() -> Promise<boolean>

第5层(阶段初始化): appInit.initPhase1_BaseModules() -> Promise<boolean>

每一层封装都在原有功能的基础上添加了新的职责:

  • 第2层 KVDatabase:添加了错误处理、日志记录、实例管理
  • 第3层 NewsManager:添加了业务逻辑封装、数据库实例缓存
  • 第4层 initManagers:添加了状态追踪、多管理器协调
  • 第5层 initPhase1:添加了阶段划分、步骤编排、进度报告
层层封装对应用架构的影响
正面影响

1. 职责分离与单一职责原则

每一层封装都有其明确的职责边界,这种设计符合SOLID原则中的单一职责原则:

  • KVDatabase类:负责键值数据库的底层操作,屏蔽系统接口的复杂性
  • NewsManager类:负责新闻数据的业务逻辑,不关心数据库的具体实现
  • AppInit类:负责应用的初始化流程编排,不关心各模块的内部实现细节

这种分层使得每个模块的代码更加内聚,修改某一层的实现不会影响其他层。比如我们后续如果要将键值数据库换成关系型数据库,只需要修改KVDatabase类的实现,而NewsManagerAppInit的代码无需改动。

2. 代码复用性提升

通过封装,我们避免了代码重复。比如kvDatabase.getKVStoreById()这个方法在项目中被多个Manager调用:

1
2
3
4
5
6
7
8
// NewsManager中使用
const res = await kvDatabase.getKVStoreById(APP_KV_DB_ID)

// 未来可能的UserManager中也会使用
const userStore = await kvDatabase.getKVStoreById(USER_KV_DB_ID)

// ConfigManager中也会使用
const configStore = await kvDatabase.getKVStoreById(CONFIG_KV_DB_ID)

如果没有这层封装,每个Manager都需要重复编写获取数据库的逻辑、错误处理、日志记录等代码,这会导致大量的代码重复和维护困难。

3. 错误处理的层次化

每一层都可以根据自己的职责添加适当的错误处理策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 第2层: KVDatabase - 捕获系统异常,返回null
async getKVStoreById(storeId:string):Promise<distributedKVStore.SingleKVStore|null>{
try {
const kVStore = await this.kvManager.getKVStore(storeId,options)
return kVStore
} catch (e) {
logger.error(`获取KV数据库实例对象异常`)
return null // 转换异常为null值
}
}

// 第3层: NewsManager - 检查null,返回boolean
async init(context: common.UIAbilityContext): Promise<boolean> {
const res = await kvDatabase.getKVStoreById(APP_KV_DB_ID)
if (res) {
this.appKVDb = res
return true
}
return false // 将null转换为失败状态
}

// 第4层: AppInit - 记录详细状态,影响整体初始化
private async initManagers(context: common.UIAbilityContext): Promise<boolean> {
const newsManagerInitSuccess = await newsManager.init(context)
if (newsManagerInitSuccess) {
this.initStatus.managers = true // 记录到状态追踪
} else {
logger.error(`新闻管理器初始化失败`)
}
return newsManagerInitSuccess
}

这种层次化的错误处理使得异常可以在最合适的层级被处理,上层代码不需要关心底层的具体异常类型,只需要关心操作是否成功。

负面影响

1. 性能开销

每一层的封装都会带来一定的性能开销,主要体现在:

  • 函数调用栈的增加:从getKVStore到最终的initPhase1,需要经过5层函数调用
  • Promise链条的延长:每一层都是一个Promise,意味着至少5次的Promise状态转换
  • 错误处理的重复:每一层都可能有try-catch,增加了错误检查的次数

不过在应用初始化这种非高频场景中,这些性能开销是可以接受的。如果是在高频调用的场景(比如滚动列表的渲染),就需要仔细权衡封装层次。

2. 调试复杂度增加

当出现问题时,需要逐层排查才能定位问题根源。比如当新闻管理器初始化失败时,可能的原因有:

  • 系统层:kvManager.getKVStore()调用失败
  • 封装层:KVDatabase初始化失败,kvManager为null
  • 业务层:storeId配置错误
  • 调用层:context传递错误

需要通过日志输出才能快速定位问题所在的层级,这就是为什么我们在每一层都添加了详细的日志记录。

3. 异步链条的传播效应

这是最重要也是最容易被忽视的影响。一旦底层函数是异步的,整个调用链条都会变成异步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 底层是异步的
async getKVStore() -> Promise<T>

// 导致所有上层都必须是异步的
async getKVStoreById() -> Promise<SingleKVStore|null>
async init() -> Promise<boolean>
async initManagers() -> Promise<boolean>
async initPhase1_BaseModules() -> Promise<boolean>

// 甚至影响到调用方
onCreate() {
// 必须使用异步方式调用
this.phase1Promise = appInit.initPhase1_BaseModules(this.context)
}

这种”异步传染”是不可避免的,一旦某个底层函数返回Promise,所有依赖它的上层函数都必须处理这个异步性。

层层封装对异步管理的影响
1. 异步操作的串行化

由于每一层都依赖于下一层的执行结果,这些异步操作必然是串行执行的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async initPhase1_BaseModules() {
// 步骤1: 初始化数据库 (异步)
const dbInitSuccess = await this.initDatabases(uiAbilityContext)
if (!dbInitSuccess) return false

// 步骤2: 加载用户配置 (同步,但依赖步骤1)
const configInitSuccess = this.initUserConfig(uiAbilityContext)

// 步骤3: 初始化管理器 (异步,依赖步骤1)
const managersInitSuccess = await this.initManagers(uiAbilityContext)

// 步骤4: 预加载数据 (异步,依赖步骤3)
await this.preloadData()
}

虽然我们使用了await来等待异步操作完成,但这种串行化也意味着总耗时是所有步骤耗时的总和。如果某个步骤耗时较长,会直接影响整体的初始化速度。

2. 异步操作的并行优化

在层层封装的架构下,我们仍然可以在合适的层级引入并行优化。比如在preloadData()中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private async preloadData(): Promise<void> {
try {
// 使用Promise.all实现并行加载
Promise.all([
newsManager.updateNewsListToDB(),
newsManager.updateNewsSwiperToDB()
]).then(() => {
logger.info(`数据预加载完成`)
}).catch((error: Error) => {
logger.warn(`数据预加载失败: ${JSON.stringify(error)}`)
})
} catch (error) {
logger.warn(`数据预加载异常: ${JSON.stringify(error)}`)
}
}

这里我们使用Promise.all()让新闻列表和轮播图的加载并行进行,而不是串行等待。这种优化可以在不破坏封装结构的前提下提升性能。

这里我们额外添加一些针对于Promise.all()的原理解析:

Promise.all()的工作机制

Promise.all()是JavaScript/TypeScript中用于处理多个异步操作的静态方法,它的核心特点是:

  1. 并行启动:接收一个Promise数组,会立即启动所有Promise,而不是等待前一个完成
  2. 全部等待:等待数组中所有Promise都resolve后才返回结果
  3. 快速失败:只要有一个Promise reject,整个Promise.all()就会立即reject

让我们通过代码对比来理解串行与并行的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 串行执行:总耗时 = 耗时1 + 耗时2
async function serialLoad() {
const startTime = Date.now()

// 第一个请求:假设耗时2秒
const newsList = await newsManager.updateNewsListToDB()
console.log(`新闻列表加载完成: ${Date.now() - startTime}ms`)

// 第二个请求:假设耗时1.5秒
const swiper = await newsManager.updateNewsSwiperToDB()
console.log(`轮播图加载完成: ${Date.now() - startTime}ms`)

// 总耗时约:2000ms + 1500ms = 3500ms
console.log(`串行总耗时: ${Date.now() - startTime}ms`)
}

// 并行执行:总耗时 = max(耗时1, 耗时2)
async function parallelLoad() {
const startTime = Date.now()

// 两个请求同时发起
const results = await Promise.all([
newsManager.updateNewsListToDB(), // 耗时2秒
newsManager.updateNewsSwiperToDB() // 耗时1.5秒
])

// 总耗时约:max(2000ms, 1500ms) = 2000ms
console.log(`并行总耗时: ${Date.now() - startTime}ms`)
// 性能提升:(3500-2000)/3500 = 42.8%
}

在我们的实际场景中,如果新闻列表加载需要800ms,轮播图加载需要600ms:

  • 串行执行:总耗时 = 800ms + 600ms = 1400ms
  • 并行执行:总耗时 = max(800ms, 600ms) = 800ms
  • 性能提升:约43%的启动速度提升

Promise.all()的内部执行流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Promise.all()的简化实现原理
function promiseAll(promises: Promise<any>[]): Promise<any[]> {
return new Promise((resolve, reject) => {
const results: any[] = []
let completedCount = 0

// 关键:立即启动所有Promise
promises.forEach((promise, index) => {
promise
.then(value => {
results[index] = value // 保持结果顺序
completedCount++

// 所有Promise都完成时,resolve整体结果
if (completedCount === promises.length) {
resolve(results)
}
})
.catch(error => {
// 任何一个Promise失败,立即reject
reject(error)
})
})
})
}

从这个实现可以看出几个关键点:

  1. 立即执行forEach会立即遍历所有Promise,触发它们的执行,而不是等待前一个完成
  2. 结果顺序:通过results[index]保证返回结果的顺序与输入顺序一致,即使某个Promise先完成
  3. 计数机制:通过completedCount追踪已完成的Promise数量
  4. 快速失败:任何一个Promise的reject都会导致整体reject,不会等待其他Promise

在我们项目中的实际应用

回到我们的preloadData()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private async preloadData(): Promise<void> {
try {
Promise.all([
newsManager.updateNewsListToDB(),
newsManager.updateNewsSwiperToDB()
]).then(() => {
logger.info(`数据预加载完成`)
}).catch((error: Error) => {
logger.warn(`数据预加载失败: ${JSON.stringify(error)}`)
})
} catch (error) {
logger.warn(`数据预加载异常: ${JSON.stringify(error)}`)
}
}

这里有一个值得注意的设计细节:我们没有使用await来等待Promise.all()

1
2
3
4
5
// 当前实现:不阻塞主流程
Promise.all([...]).then(...) // 异步执行,立即返回

// 如果使用await:会阻塞主流程
await Promise.all([...]) // 必须等待完成才能继续

这样做的原因是:

  • 数据预加载属于非关键路径,失败不应该阻止应用启动
  • 用户可以先看到界面,数据稍后加载完成后再显示
  • 如果网络较慢,不会让用户等待过长时间才看到界面

Promise.all()的风险与替代方案

虽然Promise.all()很强大,但也有其局限性:

风险1:一个失败导致全部失败

1
2
3
4
5
6
7
// 如果新闻列表加载失败,轮播图即使成功也会被忽略
Promise.all([
newsManager.updateNewsListToDB(), // 失败
newsManager.updateNewsSwiperToDB() // 成功但被忽略
]).catch(() => {
// 整体失败,无法获取轮播图数据
})

解决方案:使用Promise.allSettled()

1
2
3
4
5
6
7
8
9
10
11
12
13
// Promise.allSettled()会等待所有Promise完成,不管成功还是失败
const results = await Promise.allSettled([
newsManager.updateNewsListToDB(),
newsManager.updateNewsSwiperToDB()
])

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
logger.info(`任务${index}成功: ${result.value}`)
} else {
logger.warn(`任务${index}失败: ${result.reason}`)
}
})

这种方式更加健壮,即使某个数据源失败,其他数据仍然可以正常显示。

风险2:并发请求过多导致资源竞争

1
2
3
4
5
6
// 不好的做法:同时发起100个请求
const promises = []
for (let i = 0; i < 100; i++) {
promises.push(fetchData(i))
}
await Promise.all(promises) // 可能导致浏览器/服务器崩溃

解决方案:分批执行

1
2
3
4
5
6
7
8
9
10
11
12
13
// 好的做法:每次最多5个并发
async function batchLoad(tasks: Function[], concurrency: number = 5) {
const results: any[] = []

for (let i = 0; i < tasks.length; i += concurrency) {
const batch = tasks.slice(i, i + concurrency)
const batchResults = await Promise.all(batch.map(task => task()))
results.push(...batchResults)
logger.info(`完成批次 ${i / concurrency + 1},已加载 ${results.length}/${tasks.length}`)
}

return results
}

性能监控与调优

在实际开发中,我们可以添加性能监控来验证并行优化的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private async preloadData(): Promise<void> {
const startTime = Date.now()

try {
const promises = [
this.measureTime('新闻列表', newsManager.updateNewsListToDB()),
this.measureTime('轮播图', newsManager.updateNewsSwiperToDB())
]

await Promise.all(promises)

const totalTime = Date.now() - startTime
logger.info(`${LOG_TAG.APP_INIT}数据预加载完成,总耗时: ${totalTime}ms`)

} catch (error) {
logger.warn(`${LOG_TAG.APP_INIT}数据预加载失败: ${JSON.stringify(error)}`)
}
}

// 辅助方法:测量单个任务的执行时间
private async measureTime<T>(taskName: string, promise: Promise<T>): Promise<T> {
const start = Date.now()
try {
const result = await promise
logger.info(`${LOG_TAG.APP_INIT}${taskName} 完成,耗时: ${Date.now() - start}ms`)
return result
} catch (error) {
logger.error(`${LOG_TAG.APP_INIT}${taskName} 失败,耗时: ${Date.now() - start}ms`)
throw error
}
}

通过这样的监控,我们可以在开发阶段就发现性能瓶颈,并针对性地进行优化。

小结

Promise.all()是异步编程中非常重要的工具,它让我们能够在保持代码清晰度的同时显著提升性能。关键要点:

  1. 适用场景:多个独立的异步操作,彼此之间没有依赖关系
  2. 性能收益:总耗时从所有任务之和降低到最慢任务的耗时
  3. 错误处理:需要考虑部分失败的情况,必要时使用Promise.allSettled()
  4. 并发控制:大量并发请求时要考虑分批执行,避免资源耗尽
  5. 监控调优:添加性能监控,用数据驱动优化决策

关键点在于识别哪些操作是可以并行的:

  • 数据库初始化和用户配置加载:不能并行,因为配置加载依赖数据库
  • 新闻列表和轮播图加载:可以并行,它们之间没有依赖关系

这也是我之前所提到的拓扑学,我们需要理清楚各个模块之间的依赖关系,才能设计出合理的并行策略。

3. 异步状态的管理复杂度

在多层异步调用中,状态管理变得更加复杂。我们需要追踪每一层的执行状态:

1
2
3
4
5
6
7
private initStatus: InitStatus = {
databases: false,
userConfig: false,
managers: false,
appStorageV2: false,
markdown: false
}

这个状态对象需要在合适的时机更新,但由于异步操作的存在,更新时机很容易出错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 错误示例: 在异步操作完成前就标记为成功
async initManagers() {
newsManager.init(context) // 忘记await
this.initStatus.managers = true // 错误!此时init可能还未完成
return true
}

// 正确示例: 等待异步操作完成后再更新状态
async initManagers() {
const success = await newsManager.init(context) // 正确使用await
if (success) {
this.initStatus.managers = true // 此时可以确保init已完成
}
return success
}
4. 异步链条中的错误传播

在层层封装的异步调用中,错误的传播路径需要精心设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 底层抛出异常
async getKVStore() {
throw new Error("Database connection failed")
}

// 中间层捕获并转换
async getKVStoreById() {
try {
return await this.kvManager.getKVStore(storeId, options)
} catch (e) {
logger.error(`获取KV数据库异常`)
return null // 转换为null,不继续向上抛异常
}
}

// 上层检查null值
async init() {
const res = await kvDatabase.getKVStoreById(APP_KV_DB_ID)
if (res) {
return true
}
return false // 将null转换为false
}

// 最上层处理false值
async initPhase1() {
const dbInitSuccess = await this.initDatabases(context)
if (!dbInitSuccess) {
logger.error(`数据库初始化失败,终止初始化流程`)
return false // 向调用者返回失败状态
}
}

这种设计模式将异常转换为返回值,使得错误处理更加可控,避免了未捕获异常导致的应用崩溃。但代价是需要在每一层都进行状态检查。

实践经验总结

通过这次AppInit的重构和异步管理的实践,我总结出以下几点经验:

1. 封装层次要适度

不是封装层次越多越好,也不是越少越好。关键是每一层都要有其存在的价值:

  • 如果某一层只是简单的转发调用,没有添加任何额外逻辑,那这一层可能是多余的
  • 如果某一层承担了过多的职责,那可能需要进一步拆分

2. 异步操作要明确标注

在ts和ArkTS中,一定要明确标注函数的返回类型:

1
2
3
4
5
// 好的做法: 明确标注返回Promise<boolean>
async init(context: common.UIAbilityContext): Promise<boolean>

// 不好的做法: 依赖类型推断
async init(context: common.UIAbilityContext) // 返回类型不明确

明确的类型标注可以帮助IDE提供更好的代码补全,也能让其他开发者一眼看出这是一个异步函数。

3. 日志记录要分层详细

每一层都应该有自己的日志记录,且要包含足够的上下文信息:

1
2
3
logger.info(`${LOG_TAG.APP_INIT}步骤 1/4: 初始化数据库模块...`)
logger.info(`${LOG_TAG.KV_DATABASE}成功获取storeId:${storeId}数据库实例对象`)
logger.info(`${LOG_TAG.NEWS_MANAGER}init: 获取appKVDb成功`)

通过不同的LOG_TAG和详细的描述,可以快速定位问题所在的层级。

4. 状态管理要及时准确

在异步操作完成后立即更新状态,不要延迟,这也包含了数据库中所学的原子化操作的思想,我们虽然不可能在异步操作执行成功的“时刻”进行分秒不差的同步状态更新,但我们可以将操作完成到状态更新之间的操作尽可能的缩小,压缩到所有操作产生的延时都可以小到忽略不计,将任务对象与其状态标识符进行“强绑定”:

1
2
3
4
5
const newsManagerInitSuccess = await newsManager.init(context)
if (newsManagerInitSuccess) {
this.initStatus.managers = true // 立即更新状态
logger.info(`新闻管理器初始化成功`)
}

5. 要为异步操作预留缓冲时间

如同我们在printFinalInitStatus()中使用setTimeout一样,要考虑到异步操作的不确定性:

1
2
3
setTimeout(() => {
this.printFinalInitStatus()
}, 100) // 给异步操作留出完成时间

这种设计虽然看起来不够优雅,但在复杂的异步场景中是必要的容错机制。

对于AI辅助开发的思考

在这次重构中,Claude提供的代码质量确实很高,但也暴露了一些AI的局限性:

  1. AI对异步执行顺序的理解有限:最初的版本没有考虑到异步操作可能晚于状态打印执行的问题,需要人工发现并修复
  2. AI倾向于过度工程化:生成的代码注释非常详细,封装层次也很完整,但可能对小型项目来说过于复杂
  3. AI缺乏实际运行环境的感知:只有在真实设备上运行才能发现日志顺序的问题

因此,AI辅助开发的最佳实践应该是:AI负责生成规范化的代码框架,人类负责根据实际运行情况进行调优。就像这次重构,Claude提供了优秀的架构设计和详细的注释,而我通过实际测试发现并修复了异步执行顺序的问题,两者结合才能产出高质量的代码。


对于加载数据流的改造

问题描述

当下我面临的另一个严峻的问题就在于数据源的加载速度过慢。这一方面是我的服务器仅仅是暂时共享了我的博客服务器,属于是最基础的一档服务器,带宽很小,另外一方面是在于我当下使用的是/api/news/?all=true这样一个最基本的接口,他会默认的将全部的数据不加分类不加分页的一口气全部发送过来,这就导致数十M的数据在我服务器本就局限的带宽上跑的愈发缓慢了,我绝对不能放开带宽限制,要不然单词更新就会将我服务器的下行带宽完全堵死。不过后面完成后部署到中软那边的服务器应该就不会出现这个问题了。

但在当前环境下,我的单次刷新会长达20秒左右的加载时间,对于首次启动会是一个比较致命的问题,毕竟启动页的延时是远远不够加载全部数据的,在首次渲染时注定是没有数据的。首先这个接口并不是流式输出接口,我们必须等待这个接口的单次响应被完整的接收后才会开始去进行数据库数据的更新以及渲染数据的更新。

为了解决这个问题,我首先想到的是进行分页处理,对于数据进行分页获取处理,通过page_sizepage,两个参数来去进行数据的分页,在单次响应中会包含有"has_next""has_prev"这样两个参数来为客户端的数据流提供结束标识符。对于最后一页的数据可能会出现page_size的大小比剩余的数据量大的情况,这种情况在我的后端中是会自动的去处理的并不会出现越界的异常,所以我们前端的分页数据流仅需要停止在当"has_next"为false时去终止即可。

对于这种方式因为中间会加入很多的确认是否完整获取,以及当前数据处于整段数据流中的什么位置的流程,这都是为了保证在更新数据库数据时能够正确的按照后端排好的日期顺序去进行存入,所以整体的获取时间会被拉长,但是在前台也就是用户能感知的到的流程上来看是可以被压缩到一秒到两秒之内就完成的,因为我们可以将当前显示的数据的前20条更新为最新后就结束Refresh组件的onRefreshing流程,随后我们就会在后台去进行完整的数据获取流程,将这个流程统一的压缩至一个函数中进行控制,仅需要修改一个Refresh组件的标识符就可以完成用户感知层面的提速。

接下来我们来看一下这个关键函数的实现吧。

核心函数解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
/**
* 两阶段刷新 - 第一阶段:快速加载各分栏前20条最新数据
*
* 使用 Promise.all 并发加载所有已开发栏目的前20条数据,快速响应用户
*
* @returns Promise<{ success: boolean, loadedCount: number }> - 加载结果和成功加载的栏目数
*
* @remarks
* 刷新策略:
* - 仅加载已开发的栏目(isDeveloped === true)
* - 每个栏目获取最新 20 条数据
* - 使用 Promise.all 并发请求,最大化速度
* - 单个栏目失败不影响其他栏目
* - 同时刷新轮播图数据
*
* 适用场景:
* - 用户下拉刷新时的第一阶段
* - 需要快速看到最新内容
*
* @example
* ```typescript
* const result = await newsManager.quickRefreshCategories()
* if (result.success) {
* console.log(`快速刷新完成,加载了 ${result.loadedCount} 个栏目`)
* }
* ```
*/
async quickRefreshCategories(): Promise<QuickRefreshResult> {
logger.info(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 开始第一阶段:并发加载各分栏前20条数据`)

if (!(await ServerHealthAPI.isServerReady())) {
logger.error(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 服务端未就绪`)
const result: QuickRefreshResult = { success: false, loadedCount: 0 }
return result
}

if (!this.appKVDb) {
logger.error(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 数据库未初始化`)
const result: QuickRefreshResult = { success: false, loadedCount: 0 }
return result
}

try {
const startTime = Date.now()

// 筛选出已开发的栏目
const developedCategories = NEWS_CATEGORIES.filter(cat => cat.isDeveloped)
logger.info(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 需要刷新 ${developedCategories.length} 个栏目`)

// 创建并发加载任务数组
const loadTasks = developedCategories.map(async (category: NewsCategoryInfo) => {
try {
logger.debug(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 开始加载【${category.displayName}】前20条`)

// 调用分类 API 获取前20条数据
const response = await NewsListAPI.getNewsByCategory(category.apiCategory, 1, 20)

if (response && response.articles.length > 0) {
// 读取现有数据
let existingArticles: NewsArticle[] = []
try {
const existingData = (await this.appKVDb!.get(category.dbKey)) as string
existingArticles = JSON.parse(existingData) as NewsArticle[]
} catch (e) {
existingArticles = []
}

// 合并去重(新数据优先)
const articleMap = new Map<string, NewsArticle>()
existingArticles.forEach(article => {
if (article.id) articleMap.set(article.id, article)
})
response.articles.forEach(article => {
if (article.id) articleMap.set(article.id, article)
})

// 按日期倒序排序
const mergedArticles = Array.from(articleMap.values())
.sort((a, b) => b.date.localeCompare(a.date))

// 写入数据库
await this.appKVDb!.put(category.dbKey, JSON.stringify(mergedArticles))

logger.info(`${LOG_TAG.NEWS_MANAGER}[快速刷新] ✓ 【${category.displayName}】加载成功: ${response.articles.length}条`)
const result: CategoryLoadResult = { category: category.displayName, success: true, count: response.articles.length }
return result
} else {
logger.warn(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 【${category.displayName}】无数据`)
const result: CategoryLoadResult = { category: category.displayName, success: false, count: 0 }
return result
}
} catch (error) {
logger.error(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 【${category.displayName}】加载失败: ${JSON.stringify(error)}`)
const result: CategoryLoadResult = { category: category.displayName, success: false, count: 0 }
return result
}
})

// 同时加载轮播图数据
const swiperTask = this.updateNewsSwiperToDB()

// 并发执行所有任务
const results = await Promise.all([...loadTasks, swiperTask])

// 统计结果(最后一个是轮播图任务)
const categoryResults = results.slice(0, -1) as CategoryLoadResult[]
const swiperSuccess = results[results.length - 1] as boolean

const successCount = categoryResults.filter(r => r.success).length
const totalArticles = categoryResults.reduce((sum, r) => sum + r.count, 0)

const endTime = Date.now()
const duration = endTime - startTime

logger.info(`${LOG_TAG.NEWS_MANAGER}[快速刷新] ✓ 第一阶段完成: ${successCount}/${developedCategories.length} 个栏目成功, 共${totalArticles}条数据, 耗时${duration}ms`)
logger.info(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 轮播图刷新: ${swiperSuccess ? '成功' : '失败'}`)

const finalResult: QuickRefreshResult = {
success: successCount > 0,
loadedCount: successCount
}
return finalResult

} catch (error) {
let err = error as BusinessError
logger.error(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 异常: ${err.message}`)
const result: QuickRefreshResult = { success: false, loadedCount: 0 }
return result
}
}

/**
* 两阶段刷新 - 第二阶段:后台完整加载所有分栏数据
*
* 在后台逐个加载各栏目的全部数据,并提供进度回调实时更新 UI
*
* @param onProgress - 进度回调函数,参数为 { category: 栏目名, current: 当前进度, total: 总数 }
* @returns Promise<{ success: boolean, updatedCount: number }> - 加载结果和成功更新的栏目数
*
* @remarks
* 刷新策略:
* - 后台逐个加载已开发栏目的全部数据
* - 每个栏目完成后立即回调,通知 UI 更新
* - 单个栏目失败不影响其他栏目
* - 按日期倒序排序后存储
*
* 适用场景:
* - 快速刷新完成后的后台任务
* - 确保数据完整性和最新性
*
* @example
* ```typescript
* newsManager.fullRefreshCategories((progress) => {
* console.log(`${progress.category}: ${progress.current}/${progress.total}`)
* }).then(result => {
* console.log(`完整刷新完成,更新了 ${result.updatedCount} 个栏目`)
* })
* ```
*/
async fullRefreshCategories(
onProgress?: (progress: RefreshProgress) => void
): Promise<FullRefreshResult> {
logger.info(`${LOG_TAG.NEWS_MANAGER}[完整刷新] 开始第二阶段:后台加载全部分栏数据`)

if (!(await ServerHealthAPI.isServerReady())) {
logger.error(`${LOG_TAG.NEWS_MANAGER}[完整刷新] 服务端未就绪`)
const result: FullRefreshResult = { success: false, updatedCount: 0 }
return result
}

if (!this.appKVDb) {
logger.error(`${LOG_TAG.NEWS_MANAGER}[完整刷新] 数据库未初始化`)
const result: FullRefreshResult = { success: false, updatedCount: 0 }
return result
}

try {
const startTime = Date.now()
const developedCategories = NEWS_CATEGORIES.filter(cat => cat.isDeveloped)
const totalCategories = developedCategories.length
let updatedCount = 0

// 逐个加载栏目(避免并发过多导致服务器压力)
for (let i = 0; i < developedCategories.length; i++) {
const category = developedCategories[i]
const current = i + 1

try {
logger.info(`${LOG_TAG.NEWS_MANAGER}[完整刷新] [${current}/${totalCategories}] 加载【${category.displayName}】全部数据`)

// 获取该分类的所有数据
const allNews = await NewsListAPI.getAllNewsByCategory(category.apiCategory)

if (allNews && allNews.length > 0) {
// 按日期倒序排序
const sortedNews = allNews.sort((a, b) => b.date.localeCompare(a.date))

// 直接覆盖存储(已经是最新完整数据)
await this.appKVDb.put(category.dbKey, JSON.stringify(sortedNews))

updatedCount++
logger.info(`${LOG_TAG.NEWS_MANAGER}[完整刷新] ✓ [${current}/${totalCategories}] 【${category.displayName}】完成: ${sortedNews.length}条`)

// 回调进度更新
if (onProgress) {
const progress: RefreshProgress = {
category: category.displayName,
current: current,
total: totalCategories
}
onProgress(progress)
}
} else {
logger.warn(`${LOG_TAG.NEWS_MANAGER}[完整刷新] [${current}/${totalCategories}] 【${category.displayName}】无数据`)
}

} catch (error) {
logger.error(`${LOG_TAG.NEWS_MANAGER}[完整刷新] [${current}/${totalCategories}] 【${category.displayName}】失败: ${JSON.stringify(error)}`)
}
}

const endTime = Date.now()
const duration = endTime - startTime

logger.info(`${LOG_TAG.NEWS_MANAGER}[完整刷新] ✓ 第二阶段完成: ${updatedCount}/${totalCategories} 个栏目成功, 耗时${duration}ms`)

const finalResult: FullRefreshResult = {
success: updatedCount > 0,
updatedCount: updatedCount
}
return finalResult

} catch (error) {
let err = error as BusinessError
logger.error(`${LOG_TAG.NEWS_MANAGER}[完整刷新] 异常: ${err.message}`)
const result: FullRefreshResult = { success: false, updatedCount: 0 }
return result
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* 两阶段刷新数据
*
* 第一阶段:快速加载各分栏前20条 + 轮播图,立即结束刷新动画
* 第二阶段:后台完整加载全部数据,实时更新 UI
*
* @returns Promise<boolean> - 第一阶段是否成功
*/
async reloadAllData(): Promise<boolean> {
logger.info(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 开始刷新`)
promptAction.openToast({ message: '正在快速刷新最新数据...', duration: 1500 })

try {
// ========== 第一阶段:快速刷新(并发加载前20条) ==========
const quickResult = await newsManager.quickRefreshCategories()

if (quickResult.success) {
// 刷新成功,重新加载轮播图数据
this.newsSwiperData = await newsManager.getNewsSwiperDataFromDB()

// 触发 NewsList 组件重新加载分类数据
this.refreshTrigger++

logger.info(`${LOG_TAG.NEWS_LIST}[两阶段刷新] ✓ 第一阶段完成: ${quickResult.loadedCount}个栏目`)
promptAction.openToast({
message: `刷新成功,已更新${quickResult.loadedCount}个栏目`,
duration: 2000
})

// ========== 第二阶段:后台完整刷新(逐个加载全部数据) ==========
// 不阻塞 UI,在后台执行
newsManager.fullRefreshCategories((progress) => {
logger.debug(`${LOG_TAG.NEWS_LIST}[两阶段刷新] [${progress.current}/${progress.total}] 【${progress.category}】完成`)

// 每个栏目完成后触发 UI 更新
this.refreshTrigger++

}).then((fullResult) => {
if (fullResult.success) {
logger.info(`${LOG_TAG.NEWS_LIST}[两阶段刷新] ✓ 第二阶段完成: ${fullResult.updatedCount}个栏目`)
promptAction.openToast({
message: `后台更新完成,所有数据已是最新`,
duration: 2000
})

// 最后一次触发更新
this.refreshTrigger++
} else {
logger.warn(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 第二阶段部分失败`)
}
}).catch((error: Error) => {
logger.error(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 第二阶段异常: ${error.message}`)
})

return true
} else {
logger.error(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 第一阶段失败`)
promptAction.openToast({ message: '刷新失败,请检查网络连接', duration: 2000 })
return false
}

} catch (error) {
logger.error(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 异常: ${JSON.stringify(error)}`)
promptAction.openToast({ message: '刷新失败,请稍后再试', duration: 2000 })
return false
}
}

接下来我们将通过几个方面去继续解析。

数据结构层面

首先无论是在快速刷新方法中,还是完整更新方法中,我们是直接将对网络请求以及对数据库的更新读写操作都要封装进了内部,最终返回的仅仅是结果。在我过去的编码习惯中仅仅是返回一个布尔值,用最简单的方式去标明是否成功,然后将所有的异常信息都通过弹窗或者是日志去展示。但这样对于调用本函数的上层函数来说只能看到更新是否成功,但是并不知道具体更新成功的条数以及具体的更新过程进度如何。

我们在设计软件时最重要的一点就是考虑用户的体验,而影响用户体验最重要的因素就是是否存在“莫名其妙的卡顿”和“长时间的忙等”对于开发者来说通过日志可以看出软件的运行情况,但是对于用户来说没有信息的等待时没有任何破解方法的,是最败坏用户体验的。

而从工程的角度来说我们的逻辑管理模块要尽可能的和UI控件解耦,弹窗这种API已经经历了数次大改,使用更加稳定的语言基本语法糖而不依赖于会随版本变化的API去进行功能解耦对于后续应用的升级维护肯定是更有利的,所以我们决定用一个对象包裹原本的成功标识符以及新增的成功条数字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 栏目加载结果接口
*
* 用于记录单个栏目的加载结果
*/
export interface CategoryLoadResult {
/** 栏目名称 */
category: string
/** 是否成功 */
success: boolean
/** 加载的新闻条数 */
count: number
}

将信息传递出去,在产品定制层去进行用户UI上的提示肯定是更好的选择。上面的单个栏目加载结果就是对基础能力层提供的网络接口的进一步封装,可以做到为下一步处理提供更详细的信息以及定制化的处理。

1
2
3
4
5
6
7
8
9
10
11
logger.info(`${LOG_TAG.NEWS_MANAGER}[快速刷新] ✓ 【${category.displayName}】加载成功: ${response.articles.length}条`)
const result: CategoryLoadResult = { category: category.displayName, success: true, count: response.articles.length }
return result

logger.warn(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 【${category.displayName}】无数据`)
const result: CategoryLoadResult = { category: category.displayName, success: false, count: 0 }
return result

logger.error(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 【${category.displayName}】加载失败: ${JSON.stringify(error)}`)
const result: CategoryLoadResult = { category: category.displayName, success: false, count: 0 }
return result

可以做到向上面这样的定制化日志和返回值。

同样的思路,我们对于快速数据更新接口以及完整数据更新接口也做了类似的设计。并利用单个栏目加载结果对象传递出来的信息去进行进一步的日志打印以及返回值数据的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 快速刷新结果接口
*
* 用于两阶段刷新的第一阶段返回结果
*/
export interface QuickRefreshResult {
/** 是否成功 */
success: boolean
/** 成功加载的栏目数量 */
loadedCount: number
}

/**
* 完整刷新结果接口
*
* 用于两阶段刷新的第二阶段返回结果
*/
export interface FullRefreshResult {
/** 是否成功 */
success: boolean
/** 成功更新的栏目数量 */
updatedCount: number
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
if (!(await ServerHealthAPI.isServerReady())) {
logger.error(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 服务端未就绪`)
const result: QuickRefreshResult = { success: false, loadedCount: 0 }
return result
}
if (!this.appKVDb) {
logger.error(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 数据库未初始化`)
const result: QuickRefreshResult = { success: false, loadedCount: 0 }
return result
}


// 统计结果(最后一个是轮播图任务)
const categoryResults = results.slice(0, -1) as CategoryLoadResult[]
const swiperSuccess = results[results.length - 1] as boolean

const successCount = categoryResults.filter((r: CategoryLoadResult): boolean => r.success).length
const totalArticles = categoryResults.reduce((sum: number, r: CategoryLoadResult): number => sum + r.count, 0)

const endTime = Date.now()
const duration = endTime - startTime
logger.info(`${LOG_TAG.NEWS_MANAGER}[快速刷新] ✓ 第一阶段完成: ${successCount}/${developedCategories.length} 个栏目成功, 共${totalArticles}条数据, 耗时${duration}ms`)
logger.info(`${LOG_TAG.NEWS_MANAGER}[快速刷新] 轮播图刷新: ${swiperSuccess ? '成功' : '失败'}`)
const finalResult: QuickRefreshResult = {
success: successCount > 0,
loadedCount: successCount
}
return finalResult
1
2
3
4
5
6
7
const duration = endTime - startTime
logger.info(`${LOG_TAG.NEWS_MANAGER}[完整刷新] ✓ 第二阶段完成: ${updatedCount}/${totalCategories} 个栏目成功, 耗时${duration}ms`)
const finalResult: FullRefreshResult = {
success: updatedCount > 0,
updatedCount: updatedCount
}
return finalResult

就像上面的例子一样,利用上一步暴露出来的信息进一步封装这一步的返回值,最终就会将所有处理过符合要求的数据暴露给产品定制层,不会出现在数据管理器中还需要调用UI接口的情况。

并发控制层面

对于当前新闻更新需求,我们将更新流程拆分为了快速更新和全量加载,对于全量加载模式,每一个栏目的数据量都很大,我们不能并发加载否则会对服务器造成过大的压力,但是对于快速更新来说,每个栏目仅需要更新20条新数据,总量很小,同时要求的就是快速更新,所以说我们需要使用并发控制函数来去继续加载。

首先我们先通过数组的内置map函数去创建好待执行的任务列表

1
const loadTasks = developedCategories.map(async (category: NewsCategoryInfo): Promise<CategoryLoadResult> => {})

随后单独创建一个轮播图的更新任务Promise对象。

1
const swiperTask = this.updateNewsSwiperToDB()

随后利用Promise.all去并发执行全部快速加载任务来实现快速更新。

1
2
// 并发执行所有任务
const results = await Promise.all([...loadTasks, swiperTask])

results是接收了全部任务执行结果的结果列表。

1
const results: [...(boolean | CategoryLoadResult)[], boolean | CategoryLoadResult]

loadTasksswiperTask两者的返回结果不一致,这导致了results的类型是联合类型。

联合类型数组我们无法直接通过遍历进行处理,所以我们需要先进行截取操作,将最后一位的轮播图任务结果单独提取出来。

1
2
3
// 统计结果(最后一个是轮播图任务)
const categoryResults = results.slice(0, -1) as CategoryLoadResult[]
const swiperSuccess = results[results.length - 1] as boolean

当最后一位被截取出来后,我们就可以将两组更新结果进行类型的声明了。

这里针对于slice函数的用法去进行一下进一步的解析。读了我每日算法栏目的人应该会知道我在处理数组问题时习惯于利用slicesplice这些内置函数去进行数组操作无论是其本义的截取还是插入,删除,替换……毕竟这些函数在不针对数组元素内部的操作,仅对于数组元素层面的操作确实很万金油。

这两者的作用和用法甚至是拼写都很相似,”有个p的区别”,所以这里要展开说一下两者的区别。

slice和splice的区别

核心区别在于 是否修改原数组 以及 功能定位(截取 vs 增删改)

特性 slice splice
是否修改原数组 否(返回新数组) 是(直接修改原数组)
功能 截取数组片段(只读) 增/删/改数组元素(写操作)
返回值 截取的新数组 被删除的元素组成的数组(无删除则返回空数组)
参数 (start, end) (start, deleteCount, item1, item2, …)
参数特性 end 不包含、支持负数 deleteCount 为 0 时仅新增、支持负数索引

slice 用于从数组中截取部分片段,返回新数组,原数组保持不变

1
array.slice(start[, end])

start:截取起始索引(必填)

  • 正数:从数组开头计数(0 为第一个元素)
  • 负数:从数组末尾计数(-1 为最后一个元素)
  • 省略 / 超出数组长度:默认从 0 开始

end:截取结束索引(可选)

  • 正数:截取到该索引 前一位(不包含 end 本身)
  • 负数:从末尾计数到该索引前一位
  • 省略 / 超出数组长度:默认截取到数组末尾

根据以上规则我们可以推断出,我们截取新闻更新接口结果对象列表的数据处理代码也可以编写成如下形式:

1
2
3
4
5
6
7
// 源代码
const categoryResults = results.slice(0, -1) as CategoryLoadResult[]
const swiperSuccess = results[results.length - 1] as boolean

// 等效代码
const categoryResults = results.slice(0, results.length - 1) as CategoryLoadResult[]
const swiperSuccess = results.slice(-1)[0] as boolean

两者实现的效果是完全一致的。

当然我们在开发中还经常会遇到深浅拷贝问题,就是是我在做算法题时创建了一个新的数组存储结果,最后仅仅将新数组的引用赋值给了结果变量导致结果异常。我们可以利用slice函数截取原数组后会生成一个新数组返回的特点来去对原数组进行深拷贝。

以上提到的深拷贝仅仅是针对于数组这一层的深拷贝!!!如果数组中存放的是number、boolean、string(JS、TS、ArkTS中!!!)等基本类型的值,那么我们在进行深拷贝时,拷贝的就是实际的值,不是引用。但是如果是数组对象等引用类型的值,那么我们在进行slice时,拷贝的就是引用,而不是实际的值!!!对于对象数组还是需要对每个对象进行手动的深拷贝的!!!

重要特性:

  • 基本类型的值是 按值传递 的
  • 基本类型变量存储的就是实际的值
  • 对基本类型进行拷贝时,拷贝的是实际的值,不是引用

TypeScript中的基本类型包括:

  1. number - 数值类型
  2. string - 字符串类型
  3. boolean - 布尔类型
  4. bigint - 大整数类型
  5. symbol - 符号类型
  6. undefined - 未定义类型
  7. null - 空值类型

C语言中,string不是基本类型!这与JavaScript/TypeScript/ArkTS完全不同。

C语言的基本类型包括:

  • char - 字符类型(1字节)
  • int - 整型
  • short - 短整型
  • long - 长整型
  • float - 单精度浮点型
  • double - 双精度浮点型

C语言中的字符串处理:

  1. 字符串本质是字符数组

    1
    2
    3
    4
    5
    6
    // 方式一:字符数组
    char str1[] = "Hello"; // 自动添加'\0'结尾
    char str2[6] = {'H', 'e', 'l', 'l', 'o', '\0'};

    // 方式二:字符指针
    char* str3 = "Hello"; // 字符串字面量,通常存放在只读数据段
  2. 没有内建的字符串操作

    C语言标准库提供了<string.h>头文件中的函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include <string.h>

    // 字符串长度
    size_t len = strlen(str);

    // 字符串复制
    strcpy(dest, src);

    // 字符串连接
    strcat(dest, src);

    // 字符串比较
    int result = strcmp(str1, str2);
  3. 字符串以’\0’结尾

    这是C字符串的重要特征:

    1
    2
    char str[] = {'H', 'e', 'l', 'l', 'o', '\0'};  // 正确
    char str2[] = {'H', 'e', 'l', 'l', 'o'}; // 错误!没有结尾符

    | 特性 | JavaScript/TS/ArkTS | C语言 |
    |———|—————————-|———-|
    | string类型 | ✅ 基本类型 | ❌ 不存在 |
    | 字符串表示 | 直接使用string | char数组或char指针 |
    | 内存管理 | 自动垃圾回收 | 手动管理(malloc/free) |
    | 操作符支持 | + 连接、== 比较 | 需要函数调用 |
    | 内存安全 | 有边界检查 | 无边界检查(缓冲区溢出风险) |

而对于splice函数,我们则需要注意其会直接修改原数组,所以在使用时需要注意不要误操作导致数据丢失。

1
array.splice(start[, deleteCount[, item1[, item2[, ...]]]])

start:操作起始索引(必填)

  • 正数:从开头计数
  • 负数:从末尾计数(-1 为最后一个元素)
  • 超出数组长度:默认从数组末尾开始

deleteCount:要删除的元素个数(可选)

  • 0:不删除元素(仅用于插入)
  • 正数:删除对应个数的元素(超出剩余元素则删除到末尾)
  • 省略 / 负数:删除从 start 到数组末尾的所有元素
  • 超出数组长度:默认删除到数组末尾

item1, item2…:要插入 / 替换的元素(可选)
在 start 索引位置插入这些元素(删除后插入,或直接插入)

由此我们可以推出如果我们对原数组进行切分处理的话代码也可以写成以下形式:

1
2
3
4
5
6
7
// 源代码
const categoryResults = results.slice(0, -1) as CategoryLoadResult[]
const swiperSuccess = results[results.length - 1] as boolean

// 等效代码
const categoryResults = results.splice(0, results.length - 1) as CategoryLoadResult[]
const swiperSuccess = results[0] as boolean

当然由于splice函数会直接修改原数组,所以这种等效一般来说是不推荐的,只有满足以下两个条件时,用 splice 才不会有问题:

  • 原数组 results 后续 完全不再使用(不需要复用原数据)
  • 明确需要 “清理原数组”(比如释放内存,避免大数据占用)

但这种场景在实际开发中很少见,大多数情况下,我们更倾向于 “不修改原数据”(immutable 编程思想),避免副作用(比如函数调用后意外改变入参),而 slice 正是符合这种思想的安全方法。

总体流程控制层面

对于刷新数据的总体流程控制函数是在product/default/src/main/ets/pages/tab_contents/NewsListTabContent.ets中的reloadAllData中去进行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* 两阶段刷新数据
*
* 第一阶段:快速加载各分栏前20条 + 轮播图,立即结束刷新动画
* 第二阶段:后台完整加载全部数据,实时更新 UI
*
* @returns Promise<boolean> - 第一阶段是否成功
*/
async reloadAllData(): Promise<boolean> {
logger.info(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 开始刷新`)
promptAction.openToast({ message: '正在快速刷新最新数据...', duration: 1500 })

try {
// ========== 第一阶段:快速刷新(并发加载前20条) ==========
const quickResult = await newsManager.quickRefreshCategories()

if (quickResult.success) {
// 刷新成功,重新加载轮播图数据
this.newsSwiperData = await newsManager.getNewsSwiperDataFromDB()

// 触发 NewsList 组件重新加载分类数据
this.refreshTrigger++

logger.info(`${LOG_TAG.NEWS_LIST}[两阶段刷新] ✓ 第一阶段完成: ${quickResult.loadedCount}个栏目`)
promptAction.openToast({
message: `刷新成功,已更新${quickResult.loadedCount}个栏目`,
duration: 2000
})

// ========== 第二阶段:后台完整刷新(逐个加载全部数据) ==========
// 不阻塞 UI,在后台执行
newsManager.fullRefreshCategories((progress) => {
logger.debug(`${LOG_TAG.NEWS_LIST}[两阶段刷新] [${progress.current}/${progress.total}] 【${progress.category}】完成`)

// 每个栏目完成后触发 UI 更新
this.refreshTrigger++

}).then((fullResult) => {
if (fullResult.success) {
logger.info(`${LOG_TAG.NEWS_LIST}[两阶段刷新] ✓ 第二阶段完成: ${fullResult.updatedCount}个栏目`)
promptAction.openToast({
message: `后台更新完成,所有数据已是最新`,
duration: 2000
})

// 最后一次触发更新
this.refreshTrigger++
} else {
logger.warn(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 第二阶段部分失败`)
}
}).catch((error: Error) => {
logger.error(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 第二阶段异常: ${error.message}`)
})

return true
} else {
logger.error(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 第一阶段失败`)
promptAction.openToast({ message: '刷新失败,请检查网络连接', duration: 2000 })
return false
}

} catch (error) {
logger.error(`${LOG_TAG.NEWS_LIST}[两阶段刷新] 异常: ${JSON.stringify(error)}`)
promptAction.openToast({ message: '刷新失败,请稍后再试', duration: 2000 })
return false
}
}

这里我们从以下三点来进行解析:

  1. 两阶段刷新确保速度和完整性
  2. 进度回调函数
  3. UI扳机机制

首先对于两阶段刷新,我们此前的痛点就是在于我们每一次刷新都要等后端发回全部的数据,这就会导致我们的等待时间大大增加,无提示无变化的“忙等”会极大的降低用户的体验。

这里我们在第一阶段完成后立即触发 UI 更新,用户可以立即看到刷新结果,而不需要等待所有数据加载完成。这里的UI更新指的并不是将Refresh({ refreshing: $$this.isLoading })组件所包含的刷新动画结束,而是指将NewsList组件所包含的分类数据刷新。也就是让用户先看到新数据,并用上方仍在旋转的刷新动画来提示用户数据正在后台刷新。这样既不用徒增用户的等待时间也能正确的告知用户当前的刷新进度。

同时在这里我们也可以回顾一下对于快速刷新阶段的函数实现与全量加载的函数实现之间的区别。对于快速加载阶段我们使用的是Promise.all函数去进行并行加载的,因为单次请求的加载数据量小,同时核心目标是快。反之,对于全量加载阶段的函数来说,单次请求的数据量大,而且核心目标是要降低对服务器的低负荷,所以使用的是循环遍历待执行的Promise对象,这样一来同一时间的数据流量会被降低,同时整体的加载过程也变成了单一的线性过程,为我们下一项要说的进度回调函数打下了基础。

但是两种截然不同的加载方式被包装成了结构极其相似,均为一个成功标识符和一个成功条数的对象,这就是封装的意义,去屏蔽复杂的内部逻辑,高内聚低耦合。

对于第二点进度回调函数,其实之前我就有在好奇各种各样的回调箭头函数究竟是如何定义的,它又为什么能在指定的时期得到对应的参数并执行外部传入的逻辑的,这一次我得到了答案。

让我们直接就着具体代码来说吧。

1
2
3
4
5
6
7
newsManager.fullRefreshCategories((progress) => {
logger.debug(`${LOG_TAG.NEWS_LIST}[两阶段刷新] [${progress.current}/${progress.total}] 【${progress.category}】完成`)

// 每个栏目完成后触发 UI 更新
this.refreshTrigger++

})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
async fullRefreshCategories(
onProgress?: (progress: RefreshProgress) => void
): Promise<FullRefreshResult> {
......
// 逐个加载栏目(避免并发过多导致服务器压力)
for (let i = 0; i < developedCategories.length; i++) {
const category = developedCategories[i]
const current = i + 1

try {
logger.info(`${LOG_TAG.NEWS_MANAGER}[完整刷新] [${current}/${totalCategories}] 加载【${category.displayName}】全部数据`)

// 获取该分类的所有数据
const allNews = await NewsListAPI.getAllNewsByCategory(category.apiCategory)

if (allNews && allNews.length > 0) {
// 按日期倒序排序
const sortedNews = allNews.sort((a: NewsArticle, b: NewsArticle): number => b.date.localeCompare(a.date))

// 直接覆盖存储(已经是最新完整数据)
await this.appKVDb.put(category.dbKey, JSON.stringify(sortedNews))

updatedCount++
logger.info(`${LOG_TAG.NEWS_MANAGER}[完整刷新] ✓ [${current}/${totalCategories}] 【${category.displayName}】完成: ${sortedNews.length}条`)

// 回调进度更新
if (onProgress) {
const progress: RefreshProgress = {
category: category.displayName,
current: current,
total: totalCategories
}
onProgress(progress)
}
} else {
logger.warn(`${LOG_TAG.NEWS_MANAGER}[完整刷新] [${current}/${totalCategories}] 【${category.displayName}】无数据`)
}

} catch (error) {
logger.error(`${LOG_TAG.NEWS_MANAGER}[完整刷新] [${current}/${totalCategories}] 【${category.displayName}】失败: ${JSON.stringify(error)}`)
}
}
......
}

上面的两段代码并非完整代码,我仅仅截取了重要的部分。

首先我们看fullRefreshCategories这个函数,在声明形参的时候直接声明一个箭头函数类型的形参onProgress?: (progress: RefreshProgress) => void。这里可以注意到一个细节,就是这个参数的声明是一个可选参数而不是一个必选参数,这就提升了这个函数的灵活性,因为这个回调函数的作用仅仅是对当前的刷新进程进行进度通知,并不是功能性上的强制要求。

在加载的过程中通过向形参函数传参就可以实现将内部数据向外部暴露的除返回值以外的另一种方式。

1
2
3
4
5
6
const progress: RefreshProgress = {
category: category.displayName,
current: current,
total: totalCategories
}
onProgress(progress)

最后,UI扳机机制。

虽然说官方的的确确提供了一些监听器还有双向绑定之类的API但是此前我已经多次因为这个深浅拷贝,监听属性的深度问题等等等而浪费太多时间去调试了,所以这一次就简简单单的去监听一个基本类型的number变量就好了。

通过this.refreshTrigger++来触发更新,监听侧仅需要设置一个监听器以及回调函数就好。

1
2
3
4
5
6
7
8
9
10
11
@Monitor('refreshTrigger')
onRefreshTriggered() {
if (this.refreshTrigger > 0) {
logger.info(`${LOG_TAG.NEWS_LIST}捕获到刷新触发器变化: ${this.refreshTrigger},重新加载当前栏目数据`)
// 重新加载当前选中栏目的数据
const currentCategoryInfo = NEWS_CATEGORIES.find(cat => cat.id === this.currentCategory)
if (currentCategoryInfo && currentCategoryInfo.isDeveloped) {
this.reloadCurrentCategoryData(currentCategoryInfo)
}
}
}

就还是挺爽的一个方案。


新功能实现

这一部分我会着重将一些对于新功能的开发记录一下实现的思路、具体方式以及相关的一些细节,这也是整个开发过程中我感觉最有意思的一部分了。

热力日历图

既然是为了鼓励用户的阅读习惯培养的话,我们就需要充足的正反馈来鼓励用户的阅读,让用户感到成就感,虽然一开始可能是有些功利,但是后续的话用户就会感受到阅读带来的好处,从而形成良性循环。

而作为一个程序员来讲我第一个想到的最具备正反馈的记录形式就是GitHub上的热力日历图了。一页的绿点真的很骄傲。

1

2

3

当然类似的设计其实在很多网站都有在使用,像是gitee,gitcode这类开源代码托管平台,直接就是和Github同款的这就不说了。还有更多的软件在刚进入的时候就会弹出一个签到日历,你只要签到了就会将你签到当日的日历格子给标记成一个特殊的颜色或是画个框对吧,这一招确确实实是会激起不少人的“强迫症”的。这种例子太多了我就不放图了,其实就是一种布尔值版本的简化热力日历。

我在ohpm的三方库中心仓搜索了一下发现其实并没有有现成的热力日历图,但是有现成的日历图,所以我本来像要不直接用常规日历图做得了,但是实际用下来发现三方库的日历一方面是没办法很好的去设置不同阅读量的颜色划分,另一方面是没办法很好的设置更多的可设置项。无论选哪个SDK改起来都感觉差点什么,所以我决定自己丰衣足食,搓一个GitHub热力日历图放在应用里。

当然与此同时我也考虑到GitHub模式的热力日历有一个更加严峻的问题就是在于日期的难以辨认,这对于任何人来说都不可能直接在GitHub的热力日历图上随手指出一个指定的日期,所以我们还需要做另一版的常规日历图,这样我们就可以兼顾GitHub热力日历的强成就感渲染模式,同时可以更详细具体的按照常规月份日历的日期进行查看和数据的渲染。为此我想的方案是在展开的页面中去放入一个常规日历图,与此同时要使用共享元素转场来去符合鸿蒙的丝滑UI体验。

这个功能的实现肯定是大部分的Vibe Coding实现啦,借助我们 Claude Opus 4,时至今日我们早就无需纠结于你能不能“纯手搓”出来这个功能了,Vibe Coding能力的进化只会越来越夸张,所以大胆的放手,扩大视野到功能、用户体验、架构设计才是我们更应做的。

成品效果图

4

5

6

7

看起来还是挺简洁美观的。


文件架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
commons/common/src/main/ets/
├── modules/
│ ├── models/
│ │ └── DailyReadingStats.ets # 每日阅读统计数据模型
│ ├── config/
│ │ └── UserConfigViewModel.ets # 用户配置(含热力日历配置)
│ └── enums.ets # 枚举定义(含热力日历相关枚举)

features/feature/src/main/ets/
├── managers/
│ ├── HistoryManager.ets # 历史记录管理器(含每日统计功能)
│ └── UserConfigManager.ets # 用户配置管理器(含配置持久化)

product/default/src/main/ets/
├── pages/
│ ├── tab_contents/
│ │ └── CalenderTabContent.ets # 热力日历主页面
│ └── nav_pages/
│ ├── HeatmapSettingsPage.ets # 热力日历设置页面
│ ├── DateHistoryPage.ets # 按日期查看历史记录页面
│ └── SettingsPage.ets # 设置页面(含清理数据功能)
└── resources/rawfile/
└── calendar_icon.svg # 日历图标

核心渲染算法

我们这里着重关注一下核心算法的实现。在Vibe Coding时代常规的代码已经没有去读的必要了,我们只用关心核心算法以及对于算法的优化就好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
/**
* Copyright (c) 2025 XBXyftx
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
DEVICE_TYPES,
NAV_DESTS,
APP_STORAGE_KEYS,
UserConfigViewModel,
DailyReadingStats,
HeatmapColorScheme,
HistoryUpdateTrigger
} from "common"
import { deviceInfo } from "@kit.BasicServicesKit"
import { historyManager } from "feature"
import { AppStorageV2 } from "@kit.ArkUI"

// 共享元素转场ID
const HEATMAP_SHARED_ID = 'heatmap_shared_element'

/**
* 热力日历单元格数据
*/
interface HeatmapCell {
dateStr: string
count: number
color: string
dayOfWeek: number
dayOfMonth: number
month: number
}

/**
* 月历日期单元格
*/
interface CalendarDayCell {
day: number
dateStr: string
count: number
color: string
isCurrentMonth: boolean
isToday: boolean
}

@ComponentV2
export struct CalenderTabContent {
@Local deviceType: DEVICE_TYPES =
deviceInfo.deviceType === DEVICE_TYPES.PHONE ? DEVICE_TYPES.PHONE : DEVICE_TYPES.TABLET
@Local navPathStuck: NavPathStack =
AppStorageV2.connect(NavPathStack, APP_STORAGE_KEYS.NAV_PATH_STUCK, () => new NavPathStack())!
@Local userConfig: UserConfigViewModel =
AppStorageV2.connect(UserConfigViewModel, APP_STORAGE_KEYS.USER_CONFIG, () => new UserConfigViewModel())!
@Local historyUpdateTrigger: HistoryUpdateTrigger =
AppStorageV2.connect(HistoryUpdateTrigger, APP_STORAGE_KEYS.HISTORY_UPDATE_TRIGGER, () => new HistoryUpdateTrigger())!
@Local dailyStats: DailyReadingStats[] = []
@Local heatmapCells: HeatmapCell[] = []
@Local isLoading: boolean = false
@Local totalReadCount: number = 0
// 全屏模态控制
@Local isFullscreenShow: boolean = false
@Local cardAlpha: number = 1
// 月历相关
@Local currentYear: number = new Date().getFullYear()
@Local currentMonth: number = new Date().getMonth() + 1
@Local calendarDays: CalendarDayCell[] = []
@Local statsMap: Map<string, number> = new Map()
async aboutToAppear(): Promise<void> {
await this.loadDailyStats()
this.generateCalendarDays()
}

/**
* 下拉刷新空内容Builder
*/
@Builder
refreshingBuilder() {
Stack() {
}.width('100%')
}


@Monitor('historyUpdateTrigger.trigger')
async onHistoryUpdate(): Promise<void> {
await this.loadDailyStats()
this.generateCalendarDays()
}

@Monitor('userConfig.heatmapTimeRange')
async onTimeRangeChange(): Promise<void> {
await this.loadDailyStats()
}

@Monitor('userConfig.heatmapColorRange')
onColorRangeChange(): void {
this.generateHeatmapCells()
this.generateCalendarDays()
}

@Monitor('userConfig.heatmapColorScheme')
onColorSchemeChange(): void {
this.generateHeatmapCells()
this.generateCalendarDays()
}

@Monitor('currentMonth')
onMonthChange(): void {
this.generateCalendarDays()
}

@Monitor('currentYear')
onYearChange(): void {
this.generateCalendarDays()
}

async loadDailyStats(): Promise<void> {
this.isLoading = true
try {
// 加载365天的数据用于月历显示
this.dailyStats = await historyManager.getDailyStatsInRange(365)
this.statsMap.clear()
this.dailyStats.forEach(stat => {
if (stat.count > 0) {
this.statsMap.set(stat.dateStr, stat.count)
}
})
this.generateHeatmapCells()
this.totalReadCount = this.dailyStats
.filter(stat => {
const date = new Date(stat.dateStr)
const today = new Date()
const diffDays = Math.floor((today.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
return diffDays < this.userConfig.heatmapTimeRange
})
.reduce((sum, stat) => sum + stat.count, 0)
} catch (error) {
console.error('加载每日统计失败:', JSON.stringify(error))
} finally {
this.isLoading = false
}
}

generateHeatmapCells(): void {
const newCells: HeatmapCell[] = []
const today = new Date()
const timeRange = this.userConfig.heatmapTimeRange

for (let i = timeRange - 1; i >= 0; i--) {
const date = new Date(today)
date.setDate(today.getDate() - i)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0

newCells.push({
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
dayOfWeek: date.getDay(),
dayOfMonth: date.getDate(),
month: date.getMonth() + 1
})
}
this.heatmapCells = newCells
}

generateCalendarDays(): void {
const days: CalendarDayCell[] = []
const today = new Date()
const todayStr = this.formatDate(today)

const firstDay = new Date(this.currentYear, this.currentMonth - 1, 1)
const lastDay = new Date(this.currentYear, this.currentMonth, 0)
const firstDayOfWeek = firstDay.getDay()
const daysInMonth = lastDay.getDate()

const prevMonthLastDay = new Date(this.currentYear, this.currentMonth - 1, 0)
const prevMonthDays = prevMonthLastDay.getDate()

// 填充上个月的日期
for (let i = firstDayOfWeek - 1; i >= 0; i--) {
const day = prevMonthDays - i
const date = new Date(this.currentYear, this.currentMonth - 2, day)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0
days.push({
day: day,
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
isCurrentMonth: false,
isToday: dateStr === todayStr
})
}

// 填充当月日期
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(this.currentYear, this.currentMonth - 1, day)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0
days.push({
day: day,
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
isCurrentMonth: true,
isToday: dateStr === todayStr
})
}

// 填充下个月的日期
const remainingDays = 42 - days.length
for (let day = 1; day <= remainingDays; day++) {
const date = new Date(this.currentYear, this.currentMonth, day)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0
days.push({
day: day,
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
isCurrentMonth: false,
isToday: dateStr === todayStr
})
}

this.calendarDays = days
}

formatDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}

getColorForCount(count: number): string {
const maxRange = this.userConfig.heatmapColorRange
const ratio = Math.min(count / maxRange, 1)
const scheme = this.userConfig.heatmapColorScheme

if (scheme === HeatmapColorScheme.BLUE) {
const blueColors = ['#2d333b', '#1e3a5f', '#2563eb', '#60a5fa', '#93c5fd']
if (count === 0) return blueColors[0]
if (ratio <= 0.25) return blueColors[1]
if (ratio <= 0.5) return blueColors[2]
if (ratio <= 0.75) return blueColors[3]
return blueColors[4]
}

const colorSchemes: Record<string, string[]> = {
[HeatmapColorScheme.GREEN]: ['#2d333b', '#0e4429', '#006d32', '#26a641', '#39d353'],
[HeatmapColorScheme.RED]: ['#2d333b', '#5c2121', '#8b3d3d', '#d35f5f', '#ff8080'],
[HeatmapColorScheme.GRAY]: ['#2d333b', '#404040', '#606060', '#909090', '#c0c0c0']
}

const colors = colorSchemes[scheme] || colorSchemes[HeatmapColorScheme.GREEN]
if (count === 0) return colors[0]
if (ratio <= 0.25) return colors[1]
if (ratio <= 0.5) return colors[2]
if (ratio <= 0.75) return colors[3]
return colors[4]
}

getLegendColors(): string[] {
const scheme = this.userConfig.heatmapColorScheme
if (scheme === HeatmapColorScheme.BLUE) {
return ['#2d333b', '#1e3a5f', '#2563eb', '#60a5fa', '#93c5fd']
}
const colorSchemes: Record<string, string[]> = {
[HeatmapColorScheme.GREEN]: ['#2d333b', '#0e4429', '#006d32', '#26a641', '#39d353'],
[HeatmapColorScheme.RED]: ['#2d333b', '#5c2121', '#8b3d3d', '#d35f5f', '#ff8080'],
[HeatmapColorScheme.GRAY]: ['#2d333b', '#404040', '#606060', '#909090', '#c0c0c0']
}
return colorSchemes[scheme] || colorSchemes[HeatmapColorScheme.GREEN]
}

onCellClick(cell: HeatmapCell): void {
if (this.userConfig.heatmapClickToHistory && cell.count > 0) {
// 如果全屏模态打开,先收起再导航
if (this.isFullscreenShow) {
this.onFullscreenBack()
}
this.navPathStuck.replacePath({
name: NAV_DESTS.DATE_HISTORY,
param: cell.dateStr
})
}
}

onCalendarCellClick(dateStr: string, count: number): void {
if (this.userConfig.heatmapClickToHistory && count > 0) {
// 如果全屏模态打开,先收起再导航
if (this.isFullscreenShow) {
this.onFullscreenBack()
}
this.navPathStuck.replacePath({
name: NAV_DESTS.DATE_HISTORY,
param: dateStr
})
}
}

getWeekColumns(): HeatmapCell[][] {
const weeks: HeatmapCell[][] = []
if (this.heatmapCells.length === 0) return weeks

const firstDayOfWeek = this.heatmapCells[0].dayOfWeek
let currentWeek: HeatmapCell[] = []

for (let i = 0; i < firstDayOfWeek; i++) {
currentWeek.push({
dateStr: '',
count: 0,
color: 'transparent',
dayOfWeek: i,
dayOfMonth: 0,
month: 0
})
}

for (const cell of this.heatmapCells) {
currentWeek.push(cell)
if (currentWeek.length === 7) {
weeks.push(currentWeek)
currentWeek = []
}
}

if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push({
dateStr: '',
count: 0,
color: 'transparent',
dayOfWeek: currentWeek.length,
dayOfMonth: 0,
month: 0
})
}
weeks.push(currentWeek)
}
return weeks
}

prevMonth(): void {
if (this.currentMonth === 1) {
this.currentMonth = 12
this.currentYear--
} else {
this.currentMonth--
}
}

nextMonth(): void {
if (this.currentMonth === 12) {
this.currentMonth = 1
this.currentYear++
} else {
this.currentMonth++
}
}

/**
* 点击展开全屏
*/
private onExpandClick(): void {
this.getUIContext()?.animateTo({
duration: 350,
curve: Curve.Friction
}, () => {
this.isFullscreenShow = true
this.cardAlpha = 0
})
}

/**
* 从全屏返回
*/
private onFullscreenBack(): void {
this.getUIContext()?.animateTo({
duration: 350,
curve: Curve.Friction
}, () => {
this.isFullscreenShow = false
this.cardAlpha = 1
})
}


@Builder
HeatmapCellBuilder(cell: HeatmapCell, index: number) {
Column() {
}
.width(this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16)
.height(this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16)
.backgroundColor(cell.color)
.borderRadius(2)
.margin(1)
.onClick(() => this.onCellClick(cell))
}

@Builder
LegendBuilder() {
Row() {
Text('少')
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 10 : 12)
.fontColor($r('app.color.text_secondary'))
.margin({ right: 4 })

ForEach(this.getLegendColors(), (color: string) => {
Column()
.width(this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16)
.height(this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16)
.backgroundColor(color)
.borderRadius(2)
.margin({ right: 2 })
})

Text('多')
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 10 : 12)
.fontColor($r('app.color.text_secondary'))
.margin({ left: 4 })
}
.justifyContent(FlexAlign.Center)
.margin({ top: 16 })
}

@Builder
CalendarDayCellBuilder(cell: CalendarDayCell) {
Column() {
Text(`${cell.day}`)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 14 : 18)
.fontWeight(cell.isToday ? FontWeight.Bold : FontWeight.Normal)
.fontColor(cell.isCurrentMonth ? $r('app.color.text_primary') : $r('app.color.text_secondary'))
}
.width('100%')
.aspectRatio(1)
.justifyContent(FlexAlign.Center)
.backgroundColor(cell.count > 0 ? cell.color : Color.Transparent)
.borderRadius(this.deviceType === DEVICE_TYPES.PHONE ? 8 : 12)
.border({
width: cell.isToday ? 2 : 0,
color: $r('app.color.brand')
})
.onClick(() => this.onCalendarCellClick(cell.dateStr, cell.count))
}

/**
* 全屏模态页面内容Builder
*/
@Builder
fullscreenContentBuilder() {
GridRow({ columns: { sm: 4, md: 8, lg: 12 } }) {
GridCol({ span: { sm: 4, md: 6, lg: 6 }, offset: { sm: 0, md: 1, lg: 3 } }) {
Column() {
// 标题栏
Row() {
Text('阅读热力日历')
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 20 : 26)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)

// 关闭按钮
SymbolGlyph($r('sys.symbol.arrow_down_right_and_arrow_up_left'))
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 24 : 32)
.fontColor([$r('app.color.text_primary')])
.onClick(() => this.onFullscreenBack())
}
.width('100%')
.padding({
left: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
right: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
top: this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16,
bottom: this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16
})
.margin({ top: 48 }) // 固定顶部边距,避开状态栏

// 使用Refresh组件实现下拉返回手势
Refresh({ refreshing: false, builder: this.refreshingBuilder, offset: 4 }) {
Scroll() {
Column() {
// 统计信息卡片
Column() {
Text(`最近 ${this.userConfig.heatmapTimeRange} 天共阅读`)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 14 : 18)
.fontColor($r('app.color.text_secondary'))

Row() {
Text(`${this.totalReadCount}`)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 36 : 48)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.brand'))

Text(' 篇文章')
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 16 : 20)
.fontColor($r('app.color.text_primary'))
.margin({ bottom: 4 })
}
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(this.deviceType === DEVICE_TYPES.PHONE ? 20 : 28)
.backgroundColor($r('app.color.news_list_item_bg'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.margin({
left: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
right: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24
})

// 热力日历网格 - 共享元素
Column() {
// 热力格子
Scroll() {
Row() {
// 星期标签列
Column() {
ForEach(['', '一', '', '三', '', '五', ''], (day: string) => {
Text(day)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 9 : 11)
.fontColor($r('app.color.text_secondary'))
.height(this.deviceType === DEVICE_TYPES.PHONE ? 14 : 18)
.width(this.deviceType === DEVICE_TYPES.PHONE ? 16 : 20)
.textAlign(TextAlign.End)
.margin({ right: 4 })
})
}

// 热力格子网格
Scroll() {
Row() {
ForEach(this.getWeekColumns(), (weekCells: HeatmapCell[], weekIndex: number) => {
Column() {
ForEach(weekCells, (cell: HeatmapCell, dayIndex: number) => {
this.HeatmapCellBuilder(cell, weekIndex * 7 + dayIndex)
}, (cell: HeatmapCell, dayIdx: number) => `fs_${cell.dateStr || 'empty'}_${dayIdx}_${cell.count}`)
}
}, (weekCells: HeatmapCell[], idx: number) => `fs_week_${idx}`)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.layoutWeight(1)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')

// 图例
this.LegendBuilder()

// 范围说明
Text(`颜色范围: 0 - ${this.userConfig.heatmapColorRange} 篇`)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 10 : 12)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 8 })
}
.width('100%')
.padding(this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24)
.backgroundColor($r('app.color.news_list_item_bg'))
.borderRadius(16)
.margin({
top: this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16,
left: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
right: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24
})
// 共享元素转场 - 与卡片的id对应
.geometryTransition(HEATMAP_SHARED_ID)
.transition(TransitionEffect.OPACITY.animation({ duration: 350, curve: Curve.Friction }))

// 月历视图
Column() {
// 月份导航
Row() {
Image($r('sys.media.ohos_ic_public_arrow_left'))
.width(this.deviceType === DEVICE_TYPES.PHONE ? 24 : 32)
.height(this.deviceType === DEVICE_TYPES.PHONE ? 24 : 32)
.fillColor($r('app.color.text_primary'))
.onClick(() => this.prevMonth())

Text(`${this.currentYear}${this.currentMonth}月`)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 18 : 24)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.textAlign(TextAlign.Center)

Image($r('sys.media.ohos_ic_public_arrow_right'))
.width(this.deviceType === DEVICE_TYPES.PHONE ? 24 : 32)
.height(this.deviceType === DEVICE_TYPES.PHONE ? 24 : 32)
.fillColor($r('app.color.text_primary'))
.onClick(() => this.nextMonth())
}
.width('100%')
.padding({ bottom: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24 })

// 星期标题
Row() {
ForEach(['日', '一', '二', '三', '四', '五', '六'], (day: string) => {
Text(day)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 12 : 14)
.fontColor($r('app.color.text_secondary'))
.width('14.28%')
.textAlign(TextAlign.Center)
})
}
.width('100%')
.padding({ bottom: this.deviceType === DEVICE_TYPES.PHONE ? 8 : 12 })

// 日期网格
Grid() {
ForEach(this.calendarDays, (cell: CalendarDayCell, index: number) => {
GridItem() {
this.CalendarDayCellBuilder(cell)
}
}, (cell: CalendarDayCell, idx: number) => `cal_${cell.dateStr}_${idx}`)
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')
.rowsGap(this.deviceType === DEVICE_TYPES.PHONE ? 4 : 8)
.columnsGap(this.deviceType === DEVICE_TYPES.PHONE ? 4 : 8)
.width('100%')
}
.width('100%')
.padding(this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24)
.backgroundColor($r('app.color.news_list_item_bg'))
.borderRadius(16)
.margin({
top: this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16,
left: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
right: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24
})

// 提示文字
if (this.userConfig.heatmapClickToHistory) {
Text('点击有阅读记录的日期可查看当天阅读的文章')
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 12 : 14)
.fontColor($r('app.color.text_secondary'))
.margin({
top: this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16,
left: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
right: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
bottom: this.deviceType === DEVICE_TYPES.PHONE ? 24 : 32
})
}
}
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.None)
.width('100%')
.height('100%')
}
.onRefreshing(() => {
// 下拉刷新时触发返回
this.onFullscreenBack()
})
}
.width('100%')
.height('100%')
}
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.page_background'))
// 只扩展底部安全区域,顶部保持避开状态栏
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
.transition(TransitionEffect.asymmetric(
TransitionEffect.opacity(1),
TransitionEffect.OPACITY
))
}


build() {
Column() {
// 标题栏
Row() {
Text('阅读热力日历')
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 28 : 34)
.fontWeight(700)
.fontColor($r('app.color.text_primary'))
}
.width('100%')
.padding({
left: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
right: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
top: this.deviceType === DEVICE_TYPES.PHONE ? 20 : 28,
bottom: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 20
})

if (this.isLoading) {
Column() {
LoadingProgress()
.width(this.deviceType === DEVICE_TYPES.PHONE ? 40 : 50)
.height(this.deviceType === DEVICE_TYPES.PHONE ? 40 : 50)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
Scroll() {
Column() {
// 统计信息卡片
Column() {
Text(`最近 ${this.userConfig.heatmapTimeRange} 天共阅读`)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 14 : 18)
.fontColor($r('app.color.text_secondary'))

Row() {
Text(`${this.totalReadCount}`)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 36 : 48)
.fontWeight(700)
.fontColor($r('app.color.brand'))

Text(' 篇文章')
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 16 : 20)
.fontColor($r('app.color.text_primary'))
.margin({ bottom: 4 })
}
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(this.deviceType === DEVICE_TYPES.PHONE ? 20 : 28)
.backgroundColor($r('app.color.news_list_item_bg'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)

// 热力日历网格 - 添加共享元素转场
Stack({ alignContent: Alignment.TopEnd }) {
Column() {
// 热力格子 - 使用Grid布局,每行7天
Scroll() {
Row() {
// 星期标签列
Column() {
ForEach(['', '一', '', '三', '', '五', ''], (day: string, index: number) => {
Text(day)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 9 : 11)
.fontColor($r('app.color.text_secondary'))
.height(this.deviceType === DEVICE_TYPES.PHONE ? 14 : 18)
.width(this.deviceType === DEVICE_TYPES.PHONE ? 16 : 20)
.textAlign(TextAlign.End)
.margin({ right: 4 })
})
}

// 热力格子网格
Scroll() {
Row() {
ForEach(this.getWeekColumns(), (weekCells: HeatmapCell[], weekIndex: number) => {
Column() {
ForEach(weekCells, (cell: HeatmapCell, dayIndex: number) => {
this.HeatmapCellBuilder(cell, weekIndex * 7 + dayIndex)
}, (cell: HeatmapCell, dayIdx: number) => `${this.userConfig.heatmapTimeRange}_${this.userConfig.heatmapColorRange}_${this.userConfig.heatmapColorScheme}_${cell.dateStr || 'empty'}_${dayIdx}_${cell.count}_${cell.color}`)
}
}, (weekCells: HeatmapCell[], idx: number) => `${this.userConfig.heatmapTimeRange}_${this.userConfig.heatmapColorRange}_${this.userConfig.heatmapColorScheme}_week_${idx}_${weekCells.length}`)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.layoutWeight(1)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')

// 图例
this.LegendBuilder()

// 范围说明
Text(`颜色范围: 0 - ${this.userConfig.heatmapColorRange} 篇`)
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 10 : 12)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 8 })
}
.width('100%')
.padding(this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24)
.padding({ top: this.deviceType === DEVICE_TYPES.PHONE ? 40 : 48 })

// 全屏按钮 - 右上角
Row() {
SymbolGlyph($r('sys.symbol.arrow_up_left_and_arrow_down_right'))
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 18 : 22)
.fontColor([$r('app.color.text_secondary')])
}
.width(this.deviceType === DEVICE_TYPES.PHONE ? 32 : 40)
.height(this.deviceType === DEVICE_TYPES.PHONE ? 32 : 40)
.borderRadius(this.deviceType === DEVICE_TYPES.PHONE ? 16 : 20)
.backgroundColor($r('app.color.card_bg'))
.justifyContent(FlexAlign.Center)
.margin({
top: this.deviceType === DEVICE_TYPES.PHONE ? 8 : 12,
right: this.deviceType === DEVICE_TYPES.PHONE ? 8 : 12
})
.onClick(() => this.onExpandClick())
}
.width('100%')
.backgroundColor($r('app.color.news_list_item_bg'))
.borderRadius(16)
.margin({ top: this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16 })
.clip(true)
// 共享元素转场 - follow: true 表示跟随目标组件
.geometryTransition(HEATMAP_SHARED_ID, { follow: true })
.transition(TransitionEffect.OPACITY.animation({ duration: 350, curve: Curve.Friction }))

// 提示文字
if (this.userConfig.heatmapClickToHistory) {
Text('点击有阅读记录的日期可查看当天阅读的文章')
.fontSize(this.deviceType === DEVICE_TYPES.PHONE ? 12 : 14)
.fontColor($r('app.color.text_secondary'))
.margin({ top: this.deviceType === DEVICE_TYPES.PHONE ? 12 : 16 })
}
}
.padding({
left: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
right: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24,
bottom: this.deviceType === DEVICE_TYPES.PHONE ? 16 : 24
})
}
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
.expandSafeArea()
.opacity(this.cardAlpha)
// 绑定全屏模态页面
.bindContentCover(
this.isFullscreenShow,
this.fullscreenContentBuilder(),
{
modalTransition: ModalTransition.NONE,
onWillDisappear: () => {
this.isFullscreenShow = false
this.cardAlpha = 1
}
}
)
}
}

上面这是完整的代码接下来我们仅分析核心部分

整个热力日历的渲染核心可以拆分为三个关键算法:

  1. GitHub风格热力图生成 (generateHeatmapCells) - 按时间线性排列的热力格子
  2. 月历视图生成 (generateCalendarDays) - 传统月历布局的日期格子
  3. 周列转换算法 (getWeekColumns) - 将线性数据转换为按周分列的二维数组
1. GitHub风格热力图生成算法

这个算法的核心思路是从今天往前推N天,生成一个线性的日期数组,每个日期对应一个格子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
generateHeatmapCells(): void {
const newCells: HeatmapCell[] = []
const today = new Date()
const timeRange = this.userConfig.heatmapTimeRange // 用户配置的时间范围,如90天、180天、365天

// 关键:倒序遍历,从 timeRange-1 天前到今天
for (let i = timeRange - 1; i >= 0; i--) {
const date = new Date(today)
date.setDate(today.getDate() - i) // 计算目标日期
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0 // 从统计数据Map中获取阅读量

newCells.push({
dateStr: dateStr,
count: count,
color: this.getColorForCount(count), // 根据阅读量计算颜色
dayOfWeek: date.getDay(), // 0-6,用于后续按周分列
dayOfMonth: date.getDate(),
month: date.getMonth() + 1
})
}
this.heatmapCells = newCells
}

算法亮点分析:

这里有个很巧妙的设计,就是倒序遍历的方式。为什么要从timeRange - 1倒着数到0,而不是从0正着数到timeRange - 1呢?

1
2
3
4
5
6
7
8
9
10
11
// 方案A:正序遍历(直觉写法)
for (let i = 0; i < timeRange; i++) {
const date = new Date(today)
date.setDate(today.getDate() - (timeRange - 1 - i)) // 需要反向计算
}

// 方案B:倒序遍历(实际采用)
for (let i = timeRange - 1; i >= 0; i--) {
const date = new Date(today)
date.setDate(today.getDate() - i) // 直接减去偏移量
}

倒序遍历的好处在于:

  • 计算更直观today - i 天就是目标日期,不需要额外的timeRange - 1 - i这种反向计算
  • 语义更清晰:i 的值直接代表”距离今天的天数”,比如 i=7 就是7天前
  • 调试更友好:打印 i 的值就能直接知道是哪一天

同时这里使用了statsMap这个Map结构来存储每日统计数据,而不是数组:

1
2
3
4
5
6
7
8
9
10
// 数据加载时构建Map
this.statsMap.clear()
this.dailyStats.forEach(stat => {
if (stat.count > 0) {
this.statsMap.set(stat.dateStr, stat.count)
}
})

// 查询时O(1)复杂度
const count = this.statsMap.get(dateStr) || 0

如果用数组存储,每次查询都需要遍历整个数组找到对应日期,时间复杂度是O(n)。而Map的查询是O(1),在生成365天的热力图时,性能差异会非常明显:

  • 数组方案:365次查询 × 平均遍历182次 = 约66,430次比较操作
  • Map方案:365次查询 × 1次哈希查找 = 365次操作

性能提升了约180倍!

2. 月历视图生成算法

月历视图的难点在于需要填充上月和下月的日期,让日历始终保持完整的6行×7列(42个格子)布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
generateCalendarDays(): void {
const days: CalendarDayCell[] = []
const today = new Date()
const todayStr = this.formatDate(today)

// 计算当月的关键日期
const firstDay = new Date(this.currentYear, this.currentMonth - 1, 1) // 当月第一天
const lastDay = new Date(this.currentYear, this.currentMonth, 0) // 当月最后一天
const firstDayOfWeek = firstDay.getDay() // 第一天是星期几(0-6)
const daysInMonth = lastDay.getDate() // 当月有多少天

// 计算上个月的最后一天
const prevMonthLastDay = new Date(this.currentYear, this.currentMonth - 1, 0)
const prevMonthDays = prevMonthLastDay.getDate()

// 阶段1:填充上个月的日期(填充第一行的空白)
for (let i = firstDayOfWeek - 1; i >= 0; i--) {
const day = prevMonthDays - i // 倒推上月日期
const date = new Date(this.currentYear, this.currentMonth - 2, day)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0
days.push({
day: day,
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
isCurrentMonth: false, // 标记为非当月
isToday: dateStr === todayStr
})
}

// 阶段2:填充当月日期
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(this.currentYear, this.currentMonth - 1, day)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0
days.push({
day: day,
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
isCurrentMonth: true, // 标记为当月
isToday: dateStr === todayStr
})
}

// 阶段3:填充下个月的日期(补齐到42个格子)
const remainingDays = 42 - days.length
for (let day = 1; day <= remainingDays; day++) {
const date = new Date(this.currentYear, this.currentMonth, day)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0
days.push({
day: day,
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
isCurrentMonth: false, // 标记为非当月
isToday: dateStr === todayStr
})
}

this.calendarDays = days
}

这里着重解释一下JS、TS、ArkTS中的Date对象的参数用法。

在JavaScript/TypeScript/ArkTS中,Date对象的构造函数有一个非常巧妙的特性,理解它对于日期计算至关重要。

Date构造函数的基本语法:

1
new Date(year, monthIndex, day, hours, minutes, seconds, milliseconds)

关键点:

  • year:四位数年份(如 2025)
  • monthIndex月份索引,从0开始(0=1月,1=2月,…,11=12月)
  • day:日期(1-31)

核心特性:自动溢出处理

Date对象会自动处理参数溢出,这是它最强大的特性:

1
2
3
4
5
6
7
8
// 示例1:月份溢出
new Date(2025, 12, 1) // 月份12溢出 → 2026年1月1日
new Date(2025, -1, 1) // 月份-1溢出 → 2024年12月1日

// 示例2:日期溢出
new Date(2025, 1, 0) // 2月的第0天 → 2025年1月31日(上月最后一天)
new Date(2025, 1, -1) // 2月的第-1天 → 2025年1月30日
new Date(2025, 1, 32) // 2月的第32天 → 2025年3月4日(2月只有28天)

在月历算法中的应用:

在日历组件中,我们需要频繁计算月份的边界日期。以下三个表达式是核心:

表达式1:new Date(this.currentYear, this.currentMonth - 1, 1)

这个表达式用于获取当前月份的第一天

1
2
3
4
5
6
7
8
9
10
11
12
// 假设当前是2025年3月
this.currentYear = 2025
this.currentMonth = 3 // 注意:这里的3表示3月(人类习惯的1-12)

// 构造过程:
new Date(2025, 3 - 1, 1)
new Date(2025, 2, 1) // monthIndex=2表示3月(计算机的0-11)
202531日 星期六

// 用途:确定日历网格的起始日期
// 通过 firstDay.getDay() 可以知道这个月1号是星期几
// 从而计算出需要补充多少个上月的日期

表达式2:new Date(this.currentYear, this.currentMonth, 0)

这个表达式用于获取当前月份的最后一天(利用日期溢出特性)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 假设当前是2025年3月
this.currentYear = 2025
this.currentMonth = 3

// 构造过程:
new Date(2025, 3, 0)
new Date(2025, 3, 0) // monthIndex=3表示4月,但day=0
4月的第0天 = 3月的最后一天
2025331日 星期一

// 关键理解:
// - monthIndex=3 指向4月
// - day=0 表示"4月的前一天"
// - 结果就是3月的最后一天

// 用途:
// 1. 通过 lastDay.getDate() 获取当月天数(31天)
// 2. 确定日历网格中当月部分的结束位置

表达式3:new Date(this.currentYear, this.currentMonth - 1, 0)

这个表达式用于获取上个月的最后一天

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 假设当前是2025年3月
this.currentYear = 2025
this.currentMonth = 3

// 构造过程:
new Date(2025, 3 - 1, 0)
new Date(2025, 2, 0) // monthIndex=2表示3月,但day=0
3月的第0天 = 2月的最后一天
2025228日 星期五

// 关键理解:
// - monthIndex=2 指向3月
// - day=0 表示"3月的前一天"
// - 结果就是2月的最后一天

// 用途:
// 1. 通过 prevLastDay.getDate() 获取上月天数(28天)
// 2. 计算日历网格中需要填充的上月日期
// 例如:如果3月1日是星期六(getDay()=6),
// 需要填充上月的最后6天:23,24,25,26,27,28

三个表达式的对比总结:

表达式 monthIndex day 结果 用途
new Date(year, month-1, 1) 当月 1 当月第一天 确定月份起始,计算星期几
new Date(year, month, 0) 下月 0 当月最后一天 获取当月天数
new Date(year, month-1, 0) 当月 0 上月最后一天 获取上月天数,填充日历前部

可视化理解:

1
2
3
4
5
6
7
8
9
10
11
12
2025年2月          2025年3月          2025年4月
┌─────────┐ ┌─────────┐ ┌─────────┐
│ ... │ │ │ │ │
│ 28 ←────┼──────┼─ 0 │ │ │
└─────────┘ │ 1 ←────┼──────┼─ 0 │
│ ... │ │ 1 │
│ 31 ←────┼──────┼─ 0 │
└─────────┘ └─────────┘

表达式3 表达式1 表达式2
month-1, 0 month-1, 1 month, 0
上月最后天 当月第一天 当月最后天

实际代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 完整的日历日期计算逻辑
generateCalendar(year: number, month: number) {
// 1. 当月第一天(确定起始位置)
const firstDay = new Date(year, month - 1, 1)
const firstDayOfWeek = firstDay.getDay() // 0-6,表示星期日到星期六

// 2. 当月最后一天(确定当月天数)
const lastDay = new Date(year, month, 0)
const daysInMonth = lastDay.getDate() // 28/29/30/31

// 3. 上月最后一天(填充前置日期)
const prevLastDay = new Date(year, month - 1, 0)
const prevDaysInMonth = prevLastDay.getDate()

// 计算需要填充的上月日期数量
const prevDaysToShow = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1

// 生成日历数组
const calendar: number[] = []

// 填充上月日期
for (let i = prevDaysToShow; i > 0; i--) {
calendar.push(prevDaysInMonth - i + 1)
}

// 填充当月日期
for (let i = 1; i <= daysInMonth; i++) {
calendar.push(i)
}

// 填充下月日期(补齐到42格,6行×7列)
const remainingDays = 42 - calendar.length
for (let i = 1; i <= remainingDays; i++) {
calendar.push(i)
}

return calendar
}

// 示例:2025年3月
// firstDayOfWeek = 6 (星期六)
// daysInMonth = 31
// prevDaysInMonth = 28
// prevDaysToShow = 5
//
// 日历显示:
// 日 一 二 三 四 五 六
// 23 24 25 26 27 28 1 ← 23-28是2月,1是3月
// 2 3 4 5 6 7 8
// 9 10 11 12 13 14 15
// 16 17 18 19 20 21 22
// 23 24 25 26 27 28 29
// 30 31 1 2 3 4 5 ← 30-31是3月,1-5是4月

为什么这样设计?

这种自动溢出处理极大简化了日期计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 不使用溢出特性(复杂)
function getLastDayOfMonth(year: number, month: number): number {
if (month === 2) {
// 判断闰年
return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0) ? 29 : 28
} else if ([4, 6, 9, 11].includes(month)) {
return 30
} else {
return 31
}
}

// 使用溢出特性(简洁)
function getLastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate() // 一行搞定!
}

实战示例:

1
2
3
4
5
6
7
8
9
// 场景:计算2025年2月有多少天
const daysInFeb2025 = new Date(2025, 2, 0).getDate() // 28天

// 场景:计算2024年2月有多少天(闰年)
const daysInFeb2024 = new Date(2024, 2, 0).getDate() // 29天

// 场景:获取当前月份的最后一天是星期几
const lastDay = new Date(2025, 3, 0) // 2025年3月31日
const dayOfWeek = lastDay.getDay() // 1(星期一)

常见陷阱:

1
2
3
4
5
6
7
8
9
10
11
12
// 陷阱1:忘记月份从0开始
new Date(2025, 3, 1) // 不是3月1日,而是4月1日!
new Date(2025, 2, 1) // 这才是3月1日

// 陷阱2:混淆monthIndex和month
const currentMonth = 3 // 用户看到的3月
new Date(2025, currentMonth, 1) // 错误:会得到4月1日
new Date(2025, currentMonth - 1, 1) // 正确:得到3月1日

// 陷阱3:日期字符串解析的时区问题
new Date('2025-03-01') // 可能是UTC时间
new Date(2025, 2, 1) // 本地时间,更可靠

性能对比:

1
2
3
4
5
6
7
8
9
10
11
12
// 方案A:手动计算(容易出错)
let lastDay: number
if (month === 2) {
lastDay = isLeapYear(year) ? 29 : 28
} else if ([4,6,9,11].includes(month)) {
lastDay = 30
} else {
lastDay = 31
}

// 方案B:利用Date溢出(简洁可靠)
const lastDay = new Date(year, month, 0).getDate()

方案B不仅代码更简洁,而且由Date对象内部处理所有边界情况(闰年、大小月),完全不会出错。

算法核心要点:

  1. 为什么是42个格子?

    日历最多需要6行(某些月份第一天是周六,最后一天是周日的情况),每行7天,所以是6×7=42个格子。这样可以保证任何月份都能完整显示。

  2. 上月日期的倒推计算

    1
    2
    3
    for (let i = firstDayOfWeek - 1; i >= 0; i--) {
    const day = prevMonthDays - i
    }

    假设当月1号是周三(firstDayOfWeek = 3),上月有31天:

    • i = 2: day = 31 - 2 = 29(上月29号)
    • i = 1: day = 31 - 1 = 30(上月30号)
    • i = 0: day = 31 - 0 = 31(上月31号)

    这样就填充了周日、周一、周二三个格子。

  3. isCurrentMonth标记的妙用

    通过这个标记,我们可以在UI层对非当月日期做视觉弱化处理(比如降低透明度、改变字体颜色),让用户一眼就能区分当月和非当月的日期。

3. 周列转换算法

GitHub风格的热力图是按周分列显示的,每列代表一周(7天),而我们生成的heatmapCells是一个线性数组。这个算法负责将线性数组转换为二维数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
getWeekColumns(): HeatmapCell[][] {
const weeks: HeatmapCell[][] = []
if (this.heatmapCells.length === 0) return weeks

const firstDayOfWeek = this.heatmapCells[0].dayOfWeek
let currentWeek: HeatmapCell[] = []

// 阶段1:填充第一周的前置空白
for (let i = 0; i < firstDayOfWeek; i++) {
currentWeek.push({
dateStr: '',
count: 0,
color: 'transparent', // 透明色,不显示
dayOfWeek: i,
dayOfMonth: 0,
month: 0
})
}

// 阶段2:遍历所有日期,按周分组
for (const cell of this.heatmapCells) {
currentWeek.push(cell)
if (currentWeek.length === 7) { // 一周满了
weeks.push(currentWeek)
currentWeek = [] // 开始新的一周
}
}

// 阶段3:填充最后一周的后置空白
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push({
dateStr: '',
count: 0,
color: 'transparent',
dayOfWeek: currentWeek.length,
dayOfMonth: 0,
month: 0
})
}
weeks.push(currentWeek)
}

return weeks
}

算法可视化示例:

假设我们有10天的数据,第一天是周三(dayOfWeek = 3):

1
2
3
4
5
6
7
输入(线性数组):
[Day1(周三), Day2(周四), Day3(周五), Day4(周六), Day5(周日), Day6(周一), ...]

输出(二维数组):
Week1: [空, 空, 空, Day1, Day2, Day3, Day4]
Week2: [Day5, Day6, Day7, Day8, Day9, Day10, 空]
周日 周一 周二 周三 周四 周五 周六

这样在UI渲染时,我们只需要:

1
2
3
4
5
6
7
ForEach(this.getWeekColumns(), (weekCells: HeatmapCell[], weekIndex: number) => {
Column() { // 每一列代表一周
ForEach(weekCells, (cell: HeatmapCell, dayIndex: number) => {
// 渲染单个格子
})
}
})

就能自动形成GitHub风格的按周分列布局。

4. 颜色映射算法

颜色映射是热力图的灵魂,它决定了数据的视觉呈现效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
getColorForCount(count: number): string {
const maxRange = this.userConfig.heatmapColorRange // 用户设置的最大值,如10篇
const ratio = Math.min(count / maxRange, 1) // 计算比例,最大为1
const scheme = this.userConfig.heatmapColorScheme // 颜色方案

// 蓝色方案的特殊处理
if (scheme === HeatmapColorScheme.BLUE) {
const blueColors = ['#2d333b', '#1e3a5f', '#2563eb', '#60a5fa', '#93c5fd']
if (count === 0) return blueColors[0] // 无数据:深灰色
if (ratio <= 0.25) return blueColors[1] // 0-25%:深蓝
if (ratio <= 0.5) return blueColors[2] // 25-50%:中蓝
if (ratio <= 0.75) return blueColors[3] // 50-75%:浅蓝
return blueColors[4] // 75-100%:亮蓝
}

// 其他颜色方案
const colorSchemes: Record<string, string[]> = {
[HeatmapColorScheme.GREEN]: ['#2d333b', '#0e4429', '#006d32', '#26a641', '#39d353'],
[HeatmapColorScheme.RED]: ['#2d333b', '#5c2121', '#8b3d3d', '#d35f5f', '#ff8080'],
[HeatmapColorScheme.GRAY]: ['#2d333b', '#404040', '#606060', '#909090', '#c0c0c0']
}

const colors = colorSchemes[scheme] || colorSchemes[HeatmapColorScheme.GREEN]
if (count === 0) return colors[0]
if (ratio <= 0.25) return colors[1]
if (ratio <= 0.5) return colors[2]
if (ratio <= 0.75) return colors[3]
return colors[4]
}

颜色分级策略:

这里采用了5级颜色分级,而不是线性渐变,原因有两个:

  1. 视觉区分度更高:离散的颜色级别比连续渐变更容易让用户快速识别数据差异
  2. 性能更好:不需要实时计算RGB插值,直接返回预定义的颜色值

颜色分级的阈值设计也很讲究:

1
2
3
0%        25%       50%       75%       100%
├─────────┼─────────┼─────────┼─────────┤
无数据 少量 中等 较多 很多

这种非线性分级更符合人类的感知习惯。比如从0篇到2篇的差异,在视觉上应该比从8篇到10篇的差异更明显,因为前者代表了”从无到有”的质变。

5. 性能优化要点

在实际使用中,我发现了几个性能瓶颈并进行了优化:

优化1:使用Map代替数组查询

在日历热力图的渲染过程中,我们需要频繁地根据日期字符串查询对应的统计数据。这个查询操作在渲染42个日历格子时会被调用42次,如果使用数组查询,性能会成为瓶颈。

问题分析:数组查询的性能瓶颈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 优化前:使用数组存储和查询
class CalendarViewModel {
// 数据结构:数组
dailyStats: DailyStat[] = [] // 例如:365个元素(一年的数据)

// 查询方法:使用 Array.find()
getCountForDate(dateStr: string): number {
const stat = this.dailyStats.find(s => s.dateStr === dateStr)
return stat ? stat.count : 0
}

// 渲染日历时的调用
generateCalendarDays() {
for (let i = 0; i < 42; i++) { // 42个格子
const dateStr = this.formatDate(someDate)
const count = this.getCountForDate(dateStr) // 每次都要遍历数组!
// ... 渲染逻辑
}
}
}

性能问题:

  1. 时间复杂度:O(n)

    • Array.find() 需要遍历数组直到找到匹配项
    • 最坏情况:遍历整个数组(365次比较)
    • 平均情况:遍历一半数组(182次比较)
  2. 总体开销:O(m × n)

    • m = 日历格子数量(42个)
    • n = 统计数据数量(365个)
    • 总比较次数:42 × 182 ≈ 7,644次
  3. 实际影响:

    1
    2
    3
    // 假设每次比较耗时 0.001ms
    // 渲染一次日历:7,644 × 0.001ms ≈ 7.6ms
    // 如果用户频繁切换月份,会感觉到明显卡顿

解决方案:使用Map进行O(1)查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 优化后:使用Map存储和查询
class CalendarViewModel {
// 数据结构1:数组(保留,用于遍历和排序)
dailyStats: DailyStat[] = []

// 数据结构2:Map(新增,用于快速查询)
private statsMap: Map<string, number> = new Map()

// 数据加载时构建Map
async loadDailyStats() {
// 1. 从数据库加载数据到数组
this.dailyStats = await this.fetchStatsFromDB()

// 2. 构建Map索引(一次性操作)
this.statsMap.clear()
this.dailyStats.forEach(stat => {
this.statsMap.set(stat.dateStr, stat.count)
})
// 时间复杂度:O(n),但只执行一次
}

// 查询方法:使用 Map.get()
getCountForDate(dateStr: string): number {
return this.statsMap.get(dateStr) || 0 // O(1) 查询!
}

// 渲染日历时的调用
generateCalendarDays() {
for (let i = 0; i < 42; i++) {
const dateStr = this.formatDate(someDate)
const count = this.getCountForDate(dateStr) // 瞬间完成!
// ... 渲染逻辑
}
}
}

性能提升:

  1. 时间复杂度:O(1)

    • Map使用哈希表实现
    • 查询时间与数据量无关
    • 每次查询只需要1次哈希计算
  2. 总体开销:O(m)

    • m = 日历格子数量(42个)
    • 总查询次数:42次(每次O(1))
    • 构建Map的成本:O(n) = 365次(只执行一次)
  3. 实际对比:

    1
    2
    3
    4
    5
    6
    7
    8
    // 优化前:
    // 每次渲染:7,644次比较 ≈ 7.6ms

    // 优化后:
    // 构建Map:365次插入 ≈ 0.4ms(只在数据加载时执行一次)
    // 每次渲染:42次查询 ≈ 0.04ms

    // 性能提升:7.6ms → 0.04ms ≈ 190倍!

Map的工作原理:哈希表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// Map内部使用哈希表实现
// 简化示意(实际实现更复杂)

class SimpleMap<K, V> {
private buckets: Array<Array<[K, V]>> = []
private size = 16 // 初始桶数量

// 哈希函数:将key转换为数组索引
private hash(key: K): number {
const str = String(key)
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i)
hash = hash & hash // 转换为32位整数
}
return Math.abs(hash) % this.size
}

// 设置值:O(1)
set(key: K, value: V): void {
const index = this.hash(key)
if (!this.buckets[index]) {
this.buckets[index] = []
}
// 查找是否已存在
const bucket = this.buckets[index]
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
bucket[i][1] = value // 更新
return
}
}
bucket.push([key, value]) // 新增
}

// 获取值:O(1)
get(key: K): V | undefined {
const index = this.hash(key)
const bucket = this.buckets[index]
if (!bucket) return undefined

for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
return bucket[i][1]
}
}
return undefined
}
}

// 使用示例
const map = new SimpleMap<string, number>()
map.set('2025-03-15', 5) // 计算哈希 → 存入对应桶
const count = map.get('2025-03-15') // 计算哈希 → 从对应桶取出

为什么Map查询是O(1)?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 查询过程分解
const count = this.statsMap.get('2025-03-15')

// 步骤1:计算哈希值(固定时间)
const hash = hashFunction('2025-03-15') // 例如:hash = 42

// 步骤2:定位桶(数组索引,固定时间)
const bucket = buckets[hash % bucketSize] // buckets[42]

// 步骤3:在桶内查找(通常只有1个元素)
for (const [key, value] of bucket) {
if (key === '2025-03-15') return value
}

// 总时间:O(1) + O(1) + O(1) = O(1)
// 注:理想情况下每个桶只有1个元素,即使有冲突也很少

完整的实现示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// 数据结构定义
interface DailyStat {
dateStr: string // '2025-03-15'
count: number // 5
color?: string // '#4caf50'(可选,预计算的颜色)
}

class CalendarViewModel {
// 双数据结构策略
private dailyStats: DailyStat[] = [] // 用于遍历、排序
private statsMap: Map<string, DailyStat> = new Map() // 用于快速查询

/**
* 加载统计数据
* 时间复杂度:O(n),但只在数据变化时执行
*/
async loadDailyStats(): Promise<void> {
// 1. 从数据库加载
const stats = await kvDatabase.get<DailyStat[]>('daily_stats') || []
this.dailyStats = stats

// 2. 构建Map索引
this.statsMap.clear()
stats.forEach(stat => {
// 存储完整对象,不只是count
this.statsMap.set(stat.dateStr, stat)
})

logger.info(`加载了 ${stats.length} 条统计数据,构建了 ${this.statsMap.size} 个索引`)
}

/**
* 查询指定日期的统计数据
* 时间复杂度:O(1)
*/
getStatForDate(dateStr: string): DailyStat | null {
return this.statsMap.get(dateStr) || null
}

/**
* 查询指定日期的计数
* 时间复杂度:O(1)
*/
getCountForDate(dateStr: string): number {
const stat = this.statsMap.get(dateStr)
return stat ? stat.count : 0
}

/**
* 生成日历格子数据
* 时间复杂度:O(42) = O(1)(常数时间)
*/
generateCalendarDays(): void {
const days: CalendarDay[] = []

// 计算日历范围(省略具体逻辑)
const startDate = this.getCalendarStartDate()

for (let i = 0; i < 42; i++) {
const currentDate = new Date(startDate)
currentDate.setDate(startDate.getDate() + i)

const dateStr = this.formatDate(currentDate)

// 关键:O(1)查询!
const stat = this.statsMap.get(dateStr)
const count = stat ? stat.count : 0
const color = stat?.color || this.getDefaultColor()

days.push({
date: currentDate.getDate(),
dateStr: dateStr,
count: count,
color: color,
isCurrentMonth: this.isCurrentMonth(currentDate)
})
}

this.calendarDays = days
}

/**
* 格式化日期为字符串
*/
private formatDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
}

性能测试对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 性能测试代码
function performanceTest() {
// 准备测试数据:365天的统计
const testData: DailyStat[] = []
for (let i = 0; i < 365; i++) {
const date = new Date(2025, 0, 1)
date.setDate(date.getDate() + i)
testData.push({
dateStr: formatDate(date),
count: Math.floor(Math.random() * 10)
})
}

// 测试1:数组查询(优化前)
console.time('数组查询')
for (let i = 0; i < 42; i++) {
const dateStr = testData[Math.floor(Math.random() * 365)].dateStr
const stat = testData.find(s => s.dateStr === dateStr)
const count = stat ? stat.count : 0
}
console.timeEnd('数组查询')
// 输出:数组查询: 8.234ms

// 测试2:Map查询(优化后)
const statsMap = new Map<string, number>()
testData.forEach(stat => statsMap.set(stat.dateStr, stat.count))

console.time('Map查询')
for (let i = 0; i < 42; i++) {
const dateStr = testData[Math.floor(Math.random() * 365)].dateStr
const count = statsMap.get(dateStr) || 0
}
console.timeEnd('Map查询')
// 输出:Map查询: 0.043ms

// 性能提升:8.234 / 0.043 ≈ 191倍!
}

内存开销分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 数组方式(优化前)
const dailyStats: DailyStat[] = [] // 365个对象
// 内存占用:365 × (对象大小) ≈ 365 × 100字节 ≈ 36KB

// Map方式(优化后)
const dailyStats: DailyStat[] = [] // 365个对象
const statsMap: Map<string, DailyStat> = new Map() // 365个键值对
// 内存占用:
// - 数组:36KB
// - Map:365 × (key大小 + 指针大小) ≈ 365 × 20字节 ≈ 7KB
// - 总计:43KB

// 内存增加:7KB(约19%)
// 性能提升:191倍
// 结论:用19%的内存换取191倍的性能,非常值得!

何时使用Map?

适合使用Map的场景:

  • 需要频繁根据key查询value
  • 数据量较大(>100个元素)
  • key是字符串或数字
  • 查询频率远高于插入/删除频率

不适合使用Map的场景:

  • 数据量很小(<10个元素),数组遍历更快
  • 需要保持特定顺序(Map保持插入顺序,但不支持排序)
  • 需要频繁遍历所有元素(数组遍历更快)
  • 内存非常受限的环境

最佳实践:双数据结构策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class CalendarViewModel {
// 策略:同时维护数组和Map
private dailyStats: DailyStat[] = [] // 用于遍历、排序、展示
private statsMap: Map<string, DailyStat> = new Map() // 用于快速查询

// 优点1:查询快速(Map)
getCountForDate(dateStr: string): number {
return this.statsMap.get(dateStr)?.count || 0
}

// 优点2:遍历方便(数组)
getAllStats(): DailyStat[] {
return this.dailyStats
}

// 优点3:排序方便(数组)
getTopDays(limit: number): DailyStat[] {
return [...this.dailyStats]
.sort((a, b) => b.count - a.count)
.slice(0, limit)
}

// 维护成本:数据更新时同步两个结构
async updateStat(dateStr: string, count: number): Promise<void> {
// 1. 更新数组
const index = this.dailyStats.findIndex(s => s.dateStr === dateStr)
if (index >= 0) {
this.dailyStats[index].count = count
} else {
this.dailyStats.push({ dateStr, count })
}

// 2. 更新Map
this.statsMap.set(dateStr, this.dailyStats[index >= 0 ? index : this.dailyStats.length - 1])

// 3. 持久化
await kvDatabase.set('daily_stats', this.dailyStats)
}
}

总结:

对比项 数组查询 Map查询
时间复杂度 O(n) O(1)
42次查询耗时 ~7.6ms ~0.04ms
性能提升 - 191倍
内存增加 - +19%
代码复杂度 简单 稍复杂(需维护两个结构)
适用场景 小数据量 大数据量+频繁查询

在日历热力图这个场景中,使用Map优化是非常明智的选择,因为:

  1. 数据量大(365天)
  2. 查询频繁(每次渲染42次)
  3. 内存开销可接受(+7KB)
  4. 性能提升显著(191倍)

这就是为什么我们要用Map代替数组查询的原因。

优化2:避免重复计算

1
2
3
4
5
6
7
// 在数据加载时一次性计算所有颜色
this.dailyStats.forEach(stat => {
stat.color = this.getColorForCount(stat.count) // 预计算
})

// 渲染时直接使用
const color = stat.color // 不需要重新计算

优化3:监听器的精细化控制

1
2
3
4
5
6
7
8
9
10
@Monitor('userConfig.heatmapTimeRange')
async onTimeRangeChange(): Promise<void> {
await this.loadDailyStats() // 需要重新加载数据
}

@Monitor('userConfig.heatmapColorRange')
onColorRangeChange(): void {
this.generateHeatmapCells() // 只需要重新计算颜色
this.generateCalendarDays()
}

不同的配置变化触发不同级别的更新,避免不必要的数据重载。


热力数据核心数据结构与数据解析存储算法

鸿蒙所给出的数据库接口中总共有三种接口,分别是关系型数据库,键值数据库以及向量数据库。向量数据库并不适用于当前的存储场景,同时剩余的两种数据库存储的本质都是一些简单类型,我们为了能存储按日期划分的历史记录数据就需要进行一些数据结构上的逻辑转化。我们当前应用使用的是键值数据库,为了应用整体的数据处理风格的一致性,我们选择使用以功能模块为键,以json字符串为值的形式去进行数据的存储。

接下来我们来分析一下核心历史记录处理函数的处理细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 获取每日阅读统计 Map
*
* @returns 日期到统计数据的 Map
*/
async getDailyStatsMap(): Promise<Map<string, DailyReadingStats>> {
if (!this.appKVDb) {
await this.init()
}

const statsMap = new Map<string, DailyReadingStats>()

if (!this.appKVDb) {
return statsMap
}

try {
const data = await this.appKVDb.get(KV_DB_KEYS.DAILY_READING_STATS) as string
const statsArray = JSON.parse(data) as DailyReadingStats[]
statsArray.forEach(stats => {
statsMap.set(stats.dateStr, stats)
})
} catch (error) {
logger.info(`${LOG_TAG.HISTORY_MANAGER}每日统计数据为空或读取失败`)
}

return statsMap
}

getDailyStatsMap()函数是直接对数据库数据进行读取以及格式转化的函数。其核心逻辑与此前提到的渲染和新函数是同样的,都是维护双数据结构,利用Map来进行快速查找。

这一部分使用Map进行查找速度优化的核心原因在于,随着日期的增长,每日都会单独新增一个日期标识Key,如果仅使用数组进行对象的存储的话,我们每次都需要遍历整个数据,并逐一访问对象中的dateStr属性,直到找到对应的日期进行计数得出结果,而Map则可以直接通过Key进行查找,以dateStr属性为Key就可以通过极少的哈希计算获取当日的阅读量数据,大大提高了查找速度。

同时为了进一步的优化查找速度,我们在Map的Value对象中还单独设置了一个字段为count,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 每日阅读统计接口
*
* 用于存储用户每天阅读的新闻数量统计
*
* @interface DailyReadingStats
*/
export interface DailyReadingStats {
/**
* 日期字符串,格式:YYYY-MM-DD
* @example "2025-12-04"
*/
dateStr: string

/**
* 当天阅读的新闻数量(已去重)
*/
count: number

/**
* 当天阅读的新闻ID集合(用于去重)
*/
articleIds: string[]
}

我们每次存入的时候将存入的数据条数加到count字段中,这样我们每次查询的时候就可以直接获取到当日的阅读量,而不需要再进行遍历查找。单独维护一个简单类型的字段成本比遍历数组要小多了。

这里我们还可以注意到在DailyReadingStats接口中的articleIds字段存储的仅仅是文章ID,而不是完整文章对象,也不是完整的历史记录对象,这也是为了防止相同数据的重复存储,为了理解这里的逻辑我们需要从实际的使用场景来去理解。

用户点开热力日历页面之后首先要看到的是已经渲染好的热力日历,而不是每日的阅读记录,每日阅读记录是用户点击对应日期格子后仅需查找对应一天的数据即可,并不需要像是在渲染日历时需要全部日期的全部数据都遍历一次。

所以我们维护articleIds数组的目的仅仅是为了去重,如果此时还存储完整对象,只会增加我们的存储成本以及查找的深度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 更新每日阅读统计
*
* @param articleId - 文章 ID
* @param dateStr - 日期字符串 (YYYY-MM-DD)
*/
async updateDailyStats(articleId: string, dateStr: string): Promise<void> {
if (!this.appKVDb) {
await this.init()
}

if (!this.appKVDb) {
logger.error(`${LOG_TAG.HISTORY_MANAGER}数据库未初始化,无法更新每日统计`)
return
}

try {
const statsMap = await this.getDailyStatsMap()

if (!statsMap.has(dateStr)) {
statsMap.set(dateStr, {
dateStr: dateStr,
count: 0,
articleIds: []
})
}

const dayStats = statsMap.get(dateStr)!

// 检查当天是否已经阅读过该文章(去重)
if (!dayStats.articleIds.includes(articleId)) {
dayStats.articleIds.push(articleId)
dayStats.count = dayStats.articleIds.length
statsMap.set(dateStr, dayStats)

// 保存到数据库
const statsArray = Array.from(statsMap.values())
await this.appKVDb.put(KV_DB_KEYS.DAILY_READING_STATS, JSON.stringify(statsArray))
logger.debug(`${LOG_TAG.HISTORY_MANAGER}更新每日统计: ${dateStr}, 数量: ${dayStats.count}`)
}
} catch (error) {
let err = error as BusinessError
logger.error(`${LOG_TAG.HISTORY_MANAGER}更新每日统计失败: ${err.message}`)
}
}

updateDailyStats()函数则是对应的项数据库中写入热力历史记录数据的关键函数,我们首先回顾一下整体的数据链条逻辑。

8

在用户点进文章后文章NavPage首先调用的是历史记录Manager的addHistory()函数,这个函数会首先将完整的文章对象转化为历史记录对象进行存储,随后将文章ID传给updateDailyStats()函数,随后通过getDailyStatsMap()函数获取当前持久化数据的Map对象,进行去重以及不重复数据的写入。

对于持久化到数据库的部分,我们则直接使用数组内置函数Array.from来将Map的内置函数values()返回的迭代器转化为数组,随后将数组转化为JSON字符串进行存储。

迭代器概念简介

在ArkTS以及其他大部分语言中,迭代器是一种设计模式,它提供了一种方法来访问一个容器对象中的各个元素,而不需要暴露该对象的内部表示。常见的迭代器包括数组的内置方法如forEachmapfilter等,以及ES6标准提供的Iterator接口。

以下是一些ArkTS中常用的迭代器概念及其实现:

一、数组迭代器方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 示例:使用不同迭代器方法处理数组
let numbers = [1, 2, 3, 4, 5];

// map - 转换每个元素并返回新数组
let doubled = numbers.map(num => num * 2);
console.log('Map result:', doubled); // 输出: [2, 4, 6, 8, 10]

// filter - 筛选满足条件的元素
let evens = numbers.filter(num => num % 2 === 0);
console.log('Filter result:', evens); // 输出: [2, 4]

// forEach - 遍历每个元素(无返回值)
numbers.forEach((num, index) => {
console.log(`Index ${index}: ${num}`);
});
// 输出:
// Index 0: 1
// Index 1: 2
// Index 2: 3
// Index 3: 4
// Index 4: 5

// reduce - 将数组元素累积为单个值
let sum = numbers.reduce((acc, curr) => acc + curr, 0);
console.log('Reduce result:', sum); // 输出: 15

二、Map和Set的迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Map迭代器示例
let userMap = new Map<string, number>();
userMap.set('Alice', 25);
userMap.set('Bob', 30);
userMap.set('Charlie', 35);

// 使用for...of遍历Map
for (let [key, value] of userMap) {
console.log(`${key} is ${value} years old`);
}
// 输出:
// Alice is 25 years old
// Bob is 30 years old
// Charlie is 35 years old

// 使用keys(), values(), entries()方法
console.log('Keys:', Array.from(userMap.keys())); // ['Alice', 'Bob', 'Charlie']
console.log('Values:', Array.from(userMap.values())); // [25, 30, 35]
console.log('Entries:', Array.from(userMap.entries())); // [['Alice', 25], ['Bob', 30], ['Charlie', 35]]

// Set迭代器示例
let uniqueNumbers = new Set([1, 2, 3, 2, 1]);
for (let num of uniqueNumbers) {
console.log(num);
}
// 输出: 1, 2, 3 (去重后的值)

三、自定义迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 实现一个简单的自定义迭代器
class NumberRange {
constructor(private start: number, private end: number) {}

*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; i++) {
yield i;
}
}
}

const range = new NumberRange(1, 5);
for (const num of range) {
console.log(num);
}
// 输出: 1, 2, 3, 4, 5

// 使用扩展运算符
console.log([...range]); // [1, 2, 3, 4, 5]

代码解释:

上面的代码演示了TypeScript中生成器函数和自定义迭代器的用法:

  1. 生成器函数语法 (function*)*[Symbol.iterator]() 中的 * 表示这是一个生成器函数,可以使用 yield 关键字暂停和恢复函数执行。

  2. Symbol.iterator 接口:这是ES6标准中定义的迭代器协议,当对象需要被迭代时(如使用 for...of),JavaScript引擎会自动调用这个方法。

  3. yield 关键字:类似 return 但不会终止函数,每次遇到 yield 会暂停函数并将值返回给调用者,下次迭代时从暂停处继续执行。

  4. 执行流程:当使用 for...of 遍历 range 对象时,会自动触发 Symbol.iterator 方法,然后逐个产生从1到5的数字。

四、在HarmonyOS开发中的应用

在HarmonyOS的ArkTS开发中,迭代器经常用于处理UI组件的数据渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// HarmonyOS ArkTS中的列表渲染示例
@Entry
@Component
struct ArticleList {
@State articles: Article[] = [
{ id: 1, title: '文章1', content: '内容1' },
{ id: 2, title: '文章2', content: '内容2' },
{ id: 3, title: '文章3', content: '内容3' }
];

build() {
Column() {
// 使用foreach遍历数据创建UI组件
ForEach(this.articles, (item: Article) => {
ListItem() {
Text(item.title)
.fontSize(18)
.margin({ bottom: 5 })
Text(item.content)
.fontSize(14)
.fontColor(Color.Grey)
}
}, (item: Article) => item.id.toString())
}
}
}

迭代器模式使我们可以统一访问各种集合类型的元素,让代码更简洁、可读性更强,并且可以有效地处理大量数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
  /**
* 获取指定日期的历史记录
*
* @param dateStr - 日期字符串 (YYYY-MM-DD)
* @returns 该日期的历史记录列表
*/
async getHistoryByDate(dateStr: string): Promise<HistoryItem[]> {
const historyList = await this.getHistoryList()
return historyList.filter(item => item.dateStr === dateStr)
}

/**
* 历史记录项接口
*
* 用于存储用户浏览新闻的历史记录信息
*
* @interface HistoryItem
*
* @remarks
* 历史记录特点:
* - 自动记录:用户打开文章时自动添加
* - 去重机制:同一文章只保留最新的浏览记录
* - 时间排序:按浏览时间倒序排列
* - 数量限制:最多保存 100 条记录
*
* 存储位置:
* - KV 数据库键:KV_DB_KEYS.READING_HISTORY
* - 数据格式:JSON 字符串数组
*
* @example
* ```typescript
* const historyItem: HistoryItem = {
* article: {
* id: "news_001",
* title: "鸿蒙系统最新更新",
* date: "2025-01-15",
* url: "https://example.com/news/001",
* content: [...],
* source: "官方资讯"
* },
* timestamp: 1705123456789,
* dateStr: "2025-01-15 14:30:56"
* }
* ```
*
* @see HistoryManager 历史记录管理器
* @see NewsArticle 新闻文章数据模型
*/
export interface HistoryItem {
/**
* 新闻文章数据
*
* 包含文章的完整信息,用于显示和跳转
*/
article: NewsArticle

/**
* 浏览时间戳
*
* Unix 时间戳(毫秒),用于排序和时间计算
*
* @example 1705123456789
*/
timestamp: number

/**
* 格式化的日期字符串
*
* 用于界面显示的友好时间格式
*
* @example "2025-01-15 14:30:56"
*/
dateStr: string
}

我将这个接口列在这里就是为了说明维护两种数据结构的目的。完整的历史记录单元不光包含完整的文章对象,更是包含了额外的时间戳信息。

调用getHistoryByDate(dateStr: string)时获取的数据量相比于仅存储ID和简单类型的DailyReadingStats要大不少,所以我们仅在用户点击日历指定单元格时调用此接口拉取出单日的历史记录对象。减少内存占用。


热力数据结构转化接口的优化

问题的发现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 获取指定日期范围内的每日阅读统计
*
* @param days - 天数范围
* @returns 每日统计数组
*/
async getDailyStatsInRange(days: number): Promise<DailyReadingStats[]> {
const statsMap = await this.getDailyStatsMap()
const result: DailyReadingStats[] = []
const today = new Date()

for (let i = 0; i < days; i++) {
const date = new Date(today)
date.setDate(today.getDate() - i)
const dateStr = this.formatDate(date)

if (statsMap.has(dateStr)) {
result.push(statsMap.get(dateStr)!)
} else {
result.push({
dateStr: dateStr,
count: 0,
articleIds: []
})
}
}

return result.reverse() // 按时间正序排列
}

这个函数在我VibeCoding结束之后我感到了一些疑惑,其基本功能很简单明了,就是单纯的将Map中的数据转化为数组,同时补全缺失的日期。

其唯一用途是在两种日历渲染中承担一个颜色数组的数据源的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/**
* 月历日期单元格
*/
interface CalendarDayCell {
day: number
dateStr: string
count: number
color: string
isCurrentMonth: boolean
isToday: boolean
}

@Local calendarDays: CalendarDayCell[] = []
@Local statsMap: Map<string, number> = new Map()

async loadDailyStats(): Promise<void> {
this.isLoading = true
try {
// 加载365天的数据用于月历显示
this.dailyStats = await historyManager.getDailyStatsInRange(365)
this.statsMap.clear()
this.dailyStats.forEach(stat => {
if (stat.count > 0) {
this.statsMap.set(stat.dateStr, stat.count)
}
})
this.generateHeatmapCells()
this.totalReadCount = this.dailyStats
.filter(stat => {
const date = new Date(stat.dateStr) // 1. 将日期字符串转为 Date 对象
const today = new Date() // 2. 获取今天的日期
const diffDays = Math.floor( // 3. 计算天数差
(today.getTime() - date.getTime()) / (1000 * 60 * 60 * 24) // 1000毫秒 = 1秒 60秒 = 1分钟 60分钟 = 1小时 24小时 = 1天
)
return diffDays < this.userConfig.heatmapTimeRange // 4. 筛选条件
})
.reduce((sum, stat) => sum + stat.count, 0) // 5. 累加阅读数
} catch (error) {
console.error('加载每日统计失败:', JSON.stringify(error))
} finally {
this.isLoading = false
}
}



generateCalendarDays(): void {
const days: CalendarDayCell[] = []
const today = new Date()
const todayStr = this.formatDate(today)

const firstDay = new Date(this.currentYear, this.currentMonth - 1, 1)
const lastDay = new Date(this.currentYear, this.currentMonth, 0)
const firstDayOfWeek = firstDay.getDay()
const daysInMonth = lastDay.getDate()

const prevMonthLastDay = new Date(this.currentYear, this.currentMonth - 1, 0)
const prevMonthDays = prevMonthLastDay.getDate()

// 填充上个月的日期
for (let i = firstDayOfWeek - 1; i >= 0; i--) {
const day = prevMonthDays - i
const date = new Date(this.currentYear, this.currentMonth - 2, day)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0
days.push({
day: day,
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
isCurrentMonth: false,
isToday: dateStr === todayStr
})
}

// 填充当月日期
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(this.currentYear, this.currentMonth - 1, day)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0
days.push({
day: day,
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
isCurrentMonth: true,
isToday: dateStr === todayStr
})
}

// 填充下个月的日期
const remainingDays = 42 - days.length
for (let day = 1; day <= remainingDays; day++) {
const date = new Date(this.currentYear, this.currentMonth, day)
const dateStr = this.formatDate(date)
const count = this.statsMap.get(dateStr) || 0
days.push({
day: day,
dateStr: dateStr,
count: count,
color: this.getColorForCount(count),
isCurrentMonth: false,
isToday: dateStr === todayStr
})
}

this.calendarDays = days
}

核心就是在于以上部分代码,只是为了中和Map<string, number>Map<string, DailyReadingStats>之间的数据结构差异。但这却带来了一个由map转化为数组再转换为map的过程,我认为这是没必要的。

我想到的解决方案是从中间层的Manager新开一个接口直接返回Map<string, number>,由map直接到map,同时简化product层UI渲染部分的逻辑。当然也可以直接在product层中去直接调用getDailyStatsMap()来去进行map到map的转化。

不过这两者的核心都是省略中间数据结构“数组”来去进行内存的优化,同时简化逻辑。

原有数据流程:

1
2
3
4
5
6
7
8
9
数据库 (Map<string, DailyReadingStats>)

getDailyStatsInRange() 转换为数组

Array<DailyReadingStats>

UI 层再次转换为 Map<string, number>

渲染使用

在第一次VibeCoding优化后出现了以下问题:

  • 点击文章增加阅读量后,主页面热力日历颜色保持黑色
  • 打开全屏模式后颜色正确显示
  • 关闭全屏模式后又恢复为黑色

我详细的描述了问题,让AI进行检查发现问题的原因如下:

  1. @Local 装饰的 Map 内部变化(set/delete)不会触发 UI 更新
  2. ForEach 的 key 生成逻辑未包含能反映数据变化的标识
  3. 主页面和全屏模式的 key 生成逻辑不一致

解决方案

1 新增优化接口

HistoryManager 中新增 getDailyCountMapInRange() 方法,直接返回 Map<string, number>

新增代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 获取指定日期范围内的阅读数量 Map(优化版)
* 直接返回 Map<dateStr, count>,避免不必要的数组转换
*
* @param days - 天数范围
* @returns 日期到阅读数量的 Map
*/
async getDailyCountMapInRange(days: number): Promise<Map<string, number>> {
const statsMap = await this.getDailyStatsMap()
const result = new Map<string, number>()
const today = new Date()

for (let i = 0; i < days; i++) {
const date = new Date(today)
date.setDate(today.getDate() - i)
const dateStr = this.formatDate(date)

const stats = statsMap.get(dateStr)
if (stats && stats.count > 0) {
result.set(dateStr, stats.count)
}
}

return result
}

优势:

  • 直接返回 UI 层需要的数据结构
  • 只包含有阅读记录的日期,减少内存占用
  • 避免中间数组的创建和遍历

2 优化 UI 层数据加载

2.1 移除冗余状态变量

优化前:

1
2
@Local dailyStats: DailyReadingStats[] = []
@Local statsMap: Map<string, number> = new Map()

优化后:

1
2
// 优化:直接使用 Map 存储阅读数量,避免数组转换
@Local statsMap: Map<string, number> = new Map()

2.2 简化数据加载逻辑

优化前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async loadDailyStats(): Promise<void> {
this.isLoading = true
try {
// 加载365天的数据用于月历显示
this.dailyStats = await historyManager.getDailyStatsInRange(365)
this.statsMap.clear()
this.dailyStats.forEach(stat => {
if (stat.count > 0) {
this.statsMap.set(stat.dateStr, stat.count)
}
})
this.generateHeatmapCells()
this.totalReadCount = this.dailyStats
.filter(stat => {
const date = new Date(stat.dateStr)
const today = new Date()
const diffDays = Math.floor((today.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
return diffDays < this.userConfig.heatmapTimeRange
})
.reduce((sum, stat) => sum + stat.count, 0)
} catch (error) {
console.error('加载每日统计失败:', JSON.stringify(error))
} finally {
this.isLoading = false
}
}

优化后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
async loadDailyStats(): Promise<void> {
this.isLoading = true
try {
// 优化:直接加载 Map,避免数组转换
const timeRange = this.userConfig.heatmapTimeRange
const newStatsMap = await historyManager.getDailyCountMapInRange(365)

// 强制触发 Map 更新(重新赋值以触发响应式)
this.statsMap = newStatsMap

this.generateHeatmapCells()

// 计算总阅读数
this.totalReadCount = 0
const today = new Date()
this.statsMap.forEach((count, dateStr) => {
const date = new Date(dateStr)
const diffDays = Math.floor((today.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < timeRange) {
this.totalReadCount += count
}
})
} catch (error) {
console.error('加载每日统计失败:', JSON.stringify(error))
} finally {
this.isLoading = false
}
}

改进点:

  • 直接调用新接口,省略数组中间层
  • 通过重新赋值 this.statsMap = newStatsMap 触发响应式更新
  • 简化 totalReadCount 计算,直接遍历 Map
  • 减少代码行数,提升可读性

3 修复响应式更新机制

3.1 完善监听器

优化前:

1
2
3
4
5
@Monitor('historyUpdateTrigger.trigger')
async onHistoryUpdate(): Promise<void> {
await this.loadDailyStats()
this.generateCalendarDays()
}

优化后:

1
2
3
4
5
6
@Monitor('historyUpdateTrigger.trigger')
async onHistoryUpdate(): Promise<void> {
await this.loadDailyStats()
this.generateHeatmapCells() // 新增:确保热力格子也更新
this.generateCalendarDays()
}

3.2 统一 ForEach Key 生成逻辑

主页面热力格子 - 优化前:

1
2
3
4
5
ForEach(weekCells, (cell: HeatmapCell, dayIndex: number) => {
this.HeatmapCellBuilder(cell, weekIndex * 7 + dayIndex)
}, (cell: HeatmapCell, dayIdx: number) =>
`${this.userConfig.heatmapTimeRange}_${this.userConfig.heatmapColorRange}_${this.userConfig.heatmapColorScheme}_${cell.dateStr || 'empty'}_${dayIdx}_${cell.count}_${cell.color}`
)

主页面热力格子 - 优化后:

1
2
3
4
5
ForEach(weekCells, (cell: HeatmapCell, dayIndex: number) => {
this.HeatmapCellBuilder(cell, weekIndex * 7 + dayIndex)
}, (cell: HeatmapCell, dayIdx: number) =>
`main_${this.historyUpdateTrigger.trigger}_${cell.dateStr || 'empty'}_${dayIdx}_${cell.count}_${cell.color}`
)

全屏模式热力格子 - 优化前:

1
2
3
4
5
ForEach(weekCells, (cell: HeatmapCell, dayIndex: number) => {
this.HeatmapCellBuilder(cell, weekIndex * 7 + dayIndex)
}, (cell: HeatmapCell, dayIdx: number) =>
`fs_${cell.dateStr || 'empty'}_${dayIdx}_${cell.count}`
)

全屏模式热力格子 - 优化后:

1
2
3
4
5
ForEach(weekCells, (cell: HeatmapCell, dayIndex: number) => {
this.HeatmapCellBuilder(cell, weekIndex * 7 + dayIndex)
}, (cell: HeatmapCell, dayIdx: number) =>
`fs_${this.historyUpdateTrigger.trigger}_${cell.dateStr || 'empty'}_${dayIdx}_${cell.count}_${cell.color}`
)

月历格子 - 优化前:

1
2
3
4
5
ForEach(this.calendarDays, (cell: CalendarDayCell, index: number) => {
GridItem() {
this.CalendarDayCellBuilder(cell)
}
}, (cell: CalendarDayCell, idx: number) => `cal_${cell.dateStr}_${idx}`)

月历格子 - 优化后:

1
2
3
4
5
6
7
ForEach(this.calendarDays, (cell: CalendarDayCell, index: number) => {
GridItem() {
this.CalendarDayCellBuilder(cell)
}
}, (cell: CalendarDayCell, idx: number) =>
`cal_${this.historyUpdateTrigger.trigger}_${cell.dateStr}_${cell.count}_${cell.color}`
)

改进点:

  • 所有 ForEach key 都包含 historyUpdateTrigger.trigger
  • 确保触发器变化时强制重新渲染
  • 统一主页面和全屏模式的 key 生成逻辑
  • 包含 countcolor 确保数据变化时更新

这里着重讲解一下ForEach Key的作用。

1
2
3
4
5
ForEach(this.calendarDays, (cell: CalendarDayCell, index: number) => {
GridItem() {
this.CalendarDayCellBuilder(cell)
}
})

在过去大部分情况下我们使用循环渲染一般都只是使用第一和第二个参数,分别是循环渲染的数据源和渲染函数,这两者对于绝大部分的静态数据来讲都是有着很优秀的渲染表现效果的,而第三个参数则是Key的生成函数。

1
2
3
4
5
6
7
ForEach(this.calendarDays, (cell: CalendarDayCell, index: number) => {
GridItem() {
this.CalendarDayCellBuilder(cell)
}
}, (cell: CalendarDayCell, idx: number) =>
`cal_${this.historyUpdateTrigger.trigger}_${cell.dateStr}_${cell.count}_${cell.color}`
)

所谓Key就是对于当前循环单元的唯一标识,当这个标识发生变化的时候,渲染引擎就会强制使目标单元进行重绘,因为Key变化了,哪怕真实渲染的内容没有变化,引擎也会认为其更换了一个全新的UI元素。

而在初次修改的版本中,因为我们取消了原有的数组中间结构,@Local装饰器无法再正常监听到数据的变化,所以我们需要通过Key值的变化来触发重绘。

第一次VibeCoding的结果就是因为Key中没有包含任何与本机架构相关的信息,在全局扳机机制激活时末端并没有真正的触发UI的重绘,而在第二次修改时则将historyUpdateTrigger.trigger包含在了Key中,这样在全局扳机机制激活时,Key值发生了变化,渲染引擎就会强制进行重绘,从而实现了UI的更新。

4 优化效果

指标 优化前 优化后 提升
数据转换次数 2次 (Map→Array→Map) 1次 (Map→Map) 减少50%
内存占用 需要额外数组存储 只需一个 Map 减少约50%
代码行数 约25行 约18行 减少28%

跟手弹窗的实现

起因

在开发新闻笔记功能的过程中,我想为用户提供更多可选的笔记弹窗选项,如果统一为全屏弹窗很可能会覆盖哪些想要查看原文的用户。同时这作为一个具备一多能力的产品,在大屏场景下本就拥有远多于手机屏幕空间的想象力和创造能力,所以我在设置项中设置了多种不同的弹窗选项。

9

10

11

12

13

以上这四种弹窗都直接使用官方提供的半模态或是自定义弹窗都很好实现,但是唯独我最想要的可以自由拖拽的小弹窗我查遍文档都没有看到相关接口。

我看到的唯一个比较接近的描述是在半模态转场中的SheetType样式枚举类,其中有一个枚举值是POPUP,其官方中文解释是“跟手弹窗。跟手弹窗面板不支持跟手滑动,下滑面板不关闭。”

14

15

跟手弹窗不支持跟手滑动?那你为啥叫“跟手”弹窗?

实际尝试了一下发现确确实实没办法进行任何的拖拽或者是进行更多的自定义。

当然关于这一点我也是进一步的去问了问AI,它给出的回复是:

16

原来是“历史遗留问题”,好吧那看来就是没有应用内可自由移动的弹窗接口了,那就手搓!!!

实现的接口选择

其实思路也很好理解,就是监测拖拽,然后根据拖拽的位置来去改变组件的位置。随后我就去寻找相关的接口。

17

我第一个想到的就是组件拖拽事件,也是最符合直觉的搜索结果,我在大致浏览其接口后就优先去查看了其给出的示例代码,发现其主要的示例代码方向都是对于数据的传递,像是图标、文字、文件的拖拽,与我的目的并不相符,虽然可以实现效果,但没准有更简单易行的接口。

18

哦!手势,对哦,这类操作确实可以被称为是一种“手势”

19

太对了,就它了。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
NavDestination() {
Stack() {
this.ArticleContentBuilder()

// 圆形导航菜单 - 右下角
CircleNavigation(……)
.position(……)

// 可拖动浮动笔记编辑器
if (this.showDraggableEditor) {
// 半透明遮罩层
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(0, 0, 0, 0.4)')
.onClick(() => {
// 点击遮罩不关闭,需要点击关闭按钮
})

// 可拖动的编辑器窗口
Column() {
this.DraggableNoteEditorBuilder()
}
.translate({ x: this.dragOffsetX, y: this.dragOffsetY })
.gesture(
PanGesture()
.onActionUpdate((event: GestureEvent | undefined) => {
if (event) {
const newX = this.dragPositionX + event.offsetX
const newY = this.dragPositionY + event.offsetY
this.clampDragPosition(newX, newY)
}
})
.onActionEnd(() => {
this.dragPositionX = this.dragOffsetX
this.dragPositionY = this.dragOffsetY
})
)
}
}
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.screenWidth = newValue.width as number
this.screenHeight = newValue.height as number
})
}
.hideTitleBar(true)
.hideBackButton(true)
.hideToolBar(true)
.borderRadius(8)
.onReady((navDestinationContext: NavDestinationContext) => {
this.article = navDestinationContext.pathInfo.param as NewsArticle
this.checkFavoriteStatus()
this.checkLikeStatus()
})
.width('100%')
.height('100%')
.bindSheet(
this.showNoteEditor,
this.NoteEditorSheetBuilder(),
{
height: '100%',
width: this.deviceType === DEVICE_TYPES.PHONE ? '95%' : '50%',
dragBar: true,
showClose: false,
backgroundColor: $r('app.color.page_background'),
preferType: this.userConfig.noteEditorSheetType as SheetType
}
)

以上代码我已经省略了大部分无关代码,保留了核心的手势绑定以及位置计算部分的逻辑代码。其中我们为了限制拖拽位置在屏幕边界内,还额外封装了一个clampDragPosition函数。其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 限制拖动位置在屏幕边界内
*/
clampDragPosition(x: number, y: number): void {
const windowWidth = this.deviceType === DEVICE_TYPES.PHONE ? this.screenWidth * 0.9 : this.screenWidth * 0.5
const windowHeight = this.screenHeight * 0.75
const minX = -windowWidth + 60
const maxX = this.screenWidth - 60
const minY = 0
const maxY = this.screenHeight - 100
this.dragOffsetX = Math.max(minX, Math.min(maxX, x))
this.dragOffsetY = Math.max(minY, Math.min(maxY, y))
}

接下来我们针对这段代码来进行一下分析:

核心算法:边界限制(Clamping)

clampDragPosition 函数的核心作用是将拖拽位置限制在屏幕可视范围内,防止弹窗被拖出屏幕导致用户无法操作。这是一个经典的边界限制算法(Clamping Algorithm)

算法原理:

1
2
3
4
5
6
7
8
9
10
11
// 核心公式:将值限制在 [min, max] 区间内
clampedValue = Math.max(min, Math.min(max, value))

// 等价于:
if (value < min) {
clampedValue = min
} else if (value > max) {
clampedValue = max
} else {
clampedValue = value
}

这个公式的巧妙之处在于用两次比较完成三种情况的判断

  1. Math.min(max, value) - 确保不超过最大值
  2. Math.max(min, ...) - 确保不低于最小值

代码逐行解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
clampDragPosition(x: number, y: number): void {
// 1. 计算弹窗尺寸(根据设备类型自适应)
const windowWidth = this.deviceType === DEVICE_TYPES.PHONE
? this.screenWidth * 0.9 // 手机:屏幕宽度的 90%
: this.screenWidth * 0.5 // 平板:屏幕宽度的 50%

const windowHeight = this.screenHeight * 0.75 // 高度统一为屏幕的 75%

// 2. 计算 X 轴边界
const minX = -windowWidth + 60 // 左边界:允许弹窗左移,但至少保留 60px 可见
const maxX = this.screenWidth - 60 // 右边界:允许弹窗右移,但至少保留 60px 可见

// 3. 计算 Y 轴边界
const minY = 0 // 上边界:不允许超出屏幕顶部
const maxY = this.screenHeight - 100 // 下边界:底部保留 100px 空间(避免遮挡底部导航)

// 4. 应用边界限制
this.dragOffsetX = Math.max(minX, Math.min(maxX, x)) // X 轴限制
this.dragOffsetY = Math.max(minY, Math.min(maxY, y)) // Y 轴限制
}
边界计算的设计思路

1. X 轴边界设计(水平方向)

1
2
3
4
5
6
7
8
9
屏幕左边缘                                    屏幕右边缘
↓ ↓
|←─────────── screenWidth ─────────────→|
| |
| [弹窗] |
| ├─────────────────┤ |
| ↑ ↑ |
| minX maxX |
| (保留60px) (保留60px) |

为什么 minX = -windowWidth + 60?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
场景:弹窗向左拖拽

正常位置(x = 0):
┌─────────────────┐
│ 弹窗内容 │
└─────────────────┘

拖到左边界(x = minX):
┌─────────────────┐
│ 弹窗内容 │ ← 只有右侧 60px 可见
└─────────────────┘

屏幕左边缘

计算过程:
弹窗左边缘位置 = x
弹窗右边缘位置 = x + windowWidth
要求:弹窗右边缘至少在屏幕内 60px
即:x + windowWidth >= 60
解得:x >= 60 - windowWidth
因此:minX = -windowWidth + 60

为什么 maxX = screenWidth - 60?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
场景:弹窗向右拖拽

拖到右边界(x = maxX):
┌─────────────────┐
← 只有左侧 60px 可见│ 弹窗内容 │
└─────────────────┘

屏幕右边缘

计算过程:
弹窗左边缘位置 = x
要求:弹窗左边缘至少在屏幕内 60px
即:x <= screenWidth - 60
因此:maxX = screenWidth - 60

2. Y 轴边界设计(垂直方向)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
屏幕顶部

├─────────────────┐
│ │
│ [弹窗] │
│ ┌───────────┐ │
│ │ │ │
│ │ 内容区 │ │
│ │ │ │
│ └───────────┘ │
│ │
├─────────────────┤ ← maxY (screenHeight - 100)
│ 底部导航区 │ ← 保留 100px
└─────────────────┘

屏幕底部

为什么 minY = 0?

1
2
3
4
5
设计原则:弹窗不允许超出屏幕顶部
原因:
1. 顶部通常有状态栏,超出会被遮挡
2. 用户无法向上拖拽来恢复弹窗位置
3. 保持 UI 的整洁性

为什么 maxY = screenHeight - 100?

1
2
3
4
5
6
7
设计原则:底部保留 100px 空间
原因:
1. 避免遮挡底部导航栏(通常 48-56px)
2. 预留手势操作区域(系统手势通常在底部 20-30px)
3. 确保弹窗标题栏始终可见(标题栏通常 44-56px)

100px = 导航栏高度 + 手势区域 + 安全边距
实际应用场景分析

场景 1:手机端拖拽

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 假设:iPhone 14 Pro (screenWidth=393, screenHeight=852)
const windowWidth = 393 * 0.9 = 353.7px // 弹窗宽度
const windowHeight = 852 * 0.75 = 639px // 弹窗高度

// X 轴边界:
minX = -353.7 + 60 = -293.7px // 可以向左拖出 293.7px
maxX = 393 - 60 = 333px // 可以向右拖出 60px

// Y 轴边界:
minY = 0px // 不能超出顶部
maxY = 852 - 100 = 752px // 底部保留 100px

// 有效拖拽范围:
// X: [-293.7, 333] → 总宽度 626.7px
// Y: [0, 752] → 总高度 752px

场景 2:平板端拖拽

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 假设:iPad Pro 12.9" (screenWidth=1024, screenHeight=1366)
const windowWidth = 1024 * 0.5 = 512px // 弹窗宽度(平板更窄)
const windowHeight = 1366 * 0.75 = 1024.5px

// X 轴边界:
minX = -512 + 60 = -452px // 可以向左拖出 452px
maxX = 1024 - 60 = 964px // 可以向右拖出 60px

// Y 轴边界:
minY = 0px
maxY = 1366 - 100 = 1266px

// 有效拖拽范围:
// X: [-452, 964] → 总宽度 1416px(比屏幕宽!)
// Y: [0, 1266] → 总高度 1266px
边界限制的数学原理

Clamping 函数的数学定义:

1
2
3
4
5
clamp(x, min, max) = {
min, if x < min
x, if min ≤ x ≤ max
max, if x > max
}

实现方式对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 方式 1:if-else(直观但冗长)
function clamp1(x: number, min: number, max: number): number {
if (x < min) return min
if (x > max) return max
return x
}

// 方式 2:Math.max + Math.min(简洁高效)
function clamp2(x: number, min: number, max: number): number {
return Math.max(min, Math.min(max, x))
}

// 方式 3:三元运算符(可读性差)
function clamp3(x: number, min: number, max: number): number {
return x < min ? min : (x > max ? max : x)
}

// 性能对比(1000万次调用):
// 方式 1: ~45ms
// 方式 2: ~42ms ✓ 最快
// 方式 3: ~48ms

为什么 Math.max + Math.min 最快?

1
2
3
4
5
6
7
8
原因 1:分支预测优化
Math.max/min 是 CPU 原生指令,分支预测更准确

原因 2:指令流水线
两次比较可以并行执行(现代 CPU 的超标量特性)

原因 3:编译器优化
JavaScript 引擎(V8/JSCore)对 Math 函数有特殊优化
边界值的选择依据

60px 的选择(X 轴保留宽度):

1
2
3
4
5
6
设计考量:
1. 最小可点击区域:44x44px(iOS 人机界面指南)
2. 关闭按钮尺寸:通常 32-40px
3. 视觉识别:至少 50px 才能识别出是弹窗

结论:60px = 关闭按钮(40px) + 边距(20px)

100px 的选择(Y 轴底部保留):

1
2
3
4
5
6
设计考量:
1. 底部导航栏:48-56px(Material Design / iOS)
2. 系统手势区域:20-34px(iPhone X 及以后)
3. 安全边距:16-20px

结论:100px = 导航栏(56px) + 手势区(34px) + 边距(10px)
优化建议

1. 响应式边界值

1
2
3
4
5
6
7
8
9
10
11
// 当前实现:固定值
const minX = -windowWidth + 60
const maxX = this.screenWidth - 60

// 优化建议:根据设备动态调整
const edgeMargin = this.deviceType === DEVICE_TYPES.PHONE ? 60 : 80 // 平板更大
const bottomMargin = this.deviceType === DEVICE_TYPES.PHONE ? 100 : 120

const minX = -windowWidth + edgeMargin
const maxX = this.screenWidth - edgeMargin
const maxY = this.screenHeight - bottomMargin

2. 考虑安全区域(Safe Area)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 问题:刘海屏、挖孔屏的安全区域未考虑
//
// 优化方案:
import { window } from '@kit.ArkUI'

async getSafeAreaInsets() {
const win = await window.getLastWindow(this.context)
const avoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM)

return {
top: avoidArea.topRect.height, // 状态栏高度
bottom: avoidArea.bottomRect.height // 导航栏高度
}
}

// 应用到边界计算:
const safeArea = await this.getSafeAreaInsets()
const minY = safeArea.top // 避开状态栏
const maxY = this.screenHeight - safeArea.bottom - 20 // 避开导航栏 + 边距

3. 添加边界反弹动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 当前实现:硬性限制(瞬间停止)
this.dragOffsetX = Math.max(minX, Math.min(maxX, x))

// 优化建议:添加弹性效果
clampDragPositionWithBounce(x: number, y: number): void {
const clampedX = Math.max(minX, Math.min(maxX, x))
const clampedY = Math.max(minY, Math.min(maxY, y))

// 如果触碰边界,添加弹性动画
if (x !== clampedX || y !== clampedY) {
animateTo({
duration: 200,
curve: Curve.FastOutSlowIn
}, () => {
this.dragOffsetX = clampedX
this.dragOffsetY = clampedY
})
} else {
this.dragOffsetX = clampedX
this.dragOffsetY = clampedY
}
}

4. 性能优化:避免重复计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 当前实现:每次拖拽都重新计算边界
clampDragPosition(x: number, y: number): void {
const windowWidth = this.deviceType === DEVICE_TYPES.PHONE ? ... // 每次都计算
const windowHeight = this.screenHeight * 0.75
// ...
}

// 优化建议:缓存边界值
private bounds: { minX: number, maxX: number, minY: number, maxY: number } | null = null

private calculateBounds(): void {
const windowWidth = this.deviceType === DEVICE_TYPES.PHONE
? this.screenWidth * 0.9
: this.screenWidth * 0.5
const windowHeight = this.screenHeight * 0.75

this.bounds = {
minX: -windowWidth + 60,
maxX: this.screenWidth - 60,
minY: 0,
maxY: this.screenHeight - 100
}
}

clampDragPosition(x: number, y: number): void {
if (!this.bounds) {
this.calculateBounds() // 首次计算
}

this.dragOffsetX = Math.max(this.bounds!.minX, Math.min(this.bounds!.maxX, x))
this.dragOffsetY = Math.max(this.bounds!.minY, Math.min(this.bounds!.maxY, y))
}

// 在屏幕尺寸变化时重新计算
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.screenWidth = newValue.width as number
this.screenHeight = newValue.height as number
this.bounds = null // 清除缓存,下次拖拽时重新计算
})
总结

clampDragPosition 函数虽然只有短短几行代码,但体现了多个重要的设计原则:

  1. 边界限制算法:使用 Math.max + Math.min 实现高效的值域限制
  2. 响应式设计:根据设备类型(手机/平板)动态调整弹窗尺寸
  3. 用户体验:保留最小可见区域(60px),确保用户始终能操作弹窗
  4. 安全边距:底部预留 100px,避免遮挡系统 UI
  5. 性能优化:使用简洁的数学运算代替复杂的条件判断

这个函数是实现可拖拽浮动窗口的核心组件,确保了无论用户如何拖拽,弹窗都不会完全离开屏幕,始终保持可操作性。

哦!AI给出的优化建议确实很有道理,让我来喂给AI进行一下尝试吧。

新增代码:

1
2
3
4
5
6
7
// 边界值类型
interface BoundsInfo {
minX: number
maxX: number
minY: number
maxY: number
}
1
2
3
4
5
// 边界缓存
private bounds: BoundsInfo | null = null
// 安全区域
private safeAreaTop: number = 0
private safeAreaBottom: number = 0

边界限制问题

在上文优化过程中发现在当前限制条件仍旧存在问题。向右和向下的限制并不好,向左和向上倒是没事。

20

21

为了更有效的限制,我设置了更多的参数来去限制可移动范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 计算边界值(响应式,考虑设备类型和安全区域)
*/
private calculateBounds(): void {
const windowWidth = this.deviceType === DEVICE_TYPES.PHONE ? this.screenWidth * 0.9 : this.screenWidth * 0.5
const windowHeight = this.screenHeight * 0.75

// 左侧可以隐藏较多,右侧和底部需要保留更多可见区域
const leftMargin = this.deviceType === DEVICE_TYPES.PHONE ? 60 : 80 // 左侧最小可见宽度
const rightVisibleRatio = this.deviceType === DEVICE_TYPES.PHONE ? 0.4 : 0.9 // 右侧至少保留 90% 可见
const bottomVisibleRatio = 0.5 // 底部至少保留 60% 可见

this.bounds = {
// 左侧:允许窗口几乎完全隐藏,只露出 leftMargin 的宽度
minX: -windowWidth + leftMargin,
// 右侧:窗口左上角最多到 (屏幕宽度 - 窗口宽度 * 60%),确保右侧至少 60% 可见
maxX: this.screenWidth - windowWidth * rightVisibleRatio,
// 顶部:避开状态栏
minY: Math.max(0, this.safeAreaTop),
// 底部:窗口左上角最多到 (屏幕高度 - 窗口高度 * 60%),确保底部至少 60% 可见
maxY: this.screenHeight - this.safeAreaBottom - windowHeight * bottomVisibleRatio
}

logger.info(`${LOG_TAG.MAIN_PAGE}边界计算完成: ${JSON.stringify(this.bounds)}`)
logger.info(`${LOG_TAG.MAIN_PAGE}窗口尺寸: ${windowWidth}x${windowHeight}, 屏幕尺寸: ${this.screenWidth}x${this.screenHeight}`)
}

关键改进:

边界 优化前 优化后 改进说明
左侧 (minX) -windowWidth + 60 -windowWidth + 60/80 平板使用更大边距
右侧 (maxX) screenWidth - 60 screenWidth - windowWidth × 0.4/0.9 使用可见比例,防止完全拖出
顶部 (minY) 0 Math.max(0, safeAreaTop) 考虑安全区域
底部 (maxY) screenHeight - 100 screenHeight - safeAreaBottom - windowHeight × 0.5 使用可见比例 + 安全区域

核心优化点:

  1. 分离计算逻辑:将边界计算独立为 calculateBounds() 方法
  2. 边界缓存:计算结果存入 bounds,避免重复计算
  3. 可见比例:右侧和底部使用百分比而非固定像素
    • 手机右侧:40% 可见
    • 平板右侧:90% 可见
    • 底部:50% 可见
  4. 响应式设计:根据设备类型动态调整参数
  5. 安全区域适配:考虑系统 UI 高度

22

问题修复

热力日历的设置与渲染更新不同步的问题

问题现象

我发现这个问题是在我更新我当前项目仓库的Github Readme文件时出现的。发现在为了演示设置项变动的效果时出现了在Tab页的渲染问题,我也及时截图记录了下来。

  1. 现象一:在修改了热力日历的色系设置项后出现了Tab页的颜色仅局部更新的情况。
    具体来说,是Tab页的颜色图例是正常的随设置项及时更新,但是日历的每一个单元格的颜色并没有被及时的重绘,我怀疑是扳机机制的问题。

    23

    与此同时,点开详情页后日历的颜色就是正常的了,这也不难理解,因为详情页的组件本质是一个bindContentCover全模态,其渲染肯定是在设置项修改完成之后的,但是Tab页面除非通过扳机机制或是状态变量更新否则是不会去主动重绘的。

    24

  2. 现象二:在修改设置项中记录日期长度之后出现了局部的重复渲染以及渲染缺失的情况。
    触发的时候我是从180天修改为90天时出现了如下图所示的情况。

    25

    仔细观察发现并不是渲染缺失,实质是渲染了两次,中间的空缺是第一个完整矩阵剩余的本周未来空白天数。所以说对于格子的计算或是渲染时的循环条件有问题。

    同样的,这个现象也是点开详情页后会消失,但返回Tab页后依旧存在。

    27

    通过平板我们可以看的更加清晰。

    28

    29

    30

    31

    32

    每切换一次设置项,Tab页的单元格是被新计算出的单元格矩阵叠加在后面,这说明我们重新渲染时并没有把上一次的矩阵数据数组清空而是直接push的新数据。

修复

我最近对于VibeCoding有了一个新的认知,在对于没有上下文历史记录的情况下,我们需要做的是先不指明我们的目的,先让模型无目的全面阅读目标板块的逻辑,这样有利于让模型先从全局视角大致把握项目,虽然会消耗一部分token,但这还是很值得的。因为这样会省下很多很多对于项目不了解导致错误的生成一些本就存在的类、接口,没有按现有数据结构去调用一些接口。

26

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
非常好的观察!问题确实很明显 - 每次切换设置项时,新的格子矩阵被**追加**到现有的后面,而不是替换。这说明 `getWeekColumns()` 方法在被多次调用时,返回的数组在累积而不是重新生成。

让我检查一下代码,找出问题所在:

让我查看 ForEach 渲染热力格子的部分:

现在我明白问题了!问题不在 `getWeekColumns()` 方法本身,而在于 **ForEach 的 key 生成策略**。让我查看这两处 ForEach 的 key:

找到问题了!ForEach 的 key 包含了 `historyUpdateTrigger.trigger`,但是当**配置项变化**时(如 `heatmapTimeRange`),`historyUpdateTrigger` 并没有变化,导致 ForEach 认为是相同的数据,采用了**增量更新**而不是完全重新渲染。

问题的根源是:
1. 修改 `heatmapTimeRange` 时,触发 `onTimeRangeChange()`
2. 调用 `loadDailyStats()` 重新生成 `heatmapCells`
3.`historyUpdateTrigger.trigger` 没有变化
4. ForEach 的 key 没有变化,导致 UI 框架认为是相同的列表
5. 框架尝试复用旧的组件,导致叠加渲染

**解决方案:在配置项变化时,也需要更新触发器,或者使用配置项本身作为 key 的一部分。**

现在让我验证修改是否正确:

完美!现在让我更新修复报告:

完美!现在让我创建一个简短的总结:

## 🎯 Bug 修复完成

找到并修复了**严重的叠加渲染Bug**

### 问题根源
ForEach 的 key 只包含 `historyUpdateTrigger.trigger`,当配置项变化时(如时间范围从7天改为30天),触发器没有变化,导致 ArkTS 框架认为是相同的数据源,采用**增量更新**策略,把新数据追加到旧数据后面。

### 修复方案
在 ForEach key 中添加所有影响渲染的配置项:


// 修复前
`main_week_${this.historyUpdateTrigger.trigger}_${idx}`

// 修复后
`main_week_${this.userConfig.heatmapTimeRange}_${this.userConfig.heatmapColorRange}_${this.userConfig.heatmapColorScheme}_${this.historyUpdateTrigger.trigger}_${idx}`

### 修复效果
- ✅ 7天→30天:不再叠加,只显示30天
- ✅ 修改颜色系:立即更新,不叠加
- ✅ 修改颜色范围:立即更新,不叠加
- ✅ 快速切换配置:正常响应,无叠加

现在你可以测试一下,应该完全正常了!这是一个典型的 **ForEach key 设计不当导致的组件复用问题**

修复总结

经过测试这个问题被成功修复了,可以看到最终的解决方案与此前热力数据结构转化接口的优化时离不开干系的,原本的结构虽然有数据结构上的冗余但整体的扳机机制到UI渲染的重绘是逻辑自洽的,运转的很平稳。强如Claude Sonnet 4.5也难以完全把握扳机系统的全部细节,但这仅仅是小项目的一个模块的问题,这也说明了当前模型能力的局限性,我们程序员仍然需要有对于项目整体运作逻辑的理解和把握。同时也说明了“测试”这个环节的重要性。