最近在做一个本地生活类的信息服务平台,技术选型是php + uniapp,面向城市商铺分类信息、活动发布与展示这类场景,最终产物要覆盖微信小程序和移动端 App。这个组合乍一看不算新潮,但跑完整个开发、打包、上架、适配流程之后,我反而觉得它对中小团队和外包项目来说,是性价比很高的一个搭配。
这篇文章就把这个项目从设计到落地的全过程梳理一遍,重点说说为什么选这个技术栈、后端接口怎么设计、小程序端有哪些容易被忽略的坑,以及我在打包上架和真机调试中踩过的问题。内容都来自实操,不是理论推演,打算做同类型项目的朋友可以直接拿来参考。
1. 项目整体设计与技术选型思路
1.1 为什么选 php + uniapp,而不是别的组合
最开始也纠结过要不要上 Java + Vue 或者 Node + React Native,但仔细算了一笔账:这个项目的核心不是高并发,不是复杂算法,而是“信息发布 + 分类展示 + 本地生活服务入口”,这类业务的特点是逻辑简单、CRUD 密集、后台管理需求明确、上线周期短。
PHP 在这个场景下的优势非常直接:开发效率高、部署成本低、生态成熟。随便一台虚拟主机或者廉价云服务器都能跑起来,不需要像 Java 那样配一堆中间件。对于预算有限、又希望快速验证商业模式的项目来说,这很重要。我甚至见过不少线上跑得不错的同类型平台,后端就是 PHP,数据库用 MySQL,Redis 都不用上,照样稳定运行。
前端选 uniapp 的理由更简单:一套代码编译到微信小程序、H5、Android、iOS,省掉至少两套开发人力。虽然 uniapp 在复杂动画和重型交互上确实不如原生,但这类信息流展示、表单提交、列表加载为主的业务,它完全游刃有余。更关键的是,uniapp 的生态很完整,UI 库、图表、地图、富文本解析都有现成插件,基本不用从零造轮子。
1.2 业务模块拆解与数据流整理
这个平台的核心业务可以拆成三大块:商铺信息、分类信息、活动服务。听起来简单,但真正开始设计数据库和接口时才发现,每块都有不少细节。
商铺信息不只是“店铺名称 + 地址 + 电话”,还涉及营业时间、经营类目、图片列表、所在商圈、经纬度坐标、评分、浏览量统计这些维度。其中经纬度坐标是必须有的,因为后面要接入地图,做“附近的店铺”这类基于位置的服务。没有坐标,地图上的 marker 就标不出来。
分类信息则更像一个简化版的生活分类广告:转让、招聘、二手、家政、拼车……每条信息可能有图片、有描述、有联系方式,还可能有置顶和过期时间的概念。这里的难点是分类的多层级管理,以及不同分类下字段的差异性。比如招聘信息需要薪资字段,二手转让需要成色描述,家政服务需要技能标签。如果给每个分类建表,后期维护成本极高。我采用的是“主表存公共字段 + 扩展表存自定义字段”的方案,灵活性和查询性能之间取了一个平衡。
活动服务平台是另一个大头。活动有报名机制、有开始和结束时间、有参与人数上限、有活动地点,甚至可能有缴费逻辑和数据看板需求。这块在设计时要重点考虑状态流转:草稿、报名中、进行中、已结束、已取消,这些状态直接影响前端按钮的显示逻辑和列表筛选条件。
数据流实际上是这样走的:用户在小程序端提交商铺入驻申请或发布分类信息 → 后端写入待审核状态 → 管理后台人工审核 → 审核通过后数据进入线上列表 → 用户端通过分类筛选、关键词搜索、地理位置圈选来获取数据。活动模块类似,但多了一个报名和参会的闭环。
1.3 多端适配:微信小程序 vs Android / iOS / 鸿蒙
uniapp 宣称“一套代码,多端运行”,但真到适配环节,你会发现“能跑”和“跑得好”是两码事。
微信小程序端的限制最多,尤其是包体积限制(主包 2MB,总体积 20MB 左右)。我们的项目引入了 uview-plus、mp-html、echarts 这几个重量级插件后,包体积一度逼近上限。解决办法是:把 echarts 按需引入组件模块、图片资源全部转成 CDN 外链、分包加载子模块。比如“活动详情”这种不常访问的页面,不要放在主包里,直接抽到分包里,小程序会自动优先加载主包。
Android 端的坑主要在权限和样式统一上。比如定位权限,Android 13 以后有精细定位和模糊定位的区分;相机权限、相册权限也都需要在 manifest 里声明。另外,uniapp 的 view 组件在 Android WebView 渲染下,部分 css 样式(比如 position: fixed 和 transform 組合)会出现闪屏问题,这在长列表滚动时特别明显。后来我用 scroll-view 替代了部分原生页面滚动,并把动画效果降级,问题才缓解。
iOS 端我遇到过的最典型问题是 canvas 相关的兼容性。项目中有一个分享海报生成的功能,用 canvas 绘制背景图和二维码。在 iOS 的 WebView 环境下,canvas 导出图片偶尔会得到一张白图,这是因为 canvas 绘制异步执行的时序问题,必须在ctx.draw()的回调里再执行uni.canvasToTempFilePath,不能直接跟踪写法。另外 iOS 键盘弹起会把 fixed 定位的元素顶起来,页面布局会被打乱,需要监听键盘高度做适配。
鸿蒙端目前更多是“能跑起来”的状态。uniapp 官方还在持续完善对它的支持,基础的页面渲染、路由跳转、request 请求都没问题,但一些原生插件(比如百度地图定位)还没有完全适配鸿蒙。我的建议是:如果目标用户里有相当比例的鸿蒙设备,务必在开发早期就准备一台真机测试,越早发现问题成本越低。
2. 后端接口设计与数据规范化
2.1 PHP 接口的统一返回结构
做接口联调时最怕什么?最怕每个接口返回的格式都不一样。有的接口返回{status:1},有的返回{code:200},还有的直接返回一个裸数组,前端对接时每个接口都要单独判断,效率极低,还容易出 bug。
我在这个项目里做了统一封装,所有接口都遵循同一个返回结构:
{ "code": 0, "msg": "success", "data": { "list": [], "total": 100, "page": 1 } }code为 0 表示成功,非 0 表示业务层错误,比如参数错误、未登录、无权限。msg是给前端提示文案用的。data里放着真正的业务数据。
前端在uni.request的封装层做了统一拦截:网络请求成功但code != 0时自动弹 toast 提示msg;code == 401时自动跳转登录页。这样业务代码里只需要关注data部分,不需要每个页面都写一遍错误处理逻辑。
后端 PHP 这边用了一个简单的基类方法:
public static function success($data = []) { header('Content-Type: application/json'); echo json_encode(['code' => 0, 'msg' => 'success', 'data' => $data]); exit; } public static function error($msg, $code = 1) { header('Content-Type: application/json'); echo json_encode(['code' => $code, 'msg' => $msg, 'data' => null]); exit; }所有控制器都继承这个基类,返回结果时直接调用对应方法。这样写的好处是全项目只有两个出口,格式想乱都乱不起来。
2.2 参数过滤与“取出数字”这类常见需求
做接口开发时,前端传上来的参数不可信,这是基本常识。我养成了一个习惯:任何参数进到后端,先过滤、再校验、后使用。
比如获取列表页的分页参数,前端传过来的是page和pageSize。正常情况下它们应该是整型,但有很多情况前端会传成字符串,甚至有的第三方框架会传带引号的数字,比如"1"这种。直接拿来做 SQL 拼接很可能出问题。我的做法是强制类型转换:
$page = intval(trim($_GET['page'] ?? 1)); $pageSize = intval(trim($_GET['pageSize'] ?? 10)); if ($page < 1) $page = 1; if ($pageSize < 1 || $pageSize > 100) $pageSize = 10;类似的需求还有:从前端传来的字符串中提取数字。比如店铺编号可能混在订单号里,或者分类 ID 和名称一起传过来。在 PHP 中,最稳妥的取数字方式是:
$num = preg_replace('/\D/', '', $str); // 只保留纯数字这里用正则\D匹配非数字字符并替换掉,剩下的自然就是连续的数字串。如果想要提取出现的第一个数字,用preg_match('/\d+/', $str, $matches),再取$matches[0]即可。这类小工具函数建议统一放在公共函数文件里,全项目复用。
2.3 跨域与 jsonp 的取舍
这个项目的前端分为小程序端和 H5 端,两者的接口请求环境截然不同。
小程序端不存在跨域问题,因为uni.request在小程序环境走的不是一个标准浏览器请求,而是小程序底层的 HTTP 请求,不受同源策略管制。所以做小程序开发时,只要域名在小程序后台配置过合法域名,请求就没问题。
H5 端就没这么幸运了。当你用浏览器打开 H5 页面,然后请求一个不同域名下的 PHP 接口时,标准的同源策略会直接拦截响应。解决办法无非两种:CORS 和 JSONP。
我在项目里选了 CORS,因为它更健壮,支持 POST、PUT 等所有请求方法,而且处理逻辑是在 PHP 后端统一加的响应头:
header('Access-Control-Allow-Origin: *'); // 线上可收紧为具体域名 header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, token'); header('Access-Control-Max-Age: 86400');需要注意的一个坑是:当浏览器发起带自定义请求头(比如靠 token 做登录校验)的跨域请求时,会先发送一个OPTIONS预检请求。这个预检请求必须返回 200,并且带上允许的请求头,否则浏览器会拦截正式请求。很多新手一遇到跨域报错就怀疑是后端没配置跨域,其实很可能只是OPTIONS请求没有被正确响应。
JSONP 在项目里基本没用了,它只能支持 GET 请求,而且存在回调注入的安全风险。除非你的接口只需要给老旧的第三方页面做简单数据对接,否则没必要再用 JSONP。
3. 小程序端核心功能实现详解
3.1 manifest 配置与应用打包上架要点
uniapp 项目里,manifest.json是整个多端配置的中心。微信小程序、App、H5 各自的配置都汇总在这个文件里,每次运行到不同端,HBuilderX 会根据这个文件生成对应平台的配置文件。
微信小程序这边,我踩过最深的一个坑是appid配置。如果你用的是测试号,则无法在正式环境里调起微信登录、支付这些能力,也无法真机预览。这个问题必须在项目初期就解决:去微信公众平台注册正式小程序账号,拿到自己的appid,再填到 manifest 的微信小程序配置项里。
App 端的配置更细。打包安卓安装包之前,至少要做以下几件事:
- 在 manifest 里的 App 模块配置中勾选你用到的基础模块,比如地理位置、地图、分享等。不勾选的话,相关 JS API 调用会直接报错。
- 配置安卓 SDK 里的包名。包名不能跟其他应用重复,否则上架时会被判定为冲突。
- 准备签名证书。安卓应用市场普遍要求使用自有证书签名,没有证书的应用无法上架。在 HBuilderX 的云打包界面里可以生成证书,也可以本地用 Android Studio 生成,注意保管好 keystore 文件,密码丢失就麻烦了。
- 图标和启动页配置。应用市场的审核对图标的规格有要求,建议提前准备好 144x144 以上的高清图标,否则打包后效果会很糊。
上架安卓应用市场的流程也是有一堆细节的。我这次同时提交了华为、小米、OPPO 三个市场,每个市场的审核要求和上架流程都不一样。比如华为要求提供隐私说明、权限说明,还必须进行隐私合规检测;小米要求在应用详情里提供截图和软著证明;OPPO 则要求应用包必须是签名后的 release 版本。建议准备一份统一的“应用说明文档”和“隐私政策页面”,所有市场提交时复制粘贴再少量微调,效率会高很多。
iOS 上架又是另一套体系,需要苹果开发者账号、通过 App Store 审核,成本更高。如果你的目标平台以国内为主,可以先重点做安卓 + 小程序,iOS 的量再观察看看。
3.2 登录态维持与缓存时间设计
这个平台的所有业务接口都依赖登录态。用户登录后,后端返回一个 token 和一个用户信息对象,前端把 token 缓存起来,每次请求时带上。
小程序端的缓存 api 是uni.setStorageSync和uni.getStorageSync。这里有个细节:uni.setStorageSync在没有指定过期时间的情况下是永久保存的,但小程序的 storage 机制在不同端表现不同。微信小程序里 storage 是本地持久化的,不管是冷启动还是热启动,都能读到;App 端也是一样。但 H5 端如果浏览器清缓存或者用户开隐私模式,storage 就可能会丢。
我的设计是 token 的有效期设置为 7 天,前端在用户每次成功请求接口时做一个“滑动续期”逻辑:如果 token 还剩不到 2 天过期,就自动调一次刷新接口,更新 token 和过期时间。这样用户只要每两天至少打开一次应用,就能一直保持登录状态,不需要反复重新登录,体验会舒服很多。
但缓存时间又不能让用户永远不清不楚地保持登录。电商和支付类场景对时效性的要求更高,建议把有效期控制在 30 分钟到 2 小时之间。我们项目的视频和信息流内容对安全性要求中等,7 天是比较合理的折中方案。
3.3 首页信息流与富文本展示(mp-html)
首页是用户第一眼看到的页面,核心诉求是“信息对、加载快、样式统一”。信息流页面需要请求接口,拿到商铺列表或分类信息列表,然后用列表组件渲染。需要注意骨架屏和加载状态的处理,不要让用户看到一片空白页。
富文本这一块,我强烈推荐 mp-html 这个组件。它专门解决小程序端解析 HTML 富文本的问题。比如活动详情、公告内容、商铺介绍,后台用的是富文本编辑器编辑内容,存到数据库里是带 HTML 标签的字符串。小程序原生rich-text组件对 HTML 的支持有限,很多标签和样式解析不出来,比如表格、视频、自定义 class。mp-html 做到了比较完整的解析,支持table、video、img、ul、ol等绝大多数标签,还有lazy-load加载图片的选项,对于内容较长的页面可以显著提升首屏速度。
引入方式也很简单,在 HBuilderX 插件市场搜索 mp-html,导入后直接在页面里使用:
<mp-html :content="detailData.content" />需要注意,mp-html 组件在小程序和 App 端的解析性能有差异。在 App 端,如果富文本内容特别长(比如超过几十 KB),渲染可能较卡,建议在后端截断摘要,详情页再做完整渲染。
4. 地图、图表与分享功能的工程化处理
4.1 百度地图接入与城市定位逻辑
商铺信息平台离不开地图。我计划中的功能“附近的店铺”需要获取用户当前位置的经纬度,然后展示坐标范围内的商铺。
uniapp 里调起地图有两种方式:一是使用内置的uni.getLocation获取定位,然后用uni.openLocation或者uni.chooseLocation打开发地图;二是使用百度地图或者高德地图的原生插件,实现更复杂的自定义地图展示和交互。
这个项目用的是百度地图。在 manifest 的 App 模块配置里勾选“地图-百度地图”,再填入从百度地图开放平台申请的 key。Android 端还需要配置 SHA1 指纹,这里要注意 debug 和 release 的签名指纹不同,两个都要绑定,不然正式包定位会失败。
城市定位这块的逻辑是:用户进入首页时优先使用高精度定位,如果定位失败或者用户主动选择了城市,就读取城市切换器的值。切换城市时,导航栏下方的分类列表要联动刷新,这个状态我放在了 Vuex 里,切换城市时触发分类信息刷新接口。
4.2 echarts 在 uniapp 中的使用技巧
项目中有一个“活动数据看板”功能,需要展示活动报名趋势、参与人数分布、分类占比等图表。echarts 在 H5 端很好用,但小程序端直接引入完整的 echarts 包会把包体积撑爆。我采用的是官方推荐的按需引入方式:
// 只引入需要用到的图表组件 import * as echarts from '@/components/echarts/echarts'; import '@/components/echarts/components/bar-chart'; import '@/components/echarts/components/line-chart';在 uniapp 里,echarts 通常配合renderjs或canvas来使用。我这次在真机调试时发现,小程序端的 canvas 渲染图表存在尺寸计算不准的问题。解决方案是把 canvas 的宽高通过 CSS 强制设定为固定值,或者用 uni 提供的uni.createSelectorQuery()获取真实组件尺寸后再设置图表宽度。
另外,图表数据更新时不要直接重新 setOption,最好先dispose旧实例再重新 init,避免内存泄漏和渲染闪烁。这个细节在安卓低端机上特别明显。
4.3 自定义分享好友与 canvas 导出白图的坑
小程序分享是裂变的核心路径。uniapp 里自定义分享有两种方式:一是通过uni.share调起 App 的分享面板(App 端),二是通过onShareAppMessage配置小程序的分享卡片。
我在做“分享海报”功能时,遇到了最典型也最折磨人的坑:canvas 导出白图。这个问题主要出现在 iOS 上,原因有两个:
一是canvasToTempFilePath必须在ctx.draw()的回调函数里调用。如果 draw 还没执行完就导出,canvas 里还是空白的,导出自然就是白图。正确做法是嵌套回调:
ctx.draw(false, () => { setTimeout(() => { uni.canvasToTempFilePath({ canvasId: 'posterCanvas', success: (res) => { // 得到临时图片路径,可以预览或直接分享 } }); }, 200); // 这个延迟是为了确保渲染完成 });二是绘制图片时,图片源如果存在跨域问题,canvas 里会出现脏数据,导出时就会被浏览器拦截,输出一张白图。解决方案是:先用uni.downloadFile把网络图片下载到本地,拿到临时文件路径后再绘制。小程序端使用网络图片绘制前,一定要在微信公众平台的后台把图片域名配置为合法下载域名。
分享功能的另一个坑是分享卡片标题和图片的配置。onShareAppMessage 返回的对象,小程序必须设置title和path;如果想让分享图片更美观,可以返回imageUrl,建议使用一张长宽比为 5:4 的图片,否则会被裁切。
5. 常见问题汇总与排查实录
5.1 真机调试不打印日志怎么办
uniapp 在 App 端真机调试时,经常遇到 console.log 不输出的情况。一开始我以为是代码问题,排查半天才发现是调试模式设置的问题。HBuilderX 真机运行默认连接的是标准基座,需要在项目 manifest 里把调试模式打开,并且在手机的开发者选项里允许 USB 调试(Android)或通过证书信任(iOS),日志才能真正打出来。
如果按插件市场引入的原生插件导致基础基座没法运行,那基本就是自定义基座或者云打包的活。这种情况建议直接使用 HBuilderX 的“云打包”功能,选择“使用自定义基座运行”,排错路径会顺畅很多。
5.2 顶部导航栏高度计算与动态设置标题
小程序端的顶部导航栏分为“原生导航栏”和“自定义导航栏”两种。使用原生导航栏时,页面标题可以直接通过uni.setNavigationBarTitle动态设置,这也是平台详情页展示不同商铺名称的常用手段。
但原生导航栏有两个限制:背景色只能单调设置、无法插入自定义按钮。如果设计稿里要求导航栏有渐变效果、自定义搜索框或双排样式,就要选自定义导航栏。这带来一个新的问题:状态栏高度在不同机型上不统一,需要用uni.getSystemInfoSync().statusBarHeight动态获取状态栏高度,再去计算导航栏容器的高度。
我实测下来,最稳妥的做法是把自定义导航栏的容器padding-top设置为statusBarHeight,内部再放一个固定 44px 高的导航栏主体。这也是大部分 uniapp 生态 UI 库(如 uview-plus)采用的做法。
5.3 微信小程序单选框等表单组件的适配
项目里有一个“发布分类信息”的表单页,里头用到了单选、多选、图片上传、日期选择等组件。微信小程序的表单组件(radio、checkbox)默认样式很老气,而且在不同端的渲染不一致。
我最终的做法是:不使用默认的单选框,而是用自定义样式实现选项卡片。选中态通过一个active状态来控制 class 切换,这样在 App、小程序、H5 三端样式都能统一。交互上要注意,小程序的radio-group和checkbox-group不能直接监听原生事件来获取选项值,需要设置一个>$param = trim($param, "\"[]'′"); $ids = array_filter(explode(',', $param)); // 过滤空值
这里′是中文引号,必须手动清理。这类细节问题很难在测试阶段发现,往往只有真实用户提交数据时才会踩到。我的建议是,前端提交复杂结构参数时统一使用JSON.stringify,后端统一使用json_decode接收,不要用字符串拼接的方式传参,会省掉很多不必要的麻烦。
另外,PHP 后端接收前端数据时也要注意请求方法。uniapp 的uni.request默认Content-Type是application/json,PHP 端不能用$_POST直接读取,要用file_get_contents('php://input')读取原始请求体,再json_decode解析。这个问题我至少见过三次以上,每次都有人踩进去。
最后再说几个实操中的经验和教训
这个项目跑下来,我最大的体会是:php + uniapp 不是最强的技术组合,但它非常稳。对中小型业务来说,稳定、可控、成本低,比技术栈的“逼格”重要得多。
如果让我给新做同类型项目的朋友一个建议,我会说:先把接口规范定死,把统一返回结构和错误码约定好,再开始写业务代码。双方联调时最耗时的就是“你的格式和我的格式对不上”这种破事,提前统一能省掉一半的联调时间。
还有一个值得反复检查的细节:打包前重新梳理一遍 manifest 里的权限配置和模块勾选。多勾选不用的权限会导致应用市场审核被拒,少勾选了需要的权限又会导致运行时报错。我们第一次打包安卓时就因为漏掉了定位模块,导致“附近的店铺”功能直接崩溃,排查了很久才发现是 SDK 权限没勾上。
希望对正在做同类型项目的朋友有参考价值。有问题可以留言交流,我尽量回复。