最近用 Cursor 写了个 uniapp 小程序,发现 AI 生成的代码比我自己写的更懂页面性能
用 Cursor 优化 uniapp 列表页:虚拟列表与按需更新实践
我为什么开始用 AI 写小程序
先说说我的处境。我在一个小团队做前端,主要用 uniapp + Vue 2 写小程序。之前我一直觉得 AI 写代码就是个玩具,顶多写写 demo 或者补注释,真要上生产还得靠人工调优。但上个月接了个社区团购小程序——商品列表、购物车、订单管理,还要实时显示库存,需求方要求 10 天上线,团队只有两个人。时间紧得我只能硬着头皮试 Cursor。
原本我打算让 AI 把基础页面和 API 对接写完,我自己再改性能问题。结果发现,AI 在我完全没想到的地方自动做了优化,甚至比我手写的代码更清楚小程序页面的性能瓶颈在哪里。
我写的列表页为什么卡?
先自曝一下我的老问题。以前写 uniapp 商品列表页,我习惯用 v-for 直接渲染全部数据,配合 onReachBottom 做分页加载。逻辑很简单:页面加载时请求第一页,滚到底部时请求下一页,新数据 concat 到原数组。列表项里每个商品都带图片、价格、标签、库存状态。
这种写法在小数据量(几十条)时没问题,但列表项超过 200 条就开始卡,尤其低端安卓机上滑动时掉帧明显。我之前的优化手段就是加个 v-if 控制图片懒加载,或者用 wx:if 代替 v-show,效果有限。
用 Cursor 时,我给的提示词很简单:“用 uniapp 写一个商品列表页,支持分页加载,列表项包含图片、价格和库存,注意性能。”它输出的代码让我有点意外,因为它自动做了几件我在写类似页面时根本没想到的事:
- 用 virtual-list 组件代替了 scroll-view + v-for。
- 对图片做了预加载和内存回收。
- 对库存状态变化做了按需更新,而不是整个列表重新渲染。
三个被 AI 提醒的优化细节
- 虚拟列表:不是所有数据都要渲染
AI 生成的代码直接用了 uniapp 的 uni-virtual-list 组件。这个组件我之前知道,但总觉得配置复杂,担心兼容性问题,一直没敢用。AI 却毫不犹豫地用了,而且配置得很干净。
核心思路是只渲染当前可见区域内的列表项,超出视口的 dom 节点被回收。比如一个 500 条数据的列表,实际 dom 节点可能只有 20 个。这直接解决了滑动卡顿的问题。
AI 生成的代码大致长这样(我做了简化):
javascript
{{ item.name }}
{{ item.price }}
而我之前手动写的是嵌套在 scroll-view 里的 v-for。两者性能差距在真机上非常明显:同样 300 条数据,用虚拟列表的页面滑动帧率稳定在 55fps 以上,而我的老写法在低端机上只有 20fps 左右。
坑点注意:uni-virtual-list 要求列表项高度固定,否则会计算错位。如果你的列表项高度不固定(比如有的商品描述文字多、有的少),需要提前做高度估算,或者用 item-key 配合动态高度。AI 在这里没处理动态高度,我是后来手动加了个 setTimeout 重新计算高度才解决的。
- 图片懒加载 + 内存回收:AI 自动加了“垃圾回收”
另一个让我惊讶的点是,AI 在图片加载上做了两层优化:
- 第一层:用 lazy-load 属性,让图片只在进入视口时才加载。
- 第二层:在 onUnload 生命周期里,手动清理图片缓存。
第二层优化我从来没想到过。小程序不像浏览器,它的内存管理很保守,大量图片加载后如果不释放,会导致页面越来越卡。AI 生成的代码里,这样写:
javascript
onUnload() {
this.goodsList.forEach(item => {
if (item.thumb) {
uni.downloadFile({ url: item.thumb, success: (res) => {
// 这里其实应该用 wx.cleanFileCache 之类的方法
// 但 AI 写的是手动释放内存,我改成了调用 SDK 方法
}})
}
})
}
虽然这个写法不够严谨(uni.downloadFile 不负责清理),但它提醒了我一个方向。后来我改成了调用 uni.getSavedFileList 和 uni.removeSavedFile 来清理临时文件。这个优化在用户频繁切换页面时效果很明显,页面回退后不会因为缓存堆积而卡顿。
我踩的坑:AI 生成的代码里,onUnload 中直接循环清理,但如果列表很大(比如 1000 条),这个操作本身就会卡住页面。我后来加了个 nextTick 和分批清理的逻辑,类似这样:
javascript
let batchSize = 50
for (let i = 0; i {
this.goodsList.slice(i, i + batchSize).forEach(...)
}, 0)
}
- 按需更新库存:避免整个列表重渲染
我们项目的库存是实时变化的,用户下单后,其他用户的页面上库存数字要立刻更新。以前我的做法是:收到 WebSocket 推送后,重新请求整个列表数据,然后覆盖 goodsList。这会导致整个列表重新渲染,即使只改了一个商品的库存。
AI 生成的代码里,它用了一个很巧妙的方案:维护一个 stockMap 对象,键是商品 ID,值是库存数量。列表渲染时,直接从 stockMap 读取库存,而不是从商品数据里取。更新时只更新 stockMap 中对应 ID 的值,不触发整个列表的 setData。
代码思路:
javascript
data() {
return {
goodsList: [],
stockMap: {} // { goodsId: stock }
}
},
methods: {
updateStock(goodsId, newStock) {
this.stockMap[goodsId] = newStock
// 只更新 stockMap,不重新设置 goodsList
this.$forceUpdate() // 或者用 Vue.set
}
}
虽然 $forceUpdate 仍然会触发一些组件更新,但比重新设置整个 goodsList 要轻量得多。而且 AI 在模板里用 stockMap[item.id] 而不是 item.stock,这一点我手动改代码时经常忽略。
代价:这个方案会增加内存开销,因为多维护了一个 stockMap 对象。但相比性能提升,这点内存完全可以接受。如果你的列表项超过 10000 条,可以考虑用 Map 代替普通对象,或者用 WeakMap 做自动回收。
AI 不是万能的,需要人工“兜底”
虽然 Cursor 帮我写了不少好代码,但我也发现了一些需要人工处理的问题:
-
虚拟列表的滚动事件兼容性:uni-virtual-list 在 iOS 上偶尔会出现滚动位置偏移,AI 没处理。我手动加了一个 @scroll 事件监听,用 scrollTop 做二次校准。
-
图片懒加载的占位图:AI 生成的代码没给 image 加 placeholder 属性,导致图片加载前显示空白,用户体验不好。我手动加了 @error 和 @load 事件来处理加载失败和成功。
-
库存更新的并发问题:如果 WebSocket 推送太快(比如秒杀场景),stockMap 的更新可能会覆盖。我加了一个队列机制,保证每次更新都是基于最新值。
-
代码可读性:AI 生成的代码虽然高效,但变量命名很随意(比如 item、temp、arr)。如果不重命名,团队协作时别人看不懂。我花了半小时把变量名改成有业务含义的。
总结:AI 帮我打开了性能优化的新视角
这次用 Cursor 写 uniapp 小程序的经历,让我意识到两件事:
- 第一,AI 在性能优化上的“直觉”比很多开发者都强,因为它训练的数据里包含了大量真实项目的性能优化模式,而这些模式我们平时很少主动学习。
- 第二,AI 不能替代开发者的判断力,但可以作为一个“性能顾问”,帮你指出那些你根本没想到的优化点。
如果你也在用 uniapp 做小程序,强烈建议试试让 AI 帮你生成列表页、详情页这类高频渲染的页面。但记得,AI 输出的代码要逐行审查,尤其是边界条件和内存管理。毕竟,最懂你业务逻辑的,还是你自己。
最后,如果你也在用 Cursor 或者 Copilot 写 uniapp,欢迎在评论区分享你遇到的坑和优化技巧。
用 Cursor 优化 uniapp 列表页:虚拟列表与按需更新实践
我为什么开始用 AI 写小程序
先说说我的处境。我在一个小团队做前端,主要用 uniapp + Vue 2 写小程序。之前我一直觉得 AI 写代码就是个玩具,顶多写写 demo 或者补注释,真要上生产还得靠人工调优。但上个月接了个社区团购小程序——商品列表、购物车、订单管理,还要实时显示库存,需求方要求 10 天上线,团队只有两个人。时间紧得我只能硬着头皮试 Cursor。
原本我打算让 AI 把基础页面和 API 对接写完,我自己再改性能问题。结果发现,AI 在我完全没想到的地方自动做了优化,甚至比我手写的代码更清楚小程序页面的性能瓶颈在哪里。
我写的列表页为什么卡?
先自曝一下我的老问题。以前写 uniapp 商品列表页,我习惯用 v-for 直接渲染全部数据,配合 onReachBottom 做分页加载。逻辑很简单:页面加载时请求第一页,滚到底部时请求下一页,新数据 concat 到原数组。列表项里每个商品都带图片、价格、标签、库存状态。
这种写法在小数据量(几十条)时没问题,但列表项超过 200 条就开始卡,尤其低端安卓机上滑动时掉帧明显。我之前的优化手段就是加个 v-if 控制图片懒加载,或者用 wx:if 代替 v-show,效果有限。
用 Cursor 时,我给的提示词很简单:“用 uniapp 写一个商品列表页,支持分页加载,列表项包含图片、价格和库存,注意性能。”它输出的代码让我有点意外,因为它自动做了几件我在写类似页面时根本没想到的事:
- 用 virtual-list 组件代替了 scroll-view + v-for。
- 对图片做了预加载和内存回收。
- 对库存状态变化做了按需更新,而不是整个列表重新渲染。
三个被 AI 提醒的优化细节
- 虚拟列表:不是所有数据都要渲染
AI 生成的代码直接用了 uniapp 的 uni-virtual-list 组件。这个组件我之前知道,但总觉得配置复杂,担心兼容性问题,一直没敢用。AI 却毫不犹豫地用了,而且配置得很干净。
核心思路是只渲染当前可见区域内的列表项,超出视口的 dom 节点被回收。比如一个 500 条数据的列表,实际 dom 节点可能只有 20 个。这直接解决了滑动卡顿的问题。
AI 生成的代码大致长这样(我做了简化):
javascript
{{ item.name }}
{{ item.price }}
而我之前手动写的是嵌套在 scroll-view 里的 v-for。两者性能差距在真机上非常明显:同样 300 条数据,用虚拟列表的页面滑动帧率稳定在 55fps 以上,而我的老写法在低端机上只有 20fps 左右。
坑点注意:uni-virtual-list 要求列表项高度固定,否则会计算错位。如果你的列表项高度不固定(比如有的商品描述文字多、有的少),需要提前做高度估算,或者用 item-key 配合动态高度。AI 在这里没处理动态高度,我是后来手动加了个 setTimeout 重新计算高度才解决的。
- 图片懒加载 + 内存回收:AI 自动加了“垃圾回收”
另一个让我惊讶的点是,AI 在图片加载上做了两层优化:
- 第一层:用 lazy-load 属性,让图片只在进入视口时才加载。
- 第二层:在 onUnload 生命周期里,手动清理图片缓存。
第二层优化我从来没想到过。小程序不像浏览器,它的内存管理很保守,大量图片加载后如果不释放,会导致页面越来越卡。AI 生成的代码里,这样写:
javascript
onUnload() {
this.goodsList.forEach(item => {
if (item.thumb) {
uni.downloadFile({ url: item.thumb, success: (res) => {
// 这里其实应该用 wx.cleanFileCache 之类的方法
// 但 AI 写的是手动释放内存,我改成了调用 SDK 方法
}})
}
})
}
虽然这个写法不够严谨(uni.downloadFile 不负责清理),但它提醒了我一个方向。后来我改成了调用 uni.getSavedFileList 和 uni.removeSavedFile 来清理临时文件。这个优化在用户频繁切换页面时效果很明显,页面回退后不会因为缓存堆积而卡顿。
我踩的坑:AI 生成的代码里,onUnload 中直接循环清理,但如果列表很大(比如 1000 条),这个操作本身就会卡住页面。我后来加了个 nextTick 和分批清理的逻辑,类似这样:
javascript
let batchSize = 50
for (let i = 0; i {
this.goodsList.slice(i, i + batchSize).forEach(...)
}, 0)
}
- 按需更新库存:避免整个列表重渲染
我们项目的库存是实时变化的,用户下单后,其他用户的页面上库存数字要立刻更新。以前我的做法是:收到 WebSocket 推送后,重新请求整个列表数据,然后覆盖 goodsList。这会导致整个列表重新渲染,即使只改了一个商品的库存。
AI 生成的代码里,它用了一个很巧妙的方案:维护一个 stockMap 对象,键是商品 ID,值是库存数量。列表渲染时,直接从 stockMap 读取库存,而不是从商品数据里取。更新时只更新 stockMap 中对应 ID 的值,不触发整个列表的 setData。
代码思路:
javascript
data() {
return {
goodsList: [],
stockMap: {} // { goodsId: stock }
}
},
methods: {
updateStock(goodsId, newStock) {
this.stockMap[goodsId] = newStock
// 只更新 stockMap,不重新设置 goodsList
this.$forceUpdate() // 或者用 Vue.set
}
}
虽然 $forceUpdate 仍然会触发一些组件更新,但比重新设置整个 goodsList 要轻量得多。而且 AI 在模板里用 stockMap[item.id] 而不是 item.stock,这一点我手动改代码时经常忽略。
代价:这个方案会增加内存开销,因为多维护了一个 stockMap 对象。但相比性能提升,这点内存完全可以接受。如果你的列表项超过 10000 条,可以考虑用 Map 代替普通对象,或者用 WeakMap 做自动回收。
AI 不是万能的,需要人工“兜底”
虽然 Cursor 帮我写了不少好代码,但我也发现了一些需要人工处理的问题:
-
虚拟列表的滚动事件兼容性:uni-virtual-list 在 iOS 上偶尔会出现滚动位置偏移,AI 没处理。我手动加了一个 @scroll 事件监听,用 scrollTop 做二次校准。
-
图片懒加载的占位图:AI 生成的代码没给 image 加 placeholder 属性,导致图片加载前显示空白,用户体验不好。我手动加了 @error 和 @load 事件来处理加载失败和成功。
-
库存更新的并发问题:如果 WebSocket 推送太快(比如秒杀场景),stockMap 的更新可能会覆盖。我加了一个队列机制,保证每次更新都是基于最新值。
-
代码可读性:AI 生成的代码虽然高效,但变量命名很随意(比如 item、temp、arr)。如果不重命名,团队协作时别人看不懂。我花了半小时把变量名改成有业务含义的。
总结:AI 帮我打开了性能优化的新视角
这次用 Cursor 写 uniapp 小程序的经历,让我意识到两件事:
- 第一,AI 在性能优化上的“直觉”比很多开发者都强,因为它训练的数据里包含了大量真实项目的性能优化模式,而这些模式我们平时很少主动学习。
- 第二,AI 不能替代开发者的判断力,但可以作为一个“性能顾问”,帮你指出那些你根本没想到的优化点。
如果你也在用 uniapp 做小程序,强烈建议试试让 AI 帮你生成列表页、详情页这类高频渲染的页面。但记得,AI 输出的代码要逐行审查,尤其是边界条件和内存管理。毕竟,最懂你业务逻辑的,还是你自己。
最后,如果你也在用 Cursor 或者 Copilot 写 uniapp,欢迎在评论区分享你遇到的坑和优化技巧。
AI 写了个 Vue3 页面,我却花了半小时改它的“uni-app 兼容性”——这到底是谁在学谁?
我用AI生成商品详情页,结果在iPhone和安卓上各报不同错——这半小时值了
上周接到一个“商品详情页”重构需求,用的uni-app。工期压得紧,我决定赌一把:让AI先写个Vue3版本,我再改成uni-app能跑的。结果AI花10秒生成了一个“完美”页面——轮播图、SKU选择、加购按钮全有,代码干净得像教科书。但我花了整整半小时改兼容性,期间在iPhone 14 Pro Max和一台小米折叠屏上反复测试,白屏、报错、样式崩,一个没落下。
我给了AI一段需求,它给我造了个“浏览器专属”页面
我输入的prompt是:“用Vue3 + Composition API写商品详情页,包含轮播图、SKU选择、加购按钮,数据用mock模拟。”AI立刻吐出代码:、v-model绑定、watch监听SKU变化、onMounted模拟接口请求。结构清晰,逻辑严谨,一眼看去就是“最佳实践”。
但当我把它粘进uni-app项目,直接在微信开发者工具里预览——页面白屏,控制台报错:document is not defined。我盯着屏幕愣了三秒,才反应过来:AI默认跑的是浏览器环境,而uni-app在小程序和App端没有document和window。这个坑,AI永远不会主动告诉我。
半小时里我踩的三个大坑
- 轮播图高度设置:一个document.querySelector引发的连锁反应
AI在轮播图组件里写了这样一行:
javascript
document.querySelector('.swiper').style.height = '300px'
在浏览器里没问题,但在uni-app里直接报错。更隐蔽的是,它还在onMounted里用了window.addEventListener('resize')来监听屏幕旋转——这在H5端勉强能用,但一跑到微信小程序端就失效,因为小程序根本没有window对象。
我一开始还想着“能不能保留这个逻辑,只在H5端跑”,但项目要求覆盖小程序和App端。最后只能全部替换:把document.querySelector改成uni.createSelectorQuery(),把window.resize监听换成CSS的vh/vw加rpx组合,或者干脆用onResize生命周期(只在App和H5支持)。
一个真实教训:在小米折叠屏上测试时,轮播图高度设置成固定300px,结果在展开状态下只占屏幕三分之一,缩成一团。用rpx后,宽度按750设计稿换算,高度用宽高比动态计算,才解决。
- CSS单位与样式隔离:iPhone上正常,安卓上崩了
AI写的轮播图用了width: 100%和height: 300px。在iPhone 14上看着正常,但在我那台华为折叠屏上,轮播图直接缩成一团——因为不同设备物理像素不同,px不会自动缩放。
更麻烦的是,AI用了全局CSS选择器(比如直接写.swiper),而uni-app在H5端有样式隔离,小程序端又用shadow DOM,全局样式经常失效。我一开始没注意,结果在安卓真机上,轮播图底部的指示器样式全乱了——因为全局样式被隔离,没生效。
最后我把所有px改成rpx,所有全局选择器改成scoped,并且用::v-deep穿透子组件(如果子组件是第三方库)。甚至用条件编译处理端差异:
css
/ #ifdef H5 /
.swiper { height: 400px; }
/ #endif /
/ #ifdef MP-WEIXIN /
.swiper { height: 400rpx; }
/ #endif /
条件编译增加了代码量,但这是保证多端一致性的唯一办法。
- 生命周期差异:用户从购物车返回,SKU状态没刷新
AI的SKU选择器用v-for渲染,数据用reactive管理,逻辑上没问题。但它没有处理uni-app特有的“页面生命周期”——比如onShow、onHide。
在Web里,页面切换就是路由跳转,每次进入页面都会重新挂载组件。但小程序里,页面返回时数据可能被缓存,如果不监听onShow,用户从购物车页面返回时,SKU状态不会刷新,导致显示错乱——比如用户之前选了“红色-大号”,返回后页面还显示这个选择,但实际库存可能已经变了。
我补充了uni-app的页面生命周期:
javascript
import { onShow, onHide } from '@dcloudio/uni-app'
onShow(() => {
// 每次页面显示时重新获取SKU库存状态
refreshSkuStatus()
})
同时把原来用watch监听SKU变化改成用computed配合onShow刷新,因为watch在小程序端有时会因作用域问题失效,而computed更稳定。
我是怎么把AI代码“翻译”成uni-app能跑的
既然AI的代码直接不能用,那就把它当“伪代码”来改。我一步步把Web版翻译成uni-app兼容版,主要做了三件事。
第一步:替换所有Web API为uni-app跨端API
把document.querySelector换成uni.createSelectorQuery()。比如动态设置轮播图高度,原来AI写的:
javascript
document.querySelector('.swiper').style.height = '300px'
改成:
javascript
uni.createSelectorQuery().select('.swiper').boundingClientRect(rect => {
// 这里不直接设高度,而是通过data绑定
this.swiperHeight = rect.width * 0.5 // 按宽高比计算
}).exec()
对于window.addEventListener('resize'),我换成了uni-app的onResize生命周期(只在App和H5端支持),或者干脆用CSS的vh/vw加rpx组合实现自适应,避免监听事件。
一个关键发现:不是所有Web API都有uni-app等价物。比如requestAnimationFrame在部分小程序端不支持,我直接改成了setTimeout加帧率控制,虽然性能差一点,但保证多端可用。
第二步:重写CSS,用rpx + scoped + 条件编译
我建了一个mixins文件,把常用尺寸转成rpx:
css
/ 原来AI写的 /
width: 100%;
height: 300px;
/ 改成 /
width: 750rpx; / 按设计稿750px宽度换算 /
height: 400rpx;
对于样式隔离,我把所有全局选择器改成了scoped,并且用::v-deep穿透子组件(如果子组件是第三方库)。更绝的是,我用条件编译处理端差异:
css
/ #ifdef H5 /
.swiper { height: 400px; }
/ #endif /
/ #ifdef MP-WEIXIN /
.swiper { height: 400rpx; }
/ #endif /
一个取舍:条件编译增加了代码量,但这是保证多端一致性的唯一办法。如果项目只做两三个端,可以接受;如果要覆盖全部端,建议直接用rpx统一处理,少用条件编译。
第三步:添加uni-app生命周期钩子
AI的代码只有Vue3的生命周期(onMounted、onUnmounted),我补充了uni-app的页面生命周期:
javascript
import { onShow, onHide } from '@dcloudio/uni-app'
onShow(() => {
// 每次页面显示时重新获取SKU库存状态
refreshSkuStatus()
})
同时,我把原来用watch监听SKU变化改成用computed配合onShow刷新,避免多次重复监听。因为watch在小程序端有时会因作用域问题失效,而computed更稳定。
给想“偷懒”用AI写uni-app的人几条经验
如果你也想用AI生成uni-app代码,这几条经验能帮你省下半小时:
- 明确告诉AI环境:在prompt里加一句“请生成uni-app兼容的代码,不要使用document、window、CSS的px单位,组件用uni-ui”。虽然AI不一定完全照做,但能减少70%的Web特有代码。
- 不要信任AI的“多端适配”:AI经常说“本代码兼容多端”,但实际上它只测试了H5。你必须在真机或模拟器上跑一遍小程序端,才能发现隐藏问题。我就是在小米折叠屏上测试时,才发现轮播图高度和样式隔离的问题。
- 优先用uni-app官方组件:AI喜欢用HTML原生标签(比如、),但在uni-app里应该用、。我改代码时几乎把所有替换成,把换成,并加了mode="widthFix"属性来做图片自适应。
- 小心AI的“过度封装”:AI可能把轮播图、SKU选择器封装成独立组件,但uni-app的组件通信在跨平台时有坑。比如用provide/inject时,小程序端可能无法正确传递响应式数据,我最后改成了props加emit这种最保守的方式。
最后:AI是“翻译官”,不是“全栈工程师”
这半小时的修改让我意识到:AI写代码的核心优势是“快速生成框架和逻辑”,但它缺乏对“平台差异”的认知。它像是一个精通标准英语的翻译官,但你要把它翻译成方言(uni-app),就必须自己懂方言的语法。
最终,我保留AI生成的数据流逻辑(比如SKU计算、购物车数量管理),但重写了80%的UI层和50%的交互层。项目按时交付了,但我也更加确信:在uni-app这种跨端框架里,经验比代码生成更重要。AI可以帮你写90%的“正确代码”,但剩下的10%平台适配,才是真正区分“能用”和“好用”的关键。
下次再用AI写uni-app页面,我会先想清楚:我是想省时间,还是想找个“代码翻译官”给自己添堵?至少现在,我更愿意自己写骨架,让AI帮我补细节——而不是反过来。
我用AI生成商品详情页,结果在iPhone和安卓上各报不同错——这半小时值了
上周接到一个“商品详情页”重构需求,用的uni-app。工期压得紧,我决定赌一把:让AI先写个Vue3版本,我再改成uni-app能跑的。结果AI花10秒生成了一个“完美”页面——轮播图、SKU选择、加购按钮全有,代码干净得像教科书。但我花了整整半小时改兼容性,期间在iPhone 14 Pro Max和一台小米折叠屏上反复测试,白屏、报错、样式崩,一个没落下。
我给了AI一段需求,它给我造了个“浏览器专属”页面
我输入的prompt是:“用Vue3 + Composition API写商品详情页,包含轮播图、SKU选择、加购按钮,数据用mock模拟。”AI立刻吐出代码:、v-model绑定、watch监听SKU变化、onMounted模拟接口请求。结构清晰,逻辑严谨,一眼看去就是“最佳实践”。
但当我把它粘进uni-app项目,直接在微信开发者工具里预览——页面白屏,控制台报错:document is not defined。我盯着屏幕愣了三秒,才反应过来:AI默认跑的是浏览器环境,而uni-app在小程序和App端没有document和window。这个坑,AI永远不会主动告诉我。
半小时里我踩的三个大坑
- 轮播图高度设置:一个document.querySelector引发的连锁反应
AI在轮播图组件里写了这样一行:
javascript
document.querySelector('.swiper').style.height = '300px'
在浏览器里没问题,但在uni-app里直接报错。更隐蔽的是,它还在onMounted里用了window.addEventListener('resize')来监听屏幕旋转——这在H5端勉强能用,但一跑到微信小程序端就失效,因为小程序根本没有window对象。
我一开始还想着“能不能保留这个逻辑,只在H5端跑”,但项目要求覆盖小程序和App端。最后只能全部替换:把document.querySelector改成uni.createSelectorQuery(),把window.resize监听换成CSS的vh/vw加rpx组合,或者干脆用onResize生命周期(只在App和H5支持)。
一个真实教训:在小米折叠屏上测试时,轮播图高度设置成固定300px,结果在展开状态下只占屏幕三分之一,缩成一团。用rpx后,宽度按750设计稿换算,高度用宽高比动态计算,才解决。
- CSS单位与样式隔离:iPhone上正常,安卓上崩了
AI写的轮播图用了width: 100%和height: 300px。在iPhone 14上看着正常,但在我那台华为折叠屏上,轮播图直接缩成一团——因为不同设备物理像素不同,px不会自动缩放。
更麻烦的是,AI用了全局CSS选择器(比如直接写.swiper),而uni-app在H5端有样式隔离,小程序端又用shadow DOM,全局样式经常失效。我一开始没注意,结果在安卓真机上,轮播图底部的指示器样式全乱了——因为全局样式被隔离,没生效。
最后我把所有px改成rpx,所有全局选择器改成scoped,并且用::v-deep穿透子组件(如果子组件是第三方库)。甚至用条件编译处理端差异:
css
/ #ifdef H5 /
.swiper { height: 400px; }
/ #endif /
/ #ifdef MP-WEIXIN /
.swiper { height: 400rpx; }
/ #endif /
条件编译增加了代码量,但这是保证多端一致性的唯一办法。
- 生命周期差异:用户从购物车返回,SKU状态没刷新
AI的SKU选择器用v-for渲染,数据用reactive管理,逻辑上没问题。但它没有处理uni-app特有的“页面生命周期”——比如onShow、onHide。
在Web里,页面切换就是路由跳转,每次进入页面都会重新挂载组件。但小程序里,页面返回时数据可能被缓存,如果不监听onShow,用户从购物车页面返回时,SKU状态不会刷新,导致显示错乱——比如用户之前选了“红色-大号”,返回后页面还显示这个选择,但实际库存可能已经变了。
我补充了uni-app的页面生命周期:
javascript
import { onShow, onHide } from '@dcloudio/uni-app'
onShow(() => {
// 每次页面显示时重新获取SKU库存状态
refreshSkuStatus()
})
同时把原来用watch监听SKU变化改成用computed配合onShow刷新,因为watch在小程序端有时会因作用域问题失效,而computed更稳定。
我是怎么把AI代码“翻译”成uni-app能跑的
既然AI的代码直接不能用,那就把它当“伪代码”来改。我一步步把Web版翻译成uni-app兼容版,主要做了三件事。
第一步:替换所有Web API为uni-app跨端API
把document.querySelector换成uni.createSelectorQuery()。比如动态设置轮播图高度,原来AI写的:
javascript
document.querySelector('.swiper').style.height = '300px'
改成:
javascript
uni.createSelectorQuery().select('.swiper').boundingClientRect(rect => {
// 这里不直接设高度,而是通过data绑定
this.swiperHeight = rect.width * 0.5 // 按宽高比计算
}).exec()
对于window.addEventListener('resize'),我换成了uni-app的onResize生命周期(只在App和H5端支持),或者干脆用CSS的vh/vw加rpx组合实现自适应,避免监听事件。
一个关键发现:不是所有Web API都有uni-app等价物。比如requestAnimationFrame在部分小程序端不支持,我直接改成了setTimeout加帧率控制,虽然性能差一点,但保证多端可用。
第二步:重写CSS,用rpx + scoped + 条件编译
我建了一个mixins文件,把常用尺寸转成rpx:
css
/ 原来AI写的 /
width: 100%;
height: 300px;
/ 改成 /
width: 750rpx; / 按设计稿750px宽度换算 /
height: 400rpx;
对于样式隔离,我把所有全局选择器改成了scoped,并且用::v-deep穿透子组件(如果子组件是第三方库)。更绝的是,我用条件编译处理端差异:
css
/ #ifdef H5 /
.swiper { height: 400px; }
/ #endif /
/ #ifdef MP-WEIXIN /
.swiper { height: 400rpx; }
/ #endif /
一个取舍:条件编译增加了代码量,但这是保证多端一致性的唯一办法。如果项目只做两三个端,可以接受;如果要覆盖全部端,建议直接用rpx统一处理,少用条件编译。
第三步:添加uni-app生命周期钩子
AI的代码只有Vue3的生命周期(onMounted、onUnmounted),我补充了uni-app的页面生命周期:
javascript
import { onShow, onHide } from '@dcloudio/uni-app'
onShow(() => {
// 每次页面显示时重新获取SKU库存状态
refreshSkuStatus()
})
同时,我把原来用watch监听SKU变化改成用computed配合onShow刷新,避免多次重复监听。因为watch在小程序端有时会因作用域问题失效,而computed更稳定。
给想“偷懒”用AI写uni-app的人几条经验
如果你也想用AI生成uni-app代码,这几条经验能帮你省下半小时:
- 明确告诉AI环境:在prompt里加一句“请生成uni-app兼容的代码,不要使用document、window、CSS的px单位,组件用uni-ui”。虽然AI不一定完全照做,但能减少70%的Web特有代码。
- 不要信任AI的“多端适配”:AI经常说“本代码兼容多端”,但实际上它只测试了H5。你必须在真机或模拟器上跑一遍小程序端,才能发现隐藏问题。我就是在小米折叠屏上测试时,才发现轮播图高度和样式隔离的问题。
- 优先用uni-app官方组件:AI喜欢用HTML原生标签(比如、),但在uni-app里应该用、。我改代码时几乎把所有替换成,把换成,并加了mode="widthFix"属性来做图片自适应。
- 小心AI的“过度封装”:AI可能把轮播图、SKU选择器封装成独立组件,但uni-app的组件通信在跨平台时有坑。比如用provide/inject时,小程序端可能无法正确传递响应式数据,我最后改成了props加emit这种最保守的方式。
最后:AI是“翻译官”,不是“全栈工程师”
这半小时的修改让我意识到:AI写代码的核心优势是“快速生成框架和逻辑”,但它缺乏对“平台差异”的认知。它像是一个精通标准英语的翻译官,但你要把它翻译成方言(uni-app),就必须自己懂方言的语法。
最终,我保留AI生成的数据流逻辑(比如SKU计算、购物车数量管理),但重写了80%的UI层和50%的交互层。项目按时交付了,但我也更加确信:在uni-app这种跨端框架里,经验比代码生成更重要。AI可以帮你写90%的“正确代码”,但剩下的10%平台适配,才是真正区分“能用”和“好用”的关键。
下次再用AI写uni-app页面,我会先想清楚:我是想省时间,还是想找个“代码翻译官”给自己添堵?至少现在,我更愿意自己写骨架,让AI帮我补细节——而不是反过来。
收起阅读 »AI 写了个组件,但我调样式调了一下午:聊聊 Cursor 和 uni-app 的磨今天试了用 Cursor 写一个 uni-app 页面,AI 生成的样式比我自己调的还快
用Cursor写uni-app页面:从手动调样式到10分钟出活
上周五下午,产品扔过来一个需求:在uni-app项目里加一个“会员权益对比”页面,横向展示免费版、标准版、专业版三列,每列有功能列表、价格标签、推荐标识,还得适配不同屏幕尺寸。任务不算复杂,但我心里咯噔一下——这种多列布局加自适应,在移动端调样式经常要反复改flex比例、边距、圆角,甚至可能遇到不同机型下的像素偏差。以前我手动搞过一个类似的页面,从写结构到上线,光调试样式就花了整整一下午,最后还在某台老安卓上崩了布局。
这次我决定试试用Cursor(AI辅助编程工具)直接生成页面。结果出乎意料:从输入需求描述到拿到可用的.vue文件,只花了不到10分钟,生成的样式在真机上几乎不用改。这篇文章就聊聊这次实践的具体过程、遇到的坑,以及我对“AI写样式”这件事的真实感受。
为什么手动调样式经常翻车?我踩过的坑
在uni-app里写页面,尤其是带多列对比、自适应、卡片样式的页面,容易踩的坑有几个:
- flex布局的跨端兼容:比如在微信小程序里,某些flex属性(如gap)支持不全,需要手动用margin替代。我之前写对比列表时,因为flex-wrap和align-items的组合在不同端表现不一致,最后不得不写两套样式(H5和小程序分别调)。
- 像素级对齐:三列卡片,每列内容高度不固定,要求所有列的“购买按钮”底部对齐,同时卡片内的文字行高、间距要一致。手动设置min-height和padding很容易出现某列多一行文字就撑破布局的情况。
- 主题色和可维护性:产品要求推荐列(专业版)要有高亮边框和背景,其他两列保持普通样式。如果手动写,需要单独为推荐列加一个class,然后覆盖多个属性(border-color、box-shadow、背景渐变),改起来容易遗漏。
这些问题本质上是因为:样式本身就是“状态叠加”的结果,手动组合时很容易漏掉某个端或某个状态下的表现。而AI生成样式时,会一次性把所有相关属性都写出来,反而减少了遗漏。
我用Cursor生成页面的具体步骤
我的做法很简单:打开Cursor,在聊天窗口里输入一段描述,然后让AI生成一个完整的.vue文件。下面是我用的prompt(核心部分):
请生成一个uni-app的.vue组件,用于展示会员权益对比页面。
要求:
- 三列横向布局:免费版、标准版、专业版,其中专业版为推荐方案,有高亮边框和背景。
- 每列包含:方案名称、价格(免费版显示“免费”,其他显示“¥XX/月”)、功能列表(至少5项,用勾选图标表示是否支持)、一个“立即开通”按钮。
- 功能列表每项文字左对齐,前面有对号/叉号图标。
- 三列卡片的按钮底部对齐。
- 适配各种屏幕宽度,在iPhone SE(小屏)上不溢出,在iPad(大屏)上三列均匀分布。
- 使用scss,变量定义主色#007AFF,推荐列高亮色#FF9500。
- 数据用页面内定义的数组模拟,方便后续改为接口调用。
Cursor生成的代码包含template、script、style三部分。我直接复制到一个新.vue文件里,导入到页面后,在HBuilder的预览里跑了一下,效果基本符合预期。但马上发现了一个问题:专业版的推荐标识(比如“推荐”标签)没有显示。检查代码后发现,AI只生成了高亮边框和背景,但没加标签元素。于是我在聊天里追加了一句:“在专业版卡片顶部加一个‘推荐’角标,使用绝对定位,背景色为#FF9500,白色文字。” 它很快就补上了。
代码思路与关键片段
生成的代码结构清晰,我挑几个关键点说一下:
-
数据定义:AI用了一个数组,每个对象包含name、price、features(数组,每个元素是{text, supported})、recommended(布尔值)。这样后续改接口时,只需要替换数据源,样式基本不用动。
-
布局实现:外层用display: flex; justify-content: space-between;,每列用flex: 1; margin: 0 8rpx;。为了按钮对齐,AI在每列卡片内使用了display: flex; flex-direction: column;,然后让按钮区域margin-top: auto;。这个技巧很实用,避免了手动计算高度。
-
推荐列高亮:通过:class绑定,当item.recommended为true时,加上一个.recommended类,里面写border: 2px solid #FF9500; box-shadow: 0 4px 12px rgba(255,149,0,0.3);。这个做法比我之前手动加多个属性要简洁。
vue
推荐
{{ item.name }}
{{ item.price }}
{{ feat.supported ? '✓' : '✗' }}
{{ feat.text }}
立即开通
.compare-container {
display: flex;
justify-content: space-between;
padding: 20rpx;
}
.card {
flex: 1;
margin: 0 8rpx;
display: flex;
flex-direction: column;
border: 1px solid #e0e0e0;
border-radius: 16rpx;
padding: 24rpx;
background: #fff;
position: relative;
&.recommended {
border: 2px solid #FF9500;
box-shadow: 0 4px 12px rgba(255,149,0,0.3);
}
}
.btn {
margin-top: auto; / 关键:按钮自动对齐到底部 /
background: #007AFF;
color: #fff;
border-radius: 8rpx;
padding: 16rpx;
}
实际运行后我做的几处调整
虽然整体效果不错,但实际运行后我还是做了几处调整:
- 角标定位:AI生成的推荐角标用了position: absolute; top: -10rpx; right: -10rpx;,在真机上角标被卡片的overflow: hidden裁剪了一部分。我改成top: 0; right: 0;并调整了角标的border-radius和padding。
- 图标字体:AI直接用Unicode字符“✓”和“✗”,在小屏上显示正常,但在某些老旧安卓设备上,叉号符号可能显示成方框。我改成了用图片或iconfont图标,保证跨端一致性。
- 按钮点击状态:AI没有写hover-class或:active样式,导致点击按钮时没有反馈。我加了一个简单的hover-class="btn-hover",设置透明度变化。
这些坑其实都不难修,但说明一个问题:AI生成的代码是“理想状态”下的版本,它不会自动考虑极端屏幕、老旧设备或交互细节。作为开发者,我们仍然需要根据自己的项目环境和测试结果做微调。
关于AI写样式的几点真实感受
这次尝试让我对AI辅助前端开发有了更实际的看法:
- 效率提升明显:从0到可用的页面,AI节省了至少70%的样式编写时间。特别是布局和自适应部分,AI能一次性给出完整的flex方案,避免了手动试错。
- 适合“模式化”页面:对比列表、表单、卡片布局这类有固定模式的页面,AI生成的质量很高。但如果是高度定制化的动画、复杂交互,AI目前还容易写出逻辑混乱的代码。
- 必须有人把关:AI生成的样式不会考虑你的项目已有的全局样式、组件库规范、版本兼容性。比如它用了gap属性,但在微信小程序的旧基础库上可能无效。所以,把AI当成一个“高级模板生成器”,而不是替代你的判断。
最后,建议大家在团队里可以试试这种工作流:先用AI生成页面骨架和基础样式,然后人工检查兼容性、交互细节,再接入真实数据。这样既能享受AI的效率,又不会丢掉对代码的控制权。如果你也试过类似的实践,欢迎在评论区聊聊踩过的坑。
用Cursor写uni-app页面:从手动调样式到10分钟出活
上周五下午,产品扔过来一个需求:在uni-app项目里加一个“会员权益对比”页面,横向展示免费版、标准版、专业版三列,每列有功能列表、价格标签、推荐标识,还得适配不同屏幕尺寸。任务不算复杂,但我心里咯噔一下——这种多列布局加自适应,在移动端调样式经常要反复改flex比例、边距、圆角,甚至可能遇到不同机型下的像素偏差。以前我手动搞过一个类似的页面,从写结构到上线,光调试样式就花了整整一下午,最后还在某台老安卓上崩了布局。
这次我决定试试用Cursor(AI辅助编程工具)直接生成页面。结果出乎意料:从输入需求描述到拿到可用的.vue文件,只花了不到10分钟,生成的样式在真机上几乎不用改。这篇文章就聊聊这次实践的具体过程、遇到的坑,以及我对“AI写样式”这件事的真实感受。
为什么手动调样式经常翻车?我踩过的坑
在uni-app里写页面,尤其是带多列对比、自适应、卡片样式的页面,容易踩的坑有几个:
- flex布局的跨端兼容:比如在微信小程序里,某些flex属性(如gap)支持不全,需要手动用margin替代。我之前写对比列表时,因为flex-wrap和align-items的组合在不同端表现不一致,最后不得不写两套样式(H5和小程序分别调)。
- 像素级对齐:三列卡片,每列内容高度不固定,要求所有列的“购买按钮”底部对齐,同时卡片内的文字行高、间距要一致。手动设置min-height和padding很容易出现某列多一行文字就撑破布局的情况。
- 主题色和可维护性:产品要求推荐列(专业版)要有高亮边框和背景,其他两列保持普通样式。如果手动写,需要单独为推荐列加一个class,然后覆盖多个属性(border-color、box-shadow、背景渐变),改起来容易遗漏。
这些问题本质上是因为:样式本身就是“状态叠加”的结果,手动组合时很容易漏掉某个端或某个状态下的表现。而AI生成样式时,会一次性把所有相关属性都写出来,反而减少了遗漏。
我用Cursor生成页面的具体步骤
我的做法很简单:打开Cursor,在聊天窗口里输入一段描述,然后让AI生成一个完整的.vue文件。下面是我用的prompt(核心部分):
请生成一个uni-app的.vue组件,用于展示会员权益对比页面。
要求:
- 三列横向布局:免费版、标准版、专业版,其中专业版为推荐方案,有高亮边框和背景。
- 每列包含:方案名称、价格(免费版显示“免费”,其他显示“¥XX/月”)、功能列表(至少5项,用勾选图标表示是否支持)、一个“立即开通”按钮。
- 功能列表每项文字左对齐,前面有对号/叉号图标。
- 三列卡片的按钮底部对齐。
- 适配各种屏幕宽度,在iPhone SE(小屏)上不溢出,在iPad(大屏)上三列均匀分布。
- 使用scss,变量定义主色#007AFF,推荐列高亮色#FF9500。
- 数据用页面内定义的数组模拟,方便后续改为接口调用。
Cursor生成的代码包含template、script、style三部分。我直接复制到一个新.vue文件里,导入到页面后,在HBuilder的预览里跑了一下,效果基本符合预期。但马上发现了一个问题:专业版的推荐标识(比如“推荐”标签)没有显示。检查代码后发现,AI只生成了高亮边框和背景,但没加标签元素。于是我在聊天里追加了一句:“在专业版卡片顶部加一个‘推荐’角标,使用绝对定位,背景色为#FF9500,白色文字。” 它很快就补上了。
代码思路与关键片段
生成的代码结构清晰,我挑几个关键点说一下:
-
数据定义:AI用了一个数组,每个对象包含name、price、features(数组,每个元素是{text, supported})、recommended(布尔值)。这样后续改接口时,只需要替换数据源,样式基本不用动。
-
布局实现:外层用display: flex; justify-content: space-between;,每列用flex: 1; margin: 0 8rpx;。为了按钮对齐,AI在每列卡片内使用了display: flex; flex-direction: column;,然后让按钮区域margin-top: auto;。这个技巧很实用,避免了手动计算高度。
-
推荐列高亮:通过:class绑定,当item.recommended为true时,加上一个.recommended类,里面写border: 2px solid #FF9500; box-shadow: 0 4px 12px rgba(255,149,0,0.3);。这个做法比我之前手动加多个属性要简洁。
vue
推荐
{{ item.name }}
{{ item.price }}
{{ feat.supported ? '✓' : '✗' }}
{{ feat.text }}
立即开通
.compare-container {
display: flex;
justify-content: space-between;
padding: 20rpx;
}
.card {
flex: 1;
margin: 0 8rpx;
display: flex;
flex-direction: column;
border: 1px solid #e0e0e0;
border-radius: 16rpx;
padding: 24rpx;
background: #fff;
position: relative;
&.recommended {
border: 2px solid #FF9500;
box-shadow: 0 4px 12px rgba(255,149,0,0.3);
}
}
.btn {
margin-top: auto; / 关键:按钮自动对齐到底部 /
background: #007AFF;
color: #fff;
border-radius: 8rpx;
padding: 16rpx;
}
实际运行后我做的几处调整
虽然整体效果不错,但实际运行后我还是做了几处调整:
- 角标定位:AI生成的推荐角标用了position: absolute; top: -10rpx; right: -10rpx;,在真机上角标被卡片的overflow: hidden裁剪了一部分。我改成top: 0; right: 0;并调整了角标的border-radius和padding。
- 图标字体:AI直接用Unicode字符“✓”和“✗”,在小屏上显示正常,但在某些老旧安卓设备上,叉号符号可能显示成方框。我改成了用图片或iconfont图标,保证跨端一致性。
- 按钮点击状态:AI没有写hover-class或:active样式,导致点击按钮时没有反馈。我加了一个简单的hover-class="btn-hover",设置透明度变化。
这些坑其实都不难修,但说明一个问题:AI生成的代码是“理想状态”下的版本,它不会自动考虑极端屏幕、老旧设备或交互细节。作为开发者,我们仍然需要根据自己的项目环境和测试结果做微调。
关于AI写样式的几点真实感受
这次尝试让我对AI辅助前端开发有了更实际的看法:
- 效率提升明显:从0到可用的页面,AI节省了至少70%的样式编写时间。特别是布局和自适应部分,AI能一次性给出完整的flex方案,避免了手动试错。
- 适合“模式化”页面:对比列表、表单、卡片布局这类有固定模式的页面,AI生成的质量很高。但如果是高度定制化的动画、复杂交互,AI目前还容易写出逻辑混乱的代码。
- 必须有人把关:AI生成的样式不会考虑你的项目已有的全局样式、组件库规范、版本兼容性。比如它用了gap属性,但在微信小程序的旧基础库上可能无效。所以,把AI当成一个“高级模板生成器”,而不是替代你的判断。
最后,建议大家在团队里可以试试这种工作流:先用AI生成页面骨架和基础样式,然后人工检查兼容性、交互细节,再接入真实数据。这样既能享受AI的效率,又不会丢掉对代码的控制权。如果你也试过类似的实践,欢迎在评论区聊聊踩过的坑。
Uniapp安卓(包含华为)BLE低功耗蓝牙写入时10007,writeBLECharacteristicValue:fail property not support
Uni-App BLE 蓝牙通讯跨平台适配经验分享
一、背景说明
这篇文章用于整理当前项目在 uni-app 中做 BLE 蓝牙通讯的实战经验,重点覆盖以下内容:
- BLE 蓝牙通讯初始化示例代码;
- BLE 发送命令并等待回包的示例代码;
- 微信小程序、iOS 环境下的标准流程;
- 安卓 APP 环境下的通用兼容处理;
- 华为安卓 APP 环境下的特殊时序兼容处理;
- 实际踩过的问题,以及最终的处理思路。
当前项目的协议属于典型的“一问一答”模式,核心约定如下:
- APP 下发
type=cmd; - 设备回包
type=ack; - 设备主动事件上报为
type=evt; ack需要回显相同的cmd与req;- 当前主控制链路主要使用:
E0F00001-0000-1000-8000-00805F9B34FB:控制命令通道,APP 写入;E0F00002-0000-1000-8000-00805F9B34FB:控制响应通道,设备 Notify 回包。
二、推荐的初始化流程
对于 BLE 通讯,推荐把初始化流程固定成下面这个顺序:
- 初始化蓝牙模块;
- 扫描设备;
- 建立连接;
- 获取服务;
- 协商 MTU;
- 获取特征;
- 注册 Notify 监听;
- 开启响应特征的 Notify;
- 等待链路稳定后再发送首条命令。
注意:
- 微信里和 iOS 基本可以按标准流程直接走;
- 安卓 APP 需要增加“首条命令延迟发送”的兼容;
- 华为安卓 APP 还要进一步把“开启 Notify”本身也延后。
三、BLE 初始化代码示例
下面的示例代码是分享思路用的简化版本。
const SERVICE_UUID = 'E0F00000-0000-1000-8000-00805F9B34FB';
const CHAR_UUIDS = {
COMMAND: 'E0F00001-0000-1000-8000-00805F9B34FB',
RESPONSE: 'E0F00002-0000-1000-8000-00805F9B34FB',
};
let deviceId = '';
let negotiatedMtu = 20;
let notifyReadyAt = 0;
let isFirstCommandAfterConnect = false;
let pendingReqMap = {};
let reqSeed = 1;
/**
* 延迟函数
* 说明:
* 1. BLE 初始化和命令发送时,很多兼容逻辑都依赖显式延时。
* 2. 这里单独抽出,便于统一控制所有时序等待。
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 注册全局 Notify 监听
* 说明:
* 1. 设备通过 RESPONSE 特征回传 ack。
* 2. 这里拿到回包后,通过 req 去匹配对应的待处理 Promise。
* 3. 如果收到 evt,也可以在这里统一分发给页面。
*/
function registerNotifyListener() {
uni.onBLECharacteristicValueChange((res) => {
if ((res.characteristicId || '').toUpperCase() !== CHAR_UUIDS.RESPONSE) {
return;
}
const raw = String.fromCharCode.apply(null, new Uint8Array(res.value));
const packet = JSON.parse(raw);
if (packet.type === 'ack' && packet.req && pendingReqMap[packet.req]) {
const pending = pendingReqMap[packet.req];
clearTimeout(pending.timer);
delete pendingReqMap[packet.req];
pending.resolve(packet);
}
if (packet.type === 'evt') {
console.log('[BLE][evt] 收到设备主动事件:', packet);
}
});
}
/**
* 协商 MTU
* 说明:
* 1. 安卓 / 鸿蒙支持主动设置 MTU。
* 2. iOS 通常由系统自动协商,不需要手动设置。
* 3. 这里的示例只展示安卓风格写法。
*/
function setMtu(currentDeviceId, mtu = 480) {
return new Promise((resolve) => {
if (!uni.setBLEMTU) {
resolve();
return;
}
uni.setBLEMTU({
deviceId: currentDeviceId,
mtu,
success: (res) => {
negotiatedMtu = res.mtu || mtu;
console.log('[BLE] MTU 协商成功:', negotiatedMtu);
resolve();
},
fail: () => {
negotiatedMtu = 20;
resolve();
},
});
});
}
/**
* 标准 BLE 初始化
* 说明:
* 1. 这个函数展示的是“普通安卓 / 微信 / iOS”的标准初始化流程。
* 2. 华为安卓 APP 的特殊处理会放到后面单独说明。
* 3. 当前项目中,实际也是在服务发现后再协商 MTU,再找特征,再开 Notify。
*/
async function initBleConnection(targetDeviceId) {
deviceId = targetDeviceId;
await new Promise((resolve, reject) => {
uni.createBLEConnection({
deviceId,
success: resolve,
fail: reject,
});
});
// 连接建立后先等一小段时间,避免刚连上时底层状态还不稳定。
await delay(300);
await new Promise((resolve, reject) => {
uni.getBLEDeviceServices({
deviceId,
success: resolve,
fail: reject,
});
});
// 安卓平台建议在发现服务后再协商 MTU。
await setMtu(deviceId, 480);
await new Promise((resolve, reject) => {
uni.getBLEDeviceCharacteristics({
deviceId,
serviceId: SERVICE_UUID,
success: resolve,
fail: reject,
});
});
// 先注册监听,避免 Notify 打开后设备太快回包而前端还没开始监听。
registerNotifyListener();
await new Promise((resolve, reject) => {
uni.notifyBLECharacteristicValueChange({
deviceId,
serviceId: SERVICE_UUID,
characteristicId: CHAR_UUIDS.RESPONSE,
state: true,
success: resolve,
fail: reject,
});
});
notifyReadyAt = Date.now();
isFirstCommandAfterConnect = true;
}
四、发送命令代码示例
发送命令时,建议统一封装成“发送 JSON 指令并等待对应 ack”的模式。
/**
* 字符串转 ArrayBuffer
* 说明:
* 1. uni.writeBLECharacteristicValue 需要传 ArrayBuffer。
* 2. 这里把 JSON 字符串编码成 Uint8Array 后再取 buffer。
*/
function strToArrayBuffer(str) {
const encoder = new TextEncoder();
return encoder.encode(str).buffer;
}
/**
* 生成业务报文
* 说明:
* 1. req 用于把一次请求和对应 ack 做精确匹配。
* 2. ts 主要用于诊断日志和设备侧排障。
*/
function buildCmdPacket(cmd, payload = {}) {
const req = reqSeed++;
return {
_req: req,
ver: '1.0',
type: 'cmd',
cmd,
req,
ts: Date.now(),
payload,
};
}
/**
* 按 MTU 分包写入
* 说明:
* 1. 大部分短命令在 MTU=480 时可以单包写完。
* 2. 这里仍保留分包逻辑,作为 MTU 协商失败时的兜底。
* 3. 安卓平台多包之间建议保留短暂间隔,减少底层写队列拥堵。
*/
async function writeInChunks(currentDeviceId, buffer, writeType = 'write') {
const chunkSize = Math.max(negotiatedMtu - 3, 20);
const bytes = new Uint8Array(buffer);
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
const chunk = bytes.slice(offset, offset + chunkSize);
await new Promise((resolve, reject) => {
uni.writeBLECharacteristicValue({
deviceId: currentDeviceId,
serviceId: SERVICE_UUID,
characteristicId: CHAR_UUIDS.COMMAND,
value: chunk.buffer,
writeType,
success: resolve,
fail: reject,
});
});
// 安卓多包写入建议保留一个小间隔,降低连续写入过快导致失败的概率。
await delay(180);
}
}
/**
* 发送命令并等待 ack
* 说明:
* 1. 首条命令在安卓 APP 环境通常不要紧接着 Notify 成功后立即发送。
* 2. 微信和 iOS 大多数情况下可以按标准流程直接写入。
* 3. 这里为了演示,统一保留了“首条命令延迟发送”的处理。
*/
async function sendCommand(cmd, payload = {}, timeout = 10000) {
const packet = buildCmdPacket(cmd, payload);
const req = packet._req;
delete packet._req;
if (isFirstCommandAfterConnect) {
const waitMs = Math.max(notifyReadyAt + 1000 - Date.now(), 0);
if (waitMs > 0) {
await delay(waitMs);
}
isFirstCommandAfterConnect = false;
}
const json = JSON.stringify(packet);
const buffer = strToArrayBuffer(json);
return new Promise(async (resolve, reject) => {
const timer = setTimeout(() => {
delete pendingReqMap[req];
reject(new Error(`命令 ${cmd} 超时`));
}, timeout);
pendingReqMap[req] = { resolve, reject, timer };
try {
await writeInChunks(deviceId, buffer, 'write');
} catch (err) {
clearTimeout(timer);
delete pendingReqMap[req];
reject(err);
}
});
}
/**
* 示例:读取设备信息
* 说明:
* 1. 当前项目里最常用的测试命令之一就是 device_info_get。
* 2. 这个命令非常适合用来验证“连接成功后整条链路是否已经打通”。
*/
async function getDeviceInfo() {
const ack = await sendCommand('device_info_get', {});
return ack.payload.info;
}
五、微信小程序与 iOS:按标准流程走即可
从当前项目的实测结果来看:
- 微信小程序:标准流程稳定;
- iOS APP:标准流程稳定;
- 安卓非华为:大多数情况下标准流程也能跑通,但建议保留通用兼容处理。
微信小程序与 iOS 的推荐流程如下:
- 连接设备;
- 获取服务;
- 获取特征;
- 开启响应特征 Notify;
- 注册监听;
- 发送首条业务命令;
- 设备返回 ack;
- 前端按
req匹配回包。
标准时序图如下:
APP 设备
| |
|-- 连接设备 ----------------------------------------->|
|<------------------------- 连接成功 -------------------|
|-- 获取服务 ----------------------------------------->|
|<------------------------- 服务列表 -------------------|
|-- 获取特征 ----------------------------------------->|
|<------------------------- 特征列表 -------------------|
|-- 开启 E0F00002 Notify ----------------------------->|
|<------------------------- Notify 开启成功 ------------|
|-- 写 E0F00001: type=cmd ---------------------------->|
| |-- 执行业务逻辑
|<===== Notify E0F00002: type=ack =====================|
|-- 按 req 匹配 pending 请求并返回结果 ----------------|
六、安卓 APP:需要增加通用兼容处理
虽然安卓非华为很多时候也能按标准流程直接成功,但在真实项目里,建议仍然加上通用兼容逻辑,原因是安卓不同 ROM、不同蓝牙芯片和不同系统版本差异很大。
当前项目对安卓 APP 的通用处理主要包括:
1. 连接成功后先等 300ms
目的:
- 避免刚建立连接后马上做 GATT 操作,底层状态还没稳定。
2. 发现服务后再协商 MTU
目的:
- 避免过早协商 MTU 导致某些安卓机型初始化顺序异常。
3. Notify 开启后,首条命令额外延后 1000ms
目的:
- 减少 CCCD 刚写成功后,立即写命令造成失败的概率。
4. 如果首写失败,再补等到 1600ms 再重试一次
目的:
- 对偶发的
10007做一次时序层面的兜底。
5. 多包写入时每包间隔约 180ms
目的:
- 避免连续快速写入把安卓底层队列打满。
6. 写入失败时尝试切换 writeType
当前项目会在必要时尝试:
writeomitwriteNoResponse
这样做的原因是:
- 不同安卓机型对
writeType的兼容表现不完全一致; - 某些机型表面上特征支持
write,但实际写入时仍然会表现异常; - 多一种候选写法,能提高项目现场的兼容成功率。
七、华为安卓 APP:不是普通安卓问题,而是更强的时序敏感
华为安卓 APP 是当前项目里最典型、也最容易踩坑的平台。
1. 第一阶段遇到的问题
一开始按标准流程:
- 连接成功;
- 发现特征;
- 立即开启 Notify;
- 很快发送首条命令;
结果经常出现错误:
writeBLECharacteristicValue:fail property not support- 错误码
10007
这个现象的关键点是:
- 设备的
E0F00001明明支持写; - 微信小程序、iOS、小米安卓都能正常工作;
- 只有华为安卓 APP 在“开 Notify 后很快发送首条命令”时容易失败。
所以问题不在协议本身,而在华为安卓 APP 下的 GATT 时序。
2. 第二阶段验证后的新结论
后续继续做时序测试后发现:
- 华为并不是“绝对不能开 Notify”;
- 真正敏感的是“何时开 Notify”和“开完 Notify 后多久发送第一条命令”;
- 如果调整成更保守的时序,标准 Notify 链路是可以恢复成功的。
3. 华为当前推荐时序
当前项目里,华为安卓 APP 推荐按下面这个顺序走:
- 连接设备;
- 获取服务;
- 协商 MTU;
- 获取特征;
- 先注册监听;
- 发现特征后等待约
2000ms; - 开启
E0F00002的 Notify; - Notify 成功后再等待约
2000ms; - 再发送首条业务命令;
- 后续按标准 Notify 回包处理。
对应简化时序如下:
APP 设备
| |
|-- 连接设备 ----------------------------------------->|
|<------------------------- 连接成功 -------------------|
|-- 获取服务 / MTU / 特征 ----------------------------->|
|<------------------------- 初始化完成 -----------------|
|-- 注册监听 ------------------------------------------|
|-- 等待约 2 秒 -------------------------------------->|
|-- 开启 E0F00002 Notify ----------------------------->|
|<------------------------- Notify 开启成功 ------------|
|-- 再等待约 2 秒 ------------------------------------>|
|-- 写 E0F00001: type=cmd ---------------------------->|
| |-- 执行业务逻辑
|<===== Notify E0F00002: type=ack =====================|
|-- 按 req 匹配 pending 请求并返回结果 ----------------|
4. 华为兼容代码示例
下面是一个简化后的华为时序示例:
/**
* 判断是否属于华为安卓 APP
* 说明:
* 1. 项目里通常会结合 brand、manufacturer、romName、osName 等信息综合判断。
* 2. 这里示例只表达“识别华为家族设备后走更严格的时序”这个思路。
*/
function isHuaweiAndroidApp(systemInfo) {
const brand = (systemInfo.brand || '').toLowerCase();
const platform = (systemInfo.platform || '').toLowerCase();
return platform === 'android' && (brand.includes('huawei') || brand.includes('honor'));
}
/**
* 华为安卓 APP 初始化
* 说明:
* 1. 和普通安卓相比,关键差异是“先等 2 秒再开 Notify”。
* 2. Notify 成功后,还要把首条命令的发送时间继续往后推。
*/
async function initBleConnectionForHuawei(targetDeviceId) {
const systemInfo = uni.getSystemInfoSync();
await initBleConnectionBase(targetDeviceId);
if (isHuaweiAndroidApp(systemInfo)) {
// 发现特征后,不要马上开 Notify,先等待底层 GATT 队列更稳定。
await delay(2000);
await new Promise((resolve, reject) => {
uni.notifyBLECharacteristicValueChange({
deviceId: targetDeviceId,
serviceId: SERVICE_UUID,
characteristicId: CHAR_UUIDS.RESPONSE,
state: true,
success: resolve,
fail: reject,
});
});
// 记录 Notify 就绪时刻,后面首条命令还要继续延迟。
notifyReadyAt = Date.now();
isFirstCommandAfterConnect = true;
}
}
/**
* 华为首条命令发送保护
* 说明:
* 1. 华为不是只延后开启 Notify,还要延后首条命令。
* 2. 当前项目里实测可用的是“Notify 成功后再等待约 2 秒”。
*/
async function prepareBeforeHuaweiFirstCommand() {
const waitMs = Math.max(notifyReadyAt + 2000 - Date.now(), 0);
if (waitMs > 0) {
await delay(waitMs);
}
isFirstCommandAfterConnect = false;
}
八、一次次遇到的问题,以及如何解决
下面按真实踩坑顺序,总结一次次遇到的问题和处理思路。
问题 1:标准流程在华为安卓 APP 下出现 10007
现象:
- 设备连接成功;
- 服务、特征都发现成功;
- 一旦开启 Notify 后很快发送首条命令;
- 就出现
writeBLECharacteristicValue:fail property not support; - 命令实际没有写到设备。
排查结果:
- 设备并不是不支持写;
- 协议命令也没有错;
- 因为微信小程序、iOS、小米安卓都正常;
- 所以问题集中在华为安卓 APP 下的 GATT 时序。
解决办法:
- 不再把“Notify 开启”和“首条命令发送”挤得太近;
- 改成更保守的延后时序。
问题 2:只是“开完 Notify 后等 2 秒”仍然不稳定
现象:
- 有些测试里,仅仅把“开完 Notify 后发送命令”延后到 2 秒,仍然失败;
- 说明不仅是首条命令时间有问题,Notify 本身的开启时机也有问题。
排查结果:
- 华为底层可能对 CCCD 写入前后的 GATT 操作都比较敏感;
- 不能只延后命令发送,还要延后 Notify 的开启时机。
解决办法:
- 发现特征后先等待约
2000ms; - 再开启 Notify;
- Notify 成功后再等待约
2000ms; - 然后再发首条命令。
问题 3:安卓平台个别机型偶发首写失败
现象:
- 非华为安卓大多数可以成功;
- 但个别安卓机型偶发首条命令失败。
排查结果:
- 很多时候不是协议问题,而是初始化顺序、首写时机、包间隔过快导致。
解决办法:
- 连接后先等
300ms; - 服务发现后再协商 MTU;
- Notify 开启后首条命令默认等
1000ms; - 如果首写报
10007,再补等到1600ms再重试一次; - 多包写入时每包间隔约
180ms。
问题 4:设备日志出现 GATTS_SendRsp / Sending response failed
现象:
- 设备日志里可见:
BT_GATT: GATTS_SendRsp ... waiting for op_code = 00BT_APPL: Sending response failed
排查结果:
- 从当前成功样本看,这两条日志并没有阻断最终
ack返回; - 所以它是固件端需要单独排查的问题,但不是当前 APP 成功与否的唯一决定因素。
解决办法:
- 前端层面先把 BLE 时序兼容处理到位;
- 固件侧继续检查是否存在“自动响应模式”和“手动响应”冲突问题。
问题 5:客户现场无法提供足够日志
现象:
- 很多 BLE 问题只在客户手机上出现;
- 客户通常无法提供完整控制台日志。
解决办法:
- 在前端维护最近 BLE 关键日志的内存缓冲区;
- 页面出错时,把系统信息、设备信息、BLE 快照、当前阶段、最近日志统一上传后端;
- 这样即使用户不会看 console,也能还原失败链路。
九、平台建议总结
当前建议如下:
- 微信小程序:按标准 BLE 流程开发;
- iOS APP:按标准 BLE 流程开发;
- 安卓 APP(非华为):按标准流程开发,但建议保留通用兼容时序;
- 华为安卓 APP:必须走“延后开 Notify + 延后首发命令”的兼容逻辑;
- 纯血鸿蒙:还需要继续单独验证。
十、结语
这次 BLE 跨平台兼容实践最重要的经验,不是“协议写对了就一定能通”,而是:
- BLE 在不同平台、不同 ROM、不同蓝牙栈上的时序表现差异非常大;
- 微信小程序和 iOS 相对标准;
- 安卓需要更谨慎地处理首条命令发送时机;
- 华为安卓 APP 对 Notify 时序尤其敏感;
- 只要日志留得够完整,问题最终都能从“看起来像协议错了”还原到“其实是平台时序差异”。
如果后续要继续提升稳定性,建议优先做两件事:
- 继续验证更多业务指令在华为安卓 APP 下的稳定性;
- 继续验证设备主动上报
evt在当前延后时序下是否同样稳定。
以上经验分享,你可以把以上信息扔给AI,让AI封装一个符合你项目的bleHelper.js工具类,用于处理BLE通讯。
Uni-App BLE 蓝牙通讯跨平台适配经验分享
一、背景说明
这篇文章用于整理当前项目在 uni-app 中做 BLE 蓝牙通讯的实战经验,重点覆盖以下内容:
- BLE 蓝牙通讯初始化示例代码;
- BLE 发送命令并等待回包的示例代码;
- 微信小程序、iOS 环境下的标准流程;
- 安卓 APP 环境下的通用兼容处理;
- 华为安卓 APP 环境下的特殊时序兼容处理;
- 实际踩过的问题,以及最终的处理思路。
当前项目的协议属于典型的“一问一答”模式,核心约定如下:
- APP 下发
type=cmd; - 设备回包
type=ack; - 设备主动事件上报为
type=evt; ack需要回显相同的cmd与req;- 当前主控制链路主要使用:
E0F00001-0000-1000-8000-00805F9B34FB:控制命令通道,APP 写入;E0F00002-0000-1000-8000-00805F9B34FB:控制响应通道,设备 Notify 回包。
二、推荐的初始化流程
对于 BLE 通讯,推荐把初始化流程固定成下面这个顺序:
- 初始化蓝牙模块;
- 扫描设备;
- 建立连接;
- 获取服务;
- 协商 MTU;
- 获取特征;
- 注册 Notify 监听;
- 开启响应特征的 Notify;
- 等待链路稳定后再发送首条命令。
注意:
- 微信里和 iOS 基本可以按标准流程直接走;
- 安卓 APP 需要增加“首条命令延迟发送”的兼容;
- 华为安卓 APP 还要进一步把“开启 Notify”本身也延后。
三、BLE 初始化代码示例
下面的示例代码是分享思路用的简化版本。
const SERVICE_UUID = 'E0F00000-0000-1000-8000-00805F9B34FB';
const CHAR_UUIDS = {
COMMAND: 'E0F00001-0000-1000-8000-00805F9B34FB',
RESPONSE: 'E0F00002-0000-1000-8000-00805F9B34FB',
};
let deviceId = '';
let negotiatedMtu = 20;
let notifyReadyAt = 0;
let isFirstCommandAfterConnect = false;
let pendingReqMap = {};
let reqSeed = 1;
/**
* 延迟函数
* 说明:
* 1. BLE 初始化和命令发送时,很多兼容逻辑都依赖显式延时。
* 2. 这里单独抽出,便于统一控制所有时序等待。
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 注册全局 Notify 监听
* 说明:
* 1. 设备通过 RESPONSE 特征回传 ack。
* 2. 这里拿到回包后,通过 req 去匹配对应的待处理 Promise。
* 3. 如果收到 evt,也可以在这里统一分发给页面。
*/
function registerNotifyListener() {
uni.onBLECharacteristicValueChange((res) => {
if ((res.characteristicId || '').toUpperCase() !== CHAR_UUIDS.RESPONSE) {
return;
}
const raw = String.fromCharCode.apply(null, new Uint8Array(res.value));
const packet = JSON.parse(raw);
if (packet.type === 'ack' && packet.req && pendingReqMap[packet.req]) {
const pending = pendingReqMap[packet.req];
clearTimeout(pending.timer);
delete pendingReqMap[packet.req];
pending.resolve(packet);
}
if (packet.type === 'evt') {
console.log('[BLE][evt] 收到设备主动事件:', packet);
}
});
}
/**
* 协商 MTU
* 说明:
* 1. 安卓 / 鸿蒙支持主动设置 MTU。
* 2. iOS 通常由系统自动协商,不需要手动设置。
* 3. 这里的示例只展示安卓风格写法。
*/
function setMtu(currentDeviceId, mtu = 480) {
return new Promise((resolve) => {
if (!uni.setBLEMTU) {
resolve();
return;
}
uni.setBLEMTU({
deviceId: currentDeviceId,
mtu,
success: (res) => {
negotiatedMtu = res.mtu || mtu;
console.log('[BLE] MTU 协商成功:', negotiatedMtu);
resolve();
},
fail: () => {
negotiatedMtu = 20;
resolve();
},
});
});
}
/**
* 标准 BLE 初始化
* 说明:
* 1. 这个函数展示的是“普通安卓 / 微信 / iOS”的标准初始化流程。
* 2. 华为安卓 APP 的特殊处理会放到后面单独说明。
* 3. 当前项目中,实际也是在服务发现后再协商 MTU,再找特征,再开 Notify。
*/
async function initBleConnection(targetDeviceId) {
deviceId = targetDeviceId;
await new Promise((resolve, reject) => {
uni.createBLEConnection({
deviceId,
success: resolve,
fail: reject,
});
});
// 连接建立后先等一小段时间,避免刚连上时底层状态还不稳定。
await delay(300);
await new Promise((resolve, reject) => {
uni.getBLEDeviceServices({
deviceId,
success: resolve,
fail: reject,
});
});
// 安卓平台建议在发现服务后再协商 MTU。
await setMtu(deviceId, 480);
await new Promise((resolve, reject) => {
uni.getBLEDeviceCharacteristics({
deviceId,
serviceId: SERVICE_UUID,
success: resolve,
fail: reject,
});
});
// 先注册监听,避免 Notify 打开后设备太快回包而前端还没开始监听。
registerNotifyListener();
await new Promise((resolve, reject) => {
uni.notifyBLECharacteristicValueChange({
deviceId,
serviceId: SERVICE_UUID,
characteristicId: CHAR_UUIDS.RESPONSE,
state: true,
success: resolve,
fail: reject,
});
});
notifyReadyAt = Date.now();
isFirstCommandAfterConnect = true;
}
四、发送命令代码示例
发送命令时,建议统一封装成“发送 JSON 指令并等待对应 ack”的模式。
/**
* 字符串转 ArrayBuffer
* 说明:
* 1. uni.writeBLECharacteristicValue 需要传 ArrayBuffer。
* 2. 这里把 JSON 字符串编码成 Uint8Array 后再取 buffer。
*/
function strToArrayBuffer(str) {
const encoder = new TextEncoder();
return encoder.encode(str).buffer;
}
/**
* 生成业务报文
* 说明:
* 1. req 用于把一次请求和对应 ack 做精确匹配。
* 2. ts 主要用于诊断日志和设备侧排障。
*/
function buildCmdPacket(cmd, payload = {}) {
const req = reqSeed++;
return {
_req: req,
ver: '1.0',
type: 'cmd',
cmd,
req,
ts: Date.now(),
payload,
};
}
/**
* 按 MTU 分包写入
* 说明:
* 1. 大部分短命令在 MTU=480 时可以单包写完。
* 2. 这里仍保留分包逻辑,作为 MTU 协商失败时的兜底。
* 3. 安卓平台多包之间建议保留短暂间隔,减少底层写队列拥堵。
*/
async function writeInChunks(currentDeviceId, buffer, writeType = 'write') {
const chunkSize = Math.max(negotiatedMtu - 3, 20);
const bytes = new Uint8Array(buffer);
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
const chunk = bytes.slice(offset, offset + chunkSize);
await new Promise((resolve, reject) => {
uni.writeBLECharacteristicValue({
deviceId: currentDeviceId,
serviceId: SERVICE_UUID,
characteristicId: CHAR_UUIDS.COMMAND,
value: chunk.buffer,
writeType,
success: resolve,
fail: reject,
});
});
// 安卓多包写入建议保留一个小间隔,降低连续写入过快导致失败的概率。
await delay(180);
}
}
/**
* 发送命令并等待 ack
* 说明:
* 1. 首条命令在安卓 APP 环境通常不要紧接着 Notify 成功后立即发送。
* 2. 微信和 iOS 大多数情况下可以按标准流程直接写入。
* 3. 这里为了演示,统一保留了“首条命令延迟发送”的处理。
*/
async function sendCommand(cmd, payload = {}, timeout = 10000) {
const packet = buildCmdPacket(cmd, payload);
const req = packet._req;
delete packet._req;
if (isFirstCommandAfterConnect) {
const waitMs = Math.max(notifyReadyAt + 1000 - Date.now(), 0);
if (waitMs > 0) {
await delay(waitMs);
}
isFirstCommandAfterConnect = false;
}
const json = JSON.stringify(packet);
const buffer = strToArrayBuffer(json);
return new Promise(async (resolve, reject) => {
const timer = setTimeout(() => {
delete pendingReqMap[req];
reject(new Error(`命令 ${cmd} 超时`));
}, timeout);
pendingReqMap[req] = { resolve, reject, timer };
try {
await writeInChunks(deviceId, buffer, 'write');
} catch (err) {
clearTimeout(timer);
delete pendingReqMap[req];
reject(err);
}
});
}
/**
* 示例:读取设备信息
* 说明:
* 1. 当前项目里最常用的测试命令之一就是 device_info_get。
* 2. 这个命令非常适合用来验证“连接成功后整条链路是否已经打通”。
*/
async function getDeviceInfo() {
const ack = await sendCommand('device_info_get', {});
return ack.payload.info;
}
五、微信小程序与 iOS:按标准流程走即可
从当前项目的实测结果来看:
- 微信小程序:标准流程稳定;
- iOS APP:标准流程稳定;
- 安卓非华为:大多数情况下标准流程也能跑通,但建议保留通用兼容处理。
微信小程序与 iOS 的推荐流程如下:
- 连接设备;
- 获取服务;
- 获取特征;
- 开启响应特征 Notify;
- 注册监听;
- 发送首条业务命令;
- 设备返回 ack;
- 前端按
req匹配回包。
标准时序图如下:
APP 设备
| |
|-- 连接设备 ----------------------------------------->|
|<------------------------- 连接成功 -------------------|
|-- 获取服务 ----------------------------------------->|
|<------------------------- 服务列表 -------------------|
|-- 获取特征 ----------------------------------------->|
|<------------------------- 特征列表 -------------------|
|-- 开启 E0F00002 Notify ----------------------------->|
|<------------------------- Notify 开启成功 ------------|
|-- 写 E0F00001: type=cmd ---------------------------->|
| |-- 执行业务逻辑
|<===== Notify E0F00002: type=ack =====================|
|-- 按 req 匹配 pending 请求并返回结果 ----------------|
六、安卓 APP:需要增加通用兼容处理
虽然安卓非华为很多时候也能按标准流程直接成功,但在真实项目里,建议仍然加上通用兼容逻辑,原因是安卓不同 ROM、不同蓝牙芯片和不同系统版本差异很大。
当前项目对安卓 APP 的通用处理主要包括:
1. 连接成功后先等 300ms
目的:
- 避免刚建立连接后马上做 GATT 操作,底层状态还没稳定。
2. 发现服务后再协商 MTU
目的:
- 避免过早协商 MTU 导致某些安卓机型初始化顺序异常。
3. Notify 开启后,首条命令额外延后 1000ms
目的:
- 减少 CCCD 刚写成功后,立即写命令造成失败的概率。
4. 如果首写失败,再补等到 1600ms 再重试一次
目的:
- 对偶发的
10007做一次时序层面的兜底。
5. 多包写入时每包间隔约 180ms
目的:
- 避免连续快速写入把安卓底层队列打满。
6. 写入失败时尝试切换 writeType
当前项目会在必要时尝试:
writeomitwriteNoResponse
这样做的原因是:
- 不同安卓机型对
writeType的兼容表现不完全一致; - 某些机型表面上特征支持
write,但实际写入时仍然会表现异常; - 多一种候选写法,能提高项目现场的兼容成功率。
七、华为安卓 APP:不是普通安卓问题,而是更强的时序敏感
华为安卓 APP 是当前项目里最典型、也最容易踩坑的平台。
1. 第一阶段遇到的问题
一开始按标准流程:
- 连接成功;
- 发现特征;
- 立即开启 Notify;
- 很快发送首条命令;
结果经常出现错误:
writeBLECharacteristicValue:fail property not support- 错误码
10007
这个现象的关键点是:
- 设备的
E0F00001明明支持写; - 微信小程序、iOS、小米安卓都能正常工作;
- 只有华为安卓 APP 在“开 Notify 后很快发送首条命令”时容易失败。
所以问题不在协议本身,而在华为安卓 APP 下的 GATT 时序。
2. 第二阶段验证后的新结论
后续继续做时序测试后发现:
- 华为并不是“绝对不能开 Notify”;
- 真正敏感的是“何时开 Notify”和“开完 Notify 后多久发送第一条命令”;
- 如果调整成更保守的时序,标准 Notify 链路是可以恢复成功的。
3. 华为当前推荐时序
当前项目里,华为安卓 APP 推荐按下面这个顺序走:
- 连接设备;
- 获取服务;
- 协商 MTU;
- 获取特征;
- 先注册监听;
- 发现特征后等待约
2000ms; - 开启
E0F00002的 Notify; - Notify 成功后再等待约
2000ms; - 再发送首条业务命令;
- 后续按标准 Notify 回包处理。
对应简化时序如下:
APP 设备
| |
|-- 连接设备 ----------------------------------------->|
|<------------------------- 连接成功 -------------------|
|-- 获取服务 / MTU / 特征 ----------------------------->|
|<------------------------- 初始化完成 -----------------|
|-- 注册监听 ------------------------------------------|
|-- 等待约 2 秒 -------------------------------------->|
|-- 开启 E0F00002 Notify ----------------------------->|
|<------------------------- Notify 开启成功 ------------|
|-- 再等待约 2 秒 ------------------------------------>|
|-- 写 E0F00001: type=cmd ---------------------------->|
| |-- 执行业务逻辑
|<===== Notify E0F00002: type=ack =====================|
|-- 按 req 匹配 pending 请求并返回结果 ----------------|
4. 华为兼容代码示例
下面是一个简化后的华为时序示例:
/**
* 判断是否属于华为安卓 APP
* 说明:
* 1. 项目里通常会结合 brand、manufacturer、romName、osName 等信息综合判断。
* 2. 这里示例只表达“识别华为家族设备后走更严格的时序”这个思路。
*/
function isHuaweiAndroidApp(systemInfo) {
const brand = (systemInfo.brand || '').toLowerCase();
const platform = (systemInfo.platform || '').toLowerCase();
return platform === 'android' && (brand.includes('huawei') || brand.includes('honor'));
}
/**
* 华为安卓 APP 初始化
* 说明:
* 1. 和普通安卓相比,关键差异是“先等 2 秒再开 Notify”。
* 2. Notify 成功后,还要把首条命令的发送时间继续往后推。
*/
async function initBleConnectionForHuawei(targetDeviceId) {
const systemInfo = uni.getSystemInfoSync();
await initBleConnectionBase(targetDeviceId);
if (isHuaweiAndroidApp(systemInfo)) {
// 发现特征后,不要马上开 Notify,先等待底层 GATT 队列更稳定。
await delay(2000);
await new Promise((resolve, reject) => {
uni.notifyBLECharacteristicValueChange({
deviceId: targetDeviceId,
serviceId: SERVICE_UUID,
characteristicId: CHAR_UUIDS.RESPONSE,
state: true,
success: resolve,
fail: reject,
});
});
// 记录 Notify 就绪时刻,后面首条命令还要继续延迟。
notifyReadyAt = Date.now();
isFirstCommandAfterConnect = true;
}
}
/**
* 华为首条命令发送保护
* 说明:
* 1. 华为不是只延后开启 Notify,还要延后首条命令。
* 2. 当前项目里实测可用的是“Notify 成功后再等待约 2 秒”。
*/
async function prepareBeforeHuaweiFirstCommand() {
const waitMs = Math.max(notifyReadyAt + 2000 - Date.now(), 0);
if (waitMs > 0) {
await delay(waitMs);
}
isFirstCommandAfterConnect = false;
}
八、一次次遇到的问题,以及如何解决
下面按真实踩坑顺序,总结一次次遇到的问题和处理思路。
问题 1:标准流程在华为安卓 APP 下出现 10007
现象:
- 设备连接成功;
- 服务、特征都发现成功;
- 一旦开启 Notify 后很快发送首条命令;
- 就出现
writeBLECharacteristicValue:fail property not support; - 命令实际没有写到设备。
排查结果:
- 设备并不是不支持写;
- 协议命令也没有错;
- 因为微信小程序、iOS、小米安卓都正常;
- 所以问题集中在华为安卓 APP 下的 GATT 时序。
解决办法:
- 不再把“Notify 开启”和“首条命令发送”挤得太近;
- 改成更保守的延后时序。
问题 2:只是“开完 Notify 后等 2 秒”仍然不稳定
现象:
- 有些测试里,仅仅把“开完 Notify 后发送命令”延后到 2 秒,仍然失败;
- 说明不仅是首条命令时间有问题,Notify 本身的开启时机也有问题。
排查结果:
- 华为底层可能对 CCCD 写入前后的 GATT 操作都比较敏感;
- 不能只延后命令发送,还要延后 Notify 的开启时机。
解决办法:
- 发现特征后先等待约
2000ms; - 再开启 Notify;
- Notify 成功后再等待约
2000ms; - 然后再发首条命令。
问题 3:安卓平台个别机型偶发首写失败
现象:
- 非华为安卓大多数可以成功;
- 但个别安卓机型偶发首条命令失败。
排查结果:
- 很多时候不是协议问题,而是初始化顺序、首写时机、包间隔过快导致。
解决办法:
- 连接后先等
300ms; - 服务发现后再协商 MTU;
- Notify 开启后首条命令默认等
1000ms; - 如果首写报
10007,再补等到1600ms再重试一次; - 多包写入时每包间隔约
180ms。
问题 4:设备日志出现 GATTS_SendRsp / Sending response failed
现象:
- 设备日志里可见:
BT_GATT: GATTS_SendRsp ... waiting for op_code = 00BT_APPL: Sending response failed
排查结果:
- 从当前成功样本看,这两条日志并没有阻断最终
ack返回; - 所以它是固件端需要单独排查的问题,但不是当前 APP 成功与否的唯一决定因素。
解决办法:
- 前端层面先把 BLE 时序兼容处理到位;
- 固件侧继续检查是否存在“自动响应模式”和“手动响应”冲突问题。
问题 5:客户现场无法提供足够日志
现象:
- 很多 BLE 问题只在客户手机上出现;
- 客户通常无法提供完整控制台日志。
解决办法:
- 在前端维护最近 BLE 关键日志的内存缓冲区;
- 页面出错时,把系统信息、设备信息、BLE 快照、当前阶段、最近日志统一上传后端;
- 这样即使用户不会看 console,也能还原失败链路。
九、平台建议总结
当前建议如下:
- 微信小程序:按标准 BLE 流程开发;
- iOS APP:按标准 BLE 流程开发;
- 安卓 APP(非华为):按标准流程开发,但建议保留通用兼容时序;
- 华为安卓 APP:必须走“延后开 Notify + 延后首发命令”的兼容逻辑;
- 纯血鸿蒙:还需要继续单独验证。
十、结语
这次 BLE 跨平台兼容实践最重要的经验,不是“协议写对了就一定能通”,而是:
- BLE 在不同平台、不同 ROM、不同蓝牙栈上的时序表现差异非常大;
- 微信小程序和 iOS 相对标准;
- 安卓需要更谨慎地处理首条命令发送时机;
- 华为安卓 APP 对 Notify 时序尤其敏感;
- 只要日志留得够完整,问题最终都能从“看起来像协议错了”还原到“其实是平台时序差异”。
如果后续要继续提升稳定性,建议优先做两件事:
- 继续验证更多业务指令在华为安卓 APP 下的稳定性;
- 继续验证设备主动上报
evt在当前延后时序下是否同样稳定。
以上经验分享,你可以把以上信息扔给AI,让AI封装一个符合你项目的bleHelper.js工具类,用于处理BLE通讯。
收起阅读 »写了个开箱即用的 UniApp 现代化 CLI,请大家帮忙测试
各位 UniApp 开发圈的前辈和小伙伴们,大家好!
日常在做 UniApp 项目时,我发现每次开新项目,都要重复花不少时间去配置 ESLint、Prettier、Husky、Git 提交规范这些工程化的东西。为了让自己和团队以后能“一键开箱即用”,我最近折腾着写了一个小工具,叫 create-vite-uniapp。
由于个人能力和测试场景有限,非常想把它发到论坛里,请各位经验丰富的前辈和正在一线开发的小伙伴帮忙把把关,提提意见。
👉 GitHub 源码地址:https://github.com/zeMinng/create-vite-uniapp
🛠️ 这个小工具主要做了什么?
其实它的核心功能很简单,就是想帮大家省去新项目繁琐的初始化配置。目前它主要整合了以下内容:
- 基础规范全全配好:内置集成了较新版本的 ESLint 和 pinia,以及 StyleLint。
- 技术栈偏向现代:脚手架自己是用 Node.js 22+ 和 TSdown 写的,不过生成的项目模板我已经通过配置,保证了在 Node 20+ 的环境下都能正常且稳定地构建运行。
- 支持按需组合:里面做了一套简单的插件组合逻辑,大家可以根据自己的业务需要,在命令行里勾选不同的配置,不会一股脑把用不到的依赖全塞进去。
🚀 如何试用?
不需要全局安装,直接在终端跑这行命令就可以试一下:
npm create vite-uniapp@latest
💬 想向大家请教的问题
为了让这个工具以后能真正帮到更多人,我想诚恳地向大家请教几个实际开发中的痛点:
- 大家目前在多端(微信小程序、H5、App 等)打包时,最头疼的依赖冲突或者配置痛点是什么?我看看能不能把这些避坑配置直接集成到模板里。
- 关于代码规范(Lint),不同团队的松紧度不一样,目前我默认配得相对严格,大家更倾向于“严格约束”还是“只管基础格式”?
- 在 Windows 环境下使用时,有没有遇到什么奇怪的路径或者软链接报错?(虽然我自己做了一部分跨平台测试,但还是怕有遗漏)。
目前这个项目还非常年轻,肯定有很多不完善、甚至写得不优雅的地方。非常欢迎大家去仓库点个 Star ⭐️ 关注,更期待大家在评论区或者 GitHub Issue 里狠狠地吐槽、提建议或报 Bug。
先谢谢各位了!
各位 UniApp 开发圈的前辈和小伙伴们,大家好!
日常在做 UniApp 项目时,我发现每次开新项目,都要重复花不少时间去配置 ESLint、Prettier、Husky、Git 提交规范这些工程化的东西。为了让自己和团队以后能“一键开箱即用”,我最近折腾着写了一个小工具,叫 create-vite-uniapp。
由于个人能力和测试场景有限,非常想把它发到论坛里,请各位经验丰富的前辈和正在一线开发的小伙伴帮忙把把关,提提意见。
👉 GitHub 源码地址:https://github.com/zeMinng/create-vite-uniapp
🛠️ 这个小工具主要做了什么?
其实它的核心功能很简单,就是想帮大家省去新项目繁琐的初始化配置。目前它主要整合了以下内容:
- 基础规范全全配好:内置集成了较新版本的 ESLint 和 pinia,以及 StyleLint。
- 技术栈偏向现代:脚手架自己是用 Node.js 22+ 和 TSdown 写的,不过生成的项目模板我已经通过配置,保证了在 Node 20+ 的环境下都能正常且稳定地构建运行。
- 支持按需组合:里面做了一套简单的插件组合逻辑,大家可以根据自己的业务需要,在命令行里勾选不同的配置,不会一股脑把用不到的依赖全塞进去。
🚀 如何试用?
不需要全局安装,直接在终端跑这行命令就可以试一下:
npm create vite-uniapp@latest
💬 想向大家请教的问题
为了让这个工具以后能真正帮到更多人,我想诚恳地向大家请教几个实际开发中的痛点:
- 大家目前在多端(微信小程序、H5、App 等)打包时,最头疼的依赖冲突或者配置痛点是什么?我看看能不能把这些避坑配置直接集成到模板里。
- 关于代码规范(Lint),不同团队的松紧度不一样,目前我默认配得相对严格,大家更倾向于“严格约束”还是“只管基础格式”?
- 在 Windows 环境下使用时,有没有遇到什么奇怪的路径或者软链接报错?(虽然我自己做了一部分跨平台测试,但还是怕有遗漏)。
目前这个项目还非常年轻,肯定有很多不完善、甚至写得不优雅的地方。非常欢迎大家去仓库点个 Star ⭐️ 关注,更期待大家在评论区或者 GitHub Issue 里狠狠地吐槽、提建议或报 Bug。
先谢谢各位了!
收起阅读 »H5页面中的图片、视频加载缓慢
问题现象
浏览应用内带有图片、视频的内容时,出现加载慢的现象。
背景知识
ArkWeb提供了Web组件,页面加载是Web组件的基本功能。可以在Web组件创建时指定默认加载的网络页面,后续需要变更页面,可以通过loadUrl()接口加载指定的网页。
图片资源加载优化:在应用开发中,当图片资源数量或大小超过特定阈值时,由于图片资源的格式需要通过CPU解压缩为纹理格式才能直接被GPU读取,将增加CPU的处理时间,导致图片加载延迟,此时应采用图片资源加载优化方案优化图片资源的处理。
ArkUI Inspector:DevEco Studio提供的布局分析工具,开发者可以借助它预览真机或模拟器中的UI效果,快速定位布局层级问题,也可以观察组件属性、不同组件之间的关系等。
点击放大
ArkWeb分析:DevEco Profiler提供的ArkWeb加载和丢帧等问题分析模板,可以结合ArkWeb执行流程的关键Trace点来定位问题发生的阶段。如果问题发生在渲染阶段,可以结合H:RosenWeb数据,线程运行状态以及帧渲染流程打点数据,进一步分析丢帧问题。
Web页面加载流程Trace关键点:
Web网页加载流程拆解
Trace关键字
应用收到点击事件
DispatchTouchEvent
Web组件初始化
NWebImpl|CreateNWeb
导航
H:LocalFrame::Navigate| url=https://xxx
创建URLLoader加载图片资源
H:URLLoader::URLLoader | url=https://xxx
启动请求
H:HttpCacheTransaction::Start | url=https://xxx
创建HttpStream
H:HttpNetworkTransaction::DoCreateStream |url=https://xxx
发送请求
H:HttpNetworkTransaction::DoSendRequestComplete | url=https://xxx
接收到响应头
H:HttpNetworkTransaction::DoReadHeadersComplete | url=https://xxx
开始接收响应体
H:NavigationBodyLoader::StartLoadingBody |url=https://xxx
数据接收完成
H:URLLoader::NotifyCompleted | responseCode | url=https://xxx
绘制
H:SingleThreadProxy::ScheduledActionSendBeginMainFrame
渲染
NotifyReadyToCommitOnImpl
完成时延结束
SkiaOutputSurfaceImplOnGpu::SwapBuffers
Web页资源加载全流程:
不同于HarmonyOS ArkUI页面,Web页面的所有信息均是通过URL链接获取,在本地解析成DOM结构后渲染为适配当前设备屏幕比例、可操控的页面。全流程分为三大部分:浏览器控制流程,资源下载流程,渲染送显流程。
点击放大
浏览器控制流程主要负责处理用户输入、创建Web组件、管控资源下载流程和渲染送显流程:
接收并处理用户输入:
接收用户点击跳转Web页的指令,根据相应的触摸事件逻辑或指定跳转命令,获得目的URL地址、渲染坐标等信息,初始化Web组件。
创建资源下载流程和渲染送显流程:
调用相关接口,将所需数据打包,用于初始化资源下载流程和渲染送显流程。
传递资源:
在网络进程将页面资源下载后,打包传递给渲染进程。
关闭本次加载并释放相应资源:
在渲染进程完成后,检查各进程状态并释放相应资源,关闭本次加载。
资源下载流程:
点击放大
启动请求,创建URLRequest:
网页加载管理器URLLoader将初始化一个含有当前网络参数的URLRequest,它将驱动一次完整的网络请求任务。
检查缓存项:
URLRequest将从网络请求中拿到cookies并检查是否存在缓存项,如果不存在则会新建。
通过HttpStream建立网络连接:
URLRequest请求一个已匹配缓存项的数据传输流进程HttpStream,并生成一个包含主机名、端口号等信息的socket。
根据DNS域名服务器返回的重定向路径,发送socket,完成与接收方的TCP连接。
发送请求,读取响应头head:
URLRequest通过传输接口格式化请求头和请求体后,发送到接收方。接收方会等待并接收响应头,向上传递给接收方的URLRequest并保存cookies。
读取响应体:
发送方URLRequest会创建一个数据管道,并将一端传递给接收方。发送方每次会先从管道获取一个新的共享内存,再通过URLRequest写入数据,直到数据全部发送完毕。
服务器端会接收到共享内存的读取请求并依次读取,直到读取完毕全部数据。
断开连接并销毁URLRequest:
读取完成后,将关闭TCP连接及与这个请求相关的进程,并保存可复用的socket,最后销毁URLRequest。
渲染流程:
将html转换为树形结构DOM树:
通过html解析器,分析并拆解html页面信息,将其重构为元素相互独立的DOM树结构。再根据元素在html页面中的分布位置和形态,分析出对应ArkUI组件及其具体属性值、坐标信息。
绘制准备:
通过图层树,实现层叠上下文,保证DOM重叠时可以用正确的顺序合成页面。再将每个图层的绘制拆分成很多小的绘制指令,组成待绘制列表。提交给合成器后会将图层划分为图块(大图层和小图层以相同大小的图块进行管理、缓存和渲染)。
渲染送显:
将图块信息转换为带有颜色透明度信息的位图(光栅化)。之后合成器会通过viz线程向GPU_task插入送显任务,将像素点输出到屏幕上。
问题定位
使用Ark Inspector查看应用布局,该页面布局为Web页面。在右侧搜索框输入content确认该网页地址。
点击放大
在浏览器中输入该网页地址,然后打开网络(Ctrl+U),可以看到图片/视频资源地址为"https://xxx"。
点击放大
使用DevEco Studio Profiler的ArkWeb分析抓取该图片/视频资源加载过程的Trace,再根据加载流程中的Trace关键字表拆分该图片/视频资源加载过程,可找到其加载过程及耗时。
以图片举例,具体步骤如下所示(对应图中红点标识):
首先将Trace关键字表中的对应关键字放入搜索框,例如“发送请求”:H:HttpNetworkTransaction::DoSendRequestComplete| url=https://xxx (改为对应的图片URL)。
然后在搜索结果中筛选目标,找出任意一次成功复现的节点。
最后放大该时间轴比例,直至可以清晰显示该线程的运行时长及其具体运行时间点。
点击放大
重复以上步骤,依次搜索“应用收到点击事件”、“发起请求”、“开始接收资源下载数据”、“资源下载完成”、“绘制”、“渲染完成”的Trace关键字,获知以下加载流程全景图:
点击放大
点击放大
选中A点时间坐标,拖向B点的时间坐标,即可标记该时间段,并显示其时长。依次标记所需时间段,可知:总时长2s,应用收到点击事件到开始接收响应体耗时137ms,下载全程耗时1.5s,渲染流程单独耗时337ms。由此可知,总时长过长的主要原因是过长的下载耗时。
点击放大
复制URL到浏览器,可打开下载该图片资源,发现该图片大小为3.2M,明显超出该场景表现所需的图片资源大小,在未经充分加载优化的情况下会导致加载耗时过长。
点击放大
分析结论
由于该图片/视频资源较大、未经过充分加载优化,导致出现加载缓慢的现象。
https://pastebin.com/QNRn2bjK
https://pastebin.com/hwbnfATi
https://pastebin.com/gAzAXLff
https://pastebin.com/VfQdx54p
https://pastebin.com/m1kt60bz
https://pastebin.com/Y9zewtFb
https://pastebin.com/NZ2QMAgn
https://pastebin.com/r1zJPik4
https://pastebin.com/RuXS6qcT
https://pastebin.com/MGtrMJwL
https://pastebin.com/XpN65jfS
https://pastebin.com/kxWz7LwQ
https://pastebin.com/DgAzrw6P
https://pastebin.com/bD6uV8t5
https://pastebin.com/EaSLm137
https://pastebin.com/8WvnTSR4
https://pastebin.com/NHTUPBV5
https://pastebin.com/zwr6rKV6
https://pastebin.com/VbQkGn6E
https://pastebin.com/418ZtBAb
https://pastebin.com/H3Nk02Bz
https://pastebin.com/vdi2RUcw
https://pastebin.com/e6WB7EyF
https://pastebin.com/kcAeSAKN
https://pastebin.com/T3t1yXbH
https://pastebin.com/2kzjNjPh
https://pastebin.com/BZEHW3GE
https://pastebin.com/riFvG5sv
https://pastebin.com/rq1Rt35d
https://pastebin.com/81zfaGBF
https://pastebin.com/HWj6grJT
https://pastebin.com/vwQtFAVv
https://pastebin.com/5ZE1KmsZ
https://pastebin.com/LzstGNxy
https://pastebin.com/ZqSsy7w0
https://pastebin.com/KJb7c7K4
https://pastebin.com/HCn7vnUt
https://pastebin.com/wAFYUfqT
https://pastebin.com/5gn7rZ5i
https://pastebin.com/fgErKAft
https://pastebin.com/bsgyzqGY
https://pastebin.com/wnJ7V3nb
https://pastebin.com/Ue4ci9LT
https://pastebin.com/qFTp5130
https://pastebin.com/DSxEQKav
https://pastebin.com/HrYaKKZS
https://pastebin.com/LDNChUMj
https://pastebin.com/jwBzr0Nq
https://pastebin.com/Y8CankGX
https://pastebin.com/jMuDwuFH
https://pastebin.com/B3aHx3P4
https://pastebin.com/cpUmKPEN
https://pastebin.com/ADZzerpL
https://pastebin.com/fdU1T7HH
https://pastebin.com/zVykfg7b
https://pastebin.com/7e4fAuMN
https://pastebin.com/u92VZShy
https://pastebin.com/BnB6cKvJ
https://pastebin.com/w6Yr9VB8
https://pastebin.com/kKE1AiEx
https://pastebin.com/v32qznLW
https://pastebin.com/tBQzSBqT
https://pastebin.com/eVuDgJzE
https://pastebin.com/2NPkE4kD
https://pastebin.com/Lnge5Pks
https://pastebin.com/4vuq5CFH
https://pastebin.com/qPzfcYwV
https://pastebin.com/MpK1mx7q
https://pastebin.com/2NFh64Ff
https://pastebin.com/A1Au9Phs
https://pastebin.com/aR3XMgi8
https://pastebin.com/9NDy94AJ
https://pastebin.com/nF6y4CM6
https://pastebin.com/snUJbCD4
https://pastebin.com/4221ZXt8
https://pastebin.com/96vLQn1S
https://pastebin.com/b4J1iVU5
https://pastebin.com/YRqJQZHn
https://pastebin.com/EHz8VFhH
https://pastebin.com/NCVFXb2E
https://pastebin.com/NhUJhfsJ
https://pastebin.com/4RbA0t9X
https://pastebin.com/tntHazTQ
https://pastebin.com/GeLTXqsa
https://pastebin.com/GEc14SUH
https://pastebin.com/98UxqNxj
https://pastebin.com/bXgwyxDS
https://pastebin.com/4a4zBHH9
https://pastebin.com/eVAEe1Bq
https://pastebin.com/NAVsusn4
https://pastebin.com/SSmEJFWc
https://pastebin.com/tgbeKpmw
https://pastebin.com/Sp7H2HF7
https://pastebin.com/sfE6aaHr
https://pastebin.com/2DZ4qnLe
https://pastebin.com/FZpnVJJM
https://pastebin.com/5Uncff3D
https://pastebin.com/Fiv90dVc
https://pastebin.com/nETky3vG
https://pastebin.com/FCYRxim3
https://pastebin.com/XZ0VcS3K
https://pastebin.com/RBuxUwxW
https://pastebin.com/Bba7JVAJ
https://pastebin.com/eJnQrngW
https://pastebin.com/BNpBq32h
https://pastebin.com/6AxaMU1T
https://pastebin.com/fsw2qeC0
https://pastebin.com/vDN7GuP6
https://pastebin.com/e72g4X5w
https://pastebin.com/ziybYwjQ
https://pastebin.com/6bUmWAWF
https://pastebin.com/CtBGzzJT
https://pastebin.com/LzYyEXGk
https://pastebin.com/aQpDbtqP
https://pastebin.com/AD40Arih
https://pastebin.com/t2cZzG8u
https://pastebin.com/w63u8Xq3
https://pastebin.com/fmaZHkGd
https://pastebin.com/2PMGAfKB
https://pastebin.com/7b8q6q48
https://pastebin.com/VfhdfPiN
https://pastebin.com/crmU54jF
https://pastebin.com/2SkQYm5Q
https://pastebin.com/CL8uckZw
https://pastebin.com/GBBP3d39
https://pastebin.com/W9UPpcg7
https://pastebin.com/FZ1WaffX
https://pastebin.com/GRPN2xFr
https://pastebin.com/fwtQ2RX0
https://pastebin.com/Jc9XRYm2
https://pastebin.com/maABnEXG
https://pastebin.com/bjPuX9pQ
https://pastebin.com/G9wCG67H
https://pastebin.com/f1yWEYvA
https://pastebin.com/dDYDV16c
https://pastebin.com/hW75kfej
https://pastebin.com/Pqgf8TBW
https://pastebin.com/e1xYhRPV
https://pastebin.com/yeGBY9BX
https://pastebin.com/3qyBsxEu
https://pastebin.com/UTNZXBmX
https://pastebin.com/SJZ4Ui0t
https://pastebin.com/VuHG278E
https://pastebin.com/CUuJ7N0B
https://pastebin.com/kusPdf8X
https://pastebin.com/N3YRkwXs
https://pastebin.com/15AS2ghw
https://pastebin.com/MTLW2jVp
https://pastebin.com/rzpuv3P7
https://pastebin.com/Z8CRch9q
https://pastebin.com/NqFarRyP
https://pastebin.com/i9k48G0n
https://pastebin.com/TWvwMyXC
https://pastebin.com/rvm7dsDL
https://pastebin.com/ED4TcSrY
https://pastebin.com/e9PLWh3t
https://pastebin.com/s2PuH6uv
https://pastebin.com/GrTLVWMe
https://pastebin.com/GrD1gY9i
https://pastebin.com/AGRV1q3A
https://pastebin.com/pKkNt9Ey
https://pastebin.com/pmGnKiCt
https://pastebin.com/mmStCkUc
https://pastebin.com/0Vjus1Tn
https://pastebin.com/dBWsMvS8
https://pastebin.com/srsJ0MtM
https://pastebin.com/kaLXpsUc
https://pastebin.com/FDgPZKBG
https://pastebin.com/qWTvEJSx
https://pastebin.com/hvG11U3X
问题现象
浏览应用内带有图片、视频的内容时,出现加载慢的现象。
背景知识
ArkWeb提供了Web组件,页面加载是Web组件的基本功能。可以在Web组件创建时指定默认加载的网络页面,后续需要变更页面,可以通过loadUrl()接口加载指定的网页。
图片资源加载优化:在应用开发中,当图片资源数量或大小超过特定阈值时,由于图片资源的格式需要通过CPU解压缩为纹理格式才能直接被GPU读取,将增加CPU的处理时间,导致图片加载延迟,此时应采用图片资源加载优化方案优化图片资源的处理。
ArkUI Inspector:DevEco Studio提供的布局分析工具,开发者可以借助它预览真机或模拟器中的UI效果,快速定位布局层级问题,也可以观察组件属性、不同组件之间的关系等。
点击放大
ArkWeb分析:DevEco Profiler提供的ArkWeb加载和丢帧等问题分析模板,可以结合ArkWeb执行流程的关键Trace点来定位问题发生的阶段。如果问题发生在渲染阶段,可以结合H:RosenWeb数据,线程运行状态以及帧渲染流程打点数据,进一步分析丢帧问题。
Web页面加载流程Trace关键点:
Web网页加载流程拆解
Trace关键字
应用收到点击事件
DispatchTouchEvent
Web组件初始化
NWebImpl|CreateNWeb
导航
H:LocalFrame::Navigate| url=https://xxx
创建URLLoader加载图片资源
H:URLLoader::URLLoader | url=https://xxx
启动请求
H:HttpCacheTransaction::Start | url=https://xxx
创建HttpStream
H:HttpNetworkTransaction::DoCreateStream |url=https://xxx
发送请求
H:HttpNetworkTransaction::DoSendRequestComplete | url=https://xxx
接收到响应头
H:HttpNetworkTransaction::DoReadHeadersComplete | url=https://xxx
开始接收响应体
H:NavigationBodyLoader::StartLoadingBody |url=https://xxx
数据接收完成
H:URLLoader::NotifyCompleted | responseCode | url=https://xxx
绘制
H:SingleThreadProxy::ScheduledActionSendBeginMainFrame
渲染
NotifyReadyToCommitOnImpl
完成时延结束
SkiaOutputSurfaceImplOnGpu::SwapBuffers
Web页资源加载全流程:
不同于HarmonyOS ArkUI页面,Web页面的所有信息均是通过URL链接获取,在本地解析成DOM结构后渲染为适配当前设备屏幕比例、可操控的页面。全流程分为三大部分:浏览器控制流程,资源下载流程,渲染送显流程。
点击放大
浏览器控制流程主要负责处理用户输入、创建Web组件、管控资源下载流程和渲染送显流程:
接收并处理用户输入:
接收用户点击跳转Web页的指令,根据相应的触摸事件逻辑或指定跳转命令,获得目的URL地址、渲染坐标等信息,初始化Web组件。
创建资源下载流程和渲染送显流程:
调用相关接口,将所需数据打包,用于初始化资源下载流程和渲染送显流程。
传递资源:
在网络进程将页面资源下载后,打包传递给渲染进程。
关闭本次加载并释放相应资源:
在渲染进程完成后,检查各进程状态并释放相应资源,关闭本次加载。
资源下载流程:
点击放大
启动请求,创建URLRequest:
网页加载管理器URLLoader将初始化一个含有当前网络参数的URLRequest,它将驱动一次完整的网络请求任务。
检查缓存项:
URLRequest将从网络请求中拿到cookies并检查是否存在缓存项,如果不存在则会新建。
通过HttpStream建立网络连接:
URLRequest请求一个已匹配缓存项的数据传输流进程HttpStream,并生成一个包含主机名、端口号等信息的socket。
根据DNS域名服务器返回的重定向路径,发送socket,完成与接收方的TCP连接。
发送请求,读取响应头head:
URLRequest通过传输接口格式化请求头和请求体后,发送到接收方。接收方会等待并接收响应头,向上传递给接收方的URLRequest并保存cookies。
读取响应体:
发送方URLRequest会创建一个数据管道,并将一端传递给接收方。发送方每次会先从管道获取一个新的共享内存,再通过URLRequest写入数据,直到数据全部发送完毕。
服务器端会接收到共享内存的读取请求并依次读取,直到读取完毕全部数据。
断开连接并销毁URLRequest:
读取完成后,将关闭TCP连接及与这个请求相关的进程,并保存可复用的socket,最后销毁URLRequest。
渲染流程:
将html转换为树形结构DOM树:
通过html解析器,分析并拆解html页面信息,将其重构为元素相互独立的DOM树结构。再根据元素在html页面中的分布位置和形态,分析出对应ArkUI组件及其具体属性值、坐标信息。
绘制准备:
通过图层树,实现层叠上下文,保证DOM重叠时可以用正确的顺序合成页面。再将每个图层的绘制拆分成很多小的绘制指令,组成待绘制列表。提交给合成器后会将图层划分为图块(大图层和小图层以相同大小的图块进行管理、缓存和渲染)。
渲染送显:
将图块信息转换为带有颜色透明度信息的位图(光栅化)。之后合成器会通过viz线程向GPU_task插入送显任务,将像素点输出到屏幕上。
问题定位
使用Ark Inspector查看应用布局,该页面布局为Web页面。在右侧搜索框输入content确认该网页地址。
点击放大
在浏览器中输入该网页地址,然后打开网络(Ctrl+U),可以看到图片/视频资源地址为"https://xxx"。
点击放大
使用DevEco Studio Profiler的ArkWeb分析抓取该图片/视频资源加载过程的Trace,再根据加载流程中的Trace关键字表拆分该图片/视频资源加载过程,可找到其加载过程及耗时。
以图片举例,具体步骤如下所示(对应图中红点标识):
首先将Trace关键字表中的对应关键字放入搜索框,例如“发送请求”:H:HttpNetworkTransaction::DoSendRequestComplete| url=https://xxx (改为对应的图片URL)。
然后在搜索结果中筛选目标,找出任意一次成功复现的节点。
最后放大该时间轴比例,直至可以清晰显示该线程的运行时长及其具体运行时间点。
点击放大
重复以上步骤,依次搜索“应用收到点击事件”、“发起请求”、“开始接收资源下载数据”、“资源下载完成”、“绘制”、“渲染完成”的Trace关键字,获知以下加载流程全景图:
点击放大
点击放大
选中A点时间坐标,拖向B点的时间坐标,即可标记该时间段,并显示其时长。依次标记所需时间段,可知:总时长2s,应用收到点击事件到开始接收响应体耗时137ms,下载全程耗时1.5s,渲染流程单独耗时337ms。由此可知,总时长过长的主要原因是过长的下载耗时。
点击放大
复制URL到浏览器,可打开下载该图片资源,发现该图片大小为3.2M,明显超出该场景表现所需的图片资源大小,在未经充分加载优化的情况下会导致加载耗时过长。
点击放大
分析结论
由于该图片/视频资源较大、未经过充分加载优化,导致出现加载缓慢的现象。
https://pastebin.com/QNRn2bjK
https://pastebin.com/hwbnfATi
https://pastebin.com/gAzAXLff
https://pastebin.com/VfQdx54p
https://pastebin.com/m1kt60bz
https://pastebin.com/Y9zewtFb
https://pastebin.com/NZ2QMAgn
https://pastebin.com/r1zJPik4
https://pastebin.com/RuXS6qcT
https://pastebin.com/MGtrMJwL
https://pastebin.com/XpN65jfS
https://pastebin.com/kxWz7LwQ
https://pastebin.com/DgAzrw6P
https://pastebin.com/bD6uV8t5
https://pastebin.com/EaSLm137
https://pastebin.com/8WvnTSR4
https://pastebin.com/NHTUPBV5
https://pastebin.com/zwr6rKV6
https://pastebin.com/VbQkGn6E
https://pastebin.com/418ZtBAb
https://pastebin.com/H3Nk02Bz
https://pastebin.com/vdi2RUcw
https://pastebin.com/e6WB7EyF
https://pastebin.com/kcAeSAKN
https://pastebin.com/T3t1yXbH
https://pastebin.com/2kzjNjPh
https://pastebin.com/BZEHW3GE
https://pastebin.com/riFvG5sv
https://pastebin.com/rq1Rt35d
https://pastebin.com/81zfaGBF
https://pastebin.com/HWj6grJT
https://pastebin.com/vwQtFAVv
https://pastebin.com/5ZE1KmsZ
https://pastebin.com/LzstGNxy
https://pastebin.com/ZqSsy7w0
https://pastebin.com/KJb7c7K4
https://pastebin.com/HCn7vnUt
https://pastebin.com/wAFYUfqT
https://pastebin.com/5gn7rZ5i
https://pastebin.com/fgErKAft
https://pastebin.com/bsgyzqGY
https://pastebin.com/wnJ7V3nb
https://pastebin.com/Ue4ci9LT
https://pastebin.com/qFTp5130
https://pastebin.com/DSxEQKav
https://pastebin.com/HrYaKKZS
https://pastebin.com/LDNChUMj
https://pastebin.com/jwBzr0Nq
https://pastebin.com/Y8CankGX
https://pastebin.com/jMuDwuFH
https://pastebin.com/B3aHx3P4
https://pastebin.com/cpUmKPEN
https://pastebin.com/ADZzerpL
https://pastebin.com/fdU1T7HH
https://pastebin.com/zVykfg7b
https://pastebin.com/7e4fAuMN
https://pastebin.com/u92VZShy
https://pastebin.com/BnB6cKvJ
https://pastebin.com/w6Yr9VB8
https://pastebin.com/kKE1AiEx
https://pastebin.com/v32qznLW
https://pastebin.com/tBQzSBqT
https://pastebin.com/eVuDgJzE
https://pastebin.com/2NPkE4kD
https://pastebin.com/Lnge5Pks
https://pastebin.com/4vuq5CFH
https://pastebin.com/qPzfcYwV
https://pastebin.com/MpK1mx7q
https://pastebin.com/2NFh64Ff
https://pastebin.com/A1Au9Phs
https://pastebin.com/aR3XMgi8
https://pastebin.com/9NDy94AJ
https://pastebin.com/nF6y4CM6
https://pastebin.com/snUJbCD4
https://pastebin.com/4221ZXt8
https://pastebin.com/96vLQn1S
https://pastebin.com/b4J1iVU5
https://pastebin.com/YRqJQZHn
https://pastebin.com/EHz8VFhH
https://pastebin.com/NCVFXb2E
https://pastebin.com/NhUJhfsJ
https://pastebin.com/4RbA0t9X
https://pastebin.com/tntHazTQ
https://pastebin.com/GeLTXqsa
https://pastebin.com/GEc14SUH
https://pastebin.com/98UxqNxj
https://pastebin.com/bXgwyxDS
https://pastebin.com/4a4zBHH9
https://pastebin.com/eVAEe1Bq
https://pastebin.com/NAVsusn4
https://pastebin.com/SSmEJFWc
https://pastebin.com/tgbeKpmw
https://pastebin.com/Sp7H2HF7
https://pastebin.com/sfE6aaHr
https://pastebin.com/2DZ4qnLe
https://pastebin.com/FZpnVJJM
https://pastebin.com/5Uncff3D
https://pastebin.com/Fiv90dVc
https://pastebin.com/nETky3vG
https://pastebin.com/FCYRxim3
https://pastebin.com/XZ0VcS3K
https://pastebin.com/RBuxUwxW
https://pastebin.com/Bba7JVAJ
https://pastebin.com/eJnQrngW
https://pastebin.com/BNpBq32h
https://pastebin.com/6AxaMU1T
https://pastebin.com/fsw2qeC0
https://pastebin.com/vDN7GuP6
https://pastebin.com/e72g4X5w
https://pastebin.com/ziybYwjQ
https://pastebin.com/6bUmWAWF
https://pastebin.com/CtBGzzJT
https://pastebin.com/LzYyEXGk
https://pastebin.com/aQpDbtqP
https://pastebin.com/AD40Arih
https://pastebin.com/t2cZzG8u
https://pastebin.com/w63u8Xq3
https://pastebin.com/fmaZHkGd
https://pastebin.com/2PMGAfKB
https://pastebin.com/7b8q6q48
https://pastebin.com/VfhdfPiN
https://pastebin.com/crmU54jF
https://pastebin.com/2SkQYm5Q
https://pastebin.com/CL8uckZw
https://pastebin.com/GBBP3d39
https://pastebin.com/W9UPpcg7
https://pastebin.com/FZ1WaffX
https://pastebin.com/GRPN2xFr
https://pastebin.com/fwtQ2RX0
https://pastebin.com/Jc9XRYm2
https://pastebin.com/maABnEXG
https://pastebin.com/bjPuX9pQ
https://pastebin.com/G9wCG67H
https://pastebin.com/f1yWEYvA
https://pastebin.com/dDYDV16c
https://pastebin.com/hW75kfej
https://pastebin.com/Pqgf8TBW
https://pastebin.com/e1xYhRPV
https://pastebin.com/yeGBY9BX
https://pastebin.com/3qyBsxEu
https://pastebin.com/UTNZXBmX
https://pastebin.com/SJZ4Ui0t
https://pastebin.com/VuHG278E
https://pastebin.com/CUuJ7N0B
https://pastebin.com/kusPdf8X
https://pastebin.com/N3YRkwXs
https://pastebin.com/15AS2ghw
https://pastebin.com/MTLW2jVp
https://pastebin.com/rzpuv3P7
https://pastebin.com/Z8CRch9q
https://pastebin.com/NqFarRyP
https://pastebin.com/i9k48G0n
https://pastebin.com/TWvwMyXC
https://pastebin.com/rvm7dsDL
https://pastebin.com/ED4TcSrY
https://pastebin.com/e9PLWh3t
https://pastebin.com/s2PuH6uv
https://pastebin.com/GrTLVWMe
https://pastebin.com/GrD1gY9i
https://pastebin.com/AGRV1q3A
https://pastebin.com/pKkNt9Ey
https://pastebin.com/pmGnKiCt
https://pastebin.com/mmStCkUc
https://pastebin.com/0Vjus1Tn
https://pastebin.com/dBWsMvS8
https://pastebin.com/srsJ0MtM
https://pastebin.com/kaLXpsUc
https://pastebin.com/FDgPZKBG
https://pastebin.com/qWTvEJSx
https://pastebin.com/hvG11U3X
用AI写了个uni-app插件,结果自己改了三版才跑通——聊聊AI编程的“靠谱度”边界
背景:一个看似简单,实则处处是坑的需求
上个月接到一个任务:在 uni-app 项目中实现一个图片裁剪插件,支持缩放、旋转和拖拽。我第一反应是“这不简单吗?网上模板一大堆,让 AI 写一个不就完了?”于是,我打开常用的 AI 代码助手,把需求描述得清清楚楚:用 uni-app 写一个图片裁剪组件,支持手势缩放、旋转、拖拽,兼容 H5 和微信小程序。
AI 秒回一个完整的 vue 组件,代码结构清晰,注释到位。我心里一阵窃喜:这效率,以后谁还自己写?结果一跑,直接翻车。H5 端页面卡死,小程序端裁剪框歪了,旋转后图片飞出边界。更离谱的是,AI 生成的代码里,touch 事件直接用了 @touchmove.prevent,在微信小程序里压根不生效。
于是,我开始了“AI 写一版,我改一版”的循环。前后改了三个大版本,踩了无数坑,才算勉强跑通。这篇文章就来聊聊——AI 写 uni-app 插件到底靠不靠谱,以及我们该怎么用好它。
问题分析:AI 编程的“三大盲区”
在改代码的过程中,我逐渐摸清了 AI 的弱点。它擅长的是“拼凑”,而不是“适配”。
-
平台差异被忽略
uni-app 最大的特点就是跨平台。AI 生成的代码往往默认“一套逻辑走天下”。比如,在 H5 端,touch 事件处理非常自由,可以用 e.touches[0] 直接取坐标;但在微信小程序里,touches 对象的 clientX 和 pageX 在不同组件层级下表现不同。AI 生成的裁剪组件,在小程序里拖拽时,坐标计算完全错位,因为没考虑到 uni.createSelectorQuery() 的异步特性。 -
边界情况全靠猜
AI 会写“理想状态”的代码,但不会考虑用户的实际操作。比如,它生成的旋转逻辑是:每次旋转 90°,然后更新 transform。但用户如果连续快速旋转,或者先旋转再缩放,图片的 transform-origin 就会乱。AI 没有处理这种“状态叠加”的场景,导致图片在多次操作后直接飞出去。 -
性能优化完全缺失
uni-app 的 H5 端和原生小程序端,性能模型完全不同。AI 生成的代码在 H5 端用 v-bind:style 动态绑定 transform,每次手指滑动都触发一次重渲染。这在 H5 上勉强能跑,但在小程序里,由于 setData 的异步机制和渲染层与逻辑层的通信开销,直接导致页面卡死。AI 不会主动考虑用 requestAnimationFrame 节流,也不会用 computed 缓存计算结果。
第一版:推倒重来,从“事件模型”开始改
第一版 AI 代码的核心逻辑是:在 touchstart 时记录初始位置,touchmove 时更新 transform: translate(X, Y) scale(S) rotate(R)。看起来没问题,但小程序里一跑,拖拽时有延迟,而且缩放后图片边界不对。
问题定位:AI 用了 @touchmove="onTouchMove",但在 onTouchMove 里直接计算并赋值给 this.style。在小程序里,touchmove 事件触发频率极高(每秒几十次),每次都要执行 setData 更新样式,性能瓶颈就在这里。
解决方案:改用 uni.createSelectorQuery() 获取组件边界,然后用 requestAnimationFrame 节流。具体来说:
javascript
// 节流函数,控制更新频率
let ticking = false;
function onTouchMove(e) {
if (!ticking) {
requestAnimationFrame(() => {
updateTransform(e.touches[0]);
ticking = false;
});
ticking = true;
}
}
同时,把 transform 的计算结果缓存到 computed 属性里,只在手势结束时才一次性写入 data。这样,setData 的调用次数从每秒几十次降到每秒一次。
踩坑记录:requestAnimationFrame 在微信小程序里是支持的,但要注意,它返回的是一个 number 类型的 ID,不能用 cancelAnimationFrame 直接取消(因为小程序的实现有差异)。我的做法是:用一个 flag 变量控制,而不是依赖 cancelAnimationFrame。
第二版:旋转与缩放的“叠加噩梦”
第二版改完,拖拽和缩放基本 ok,但旋转加入后,问题又来了。用户先旋转 90°,再缩放 2x,图片位置会跳变。原因是 AI 的旋转逻辑是:每次旋转后,重新计算 transform: translate(x, y) scale(s) rotate(r),但 x 和 y 是基于初始坐标的,旋转后图片的视觉中心变了,translate 的偏移量却没更新。
问题定位:旋转改变了图片的坐标系,但 translate 的偏移量还是基于原始坐标系计算的。比如,图片中心在 (100, 100),旋转 90° 后,中心点还在 (100, 100),但图片的视觉边界变了。AI 没有考虑“旋转后中心点偏移”这个细节。
解决方案:改用“矩阵变换”的思路,而不是直接操作 translate、scale、rotate。因为 transform: matrix() 可以统一处理所有变换,而且能保持坐标系的连续性。
javascript
// 使用矩阵变换,把所有操作叠加到一个 matrix 上
let currentMatrix = [1, 0, 0, 1, 0, 0]; // [a, b, c, d, tx, ty]
function applyRotation(angle) {
const rad = angle Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// 矩阵乘法:旋转矩阵 当前矩阵
currentMatrix = [
cos currentMatrix[0] + sin currentMatrix[2],
cos currentMatrix[1] + sin currentMatrix[3],
-sin currentMatrix[0] + cos currentMatrix[2],
-sin currentMatrix[1] + cos currentMatrix[3],
currentMatrix[4],
currentMatrix[5]
];
updateStyle();
}
踩坑记录:矩阵变换虽然精准,但在 uni-app 里,transform: matrix() 的兼容性要特别注意。微信小程序支持 matrix,但 H5 端的某些旧版浏览器可能不支持。我的做法是:在 onReady 里用 uni.getSystemInfoSync() 判断平台,如果是 H5 且版本低,就回退到 translate + scale + rotate 的分步写法。
第三版:裁剪框的“边界锁定”与“用户体验”
前两版跑通后,第三版主要解决裁剪框的边界锁定问题。AI 生成的代码里,用户可以把图片拖到裁剪框外面,然后裁剪出空白区域。这显然不行。
问题定位:AI 只计算了图片的 transform 值,但没有计算图片在裁剪框内的有效显示区域。它不会自动判断“图片边缘是否超出裁剪框边界”,更不会做“边界回弹”。
解决方案:在每次手势结束时,计算图片的四个角在裁剪框坐标系中的坐标,然后做边界约束。
javascript
function clampBounds() {
// 获取裁剪框的尺寸(假设已知 width, height)
const cropRect = { x: 0, y: 0, w: cropWidth, h: cropHeight };
// 获取图片当前变换后的四个角坐标(通过矩阵计算)
const corners = getImageCorners(currentMatrix, imageWidth, imageHeight);
// 计算需要偏移的量,保证所有角都在裁剪框内
let dx = 0, dy = 0;
for (const corner of corners) {
if (corner.x cropRect.x + cropRect.w) dx = Math.min(dx, cropRect.x + cropRect.w - corner.x);
if (corner.y cropRect.y + cropRect.h) dy = Math.min(dy, cropRect.y + cropRect.h - corner.y);
}
// 更新矩阵的平移部分
currentMatrix[4] += dx;
currentMatrix[5] += dy;
updateStyle();
}
用户体验优化:边界锁定后,用户如果拖动到边缘,会感觉“卡死了”。我加了弹性效果:当图片边缘超出边界时,用 transition: all 0.2s ease-out 让图片“弹回”边界内。这样用户操作时,手感更自然。
踩坑记录:弹性效果在 H5 端用 CSS transition 就行,但在小程序里,transition 对 transform 的支持有限(特别是微信小程序的旧版基础库)。我的做法是:用 uni.createAnimation() 创建动画实例,手动控制回弹的缓动效果。
注意事项:AI 编程的“靠谱度”边界总结
通过这次三版迭代,我总结了几条 AI 编程的“靠谱度”边界,供大家参考:
- AI 擅长的:快速生成“骨架代码”,比如组件的基本结构、事件绑定、样式模板。这些工作重复性高,AI 效率极高。
- AI 不擅长的:处理平台差异、边界情况、性能优化、用户体验细节。这些需要开发者对 uni-app 的底层机制有深入理解。
- 建议做法:让 AI 写 70% 的代码(基础功能 + 模板),自己写 30% 的“硬核”部分(事件处理、状态管理、性能优化)。不要期望 AI 能输出一个生产级的插件。
- 测试策略:AI 生成的代码,一定要在“最小环境”里测试,比如单独创建一个空白 uni-app 项目,只引入这个组件,测试 H5 和微信小程序两个端。等基础功能 ok,再集成到实际项目中。
总结:AI 是“实习生”,不是“主力”
这次经历让我从一个“AI 万能论者”变成了“AI 实用主义者”。AI 能帮我们省下写模板的时间,但真正决定代码质量的,还是我们自己对 uni-app 的理解深度。比如,你是否知道 uni-app 的 touch 事件在 H5 和小程序里的坐标系差异?你是否了解 requestAnimationFrame 在跨平台下的兼容性?这些知识,AI 不会主动告诉你,只能靠你自己去踩坑、去总结。
如果你也想用 AI 写 uni-app 插件,我的建议是:先让 AI 写一个“玩具版”,然后基于这个“玩具版”去迭代。不要指望一次成型。每次迭代,你都会发现自己对问题的理解更深一层。这,可能才是 AI 编程真正有价值的地方——它逼着我们思考那些原本会被忽略的细节。
背景:一个看似简单,实则处处是坑的需求
上个月接到一个任务:在 uni-app 项目中实现一个图片裁剪插件,支持缩放、旋转和拖拽。我第一反应是“这不简单吗?网上模板一大堆,让 AI 写一个不就完了?”于是,我打开常用的 AI 代码助手,把需求描述得清清楚楚:用 uni-app 写一个图片裁剪组件,支持手势缩放、旋转、拖拽,兼容 H5 和微信小程序。
AI 秒回一个完整的 vue 组件,代码结构清晰,注释到位。我心里一阵窃喜:这效率,以后谁还自己写?结果一跑,直接翻车。H5 端页面卡死,小程序端裁剪框歪了,旋转后图片飞出边界。更离谱的是,AI 生成的代码里,touch 事件直接用了 @touchmove.prevent,在微信小程序里压根不生效。
于是,我开始了“AI 写一版,我改一版”的循环。前后改了三个大版本,踩了无数坑,才算勉强跑通。这篇文章就来聊聊——AI 写 uni-app 插件到底靠不靠谱,以及我们该怎么用好它。
问题分析:AI 编程的“三大盲区”
在改代码的过程中,我逐渐摸清了 AI 的弱点。它擅长的是“拼凑”,而不是“适配”。
-
平台差异被忽略
uni-app 最大的特点就是跨平台。AI 生成的代码往往默认“一套逻辑走天下”。比如,在 H5 端,touch 事件处理非常自由,可以用 e.touches[0] 直接取坐标;但在微信小程序里,touches 对象的 clientX 和 pageX 在不同组件层级下表现不同。AI 生成的裁剪组件,在小程序里拖拽时,坐标计算完全错位,因为没考虑到 uni.createSelectorQuery() 的异步特性。 -
边界情况全靠猜
AI 会写“理想状态”的代码,但不会考虑用户的实际操作。比如,它生成的旋转逻辑是:每次旋转 90°,然后更新 transform。但用户如果连续快速旋转,或者先旋转再缩放,图片的 transform-origin 就会乱。AI 没有处理这种“状态叠加”的场景,导致图片在多次操作后直接飞出去。 -
性能优化完全缺失
uni-app 的 H5 端和原生小程序端,性能模型完全不同。AI 生成的代码在 H5 端用 v-bind:style 动态绑定 transform,每次手指滑动都触发一次重渲染。这在 H5 上勉强能跑,但在小程序里,由于 setData 的异步机制和渲染层与逻辑层的通信开销,直接导致页面卡死。AI 不会主动考虑用 requestAnimationFrame 节流,也不会用 computed 缓存计算结果。
第一版:推倒重来,从“事件模型”开始改
第一版 AI 代码的核心逻辑是:在 touchstart 时记录初始位置,touchmove 时更新 transform: translate(X, Y) scale(S) rotate(R)。看起来没问题,但小程序里一跑,拖拽时有延迟,而且缩放后图片边界不对。
问题定位:AI 用了 @touchmove="onTouchMove",但在 onTouchMove 里直接计算并赋值给 this.style。在小程序里,touchmove 事件触发频率极高(每秒几十次),每次都要执行 setData 更新样式,性能瓶颈就在这里。
解决方案:改用 uni.createSelectorQuery() 获取组件边界,然后用 requestAnimationFrame 节流。具体来说:
javascript
// 节流函数,控制更新频率
let ticking = false;
function onTouchMove(e) {
if (!ticking) {
requestAnimationFrame(() => {
updateTransform(e.touches[0]);
ticking = false;
});
ticking = true;
}
}
同时,把 transform 的计算结果缓存到 computed 属性里,只在手势结束时才一次性写入 data。这样,setData 的调用次数从每秒几十次降到每秒一次。
踩坑记录:requestAnimationFrame 在微信小程序里是支持的,但要注意,它返回的是一个 number 类型的 ID,不能用 cancelAnimationFrame 直接取消(因为小程序的实现有差异)。我的做法是:用一个 flag 变量控制,而不是依赖 cancelAnimationFrame。
第二版:旋转与缩放的“叠加噩梦”
第二版改完,拖拽和缩放基本 ok,但旋转加入后,问题又来了。用户先旋转 90°,再缩放 2x,图片位置会跳变。原因是 AI 的旋转逻辑是:每次旋转后,重新计算 transform: translate(x, y) scale(s) rotate(r),但 x 和 y 是基于初始坐标的,旋转后图片的视觉中心变了,translate 的偏移量却没更新。
问题定位:旋转改变了图片的坐标系,但 translate 的偏移量还是基于原始坐标系计算的。比如,图片中心在 (100, 100),旋转 90° 后,中心点还在 (100, 100),但图片的视觉边界变了。AI 没有考虑“旋转后中心点偏移”这个细节。
解决方案:改用“矩阵变换”的思路,而不是直接操作 translate、scale、rotate。因为 transform: matrix() 可以统一处理所有变换,而且能保持坐标系的连续性。
javascript
// 使用矩阵变换,把所有操作叠加到一个 matrix 上
let currentMatrix = [1, 0, 0, 1, 0, 0]; // [a, b, c, d, tx, ty]
function applyRotation(angle) {
const rad = angle Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// 矩阵乘法:旋转矩阵 当前矩阵
currentMatrix = [
cos currentMatrix[0] + sin currentMatrix[2],
cos currentMatrix[1] + sin currentMatrix[3],
-sin currentMatrix[0] + cos currentMatrix[2],
-sin currentMatrix[1] + cos currentMatrix[3],
currentMatrix[4],
currentMatrix[5]
];
updateStyle();
}
踩坑记录:矩阵变换虽然精准,但在 uni-app 里,transform: matrix() 的兼容性要特别注意。微信小程序支持 matrix,但 H5 端的某些旧版浏览器可能不支持。我的做法是:在 onReady 里用 uni.getSystemInfoSync() 判断平台,如果是 H5 且版本低,就回退到 translate + scale + rotate 的分步写法。
第三版:裁剪框的“边界锁定”与“用户体验”
前两版跑通后,第三版主要解决裁剪框的边界锁定问题。AI 生成的代码里,用户可以把图片拖到裁剪框外面,然后裁剪出空白区域。这显然不行。
问题定位:AI 只计算了图片的 transform 值,但没有计算图片在裁剪框内的有效显示区域。它不会自动判断“图片边缘是否超出裁剪框边界”,更不会做“边界回弹”。
解决方案:在每次手势结束时,计算图片的四个角在裁剪框坐标系中的坐标,然后做边界约束。
javascript
function clampBounds() {
// 获取裁剪框的尺寸(假设已知 width, height)
const cropRect = { x: 0, y: 0, w: cropWidth, h: cropHeight };
// 获取图片当前变换后的四个角坐标(通过矩阵计算)
const corners = getImageCorners(currentMatrix, imageWidth, imageHeight);
// 计算需要偏移的量,保证所有角都在裁剪框内
let dx = 0, dy = 0;
for (const corner of corners) {
if (corner.x cropRect.x + cropRect.w) dx = Math.min(dx, cropRect.x + cropRect.w - corner.x);
if (corner.y cropRect.y + cropRect.h) dy = Math.min(dy, cropRect.y + cropRect.h - corner.y);
}
// 更新矩阵的平移部分
currentMatrix[4] += dx;
currentMatrix[5] += dy;
updateStyle();
}
用户体验优化:边界锁定后,用户如果拖动到边缘,会感觉“卡死了”。我加了弹性效果:当图片边缘超出边界时,用 transition: all 0.2s ease-out 让图片“弹回”边界内。这样用户操作时,手感更自然。
踩坑记录:弹性效果在 H5 端用 CSS transition 就行,但在小程序里,transition 对 transform 的支持有限(特别是微信小程序的旧版基础库)。我的做法是:用 uni.createAnimation() 创建动画实例,手动控制回弹的缓动效果。
注意事项:AI 编程的“靠谱度”边界总结
通过这次三版迭代,我总结了几条 AI 编程的“靠谱度”边界,供大家参考:
- AI 擅长的:快速生成“骨架代码”,比如组件的基本结构、事件绑定、样式模板。这些工作重复性高,AI 效率极高。
- AI 不擅长的:处理平台差异、边界情况、性能优化、用户体验细节。这些需要开发者对 uni-app 的底层机制有深入理解。
- 建议做法:让 AI 写 70% 的代码(基础功能 + 模板),自己写 30% 的“硬核”部分(事件处理、状态管理、性能优化)。不要期望 AI 能输出一个生产级的插件。
- 测试策略:AI 生成的代码,一定要在“最小环境”里测试,比如单独创建一个空白 uni-app 项目,只引入这个组件,测试 H5 和微信小程序两个端。等基础功能 ok,再集成到实际项目中。
总结:AI 是“实习生”,不是“主力”
这次经历让我从一个“AI 万能论者”变成了“AI 实用主义者”。AI 能帮我们省下写模板的时间,但真正决定代码质量的,还是我们自己对 uni-app 的理解深度。比如,你是否知道 uni-app 的 touch 事件在 H5 和小程序里的坐标系差异?你是否了解 requestAnimationFrame 在跨平台下的兼容性?这些知识,AI 不会主动告诉你,只能靠你自己去踩坑、去总结。
如果你也想用 AI 写 uni-app 插件,我的建议是:先让 AI 写一个“玩具版”,然后基于这个“玩具版”去迭代。不要指望一次成型。每次迭代,你都会发现自己对问题的理解更深一层。这,可能才是 AI 编程真正有价值的地方——它逼着我们思考那些原本会被忽略的细节。
收起阅读 »如何解决XComponent无法触发触摸事件问题
问题现象
在XComponent组件中绑定onTouch方法,但发现该方法中的日志未被输出,表明onTouch事件未能正常触发。
问题参考如下:
Column() {
XComponent({
id: 'xcomponentId-00',
type: "12314",
libraryname: 'nativerender'
})
.onTouch((event: TouchEvent) => {
console.info(onTouch ${event.type})
})
}
.height(CommonConstants.XCOMPONENT_HEIGHT)
.width('100%')
背景知识
XComponent接口提供用于图形绘制和媒体数据写入的Surface,XComponent负责将其嵌入到视图中,支持应用自定义Surface位置和大小。当XComponentType参数为SURFACE或TEXTURE时,支持onTouch等通用事件。
解决方案
当问题代码中使用XComponent(value: {id: string, type: XComponentType, libraryname?: string, controller?: XComponentController})接口时,若配置了libraryname参数,点击事件、触摸事件、按键事件等通用事件将仅响应C-API侧的事件接口,因此问题代码中的onTouch事件无法被触发。
为使onTouch事件正常响应,建议改用不含libraryname参数的XComponent(options: XComponentOptions)接口进行配置。
@Entry
@Component
struct TouchEventDemo {
xComponentController: XComponentController = new XComponentController();
private aiController: ImageAnalyzerController = new ImageAnalyzerController();
private options: ImageAIOptions = {
types: [ImageAnalyzerType.SUBJECT, ImageAnalyzerType.TEXT],
aiController: this.aiController
};
build() {
RelativeContainer() {
Column() {
XComponent({
type: XComponentType.SURFACE,
controller: this.xComponentController,
imageAIOptions: this.options
})
.onTouch((event: TouchEvent) => {
console.info(onTouch ${event.type});
})
}
.height('100%')
.width('100%')
}
}
}
https://pastebin.com/hvG11U3X
https://pastebin.com/5vptr8Vj
https://pastebin.com/spr7PWcm
https://pastebin.com/tUag4haX
https://pastebin.com/jzrFTCja
https://pastebin.com/tfu0SQsU
https://pastebin.com/2JeU38Mk
https://pastebin.com/eKgfgpUT
https://pastebin.com/73u8nze7
https://pastebin.com/4R6vjQ2e
https://pastebin.com/Ft9TAMiA
https://pastebin.com/skk2MENv
https://pastebin.com/C8J9MCsS
https://pastebin.com/D8eS2eVs
https://pastebin.com/RaU1cpC8
https://pastebin.com/wMA4np3d
https://pastebin.com/ZU1mhEvu
https://pastebin.com/5aUaBFzL
https://pastebin.com/KCTNuVQP
https://pastebin.com/PFhGqsLd
https://pastebin.com/CqqNRshL
https://pastebin.com/xyiRj81f
https://pastebin.com/156bZ7kc
https://pastebin.com/pKpShzm2
https://pastebin.com/h7PuBnh2
https://pastebin.com/8UcYTqbk
https://pastebin.com/xkMGBUL8
https://pastebin.com/BivPc7y2
https://pastebin.com/JGtxHhde
https://pastebin.com/BjDgBxnN
https://pastebin.com/ap8KeUuq
https://pastebin.com/xHKEb7rn
https://pastebin.com/DM1qdNRY
https://pastebin.com/vtxzFrXD
https://pastebin.com/aQdD2bLf
https://pastebin.com/d0ugdU4E
https://pastebin.com/Kygm5PDF
https://pastebin.com/mvAFf2cH
https://pastebin.com/wyUkyjiR
https://pastebin.com/riLYiEup
https://pastebin.com/gyyG9xim
https://pastebin.com/92KWyBzX
https://pastebin.com/SYxiX3vk
https://pastebin.com/ccQnUpjp
https://pastebin.com/9itPbhvZ
https://pastebin.com/kK5SsjRL
https://pastebin.com/fKJVLMK4
https://pastebin.com/2bRA2tTD
https://pastebin.com/bK56A1Ji
https://pastebin.com/qZdZ0ZJc
https://pastebin.com/QkWkCjEe
https://pastebin.com/uuKEVPS5
https://pastebin.com/0SqY91RV
https://pastebin.com/64YFs9BU
https://pastebin.com/KXZMGD1y
https://pastebin.com/cmDJHdy8
https://pastebin.com/XhikpFRA
https://pastebin.com/a967PHpL
https://pastebin.com/82SJ8KES
https://pastebin.com/JGUMK4Nx
https://pastebin.com/k65Apwb6
https://pastebin.com/9MKp2Bps
https://pastebin.com/dZ3k4mTn
https://pastebin.com/PgdZ425U
https://pastebin.com/ecG58Lav
https://pastebin.com/N0BBP0u7
https://pastebin.com/yHRL54ZE
https://pastebin.com/U0xa6Y0G
https://pastebin.com/UTMgGYNa
https://pastebin.com/R0zbC3rT
https://pastebin.com/97HSLFJD
https://pastebin.com/9khsxUzA
https://pastebin.com/9fNLJNN7
https://pastebin.com/4WNM1pZx
https://pastebin.com/Tw5GufjZ
https://pastebin.com/Wt26ty6F
https://pastebin.com/inqkXD0y
https://pastebin.com/ni9a0CDf
https://pastebin.com/p7MJC9KZ
https://pastebin.com/vAY8apDR
https://pastebin.com/Arn9MVZD
https://pastebin.com/QjR2Tbc9
https://pastebin.com/pZJsQkbP
https://pastebin.com/34a3s54B
https://pastebin.com/Qd8jWSJy
https://pastebin.com/5ULyrRin
https://pastebin.com/Mysz4mvi
https://pastebin.com/NSCN6QCt
https://pastebin.com/73SimwCg
https://pastebin.com/dkxTW70g
https://pastebin.com/YiqBc7eL
https://pastebin.com/T0Mi3Rtw
https://pastebin.com/5gxVnwcS
https://pastebin.com/4fcAGJqe
https://pastebin.com/DpHm4jGV
https://pastebin.com/3pgkL5s1
https://pastebin.com/xxXpzDjG
https://pastebin.com/a7GwePbx
https://pastebin.com/TiwMauwm
https://pastebin.com/Bdv3mYgA
https://pastebin.com/16hgsDGD
https://pastebin.com/VG4LgHSx
https://pastebin.com/M66rtgJR
https://pastebin.com/HtKqFQsB
https://pastebin.com/bbXWPnfj
https://pastebin.com/rWUN3huF
https://pastebin.com/UcTg7hJ1
https://pastebin.com/dn8Xx3GV
https://pastebin.com/1FP6Ntcf
https://pastebin.com/51v1RYdK
https://pastebin.com/P7UWVTwF
https://pastebin.com/Ft0AhqBE
https://pastebin.com/ZzVNTgPn
https://pastebin.com/sNjruc5F
https://pastebin.com/jMpMTVV8
https://pastebin.com/ci0DJWN1
https://pastebin.com/xxGwHDYx
https://pastebin.com/pW2P4G7e
https://pastebin.com/Zt4KZFQJ
https://pastebin.com/1zS2gHeb
https://pastebin.com/JHuHqTqf
https://pastebin.com/SwWKxw4j
https://pastebin.com/5PL1YwjX
https://pastebin.com/RrDicvTi
https://pastebin.com/UazzqSb2
https://pastebin.com/S1gyeG3P
https://pastebin.com/GKQbJJv2
https://pastebin.com/CJjdP4B3
https://pastebin.com/GB663WX1
https://pastebin.com/RQ19buAq
https://pastebin.com/148WQqS3
https://pastebin.com/U2qz1gVU
https://pastebin.com/ZeTTswjH
https://pastebin.com/vJfCU6ua
https://pastebin.com/sJgFKYdu
https://pastebin.com/F1Sj7ehQ
https://pastebin.com/CAYPv3Wv
https://pastebin.com/QC2VFSwf
https://pastebin.com/WRgpuuNh
https://pastebin.com/12Kn9u92
https://pastebin.com/yE8AK7QP
https://pastebin.com/XbqQm6PP
https://pastebin.com/WJmTxCVM
https://pastebin.com/NPNT49x6
https://pastebin.com/U1xYBpaB
https://pastebin.com/CAJ57UsS
https://pastebin.com/vqnVBxTA
https://pastebin.com/pHTHBxzP
https://pastebin.com/bqL71TeY
https://pastebin.com/wu4su4Fw
https://pastebin.com/sn6Dt1qx
https://pastebin.com/TQSHKCdX
https://pastebin.com/QbqNaMzW
https://pastebin.com/ntxUbuAm
https://pastebin.com/mTKE6ebR
https://pastebin.com/BWfdfKAF
https://pastebin.com/5i405zXJ
https://pastebin.com/tMKCxf2E
https://pastebin.com/SxfitKct
问题现象
在XComponent组件中绑定onTouch方法,但发现该方法中的日志未被输出,表明onTouch事件未能正常触发。
问题参考如下:
Column() {
XComponent({
id: 'xcomponentId-00',
type: "12314",
libraryname: 'nativerender'
})
.onTouch((event: TouchEvent) => {
console.info(onTouch ${event.type})
})
}
.height(CommonConstants.XCOMPONENT_HEIGHT)
.width('100%')
背景知识
XComponent接口提供用于图形绘制和媒体数据写入的Surface,XComponent负责将其嵌入到视图中,支持应用自定义Surface位置和大小。当XComponentType参数为SURFACE或TEXTURE时,支持onTouch等通用事件。
解决方案
当问题代码中使用XComponent(value: {id: string, type: XComponentType, libraryname?: string, controller?: XComponentController})接口时,若配置了libraryname参数,点击事件、触摸事件、按键事件等通用事件将仅响应C-API侧的事件接口,因此问题代码中的onTouch事件无法被触发。
为使onTouch事件正常响应,建议改用不含libraryname参数的XComponent(options: XComponentOptions)接口进行配置。
@Entry
@Component
struct TouchEventDemo {
xComponentController: XComponentController = new XComponentController();
private aiController: ImageAnalyzerController = new ImageAnalyzerController();
private options: ImageAIOptions = {
types: [ImageAnalyzerType.SUBJECT, ImageAnalyzerType.TEXT],
aiController: this.aiController
};
build() {
RelativeContainer() {
Column() {
XComponent({
type: XComponentType.SURFACE,
controller: this.xComponentController,
imageAIOptions: this.options
})
.onTouch((event: TouchEvent) => {
console.info(onTouch ${event.type});
})
}
.height('100%')
.width('100%')
}
}
}
https://pastebin.com/hvG11U3X
https://pastebin.com/5vptr8Vj
https://pastebin.com/spr7PWcm
https://pastebin.com/tUag4haX
https://pastebin.com/jzrFTCja
https://pastebin.com/tfu0SQsU
https://pastebin.com/2JeU38Mk
https://pastebin.com/eKgfgpUT
https://pastebin.com/73u8nze7
https://pastebin.com/4R6vjQ2e
https://pastebin.com/Ft9TAMiA
https://pastebin.com/skk2MENv
https://pastebin.com/C8J9MCsS
https://pastebin.com/D8eS2eVs
https://pastebin.com/RaU1cpC8
https://pastebin.com/wMA4np3d
https://pastebin.com/ZU1mhEvu
https://pastebin.com/5aUaBFzL
https://pastebin.com/KCTNuVQP
https://pastebin.com/PFhGqsLd
https://pastebin.com/CqqNRshL
https://pastebin.com/xyiRj81f
https://pastebin.com/156bZ7kc
https://pastebin.com/pKpShzm2
https://pastebin.com/h7PuBnh2
https://pastebin.com/8UcYTqbk
https://pastebin.com/xkMGBUL8
https://pastebin.com/BivPc7y2
https://pastebin.com/JGtxHhde
https://pastebin.com/BjDgBxnN
https://pastebin.com/ap8KeUuq
https://pastebin.com/xHKEb7rn
https://pastebin.com/DM1qdNRY
https://pastebin.com/vtxzFrXD
https://pastebin.com/aQdD2bLf
https://pastebin.com/d0ugdU4E
https://pastebin.com/Kygm5PDF
https://pastebin.com/mvAFf2cH
https://pastebin.com/wyUkyjiR
https://pastebin.com/riLYiEup
https://pastebin.com/gyyG9xim
https://pastebin.com/92KWyBzX
https://pastebin.com/SYxiX3vk
https://pastebin.com/ccQnUpjp
https://pastebin.com/9itPbhvZ
https://pastebin.com/kK5SsjRL
https://pastebin.com/fKJVLMK4
https://pastebin.com/2bRA2tTD
https://pastebin.com/bK56A1Ji
https://pastebin.com/qZdZ0ZJc
https://pastebin.com/QkWkCjEe
https://pastebin.com/uuKEVPS5
https://pastebin.com/0SqY91RV
https://pastebin.com/64YFs9BU
https://pastebin.com/KXZMGD1y
https://pastebin.com/cmDJHdy8
https://pastebin.com/XhikpFRA
https://pastebin.com/a967PHpL
https://pastebin.com/82SJ8KES
https://pastebin.com/JGUMK4Nx
https://pastebin.com/k65Apwb6
https://pastebin.com/9MKp2Bps
https://pastebin.com/dZ3k4mTn
https://pastebin.com/PgdZ425U
https://pastebin.com/ecG58Lav
https://pastebin.com/N0BBP0u7
https://pastebin.com/yHRL54ZE
https://pastebin.com/U0xa6Y0G
https://pastebin.com/UTMgGYNa
https://pastebin.com/R0zbC3rT
https://pastebin.com/97HSLFJD
https://pastebin.com/9khsxUzA
https://pastebin.com/9fNLJNN7
https://pastebin.com/4WNM1pZx
https://pastebin.com/Tw5GufjZ
https://pastebin.com/Wt26ty6F
https://pastebin.com/inqkXD0y
https://pastebin.com/ni9a0CDf
https://pastebin.com/p7MJC9KZ
https://pastebin.com/vAY8apDR
https://pastebin.com/Arn9MVZD
https://pastebin.com/QjR2Tbc9
https://pastebin.com/pZJsQkbP
https://pastebin.com/34a3s54B
https://pastebin.com/Qd8jWSJy
https://pastebin.com/5ULyrRin
https://pastebin.com/Mysz4mvi
https://pastebin.com/NSCN6QCt
https://pastebin.com/73SimwCg
https://pastebin.com/dkxTW70g
https://pastebin.com/YiqBc7eL
https://pastebin.com/T0Mi3Rtw
https://pastebin.com/5gxVnwcS
https://pastebin.com/4fcAGJqe
https://pastebin.com/DpHm4jGV
https://pastebin.com/3pgkL5s1
https://pastebin.com/xxXpzDjG
https://pastebin.com/a7GwePbx
https://pastebin.com/TiwMauwm
https://pastebin.com/Bdv3mYgA
https://pastebin.com/16hgsDGD
https://pastebin.com/VG4LgHSx
https://pastebin.com/M66rtgJR
https://pastebin.com/HtKqFQsB
https://pastebin.com/bbXWPnfj
https://pastebin.com/rWUN3huF
https://pastebin.com/UcTg7hJ1
https://pastebin.com/dn8Xx3GV
https://pastebin.com/1FP6Ntcf
https://pastebin.com/51v1RYdK
https://pastebin.com/P7UWVTwF
https://pastebin.com/Ft0AhqBE
https://pastebin.com/ZzVNTgPn
https://pastebin.com/sNjruc5F
https://pastebin.com/jMpMTVV8
https://pastebin.com/ci0DJWN1
https://pastebin.com/xxGwHDYx
https://pastebin.com/pW2P4G7e
https://pastebin.com/Zt4KZFQJ
https://pastebin.com/1zS2gHeb
https://pastebin.com/JHuHqTqf
https://pastebin.com/SwWKxw4j
https://pastebin.com/5PL1YwjX
https://pastebin.com/RrDicvTi
https://pastebin.com/UazzqSb2
https://pastebin.com/S1gyeG3P
https://pastebin.com/GKQbJJv2
https://pastebin.com/CJjdP4B3
https://pastebin.com/GB663WX1
https://pastebin.com/RQ19buAq
https://pastebin.com/148WQqS3
https://pastebin.com/U2qz1gVU
https://pastebin.com/ZeTTswjH
https://pastebin.com/vJfCU6ua
https://pastebin.com/sJgFKYdu
https://pastebin.com/F1Sj7ehQ
https://pastebin.com/CAYPv3Wv
https://pastebin.com/QC2VFSwf
https://pastebin.com/WRgpuuNh
https://pastebin.com/12Kn9u92
https://pastebin.com/yE8AK7QP
https://pastebin.com/XbqQm6PP
https://pastebin.com/WJmTxCVM
https://pastebin.com/NPNT49x6
https://pastebin.com/U1xYBpaB
https://pastebin.com/CAJ57UsS
https://pastebin.com/vqnVBxTA
https://pastebin.com/pHTHBxzP
https://pastebin.com/bqL71TeY
https://pastebin.com/wu4su4Fw
https://pastebin.com/sn6Dt1qx
https://pastebin.com/TQSHKCdX
https://pastebin.com/QbqNaMzW
https://pastebin.com/ntxUbuAm
https://pastebin.com/mTKE6ebR
https://pastebin.com/BWfdfKAF
https://pastebin.com/5i405zXJ
https://pastebin.com/tMKCxf2E
https://pastebin.com/SxfitKct
uni-app 国际化问题配置示例
如有更好的解决方法,欢迎大家评论和指正。
-
目前存在的问题
- IOS平台 通用启动界面的应用名称,会使用回退语言,没有实现国际化配置,可参考iOS平台自定义storyboard启动界面;
- 还有目前没有涉及到的国际化,如:uniCloud和Android、IOS的strings 等。
-
项目根目录下 locale 文件夹
├── locale │ ├── en.json │ ├── index.js │ ├── // 如果有其他语言,如日语,则添加 ja.json │ ├── uni-app.en.json │ ├── // 如果有其他语言,如日语,则添加 uni-app.ja.json │ ├── uni-app.zh-Hans.json │ ├── uni-app.zh-Hant.json │ ├── zh-Hans.json │ └── zh-Hant.json这里做下简单说明:
- uni-app.
*.json 这些文件,决定了手机系统桌面应用名称、默认启动图显示名称和系统默认tabbar定义的国际化; - uni-app.
*.json 使用跟官方文档一致 如:%app.name%,%privacyDescription.NSBluetoothAlwaysUsageDescription%
-
index.js
import en from './en.json' import zhHans from './zh-Hans.json' import zhHant from './zh-Hant.json' // import ja from './ja.json' export default { en, 'zh-Hans': zhHans, 'zh-Hant': zhHant, // 如果有其他语言,如日语,则添加 ja } -
en.json
{ "tabbar.index": "Home", "tabbar.list": "List", "tabbar.me": "Me", // ... 其他字段 } -
zh-Hans.json
{ "tabbar.index": "首页", "tabbar.list": "列表", "tabbar.me": "我的", // ... 其他字段 } -
zh-Hant.json
{ "tabbar.index": "首頁", "tabbar.list": "清單", "tabbar.me": "我的", // ... 其他字段 }
配置应用名称和IOS平台配置隐私权限描述国际化,还有一种方式官方提供的 manifest.json国际化配置【本文章末尾】
注意:
我试过在uni-app.en.json中把隐私权限描述写到下面ios内打包,没有效果// 这里是错误说明,不要这样写,正确写法是下面的 uni-app.en.json 示例 { "common": { "app.name": "app name", "app.description": "app description", }, "ios": { "privacyDescription":{ "NSBluetoothAlwaysUsageDescription": "Requires access to your Bluetooth to function properly", "NSBluetoothPeripheralUsageDescription": "Requires access to your Bluetooth to function properly" } }, "android": {} }
-
uni-app.en.json
{ "common": { "app.name": "app name", "app.description": "app description", "privacyDescription.NSBluetoothAlwaysUsageDescription": "Requires access to your Bluetooth to function properly", "privacyDescription.NSBluetoothPeripheralUsageDescription": "Requires access to your Bluetooth to function properly" }, "ios": {}, "android": {} } -
uni-app.zh-Hans.json
{ "common": { "app.name": "应用名称", "app.description": "应用描述", "privacyDescription.NSBluetoothAlwaysUsageDescription": "需要访问您的蓝牙以正常工作", "privacyDescription.NSBluetoothPeripheralUsageDescription": "需要访问您的蓝牙以正常工作" }, "ios": {}, "android": {} } -
uni-app.zh-Hant.json
{ "common": { "app.name": "應用名稱", "app.description": "應用描述", "privacyDescription.NSBluetoothAlwaysUsageDescription": "需要訪問您的藍牙以正常工作", "privacyDescription.NSBluetoothPeripheralUsageDescription": "需要訪問您的藍牙以正常工作" }, "ios": {}, "android": {} }
- uni-app.
-
manifest.json
-
配置 manifest.json 的默认语言 在 manifest.json 中设置 locale 为 "auto",这是跟随系统语言的关键。
{ "name": "%app.name%", "appid": "__your_appid__", "locale": "auto" // 设置为 auto,跟随系统 os 的语言 "fallbackLocale" : "en" // 默认回退语言 这个根据自己需求设置 // ... 其他配置 } -
配置 android 和 ios 原生平台支持的语言列表 (关键步骤) 这一步在标准基座下由 dcloud 预先完成,但你的应用必须自行配置,否则打包后系统无法识别你的应用支持哪些语言,通常会回退到默认语言 (如英文)。【这里不提供详细示例,可以直接看官方文档中的示例】
{ "app-plus" : { "locales": { "en": {}, // 英文 "zh": {}, // 中文 (特别注意) "zh-Hans": {}, // 简体中文 "zh-Hant": {}, // 繁体中文 // 如果有其他语言,如日语,则添加 "ja": {} } } }关于
"zh": {}这个配置说明:- 如果这个
"zh": {}不配置,当系统语言是【简体中文】,应用名称和隐私权限描述会回退到【默认语言】,而不是使用【简体中文】-搞不懂为什么 - 虽然官方文档语言代码中提到locales下的键名(key)可以取值示例,没有
zh-Hans和zh-Hant两个键名,但是实际测试中,其可以正常工作,其他键名会回退到【默认语言】,而不是当前【系统语言】
- 如果这个
如有更好的解决方法,欢迎大家评论和指正。
-
目前存在的问题
- IOS平台 通用启动界面的应用名称,会使用回退语言,没有实现国际化配置,可参考iOS平台自定义storyboard启动界面;
- 还有目前没有涉及到的国际化,如:uniCloud和Android、IOS的strings 等。
-
项目根目录下 locale 文件夹
├── locale │ ├── en.json │ ├── index.js │ ├── // 如果有其他语言,如日语,则添加 ja.json │ ├── uni-app.en.json │ ├── // 如果有其他语言,如日语,则添加 uni-app.ja.json │ ├── uni-app.zh-Hans.json │ ├── uni-app.zh-Hant.json │ ├── zh-Hans.json │ └── zh-Hant.json这里做下简单说明:
- uni-app.
*.json 这些文件,决定了手机系统桌面应用名称、默认启动图显示名称和系统默认tabbar定义的国际化; - uni-app.
*.json 使用跟官方文档一致 如:%app.name%,%privacyDescription.NSBluetoothAlwaysUsageDescription%
-
index.js
import en from './en.json' import zhHans from './zh-Hans.json' import zhHant from './zh-Hant.json' // import ja from './ja.json' export default { en, 'zh-Hans': zhHans, 'zh-Hant': zhHant, // 如果有其他语言,如日语,则添加 ja } -
en.json
{ "tabbar.index": "Home", "tabbar.list": "List", "tabbar.me": "Me", // ... 其他字段 } -
zh-Hans.json
{ "tabbar.index": "首页", "tabbar.list": "列表", "tabbar.me": "我的", // ... 其他字段 } -
zh-Hant.json
{ "tabbar.index": "首頁", "tabbar.list": "清單", "tabbar.me": "我的", // ... 其他字段 }
配置应用名称和IOS平台配置隐私权限描述国际化,还有一种方式官方提供的 manifest.json国际化配置【本文章末尾】
注意:
我试过在uni-app.en.json中把隐私权限描述写到下面ios内打包,没有效果// 这里是错误说明,不要这样写,正确写法是下面的 uni-app.en.json 示例 { "common": { "app.name": "app name", "app.description": "app description", }, "ios": { "privacyDescription":{ "NSBluetoothAlwaysUsageDescription": "Requires access to your Bluetooth to function properly", "NSBluetoothPeripheralUsageDescription": "Requires access to your Bluetooth to function properly" } }, "android": {} }
-
uni-app.en.json
{ "common": { "app.name": "app name", "app.description": "app description", "privacyDescription.NSBluetoothAlwaysUsageDescription": "Requires access to your Bluetooth to function properly", "privacyDescription.NSBluetoothPeripheralUsageDescription": "Requires access to your Bluetooth to function properly" }, "ios": {}, "android": {} } -
uni-app.zh-Hans.json
{ "common": { "app.name": "应用名称", "app.description": "应用描述", "privacyDescription.NSBluetoothAlwaysUsageDescription": "需要访问您的蓝牙以正常工作", "privacyDescription.NSBluetoothPeripheralUsageDescription": "需要访问您的蓝牙以正常工作" }, "ios": {}, "android": {} } -
uni-app.zh-Hant.json
{ "common": { "app.name": "應用名稱", "app.description": "應用描述", "privacyDescription.NSBluetoothAlwaysUsageDescription": "需要訪問您的藍牙以正常工作", "privacyDescription.NSBluetoothPeripheralUsageDescription": "需要訪問您的藍牙以正常工作" }, "ios": {}, "android": {} }
- uni-app.
-
manifest.json
-
配置 manifest.json 的默认语言 在 manifest.json 中设置 locale 为 "auto",这是跟随系统语言的关键。
{ "name": "%app.name%", "appid": "__your_appid__", "locale": "auto" // 设置为 auto,跟随系统 os 的语言 "fallbackLocale" : "en" // 默认回退语言 这个根据自己需求设置 // ... 其他配置 } -
配置 android 和 ios 原生平台支持的语言列表 (关键步骤) 这一步在标准基座下由 dcloud 预先完成,但你的应用必须自行配置,否则打包后系统无法识别你的应用支持哪些语言,通常会回退到默认语言 (如英文)。【这里不提供详细示例,可以直接看官方文档中的示例】
{ "app-plus" : { "locales": { "en": {}, // 英文 "zh": {}, // 中文 (特别注意) "zh-Hans": {}, // 简体中文 "zh-Hant": {}, // 繁体中文 // 如果有其他语言,如日语,则添加 "ja": {} } } }关于
"zh": {}这个配置说明:- 如果这个
"zh": {}不配置,当系统语言是【简体中文】,应用名称和隐私权限描述会回退到【默认语言】,而不是使用【简体中文】-搞不懂为什么 - 虽然官方文档语言代码中提到locales下的键名(key)可以取值示例,没有
zh-Hans和zh-Hant两个键名,但是实际测试中,其可以正常工作,其他键名会回退到【默认语言】,而不是当前【系统语言】
- 如果这个
使用openCustomDialog实现菜单弹窗并返回选中项回调
问题现象
如何使用openCustomDialog实现一个从外部传参的菜单弹窗,并且可以在外部监听菜单项的选中状态。
效果预览
点击放大
背景知识
不依赖UI组件的全局自定义弹出框 (openCustomDialog)存在两种入参方式创建自定义弹出框,ComponentContent和Builder:
ComponentContent封装内容可以与UI界面解耦,调用更加灵活。
使用Builder的方式,必须要与上下文做绑定,与UI存在一定耦合,具体请参考:不依赖UI组件的全局自定义弹出框 (openCustomDialog)的说明部分和@Builder装饰器使用说明。
解决方案
参考完整示例实现弹窗工具类,其核心是以下属性的具体实现:
ctx:获取UI上下文用于实现弹窗服务。
contentNode:构建弹窗的内容节点,其中弹窗中菜单项的个数,内容和传给外部监听的回调通过Params类型的数组传递。
options:弹窗的配置选项,如位置、样式、动画等。
export class CustomerPromptAction {
static ctx: UIContext;
static contentNode: ComponentContent<Object>;
static options: promptAction.BaseDialogOptions;
// 定义方法,从外部传入上下文。
static setContext(context: UIContext) {
CustomerPromptAction.ctx = context;
}
// 配置内容节点。
static setContentNode(context: UIContext, para: Params[]) {
let contentNode: ComponentContent<Object> = new ComponentContent(context, wrapBuilder(buildText), para);
CustomerPromptAction.contentNode = contentNode;
}
// 弹窗配置项。
static setOptions(options: promptAction.BaseDialogOptions) {
CustomerPromptAction.options = options;
}
// 定义弹窗打开的方法。
static openDialog(options: promptAction.BaseDialogOptions, context: UIContext, para: Params[]) {
CustomerPromptAction.setContext(context);
CustomerPromptAction.setContentNode(context, para);
CustomerPromptAction.setOptions({ alignment: DialogAlignment.Bottom, offset: { dx: 0, dy: -20 } });
if (CustomerPromptAction.contentNode !== null) {
CustomerPromptAction.ctx.getPromptAction()
.openCustomDialog(CustomerPromptAction.contentNode, options)
.then(() => {
})
.catch(() => {
});
}
}
// 关闭弹窗。
static closeDialog() {
if (CustomerPromptAction.contentNode !== null) {
CustomerPromptAction.ctx.getPromptAction()
.closeCustomDialog(CustomerPromptAction.contentNode)
.then(() => {
})
.catch(() => {
});
}
}
}
在页面中调用弹窗。
自定义菜单项实体类,声明属性方法,并通过构造器实例化。
export class Params {
title: string = "";
iconName: Resource;
click: (index: number) => void;
constructor(title: string, iconName: Resource, click: (index: number) => void) {
this.title = title;
this.iconName = iconName;
this.click = click;
}
}
定义接收菜单项选中回调的函数,根据返回的index处理逻辑。
// 点击弹窗中按钮的回调,并根据返回的index判断逻辑。
callBack = (index: number) => {
switch (index) {
case 0:
this.getUIContext().getPromptAction().showToast({
message: '点击了关闭',
duration: 2000
});
break;
case 1:
this.getUIContext().getPromptAction().showToast({
message: '点击了刷新',
duration: 2000
});
break;
case 2:
this.getUIContext().getPromptAction().showToast({
message: '点击了帮助',
duration: 2000
});
break;
}
};
初始化菜单数据源。
@State arr: Params[] = [
new Params('关闭', $r('app.media.startIcon'), this.callBack),
new Params('刷新', $r('app.media.startIcon'), this.callBack),
new Params('帮助', $r('app.media.startIcon'), this.callBack)];
完整示例参考如下:
CustomerPromptAction.ets。
import { ComponentContent, promptAction } from '@kit.ArkUI';
import { UIContext } from '@ohos.arkui.UIContext';
import { Params } from './Params';
@Builder
function buildText(params: Params[]) {
Column() {
Flex({ direction: FlexDirection.Row, justifyContent: FlexAlign.SpaceAround }) {
ForEach(params, (item: Params, index: number) => {
Column() {
Image(item.iconName)
.width(30)
.height(30)
.margin({ top: 20 })
.onClick(() => {
item.click(index);
});
Text(item.title)
.fontSize(15)
.margin({ top: 20 });
};
});
}
.width('100%');
Text('取消')
.margin({ top: 20 })
.height(50)
.width('100%')
.textAlign(TextAlign.Center)
.fontColor(Color.Blue)
.onClick(() => {
CustomerPromptAction.closeDialog();
});
}
.backgroundColor('#FFF0F0F0')
.width('100%')
.borderRadius(10);
}
export class CustomerPromptAction {
static ctx: UIContext;
static contentNode: ComponentContent<Object>;
static options: promptAction.BaseDialogOptions;
// 定义方法,从外部传入上下文。
static setContext(context: UIContext) {
CustomerPromptAction.ctx = context;
}
// 配置内容节点。
static setContentNode(context: UIContext, para: Params[]) {
let contentNode: ComponentContent<Object> = new ComponentContent(context, wrapBuilder(buildText), para);
CustomerPromptAction.contentNode = contentNode;
}
// 弹窗配置项。
static setOptions(options: promptAction.BaseDialogOptions) {
CustomerPromptAction.options = options;
}
// 定义弹窗打开的方法。
static openDialog(options: promptAction.BaseDialogOptions, context: UIContext, para: Params[]) {
CustomerPromptAction.setContext(context);
CustomerPromptAction.setContentNode(context, para);
CustomerPromptAction.setOptions({ alignment: DialogAlignment.Bottom, offset: { dx: 0, dy: -20 } });
if (CustomerPromptAction.contentNode !== null) {
CustomerPromptAction.ctx.getPromptAction()
.openCustomDialog(CustomerPromptAction.contentNode, options)
.then(() => {
})
.catch(() => {
});
}
}
// 关闭弹窗。
static closeDialog() {
if (CustomerPromptAction.contentNode !== null) {
CustomerPromptAction.ctx.getPromptAction()
.closeCustomDialog(CustomerPromptAction.contentNode)
.then(() => {
})
.catch(() => {
});
}
}
}
Index.ets。
import { Params } from './Params';
import { CustomerPromptAction } from './CustomerPromptAction';
@Entry
@Component
struct Index {
// 点击弹窗中按钮的回调,并根据返回的index判断逻辑。
callBack = (index: number) => {
switch (index) {
case 0:
this.getUIContext().getPromptAction().showToast({
message: '点击了关闭',
duration: 2000
});
break;
case 1:
this.getUIContext().getPromptAction().showToast({
message: '点击了刷新',
duration: 2000
});
break;
case 2:
this.getUIContext().getPromptAction().showToast({
message: '点击了帮助',
duration: 2000
});
break;
}
};
// 初始化弹窗菜单的数据源。
@State arr: Params[] = [
new Params('关闭', $r('app.media.startIcon'), this.callBack),
new Params('刷新', $r('app.media.startIcon'), this.callBack),
new Params('帮助', $r('app.media.startIcon'), this.callBack)];
build() {
Row() {
Column() {
Button('横向菜单')
.margin({ top: 50 })
.onClick(() => {
CustomerPromptAction.openDialog({
alignment: DialogAlignment.Bottom,
offset: { dx: 0, dy: -20 }
}, this.getUIContext(), this.arr);
});
}
.width('100%')
.height('100%');
}
.height('100%');
}
}
Params.ets。
export class Params {
title: string = "";
iconName: Resource;
click: (index: number) => void;
constructor(title: string, iconName: Resource, click: (index: number) => void) {
this.title = title;
this.iconName = iconName;
this.click = click;
}
}
https://pastebin.com/epc9DuYu
https://pastebin.com/CErz3YL5
https://pastebin.com/6YfF0p9z
https://pastebin.com/kvpAvpdi
https://pastebin.com/X9fq64a2
https://pastebin.com/R0mRDZm4
https://pastebin.com/urWyLjrt
https://pastebin.com/hcKewAUE
https://pastebin.com/MjLnmvNu
https://pastebin.com/DYMeHxN4
https://pastebin.com/L5C77sX1
https://pastebin.com/rM22qRKb
https://pastebin.com/s8kwwq5k
https://pastebin.com/A6qfadrP
https://pastebin.com/PmbE57qe
https://pastebin.com/FiuhbGfN
https://pastebin.com/B2XYkbnq
https://pastebin.com/e0q36QBZ
https://pastebin.com/VwfcTkgK
https://pastebin.com/STYB1p4a
https://pastebin.com/dL8avv7N
https://pastebin.com/WgRdkJJY
https://pastebin.com/MHmf76dA
https://pastebin.com/2b2X9tbY
https://pastebin.com/uVqiYmpP
https://pastebin.com/iWFWeXfu
https://pastebin.com/tcz08Ga5
https://pastebin.com/rFqwzRhK
https://pastebin.com/bCm1D1Wj
https://pastebin.com/EWbGJZcy
https://pastebin.com/R30cJtHA
https://pastebin.com/aV9QTvJL
https://pastebin.com/EF75B0Xs
https://pastebin.com/UFQ2LwDh
https://pastebin.com/2CK4bM77
https://pastebin.com/ksCNHaF7
https://pastebin.com/k61KcBd4
https://pastebin.com/5GUXdSsP
https://pastebin.com/byn4kM9X
https://pastebin.com/nLG9ztxg
https://pastebin.com/PGrz7isp
https://pastebin.com/Jdv6Vcs1
https://pastebin.com/wc3q2WXq
https://pastebin.com/7JM3Dt9d
https://pastebin.com/PKueg3Q9
https://pastebin.com/C8hvWEnm
https://pastebin.com/nXYRTagp
https://pastebin.com/rzgJVLh2
https://pastebin.com/GXM3nwFL
https://pastebin.com/t0GkNTPB
https://pastebin.com/SGvqUZUw
https://pastebin.com/8SfWYWuA
https://pastebin.com/Cy2FKdCM
https://pastebin.com/Cwi3azHM
https://pastebin.com/WkBmAyzu
https://pastebin.com/NtcjEH8D
https://pastebin.com/vPFjfiTV
https://pastebin.com/VWQEns27
https://pastebin.com/W6sGgxN4
https://pastebin.com/BRTkHWLB
https://pastebin.com/b7kSTqmz
https://pastebin.com/TKxKXExY
https://pastebin.com/WbG5WLAC
https://pastebin.com/kNf5AwB7
https://pastebin.com/cgRrpGud
https://pastebin.com/TqFJkVxd
https://pastebin.com/Vwrjm3w7
https://pastebin.com/4ZMkzUBr
https://pastebin.com/GBW5cMYt
https://pastebin.com/RmGU8Ame
https://pastebin.com/RJ11SAm3
https://pastebin.com/kQrLCYA9
https://pastebin.com/9EH4eaY8
https://pastebin.com/YacTrPKQ
https://pastebin.com/DBYCLtGD
https://pastebin.com/y9eemXwr
https://pastebin.com/jTRFengj
https://pastebin.com/5vE7dpsX
https://pastebin.com/Kk4bZ1L8
https://pastebin.com/Kaxbk4tA
https://pastebin.com/LGR3sxN9
https://pastebin.com/2jbBiu33
https://pastebin.com/p0i2Fj9p
https://pastebin.com/uThxW0MS
https://pastebin.com/hr1MtNuu
https://pastebin.com/nt6fFaYk
https://pastebin.com/9RMs3tAN
https://pastebin.com/3pp9A2SP
https://pastebin.com/fbQQkXb2
https://pastebin.com/RNXkhP0Y
https://pastebin.com/62GMDSK1
https://pastebin.com/A47VdXsL
https://pastebin.com/TMXrdhxC
https://pastebin.com/pZV96ZWh
https://pastebin.com/BnZ4zhH2
https://pastebin.com/hqGmeuVV
https://pastebin.com/aqnxjmby
https://pastebin.com/uNxvnAFc
https://pastebin.com/tkDrfMPT
https://pastebin.com/6ufWP0fd
https://pastebin.com/H9PDFDiL
https://pastebin.com/qM9xgF7K
https://pastebin.com/iyJTH6aB
https://pastebin.com/ZiJyZLfp
https://pastebin.com/yZ0LA5dB
https://pastebin.com/8YX63tah
https://pastebin.com/v6DvRcJ2
https://pastebin.com/dQSyGA1C
https://pastebin.com/VLfUFJ3F
https://pastebin.com/1dHdGtmp
https://pastebin.com/ufJFhNjk
https://pastebin.com/t1WuAJPZ
https://pastebin.com/pZUndcCA
https://pastebin.com/ZPDUXz25
https://pastebin.com/ULjPJmEH
https://pastebin.com/EhwMqtVW
https://pastebin.com/EMCGkgwT
https://pastebin.com/60tHCxEM
https://pastebin.com/c4nYDLJs
https://pastebin.com/gQSdXcnY
https://pastebin.com/jc1GvWkA
https://pastebin.com/EVjzT0Bn
https://pastebin.com/hE2m4nfV
https://pastebin.com/yf3kYzc4
https://pastebin.com/Cj6MtRgk
https://pastebin.com/e7Hmp2wM
https://pastebin.com/0HzG8VJj
https://pastebin.com/XSTJdK3z
https://pastebin.com/mnfsGkiu
https://pastebin.com/REx1hsyn
https://pastebin.com/50VxQZdS
https://pastebin.com/0mxMQAfy
https://pastebin.com/AtGFtGFZ
https://pastebin.com/eWZmH6hX
https://pastebin.com/68JVfXcf
https://pastebin.com/Q47SWvBD
https://pastebin.com/pRR9gzGf
https://pastebin.com/KiMStGV8
https://pastebin.com/k8vp0yy7
https://pastebin.com/tN4ChEUp
https://pastebin.com/7pAZ3CdD
https://pastebin.com/rpSjghL8
https://pastebin.com/dYFDgUM2
https://pastebin.com/NQyE3NAS
https://pastebin.com/LYbXc4ma
https://pastebin.com/naCsLg7e
https://pastebin.com/YKnWCm7f
https://pastebin.com/zpSHGbnT
https://pastebin.com/qQg87Qa5
https://pastebin.com/wkijxdgV
https://pastebin.com/ASWCH7DB
https://pastebin.com/NAQqmtQW
https://pastebin.com/6QHpyMV1
https://pastebin.com/WT2xgp5n
https://pastebin.com/qnTJDwPy
https://pastebin.com/fZvEStv0
https://pastebin.com/VfxtAWJG
https://pastebin.com/vyQJKxFQ
https://pastebin.com/ExGA44gn
https://pastebin.com/qa32zS0w
https://pastebin.com/GfBS4n1X
https://pastebin.com/JhJXWrUD
https://pastebin.com/WNPAYC7r
https://pastebin.com/i77xpTm7
https://pastebin.com/jvyz7bDq
https://pastebin.com/h9iLgLDD
https://pastebin.com/0dtPxmtT
https://pastebin.com/4w2UaMSn
https://pastebin.com/Xy4S2CRs
https://pastebin.com/EHQtV6b7
https://pastebin.com/ucP8B1iB
https://pastebin.com/2Wcf2GNC
https://pastebin.com/L9SiHQ9Z
https://pastebin.com/wXCZSJuj
https://pastebin.com/vbPTR3SF
https://pastebin.com/7yxGcVHj
https://pastebin.com/MNAcjFjQ
https://pastebin.com/0A4KPjNU
https://pastebin.com/8nBAWbca
https://pastebin.com/Gh9dhxLt
https://pastebin.com/08qFJBye
https://pastebin.com/mfweHrVr
https://pastebin.com/RWwcnrYC
https://pastebin.com/anp3JpJ4
https://pastebin.com/hvBGQjK0
https://pastebin.com/EGww5k1k
https://pastebin.com/wdwGG4Mx
https://pastebin.com/qKr2rRmv
https://pastebin.com/VpBYTpBH
https://pastebin.com/Bsix0p2q
https://pastebin.com/d0rRmyuc
https://pastebin.com/1YVNhKx3
https://pastebin.com/PGscNx9F
https://pastebin.com/sE0Mv39Y
https://pastebin.com/20dhC8pa
https://pastebin.com/wGmcz5e6
https://pastebin.com/cApeLHfX
https://pastebin.com/mBT8cEji
https://pastebin.com/4hXLXigr
https://pastebin.com/fgt151cu
问题现象
如何使用openCustomDialog实现一个从外部传参的菜单弹窗,并且可以在外部监听菜单项的选中状态。
效果预览
点击放大
背景知识
不依赖UI组件的全局自定义弹出框 (openCustomDialog)存在两种入参方式创建自定义弹出框,ComponentContent和Builder:
ComponentContent封装内容可以与UI界面解耦,调用更加灵活。
使用Builder的方式,必须要与上下文做绑定,与UI存在一定耦合,具体请参考:不依赖UI组件的全局自定义弹出框 (openCustomDialog)的说明部分和@Builder装饰器使用说明。
解决方案
参考完整示例实现弹窗工具类,其核心是以下属性的具体实现:
ctx:获取UI上下文用于实现弹窗服务。
contentNode:构建弹窗的内容节点,其中弹窗中菜单项的个数,内容和传给外部监听的回调通过Params类型的数组传递。
options:弹窗的配置选项,如位置、样式、动画等。
export class CustomerPromptAction {
static ctx: UIContext;
static contentNode: ComponentContent<Object>;
static options: promptAction.BaseDialogOptions;
// 定义方法,从外部传入上下文。
static setContext(context: UIContext) {
CustomerPromptAction.ctx = context;
}
// 配置内容节点。
static setContentNode(context: UIContext, para: Params[]) {
let contentNode: ComponentContent<Object> = new ComponentContent(context, wrapBuilder(buildText), para);
CustomerPromptAction.contentNode = contentNode;
}
// 弹窗配置项。
static setOptions(options: promptAction.BaseDialogOptions) {
CustomerPromptAction.options = options;
}
// 定义弹窗打开的方法。
static openDialog(options: promptAction.BaseDialogOptions, context: UIContext, para: Params[]) {
CustomerPromptAction.setContext(context);
CustomerPromptAction.setContentNode(context, para);
CustomerPromptAction.setOptions({ alignment: DialogAlignment.Bottom, offset: { dx: 0, dy: -20 } });
if (CustomerPromptAction.contentNode !== null) {
CustomerPromptAction.ctx.getPromptAction()
.openCustomDialog(CustomerPromptAction.contentNode, options)
.then(() => {
})
.catch(() => {
});
}
}
// 关闭弹窗。
static closeDialog() {
if (CustomerPromptAction.contentNode !== null) {
CustomerPromptAction.ctx.getPromptAction()
.closeCustomDialog(CustomerPromptAction.contentNode)
.then(() => {
})
.catch(() => {
});
}
}
}
在页面中调用弹窗。
自定义菜单项实体类,声明属性方法,并通过构造器实例化。
export class Params {
title: string = "";
iconName: Resource;
click: (index: number) => void;
constructor(title: string, iconName: Resource, click: (index: number) => void) {
this.title = title;
this.iconName = iconName;
this.click = click;
}
}
定义接收菜单项选中回调的函数,根据返回的index处理逻辑。
// 点击弹窗中按钮的回调,并根据返回的index判断逻辑。
callBack = (index: number) => {
switch (index) {
case 0:
this.getUIContext().getPromptAction().showToast({
message: '点击了关闭',
duration: 2000
});
break;
case 1:
this.getUIContext().getPromptAction().showToast({
message: '点击了刷新',
duration: 2000
});
break;
case 2:
this.getUIContext().getPromptAction().showToast({
message: '点击了帮助',
duration: 2000
});
break;
}
};
初始化菜单数据源。
@State arr: Params[] = [
new Params('关闭', $r('app.media.startIcon'), this.callBack),
new Params('刷新', $r('app.media.startIcon'), this.callBack),
new Params('帮助', $r('app.media.startIcon'), this.callBack)];
完整示例参考如下:
CustomerPromptAction.ets。
import { ComponentContent, promptAction } from '@kit.ArkUI';
import { UIContext } from '@ohos.arkui.UIContext';
import { Params } from './Params';
@Builder
function buildText(params: Params[]) {
Column() {
Flex({ direction: FlexDirection.Row, justifyContent: FlexAlign.SpaceAround }) {
ForEach(params, (item: Params, index: number) => {
Column() {
Image(item.iconName)
.width(30)
.height(30)
.margin({ top: 20 })
.onClick(() => {
item.click(index);
});
Text(item.title)
.fontSize(15)
.margin({ top: 20 });
};
});
}
.width('100%');
Text('取消')
.margin({ top: 20 })
.height(50)
.width('100%')
.textAlign(TextAlign.Center)
.fontColor(Color.Blue)
.onClick(() => {
CustomerPromptAction.closeDialog();
});
}
.backgroundColor('#FFF0F0F0')
.width('100%')
.borderRadius(10);
}
export class CustomerPromptAction {
static ctx: UIContext;
static contentNode: ComponentContent<Object>;
static options: promptAction.BaseDialogOptions;
// 定义方法,从外部传入上下文。
static setContext(context: UIContext) {
CustomerPromptAction.ctx = context;
}
// 配置内容节点。
static setContentNode(context: UIContext, para: Params[]) {
let contentNode: ComponentContent<Object> = new ComponentContent(context, wrapBuilder(buildText), para);
CustomerPromptAction.contentNode = contentNode;
}
// 弹窗配置项。
static setOptions(options: promptAction.BaseDialogOptions) {
CustomerPromptAction.options = options;
}
// 定义弹窗打开的方法。
static openDialog(options: promptAction.BaseDialogOptions, context: UIContext, para: Params[]) {
CustomerPromptAction.setContext(context);
CustomerPromptAction.setContentNode(context, para);
CustomerPromptAction.setOptions({ alignment: DialogAlignment.Bottom, offset: { dx: 0, dy: -20 } });
if (CustomerPromptAction.contentNode !== null) {
CustomerPromptAction.ctx.getPromptAction()
.openCustomDialog(CustomerPromptAction.contentNode, options)
.then(() => {
})
.catch(() => {
});
}
}
// 关闭弹窗。
static closeDialog() {
if (CustomerPromptAction.contentNode !== null) {
CustomerPromptAction.ctx.getPromptAction()
.closeCustomDialog(CustomerPromptAction.contentNode)
.then(() => {
})
.catch(() => {
});
}
}
}
Index.ets。
import { Params } from './Params';
import { CustomerPromptAction } from './CustomerPromptAction';
@Entry
@Component
struct Index {
// 点击弹窗中按钮的回调,并根据返回的index判断逻辑。
callBack = (index: number) => {
switch (index) {
case 0:
this.getUIContext().getPromptAction().showToast({
message: '点击了关闭',
duration: 2000
});
break;
case 1:
this.getUIContext().getPromptAction().showToast({
message: '点击了刷新',
duration: 2000
});
break;
case 2:
this.getUIContext().getPromptAction().showToast({
message: '点击了帮助',
duration: 2000
});
break;
}
};
// 初始化弹窗菜单的数据源。
@State arr: Params[] = [
new Params('关闭', $r('app.media.startIcon'), this.callBack),
new Params('刷新', $r('app.media.startIcon'), this.callBack),
new Params('帮助', $r('app.media.startIcon'), this.callBack)];
build() {
Row() {
Column() {
Button('横向菜单')
.margin({ top: 50 })
.onClick(() => {
CustomerPromptAction.openDialog({
alignment: DialogAlignment.Bottom,
offset: { dx: 0, dy: -20 }
}, this.getUIContext(), this.arr);
});
}
.width('100%')
.height('100%');
}
.height('100%');
}
}
Params.ets。
export class Params {
title: string = "";
iconName: Resource;
click: (index: number) => void;
constructor(title: string, iconName: Resource, click: (index: number) => void) {
this.title = title;
this.iconName = iconName;
this.click = click;
}
}
https://pastebin.com/epc9DuYu
https://pastebin.com/CErz3YL5
https://pastebin.com/6YfF0p9z
https://pastebin.com/kvpAvpdi
https://pastebin.com/X9fq64a2
https://pastebin.com/R0mRDZm4
https://pastebin.com/urWyLjrt
https://pastebin.com/hcKewAUE
https://pastebin.com/MjLnmvNu
https://pastebin.com/DYMeHxN4
https://pastebin.com/L5C77sX1
https://pastebin.com/rM22qRKb
https://pastebin.com/s8kwwq5k
https://pastebin.com/A6qfadrP
https://pastebin.com/PmbE57qe
https://pastebin.com/FiuhbGfN
https://pastebin.com/B2XYkbnq
https://pastebin.com/e0q36QBZ
https://pastebin.com/VwfcTkgK
https://pastebin.com/STYB1p4a
https://pastebin.com/dL8avv7N
https://pastebin.com/WgRdkJJY
https://pastebin.com/MHmf76dA
https://pastebin.com/2b2X9tbY
https://pastebin.com/uVqiYmpP
https://pastebin.com/iWFWeXfu
https://pastebin.com/tcz08Ga5
https://pastebin.com/rFqwzRhK
https://pastebin.com/bCm1D1Wj
https://pastebin.com/EWbGJZcy
https://pastebin.com/R30cJtHA
https://pastebin.com/aV9QTvJL
https://pastebin.com/EF75B0Xs
https://pastebin.com/UFQ2LwDh
https://pastebin.com/2CK4bM77
https://pastebin.com/ksCNHaF7
https://pastebin.com/k61KcBd4
https://pastebin.com/5GUXdSsP
https://pastebin.com/byn4kM9X
https://pastebin.com/nLG9ztxg
https://pastebin.com/PGrz7isp
https://pastebin.com/Jdv6Vcs1
https://pastebin.com/wc3q2WXq
https://pastebin.com/7JM3Dt9d
https://pastebin.com/PKueg3Q9
https://pastebin.com/C8hvWEnm
https://pastebin.com/nXYRTagp
https://pastebin.com/rzgJVLh2
https://pastebin.com/GXM3nwFL
https://pastebin.com/t0GkNTPB
https://pastebin.com/SGvqUZUw
https://pastebin.com/8SfWYWuA
https://pastebin.com/Cy2FKdCM
https://pastebin.com/Cwi3azHM
https://pastebin.com/WkBmAyzu
https://pastebin.com/NtcjEH8D
https://pastebin.com/vPFjfiTV
https://pastebin.com/VWQEns27
https://pastebin.com/W6sGgxN4
https://pastebin.com/BRTkHWLB
https://pastebin.com/b7kSTqmz
https://pastebin.com/TKxKXExY
https://pastebin.com/WbG5WLAC
https://pastebin.com/kNf5AwB7
https://pastebin.com/cgRrpGud
https://pastebin.com/TqFJkVxd
https://pastebin.com/Vwrjm3w7
https://pastebin.com/4ZMkzUBr
https://pastebin.com/GBW5cMYt
https://pastebin.com/RmGU8Ame
https://pastebin.com/RJ11SAm3
https://pastebin.com/kQrLCYA9
https://pastebin.com/9EH4eaY8
https://pastebin.com/YacTrPKQ
https://pastebin.com/DBYCLtGD
https://pastebin.com/y9eemXwr
https://pastebin.com/jTRFengj
https://pastebin.com/5vE7dpsX
https://pastebin.com/Kk4bZ1L8
https://pastebin.com/Kaxbk4tA
https://pastebin.com/LGR3sxN9
https://pastebin.com/2jbBiu33
https://pastebin.com/p0i2Fj9p
https://pastebin.com/uThxW0MS
https://pastebin.com/hr1MtNuu
https://pastebin.com/nt6fFaYk
https://pastebin.com/9RMs3tAN
https://pastebin.com/3pp9A2SP
https://pastebin.com/fbQQkXb2
https://pastebin.com/RNXkhP0Y
https://pastebin.com/62GMDSK1
https://pastebin.com/A47VdXsL
https://pastebin.com/TMXrdhxC
https://pastebin.com/pZV96ZWh
https://pastebin.com/BnZ4zhH2
https://pastebin.com/hqGmeuVV
https://pastebin.com/aqnxjmby
https://pastebin.com/uNxvnAFc
https://pastebin.com/tkDrfMPT
https://pastebin.com/6ufWP0fd
https://pastebin.com/H9PDFDiL
https://pastebin.com/qM9xgF7K
https://pastebin.com/iyJTH6aB
https://pastebin.com/ZiJyZLfp
https://pastebin.com/yZ0LA5dB
https://pastebin.com/8YX63tah
https://pastebin.com/v6DvRcJ2
https://pastebin.com/dQSyGA1C
https://pastebin.com/VLfUFJ3F
https://pastebin.com/1dHdGtmp
https://pastebin.com/ufJFhNjk
https://pastebin.com/t1WuAJPZ
https://pastebin.com/pZUndcCA
https://pastebin.com/ZPDUXz25
https://pastebin.com/ULjPJmEH
https://pastebin.com/EhwMqtVW
https://pastebin.com/EMCGkgwT
https://pastebin.com/60tHCxEM
https://pastebin.com/c4nYDLJs
https://pastebin.com/gQSdXcnY
https://pastebin.com/jc1GvWkA
https://pastebin.com/EVjzT0Bn
https://pastebin.com/hE2m4nfV
https://pastebin.com/yf3kYzc4
https://pastebin.com/Cj6MtRgk
https://pastebin.com/e7Hmp2wM
https://pastebin.com/0HzG8VJj
https://pastebin.com/XSTJdK3z
https://pastebin.com/mnfsGkiu
https://pastebin.com/REx1hsyn
https://pastebin.com/50VxQZdS
https://pastebin.com/0mxMQAfy
https://pastebin.com/AtGFtGFZ
https://pastebin.com/eWZmH6hX
https://pastebin.com/68JVfXcf
https://pastebin.com/Q47SWvBD
https://pastebin.com/pRR9gzGf
https://pastebin.com/KiMStGV8
https://pastebin.com/k8vp0yy7
https://pastebin.com/tN4ChEUp
https://pastebin.com/7pAZ3CdD
https://pastebin.com/rpSjghL8
https://pastebin.com/dYFDgUM2
https://pastebin.com/NQyE3NAS
https://pastebin.com/LYbXc4ma
https://pastebin.com/naCsLg7e
https://pastebin.com/YKnWCm7f
https://pastebin.com/zpSHGbnT
https://pastebin.com/qQg87Qa5
https://pastebin.com/wkijxdgV
https://pastebin.com/ASWCH7DB
https://pastebin.com/NAQqmtQW
https://pastebin.com/6QHpyMV1
https://pastebin.com/WT2xgp5n
https://pastebin.com/qnTJDwPy
https://pastebin.com/fZvEStv0
https://pastebin.com/VfxtAWJG
https://pastebin.com/vyQJKxFQ
https://pastebin.com/ExGA44gn
https://pastebin.com/qa32zS0w
https://pastebin.com/GfBS4n1X
https://pastebin.com/JhJXWrUD
https://pastebin.com/WNPAYC7r
https://pastebin.com/i77xpTm7
https://pastebin.com/jvyz7bDq
https://pastebin.com/h9iLgLDD
https://pastebin.com/0dtPxmtT
https://pastebin.com/4w2UaMSn
https://pastebin.com/Xy4S2CRs
https://pastebin.com/EHQtV6b7
https://pastebin.com/ucP8B1iB
https://pastebin.com/2Wcf2GNC
https://pastebin.com/L9SiHQ9Z
https://pastebin.com/wXCZSJuj
https://pastebin.com/vbPTR3SF
https://pastebin.com/7yxGcVHj
https://pastebin.com/MNAcjFjQ
https://pastebin.com/0A4KPjNU
https://pastebin.com/8nBAWbca
https://pastebin.com/Gh9dhxLt
https://pastebin.com/08qFJBye
https://pastebin.com/mfweHrVr
https://pastebin.com/RWwcnrYC
https://pastebin.com/anp3JpJ4
https://pastebin.com/hvBGQjK0
https://pastebin.com/EGww5k1k
https://pastebin.com/wdwGG4Mx
https://pastebin.com/qKr2rRmv
https://pastebin.com/VpBYTpBH
https://pastebin.com/Bsix0p2q
https://pastebin.com/d0rRmyuc
https://pastebin.com/1YVNhKx3
https://pastebin.com/PGscNx9F
https://pastebin.com/sE0Mv39Y
https://pastebin.com/20dhC8pa
https://pastebin.com/wGmcz5e6
https://pastebin.com/cApeLHfX
https://pastebin.com/mBT8cEji
https://pastebin.com/4hXLXigr
https://pastebin.com/fgt151cu





