从 0 到 1,我用小程序 + 云开发打造了一个“记忆瓶子”,记录那些重要的日子!
缘起:为什么需要一个“记忆瓶子”?
市面上有很多纪念日 APP,但它们常常伴随着各种广告弹窗、冗余功能,或者在UI设计上未能满足我个人对于“简洁与美”的追求。我希望能有一个小而美的应用,能够:
- 纯粹地记录重要的公历或农历纪念日。
- 能够自由设置重复提醒。
- 界面美观,甚至可以自定义背景图。
- 最重要的是,没有多余的干扰,安静地守护那些珍贵的回忆。
于是,“记忆瓶子”的构思便在脑海中逐渐成形。在深入技术细节之前,欢迎扫码快速体验‘记忆瓶子’! ![]()
技术选型:原生小程序 + 云开发的“双剑合璧”
作为一名开发者,选择合适的技术栈是项目成功的关键。
- 微信小程序: 无疑是触达用户最便捷的平台之一。其轻量级、无需下载安装的特性,以及微信生态内丰富的接口能力(订阅消息、用户授权等),都让它成为首选。
- 微信云开发: 这次我选择了“All in 云开发”的模式。云开发提供了数据库、云存储、云函数等一站式服务,大大降低了后端开发和运维的复杂度。对于个人开发者而言,免费额度友好,上手成本极低,能够让我们更专注于前端和业务逻辑的实现。
这套组合让我在短时间内能够快速迭代,将产品想法落地。
核心功能与技术亮点
1. 公农历转换与复杂日期计算的“艺术”
“纪念日”的核心就是日期。但它远非简单的加减法,公历、农历、重复、指定年数,甚至还有时区问题,都让日期计算变得异常复杂。
痛点: 用户可能想记录一个农历生日,每年自动提醒;或者一个固定公历的周年纪念日;又或者是一个只发生一次的特殊事件。如何精准地计算出这些纪念日的“下一个发生日期”,并与当前日期进行比对,是小程序的核心挑战。 解决方案: 我引入了两个强大的日期处理库:
-
solarlunar: 用于精准地进行公历和农历之间的转换。尤其是在处理闰月等复杂情况时,它的表现非常可靠。 -
dayjs及其utc/timezone插件: 这两个插件对于处理跨时区(尤其是中国大陆)的日期计算至关重要。我发现,仅仅使用new Date()可能会在服务器和用户手机之间产生时区差异,导致“今天”的判断不准确。通过dayjs().tz('Asia/Shanghai').startOf('day')能够确保在云函数和前端都以统一的“上海时间”零点作为基准,从而确保倒计时和提醒的精确性。
核心逻辑片段(getList 云函数中计算 nextOccurrenceTimestamp 的部分简化版):
// 假设 item.timestamp 是事件的UTC时间戳
const eventDayjsUTC = dayjs(item.timestamp);
const eventInTimeZone = eventDayjsUTC.tz(TARGET_TIMEZONE); // TARGET_TIMEZONE = 'Asia/Shanghai'
const todayStartInTimeZone = dayjs().tz(TARGET_TIMEZONE).startOf('day');
let nextSolarYear, nextSolarMonth, nextSolarDay;
if (item.dateType === 'lunar' && item.isRepeat) {
// 农历重复纪念日的复杂计算...
let nextSolar = solarlunar.lunar2solar(todayStartInTimeZone.year(), item.lunarMonth, item.lunarDay, item.isLeapMonth || false);
// ... 如果今年已过,则计算明年的 ...
if (dayjs.tz(`${nextSolar.year}-${nextSolar.month}-${nextSolar.day}`, TARGET_TIMEZONE).valueOf() < todayStartInTimeZone.valueOf()) {
nextSolar = solarlunar.lunar2solar(todayStartInTimeZone.year() + 1, item.lunarMonth, item.lunarDay, item.isLeapMonth || false);
}
nextSolarYear = nextSolar.year;
nextSolarMonth = nextSolar.month;
nextSolarDay = nextSolar.day;
} else if (item.dateType === 'solar' && item.isRepeat) {
// 公历重复纪念日的计算...
nextSolarYear = todayStartInTimeZone.year();
nextSolarMonth = eventInTimeZone.month() + 1;
nextSolarDay = eventInTimeZone.date();
// ... 如果今年已过,则计算明年的 ...
if (dayjs.tz(`${nextSolarYear}-${nextSolarMonth}-${nextSolarDay}`, TARGET_TIMEZONE).valueOf() < todayStartInTimeZone.valueOf()) {
nextSolarYear++;
}
} else {
// 一次性纪念日的计算...
nextSolarYear = eventInTimeZone.year();
nextSolarMonth = eventInTimeZone.month() + 1;
nextSolarDay = eventInTimeZone.date();
}
// 最终构建下一个事件的 Day.js 对象
const nextEventDateStr = `${nextSolarYear}-${String(nextSolarMonth).padStart(2,'0')}-${String(nextSolarDay).padStart(2,'0')}`;
const nextEventStartInTimeZone = dayjs.tz(nextEventDateStr, TARGET_TIMEZONE);
const nextOccurrenceTimestamp = nextEventStartInTimeZone.valueOf();
// ... 剩余的倒计时计算 ...
订阅消息:不错过每一个重要提醒
纪念日如果没有提醒,就失去了意义。“记忆瓶子”集成了微信小程序的订阅消息功能。
实现:
- 用户在小程序内通过
wx.requestSubscribeMessage授权接收提醒。 - 我部署了一个定时触发的云函数
checkReminders。该云函数会每天运行,遍历所有用户的纪念日,计算每个纪念日的目标提醒日期(即“下一个发生日期”减去“提前提醒天数”)。 - 如果目标提醒日期恰好是今天,云函数便会调用
cloud.openapi.subscribeMessage.send发送订阅消息。 -
踩坑提示: 在发送订阅消息时,有一个关键参数
miniprogramState。初期为了调试方便,我将其设为'developer',导致用户点击消息后跳转到开发版小程序! 正式上线后,务必将其改为'formal',确保用户跳转到正式发布版。
checkReminders 云函数核心片段:
// 假设 nextOccurrenceTimestamp 已经计算好
const targetReminderTimestamp = nextOccurrenceTimestamp - (reminderDaysBefore * ONE_DAY_MS);
if (targetReminderTimestamp === todayTimestamp) { // todayTimestamp 是目标时区今天的0点时间戳
// 构建消息数据
const messageData = { /* ... */ };
remindersToSend.push({
touser: item._openid,
templateId: REMINDER_TEMPLATE_ID,
page: `pages/detail/index?id=${item._id}`,
data: messageData,
miniprogramState: 'formal' // 【关键】确保跳转到正式版!
});
}
云存储与 Base64 图片上传:打造个性化背景
为了让每个纪念日卡片都能拥有独特的视觉效果,我引入了自定义背景图功能。然而,云开发免费版存储空间有限,直接上传大量图片并不是最优解。
巧妙的解决方案:
- 用户选择图片后,小程序端会先对图片进行压缩处理(减少数据量)。
- 接着,将压缩后的图片数据转为 Base64 编码字符串。
- 这个 Base64 字符串会被直接作为纪念日记录的一个字段,存储在云数据库中(而非云存储)。
- 前端渲染时,直接将这个 Base64 字符串赋值给
<img>标签的src属性,图片便能直接显示。
【好处】 : 这种方式巧妙地绕过了云存储的容量限制,对于图片数量不多的个人应用来说,大大节约了成本,并且部署简单,加载速度也很快。
UI/UX 优化与样式攻坚战:Vant Weapp 的“爱恨交织”
为了快速构建美观的界面,我选择了 Vant Weapp 组件库。它提供了丰富的组件和完善的文档,确实提升了开发效率。然而,在详情页的底部操作按钮布局上,我遭遇了一场漫长而痛苦的“攻坚战”!
问题描述: 在详情页,我希望“编辑”和“删除”两个按钮能够并排显示,且平分底部空间,左右留出相等的内边距。在开发者工具模拟器上,通过 display: flex; gap: 24rpx; 配合 <van-button custom-class="action-button" /> 和 .action-button { flex: 1; min-width: 0; box-sizing: border-box; } 似乎完美解决了。然而,在真机上,按钮的布局却始终是“左边有空白,右边没有”,呈现出不均衡甚至轻微溢出的情况!
排查过程:
- 检查
flex容器和子项的padding,margin,box-sizing。 - 尝试使用
calc()函数精确计算宽度。 - 利用真机调试功能,我发现罪魁祸首竟然是微信小程序底层对原生
button注入的默认样式:wx-button:not([size=mini]) { width: 184px; ...; margin-left: auto; margin-right: auto; }。这个固定宽度和auto外边距在 Vant Weapp 复杂的组件结构内部,以某种难以覆盖的方式生效,破坏了我的flex: 1布局!
最终解决方案: 在尝试了各种 !important 覆盖、多层选择器、甚至修改 Vant 内部样式(失败告终)之后,我做出了一个决定:放弃使用 van-button 来实现底部操作按钮。
我选择用两个原生的 <view> 标签来模拟按钮:
<view class="action-button-wrapper">
<view class="custom-action-button custom-edit-button" hover-class="button-hover" bindtap="onEdit">
<text>编 辑</text>
</view>
<view class="custom-action-button custom-delete-button" hover-class="button-hover" bindtap="onDelete">
<text>删 除</text>
</view>
</view>
并结合以下 CSS 样式:
.action-button-wrapper {
display: flex;
gap: 24rpx;
padding: 40rpx 24rpx;
box-sizing: border-box;
width: 100%;
}
.custom-action-button {
flex: 1;
min-width: 0;
box-sizing: border-box;
height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: bold;
color: #FFFFFF;
text-align: center;
line-height: 80rpx;
}
.custom-edit-button { background-color: #C8B6B6; }
.custom-delete-button { background-color: #ee0a24; }
.button-hover { opacity: 0.85; }
这套方案简单、直接,让我对样式有了 100% 的掌控力,最终在真机上完美实现了预期布局。有时候,回归原生是最可靠的解决方案!
版本更新机制:确保用户始终使用最新功能
为了避免用户因为小程序缓存而无法体验最新功能或修复的 Bug,我在 app.js 中集成了 wx.getUpdateManager()。它能检测到小程序新版本下载完成后,弹窗提示用户重启。
关键点:
- 监听器
onUpdateReady必须在onLaunch时就设置好。 -
【非常重要】 每次上传新版本前,务必手动修改项目根目录下
project.config.json文件中的"version"字段(如从1.0.0改为1.1.0),否则微信后台无法识别为新版本,更新机制也无法触发。
产品未来展望
“记忆瓶子”才刚刚起步,未来还有很多有趣的功能等待探索:
- 共享空间: 允许多人(如情侣、家人)共同创建和维护纪念日,分享彼此的珍贵时刻。
- 更多主题与精美分享: 提供更丰富的 UI 主题、背景素材,并支持一键生成精美的分享图片。
- 年度总结与历史上的今天: 增加趣味性功能,回顾一年的点滴,或发现“历史上的今天”发生了什么。
总结
开发“记忆瓶子”的过程,是一个不断学习、不断解决问题的过程。它让我对小程序和云开发有了更深的理解,也再次体会到作为一名开发者,能够将自己的想法变为现实的乐趣。
如果你也对这个小程序感兴趣,欢迎扫码体验,并留下宝贵的反馈和建议!你的每一次使用和反馈,都将是“记忆瓶子”继续成长的动力。再次邀请体验和反馈
感谢阅读!