中式美食的菜谱详情页有两个很容易被写到一起的东西:收藏和笔记。页面上它们确实离得很近,一个是点一下收藏,另一个是写几句自己的口味调整。但我真落库的时候发现,这两个东西不能混。收藏是用户的一个行为,笔记是用户写下来的内容。取消收藏不应该把笔记删掉,改笔记也不应该影响收藏列表的排序。
这次主要解决什么
| 问题 | 大白话解释 |
|---|---|
| 收藏和笔记是不是一张表 | 不是。收藏是动作,笔记是内容 |
| 取消收藏要不要删除笔记 | 不要。用户写过的内容应该留下 |
| 改笔记要不要刷新收藏时间 | 不要。否则收藏页排序会乱 |
| 推荐排序能不能用这两个信号 | 能,但权重要分开 |
当时如果偷懒会怎么写
最省事的写法是做一个大对象,里面什么都有。
exportinterfaceRecipeUserState{recipeId:stringisFavorite:booleanfavoriteAt?:numbernoteText?:stringnoteUpdatedAt?:number}这个对象给页面用可以,但不能直接当数据库结构。因为它把两种生命周期完全不同的数据塞到了一起。页面想展示时可以合并,落库时必须拆开。
我最后怎么拆
| 表 | 只管什么 | 不管什么 |
|---|---|---|
| favorite_recipe | 这道菜有没有被收藏、什么时候收藏 | 笔记正文 |
| recipe_note | 用户写了什么、什么时候改 | 是否收藏 |
CREATETABLEIFNOTEXISTSfavorite_recipe(recipe_idTEXTPRIMARYKEY,created_atINTEGERNOTNULL,source_pageTEXTNOTNULLDEFAULT'detail');CREATETABLEIFNOTEXISTSrecipe_note(recipe_idTEXTPRIMARYKEY,note_textTEXTNOTNULL,updated_atINTEGERNOTNULL,pinnedINTEGERNOTNULLDEFAULT0);这样拆完以后,取消收藏就只删 favorite_recipe,保存笔记就只改 recipe_note。两个动作互不误伤。
ArkTS 层怎么写更清楚
收藏仓储只处理收藏。
exportclassFavoriteRepository{asyncsetFavorite(recipeId:string,favorite:boolean):Promise<void>{if(favorite){awaitthis.dao.upsertFavorite({recipeId,createdAt:Date.now(),sourcePage:'detail'})return}awaitthis.dao.deleteFavorite(recipeId)}}笔记仓储只处理笔记。
exportclassRecipeNoteRepository{asyncsave(recipeId:string,text:string):Promise<void>{constnoteText=text.trim()if(!noteText){awaitthis.dao.deleteNote(recipeId)return}awaitthis.dao.upsertNote({recipeId,noteText,updatedAt:Date.now(),pinned:0})}}详情页要用的时候,再由 ViewModel 合并。
exportclassRecipeDetailViewModel{asyncloadUserState(recipeId:string):Promise<RecipeUserState>{constfavorite=awaitthis.favoriteRepository.find(recipeId)constnote=awaitthis.noteRepository.find(recipeId)return{recipeId,isFavorite:!!favorite,favoriteAt:favorite?.createdAt,noteText:note?.noteText,noteUpdatedAt:note?.updatedAt}}}环境和验证
| 项目 | 说明 |
|---|---|
| 技术栈 | HarmonyOS、ArkTS、ArkUI、RDB relationalStore |
| 页面 | 菜谱详情页、收藏页、我的厨房 |
| 核心文件 | FavoriteRepository.ets、RecipeNoteRepository.ets、RecipeDetailViewModel.ets |
| 验收方式 | 点收藏、写笔记、取消收藏、重新进入详情页 |
| 操作 | 预期结果 |
|---|---|
| 收藏一道菜并写笔记 | 收藏页有这道菜,详情页能看到笔记 |
| 取消收藏 | 收藏页移除,但笔记还在 |
| 只写笔记不收藏 | 我的厨房笔记入口能找到,收藏页不出现 |
| 修改笔记 | 笔记时间更新,收藏时间不变 |
asyncfunctionverifyCancelFavoriteKeepsNote(){awaitfavoriteRepository.setFavorite('mapo-tofu',true)awaitnoteRepository.save('mapo-tofu','少放辣,家里老人也能吃')awaitfavoriteRepository.setFavorite('mapo-tofu',false)conststate=awaitdetailViewModel.loadUserState('mapo-tofu')assert(state.isFavorite===false)assert(state.noteText==='少放辣,家里老人也能吃')}总结一下
这块不是为了把表设计得多复杂,而是为了不让后面的功能互相踩。收藏、笔记、推荐、我的厨房都会读这部分数据。边界现在拆清楚,后面加“常做菜”“带笔记的菜”“最近调整口味”这类功能时,就不用再回头补救。
我这里真正防的是误删
这个设计看起来只是分两张表,实际防的是一个很常见的坑:用户点了取消收藏,开发者顺手把“这道菜的用户状态”整条删掉。这样写代码最省事,但用户之前写的笔记也没了。中式美食这种做饭类应用里,笔记往往是用户自己的经验,比如“少放盐”“孩子不吃辣”“下次换成鸡腿肉”。这些内容比收藏按钮本身更重要。
我最后给自己定了一个很简单的规则:只要是用户手写出来的内容,就不能跟着一个按钮状态一起删除。按钮状态可以删,用户内容要单独删,而且必须是用户明确清空笔记。
| 误写法 | 问题 |
|---|---|
| deleteRecipeUserState(recipeId) | 收藏和笔记一起没了 |
| saveUserState({ isFavorite, noteText }) | 改一个字段时容易覆盖另一个字段 |
| 用 updatedAt 同时表示收藏和笔记更新时间 | 排序会被污染 |
asyncfunctionunsafeCancelFavorite(recipeId:string){// 这类写法要避免:看似清理状态,实际可能把笔记也清掉awaituserStateDao.deleteByRecipeId(recipeId)}正确做法是动作对动作,内容对内容。
asyncfunctionsafeCancelFavorite(recipeId:string){awaitfavoriteRepository.setFavorite(recipeId,false)// 不调用 noteRepository.delete}开发时怎么判断边界对不对
我判断一张表该不该拆,主要看三个问题。
| 判断问题 | 如果答案是“是” | 处理 |
|---|---|---|
| 两个字段会不会独立删除 | 会 | 拆表 |
| 两个字段排序规则是否不同 | 不同 | 拆表 |
| 两个字段是否给不同业务使用 | 是 | 拆表 |
收藏和笔记三个问题都命中,所以不适合塞一起。收藏给收藏页、推荐排序、首页回流用;笔记给我的厨房、详情页、私人记录用。它们只是经常在同一个页面出现,不代表它们应该存成同一个东西。
后续扩展会更轻
分表以后,后面要做“有笔记的菜”“最近修改过的菜”“收藏最多的分类”都很直接。
SELECTrecipe_id,updated_atFROMrecipe_noteWHEREnote_text<>''ORDERBYupdated_atDESC;SELECTrecipe_id,created_atFROMfavorite_recipeORDERBYcreated_atDESC;这两条查询如果都压在一个混合 JSON 字段里,就要先读出来再解析,再排序。数据量小时能跑,功能多了以后很难维护。
排错时我会先看这几项
收藏和笔记出了问题,不要先去看页面样式,先看数据是不是被写错。
| 排查点 | 看什么 |
|---|---|
| favorite_recipe 是否存在 | 判断收藏按钮状态 |
| recipe_note 是否存在 | 判断笔记是否应该回显 |
| created_at 是否变化 | 判断收藏排序有没有被误刷新 |
| updated_at 是否变化 | 判断笔记保存是否真的生效 |
asyncfunctiondebugRecipeUserData(recipeId:string){constfavorite=awaitfavoriteRepository.find(recipeId)constnote=awaitnoteRepository.find(recipeId)console.info('favorite',favorite)console.info('note',note)}这个排查顺序很朴素,但有效。先确认数据,再看 ViewModel,最后才看页面。否则很容易把一个落库边界问题误判成 ArkUI 渲染问题。