「每日一诗」是我开发的一款诗词打卡微信小程序,每天推荐一首古诗词,配合打卡签到、徽章成就等玩法。随着用户增长,个性化的需求越来越强烈——有人喜欢春天的绿色,有人偏爱夜晚的深紫,春节时又该有一抹喜庆的红。
于是,一套完整的换肤系统应运而生:9款皮肤(6通用 + 3节日)、右上角调色盘一键切换、节日当天自动换肤。本文分享这套系统的设计思路与核心实现。
技术选型:为什么是 CSS Variables?
小程序换肤常见三种方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 多 WXSS 文件 | 切换样式表引用 | 完全隔离 | 需重新渲染,闪烁明显 |
| CSS 类名切换 | .theme-dark .card { ... } | 简单 | 代码量大,耦合高 |
| CSS Variables | 运行时修改变量值 | 无需重载,一套代码 | 小程序中需注意注入方式 |
最终选择 CSS Variables,三个核心理由:
- 运行时动态切换:修改
style属性即可换肤,页面不闪烁、不重载 - 一套代码适配所有主题:所有样式统一用
var(--xxx)引用,新增皮肤零代码改动 - 配置化驱动:皮肤定义存数据库 JSONB,后台可增减皮肤,无需发版
小程序中 CSS Variables 的关键限制:不支持
:root定义,只能通过容器节点的行内style注入,子节点通过var()继承。
架构总览
┌─────────────────────────────────────┐
│ 微信小程序前端 │
│ skinManager.js ← 皮肤管理器 │
│ pages/index/ ← 主页+调色盘面板 │
│ pages/skin-store/ ← 皮肤商店页面 │
└──────────────┬──────────────────────┘
│ HTTP
┌──────────────▼──────────────────────┐
│ Supabase Edge Functions │
│ get-user-skin 获取今日皮肤 │
│ set-user-preferred-skin 保存偏好 │
│ get-unlocked-skins 获取已解锁列表 │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ PostgreSQL │
│ skins 皮肤定义(9款) │
│ user_skin_states 用户皮肤状态 │
│ skin_usage_history 皮肤使用历史 │
└─────────────────────────────────────┘
前端只做两件事:注入 CSS 变量 + 交互展示;所有皮肤选择逻辑在后端 Edge Function 中完成,前端仅消费结果。
一、主题变量设计:10 个语义化 CSS 变量
核心思路是用最少的变量覆盖全页面颜色,分四层:
| 层级 | 变量 | 语义 |
|---|---|---|
| 品牌色 | --primary-color / --primary-dark | 按钮、标签、强调 |
| 页面背景 | --bg-gradient-start / --bg-gradient-end | 容器渐变 |
| 卡片背景 | --card-bg-start / --card-bg-end | 诗词卡片渐变 |
| 文字层级 | --text-primary / --text-secondary / --text-muted / --text-card | 标题、副文、提示、卡片内 |
10 个变量,不多不少,刚好覆盖一个内容型小程序的所有色彩需求。
每个皮肤的配置存储为 JSONB,直接存在数据库 skins 表中。好处是:新增皮肤只需插入一行 SQL,前端零改动。前端拿到配置后,遍历字段拼接成 CSS 变量字符串,注入到页面根容器的 style 属性即可。
二、皮肤获取优先级算法——系统的核心
这是整个换肤系统最关键的设计。每天用户打开小程序,看到哪款皮肤?答案由 get-user-skin Edge Function 的三级优先级链路决定:
优先级 1: 用户有偏好皮肤 (preferred_skin_id) → 直接返回
优先级 2: 节日当天 → 自动返回节日皮肤
优先级 3: 其他 → 轮播算法选择
2.1 偏好皮肤优先
用户手动选择皮肤后,preferred_skin_id 保存到 user_skin_states 表。每次获取皮肤时,优先检查偏好设置。这尊重了用户的选择——如果你选了「静谧夜空」,即使春节也不会被强制切换(当然可以调整策略让节日皮肤优先级更高)。
2.2 节日皮肤自动切换——难点在农历
春节、中秋是农历节日,不能简单用公历日期判断。这里需要一个公历转农历算法。
实现思路:
- 经典的
lunarInfo查表法,用一段 hex 数组编码 1900-2100 年每月大小月和闰月信息 - 输入公历年月日 → 逐月减去天数 → 定位农历月日
- 输出格式
"01-01"表示正月初一,"08-15"表示八月十五
数据库中节日皮肤的 lunar_date 字段存储农历日期,checkFestivalSkin() 将当天公历转农历后逐个匹配。
| 皮肤 | 名称 | lunar_date | 含义 |
|---|---|---|---|
| spring_festival | 新春佳节 | 01-01 | 正月初一 |
| mid_autumn | 中秋月圆 | 08-15 | 八月十五 |
| qingming | 清明时节 | NULL | 清明按公历,前端处理 |
清明节是节气,固定在公历 4 月 4-6 日,不走农历匹配逻辑,而是在前端
festival.js中按公历判断。
2.3 日常轮播算法
没有偏好、也不是节日时,怎么决定今天展示哪款皮肤?答案是确定性轮播:
skinIndex = (daysSinceEpoch + hash(openid)) % skins.length
两个关键设计:
daysSinceEpoch:距参考日期的天数,保证每天换一个皮肤,避免审美疲劳hash(openid):用户唯一哈希,保证不同用户同一天看到不同皮肤,增加多样性- 确定性:相同输入一定得到相同输出,用户刷新页面不会闪烁切换
2.4 当日缓存机制
一旦确定了今日皮肤,就把 skin_rotation_date 设为当天。后续请求发现日期匹配,直接返回已有结果,避免重复计算和频繁查库。
三、前端交互设计
3.1 调色盘快速切换
入口:主页日期栏右侧放置🎨图标,点击弹出底部面板。
面板设计:4列网格,免费皮肤和节日皮肤分组展示。每个皮肤用一个主色渐变色块预览,当前使用的皮肤显示 ✓ 标记。
交互流程:点击 → 确认弹窗 → 应用皮肤 → 本地缓存 + 云端保存偏好
体验细节:
wx.vibrateShort()触觉反馈,增强操作质感- 重复点击当前皮肤,直接关闭面板,不弹确认
- 面板滑入使用
cubic-bezier(0.4, 0, 0.2, 1)缓动,手感流畅
3.2 皮肤商店页面
独立页面,三列网格展示全部皮肤,分「免费」和「节日」两个区域。每个皮肤卡片包含:预览色块 + 名称 + 类型标签。
跨页面状态同步的关键问题:在皮肤商店切换后,主页如何同步更新?这里用了一个简单有效的方案——getCurrentPages() 找到首页实例,直接调用 setData 更新。对于只有两个页面的场景足够好用。
3.3 首屏秒开:缓存先行 + 后台更新
换肤不应该拖慢首屏加载。策略是:
- 先读本地缓存,瞬间渲染上次的皮肤
- 后台请求云端,获取今日皮肤
- 有变化再覆盖,无变化不动
用户感知到的是:打开即展示,几乎无等待。
四、数据库设计要点
4.1 skins 表——皮肤定义
关键字段:
category:区分 free / festival,用于查询过滤theme_config:JSONB 存储色彩配置,增删皮肤无需改代码priority:数值越高优先级越高,节日皮肤设为 100+lunar_date:农历日期字符串,节日皮肤专用,普通皮肤为 NULLis_active:软开关,下架皮肤不需删数据
4.2 user_skin_states 表——用户皮肤状态
关键字段:
preferred_skin_id:用户手动选择的偏好皮肤current_skin_id:当日实际使用的皮肤skin_rotation_date:当日皮肤确定日期,用于缓存判断unlocked_skins:已解锁皮肤 ID 数组
preferred_skin_id是后续迁移添加的,初始版本只有current_skin_id。用ALTER TABLE ... ADD COLUMN IF NOT EXISTS实现平滑升级。
4.3 RLS 安全策略
- 皮肤定义表:公开读取,所有用户可见
- 用户皮肤状态:仅自己可读写,通过
current_setting('app.current_openid', true)实现
五、踩坑与经验
5.1 CSS Variables 注入方式
小程序不支持 :root 伪类和 JS 动态修改变量,唯一方式是行内 style 注入。这意味着所有使用变量的元素必须是注入节点的后代,布局时需注意容器层级。
5.2 底部面板滚动穿透
弹出面板内的 scroll-view 需设置固定高度并启用 enhanced="true",否则手指在面板内滑动会穿透到背后页面。
5.3 农历算法的边界
查表法覆盖 1900-2100 年,精度够用。但清明节这类按公历计算的节日,不应走农历匹配,需在 lunar_date 设为 NULL,由前端公历逻辑单独处理。
5.4 跨页面通信的取舍
getCurrentPages() 直接操作其他页面实例,简单粗暴且有效,但不适合复杂场景——如果页面层级不确定或页面可能被销毁,建议改用 getApp() 全局状态或自定义事件中心。
六、未来展望
- 用户自定义主题色:开放主色选择,自动生成完整配色方案
- 皮肤使用数据统计:利用
skin_usage_history表分析用户偏好,指导新皮肤设计 - AI 生成配色:接入 LLM,根据当日诗词意境自动生成匹配的主题色
总结
这套换肤系统的核心思路可以概括为三句话:
- CSS Variables 做主题引擎——10 个语义化变量,配置化驱动,新增皮肤零代码改动
- 三级优先级算法做选择逻辑——偏好优先 > 节日自动 > 日常轮播,确定性设计避免闪烁
- 农历算法做节日自动换肤——查表法公历转农历,数据库存储农历日期,运行时匹配
整套方案从数据库到前端渲染完全闭环,核心思路可直接迁移到其他小程序项目。希望对你有帮助!