中式美食的菜谱封面一开始很好处理:列表卡片里直接写一张图,详情页也能显示。问题是页面一多,图片很快就乱了。同一道菜在首页是一张图,详情页又是一张图;有的菜缺封面,列表里露出空白;推荐卡片尺寸和列表卡片不一样,滚动时高度还会抖。图片看起来只是 UI 细节,但它会直接影响列表稳定性和用户对应用的第一眼判断。
我这次不把图片问题放到每个页面里单独修,而是把封面来源、兜底图、尺寸比例和缓存 key 收到一个小的资源层。页面只问“这道菜应该显示哪张封面”,不用关心图片来自本地资源、远程地址,还是默认占位。
环境和边界
| 项目 | 说明 |
|---|---|
| 应用 | 中式美食 |
| 技术栈 | HarmonyOS / ArkTS / ArkUI |
| 相关能力 | ResourceStr、Image、固定比例容器、默认图兜底 |
| 使用页面 | 首页列表、菜谱详情、收藏列表、推荐卡片 |
| 核心目标 | 同一道菜在不同页面使用同一套封面解析规则 |
这篇只处理菜谱封面,不展开讲图片上传、图片编辑和云端 CDN。先把本地资源和页面消费边界稳住,后面再接远程图会顺很多。
图片来源先分清楚
| 来源 | 例子 | 使用建议 |
|---|---|---|
| 本地资源 | 内置菜谱封面、默认封面 | 适合首批固定菜谱和兜底图 |
| 远程地址 | 后续后台或云端图 | 需要缓存 key 和失败兜底 |
| 生成图 | 运营封面、文章配图 | 不要直接散落在页面里 |
| 默认图 | 缺图、加载失败、数据异常 | 必须统一,不要每页各写一张 |
如果页面各自决定图片来源,后面肯定会出问题。首页卡片可能写$r('app.media.mapo_tofu'),详情页写另一个资源名,推荐卡片忘记兜底。看着都能运行,但产品体验会越来越散。
先定义封面描述
页面不应该直接处理所有图片来源。可以先定义一个统一的描述对象,让服务层把业务字段转换成页面能消费的图片结果。
typeRecipeCoverSource=|{type:'local';value:ResourceStr}|{type:'remote';value:string;cacheKey:string}|{type:'fallback';value:ResourceStr}interfaceRecipeCoverMeta{recipeId:stringname:stringsource:RecipeCoverSource aspectRatio:number}这里的aspectRatio我会放进元信息里。因为列表、详情和推荐卡片虽然尺寸不同,但比例最好稳定。比例不稳定,长列表滚动时卡片高度就容易跳。
ResourceStr 注册表负责本地图
本地封面不要散落在页面里。页面只拿菜谱 id,注册表返回对应资源;没有就返回默认图。
classRecipeImageRegistry{privatereadonlydefaultCover:ResourceStr=$r('app.media.default_recipe_cover')privatereadonlylocalCovers=newMap<string,ResourceStr>([['mapo_tofu',$r('app.media.cover_mapo_tofu')],['tomato_egg',$r('app.media.cover_tomato_egg')],['kung_pao_chicken',$r('app.media.cover_kung_pao_chicken')]])getLocalCover(recipeId:string):ResourceStr{returnthis.localCovers.get(recipeId)??this.defaultCover}hasLocalCover(recipeId:string):boolean{returnthis.localCovers.has(recipeId)}}这段代码的价值不是Map本身,而是把“哪个菜用哪张图”从页面里拿出来。以后换图,只改注册表;页面不用一个个翻。
封面解析服务统一出口
注册表只管本地资源,真正给页面用的可以是一个RecipeCoverResolver。它先看菜谱有没有远程封面,再看本地注册表,最后给默认图。
classRecipeCoverResolver{constructor(privateimageRegistry:RecipeImageRegistry){}resolve(recipe:RecipeCardModel):RecipeCoverMeta{if(recipe.remoteCoverUrl&&recipe.remoteCoverUrl.startsWith('https://')){return{recipeId:recipe.id,name:recipe.name,source:{type:'remote',value:recipe.remoteCoverUrl,cacheKey:this.buildCacheKey(recipe)},aspectRatio:16/10}}return{recipeId:recipe.id,name:recipe.name,source:{type:this.imageRegistry.hasLocalCover(recipe.id)?'local':'fallback',value:this.imageRegistry.getLocalCover(recipe.id)},aspectRatio:16/10}}privatebuildCacheKey(recipe:RecipeCardModel):string{return`${recipe.id}_${recipe.coverVersion??0}`}}cacheKey里带coverVersion是为了后面换图时能刷新缓存。否则远程图地址不变、内容换了,页面可能还显示旧图。
页面只用一个图片组件
列表卡片、详情页头图、推荐卡片都可以共用一个封面组件,只是尺寸不同。组件内部处理本地图、远程图、兜底图和 objectFit。
@Componentstruct RecipeCoverImage{cover:RecipeCoverMeta widthValue:Length='100%'heightValue:Length=120build(){Stack(){if(this.cover.source.type==='remote'){Image(this.cover.source.value).alt($r('app.media.default_recipe_cover')).objectFit(ImageFit.Cover).width(this.widthValue).height(this.heightValue)}else{Image(this.cover.source.value).objectFit(ImageFit.Cover).width(this.widthValue).height(this.heightValue)}}.clip(true).borderRadius(10).backgroundColor('#F6F1EA')}}页面里不要再直接Image(recipe.cover)。这样写虽然快,但一旦有远程图、缺图和默认图,所有页面都要重复一遍判断。
列表卡片要固定比例
图片加载慢时,如果容器高度跟着图片走,列表会抖。对菜谱列表来说,我宁愿先固定卡片尺寸,再等图片填进去。
@Componentstruct RecipeCard{recipe:RecipeCardModel coverResolver:RecipeCoverResolverbuild(){Column(){RecipeCoverImage({cover:this.coverResolver.resolve(this.recipe),widthValue:'100%',heightValue:132})Text(this.recipe.name).fontSize(16).fontWeight(FontWeight.Medium).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis})Text(`${this.recipe.cookMinutes}分钟 ·${this.recipe.difficultyText}`).fontSize(12).fontColor('#8A7562')}.width('100%')}}固定高度不是为了偷懒,而是为了滚动稳定。菜谱封面质量参差不齐时,统一比例能让列表看起来更像一个产品,而不是一堆随手拼起来的图片。
缓存 key 怎么设计
如果后面封面来自远程,缓存 key 不能只用 URL。换图时如果 URL 不变,缓存可能不刷新。我会把菜谱 id 和封面版本一起放进去。
| 字段 | 作用 |
|---|---|
recipeId | 标记是哪道菜 |
coverVersion | 封面更新时主动变化 |
sourceType | 区分本地资源和远程资源 |
fallback | 加载失败时统一兜底 |
interfaceCoverCacheRecord{recipeId:stringsourceType:'local'|'remote'cacheKey:stringupdatedAt:number}functionbuildCoverCacheKey(recipe:RecipeCardModel):string{constversion=recipe.coverVersion??0constsource=recipe.remoteCoverUrl?'remote':'local'return`${source}_${recipe.id}_${version}`}这套 key 不复杂,但能避免两个常见问题:不同菜用了同一个缓存名,或者封面更新后页面还拿旧图。
验收时我会看这些点
| 场景 | 操作 | 期望结果 |
|---|---|---|
| 列表正常封面 | 打开首页菜谱列表 | 每张卡片比例稳定,不跳动 |
| 详情页同一道菜 | 从列表进入详情 | 详情页和列表使用同一张封面 |
| 缺图菜谱 | 构造没有封面的菜谱 | 显示统一默认图,不出现空白 |
| 推荐卡片 | 打开推荐区域 | 推荐图和列表图来源一致 |
| 更换封面版本 | 修改coverVersion | 缓存 key 变化,旧图不会继续占用 |
图片问题不能只靠“看起来有图”。我会同时看列表、详情和推荐三个入口,因为封面不一致通常就是多入口暴露出来的。
失败用例也要提前兜住
| 失败用例 | 可能后果 | 处理方式 |
|---|---|---|
| 本地资源没有注册 | 页面空白或报错 | 注册表返回默认图 |
| 远程图地址为空 | Image 加载失败 | Resolver 转成本地兜底 |
| 同一道菜多处写死封面 | 列表和详情不一致 | 页面只消费 Resolver |
| 图片尺寸不固定 | 列表滚动时卡片跳动 | 图片容器固定高度和比例 |
| 封面更新后缓存不刷新 | 用户仍看到旧图 | cacheKey 带coverVersion |
这些失败用例看着小,但它们会直接影响用户对应用的判断。做菜应用图片很多,封面一乱,内容再好也会显得不稳。
这次拆完后的判断
图片资源管理不是单纯为了好看,而是为了让中式美食的列表、详情、收藏和推荐看起来像同一个应用。封面来源统一以后,页面代码会轻一点,缺图兜底也不会到处复制。
我的经验是:图片越多,越不能让页面各自发挥。先把资源注册、封面解析、默认图和缓存 key 收到一处,后面无论换成本地图、远程图还是生成图,页面都不用大改。这个边界稳住以后,再去打磨视觉,才不会边做边漏。