【私房菜集 HarmonyOS ArkTS 实战系列 03】内容资产工程:把 514 道菜装进 rawfile 本地数据源 【私房菜集 HarmonyOS ArkTS 实战系列 03】内容资产工程把 514 道菜装进 rawfile 本地数据源前两篇已经完成工程骨架和主 Tab 宿主的拆解应用能启动首页、探索、收藏、我的四个入口也已经落在同一个 ArkUI 宿主里。本篇进入内容资产层重点拆解 514 道内置菜谱如何从rawfile/dishes/dishes.json读取、解码、过滤再映射成页面可以直接消费的Recipe、RecipeSummary和Category。一、内容资产为什么要单独成工程菜谱应用最容易被写成“页面里直接放几条假数据”的形态。这样做在 Demo 阶段很快但进入真实内容规模后会马上暴露问题首页推荐、探索列表、分类页和详情页都需要同一份菜谱数据。每道菜不只是标题还包含分类、描述、用料、步骤、技巧、难度、时长和图片。图片路径必须能被 ArkUI 正确渲染不能停留在普通文件路径。搜索、推荐、收藏和最近浏览都依赖稳定的菜谱 ID。内容缺图时列表和详情页需要有明确的兜底策略。因此“私房菜集”没有把菜谱散落在页面状态里而是把内置内容放进resources/rawfile/dishes/dishes.json再用RecipeDataSource做统一读取与映射。页面拿到的是领域模型不直接面对原始 JSON。二、源码对象总览源码对象作用entry/src/main/resources/rawfile/dishes/dishes.json内置菜谱资产文件包含 15 个分类、514 道菜和图片路径。entry/src/main/ets/services/RecipeDataSource.etsrawfile 读取、JSON 解码、菜谱过滤、模型映射的入口。entry/src/main/ets/models/RecipeModels.ets定义Recipe、RecipeSummary、Category、RecipeStep等领域模型。entry/src/main/ets/services/RecipeService.ets在数据源之上组合首页、探索、分类、详情等业务数据。entry/src/main/ets/pages/Index.ets探索页消费ExploreData渲染分类宫格和菜品列表。本篇重点在前三个对象RecipeService和Index.ets作为验证链路数据源是否可用最终要看探索页是否能渲染分类与菜品列表。三、rawfile 里的内容规模当前dishes.json的数据规模如下指标数量分类数量15菜品数量514带封面的菜品514图片资源记录601前几类数据分布如下分类菜品数家常菜134地方特色菜65小吃夜宵45面点48汤羹炖品43凉菜44这种规模已经不适合放在 ArkUI 页面代码里。页面只应该关心“要展示什么”不应该关心 JSON 字段怎么解析、图片路径怎么拼、难度文案怎么规整。四、原始 JSON 与领域模型分开RecipeDataSource.ets先定义 rawfile 的原始结构interface RawImage { rawfilePath: string; } interface RawIngredient { name: string; amount: string; treatment?: string; } interface RawDish { id: string; categoryId: string; name: string; description: string; servings: number; prepTimeMinutes: number; cookTimeMinutes: number; difficulty: string; ingredients: RawIngredient[]; seasonings?: RawIngredient[]; steps: string[]; tips?: string[]; categoryName: string; images?: RawImage[]; } interface RawCategory { id: string; name: string; description: string; } interface RawDishFile { categories: RawCategory[]; dishes: RawDish[]; }这里没有直接把RawDish暴露给页面。原因很简单原始 JSON 是内容资产格式页面需要的是应用领域模型。两者字段相似但职责不同。比如原始菜谱里叫name页面领域模型里叫title原始图片字段是rawfilePath页面里需要的是resource://RAWFILE/...原始难度可能是“入门”“进阶”页面需要稳定的simple / medium / hard类型。领域模型定义在RecipeModels.ets中export type RecipeDifficulty simple | medium | hard; export type RecipeSource builtIn | userCreated; export type RecipeSortKey popular | latest | shortTime; export interface Recipe { id: string; title: string; description: string; categoryId: string; categoryName: string; coverImages: string[]; durationMinutes: number; difficulty: RecipeDifficulty; difficultyText: string; serving: string; ingredients: Ingredient[]; steps: RecipeStep[]; tips: string[]; viewCount: number; source: RecipeSource; tags: string[]; createdAt: number; updatedAt: number; }这个模型更适合被首页、列表、详情、搜索和收藏复用。五、读取 rawfile同步读取统一解码数据源入口是ensureLoaded()。它只在首次访问时读取 rawfile后续直接复用内存里的categories和recipesprivate ensureLoaded(): void { if (this.isLoaded) { return; } if (!this.context) { return; } const rawFile this.context.resourceManager.getRawFileContentSync(dishes/dishes.json); const jsonText util.TextDecoder.create(utf-8, { ignoreBOM: true }).decodeWithStream(rawFile); const parsed JSON.parse(jsonText) as RawDishFile; const visibleDishes parsed.dishes.filter((item: RawDish) this.hasCoverImage(item)); this.categories parsed.categories.map((item: RawCategory, index: number): Category { return { id: item.id, name: item.name, description: item.description, icon: this.categoryIcon(index), sort: index }; }).filter((category: Category) visibleDishes.some((dish: RawDish) dish.categoryId category.id)); this.recipes visibleDishes.map((item: RawDish, index: number): Recipe this.mapDish(item, index)); this.isLoaded true; }这段代码里有几个关键取舍。第一读取入口集中。页面不会直接调用resourceManager.getRawFileContentSync避免每个页面都写一套 JSON 解析逻辑。第二使用TextDecoder明确按 UTF-8 解码。菜谱里大量中文标题、描述、步骤和用料如果解码不稳定页面最终会出现乱码。第三先过滤无封面的菜品。探索页和首页都高度依赖图片如果把无图数据直接放进列表页面就会出现不完整内容。当前 514 道菜都带封面过滤逻辑仍然保留后续扩充数据时可以继续兜底。第四分类也跟着可见菜品过滤。没有可展示菜品的分类不会出现在探索页避免分类入口点进去后为空。六、图片路径从 rawfilePath 到 ArkUI 可渲染资源原始 JSON 中的图片路径类似{ rawfilePath: dishes/images/tomato_scrambled_eggs-1.jpg }页面组件不能直接消费这个路径数据源会统一转换成 ArkUI 可识别的 rawfile 资源地址private mapImages(images: RawImage[]): string[] { return images .filter((item: RawImage) item.rawfilePath item.rawfilePath.length 0) .map((item: RawImage) resource://RAWFILE/${item.rawfilePath}); }这一步非常关键。后续RecipeImage、RecipeGridCard、RecipeHeroCard和RecipeListItem都只接收coverImage或coverImages不需要再关心资源路径格式。步骤图也复用了同一套图片映射private pickStepImage(item: RawDish, stepIndex: number): string | undefined { const images this.mapImages(item.images ?? []); if (images.length stepIndex 1) { return images[stepIndex 1]; } return undefined; }这里把第 1 张图留给封面后续图片才按步骤分配。不是每个步骤都强制有图因此返回类型是string | undefined详情页可以按有图/无图分别渲染。七、从 RawDish 映射成 Recipe真正完成资产工程化的是mapDish()private mapDish(item: RawDish, index: number): Recipe { const duration Math.max(1, (item.prepTimeMinutes ?? 0) (item.cookTimeMinutes ?? 0)); const ingredients this.mapIngredients(item.ingredients ?? [], item.seasonings ?? []); const steps: RecipeStep[] (item.steps ?? []).map((content: string, stepIndex: number): RecipeStep { return { id: ${item.id}_step_${stepIndex 1}, order: stepIndex 1, content, image: this.pickStepImage(item, stepIndex) }; }); return { id: item.id, title: item.name, description: item.description, categoryId: item.categoryId, categoryName: item.categoryName, coverImages: this.mapImages(item.images ?? []), durationMinutes: duration, difficulty: this.mapDifficulty(item.difficulty), difficultyText: this.normalizeDifficultyText(item.difficulty), serving: ${item.servings ?? 2}人份, ingredients, steps, tips: item.tips ?? [], viewCount: 1200 ((index * 137) % 16000), source: builtIn, tags: [item.categoryName, this.normalizeDifficultyText(item.difficulty), ${duration}分钟], createdAt: 1763748000000 index, updatedAt: 1763748000000 index }; }这段映射解决了几个页面层不该反复处理的问题总时长由准备时间和烹饪时间相加并使用Math.max(1, ...)兜底。主料和调料合并成统一的Ingredient[]详情页只需要渲染一个用料列表。步骤生成稳定 ID便于列表渲染和后续扩展。难度同时保留枚举值和展示文案。viewCount用稳定算法生成展示热度首页热门排序可以直接使用。tags由分类、难度、时长组成后续搜索和列表展示都能复用。页面不再需要知道prepTimeMinutes和cookTimeMinutes怎么合并也不需要知道seasonings要不要单独展示。数据源先把内容变成统一领域对象页面只做展示。八、难度与摘要模型的降维难度映射保留了两层表达private mapDifficulty(value: string): RecipeDifficulty { if (value 进阶 || value 困难) { return hard; } if (value 中等) { return medium; } return simple; } private normalizeDifficultyText(value: string): string { if (value 入门) { return 简单; } if (value 进阶) { return 困难; } return value || 简单; }枚举值给业务判断使用展示文案给 UI 使用。这样后续如果要按难度过滤使用simple / medium / hard如果只是在卡片上显示用difficultyText。列表页并不需要完整Recipe所以数据源提供toSummary()toSummary(recipe: Recipe): RecipeSummary { return { id: recipe.id, title: recipe.title, description: recipe.description, categoryId: recipe.categoryId, categoryName: recipe.categoryName, coverImage: recipe.coverImages[0] ?? , durationMinutes: recipe.durationMinutes, difficultyText: recipe.difficultyText, viewCount: recipe.viewCount, tags: recipe.tags }; }RecipeSummary是首页、探索、收藏和搜索结果的共同语言。它只保留列表展示需要的字段避免大列表中携带完整步骤、用料和技巧。九、探索页如何消费这份数据RecipeService.getExploreData()把数据源输出组合成探索页需要的结构async getExploreData(sortKey: RecipeSortKey popular): PromiseExploreData { const recipes this.getAllRecipes().map((item: Recipe): RecipeSummary recipeDataSource.toSummary(item)); return { categories: recipeDataSource.loadCategories().slice(0, 9), hotKeywords: [红烧肉, 番茄炒蛋, 鸡翅, 汤面, 夜宵, 低脂], recipes: sortKey popular ? this.pickRandomSummaries(recipes, 16) : this.sortSummaries(recipes, sortKey).slice(0, 16) }; }这里的探索页数据有三部分categories最多展示 9 个分类入口。hotKeywords给搜索模块提供常用关键词。recipes默认给出 16 道菜品列表支持按热门、最新、短时间排序。探索页的分类宫格和全部菜品列表就来自这份ExploreDataSectionHeader({ title: 分类, actionText: 全部, onAction: () this.openAllCategories() }) Grid() { ForEach(this.exploreData.categories, (category: Category) { GridItem() { Column({ space: 6 }) { Text(category.icon) .fontSize(16) .fontWeight(FontWeight.Bold) .fontColor($r(app.color.primary_orange)) .width(42) .height(42) .textAlign(TextAlign.Center) .backgroundColor($r(app.color.primary_orange_light)) .borderRadius(21) Text(category.name) .fontSize(12) .fontColor($r(app.color.text_primary)) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .onClick(() this.openCategory(category.id)) } }, (category: Category) category.id) }截图中看到的“家常菜、地方特色菜、小吃夜宵、面点”等入口背后不是硬编码的页面按钮而是 rawfile 分类经过RecipeDataSource映射后的结果。菜品列表同理ForEach(this.exploreData.recipes, (recipe: RecipeSummary) { RecipeListItem({ recipe, onRecipeClick: (id: string) this.openDetail(id) }) }, (recipe: RecipeSummary) recipe.id)RecipeListItem只拿RecipeSummary展示图片、标题、描述、时长和难度。详情页需要完整数据时再通过recipeId回查Recipe。十、运行与验收本篇截图来自本机 HarmonyOS 模拟器真实运行页面操作路径如下hdc shell aa start -a EntryAbility -b com.lesson.myapplicationsfcj hdc shell uitest uiInput click 502 2635 hdc shell snapshot_display -f /data/local/tmp/sfcj_03_explore_data.jpeg hdc file recv /data/local/tmp/sfcj_03_explore_data.jpeg .\SFCJ\screenshots\03_explore_data_raw.jpeg验收重点可以按下面清单检查探索页能正常显示搜索入口、分类宫格和全部菜品列表。分类数量来自RecipeDataSource.loadCategories()不是页面手写按钮。列表项能显示菜品封面、标题、描述、时长和难度。点击分类入口能携带categoryId进入分类列表页。点击菜品列表项能携带recipeId进入详情页。514 道内置菜谱都带封面缺图过滤不会误删当前数据。图片路径统一为resource://RAWFILE/...组件无需拼 rawfile 路径。十一、问题复盘为什么不直接把 JSON 给页面把 JSON 直接交给页面短期看少写一层代码长期会带来三个问题。第一页面会被内容格式绑死。只要 JSON 字段变化首页、探索、分类、详情都要跟着改。第二图片路径会分散处理。不同页面如果各自拼resource://RAWFILE/很容易出现某个页面能显示图片、另一个页面不显示的情况。第三业务字段会反复计算。总时长、难度文案、摘要模型、步骤图分配、封面兜底都应该集中在数据源层否则页面代码会越来越厚。当前方案的边界更清楚rawfile 负责存内容RecipeDataSource负责把内容变成领域模型RecipeService负责组合业务数据页面负责展示和交互。后续可以继续优化的点也很明确当内容规模继续扩大时可以给RecipeDataSource增加索引缓存当搜索能力增强时可以在数据源层预计算关键词字段当图片资源迁移时只需要改图片映射函数不需要逐个页面调整。十二、下一篇衔接内容资产打通之后首页就不再是静态展示页。下一篇进入首页推荐流RecipeService.getHomeData()如何从 514 道菜里组织今日热榜、最近浏览、热门精选和随机厨房并让首页变成“今天看什么”的入口。