第8篇|HSP 拆包后资源找不到:把页面、资源和路由边界一次迁干净
第8篇HSP 拆包后资源找不到把页面、资源和路由边界一次迁干净摘要HarmonyOS 项目做 HSP 拆包时最容易出问题的不是 ArkTS 页面搬不过去而是页面搬走了图片、字符串、路由入口、模块依赖和对外导出还留在旧边界里。结果通常表现为构建阶段资源引用报错、真机点击入口白屏、返回路径异常或者 feature 模块反过来依赖entry。这类问题不能靠继续改路径硬撑应该把页面归属、资源归属、路由归属和依赖方向一起校正。我在一个课程类应用里拆过一次学习模块课程详情页从entry迁到learning_featureHSP页面文件移动很快问题却连续出现。先是$r(app.media.course_empty)找不到接着首页课程卡片点击后进入空白页最后发现播放器页还能通过旧入口打开。真正的根因不是某一个资源名写错而是拆包时只移动了页面文件没有同步重建模块边界。这篇文章解决四个实际问题拆 HSP 前怎样判断页面和资源属于哪个模块。页面迁移后资源、路由、依赖和导出为什么要一起改。entry、feature HSP、common HAR 的职责怎样收口。拆完后如何用构建和真机入口确认没有留下旧边界。外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传适用环境和版本边界本文讨论的是 Stage 模型下的 ArkTS 工程重点放在Navigation、NavPathStack、router_map.json、模块资源目录和 HSP/HAR 依赖边界。示例适合已经使用 Navigation 或正在从旧 Router 迁移到 Navigation 的项目。内容适用边界页面容器NavigationNavDestination页面入口推荐使用系统路由表router_map.json模块形态entry作为宿主业务模块使用 HSP公共能力使用 HAR资源引用页面优先引用所属模块的resources验证环境以模拟器或真机为准跨包路由不要只看 Previewer这里要先说明一个边界不同项目的模块命名、包名和路由注册方式可能不同文章里的代码用于表达工程组织方法不是要求所有项目照抄文件名。真正迁移时要以自己工程的module.json5、oh-package.json5、router_map.json和资源目录为准。先复盘故障链路这次问题一开始看起来只是“资源路径不对”。但如果只盯着报错位置很容易修一个漏一个。阶段表现暴露的真实问题预期结果编译课程空态图找不到页面迁到 HSP专属资源还在entryHSP 内能解析自己的图片和字符串运行首页点击课程卡片后白屏路由入口仍指向旧页面路径入口按注册名进入学习详情页返回播放器返回到错误页面跨模块导航没有统一收口返回详情页再回宿主首页维护feature 反向引用宿主工具公共能力没有下沉到 HARfeature 只依赖公共 HAR不依赖entry这张表的价值在于把“路径错误”拆成四类边界错误。后续修复时就不会只改图片引用而是把页面、资源、路由和依赖方向一起检查。拆包前先定归属规则HSP 拆分不是按文件夹好不好看决定而是按运行职责决定。迁移前我会先给页面和资源做一轮归属判断。对象判断问题建议归属课程详情页是否只服务学习业务learning_featureHSP课程空态图删除学习页时是否一起删除learning_featureresources课程摘要模型是否被首页、搜索、学习页复用common_baseHAR应用图标是否属于应用级入口entry或 AppScope全局路由装配是否负责接入多个 featureentry一个实用判断是如果资源的生命周期跟页面一致它就应该跟页面走如果能力被多个业务域稳定复用才适合下沉到 HAR。不要因为“以后可能会复用”提前把业务资源放进公共层否则公共模块会很快变成杂物间。entry 只保留宿主职责拆包后entry更适合做宿主模块启动 Ability、窗口创建、全局导航装配、应用级配置和少量壳层页面。它可以依赖 feature HSP 和 common HAR但业务 HSP 不应该反向依赖entry。// entry/oh-package.json5 { dependencies: { learning_feature: file:../learning_feature, common_base: file:../common_base } }这段配置表达的是依赖方向宿主负责把业务模块接进来业务模块不反过来读取宿主页面或资源。只要 feature 里出现entry/src/main/...这种引用基本就说明拆分边界还没干净。这段配置解决的是“谁依赖谁”的问题不解决“页面怎样被打开”的问题。页面入口还需要路由表或自定义注册器单独处理不能把依赖配置当成路由配置。页面和专属资源一起迁移课程详情页迁进learning_feature后它自己的空态图、页面标题、业务文案也要同步进入这个 HSP。learning_feature/ src/main/ets/pages/CourseDetailPage.ets src/main/ets/pages/CoursePlayerPage.ets src/main/resources/base/media/course_empty.png src/main/resources/base/media/player_loading.png src/main/resources/base/element/string.json src/main/resources/base/profile/router_map.json这样做不是为了目录整齐而是为了让页面的运行依赖留在同一个模块里。以后删除学习模块时专属资源能跟着删排查资源缺失时也不需要在entry和多个公共模块之间来回找。资源迁移后页面里继续使用模块内资源引用// learning_feature/src/main/ets/pages/CourseDetailPage.etsComponentstruct CourseEmptyState{build(){Column({space:12}){Image($r(app.media.course_empty)).width(168).height(168)Text($r(app.string.course_empty_title)).fontSize(18).fontWeight(FontWeight.Medium)}.width(100%).padding({top:32,bottom:32})}}这段代码的边界很明确空态组件属于学习模块图片和字符串也属于学习模块。页面不需要知道entry的资源目录也不应该依赖宿主里的临时图片。还有一个容易忽略的点HAR/HSP 中的$r(app.media.xxx)会解析到当前模块自己的资源。不要在多个模块里重复定义同名资源尤其是同名颜色、图片和字符串。打包时即使出现冲突提示运行时最终取到哪个定义也可能让人难以判断。更稳的做法是一个资源 key 只有一个明确拥有者。公共能力下沉到 HAR有些代码不能跟页面一起放进 HSP比如课程摘要模型、进度格式化、统一结果类型。这些能力被多个模块使用更适合放进 common HAR。// common_base/src/main/ets/model/CourseSummary.etsexportinterfaceCourseSummary{id:stringtitle:stringteacherName:stringprogress:number}exportfunctionnormalizeCourseProgress(progress:number):number{if(progress0){return0}if(progress100){return100}returnprogress}这段代码只处理稳定的课程摘要模型和进度边界不接触页面生命周期也不引用业务图片。它预防的是“为了让 HSP 编过把公共模型随手放进 feature最后多个模块互相引用”的问题。首页、搜索页、学习模块都可以复用CourseSummary但课程详情页的 UI 状态仍然留在学习模块内部。路由入口用注册名收口页面迁移后白屏经常不是页面本身坏了而是入口还在找旧路径。按钮、卡片、通知、快捷入口如果各写各的路径拆包时一定会漏。对 Navigation 项目来说更稳的方式是让业务 HSP 提供自己的系统路由表// learning_feature/src/main/resources/base/profile/router_map.json{routerMap:[{name:LearningCourseDetail,pageSourceFile:src/main/ets/pages/CourseDetailPage.ets,buildFunction:CourseDetailBuilder,data:{owner:learning_feature}},{name:LearningCoursePlayer,pageSourceFile:src/main/ets/pages/CoursePlayerPage.ets,buildFunction:CoursePlayerBuilder}]}路由表解决的是“跨模块页面用什么名字进入”的问题。name应该稳定、可读不要直接暴露旧目录路径。data里可以放一些轻量元信息例如页面归属、是否需要登录、横竖屏要求但不要把业务数据塞进路由配置。模块还需要在module.json5中声明路由表// learning_feature/src/main/module.json5 { module: { name: learning_feature, type: shared, routerMap: $profile:router_map } }这段配置让系统知道该模块有哪些可被 Navigation 按名称打开的页面。实际工程里还会有deviceTypes、abilities、extensionAbilities等配置项文章这里只保留和路由相关的关键字段避免示例干扰主线。页面 Builder 要和路由表对应router_map.json里写了buildFunction页面文件就要导出同名 Builder。否则路由名能找到页面仍然创建不出来。// learning_feature/src/main/ets/pages/CourseDetailPage.etsBuilderexportfunctionCourseDetailBuilder(name:string,param:Object){CourseDetailPage({courseId:readCourseId(param),source:readEntrySource(param)})}functionreadCourseId(param:Object):string{constpayloadparamasRecordstring,Objectconstvaluepayload[courseId]returntypeofvaluestring?value:}functionreadEntrySource(param:Object):string{constpayloadparamasRecordstring,Objectconstvaluepayload[from]returntypeofvaluestring?value:unknown}这里没有直接把param强转成业务对象是因为跨模块入口可能来自首页、搜索、通知或最近学习列表。参数一旦缺字段页面至少应该能显示错误态或返回入口而不是在 Builder 阶段就崩掉。这个写法把“参数可信度”控制在页面入口处后面的页面状态就更容易维护。宿主入口只传业务参数entry里的首页卡片不应该拼页面文件路径也不应该直接 import 业务页面组件。它只需要按注册名进入目标页并传入稳定的业务参数。// entry/src/main/ets/navigation/AppRoutes.etsexportconstAppRoutes{home:Home,courseDetail:LearningCourseDetail,coursePlayer:LearningCoursePlayer}asconstexporttypeAppRouteNamekeyoftypeofAppRoutes// entry/src/main/ets/pages/HomePage.etsprivateopenCourseDetail(courseId:string):void{this.navStack.pushPathByName(AppRoutes.courseDetail,{courseId,from:home_card})}这样写的好处是入口和页面文件位置解耦。以后学习模块内部把CourseDetailPage.ets改名为LearningDetailPage.ets只要路由表和 Builder 对应关系保持稳定首页入口就不用跟着改。预期运行结果很明确点击课程卡片后Navigation 按LearningCourseDetail找到学习模块的路由配置创建课程详情页详情页拿到courseId后加载课程数据。如果路由名不存在问题会集中暴露在路由注册和模块依赖上而不是散落在每个按钮回调里。feature 对外导出要收窄拆成 HSP 后不要把内部页面、服务、缓存、组件全都导出去。宿主通常只需要路由名、安装器或少量入口协议。// learning_feature/src/main/ets/index.etsexport{LearningRoutes}from./navigation/LearningRoutesexport{LearningFeatureInstaller}from./navigation/LearningFeatureInstaller导出越多模块边界越脆。比如entry直接 importCourseDetailPage后续学习模块内部重构页面结构就会牵动宿主。更合理的边界是宿主知道怎么安装学习模块、怎么进入学习模块但不知道学习模块内部有哪些页面组件和私有服务。我会给 feature HSP 设一个简单规则路由常量可以导出。注册器或安装器可以导出。私有页面组件不导出。业务内部服务默认不导出。真正跨模块复用的模型先考虑进 HAR。迁移后做一次反向搜索页面能打开不代表旧边界已经清干净。迁移完成后我会做几类反向搜索。rgentry/src/main/resources|pages/CourseDetailPage|course_emptylearning_feature entry common_base rgfrom ../entry|from ../../entry|bundle:entrylearning_feature rgCoursePlayerPage|CourseDetailPageentry/src/main/ets rgLearningCourseDetail|LearningCoursePlayerentry learning_feature第一条看资源和旧页面路径是否残留第二条看 feature 是否反向依赖宿主第三条看宿主是否直接引用业务内部页面第四条确认新路由名是否集中可追踪。搜出来的结果不一定全是错误但每一处都要解释得通。我还会把结果分成三类处理搜索结果处理方式feature 引用entry资源把专属资源迁入 featureentry 直接引用业务页面文件改为路由名或注册器多模块复用同一模型下沉到 common HAR新旧路由名并存删除旧入口或保留明确兼容层这一步很快但经常能抓到“构建没报、运行偶现”的旧路径。构建通过后还要走真机入口HSP 拆包不能只看编译结果。构建能过只说明依赖大体能被编译器接受真实入口还要验证资源加载、参数传递、返回路径和清空数据后的首次进入。.\hvigorw.bat assembleHap--no-daemon构建后我会按下面的链路复查首页课程卡片进入课程详情页。详情页空态图、标题文案、加载态资源正常显示。详情页进入播放器页播放器资源正常加载。播放器返回详情页再返回宿主首页。从通知、搜索结果或最近学习入口进入同一课程。清空应用数据后首次进入学习模块确认没有依赖旧缓存。如果只有首页入口能进其他入口白屏说明路由还没统一如果页面能进但图片缺失说明资源归属没迁干净如果返回路径乱跳说明跨模块导航没有收口。这里还有一个实践提醒跨包路由以模拟器或真机结果为准不要只看 Previewer。Previewer 适合快速看单页面布局但它不能覆盖 HSP 安装、系统路由表加载、真实资源打包和页面栈返回这些链路。风险提示和回退方案拆 HSP 时最怕一次改太多出了问题不知道该回哪一步。我的做法是把迁移动作拆成可回退的小段。风险触发场景回退方式新路由打不开router_map.json或 Builder 不匹配先保留旧入口修正路由注册后再删除资源名冲突多模块定义同名图片或颜色给资源重新命名确认唯一 ownerfeature 依赖宿主为了快速编译直接 importentry把共享类型或工具下沉到 HAR返回路径变化页面从旧 Router 迁到 Navigation先固定主链路再处理边缘入口真机和本地表现不同只在预览环境看过页面用模拟器或真机重新走入口这张表不是为了保守而是为了让迁移有节奏。先保证主入口能进、能返回、资源能显示再清理兼容代码和旧路径。一次性把路由、资源、状态和页面结构全改掉排查成本会高很多。给模块 README 留一段边界说明多人协作项目里拆完模块最好补一段很短的边界说明。它不需要写成长文但要让后续开发知道东西该往哪里放。learning_feature owns: - learning pages - learning-specific resources - course detail and player route entries common_base owns: - shared models - shared formatters - shared result types entry owns: - app startup - global navigation assembly - app-level resources这段说明能减少后续回潮新同事加一个学习页空态时会优先放到learning_feature加一个跨业务课程摘要模型时才会考虑common_base。边界写清楚比靠口头约定稳定。常见问题和处理方式现象常见原因处理方式编译提示资源找不到页面进了 HSP资源还在entry专属资源跟页面迁入 feature点击入口白屏路由表仍指向旧页面路径统一跨模块路由名和注册feature 依赖entry公共模型或工具放错层把跨模块能力下沉到 HAR构建过了但真机图片丢失只改代码没迁资源检查 HSP 的 resources 目录首页能进通知进不去多个入口散写路径所有入口引用同一份路由名路由名存在但页面不显示Builder 名称或导出不匹配对齐buildFunction和导出的 Builder返回路径乱跳跨模块页面没有统一导航栈用宿主导航层收口进入和返回复查清单发布前我会按这份清单收尾entry只保留宿主入口、全局导航和应用级资源。feature HSP 内包含自己的页面、专属图片、专属字符串和私有服务。common HAR 只放跨模块复用的模型、工具和结果类型。业务 HSP 声明自己的router_map.json并在module.json5中引用。页面 Builder 名称和路由表里的buildFunction对齐。页面入口不散写旧路径统一走路由名或注册器。feature 没有反向引用entry的页面、资源和服务。构建命令执行成功。真机走过首页、通知、搜索等所有进入学习页的入口。清空应用数据后再次进入资源和路由仍然正常。这次问题给我的复盘我后来复盘这次拆包真正的问题不是“我少搬了一张图”而是我把拆包当成了文件迁移。文件迁移只关心代码在哪个目录模块迁移还要关心谁拥有页面、谁拥有资源、谁注册路由、谁暴露能力、谁负责验证入口。如果重新做一次我会按这个顺序来先写模块归属表确认页面、资源、模型和路由分别归谁。再迁页面和专属资源避免页面先跑到 HSP、资源还留在entry。接着注册路由表和 Builder用稳定路由名替换旧路径。然后反向搜索旧路径和反向依赖。最后用真机走主入口和边缘入口。这个顺序看起来多了几步但它能减少返工。拆包不是把项目切成更多目录而是让每个模块知道自己该负责什么、不该依赖什么。小结拆包要迁移边界不只是迁移文件HSP/HAR 拆分要稳定关键不是把页面文件拖到新目录而是把页面归属、资源归属、路由入口、依赖方向和导出边界一起迁移。entry做宿主feature HSP 承载业务页面和专属资源common HAR 承载公共模型和工具。拆完后既跑构建也走真实入口这样拆包才是在整理工程结构而不是把旧路径问题搬到新模块里。