HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

如何实现Matrix2D图形在画布中心点放大并旋转角度

问题现象
如何在画布中心点实现Matrix2D图形指定倍数的放大和指定角度的旋转?

背景知识
Matrix2D组件的方法rotate,能够以旋转点为中心、对当前矩阵进行右乘旋转运算,调用公式为:rotate(degree: number, rx?: number, ry?: number),其中degree为旋转角度,rx和ry为旋转中心点相对[0,0]点的水平和垂直方向坐标,如果[0,0]点通过translate发生移动,则以新的点计算相对的rx和ry值。
translate方法能够对当前矩阵进行左乘平移运算,调用公式为:translate(tx?: number, ty?: number)。
scale方法能够对当前矩阵进行右乘缩放运算,调用公式为:scale(sx?: number, sy?: number),其中sx和sy为水平和垂直缩放比例系数。以[0,0]为基准进行缩放,如果[0,0]点通过translate发生移动,则以移动后的点为基准点进行缩放。
解决方案
使用Flex布局将Canvas组件居中显示,创建矩形。
使用rotate方法对矩阵进行旋转操作,旋转角度由angle决定,旋转中心为矩形的中心;使用scale方法对矩阵进行缩放操作,缩放倍数由scaleTimesX和scaleTimesY决定,以中心点为基准进行缩放。rotate和scale存在调用顺序,遵循就近原则,因此需要将scale方法的使用放在rotate上方。
使用translate方法对矩阵进行平移操作,将画布的原点移动到画布的中心,即可将图形移动至画布中心。将经过变换的矩阵应用到CanvasRenderingContext2D对象上。
用fillRect()方法在画布上绘制矩形。
完整示例参考如下:
@Entry
@Component
struct MatrixChange {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private matrix: Matrix2D = new Matrix2D();
x: number = 50; // 图形大小
y: number = 50; // 图形大小
canvasWidth: number = 200; // 画布大小
canvasHeight: number = 200; // 画布大小
scaleTimesX: number = 2; // 缩放设置
scaleTimesY: number = 2; // 缩放设置
angle: number = -30; // 旋转角度

build() {
Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }) {
Canvas(this.context)
.width(this.canvasWidth)
.height(this.canvasHeight)
.backgroundColor('#0D5AF5')
.onReady(() => {
this.matrix.scaleX = 1;
this.matrix.scaleY = 1;
this.matrix.scale(this.scaleTimesX, this.scaleTimesY);
this.matrix.rotate(this.angle Math.PI / 180, this.x / 2, this.y / 2);
this.matrix.translate(this.canvasWidth / 2, this.canvasHeight / 2);
this.matrix.translate(-this.x
this.scaleTimesX / 2, -this.y * this.scaleTimesY / 2);
this.context.setTransform(this.matrix);
this.context.fillRect(0, 0, this.x, this.y);
});
}
.width('100%')
.height('100%');
}
}
总结
Matrix2D图形能够在画布中心点放大指定倍数并旋转指定角度,该功能适用于需要展示图形并对其进行各种变换的场景,例如数据可视化、图形编辑器等。
https://pastebin.com/jYcy8DG4
https://pastebin.com/Hp1sbSaS
https://pastebin.com/hTxV2yyS
https://pastebin.com/TQCjtTcP
https://pastebin.com/JpF1LSct
https://pastebin.com/Fr3XnQdE
https://pastebin.com/yTvhcbNw
https://pastebin.com/hRe1DgNt
https://pastebin.com/M3y1HL1H
https://pastebin.com/VraVGXVf
https://pastebin.com/griS9fnH
https://pastebin.com/pwrdFgiY
https://pastebin.com/Pbn36K7e
https://pastebin.com/7DMANuaz
https://pastebin.com/rHKJdsZT
https://pastebin.com/NLD2BSd3
https://pastebin.com/95Shz0dA
https://pastebin.com/zhphtiyk
https://pastebin.com/3HY6p8t3
https://pastebin.com/XQEtXnKJ
https://pastebin.com/56Zk2nAE
https://pastebin.com/FHzZvcGp
https://pastebin.com/jvCBLRx8
https://pastebin.com/UQ8bqVpS
https://pastebin.com/H5XxuvFD
https://pastebin.com/ZTRtEnKD
https://pastebin.com/DXXEFpJ7
https://pastebin.com/Xz7is4PA
https://pastebin.com/ujgDH7CQ
https://pastebin.com/QXvsH58y
https://pastebin.com/Syp4LL5s
https://pastebin.com/CLgzB5B2
https://pastebin.com/B1P1eZiT
https://pastebin.com/XsyVXiCg
https://pastebin.com/1XCeaqAU
https://pastebin.com/Jw13Rebn
https://pastebin.com/DyCcgNsv
https://pastebin.com/7LEMiQZ2
https://pastebin.com/dS3rfyQ6
https://pastebin.com/wLwm6rp7
https://pastebin.com/4WGjDaH1
https://pastebin.com/zb1zD5aE
https://pastebin.com/c1zNryeR
https://pastebin.com/4s5BeWXh
https://pastebin.com/Ce3nxNXr
https://pastebin.com/cmj5c080
https://pastebin.com/1H5LCdEY
https://pastebin.com/zZkVFh7Q
https://pastebin.com/BBrYWefF
https://pastebin.com/U4vvzbtw
https://pastebin.com/BWKQJ9Bf
https://pastebin.com/B5CXLSym
https://pastebin.com/PebrcV9M
https://pastebin.com/Ee30dGS4
https://pastebin.com/beXkZr16
https://pastebin.com/qfWN2Ryj
https://pastebin.com/c7mAxDum
https://pastebin.com/MpP9YNFU
https://pastebin.com/SQ8jWZqx
https://pastebin.com/9VALda7W
https://pastebin.com/xZ8P3Ks8
https://pastebin.com/niR9ymit
https://pastebin.com/Rv0T6rUx
https://pastebin.com/jAh0PWYa
https://pastebin.com/3wv5Agv8
https://pastebin.com/EtNNfJmj
https://pastebin.com/a9j6TNjC
https://pastebin.com/x6xxPkPX
https://pastebin.com/az8dYRbQ
https://pastebin.com/husRdHb4
https://pastebin.com/ZQw2Pbhx
https://pastebin.com/C59eWK5e
https://pastebin.com/kzcn3KvK
https://pastebin.com/mT8ehVrP
https://pastebin.com/uv3DmK2r
https://pastebin.com/9A90NJRS
https://pastebin.com/nxKRJ6DM
https://pastebin.com/em4D4Psa
https://pastebin.com/sq1LkcWj
https://pastebin.com/X9Qr3LsF
https://pastebin.com/0yhb1G5u
https://pastebin.com/RNBk3wcp
https://pastebin.com/n21GrF8N
https://pastebin.com/BVGGULWt
https://pastebin.com/2LVYSav1
https://pastebin.com/37m2vAg8
https://pastebin.com/YALGJ9Dj
https://pastebin.com/MPrNKc2X
https://pastebin.com/WcQR7d4P
https://pastebin.com/frpzGFbV
https://pastebin.com/MTZN74bH
https://pastebin.com/4XFYvRhd
https://pastebin.com/XEnbVYfF
https://pastebin.com/iGNu6WyC
https://pastebin.com/iC4qCnDP
https://pastebin.com/SubVHQiU
https://pastebin.com/GtmFQfJ6
https://pastebin.com/PqcdaaqX
https://pastebin.com/wbpVhAa4
https://pastebin.com/Gs8c9UJM
https://pastebin.com/rvBJLy48
https://pastebin.com/qJhsAkBV
https://pastebin.com/GsUxwvRp
https://pastebin.com/xhtemkVi
https://pastebin.com/VpcarPwJ
https://pastebin.com/M2MZ4XjZ
https://pastebin.com/1hsmtBAh
https://pastebin.com/FBKiMKLM
https://pastebin.com/TFcszpgY
https://pastebin.com/wZyc488a
https://pastebin.com/eyzX1hF0
https://pastebin.com/pn2C5r5y
https://pastebin.com/dyHRSz7V
https://pastebin.com/qspZ7UVQ
https://pastebin.com/hXZ59vW5
https://pastebin.com/EBymVseu
https://pastebin.com/Pi2qAyiG
https://pastebin.com/r53FkZrX
https://pastebin.com/pBxp5x6P
https://pastebin.com/kRmwv8xX
https://pastebin.com/YPBtC4bD
https://pastebin.com/zaazK0qN
https://pastebin.com/MH9s5BWr
https://pastebin.com/kUWEmtQ3
https://pastebin.com/EiVfHUPQ
https://pastebin.com/F1zDdap8
https://pastebin.com/x6ninbWm
https://pastebin.com/kp5xBGss
https://pastebin.com/X8rLUdJN
https://pastebin.com/V1Zmj7FJ
https://pastebin.com/UheVuGRp
https://pastebin.com/yKHmpBX3
https://pastebin.com/GQaa3tDW
https://pastebin.com/D00GfaUn
https://pastebin.com/2bTz1JVU
https://pastebin.com/hhdmx2bc
https://pastebin.com/KaXVaXRZ
https://pastebin.com/R0R3JCNA
https://pastebin.com/zQGT96rX
https://pastebin.com/cbqQbPyf
https://pastebin.com/6PJN3M7N
https://pastebin.com/k1yeEBr9
https://pastebin.com/GAVHCfGW
https://pastebin.com/V9BDRArR
https://pastebin.com/Ms7giaW9
https://pastebin.com/ddfs4xn7
https://pastebin.com/bhtqrAfp
https://pastebin.com/hCwrGzXp
https://pastebin.com/Cf6NEq1t
https://pastebin.com/i2YZAQTR
https://pastebin.com/PBVaCkjV
https://pastebin.com/CZh9JdFZ
https://pastebin.com/Cf4LLKEd
https://pastebin.com/EiNK5had
https://pastebin.com/j2XSp6pu
https://pastebin.com/1EM6BSxm
https://pastebin.com/P2Zf7gHn
https://pastebin.com/tjjJFy7s
https://pastebin.com/jZ2QMP9d
https://pastebin.com/vtrnXw8f
https://pastebin.com/rKNy2k7F
https://pastebin.com/t792XJE7
https://pastebin.com/BBb77Pfy
https://pastebin.com/euTtybUX
https://pastebin.com/DDiRxvXw
https://pastebin.com/SpLWzDPF
https://pastebin.com/jVk5exQP
https://pastebin.com/FXCHxBMy
https://pastebin.com/WWUqnR5j
https://pastebin.com/fPQs5mLa
https://pastebin.com/RH5zBVEh
https://pastebin.com/C5w1KpPC
https://pastebin.com/1u6PSz3M
https://pastebin.com/hEWn5WFA
https://pastebin.com/8yH2QWxL
https://pastebin.com/TVdRsjMp
https://pastebin.com/rDbhUfwu
https://pastebin.com/drQEKtGE
https://pastebin.com/99Rmnksr
https://pastebin.com/BXZmvJNc
https://pastebin.com/WG4vFfa5
https://pastebin.com/mMuTtyhm
https://pastebin.com/24dfdEw2
https://pastebin.com/3TkPm4xm
https://pastebin.com/X32JiquQ
https://pastebin.com/j5e9PiRm
https://pastebin.com/cuDaup6h
https://pastebin.com/cwxTRY00
https://pastebin.com/J71r912Y
https://pastebin.com/CzD4M9uD
https://pastebin.com/7KBxgcqK
https://pastebin.com/7NfWAa0J
https://pastebin.com/zwkkUavP
https://pastebin.com/shhZgj5L
https://pastebin.com/H68TdJqL
https://pastebin.com/A5DUFX8G
https://pastebin.com/pbPakzr2
https://pastebin.com/XyVssEmP
https://pastebin.com/wtuvMrT4
https://pastebin.com/w24BpmWr
https://pastebin.com/aiG2D9nf
https://pastebin.com/maNzV2CK
https://pastebin.com/cfE0zNBy

继续阅读 »

问题现象
如何在画布中心点实现Matrix2D图形指定倍数的放大和指定角度的旋转?

背景知识
Matrix2D组件的方法rotate,能够以旋转点为中心、对当前矩阵进行右乘旋转运算,调用公式为:rotate(degree: number, rx?: number, ry?: number),其中degree为旋转角度,rx和ry为旋转中心点相对[0,0]点的水平和垂直方向坐标,如果[0,0]点通过translate发生移动,则以新的点计算相对的rx和ry值。
translate方法能够对当前矩阵进行左乘平移运算,调用公式为:translate(tx?: number, ty?: number)。
scale方法能够对当前矩阵进行右乘缩放运算,调用公式为:scale(sx?: number, sy?: number),其中sx和sy为水平和垂直缩放比例系数。以[0,0]为基准进行缩放,如果[0,0]点通过translate发生移动,则以移动后的点为基准点进行缩放。
解决方案
使用Flex布局将Canvas组件居中显示,创建矩形。
使用rotate方法对矩阵进行旋转操作,旋转角度由angle决定,旋转中心为矩形的中心;使用scale方法对矩阵进行缩放操作,缩放倍数由scaleTimesX和scaleTimesY决定,以中心点为基准进行缩放。rotate和scale存在调用顺序,遵循就近原则,因此需要将scale方法的使用放在rotate上方。
使用translate方法对矩阵进行平移操作,将画布的原点移动到画布的中心,即可将图形移动至画布中心。将经过变换的矩阵应用到CanvasRenderingContext2D对象上。
用fillRect()方法在画布上绘制矩形。
完整示例参考如下:
@Entry
@Component
struct MatrixChange {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private matrix: Matrix2D = new Matrix2D();
x: number = 50; // 图形大小
y: number = 50; // 图形大小
canvasWidth: number = 200; // 画布大小
canvasHeight: number = 200; // 画布大小
scaleTimesX: number = 2; // 缩放设置
scaleTimesY: number = 2; // 缩放设置
angle: number = -30; // 旋转角度

build() {
Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }) {
Canvas(this.context)
.width(this.canvasWidth)
.height(this.canvasHeight)
.backgroundColor('#0D5AF5')
.onReady(() => {
this.matrix.scaleX = 1;
this.matrix.scaleY = 1;
this.matrix.scale(this.scaleTimesX, this.scaleTimesY);
this.matrix.rotate(this.angle Math.PI / 180, this.x / 2, this.y / 2);
this.matrix.translate(this.canvasWidth / 2, this.canvasHeight / 2);
this.matrix.translate(-this.x
this.scaleTimesX / 2, -this.y * this.scaleTimesY / 2);
this.context.setTransform(this.matrix);
this.context.fillRect(0, 0, this.x, this.y);
});
}
.width('100%')
.height('100%');
}
}
总结
Matrix2D图形能够在画布中心点放大指定倍数并旋转指定角度,该功能适用于需要展示图形并对其进行各种变换的场景,例如数据可视化、图形编辑器等。
https://pastebin.com/jYcy8DG4
https://pastebin.com/Hp1sbSaS
https://pastebin.com/hTxV2yyS
https://pastebin.com/TQCjtTcP
https://pastebin.com/JpF1LSct
https://pastebin.com/Fr3XnQdE
https://pastebin.com/yTvhcbNw
https://pastebin.com/hRe1DgNt
https://pastebin.com/M3y1HL1H
https://pastebin.com/VraVGXVf
https://pastebin.com/griS9fnH
https://pastebin.com/pwrdFgiY
https://pastebin.com/Pbn36K7e
https://pastebin.com/7DMANuaz
https://pastebin.com/rHKJdsZT
https://pastebin.com/NLD2BSd3
https://pastebin.com/95Shz0dA
https://pastebin.com/zhphtiyk
https://pastebin.com/3HY6p8t3
https://pastebin.com/XQEtXnKJ
https://pastebin.com/56Zk2nAE
https://pastebin.com/FHzZvcGp
https://pastebin.com/jvCBLRx8
https://pastebin.com/UQ8bqVpS
https://pastebin.com/H5XxuvFD
https://pastebin.com/ZTRtEnKD
https://pastebin.com/DXXEFpJ7
https://pastebin.com/Xz7is4PA
https://pastebin.com/ujgDH7CQ
https://pastebin.com/QXvsH58y
https://pastebin.com/Syp4LL5s
https://pastebin.com/CLgzB5B2
https://pastebin.com/B1P1eZiT
https://pastebin.com/XsyVXiCg
https://pastebin.com/1XCeaqAU
https://pastebin.com/Jw13Rebn
https://pastebin.com/DyCcgNsv
https://pastebin.com/7LEMiQZ2
https://pastebin.com/dS3rfyQ6
https://pastebin.com/wLwm6rp7
https://pastebin.com/4WGjDaH1
https://pastebin.com/zb1zD5aE
https://pastebin.com/c1zNryeR
https://pastebin.com/4s5BeWXh
https://pastebin.com/Ce3nxNXr
https://pastebin.com/cmj5c080
https://pastebin.com/1H5LCdEY
https://pastebin.com/zZkVFh7Q
https://pastebin.com/BBrYWefF
https://pastebin.com/U4vvzbtw
https://pastebin.com/BWKQJ9Bf
https://pastebin.com/B5CXLSym
https://pastebin.com/PebrcV9M
https://pastebin.com/Ee30dGS4
https://pastebin.com/beXkZr16
https://pastebin.com/qfWN2Ryj
https://pastebin.com/c7mAxDum
https://pastebin.com/MpP9YNFU
https://pastebin.com/SQ8jWZqx
https://pastebin.com/9VALda7W
https://pastebin.com/xZ8P3Ks8
https://pastebin.com/niR9ymit
https://pastebin.com/Rv0T6rUx
https://pastebin.com/jAh0PWYa
https://pastebin.com/3wv5Agv8
https://pastebin.com/EtNNfJmj
https://pastebin.com/a9j6TNjC
https://pastebin.com/x6xxPkPX
https://pastebin.com/az8dYRbQ
https://pastebin.com/husRdHb4
https://pastebin.com/ZQw2Pbhx
https://pastebin.com/C59eWK5e
https://pastebin.com/kzcn3KvK
https://pastebin.com/mT8ehVrP
https://pastebin.com/uv3DmK2r
https://pastebin.com/9A90NJRS
https://pastebin.com/nxKRJ6DM
https://pastebin.com/em4D4Psa
https://pastebin.com/sq1LkcWj
https://pastebin.com/X9Qr3LsF
https://pastebin.com/0yhb1G5u
https://pastebin.com/RNBk3wcp
https://pastebin.com/n21GrF8N
https://pastebin.com/BVGGULWt
https://pastebin.com/2LVYSav1
https://pastebin.com/37m2vAg8
https://pastebin.com/YALGJ9Dj
https://pastebin.com/MPrNKc2X
https://pastebin.com/WcQR7d4P
https://pastebin.com/frpzGFbV
https://pastebin.com/MTZN74bH
https://pastebin.com/4XFYvRhd
https://pastebin.com/XEnbVYfF
https://pastebin.com/iGNu6WyC
https://pastebin.com/iC4qCnDP
https://pastebin.com/SubVHQiU
https://pastebin.com/GtmFQfJ6
https://pastebin.com/PqcdaaqX
https://pastebin.com/wbpVhAa4
https://pastebin.com/Gs8c9UJM
https://pastebin.com/rvBJLy48
https://pastebin.com/qJhsAkBV
https://pastebin.com/GsUxwvRp
https://pastebin.com/xhtemkVi
https://pastebin.com/VpcarPwJ
https://pastebin.com/M2MZ4XjZ
https://pastebin.com/1hsmtBAh
https://pastebin.com/FBKiMKLM
https://pastebin.com/TFcszpgY
https://pastebin.com/wZyc488a
https://pastebin.com/eyzX1hF0
https://pastebin.com/pn2C5r5y
https://pastebin.com/dyHRSz7V
https://pastebin.com/qspZ7UVQ
https://pastebin.com/hXZ59vW5
https://pastebin.com/EBymVseu
https://pastebin.com/Pi2qAyiG
https://pastebin.com/r53FkZrX
https://pastebin.com/pBxp5x6P
https://pastebin.com/kRmwv8xX
https://pastebin.com/YPBtC4bD
https://pastebin.com/zaazK0qN
https://pastebin.com/MH9s5BWr
https://pastebin.com/kUWEmtQ3
https://pastebin.com/EiVfHUPQ
https://pastebin.com/F1zDdap8
https://pastebin.com/x6ninbWm
https://pastebin.com/kp5xBGss
https://pastebin.com/X8rLUdJN
https://pastebin.com/V1Zmj7FJ
https://pastebin.com/UheVuGRp
https://pastebin.com/yKHmpBX3
https://pastebin.com/GQaa3tDW
https://pastebin.com/D00GfaUn
https://pastebin.com/2bTz1JVU
https://pastebin.com/hhdmx2bc
https://pastebin.com/KaXVaXRZ
https://pastebin.com/R0R3JCNA
https://pastebin.com/zQGT96rX
https://pastebin.com/cbqQbPyf
https://pastebin.com/6PJN3M7N
https://pastebin.com/k1yeEBr9
https://pastebin.com/GAVHCfGW
https://pastebin.com/V9BDRArR
https://pastebin.com/Ms7giaW9
https://pastebin.com/ddfs4xn7
https://pastebin.com/bhtqrAfp
https://pastebin.com/hCwrGzXp
https://pastebin.com/Cf6NEq1t
https://pastebin.com/i2YZAQTR
https://pastebin.com/PBVaCkjV
https://pastebin.com/CZh9JdFZ
https://pastebin.com/Cf4LLKEd
https://pastebin.com/EiNK5had
https://pastebin.com/j2XSp6pu
https://pastebin.com/1EM6BSxm
https://pastebin.com/P2Zf7gHn
https://pastebin.com/tjjJFy7s
https://pastebin.com/jZ2QMP9d
https://pastebin.com/vtrnXw8f
https://pastebin.com/rKNy2k7F
https://pastebin.com/t792XJE7
https://pastebin.com/BBb77Pfy
https://pastebin.com/euTtybUX
https://pastebin.com/DDiRxvXw
https://pastebin.com/SpLWzDPF
https://pastebin.com/jVk5exQP
https://pastebin.com/FXCHxBMy
https://pastebin.com/WWUqnR5j
https://pastebin.com/fPQs5mLa
https://pastebin.com/RH5zBVEh
https://pastebin.com/C5w1KpPC
https://pastebin.com/1u6PSz3M
https://pastebin.com/hEWn5WFA
https://pastebin.com/8yH2QWxL
https://pastebin.com/TVdRsjMp
https://pastebin.com/rDbhUfwu
https://pastebin.com/drQEKtGE
https://pastebin.com/99Rmnksr
https://pastebin.com/BXZmvJNc
https://pastebin.com/WG4vFfa5
https://pastebin.com/mMuTtyhm
https://pastebin.com/24dfdEw2
https://pastebin.com/3TkPm4xm
https://pastebin.com/X32JiquQ
https://pastebin.com/j5e9PiRm
https://pastebin.com/cuDaup6h
https://pastebin.com/cwxTRY00
https://pastebin.com/J71r912Y
https://pastebin.com/CzD4M9uD
https://pastebin.com/7KBxgcqK
https://pastebin.com/7NfWAa0J
https://pastebin.com/zwkkUavP
https://pastebin.com/shhZgj5L
https://pastebin.com/H68TdJqL
https://pastebin.com/A5DUFX8G
https://pastebin.com/pbPakzr2
https://pastebin.com/XyVssEmP
https://pastebin.com/wtuvMrT4
https://pastebin.com/w24BpmWr
https://pastebin.com/aiG2D9nf
https://pastebin.com/maNzV2CK
https://pastebin.com/cfE0zNBy

收起阅读 »

如何在不同场景下实现页面置灰效果

问题现象
在不同的场景下,如何实现将单个组件、页面或者整个app置灰的效果,具体场景如下:

场景

场景说明

场景一:单个组件置灰

将单个组件颜色置灰,其他组件不受影响,例如将一张彩色图片变成灰白色图片

场景二:单个页面置灰

在多个页面场景中,只将其中某个页面颜色置灰,其他页面不受影响

场景三:所有页面置灰

将app中的所有页面颜色置灰,例如当发生一些重大事件(哀悼日)时,要求将整个app的页面颜色统一设置为灰色

背景知识
Navigation:路由导航的根视图容器,一般作为Page页面的根容器使用,其内部默认包含了标题栏、内容区和工具栏,其中内容区默认首页显示导航内容(Navigation的子组件)或非首页显示(NavDestination的子组件),首页和非首页通过路由进行切换。
grayscale:为组件添加灰度效果。上层渲染灰度会覆盖下层子组件渲染。值定义为灰度转换的比例,入参1则完全转为灰度图像,入参0则图像无变化,入参在0和1之间时,效果呈线性变化。
saturate:为组件添加饱和度效果,饱和度为颜色中的含色成分和消色成分(灰)的比例,入参为1时,显示原图像,大于1时含色成分越大,饱和度越大,小于1时消色成分越大,饱和度越小。
解决方案
场景一:单个组件置灰。
通过给Image组件添加grayscale属性并将属性值设置为1来实现图片置灰效果,或者使用saturate属性并将属性值设置为0来实现图片置灰效果。示例代码如下:
@Entry
@Component
struct GrayDemo {
@State grayscaleValue: number = 0; // 灰度默认为0,正常显示
@State saturateValue: number = 1; // 饱和度默认为1,正常显示

build() {
Row() {
Column() {
Text('grayscale:')
Image($r('app.media.startIcon'))
.autoResize(true)
.width(100)
.height(100)
.margin(16)
.grayscale(this.grayscaleValue) // 设置图片灰度效果
Button('一键置灰').onClick(() => {
this.grayscaleValue = 1; // 设置灰度为100%
})

    Text('saturate:').margin({ top: 16 })  
    Image($r('app.media.startIcon'))  
      .autoResize(true)  
      .width(100)  
      .height(100)  
      .margin(16)  
      .saturate(this.saturateValue) // 设置图片的饱和度  
    Button('一键置灰').onClick(() => {  
      this.saturateValue = 0; // 将饱和度设置为0,显示灰白  
    })  
  }  
}  
.justifyContent(FlexAlign.Center)  
.alignItems(VerticalAlign.Center)  
.width('100%')  
.height('100%')  

}
}
效果图如下:

点击放大

场景二:单个页面置灰。
在多个页面场景,如果只有某个页面需要置灰,其他页面不变,只需将相应页面根元素设置grayscale属性为1。

首先定义一个变量grayscaleValueSingle,本页面根组件Column设置grayscale属性,分别给用于实现本页面置灰和还原效果的Button绑定onClick事件,该事件用于修改grayscaleValueSingle的值,当该值变化时,即可实现本页面的置灰效果,子页面没有置灰。

效果图如下:

点击放大

场景三:所有页面置灰。Navigation设置的一些属性会影响所有的页面,将主页的Navigation组件设置grayscale属性为1,可实现所有页面置灰的效果。完整示例代码如下:主页Index.ets示例代码如下:
@Entry
@Component
struct Index {
@State grayscaleValueAll: number = 0; // 灰度默认为0,正常显示
@State grayscaleValueSingle: number = 0;
pageStack: NavPathStack = new NavPathStack();

build() {
Navigation(this.pageStack) {
Column() {
Button('前往子页', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
.onClick(() => {
this.pageStack.pushPathByName('PageOne', null, false);
})
Image($r('app.media.startIcon'))
.width('50%')
.height('30%')
Button('本页面置灰', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
.onClick(() => {
this.grayscaleValueSingle = 1; // 设置灰度为100%
})
Button('全局置灰', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.onClick(() => {
this.grayscaleValueAll = 1; // 设置灰度为100%
})
Button('还原', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
.onClick(() => {
this.grayscaleValueAll = 0;
this.grayscaleValueSingle = 0;
})
}.grayscale(this.grayscaleValueSingle)
}
.title('主页')
.grayscale(this.grayscaleValueAll)
}
}
子页PageOne.ets示例代码如下:

@Builder
export function PageOneBuilder() {
PageOne()
}

@Entry
@Component
struct PageOne {
build() {
NavDestination() {
Column() {
Button('这是子页', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
}
.width('100%')
.height('100%')
}
}
}
route_map需放置在项目名/src/main/resources/base/profile/route_map.json路径下,具体代码如下:

{
"routerMap": [
{
"name": "PageOne",
"pageSourceFile": "src/main/ets/pages/PageOne.ets",
"buildFunction": "PageOneBuilder",
"data": {
"description": "This is PageOne"
}
}
]
}
在module.json5配置文件的module标签中定义routerMap字段,指向定义的路由表配置文件route_map.json,如:"routerMap": "$profile:route_map"。

效果图如下:

点击放大

常见FAQ
Q:在Navigation组件中添加的属性,在NavDestination组件中也会生效吗?

A:NavDestination作为子页面的根容器,用于显示Navigation的内容区。NavDestination组件在渲染时会使用到Navigation组件中添加的属性。
https://pastebin.com/tB45X2Jp
https://pastebin.com/hjD1EQe6
https://pastebin.com/ZPZSXrit
https://pastebin.com/BKvXc1XH
https://pastebin.com/EWXDC53c
https://pastebin.com/cZtc3Gm0
https://pastebin.com/D8xdXuhM
https://pastebin.com/EgrbEUJD
https://pastebin.com/8VvqjShG
https://pastebin.com/C8U16GGp
https://pastebin.com/601zAGr0
https://pastebin.com/VUYU8tTc
https://pastebin.com/4eSAuAFs
https://pastebin.com/hYWauvkZ
https://pastebin.com/dKtpzYwL
https://pastebin.com/faS8dizU
https://pastebin.com/epWAdZjT
https://pastebin.com/N28eTbrZ
https://pastebin.com/4Yhy1g4R
https://pastebin.com/pVHAppet
https://pastebin.com/6n0m31zk
https://pastebin.com/BwNRYEsp
https://pastebin.com/UHgxVZrY
https://pastebin.com/sdBk4pwS
https://pastebin.com/qw3Qeiv3
https://pastebin.com/ZE3yTP0u
https://pastebin.com/fbLvkuFA
https://pastebin.com/jrQByFhQ
https://pastebin.com/BLDqNVZd
https://pastebin.com/rsWambwi
https://pastebin.com/jSNv47wj
https://pastebin.com/SgFwMwCn
https://pastebin.com/ii9Mx19w
https://pastebin.com/U12DCAjc
https://pastebin.com/J4UTSFCv
https://pastebin.com/vLKGZmEC
https://pastebin.com/45sT2V0T
https://pastebin.com/HNSU6w07
https://pastebin.com/Q1ez1SyN
https://pastebin.com/NU2YtB2j
https://pastebin.com/4vYkErDF
https://pastebin.com/HbEs4xd0
https://pastebin.com/EQ5rygX0
https://pastebin.com/56zyx4mu
https://pastebin.com/BxL1hQtN
https://pastebin.com/u95LA4eA
https://pastebin.com/SERQ5L1R
https://pastebin.com/1HLR4hfE
https://pastebin.com/uhYYhvY7
https://pastebin.com/Gzwqu9J8
https://pastebin.com/TRzJAa7C
https://pastebin.com/jkvnkxUf
https://pastebin.com/tQniYitM
https://pastebin.com/teftqPhB
https://pastebin.com/ghmm3Z45
https://pastebin.com/u8zhaGJe
https://pastebin.com/pBUjLT6a
https://pastebin.com/F8xircWV
https://pastebin.com/bHfQyddL
https://pastebin.com/US11UXan
https://pastebin.com/uUq8A9PG
https://pastebin.com/gf0TnLaX
https://pastebin.com/KbjDei75
https://pastebin.com/Eg8SBXip
https://pastebin.com/TRnQT00H
https://pastebin.com/hrJTVPDq
https://pastebin.com/FQybjNNY
https://pastebin.com/vkjZ4wN3
https://pastebin.com/Em0EVbKz
https://pastebin.com/8SQtnCqe
https://pastebin.com/JzVba9rR
https://pastebin.com/VYum9agp
https://pastebin.com/kkQzAQQb
https://pastebin.com/rupgeDSt
https://pastebin.com/zaLpEv2s
https://pastebin.com/GcbHRNFi
https://pastebin.com/bpp4uE8Z
https://pastebin.com/7HKMVmd2
https://pastebin.com/Fg7cmq4m
https://pastebin.com/4yzireaB
https://pastebin.com/8JxAZZaT
https://pastebin.com/Ja0YVzXd
https://pastebin.com/ZD0SeiaT
https://pastebin.com/03wkfuNw
https://pastebin.com/2LsPES0D
https://pastebin.com/kpcMTN8H
https://pastebin.com/wDTn15yX
https://pastebin.com/DpmFJRhA
https://pastebin.com/WSgL8jW4
https://pastebin.com/XpDeHfFU
https://pastebin.com/HwhXqV7d
https://pastebin.com/KQuJBJGJ
https://pastebin.com/PvAf8UmX
https://pastebin.com/NvrpD52h
https://pastebin.com/mVcns0hQ
https://pastebin.com/iHkN8Zqt
https://pastebin.com/CtB7EXHR
https://pastebin.com/LGWxiLVh
https://pastebin.com/e1yYzpGV
https://pastebin.com/r690DqD6
https://pastebin.com/dxHZMZpR
https://pastebin.com/9Bt6QmGx
https://pastebin.com/3GR4wnyg
https://pastebin.com/U48VGWDZ
https://pastebin.com/DsvcvGfg
https://pastebin.com/5mJKbEHB
https://pastebin.com/XJHSbEB5
https://pastebin.com/K2TCyawf
https://pastebin.com/Ewn4DHM5
https://pastebin.com/Ta7ufBT4
https://pastebin.com/atf8fKFA
https://pastebin.com/SCwnf7Kq
https://pastebin.com/WwPcSmwn
https://pastebin.com/u1BUyfCp
https://pastebin.com/MdTnRfHn
https://pastebin.com/EbeshHhc
https://pastebin.com/qGcygDuP
https://pastebin.com/SqRmqdgU
https://pastebin.com/7npW5utw
https://pastebin.com/GjSNL297
https://pastebin.com/unDuaQRL
https://pastebin.com/GW5NYdDa
https://pastebin.com/v7NDFUPc
https://pastebin.com/8VRW7Q9K
https://pastebin.com/2KXX6VGt
https://pastebin.com/MCmswb3v
https://pastebin.com/iiuA6adw
https://pastebin.com/D58m3xJB
https://pastebin.com/0S5C0W5s
https://pastebin.com/kd37nDY6
https://pastebin.com/THKnHxWN
https://pastebin.com/b0cNSgs9
https://pastebin.com/4EgxXbvr
https://pastebin.com/e0Y46BLB
https://pastebin.com/JYnQsScA
https://pastebin.com/0JHz4u7D
https://pastebin.com/eLygzWDz
https://pastebin.com/BpNaLvtM
https://pastebin.com/YEpynedk
https://pastebin.com/nr92i4cm
https://pastebin.com/GyJdL3jT
https://pastebin.com/5J59xjwP
https://pastebin.com/YzKHrHf8
https://pastebin.com/9e9Fw0qZ
https://pastebin.com/xeyWtjCZ
https://pastebin.com/ZuNfjEXd
https://pastebin.com/1eAXJXch
https://pastebin.com/wScAwcxA
https://pastebin.com/veLVP8JY
https://pastebin.com/gp9ivT1n
https://pastebin.com/09pRUVcp
https://pastebin.com/KDpZcsSs
https://pastebin.com/qPuKxaGF
https://pastebin.com/Z1HRFRjF
https://pastebin.com/jR9E2zAk
https://pastebin.com/E42XsidQ
https://pastebin.com/vnpF0CrJ
https://pastebin.com/tPdeYkh9
https://pastebin.com/SvdMHSSw
https://pastebin.com/8vBeN0S8
https://pastebin.com/B6WRVRQ4
https://pastebin.com/zydaXiMd
https://pastebin.com/nsb4NEC3
https://pastebin.com/QxyaK9dt
https://pastebin.com/aztZVSES
https://pastebin.com/gq0Ugnzu
https://pastebin.com/WQKjeyU6
https://pastebin.com/JjqU9JHd
https://pastebin.com/m8x0g5jW
https://pastebin.com/5nbWUgFh
https://pastebin.com/Rzgpje7d
https://pastebin.com/K0wf5rAY
https://pastebin.com/ZDPfudPN
https://pastebin.com/RSxAxA0k
https://pastebin.com/WwvnWgmu
https://pastebin.com/pGpEm8MC
https://pastebin.com/yeGNby1Y
https://pastebin.com/G0i3MQkj
https://pastebin.com/te38JUac
https://pastebin.com/dDmpLYkT
https://pastebin.com/a6eywsqw
https://pastebin.com/u2aXcVQm
https://pastebin.com/ECmeMX9f
https://pastebin.com/VcR7W9zx
https://pastebin.com/XRU0XdJV
https://pastebin.com/vrr5hgAv
https://pastebin.com/fRf4Uz2n
https://pastebin.com/wNUGRHMQ
https://pastebin.com/yVnHymtb
https://pastebin.com/gQWJU5a2
https://pastebin.com/VfquXqXb
https://pastebin.com/k1bQrXjs
https://pastebin.com/2BRqX78a
https://pastebin.com/qhfE1Qni
https://pastebin.com/uxwXhuAJ
https://pastebin.com/Z68kDKtq
https://pastebin.com/DUsNuWn8
https://pastebin.com/ALf4SeRQ
https://pastebin.com/6922Wz0j
https://pastebin.com/GwpbvjZw
https://pastebin.com/psciSpxN
https://pastebin.com/3gZnEA2Q
https://pastebin.com/n0cwrgXU
https://pastebin.com/hq907uQR
https://pastebin.com/9VehTSWL
https://pastebin.com/8UE4KeVN

继续阅读 »

问题现象
在不同的场景下,如何实现将单个组件、页面或者整个app置灰的效果,具体场景如下:

场景

场景说明

场景一:单个组件置灰

将单个组件颜色置灰,其他组件不受影响,例如将一张彩色图片变成灰白色图片

场景二:单个页面置灰

在多个页面场景中,只将其中某个页面颜色置灰,其他页面不受影响

场景三:所有页面置灰

将app中的所有页面颜色置灰,例如当发生一些重大事件(哀悼日)时,要求将整个app的页面颜色统一设置为灰色

背景知识
Navigation:路由导航的根视图容器,一般作为Page页面的根容器使用,其内部默认包含了标题栏、内容区和工具栏,其中内容区默认首页显示导航内容(Navigation的子组件)或非首页显示(NavDestination的子组件),首页和非首页通过路由进行切换。
grayscale:为组件添加灰度效果。上层渲染灰度会覆盖下层子组件渲染。值定义为灰度转换的比例,入参1则完全转为灰度图像,入参0则图像无变化,入参在0和1之间时,效果呈线性变化。
saturate:为组件添加饱和度效果,饱和度为颜色中的含色成分和消色成分(灰)的比例,入参为1时,显示原图像,大于1时含色成分越大,饱和度越大,小于1时消色成分越大,饱和度越小。
解决方案
场景一:单个组件置灰。
通过给Image组件添加grayscale属性并将属性值设置为1来实现图片置灰效果,或者使用saturate属性并将属性值设置为0来实现图片置灰效果。示例代码如下:
@Entry
@Component
struct GrayDemo {
@State grayscaleValue: number = 0; // 灰度默认为0,正常显示
@State saturateValue: number = 1; // 饱和度默认为1,正常显示

build() {
Row() {
Column() {
Text('grayscale:')
Image($r('app.media.startIcon'))
.autoResize(true)
.width(100)
.height(100)
.margin(16)
.grayscale(this.grayscaleValue) // 设置图片灰度效果
Button('一键置灰').onClick(() => {
this.grayscaleValue = 1; // 设置灰度为100%
})

    Text('saturate:').margin({ top: 16 })  
    Image($r('app.media.startIcon'))  
      .autoResize(true)  
      .width(100)  
      .height(100)  
      .margin(16)  
      .saturate(this.saturateValue) // 设置图片的饱和度  
    Button('一键置灰').onClick(() => {  
      this.saturateValue = 0; // 将饱和度设置为0,显示灰白  
    })  
  }  
}  
.justifyContent(FlexAlign.Center)  
.alignItems(VerticalAlign.Center)  
.width('100%')  
.height('100%')  

}
}
效果图如下:

点击放大

场景二:单个页面置灰。
在多个页面场景,如果只有某个页面需要置灰,其他页面不变,只需将相应页面根元素设置grayscale属性为1。

首先定义一个变量grayscaleValueSingle,本页面根组件Column设置grayscale属性,分别给用于实现本页面置灰和还原效果的Button绑定onClick事件,该事件用于修改grayscaleValueSingle的值,当该值变化时,即可实现本页面的置灰效果,子页面没有置灰。

效果图如下:

点击放大

场景三:所有页面置灰。Navigation设置的一些属性会影响所有的页面,将主页的Navigation组件设置grayscale属性为1,可实现所有页面置灰的效果。完整示例代码如下:主页Index.ets示例代码如下:
@Entry
@Component
struct Index {
@State grayscaleValueAll: number = 0; // 灰度默认为0,正常显示
@State grayscaleValueSingle: number = 0;
pageStack: NavPathStack = new NavPathStack();

build() {
Navigation(this.pageStack) {
Column() {
Button('前往子页', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
.onClick(() => {
this.pageStack.pushPathByName('PageOne', null, false);
})
Image($r('app.media.startIcon'))
.width('50%')
.height('30%')
Button('本页面置灰', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
.onClick(() => {
this.grayscaleValueSingle = 1; // 设置灰度为100%
})
Button('全局置灰', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.onClick(() => {
this.grayscaleValueAll = 1; // 设置灰度为100%
})
Button('还原', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
.onClick(() => {
this.grayscaleValueAll = 0;
this.grayscaleValueSingle = 0;
})
}.grayscale(this.grayscaleValueSingle)
}
.title('主页')
.grayscale(this.grayscaleValueAll)
}
}
子页PageOne.ets示例代码如下:

@Builder
export function PageOneBuilder() {
PageOne()
}

@Entry
@Component
struct PageOne {
build() {
NavDestination() {
Column() {
Button('这是子页', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
}
.width('100%')
.height('100%')
}
}
}
route_map需放置在项目名/src/main/resources/base/profile/route_map.json路径下,具体代码如下:

{
"routerMap": [
{
"name": "PageOne",
"pageSourceFile": "src/main/ets/pages/PageOne.ets",
"buildFunction": "PageOneBuilder",
"data": {
"description": "This is PageOne"
}
}
]
}
在module.json5配置文件的module标签中定义routerMap字段,指向定义的路由表配置文件route_map.json,如:"routerMap": "$profile:route_map"。

效果图如下:

点击放大

常见FAQ
Q:在Navigation组件中添加的属性,在NavDestination组件中也会生效吗?

A:NavDestination作为子页面的根容器,用于显示Navigation的内容区。NavDestination组件在渲染时会使用到Navigation组件中添加的属性。
https://pastebin.com/tB45X2Jp
https://pastebin.com/hjD1EQe6
https://pastebin.com/ZPZSXrit
https://pastebin.com/BKvXc1XH
https://pastebin.com/EWXDC53c
https://pastebin.com/cZtc3Gm0
https://pastebin.com/D8xdXuhM
https://pastebin.com/EgrbEUJD
https://pastebin.com/8VvqjShG
https://pastebin.com/C8U16GGp
https://pastebin.com/601zAGr0
https://pastebin.com/VUYU8tTc
https://pastebin.com/4eSAuAFs
https://pastebin.com/hYWauvkZ
https://pastebin.com/dKtpzYwL
https://pastebin.com/faS8dizU
https://pastebin.com/epWAdZjT
https://pastebin.com/N28eTbrZ
https://pastebin.com/4Yhy1g4R
https://pastebin.com/pVHAppet
https://pastebin.com/6n0m31zk
https://pastebin.com/BwNRYEsp
https://pastebin.com/UHgxVZrY
https://pastebin.com/sdBk4pwS
https://pastebin.com/qw3Qeiv3
https://pastebin.com/ZE3yTP0u
https://pastebin.com/fbLvkuFA
https://pastebin.com/jrQByFhQ
https://pastebin.com/BLDqNVZd
https://pastebin.com/rsWambwi
https://pastebin.com/jSNv47wj
https://pastebin.com/SgFwMwCn
https://pastebin.com/ii9Mx19w
https://pastebin.com/U12DCAjc
https://pastebin.com/J4UTSFCv
https://pastebin.com/vLKGZmEC
https://pastebin.com/45sT2V0T
https://pastebin.com/HNSU6w07
https://pastebin.com/Q1ez1SyN
https://pastebin.com/NU2YtB2j
https://pastebin.com/4vYkErDF
https://pastebin.com/HbEs4xd0
https://pastebin.com/EQ5rygX0
https://pastebin.com/56zyx4mu
https://pastebin.com/BxL1hQtN
https://pastebin.com/u95LA4eA
https://pastebin.com/SERQ5L1R
https://pastebin.com/1HLR4hfE
https://pastebin.com/uhYYhvY7
https://pastebin.com/Gzwqu9J8
https://pastebin.com/TRzJAa7C
https://pastebin.com/jkvnkxUf
https://pastebin.com/tQniYitM
https://pastebin.com/teftqPhB
https://pastebin.com/ghmm3Z45
https://pastebin.com/u8zhaGJe
https://pastebin.com/pBUjLT6a
https://pastebin.com/F8xircWV
https://pastebin.com/bHfQyddL
https://pastebin.com/US11UXan
https://pastebin.com/uUq8A9PG
https://pastebin.com/gf0TnLaX
https://pastebin.com/KbjDei75
https://pastebin.com/Eg8SBXip
https://pastebin.com/TRnQT00H
https://pastebin.com/hrJTVPDq
https://pastebin.com/FQybjNNY
https://pastebin.com/vkjZ4wN3
https://pastebin.com/Em0EVbKz
https://pastebin.com/8SQtnCqe
https://pastebin.com/JzVba9rR
https://pastebin.com/VYum9agp
https://pastebin.com/kkQzAQQb
https://pastebin.com/rupgeDSt
https://pastebin.com/zaLpEv2s
https://pastebin.com/GcbHRNFi
https://pastebin.com/bpp4uE8Z
https://pastebin.com/7HKMVmd2
https://pastebin.com/Fg7cmq4m
https://pastebin.com/4yzireaB
https://pastebin.com/8JxAZZaT
https://pastebin.com/Ja0YVzXd
https://pastebin.com/ZD0SeiaT
https://pastebin.com/03wkfuNw
https://pastebin.com/2LsPES0D
https://pastebin.com/kpcMTN8H
https://pastebin.com/wDTn15yX
https://pastebin.com/DpmFJRhA
https://pastebin.com/WSgL8jW4
https://pastebin.com/XpDeHfFU
https://pastebin.com/HwhXqV7d
https://pastebin.com/KQuJBJGJ
https://pastebin.com/PvAf8UmX
https://pastebin.com/NvrpD52h
https://pastebin.com/mVcns0hQ
https://pastebin.com/iHkN8Zqt
https://pastebin.com/CtB7EXHR
https://pastebin.com/LGWxiLVh
https://pastebin.com/e1yYzpGV
https://pastebin.com/r690DqD6
https://pastebin.com/dxHZMZpR
https://pastebin.com/9Bt6QmGx
https://pastebin.com/3GR4wnyg
https://pastebin.com/U48VGWDZ
https://pastebin.com/DsvcvGfg
https://pastebin.com/5mJKbEHB
https://pastebin.com/XJHSbEB5
https://pastebin.com/K2TCyawf
https://pastebin.com/Ewn4DHM5
https://pastebin.com/Ta7ufBT4
https://pastebin.com/atf8fKFA
https://pastebin.com/SCwnf7Kq
https://pastebin.com/WwPcSmwn
https://pastebin.com/u1BUyfCp
https://pastebin.com/MdTnRfHn
https://pastebin.com/EbeshHhc
https://pastebin.com/qGcygDuP
https://pastebin.com/SqRmqdgU
https://pastebin.com/7npW5utw
https://pastebin.com/GjSNL297
https://pastebin.com/unDuaQRL
https://pastebin.com/GW5NYdDa
https://pastebin.com/v7NDFUPc
https://pastebin.com/8VRW7Q9K
https://pastebin.com/2KXX6VGt
https://pastebin.com/MCmswb3v
https://pastebin.com/iiuA6adw
https://pastebin.com/D58m3xJB
https://pastebin.com/0S5C0W5s
https://pastebin.com/kd37nDY6
https://pastebin.com/THKnHxWN
https://pastebin.com/b0cNSgs9
https://pastebin.com/4EgxXbvr
https://pastebin.com/e0Y46BLB
https://pastebin.com/JYnQsScA
https://pastebin.com/0JHz4u7D
https://pastebin.com/eLygzWDz
https://pastebin.com/BpNaLvtM
https://pastebin.com/YEpynedk
https://pastebin.com/nr92i4cm
https://pastebin.com/GyJdL3jT
https://pastebin.com/5J59xjwP
https://pastebin.com/YzKHrHf8
https://pastebin.com/9e9Fw0qZ
https://pastebin.com/xeyWtjCZ
https://pastebin.com/ZuNfjEXd
https://pastebin.com/1eAXJXch
https://pastebin.com/wScAwcxA
https://pastebin.com/veLVP8JY
https://pastebin.com/gp9ivT1n
https://pastebin.com/09pRUVcp
https://pastebin.com/KDpZcsSs
https://pastebin.com/qPuKxaGF
https://pastebin.com/Z1HRFRjF
https://pastebin.com/jR9E2zAk
https://pastebin.com/E42XsidQ
https://pastebin.com/vnpF0CrJ
https://pastebin.com/tPdeYkh9
https://pastebin.com/SvdMHSSw
https://pastebin.com/8vBeN0S8
https://pastebin.com/B6WRVRQ4
https://pastebin.com/zydaXiMd
https://pastebin.com/nsb4NEC3
https://pastebin.com/QxyaK9dt
https://pastebin.com/aztZVSES
https://pastebin.com/gq0Ugnzu
https://pastebin.com/WQKjeyU6
https://pastebin.com/JjqU9JHd
https://pastebin.com/m8x0g5jW
https://pastebin.com/5nbWUgFh
https://pastebin.com/Rzgpje7d
https://pastebin.com/K0wf5rAY
https://pastebin.com/ZDPfudPN
https://pastebin.com/RSxAxA0k
https://pastebin.com/WwvnWgmu
https://pastebin.com/pGpEm8MC
https://pastebin.com/yeGNby1Y
https://pastebin.com/G0i3MQkj
https://pastebin.com/te38JUac
https://pastebin.com/dDmpLYkT
https://pastebin.com/a6eywsqw
https://pastebin.com/u2aXcVQm
https://pastebin.com/ECmeMX9f
https://pastebin.com/VcR7W9zx
https://pastebin.com/XRU0XdJV
https://pastebin.com/vrr5hgAv
https://pastebin.com/fRf4Uz2n
https://pastebin.com/wNUGRHMQ
https://pastebin.com/yVnHymtb
https://pastebin.com/gQWJU5a2
https://pastebin.com/VfquXqXb
https://pastebin.com/k1bQrXjs
https://pastebin.com/2BRqX78a
https://pastebin.com/qhfE1Qni
https://pastebin.com/uxwXhuAJ
https://pastebin.com/Z68kDKtq
https://pastebin.com/DUsNuWn8
https://pastebin.com/ALf4SeRQ
https://pastebin.com/6922Wz0j
https://pastebin.com/GwpbvjZw
https://pastebin.com/psciSpxN
https://pastebin.com/3gZnEA2Q
https://pastebin.com/n0cwrgXU
https://pastebin.com/hq907uQR
https://pastebin.com/9VehTSWL
https://pastebin.com/8UE4KeVN

收起阅读 »

如何解决CustomDialog内嵌套Navigation导致弹窗无法底部对齐的问题

问题现象
当CustomDialog内部嵌套Navigation容器时,弹窗设置底部显示alignment: DialogAlignment.Bottom时失效,问题代码如下:

@CustomDialog
export struct MyDialog {
controller: CustomDialogController;

build() {
Navigation() {
Column() {
Text('我是弹窗')
.margin({ top: 20 });
}
.width('100%')
.height(200)
.backgroundColor(Color.White);
};
}
}

@Entry
@Component
struct Dialog {
myDiaController: CustomDialogController = new CustomDialogController({
builder: MyDialog({}),
customStyle: true, // 弹窗容器样式是否自定义
autoCancel: false, // 是否允许点击遮障层退出
alignment: DialogAlignment.Bottom, // 弹窗在竖直方向上的对齐,底部对齐失效
});

onPageShow(): void {
this.myDiaController.open();
}

build() {
}
}
问题现象如下:

点击放大

背景知识
CustomDialog:是一种常见的自定义弹窗方式,当其内部嵌套Navigation容器时,Navigation容器在不设置高度的情况下会默认撑满屏幕,而Navigation内部默认至上而下显示,所以导致弹窗的底部显示命令在显示效果上没有生效。

解决方案
由于Navigation在不设置高度时,默认撑满整个手机屏幕,导致嵌套Navigation的弹窗也是全屏显示,从而导致DialogAlignment.Bottom从体验上未生效。实际逻辑是弹窗已经是全屏显示,弹窗底部对齐后依旧是全屏显示。
为Navigation容器设置高度限制(本示例设置300vp),并设置背景颜色为蓝色后,可以发现弹窗为底部对齐效果:
@CustomDialog
export struct MyDialog {
controller: CustomDialogController;
pathStack: NavPathStack = new NavPathStack();

build() {
// 弹窗内使用Navigation可实现弹窗内路由跳转,从而更换弹窗内显示的页面
Navigation(this.pathStack) {
Column() {
Text('我是弹窗')
.margin({ top: 20 });
}
.width('100%')
.height(200)
.backgroundColor(Color.White);
}
.height(300)
.backgroundColor('#0a59f7');
}
}

@Entry
@Component
struct DialogDemo {
myDialogController: CustomDialogController = new CustomDialogController({
builder: MyDialog({}),
customStyle: true, // 弹窗容器样式是否自定义
autoCancel: false, // 是否允许点击遮障层退出
alignment: DialogAlignment.Bottom, // 弹窗在竖直方向上的对齐,底部对齐失效
});

onPageShow(): void {
this.myDialogController.open();
}

build() {
}
}
实现效果如下:

点击放大

上图中弹窗为白色与蓝色部分(其中白色是弹窗中子组件背景色,蓝色是弹窗背景色),弹窗底部对齐。

总结
多数情况下,父容器的大小在未设置尺寸限制的情况下默认自适应子组件大小,所以,该思维惯性会陷入一个误区:默认Navigation容器未设置尺寸时会自适应其子组件Column的高度200vp,从而默认弹窗高度是200vp、底部对齐命令未生效。而实际上,部分容器在未设置尺寸限制时会默认全屏显示,例如Navigation、Tabs等。
https://pastebin.com/WJ5W0twm
https://pastebin.com/jLpfh6jM
https://pastebin.com/NjyZTRcj
https://pastebin.com/Ez67aS1j
https://pastebin.com/PWLApZGh
https://pastebin.com/kgcxtYRV
https://pastebin.com/zm7shgpT
https://pastebin.com/Hs9GycZW
https://pastebin.com/7vv5yCtq
https://pastebin.com/ZS9f3J4Y
https://pastebin.com/46rvnNrr
https://pastebin.com/NwPLquMj
https://pastebin.com/MvzT3WtH
https://pastebin.com/s7291Kt8
https://pastebin.com/xRq94uf9
https://pastebin.com/uMBCp2ev
https://pastebin.com/BWMV3JqP
https://pastebin.com/XPfp8bc9
https://pastebin.com/nKfsMJA5
https://pastebin.com/aKp1dGqk
https://pastebin.com/h2EmDCVE
https://pastebin.com/rZVpf79t
https://pastebin.com/e0rWL4nZ
https://pastebin.com/T1rBGcwT
https://pastebin.com/GW3cpe4h
https://pastebin.com/7L9MDU8B
https://pastebin.com/DdCFC9uX
https://pastebin.com/H6JCTycv
https://pastebin.com/2rF8HFxn
https://pastebin.com/27LRXmHN
https://pastebin.com/vZNhsMEz
https://pastebin.com/EUv3D6T5
https://pastebin.com/DhBRDNpd
https://pastebin.com/LJQy75bK
https://pastebin.com/8Thp0a3g
https://pastebin.com/mHTr3cC7
https://pastebin.com/9ceFaWS0
https://pastebin.com/XfLnUar9
https://pastebin.com/2Ktya3Wm
https://pastebin.com/mMC8tKu4
https://pastebin.com/GGXKYc2R
https://pastebin.com/KjeqQ7WL
https://pastebin.com/pgkpMu3E
https://pastebin.com/WrTGvh8T
https://pastebin.com/PTmNxwCM
https://pastebin.com/nYB5PuJ3
https://pastebin.com/cPwhmWyN
https://pastebin.com/sfACvBXT
https://pastebin.com/BdWfvQ4M
https://pastebin.com/6Y4DCw3k
https://pastebin.com/uiiHmdFq
https://pastebin.com/epBnZPYr
https://pastebin.com/pzrtCDpT
https://pastebin.com/8Y7NGzus
https://pastebin.com/nEh0yKwb
https://pastebin.com/1y8Pauz0
https://pastebin.com/VBdFdQAX
https://pastebin.com/Gzsyyvan
https://pastebin.com/d6KRbrEp
https://pastebin.com/digDdLJy
https://pastebin.com/vXZwSt7S
https://pastebin.com/PFArZUea
https://pastebin.com/fbtycRsf
https://pastebin.com/hZy1Ej5X
https://pastebin.com/q5Ux9M5v
https://pastebin.com/QpqL4m8e
https://pastebin.com/ryZX354t
https://pastebin.com/7fLHzGaX
https://pastebin.com/AK4yYQ8V
https://pastebin.com/K9h66hwy
https://pastebin.com/nsVap5H0
https://pastebin.com/swvzwCcG
https://pastebin.com/DWzPDMcY
https://pastebin.com/rZjvixYp
https://pastebin.com/MJAQGYNi
https://pastebin.com/gr2HUq25
https://pastebin.com/G59auG15
https://pastebin.com/USKWbQEt
https://pastebin.com/FCWwERJp
https://pastebin.com/pk8Mg2gd
https://pastebin.com/EweSwHmL
https://pastebin.com/NgbmDKYh
https://pastebin.com/dA414jhS
https://pastebin.com/4DP40dP2
https://pastebin.com/8wJ89BfG
https://pastebin.com/np7qgdcd
https://pastebin.com/waRHhrn0
https://pastebin.com/XfgUtMpu
https://pastebin.com/H2Gn2Cd2
https://pastebin.com/YghYQ0uy
https://pastebin.com/gj198nVk
https://pastebin.com/BMvr1bRK
https://pastebin.com/stJjWb7K
https://pastebin.com/PgV71Qx1
https://pastebin.com/rjS5iJhU
https://pastebin.com/naLAjPCs
https://pastebin.com/9Zd4qDyw
https://pastebin.com/NF88dyqv
https://pastebin.com/TyG5AVCY
https://pastebin.com/09sR3TSA
https://pastebin.com/GMCCXfv4
https://pastebin.com/ekY0AWb5
https://pastebin.com/ydiwskGD
https://pastebin.com/dVAGRzeJ
https://pastebin.com/iPPHqvfH
https://pastebin.com/w2dhvkWf
https://pastebin.com/mJdRLzGT
https://pastebin.com/c0bQVCVG
https://pastebin.com/QxWJcQAE
https://pastebin.com/URvark7U
https://pastebin.com/HYmjV2sW
https://pastebin.com/dxAu9Mws
https://pastebin.com/TqsS1fuf
https://pastebin.com/fD3GdwWA
https://pastebin.com/eummuY4R
https://pastebin.com/Zi6pLB5b
https://pastebin.com/AGrFQH97
https://pastebin.com/JAXJz8ue
https://pastebin.com/vDcgWd7V
https://pastebin.com/Ny9Rpnqc
https://pastebin.com/FHC1TNwz
https://pastebin.com/DAm7ysS3
https://pastebin.com/XT3ukakt
https://pastebin.com/Y8rD5b75
https://pastebin.com/p1ywR3cC
https://pastebin.com/8Bf036kt
https://pastebin.com/jQVftHBx
https://pastebin.com/n3AC806q
https://pastebin.com/HaQjLjxt
https://pastebin.com/Az91WjQy
https://pastebin.com/TuQUMZxV
https://pastebin.com/xrcq9wBE
https://pastebin.com/XUSJaa6K
https://pastebin.com/kmRpMKVd
https://pastebin.com/vJrrEp4a
https://pastebin.com/aMHtNZwQ
https://pastebin.com/hj0XLquF
https://pastebin.com/10MqCtqC
https://pastebin.com/diTK3Pbx
https://pastebin.com/8cgt7F7S
https://pastebin.com/e1VQgBmN
https://pastebin.com/fXUSWCNx
https://pastebin.com/r7rQyL9M
https://pastebin.com/21AGukpK
https://pastebin.com/g5a4KjRk
https://pastebin.com/Gha4HHQa
https://pastebin.com/UVUx04a9
https://pastebin.com/UZ8wV9iD
https://pastebin.com/fBLkWarW
https://pastebin.com/9AxDJFZh
https://pastebin.com/Ccapyvbv
https://pastebin.com/GHkYrv7E
https://pastebin.com/nLvZnY0S
https://pastebin.com/UyxZ5bTB
https://pastebin.com/nDtYHgrc
https://pastebin.com/HPsCHN5d
https://pastebin.com/cBLNxnW1
https://pastebin.com/EhH7qWW2
https://pastebin.com/ARGDwZDM
https://pastebin.com/Efm309DL
https://pastebin.com/Nty90f6F
https://pastebin.com/5vzhFkmH
https://pastebin.com/QhaJcfCq
https://pastebin.com/G6WKRLhJ
https://pastebin.com/8iWWkQ0i
https://pastebin.com/cWQWa4sy
https://pastebin.com/6KBPeVqL

继续阅读 »

问题现象
当CustomDialog内部嵌套Navigation容器时,弹窗设置底部显示alignment: DialogAlignment.Bottom时失效,问题代码如下:

@CustomDialog
export struct MyDialog {
controller: CustomDialogController;

build() {
Navigation() {
Column() {
Text('我是弹窗')
.margin({ top: 20 });
}
.width('100%')
.height(200)
.backgroundColor(Color.White);
};
}
}

@Entry
@Component
struct Dialog {
myDiaController: CustomDialogController = new CustomDialogController({
builder: MyDialog({}),
customStyle: true, // 弹窗容器样式是否自定义
autoCancel: false, // 是否允许点击遮障层退出
alignment: DialogAlignment.Bottom, // 弹窗在竖直方向上的对齐,底部对齐失效
});

onPageShow(): void {
this.myDiaController.open();
}

build() {
}
}
问题现象如下:

点击放大

背景知识
CustomDialog:是一种常见的自定义弹窗方式,当其内部嵌套Navigation容器时,Navigation容器在不设置高度的情况下会默认撑满屏幕,而Navigation内部默认至上而下显示,所以导致弹窗的底部显示命令在显示效果上没有生效。

解决方案
由于Navigation在不设置高度时,默认撑满整个手机屏幕,导致嵌套Navigation的弹窗也是全屏显示,从而导致DialogAlignment.Bottom从体验上未生效。实际逻辑是弹窗已经是全屏显示,弹窗底部对齐后依旧是全屏显示。
为Navigation容器设置高度限制(本示例设置300vp),并设置背景颜色为蓝色后,可以发现弹窗为底部对齐效果:
@CustomDialog
export struct MyDialog {
controller: CustomDialogController;
pathStack: NavPathStack = new NavPathStack();

build() {
// 弹窗内使用Navigation可实现弹窗内路由跳转,从而更换弹窗内显示的页面
Navigation(this.pathStack) {
Column() {
Text('我是弹窗')
.margin({ top: 20 });
}
.width('100%')
.height(200)
.backgroundColor(Color.White);
}
.height(300)
.backgroundColor('#0a59f7');
}
}

@Entry
@Component
struct DialogDemo {
myDialogController: CustomDialogController = new CustomDialogController({
builder: MyDialog({}),
customStyle: true, // 弹窗容器样式是否自定义
autoCancel: false, // 是否允许点击遮障层退出
alignment: DialogAlignment.Bottom, // 弹窗在竖直方向上的对齐,底部对齐失效
});

onPageShow(): void {
this.myDialogController.open();
}

build() {
}
}
实现效果如下:

点击放大

上图中弹窗为白色与蓝色部分(其中白色是弹窗中子组件背景色,蓝色是弹窗背景色),弹窗底部对齐。

总结
多数情况下,父容器的大小在未设置尺寸限制的情况下默认自适应子组件大小,所以,该思维惯性会陷入一个误区:默认Navigation容器未设置尺寸时会自适应其子组件Column的高度200vp,从而默认弹窗高度是200vp、底部对齐命令未生效。而实际上,部分容器在未设置尺寸限制时会默认全屏显示,例如Navigation、Tabs等。
https://pastebin.com/WJ5W0twm
https://pastebin.com/jLpfh6jM
https://pastebin.com/NjyZTRcj
https://pastebin.com/Ez67aS1j
https://pastebin.com/PWLApZGh
https://pastebin.com/kgcxtYRV
https://pastebin.com/zm7shgpT
https://pastebin.com/Hs9GycZW
https://pastebin.com/7vv5yCtq
https://pastebin.com/ZS9f3J4Y
https://pastebin.com/46rvnNrr
https://pastebin.com/NwPLquMj
https://pastebin.com/MvzT3WtH
https://pastebin.com/s7291Kt8
https://pastebin.com/xRq94uf9
https://pastebin.com/uMBCp2ev
https://pastebin.com/BWMV3JqP
https://pastebin.com/XPfp8bc9
https://pastebin.com/nKfsMJA5
https://pastebin.com/aKp1dGqk
https://pastebin.com/h2EmDCVE
https://pastebin.com/rZVpf79t
https://pastebin.com/e0rWL4nZ
https://pastebin.com/T1rBGcwT
https://pastebin.com/GW3cpe4h
https://pastebin.com/7L9MDU8B
https://pastebin.com/DdCFC9uX
https://pastebin.com/H6JCTycv
https://pastebin.com/2rF8HFxn
https://pastebin.com/27LRXmHN
https://pastebin.com/vZNhsMEz
https://pastebin.com/EUv3D6T5
https://pastebin.com/DhBRDNpd
https://pastebin.com/LJQy75bK
https://pastebin.com/8Thp0a3g
https://pastebin.com/mHTr3cC7
https://pastebin.com/9ceFaWS0
https://pastebin.com/XfLnUar9
https://pastebin.com/2Ktya3Wm
https://pastebin.com/mMC8tKu4
https://pastebin.com/GGXKYc2R
https://pastebin.com/KjeqQ7WL
https://pastebin.com/pgkpMu3E
https://pastebin.com/WrTGvh8T
https://pastebin.com/PTmNxwCM
https://pastebin.com/nYB5PuJ3
https://pastebin.com/cPwhmWyN
https://pastebin.com/sfACvBXT
https://pastebin.com/BdWfvQ4M
https://pastebin.com/6Y4DCw3k
https://pastebin.com/uiiHmdFq
https://pastebin.com/epBnZPYr
https://pastebin.com/pzrtCDpT
https://pastebin.com/8Y7NGzus
https://pastebin.com/nEh0yKwb
https://pastebin.com/1y8Pauz0
https://pastebin.com/VBdFdQAX
https://pastebin.com/Gzsyyvan
https://pastebin.com/d6KRbrEp
https://pastebin.com/digDdLJy
https://pastebin.com/vXZwSt7S
https://pastebin.com/PFArZUea
https://pastebin.com/fbtycRsf
https://pastebin.com/hZy1Ej5X
https://pastebin.com/q5Ux9M5v
https://pastebin.com/QpqL4m8e
https://pastebin.com/ryZX354t
https://pastebin.com/7fLHzGaX
https://pastebin.com/AK4yYQ8V
https://pastebin.com/K9h66hwy
https://pastebin.com/nsVap5H0
https://pastebin.com/swvzwCcG
https://pastebin.com/DWzPDMcY
https://pastebin.com/rZjvixYp
https://pastebin.com/MJAQGYNi
https://pastebin.com/gr2HUq25
https://pastebin.com/G59auG15
https://pastebin.com/USKWbQEt
https://pastebin.com/FCWwERJp
https://pastebin.com/pk8Mg2gd
https://pastebin.com/EweSwHmL
https://pastebin.com/NgbmDKYh
https://pastebin.com/dA414jhS
https://pastebin.com/4DP40dP2
https://pastebin.com/8wJ89BfG
https://pastebin.com/np7qgdcd
https://pastebin.com/waRHhrn0
https://pastebin.com/XfgUtMpu
https://pastebin.com/H2Gn2Cd2
https://pastebin.com/YghYQ0uy
https://pastebin.com/gj198nVk
https://pastebin.com/BMvr1bRK
https://pastebin.com/stJjWb7K
https://pastebin.com/PgV71Qx1
https://pastebin.com/rjS5iJhU
https://pastebin.com/naLAjPCs
https://pastebin.com/9Zd4qDyw
https://pastebin.com/NF88dyqv
https://pastebin.com/TyG5AVCY
https://pastebin.com/09sR3TSA
https://pastebin.com/GMCCXfv4
https://pastebin.com/ekY0AWb5
https://pastebin.com/ydiwskGD
https://pastebin.com/dVAGRzeJ
https://pastebin.com/iPPHqvfH
https://pastebin.com/w2dhvkWf
https://pastebin.com/mJdRLzGT
https://pastebin.com/c0bQVCVG
https://pastebin.com/QxWJcQAE
https://pastebin.com/URvark7U
https://pastebin.com/HYmjV2sW
https://pastebin.com/dxAu9Mws
https://pastebin.com/TqsS1fuf
https://pastebin.com/fD3GdwWA
https://pastebin.com/eummuY4R
https://pastebin.com/Zi6pLB5b
https://pastebin.com/AGrFQH97
https://pastebin.com/JAXJz8ue
https://pastebin.com/vDcgWd7V
https://pastebin.com/Ny9Rpnqc
https://pastebin.com/FHC1TNwz
https://pastebin.com/DAm7ysS3
https://pastebin.com/XT3ukakt
https://pastebin.com/Y8rD5b75
https://pastebin.com/p1ywR3cC
https://pastebin.com/8Bf036kt
https://pastebin.com/jQVftHBx
https://pastebin.com/n3AC806q
https://pastebin.com/HaQjLjxt
https://pastebin.com/Az91WjQy
https://pastebin.com/TuQUMZxV
https://pastebin.com/xrcq9wBE
https://pastebin.com/XUSJaa6K
https://pastebin.com/kmRpMKVd
https://pastebin.com/vJrrEp4a
https://pastebin.com/aMHtNZwQ
https://pastebin.com/hj0XLquF
https://pastebin.com/10MqCtqC
https://pastebin.com/diTK3Pbx
https://pastebin.com/8cgt7F7S
https://pastebin.com/e1VQgBmN
https://pastebin.com/fXUSWCNx
https://pastebin.com/r7rQyL9M
https://pastebin.com/21AGukpK
https://pastebin.com/g5a4KjRk
https://pastebin.com/Gha4HHQa
https://pastebin.com/UVUx04a9
https://pastebin.com/UZ8wV9iD
https://pastebin.com/fBLkWarW
https://pastebin.com/9AxDJFZh
https://pastebin.com/Ccapyvbv
https://pastebin.com/GHkYrv7E
https://pastebin.com/nLvZnY0S
https://pastebin.com/UyxZ5bTB
https://pastebin.com/nDtYHgrc
https://pastebin.com/HPsCHN5d
https://pastebin.com/cBLNxnW1
https://pastebin.com/EhH7qWW2
https://pastebin.com/ARGDwZDM
https://pastebin.com/Efm309DL
https://pastebin.com/Nty90f6F
https://pastebin.com/5vzhFkmH
https://pastebin.com/QhaJcfCq
https://pastebin.com/G6WKRLhJ
https://pastebin.com/8iWWkQ0i
https://pastebin.com/cWQWa4sy
https://pastebin.com/6KBPeVqL

收起阅读 »

#插件需求# uniapp x实现APP热更新

插件需求

uniapp x蒸汽模式希望官方出一个 wgt热更新,或是出一个热更新的解决方案,便于APP后期持续开发

uniapp x蒸汽模式希望官方出一个 wgt热更新,或是出一个热更新的解决方案,便于APP后期持续开发

如何实现键盘上方自定义工具栏

问题现象
跟随键盘的工具栏如何实现?

背景知识
自定义弹窗(CustomDialog):通过CustomDialogController类显示自定义弹窗。使用弹窗组件时,优先考虑自定义弹窗,便于弹窗样式与内容的自定义。
defaultFocus:设置当前组件是否为当前页面上的默认焦点。
on('keyboardHeightChange'):开启固定态软键盘高度变化的监听,当软键盘由本窗口唤出并存在重叠区域时通知键盘高度变化。
Stack:堆叠容器,子组件按照顺序依次入栈,后一个子组件覆盖前一个子组件。
alignContent:设置子组件在容器内的对齐方式。该属性与通用属性align同时设置时,后设置的属性生效。
解决方案
针对跟随键盘的工具栏,可采用以下两种实现方案:

方案一:使用CustomDialog组件自定义工具栏。以下示例通过点击文本组件使弹窗自动获焦,设置弹窗从底部弹出,并为弹窗添加弹出与关闭时的过渡动画,以实现跟随键盘的动画效果。
// 自定义对话框组件,用于显示输入框
@CustomDialog
@Component
export struct Dialog {
dialogController: CustomDialogController;

build() {
Column() {
TextInput({ text: '', placeholder: 'input your word...' })
.type(InputType.Normal)
.defaultFocus(true) // 设置为默认聚焦状态
.onBlur(() => {
// 输入框失去焦点时关闭对话框
this.dialogController.close();
})
.height('50%')
.margin({ top: 25, left: 16, right: 16 });
}
.height(100)
.backgroundColor(Color.White);
}
}

@Entry
@Component
struct Index {
message: string = 'Hello World';
// 自定义对话框控制器,配置对话框的显示参数
customDialogController: CustomDialogController = new CustomDialogController({
builder: Dialog(), // 设置对话框内容组件
alignment: DialogAlignment.Bottom, // 对话框从底部弹出
cornerRadius: 0,
customStyle: true,
openAnimation: { duration: 0.001, tempo: 0 }, // 打开弹窗时动画设置
closeAnimation: { duration: 0.001, tempo: 0 }, // 关闭弹窗时动画设置
});

build() {
Column() {
// 点击按钮打开对话框
Text(this.message)
.onClick(() => {
this.customDialogController.open(); // 触发对话框显示
})
.id('HelloWorld')
.fontSize(50)
.fontWeight(FontWeight.Bold);
}
.height('100%')
.width('100%');
}
}
效果预览:

点击放大

方案二:自定义工具栏组件,借助堆叠容器Stack进行页面布局,将组件设置为底部横向居中。定义状态变量isVisible以控制自定义工具栏的显示与隐藏:在TextInput组件聚焦时显示,失焦时隐藏。同时,通过on('keyboardHeightChange')方法监听软键盘高度变化,依据键盘高度动态调整自定义工具栏的位置,使其紧贴于键盘上方。
import { window } from '@kit.ArkUI';

@Entry
@Component
struct Index {
message: string = '图文信息区域';
// 控制自定义工具栏组件可见性
@State isVisible: Visibility = Visibility.Hidden;
// 存储键盘高度信息
@State keyboardHeight: string = '0px';
controller: RichEditorController = new RichEditorController();

// 页面加载时监听键盘高度变化
aboutToAppear(): void {
window.getLastWindow(this.getUIContext().getHostContext()).then((win) => {
win.on('keyboardHeightChange', (height) => {
console.info('height' + height.toString());
if (height) {
this.keyboardHeight = height.toString() + 'px'; // 保存键盘高度
this.isVisible = Visibility.Visible; // 显示自定义组件
} else {
this.isVisible = Visibility.Hidden; // 隐藏自定义组件
}
});
});
}

build() {
// 使用Stack布局,底部对齐
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Text(this.message)
.fontSize(52)
.height('90%')
.width('100%')
.textAlign(TextAlign.Center)
.fontColor(Color.Black);
// 操作按钮行
Row() {
Text('点击拉起键盘')
.fontColor(Color.White)
.width('90%')
.height('40')
.backgroundColor('#ff1a73f8')
.borderRadius(20)
.textAlign(TextAlign.Center)
.margin({ left: 16, right: 16 })
.onClick(() => {
this.isVisible = Visibility.Visible; // 显示自定义组件
setTimeout(() => {
focusControl.requestFocus('textArea'); // 焦点切换到输入框
}, 10);
});
}.height('10%').width('100%');
}
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM]); // 适配键盘区域

  // 遮罩层(点击隐藏自定义)  
  Column()  
    .width('100%')  
    .height('120%')  
    .opacity(0.5)  
    .backgroundColor('rgba(0,0,0,0.5)')  
    .onClick(() => {  
      this.isVisible = Visibility.Hidden; // 隐藏自定义组件  
    })  
    .visibility(this.isVisible)  
    .expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM, SafeAreaEdge.TOP]);  
  // 动态加载自定义组件  
  CommentComponent({ isVisible: this.isVisible, keyboardHeight: this.keyboardHeight });  
};  

}

// 页面卸载时隐藏自定义组件
aboutToDisappear(): void {
this.isVisible = Visibility.Hidden;
}
}

// 自定义工具栏组件
@Component
struct CommentComponent {
controller: RichEditorController = new RichEditorController();
@Link isVisible: Visibility;
@Link keyboardHeight: string;

build() {
Flex({ direction: FlexDirection.ColumnReverse, justifyContent: FlexAlign.Start, alignItems: ItemAlign.Stretch }) {
Row() {
RichEditor({ controller: this.controller })
.id('textArea') // 设置输入框ID用于焦点控制
.placeholder('喜欢就评论一下吧~', { fontColor: Color.Gray, font: { size: 16, weight: FontWeight.Normal } })
.width('65%');
Flex({ direction: FlexDirection.Row, justifyContent: FlexAlign.End, alignItems: ItemAlign.Stretch }) {
Image($r('app.media.startIcon')).height(20).constraintSize({ minWidth: 20, maxWidth: 20 });
}
.width(110);
}
.margin({
top: 10,
bottom: this.keyboardHeight, // 根据键盘高度设置动态调整底部边距
left: 10,
right: 10
})
.border({ width: 1 })
.borderRadius(50);
}
.width('100%')
.height(380)
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM, SafeAreaEdge.TOP]) // 适配键盘区域
.backgroundColor(Color.White)
.visibility(this.isVisible);
}
}
https://pastebin.com/6KBPeVqL
https://pastebin.com/bN1ddiZp
https://pastebin.com/gtYW43NW
https://pastebin.com/eJcix7ad
https://pastebin.com/HcrRQFtA
https://pastebin.com/XgyPuxHD
https://pastebin.com/263D2tG3
https://pastebin.com/VFXnShya
https://pastebin.com/JcCfedC7
https://pastebin.com/XgiWLKtb
https://pastebin.com/SnA9AdtL
https://pastebin.com/gXXg0rwX
https://pastebin.com/6JGUtchA
https://pastebin.com/ZbTwd0Ua
https://pastebin.com/0s559jyS
https://pastebin.com/FF4bCbp7
https://pastebin.com/rbJciCS7
https://pastebin.com/fMBrbeYx
https://pastebin.com/3ZzHtZqE
https://pastebin.com/xgZc6Wpx
https://pastebin.com/q9MHz0sP
https://pastebin.com/7BGqgqgb
https://pastebin.com/762Emz0H
https://pastebin.com/KS7UgY63
https://pastebin.com/E0CUmHyN
https://pastebin.com/RaxmNbrv
https://pastebin.com/sWGhM2xB
https://pastebin.com/q4USiCDw
https://pastebin.com/XBjfa0Jv
https://pastebin.com/5R6CQqw3
https://pastebin.com/Q7C1fgyH
https://pastebin.com/fBtA7KM6
https://pastebin.com/dZHaQ1uQ
https://pastebin.com/YEKBNnfc
https://pastebin.com/T3fNdkNE
https://pastebin.com/r2YxuAHc
https://pastebin.com/Y1Fzshsu
https://pastebin.com/KJvxU2vN
https://pastebin.com/57egjdLv
https://pastebin.com/VxNKdCta
https://pastebin.com/3YsNKCSd
https://pastebin.com/Wk06JeP9
https://pastebin.com/T2pGjSNZ
https://pastebin.com/M0kVVxRE
https://pastebin.com/7kAYYh9u
https://pastebin.com/uiKRsuLh
https://pastebin.com/XLg7wEiV
https://pastebin.com/Uk7dPXfC
https://pastebin.com/HXaxi2bh
https://pastebin.com/2stztMET
https://pastebin.com/RxKisB4V
https://pastebin.com/ig8KqDGg
https://pastebin.com/cRQ0yb3J
https://pastebin.com/4hR7SdmD
https://pastebin.com/EwmMgWdr
https://pastebin.com/4b5AFeRk
https://pastebin.com/3CFxLac5
https://pastebin.com/mKn2fjXp
https://pastebin.com/BSa8L13Z
https://pastebin.com/NAtqrReY
https://pastebin.com/34KDinEG
https://pastebin.com/YaXZqyZQ
https://pastebin.com/j2MhC6kw
https://pastebin.com/c1n5dn5T
https://pastebin.com/weATT6j7
https://pastebin.com/zV5bp85z
https://pastebin.com/isfmyWk7
https://pastebin.com/S86UAeMg
https://pastebin.com/XkUgvZQW
https://pastebin.com/v9j6jg3T
https://pastebin.com/3ip0M9T8
https://pastebin.com/W9c1w8Xb
https://pastebin.com/jtcypgTt
https://pastebin.com/tgPhxe2L
https://pastebin.com/bs9jzpuL
https://pastebin.com/W8sLLPtk
https://pastebin.com/gJTGQYCL
https://pastebin.com/Qh2u2nkn
https://pastebin.com/EPEHmxwS
https://pastebin.com/y41haJUr
https://pastebin.com/J6cevDf0
https://pastebin.com/24RSFBR0
https://pastebin.com/KZsSaA18
https://pastebin.com/21egpH32
https://pastebin.com/wDziwJnn
https://pastebin.com/QeQvkA9r
https://pastebin.com/icJRn8VJ
https://pastebin.com/WsvP4LwZ
https://pastebin.com/H8nZikEJ
https://pastebin.com/rn2gyWiP
https://pastebin.com/tfehkve3
https://pastebin.com/kn7dX8Fh
https://pastebin.com/Dn1ZfNEV
https://pastebin.com/yy811Lis
https://pastebin.com/QcqL3CXH
https://pastebin.com/QbMe6xXP
https://pastebin.com/NpR2Ra0H
https://pastebin.com/DeevkS6j
https://pastebin.com/yhumdvyC
https://pastebin.com/mHjSzVib
https://pastebin.com/F2wyRKNJ
https://pastebin.com/cc96TLXV
https://pastebin.com/2U1qwTrS
https://pastebin.com/n6hYfTdQ
https://pastebin.com/rhAFXjq6
https://pastebin.com/Hmj59VVm
https://pastebin.com/YmrAxXMU
https://pastebin.com/JBF9n5PL
https://pastebin.com/f21MNNJt
https://pastebin.com/D1hn89Qa
https://pastebin.com/xqdaxD0A
https://pastebin.com/tm2zPQbn
https://pastebin.com/8j3PBMNW
https://pastebin.com/3LcqrmTu
https://pastebin.com/GY4abWzw
https://pastebin.com/9LnRRWuk
https://pastebin.com/N5HJByTK
https://pastebin.com/aaSvnC3j
https://pastebin.com/UYFUHSWk
https://pastebin.com/fcvPwAqU
https://pastebin.com/fS9cgSGG
https://pastebin.com/NP5mQWhJ
https://pastebin.com/em54RVwp
https://pastebin.com/FhSzXrRD
https://pastebin.com/EaU2AmjN
https://pastebin.com/86fScibj
https://pastebin.com/rgPcewwp
https://pastebin.com/FAhu2G96
https://pastebin.com/fU6un6pq
https://pastebin.com/Fmpvt6K9
https://pastebin.com/DMJHzHuW
https://pastebin.com/F39T4W12
https://pastebin.com/9wGgKwMC
https://pastebin.com/xJXUSDnS
https://pastebin.com/FqUGuWTV
https://pastebin.com/Z3ppjriB
https://pastebin.com/A2AQwvEd
https://pastebin.com/tqBWPMe7
https://pastebin.com/LQ3LFEHs
https://pastebin.com/qgkbKhDs
https://pastebin.com/vNvhLWpB
https://pastebin.com/BYu5Twtq
https://pastebin.com/9MapWu1K
https://pastebin.com/DUS2V33c
https://pastebin.com/WSuFmrmW
https://pastebin.com/vKwBpE8f
https://pastebin.com/ARLCtkJy
https://pastebin.com/R0eRbYXX
https://pastebin.com/vzusjTqP
https://pastebin.com/6xQWzuPp
https://pastebin.com/XZsLs6N3
https://pastebin.com/ccCGb1c1
https://pastebin.com/GbxBPVJ6
https://pastebin.com/c6rWM7yT
https://pastebin.com/0fhv7SWU
https://pastebin.com/LCXSxwqu
https://pastebin.com/UKiEaw1b
https://pastebin.com/rR7WFgRd
https://pastebin.com/GMr8Uit3
https://pastebin.com/EeUAEUzt
https://pastebin.com/iMdgtEvx
https://pastebin.com/M54w1AB8
https://pastebin.com/NBenUCdx
https://pastebin.com/gcYXCccc
https://pastebin.com/VKbbyQWT
https://pastebin.com/AjjbCsHT
https://pastebin.com/95MGXGGW
https://pastebin.com/MGSQGvHt
https://pastebin.com/8vVDgwEX
https://pastebin.com/xg8NhUUu
https://pastebin.com/ejAPdK9h
https://pastebin.com/HC44DqUE
https://pastebin.com/AAERa624
https://pastebin.com/TrqEhKJ0
https://pastebin.com/PhRqJLQH
https://pastebin.com/s10g8uCx
https://pastebin.com/a3RKhwkq
https://pastebin.com/PEsA2ngB

继续阅读 »

问题现象
跟随键盘的工具栏如何实现?

背景知识
自定义弹窗(CustomDialog):通过CustomDialogController类显示自定义弹窗。使用弹窗组件时,优先考虑自定义弹窗,便于弹窗样式与内容的自定义。
defaultFocus:设置当前组件是否为当前页面上的默认焦点。
on('keyboardHeightChange'):开启固定态软键盘高度变化的监听,当软键盘由本窗口唤出并存在重叠区域时通知键盘高度变化。
Stack:堆叠容器,子组件按照顺序依次入栈,后一个子组件覆盖前一个子组件。
alignContent:设置子组件在容器内的对齐方式。该属性与通用属性align同时设置时,后设置的属性生效。
解决方案
针对跟随键盘的工具栏,可采用以下两种实现方案:

方案一:使用CustomDialog组件自定义工具栏。以下示例通过点击文本组件使弹窗自动获焦,设置弹窗从底部弹出,并为弹窗添加弹出与关闭时的过渡动画,以实现跟随键盘的动画效果。
// 自定义对话框组件,用于显示输入框
@CustomDialog
@Component
export struct Dialog {
dialogController: CustomDialogController;

build() {
Column() {
TextInput({ text: '', placeholder: 'input your word...' })
.type(InputType.Normal)
.defaultFocus(true) // 设置为默认聚焦状态
.onBlur(() => {
// 输入框失去焦点时关闭对话框
this.dialogController.close();
})
.height('50%')
.margin({ top: 25, left: 16, right: 16 });
}
.height(100)
.backgroundColor(Color.White);
}
}

@Entry
@Component
struct Index {
message: string = 'Hello World';
// 自定义对话框控制器,配置对话框的显示参数
customDialogController: CustomDialogController = new CustomDialogController({
builder: Dialog(), // 设置对话框内容组件
alignment: DialogAlignment.Bottom, // 对话框从底部弹出
cornerRadius: 0,
customStyle: true,
openAnimation: { duration: 0.001, tempo: 0 }, // 打开弹窗时动画设置
closeAnimation: { duration: 0.001, tempo: 0 }, // 关闭弹窗时动画设置
});

build() {
Column() {
// 点击按钮打开对话框
Text(this.message)
.onClick(() => {
this.customDialogController.open(); // 触发对话框显示
})
.id('HelloWorld')
.fontSize(50)
.fontWeight(FontWeight.Bold);
}
.height('100%')
.width('100%');
}
}
效果预览:

点击放大

方案二:自定义工具栏组件,借助堆叠容器Stack进行页面布局,将组件设置为底部横向居中。定义状态变量isVisible以控制自定义工具栏的显示与隐藏:在TextInput组件聚焦时显示,失焦时隐藏。同时,通过on('keyboardHeightChange')方法监听软键盘高度变化,依据键盘高度动态调整自定义工具栏的位置,使其紧贴于键盘上方。
import { window } from '@kit.ArkUI';

@Entry
@Component
struct Index {
message: string = '图文信息区域';
// 控制自定义工具栏组件可见性
@State isVisible: Visibility = Visibility.Hidden;
// 存储键盘高度信息
@State keyboardHeight: string = '0px';
controller: RichEditorController = new RichEditorController();

// 页面加载时监听键盘高度变化
aboutToAppear(): void {
window.getLastWindow(this.getUIContext().getHostContext()).then((win) => {
win.on('keyboardHeightChange', (height) => {
console.info('height' + height.toString());
if (height) {
this.keyboardHeight = height.toString() + 'px'; // 保存键盘高度
this.isVisible = Visibility.Visible; // 显示自定义组件
} else {
this.isVisible = Visibility.Hidden; // 隐藏自定义组件
}
});
});
}

build() {
// 使用Stack布局,底部对齐
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Text(this.message)
.fontSize(52)
.height('90%')
.width('100%')
.textAlign(TextAlign.Center)
.fontColor(Color.Black);
// 操作按钮行
Row() {
Text('点击拉起键盘')
.fontColor(Color.White)
.width('90%')
.height('40')
.backgroundColor('#ff1a73f8')
.borderRadius(20)
.textAlign(TextAlign.Center)
.margin({ left: 16, right: 16 })
.onClick(() => {
this.isVisible = Visibility.Visible; // 显示自定义组件
setTimeout(() => {
focusControl.requestFocus('textArea'); // 焦点切换到输入框
}, 10);
});
}.height('10%').width('100%');
}
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM]); // 适配键盘区域

  // 遮罩层(点击隐藏自定义)  
  Column()  
    .width('100%')  
    .height('120%')  
    .opacity(0.5)  
    .backgroundColor('rgba(0,0,0,0.5)')  
    .onClick(() => {  
      this.isVisible = Visibility.Hidden; // 隐藏自定义组件  
    })  
    .visibility(this.isVisible)  
    .expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM, SafeAreaEdge.TOP]);  
  // 动态加载自定义组件  
  CommentComponent({ isVisible: this.isVisible, keyboardHeight: this.keyboardHeight });  
};  

}

// 页面卸载时隐藏自定义组件
aboutToDisappear(): void {
this.isVisible = Visibility.Hidden;
}
}

// 自定义工具栏组件
@Component
struct CommentComponent {
controller: RichEditorController = new RichEditorController();
@Link isVisible: Visibility;
@Link keyboardHeight: string;

build() {
Flex({ direction: FlexDirection.ColumnReverse, justifyContent: FlexAlign.Start, alignItems: ItemAlign.Stretch }) {
Row() {
RichEditor({ controller: this.controller })
.id('textArea') // 设置输入框ID用于焦点控制
.placeholder('喜欢就评论一下吧~', { fontColor: Color.Gray, font: { size: 16, weight: FontWeight.Normal } })
.width('65%');
Flex({ direction: FlexDirection.Row, justifyContent: FlexAlign.End, alignItems: ItemAlign.Stretch }) {
Image($r('app.media.startIcon')).height(20).constraintSize({ minWidth: 20, maxWidth: 20 });
}
.width(110);
}
.margin({
top: 10,
bottom: this.keyboardHeight, // 根据键盘高度设置动态调整底部边距
left: 10,
right: 10
})
.border({ width: 1 })
.borderRadius(50);
}
.width('100%')
.height(380)
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM, SafeAreaEdge.TOP]) // 适配键盘区域
.backgroundColor(Color.White)
.visibility(this.isVisible);
}
}
https://pastebin.com/6KBPeVqL
https://pastebin.com/bN1ddiZp
https://pastebin.com/gtYW43NW
https://pastebin.com/eJcix7ad
https://pastebin.com/HcrRQFtA
https://pastebin.com/XgyPuxHD
https://pastebin.com/263D2tG3
https://pastebin.com/VFXnShya
https://pastebin.com/JcCfedC7
https://pastebin.com/XgiWLKtb
https://pastebin.com/SnA9AdtL
https://pastebin.com/gXXg0rwX
https://pastebin.com/6JGUtchA
https://pastebin.com/ZbTwd0Ua
https://pastebin.com/0s559jyS
https://pastebin.com/FF4bCbp7
https://pastebin.com/rbJciCS7
https://pastebin.com/fMBrbeYx
https://pastebin.com/3ZzHtZqE
https://pastebin.com/xgZc6Wpx
https://pastebin.com/q9MHz0sP
https://pastebin.com/7BGqgqgb
https://pastebin.com/762Emz0H
https://pastebin.com/KS7UgY63
https://pastebin.com/E0CUmHyN
https://pastebin.com/RaxmNbrv
https://pastebin.com/sWGhM2xB
https://pastebin.com/q4USiCDw
https://pastebin.com/XBjfa0Jv
https://pastebin.com/5R6CQqw3
https://pastebin.com/Q7C1fgyH
https://pastebin.com/fBtA7KM6
https://pastebin.com/dZHaQ1uQ
https://pastebin.com/YEKBNnfc
https://pastebin.com/T3fNdkNE
https://pastebin.com/r2YxuAHc
https://pastebin.com/Y1Fzshsu
https://pastebin.com/KJvxU2vN
https://pastebin.com/57egjdLv
https://pastebin.com/VxNKdCta
https://pastebin.com/3YsNKCSd
https://pastebin.com/Wk06JeP9
https://pastebin.com/T2pGjSNZ
https://pastebin.com/M0kVVxRE
https://pastebin.com/7kAYYh9u
https://pastebin.com/uiKRsuLh
https://pastebin.com/XLg7wEiV
https://pastebin.com/Uk7dPXfC
https://pastebin.com/HXaxi2bh
https://pastebin.com/2stztMET
https://pastebin.com/RxKisB4V
https://pastebin.com/ig8KqDGg
https://pastebin.com/cRQ0yb3J
https://pastebin.com/4hR7SdmD
https://pastebin.com/EwmMgWdr
https://pastebin.com/4b5AFeRk
https://pastebin.com/3CFxLac5
https://pastebin.com/mKn2fjXp
https://pastebin.com/BSa8L13Z
https://pastebin.com/NAtqrReY
https://pastebin.com/34KDinEG
https://pastebin.com/YaXZqyZQ
https://pastebin.com/j2MhC6kw
https://pastebin.com/c1n5dn5T
https://pastebin.com/weATT6j7
https://pastebin.com/zV5bp85z
https://pastebin.com/isfmyWk7
https://pastebin.com/S86UAeMg
https://pastebin.com/XkUgvZQW
https://pastebin.com/v9j6jg3T
https://pastebin.com/3ip0M9T8
https://pastebin.com/W9c1w8Xb
https://pastebin.com/jtcypgTt
https://pastebin.com/tgPhxe2L
https://pastebin.com/bs9jzpuL
https://pastebin.com/W8sLLPtk
https://pastebin.com/gJTGQYCL
https://pastebin.com/Qh2u2nkn
https://pastebin.com/EPEHmxwS
https://pastebin.com/y41haJUr
https://pastebin.com/J6cevDf0
https://pastebin.com/24RSFBR0
https://pastebin.com/KZsSaA18
https://pastebin.com/21egpH32
https://pastebin.com/wDziwJnn
https://pastebin.com/QeQvkA9r
https://pastebin.com/icJRn8VJ
https://pastebin.com/WsvP4LwZ
https://pastebin.com/H8nZikEJ
https://pastebin.com/rn2gyWiP
https://pastebin.com/tfehkve3
https://pastebin.com/kn7dX8Fh
https://pastebin.com/Dn1ZfNEV
https://pastebin.com/yy811Lis
https://pastebin.com/QcqL3CXH
https://pastebin.com/QbMe6xXP
https://pastebin.com/NpR2Ra0H
https://pastebin.com/DeevkS6j
https://pastebin.com/yhumdvyC
https://pastebin.com/mHjSzVib
https://pastebin.com/F2wyRKNJ
https://pastebin.com/cc96TLXV
https://pastebin.com/2U1qwTrS
https://pastebin.com/n6hYfTdQ
https://pastebin.com/rhAFXjq6
https://pastebin.com/Hmj59VVm
https://pastebin.com/YmrAxXMU
https://pastebin.com/JBF9n5PL
https://pastebin.com/f21MNNJt
https://pastebin.com/D1hn89Qa
https://pastebin.com/xqdaxD0A
https://pastebin.com/tm2zPQbn
https://pastebin.com/8j3PBMNW
https://pastebin.com/3LcqrmTu
https://pastebin.com/GY4abWzw
https://pastebin.com/9LnRRWuk
https://pastebin.com/N5HJByTK
https://pastebin.com/aaSvnC3j
https://pastebin.com/UYFUHSWk
https://pastebin.com/fcvPwAqU
https://pastebin.com/fS9cgSGG
https://pastebin.com/NP5mQWhJ
https://pastebin.com/em54RVwp
https://pastebin.com/FhSzXrRD
https://pastebin.com/EaU2AmjN
https://pastebin.com/86fScibj
https://pastebin.com/rgPcewwp
https://pastebin.com/FAhu2G96
https://pastebin.com/fU6un6pq
https://pastebin.com/Fmpvt6K9
https://pastebin.com/DMJHzHuW
https://pastebin.com/F39T4W12
https://pastebin.com/9wGgKwMC
https://pastebin.com/xJXUSDnS
https://pastebin.com/FqUGuWTV
https://pastebin.com/Z3ppjriB
https://pastebin.com/A2AQwvEd
https://pastebin.com/tqBWPMe7
https://pastebin.com/LQ3LFEHs
https://pastebin.com/qgkbKhDs
https://pastebin.com/vNvhLWpB
https://pastebin.com/BYu5Twtq
https://pastebin.com/9MapWu1K
https://pastebin.com/DUS2V33c
https://pastebin.com/WSuFmrmW
https://pastebin.com/vKwBpE8f
https://pastebin.com/ARLCtkJy
https://pastebin.com/R0eRbYXX
https://pastebin.com/vzusjTqP
https://pastebin.com/6xQWzuPp
https://pastebin.com/XZsLs6N3
https://pastebin.com/ccCGb1c1
https://pastebin.com/GbxBPVJ6
https://pastebin.com/c6rWM7yT
https://pastebin.com/0fhv7SWU
https://pastebin.com/LCXSxwqu
https://pastebin.com/UKiEaw1b
https://pastebin.com/rR7WFgRd
https://pastebin.com/GMr8Uit3
https://pastebin.com/EeUAEUzt
https://pastebin.com/iMdgtEvx
https://pastebin.com/M54w1AB8
https://pastebin.com/NBenUCdx
https://pastebin.com/gcYXCccc
https://pastebin.com/VKbbyQWT
https://pastebin.com/AjjbCsHT
https://pastebin.com/95MGXGGW
https://pastebin.com/MGSQGvHt
https://pastebin.com/8vVDgwEX
https://pastebin.com/xg8NhUUu
https://pastebin.com/ejAPdK9h
https://pastebin.com/HC44DqUE
https://pastebin.com/AAERa624
https://pastebin.com/TrqEhKJ0
https://pastebin.com/PhRqJLQH
https://pastebin.com/s10g8uCx
https://pastebin.com/a3RKhwkq
https://pastebin.com/PEsA2ngB

收起阅读 »

zero-markdown-view插件解析markdown问题修复

使用zero-markdown-view插件时解析的markdown,如果markdown中表格上面有一行文字就识别不到表格,示例如下

### 标题  
从多维度对比差异:  
| 表头1       | 表头2               | 表头3               |  
|----------------|----------------------------|----------------------------|  
| 1   | 2    | 3      |

只有将表格上空出一行才正常识别到表格,正常的如下:

### 标题  
从多维度对比差异:  

| 表头1       | 表头2               | 表头3               |  
|----------------|----------------------------|----------------------------|  
| 1   | 2    | 3      |

修复方法:
此插件依赖marked.min.js解析

  1. 将这个js文件下载下来https://cdn.jsdelivr.net/npm/marked@18.0.7/lib/marked.esm.js
  2. 将上面下载的js文件放到这个目录下/uni_modules/zero-markdown-view/components/mp-html/markdown/
  3. 打开这个文件/uni_modules/zero-markdown-view/components/mp-html/markdown/index.js
  4. 把第六行import marked from './marked.min'替换成import {marked} from './marked.esm'
    这样就ok了
继续阅读 »

使用zero-markdown-view插件时解析的markdown,如果markdown中表格上面有一行文字就识别不到表格,示例如下

### 标题  
从多维度对比差异:  
| 表头1       | 表头2               | 表头3               |  
|----------------|----------------------------|----------------------------|  
| 1   | 2    | 3      |

只有将表格上空出一行才正常识别到表格,正常的如下:

### 标题  
从多维度对比差异:  

| 表头1       | 表头2               | 表头3               |  
|----------------|----------------------------|----------------------------|  
| 1   | 2    | 3      |

修复方法:
此插件依赖marked.min.js解析

  1. 将这个js文件下载下来https://cdn.jsdelivr.net/npm/marked@18.0.7/lib/marked.esm.js
  2. 将上面下载的js文件放到这个目录下/uni_modules/zero-markdown-view/components/mp-html/markdown/
  3. 打开这个文件/uni_modules/zero-markdown-view/components/mp-html/markdown/index.js
  4. 把第六行import marked from './marked.min'替换成import {marked} from './marked.esm'
    这样就ok了
收起阅读 »

如何解决Repeat渲染数据重复显示问题

问题现象
使用Repeat实现列表功能,加载新数据会出现重复显示问题,一般有下面几种问题场景:

常见问题场景

问题描述

场景一

滑动加载新数据,在aboutToAppear方法里处理数据逻辑,导致数据重复。

场景二

Repeat与@Builder混用场景下,传参错误,导致数据重复。

场景一:滑动加载新数据,在aboutToAppear方法里处理数据逻辑,问题如图所示:
点击放大

场景二:进行Repeat与@Builder混用场景下,传参使用错误,导致数据重复,问题如图所示:
点击放大

背景知识
Repeat根据容器组件的有效加载范围(屏幕可视区域+预加载区域)加载子组件。当容器滑动/数组改变时,Repeat会根据父容器组件的布局过程重新计算有效加载范围,并管理列表子组件节点的创建与销毁。Repeat通过组件节点更新/复用从而优化性能表现。
节点更新复用能力说明:当滚动容器组件滑动/数组改变时,Repeat将失效的子组件节点(离开容器组件的显示区域和预加载区域)加入空闲节点缓存池中,即断开组件节点与页面组件树的连接但不销毁节点。在需要生成新的组件时,对缓存池里的组件节点进行复用。
aboutToAppear:aboutToAppear函数在创建自定义组件的新实例后,在执行其build()函数之前执行。允许在aboutToAppear函数中改变状态变量,更改将在后续执行build()函数中生效。
问题定位
场景一:
根据问题代码分析,数据更新是在子组件的aboutToAppear方法里完成的,aboutToAppear在组件被创建时触发,之后不会再触发。因为Repeat提供了节点复用的能力,后续加载的是之前复用的节点,不会走aboutToAppear方法,导致数据没有更新。

问题代码如下:

@ComponentV2
struct LowCodeTitleView{

@Param ids: string = ''
@Local changes:string = ''

aboutToAppear(): void {
this.changes = this.ids // 数据更新是在子组件的aboutToAppear方法里完成的
}

build() {
Column(){
Text(this.changes+"标题内容")
}
}
}

@Entry
@ComponentV2
struct Index {
@Local dataArr: Array<string> = [];
aboutToAppear(): void {
for (let i = 0; i < 50; i++) {
this.dataArr.push(data_${i}); // 为数组添加一些数据
}
}
build() {
RelativeContainer() {
List({ space: 3 }) {
Repeat<string>(this.dataArr)
.each((ri: RepeatItem<string>) => {
ListItem() {
LowCodeTitleView({
ids:ri.item
})
}
})
.key((item: string, index: number): string => "__test_lowcode--"+index)
.virtualScroll({ totalCount: this.dataArr.length })
}.cachedCount(1)
}
.height('100%')
.width('100%')
}
}
场景二:
根据问题代码分析:

首页展示正常,说明节点创建操作正常。
当滚动容器组件滑动/数组改变时,Repeat将失效的子组件节点(离开有效加载范围)加入空闲节点缓存池中,即断开组件节点与页面组件树的连接但不销毁节点。在需要生成新的组件时,对缓存池里的组件节点进行复用。下滑后发现节点与历史出现的节点重复,说明节点复用时异常,推测传参的方式或类型不符合要求。
Repeat与Builder混用章节中描述:当Repeat与@Builder混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递RepeatItem.item或RepeatItem.index,将会出现UI渲染异常。
基于以上可确认问题原因在于组件imageItemView的传参有误。
问题代码如下:

List({ space: 10 }) {
Repeat<ItemDataV2>(this.imageList)
.each((ri: RepeatItem<ItemDataV2>) => {
ListItem() {
this.imageItemView(ri.item);
};
})
.key((item: ItemDataV2) => item.id.toString())
.templateId((item: ItemDataV2) => {
return item.type;
})
.virtualScroll({ totalCount: this.imageList.length });
}
分析结论
场景一:Repeat提供了节点复用的能力,节点复用的时候没有办法重走aboutToAppear,导致复用组件的数据没有及时刷新。需要把要更新的数据放在数据源里,通过数据源变化来触发Repeat刷新。
场景二:UI渲染异常根源在于Repeat与@Builder混用时传参错误,必须将RepeatItem类型整体进行传参而不是只传递RepeatItem.item或RepeatItem.index。
修改建议
场景一:
方案一:由于复用时aboutToAppear方法不会执行,无法在aboutToAppear里更新数据源,因此可以考虑通过Repeat直接监听数据源变化触发刷新,示例代码如下:
class Title {
id: number;
title: string;

constructor(id: number, title: string) {
this.id = id;
this.title = title;
}
}

@ComponentV2
struct LowCodeTitleView {
@Param title: Title = new Title(0, '标题内容'); // 数据从父组件传递给子组件,不走aboutToAppear

build() {
Column() {
Text(this.title.id + this.title.title);
}.margin({ left: 16, top: 10, bottom: 10 });
}
}

@Entry
@ComponentV2
struct RepeatLoadDataDemo {
@Local dataArr: Array<Title> = [];

aboutToAppear(): void {
for (let i = 0; i < 50; i++) {
this.dataArr.push(new Title(i, '标题内容')); // 为数组添加一些数据
}
}

build() {
RelativeContainer() {
List({ space: 3 }) {
Repeat<Title>(this.dataArr) // 把更新的数据放在数据源里
.each((ri: RepeatItem<Title>) => {
ListItem() {
LowCodeTitleView({
title: ri.item
});
};
})
.key((_item: Title, index: number): string => '__test_lowcode--' + index)
.virtualScroll({ totalCount: this.dataArr.length });
}.cachedCount(1).expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]);
}.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])

.height('100%')  
.width('100%');  

}
}
方案二:可以使用LazyForEach结合@Reusable实现列表组件的组件复用,在节点复用的时候使用aboutToReuse生命周期触发节点触发更新数据,参考链接示例代码:列表滚动配合LazyForEach使用。
在API 18后,Repeat提供了关闭自身复用的能力,详细参考:VirtualScrollOptions;配合@ReusableV2装饰的组件使用,复用的时候也可以触发组件的aboutToReuse生命周期函数。

场景二:
上述问题代码中this.imageItemView(ri.item)传参存在问题,必须将RepeatItem类型整体进行传参,应该改为this.imageItemView(ri)。

示例代码如下:

@ObservedV2
export class ItemDataV2 {
@Trace
id: number;
@Trace
type: string = '';
@Trace
title: ResourceStr;
@Trace
img: Resource;

constructor(id: number, title: ResourceStr, img: Resource, type?: string) {
this.id = id;
this.title = title;
this.img = img;
if (type) {
this.type = type;
}
}
}

@Entry
@ComponentV2
struct RepeatDemo {
@Local imageList: Array<ItemDataV2> = this.getFirstPageData();

// 模拟的第一页的数据
getFirstPageData(): Array<ItemDataV2> {
let imageList: Array<ItemDataV2> = [];
imageList.push(...getItemData(2, 10));
return imageList;
}

build() {
Column() {
Button('addItem').onClick(() => { // 点击按钮添加一页的数据
this.addTestData();
});

  List({ space: 10 }) {  
    Repeat<ItemDataV2>(this.imageList)  
      .each((ri: RepeatItem<ItemDataV2>) => {  
        ListItem() {  
          this.imageItemView(ri);  
        };  
      })  
      .key((item: ItemDataV2) => item.id.toString())  
      .templateId((item: ItemDataV2) => {  
        return item.type;  
      })  
      .virtualScroll({ totalCount: this.imageList.length });  
  }.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])  
  .cachedCount(6)  
  .width('100%')  
  .padding({  
    top: 15,  
    right: 15,  
    left: 15,  
    bottom: 12  
  });  
}.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]);  

}

// 添加一页的数据
addTestData(): void {
let count = this.imageList.length;
let moreArr: ItemDataV2[] = getItemData(count, 10);
this.imageList.push(...moreArr);
}

@Builder
imageItemView(ri: RepeatItem<ItemDataV2>) {
Stack() {
Image(ri.item.img)
.objectFit(ImageFit.Cover)
.aspectRatio(3)
.borderRadius(12);

  Text(ri.item.title)  
    .padding(15)  
    .fontSize(30)  
    .fontWeight(FontWeight.Bold)  
    .fontColor(Color.Black);  

}.alignContent(Alignment.TopStart)  
.width('100%');  

}
}

let swiperImg: Array<Resource> = [$r('app.media.backImage')]; // 背景图

// 创建模拟的数据
function getItemData(start: number, count: number): ItemDataV2[] {
let arr: ItemDataV2[] = [];
for (let i = 0; i < count; i++) {
let imageIndex = i % swiperImg.length;
arr.push(new ItemDataV2(i + start, (i + start).toString(), swiperImg[imageIndex]));
}
return arr;
}
https://pastebin.com/BMW3u7yk
https://pastebin.com/zr8N97Ne
https://pastebin.com/Gs298WgD
https://pastebin.com/FnxZDBcW
https://pastebin.com/i8rpfLEP
https://pastebin.com/ez7BZPE3
https://pastebin.com/U0KwnFSL
https://pastebin.com/R4cMh2SL
https://pastebin.com/676zm0WG
https://pastebin.com/H9pKi7w7
https://pastebin.com/dbtzJw0x
https://pastebin.com/QLWvWbqU
https://pastebin.com/yGPiJTZD
https://pastebin.com/7VminAc3
https://pastebin.com/rsScjjFE
https://pastebin.com/ez8zq4VT
https://pastebin.com/1efA7vcf
https://pastebin.com/1HpF960z
https://pastebin.com/DLn96YHA
https://pastebin.com/8dFFy65M
https://pastebin.com/TKEbGuxn
https://pastebin.com/FVgewR7x
https://pastebin.com/XTKjPrmz
https://pastebin.com/CjtL2CbZ
https://pastebin.com/8JsJ7bMj
https://pastebin.com/a9RikA98
https://pastebin.com/DBn55k14
https://pastebin.com/fNpm9vYB
https://pastebin.com/72ZfQ149
https://pastebin.com/i7N5Avqf
https://pastebin.com/GUXF7KDm
https://pastebin.com/Ku19TS4j
https://pastebin.com/sqttnjrA
https://pastebin.com/9BVvdDdC
https://pastebin.com/CHYZ1Gvb
https://pastebin.com/hrbjEy3W
https://pastebin.com/Eejnahjn
https://pastebin.com/BJjGW58r
https://pastebin.com/TA5zPz46
https://pastebin.com/aWz5V4yR
https://pastebin.com/gLqvHV7C
https://pastebin.com/zrFtuaUg
https://pastebin.com/225KFvYC
https://pastebin.com/hYe0TumF
https://pastebin.com/KE2Ux8wJ
https://pastebin.com/TMdN3eBf
https://pastebin.com/JACkCBXg
https://pastebin.com/uQEujZg0
https://pastebin.com/aqgRAuwQ
https://pastebin.com/jpxU5vmQ
https://pastebin.com/Zhmfn6xS
https://pastebin.com/gLz2BJup
https://pastebin.com/qwWaQzjV
https://pastebin.com/dsc3dh7Q
https://pastebin.com/MEeAfNrK
https://pastebin.com/s13exYvP
https://pastebin.com/BGw364Z8
https://pastebin.com/cJKMevm3
https://pastebin.com/6DDggPWC
https://pastebin.com/UpDpAga4
https://pastebin.com/Lw1s28nY
https://pastebin.com/tLM0z9LE
https://pastebin.com/bVVHFj6b
https://pastebin.com/FunGXsh7
https://pastebin.com/ChnzMQ8K
https://pastebin.com/TLQiKJqr
https://pastebin.com/3jiBF2qM
https://pastebin.com/bXUHz9bE
https://pastebin.com/L3MVPMQq
https://pastebin.com/WYWQGEQV
https://pastebin.com/qB8KP2UN
https://pastebin.com/sf84cdKm
https://pastebin.com/1hmh7csp
https://pastebin.com/ZdW6ZsCP
https://pastebin.com/83GH1cLG
https://pastebin.com/dVp5TAsp
https://pastebin.com/D9MPcZSw
https://pastebin.com/dyUNcKUb
https://pastebin.com/GVGM2jKa
https://pastebin.com/ttCQv2jU
https://pastebin.com/U2hAp61K
https://pastebin.com/uX8yxjv3
https://pastebin.com/SE3Yq25k
https://pastebin.com/NkvV232B
https://pastebin.com/SGPc5xik
https://pastebin.com/fBmMd2cN
https://pastebin.com/9wsVURDP
https://pastebin.com/y4TmwfYR
https://pastebin.com/JcTuRYrc
https://pastebin.com/V8RPz7mM
https://pastebin.com/YqASCN8G
https://pastebin.com/6JMnz3jC
https://pastebin.com/JKN9bkBR
https://pastebin.com/YkX3cz9S
https://pastebin.com/L71qyZBH
https://pastebin.com/af14FyAF
https://pastebin.com/rYKWY5hk
https://pastebin.com/bcHNKC0q
https://pastebin.com/avy1Qyra
https://pastebin.com/v08NsSpW
https://pastebin.com/bxPJwdh2
https://pastebin.com/vV3Lxu9E
https://pastebin.com/PMwyJDrr
https://pastebin.com/GNceZj3i
https://pastebin.com/QAgS2DBq
https://pastebin.com/xU0LsnLd
https://pastebin.com/QaNHe5E3
https://pastebin.com/qxtNYsxX
https://pastebin.com/ApPBs6pZ
https://pastebin.com/W2N66ehs
https://pastebin.com/7QVU487N
https://pastebin.com/NK2wvLaE
https://pastebin.com/LxeFEpZ5
https://pastebin.com/0RnYrav7
https://pastebin.com/EYw7h4gC
https://pastebin.com/7yqhn05R
https://pastebin.com/Pkhb5WGT
https://pastebin.com/dcF8cERK
https://pastebin.com/WSitbd4k
https://pastebin.com/VJkwygDS
https://pastebin.com/AWqu7HfY
https://pastebin.com/dpSAXydY
https://pastebin.com/QRC4ygGj
https://pastebin.com/2E991jhR
https://pastebin.com/349smxri
https://pastebin.com/d8LJ0Q6C
https://pastebin.com/KYmcfhUq
https://pastebin.com/NYF6CT4C
https://pastebin.com/Fa6XQ06R
https://pastebin.com/DRe4gE72
https://pastebin.com/NvzHJugB
https://pastebin.com/bpYrBJ9Q
https://pastebin.com/AnC8XMWr
https://pastebin.com/1c2sjpwh
https://pastebin.com/yhXGhB8E
https://pastebin.com/itRM2ZMd
https://pastebin.com/aR9HpTRH
https://pastebin.com/m19RYG7j
https://pastebin.com/xjpU9ksH
https://pastebin.com/xiN4JaXB
https://pastebin.com/1gU4EArR
https://pastebin.com/6qhtwYk1
https://pastebin.com/H64fri79
https://pastebin.com/xsRKbG1m
https://pastebin.com/UipZaKhK
https://pastebin.com/RAbT0kVq
https://pastebin.com/0i00ADZz
https://pastebin.com/iurUhSyk
https://pastebin.com/AJ90NhRk
https://pastebin.com/rsyZZcM5
https://pastebin.com/fST61sHE
https://pastebin.com/tnxRKNzD
https://pastebin.com/dk9KKKDm
https://pastebin.com/gqwbsWku
https://pastebin.com/uxSXWcdC
https://pastebin.com/T59UHk0f
https://pastebin.com/058HRADt
https://pastebin.com/Qa8MpNXB
https://pastebin.com/W5KVa6Bu
https://pastebin.com/gJUwCVjy
https://pastebin.com/86avgWTq
https://pastebin.com/w8JAu1sb
https://pastebin.com/LbUD3yhy
https://pastebin.com/RAWTZN9P
https://pastebin.com/ivG5UTZ1
https://pastebin.com/vU366iR6
https://pastebin.com/XCA86ssH
https://pastebin.com/YgKSxnVv
https://pastebin.com/B6suLGgy
https://pastebin.com/vxZ0nFqR
https://pastebin.com/DZ0nxixL
https://pastebin.com/PqdR7bxv
https://pastebin.com/RmZa7vqb
https://pastebin.com/EhUSwQ4z
https://pastebin.com/9pHd3GTL
https://pastebin.com/VfF8FGVe
https://pastebin.com/T66RxLCX
https://pastebin.com/7fCyXFKW
https://pastebin.com/vcrXHYSx
https://pastebin.com/bj4mJD6y
https://pastebin.com/5rgDxsgD
https://pastebin.com/T0pMab3R
https://pastebin.com/G5LQvwML
https://pastebin.com/tvyGHmzU
https://pastebin.com/ax9M09Mi
https://pastebin.com/8ZduUtUd
https://pastebin.com/9490Ajrb
https://pastebin.com/2VLuQDwG
https://pastebin.com/tt4yG2tw
https://pastebin.com/zJmwjhrr
https://pastebin.com/dbde4zgM
https://pastebin.com/QFkQaF3K
https://pastebin.com/V3S6HFfw
https://pastebin.com/zuip5PZw
https://pastebin.com/rfSqfzE4
https://pastebin.com/LRWRUJcA
https://pastebin.com/1Dz9d3J6
https://pastebin.com/QNRn2bjK

继续阅读 »

问题现象
使用Repeat实现列表功能,加载新数据会出现重复显示问题,一般有下面几种问题场景:

常见问题场景

问题描述

场景一

滑动加载新数据,在aboutToAppear方法里处理数据逻辑,导致数据重复。

场景二

Repeat与@Builder混用场景下,传参错误,导致数据重复。

场景一:滑动加载新数据,在aboutToAppear方法里处理数据逻辑,问题如图所示:
点击放大

场景二:进行Repeat与@Builder混用场景下,传参使用错误,导致数据重复,问题如图所示:
点击放大

背景知识
Repeat根据容器组件的有效加载范围(屏幕可视区域+预加载区域)加载子组件。当容器滑动/数组改变时,Repeat会根据父容器组件的布局过程重新计算有效加载范围,并管理列表子组件节点的创建与销毁。Repeat通过组件节点更新/复用从而优化性能表现。
节点更新复用能力说明:当滚动容器组件滑动/数组改变时,Repeat将失效的子组件节点(离开容器组件的显示区域和预加载区域)加入空闲节点缓存池中,即断开组件节点与页面组件树的连接但不销毁节点。在需要生成新的组件时,对缓存池里的组件节点进行复用。
aboutToAppear:aboutToAppear函数在创建自定义组件的新实例后,在执行其build()函数之前执行。允许在aboutToAppear函数中改变状态变量,更改将在后续执行build()函数中生效。
问题定位
场景一:
根据问题代码分析,数据更新是在子组件的aboutToAppear方法里完成的,aboutToAppear在组件被创建时触发,之后不会再触发。因为Repeat提供了节点复用的能力,后续加载的是之前复用的节点,不会走aboutToAppear方法,导致数据没有更新。

问题代码如下:

@ComponentV2
struct LowCodeTitleView{

@Param ids: string = ''
@Local changes:string = ''

aboutToAppear(): void {
this.changes = this.ids // 数据更新是在子组件的aboutToAppear方法里完成的
}

build() {
Column(){
Text(this.changes+"标题内容")
}
}
}

@Entry
@ComponentV2
struct Index {
@Local dataArr: Array<string> = [];
aboutToAppear(): void {
for (let i = 0; i < 50; i++) {
this.dataArr.push(data_${i}); // 为数组添加一些数据
}
}
build() {
RelativeContainer() {
List({ space: 3 }) {
Repeat<string>(this.dataArr)
.each((ri: RepeatItem<string>) => {
ListItem() {
LowCodeTitleView({
ids:ri.item
})
}
})
.key((item: string, index: number): string => "__test_lowcode--"+index)
.virtualScroll({ totalCount: this.dataArr.length })
}.cachedCount(1)
}
.height('100%')
.width('100%')
}
}
场景二:
根据问题代码分析:

首页展示正常,说明节点创建操作正常。
当滚动容器组件滑动/数组改变时,Repeat将失效的子组件节点(离开有效加载范围)加入空闲节点缓存池中,即断开组件节点与页面组件树的连接但不销毁节点。在需要生成新的组件时,对缓存池里的组件节点进行复用。下滑后发现节点与历史出现的节点重复,说明节点复用时异常,推测传参的方式或类型不符合要求。
Repeat与Builder混用章节中描述:当Repeat与@Builder混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递RepeatItem.item或RepeatItem.index,将会出现UI渲染异常。
基于以上可确认问题原因在于组件imageItemView的传参有误。
问题代码如下:

List({ space: 10 }) {
Repeat<ItemDataV2>(this.imageList)
.each((ri: RepeatItem<ItemDataV2>) => {
ListItem() {
this.imageItemView(ri.item);
};
})
.key((item: ItemDataV2) => item.id.toString())
.templateId((item: ItemDataV2) => {
return item.type;
})
.virtualScroll({ totalCount: this.imageList.length });
}
分析结论
场景一:Repeat提供了节点复用的能力,节点复用的时候没有办法重走aboutToAppear,导致复用组件的数据没有及时刷新。需要把要更新的数据放在数据源里,通过数据源变化来触发Repeat刷新。
场景二:UI渲染异常根源在于Repeat与@Builder混用时传参错误,必须将RepeatItem类型整体进行传参而不是只传递RepeatItem.item或RepeatItem.index。
修改建议
场景一:
方案一:由于复用时aboutToAppear方法不会执行,无法在aboutToAppear里更新数据源,因此可以考虑通过Repeat直接监听数据源变化触发刷新,示例代码如下:
class Title {
id: number;
title: string;

constructor(id: number, title: string) {
this.id = id;
this.title = title;
}
}

@ComponentV2
struct LowCodeTitleView {
@Param title: Title = new Title(0, '标题内容'); // 数据从父组件传递给子组件,不走aboutToAppear

build() {
Column() {
Text(this.title.id + this.title.title);
}.margin({ left: 16, top: 10, bottom: 10 });
}
}

@Entry
@ComponentV2
struct RepeatLoadDataDemo {
@Local dataArr: Array<Title> = [];

aboutToAppear(): void {
for (let i = 0; i < 50; i++) {
this.dataArr.push(new Title(i, '标题内容')); // 为数组添加一些数据
}
}

build() {
RelativeContainer() {
List({ space: 3 }) {
Repeat<Title>(this.dataArr) // 把更新的数据放在数据源里
.each((ri: RepeatItem<Title>) => {
ListItem() {
LowCodeTitleView({
title: ri.item
});
};
})
.key((_item: Title, index: number): string => '__test_lowcode--' + index)
.virtualScroll({ totalCount: this.dataArr.length });
}.cachedCount(1).expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]);
}.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])

.height('100%')  
.width('100%');  

}
}
方案二:可以使用LazyForEach结合@Reusable实现列表组件的组件复用,在节点复用的时候使用aboutToReuse生命周期触发节点触发更新数据,参考链接示例代码:列表滚动配合LazyForEach使用。
在API 18后,Repeat提供了关闭自身复用的能力,详细参考:VirtualScrollOptions;配合@ReusableV2装饰的组件使用,复用的时候也可以触发组件的aboutToReuse生命周期函数。

场景二:
上述问题代码中this.imageItemView(ri.item)传参存在问题,必须将RepeatItem类型整体进行传参,应该改为this.imageItemView(ri)。

示例代码如下:

@ObservedV2
export class ItemDataV2 {
@Trace
id: number;
@Trace
type: string = '';
@Trace
title: ResourceStr;
@Trace
img: Resource;

constructor(id: number, title: ResourceStr, img: Resource, type?: string) {
this.id = id;
this.title = title;
this.img = img;
if (type) {
this.type = type;
}
}
}

@Entry
@ComponentV2
struct RepeatDemo {
@Local imageList: Array<ItemDataV2> = this.getFirstPageData();

// 模拟的第一页的数据
getFirstPageData(): Array<ItemDataV2> {
let imageList: Array<ItemDataV2> = [];
imageList.push(...getItemData(2, 10));
return imageList;
}

build() {
Column() {
Button('addItem').onClick(() => { // 点击按钮添加一页的数据
this.addTestData();
});

  List({ space: 10 }) {  
    Repeat<ItemDataV2>(this.imageList)  
      .each((ri: RepeatItem<ItemDataV2>) => {  
        ListItem() {  
          this.imageItemView(ri);  
        };  
      })  
      .key((item: ItemDataV2) => item.id.toString())  
      .templateId((item: ItemDataV2) => {  
        return item.type;  
      })  
      .virtualScroll({ totalCount: this.imageList.length });  
  }.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])  
  .cachedCount(6)  
  .width('100%')  
  .padding({  
    top: 15,  
    right: 15,  
    left: 15,  
    bottom: 12  
  });  
}.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]);  

}

// 添加一页的数据
addTestData(): void {
let count = this.imageList.length;
let moreArr: ItemDataV2[] = getItemData(count, 10);
this.imageList.push(...moreArr);
}

@Builder
imageItemView(ri: RepeatItem<ItemDataV2>) {
Stack() {
Image(ri.item.img)
.objectFit(ImageFit.Cover)
.aspectRatio(3)
.borderRadius(12);

  Text(ri.item.title)  
    .padding(15)  
    .fontSize(30)  
    .fontWeight(FontWeight.Bold)  
    .fontColor(Color.Black);  

}.alignContent(Alignment.TopStart)  
.width('100%');  

}
}

let swiperImg: Array<Resource> = [$r('app.media.backImage')]; // 背景图

// 创建模拟的数据
function getItemData(start: number, count: number): ItemDataV2[] {
let arr: ItemDataV2[] = [];
for (let i = 0; i < count; i++) {
let imageIndex = i % swiperImg.length;
arr.push(new ItemDataV2(i + start, (i + start).toString(), swiperImg[imageIndex]));
}
return arr;
}
https://pastebin.com/BMW3u7yk
https://pastebin.com/zr8N97Ne
https://pastebin.com/Gs298WgD
https://pastebin.com/FnxZDBcW
https://pastebin.com/i8rpfLEP
https://pastebin.com/ez7BZPE3
https://pastebin.com/U0KwnFSL
https://pastebin.com/R4cMh2SL
https://pastebin.com/676zm0WG
https://pastebin.com/H9pKi7w7
https://pastebin.com/dbtzJw0x
https://pastebin.com/QLWvWbqU
https://pastebin.com/yGPiJTZD
https://pastebin.com/7VminAc3
https://pastebin.com/rsScjjFE
https://pastebin.com/ez8zq4VT
https://pastebin.com/1efA7vcf
https://pastebin.com/1HpF960z
https://pastebin.com/DLn96YHA
https://pastebin.com/8dFFy65M
https://pastebin.com/TKEbGuxn
https://pastebin.com/FVgewR7x
https://pastebin.com/XTKjPrmz
https://pastebin.com/CjtL2CbZ
https://pastebin.com/8JsJ7bMj
https://pastebin.com/a9RikA98
https://pastebin.com/DBn55k14
https://pastebin.com/fNpm9vYB
https://pastebin.com/72ZfQ149
https://pastebin.com/i7N5Avqf
https://pastebin.com/GUXF7KDm
https://pastebin.com/Ku19TS4j
https://pastebin.com/sqttnjrA
https://pastebin.com/9BVvdDdC
https://pastebin.com/CHYZ1Gvb
https://pastebin.com/hrbjEy3W
https://pastebin.com/Eejnahjn
https://pastebin.com/BJjGW58r
https://pastebin.com/TA5zPz46
https://pastebin.com/aWz5V4yR
https://pastebin.com/gLqvHV7C
https://pastebin.com/zrFtuaUg
https://pastebin.com/225KFvYC
https://pastebin.com/hYe0TumF
https://pastebin.com/KE2Ux8wJ
https://pastebin.com/TMdN3eBf
https://pastebin.com/JACkCBXg
https://pastebin.com/uQEujZg0
https://pastebin.com/aqgRAuwQ
https://pastebin.com/jpxU5vmQ
https://pastebin.com/Zhmfn6xS
https://pastebin.com/gLz2BJup
https://pastebin.com/qwWaQzjV
https://pastebin.com/dsc3dh7Q
https://pastebin.com/MEeAfNrK
https://pastebin.com/s13exYvP
https://pastebin.com/BGw364Z8
https://pastebin.com/cJKMevm3
https://pastebin.com/6DDggPWC
https://pastebin.com/UpDpAga4
https://pastebin.com/Lw1s28nY
https://pastebin.com/tLM0z9LE
https://pastebin.com/bVVHFj6b
https://pastebin.com/FunGXsh7
https://pastebin.com/ChnzMQ8K
https://pastebin.com/TLQiKJqr
https://pastebin.com/3jiBF2qM
https://pastebin.com/bXUHz9bE
https://pastebin.com/L3MVPMQq
https://pastebin.com/WYWQGEQV
https://pastebin.com/qB8KP2UN
https://pastebin.com/sf84cdKm
https://pastebin.com/1hmh7csp
https://pastebin.com/ZdW6ZsCP
https://pastebin.com/83GH1cLG
https://pastebin.com/dVp5TAsp
https://pastebin.com/D9MPcZSw
https://pastebin.com/dyUNcKUb
https://pastebin.com/GVGM2jKa
https://pastebin.com/ttCQv2jU
https://pastebin.com/U2hAp61K
https://pastebin.com/uX8yxjv3
https://pastebin.com/SE3Yq25k
https://pastebin.com/NkvV232B
https://pastebin.com/SGPc5xik
https://pastebin.com/fBmMd2cN
https://pastebin.com/9wsVURDP
https://pastebin.com/y4TmwfYR
https://pastebin.com/JcTuRYrc
https://pastebin.com/V8RPz7mM
https://pastebin.com/YqASCN8G
https://pastebin.com/6JMnz3jC
https://pastebin.com/JKN9bkBR
https://pastebin.com/YkX3cz9S
https://pastebin.com/L71qyZBH
https://pastebin.com/af14FyAF
https://pastebin.com/rYKWY5hk
https://pastebin.com/bcHNKC0q
https://pastebin.com/avy1Qyra
https://pastebin.com/v08NsSpW
https://pastebin.com/bxPJwdh2
https://pastebin.com/vV3Lxu9E
https://pastebin.com/PMwyJDrr
https://pastebin.com/GNceZj3i
https://pastebin.com/QAgS2DBq
https://pastebin.com/xU0LsnLd
https://pastebin.com/QaNHe5E3
https://pastebin.com/qxtNYsxX
https://pastebin.com/ApPBs6pZ
https://pastebin.com/W2N66ehs
https://pastebin.com/7QVU487N
https://pastebin.com/NK2wvLaE
https://pastebin.com/LxeFEpZ5
https://pastebin.com/0RnYrav7
https://pastebin.com/EYw7h4gC
https://pastebin.com/7yqhn05R
https://pastebin.com/Pkhb5WGT
https://pastebin.com/dcF8cERK
https://pastebin.com/WSitbd4k
https://pastebin.com/VJkwygDS
https://pastebin.com/AWqu7HfY
https://pastebin.com/dpSAXydY
https://pastebin.com/QRC4ygGj
https://pastebin.com/2E991jhR
https://pastebin.com/349smxri
https://pastebin.com/d8LJ0Q6C
https://pastebin.com/KYmcfhUq
https://pastebin.com/NYF6CT4C
https://pastebin.com/Fa6XQ06R
https://pastebin.com/DRe4gE72
https://pastebin.com/NvzHJugB
https://pastebin.com/bpYrBJ9Q
https://pastebin.com/AnC8XMWr
https://pastebin.com/1c2sjpwh
https://pastebin.com/yhXGhB8E
https://pastebin.com/itRM2ZMd
https://pastebin.com/aR9HpTRH
https://pastebin.com/m19RYG7j
https://pastebin.com/xjpU9ksH
https://pastebin.com/xiN4JaXB
https://pastebin.com/1gU4EArR
https://pastebin.com/6qhtwYk1
https://pastebin.com/H64fri79
https://pastebin.com/xsRKbG1m
https://pastebin.com/UipZaKhK
https://pastebin.com/RAbT0kVq
https://pastebin.com/0i00ADZz
https://pastebin.com/iurUhSyk
https://pastebin.com/AJ90NhRk
https://pastebin.com/rsyZZcM5
https://pastebin.com/fST61sHE
https://pastebin.com/tnxRKNzD
https://pastebin.com/dk9KKKDm
https://pastebin.com/gqwbsWku
https://pastebin.com/uxSXWcdC
https://pastebin.com/T59UHk0f
https://pastebin.com/058HRADt
https://pastebin.com/Qa8MpNXB
https://pastebin.com/W5KVa6Bu
https://pastebin.com/gJUwCVjy
https://pastebin.com/86avgWTq
https://pastebin.com/w8JAu1sb
https://pastebin.com/LbUD3yhy
https://pastebin.com/RAWTZN9P
https://pastebin.com/ivG5UTZ1
https://pastebin.com/vU366iR6
https://pastebin.com/XCA86ssH
https://pastebin.com/YgKSxnVv
https://pastebin.com/B6suLGgy
https://pastebin.com/vxZ0nFqR
https://pastebin.com/DZ0nxixL
https://pastebin.com/PqdR7bxv
https://pastebin.com/RmZa7vqb
https://pastebin.com/EhUSwQ4z
https://pastebin.com/9pHd3GTL
https://pastebin.com/VfF8FGVe
https://pastebin.com/T66RxLCX
https://pastebin.com/7fCyXFKW
https://pastebin.com/vcrXHYSx
https://pastebin.com/bj4mJD6y
https://pastebin.com/5rgDxsgD
https://pastebin.com/T0pMab3R
https://pastebin.com/G5LQvwML
https://pastebin.com/tvyGHmzU
https://pastebin.com/ax9M09Mi
https://pastebin.com/8ZduUtUd
https://pastebin.com/9490Ajrb
https://pastebin.com/2VLuQDwG
https://pastebin.com/tt4yG2tw
https://pastebin.com/zJmwjhrr
https://pastebin.com/dbde4zgM
https://pastebin.com/QFkQaF3K
https://pastebin.com/V3S6HFfw
https://pastebin.com/zuip5PZw
https://pastebin.com/rfSqfzE4
https://pastebin.com/LRWRUJcA
https://pastebin.com/1Dz9d3J6
https://pastebin.com/QNRn2bjK

收起阅读 »

线上药店商城小程序,可开电子处方,用药安全提醒,药品追溯码

线上药店必备三大合规功能:电子处方,用药安全,药品追溯码
电子处方|线上开方、药师审方、全程留痕
用药安全|风险弹窗、用户确认、凭证归档
药品追溯|一盒一码、订单直扫、来源可查
三大功能形成完整业务闭环,线上售药标准化落地。
前端uniapp,后台php,全套开源代码,可以二开。

继续阅读 »

线上药店必备三大合规功能:电子处方,用药安全,药品追溯码
电子处方|线上开方、药师审方、全程留痕
用药安全|风险弹窗、用户确认、凭证归档
药品追溯|一盒一码、订单直扫、来源可查
三大功能形成完整业务闭环,线上售药标准化落地。
前端uniapp,后台php,全套开源代码,可以二开。

收起阅读 »

最近用 Cursor 写了个 uniapp 小程序,发现 AI 生成的代码比我自己写的更懂页面性能

uni-app

用 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 写一个商品列表页,支持分页加载,列表项包含图片、价格和库存,注意性能。”它输出的代码让我有点意外,因为它自动做了几件我在写类似页面时根本没想到的事:

  1. 用 virtual-list 组件代替了 scroll-view + v-for。
  2. 对图片做了预加载和内存回收。
  3. 对库存状态变化做了按需更新,而不是整个列表重新渲染。

三个被 AI 提醒的优化细节

  1. 虚拟列表:不是所有数据都要渲染

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 重新计算高度才解决的。

  1. 图片懒加载 + 内存回收: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)
}

  1. 按需更新库存:避免整个列表重渲染

我们项目的库存是实时变化的,用户下单后,其他用户的页面上库存数字要立刻更新。以前我的做法是:收到 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 帮我写了不少好代码,但我也发现了一些需要人工处理的问题:

  1. 虚拟列表的滚动事件兼容性:uni-virtual-list 在 iOS 上偶尔会出现滚动位置偏移,AI 没处理。我手动加了一个 @scroll 事件监听,用 scrollTop 做二次校准。

  2. 图片懒加载的占位图:AI 生成的代码没给 image 加 placeholder 属性,导致图片加载前显示空白,用户体验不好。我手动加了 @error 和 @load 事件来处理加载失败和成功。

  3. 库存更新的并发问题:如果 WebSocket 推送太快(比如秒杀场景),stockMap 的更新可能会覆盖。我加了一个队列机制,保证每次更新都是基于最新值。

  4. 代码可读性: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 写一个商品列表页,支持分页加载,列表项包含图片、价格和库存,注意性能。”它输出的代码让我有点意外,因为它自动做了几件我在写类似页面时根本没想到的事:

  1. 用 virtual-list 组件代替了 scroll-view + v-for。
  2. 对图片做了预加载和内存回收。
  3. 对库存状态变化做了按需更新,而不是整个列表重新渲染。

三个被 AI 提醒的优化细节

  1. 虚拟列表:不是所有数据都要渲染

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 重新计算高度才解决的。

  1. 图片懒加载 + 内存回收: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)
}

  1. 按需更新库存:避免整个列表重渲染

我们项目的库存是实时变化的,用户下单后,其他用户的页面上库存数字要立刻更新。以前我的做法是:收到 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 帮我写了不少好代码,但我也发现了一些需要人工处理的问题:

  1. 虚拟列表的滚动事件兼容性:uni-virtual-list 在 iOS 上偶尔会出现滚动位置偏移,AI 没处理。我手动加了一个 @scroll 事件监听,用 scrollTop 做二次校准。

  2. 图片懒加载的占位图:AI 生成的代码没给 image 加 placeholder 属性,导致图片加载前显示空白,用户体验不好。我手动加了 @error 和 @load 事件来处理加载失败和成功。

  3. 库存更新的并发问题:如果 WebSocket 推送太快(比如秒杀场景),stockMap 的更新可能会覆盖。我加了一个队列机制,保证每次更新都是基于最新值。

  4. 代码可读性:AI 生成的代码虽然高效,但变量命名很随意(比如 item、temp、arr)。如果不重命名,团队协作时别人看不懂。我花了半小时把变量名改成有业务含义的。

总结:AI 帮我打开了性能优化的新视角

这次用 Cursor 写 uniapp 小程序的经历,让我意识到两件事:

  • 第一,AI 在性能优化上的“直觉”比很多开发者都强,因为它训练的数据里包含了大量真实项目的性能优化模式,而这些模式我们平时很少主动学习。
  • 第二,AI 不能替代开发者的判断力,但可以作为一个“性能顾问”,帮你指出那些你根本没想到的优化点。

如果你也在用 uniapp 做小程序,强烈建议试试让 AI 帮你生成列表页、详情页这类高频渲染的页面。但记得,AI 输出的代码要逐行审查,尤其是边界条件和内存管理。毕竟,最懂你业务逻辑的,还是你自己。

最后,如果你也在用 Cursor 或者 Copilot 写 uniapp,欢迎在评论区分享你遇到的坑和优化技巧。

收起阅读 »

AI 写了个 Vue3 页面,我却花了半小时改它的“uni-app 兼容性”——这到底是谁在学谁?

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永远不会主动告诉我。

半小时里我踩的三个大坑

  1. 轮播图高度设置:一个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设计稿换算,高度用宽高比动态计算,才解决。

  1. 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 /

条件编译增加了代码量,但这是保证多端一致性的唯一办法。

  1. 生命周期差异:用户从购物车返回,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永远不会主动告诉我。

半小时里我踩的三个大坑

  1. 轮播图高度设置:一个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设计稿换算,高度用宽高比动态计算,才解决。

  1. 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 /

条件编译增加了代码量,但这是保证多端一致性的唯一办法。

  1. 生命周期差异:用户从购物车返回,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 生成的样式比我自己调的还快

uni-app

用Cursor写uni-app页面:从手动调样式到10分钟出活

上周五下午,产品扔过来一个需求:在uni-app项目里加一个“会员权益对比”页面,横向展示免费版、标准版、专业版三列,每列有功能列表、价格标签、推荐标识,还得适配不同屏幕尺寸。任务不算复杂,但我心里咯噔一下——这种多列布局加自适应,在移动端调样式经常要反复改flex比例、边距、圆角,甚至可能遇到不同机型下的像素偏差。以前我手动搞过一个类似的页面,从写结构到上线,光调试样式就花了整整一下午,最后还在某台老安卓上崩了布局。

这次我决定试试用Cursor(AI辅助编程工具)直接生成页面。结果出乎意料:从输入需求描述到拿到可用的.vue文件,只花了不到10分钟,生成的样式在真机上几乎不用改。这篇文章就聊聊这次实践的具体过程、遇到的坑,以及我对“AI写样式”这件事的真实感受。

为什么手动调样式经常翻车?我踩过的坑

在uni-app里写页面,尤其是带多列对比、自适应、卡片样式的页面,容易踩的坑有几个:

  1. flex布局的跨端兼容:比如在微信小程序里,某些flex属性(如gap)支持不全,需要手动用margin替代。我之前写对比列表时,因为flex-wrap和align-items的组合在不同端表现不一致,最后不得不写两套样式(H5和小程序分别调)。
  2. 像素级对齐:三列卡片,每列内容高度不固定,要求所有列的“购买按钮”底部对齐,同时卡片内的文字行高、间距要一致。手动设置min-height和padding很容易出现某列多一行文字就撑破布局的情况。
  3. 主题色和可维护性:产品要求推荐列(专业版)要有高亮边框和背景,其他两列保持普通样式。如果手动写,需要单独为推荐列加一个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,白色文字。” 它很快就补上了。

代码思路与关键片段

生成的代码结构清晰,我挑几个关键点说一下:

  1. 数据定义:AI用了一个数组,每个对象包含name、price、features(数组,每个元素是{text, supported})、recommended(布尔值)。这样后续改接口时,只需要替换数据源,样式基本不用动。

  2. 布局实现:外层用display: flex; justify-content: space-between;,每列用flex: 1; margin: 0 8rpx;。为了按钮对齐,AI在每列卡片内使用了display: flex; flex-direction: column;,然后让按钮区域margin-top: auto;。这个技巧很实用,避免了手动计算高度。

  3. 推荐列高亮:通过: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辅助前端开发有了更实际的看法:

  1. 效率提升明显:从0到可用的页面,AI节省了至少70%的样式编写时间。特别是布局和自适应部分,AI能一次性给出完整的flex方案,避免了手动试错。
  2. 适合“模式化”页面:对比列表、表单、卡片布局这类有固定模式的页面,AI生成的质量很高。但如果是高度定制化的动画、复杂交互,AI目前还容易写出逻辑混乱的代码。
  3. 必须有人把关:AI生成的样式不会考虑你的项目已有的全局样式、组件库规范、版本兼容性。比如它用了gap属性,但在微信小程序的旧基础库上可能无效。所以,把AI当成一个“高级模板生成器”,而不是替代你的判断。

最后,建议大家在团队里可以试试这种工作流:先用AI生成页面骨架和基础样式,然后人工检查兼容性、交互细节,再接入真实数据。这样既能享受AI的效率,又不会丢掉对代码的控制权。如果你也试过类似的实践,欢迎在评论区聊聊踩过的坑。

继续阅读 »

用Cursor写uni-app页面:从手动调样式到10分钟出活

上周五下午,产品扔过来一个需求:在uni-app项目里加一个“会员权益对比”页面,横向展示免费版、标准版、专业版三列,每列有功能列表、价格标签、推荐标识,还得适配不同屏幕尺寸。任务不算复杂,但我心里咯噔一下——这种多列布局加自适应,在移动端调样式经常要反复改flex比例、边距、圆角,甚至可能遇到不同机型下的像素偏差。以前我手动搞过一个类似的页面,从写结构到上线,光调试样式就花了整整一下午,最后还在某台老安卓上崩了布局。

这次我决定试试用Cursor(AI辅助编程工具)直接生成页面。结果出乎意料:从输入需求描述到拿到可用的.vue文件,只花了不到10分钟,生成的样式在真机上几乎不用改。这篇文章就聊聊这次实践的具体过程、遇到的坑,以及我对“AI写样式”这件事的真实感受。

为什么手动调样式经常翻车?我踩过的坑

在uni-app里写页面,尤其是带多列对比、自适应、卡片样式的页面,容易踩的坑有几个:

  1. flex布局的跨端兼容:比如在微信小程序里,某些flex属性(如gap)支持不全,需要手动用margin替代。我之前写对比列表时,因为flex-wrap和align-items的组合在不同端表现不一致,最后不得不写两套样式(H5和小程序分别调)。
  2. 像素级对齐:三列卡片,每列内容高度不固定,要求所有列的“购买按钮”底部对齐,同时卡片内的文字行高、间距要一致。手动设置min-height和padding很容易出现某列多一行文字就撑破布局的情况。
  3. 主题色和可维护性:产品要求推荐列(专业版)要有高亮边框和背景,其他两列保持普通样式。如果手动写,需要单独为推荐列加一个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,白色文字。” 它很快就补上了。

代码思路与关键片段

生成的代码结构清晰,我挑几个关键点说一下:

  1. 数据定义:AI用了一个数组,每个对象包含name、price、features(数组,每个元素是{text, supported})、recommended(布尔值)。这样后续改接口时,只需要替换数据源,样式基本不用动。

  2. 布局实现:外层用display: flex; justify-content: space-between;,每列用flex: 1; margin: 0 8rpx;。为了按钮对齐,AI在每列卡片内使用了display: flex; flex-direction: column;,然后让按钮区域margin-top: auto;。这个技巧很实用,避免了手动计算高度。

  3. 推荐列高亮:通过: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辅助前端开发有了更实际的看法:

  1. 效率提升明显:从0到可用的页面,AI节省了至少70%的样式编写时间。特别是布局和自适应部分,AI能一次性给出完整的flex方案,避免了手动试错。
  2. 适合“模式化”页面:对比列表、表单、卡片布局这类有固定模式的页面,AI生成的质量很高。但如果是高度定制化的动画、复杂交互,AI目前还容易写出逻辑混乱的代码。
  3. 必须有人把关: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 需要回显相同的 cmdreq
  • 当前主控制链路主要使用:
    • E0F00001-0000-1000-8000-00805F9B34FB:控制命令通道,APP 写入;
    • E0F00002-0000-1000-8000-00805F9B34FB:控制响应通道,设备 Notify 回包。

二、推荐的初始化流程

对于 BLE 通讯,推荐把初始化流程固定成下面这个顺序:

  1. 初始化蓝牙模块;
  2. 扫描设备;
  3. 建立连接;
  4. 获取服务;
  5. 协商 MTU;
  6. 获取特征;
  7. 注册 Notify 监听;
  8. 开启响应特征的 Notify;
  9. 等待链路稳定后再发送首条命令。

注意:

  • 微信里和 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 的推荐流程如下:

  1. 连接设备;
  2. 获取服务;
  3. 获取特征;
  4. 开启响应特征 Notify;
  5. 注册监听;
  6. 发送首条业务命令;
  7. 设备返回 ack;
  8. 前端按 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

当前项目会在必要时尝试:

  • write
  • omit
  • writeNoResponse

这样做的原因是:

  • 不同安卓机型对 writeType 的兼容表现不完全一致;
  • 某些机型表面上特征支持 write,但实际写入时仍然会表现异常;
  • 多一种候选写法,能提高项目现场的兼容成功率。

七、华为安卓 APP:不是普通安卓问题,而是更强的时序敏感

华为安卓 APP 是当前项目里最典型、也最容易踩坑的平台。

1. 第一阶段遇到的问题

一开始按标准流程:

  1. 连接成功;
  2. 发现特征;
  3. 立即开启 Notify;
  4. 很快发送首条命令;

结果经常出现错误:

  • writeBLECharacteristicValue:fail property not support
  • 错误码 10007

这个现象的关键点是:

  • 设备的 E0F00001 明明支持写;
  • 微信小程序、iOS、小米安卓都能正常工作;
  • 只有华为安卓 APP 在“开 Notify 后很快发送首条命令”时容易失败。

所以问题不在协议本身,而在华为安卓 APP 下的 GATT 时序。

2. 第二阶段验证后的新结论

后续继续做时序测试后发现:

  • 华为并不是“绝对不能开 Notify”;
  • 真正敏感的是“何时开 Notify”和“开完 Notify 后多久发送第一条命令”;
  • 如果调整成更保守的时序,标准 Notify 链路是可以恢复成功的。

3. 华为当前推荐时序

当前项目里,华为安卓 APP 推荐按下面这个顺序走:

  1. 连接设备;
  2. 获取服务;
  3. 协商 MTU;
  4. 获取特征;
  5. 先注册监听;
  6. 发现特征后等待约 2000ms
  7. 开启 E0F00002 的 Notify;
  8. Notify 成功后再等待约 2000ms
  9. 再发送首条业务命令;
  10. 后续按标准 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 = 00
    • BT_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 时序尤其敏感;
  • 只要日志留得够完整,问题最终都能从“看起来像协议错了”还原到“其实是平台时序差异”。

如果后续要继续提升稳定性,建议优先做两件事:

  1. 继续验证更多业务指令在华为安卓 APP 下的稳定性;
  2. 继续验证设备主动上报 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 需要回显相同的 cmdreq
  • 当前主控制链路主要使用:
    • E0F00001-0000-1000-8000-00805F9B34FB:控制命令通道,APP 写入;
    • E0F00002-0000-1000-8000-00805F9B34FB:控制响应通道,设备 Notify 回包。

二、推荐的初始化流程

对于 BLE 通讯,推荐把初始化流程固定成下面这个顺序:

  1. 初始化蓝牙模块;
  2. 扫描设备;
  3. 建立连接;
  4. 获取服务;
  5. 协商 MTU;
  6. 获取特征;
  7. 注册 Notify 监听;
  8. 开启响应特征的 Notify;
  9. 等待链路稳定后再发送首条命令。

注意:

  • 微信里和 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 的推荐流程如下:

  1. 连接设备;
  2. 获取服务;
  3. 获取特征;
  4. 开启响应特征 Notify;
  5. 注册监听;
  6. 发送首条业务命令;
  7. 设备返回 ack;
  8. 前端按 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

当前项目会在必要时尝试:

  • write
  • omit
  • writeNoResponse

这样做的原因是:

  • 不同安卓机型对 writeType 的兼容表现不完全一致;
  • 某些机型表面上特征支持 write,但实际写入时仍然会表现异常;
  • 多一种候选写法,能提高项目现场的兼容成功率。

七、华为安卓 APP:不是普通安卓问题,而是更强的时序敏感

华为安卓 APP 是当前项目里最典型、也最容易踩坑的平台。

1. 第一阶段遇到的问题

一开始按标准流程:

  1. 连接成功;
  2. 发现特征;
  3. 立即开启 Notify;
  4. 很快发送首条命令;

结果经常出现错误:

  • writeBLECharacteristicValue:fail property not support
  • 错误码 10007

这个现象的关键点是:

  • 设备的 E0F00001 明明支持写;
  • 微信小程序、iOS、小米安卓都能正常工作;
  • 只有华为安卓 APP 在“开 Notify 后很快发送首条命令”时容易失败。

所以问题不在协议本身,而在华为安卓 APP 下的 GATT 时序。

2. 第二阶段验证后的新结论

后续继续做时序测试后发现:

  • 华为并不是“绝对不能开 Notify”;
  • 真正敏感的是“何时开 Notify”和“开完 Notify 后多久发送第一条命令”;
  • 如果调整成更保守的时序,标准 Notify 链路是可以恢复成功的。

3. 华为当前推荐时序

当前项目里,华为安卓 APP 推荐按下面这个顺序走:

  1. 连接设备;
  2. 获取服务;
  3. 协商 MTU;
  4. 获取特征;
  5. 先注册监听;
  6. 发现特征后等待约 2000ms
  7. 开启 E0F00002 的 Notify;
  8. Notify 成功后再等待约 2000ms
  9. 再发送首条业务命令;
  10. 后续按标准 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 = 00
    • BT_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 时序尤其敏感;
  • 只要日志留得够完整,问题最终都能从“看起来像协议错了”还原到“其实是平台时序差异”。

如果后续要继续提升稳定性,建议优先做两件事:

  1. 继续验证更多业务指令在华为安卓 APP 下的稳定性;
  2. 继续验证设备主动上报 evt 在当前延后时序下是否同样稳定。

以上经验分享,你可以把以上信息扔给AI,让AI封装一个符合你项目的bleHelper.js工具类,用于处理BLE通讯。

收起阅读 »