HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(三十):【社区分享】本地社区功能------让菜谱从"独享"走向"共享"
摘要:一个人做饭是生活,一群人分享是社区。前面 29 篇中,《灵犀厨房》的菜谱从推荐、收藏到烹饪,都围绕着"我一个人"。但烹饪的乐趣有一半在于分享------把自己拿手的番茄牛腩煲分享出去,看看别人做了什么。本篇为《灵犀厨房》新增"社区广场"------用户可在菜谱详情页一键分享菜谱到社区,其他用户可浏览所有分享,无需后端服务器,纯本地数据库实现。
一、引言:从"一个人"到"一群人"
打开《灵犀厨房》第 29 篇的版本,浏览菜谱、收藏、烹饪------一切围绕"我"。但你有没有想过:隔壁老王做的宫保鸡丁可能比菜谱库里的还正宗?同事小张的低脂健身餐才是减肥必备?
目前的设计无法满足一个基本需求:用户生成内容(UGC)。所有菜谱都来自我们预设的 10 道模拟数据,用户只能消费,不能生产。这既限制了内容生态的扩展,也缺少了社区互动带来的活跃度。
| 功能现状 | 用户需求 | 差距 |
|---|---|---|
| 菜谱来自预设 MockData | 用户想分享自己的拿手菜 | 缺少 UGC 入口 |
| 菜谱详情只读 | 用户想浏览他人的分享 | 缺少社区展示页 |
| 数据在本地 | 分享需要持久化存储 | 缺少存储表 |
🎯 本篇目标 :用
shared_recipes持久化表(已在第 28 篇预建)和约 130 行新代码,让用户可以分享菜谱到社区广场、浏览所有分享的菜谱。不需要后端服务器,纯本地数据库实现,为后续的云端社区打下基础。
二、功能设计:两个页面,一套数据
菜谱详情页 社区广场
┌──────────────────┐ ┌──────────────────┐
│ 🍳 番茄牛腩煲 │ │ 👤 灵犀大厨 │
│ 📋 食材清单 │ │ 🍳 番茄牛腩煲 │
│ 第1步 焯水 │ │ 食材:牛腩、番茄... │
│ 第2步 炒香 │ 点击分享 │ 步骤:焯水; 炒香... │
│ ... │────────→│ 5/28 14:30 │
│ │ │ │
│ [🔄分享] [❤收藏] │ │ 👤 小明 │
└──────────────────┘ │ 🍳 宫保鸡丁 │
│ ... │
└──────────────────┘
交互模型:
- 用户在菜谱详情页点击分享按钮 → 菜谱信息写入
shared_recipes表 - 用户在"我的"Tab 进入社区广场 → 从数据库按时间倒序加载所有分享
- 点击社区广场中的某条分享 → 进入该菜谱的详情页(复用现有 RecipeDetailPage)
三、数据层:第 28 篇已预建的表
在第 28 篇中,我们已经预建了 shared_recipes 表:
sql
CREATE TABLE IF NOT EXISTS shared_recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_name TEXT NOT NULL,
recipe_name TEXT NOT NULL,
ingredients TEXT,
steps TEXT,
shared_at INTEGER DEFAULT (strftime('%s','now'))
);
本篇新增的用途:在第 28 篇中,这张表只是"预留字段",没有写入逻辑。本篇将实现完整的 INSERT(分享)和 SELECT(广场加载)操作。
| 字段 | 类型 | 存储格式 | 用途 |
|---|---|---|---|
user_name |
TEXT | 固定值"灵犀大厨"(后续对接登录系统) | 分享者标识 |
ingredients |
TEXT | 、 分隔(如"牛腩、番茄、洋葱") |
食材展示 |
steps |
TEXT | ; 分隔(如"焯水:...; 炒香:...") |
步骤展示 |
shared_at |
INTEGER | Unix 时间戳 | 按时间排序 |
设计考量 :为什么
ingredients和steps用分隔符字符串而非 JSON?因为这两个字段在本篇中只用于展示,不需要结构化查询。分隔符字符串可以直接展示(牛腩、番茄、洋葱),不需要JSON.parse再join。如果后续需要查询"包含牛腩的所有分享",再迁移到 JSON 不迟。
四、分享按钮:从详情页到数据库的"一键传送"
4.1 入口位置
在 RecipeDetailPage 底部操作栏中,收藏按钮之前新增橙色分享按钮:
typescript
// ★ 分享到社区
Button({ type: ButtonType.Circle }) {
SymbolGlyph($r('sys.symbol.square_and_arrow_up')).fontSize(16).fontColor([Color.White])
}
.width(38).height(38).backgroundColor('#FF8C5A')
.onClick(() => this.shareToCommunity())
4.2 shareToCommunity 方法
typescript
private async shareToCommunity(): Promise<void> {
try {
await storeHelper.insert('shared_recipes', {
user_name: '灵犀大厨',
recipe_name: this.recipe.name,
ingredients: this.recipe.ingredients.join('、'),
steps: this.recipe.steps.join('; '),
shared_at: Date.now()
});
ToastUtil.showToast(this.getUIContext(), '✅ 已分享到社区');
} catch (err) {
ToastUtil.showToast(this.getUIContext(), '分享失败,请重试');
}
}
SQLite RelationalStoreHelper RecipeDetailPage 👤 用户 SQLite RelationalStoreHelper RecipeDetailPage 👤 用户 #mermaid-svg-1o2TAdwm5QvrQolA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1o2TAdwm5QvrQolA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1o2TAdwm5QvrQolA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1o2TAdwm5QvrQolA .error-icon{fill:#552222;}#mermaid-svg-1o2TAdwm5QvrQolA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1o2TAdwm5QvrQolA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1o2TAdwm5QvrQolA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1o2TAdwm5QvrQolA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1o2TAdwm5QvrQolA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1o2TAdwm5QvrQolA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1o2TAdwm5QvrQolA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1o2TAdwm5QvrQolA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1o2TAdwm5QvrQolA .marker.cross{stroke:#333333;}#mermaid-svg-1o2TAdwm5QvrQolA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1o2TAdwm5QvrQolA p{margin:0;}#mermaid-svg-1o2TAdwm5QvrQolA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1o2TAdwm5QvrQolA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-1o2TAdwm5QvrQolA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1o2TAdwm5QvrQolA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-1o2TAdwm5QvrQolA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-1o2TAdwm5QvrQolA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-1o2TAdwm5QvrQolA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-1o2TAdwm5QvrQolA .sequenceNumber{fill:white;}#mermaid-svg-1o2TAdwm5QvrQolA #sequencenumber{fill:#333;}#mermaid-svg-1o2TAdwm5QvrQolA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-1o2TAdwm5QvrQolA .messageText{fill:#333;stroke:none;}#mermaid-svg-1o2TAdwm5QvrQolA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1o2TAdwm5QvrQolA .labelText,#mermaid-svg-1o2TAdwm5QvrQolA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-1o2TAdwm5QvrQolA .loopText,#mermaid-svg-1o2TAdwm5QvrQolA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-1o2TAdwm5QvrQolA .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1o2TAdwm5QvrQolA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-1o2TAdwm5QvrQolA .noteText,#mermaid-svg-1o2TAdwm5QvrQolA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-1o2TAdwm5QvrQolA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1o2TAdwm5QvrQolA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1o2TAdwm5QvrQolA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1o2TAdwm5QvrQolA .actorPopupMenu{position:absolute;}#mermaid-svg-1o2TAdwm5QvrQolA .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-1o2TAdwm5QvrQolA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1o2TAdwm5QvrQolA .actor-man circle,#mermaid-svg-1o2TAdwm5QvrQolA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-1o2TAdwm5QvrQolA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击分享按钮 insert('shared_recipes', {name, ingredients, steps}) INSERT INTO shared_recipes rowId ✅ Toast "已分享到社区"
图一解读 :分享流程极简------用户点击按钮,数据直接写入 SQLite,返回成功提示。没有网络请求、没有队列、没有审核。这不是阉割版,是 MVP(最小可行产品)的哲学:先验证用户是否愿意分享,再考虑内容审核和云端同步。
4.3 设计考量:为什么固定用户名"灵犀大厨"?
当前版本还没有完整的用户登录与身份管理功能,用户名使用固定值"灵犀大厨"占位。当后续完成登录功能对接后,只需将此处替换为 authViewModel.username,即可实现真实用户名分享。
五、社区广场页面:SQLite 的"流式读取"
5.1 CommunityPage.ets
新建页面 entry/src/main/ets/pages/CommunityPage.ets:
typescript
@Entry
@ComponentV2
struct CommunityPage {
@Local recipes: SharedRecipe[] = [];
@Local isLoading: boolean = true;
async aboutToAppear(): Promise<void> {
await this.loadSharedRecipes();
}
private async loadSharedRecipes(): Promise<void> {
try {
const rs = await storeHelper.querySql(
'SELECT * FROM shared_recipes ORDER BY shared_at DESC LIMIT 50'
);
this.recipes = [];
while (rs.goToNextRow()) {
this.recipes.push({
id: rs.getLong(rs.getColumnIndex('id')),
user_name: rs.getString(rs.getColumnIndex('user_name')),
recipe_name: rs.getString(rs.getColumnIndex('recipe_name')),
ingredients: rs.getString(rs.getColumnIndex('ingredients')),
steps: rs.getString(rs.getColumnIndex('steps')),
shared_at: rs.getLong(rs.getColumnIndex('shared_at'))
});
}
rs.close();
this.isLoading = false;
} catch (err) {
console.error('[Community] 加载失败:', JSON.stringify(err));
this.isLoading = false;
}
}
}
5.2 UI 布局
每个 ListItem 展示:
┌──────────────────────────────────┐
│ 👤 灵犀大厨 5/28 14:30 │
│ 🍳 番茄牛腩煲 │
│ 食材:牛腩、番茄、洋葱、胡萝卜... │
│ 步骤:焯水:牛腩切块...; 炒香... │
└──────────────────────────────────┘
- 分享者名 + 时间戳在顶部
- 菜谱名用主题色高亮
- 食材和步骤各占两行,超出省略
- 卡片背景为
$r('app.color.bg_card'),自动适配深色模式
5.3 路由注册
需在 main_pages.json 中添加路由:
json
{ "src": ["pages/CommunityPage"] }
5.4 入口位置
在"我的"Tab 中新增社区广场入口:
typescript
// MainContainer.ets → ProfileTabContent
Row() {
Row({ space: 10 }) {
Text('🌐').fontSize(18)
Text('社区广场').fontSize(15).fontColor('#333')
}
Blank()
SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#CCC'])
}
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/CommunityPage' })
})
六、代码交付清单
| 文件 | 新增/修改 | 行数 | 说明 |
|---|---|---|---|
RecipeDetailPage.ets |
修改 | +15 | 新增分享按钮 + shareToCommunity() 方法 |
CommunityPage.ets |
新文件 | +105 | 社区广场页面 |
MainContainer.ets |
修改 | +10 | "我的"Tab 新增社区广场入口 |
main_pages.json |
修改 | +1 | 新增路由注册 |
RelationalStoreHelper.ets |
无需修改 | 0 | shared_recipes 表已在第 28 篇预建 |
七、设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 纯本地数据库 | 不依赖后端 | 当前阶段无需服务器;后续可扩展为云端同步 |
ingredients/steps 用分隔符字符串 |
、 分隔食材,; 分隔步骤 |
只展示不查询,分隔符比 JSON 更直观 |
| 固定用户名"灵犀大厨" | 占位值 | 登录系统对接后替换为 authViewModel.username,当前保证功能可跑通 |
shared_at 用 Unix 时间戳 |
整数排序比字符串快 | 展示时用 formatDate() 转换即可 |
| 查询 LIMIT 50 | 限制单次加载量 | 本地数据库中数据量有限,50 条足够展示全部历史分享 |
八、设计哲学:MVP 的"先跑通再完善"
本篇的分享功能是一个典型的 MVP(最小可行产品)实现。它有三个"不完美":
- 用户名是固定的:"灵犀大厨"不是真实用户名,但现阶段没有登录系统,真实用户名需要等登录功能完善后对接。
- 没有内容审核:用户可以分享任何内容,但当前是本地数据库,数据只在用户自己的设备上,不存在合规风险。
- 没有云端同步 :分享只在本地,其他设备看不到,但后续接入后端 API 后,只需将
storeHelper.insert替换为apiService.shareRecipe(),其他代码零改动。
这三个"不完美"是有意为之,不是能力不够。MVP 的核心哲学是:先验证核心行为(用户是否愿意分享),再逐步完善周边设施(身份、审核、同步)。 如果用户根本不愿意分享,你把审核系统做得再完善也是浪费。
九、本阶段总结与下篇预告
本篇用约 130 行新代码,为《灵犀厨房》打开了从"独享"到"共享"的大门:
- 分享按钮:在菜谱详情页一键分享菜谱到社区,食材和步骤自动格式化
- 社区广场:按时间倒序展示所有分享,卡片式布局清晰展示菜谱信息
- 纯本地实现 :不依赖后端服务器,
shared_recipes表在第 28 篇已预建 - MVP 哲学:先验证分享行为,再逐步完善身份和审核
现在的社交体验:
🍳 做了一道拿手的番茄牛腩煲 → 点击分享 → 出现在社区广场 → 其他人打开 App 就能看到!
下篇预告:第 31 篇《应用权限管理与隐私保护最佳实践》。我们将系统梳理《灵犀厨房》需要的权限,按"最小权限原则"清理不必要的权限声明,并讲解 Health Kit 授权和 OAuth 隐私合规的最佳实践。
📚 本系列持续更新中:下一篇将让 App 在权限和隐私上合规,为发布到 AppGallery 做最后的准备。
🔗 专栏入口:《HarmonyOS6.1全场景实战》合集
📦 获取基线版本源码包 :包括第1-15篇所有代码 + 架构文档 + Flask 后端
如果你觉得这篇文章对您有所帮助,麻烦您动动发财之手点赞 👍、收藏 ⭐ 和评论 💬。谢谢大家!!纯血鸿蒙,用心造厨。我们下一篇见!