去年接了个旅游类的小程序需求,产品经理的诉求特别直白:"用户打开小程序,能看到几个合口味的景点,而不是一屏全是网红打卡地"。听起来简单,真正落地的时候才发现,"推荐"这两个字后面藏着标签体系、用户画像、行为采集、冷启动一长串问题。最终项目选择了UniApp这套跨端框架来做微信小程序端,推荐引擎没有上机器学习,而是用了一套轻量的标签加权加协同过滤方案,实测效果在中小体量数据下完全够用。
这篇文章把整个项目的技术链路拆开讲一遍,从技术选型、推荐算法落地、UniApp开发联调,到发布上线前的配置坑,尽量按实操顺序来。适合正在做小程序开发、或者准备用UniApp接旅游类推荐需求的工程师参考,有数据规模不大、想快速落地推荐功能的朋友也可以直接抄作业。
1. 为什么这个项目最终选了UniApp:从技术选型到架构拆分
1.1 需求本质:表面是推荐系统,底层是内容分发
先把需求拆清楚。所谓旅游景点推荐系统,本质上是一个"内容分发"问题:数据库中可能有几千上万个景点POI(兴趣点),每个POI有坐标、门票、评分、标签、图片、简介。用户打开小程序时,不可能把所有POI一次性倒给用户,手机屏幕就那么大,用户耐心就那么几秒,必须做到"千人千面",把最可能符合当前用户偏好的几个景点推到首页。
这里有个容易犯的错误:一提到推荐,就想着上协同过滤、上深度学习、上向量召回。对一个小程序项目来说,数据量可能只有几千个景点、几千个日活用户,跑这么重的框架纯属给自己加班。推荐系统的目标不是炫技,而是让用户在有限的信息流里快速找到想去的地方。
围绕这个目标,我把系统拆成五个核心模块:
- 景点管理:后台录入景点的名称、坐标、标签、图片、评分、开放时间等基础数据
- 用户模块:微信登录、用户画像、浏览记录、收藏、足迹
- 推荐引擎:基于标签和行为的召回排序服务,输出个性化景点列表
- 小程序端:首页信息流、景点详情、地图附近、个人中心
- 管理与运营:热门景点人工干预、兜底策略配置
1.2 UniApp对比原生开发的取舍点
这个项目最初也纠结过要不要用微信原生小程序开发,后来团队评估完还是选了UniApp。考虑因素如下表:
| 维度 | UniApp | 微信原生小程序 |
|---|---|---|
| 多端复用 | 一套Vue代码可编译到微信小程序、App、H5 | 仅微信端,要做App得另起炉灶 |
| 开发效率 | Vue单文件组件,生态丰富,上手快 | 原生WXML/WXSS,写法偏自定义 |
| 性能 | 经HBuilderX编译后接近原生,略有一层转换开销 | 性能最优,组件和API最底层 |
| 周边生态 | 插件市场有大量现成组件 | 微信官方组件和API最全 |
| 技能复用 | Vue技术栈可迁移到Web/App | 原生技能只能用在微信生态 |
这个项目有一个隐含需求,后续可能要做App端或者H5端,如果一开始用原生小程序写,将来迁移等于重写一遍。UniApp虽然性能上有一点折损,但对一个以信息展示和推荐分发为核心的小程序来说,性能瓶颈根本轮不到框架层,真正的瓶颈在图片体积和接口设计上。
1.3 数据模型设计:推荐系统的地基
数据模型在动手写代码之前就得定好。推荐系统最忌讳"先开发页面,后补数据",因为画像、行为、标签这些数据如果不在源头设计好,后面全得返工。
我用几张核心表来支撑推荐逻辑:
- 景点表(poi):id、名称、城市、经度、纬度、评分、热度、封面图、标签IDs
- 标签表(tag):id、标签名(如"亲子""自然""人文""美食")
- 景点标签关联表(poi_tag):poi_id、tag_id
- 用户行为表(user_behavior):id、用户ID、景点ID、行为类型(浏览/收藏/搜索)、发生时间
- 用户画像表(user_profile):id、用户ID、标签权重JSON、偏好城市、活跃度
用户画像表里的标签权重JSON是推荐引擎的核心输入,格式大概是{"自然": 0.8, "亲子": 0.6, "人文": 0.3}。这个权重不是拍脑袋定的,是用户行为通过积分规则累积出来的,后面会细说。
1.4 技术栈全景
最终的技术栈如下:
- 前端框架:UniApp(Vue 3语法),编译目标为微信小程序
- 后端服务:Spring Boot + MyBatis Plus,部署在云服务器上
- 数据库:MySQL 8.0,存放景点、标签、用户、行为数据
- 缓存:Redis,用来缓存推荐结果和热点数据
- 推荐计算:Java后端定时任务 + 轻量算法,输入用户ID,输出有序景点ID列表
小程序端不需要承担推荐计算任务,只负责展示结果。这个分离很重要,小程序端跑复杂计算既耗性能又费流量,而且推荐逻辑更新时还必须发版,放在服务端随时可调。
2. 推荐引擎不堆算法:标签画像、召回排序与冷启动的落地实现
2.1 景点标签和用户画像:推荐的地基
推荐系统的地基不是算法,而是标签。一个景点如果没有任何标签,推荐引擎就算把数学玩出花来也算不出它适合谁。
我先给景点规划了基础标签体系,按旅游决策场景分成几个维度:
- 主题类型:自然风光、人文古迹、主题乐园、城市漫步、宗教寺庙、科普研学
- 人群偏好:亲子、情侣、老人、朋友聚会、独自旅行
- 场景属性:网红打卡、小众秘境、摄影胜地、夜游、徒步登山、水上项目
每个景点建议打3到5个标签,宁缺毋滥。比如西湖可以打"自然风光""城市漫步""网红打卡""情侣",但不要打"主题乐园"这种明显不符合的标签。标签不准,后面画像再准也白搭。
用户画像的构建思路是"行为映射到标签":用户浏览了一个景点,就把这个景点的标签权重累加到用户的标签权重上,再乘时间衰减因子。
2.2 召回与排序:一条公式把推荐跑起来
推荐的完整流程分召回和排序两步。
召回阶段,根据用户的画像标签,从景点库里捞出一批候选景点。简单做法是:遍历所有景点,计算每个景点标签集合与用户标签权重的匹配度,取TopN作为候选集。这里我用余弦相似度计算用户画像和景点标签之间的匹配分:
matchScore = (用户标签权重向量 · 景点标签向量) / (用户画像权重模长 × 景点标签模长)这个值在0到1之间,越接近1说明这个景点越符合用户的兴趣偏好。
排序阶段,在召回集的基础上加距离、热度、评分做加权。最终评分公式是:
finalScore = 0.4 × 标签匹配度 + 0.3 × 距离因子 + 0.2 × 热度分 + 0.1 × 评分分其中距离因子按线性衰减:1 - min(实际距离/搜索半径, 1),也就是说距离越近得分越高。热度分用景点的访问量归一化得到,评分分用景点的用户评分除以5得到。权重可以根据运营需要随时调整,比如旅游旺季可以把距离因子调高,引导用户去附近景区,而非大老远跑到别的城市。
这部分在Java里就是一次简单的遍历计算,几千个景点性能完全没问题。核心代码如下:
public List<Poi> recommend(Integer userId, Double lat, Double lng, int topN) { // 1. 读取用户画像标签权重 UserProfile profile = userProfileMapper.selectByUserId(userId); // 2. 召回:遍历景点,计算标签匹配度 List<Poi> allPois = poiMapper.selectAll(); List<ScoredPoi> scoredList = new ArrayList<>(); for (Poi poi : allPois) { double tagScore = calcTagMatch(profile.getTagWeights(), poi.getTags()); double distance = DistanceUtil.distance(lat, lng, poi.getLat(), poi.getLng()); double distanceFactor = Math.max(0, 1 - distance / searchRadius); double hotScore = poi.getHotRank() / maxHotRank; double ratingScore = poi.getRating() / 5.0; double finalScore = 0.4 * tagScore + 0.3 * distanceFactor + 0.2 * hotScore + 0.1 * ratingScore; scoredList.add(new ScoredPoi(poi, finalScore)); } // 3. 排序取TopN scoredList.sort((a, b) -> Double.compare(b.getScore(), a.getScore())); return scoredList.subList(0, Math.min(topN, scoredList.size())) .stream().map(ScoredPoi::getPoi).collect(Collectors.toList()); }这里还有一个协同过滤的轻量落地。当用户的行为数据积累到一定量后,可以基于"看了A景点的人也看了B景点"做关联推荐。实现不复杂:在用户行为表里统计共现次数,建立景点间的共现矩阵,存入Redis,当推荐结果里标签匹配度都太低时,用协同过滤结果做补充。
2.3 冷启动方案:新用户不看空页面
冷启动是推荐系统绕不开的问题。新用户没有任何行为数据,画像为空,如果直接跑推荐公式,所有景点的标签匹配度都是0,最终只能按距离、热度、评分排序,展示效果跟"随便看看"没有区别,这就失去了推荐的意义。
我的冷启动方案分三层:
- 第一层:根据用户当前定位,推荐"附近热门景点Top10",用城市优先兜底
- 第二层:如果用户授权了手机号,尝试根据微信登录返回的性别年龄信息(如果有)做粗略画像匹配
- 第三层:在首页推荐流里混入几类热门标签的代表性景点,每个标签出1到2个,既保证多样性,也给用户提供"点开即训练画像"的素材
冷启动阶段最忌讳的是推荐结果过于单一。比如用户在大理,就给他推10个洱海周边景点,结果用户觉得全是海景房。我的做法是强制"标签多样":候选集按标签分桶,每个桶取Top2,然后拼接成最终的推荐流。
2.4 为什么不在小程序端直接算推荐
这个项目从一开始就定了一个原则:小程序端坚决不做推荐计算。原因有三。
第一,数据安全问题。推荐逻辑放在前端,等于把整个推荐策略暴露给用户,运营和商业价值全没了。考察一下竞品,没有哪个成熟产品会在客户端直接跑核心算法。
第二,性能问题。小程序端JavaScript引擎处理几千条数据的遍历计算虽然不至于卡死,但在低端安卓机上,用户滑个列表还要等本地算完才有数据,体验很差。
第三,更新问题。推荐策略必须能快速迭代。周末景区人流量异常,运营想临时调权重,如果逻辑在前端,就得提审、发版,等两三天审核,黄花菜都凉了。放在服务端,Redis配置一改,秒级生效。
小程序端只做一件事:调用后端接口拿推荐结果,再渲染页面。这样职责清晰,也方便后续推荐逻辑升级。
3. 从HBuilderX创建工程到微信开发者工具跑通:开发链路全记录
3.1 HBuilderX创建项目与工程结构
UniApp开发我用的HBuilderX,它是DCloud官方的IDE,对UniApp的支持最无缝,创建、编译、调试、打包一站式解决。用命令行的方式也可以,但HBuilderX对新手更友好,不用自己配一堆环境变量。
创建项目的流程:打开HBuilderX → 文件 → 新建 → 项目 → 选择"uni-app"模板 → 项目名称填travel-recommend-miniapp,框架选Vue 3。HBuilderX会自动生成一套标准的UniApp目录结构。
创建完的目录如下,每个目录职责要明确:
travel-recommend-miniapp/ ├── pages/ # 页面目录,所有页面都放这 │ ├── index/ # 首页推荐Feed │ ├── explore/ # 附近景点/地图页 │ ├── detail/ # 景点详情页 │ └── mine/ # 个人中心 ├── components/ # 可复用组件(景点卡片、标签等) ├── api/ # 接口请求封装 ├── static/ # 静态资源 ├── utils/ # 工具函数(距离计算、格式化等) ├── App.vue # 应用入口,生命周期,全局样式 ├── main.js # Vue实例入口 ├── manifest.json # 应用配置,包含小程序AppID等 ├── pages.json # 页面路由和导航栏配置 └── uni.scss # 全局样式变量pages.json相当于小程序原生里的app.json,页面路由、TabBar、导航栏样式全在这里配置。这个文件我建议一开始就完整配置好,别等页面多了再补,后面路由跳转全依赖它。
3.2 manifest.json和AppID配置:最容易被忽略的第一步
这是整个开发流程里最容易踩坑的地方。manifest.json里有个"微信小程序配置"区块,里面的appid一定要填真实的小程序AppID,否则微信开发者工具编译后照样会报错或功能受限。
获取AppID的流程:微信公众平台注册小程序账号,登录后在"开发 > 开发管理 > 开发设置"里可以找到AppID。这里分两种:测试号AppID和正式AppID。开发阶段可以用测试号,但调试登录、获取手机号等能力时测试号有限制,建议尽早用正式号。
配置路径:manifest.json → 微信小程序配置 → 填入AppID。填完之后,还要在HBuilderX里设置"微信开发者工具运行路径"。在HBuilderX的"运行 > 运行到小程序模拟器 > 微信开发者工具"里,第一次会要求指定微信开发者工具的安装路径。这里注意,微信开发者工具需要开启"服务端口"才能接收HBuilderX的推送编译。
微信开发者工具设置路径:右上角设置 → 安全设置 → 服务端口 → 打开。不开这个端口,HBuilderX编译完根本推不进去。
3.3 微信开发者工具联调:从编译到真机预览
配置完成后,HBuilderX工具栏点"运行 > 运行到小程序模拟器 > 微信开发者工具",编译完会自动打开微信开发者工具加载项目。这里的联调流程我建议养成固定的习惯:
- 先看微信开发者工具的Console面板,有红色报错优先处理
- 网络请求在Network面板看,开发阶段记得勾选"不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书"
- 真机预览用微信扫一扫,手机上装的是体验版,注意开发者工具上"预览"按钮生成的二维码有时效
开发阶段总会遇到一个窒息场景:真机上样式和模拟器里完全不一样。模拟器用的是Chromium内核渲染,真机是XWeb内核,部分CSS属性支持度有差异。遇到这种问题我的做法是尽量用Flex布局,少用绝对定位,圆角和阴影适当保守一点。这个项目首页Feed卡片曾经在模拟器里好好的,真机上出现底部留白问题,排查发现是padding-bottom和env(safe-area-inset-bottom)冲突导致的,后面统一用安全区适配才解决。
3.4 菜单路由、TabBar与页面间通信
TabBar配置在pages.json里,我配了三个:首页推荐、附近探索、我的。图标用的是iconfont字体图标,放在static目录。这里有个小技巧:TabBar的图标尺寸建议遵循微信官方规范,81px × 81px,图标过大或过小都会影响真机显示。
页面跳转要注意,UniApp里uni.navigateTo和uni.switchTab是有区别的。navigateTo用于页面栈内的普通页面跳转,switchTab用于跳转到TabBar页面,两者不能混用。从景点详情页返回首页时,如果用了navigateBack,页面栈里可能没有上一页,会直接卡死。我的做法是详情页入口统一用navigateTo,详情页里放一个"返回首页"按钮,用uni.reLaunch直接重置页面栈到首页。
页面间传参我用三元运算符式的URL传参,简单直接。比如详情页跳转:
uni.navigateTo({ url: `/pages/detail/detail?id=${poiId}` });接收参数时用onLoad(options)里的options.id。复杂对象传参用eventChannel或者全局状态管理,但项目里我尽量少用全局Store,因为小程序页面刷新后全局数据容易丢,还是要以URL参数和本地缓存为主。
4. 景点Feed、地图定位与"附近推荐":三个核心页面的实现细节
4.1 首页推荐Feed:组件拆分与骨架屏
首页是整个小程序的门面,推荐流长什么样直接决定用户愿不愿意留。把首页拆成三个区块:顶部城市定位 + 标签偏好快捷选择、推荐景点卡片Feed、底部加载更多。
推荐卡片我抽成了一个组件PoiCard,在components目录下。这个组件接收景点对象,渲染封面图、名称、标签、评分、距离信息。组件化有两个好处:详情页关联推荐、探索页列表都要用同一套卡片样式,改样式时只改一处。
卡片布局用的是经典的上下结构:上面是大图,下面叠加文字信息。封面图这块有个性能细节:图片尺寸一定要压缩。微信小程序单包限制2MB,图片资源过大不只影响体积,还会在列表滚动时产生白屏和卡顿。我的做法是后端接口直接返回裁剪后的图片URL,统一缩放到750px宽度,使用WebP格式,实测体积能小60%以上。
骨架屏是提升首屏体验的关键。推荐接口返回前,页面不能白着,用骨架屏占位,给用户"马上就好"的暗示。UniApp里实现骨架屏很简单,在页面loading状态渲染几个灰色块模拟卡片布局。重点:骨架屏样式要跟真实卡片保持一致,否则用户会感觉页面跳了一下。
接口请求封装我放在api目录下,代码如下:
// api/index.js const BASE_URL = 'https://api.example.com'; export function getRecommendList(data) { return new Promise((resolve, reject) => { uni.request({ url: `${BASE_URL}/api/recommend`, method: 'GET', data, success: (res) => resolve(res.data), fail: (err) => reject(err) }); }); }这里注意,不要每次请求都写一遍uni.request,封装成Promise后,页面里用async/await调用,代码会清爽很多。Loading状态我统一用页面级变量控制,不用uni.showLoading,因为showLoading在快速频繁请求时会出现闪烁,体验很糟。
4.2 地图组件与景点标记:探索页的核心
探索页的地图用的是UniApp内置的map组件,它底层映射微信小程序的map组件。实现逻辑不复杂:拿到用户当前定位坐标,然后查询附近的景点POI,在map上渲染marker标记点。
map组件的核心配置:
<map :latitude="lat" :longitude="lng" :markers="markers" :scale="12" show-location @markertap="onMarkerTap"> </map>marker数据要按微信的要求格式组织:id、latitude、longitude、iconPath、width、height。iconPath可以是自定义的景点类别图标,比如自然景点用一个绿色树图标,人文景点用一个红色建筑图标,这样地图一眼看过去就很有信息层。
@markertap事件返回选中的marker的id,根据id去查询景点详情再跳转。这块有个容易忽略的点:map组件在页面中占位很大,如果地图页和列表页共用同一个页面,建议用tab切换而不是两个页面互跳,这样地图状态和滚动位置都能保持。
4.3 定位授权与距离计算:家政服务式的本地化推荐
距离计算是旅游推荐绕不开的能力。获取定位有两条路:
uni.getLocation:获取经纬度- 微信原生
wx.chooseLocation:让用户在地图上手动选点
我的场景是首页自动定位城市、探索页获取附近景点,所以用uni.getLocation就够了。
uni.getLocation({ type: 'gcj02', isHighAccuracy: true, success: (res) => { this.lat = res.latitude; this.lng = res.longitude; // 调用推荐接口,传入坐标 this.fetchRecommend(); }, fail: () => { // 用户拒绝授权,用默认城市兜底 uni.showToast({ title: '定位失败,显示默认推荐', icon: 'none' }); } });坐标类型用gcj02,这是国测局坐标标准,微信小程序内map组件和接口返回的景点坐标都用这个,不要误用wgs84,否则地图上标记位置会偏几百米。
拿到坐标之后,距离计算是后端做的,前端只负责展示。两点距离用Haversine公式算,这个公式就几行代码,Java里用Math库就能写:
public static double distance(double lat1, double lng1, double lat2, double lng2) { double radLat1 = Math.toRadians(lat1); double radLat2 = Math.toRadians(lat2); double a = radLat1 - radLat2; double b = Math.toRadians(lng1) - Math.toRadians(lng2); double s = 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2) )); return s * 6371.0; // 地球半径6371公里 }注意不要在JS前端算距离。一是后端算可以按距离参与推荐排序,二是前端JS的浮点精度偶尔会出幺蛾子,显示距离多出几十米会被用户吐槽。
4.4 图片资源优化与分包策略
这个项目踩过一个大坑:开发阶段图片全放在static目录里,用了很多体积很大的JPG。结果微信开发者工具编译时报"主包体积超过2MB限制"。后来我把策略改成:
- 所有景点图片全部走CDN,按需加载,不进小程序包
- 只有TabBar图标、默认占位图等必须的本资源才放在static
- 图片URL由后端接口下发给前端,前端直接用
<image>标签的:src属性渲染
对于实在需要打进包里的资源,用image组件的懒加载属性lazy-load,滚动列表时图片进入视口才开始加载,性能提升明显。
如果页面数量多,还可以用微信小程序的分包加载能力,把"景点详情页"等低频页面拆到分包里,主包只放TabBar三个主页面,这样主包体积能控制在2MB以内。UniApp里在pages.json中配置"subPackages"字段即可。
5. 登录鉴权和行为埋点:让推荐系统越用越准的数据闭环
5.1 uni.login静默登录与用户身份绑定
推荐系统要形成数据闭环,前提是知道"谁在操作"。微信小程序登录用的是uni.login,它的核心是拿到一个code,然后后端拿着code去微信服务器换openid和session_key。
uni.login({ provider: 'weixin', success: async (loginRes) => { const res = await api.login({ code: loginRes.code }); uni.setStorageSync('token', res.data.token); } });后端的处理逻辑是:接收前端传上来的code,调用微信的https://api.weixin.qq.com/sns/jscode2session接口,拿到openid。openid是用户的唯一标识,首次登录则创建用户记录,已存在则直接返回token。
整个登录过程对用户是无感的,不用弹窗,不用授权,所以叫"静默登录"。用户第一次打开小程序时,如果用户没有任何明确动作,只要触发了uni.login,后端就已经把这个用户识别出来了。这里的关键点:用户画像和推荐结果都可以先产生,等用户授权手机号后,再把手机号绑定到已有用户记录上,而不是等手机号授权了才建立用户账户。
5.2 手机号授权的正确打开方式
获取手机号这个功能是微信小程序商业化的常见需求,比如做会员系统、优惠券触达。它需要用到button组件的open-type="getPhoneNumber",然后前端拿到code传给后端换手机号。
代码示例:
<button open-type="getPhoneNumber" @getphonenumber="onGetPhoneNumber">授权手机号</button>onGetPhoneNumber(e) { if (e.detail.errMsg === 'getPhoneNumber:ok') { // 将code发送给后端 api.bindPhone({ code: e.detail.code }) .then(res => { uni.showToast({ title: '绑定成功', icon: 'success' }); }); } else { // 用户拒绝授权,不要一直弹窗 uni.showToast({ title: '未授权手机号', icon: 'none' }); } }后端拿这个code调用微信接口换手机号,注意必须传openid,因为手机号换取的接口要求用户必须已经登录,否则返回错误码。
这是产品设计上的一个坑:不要一进小程序就强制用户授权手机号。微信对这种强制授权的审核是零容忍的,而且用户会有强烈的被冒犯感。我的做法是:用户第一次点击"领取优惠券"或"查看完整行程"这类业务需要时,才弹出手机号授权,并且允许用户拒绝。拒绝后仍然可以正常浏览景点,只是部分需要手机号的功能暂时不可用。
5.3 行为埋点:浏览、收藏、搜索的数据采集
有了用户身份,下一步是采集行为。推荐系统的AI含量全靠行为数据的质量,埋点设计直接影响模型效果。
我埋了四类行为,分别对应不同场景:
| 行为类型 | 触发时机 | 权重 |
|---|---|---|
| 浏览 | 进入景点详情页 | 1 |
| 收藏 | 点击收藏按钮 | 3 |
| 搜索 | 搜索关键词 | 2 |
| 停留时长 | 页面停留超过10秒 | 1.5 |
埋点上报用uni.request异步发送,不阻塞用户操作。为了防丢,我在本地的storage里累积了上报事件,然后定时批量上传。这里注意:埋点接口和业务接口最好分开,不要互相阻塞,否则埋点报错会影响正常页面加载。
采集到的行为数据进入user_behavior表后,后端定时任务会定期更新用户画像。有个容易被忽略的细节:行为数据只有用户处于登录状态才有意义。如果在用户未登录时就产生行为,这个行为数据要么不收集,要么等用户登录后再挂载到用户ID上。我是用本地缓存的临时ID来标记未登录用户,登录后把临时ID关联到正式用户ID,避免丢数据。
5.4 画像更新与推荐结果刷新机制
用户画像更新不能每次行为都实时全量计算,那样数据库压力很大。我设计了三个更新粒度:
- 实时更新:用户点击收藏按钮时,同步更新画像中的标签权重,让用户立刻感知到推荐变化
- 定时更新:每天凌晨跑一次批处理,根据当天全部行为重新计算画像
- 手动触发:用户下拉刷新推荐列表时,强制后端做一次增量计算
推荐结果的缓存策略是:推荐结果按用户ID缓存10分钟。用户在10分钟内重复进入首页,直接返回缓存,不用每次重新计算。但如果用户产生了新的行为,则主动清除该用户的推荐缓存,让下一次请求重新计算。这样既能保证实时性,又不至于频繁计算。
最关键的还是"反馈闭环":用户往下滑了很久,点进去看的永远是同一种类型的景点,那说明画像更新可能太滞后了。我会运营同学配合做一个小功能:在推荐流里插入"不喜欢"按钮,用户点掉某个景点后,这个景点的标签权重在当前会话内降低50%,系统会减少同类景点的推荐。这个功能虽然偶尔被吐槽"点不干净",但在画像冷启动阶段非常有用。
6. 发布上线前的配置清单与真机踩坑记录
6.1 request合法域名:上线卡脖子的第一道坎
开发阶段可以在微信开发者工具里勾选"不校验合法域名",上线后这个选项就失效了。微信小程序要求所有uni.request请求的域名必须在微信公众平台后台配置为request合法域名,并且必须是HTTPS。
配置路径:微信公众平台 → 开发管理 → 开发设置 → 服务器域名 → request合法域名。
坑点集中在三个方面。
第一,域名必须备案,而且不能是IP地址。很多团队开发时用http://192.168.1.100:8080,开发阶段没问题,上线前必须换成已备案的HTTPS域名。
第二,域名配置生效有延迟,修改后建议等5到10分钟再测试,不要一改完就狂请求然后怀疑人生。
第三,正式环境不要用uni.request直接访问IP地址或内网域名,微信审核时会直接驳回。遇到过同行为了快速上线,把后端接口放在阿里云函数计算上,结果域名没备案,小程序审核被连续驳回两次,最后只能临时买了个备案域名才解决。
我的建议是:项目建好后第一周就去申请备案域名并配置HTTPS证书,开发阶段就开始用正式域名做联调,不要等到上线前再来折腾。
6.2 主包2MB限制:图片和代码怎么瘦身
微信小程序主包限制2MB,这个数字卡死了无数项目。UniApp项目编译后,JavaScript、WXML、WXSS、静态资源都会计入体积。我的瘦身步骤:
- 第一步,把图片全部移到CDN,远程图片不计入主包体积
- 第二步,不用的组件和库坚决删掉。比如开发阶段引了
moment.js做时间处理,一个库就300KB,后来换成自己写的几行格式化函数,省下来的空间非常可观 - 第三步,低频页面拆到分包,详情页、搜索页、我的足迹都放subPackages里
- 第四步,压缩静态资源,字体图标能用SVG就不要用多张PNG
编译后如果提示source size exceeds max limit,看HBuilderX的编译日志可以知道具体是哪个文件占了大头。我有一个临时"大招":如果实在超了,可以把整个页面用大图懒加载,把一些组件改成按需注入,但这个只能应急,根治还是在资源管理。
6.3 顶部导航栏和胶囊按钮的高度适配
微信小程序的导航栏很特殊,不同机型上胶囊按钮(右上角的"..."和"○")高度不同,刘海屏和非刘海屏的状态栏高度也不同。如果页面里有自定义导航栏(navigationStyle: custom),就得自己适配高度。
获取正确高度的方法:
const systemInfo = uni.getSystemInfoSync(); const statusBarHeight = systemInfo.statusBarHeight; // 状态栏高度 const menuButton = uni.getMenuButtonBoundingClientRect(); // 胶囊按钮位置信息 const navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height;这个计算结果就是自定义导航栏的总高度,页面顶部留出statusBarHeight + navBarHeight的空间,导航栏内容(标题、返回按钮)就不会被胶囊按钮挡住。
坑点提醒:uni.getMenuButtonBoundingClientRect()在部分安卓机型上返回值可能不准确,建议在onReady之后调用,并且处理异常情况设置一个默认值(比如45px)。另外,iPhone 14 Pro等带灵动岛的机型状态栏高度更高,不要写死,必须动态计算。
6.4 体验版发布、审核注意事项与灰度策略
开发完进入发布流程时,有几个容易忽略的细节。
体验版不是正式版。微信开发者工具点"上传"按钮,填好版本号和备注,然后到微信公众平台"版本管理"里把该版本设为体验版。体验版二维码发给测试人员,测试人员必须成为小程序的体验成员才能打开。这里注意,体验版和正式版的域名校验都是强制生效的,不能因为"正在测试"就跳过HTTPS。
审核环节有几个硬性要求需要提前自查:
- 小程序首页必须有实质内容,不能是一张海报或一个登录框
- 涉及旅游信息展示,最好在页面底部放上免责声明,比如"景点信息仅供参考,请以景区实际开放情况为准"
- 用户隐私协议必须明确说明收集了位置信息和行为数据,微信审核对隐私协议越来越严格
- 操作按钮不能诱导分享,比如"分享给好友解锁更多景点"这类逻辑会被判违规
灰度策略上,我习惯在上线初期用"白名单灰度":后端配置一个灰度开关,只让内部测试账号走新版推荐算法,其他用户走旧版本兜底。等观察一段时间数据表现稳定,再逐步放量到100%。这样做最大的好处是,推荐逻辑有问题时不会直接暴露到所有用户面前,回滚也快。
最后说一句个人体会
做这个项目最大的感悟是:推荐系统在小程序场景下,算法权重远不如数据干净度重要。标签打准、行为埋点不漏、用户身份尽早统一,这三件事做好了,哪怕只用简单的公式排序,效果也能吊打那些数据一团糟却硬上深度学习的项目。如果你正准备做类似的需求,我的建议是先把数据模型和埋点设计清楚,再谈推荐策略,顺序反了后面会一直在填坑。另外,上线前一定要用自己的真机完整走一遍从启动到授权的流程,模拟器里跑得通不代表真机上不会出问题。这套方案在中小体量的旅游推荐场景下我已经跑过一遍,你可以放心照着这个思路去搭。