前言 在完成了鸿小易以及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 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 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" ;export class AppInit { private initStatus : InitStatus = { databases : false , userConfig : false , managers : false , appStorageV2 : false , markdown : false } async initPhase1_BaseModules (uiAbilityContext : common.UIAbilityContext ): Promise <boolean > { logger.info (`${LOG_TAG.APP_INIT} ========== 开始阶段 1:基础模块初始化 ==========` ) try { logger.info (`${LOG_TAG.APP_INIT} 步骤 1/4:初始化数据库模块...` ) const dbInitSuccess = await this .initDatabases (uiAbilityContext) if (!dbInitSuccess) { logger.error (`${LOG_TAG.APP_INIT} ✗ 数据库初始化失败,终止初始化流程 - 请检查应用权限和存储空间` ) return false } logger.info (`${LOG_TAG.APP_INIT} 步骤 2/4:加载用户配置...` ) const configInitSuccess = this .initUserConfig (uiAbilityContext) if (!configInitSuccess) { logger.warn (`${LOG_TAG.APP_INIT} 用户配置初始化失败,将使用默认配置` ) } logger.info (`${LOG_TAG.APP_INIT} 步骤 3/4:初始化业务管理器...` ) const managersInitSuccess = await this .initManagers (uiAbilityContext) if (!managersInitSuccess) { logger.error (`${LOG_TAG.APP_INIT} ✗ 管理器初始化失败 - 应用可继续运行但新闻功能不可用` ) } 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 } } private async initDatabases (context : common.UIAbilityContext ): Promise <boolean > { 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 } 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 } } 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 } } 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)} ` ) } } 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 } } initPhase3_UIDependent (): boolean { logger.info (`${LOG_TAG.APP_INIT} ========== 开始阶段 3:UI 依赖模块初始化 ==========` ) try { 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 } } markDownConfigInit (): boolean { try { 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 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 } } 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} [业务管理器] ✗ 初始化失败 - 影响:新闻数据功能不可用,请检查网络或数据库` ) } if (this .initStatus .appStorageV2 ) { logger.info (`${LOG_TAG.APP_INIT} [AppStorageV2] ✓ 初始化成功` ) } else { logger.warn (`${LOG_TAG.APP_INIT} [AppStorageV2] ✗ 初始化失败 - 影响:主题切换功能可能不可用` ) } 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} ======================================` ) } getInitStatus (): InitStatus { return { databases : this .initStatus .databases , userConfig : this .initStatus .userConfig , managers : this .initStatus .managers , appStorageV2 : this .initStatus .appStorageV2 , markdown : this .initStatus .markdown } } isFullyInitialized (): boolean { return this .initStatus .databases && this .initStatus .userConfig && this .initStatus .managers && this .initStatus .appStorageV2 && this .initStatus .markdown } } 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 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' ;const DOMAIN = 0x0000 ;const TAG = 'EntryAbility' ;export default class EntryAbility extends UIAbility { private phase1Promise : Promise <boolean > | null = null onCreate (want : Want , launchParam : AbilityConstant .LaunchParam ): void { hilog.info (DOMAIN , TAG , '%{public}s' , 'Ability onCreate' ); logger.info (`${LOG_TAG.ENTRY_ABILITY} 应用启动,开始初始化流程` ) this .phase1Promise = appInit.initPhase1_BaseModules (this .context ) } onDestroy (): void { hilog.info (DOMAIN , TAG , '%{public}s' , 'Ability onDestroy' ); logger.info (`${LOG_TAG.ENTRY_ABILITY} 应用销毁` ) } onWindowStageCreate (windowStage : window .WindowStage ): void { hilog.info (DOMAIN , TAG , '%{public}s' , 'Ability onWindowStageCreate' ); logger.info (`${LOG_TAG.ENTRY_ABILITY} 窗口创建,等待阶段 1 完成...` ) const phase1Promise = this .phase1Promise || Promise .resolve (false ) phase1Promise.then ((phase1Success ) => { if (phase1Success) { logger.info (`${LOG_TAG.ENTRY_ABILITY} ✓ 阶段 1 初始化成功` ) } else { logger.error (`${LOG_TAG.ENTRY_ABILITY} ✗ 阶段 1 初始化失败,应用可能无法正常运行` ) } 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 初始化失败,部分功能可能受影响` ) } 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} 启动页加载成功` ) 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 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} ========================================` ) } onWindowStageDestroy (): void { hilog.info (DOMAIN , TAG , '%{public}s' , 'Ability onWindowStageDestroy' ); logger.info (`${LOG_TAG.ENTRY_ABILITY} 窗口销毁` ) } onForeground (): void { hilog.info (DOMAIN , TAG , '%{public}s' , 'Ability onForeground' ); logger.info (`${LOG_TAG.ENTRY_ABILITY} 应用进入前台` ) } 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是在Ability的onCreate中调用的,所以我们所有的异步操作都是在Ability的onCreate中完成的。但是问题在于后面的onWindowStageCreate窗口创建阶段与我们的onCreate函数是两个独立的代码块,彼此之间的局部变量并不互通,我们在onCreate的函数中创建的Promise实例对象无法在onWindowStageCreate中访问,所以为了保证阶段二的执行顺序,我们要将appInit.initPhase1_BaseModules对象的可见区域扩大,扩大至当前EntryAbility类的局部变量中。
1 2 3 4 5 6 7 8 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} 启动页加载成功` ) 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 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 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 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 } } 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 } 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 } 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类的实现,而NewsManager和AppInit的代码无需改动。
2. 代码复用性提升
通过封装,我们避免了代码重复。比如kvDatabase.getKVStoreById()这个方法在项目中被多个Manager调用:
1 2 3 4 5 6 7 8 const res = await kvDatabase.getKVStoreById (APP_KV_DB_ID )const userStore = await kvDatabase.getKVStoreById (USER_KV_DB_ID )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 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 } } 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 } 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 ( ) { const dbInitSuccess = await this .initDatabases (uiAbilityContext) if (!dbInitSuccess) return false const configInitSuccess = this .initUserConfig (uiAbilityContext) const managersInitSuccess = await this .initManagers (uiAbilityContext) 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 ([ 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中用于处理多个异步操作的静态方法,它的核心特点是:
并行启动 :接收一个Promise数组,会立即启动所有Promise,而不是等待前一个完成
全部等待 :等待数组中所有Promise都resolve后才返回结果
快速失败 :只要有一个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 async function serialLoad ( ) { const startTime = Date .now () const newsList = await newsManager.updateNewsListToDB () console .log (`新闻列表加载完成: ${Date .now() - startTime} ms` ) const swiper = await newsManager.updateNewsSwiperToDB () console .log (`轮播图加载完成: ${Date .now() - startTime} ms` ) console .log (`串行总耗时: ${Date .now() - startTime} ms` ) } async function parallelLoad ( ) { const startTime = Date .now () const results = await Promise .all ([ newsManager.updateNewsListToDB (), newsManager.updateNewsSwiperToDB () ]) console .log (`并行总耗时: ${Date .now() - startTime} ms` ) }
在我们的实际场景中,如果新闻列表加载需要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 function promiseAll (promises : Promise <any >[] ): Promise <any []> { return new Promise ((resolve, reject ) => { const results : any [] = [] let completedCount = 0 promises.forEach ((promise, index ) => { promise .then (value => { results[index] = value completedCount++ if (completedCount === promises.length ) { resolve (results) } }) .catch (error => { reject (error) }) }) }) }
从这个实现可以看出几个关键点:
立即执行 :forEach会立即遍历所有Promise,触发它们的执行,而不是等待前一个完成
结果顺序 :通过results[index]保证返回结果的顺序与输入顺序一致,即使某个Promise先完成
计数机制 :通过completedCount追踪已完成的Promise数量
快速失败 :任何一个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 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 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 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 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()是异步编程中非常重要的工具,它让我们能够在保持代码清晰度的同时显著提升性能。关键要点:
适用场景 :多个独立的异步操作,彼此之间没有依赖关系
性能收益 :总耗时从所有任务之和降低到最慢任务的耗时
错误处理 :需要考虑部分失败的情况,必要时使用Promise.allSettled()
并发控制 :大量并发请求时要考虑分批执行,避免资源耗尽
监控调优 :添加性能监控,用数据驱动优化决策
关键点在于识别哪些操作是可以并行的:
数据库初始化和用户配置加载 :不能并行,因为配置加载依赖数据库
新闻列表和轮播图加载 :可以并行,它们之间没有依赖关系
这也是我之前所提到的拓扑学,我们需要理清楚各个模块之间的依赖关系,才能设计出合理的并行策略。
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) this .initStatus .managers = true return true } async initManagers ( ) { const success = await newsManager.init (context) if (success) { this .initStatus .managers = true } 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 } } async init ( ) { const res = await kvDatabase.getKVStoreById (APP_KV_DB_ID ) if (res) { return true } return 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 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的局限性:
AI对异步执行顺序的理解有限 :最初的版本没有考虑到异步操作可能晚于状态打印执行的问题,需要人工发现并修复
AI倾向于过度工程化 :生成的代码注释非常详细,封装层次也很完整,但可能对小型项目来说过于复杂
AI缺乏实际运行环境的感知 :只有在真实设备上运行才能发现日志顺序的问题
因此,AI辅助开发的最佳实践应该是:AI负责生成规范化的代码框架,人类负责根据实际运行情况进行调优 。就像这次重构,Claude提供了优秀的架构设计和详细的注释,而我通过实际测试发现并修复了异步执行顺序的问题,两者结合才能产出高质量的代码。
对于加载数据流的改造 问题描述 当下我面临的另一个严峻的问题就在于数据源的加载速度过慢。这一方面是我的服务器仅仅是暂时共享了我的博客服务器,属于是最基础的一档服务器,带宽很小,另外一方面是在于我当下使用的是/api/news/?all=true这样一个最基本的接口,他会默认的将全部的数据不加分类不加分页的一口气全部发送过来,这就导致数十M的数据在我服务器本就局限的带宽上跑的愈发缓慢了,我绝对不能放开带宽限制,要不然单词更新就会将我服务器的下行带宽完全堵死。不过后面完成后部署到中软那边的服务器应该就不会出现这个问题了。
但在当前环境下,我的单次刷新会长达20秒左右的加载时间,对于首次启动会是一个比较致命的问题,毕竟启动页的延时是远远不够加载全部数据的,在首次渲染时注定是没有数据的。首先这个接口并不是流式输出接口,我们必须等待这个接口的单次响应被完整的接收后才会开始去进行数据库数据的更新以及渲染数据的更新。
为了解决这个问题,我首先想到的是进行分页处理,对于数据进行分页获取处理,通过page_size和page,两个参数来去进行数据的分页,在单次响应中会包含有"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 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条` ) 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 } } 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 async reloadAllData (): Promise <boolean > { logger.info (`${LOG_TAG.NEWS_LIST} [两阶段刷新] 开始刷新` ) promptAction.openToast ({ message : '正在快速刷新最新数据...' , duration : 1500 }) try { const quickResult = await newsManager.quickRefreshCategories () if (quickResult.success ) { this .newsSwiperData = await newsManager.getNewsSwiperDataFromDB () this .refreshTrigger ++ logger.info (`${LOG_TAG.NEWS_LIST} [两阶段刷新] ✓ 第一阶段完成: ${quickResult.loadedCount} 个栏目` ) promptAction.openToast ({ message : `刷新成功,已更新${quickResult.loadedCount} 个栏目` , duration : 2000 }) newsManager.fullRefreshCategories ((progress ) => { logger.debug (`${LOG_TAG.NEWS_LIST} [两阶段刷新] [${progress.current} /${progress.total} ] 【${progress.category} 】完成` ) 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 resultlogger.warn (`${LOG_TAG.NEWS_MANAGER} [快速刷新] 【${category.displayName} 】无数据` ) const result : CategoryLoadResult = { category : category.displayName , success : false , count : 0 }return resultlogger.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 - startTimelogger.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 - startTimelogger.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 ]
loadTasks和swiperTask两者的返回结果不一致,这导致了results的类型是联合类型。
联合类型数组我们无法直接通过遍历进行处理,所以我们需要先进行截取操作,将最后一位的轮播图任务结果单独提取出来。
1 2 3 const categoryResults = results.slice (0 , -1 ) as CategoryLoadResult []const swiperSuccess = results[results.length - 1 ] as boolean
当最后一位被截取出来后,我们就可以将两组更新结果进行类型的声明了。
这里针对于slice函数的用法去进行一下进一步的解析。读了我每日算法栏目的人应该会知道我在处理数组问题时习惯于利用slice、splice这些内置函数去进行数组操作无论是其本义的截取还是插入,删除,替换……毕竟这些函数在不针对数组元素内部的操作,仅对于数组元素层面的操作确实很万金油。
这两者的作用和用法甚至是拼写都很相似,”有个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中的基本类型包括:
number - 数值类型
string - 字符串类型
boolean - 布尔类型
bigint - 大整数类型
symbol - 符号类型
undefined - 未定义类型
null - 空值类型
在C语言中,string不是基本类型 !这与JavaScript/TypeScript/ArkTS完全不同。
C语言的基本类型包括:
char - 字符类型(1字节)
int - 整型
short - 短整型
long - 长整型
float - 单精度浮点型
double - 双精度浮点型
C语言中的字符串处理:
字符串本质是字符数组
1 2 3 4 5 6 char str1[] = "Hello" ; char str2[6 ] = {'H' , 'e' , 'l' , 'l' , 'o' , '\0' };char * str3 = "Hello" ;
没有内建的字符串操作
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);
字符串以’\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 async reloadAllData (): Promise <boolean > { logger.info (`${LOG_TAG.NEWS_LIST} [两阶段刷新] 开始刷新` ) promptAction.openToast ({ message : '正在快速刷新最新数据...' , duration : 1500 }) try { const quickResult = await newsManager.quickRefreshCategories () if (quickResult.success ) { this .newsSwiperData = await newsManager.getNewsSwiperDataFromDB () this .refreshTrigger ++ logger.info (`${LOG_TAG.NEWS_LIST} [两阶段刷新] ✓ 第一阶段完成: ${quickResult.loadedCount} 个栏目` ) promptAction.openToast ({ message : `刷新成功,已更新${quickResult.loadedCount} 个栏目` , duration : 2000 }) newsManager.fullRefreshCategories ((progress ) => { logger.debug (`${LOG_TAG.NEWS_LIST} [两阶段刷新] [${progress.current} /${progress.total} ] 【${progress.category} 】完成` ) 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扳机机制
首先对于两阶段刷新,我们此前的痛点就是在于我们每一次刷新都要等后端发回全部的数据,这就会导致我们的等待时间大大增加,无提示无变化的“忙等”会极大的降低用户的体验。
这里我们在第一阶段完成后立即触发 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} 】完成` ) 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上的热力日历图了。一页的绿点真的很骄傲。
当然类似的设计其实在很多网站都有在使用,像是gitee,gitcode这类开源代码托管平台,直接就是和Github同款的这就不说了。还有更多的软件在刚进入的时候就会弹出一个签到日历,你只要签到了就会将你签到当日的日历格子给标记成一个特殊的颜色或是画个框对吧,这一招确确实实是会激起不少人的“强迫症”的。这种例子太多了我就不放图了,其实就是一种布尔值版本的简化热力日历。
我在ohpm的三方库中心仓搜索了一下发现其实并没有有现成的热力日历图,但是有现成的日历图,所以我本来像要不直接用常规日历图做得了,但是实际用下来发现三方库的日历一方面是没办法很好的去设置不同阅读量的颜色划分,另一方面是没办法很好的设置更多的可设置项。无论选哪个SDK改起来都感觉差点什么,所以我决定自己丰衣足食,搓一个GitHub热力日历图放在应用里。
当然与此同时我也考虑到GitHub模式的热力日历有一个更加严峻的问题就是在于日期的难以辨认,这对于任何人来说都不可能直接在GitHub的热力日历图上随手指出一个指定的日期,所以我们还需要做另一版的常规日历图,这样我们就可以兼顾GitHub热力日历的强成就感渲染模式,同时可以更详细具体的按照常规月份日历的日期进行查看和数据的渲染。为此我想的方案是在展开的页面中去放入一个常规日历图,与此同时要使用共享元素转场来去符合鸿蒙的丝滑UI体验。
这个功能的实现肯定是大部分的Vibe Coding实现啦,借助我们 Claude Opus 4,时至今日我们早就无需纠结于你能不能“纯手搓”出来这个功能了,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 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 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" 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 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 { 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 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 ({ 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 }) .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 () { 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 ) .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 } } ) } }
上面这是完整的代码接下来我们仅分析核心部分
整个热力日历的渲染核心可以拆分为三个关键算法:
GitHub风格热力图生成 (generateHeatmapCells) - 按时间线性排列的热力格子
月历视图生成 (generateCalendarDays) - 传统月历布局的日期格子
周列转换算法 (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 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 }
算法亮点分析:
这里有个很巧妙的设计,就是倒序遍历 的方式。为什么要从timeRange - 1倒着数到0,而不是从0正着数到timeRange - 1呢?
1 2 3 4 5 6 7 8 9 10 11 for (let i = 0 ; i < timeRange; i++) { const date = new Date (today) date.setDate (today.getDate () - (timeRange - 1 - i)) } 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 this .statsMap .clear ()this .dailyStats .forEach (stat => { if (stat.count > 0 ) { this .statsMap .set (stat.dateStr , stat.count ) } }) 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 () 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 }
这里着重解释一下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 new Date (2025 , 12 , 1 ) new Date (2025 , -1 , 1 ) new Date (2025 , 1 , 0 ) new Date (2025 , 1 , -1 ) new Date (2025 , 1 , 32 )
在月历算法中的应用:
在日历组件中,我们需要频繁计算月份的边界日期。以下三个表达式是核心:
表达式1:new Date(this.currentYear, this.currentMonth - 1, 1)
这个表达式用于获取当前月份的第一天 。
1 2 3 4 5 6 7 8 9 10 11 12 this .currentYear = 2025 this .currentMonth = 3 new Date (2025 , 3 - 1 , 1 )→ new Date (2025 , 2 , 1 ) → 2025 年3 月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 this .currentYear = 2025 this .currentMonth = 3 new Date (2025 , 3 , 0 )→ new Date (2025 , 3 , 0 ) → 4 月的第0 天 = 3 月的最后一天 → 2025 年3 月31 日 星期一
表达式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 this .currentYear = 2025 this .currentMonth = 3 new Date (2025 , 3 - 1 , 0 )→ new Date (2025 , 2 , 0 ) → 3 月的第0 天 = 2 月的最后一天 → 2025 年2 月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 ) { const firstDay = new Date (year, month - 1 , 1 ) const firstDayOfWeek = firstDay.getDay () const lastDay = new Date (year, month, 0 ) const daysInMonth = lastDay.getDate () 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) } const remainingDays = 42 - calendar.length for (let i = 1 ; i <= remainingDays; i++) { calendar.push (i) } return calendar }
为什么这样设计?
这种自动溢出处理极大简化了日期计算:
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 const daysInFeb2025 = new Date (2025 , 2 , 0 ).getDate () const daysInFeb2024 = new Date (2024 , 2 , 0 ).getDate () const lastDay = new Date (2025 , 3 , 0 ) const dayOfWeek = lastDay.getDay ()
常见陷阱:
1 2 3 4 5 6 7 8 9 10 11 12 new Date (2025 , 3 , 1 ) new Date (2025 , 2 , 1 ) const currentMonth = 3 new Date (2025 , currentMonth, 1 ) new Date (2025 , currentMonth - 1 , 1 ) new Date ('2025-03-01' ) new Date (2025 , 2 , 1 )
性能对比:
1 2 3 4 5 6 7 8 9 10 11 12 let lastDay : number if (month === 2 ) { lastDay = isLeapYear (year) ? 29 : 28 } else if ([4 ,6 ,9 ,11 ].includes (month)) { lastDay = 30 } else { lastDay = 31 } const lastDay = new Date (year, month, 0 ).getDate ()
方案B不仅代码更简洁,而且由Date对象内部处理所有边界情况(闰年、大小月),完全不会出错。
算法核心要点:
为什么是42个格子?
日历最多需要6行(某些月份第一天是周六,最后一天是周日的情况),每行7天,所以是6×7=42个格子。这样可以保证任何月份都能完整显示。
上月日期的倒推计算
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号)
这样就填充了周日、周一、周二三个格子。
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 [] = [] 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 }
算法可视化示例:
假设我们有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 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 ] }
颜色分级策略:
这里采用了5级颜色分级 ,而不是线性渐变,原因有两个:
视觉区分度更高 :离散的颜色级别比连续渐变更容易让用户快速识别数据差异
性能更好 :不需要实时计算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 [] = [] 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++) { const dateStr = this .formatDate (someDate) const count = this .getCountForDate (dateStr) } } }
性能问题:
时间复杂度:O(n)
Array.find() 需要遍历数组直到找到匹配项
最坏情况:遍历整个数组(365次比较)
平均情况:遍历一半数组(182次比较)
总体开销:O(m × n)
m = 日历格子数量(42个)
n = 统计数据数量(365个)
总比较次数:42 × 182 ≈ 7,644次
实际影响:
解决方案:使用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 class CalendarViewModel { dailyStats : DailyStat [] = [] private statsMap : Map <string , number > = new Map () async loadDailyStats ( ) { this .dailyStats = await this .fetchStatsFromDB () this .statsMap .clear () this .dailyStats .forEach (stat => { this .statsMap .set (stat.dateStr , stat.count ) }) } getCountForDate (dateStr : string ): number { return this .statsMap .get (dateStr) || 0 } generateCalendarDays ( ) { for (let i = 0 ; i < 42 ; i++) { const dateStr = this .formatDate (someDate) const count = this .getCountForDate (dateStr) } } }
性能提升:
时间复杂度:O(1)
Map使用哈希表实现
查询时间与数据量无关
每次查询只需要1次哈希计算
总体开销:O(m)
m = 日历格子数量(42个)
总查询次数:42次(每次O(1))
构建Map的成本:O(n) = 365次(只执行一次)
实际对比:
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 class SimpleMap <K, V> { private buckets : Array <Array <[K, V]>> = [] private size = 16 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 } return Math .abs (hash) % this .size } 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]) } 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' )const hash = hashFunction ('2025-03-15' ) const bucket = buckets[hash % bucketSize] for (const [key, value] of bucket) { if (key === '2025-03-15' ) return value }
完整的实现示例:
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 count : number color ?: string } class CalendarViewModel { private dailyStats : DailyStat [] = [] private statsMap : Map <string , DailyStat > = new Map () async loadDailyStats (): Promise <void > { const stats = await kvDatabase.get <DailyStat []>('daily_stats' ) || [] this .dailyStats = stats this .statsMap .clear () stats.forEach (stat => { this .statsMap .set (stat.dateStr , stat) }) logger.info (`加载了 ${stats.length} 条统计数据,构建了 ${this .statsMap.size} 个索引` ) } getStatForDate (dateStr : string ): DailyStat | null { return this .statsMap .get (dateStr) || null } getCountForDate (dateStr : string ): number { const stat = this .statsMap .get (dateStr) return stat ? stat.count : 0 } 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) 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 ( ) { 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 ) }) } 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 ('数组查询' ) 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查询' ) }
内存开销分析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const dailyStats : DailyStat [] = [] const dailyStats : DailyStat [] = [] const statsMap : Map <string , DailyStat > = new Map ()
何时使用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 { private dailyStats : DailyStat [] = [] private statsMap : Map <string , DailyStat > = new Map () getCountForDate (dateStr : string ): number { return this .statsMap .get (dateStr)?.count || 0 } getAllStats (): DailyStat [] { return this .dailyStats } 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 > { const index = this .dailyStats .findIndex (s => s.dateStr === dateStr) if (index >= 0 ) { this .dailyStats [index].count = count } else { this .dailyStats .push ({ dateStr, count }) } this .statsMap .set (dateStr, this .dailyStats [index >= 0 ? index : this .dailyStats .length - 1 ]) await kvDatabase.set ('daily_stats' , this .dailyStats ) } }
总结:
对比项
数组查询
Map查询
时间复杂度
O(n)
O(1)
42次查询耗时
~7.6ms
~0.04ms
性能提升
-
191倍
内存增加
-
+19%
代码复杂度
简单
稍复杂(需维护两个结构)
适用场景
小数据量
大数据量+频繁查询
在日历热力图这个场景中,使用Map优化是非常明智的选择,因为:
数据量大(365天)
查询频繁(每次渲染42次)
内存开销可接受(+7KB)
性能提升显著(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 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 export interface DailyReadingStats { dateStr : string count : number 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 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()函数则是对应的项数据库中写入热力历史记录数据的关键函数,我们首先回顾一下整体的数据链条逻辑。
在用户点进文章后文章NavPage首先调用的是历史记录Manager的addHistory()函数,这个函数会首先将完整的文章对象转化为历史记录对象进行存储,随后将文章ID传给updateDailyStats()函数,随后通过getDailyStatsMap()函数获取当前持久化数据的Map对象,进行去重以及不重复数据的写入。
对于持久化到数据库的部分,我们则直接使用数组内置函数Array.from来将Map的内置函数values()返回的迭代器转化为数组,随后将数组转化为JSON字符串进行存储。
迭代器概念简介 在ArkTS以及其他大部分语言中,迭代器是一种设计模式,它提供了一种方法来访问一个容器对象中的各个元素,而不需要暴露该对象的内部表示。常见的迭代器包括数组的内置方法如forEach、map、filter等,以及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 ];let doubled = numbers.map (num => num * 2 );console .log ('Map result:' , doubled); let evens = numbers.filter (num => num % 2 === 0 );console .log ('Filter result:' , evens); numbers.forEach ((num, index ) => { console .log (`Index ${index} : ${num} ` ); }); let sum = numbers.reduce ((acc, curr ) => acc + curr, 0 );console .log ('Reduce result:' , sum);
二、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 let userMap = new Map <string , number >();userMap.set ('Alice' , 25 ); userMap.set ('Bob' , 30 ); userMap.set ('Charlie' , 35 ); for (let [key, value] of userMap) { console .log (`${key} is ${value} years old` ); } console .log ('Keys:' , Array .from (userMap.keys ())); console .log ('Values:' , Array .from (userMap.values ())); console .log ('Entries:' , Array .from (userMap.entries ())); let uniqueNumbers = new Set ([1 , 2 , 3 , 2 , 1 ]);for (let num of uniqueNumbers) { console .log (num); }
三、自定义迭代器
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); } console .log ([...range]);
代码解释:
上面的代码演示了TypeScript中生成器函数和自定义迭代器的用法:
生成器函数语法 (function*) :*[Symbol.iterator]() 中的 * 表示这是一个生成器函数,可以使用 yield 关键字暂停和恢复函数执行。
Symbol.iterator 接口 :这是ES6标准中定义的迭代器协议,当对象需要被迭代时(如使用 for...of),JavaScript引擎会自动调用这个方法。
yield 关键字 :类似 return 但不会终止函数,每次遇到 yield 会暂停函数并将值返回给调用者,下次迭代时从暂停处继续执行。
执行流程 :当使用 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 @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 (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 async getHistoryByDate (dateStr : string ): Promise <HistoryItem []> { const historyList = await this .getHistoryList () return historyList.filter (item => item.dateStr === dateStr) } export interface HistoryItem { article : NewsArticle timestamp : number 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 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 { 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 } } 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进行检查发现问题的原因如下:
@Local 装饰的 Map 内部变化(set/delete)不会触发 UI 更新
ForEach 的 key 生成逻辑未包含能反映数据变化的标识
主页面和全屏模式的 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 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 @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 { 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 { const timeRange = this .userConfig .heatmapTimeRange const newStatsMap = await historyManager.getDailyCountMapInRange (365 ) 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 生成逻辑
包含 count 和 color 确保数据变化时更新
这里着重讲解一下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%
跟手弹窗的实现 起因 在开发新闻笔记功能的过程中,我想为用户提供更多可选的笔记弹窗选项,如果统一为全屏弹窗很可能会覆盖哪些想要查看原文的用户。同时这作为一个具备一多能力的产品,在大屏场景下本就拥有远多于手机屏幕空间的想象力和创造能力,所以我在设置项中设置了多种不同的弹窗选项。
以上这四种弹窗都直接使用官方提供的半模态或是自定义弹窗都很好实现,但是唯独我最想要的可以自由拖拽的小弹窗我查遍文档都没有看到相关接口。
我看到的唯一个比较接近的描述是在半模态转场中的SheetType样式枚举类,其中有一个枚举值是POPUP,其官方中文解释是“跟手弹窗。跟手弹窗面板不支持跟手滑动,下滑面板不关闭。”
?跟手弹窗 不支持跟手滑动 ?那你为啥叫“跟手”弹窗?
实际尝试了一下发现确确实实没办法进行任何的拖拽或者是进行更多的自定义。
当然关于这一点我也是进一步的去问了问AI,它给出的回复是:
原来是“历史遗留问题”,好吧那看来就是没有应用内可自由移动的弹窗接口了,那就手搓!!!
实现的接口选择 其实思路也很好理解,就是监测拖拽,然后根据拖拽的位置来去改变组件的位置。随后我就去寻找相关的接口。
我第一个想到的就是组件拖拽事件,也是最符合直觉的搜索结果,我在大致浏览其接口后就优先去查看了其给出的示例代码,发现其主要的示例代码方向都是对于数据的传递,像是图标、文字、文件的拖拽,与我的目的并不相符,虽然可以实现效果,但没准有更简单易行的接口。
哦!手势,对哦,这类操作确实可以被称为是一种“手势”
太对了,就它了。
代码分析 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 clampedValue = Math .max (min, Math .min (max, value)) if (value < min) { clampedValue = min } else if (value > max) { clampedValue = max } else { clampedValue = value }
这个公式的巧妙之处在于用两次比较完成三种情况的判断 :
Math.min(max, value) - 确保不超过最大值
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 { 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)) }
边界计算的设计思路 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 const windowWidth = 393 * 0.9 = 353. 7px const windowHeight = 852 * 0.75 = 639px minX = -353.7 + 60 = -293. 7px maxX = 393 - 60 = 333px minY = 0px maxY = 852 - 100 = 752px
场景 2:平板端拖拽
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const windowWidth = 1024 * 0.5 = 512px const windowHeight = 1366 * 0.75 = 1024. 5pxminX = -512 + 60 = -452px maxX = 1024 - 60 = 964px minY = 0px maxY = 1366 - 100 = 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 function clamp1 (x : number , min : number , max : number ): number { if (x < min) return min if (x > max) return max return x } function clamp2 (x : number , min : number , max : number ): number { return Math .max (min, Math .min (max, x)) } function clamp3 (x : number , min : number , max : number ): number { return x < min ? min : (x > max ? max : x) }
为什么 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 + edgeMarginconst maxX = this .screenWidth - edgeMarginconst 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 函数虽然只有短短几行代码,但体现了多个重要的设计原则:
边界限制算法 :使用 Math.max + Math.min 实现高效的值域限制
响应式设计 :根据设备类型(手机/平板)动态调整弹窗尺寸
用户体验 :保留最小可见区域(60px),确保用户始终能操作弹窗
安全边距 :底部预留 100px,避免遮挡系统 UI
性能优化 :使用简洁的数学运算代替复杂的条件判断
这个函数是实现可拖拽浮动窗口的核心组件,确保了无论用户如何拖拽,弹窗都不会完全离开屏幕,始终保持可操作性。
哦!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
边界限制问题 在上文优化过程中发现在当前限制条件仍旧存在问题。向右和向下的限制并不好,向左和向上倒是没事。
为了更有效的限制,我设置了更多的参数来去限制可移动范围。
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 const bottomVisibleRatio = 0.5 this .bounds = { minX : -windowWidth + leftMargin, maxX : this .screenWidth - windowWidth * rightVisibleRatio, minY : Math .max (0 , this .safeAreaTop ), 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
使用可见比例 + 安全区域
核心优化点:
分离计算逻辑 :将边界计算独立为 calculateBounds() 方法
边界缓存 :计算结果存入 bounds,避免重复计算
可见比例 :右侧和底部使用百分比而非固定像素
手机右侧:40% 可见
平板右侧:90% 可见
底部:50% 可见
响应式设计 :根据设备类型动态调整参数
安全区域适配 :考虑系统 UI 高度
问题修复 热力日历的设置与渲染更新不同步的问题 问题现象 我发现这个问题是在我更新我当前项目仓库的Github Readme文件时出现的。发现在为了演示设置项变动的效果时出现了在Tab页的渲染问题,我也及时截图记录了下来。
现象一:在修改了热力日历的色系设置项后出现了Tab页的颜色仅局部更新的情况。 具体来说,是Tab页的颜色图例是正常的随设置项及时更新,但是日历的每一个单元格的颜色并没有被及时的重绘,我怀疑是扳机机制的问题。
与此同时,点开详情页后日历的颜色就是正常的了,这也不难理解,因为详情页的组件本质是一个bindContentCover全模态,其渲染肯定是在设置项修改完成之后的,但是Tab页面除非通过扳机机制或是状态变量更新否则是不会去主动重绘的。
现象二:在修改设置项中记录日期长度之后出现了局部的重复渲染以及渲染缺失的情况。 触发的时候我是从180天修改为90天时出现了如下图所示的情况。
仔细观察发现并不是渲染缺失,实质是渲染了两次,中间的空缺是第一个完整矩阵剩余的本周未来空白天数。所以说对于格子的计算或是渲染时的循环条件有问题。
同样的,这个现象也是点开详情页后会消失,但返回Tab页后依旧存在。
通过平板我们可以看的更加清晰。
每切换一次设置项,Tab页的单元格是被新计算出的单元格矩阵叠加在后面,这说明我们重新渲染时并没有把上一次的矩阵数据数组清空而是直接push的新数据。
修复 我最近对于VibeCoding有了一个新的认知,在对于没有上下文历史记录的情况下,我们需要做的是先不指明我们的目的,先让模型无目的全面阅读目标板块的逻辑,这样有利于让模型先从全局视角大致把握项目,虽然会消耗一部分token,但这还是很值得的。因为这样会省下很多很多对于项目不了解导致错误的生成一些本就存在的类、接口,没有按现有数据结构去调用一些接口。
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也难以完全把握扳机系统的全部细节,但这仅仅是小项目的一个模块的问题,这也说明了当前模型能力的局限性,我们程序员仍然需要有对于项目整体运作逻辑的理解和把握。同时也说明了“测试”这个环节的重要性。