用uni-app搞了个足球战术板,踩了不少canvas坑,分享一下经验
前言
最近开发了一款【苏超排名助手】,里面有一个功能页:足球战术板,主要就是让用户能在球场上画箭头、线条、曲线啥的,方便教练布置战术。本来以为挺简单的,结果各种坑,尤其是要兼容鸿蒙,差点没给我整崩溃。不过最后还是搞定了,记录一下,给后面的兄弟们少踩点坑。
|
|---|
需求是啥
说白了就是这几个功能:
- 在球场上画箭头(传球路线)
- 画直线和曲线(跑位路线)
- 橡皮擦,画错了能擦掉
- 能保存成图片分享
- 还得支持鸿蒙(这个最坑)
Canvas初始化 - 第一个大坑
鸿蒙和其他平台的API不一样
uni-app有两种Canvas API:
- 旧的:
uni.createCanvasContext('canvasId') - 新的:Canvas 2D(type="2d")
鸿蒙现在只支持旧API,所以得这样写:
<!-- #ifdef APP-HARMONY -->
<canvas
canvas-id="tacticsCanvas"
id="tacticsCanvas"
class="canvas-board"
@touchstart="handleCanvasTouchStart"
@touchmove="handleCanvasTouchMove"
@touchend="handleCanvasTouchEnd"
disable-scroll="true"
></canvas>
<!-- #endif -->
<!-- #ifndef APP-HARMONY -->
<canvas
type="2d"
id="tacticsCanvas"
canvas-id="tacticsCanvas"
class="canvas-board"
@touchstart="handleCanvasTouchStart"
@touchmove.prevent="handleCanvasTouchMove"
@touchend="handleCanvasTouchEnd"
></canvas>
<!-- #endif -->
注意几个细节:
- 鸿蒙不用写
type="2d" - 鸿蒙用
disable-scroll="true",其他平台用@touchmove.prevent - 两个平台都得有
canvas-id和id
初始化时机很关键
不能在onMounted里直接初始化,得等页面真正渲染完了再搞。我试了好几次,最后发现延迟个200ms比较靠谱:
onMounted(() => {
// #ifdef APP-HARMONY
setTimeout(() => {
const systemInfo = uni.getSystemInfoSync();
canvasWidth.value = systemInfo.windowWidth;
canvasHeight.value = systemInfo.windowWidth * 1.25;
ctx.value = uni.createCanvasContext("tacticsCanvas");
ctx.value.isCanvas2d = false; // 标记是旧API
// 再延迟获取真实边界
setTimeout(() => {
const query = uni.createSelectorQuery();
query.select(".canvas-board").boundingClientRect((rect) => {
if (rect) {
boardRect.value = rect;
canvasReady.value = true; // 就绪标记
}
}).exec();
}, 100);
}, 200);
// #endif
});
为啥要搞个isCanvas2d标记?因为后面很多API调用方式不一样,得区分开。
触摸绘制 - 坐标转换是重点
坐标系问题
这个坑我踩了好久。触摸事件返回的坐标,在不同平台上格式不一样:
- 鸿蒙:
touch.x/touch.y(可能是相对坐标) - 其他平台:
touch.clientX/touch.clientY(需要减去canvas的偏移)
我写了个通用函数处理:
const getCanvasCoords = (touch) => {
let x, y;
// #ifdef APP-HARMONY
if (touch.x !== undefined && touch.y !== undefined) {
const rawX = touch.x;
const rawY = touch.y;
// 如果在合理范围内,直接用
if (rawX >= 0 && rawX <= canvasWidth.value &&
rawY >= 0 && rawY <= canvasHeight.value) {
x = rawX;
y = rawY;
}
// 超出范围就减去偏移
else if (boardRect.value && rawX > canvasWidth.value) {
x = rawX - boardRect.value.left;
y = rawY - boardRect.value.top;
}
else {
x = rawX;
y = rawY;
}
}
// #endif
// #ifndef APP-HARMONY
if (touch.clientX !== undefined && touch.clientY !== undefined && boardRect.value) {
x = touch.clientX - boardRect.value.left;
y = touch.clientY - boardRect.value.top;
}
// #endif
// 限制在画布范围内
x = Math.max(0, Math.min(x, canvasWidth.value));
y = Math.max(0, Math.min(y, canvasHeight.value));
return { x, y };
};
绘制流程
整个绘制流程是这样的:
-
touchstart:记录起点,初始化当前绘制对象
const handleCanvasTouchStart = (e) => { if (!canvasReady.value) { uni.showToast({ title: "画布初始化中,请稍候", icon: "none" }); return; } const touch = e.touches[0]; const coords = getCanvasCoords(touch); isDrawing.value = true; currentDrawing.value = { type: currentTool.value, // arrow/line/curve startX: coords.x, startY: coords.y, endX: coords.x, endY: coords.y, points: [{ x: coords.x, y: coords.y }], // 曲线用 color: currentTool.value === 'arrow' ? '#ff6b35' : '#1a3b6e' }; }; -
touchmove:更新终点或添加曲线点
const handleCanvasTouchMove = (e) => { if (!isDrawing.value || !currentDrawing.value) return; const touch = e.touches[0]; const coords = getCanvasCoords(touch); if (currentTool.value === 'curve') { // 曲线:不断添加点 currentDrawing.value.points.push({ x: coords.x, y: coords.y }); } else { // 箭头/直线:更新终点 currentDrawing.value.endX = coords.x; currentDrawing.value.endY = coords.y; } redrawCanvas(); // 实时重绘 }; -
touchend:保存到数组
const handleCanvasTouchEnd = () => { if (isDrawing.value && currentDrawing.value) { drawings.value.push({ ...currentDrawing.value }); isDrawing.value = false; currentDrawing.value = null; redrawCanvas(); } };
绘制各种图形
兼容两种API的方法
旧API和新API的方法名不一样,得封装一下:
const drawShape = (shape, context = null) => {
const drawCtx = context || ctx.value;
if (!drawCtx) return;
// 兼容函数
const setStrokeStyle = (color) => {
if (drawCtx.isCanvas2d) {
drawCtx.strokeStyle = color;
} else {
drawCtx.setStrokeStyle(color); // 旧API
}
};
const setLineWidth = (width) => {
if (drawCtx.isCanvas2d) {
drawCtx.lineWidth = width;
} else {
drawCtx.setLineWidth(width);
}
};
const setLineCap = (cap) => {
if (drawCtx.isCanvas2d) {
drawCtx.lineCap = cap;
} else {
drawCtx.setLineCap(cap);
}
};
// ... 其他方法类似
};
绘制箭头
箭头是最常用的,分两部分:线段 + 箭头头部
if (shape.type === 'arrow') {
const endX = shape.endX || shape.startX;
const endY = shape.endY || shape.startY;
// 1. 画线
drawCtx.beginPath();
drawCtx.moveTo(shape.startX, shape.startY);
drawCtx.lineTo(endX, endY);
drawCtx.stroke();
// 2. 画箭头头部(两条边)
const angle = Math.atan2(endY - shape.startY, endX - shape.startX);
const arrowLength = 25;
setLineWidth(5);
drawCtx.beginPath();
drawCtx.moveTo(endX, endY);
// 左边
drawCtx.lineTo(
endX - arrowLength * Math.cos(angle - Math.PI / 6),
endY - arrowLength * Math.sin(angle - Math.PI / 6)
);
drawCtx.moveTo(endX, endY);
// 右边
drawCtx.lineTo(
endX - arrowLength * Math.cos(angle + Math.PI / 6),
endY - arrowLength * Math.sin(angle + Math.PI / 6)
);
drawCtx.stroke();
}
箭头的原理:
- 先算出线的角度:
Math.atan2(dy, dx) - 箭头两边各偏离30度(Math.PI / 6)
- 用三角函数算出两条边的终点坐标
绘制曲线(虚线)
曲线用来表示跑位,所以用虚线:
if (shape.type === 'curve' && shape.points) {
setLineDash([10, 5]); // 虚线:10px实线,5px空白
drawCtx.beginPath();
drawCtx.moveTo(shape.points[0].x, shape.points[0].y);
for (let i = 1; i < shape.points.length; i++) {
drawCtx.lineTo(shape.points[i].x, shape.points[i].y);
}
drawCtx.stroke();
setLineDash([]); // 恢复实线
}
橡皮擦功能 - 点到线段距离算法
橡皮擦要判断点击的位置是不是靠近某条线,这个用到点到线段距离公式:
const pointToLineDistance = (px, py, x1, y1, x2, y2) => {
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) param = dot / lenSq;
let xx, yy;
if (param < 0) {
// 点在线段外侧,靠近起点
xx = x1;
yy = y1;
} else if (param > 1) {
// 点在线段外侧,靠近终点
xx = x2;
yy = y2;
} else {
// 点在线段范围内
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = px - xx;
const dy = py - yy;
return Math.sqrt(dx * dx + dy * dy);
};
这个算法的核心思路:
- 把点投影到直线上
- 判断投影点是不是在线段范围内
- 算出点到投影点的距离
然后在touchstart判断:
if (currentTool.value === 'eraser') {
const clickX = coords.x;
const clickY = coords.y;
const eraseRadius = 30; // 橡皮擦范围
for (let i = drawings.value.length - 1; i >= 0; i--) {
const drawing = drawings.value[i];
let isNear = false;
if (drawing.type === 'line' || drawing.type === 'arrow') {
const dist = pointToLineDistance(
clickX, clickY,
drawing.startX, drawing.startY,
drawing.endX, drawing.endY
);
isNear = dist < eraseRadius;
} else if (drawing.type === 'curve') {
// 曲线:检查是否靠近任意点
for (let point of drawing.points) {
const dist = Math.sqrt(
Math.pow(clickX - point.x, 2) + Math.pow(clickY - point.y, 2)
);
if (dist < eraseRadius) {
isNear = true;
break;
}
}
}
if (isNear) {
drawings.value.splice(i, 1);
redrawCanvas();
break;
}
}
}
重绘画布 - 性能优化
每次操作都要重绘整个画布,顺序很重要:
const redrawCanvas = () => {
if (!ctx.value) return;
// 1. 清空画布(但不渲染)
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
// 2. 绘制所有已保存的图形
drawings.value.forEach((drawing) => {
drawShape(drawing);
});
// 3. 绘制当前正在画的(实时反馈)
if (currentDrawing.value) {
drawShape(currentDrawing.value);
}
// 4. 旧API必须调用draw()才能渲染到屏幕
if (!ctx.value.isCanvas2d) {
ctx.value.draw(false); // false表示不清空之前的内容
}
};
注意:
- 新API(Canvas 2D):每次绘制都是实时的
- 旧API:必须调用
draw()才会显示 - 鸿蒙用的是旧API,别忘了
draw() draw(false)的false很关键,true会清空画布
导出图片 - 生成分享海报
这个功能挺实用的,步骤是这样的:
- 先截取战术板:把球场、球员、战术线都画到临时canvas上
- 再画海报:在另一个canvas上画标题、战术板图片、底部信息
- 导出为图片:用
uni.canvasToTempFilePath
绘制战术板完整内容
const captureBoard = () => {
return new Promise((resolve, reject) => {
const tempCtx = uni.createCanvasContext("tacticsCanvas");
tempCtx.isCanvas2d = false;
// 1. 画背景(球场)
tempCtx.setFillStyle("#2d8659");
tempCtx.fillRect(0, 0, boardWidth, boardHeight);
// 2. 画场地线(中线、禁区等)
tempCtx.setStrokeStyle("rgba(255, 255, 255, 0.6)");
tempCtx.setLineWidth(2);
tempCtx.beginPath();
tempCtx.moveTo(0, boardHeight * 0.5);
tempCtx.lineTo(boardWidth, boardHeight * 0.5);
tempCtx.stroke();
// 中圈
const centerCircleRadius = (100 / 750) * boardWidth;
tempCtx.beginPath();
tempCtx.arc(boardWidth * 0.5, boardHeight * 0.5, centerCircleRadius, 0, 2 * Math.PI);
tempCtx.stroke();
// ... 更多场地线
// 3. 画战术线路
drawings.value.forEach((drawing) => {
drawShape(drawing, tempCtx);
});
// 4. 画球员
playersOnField.value.forEach((player) => {
const markerRadius = (60 / 2) * (screenWidth / 750);
const centerX = player.x + markerRadius;
const centerY = player.y + markerRadius;
// 圆圈
tempCtx.setFillStyle(player.team === 'home' ? '#1a3b6e' : '#ff6b35');
tempCtx.beginPath();
tempCtx.arc(centerX, centerY, markerRadius, 0, 2 * Math.PI);
tempCtx.fill();
// 号码
tempCtx.setFillStyle('#ffffff');
tempCtx.setFontSize(markerRadius * 0.8);
tempCtx.setTextAlign('center');
tempCtx.setTextBaseline('middle');
tempCtx.fillText(player.number, centerX, centerY);
});
// 5. 导出
tempCtx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: "tacticsCanvas",
success: (res) => {
resolve(res.tempFilePath);
},
fail: reject
});
}, 500); // 等渲染完成
});
});
};
绘制海报
const drawSharePoster = (boardImagePath) => {
return new Promise((resolve, reject) => {
const pWidth = 375;
const pHeight = 550;
const pCtx = uni.createCanvasContext("sharePosterCanvas");
// 1. 背景渐变
const gradient = pCtx.createLinearGradient(0, 0, 0, pHeight);
gradient.addColorStop(0, "#1a3b6e");
gradient.addColorStop(1, "#2c5282");
pCtx.setFillStyle(gradient);
pCtx.fillRect(0, 0, pWidth, pHeight);
// 2. 标题
pCtx.setFillStyle("#ffffff");
pCtx.setFontSize(24);
pCtx.setTextAlign("center");
pCtx.fillText("⚽ 足球战术板", pWidth / 2, 40);
// 3. 副标题
pCtx.setFillStyle("rgba(255, 255, 255, 0.8)");
pCtx.setFontSize(14);
pCtx.fillText("Football Tactics Board", pWidth / 2, 65);
// 4. 战术板图片(重点!)
const margin = 20;
const maxBoardWidth = pWidth - margin * 2;
const boardAspect = boardRect.value.width / boardRect.value.height;
let drawWidth = maxBoardWidth;
let drawHeight = drawWidth / boardAspect;
const boardX = (pWidth - drawWidth) / 2;
const boardY = 90;
// 白色卡片背景
pCtx.setFillStyle("#ffffff");
pCtx.fillRect(boardX - 8, boardY - 8, drawWidth + 16, drawHeight + 16);
// 画战术板图片
pCtx.drawImage(boardImagePath, boardX, boardY, drawWidth, drawHeight);
// 5. 底部信息
const footerY = pHeight - 50;
pCtx.setFillStyle("rgba(255, 255, 255, 0.9)");
pCtx.setFontSize(16);
pCtx.fillText("苏超排名助手", pWidth / 2, footerY);
const now = new Date();
const timeStr = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()}`;
pCtx.setFillStyle("rgba(255, 255, 255, 0.5)");
pCtx.setFontSize(10);
pCtx.fillText(`生成时间: ${timeStr}`, pWidth / 2, footerY + 25);
// 6. 导出高清图(2倍分辨率)
pCtx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: "sharePosterCanvas",
width: pWidth,
height: pHeight,
destWidth: pWidth * 2, // 2倍分辨率
destHeight: pHeight * 2,
fileType: "png",
quality: 1,
success: (res) => resolve(res.tempFilePath),
fail: reject
});
}, 800);
});
});
};
注意destWidth和destHeight,设置成2倍能提高导出图片的清晰度。
踩过的坑总结
1. 初始化时机问题
症状:获取不到canvas尺寸,或者坐标转换不准
原因:页面还没渲染完就初始化了
解决:延迟200ms,再用createSelectorQuery获取真实位置
setTimeout(() => {
const query = uni.createSelectorQuery();
query.select(".canvas-board").boundingClientRect((rect) => {
boardRect.value = rect;
canvasReady.value = true;
}).exec();
}, 200);
2. 坐标转换不准确
症状:画的位置跟手指点的位置对不上
原因:没考虑canvas的偏移量(padding、margin等)
解决:必须用boundingClientRect获取真实位置,然后减去偏移
x = touch.clientX - boardRect.value.left;
y = touch.clientY - boardRect.value.top;
3. 鸿蒙draw()必须调用
症状:画了但是屏幕上看不到
原因:旧API必须调用draw()才会渲染
解决:每次绘制完都调用
if (!ctx.value.isCanvas2d) {
ctx.value.draw(false);
}
4. 导出图片时机问题
症状:导出的图片是空白的或者不完整
原因:draw()是异步的,还没渲染完就导出了
解决:在draw()的回调里,再延迟500ms
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({...});
}, 500);
});
5. 橡皮擦范围不好控制
症状:点了删不掉,或者不小心删了别的线
原因:距离阈值设置不合理
解决:30-40px比较合适,太小不好点,太大容易误删
const eraseRadius = 30; // 橡皮擦范围
if (dist < eraseRadius) {
// 删除
}
6. touchmove和页面滚动冲突
症状:画线的时候页面跟着滚动
原因:touchmove事件冒泡到了页面
解决:加.stop.prevent修饰符
<canvas @touchmove.stop.prevent="handleCanvasTouchMove"></canvas>
但是鸿蒙不支持.prevent,要用disable-scroll="true":
<!-- #ifdef APP-HARMONY -->
<canvas disable-scroll="true"></canvas>
<!-- #endif -->
7. 重绘性能问题
症状:线条多了之后很卡
原因:每次touchmove都要重绘所有线条
解决:
- 限制绘制对象数量(最多50个)
- touchmove节流(每10ms更新一次)
- 复杂背景用离屏canvas缓存
let lastTime = 0;
const handleCanvasTouchMove = (e) => {
const now = Date.now();
if (now - lastTime < 10) return; // 节流
lastTime = now;
// ...
};
8. 图片清晰度问题
症状:导出的图片很模糊
原因:没设置destWidth和destHeight
解决:设置成实际尺寸的2倍
uni.canvasToTempFilePath({
canvasId: "myCanvas",
destWidth: width * 2,
destHeight: height * 2,
fileType: "png",
quality: 1
});
性能优化建议
1. 节流touchmove
绘制曲线时,不用每次touchmove都添加点:
let lastTime = 0;
const handleCanvasTouchMove = (e) => {
const now = Date.now();
if (now - lastTime < 16) return; // 约60fps
lastTime = now;
const coords = getCanvasCoords(e.touches[0]);
currentDrawing.value.points.push(coords);
redrawCanvas();
};
2. 限制绘制数量
太多绘制对象会导致重绘变慢:
const MAX_DRAWINGS = 50;
const handleCanvasTouchEnd = () => {
if (isDrawing.value && currentDrawing.value) {
drawings.value.push({ ...currentDrawing.value });
// 限制数量
if (drawings.value.length > MAX_DRAWINGS) {
drawings.value.shift(); // 删除最早的
}
redrawCanvas();
}
};
3. 离屏canvas缓存背景
球场背景每次都画很费性能,可以缓存起来:
let backgroundCache = null;
const cacheBackground = () => {
const offscreenCanvas = uni.createOffscreenCanvas({
type: '2d',
width: canvasWidth.value,
height: canvasHeight.value
});
const offCtx = offscreenCanvas.getContext('2d');
// 画球场背景
offCtx.fillStyle = '#2d8659';
offCtx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
// ... 画其他场地线
backgroundCache = offscreenCanvas;
};
const redrawCanvas = () => {
if (!ctx.value) return;
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
// 用缓存的背景
if (backgroundCache) {
ctx.value.drawImage(backgroundCache, 0, 0);
}
// 画战术线
drawings.value.forEach(drawing => drawShape(drawing));
if (!ctx.value.isCanvas2d) {
ctx.value.draw(false);
}
};
4. 按需重绘
如果只是改变某个元素,可以不重绘整个画布(但Canvas API不支持局部重绘,这个比较难实现)。
替代方案:把不同层分开
- 背景层:静态的球场(很少变化)
- 战术层:箭头、线条(经常变化)
- 球员层:用普通DOM而不是Canvas
<!-- 背景canvas -->
<canvas id="bgCanvas" class="bg-layer"></canvas>
<!-- 战术canvas -->
<canvas id="tacticsCanvas" class="tactics-layer"></canvas>
<!-- 球员用普通DOM -->
<view class="players-layer">
<view v-for="player in players" :key="player.id"
:style="{left: player.x, top: player.y}">
{{ player.number }}
</view>
</view>
这样球员拖动就不需要重绘canvas了。
实用技巧
1. 调试坐标
在开发时可以实时显示坐标,方便调试:
const handleCanvasTouchMove = (e) => {
const coords = getCanvasCoords(e.touches[0]);
console.log(`x: ${coords.x}, y: ${coords.y}`);
// 或者显示在页面上
debugInfo.value = `x: ${coords.x}, y: ${coords.y}`;
};
2. 撤销功能
保存历史记录数组:
const history = ref([]);
const historyIndex = ref(-1);
const saveHistory = () => {
// 删除当前索引之后的历史
history.value = history.value.slice(0, historyIndex.value + 1);
// 添加新状态
history.value.push(JSON.parse(JSON.stringify(drawings.value)));
historyIndex.value++;
// 限制历史记录数量
if (history.value.length > 20) {
history.value.shift();
historyIndex.value--;
}
};
const undo = () => {
if (historyIndex.value > 0) {
historyIndex.value--;
drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));
redrawCanvas();
}
};
const redo = () => {
if (historyIndex.value < history.value.length - 1) {
historyIndex.value++;
drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));
redrawCanvas();
}
};
3. 颜色选择器
让用户自定义线条颜色:
const colors = ['#ff6b35', '#1a3b6e', '#4caf50', '#f44336', '#9e9e9e'];
const currentColor = ref('#ff6b35');
const handleCanvasTouchStart = (e) => {
// ...
currentDrawing.value = {
type: currentTool.value,
startX: coords.x,
startY: coords.y,
color: currentColor.value // 使用选中的颜色
};
};
4. 线条粗细调整
const lineWidths = [2, 4, 6, 8];
const currentWidth = ref(4);
const drawShape = (shape) => {
// ...
const lineWidth = shape.width || 4;
setLineWidth(lineWidth);
// ...
};
最后
整个功能做下来,最大的感受就是:Canvas在uni-app编译到鸿蒙还是有不少坑的。不过搞定之后还挺有成就感的。
开发建议:
- 先在H5上调试,等逻辑都对了再适配鸿蒙
- 鸿蒙的真机调试很慢,多用
console.log和uni.showToast - 坐标问题一定要用实际设备测试,模拟器不准
- 多保存代码,Canvas相关的代码很容易改崩
适用场景:
- 战术板(足球、篮球等)
- 签名功能
- 涂鸦板
- 路径规划
- 图片标注
代码在附件,有问题可以留言交流。希望能帮到要做类似功能的兄弟们!
前言
最近开发了一款【苏超排名助手】,里面有一个功能页:足球战术板,主要就是让用户能在球场上画箭头、线条、曲线啥的,方便教练布置战术。本来以为挺简单的,结果各种坑,尤其是要兼容鸿蒙,差点没给我整崩溃。不过最后还是搞定了,记录一下,给后面的兄弟们少踩点坑。
需求是啥
说白了就是这几个功能:
- 在球场上画箭头(传球路线)
- 画直线和曲线(跑位路线)
- 橡皮擦,画错了能擦掉
- 能保存成图片分享
- 还得支持鸿蒙(这个最坑)
Canvas初始化 - 第一个大坑
鸿蒙和其他平台的API不一样
uni-app有两种Canvas API:
- 旧的:
uni.createCanvasContext('canvasId') - 新的:Canvas 2D(type="2d")
鸿蒙现在只支持旧API,所以得这样写:
<!-- #ifdef APP-HARMONY -->
<canvas
canvas-id="tacticsCanvas"
id="tacticsCanvas"
class="canvas-board"
@touchstart="handleCanvasTouchStart"
@touchmove="handleCanvasTouchMove"
@touchend="handleCanvasTouchEnd"
disable-scroll="true"
></canvas>
<!-- #endif -->
<!-- #ifndef APP-HARMONY -->
<canvas
type="2d"
id="tacticsCanvas"
canvas-id="tacticsCanvas"
class="canvas-board"
@touchstart="handleCanvasTouchStart"
@touchmove.prevent="handleCanvasTouchMove"
@touchend="handleCanvasTouchEnd"
></canvas>
<!-- #endif -->
注意几个细节:
- 鸿蒙不用写
type="2d" - 鸿蒙用
disable-scroll="true",其他平台用@touchmove.prevent - 两个平台都得有
canvas-id和id
初始化时机很关键
不能在onMounted里直接初始化,得等页面真正渲染完了再搞。我试了好几次,最后发现延迟个200ms比较靠谱:
onMounted(() => {
// #ifdef APP-HARMONY
setTimeout(() => {
const systemInfo = uni.getSystemInfoSync();
canvasWidth.value = systemInfo.windowWidth;
canvasHeight.value = systemInfo.windowWidth * 1.25;
ctx.value = uni.createCanvasContext("tacticsCanvas");
ctx.value.isCanvas2d = false; // 标记是旧API
// 再延迟获取真实边界
setTimeout(() => {
const query = uni.createSelectorQuery();
query.select(".canvas-board").boundingClientRect((rect) => {
if (rect) {
boardRect.value = rect;
canvasReady.value = true; // 就绪标记
}
}).exec();
}, 100);
}, 200);
// #endif
});
为啥要搞个isCanvas2d标记?因为后面很多API调用方式不一样,得区分开。
触摸绘制 - 坐标转换是重点
坐标系问题
这个坑我踩了好久。触摸事件返回的坐标,在不同平台上格式不一样:
- 鸿蒙:
touch.x/touch.y(可能是相对坐标) - 其他平台:
touch.clientX/touch.clientY(需要减去canvas的偏移)
我写了个通用函数处理:
const getCanvasCoords = (touch) => {
let x, y;
// #ifdef APP-HARMONY
if (touch.x !== undefined && touch.y !== undefined) {
const rawX = touch.x;
const rawY = touch.y;
// 如果在合理范围内,直接用
if (rawX >= 0 && rawX <= canvasWidth.value &&
rawY >= 0 && rawY <= canvasHeight.value) {
x = rawX;
y = rawY;
}
// 超出范围就减去偏移
else if (boardRect.value && rawX > canvasWidth.value) {
x = rawX - boardRect.value.left;
y = rawY - boardRect.value.top;
}
else {
x = rawX;
y = rawY;
}
}
// #endif
// #ifndef APP-HARMONY
if (touch.clientX !== undefined && touch.clientY !== undefined && boardRect.value) {
x = touch.clientX - boardRect.value.left;
y = touch.clientY - boardRect.value.top;
}
// #endif
// 限制在画布范围内
x = Math.max(0, Math.min(x, canvasWidth.value));
y = Math.max(0, Math.min(y, canvasHeight.value));
return { x, y };
};
绘制流程
整个绘制流程是这样的:
-
touchstart:记录起点,初始化当前绘制对象
const handleCanvasTouchStart = (e) => { if (!canvasReady.value) { uni.showToast({ title: "画布初始化中,请稍候", icon: "none" }); return; } const touch = e.touches[0]; const coords = getCanvasCoords(touch); isDrawing.value = true; currentDrawing.value = { type: currentTool.value, // arrow/line/curve startX: coords.x, startY: coords.y, endX: coords.x, endY: coords.y, points: [{ x: coords.x, y: coords.y }], // 曲线用 color: currentTool.value === 'arrow' ? '#ff6b35' : '#1a3b6e' }; }; -
touchmove:更新终点或添加曲线点
const handleCanvasTouchMove = (e) => { if (!isDrawing.value || !currentDrawing.value) return; const touch = e.touches[0]; const coords = getCanvasCoords(touch); if (currentTool.value === 'curve') { // 曲线:不断添加点 currentDrawing.value.points.push({ x: coords.x, y: coords.y }); } else { // 箭头/直线:更新终点 currentDrawing.value.endX = coords.x; currentDrawing.value.endY = coords.y; } redrawCanvas(); // 实时重绘 }; -
touchend:保存到数组
const handleCanvasTouchEnd = () => { if (isDrawing.value && currentDrawing.value) { drawings.value.push({ ...currentDrawing.value }); isDrawing.value = false; currentDrawing.value = null; redrawCanvas(); } };
绘制各种图形
兼容两种API的方法
旧API和新API的方法名不一样,得封装一下:
const drawShape = (shape, context = null) => {
const drawCtx = context || ctx.value;
if (!drawCtx) return;
// 兼容函数
const setStrokeStyle = (color) => {
if (drawCtx.isCanvas2d) {
drawCtx.strokeStyle = color;
} else {
drawCtx.setStrokeStyle(color); // 旧API
}
};
const setLineWidth = (width) => {
if (drawCtx.isCanvas2d) {
drawCtx.lineWidth = width;
} else {
drawCtx.setLineWidth(width);
}
};
const setLineCap = (cap) => {
if (drawCtx.isCanvas2d) {
drawCtx.lineCap = cap;
} else {
drawCtx.setLineCap(cap);
}
};
// ... 其他方法类似
};
绘制箭头
箭头是最常用的,分两部分:线段 + 箭头头部
if (shape.type === 'arrow') {
const endX = shape.endX || shape.startX;
const endY = shape.endY || shape.startY;
// 1. 画线
drawCtx.beginPath();
drawCtx.moveTo(shape.startX, shape.startY);
drawCtx.lineTo(endX, endY);
drawCtx.stroke();
// 2. 画箭头头部(两条边)
const angle = Math.atan2(endY - shape.startY, endX - shape.startX);
const arrowLength = 25;
setLineWidth(5);
drawCtx.beginPath();
drawCtx.moveTo(endX, endY);
// 左边
drawCtx.lineTo(
endX - arrowLength * Math.cos(angle - Math.PI / 6),
endY - arrowLength * Math.sin(angle - Math.PI / 6)
);
drawCtx.moveTo(endX, endY);
// 右边
drawCtx.lineTo(
endX - arrowLength * Math.cos(angle + Math.PI / 6),
endY - arrowLength * Math.sin(angle + Math.PI / 6)
);
drawCtx.stroke();
}
箭头的原理:
- 先算出线的角度:
Math.atan2(dy, dx) - 箭头两边各偏离30度(Math.PI / 6)
- 用三角函数算出两条边的终点坐标
绘制曲线(虚线)
曲线用来表示跑位,所以用虚线:
if (shape.type === 'curve' && shape.points) {
setLineDash([10, 5]); // 虚线:10px实线,5px空白
drawCtx.beginPath();
drawCtx.moveTo(shape.points[0].x, shape.points[0].y);
for (let i = 1; i < shape.points.length; i++) {
drawCtx.lineTo(shape.points[i].x, shape.points[i].y);
}
drawCtx.stroke();
setLineDash([]); // 恢复实线
}
橡皮擦功能 - 点到线段距离算法
橡皮擦要判断点击的位置是不是靠近某条线,这个用到点到线段距离公式:
const pointToLineDistance = (px, py, x1, y1, x2, y2) => {
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) param = dot / lenSq;
let xx, yy;
if (param < 0) {
// 点在线段外侧,靠近起点
xx = x1;
yy = y1;
} else if (param > 1) {
// 点在线段外侧,靠近终点
xx = x2;
yy = y2;
} else {
// 点在线段范围内
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = px - xx;
const dy = py - yy;
return Math.sqrt(dx * dx + dy * dy);
};
这个算法的核心思路:
- 把点投影到直线上
- 判断投影点是不是在线段范围内
- 算出点到投影点的距离
然后在touchstart判断:
if (currentTool.value === 'eraser') {
const clickX = coords.x;
const clickY = coords.y;
const eraseRadius = 30; // 橡皮擦范围
for (let i = drawings.value.length - 1; i >= 0; i--) {
const drawing = drawings.value[i];
let isNear = false;
if (drawing.type === 'line' || drawing.type === 'arrow') {
const dist = pointToLineDistance(
clickX, clickY,
drawing.startX, drawing.startY,
drawing.endX, drawing.endY
);
isNear = dist < eraseRadius;
} else if (drawing.type === 'curve') {
// 曲线:检查是否靠近任意点
for (let point of drawing.points) {
const dist = Math.sqrt(
Math.pow(clickX - point.x, 2) + Math.pow(clickY - point.y, 2)
);
if (dist < eraseRadius) {
isNear = true;
break;
}
}
}
if (isNear) {
drawings.value.splice(i, 1);
redrawCanvas();
break;
}
}
}
重绘画布 - 性能优化
每次操作都要重绘整个画布,顺序很重要:
const redrawCanvas = () => {
if (!ctx.value) return;
// 1. 清空画布(但不渲染)
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
// 2. 绘制所有已保存的图形
drawings.value.forEach((drawing) => {
drawShape(drawing);
});
// 3. 绘制当前正在画的(实时反馈)
if (currentDrawing.value) {
drawShape(currentDrawing.value);
}
// 4. 旧API必须调用draw()才能渲染到屏幕
if (!ctx.value.isCanvas2d) {
ctx.value.draw(false); // false表示不清空之前的内容
}
};
注意:
- 新API(Canvas 2D):每次绘制都是实时的
- 旧API:必须调用
draw()才会显示 - 鸿蒙用的是旧API,别忘了
draw() draw(false)的false很关键,true会清空画布
导出图片 - 生成分享海报
这个功能挺实用的,步骤是这样的:
- 先截取战术板:把球场、球员、战术线都画到临时canvas上
- 再画海报:在另一个canvas上画标题、战术板图片、底部信息
- 导出为图片:用
uni.canvasToTempFilePath
绘制战术板完整内容
const captureBoard = () => {
return new Promise((resolve, reject) => {
const tempCtx = uni.createCanvasContext("tacticsCanvas");
tempCtx.isCanvas2d = false;
// 1. 画背景(球场)
tempCtx.setFillStyle("#2d8659");
tempCtx.fillRect(0, 0, boardWidth, boardHeight);
// 2. 画场地线(中线、禁区等)
tempCtx.setStrokeStyle("rgba(255, 255, 255, 0.6)");
tempCtx.setLineWidth(2);
tempCtx.beginPath();
tempCtx.moveTo(0, boardHeight * 0.5);
tempCtx.lineTo(boardWidth, boardHeight * 0.5);
tempCtx.stroke();
// 中圈
const centerCircleRadius = (100 / 750) * boardWidth;
tempCtx.beginPath();
tempCtx.arc(boardWidth * 0.5, boardHeight * 0.5, centerCircleRadius, 0, 2 * Math.PI);
tempCtx.stroke();
// ... 更多场地线
// 3. 画战术线路
drawings.value.forEach((drawing) => {
drawShape(drawing, tempCtx);
});
// 4. 画球员
playersOnField.value.forEach((player) => {
const markerRadius = (60 / 2) * (screenWidth / 750);
const centerX = player.x + markerRadius;
const centerY = player.y + markerRadius;
// 圆圈
tempCtx.setFillStyle(player.team === 'home' ? '#1a3b6e' : '#ff6b35');
tempCtx.beginPath();
tempCtx.arc(centerX, centerY, markerRadius, 0, 2 * Math.PI);
tempCtx.fill();
// 号码
tempCtx.setFillStyle('#ffffff');
tempCtx.setFontSize(markerRadius * 0.8);
tempCtx.setTextAlign('center');
tempCtx.setTextBaseline('middle');
tempCtx.fillText(player.number, centerX, centerY);
});
// 5. 导出
tempCtx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: "tacticsCanvas",
success: (res) => {
resolve(res.tempFilePath);
},
fail: reject
});
}, 500); // 等渲染完成
});
});
};
绘制海报
const drawSharePoster = (boardImagePath) => {
return new Promise((resolve, reject) => {
const pWidth = 375;
const pHeight = 550;
const pCtx = uni.createCanvasContext("sharePosterCanvas");
// 1. 背景渐变
const gradient = pCtx.createLinearGradient(0, 0, 0, pHeight);
gradient.addColorStop(0, "#1a3b6e");
gradient.addColorStop(1, "#2c5282");
pCtx.setFillStyle(gradient);
pCtx.fillRect(0, 0, pWidth, pHeight);
// 2. 标题
pCtx.setFillStyle("#ffffff");
pCtx.setFontSize(24);
pCtx.setTextAlign("center");
pCtx.fillText("⚽ 足球战术板", pWidth / 2, 40);
// 3. 副标题
pCtx.setFillStyle("rgba(255, 255, 255, 0.8)");
pCtx.setFontSize(14);
pCtx.fillText("Football Tactics Board", pWidth / 2, 65);
// 4. 战术板图片(重点!)
const margin = 20;
const maxBoardWidth = pWidth - margin * 2;
const boardAspect = boardRect.value.width / boardRect.value.height;
let drawWidth = maxBoardWidth;
let drawHeight = drawWidth / boardAspect;
const boardX = (pWidth - drawWidth) / 2;
const boardY = 90;
// 白色卡片背景
pCtx.setFillStyle("#ffffff");
pCtx.fillRect(boardX - 8, boardY - 8, drawWidth + 16, drawHeight + 16);
// 画战术板图片
pCtx.drawImage(boardImagePath, boardX, boardY, drawWidth, drawHeight);
// 5. 底部信息
const footerY = pHeight - 50;
pCtx.setFillStyle("rgba(255, 255, 255, 0.9)");
pCtx.setFontSize(16);
pCtx.fillText("苏超排名助手", pWidth / 2, footerY);
const now = new Date();
const timeStr = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()}`;
pCtx.setFillStyle("rgba(255, 255, 255, 0.5)");
pCtx.setFontSize(10);
pCtx.fillText(`生成时间: ${timeStr}`, pWidth / 2, footerY + 25);
// 6. 导出高清图(2倍分辨率)
pCtx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: "sharePosterCanvas",
width: pWidth,
height: pHeight,
destWidth: pWidth * 2, // 2倍分辨率
destHeight: pHeight * 2,
fileType: "png",
quality: 1,
success: (res) => resolve(res.tempFilePath),
fail: reject
});
}, 800);
});
});
};
注意destWidth和destHeight,设置成2倍能提高导出图片的清晰度。
踩过的坑总结
1. 初始化时机问题
症状:获取不到canvas尺寸,或者坐标转换不准
原因:页面还没渲染完就初始化了
解决:延迟200ms,再用createSelectorQuery获取真实位置
setTimeout(() => {
const query = uni.createSelectorQuery();
query.select(".canvas-board").boundingClientRect((rect) => {
boardRect.value = rect;
canvasReady.value = true;
}).exec();
}, 200);
2. 坐标转换不准确
症状:画的位置跟手指点的位置对不上
原因:没考虑canvas的偏移量(padding、margin等)
解决:必须用boundingClientRect获取真实位置,然后减去偏移
x = touch.clientX - boardRect.value.left;
y = touch.clientY - boardRect.value.top;
3. 鸿蒙draw()必须调用
症状:画了但是屏幕上看不到
原因:旧API必须调用draw()才会渲染
解决:每次绘制完都调用
if (!ctx.value.isCanvas2d) {
ctx.value.draw(false);
}
4. 导出图片时机问题
症状:导出的图片是空白的或者不完整
原因:draw()是异步的,还没渲染完就导出了
解决:在draw()的回调里,再延迟500ms
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({...});
}, 500);
});
5. 橡皮擦范围不好控制
症状:点了删不掉,或者不小心删了别的线
原因:距离阈值设置不合理
解决:30-40px比较合适,太小不好点,太大容易误删
const eraseRadius = 30; // 橡皮擦范围
if (dist < eraseRadius) {
// 删除
}
6. touchmove和页面滚动冲突
症状:画线的时候页面跟着滚动
原因:touchmove事件冒泡到了页面
解决:加.stop.prevent修饰符
<canvas @touchmove.stop.prevent="handleCanvasTouchMove"></canvas>
但是鸿蒙不支持.prevent,要用disable-scroll="true":
<!-- #ifdef APP-HARMONY -->
<canvas disable-scroll="true"></canvas>
<!-- #endif -->
7. 重绘性能问题
症状:线条多了之后很卡
原因:每次touchmove都要重绘所有线条
解决:
- 限制绘制对象数量(最多50个)
- touchmove节流(每10ms更新一次)
- 复杂背景用离屏canvas缓存
let lastTime = 0;
const handleCanvasTouchMove = (e) => {
const now = Date.now();
if (now - lastTime < 10) return; // 节流
lastTime = now;
// ...
};
8. 图片清晰度问题
症状:导出的图片很模糊
原因:没设置destWidth和destHeight
解决:设置成实际尺寸的2倍
uni.canvasToTempFilePath({
canvasId: "myCanvas",
destWidth: width * 2,
destHeight: height * 2,
fileType: "png",
quality: 1
});
性能优化建议
1. 节流touchmove
绘制曲线时,不用每次touchmove都添加点:
let lastTime = 0;
const handleCanvasTouchMove = (e) => {
const now = Date.now();
if (now - lastTime < 16) return; // 约60fps
lastTime = now;
const coords = getCanvasCoords(e.touches[0]);
currentDrawing.value.points.push(coords);
redrawCanvas();
};
2. 限制绘制数量
太多绘制对象会导致重绘变慢:
const MAX_DRAWINGS = 50;
const handleCanvasTouchEnd = () => {
if (isDrawing.value && currentDrawing.value) {
drawings.value.push({ ...currentDrawing.value });
// 限制数量
if (drawings.value.length > MAX_DRAWINGS) {
drawings.value.shift(); // 删除最早的
}
redrawCanvas();
}
};
3. 离屏canvas缓存背景
球场背景每次都画很费性能,可以缓存起来:
let backgroundCache = null;
const cacheBackground = () => {
const offscreenCanvas = uni.createOffscreenCanvas({
type: '2d',
width: canvasWidth.value,
height: canvasHeight.value
});
const offCtx = offscreenCanvas.getContext('2d');
// 画球场背景
offCtx.fillStyle = '#2d8659';
offCtx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
// ... 画其他场地线
backgroundCache = offscreenCanvas;
};
const redrawCanvas = () => {
if (!ctx.value) return;
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
// 用缓存的背景
if (backgroundCache) {
ctx.value.drawImage(backgroundCache, 0, 0);
}
// 画战术线
drawings.value.forEach(drawing => drawShape(drawing));
if (!ctx.value.isCanvas2d) {
ctx.value.draw(false);
}
};
4. 按需重绘
如果只是改变某个元素,可以不重绘整个画布(但Canvas API不支持局部重绘,这个比较难实现)。
替代方案:把不同层分开
- 背景层:静态的球场(很少变化)
- 战术层:箭头、线条(经常变化)
- 球员层:用普通DOM而不是Canvas
<!-- 背景canvas -->
<canvas id="bgCanvas" class="bg-layer"></canvas>
<!-- 战术canvas -->
<canvas id="tacticsCanvas" class="tactics-layer"></canvas>
<!-- 球员用普通DOM -->
<view class="players-layer">
<view v-for="player in players" :key="player.id"
:style="{left: player.x, top: player.y}">
{{ player.number }}
</view>
</view>
这样球员拖动就不需要重绘canvas了。
实用技巧
1. 调试坐标
在开发时可以实时显示坐标,方便调试:
const handleCanvasTouchMove = (e) => {
const coords = getCanvasCoords(e.touches[0]);
console.log(`x: ${coords.x}, y: ${coords.y}`);
// 或者显示在页面上
debugInfo.value = `x: ${coords.x}, y: ${coords.y}`;
};
2. 撤销功能
保存历史记录数组:
const history = ref([]);
const historyIndex = ref(-1);
const saveHistory = () => {
// 删除当前索引之后的历史
history.value = history.value.slice(0, historyIndex.value + 1);
// 添加新状态
history.value.push(JSON.parse(JSON.stringify(drawings.value)));
historyIndex.value++;
// 限制历史记录数量
if (history.value.length > 20) {
history.value.shift();
historyIndex.value--;
}
};
const undo = () => {
if (historyIndex.value > 0) {
historyIndex.value--;
drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));
redrawCanvas();
}
};
const redo = () => {
if (historyIndex.value < history.value.length - 1) {
historyIndex.value++;
drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));
redrawCanvas();
}
};
3. 颜色选择器
让用户自定义线条颜色:
const colors = ['#ff6b35', '#1a3b6e', '#4caf50', '#f44336', '#9e9e9e'];
const currentColor = ref('#ff6b35');
const handleCanvasTouchStart = (e) => {
// ...
currentDrawing.value = {
type: currentTool.value,
startX: coords.x,
startY: coords.y,
color: currentColor.value // 使用选中的颜色
};
};
4. 线条粗细调整
const lineWidths = [2, 4, 6, 8];
const currentWidth = ref(4);
const drawShape = (shape) => {
// ...
const lineWidth = shape.width || 4;
setLineWidth(lineWidth);
// ...
};
最后
整个功能做下来,最大的感受就是:Canvas在uni-app编译到鸿蒙还是有不少坑的。不过搞定之后还挺有成就感的。
开发建议:
- 先在H5上调试,等逻辑都对了再适配鸿蒙
- 鸿蒙的真机调试很慢,多用
console.log和uni.showToast - 坐标问题一定要用实际设备测试,模拟器不准
- 多保存代码,Canvas相关的代码很容易改崩
适用场景:
- 战术板(足球、篮球等)
- 签名功能
- 涂鸦板
- 路径规划
- 图片标注
代码在附件,有问题可以留言交流。希望能帮到要做类似功能的兄弟们!
收起阅读 »表情包搜索助手:uni-app 鸿蒙应用开发全流程解析
写在前面
这是一个用uni-app开发的表情包搜索应用,最大的亮点就是能编译运行到华为鸿蒙系统。说白了,就是用一套代码,可以在iOS、Android、小程序、鸿蒙等多个平台上跑起来。
这个教程是关于这个项目是怎么组织的,用了哪些技术,以及如何编译到鸿蒙系统。
一、整体架构概览
1.1 项目是干啥的?
这个APP就是一个表情包搜索工具,用户可以:
- 浏览热门表情包
- 搜索想要的表情
- 查看专辑分类
- 下载表情包保存到相册
很简单,就是这四大功能。
1.2 技术栈选型
咱们用的技术栈如下:
前端框架
- Vue 3 - 这是uni-app采用的最新版Vue框架
- uni-app - DCloud家的跨平台框架,可以把代码编译到各个平台
- Pinia - Vue 3推荐的状态管理工具(相当于Vuex的升级版)
开发语言
- JavaScript/ES6+ - 主要业务逻辑
- UTS (uni TypeScript) - 用来写鸿蒙原生插件的语言
UI组件
- 原生组件 - 直接用uni-app的内置组件
- 自定义组件 - 比如协议弹窗组件
网络请求
- 基于uni.request封装的HTTP请求库
- Promise风格,支持拦截器
二、项目目录结构
先看看整个项目的目录结构,这样心里有个底:
biaoqingbaosousuogit/
├── common/ # 公共模块
│ ├── api/ # 接口管理
│ │ ├── http.js # 封装的HTTP请求库
│ │ └── base.js # 业务接口定义
│ └── util/ # 工具函数
│ ├── datetime.js # 日期时间处理
│ └── util.js # 通用工具方法
│
├── components/ # 组件库
│ └── agreement-popup/ # 用户协议弹窗组件
│
├── pages/ # 页面目录
│ ├── index/ # 首页(热门表情)
│ ├── album/ # 专辑页
│ ├── search/ # 搜索页
│ ├── detail/ # 详情页
│ └── webview/ # H5页面容器
│
├── stores/ # 状态管理(Pinia)
│ └── main.js # 主store
│
├── static/ # 静态资源
│ ├── icon/ # 图标
│ ├── image/ # 图片
│ └── tabbar/ # 底部导航图标
│
├── uni_modules/ # uni插件模块
│ └── ha-downloadToSystemAlbum/ # 鸿蒙下载插件
│ └── utssdk/
│ └── app-harmony/ # 鸿蒙原生实现
│
├── harmony-configs/ # 鸿蒙配置(重点!)
│ ├── build-profile.json5 # 构建配置、签名证书
│ ├── AppScope/ # 应用级配置
│ └── entry/ # 入口模块配置
│
├── App.vue # 应用入口
├── main.js # 主入口文件
├── pages.json # 页面路由配置
├── manifest.json # 应用配置清单
└── env.js # 环境配置
目录功能说明
common/ - 公共模块,存放各种通用的东西
api/- 网络请求相关,包括封装好的请求库和所有API接口util/- 工具函数,比如日期格式化之类的
pages/ - 存放所有的页面,每个文件夹就是一个页面模块
stores/ - Pinia状态管理,用来存放全局状态数据(比如token、版本号等)
harmony-configs/ - 这个很重要!专门为鸿蒙系统准备的配置文件,包含签名证书、权限声明等
uni_modules/ - uni-app的插件系统,这里有个自定义的鸿蒙下载插件
三、核心模块详解
3.1 网络请求封装(common/api/http.js)
这个文件封装了一个通用的HTTP请求库,解决几个问题:
-
统一的请求配置
- baseUrl配置
- 默认请求头
- 超时时间
-
请求拦截
- 自动添加token
- 自动添加版本号
- 添加平台标识
-
响应处理
- 统一的错误处理
- 日志记录
代码结构大概这样:
export default {
config: {
baseUrl: baseUrl,
timeout: 10000,
// ...其他配置
},
request(options) {
// 从store获取token、版本号等信息
const mainStore = useMainStore();
// 组装请求头
options.header = Object.assign({}, options.header, {
Version: mainStore.version,
Authorization: "Bearer " + mainStore.token,
UniPlatform: mainStore.uniPlatform
});
// 返回Promise
return new Promise((resolve, reject) => {
uni.request({
...options,
complete: (response) => {
if (response.statusCode === 200) {
resolve(response.data);
} else {
reject(response.data);
}
}
});
});
},
get(url, data, options) { /* ... */ },
post(url, data, options) { /* ... */ },
// ...其他方法
}
使用起来很方便:
import api from '@/common/api/base.js'
// 发起请求
const res = await api.homeRandom({ page: 1 })
3.2 业务接口管理(common/api/base.js)
把所有的API接口集中管理,方便维护:
const homeAlbum = (data) => {
return http.request({
url: "/addon/face/home/album",
method: "GET",
data: data,
});
};
const homeRandom = (data) => {
return http.request({
url: "/addon/face/home/random",
method: "GET",
data: data,
});
};
// 导出所有接口
export default {
homeAlbum,
homeRandom,
homeSearch,
homeRelate,
homeAlbumList,
homeDownload,
};
这样做的好处:
- 接口集中管理,一目了然
- 修改接口时只需改这一个地方
- 其他地方import进来直接用
3.3 状态管理(stores/main.js)
用Pinia来管理全局状态,代码很简单:
import { defineStore } from 'pinia';
export const useMainStore = defineStore('main', {
state: () => ({
count: 0,
token: '', // 用户token
version: '', // 应用版本
uniPlatform: '', // 运行平台
}),
actions: {
increment() {
this.count++;
},
},
});
使用方式:
import { useMainStore } from '@/stores/main'
const mainStore = useMainStore()
console.log(mainStore.token) // 读取
mainStore.token = 'xxx' // 写入
比Vuex简单多了,不需要写那么多模板代码。
四、页面实现详解
4.1 首页(pages/index/index.vue)
首页是个瀑布流展示热门表情的页面,核心功能:
-
瀑布流加载
- 首次加载热门表情
- 滚动到底部自动加载更多
-
返回顶部
- 滚动超过500px显示返回顶部按钮
-
用户协议
- 首次打开显示用户协议弹窗
关键代码片段:
// 获取热门表情
const getRandomEmojis = async (isLoadMore = false) => {
if (loading.value || !hasMore.value) return;
loading.value = true;
try {
const res = await api.homeRandom({ page: page.value });
if (res.code === 200) {
if (isLoadMore) {
emojis.value = [...emojis.value, ...res.data];
} else {
emojis.value = res.data;
}
hasMore.value = res.data.length > 0;
if (hasMore.value) {
page.value++;
}
}
} catch (e) {
console.error('获取热门表情失败:', e);
} finally {
loading.value = false;
}
};
// 页面触底加载更多
onReachBottom(() => {
getRandomEmojis(true);
});
技巧点:
- 用
loading和hasMore标记来避免重复请求 - 用
isLoadMore参数区分首次加载和追加加载 onReachBottom是uni-app提供的触底钩子
4.2 详情页(pages/detail/detail.vue)
详情页展示单个表情包,可以下载保存,还会推荐相关的表情。
关键功能:
-
接收参数
onLoad((options) => { if (options.item) { emojiData.value = JSON.parse(decodeURIComponent(options.item)) } }) -
下载保存
const saveImage = () => { uni.downloadFile({ url: emojiData.value.imgurl, success: (res) => { if (res.statusCode === 200) { uni.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () => { uni.showToast({ title: '保存成功', icon: 'success' }) } }) } } }) } -
相关推荐
- 调用
api.homeRelate获取相关表情 - 点击推荐项时切换当前表情并刷新推荐列表
- 调用
五、鸿蒙适配核心技术
这部分是重点中的重点!如何让uni-app编译到鸿蒙系统。
5.1 鸿蒙配置目录(harmony-configs/)
这个目录是专门为鸿蒙准备的,uni-app在编译鸿蒙版本时会读取这些配置。
目录结构:
harmony-configs/
├── build-profile.json5 # 构建配置
├── AppScope/
│ ├── app.json5 # 应用信息
│ └── resources/ # 应用资源(图标等)
└── entry/
└── src/main/
└── module.json5 # 模块配置(权限等)
5.2 构建配置(build-profile.json5)
这个文件配置签名证书和SDK版本,非常重要:
{
"app": {
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"storePassword": "你的证书库密码",
"certpath": "你的证书文件路径",
"keyAlias": "密钥别名",
"keyPassword": "密钥密码",
"profile": "配置文件路径",
"signAlg": "SHA256withECDSA",
"storeFile": "证书库文件路径"
}
}
],
"products": [
{
"name": "default",
"signingConfig": "default",
"compatibleSdkVersion": "5.0.1(13)", // SDK版本
"runtimeOS": "HarmonyOS"
}
]
}
}
重点:
signingConfigs- 配置开发和发布证书compatibleSdkVersion- 兼容的鸿蒙SDK版本- 证书文件需要从华为AGC控制台申请
5.3 应用配置(AppScope/app.json5)
配置应用的基本信息:
{
"app": {
"bundleName": "com.letwind.biaoqingbaozhushou", // 包名
"vendor": "letwind",
"versionCode": 101,
"versionName": "1.0.1",
"icon": "$media:app_icon",
"label": "$string:app_name"
}
}
5.4 模块配置(entry/src/main/module.json5)
配置应用权限和能力:
{
"module": {
"name": "entry",
"type": "entry",
"deviceTypes": ["phone"],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET" // 网络权限
}
]
}
}
5.5 manifest.json中的鸿蒙配置
在uni-app的manifest.json里也要配置鸿蒙相关信息:
{
"vueVersion": "3",
"app-harmony": {
"distribute": {
"bundleName": "com.letwind.biaoqingbaozhushou",
"icons": {
"foreground": "static/icon/logo1024.png",
"background": "static/icon/logo1024.png"
}
}
}
}
这里的bundleName要和app.json5里的保持一致!
六、编译运行到鸿蒙
6.1 环境准备
-
安装HBuilderX
- 去DCloud官网下载最新版HBuilderX
- 必须是支持鸿蒙的版本
-
准备证书
- 去华为AGC控制台申请证书
6.2 配置证书
编辑harmony-configs/build-profile.json5:
{
"app": {
"signingConfigs": [
{
"name": "default",
"material": {
"storePassword": "实际的证书库密码",
"certpath": "证书文件绝对路径/cert.cer",
"keyAlias": "实际的密钥别名",
"keyPassword": "实际的密钥密码",
"profile": "配置文件绝对路径/profile.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "证书库文件绝对路径/cert.p12"
}
}
]
}
}
6.3 运行项目
- 在HBuilderX中打开项目
- 点击工具栏的"运行"
- 选择"运行到鸿蒙"
- 选择设备
- 等待编译完成,APP会自动安装到设备上
6.4 打包发布
-
配置发布证书
- 在
signingConfigs中配置release证书 - 发布证书要在AGC控制台单独申请
- 在
-
本地打包
- 点击HBuilderX的"发行"菜单
- 选择"鸿蒙本地打包"
- 生成.app文件
-
上架应用市场
- 登录华为应用市场控制台
- 上传.app文件
- 填写应用信息
- 提交审核
七、常见问题和解决方案
7.1 证书配置错误
问题: 编译时报错"签名失败"
解决:
- 确认证书文件路径正确(建议用绝对路径)
- 检查证书密码是否正确
- 确认证书和profile文件是匹配的
- 检查bundleName是否和证书中的包名一致
7.2 设备识别不了
问题: HBuilderX检测不到鸿蒙设备
解决:
- 确认手机已开启开发者模式和USB调试
- 更换数据线(有些数据线只能充电不能传输数据)
- 重启HBuilderX和手机
- 检查是否安装了华为手机驱动
7.3 页面跳转失败
问题: 在鸿蒙上页面跳转不成功
解决:
- 检查
pages.json中是否已注册该页面 - 检查跳转路径是否正确(不要带.vue后缀)
- 如果是tabBar页面,要用
uni.switchTab而不是uni.navigateTo
八、项目亮点总结
8.1 跨平台能力
一套代码,多端运行:
- iOS APP
- Android APP
- 鸿蒙APP(HarmonyOS NEXT)
- 微信小程序
- H5网页
这就是uni-app的最大价值,开发效率极高。
8.2 鸿蒙适配方案
完整的鸿蒙适配解决方案:
- 标准的配置文件结构
- 签名证书管理
- 系统API调用(相册、下载等)
可以作为其他uni-app项目适配鸿蒙的参考模板。
8.3 工程化实践
规范的项目结构:
- 清晰的目录划分
- API集中管理
- 组件化开发
- 状态统一管理
代码可维护性强,方便团队协作。
写在最后
这个项目虽然功能不算复杂,但麻雀虽小五脏俱全,把uni-app开发鸿蒙应用的整套流程都走通了。
关键点就这几个:
- uni-app框架 - 跨平台的基础
- harmony-configs - 鸿蒙配置的核心
- 证书签名 - 打包发布的关键
只要搞懂这几个点,你也能把自己的uni-app项目跑在鸿蒙上。
如果遇到问题,多看看官方文档,或者加入开发者社群交流。鸿蒙生态还在快速发展,遇到坑是正常的,解决问题的过程也是成长的过程。
加油!🚀
项目开源地址
GitHub: https://github.com/zwpro/uniapptohongmeng
欢迎 Star、Fork 和提交 Issue!
写在前面
这是一个用uni-app开发的表情包搜索应用,最大的亮点就是能编译运行到华为鸿蒙系统。说白了,就是用一套代码,可以在iOS、Android、小程序、鸿蒙等多个平台上跑起来。
这个教程是关于这个项目是怎么组织的,用了哪些技术,以及如何编译到鸿蒙系统。
一、整体架构概览
1.1 项目是干啥的?
这个APP就是一个表情包搜索工具,用户可以:
- 浏览热门表情包
- 搜索想要的表情
- 查看专辑分类
- 下载表情包保存到相册
很简单,就是这四大功能。
1.2 技术栈选型
咱们用的技术栈如下:
前端框架
- Vue 3 - 这是uni-app采用的最新版Vue框架
- uni-app - DCloud家的跨平台框架,可以把代码编译到各个平台
- Pinia - Vue 3推荐的状态管理工具(相当于Vuex的升级版)
开发语言
- JavaScript/ES6+ - 主要业务逻辑
- UTS (uni TypeScript) - 用来写鸿蒙原生插件的语言
UI组件
- 原生组件 - 直接用uni-app的内置组件
- 自定义组件 - 比如协议弹窗组件
网络请求
- 基于uni.request封装的HTTP请求库
- Promise风格,支持拦截器
二、项目目录结构
先看看整个项目的目录结构,这样心里有个底:
biaoqingbaosousuogit/
├── common/ # 公共模块
│ ├── api/ # 接口管理
│ │ ├── http.js # 封装的HTTP请求库
│ │ └── base.js # 业务接口定义
│ └── util/ # 工具函数
│ ├── datetime.js # 日期时间处理
│ └── util.js # 通用工具方法
│
├── components/ # 组件库
│ └── agreement-popup/ # 用户协议弹窗组件
│
├── pages/ # 页面目录
│ ├── index/ # 首页(热门表情)
│ ├── album/ # 专辑页
│ ├── search/ # 搜索页
│ ├── detail/ # 详情页
│ └── webview/ # H5页面容器
│
├── stores/ # 状态管理(Pinia)
│ └── main.js # 主store
│
├── static/ # 静态资源
│ ├── icon/ # 图标
│ ├── image/ # 图片
│ └── tabbar/ # 底部导航图标
│
├── uni_modules/ # uni插件模块
│ └── ha-downloadToSystemAlbum/ # 鸿蒙下载插件
│ └── utssdk/
│ └── app-harmony/ # 鸿蒙原生实现
│
├── harmony-configs/ # 鸿蒙配置(重点!)
│ ├── build-profile.json5 # 构建配置、签名证书
│ ├── AppScope/ # 应用级配置
│ └── entry/ # 入口模块配置
│
├── App.vue # 应用入口
├── main.js # 主入口文件
├── pages.json # 页面路由配置
├── manifest.json # 应用配置清单
└── env.js # 环境配置
目录功能说明
common/ - 公共模块,存放各种通用的东西
api/- 网络请求相关,包括封装好的请求库和所有API接口util/- 工具函数,比如日期格式化之类的
pages/ - 存放所有的页面,每个文件夹就是一个页面模块
stores/ - Pinia状态管理,用来存放全局状态数据(比如token、版本号等)
harmony-configs/ - 这个很重要!专门为鸿蒙系统准备的配置文件,包含签名证书、权限声明等
uni_modules/ - uni-app的插件系统,这里有个自定义的鸿蒙下载插件
三、核心模块详解
3.1 网络请求封装(common/api/http.js)
这个文件封装了一个通用的HTTP请求库,解决几个问题:
-
统一的请求配置
- baseUrl配置
- 默认请求头
- 超时时间
-
请求拦截
- 自动添加token
- 自动添加版本号
- 添加平台标识
-
响应处理
- 统一的错误处理
- 日志记录
代码结构大概这样:
export default {
config: {
baseUrl: baseUrl,
timeout: 10000,
// ...其他配置
},
request(options) {
// 从store获取token、版本号等信息
const mainStore = useMainStore();
// 组装请求头
options.header = Object.assign({}, options.header, {
Version: mainStore.version,
Authorization: "Bearer " + mainStore.token,
UniPlatform: mainStore.uniPlatform
});
// 返回Promise
return new Promise((resolve, reject) => {
uni.request({
...options,
complete: (response) => {
if (response.statusCode === 200) {
resolve(response.data);
} else {
reject(response.data);
}
}
});
});
},
get(url, data, options) { /* ... */ },
post(url, data, options) { /* ... */ },
// ...其他方法
}
使用起来很方便:
import api from '@/common/api/base.js'
// 发起请求
const res = await api.homeRandom({ page: 1 })
3.2 业务接口管理(common/api/base.js)
把所有的API接口集中管理,方便维护:
const homeAlbum = (data) => {
return http.request({
url: "/addon/face/home/album",
method: "GET",
data: data,
});
};
const homeRandom = (data) => {
return http.request({
url: "/addon/face/home/random",
method: "GET",
data: data,
});
};
// 导出所有接口
export default {
homeAlbum,
homeRandom,
homeSearch,
homeRelate,
homeAlbumList,
homeDownload,
};
这样做的好处:
- 接口集中管理,一目了然
- 修改接口时只需改这一个地方
- 其他地方import进来直接用
3.3 状态管理(stores/main.js)
用Pinia来管理全局状态,代码很简单:
import { defineStore } from 'pinia';
export const useMainStore = defineStore('main', {
state: () => ({
count: 0,
token: '', // 用户token
version: '', // 应用版本
uniPlatform: '', // 运行平台
}),
actions: {
increment() {
this.count++;
},
},
});
使用方式:
import { useMainStore } from '@/stores/main'
const mainStore = useMainStore()
console.log(mainStore.token) // 读取
mainStore.token = 'xxx' // 写入
比Vuex简单多了,不需要写那么多模板代码。
四、页面实现详解
4.1 首页(pages/index/index.vue)
首页是个瀑布流展示热门表情的页面,核心功能:
-
瀑布流加载
- 首次加载热门表情
- 滚动到底部自动加载更多
-
返回顶部
- 滚动超过500px显示返回顶部按钮
-
用户协议
- 首次打开显示用户协议弹窗
关键代码片段:
// 获取热门表情
const getRandomEmojis = async (isLoadMore = false) => {
if (loading.value || !hasMore.value) return;
loading.value = true;
try {
const res = await api.homeRandom({ page: page.value });
if (res.code === 200) {
if (isLoadMore) {
emojis.value = [...emojis.value, ...res.data];
} else {
emojis.value = res.data;
}
hasMore.value = res.data.length > 0;
if (hasMore.value) {
page.value++;
}
}
} catch (e) {
console.error('获取热门表情失败:', e);
} finally {
loading.value = false;
}
};
// 页面触底加载更多
onReachBottom(() => {
getRandomEmojis(true);
});
技巧点:
- 用
loading和hasMore标记来避免重复请求 - 用
isLoadMore参数区分首次加载和追加加载 onReachBottom是uni-app提供的触底钩子
4.2 详情页(pages/detail/detail.vue)
详情页展示单个表情包,可以下载保存,还会推荐相关的表情。
关键功能:
-
接收参数
onLoad((options) => { if (options.item) { emojiData.value = JSON.parse(decodeURIComponent(options.item)) } }) -
下载保存
const saveImage = () => { uni.downloadFile({ url: emojiData.value.imgurl, success: (res) => { if (res.statusCode === 200) { uni.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () => { uni.showToast({ title: '保存成功', icon: 'success' }) } }) } } }) } -
相关推荐
- 调用
api.homeRelate获取相关表情 - 点击推荐项时切换当前表情并刷新推荐列表
- 调用
五、鸿蒙适配核心技术
这部分是重点中的重点!如何让uni-app编译到鸿蒙系统。
5.1 鸿蒙配置目录(harmony-configs/)
这个目录是专门为鸿蒙准备的,uni-app在编译鸿蒙版本时会读取这些配置。
目录结构:
harmony-configs/
├── build-profile.json5 # 构建配置
├── AppScope/
│ ├── app.json5 # 应用信息
│ └── resources/ # 应用资源(图标等)
└── entry/
└── src/main/
└── module.json5 # 模块配置(权限等)
5.2 构建配置(build-profile.json5)
这个文件配置签名证书和SDK版本,非常重要:
{
"app": {
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"storePassword": "你的证书库密码",
"certpath": "你的证书文件路径",
"keyAlias": "密钥别名",
"keyPassword": "密钥密码",
"profile": "配置文件路径",
"signAlg": "SHA256withECDSA",
"storeFile": "证书库文件路径"
}
}
],
"products": [
{
"name": "default",
"signingConfig": "default",
"compatibleSdkVersion": "5.0.1(13)", // SDK版本
"runtimeOS": "HarmonyOS"
}
]
}
}
重点:
signingConfigs- 配置开发和发布证书compatibleSdkVersion- 兼容的鸿蒙SDK版本- 证书文件需要从华为AGC控制台申请
5.3 应用配置(AppScope/app.json5)
配置应用的基本信息:
{
"app": {
"bundleName": "com.letwind.biaoqingbaozhushou", // 包名
"vendor": "letwind",
"versionCode": 101,
"versionName": "1.0.1",
"icon": "$media:app_icon",
"label": "$string:app_name"
}
}
5.4 模块配置(entry/src/main/module.json5)
配置应用权限和能力:
{
"module": {
"name": "entry",
"type": "entry",
"deviceTypes": ["phone"],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET" // 网络权限
}
]
}
}
5.5 manifest.json中的鸿蒙配置
在uni-app的manifest.json里也要配置鸿蒙相关信息:
{
"vueVersion": "3",
"app-harmony": {
"distribute": {
"bundleName": "com.letwind.biaoqingbaozhushou",
"icons": {
"foreground": "static/icon/logo1024.png",
"background": "static/icon/logo1024.png"
}
}
}
}
这里的bundleName要和app.json5里的保持一致!
六、编译运行到鸿蒙
6.1 环境准备
-
安装HBuilderX
- 去DCloud官网下载最新版HBuilderX
- 必须是支持鸿蒙的版本
-
准备证书
- 去华为AGC控制台申请证书
6.2 配置证书
编辑harmony-configs/build-profile.json5:
{
"app": {
"signingConfigs": [
{
"name": "default",
"material": {
"storePassword": "实际的证书库密码",
"certpath": "证书文件绝对路径/cert.cer",
"keyAlias": "实际的密钥别名",
"keyPassword": "实际的密钥密码",
"profile": "配置文件绝对路径/profile.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "证书库文件绝对路径/cert.p12"
}
}
]
}
}
6.3 运行项目
- 在HBuilderX中打开项目
- 点击工具栏的"运行"
- 选择"运行到鸿蒙"
- 选择设备
- 等待编译完成,APP会自动安装到设备上
6.4 打包发布
-
配置发布证书
- 在
signingConfigs中配置release证书 - 发布证书要在AGC控制台单独申请
- 在
-
本地打包
- 点击HBuilderX的"发行"菜单
- 选择"鸿蒙本地打包"
- 生成.app文件
-
上架应用市场
- 登录华为应用市场控制台
- 上传.app文件
- 填写应用信息
- 提交审核
七、常见问题和解决方案
7.1 证书配置错误
问题: 编译时报错"签名失败"
解决:
- 确认证书文件路径正确(建议用绝对路径)
- 检查证书密码是否正确
- 确认证书和profile文件是匹配的
- 检查bundleName是否和证书中的包名一致
7.2 设备识别不了
问题: HBuilderX检测不到鸿蒙设备
解决:
- 确认手机已开启开发者模式和USB调试
- 更换数据线(有些数据线只能充电不能传输数据)
- 重启HBuilderX和手机
- 检查是否安装了华为手机驱动
7.3 页面跳转失败
问题: 在鸿蒙上页面跳转不成功
解决:
- 检查
pages.json中是否已注册该页面 - 检查跳转路径是否正确(不要带.vue后缀)
- 如果是tabBar页面,要用
uni.switchTab而不是uni.navigateTo
八、项目亮点总结
8.1 跨平台能力
一套代码,多端运行:
- iOS APP
- Android APP
- 鸿蒙APP(HarmonyOS NEXT)
- 微信小程序
- H5网页
这就是uni-app的最大价值,开发效率极高。
8.2 鸿蒙适配方案
完整的鸿蒙适配解决方案:
- 标准的配置文件结构
- 签名证书管理
- 系统API调用(相册、下载等)
可以作为其他uni-app项目适配鸿蒙的参考模板。
8.3 工程化实践
规范的项目结构:
- 清晰的目录划分
- API集中管理
- 组件化开发
- 状态统一管理
代码可维护性强,方便团队协作。
写在最后
这个项目虽然功能不算复杂,但麻雀虽小五脏俱全,把uni-app开发鸿蒙应用的整套流程都走通了。
关键点就这几个:
- uni-app框架 - 跨平台的基础
- harmony-configs - 鸿蒙配置的核心
- 证书签名 - 打包发布的关键
只要搞懂这几个点,你也能把自己的uni-app项目跑在鸿蒙上。
如果遇到问题,多看看官方文档,或者加入开发者社群交流。鸿蒙生态还在快速发展,遇到坑是正常的,解决问题的过程也是成长的过程。
加油!🚀
项目开源地址
GitHub: https://github.com/zwpro/uniapptohongmeng
欢迎 Star、Fork 和提交 Issue!
收起阅读 »【鸿蒙征文】UniApp(X) 让鸿蒙开发触手可及 —— LimeUI 组件库开发简录
引言:鸿蒙开发,真的有那么难吗?🤔
当鸿蒙系统(HarmonyOS)带着"分布式"、"原生智能"等前沿概念亮相时,许多开发者,尤其是前端开发者,心中难免产生畏惧:学习全新的 ArkTS 语言、掌握 DevEco Studio IDE、理解复杂的系统架构……这些障碍,似乎构筑了一道难以逾越的高墙。
然而,这种复杂的印象可能只是一种误解。如果你已经熟悉 Vue.js,那么惊喜来了——你与鸿蒙应用开发之间的距离,其实只隔了一个 UniApp(X)。本文将结合我开发 LimeUI 组件库的实战经历,向你证明:借助 UniApp(X),鸿蒙应用开发真的可以"有手就行",让前端开发者轻松拥抱鸿蒙生态。
第一章:UniApp(X) —— 鸿蒙开发的高效解决方案 🚀
在着手为鸿蒙生态贡献组件库时,作为一名熟悉UniApp的开发者,我自然选择了UniApp(X)。正是因为它会编译为ArkTS,能保持与原生相同的性能,同时极大地降低了开发门槛:
1. 零门槛的语法亲和性 ✨
对于Vue开发者而言,UniApp(X)的语法几乎是零学习成本的。你可以继续使用熟悉的template-script-style结构,继续运用v-model、v-if等指令。这种无缝衔接的体验,让鸿蒙开发的"陌生感"瞬间烟消云散。
2. 智能的平台差异抹平机制 🔄
UniApp(X) 的核心魅力在于其强大的条件编译系统。在开发 LimeUI 组件库时,我的代码结构通常是这样的:
<template>
<view class="l-button" @click="handleClick">
<text>{{ text }}</text>
</view>
</template>
<script setup lang="uts">
import { ButtonProps } from './type';
const emit = defineEmits(['click'])
const props = withDefaults(defineProps<ButtonProps>(), {
disabled: false,
ghost: false,
loading: false,
shape: 'rectangle',
size: 'medium',
type: 'default',
})
const handleClick = () => {
emit('click')
// #ifdef APP-HARMONY
// 鸿蒙平台特有的逻辑
console.log('Running on HarmonyOS!');
// #endif
// #ifdef MP-WEIXIN
// 微信小程序特有的逻辑
console.log('Running on WeChat!');
// #endif
}
</script>
通过简洁的#ifdef预处理指令,我能够轻松为不同平台(鸿蒙、微信小程序、iOS、Android等)编写差异化代码,同时保持核心业务逻辑的一致性。这种"一次开发,多端部署"的能力,让LimeUI组件库的开发效率得到了质的飞跃。
第二章:LimeUI 开发实战 —— 鸿蒙组件库的"简易模式" 🛠️
空谈理论不如实战演练。下面,我将以 LimeUI 中一个简单按钮组件的开发流程为例,带你体验这份"有手就行"的简单与高效。
步骤 1:环境搭建 —— 极简配置,快速上手 ⚡
环境配置非常简单直观,无需担心复杂的鸿蒙原生开发环境问题。按照官方教程完成几个基本步骤即可快速上手:运行和发行教程
步骤 2:组件开发 —— 用 Vue 的方式写鸿蒙组件 🎨
只需在创建 uni_modules 组件时选择相应的组件类型:
生成的uni_modules 组件目录结构如下:
├─pages
│ └─index
│ └─index.uvue
└─uni_modules
│ └─lime-button
│ │─components
│ │ └─lime-button
│ │ └─lime-button.uvue // 组件实现
│ │ └─type.ts // 类型定义
接下来,让我们开始开发 lime-button 组件:
<!-- lime-button.uvue -->
<template>
<view class="l-button" @click="handleClick">
<text>{{ text }}</text>
</view>
</template>
<script setup lang="uts">
import { ButtonProps } from './type';
const emit = defineEmits(['click'])
const props = withDefaults(defineProps<ButtonProps>(), {
block: false,
disabled: false,
ghost: false,
loading: false,
shape: 'rectangle',
size: 'medium',
type: 'default',
hoverStopPropagation: false,
hoverStartTime: 20,
hoverStayTime: 70,
lang: 'en',
sessionFrom: '',
sendMessageTitle: '',
sendMessagePath: '',
sendMessageImg: '',
appParameter: '',
showMessageCard: false
})
const handleClick = () => {
emit('click')
}
</script>
看到了吗?这完全就是标准的Vue单文件组件!没有任何鸿蒙原生的特定语法。你已掌握的Vue知识,就是开发鸿蒙组件的全部技能储备。这种熟悉感,让开发者能够立即进入高效开发状态。
步骤 3:编译与预览 —— 所见即所得 👀
在HBuilderX中,只需在manifest.json中配置好鸿蒙应用信息,然后点击菜单栏的"运行 > 运行到手机或模拟器 > 运行到鸿蒙"。
接下来,UniApp(X) 编译器会自动将你编写的 .(u)vue 文件,编译转换为标准的鸿蒙原生工程和 ArkTS 代码。你无需关心底层复杂的转换过程,只需静待编译完成,就能在模拟器上看到组件完美运行。
这正是"有手就行"的最佳诠释。你只需用熟悉的语法表达业务逻辑,UniApp(X)则默默处理好所有平台适配的复杂工作。在将整个UI组件库适配到鸿蒙平台的过程中,我几乎没有遇到实质性的技术障碍,这也是UniApp(X)最大的魅力所在。
第三章:进阶挑战与解决方案 —— 当需要调用原生能力时 💪
当然,"有手就行"并不意味着毫无挑战。在开发LimeUI组件库的过程中,我也遇到过需要调用鸿蒙平台特有能力的场景。
挑战场景:我需要开发一个功能强大的lime-svg组件,它不仅要支持颜色修改,还要兼容多种加载方式(路径、base64、XML)。🎨
解决方案:UniApp(X)贴心地提供了native-view机制,让我们能够轻松调用鸿蒙原生能力。只需在创建uni_modules组件时选择"创建uts插件-标准组件":🔌
生成的uni_modules 组件目录结构如下:
├─pages
│ └─index
│ └─index.uvue
└─uni_modules
│ └─lime-svg
│ │─components
│ │ └─lime-svg
│ │ └─lime-svg.uvue // 组件调用层
│ └─utssdk
│ └─app-harmony
│ └─builder.ets // 原生组件实现
│ └─index.uts // 桥接类导出
在组件调用层 lime-svg.uvue 中,我们这样编写:
<template>
<native-view class="l-svg" v-bind="$attrs" @init="onviewinit"></native-view>
</template>
<script setup lang="uts">
import { SvpProps } from './type'
import { NativeSvg } from "@/uni_modules/lime-svg"; // 导入桥接类
let nativeSvg : NativeSvg | null = null
const props = withDefaults(defineProps<SvpProps>(), {
src: '',
color: ''
})
const onviewinit = (e : UniNativeViewInitEvent) => {
nativeSvg = new NativeSvg(e.detail.element); // 传入native-view元素
nativeSvg?.updateSrc(props.src) // 调用实例方法更新资源
nativeSvg?.updateColor(props.color) // 调用实例方法更新颜色
}
</script>
在桥接类 index.uts 中,我们实现与原生能力的对接:
import { BuilderNode } from "@kit.ArkUI"
import buffer from '@ohos.buffer';
import { fileIo } from '@kit.CoreFileKit';
// 导入混编实现的声明式UI构建函数
import { buildSvg } from "./builder.ets"
import { getEnv } from '@dcloudio/uni-runtime';
export class NativeSvg {
private $element : UniNativeViewElement;
private builder : BuilderNode<[NativeSvgOptions]> | null = null
private svgMap : Map<string, string> = new Map<string, string>()
// 初始化 buildSvg 默认参数
private params : NativeSvgOptions = {
src: '',
onError: (message) => {
this.$element.dispatchEvent(new UniNativeViewEvent("error", { message }))
},
onComplete: (event : ESObject) => {
this.$element.dispatchEvent(new UniNativeViewEvent("load", {
width: event.width,
height: event.height
}))
},
}
constructor(element : UniNativeViewElement) {
// 绑定 wrapBuilder 函数
this.builder = element.bindHarmonyWrappedBuilder(wrapBuilder<[NativeSvgOptions]>(buildSvg), this.params)
this.$element = element
// 绑定当前实例为自定义的controller,方便其他地方通过 element 获取使用
this.$element.bindHarmonyController(this)
}
updateSrc(src : string) {
if (src.startsWith('data:image') || src.startsWith('<svg')) {
if (this.svgMap.has(src)) {
this.params.src = this.svgMap.get(src)!
} else {
// 处理临时文件路径
const tempFileName = `${Date.now()}.svg`
const tempDirPath = `${getEnv().TEMP_PATH}/svg`
const tempFilePath : string = `${tempDirPath}/${tempFileName}`
// 确保目录存在
if (!fileIo.accessSync(tempDirPath)) {
fileIo.mkdirSync(tempDirPath, true)
}
// 创建并写入文件
const file = fileIo.openSync(tempFilePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
// 根据不同格式保存SVG内容
if (src.startsWith('<svg')) {
fileIo.writeSync(file.fd, src); // 直接写入XML文本
}
// 获取资源文件的原生路径
const path = UTSHarmony.getResourcePath(tempFilePath)
this.svgMap.set(src, path) // 缓存已处理的资源
this.params.src = path
}
}
else {
// 处理普通资源路径
this.params.src = UTSHarmony.getResourcePath(src)
}
this.builder?.update(this.params) // 更新渲染
}
updateColor(color : string) {
this.params.color = color
this.builder?.update(this.params) // 更新渲染
}
}
看到代码中我导入了一些原生库,有小伙伴可能会好奇这些库是如何知道的。实际上,通过查阅华为开发者文档,搜索"如何创建临时文件"等相关问题,华为的智能小助手就能直接提供相关代码参考。我们可以基于这些参考代码,根据实际需求进行适当修改和调整,轻松实现临时文件创建等功能。
(cv大师就是我)
最后,在原生渲染层 builder.ets 中,我们定义实际的渲染逻辑:
@Builder
export function buildSvg(params: ESObject) {
Image(params.src)
.width('100%')
.height('100%')
.objectFit(ImageFit.Contain)
.fillColor(params.color) // 支持动态修改颜色
.onComplete((event)=>{
params.onComplete(event)
})
.onError((error) =>{
params.onError(error.message)
})
}
通过这种方式,即使是需要调用鸿蒙特定功能,也能通过UniAppX的UTS标准组件机制轻松实现。这种优雅的桥接设计,让我们既能享受Vue开发的便捷,又能在需要原生能力的关键地方获得与原生开发完全一致的能力。而这只是UniApp(X)调用鸿蒙生态能力的一种方式,在下一章中,我们将探索如何通过UTS API灵活调用OpenHarmony三方库中心仓的第三方库和鸿蒙系统自带的原生库,实现更灵活的功能扩展。
第四章:进阶实战 —— 用UTS API调用OpenHarmony三方库中心仓的第三方库 🚀
如果说组件开发是"有手就行",那么直接调用OpenHarmony生态库就是"如虎添翼"。UniApp(X)提供的UTS(Uni TypeScript)能力,不但能调用鸿蒙系统自带的原生能力,还能加载(ohpm,类似npm的包管理平台)的第三方库,让我们可以在页面中轻松访问各种系统API和三方库功能,为LimeUI组件库的功能扩展提供了无限可能。
案例背景:lime-crypto加密库开发 🔐
在开发过程中,我需要一个强大的加密功能库。虽然在传统UniApp中可以使用crypto-js库,但在UniAppX中并不支持。因此,我决定通过UTS机制直接调用发布到OpenHarmony三方库中心仓的@ohos/crypto-js第三方库来实现加密功能。
实现步骤:简单三步走 📋
只需在创建uni_modules组件时选择"创建uts插件-API插件":
生成的uni_modules组件目录结构如下,与lime-svg类似,它也遵循了UniApp(X)的插件规范:
├─pages
│ └─index
│ └─index.uvue
└─uni_modules
│ └─lime-crypto
│ └─utssdk
│ └─app-harmony
│ └─config.json // 原生依赖配置
│ └─index.uts // UTS桥接层实现
第一步:配置原生依赖 📦
在lime-crypto/utssdk/app-harmony/config.json中声明依赖:
{
"dependencies": {
"@ohos/crypto-js": "2.0.4"
}
}
就是这么简单!UniApp(X)会自动处理依赖管理。
第二步:编写 UTS 桥接层 🌉
在lime-crypto/utssdk/app-harmony/index.uts中:
import { CryptoJS } from '@ohos/crypto-js'
export class CryptoImpl {
constructor() {
// 初始化逻辑
}
// 获取编码器
get enc() {
return {
Utf8: CryptoJS.enc.Utf8,
Hex: CryptoJS.enc.Hex,
Base64: CryptoJS.enc.Base64,
// ... 其他编码器
}
}
// 加密算法
get AES() : CryptoJS.CipherHelper {
return CryptoJS.AES
}
get DES(): CryptoJS.CipherHelper {
return CryptoJS.DES
}
// 哈希函数
MD5(message: string) {
return CryptoJS.MD5(message)
}
SHA256(message: string) {
return CryptoJS.SHA256(message)
}
// HMAC 签名
HmacSHA256(message: string, secretKey: string) {
return CryptoJS.HmacSHA256(message, secretKey)
}
}
export function useCrypto() {
return new CryptoImpl()
}
有细心的小伙伴可能会发现,为何我要封装一个CryptoImpl类,直接导出CryptoJS不香吗?实际上,虽然直接导出CryptoJS是可行的,但封装一个专门的实现类可以更好地控制API暴露范围,提供更符合业务需求的接口,并为后续可能的功能扩展和维护提供便利(借口,凑字数而已)。
export function useCrypto() {
return CryptoJS //全网最简单的加密库实现(搬运工的日常)
}
第三步:在Vue组件中使用 🎯
现在,我们可以在普通的 Vue 组件中直接使用这个原生加密库了:
<template>
<view class="demo-container">
<lime-button @click="encryptData">加密测试</lime-button>
<text>{{ encryptedText }}</text>
</view>
</template>
<script setup lang="uts">
import { useCrypto } from '@/uni_modules/lime-crypto'
const crypto = useCrypto()
const encryptedText = ref('')
const encryptData = () => {
// 使用鸿蒙原生加密库进行 AES 加密
const encrypted = crypto.AES.encrypt(
'Hello HarmonyOS',
'secret-key-12345',
{
mode: crypto.mode.CBC,
padding: crypto.pad.Pkcs7
}
)
encryptedText.value = encrypted.toString()
console.log('加密结果:', encryptedText.value)
// 使用 SHA256 哈希
const hash = crypto.SHA256('需要哈希的数据')
console.log('SHA256 结果:', hash.toString())
}
</script>
通过lime-crypto的实现,我们可以看到UniApp(X)的UTS能力真正实现了:"用前端熟悉的语法,调用原生API的便利"。就这?就这么简单!我上我也行了!成为鸿蒙开发大佬,从此走向人生的鼎峰!
开发者既能利用Vue框架的开发便捷性快速构建UI界面,又能在需要原生能力的关键地方直接调用鸿蒙原生API。这种灵活的技术架构,为鸿蒙应用开发提供了高效且强大的解决方案。
不过,当前的开发体验仍有一些可以改进的空间:ArkTS引擎在代码修改后需要重新构建、签名和安装,这增加了开发过程中的等待时间;另外,首次编译所需的时间相对较长。希望未来的版本能够优化这些方面,进一步提升开发效率。
结语:鸿蒙开发,触手可及 🌈
通过LimeUI组件库的开发实践,我深刻体会到:UniApp(X)极大地降低了鸿蒙应用开发的技术门槛。作为开发者,你完全可以利用已掌握的Vue技术栈,以熟悉的开发方式快速进入鸿蒙开发领域。
鸿蒙生态正处于高速发展阶段,对于开发者而言,这是一片充满机遇的蓝海。UniApp(X)作为连接Vue技术栈与鸿蒙生态的桥梁,为开发者提供了一条低门槛、高效率的技术路径。
对于正在观望鸿蒙开发的开发者来说,现在正是借助UniApp(X)进入鸿蒙生态的理想时机。带着你熟悉的技术积累,你会发现鸿蒙开发并非想象中那样困难,而是可以通过现有技能平稳过渡的技术领域。
毕竟,能用熟悉的技术拥抱未来,这本身就是一件超酷的事,不是吗?💻✨
正是借助UniApp(X)的强大生态与开发便利性,我开发的LimeUI组件库中每个组件都作为独立插件上传到UniApp市场,目前已成功开源发布超过100款组件插件,为开发者社区贡献自己的一份力量!🚀
LimeUI组件库还同时支持UniApp和UniAppX双框架,让开发者可以在两个技术栈中无缝使用同一套组件,极大地提升了开发效率和代码复用性!
如果您在使用过程中发现组件库有任何不完善或缺少的组件,请随时在插件市场留言您的需求和建议。每一条反馈都是促使LimeUI不断完善的宝贵动力,我会认真对待并持续优化组件库,为开发者提供更好的使用体验!
如果您觉得我写的内容对您有所帮助,欢迎点赞加关注,一键三连支持!我第一次写文章,如果有不足之处,还请各位轻喷,我会不断学习和进步!
欢迎访问我的插件市场主页:https://ext.dcloud.net.cn/publisher?id=242774。
引言:鸿蒙开发,真的有那么难吗?🤔
当鸿蒙系统(HarmonyOS)带着"分布式"、"原生智能"等前沿概念亮相时,许多开发者,尤其是前端开发者,心中难免产生畏惧:学习全新的 ArkTS 语言、掌握 DevEco Studio IDE、理解复杂的系统架构……这些障碍,似乎构筑了一道难以逾越的高墙。
然而,这种复杂的印象可能只是一种误解。如果你已经熟悉 Vue.js,那么惊喜来了——你与鸿蒙应用开发之间的距离,其实只隔了一个 UniApp(X)。本文将结合我开发 LimeUI 组件库的实战经历,向你证明:借助 UniApp(X),鸿蒙应用开发真的可以"有手就行",让前端开发者轻松拥抱鸿蒙生态。
第一章:UniApp(X) —— 鸿蒙开发的高效解决方案 🚀
在着手为鸿蒙生态贡献组件库时,作为一名熟悉UniApp的开发者,我自然选择了UniApp(X)。正是因为它会编译为ArkTS,能保持与原生相同的性能,同时极大地降低了开发门槛:
1. 零门槛的语法亲和性 ✨
对于Vue开发者而言,UniApp(X)的语法几乎是零学习成本的。你可以继续使用熟悉的template-script-style结构,继续运用v-model、v-if等指令。这种无缝衔接的体验,让鸿蒙开发的"陌生感"瞬间烟消云散。
2. 智能的平台差异抹平机制 🔄
UniApp(X) 的核心魅力在于其强大的条件编译系统。在开发 LimeUI 组件库时,我的代码结构通常是这样的:
<template>
<view class="l-button" @click="handleClick">
<text>{{ text }}</text>
</view>
</template>
<script setup lang="uts">
import { ButtonProps } from './type';
const emit = defineEmits(['click'])
const props = withDefaults(defineProps<ButtonProps>(), {
disabled: false,
ghost: false,
loading: false,
shape: 'rectangle',
size: 'medium',
type: 'default',
})
const handleClick = () => {
emit('click')
// #ifdef APP-HARMONY
// 鸿蒙平台特有的逻辑
console.log('Running on HarmonyOS!');
// #endif
// #ifdef MP-WEIXIN
// 微信小程序特有的逻辑
console.log('Running on WeChat!');
// #endif
}
</script>
通过简洁的#ifdef预处理指令,我能够轻松为不同平台(鸿蒙、微信小程序、iOS、Android等)编写差异化代码,同时保持核心业务逻辑的一致性。这种"一次开发,多端部署"的能力,让LimeUI组件库的开发效率得到了质的飞跃。
第二章:LimeUI 开发实战 —— 鸿蒙组件库的"简易模式" 🛠️
空谈理论不如实战演练。下面,我将以 LimeUI 中一个简单按钮组件的开发流程为例,带你体验这份"有手就行"的简单与高效。
步骤 1:环境搭建 —— 极简配置,快速上手 ⚡
环境配置非常简单直观,无需担心复杂的鸿蒙原生开发环境问题。按照官方教程完成几个基本步骤即可快速上手:运行和发行教程
步骤 2:组件开发 —— 用 Vue 的方式写鸿蒙组件 🎨
只需在创建 uni_modules 组件时选择相应的组件类型:
生成的uni_modules 组件目录结构如下:
├─pages
│ └─index
│ └─index.uvue
└─uni_modules
│ └─lime-button
│ │─components
│ │ └─lime-button
│ │ └─lime-button.uvue // 组件实现
│ │ └─type.ts // 类型定义
接下来,让我们开始开发 lime-button 组件:
<!-- lime-button.uvue -->
<template>
<view class="l-button" @click="handleClick">
<text>{{ text }}</text>
</view>
</template>
<script setup lang="uts">
import { ButtonProps } from './type';
const emit = defineEmits(['click'])
const props = withDefaults(defineProps<ButtonProps>(), {
block: false,
disabled: false,
ghost: false,
loading: false,
shape: 'rectangle',
size: 'medium',
type: 'default',
hoverStopPropagation: false,
hoverStartTime: 20,
hoverStayTime: 70,
lang: 'en',
sessionFrom: '',
sendMessageTitle: '',
sendMessagePath: '',
sendMessageImg: '',
appParameter: '',
showMessageCard: false
})
const handleClick = () => {
emit('click')
}
</script>
看到了吗?这完全就是标准的Vue单文件组件!没有任何鸿蒙原生的特定语法。你已掌握的Vue知识,就是开发鸿蒙组件的全部技能储备。这种熟悉感,让开发者能够立即进入高效开发状态。
步骤 3:编译与预览 —— 所见即所得 👀
在HBuilderX中,只需在manifest.json中配置好鸿蒙应用信息,然后点击菜单栏的"运行 > 运行到手机或模拟器 > 运行到鸿蒙"。
接下来,UniApp(X) 编译器会自动将你编写的 .(u)vue 文件,编译转换为标准的鸿蒙原生工程和 ArkTS 代码。你无需关心底层复杂的转换过程,只需静待编译完成,就能在模拟器上看到组件完美运行。
这正是"有手就行"的最佳诠释。你只需用熟悉的语法表达业务逻辑,UniApp(X)则默默处理好所有平台适配的复杂工作。在将整个UI组件库适配到鸿蒙平台的过程中,我几乎没有遇到实质性的技术障碍,这也是UniApp(X)最大的魅力所在。
第三章:进阶挑战与解决方案 —— 当需要调用原生能力时 💪
当然,"有手就行"并不意味着毫无挑战。在开发LimeUI组件库的过程中,我也遇到过需要调用鸿蒙平台特有能力的场景。
挑战场景:我需要开发一个功能强大的lime-svg组件,它不仅要支持颜色修改,还要兼容多种加载方式(路径、base64、XML)。🎨
解决方案:UniApp(X)贴心地提供了native-view机制,让我们能够轻松调用鸿蒙原生能力。只需在创建uni_modules组件时选择"创建uts插件-标准组件":🔌
生成的uni_modules 组件目录结构如下:
├─pages
│ └─index
│ └─index.uvue
└─uni_modules
│ └─lime-svg
│ │─components
│ │ └─lime-svg
│ │ └─lime-svg.uvue // 组件调用层
│ └─utssdk
│ └─app-harmony
│ └─builder.ets // 原生组件实现
│ └─index.uts // 桥接类导出
在组件调用层 lime-svg.uvue 中,我们这样编写:
<template>
<native-view class="l-svg" v-bind="$attrs" @init="onviewinit"></native-view>
</template>
<script setup lang="uts">
import { SvpProps } from './type'
import { NativeSvg } from "@/uni_modules/lime-svg"; // 导入桥接类
let nativeSvg : NativeSvg | null = null
const props = withDefaults(defineProps<SvpProps>(), {
src: '',
color: ''
})
const onviewinit = (e : UniNativeViewInitEvent) => {
nativeSvg = new NativeSvg(e.detail.element); // 传入native-view元素
nativeSvg?.updateSrc(props.src) // 调用实例方法更新资源
nativeSvg?.updateColor(props.color) // 调用实例方法更新颜色
}
</script>
在桥接类 index.uts 中,我们实现与原生能力的对接:
import { BuilderNode } from "@kit.ArkUI"
import buffer from '@ohos.buffer';
import { fileIo } from '@kit.CoreFileKit';
// 导入混编实现的声明式UI构建函数
import { buildSvg } from "./builder.ets"
import { getEnv } from '@dcloudio/uni-runtime';
export class NativeSvg {
private $element : UniNativeViewElement;
private builder : BuilderNode<[NativeSvgOptions]> | null = null
private svgMap : Map<string, string> = new Map<string, string>()
// 初始化 buildSvg 默认参数
private params : NativeSvgOptions = {
src: '',
onError: (message) => {
this.$element.dispatchEvent(new UniNativeViewEvent("error", { message }))
},
onComplete: (event : ESObject) => {
this.$element.dispatchEvent(new UniNativeViewEvent("load", {
width: event.width,
height: event.height
}))
},
}
constructor(element : UniNativeViewElement) {
// 绑定 wrapBuilder 函数
this.builder = element.bindHarmonyWrappedBuilder(wrapBuilder<[NativeSvgOptions]>(buildSvg), this.params)
this.$element = element
// 绑定当前实例为自定义的controller,方便其他地方通过 element 获取使用
this.$element.bindHarmonyController(this)
}
updateSrc(src : string) {
if (src.startsWith('data:image') || src.startsWith('<svg')) {
if (this.svgMap.has(src)) {
this.params.src = this.svgMap.get(src)!
} else {
// 处理临时文件路径
const tempFileName = `${Date.now()}.svg`
const tempDirPath = `${getEnv().TEMP_PATH}/svg`
const tempFilePath : string = `${tempDirPath}/${tempFileName}`
// 确保目录存在
if (!fileIo.accessSync(tempDirPath)) {
fileIo.mkdirSync(tempDirPath, true)
}
// 创建并写入文件
const file = fileIo.openSync(tempFilePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
// 根据不同格式保存SVG内容
if (src.startsWith('<svg')) {
fileIo.writeSync(file.fd, src); // 直接写入XML文本
}
// 获取资源文件的原生路径
const path = UTSHarmony.getResourcePath(tempFilePath)
this.svgMap.set(src, path) // 缓存已处理的资源
this.params.src = path
}
}
else {
// 处理普通资源路径
this.params.src = UTSHarmony.getResourcePath(src)
}
this.builder?.update(this.params) // 更新渲染
}
updateColor(color : string) {
this.params.color = color
this.builder?.update(this.params) // 更新渲染
}
}
看到代码中我导入了一些原生库,有小伙伴可能会好奇这些库是如何知道的。实际上,通过查阅华为开发者文档,搜索"如何创建临时文件"等相关问题,华为的智能小助手就能直接提供相关代码参考。我们可以基于这些参考代码,根据实际需求进行适当修改和调整,轻松实现临时文件创建等功能。
(cv大师就是我)
最后,在原生渲染层 builder.ets 中,我们定义实际的渲染逻辑:
@Builder
export function buildSvg(params: ESObject) {
Image(params.src)
.width('100%')
.height('100%')
.objectFit(ImageFit.Contain)
.fillColor(params.color) // 支持动态修改颜色
.onComplete((event)=>{
params.onComplete(event)
})
.onError((error) =>{
params.onError(error.message)
})
}
通过这种方式,即使是需要调用鸿蒙特定功能,也能通过UniAppX的UTS标准组件机制轻松实现。这种优雅的桥接设计,让我们既能享受Vue开发的便捷,又能在需要原生能力的关键地方获得与原生开发完全一致的能力。而这只是UniApp(X)调用鸿蒙生态能力的一种方式,在下一章中,我们将探索如何通过UTS API灵活调用OpenHarmony三方库中心仓的第三方库和鸿蒙系统自带的原生库,实现更灵活的功能扩展。
第四章:进阶实战 —— 用UTS API调用OpenHarmony三方库中心仓的第三方库 🚀
如果说组件开发是"有手就行",那么直接调用OpenHarmony生态库就是"如虎添翼"。UniApp(X)提供的UTS(Uni TypeScript)能力,不但能调用鸿蒙系统自带的原生能力,还能加载(ohpm,类似npm的包管理平台)的第三方库,让我们可以在页面中轻松访问各种系统API和三方库功能,为LimeUI组件库的功能扩展提供了无限可能。
案例背景:lime-crypto加密库开发 🔐
在开发过程中,我需要一个强大的加密功能库。虽然在传统UniApp中可以使用crypto-js库,但在UniAppX中并不支持。因此,我决定通过UTS机制直接调用发布到OpenHarmony三方库中心仓的@ohos/crypto-js第三方库来实现加密功能。
实现步骤:简单三步走 📋
只需在创建uni_modules组件时选择"创建uts插件-API插件":
生成的uni_modules组件目录结构如下,与lime-svg类似,它也遵循了UniApp(X)的插件规范:
├─pages
│ └─index
│ └─index.uvue
└─uni_modules
│ └─lime-crypto
│ └─utssdk
│ └─app-harmony
│ └─config.json // 原生依赖配置
│ └─index.uts // UTS桥接层实现
第一步:配置原生依赖 📦
在lime-crypto/utssdk/app-harmony/config.json中声明依赖:
{
"dependencies": {
"@ohos/crypto-js": "2.0.4"
}
}
就是这么简单!UniApp(X)会自动处理依赖管理。
第二步:编写 UTS 桥接层 🌉
在lime-crypto/utssdk/app-harmony/index.uts中:
import { CryptoJS } from '@ohos/crypto-js'
export class CryptoImpl {
constructor() {
// 初始化逻辑
}
// 获取编码器
get enc() {
return {
Utf8: CryptoJS.enc.Utf8,
Hex: CryptoJS.enc.Hex,
Base64: CryptoJS.enc.Base64,
// ... 其他编码器
}
}
// 加密算法
get AES() : CryptoJS.CipherHelper {
return CryptoJS.AES
}
get DES(): CryptoJS.CipherHelper {
return CryptoJS.DES
}
// 哈希函数
MD5(message: string) {
return CryptoJS.MD5(message)
}
SHA256(message: string) {
return CryptoJS.SHA256(message)
}
// HMAC 签名
HmacSHA256(message: string, secretKey: string) {
return CryptoJS.HmacSHA256(message, secretKey)
}
}
export function useCrypto() {
return new CryptoImpl()
}
有细心的小伙伴可能会发现,为何我要封装一个CryptoImpl类,直接导出CryptoJS不香吗?实际上,虽然直接导出CryptoJS是可行的,但封装一个专门的实现类可以更好地控制API暴露范围,提供更符合业务需求的接口,并为后续可能的功能扩展和维护提供便利(借口,凑字数而已)。
export function useCrypto() {
return CryptoJS //全网最简单的加密库实现(搬运工的日常)
}
第三步:在Vue组件中使用 🎯
现在,我们可以在普通的 Vue 组件中直接使用这个原生加密库了:
<template>
<view class="demo-container">
<lime-button @click="encryptData">加密测试</lime-button>
<text>{{ encryptedText }}</text>
</view>
</template>
<script setup lang="uts">
import { useCrypto } from '@/uni_modules/lime-crypto'
const crypto = useCrypto()
const encryptedText = ref('')
const encryptData = () => {
// 使用鸿蒙原生加密库进行 AES 加密
const encrypted = crypto.AES.encrypt(
'Hello HarmonyOS',
'secret-key-12345',
{
mode: crypto.mode.CBC,
padding: crypto.pad.Pkcs7
}
)
encryptedText.value = encrypted.toString()
console.log('加密结果:', encryptedText.value)
// 使用 SHA256 哈希
const hash = crypto.SHA256('需要哈希的数据')
console.log('SHA256 结果:', hash.toString())
}
</script>
通过lime-crypto的实现,我们可以看到UniApp(X)的UTS能力真正实现了:"用前端熟悉的语法,调用原生API的便利"。就这?就这么简单!我上我也行了!成为鸿蒙开发大佬,从此走向人生的鼎峰!
开发者既能利用Vue框架的开发便捷性快速构建UI界面,又能在需要原生能力的关键地方直接调用鸿蒙原生API。这种灵活的技术架构,为鸿蒙应用开发提供了高效且强大的解决方案。
不过,当前的开发体验仍有一些可以改进的空间:ArkTS引擎在代码修改后需要重新构建、签名和安装,这增加了开发过程中的等待时间;另外,首次编译所需的时间相对较长。希望未来的版本能够优化这些方面,进一步提升开发效率。
结语:鸿蒙开发,触手可及 🌈
通过LimeUI组件库的开发实践,我深刻体会到:UniApp(X)极大地降低了鸿蒙应用开发的技术门槛。作为开发者,你完全可以利用已掌握的Vue技术栈,以熟悉的开发方式快速进入鸿蒙开发领域。
鸿蒙生态正处于高速发展阶段,对于开发者而言,这是一片充满机遇的蓝海。UniApp(X)作为连接Vue技术栈与鸿蒙生态的桥梁,为开发者提供了一条低门槛、高效率的技术路径。
对于正在观望鸿蒙开发的开发者来说,现在正是借助UniApp(X)进入鸿蒙生态的理想时机。带着你熟悉的技术积累,你会发现鸿蒙开发并非想象中那样困难,而是可以通过现有技能平稳过渡的技术领域。
毕竟,能用熟悉的技术拥抱未来,这本身就是一件超酷的事,不是吗?💻✨
正是借助UniApp(X)的强大生态与开发便利性,我开发的LimeUI组件库中每个组件都作为独立插件上传到UniApp市场,目前已成功开源发布超过100款组件插件,为开发者社区贡献自己的一份力量!🚀
LimeUI组件库还同时支持UniApp和UniAppX双框架,让开发者可以在两个技术栈中无缝使用同一套组件,极大地提升了开发效率和代码复用性!
如果您在使用过程中发现组件库有任何不完善或缺少的组件,请随时在插件市场留言您的需求和建议。每一条反馈都是促使LimeUI不断完善的宝贵动力,我会认真对待并持续优化组件库,为开发者提供更好的使用体验!
如果您觉得我写的内容对您有所帮助,欢迎点赞加关注,一键三连支持!我第一次写文章,如果有不足之处,还请各位轻喷,我会不断学习和进步!
欢迎访问我的插件市场主页:https://ext.dcloud.net.cn/publisher?id=242774。
收起阅读 »一个人用 uni-app 做鸿蒙日语学习 App 的踩坑之旅
一个人用 uni-app 做鸿蒙日语学习 App 的踩坑之旅
缘起:为什么要做这个应用
说起来有点巧合。我学日语断断续续好几年了,试过很多背单词的 App,总觉得差点意思——要么功能太简单,要么塞满了广告,要么离线就废了。去年听说鸿蒙系统要独立成生态,我就想,要不自己搞一个?正好练练手,也给自己学日语用。
就这样,"日语词账"这个项目在我的业余时间里慢慢成型了。从去年 10 月开始写第一行代码,到今年 3 月在华为应用市场上架,前后花了大概半年时间。期间踩了不少坑,也学到了很多东西。今天就来聊聊这个过程。
技术选型:为什么选 uni-app
一开始我是纠结的。做鸿蒙应用,到底该用什么技术?
原生开发我是不敢想的,一个人的精力有限,iOS、Android、鸿蒙三套代码,想想就头大。Flutter 我看了看,鸿蒙支持还不够成熟,社区资源也少。React Native 我倒是熟悉,但对鸿蒙的适配好像也不太行。
最后选了 uni-app,主要是看中了它的跨平台能力。DCloud 团队对鸿蒙的支持确实做得不错,而且我本身就用 Vue,上手很快。更重要的是,写一套代码就能同时跑在多个平台上,这对个人开发者来说太友好了。
功能规划:想清楚再动手
一开始我就给自己定了个原则:功能不求多,但要实用。作为一个学日语的人,我太清楚学习 App 需要什么了。
首先是词汇库。我从网上找了各种资源,最后整理出了 12000 多条词汇,从 N5 到 N1 都有。这个过程挺耗时的,光是数据清洗和格式统一就花了两周。但这是基础,必须做好。
然后是语法。日语语法说复杂也复杂,说简单也简单。我收集了 500 多条常用语法点,每条都配上例句和解释。这个主要是方便自己查询用,毕竟学着学着总会忘。
发音练习这块,我本来想直接用 TTS(文字转语音)技术的,后来发现效果不太好。尤其是五十音,机器合成的声音总感觉怪怪的。最后我找了 103 个高质量的 MP3 音频文件,每个假名都是真人录的,虽然增加了包体积,但效果确实好很多。
测验系统是后来加的。我自己学的时候发现,光看不练很容易忘,必须得有个测验功能来巩固。做了个简单的选择题系统,可以选不同的题量,随机出题,答完立马知道对错。这个功能现在我自己用得最多。
鸿蒙适配:第一次真正的挑战
说实话,在做这个项目之前,我从来没碰过鸿蒙开发。网上的资料也不算特别多,心里还是有点虚的。不过好在 uni-app 把很多底层的东西都封装好了,上手倒也不算太难。
语音这个大坑
最头疼的是语音功能。日语学习 App 没有发音功能,那基本就是个废品。但问题是,不同平台的语音 API 差别太大了,鸿蒙又是个新系统,很多东西都不确定。
我最开始想用 Web Speech API,结果在鸿蒙上根本不work。然后又试了华为的 HMS AI TTS,需要配置一堆东西,还得联网下载语音包,体验不太好。最后我找到了鸿蒙的系统原生 TTS 接口,这个算是比较靠谱的,但也不是百分百可用。
后来我就想了个办法:五十音这些基础发音,我全部用预置的 MP3 文件;复杂的单词句子,再用 TTS 合成。这样既保证了基础发音的质量,又不会让包体积太大。而且万一 TTS 不可用,至少基础功能还在。
这个方案看起来简单,实际做起来挺麻烦的。要判断当前是什么平台,要检测 TTS 是否可用,要处理各种异常情况。不过最后效果还不错,至少我自己用的时候没出过什么问题。
证书签名的折磨
鸿蒙应用必须签名才能装,这个证书配置真的是折磨人。我第一次打包的时候,证书路径配错了,一直提示签名失败。改了半天才发现是相对路径和绝对路径的问题。
还有一次更离谱,我在华为开发者平台上创建了应用,拿到了证书,结果打包的时候又说 bundleName 不匹配。原来是我在不同地方填的包名不一致,有的是点号分隔,有的是下划线。统一改成 com.******.JapaneseWords 之后才搞定。
建议大家一定要保留好证书文件,而且要注意有效期。我有一次差点因为证书过期了无法更新版本,幸好提前发现了。
性能优化的必要性
12000 多条词汇,一次性全加载进来,手机直接卡死。这个问题我也是吃了亏才学乖的。
后来改成分页加载,每次只显示 30 条,往下滑的时候再加载更多。这样首屏速度快多了,从原来的 3 秒多降到了不到 1 秒。虽然实现起来多了点代码,但用户体验提升还是很明显的。
还有就是数据缓存。词汇数据是不会变的,没必要每次都重新加载。我加了个简单的缓存机制,第一次加载完就存在本地,下次直接读缓存。这个优化也很有效。
上架的酸甜苦辣
等代码写得差不多了,就该上架了。这个过程说快也快,说慢也慢,主要看运气。
首先要在华为开发者平台注册账号,创建应用。这一步倒是不难,就是填表格,上传图标、截图什么的。图标我自己用 Figma 画的,简简单单一个日语五十音的"あ"字,配上渐变色,看起来还挺专业。
截图是个细节活。我特意选了几个最能展示功能的页面,词汇学习、发音练习、测验系统,每个都截得清清楚楚。还专门买了个模拟器,确保截图尺寸标准,没有状态栏杂乱的信息。
然后就是隐私政策。这个是必须的,哪怕你的 App 压根不收集用户数据。我就用 GitHub Pages 搭了个简单的隐私政策页面,说明应用不收集任何个人信息,所有数据都存在本地。链接填上去就行。
提交审核的时候,心里还是有点紧张的。第一次提交,过了两天被打回来了,说是权限说明不够详细。我赶紧补充了权限使用说明,说明白为什么需要网络权限(在线 TTS)、为什么需要麦克风权限(未来可能加语音识别)。第二次提交,又等了三天,终于通过了!
看到应用状态变成"已上架"的那一刻,说实话挺激动的。虽然只是个小小的学习工具,但毕竟是自己从零到一做出来的。
上架后的意外收获
应用上架一周后,下载量就破百了。虽然不多,但对个人开发者来说已经很满足了。更让我惊喜的是,有用户给我留言,说这个 App 很实用,离线能用特别好,界面也挺干净。
还有人问能不能加词组功能,能不能加听力练习。这些反馈让我意识到,原来真的有人在用我做的东西,而且还有改进的空间。这种感觉挺奇妙的。
当然也有bug反馈。有个用户说测验系统偶尔会重复出题,我查了半天才发现是随机算法的问题。还有人说暗色主题的某些文字看不清,我赶紧改了颜色对比度。
这些反馈虽然有时候挺头疼的,但也让应用越来越好。到现在我还在坚持更新,每个月至少修一两个bug,加一点小功能。
一些经验和感悟
做完这个项目,回头看看,确实学到了不少东西。
关于技术选型,我觉得 uni-app 对个人开发者真的很友好。虽然有人说它性能不如原生,但对于工具类、内容类应用来说完全够用。而且一套代码多端运行,这个优势太明显了。我现在这个 App,不仅跑在鸿蒙上,iOS 和 Android 也没问题,微信小程序版本我也在计划中。
关于鸿蒙开发,其实没有想象中那么难。很多人觉得鸿蒙是新系统,资料少,不敢尝试。但实际上,华为的文档还是挺全的,DCloud 社区也很活跃,遇到问题基本都能找到答案。而且鸿蒙生态还在快速发展,现在入局正是好时机。
关于产品设计,我学到最大的一点就是:功能要简单,体验要好。我一开始想加很多功能,听力、阅读、作文,什么都想做。后来发现根本做不过来,而且用户也不一定需要。不如把核心功能做扎实,让用户真正能用起来。
关于性能优化,这个真的不能忽视。我一开始觉得,12000 条数据也不算多吧,结果直接卡死。后来才知道,前端优化是门学问,分页加载、虚拟列表、懒加载,这些都是必须的。现在应用打开速度快了,用户体验也好了很多。
还有一点,就是要舍得删代码。我写了很多功能,后来发现有些根本没人用,反而让应用变复杂了。后来狠心删了一些,应用反而更简洁了。
给想做鸿蒙应用的朋友一些建议
如果你也想做鸿蒙应用,我的建议是:别想太多,直接开始!
技术方面,如果你会 Vue,uni-app 上手很快。不会也没关系,uni-app 官网教程挺详细的,跟着做一遍基本就能上手了。
工具方面,HBuilderX 是必备的,虽然有点笨重,但鸿蒙开发离不开它。真机调试最好准备一台鸿蒙手机,模拟器有时候不太靠谱。
资源方面,多逛逛 DCloud 社区论坛,很多问题都有前人踩过坑。GitHub 上也有不少开源项目可以参考。遇到问题别憋着,该提 issue 提 issue,该发帖发帖。
心态方面,做个人项目不要给自己太大压力。能做成最好,做不成也当学习了。我这个项目断断续续做了半年,中间也有好几次想放弃,最后还是坚持下来了。
未来的计划
这个应用目前还在持续更新中。短期内,我打算加上语音识别功能,让发音练习可以自动评分。再优化一下 UI,现在看起来还是有点朴素。
长期来看,我想做个听力练习模块,再加一个阅读理解功能。如果用户量上来了,可能还会考虑做个 Web 版,让大家在电脑上也能用。
至于商业化,暂时还没想那么多。我更希望这是一个真正对学习者有帮助的工具,而不是一个赚钱的产品。如果哪天用户量真的起来了,再考虑做个会员功能,加一些高级特性也不迟。
写在最后
从去年 10 月到现在,"日语词账"这个项目陪我度过了好多个周末和深夜。有时候改一个bug改到凌晨两点,有时候为了一个功能想破头。但看到应用慢慢成型,看到有人在用,这种成就感真的无法形容。
做独立开发者不容易,但也很有乐趣。如果你也有什么想法,不妨试试看。鸿蒙生态还在发展,机会很多。uni-app 让跨平台开发变得简单,个人开发者也能做出不错的产品。
最重要的是,开始行动。不要等到一切都准备好了才开始,边做边学,边学边改,慢慢就会越来越好。
我的这个应用已经在 GitHub 开源了,代码、数据都在上面。如果你感兴趣,可以去看看。如果有什么问题,也欢迎交流。
一起加油吧!
写于2025年11月,深圳,一个普通的周末下午
一个人用 uni-app 做鸿蒙日语学习 App 的踩坑之旅
缘起:为什么要做这个应用
说起来有点巧合。我学日语断断续续好几年了,试过很多背单词的 App,总觉得差点意思——要么功能太简单,要么塞满了广告,要么离线就废了。去年听说鸿蒙系统要独立成生态,我就想,要不自己搞一个?正好练练手,也给自己学日语用。
就这样,"日语词账"这个项目在我的业余时间里慢慢成型了。从去年 10 月开始写第一行代码,到今年 3 月在华为应用市场上架,前后花了大概半年时间。期间踩了不少坑,也学到了很多东西。今天就来聊聊这个过程。
技术选型:为什么选 uni-app
一开始我是纠结的。做鸿蒙应用,到底该用什么技术?
原生开发我是不敢想的,一个人的精力有限,iOS、Android、鸿蒙三套代码,想想就头大。Flutter 我看了看,鸿蒙支持还不够成熟,社区资源也少。React Native 我倒是熟悉,但对鸿蒙的适配好像也不太行。
最后选了 uni-app,主要是看中了它的跨平台能力。DCloud 团队对鸿蒙的支持确实做得不错,而且我本身就用 Vue,上手很快。更重要的是,写一套代码就能同时跑在多个平台上,这对个人开发者来说太友好了。
功能规划:想清楚再动手
一开始我就给自己定了个原则:功能不求多,但要实用。作为一个学日语的人,我太清楚学习 App 需要什么了。
首先是词汇库。我从网上找了各种资源,最后整理出了 12000 多条词汇,从 N5 到 N1 都有。这个过程挺耗时的,光是数据清洗和格式统一就花了两周。但这是基础,必须做好。
然后是语法。日语语法说复杂也复杂,说简单也简单。我收集了 500 多条常用语法点,每条都配上例句和解释。这个主要是方便自己查询用,毕竟学着学着总会忘。
发音练习这块,我本来想直接用 TTS(文字转语音)技术的,后来发现效果不太好。尤其是五十音,机器合成的声音总感觉怪怪的。最后我找了 103 个高质量的 MP3 音频文件,每个假名都是真人录的,虽然增加了包体积,但效果确实好很多。
测验系统是后来加的。我自己学的时候发现,光看不练很容易忘,必须得有个测验功能来巩固。做了个简单的选择题系统,可以选不同的题量,随机出题,答完立马知道对错。这个功能现在我自己用得最多。
鸿蒙适配:第一次真正的挑战
说实话,在做这个项目之前,我从来没碰过鸿蒙开发。网上的资料也不算特别多,心里还是有点虚的。不过好在 uni-app 把很多底层的东西都封装好了,上手倒也不算太难。
语音这个大坑
最头疼的是语音功能。日语学习 App 没有发音功能,那基本就是个废品。但问题是,不同平台的语音 API 差别太大了,鸿蒙又是个新系统,很多东西都不确定。
我最开始想用 Web Speech API,结果在鸿蒙上根本不work。然后又试了华为的 HMS AI TTS,需要配置一堆东西,还得联网下载语音包,体验不太好。最后我找到了鸿蒙的系统原生 TTS 接口,这个算是比较靠谱的,但也不是百分百可用。
后来我就想了个办法:五十音这些基础发音,我全部用预置的 MP3 文件;复杂的单词句子,再用 TTS 合成。这样既保证了基础发音的质量,又不会让包体积太大。而且万一 TTS 不可用,至少基础功能还在。
这个方案看起来简单,实际做起来挺麻烦的。要判断当前是什么平台,要检测 TTS 是否可用,要处理各种异常情况。不过最后效果还不错,至少我自己用的时候没出过什么问题。
证书签名的折磨
鸿蒙应用必须签名才能装,这个证书配置真的是折磨人。我第一次打包的时候,证书路径配错了,一直提示签名失败。改了半天才发现是相对路径和绝对路径的问题。
还有一次更离谱,我在华为开发者平台上创建了应用,拿到了证书,结果打包的时候又说 bundleName 不匹配。原来是我在不同地方填的包名不一致,有的是点号分隔,有的是下划线。统一改成 com.******.JapaneseWords 之后才搞定。
建议大家一定要保留好证书文件,而且要注意有效期。我有一次差点因为证书过期了无法更新版本,幸好提前发现了。
性能优化的必要性
12000 多条词汇,一次性全加载进来,手机直接卡死。这个问题我也是吃了亏才学乖的。
后来改成分页加载,每次只显示 30 条,往下滑的时候再加载更多。这样首屏速度快多了,从原来的 3 秒多降到了不到 1 秒。虽然实现起来多了点代码,但用户体验提升还是很明显的。
还有就是数据缓存。词汇数据是不会变的,没必要每次都重新加载。我加了个简单的缓存机制,第一次加载完就存在本地,下次直接读缓存。这个优化也很有效。
上架的酸甜苦辣
等代码写得差不多了,就该上架了。这个过程说快也快,说慢也慢,主要看运气。
首先要在华为开发者平台注册账号,创建应用。这一步倒是不难,就是填表格,上传图标、截图什么的。图标我自己用 Figma 画的,简简单单一个日语五十音的"あ"字,配上渐变色,看起来还挺专业。
截图是个细节活。我特意选了几个最能展示功能的页面,词汇学习、发音练习、测验系统,每个都截得清清楚楚。还专门买了个模拟器,确保截图尺寸标准,没有状态栏杂乱的信息。
然后就是隐私政策。这个是必须的,哪怕你的 App 压根不收集用户数据。我就用 GitHub Pages 搭了个简单的隐私政策页面,说明应用不收集任何个人信息,所有数据都存在本地。链接填上去就行。
提交审核的时候,心里还是有点紧张的。第一次提交,过了两天被打回来了,说是权限说明不够详细。我赶紧补充了权限使用说明,说明白为什么需要网络权限(在线 TTS)、为什么需要麦克风权限(未来可能加语音识别)。第二次提交,又等了三天,终于通过了!
看到应用状态变成"已上架"的那一刻,说实话挺激动的。虽然只是个小小的学习工具,但毕竟是自己从零到一做出来的。
上架后的意外收获
应用上架一周后,下载量就破百了。虽然不多,但对个人开发者来说已经很满足了。更让我惊喜的是,有用户给我留言,说这个 App 很实用,离线能用特别好,界面也挺干净。
还有人问能不能加词组功能,能不能加听力练习。这些反馈让我意识到,原来真的有人在用我做的东西,而且还有改进的空间。这种感觉挺奇妙的。
当然也有bug反馈。有个用户说测验系统偶尔会重复出题,我查了半天才发现是随机算法的问题。还有人说暗色主题的某些文字看不清,我赶紧改了颜色对比度。
这些反馈虽然有时候挺头疼的,但也让应用越来越好。到现在我还在坚持更新,每个月至少修一两个bug,加一点小功能。
一些经验和感悟
做完这个项目,回头看看,确实学到了不少东西。
关于技术选型,我觉得 uni-app 对个人开发者真的很友好。虽然有人说它性能不如原生,但对于工具类、内容类应用来说完全够用。而且一套代码多端运行,这个优势太明显了。我现在这个 App,不仅跑在鸿蒙上,iOS 和 Android 也没问题,微信小程序版本我也在计划中。
关于鸿蒙开发,其实没有想象中那么难。很多人觉得鸿蒙是新系统,资料少,不敢尝试。但实际上,华为的文档还是挺全的,DCloud 社区也很活跃,遇到问题基本都能找到答案。而且鸿蒙生态还在快速发展,现在入局正是好时机。
关于产品设计,我学到最大的一点就是:功能要简单,体验要好。我一开始想加很多功能,听力、阅读、作文,什么都想做。后来发现根本做不过来,而且用户也不一定需要。不如把核心功能做扎实,让用户真正能用起来。
关于性能优化,这个真的不能忽视。我一开始觉得,12000 条数据也不算多吧,结果直接卡死。后来才知道,前端优化是门学问,分页加载、虚拟列表、懒加载,这些都是必须的。现在应用打开速度快了,用户体验也好了很多。
还有一点,就是要舍得删代码。我写了很多功能,后来发现有些根本没人用,反而让应用变复杂了。后来狠心删了一些,应用反而更简洁了。
给想做鸿蒙应用的朋友一些建议
如果你也想做鸿蒙应用,我的建议是:别想太多,直接开始!
技术方面,如果你会 Vue,uni-app 上手很快。不会也没关系,uni-app 官网教程挺详细的,跟着做一遍基本就能上手了。
工具方面,HBuilderX 是必备的,虽然有点笨重,但鸿蒙开发离不开它。真机调试最好准备一台鸿蒙手机,模拟器有时候不太靠谱。
资源方面,多逛逛 DCloud 社区论坛,很多问题都有前人踩过坑。GitHub 上也有不少开源项目可以参考。遇到问题别憋着,该提 issue 提 issue,该发帖发帖。
心态方面,做个人项目不要给自己太大压力。能做成最好,做不成也当学习了。我这个项目断断续续做了半年,中间也有好几次想放弃,最后还是坚持下来了。
未来的计划
这个应用目前还在持续更新中。短期内,我打算加上语音识别功能,让发音练习可以自动评分。再优化一下 UI,现在看起来还是有点朴素。
长期来看,我想做个听力练习模块,再加一个阅读理解功能。如果用户量上来了,可能还会考虑做个 Web 版,让大家在电脑上也能用。
至于商业化,暂时还没想那么多。我更希望这是一个真正对学习者有帮助的工具,而不是一个赚钱的产品。如果哪天用户量真的起来了,再考虑做个会员功能,加一些高级特性也不迟。
写在最后
从去年 10 月到现在,"日语词账"这个项目陪我度过了好多个周末和深夜。有时候改一个bug改到凌晨两点,有时候为了一个功能想破头。但看到应用慢慢成型,看到有人在用,这种成就感真的无法形容。
做独立开发者不容易,但也很有乐趣。如果你也有什么想法,不妨试试看。鸿蒙生态还在发展,机会很多。uni-app 让跨平台开发变得简单,个人开发者也能做出不错的产品。
最重要的是,开始行动。不要等到一切都准备好了才开始,边做边学,边学边改,慢慢就会越来越好。
我的这个应用已经在 GitHub 开源了,代码、数据都在上面。如果你感兴趣,可以去看看。如果有什么问题,也欢迎交流。
一起加油吧!
写于2025年11月,深圳,一个普通的周末下午
收起阅读 »【鸿蒙征文】 炸裂!我用uni-app三天让旧应用通杀鸿蒙Next+元服务,华为商店已上架!2W奖励金即将到账。
就在其他开发者还在为鸿蒙适配焦头烂额时,我的多款uni-app旧应用已经悄然上架华为应用商店。更让人惊喜的是,连鸿蒙元服务都一并搞定!
一、缘起:鸿蒙风暴前的抉择
9月,华为宣布鸿蒙NEXT不再兼容安卓,整个互联网圈炸开了锅。我的心情和大家一样复杂——手头有10+款用uni-app开发的应用,做的最成功的应用已经在微信小程序、安卓和iOS稳定运行了8年多,累积获得用户总量50+万,服务厂商接近200个。为了我这50+万的粉丝,我必须继续披荆斩棘,用最快的速度适配鸿蒙Next,任务的紧急程度已经迫在眉睫,哪怕这50万人只有10个人用鸿蒙Next系统,我也要让他们拥有原生鸿蒙的最佳体验。问题来了,难道我真的要全部重写整个应用?噢,我滴神呀!救救我吧。
哈哈,,,转机来得比想象中更快。
作为uni-app的资深老玩家,当然是时刻追踪着uni-app适配鸿蒙的进度,期间也在不断学习原生鸿蒙ArkTS语言,但是作为Vue重度使用者(本人已使用Vue长达8年),ArkTS的语法的确让人感到繁杂,虽然都能看懂,无非还是前端的那些东西,但是真正写起来,还是不够舒畅。于是,我决定赌一把,继续使用uni-app去完成所有鸿蒙Next的适配,结果令人震惊:第一个应用仅仅只用了3天,最短的甚至不到8小时,就完成了从原有应用到鸿蒙NEXT+元服务的全适配!
二、实战全记录:72小时创造奇迹
第一阶段:环境搭建(2小时)
说实话,最开始我是忐忑的。但整个过程出乎意料的顺畅:
开发工具:
- HUAWEI DevEco Studio
- uni-app v3.99+(支持鸿蒙NEXT)
- 现有项目直接导入
安装工具还是比较快的,就是下载工具和手机端模拟器比较费时间,这就占用了1个小时,接下来就是创建一个新应用,先把工具跑起来,模拟器跑起来。走一个空白的Hello Word的空项目。
如果是window系统,需要开启虚拟化Hyper-V 、window虚拟机监控平台的相关配置,如果出错了也会有对应的提示,不得不说DevEco工具还是非常人性化的。赞一个!
第二阶段:证书申请(30分钟)
这是最关键的也是最简单,如果申请过苹果的证书,就会非常得心应手。
4大证书分别是:
- p12:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
- csr:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
- cer:打开AGC平台需要登陆华为账号进行申请。在证书、APPID 和Profile菜单下操作,需要区分开发和生产证书类型。
- p7b:打开AGC平台需要登陆华为账号进行申请。该证书是根据应用来申请的。
申请p12和csr证书截图:
申请cer和p7b证书截图:
在HBX里面的完整配置
证书部分到此结束,还是非常简单滴!主要我这边是从注册账户到注册应用,再到注册证书,所以耗时多一点,如果再添加一个新应用的配置,那就是分分钟钟的事情了。
第三阶段:代码适配(核心8小时)
这里可能是大家最关心的部分——到底要改多少代码?
答案是:少得惊人!
我以一个物流类型的应用为案例进行举例,主要修改点包括:
- 登录模块:用华为账户替换以前的注册,华为账户和业务账户的绑定。
- 权限模块:调用华为的API获取手机号
- 账户注销:所有上架的应用都需要支持用户自主取消关联和账户注销。
- 分享功能:适配华为分享
一、登录模块主要调整的原因是:华为要求统一使用华为账户进行静默登录,那么如果以前已经有账户了,那就需要进行做关联即可。使用华为提供的code通过后台接口获取到对应的UnionID,OpenID,通过这些就可以换到手机号了,手机号的权限需要在后台开通,这个申请比较慢,说个小技巧提交申请后,立马就挂个工单,这样就审核的特别快了。
其实很多路子和微信小程序很相似,只是需要后端的配合,支持调用华为的API才行。
// 修改前:微信小程序端
uni.login({
provider: 'weixin', //使用微信登录
success: function (loginRes) {
console.log(loginRes.authResult);
}
});
// 修改后:支持鸿蒙获取账户 获取后走接口拿手机号
uni.login({
success: function (loginRes) {
console.log(loginRes.authResult);
}
});
二、权限模块的调整主要是为了满足在华为手机上能有拍照权限,坐标权限,这些自行申请,也都是比较简单的。
三、关于账户注销,这个还是要求必须得有的,其实,这个更简单,上架过iOS的都知道,这个是必须的,基本上无需调整,咱们这里其实也就是改成解除和华为账户的关联关系。
这里就不多说了。四、分享等调用微信相关的,都需要根据鸿蒙的环境改成调用鸿蒙的即可。这个就需要大面积的排查代码了。简单是简单就是多,所以费时间。
// 修改前:通用分享
uni.share({
provider: "weixin",
scene: "WXSceneSession",
type: 0,
title: "分享标题",
success: function (res) {
console.log("success:" + JSON.stringify(res));
}
});
// 修改后:鸿蒙分享
uni.share({
provider: "harmony",
type: 0,
title: "分享标题",
success: function (res) {
console.log("鸿蒙分享成功:" + JSON.stringify(res));
}
});
工作量统计:
- 登录模块:2小时
- 权限调试:2小时
- 账户注销:1小时
- 其他权限和证书适配打包APP:3小时
- 总计:8小时
其实总结一句话:代码大改的地方真的很少,很少。
建议弟兄们,赶紧上手吧!
第四阶段:调试上架(8小时)
真机调试让我眼前一亮。鸿蒙设备的流畅度确实出色,应用启动速度比安卓版本就是快,用的华为navo12 真机运行非常流畅。
上架过程同样顺畅:
- 华为开发者账号注册:1小时
- 应用信息填写:1小时
- 打包提交审核:1小时
- 审核通过:一天会审核多次(比苹果快太多了!)
三、意外之喜:元服务的惊喜邂逅
在适配过程中,我发现鸿蒙元服务这个概念很有意思。简单来说,它让用户不用下载完整APP就能使用核心功能。
关键发现:uni-app对元服务的支持几乎是开箱即用!
// 在pages.json中配置元服务页面
{
"pages": [
{
"path": "pages/index/index",
"style": {
"isEntry": true,
"isAtomic": true // 标记为元服务页面
}
}
]
}
我的一个工具类应用,通过元服务实现了:
- 用户无需安装即可使用核心计算功能
- 服务卡片直接展示关键信息
- 转化率提升明显
四、技术对比:uni-app的降维打击
用过uni-app 开发的朋友都知道,他最大的优势就是全终端兼容,安卓+iOS+小程序+鸿蒙,可以说是天下无敌了,一套代码,真的是省心省力,真的是早用早享受。只要你会点前端,懂Vue,就没有任何技术难度。
当然为了让大家更直观地理解,我做了个对比表:
| 特性 | 原生鸿蒙开发 | uni-app鸿蒙适配 |
|---|---|---|
| 学习成本 | 需要学习ArkTS等新技术 | 使用熟悉的vue语法 |
| 代码复用 | 从零开始 | 90%+代码可直接复用 |
| 开发周期 | 2-4周/应用 | 1-3天/应用 |
| 多端支持 | 仅鸿蒙 | 同时支持小程序、iOS、安卓 |
| 维护成本 | 独立代码库 | 统一代码库 |
最让我震撼的是: 旧应用的适配工作远比你担心的要少太多了,只要能跑起来,运行起来,就基本上没啥问题。在这里必须感谢一下uni-app 团队的技术支持,我在适配过程中,有专门的钉钉群提供技术服务,这也是我为何能在短短3天时间就能解决所有兼容的关键。在此一并感谢整个团队!
五、避坑指南:实战中遇到的坑
当然,过程并非完全一帆风顺。记录几个可能帮到大家的坑:
- 图片路径问题:鸿蒙对绝对路径更敏感,建议使用相对路径
- CSS兼容性:部分CSS3特性需要添加鸿蒙前缀
- API异步处理:鸿蒙的API调用更强调异步编程
- 代码运行不生效:如果感觉代码没问题,热更新不生效,记得重启整个应用,甚至重启HBX,重启模拟器,有时缓存问题不得不说很难受。
// 推荐使用async/await处理鸿蒙API
async function initHarmonyFeatures() {
try {
const result = await uni.harmony.someAPI();
// 处理结果
} catch (error) {
console.error('API调用失败:', error);
}
}
六、成果展示:数字说话
截至目前,我的6款应用全部成功上架:
- 🎯 适配成功率:100%(6/6)
- ⚡ 最快适配记录:8小时(旧的成熟应用)
- 🚀 平均适配时间:2天/应用
- 💰 成本节约:相比原生开发,节省约85%成本
- 📈 用户增长:鸿蒙渠道日均新增用户300+
七、给开发者同仁的真诚建议
- 立即行动:鸿蒙生态红利期就在眼前
- 从简单应用开始:先拿一个功能简单的应用试水
- 关注元服务:这是鸿蒙的独特优势,不要错过
- 利用uni-app社区:遇到的问题基本都能找到解决方案
结语:一个人的速度,一个时代的变革
有开发者朋友问我:"现在入场是不是太晚了?"
我的回答是:"当你知道的时候,就是最好的时机。"
uni-app + 鸿蒙的组合,让我在技术变革的浪潮中抓住了先机。 从焦虑观望到全面上架,只用了不到一周时间。这种效率,在过去的移动开发史上是从未有过的。
现在,轮到你了。
在这里顺带解释一下奖励金,华为最近的活动,只要上架成功,有活跃用户,或者参加uni-app的活动,都是可以拿到奖励金的。大家加油!
在得瑟下我们的用户量;
【实战资源分享】
- uni-app 鸿蒙专题:uni-app 鸿蒙适配专题
- 鸿蒙开发者官网:鸿蒙官方技术文档
- 华为ACG 后台:登录
- 华为应用市场:应用商店
欢迎在评论区交流适配经验,我会第一时间回复大家的问题!
就在其他开发者还在为鸿蒙适配焦头烂额时,我的多款uni-app旧应用已经悄然上架华为应用商店。更让人惊喜的是,连鸿蒙元服务都一并搞定!
一、缘起:鸿蒙风暴前的抉择
9月,华为宣布鸿蒙NEXT不再兼容安卓,整个互联网圈炸开了锅。我的心情和大家一样复杂——手头有10+款用uni-app开发的应用,做的最成功的应用已经在微信小程序、安卓和iOS稳定运行了8年多,累积获得用户总量50+万,服务厂商接近200个。为了我这50+万的粉丝,我必须继续披荆斩棘,用最快的速度适配鸿蒙Next,任务的紧急程度已经迫在眉睫,哪怕这50万人只有10个人用鸿蒙Next系统,我也要让他们拥有原生鸿蒙的最佳体验。问题来了,难道我真的要全部重写整个应用?噢,我滴神呀!救救我吧。
哈哈,,,转机来得比想象中更快。
作为uni-app的资深老玩家,当然是时刻追踪着uni-app适配鸿蒙的进度,期间也在不断学习原生鸿蒙ArkTS语言,但是作为Vue重度使用者(本人已使用Vue长达8年),ArkTS的语法的确让人感到繁杂,虽然都能看懂,无非还是前端的那些东西,但是真正写起来,还是不够舒畅。于是,我决定赌一把,继续使用uni-app去完成所有鸿蒙Next的适配,结果令人震惊:第一个应用仅仅只用了3天,最短的甚至不到8小时,就完成了从原有应用到鸿蒙NEXT+元服务的全适配!
二、实战全记录:72小时创造奇迹
第一阶段:环境搭建(2小时)
说实话,最开始我是忐忑的。但整个过程出乎意料的顺畅:
开发工具:
- HUAWEI DevEco Studio
- uni-app v3.99+(支持鸿蒙NEXT)
- 现有项目直接导入
安装工具还是比较快的,就是下载工具和手机端模拟器比较费时间,这就占用了1个小时,接下来就是创建一个新应用,先把工具跑起来,模拟器跑起来。走一个空白的Hello Word的空项目。
如果是window系统,需要开启虚拟化Hyper-V 、window虚拟机监控平台的相关配置,如果出错了也会有对应的提示,不得不说DevEco工具还是非常人性化的。赞一个!
第二阶段:证书申请(30分钟)
这是最关键的也是最简单,如果申请过苹果的证书,就会非常得心应手。
4大证书分别是:
- p12:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
- csr:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
- cer:打开AGC平台需要登陆华为账号进行申请。在证书、APPID 和Profile菜单下操作,需要区分开发和生产证书类型。
- p7b:打开AGC平台需要登陆华为账号进行申请。该证书是根据应用来申请的。
申请p12和csr证书截图:
申请cer和p7b证书截图:
在HBX里面的完整配置
证书部分到此结束,还是非常简单滴!主要我这边是从注册账户到注册应用,再到注册证书,所以耗时多一点,如果再添加一个新应用的配置,那就是分分钟钟的事情了。
第三阶段:代码适配(核心8小时)
这里可能是大家最关心的部分——到底要改多少代码?
答案是:少得惊人!
我以一个物流类型的应用为案例进行举例,主要修改点包括:
- 登录模块:用华为账户替换以前的注册,华为账户和业务账户的绑定。
- 权限模块:调用华为的API获取手机号
- 账户注销:所有上架的应用都需要支持用户自主取消关联和账户注销。
- 分享功能:适配华为分享
一、登录模块主要调整的原因是:华为要求统一使用华为账户进行静默登录,那么如果以前已经有账户了,那就需要进行做关联即可。使用华为提供的code通过后台接口获取到对应的UnionID,OpenID,通过这些就可以换到手机号了,手机号的权限需要在后台开通,这个申请比较慢,说个小技巧提交申请后,立马就挂个工单,这样就审核的特别快了。
其实很多路子和微信小程序很相似,只是需要后端的配合,支持调用华为的API才行。
// 修改前:微信小程序端
uni.login({
provider: 'weixin', //使用微信登录
success: function (loginRes) {
console.log(loginRes.authResult);
}
});
// 修改后:支持鸿蒙获取账户 获取后走接口拿手机号
uni.login({
success: function (loginRes) {
console.log(loginRes.authResult);
}
});
二、权限模块的调整主要是为了满足在华为手机上能有拍照权限,坐标权限,这些自行申请,也都是比较简单的。
三、关于账户注销,这个还是要求必须得有的,其实,这个更简单,上架过iOS的都知道,这个是必须的,基本上无需调整,咱们这里其实也就是改成解除和华为账户的关联关系。
这里就不多说了。四、分享等调用微信相关的,都需要根据鸿蒙的环境改成调用鸿蒙的即可。这个就需要大面积的排查代码了。简单是简单就是多,所以费时间。
// 修改前:通用分享
uni.share({
provider: "weixin",
scene: "WXSceneSession",
type: 0,
title: "分享标题",
success: function (res) {
console.log("success:" + JSON.stringify(res));
}
});
// 修改后:鸿蒙分享
uni.share({
provider: "harmony",
type: 0,
title: "分享标题",
success: function (res) {
console.log("鸿蒙分享成功:" + JSON.stringify(res));
}
});
工作量统计:
- 登录模块:2小时
- 权限调试:2小时
- 账户注销:1小时
- 其他权限和证书适配打包APP:3小时
- 总计:8小时
其实总结一句话:代码大改的地方真的很少,很少。
建议弟兄们,赶紧上手吧!
第四阶段:调试上架(8小时)
真机调试让我眼前一亮。鸿蒙设备的流畅度确实出色,应用启动速度比安卓版本就是快,用的华为navo12 真机运行非常流畅。
上架过程同样顺畅:
- 华为开发者账号注册:1小时
- 应用信息填写:1小时
- 打包提交审核:1小时
- 审核通过:一天会审核多次(比苹果快太多了!)
三、意外之喜:元服务的惊喜邂逅
在适配过程中,我发现鸿蒙元服务这个概念很有意思。简单来说,它让用户不用下载完整APP就能使用核心功能。
关键发现:uni-app对元服务的支持几乎是开箱即用!
// 在pages.json中配置元服务页面
{
"pages": [
{
"path": "pages/index/index",
"style": {
"isEntry": true,
"isAtomic": true // 标记为元服务页面
}
}
]
}
我的一个工具类应用,通过元服务实现了:
- 用户无需安装即可使用核心计算功能
- 服务卡片直接展示关键信息
- 转化率提升明显
四、技术对比:uni-app的降维打击
用过uni-app 开发的朋友都知道,他最大的优势就是全终端兼容,安卓+iOS+小程序+鸿蒙,可以说是天下无敌了,一套代码,真的是省心省力,真的是早用早享受。只要你会点前端,懂Vue,就没有任何技术难度。
当然为了让大家更直观地理解,我做了个对比表:
| 特性 | 原生鸿蒙开发 | uni-app鸿蒙适配 |
|---|---|---|
| 学习成本 | 需要学习ArkTS等新技术 | 使用熟悉的vue语法 |
| 代码复用 | 从零开始 | 90%+代码可直接复用 |
| 开发周期 | 2-4周/应用 | 1-3天/应用 |
| 多端支持 | 仅鸿蒙 | 同时支持小程序、iOS、安卓 |
| 维护成本 | 独立代码库 | 统一代码库 |
最让我震撼的是: 旧应用的适配工作远比你担心的要少太多了,只要能跑起来,运行起来,就基本上没啥问题。在这里必须感谢一下uni-app 团队的技术支持,我在适配过程中,有专门的钉钉群提供技术服务,这也是我为何能在短短3天时间就能解决所有兼容的关键。在此一并感谢整个团队!
五、避坑指南:实战中遇到的坑
当然,过程并非完全一帆风顺。记录几个可能帮到大家的坑:
- 图片路径问题:鸿蒙对绝对路径更敏感,建议使用相对路径
- CSS兼容性:部分CSS3特性需要添加鸿蒙前缀
- API异步处理:鸿蒙的API调用更强调异步编程
- 代码运行不生效:如果感觉代码没问题,热更新不生效,记得重启整个应用,甚至重启HBX,重启模拟器,有时缓存问题不得不说很难受。
// 推荐使用async/await处理鸿蒙API
async function initHarmonyFeatures() {
try {
const result = await uni.harmony.someAPI();
// 处理结果
} catch (error) {
console.error('API调用失败:', error);
}
}
六、成果展示:数字说话
截至目前,我的6款应用全部成功上架:
- 🎯 适配成功率:100%(6/6)
- ⚡ 最快适配记录:8小时(旧的成熟应用)
- 🚀 平均适配时间:2天/应用
- 💰 成本节约:相比原生开发,节省约85%成本
- 📈 用户增长:鸿蒙渠道日均新增用户300+
七、给开发者同仁的真诚建议
- 立即行动:鸿蒙生态红利期就在眼前
- 从简单应用开始:先拿一个功能简单的应用试水
- 关注元服务:这是鸿蒙的独特优势,不要错过
- 利用uni-app社区:遇到的问题基本都能找到解决方案
结语:一个人的速度,一个时代的变革
有开发者朋友问我:"现在入场是不是太晚了?"
我的回答是:"当你知道的时候,就是最好的时机。"
uni-app + 鸿蒙的组合,让我在技术变革的浪潮中抓住了先机。 从焦虑观望到全面上架,只用了不到一周时间。这种效率,在过去的移动开发史上是从未有过的。
现在,轮到你了。
在这里顺带解释一下奖励金,华为最近的活动,只要上架成功,有活跃用户,或者参加uni-app的活动,都是可以拿到奖励金的。大家加油!
在得瑟下我们的用户量;
【实战资源分享】
- uni-app 鸿蒙专题:uni-app 鸿蒙适配专题
- 鸿蒙开发者官网:鸿蒙官方技术文档
- 华为ACG 后台:登录
- 华为应用市场:应用商店
欢迎在评论区交流适配经验,我会第一时间回复大家的问题!
收起阅读 »2025年DCloud插件大赛获奖名单
本次插件大赛收到大量优质插件,尤其是大量常用插件已完成鸿蒙Next的兼容适配。
获奖名单
一等奖
| 奖项 | 分类 | 获奖作者 | 获奖插件 |
|---|---|---|---|
| 一等奖 | 前端组件 | 陌上华年 | LimeUi 轻量高效的 Uni 生态组件库【鸿蒙Next】 |
| 一等奖 | UTS插件 | COOL团队 | 【支持鸿蒙】Cool Unix|UI组件库 |
二等奖
| 奖项 | 分类 | 获奖作者 | 获奖插件 |
|---|---|---|---|
| 二等奖 | UTS插件 | 1530948626 | 安卓保活 ios保活 鸿蒙保活 保应用程序稳定后台运行(uniapp,uniappx保活 长期维护) |
| 二等奖 | UTS插件 | 照相 | 【z-paging-x下拉刷新、上拉加载】z-paging uniappx版已上线! |
| 二等奖 | UTS插件 | tmui | TM-UI-4.0原生应用开发解决方案套装 |
| 二等奖 | UTS插件 | 前端码农boy | 【Android+IOS+harmonyOS】银联云闪付支付 |
| 二等奖 | 前端组件 | UxFrame | 【支持原生鸿蒙】UxFrame 低代码高性能UI框架 |
| 二等奖 | 前端组件 | TuiPlus | TuiPlus 4.0 焕新发布 轻如鸿毛 快如闪电 |
| 二等奖 | 前端组件 | rice_z | RiceUI 基于uniappx 的UI框架【支持APP 鸿蒙 小程序 H5】 |
| 二等奖 | 前端组件 | VK168 | 【开箱即用】uView Vue3 横空出世,继承uView1意志,再战江湖,风云再起! |
三等奖
| 奖项 | 分类 | 获奖作者 | 获奖插件 |
|---|---|---|---|
| 三等奖 | UTS插件 | GraceUI | uXui 【主流平台全兼容版】一款基于 uni-app x 的、免费、开源的 UI 框架 |
| 三等奖 | UTS插件 | 珊瑚 | Android端的AES、MD5、RSA、SHA、SM2、SM3、SM4加密解密 |
| 三等奖 | UTS插件 | shmily121314 | usb-serial |
| 三等奖 | UTS插件 | 1530948626 | Ble低功耗蓝牙uts插件 支持安卓ios 鸿蒙 微信小程序 |
| 三等奖 | UTS插件 | 小白2023 | sanfor-atrust(深信服-vpn插件) |
| 三等奖 | UTS插件 | 30天只能改一次 | 高德定位、地图、导航全功能,简单易用,支持安卓和iOS |
| 三等奖 | UTS插件 | kux | kux-marked |
| 三等奖 | HBuilderX | 猫猫猫猫 | GitHub Copilot |
| 三等奖 | 前端组件 | UviewPlus | 零云®uview-plus3.0重磅发布,全面的Vue3鸿蒙跨端移动组件库,组件丰富维护更新稳定 |
| 三等奖 | 前端组件 | 不如摸鱼去 | wot-design-uni 基于vue3+Typescript的高颜值组件库 |
| 三等奖 | 前端组件 | uViewNext | uView Next全面适配Vue3和鸿蒙,组件库更丰富,功能更全,更稳定,更高质量的UI框架 |
| 三等奖 | 前端组件 | wenju | 【ECharts组件】支持官方所有图表,支持vue3、分包、鸿蒙、nvue、uts、uniapp x |
| 三等奖 | 项目模板 | useryang | 智慧医疗 |
| 三等奖 | 项目模板 | 小疯子呵 | 福袋抽奖-看广告变现 |
鼓励奖
因很多插件值得推荐,所以增加了贡献奖的名额。
奖项设置:
本次大赛与往届有一个差别,就是更加普惠。本次没有特等奖,三等奖也发放价值2699元的纯血鸿蒙手机(全新机)。
一等奖(2名):
奖品:1万元插件包销 + 鸿蒙手机1部 + 插件市场置顶推荐半个月 + HBuilderX预置 + HBuilderX超大鼠标垫 + DCloud奖牌
二等奖(8名):
奖品:1000元插件包销 + 鸿蒙手机1部 + 插件市场置顶推荐1个星期 + HBuilderX超大鼠标垫 + DCloud奖牌
三等奖(14名):
奖品:200元uniCloud代金券 + 鸿蒙手机1部 + HBuilderX超大鼠标垫 + DCloud奖牌
说明:三等奖原设置为 20 名,但因插件总体评分的原因,本次评选出 14 名;开发者可继续迭代更新自己的插件,截止 2025 年 12 月 31 日之前,发布更新版本的插件作者,可邮件到
service@dcloud.io进行评选申请,评委会审核通过后,会补发剩余的 6 个三等奖。
贡献奖(50名):
奖品:HBuilderX超大鼠标垫
奖品说明:
-
“插件包销”,是指获奖插件通过插件市场销售,DCloud兜底包销。以1等奖的1万元包销为例,如果获奖插件在插件市场1年内销售额没有达到1万元,则由DCloud付差额给获奖者进行兜底。包销只针对付费插件,如免费插件获得二等奖及以上奖励,其中的包销奖励无效。包销插件需持续迭代,如插件作者放弃维护,则包销无效。
-
“HBuilderX预置”,是在HBuilderX新建项目界面,可直接选择该项目模板。这为插件带来大量的流量。不适合预置的插件类型,无法领取此奖项。
-
本次插件大赛,目标是普惠更广大开发者,解决很多工程师缺少鸿蒙真机的困境,故本次奖励的鸿蒙手机为统一型号为
nova 14 256GB; -
鸿蒙手机的奖励需满足两个条件:
- 插件兼容鸿蒙平台
- 插件作者通过DCloud专属链接到鸿蒙开发者平台注册一个新的开发者账号
除上述奖品外:
- 二等奖及以上获奖插件作者,都将进入DCloud VIP技术支持群,享受优先的技术支付、问题反馈。
- 所有获奖插件的集锦页面,还将通过HBuilderX工具、论坛、IM/QQ/微信群进行全量推广,给予优秀插件充分的曝光。
HBuilderX预置窗体界面如下:
奖牌照片如下:
奖品领取
请各位获奖作者尽快提交自己的邮寄地址,我们会陆续联系获奖人员发放奖品;
邮寄地址提交方式:登录ask社区,点击右上角个人头像,进入设置界面,设置界面下方补充快递邮寄地址。
已获奖的插件作者请继续升级迭代插件;
未获奖的今年还有机会,官方会继续为建设更好的uni-app x生态及更好的鸿蒙支持,推出其他计划。
不管是为了下次大赛获奖,还是为了把握uni-app x及鸿蒙替代的新浪潮,或者在插件市场通过售卖插件变现,都是值得期待的好事。
本次插件大赛收到大量优质插件,尤其是大量常用插件已完成鸿蒙Next的兼容适配。
获奖名单
一等奖
| 奖项 | 分类 | 获奖作者 | 获奖插件 |
|---|---|---|---|
| 一等奖 | 前端组件 | 陌上华年 | LimeUi 轻量高效的 Uni 生态组件库【鸿蒙Next】 |
| 一等奖 | UTS插件 | COOL团队 | 【支持鸿蒙】Cool Unix|UI组件库 |
二等奖
| 奖项 | 分类 | 获奖作者 | 获奖插件 |
|---|---|---|---|
| 二等奖 | UTS插件 | 1530948626 | 安卓保活 ios保活 鸿蒙保活 保应用程序稳定后台运行(uniapp,uniappx保活 长期维护) |
| 二等奖 | UTS插件 | 照相 | 【z-paging-x下拉刷新、上拉加载】z-paging uniappx版已上线! |
| 二等奖 | UTS插件 | tmui | TM-UI-4.0原生应用开发解决方案套装 |
| 二等奖 | UTS插件 | 前端码农boy | 【Android+IOS+harmonyOS】银联云闪付支付 |
| 二等奖 | 前端组件 | UxFrame | 【支持原生鸿蒙】UxFrame 低代码高性能UI框架 |
| 二等奖 | 前端组件 | TuiPlus | TuiPlus 4.0 焕新发布 轻如鸿毛 快如闪电 |
| 二等奖 | 前端组件 | rice_z | RiceUI 基于uniappx 的UI框架【支持APP 鸿蒙 小程序 H5】 |
| 二等奖 | 前端组件 | VK168 | 【开箱即用】uView Vue3 横空出世,继承uView1意志,再战江湖,风云再起! |
三等奖
| 奖项 | 分类 | 获奖作者 | 获奖插件 |
|---|---|---|---|
| 三等奖 | UTS插件 | GraceUI | uXui 【主流平台全兼容版】一款基于 uni-app x 的、免费、开源的 UI 框架 |
| 三等奖 | UTS插件 | 珊瑚 | Android端的AES、MD5、RSA、SHA、SM2、SM3、SM4加密解密 |
| 三等奖 | UTS插件 | shmily121314 | usb-serial |
| 三等奖 | UTS插件 | 1530948626 | Ble低功耗蓝牙uts插件 支持安卓ios 鸿蒙 微信小程序 |
| 三等奖 | UTS插件 | 小白2023 | sanfor-atrust(深信服-vpn插件) |
| 三等奖 | UTS插件 | 30天只能改一次 | 高德定位、地图、导航全功能,简单易用,支持安卓和iOS |
| 三等奖 | UTS插件 | kux | kux-marked |
| 三等奖 | HBuilderX | 猫猫猫猫 | GitHub Copilot |
| 三等奖 | 前端组件 | UviewPlus | 零云®uview-plus3.0重磅发布,全面的Vue3鸿蒙跨端移动组件库,组件丰富维护更新稳定 |
| 三等奖 | 前端组件 | 不如摸鱼去 | wot-design-uni 基于vue3+Typescript的高颜值组件库 |
| 三等奖 | 前端组件 | uViewNext | uView Next全面适配Vue3和鸿蒙,组件库更丰富,功能更全,更稳定,更高质量的UI框架 |
| 三等奖 | 前端组件 | wenju | 【ECharts组件】支持官方所有图表,支持vue3、分包、鸿蒙、nvue、uts、uniapp x |
| 三等奖 | 项目模板 | useryang | 智慧医疗 |
| 三等奖 | 项目模板 | 小疯子呵 | 福袋抽奖-看广告变现 |
鼓励奖
因很多插件值得推荐,所以增加了贡献奖的名额。
奖项设置:
本次大赛与往届有一个差别,就是更加普惠。本次没有特等奖,三等奖也发放价值2699元的纯血鸿蒙手机(全新机)。
一等奖(2名):
奖品:1万元插件包销 + 鸿蒙手机1部 + 插件市场置顶推荐半个月 + HBuilderX预置 + HBuilderX超大鼠标垫 + DCloud奖牌
二等奖(8名):
奖品:1000元插件包销 + 鸿蒙手机1部 + 插件市场置顶推荐1个星期 + HBuilderX超大鼠标垫 + DCloud奖牌
三等奖(14名):
奖品:200元uniCloud代金券 + 鸿蒙手机1部 + HBuilderX超大鼠标垫 + DCloud奖牌
说明:三等奖原设置为 20 名,但因插件总体评分的原因,本次评选出 14 名;开发者可继续迭代更新自己的插件,截止 2025 年 12 月 31 日之前,发布更新版本的插件作者,可邮件到
service@dcloud.io进行评选申请,评委会审核通过后,会补发剩余的 6 个三等奖。
贡献奖(50名):
奖品:HBuilderX超大鼠标垫
奖品说明:
-
“插件包销”,是指获奖插件通过插件市场销售,DCloud兜底包销。以1等奖的1万元包销为例,如果获奖插件在插件市场1年内销售额没有达到1万元,则由DCloud付差额给获奖者进行兜底。包销只针对付费插件,如免费插件获得二等奖及以上奖励,其中的包销奖励无效。包销插件需持续迭代,如插件作者放弃维护,则包销无效。
-
“HBuilderX预置”,是在HBuilderX新建项目界面,可直接选择该项目模板。这为插件带来大量的流量。不适合预置的插件类型,无法领取此奖项。
-
本次插件大赛,目标是普惠更广大开发者,解决很多工程师缺少鸿蒙真机的困境,故本次奖励的鸿蒙手机为统一型号为
nova 14 256GB; -
鸿蒙手机的奖励需满足两个条件:
- 插件兼容鸿蒙平台
- 插件作者通过DCloud专属链接到鸿蒙开发者平台注册一个新的开发者账号
除上述奖品外:
- 二等奖及以上获奖插件作者,都将进入DCloud VIP技术支持群,享受优先的技术支付、问题反馈。
- 所有获奖插件的集锦页面,还将通过HBuilderX工具、论坛、IM/QQ/微信群进行全量推广,给予优秀插件充分的曝光。
HBuilderX预置窗体界面如下:
奖牌照片如下:
奖品领取
请各位获奖作者尽快提交自己的邮寄地址,我们会陆续联系获奖人员发放奖品;
邮寄地址提交方式:登录ask社区,点击右上角个人头像,进入设置界面,设置界面下方补充快递邮寄地址。
已获奖的插件作者请继续升级迭代插件;
未获奖的今年还有机会,官方会继续为建设更好的uni-app x生态及更好的鸿蒙支持,推出其他计划。
不管是为了下次大赛获奖,还是为了把握uni-app x及鸿蒙替代的新浪潮,或者在插件市场通过售卖插件变现,都是值得期待的好事。
收起阅读 »iOS 上架应用市场全流程指南,App Store 审核机制、证书管理与跨平台免 Mac 上传发布方案(含开心上架实战)
'''对于所有 iOS 开发者而言,将应用成功上架到 App Store 是开发流程的最终目标。
无论是个人独立开发者,还是跨平台团队(如使用 uni-app、Flutter、React Native 等),iOS 上架始终是最关键也最繁琐的环节之一。
上架不仅仅是“上传一个 ipa 文件”,而是一套包含开发者注册、证书管理、应用配置、截图上传、审核提交流程的完整体系。
一、iOS 应用市场(App Store)概述
苹果的 App Store 是全球最大的移动应用分发平台之一,覆盖 175 个国家和地区,对应用质量与安全有严格要求。
与 Android 不同,iOS 平台的上架流程完全由苹果审核控制,这意味着开发者需要遵守以下三个核心规范:
- 内容规范(Content Guidelines):禁止违规内容;
- 隐私合规(Privacy Compliance):要求隐私政策与数据声明;
- 技术合规(Technical Requirements):必须使用合法证书签名、无崩溃错误。
因此,上架准备工作 的完整性,决定了应用能否顺利通过审核。
二、上架前准备:账号与证书
Apple Developer 账号
开发者需要注册 Apple Developer Program,
分为两种类型:
| 类型 | 费用 | 适用场景 |
|---|---|---|
| 个人账号 | 99 美元/年 | 个人或小团队 |
| 企业账号 | 299 美元/年 | 公司或内部应用分发 |
注册完成后,即可在后台创建 App ID、证书(Certificates)和描述文件(Provisioning Profiles)。
证书类型及作用
| 证书类型 | 用途 |
|---|---|
| 开发证书(Development Certificate) | 用于调试与测试安装 |
| 发布证书(Distribution Certificate) | 用于 App Store 上架 |
| 推送证书(Push Certificate) | 用于 APNs 推送功能 |
开心上架(Appuploader)可直接在 Windows / Linux / macOS 上创建 iOS 证书,无需 Mac 与钥匙串助手(Keychain Access)。
三、IPA 文件的生成与打包方式
应用在上架前必须打包为 .ipa 文件。
根据项目类型,开发者可选择不同方案:
| 项目类型 | 打包方式 |
|---|---|
| 原生 iOS 项目(Xcode) | Xcode → Product → Archive |
| 跨平台项目(Flutter / uni-app) | 使用命令行或 HBuilder 云打包 |
| 混合应用(React Native / Cordova) | CLI 工具 + iOS 证书导出 |
如果你使用 HBuilder 或 uni-app,可以直接使用云打包生成 .ipa 文件,再配合 Appuploader 进行上传,无需 Mac 环境。
四、上传到 App Store 的方式对比
传统上传方式依赖 Mac 环境,如下表所示:
| 工具 | 系统要求 | 操作方式 | 缺点 |
|---|---|---|---|
| Xcode | macOS | 打包后直接上传 | 需本地签名配置 |
| Transporter App | macOS | 拖拽上传 IPA | 无法自动化 |
| altool / Fastlane | macOS | 命令行上传 | 依赖 Transporter |
| 开心上架(Appuploader) | Windows / Linux / macOS | GUI + CLI 上传 | 免 Mac,支持自动化 |
五、开心上架(Appuploader)上传实战
命令行上传示例:
appuploader_cli -u ios@team.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/app.ipa
参数说明:
| 参数 | 含义 |
|---|---|
-u |
Apple 开发者账号 |
-p |
App 专用密码 |
-c |
上传通道(1=旧通道,2=新通道) |
-f |
指定上传的 IPA 文件路径 |
执行后,Appuploader 会自动连接 App Store Connect,
验证包体信息并上传,输出上传结果日志。
支持功能:
- 上传 IPA 文件
- 上传多语言截图与描述信息
- 自动识别应用版本号
- 输出可视化上传进度
六、App Store Connect 后台配置步骤
IPA 上传完成后,登录 App Store Connect,
完成以下设置:
填写应用信息(名称、描述、关键词);
上传截图与隐私政策链接;
选择应用分级与定价模式;
提交审核。
审核通过后,应用即可在全球 App Store 上架发布。
七、跨平台团队的免 Mac 上架实践
假设你是一个在 Windows + Flutter + Jenkins CI 环境下开发的团队,整个自动化上架流程如下:
1. Fastlane 构建 IPA
2. Appuploader CLI 上传 IPA
3. App Store Connect 自动生成构建版本
4. 邮件通知团队成员
脚本示例:
fastlane gym --scheme "MyApp"
appuploader_cli -u dev@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/MyApp.ipa
该流程完全不依赖 Mac 环境,可运行于 Linux 容器或 Jenkins Agent 节点。
八、常见审核与上架问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| “Invalid Bundle ID” | ID 不匹配 | 确认与 Apple Developer 保持一致 |
| “ITMS-90161 Invalid Provisioning Profile” | 签名错误 | 重新生成发布证书 |
| “Missing Privacy Policy” | 隐私声明缺失 | 提供完整链接 |
| 上传失败 | 网络不稳或密码错误 | 使用 App 专用密码并切换通道 |
| 审核延迟 | 应用含复杂功能 | 耐心等待或联系客服复核 |
九、iOS 应用市场上架的最佳实践
使用新通道上传(-c 2),速度更快;
上传前验证 Info.plist 的版本号与包名;
截图建议使用 6.5" + iPad Pro 尺寸自动适配;
在 App Store Connect 提交隐私政策与数据用途说明;
使用 CI 工具结合 Appuploader CLI,实现持续交付。
上架 iOS 应用市场是一项需要技术与耐心并存的工作,从证书创建到上传审核,每个环节都有其严格的规范。
第三方工具的出现,让整个流程更高效、更自由:开发者无需 Mac,即可在任意平台完成上传与发布,让 iOS 应用市场的上架不再是“平台壁垒”,而是自动化流水线的一环。'''
'''对于所有 iOS 开发者而言,将应用成功上架到 App Store 是开发流程的最终目标。
无论是个人独立开发者,还是跨平台团队(如使用 uni-app、Flutter、React Native 等),iOS 上架始终是最关键也最繁琐的环节之一。
上架不仅仅是“上传一个 ipa 文件”,而是一套包含开发者注册、证书管理、应用配置、截图上传、审核提交流程的完整体系。
一、iOS 应用市场(App Store)概述
苹果的 App Store 是全球最大的移动应用分发平台之一,覆盖 175 个国家和地区,对应用质量与安全有严格要求。
与 Android 不同,iOS 平台的上架流程完全由苹果审核控制,这意味着开发者需要遵守以下三个核心规范:
- 内容规范(Content Guidelines):禁止违规内容;
- 隐私合规(Privacy Compliance):要求隐私政策与数据声明;
- 技术合规(Technical Requirements):必须使用合法证书签名、无崩溃错误。
因此,上架准备工作 的完整性,决定了应用能否顺利通过审核。
二、上架前准备:账号与证书
Apple Developer 账号
开发者需要注册 Apple Developer Program,
分为两种类型:
| 类型 | 费用 | 适用场景 |
|---|---|---|
| 个人账号 | 99 美元/年 | 个人或小团队 |
| 企业账号 | 299 美元/年 | 公司或内部应用分发 |
注册完成后,即可在后台创建 App ID、证书(Certificates)和描述文件(Provisioning Profiles)。
证书类型及作用
| 证书类型 | 用途 |
|---|---|
| 开发证书(Development Certificate) | 用于调试与测试安装 |
| 发布证书(Distribution Certificate) | 用于 App Store 上架 |
| 推送证书(Push Certificate) | 用于 APNs 推送功能 |
开心上架(Appuploader)可直接在 Windows / Linux / macOS 上创建 iOS 证书,无需 Mac 与钥匙串助手(Keychain Access)。
三、IPA 文件的生成与打包方式
应用在上架前必须打包为 .ipa 文件。
根据项目类型,开发者可选择不同方案:
| 项目类型 | 打包方式 |
|---|---|
| 原生 iOS 项目(Xcode) | Xcode → Product → Archive |
| 跨平台项目(Flutter / uni-app) | 使用命令行或 HBuilder 云打包 |
| 混合应用(React Native / Cordova) | CLI 工具 + iOS 证书导出 |
如果你使用 HBuilder 或 uni-app,可以直接使用云打包生成 .ipa 文件,再配合 Appuploader 进行上传,无需 Mac 环境。
四、上传到 App Store 的方式对比
传统上传方式依赖 Mac 环境,如下表所示:
| 工具 | 系统要求 | 操作方式 | 缺点 |
|---|---|---|---|
| Xcode | macOS | 打包后直接上传 | 需本地签名配置 |
| Transporter App | macOS | 拖拽上传 IPA | 无法自动化 |
| altool / Fastlane | macOS | 命令行上传 | 依赖 Transporter |
| 开心上架(Appuploader) | Windows / Linux / macOS | GUI + CLI 上传 | 免 Mac,支持自动化 |
五、开心上架(Appuploader)上传实战
命令行上传示例:
appuploader_cli -u ios@team.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/app.ipa
参数说明:
| 参数 | 含义 |
|---|---|
-u |
Apple 开发者账号 |
-p |
App 专用密码 |
-c |
上传通道(1=旧通道,2=新通道) |
-f |
指定上传的 IPA 文件路径 |
执行后,Appuploader 会自动连接 App Store Connect,
验证包体信息并上传,输出上传结果日志。
支持功能:
- 上传 IPA 文件
- 上传多语言截图与描述信息
- 自动识别应用版本号
- 输出可视化上传进度
六、App Store Connect 后台配置步骤
IPA 上传完成后,登录 App Store Connect,
完成以下设置:
填写应用信息(名称、描述、关键词);
上传截图与隐私政策链接;
选择应用分级与定价模式;
提交审核。
审核通过后,应用即可在全球 App Store 上架发布。
七、跨平台团队的免 Mac 上架实践
假设你是一个在 Windows + Flutter + Jenkins CI 环境下开发的团队,整个自动化上架流程如下:
1. Fastlane 构建 IPA
2. Appuploader CLI 上传 IPA
3. App Store Connect 自动生成构建版本
4. 邮件通知团队成员
脚本示例:
fastlane gym --scheme "MyApp"
appuploader_cli -u dev@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/MyApp.ipa
该流程完全不依赖 Mac 环境,可运行于 Linux 容器或 Jenkins Agent 节点。
八、常见审核与上架问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| “Invalid Bundle ID” | ID 不匹配 | 确认与 Apple Developer 保持一致 |
| “ITMS-90161 Invalid Provisioning Profile” | 签名错误 | 重新生成发布证书 |
| “Missing Privacy Policy” | 隐私声明缺失 | 提供完整链接 |
| 上传失败 | 网络不稳或密码错误 | 使用 App 专用密码并切换通道 |
| 审核延迟 | 应用含复杂功能 | 耐心等待或联系客服复核 |
九、iOS 应用市场上架的最佳实践
使用新通道上传(-c 2),速度更快;
上传前验证 Info.plist 的版本号与包名;
截图建议使用 6.5" + iPad Pro 尺寸自动适配;
在 App Store Connect 提交隐私政策与数据用途说明;
使用 CI 工具结合 Appuploader CLI,实现持续交付。
上架 iOS 应用市场是一项需要技术与耐心并存的工作,从证书创建到上传审核,每个环节都有其严格的规范。
第三方工具的出现,让整个流程更高效、更自由:开发者无需 Mac,即可在任意平台完成上传与发布,让 iOS 应用市场的上架不再是“平台壁垒”,而是自动化流水线的一环。'''
收起阅读 »【鸿蒙征文】从创业小白到省赛获奖:我们用 uni-app 做出了"校园人人帮"
故事的开端:宿舍里诞生的创业想法
大三上学期的某个周末,宿舍四个人正窝在床上刷手机。老三突然抱怨:"又没抢到图书馆的座位,明天还得早起占座,太痛苦了!"老二接话:"要是能花点钱找人帮忙占座就好了。"这句话像一道闪电,点亮了我的思路。
"为什么不做个平台,专门解决校园里这些互助需求呢?"我坐起来,越说越兴奋:"帮拿快递、帮带外卖、帮占座位、帮打印材料......这些需求每天都在发生,但现在都是靠朋友圈和微信群,效率太低了!"
四个人一拍即合,当晚就开始构思项目方案。我们给它起名叫"校园人人帮"——人人都能发布需求,人人都能接单帮忙,顺便赚点生活费。方案写了整整一周,我们信心满满地向学校申报了创新创业项目,还准备参加省级"互联网+"大赛。
然后,现实给了我们一记重拳。
第一道坎:我们根本不会开发 App
拿到学校的项目支持资金后,我们才发现:想法很美好,但根本不知道怎么实现。
团队四个人,老大学工商管理、老二学电子商务、老三学市场营销,只有我选修过前端开发课程,勉强会点 HTML 和 CSS。要做一个真正能用的应用,我们需要:
- Android 开发?得学 Java 或 Kotlin
- iOS 开发?得学 Swift,还得买 Mac
- 小程序开发?好像容易些,但功能受限
- 鸿蒙开发?老师说比赛评委看重这个,但要学 ArkTS
每一条路都像一座大山,横在我们面前。去外面找外包团队报价,起步就要三万块,我们的启动资金根本不够。招技术合伙人?校内发了一周招募帖,只有两个人来咨询,一听说还在初期阶段,没有工资,立刻就走了。
最绝望的时候,我甚至想过放弃。"也许我们就不是做技术的料",这个念头在脑海里转了无数次。
转机:B站上的一条视频
崩溃了几天后,我决定至少要努力到最后一刻。开始在 B站、知乎、CSDN 上疯狂搜索:"零基础怎么做 App"、"大学生创业适合用什么技术"、"跨平台开发工具推荐"......
某天凌晨两点,刷到一个 up 主的视频:《大学生必看!用 uni-app 一周做出你的第一个 App》。视频里,up 主演示了用熟悉的 Vue 语法写代码,然后一键生成 Android、iOS、小程序、H5 多个平台的应用。
我整个人都精神了,立刻把视频发到宿舍群:"兄弟们,也许有救了!"
第二天一早,我就下载了 HBuilderX,跟着官方文档的 "快速上手" 开始尝试。神奇的事情发生了:
那些我在前端课上学过的 Vue 语法,在 uni-app 里几乎能直接用!
写一个任务列表页面,代码结构和我之前写的课程作业差不多:
<template>
<view class="task-list">
<view v-for="task in tasks" :key="task.id" class="task-item">
<text>{{ task.title }}</text>
<text class="reward">¥{{ task.reward }}</text>
</view>
</view>
</template>
更神奇的是,我把官方文档分享给另外两个队友,他们一个学电子商务,一个学工商管理,之前完全没碰过代码。但跟着教程做了两天 Demo 之后,居然也能写简单的页面了。有个队友还兴奋地在群里说:"原来写代码没那么难,感觉就像搭积木一样!"
<template>
<view class="task-list">
<view v-for="task in tasks" :key="task.id" class="task-item">
<text>{{ task.title }}</text>
<text class="reward">¥{{ task.reward }}</text>
</view>
</view>
</template>
v-for 循环、双括号插值、:key 绑定......这些不就是 Vue 的语法吗?我花了一个下午,就把第一个页面做出来了。在 HBuilderX 里点击"运行到浏览器",页面真的显示出来了!那一刻的激动,现在想起来还记忆犹新。
说服队友:技术门槛不再是问题
有了第一个 Demo,我立刻把另外三个队友拉到电脑前演示。"你们看,就写这么点代码,就能做出一个页面了!而且这套代码,可以同时在微信小程序、App、网页上运行!"
老二半信半疑:"我们又不会 Vue,能学会吗?"
"简单!"我打开官方教程,"你们看这个新手指南,图文并茂,还有视频讲解。咱们每天晚上花两小时学,一周就能上手。"
接下来的一周,宿舍变成了临时"培训班"。我带着他们从最基础的概念开始学:什么是组件、什么是数据绑定、什么是生命周期......每天晚上10点到12点,四台电脑的屏幕一起亮着,敲代码的键盘声此起彼伏。
第五天,老二做出了第一个功能——"任务发布表单"。
第七天,老三完成了"个人中心页面"。
第九天,老大(学工商管理的那位)居然把"订单列表"也做出来了。
看着他们从一脸懵逼到能独立写代码,我深刻体会到了 uni-app 的威力:它真的把编程的门槛降到了极低。你不需要是计算机专业,不需要学几年编程,只要愿意投入时间,跟着文档一步步来,就能把想法变成产品。
开发加速度:一套代码适配所有平台
学会基础语法后,我们开始全速推进开发。按照项目规划,核心功能包括:
- 任务发布与浏览
- 接单与抢单
- 在线支付与结算
- 实时消息通知
- 用户信用评价体系
如果用传统的原生开发,我们得分别为 Android、iOS、小程序写三套代码。但用 uni-app,我们只需要写一套,然后在不同平台运行时做些微调。
小程序先行策略
我们决定先做微信小程序版本,原因很简单:
- 校园推广最方便,扫码就能用
- 不需要用户下载安装
- 开发调试快,适合快速验证功能
写完核心功能后,在 HBuilderX 里点击"运行 → 运行到小程序模拟器 → 微信开发者工具",几秒钟就能看到效果。改完代码按保存,页面立刻刷新,这种即时反馈的开发体验,让我们的迭代速度飞快。
仅用三周,小程序第一版就上线了。
鸿蒙版的意外惊喜
老师建议我们支持鸿蒙平台,说这样在比赛中更有竞争力。说实话,一开始我心里是打鼓的:我们连 Android 原生都不会,怎么可能做鸿蒙应用?
但当我在 uni-app 官网看到"支持编译到鸿蒙"的介绍时,决定试一试。配置好证书,点击"发行 → 鸿蒙 App 打包",等待了几分钟......
安装包生成成功!
我把 .hap 文件装到队友的鸿蒙手机上,点击图标,应用启动了!界面流畅、功能正常,和小程序版几乎一模一样!
"卧槽,这也太神奇了吧!"老三拿着手机翻来覆去地测试,"同样的代码,居然真的能在鸿蒙上跑?"
那一刻我们才真正理解了 "一次开发,多端运行" 的含义。不是营销口号,而是实实在在的生产力提升。原本以为要花一个月单独开发的鸿蒙版,我们只用了一天就搞定了适配。
比赛路演:鸿蒙版成为最大亮点
省级"互联网+"创新创业大赛的路演日期到了。我们在演讲台上,把笔记本电脑、Android 手机、鸿蒙手机、平板全摆了出来。
"各位评委老师,我们的'校园人人帮'支持全平台运行。"我打开小程序,演示发布任务的流程,然后切到手机 App,演示接单操作,最后在鸿蒙平板上展示管理后台。
台下的评委们眼睛亮了起来。其中一位评委是华为的技术专家,他举手提问:"你们这个鸿蒙版本,是原生开发的吗?响应速度和适配效果都很不错。"
"报告老师,我们用的是 uni-app 跨平台框架,一套代码可以编译成不同平台的应用。"我有点紧张,不知道这个答案会不会被认为"不够原生"。
没想到,这位评委点了点头,笑着说:"很好,这才是正确的技术选型。初创团队资源有限,选对工具比硬拼技术更重要。而且现在跨平台框架的性能已经很接近原生了,你们做到了用最小的成本,覆盖最多的平台。"
另一位评委补充道:"特别是你们支持鸿蒙,说明团队有前瞻性,愿意拥抱国产生态。这个加分。"
最终,我们拿到了省赛二等奖。
虽然不是最高奖项,但对于四个非计算机专业的大学生来说,这已经是巨大的成功。更重要的是,通过这次比赛,我们验证了技术路线的正确性,也建立了对自己的信心。
真实落地:用户的认可最珍贵
比赛结束后,我们没有停止脚步。回到学校,开始在校内真正推广"校园人人帮"。
第一周,通过朋友圈、社团群、校园公众号宣传,有 200 多个同学注册了。第一天就有人发布了任务:
- "明早7点帮我占图书馆考研座位,报酬10元" ✅ 5分钟被接单
- "帮我去菜鸟驿站拿两个快递,报酬5元" ✅ 3分钟被接单
- "帮我去东门买杯奶茶,报酬8元" ✅ 秒接
看着后台订单数据一条条跳动,四个人围在电脑前激动得跳了起来。"有人真的在用!"这种成就感,比拿奖更让人兴奋。
一个女生的感谢
印象最深的是一个女生用户。她连续一周在平台上发布"帮占座"任务,每次都能准时完成。有天她主动加了我们的客服微信,发来一段话:
"谢谢你们做了这个平台!我在准备考研,但宿舍离图书馆太远,每天早起占座真的很折磨。现在只要前一天晚上发个任务,第二天早上就能有人帮忙占好座位,我可以多睡一会儿。虽然花了点钱,但真的太值了!"
读到这段话,我们四个人都沉默了。原来技术的价值,不在于多么高深复杂,而在于能解决真实的问题,改善真实的生活。
快速迭代的能力
随着用户增多,我们收到了各种反馈和需求:
- "能不能加个定位功能,显示任务地点?" → 3天加上了地图定位
- "希望能看到接单人的信用评分" → 5天上线了评价系统
- "消息通知总是不及时" → 2天优化了推送机制
因为用 uni-app 开发效率高,我们可以快速响应用户需求。每周发布一个小版本,三个月内迭代了 12 次。这种敏捷的开发节奏,让我们的产品体验持续优化,用户粘性越来越高。
扫码核销系统:半天就搞定的"大功能"
运营一段时间后,我们发现了一个问题:有些用户接了单却不去完成,或者完成了但双方对是否完成有争议。用户反馈说:"能不能做个扫码确认功能?发布者生成二维码,帮忙的人到现场扫码,这样就能证明真的完成了。"
这个需求听起来挺复杂:要生成二维码、要扫描识别、还要在多个平台都能用......如果是原生开发,光调研各平台的扫码 API 就得花好几天。
但在 uni-app 里,我只用了 半天时间 就完成了整个功能!
发布者端生成二维码:
// 订单确认页面,生成包含订单信息的二维码
const qrCodeData = {
orderId: this.orderId,
userId: this.userId,
timestamp: Date.now()
}
// 使用 uni-app 的第三方组件库,直接渲染二维码
this.qrCodeContent = JSON.stringify(qrCodeData)
接单者端扫码核销:
// 点击扫码按钮
handleScan() {
uni.scanCode({
success: (res) => {
// 解析扫到的订单信息
const orderData = JSON.parse(res.result)
// 调用后端接口确认完成
this.confirmOrder(orderData)
},
fail: (err) => {
uni.showToast({ title: '扫码失败,请重试', icon: 'none' })
}
})
}
就这么简单!uni.scanCode 这一个 API,在微信小程序、鸿蒙 App、Android App 上都能完美运行。不需要分别调用微信的 wx.scanCode、鸿蒙的扫码接口、Android 的 ZXing 库,uni-app 帮我们抹平了所有平台差异。
上线后,用户反馈说:"这个功能太实用了!现在帮忙取快递,当面扫一下码,钱就自动结算了,双方都放心。"扫码核销的使用率达到了 85%,大大提升了平台的信任度。
从提出需求到上线,整个过程只用了 一个下午加一个晚上。如果用原生开发,这至少是个一周的工作量。
还有一次紧急修复支付 bug,从发现问题、定位代码、改完测试、重新打包发布,全程只用了 3 小时。要是用原生开发,光配置打包环境就得半天,根本做不到这么快。
技术之外的深刻体会
回顾这段创业经历,uni-app 带给我们的不仅是技术层面的便利,更是三个深刻的认知升级:
第一,工具选对比技术深度更重要
我们不是最懂技术的团队,但我们选对了工具。uni-app 让我们能用有限的能力,做出超越能力范围的产品。这教会我们:创业不是比谁更厉害,而是比谁更会借力,谁能用最小的成本达成目标。
第二,小步快跑胜过完美主义
一开始我们想做得很完美,所有功能都规划好再上线。但 uni-app 的高效率让我们改变了策略:先上线最小可行产品(MVP),根据用户反馈快速迭代。事实证明,这种敏捷开发的思路,让我们少走了太多弯路。
第三,拥抱国产生态找到新机会
支持鸿蒙平台,让我们在比赛中获得了差异化优势。更重要的是,我们作为早期的鸿蒙应用开发者,积累了宝贵的经验。现在华为在大力推广鸿蒙系统,我们这份经历可能成为未来的核心竞争力。
给学弟学妹的建议
现在经常有学弟学妹问我:"学长,我也想做创业项目,但不太会编程怎么办?"
我的回答是:不要被技术门槛吓住,从现在的工具开始学起。
如果你有一点 Web 前端基础(哪怕只是 HTML + CSS),或者愿意花两周时间跟着教程学习,uni-app 就能帮你实现从想法到产品的跨越。它的学习曲线足够平缓,文档足够详细,社区足够活跃,非常适合学生创业团队。
而且,选择 uni-app 还有额外的好处:
- 就业市场认可度高:跨平台开发是行业趋势,掌握 uni-app 的开发经验,找实习找工作都是加分项
- 鸿蒙生态红利期:现在支持鸿蒙的开发者还不多,早入场意味着更多机会
- 创业成本极低:不用组建庞大的技术团队,两三个人就能启动项目
最重要的是:不要等自己"准备好了"再开始,行动本身就是最好的学习。
未来的路还很长
到今天,"校园人人帮"已经运营了半年,校内注册用户突破 500 人,日均订单量稳定在 50 单左右。虽然规模不大,但每天都在解决真实的需求,每天都有同学因为我们的产品生活变得更便捷一点。
这种感觉,比任何成就感都更持久。
接下来,我们计划:
- 推广到周边高校:验证模式的可复制性
- 开发鸿蒙元服务:利用鸿蒙的快捷入口,降低用户使用门槛
- 对接校园一卡通:实现更便捷的支付体验
- 引入更多校园服务:打印、修电脑、租借物品......
技术方面,我们会继续深入学习 uni-app 的高级特性,也会关注鸿蒙生态的最新动态。毕竟,工具在进化,我们也要跟着成长。
致谢与寄语
感谢 DCloud 团队开发了 uni-app 这样优秀的跨平台框架,让我们这些非科班出身的学生也能实现技术梦想。
感谢华为鸿蒙团队的开放生态,给了我们展示作品的舞台。
感谢所有支持和使用"校园人人帮"的同学,你们的每一个订单、每一条反馈,都是我们坚持下去的动力。
也感谢那些还在为技术门槛发愁的学弟学妹们——如果我们四个"技术小白"都能做出产品并获奖,你们一定也可以。
真正的创新,不在于掌握多么高深的技术,而在于用合适的工具,解决真实的问题,创造真实的价值。
愿每一个有梦想的大学生,都能找到属于自己的技术工具,在年轻的时候勇敢试错,在国产生态的浪潮中留下自己的足迹。
星光不负,码向未来。我们的故事还在继续,你的故事也即将开始。
最后,再附上几张效果图
故事的开端:宿舍里诞生的创业想法
大三上学期的某个周末,宿舍四个人正窝在床上刷手机。老三突然抱怨:"又没抢到图书馆的座位,明天还得早起占座,太痛苦了!"老二接话:"要是能花点钱找人帮忙占座就好了。"这句话像一道闪电,点亮了我的思路。
"为什么不做个平台,专门解决校园里这些互助需求呢?"我坐起来,越说越兴奋:"帮拿快递、帮带外卖、帮占座位、帮打印材料......这些需求每天都在发生,但现在都是靠朋友圈和微信群,效率太低了!"
四个人一拍即合,当晚就开始构思项目方案。我们给它起名叫"校园人人帮"——人人都能发布需求,人人都能接单帮忙,顺便赚点生活费。方案写了整整一周,我们信心满满地向学校申报了创新创业项目,还准备参加省级"互联网+"大赛。
然后,现实给了我们一记重拳。
第一道坎:我们根本不会开发 App
拿到学校的项目支持资金后,我们才发现:想法很美好,但根本不知道怎么实现。
团队四个人,老大学工商管理、老二学电子商务、老三学市场营销,只有我选修过前端开发课程,勉强会点 HTML 和 CSS。要做一个真正能用的应用,我们需要:
- Android 开发?得学 Java 或 Kotlin
- iOS 开发?得学 Swift,还得买 Mac
- 小程序开发?好像容易些,但功能受限
- 鸿蒙开发?老师说比赛评委看重这个,但要学 ArkTS
每一条路都像一座大山,横在我们面前。去外面找外包团队报价,起步就要三万块,我们的启动资金根本不够。招技术合伙人?校内发了一周招募帖,只有两个人来咨询,一听说还在初期阶段,没有工资,立刻就走了。
最绝望的时候,我甚至想过放弃。"也许我们就不是做技术的料",这个念头在脑海里转了无数次。
转机:B站上的一条视频
崩溃了几天后,我决定至少要努力到最后一刻。开始在 B站、知乎、CSDN 上疯狂搜索:"零基础怎么做 App"、"大学生创业适合用什么技术"、"跨平台开发工具推荐"......
某天凌晨两点,刷到一个 up 主的视频:《大学生必看!用 uni-app 一周做出你的第一个 App》。视频里,up 主演示了用熟悉的 Vue 语法写代码,然后一键生成 Android、iOS、小程序、H5 多个平台的应用。
我整个人都精神了,立刻把视频发到宿舍群:"兄弟们,也许有救了!"
第二天一早,我就下载了 HBuilderX,跟着官方文档的 "快速上手" 开始尝试。神奇的事情发生了:
那些我在前端课上学过的 Vue 语法,在 uni-app 里几乎能直接用!
写一个任务列表页面,代码结构和我之前写的课程作业差不多:
<template>
<view class="task-list">
<view v-for="task in tasks" :key="task.id" class="task-item">
<text>{{ task.title }}</text>
<text class="reward">¥{{ task.reward }}</text>
</view>
</view>
</template>
更神奇的是,我把官方文档分享给另外两个队友,他们一个学电子商务,一个学工商管理,之前完全没碰过代码。但跟着教程做了两天 Demo 之后,居然也能写简单的页面了。有个队友还兴奋地在群里说:"原来写代码没那么难,感觉就像搭积木一样!"
<template>
<view class="task-list">
<view v-for="task in tasks" :key="task.id" class="task-item">
<text>{{ task.title }}</text>
<text class="reward">¥{{ task.reward }}</text>
</view>
</view>
</template>
v-for 循环、双括号插值、:key 绑定......这些不就是 Vue 的语法吗?我花了一个下午,就把第一个页面做出来了。在 HBuilderX 里点击"运行到浏览器",页面真的显示出来了!那一刻的激动,现在想起来还记忆犹新。
说服队友:技术门槛不再是问题
有了第一个 Demo,我立刻把另外三个队友拉到电脑前演示。"你们看,就写这么点代码,就能做出一个页面了!而且这套代码,可以同时在微信小程序、App、网页上运行!"
老二半信半疑:"我们又不会 Vue,能学会吗?"
"简单!"我打开官方教程,"你们看这个新手指南,图文并茂,还有视频讲解。咱们每天晚上花两小时学,一周就能上手。"
接下来的一周,宿舍变成了临时"培训班"。我带着他们从最基础的概念开始学:什么是组件、什么是数据绑定、什么是生命周期......每天晚上10点到12点,四台电脑的屏幕一起亮着,敲代码的键盘声此起彼伏。
第五天,老二做出了第一个功能——"任务发布表单"。
第七天,老三完成了"个人中心页面"。
第九天,老大(学工商管理的那位)居然把"订单列表"也做出来了。
看着他们从一脸懵逼到能独立写代码,我深刻体会到了 uni-app 的威力:它真的把编程的门槛降到了极低。你不需要是计算机专业,不需要学几年编程,只要愿意投入时间,跟着文档一步步来,就能把想法变成产品。
开发加速度:一套代码适配所有平台
学会基础语法后,我们开始全速推进开发。按照项目规划,核心功能包括:
- 任务发布与浏览
- 接单与抢单
- 在线支付与结算
- 实时消息通知
- 用户信用评价体系
如果用传统的原生开发,我们得分别为 Android、iOS、小程序写三套代码。但用 uni-app,我们只需要写一套,然后在不同平台运行时做些微调。
小程序先行策略
我们决定先做微信小程序版本,原因很简单:
- 校园推广最方便,扫码就能用
- 不需要用户下载安装
- 开发调试快,适合快速验证功能
写完核心功能后,在 HBuilderX 里点击"运行 → 运行到小程序模拟器 → 微信开发者工具",几秒钟就能看到效果。改完代码按保存,页面立刻刷新,这种即时反馈的开发体验,让我们的迭代速度飞快。
仅用三周,小程序第一版就上线了。
鸿蒙版的意外惊喜
老师建议我们支持鸿蒙平台,说这样在比赛中更有竞争力。说实话,一开始我心里是打鼓的:我们连 Android 原生都不会,怎么可能做鸿蒙应用?
但当我在 uni-app 官网看到"支持编译到鸿蒙"的介绍时,决定试一试。配置好证书,点击"发行 → 鸿蒙 App 打包",等待了几分钟......
安装包生成成功!
我把 .hap 文件装到队友的鸿蒙手机上,点击图标,应用启动了!界面流畅、功能正常,和小程序版几乎一模一样!
"卧槽,这也太神奇了吧!"老三拿着手机翻来覆去地测试,"同样的代码,居然真的能在鸿蒙上跑?"
那一刻我们才真正理解了 "一次开发,多端运行" 的含义。不是营销口号,而是实实在在的生产力提升。原本以为要花一个月单独开发的鸿蒙版,我们只用了一天就搞定了适配。
比赛路演:鸿蒙版成为最大亮点
省级"互联网+"创新创业大赛的路演日期到了。我们在演讲台上,把笔记本电脑、Android 手机、鸿蒙手机、平板全摆了出来。
"各位评委老师,我们的'校园人人帮'支持全平台运行。"我打开小程序,演示发布任务的流程,然后切到手机 App,演示接单操作,最后在鸿蒙平板上展示管理后台。
台下的评委们眼睛亮了起来。其中一位评委是华为的技术专家,他举手提问:"你们这个鸿蒙版本,是原生开发的吗?响应速度和适配效果都很不错。"
"报告老师,我们用的是 uni-app 跨平台框架,一套代码可以编译成不同平台的应用。"我有点紧张,不知道这个答案会不会被认为"不够原生"。
没想到,这位评委点了点头,笑着说:"很好,这才是正确的技术选型。初创团队资源有限,选对工具比硬拼技术更重要。而且现在跨平台框架的性能已经很接近原生了,你们做到了用最小的成本,覆盖最多的平台。"
另一位评委补充道:"特别是你们支持鸿蒙,说明团队有前瞻性,愿意拥抱国产生态。这个加分。"
最终,我们拿到了省赛二等奖。
虽然不是最高奖项,但对于四个非计算机专业的大学生来说,这已经是巨大的成功。更重要的是,通过这次比赛,我们验证了技术路线的正确性,也建立了对自己的信心。
真实落地:用户的认可最珍贵
比赛结束后,我们没有停止脚步。回到学校,开始在校内真正推广"校园人人帮"。
第一周,通过朋友圈、社团群、校园公众号宣传,有 200 多个同学注册了。第一天就有人发布了任务:
- "明早7点帮我占图书馆考研座位,报酬10元" ✅ 5分钟被接单
- "帮我去菜鸟驿站拿两个快递,报酬5元" ✅ 3分钟被接单
- "帮我去东门买杯奶茶,报酬8元" ✅ 秒接
看着后台订单数据一条条跳动,四个人围在电脑前激动得跳了起来。"有人真的在用!"这种成就感,比拿奖更让人兴奋。
一个女生的感谢
印象最深的是一个女生用户。她连续一周在平台上发布"帮占座"任务,每次都能准时完成。有天她主动加了我们的客服微信,发来一段话:
"谢谢你们做了这个平台!我在准备考研,但宿舍离图书馆太远,每天早起占座真的很折磨。现在只要前一天晚上发个任务,第二天早上就能有人帮忙占好座位,我可以多睡一会儿。虽然花了点钱,但真的太值了!"
读到这段话,我们四个人都沉默了。原来技术的价值,不在于多么高深复杂,而在于能解决真实的问题,改善真实的生活。
快速迭代的能力
随着用户增多,我们收到了各种反馈和需求:
- "能不能加个定位功能,显示任务地点?" → 3天加上了地图定位
- "希望能看到接单人的信用评分" → 5天上线了评价系统
- "消息通知总是不及时" → 2天优化了推送机制
因为用 uni-app 开发效率高,我们可以快速响应用户需求。每周发布一个小版本,三个月内迭代了 12 次。这种敏捷的开发节奏,让我们的产品体验持续优化,用户粘性越来越高。
扫码核销系统:半天就搞定的"大功能"
运营一段时间后,我们发现了一个问题:有些用户接了单却不去完成,或者完成了但双方对是否完成有争议。用户反馈说:"能不能做个扫码确认功能?发布者生成二维码,帮忙的人到现场扫码,这样就能证明真的完成了。"
这个需求听起来挺复杂:要生成二维码、要扫描识别、还要在多个平台都能用......如果是原生开发,光调研各平台的扫码 API 就得花好几天。
但在 uni-app 里,我只用了 半天时间 就完成了整个功能!
发布者端生成二维码:
// 订单确认页面,生成包含订单信息的二维码
const qrCodeData = {
orderId: this.orderId,
userId: this.userId,
timestamp: Date.now()
}
// 使用 uni-app 的第三方组件库,直接渲染二维码
this.qrCodeContent = JSON.stringify(qrCodeData)
接单者端扫码核销:
// 点击扫码按钮
handleScan() {
uni.scanCode({
success: (res) => {
// 解析扫到的订单信息
const orderData = JSON.parse(res.result)
// 调用后端接口确认完成
this.confirmOrder(orderData)
},
fail: (err) => {
uni.showToast({ title: '扫码失败,请重试', icon: 'none' })
}
})
}
就这么简单!uni.scanCode 这一个 API,在微信小程序、鸿蒙 App、Android App 上都能完美运行。不需要分别调用微信的 wx.scanCode、鸿蒙的扫码接口、Android 的 ZXing 库,uni-app 帮我们抹平了所有平台差异。
上线后,用户反馈说:"这个功能太实用了!现在帮忙取快递,当面扫一下码,钱就自动结算了,双方都放心。"扫码核销的使用率达到了 85%,大大提升了平台的信任度。
从提出需求到上线,整个过程只用了 一个下午加一个晚上。如果用原生开发,这至少是个一周的工作量。
还有一次紧急修复支付 bug,从发现问题、定位代码、改完测试、重新打包发布,全程只用了 3 小时。要是用原生开发,光配置打包环境就得半天,根本做不到这么快。
技术之外的深刻体会
回顾这段创业经历,uni-app 带给我们的不仅是技术层面的便利,更是三个深刻的认知升级:
第一,工具选对比技术深度更重要
我们不是最懂技术的团队,但我们选对了工具。uni-app 让我们能用有限的能力,做出超越能力范围的产品。这教会我们:创业不是比谁更厉害,而是比谁更会借力,谁能用最小的成本达成目标。
第二,小步快跑胜过完美主义
一开始我们想做得很完美,所有功能都规划好再上线。但 uni-app 的高效率让我们改变了策略:先上线最小可行产品(MVP),根据用户反馈快速迭代。事实证明,这种敏捷开发的思路,让我们少走了太多弯路。
第三,拥抱国产生态找到新机会
支持鸿蒙平台,让我们在比赛中获得了差异化优势。更重要的是,我们作为早期的鸿蒙应用开发者,积累了宝贵的经验。现在华为在大力推广鸿蒙系统,我们这份经历可能成为未来的核心竞争力。
给学弟学妹的建议
现在经常有学弟学妹问我:"学长,我也想做创业项目,但不太会编程怎么办?"
我的回答是:不要被技术门槛吓住,从现在的工具开始学起。
如果你有一点 Web 前端基础(哪怕只是 HTML + CSS),或者愿意花两周时间跟着教程学习,uni-app 就能帮你实现从想法到产品的跨越。它的学习曲线足够平缓,文档足够详细,社区足够活跃,非常适合学生创业团队。
而且,选择 uni-app 还有额外的好处:
- 就业市场认可度高:跨平台开发是行业趋势,掌握 uni-app 的开发经验,找实习找工作都是加分项
- 鸿蒙生态红利期:现在支持鸿蒙的开发者还不多,早入场意味着更多机会
- 创业成本极低:不用组建庞大的技术团队,两三个人就能启动项目
最重要的是:不要等自己"准备好了"再开始,行动本身就是最好的学习。
未来的路还很长
到今天,"校园人人帮"已经运营了半年,校内注册用户突破 500 人,日均订单量稳定在 50 单左右。虽然规模不大,但每天都在解决真实的需求,每天都有同学因为我们的产品生活变得更便捷一点。
这种感觉,比任何成就感都更持久。
接下来,我们计划:
- 推广到周边高校:验证模式的可复制性
- 开发鸿蒙元服务:利用鸿蒙的快捷入口,降低用户使用门槛
- 对接校园一卡通:实现更便捷的支付体验
- 引入更多校园服务:打印、修电脑、租借物品......
技术方面,我们会继续深入学习 uni-app 的高级特性,也会关注鸿蒙生态的最新动态。毕竟,工具在进化,我们也要跟着成长。
致谢与寄语
感谢 DCloud 团队开发了 uni-app 这样优秀的跨平台框架,让我们这些非科班出身的学生也能实现技术梦想。
感谢华为鸿蒙团队的开放生态,给了我们展示作品的舞台。
感谢所有支持和使用"校园人人帮"的同学,你们的每一个订单、每一条反馈,都是我们坚持下去的动力。
也感谢那些还在为技术门槛发愁的学弟学妹们——如果我们四个"技术小白"都能做出产品并获奖,你们一定也可以。
真正的创新,不在于掌握多么高深的技术,而在于用合适的工具,解决真实的问题,创造真实的价值。
愿每一个有梦想的大学生,都能找到属于自己的技术工具,在年轻的时候勇敢试错,在国产生态的浪潮中留下自己的足迹。
星光不负,码向未来。我们的故事还在继续,你的故事也即将开始。
最后,再附上几张效果图
收起阅读 »朋友不在多少,在于真心交往
生活中,时常惦记你,才是心里有你的人,一向陪着你,才是最爱你的人,待你忽冷忽热的,也许只是寂寞,离你时远时近的,也许只是需要,伪装不出的担心,是真诚,掩饰不住的思念,是感情,每个人的心,都难免有寂寞,并非喜欢安静,是没有人能分担内心的脆弱,谁都害怕孤独,若有人能懂谁愿意寂寞,坚强不代表所有伤都能扛,而是假装着不难过,沉默不代表心里没感觉,而是现实你别无选取,不敢让自己太过在乎,是怕别人根本不在乎;不敢让自己不顾一切,因为没人会把你当成全世界,寂寞,或许很可怜;走不出寂寞,才是最可怜,缘分里有无数的擦肩而过,不属于自己的永久只是过客。生命中有那么多的难以割舍,无法挽留的终归还是错过,不是每一个相识,都能触动心灵,不是每一份相知,都能解读心音,人的一生,只求有一道风景令自己流连,自然心安,便是倾心的驿站。
生活中,时常惦记你,才是心里有你的人,一向陪着你,才是最爱你的人,待你忽冷忽热的,也许只是寂寞,离你时远时近的,也许只是需要,伪装不出的担心,是真诚,掩饰不住的思念,是感情,每个人的心,都难免有寂寞,并非喜欢安静,是没有人能分担内心的脆弱,谁都害怕孤独,若有人能懂谁愿意寂寞,坚强不代表所有伤都能扛,而是假装着不难过,沉默不代表心里没感觉,而是现实你别无选取,不敢让自己太过在乎,是怕别人根本不在乎;不敢让自己不顾一切,因为没人会把你当成全世界,寂寞,或许很可怜;走不出寂寞,才是最可怜,缘分里有无数的擦肩而过,不属于自己的永久只是过客。生命中有那么多的难以割舍,无法挽留的终归还是错过,不是每一个相识,都能触动心灵,不是每一份相知,都能解读心音,人的一生,只求有一道风景令自己流连,自然心安,便是倾心的驿站。
收起阅读 »【鸿蒙征文】折腾鸿蒙分享功能的那些事儿
事情是这样的
前两天在家撸代码,突然想给我的小破app加个分享功能。本来想偷个懒,直接用现成的插件算了。结果一看,好家伙,都要收费。我寻思着这玩意儿能有多难?不就是调个系统API嘛,自己搞一个呗。
没想到这一入坑,还真挺有意思。鸟蒙的API设计还挺人性化的,虽然踩了不少坑,但学到的东西也不少。
文件咋放的
也没搞得很复杂,就几个文件:
cool-share/
├── utssdk/
├── interface.uts # 给别人用的接口
└── app-harmony/ # 鸿蒙专用代码
├── index.uts # 入口文件
└── share.ets # 干活的代码
先定个规矩
接口嘛,就是告诉别人怎么调用我这个东西:
export type ShareWithSystemOptions = {
type: string; // 分享啥类型的玩意儿
title?: string; // 起个标题
summary?: string; // 写点描述
href?: string; // 链接或者文件在哪儿
imageUrl?: string; // 图片视频的地址
success?: () => void; // 成功了干啥
fail?: (error: string) => void; // 失败了咋办
};
开始干活了
这块儿是重点,也是我掉坑最多的地方。
先把家伙事儿准备好
import { systemShare } from "@kit.ShareKit";
import { uniformTypeDescriptor as utd } from "@kit.ArkData";
import { common } from "@kit.AbilityKit";
import { fileUri } from "@kit.CoreFileKit";
import { UTSHarmony } from "@dcloudio/uni-app-x-runtime";
这些都是鸿蒙给咱准备的工具,分别管分享、识别文件类型、获取应用信息、处理文件路径这些活儿。
分享类型定义
enum ShareType {
TEXT = "text", // 纯文本
IMAGE = "image", // 图片
VIDEO = "video", // 视频
AUDIO = "audio", // 音频
FILE = "file", // 文件
LINK = "link" // 链接
}
分享图片咋整
图片分享最常用,咱先搞这个:
function createImageShareData(
imageUrl: string,
title: string,
summary: string
): systemShare.SharedData | null {
if (imageUrl === "") {
return null;
}
// 这里要注意,需要先获取正确的文件路径
const filePath = UTSHarmony.getResourcePath(imageUrl);
// 然后获取文件的数据类型标识符
const utdTypeId = getUtdTypeByPath(filePath, utd.UniformDataType.IMAGE);
// 最后创建分享数据对象
return new systemShare.SharedData({
utd: utdTypeId, // 数据类型
uri: fileUri.getUriFromPath(filePath), // 文件URI
title: title, // 标题
description: summary // 描述
});
}
怎么用这玩意儿
在你的页面里这么写就行:
import { shareWithSystem } from "@/uni_modules/cool-share";
// 分享个图片
shareWithSystem({
type: "image",
title: "我拍的照片",
summary: "今天拍的,还不错吧",
imageUrl: "https://cool-js.com/logo.png",
success: () => {
console.log("success");
},
fail: (error) => {
console.log(error);
}
});
// 分享点文字
shareWithSystem({
type: "text",
title: "今日心情",
summary: "今天天气不错,心情美美哒~",
success: () => {
console.log("success");
}
});
// 分享个链接
shareWithSystem({
type: "link",
title: "发现个好网站",
summary: "这网站挺有意思的,你们看看",
href: "https://cool-js.com/",
success: () => {
console.log("success");
}
});
UTD是个啥东西
鸿蒙用UTD(统一数据类型标识符)来识别文件类型。简单说就是告诉系统:"嘿,这是个图片!"或者"这是个视频!"
function getUtdTypeByPath(filePath: string, defaultType: string): string {
const ext = filePath?.split(".")?.pop()?.toLowerCase() ?? "";
if (ext === "") {
return defaultType;
}
return utd.getUniformDataTypeByFilenameExtension("." + ext, defaultType);
}
这个函数就是看文件后缀名,.jpg就知道是图片,.mp4就知道是视频,就这么简单。
文件分享稍微麻烦点
文件分享比较复杂,得看是啥类型的文件:
function createFileShareData(
filePath: string,
title: string,
summary: string
): systemShare.SharedData | null {
if (filePath === "") {
return null;
}
const resourcePath = UTSHarmony.getResourcePath(filePath);
const ext = resourcePath?.split(".")?.pop()?.toLowerCase() ?? "";
// 根据文件扩展名确定数据类型
let utdType = utd.UniformDataType.FILE;
switch (ext) {
case "zip":
case "rar":
case "7z":
utdType = utd.UniformDataType.ARCHIVE; // 压缩包
break;
case "pdf":
utdType = utd.UniformDataType.PDF; // PDF文档
break;
case "doc":
case "docx":
utdType = utd.UniformDataType.WORD_DOC; // Word文档
break;
case "xls":
case "xlsx":
utdType = utd.UniformDataType.EXCEL; // Excel表格
break;
default:
utdType = utd.UniformDataType.FILE; // 普通文件
break;
}
const utdTypeId = utd.getUniformDataTypeByFilenameExtension("." + ext, utdType);
return new systemShare.SharedData({
utd: utdTypeId,
uri: fileUri.getUriFromPath(resourcePath),
title: title,
description: summary
});
}
最后一步,弹出分享框
数据都准备好了,现在可以叫出系统的分享面板了:
export function share(
type: string,
title: string,
summary: string,
href: string,
imageUrl: string,
success: () => void,
fail: (error: string) => void
): void {
// 先获取当前应用的上下文,这个是必须的
const uiContext: UIContext = UTSHarmony.getCurrentWindow()?.getUIContext();
const context: common.UIAbilityContext = uiContext.getHostContext() as common.UIAbilityContext;
// 根据不同类型创建对应的分享数据
let shareData: systemShare.SharedData | null = null;
let errorMsg = "";
switch (type) {
case ShareType.IMAGE:
shareData = createImageShareData(imageUrl, title, summary);
errorMsg = "图片路径不能为空";
break;
case ShareType.VIDEO:
shareData = createVideoShareData(imageUrl, title, summary);
errorMsg = "视频路径不能为空";
break;
case ShareType.LINK:
shareData = createLinkShareData(href, title, summary);
break;
default:
// 默认当文本处理
shareData = createTextShareData(title, summary);
break;
}
// 检查数据是否有效
if (shareData === null) {
fail(errorMsg);
return;
}
// 创建分享控制器
const controller: systemShare.ShareController = new systemShare.ShareController(shareData);
// 显示分享面板
controller
.show(context, {
selectionMode: systemShare.SelectionMode.SINGLE, // 单选模式
previewMode: systemShare.SharePreviewMode.DEFAULT // 默认预览
})
.then(() => {
success(); // 分享成功
})
.catch((error: BusinessError) => {
fail(error?.message ?? "分享失败"); // 分享失败
});
}
ETS开发小技巧
在写这个插件的过程中,我总结了一些ETS开发的小技巧:
1. 空值安全处理
ETS对空值检查很严格,要养成使用??操作符的习惯:
// 好习惯:使用空值合并操作符
const ext = filePath?.split(".")?.pop()?.toLowerCase() ?? "";
// 而不是这样(可能报错)
const ext = filePath.split(".").pop().toLowerCase();
2. 类型断言要谨慎
尽量避免使用as进行强制类型转换,多用类型检查:
// 推荐的写法
if (context instanceof common.UIAbilityContext) {
// 安全地使用context
}
// 而不是直接断言
const context = uiContext.getHostContext() as common.UIAbilityContext;
3. 错误处理要完整
鸿蒙的异步操作都是Promise,记得处理catch:
controller
.show(context, options)
.then(() => {
success();
})
.catch((error: BusinessError) => {
// 一定要处理错误情况
const errorMessage = error?.message ?? "未知错误";
fail(errorMessage);
});
4. 文件路径处理
鸿蒙对文件路径很敏感,一定要用正确的API获取路径:
// 正确的做法
const filePath = UTSHarmony.getResourcePath(imageUrl);
const uri = fileUri.getUriFromPath(filePath);
// 而不是直接拼接路径
我踩过的那些坑
1. 分享框死活出不来
刚开始的时候,代码写好了,点击分享按钮啥反应都没有。搞了半天才发现是context获取有问题:
// 错误的做法 - 可能获取不到context
const context = UTSHarmony.getUniActivity();
// 正确的做法
const uiContext = UTSHarmony.getCurrentWindow()?.getUIContext();
const context = uiContext.getHostContext() as common.UIAbilityContext;
2. 文件跟我玩捉迷藏
本地文件分享老是失败,我还以为是代码逻辑有问题。结果折腾了一晚上才发现,是文件路径搞错了:
// 错误:直接使用相对路径
imageUrl: "./static/image.jpg";
// 正确:使用绝对路径或者让UTSHarmony处理
imageUrl: "/static/image.jpg";
const realPath = UTSHarmony.getResourcePath(imageUrl);
3. 文件类型识别翻车了
有时候文件类型识别不对,分享到微信QQ就会出现奇怪的问题:
// 保险的做法:先判断扩展名,再设置UTD类型
let utdType = utd.UniformDataType.FILE; // 默认类型
switch (ext) {
case "jpg":
case "jpeg":
case "png":
utdType = utd.UniformDataType.IMAGE;
break;
// 其他类型...
}
4. 异步操作坑死人
Promise的错误处理千万别偷懒,不然出了问题你都不知道哪里错了,我就吃过这个亏:
controller
.show(context, options)
.then(() => {
console.log("分享成功"); // 调试信息很重要
success();
})
.catch((error: BusinessError) => {
console.error("分享失败:", error); // 打印错误信息
fail(error?.message ?? "分享失败");
});
5. 权限这茬儿
有时候会遇到权限不够的情况,特别是读取文件的时候。记得检查app的权限配置,不然用户点了分享啥反应都没有。
一些常见问题
Q: 分享面板怎么是空白的?
A: 检查这几个地方:
- context获取对了没
- ShareData有没有创建成功(别返回null)
- 文件路径对不对
- UTD类型匹配不
Q: 为啥有些app收不到我分享的东西?
A: 估计是UTD类型不匹配,或者那个app不认这种数据格式。试试用通用一点的类型,比如utd.UniformDataType.FILE。
Q: 网络上的图片能直接分享吗?
A: 不行,得先下载到本地,然后分享本地文件。
Q: 分享大文件会卡吗?
A: 会的,特别是视频文件。建议加个转圈圈的loading,或者提前压缩一下。
Q: 能知道用户选了哪个app分享吗?
A: 目前鸿蒙的API不支持,只能知道用户是分享成功了还是取消了。
Q: 能自己做个分享面板吗?
A: 不行,只能用系统提供的。不过可以通过selectionMode和previewMode参数稍微调整一下样式。
测试这块儿
真机测试的时候发现了不少问题,建议你们也多试试:
- 各种文件类型:图片、视频、文档啥的都试试
- 文件大小:小图片秒传,大视频可能要等一会儿
- 不同app:微信、QQ、邮箱的接收效果都不一样
- 网络状况:网不好的时候网络图片可能加载不出来
调试的时候多打印,鸿蒙的报错信息有时候说得不够清楚。
总结
折腾了好几天,总算把这个分享功能搞定了。整体感觉鸿蒙的API还挺人性化的,比我想象中好用多了。
几个心得:
- 多翻文档:鸿蒙官方文档写得还可以,遇到问题先去翻翻
- 类型别偷懒:ETS的类型检查确实严格,但写出来的代码更稳定
- 错误要处理好:异步操作的错误处理千万别省,不然出了bug找都找不到
- 真机多测试:模拟器和真机表现可能不一样,都试试保险点
现在这个小插件基本能满足日常需要了。如果你也在搞类似的东西,希望我这些踩坑经验能帮到你。有问题咱们可以一起交流。
所有代码都在uni_modules/cool-share目录里,有兴趣的朋友可以看看。
顺便安利个东西
我们团队还做了个 Cool Unix 组件库,也是基于 Uni-App X 的,完全免费开源。里面有 Tailwind CSS、多主题、国际化这些实用功能,还有挺多现成的组件和页面模板。
最有意思的是,这个组件库配合AI生成鸿蒙页面效果特别好,以后还准备加上后端接口自动生成,真正做到一句话就能搞出个app。如果你也想快速开发鸿蒙应用,可以试试看。
最后感谢一下
感谢 uni-app x 团队做出这么棒的跨平台框架,让我们这些普通开发者也能轻松搞定多端开发。特别是UTS语言和鸿蒙支持,真的降低了不少门槛。
事情是这样的
前两天在家撸代码,突然想给我的小破app加个分享功能。本来想偷个懒,直接用现成的插件算了。结果一看,好家伙,都要收费。我寻思着这玩意儿能有多难?不就是调个系统API嘛,自己搞一个呗。
没想到这一入坑,还真挺有意思。鸟蒙的API设计还挺人性化的,虽然踩了不少坑,但学到的东西也不少。
文件咋放的
也没搞得很复杂,就几个文件:
cool-share/
├── utssdk/
├── interface.uts # 给别人用的接口
└── app-harmony/ # 鸿蒙专用代码
├── index.uts # 入口文件
└── share.ets # 干活的代码
先定个规矩
接口嘛,就是告诉别人怎么调用我这个东西:
export type ShareWithSystemOptions = {
type: string; // 分享啥类型的玩意儿
title?: string; // 起个标题
summary?: string; // 写点描述
href?: string; // 链接或者文件在哪儿
imageUrl?: string; // 图片视频的地址
success?: () => void; // 成功了干啥
fail?: (error: string) => void; // 失败了咋办
};
开始干活了
这块儿是重点,也是我掉坑最多的地方。
先把家伙事儿准备好
import { systemShare } from "@kit.ShareKit";
import { uniformTypeDescriptor as utd } from "@kit.ArkData";
import { common } from "@kit.AbilityKit";
import { fileUri } from "@kit.CoreFileKit";
import { UTSHarmony } from "@dcloudio/uni-app-x-runtime";
这些都是鸿蒙给咱准备的工具,分别管分享、识别文件类型、获取应用信息、处理文件路径这些活儿。
分享类型定义
enum ShareType {
TEXT = "text", // 纯文本
IMAGE = "image", // 图片
VIDEO = "video", // 视频
AUDIO = "audio", // 音频
FILE = "file", // 文件
LINK = "link" // 链接
}
分享图片咋整
图片分享最常用,咱先搞这个:
function createImageShareData(
imageUrl: string,
title: string,
summary: string
): systemShare.SharedData | null {
if (imageUrl === "") {
return null;
}
// 这里要注意,需要先获取正确的文件路径
const filePath = UTSHarmony.getResourcePath(imageUrl);
// 然后获取文件的数据类型标识符
const utdTypeId = getUtdTypeByPath(filePath, utd.UniformDataType.IMAGE);
// 最后创建分享数据对象
return new systemShare.SharedData({
utd: utdTypeId, // 数据类型
uri: fileUri.getUriFromPath(filePath), // 文件URI
title: title, // 标题
description: summary // 描述
});
}
怎么用这玩意儿
在你的页面里这么写就行:
import { shareWithSystem } from "@/uni_modules/cool-share";
// 分享个图片
shareWithSystem({
type: "image",
title: "我拍的照片",
summary: "今天拍的,还不错吧",
imageUrl: "https://cool-js.com/logo.png",
success: () => {
console.log("success");
},
fail: (error) => {
console.log(error);
}
});
// 分享点文字
shareWithSystem({
type: "text",
title: "今日心情",
summary: "今天天气不错,心情美美哒~",
success: () => {
console.log("success");
}
});
// 分享个链接
shareWithSystem({
type: "link",
title: "发现个好网站",
summary: "这网站挺有意思的,你们看看",
href: "https://cool-js.com/",
success: () => {
console.log("success");
}
});
UTD是个啥东西
鸿蒙用UTD(统一数据类型标识符)来识别文件类型。简单说就是告诉系统:"嘿,这是个图片!"或者"这是个视频!"
function getUtdTypeByPath(filePath: string, defaultType: string): string {
const ext = filePath?.split(".")?.pop()?.toLowerCase() ?? "";
if (ext === "") {
return defaultType;
}
return utd.getUniformDataTypeByFilenameExtension("." + ext, defaultType);
}
这个函数就是看文件后缀名,.jpg就知道是图片,.mp4就知道是视频,就这么简单。
文件分享稍微麻烦点
文件分享比较复杂,得看是啥类型的文件:
function createFileShareData(
filePath: string,
title: string,
summary: string
): systemShare.SharedData | null {
if (filePath === "") {
return null;
}
const resourcePath = UTSHarmony.getResourcePath(filePath);
const ext = resourcePath?.split(".")?.pop()?.toLowerCase() ?? "";
// 根据文件扩展名确定数据类型
let utdType = utd.UniformDataType.FILE;
switch (ext) {
case "zip":
case "rar":
case "7z":
utdType = utd.UniformDataType.ARCHIVE; // 压缩包
break;
case "pdf":
utdType = utd.UniformDataType.PDF; // PDF文档
break;
case "doc":
case "docx":
utdType = utd.UniformDataType.WORD_DOC; // Word文档
break;
case "xls":
case "xlsx":
utdType = utd.UniformDataType.EXCEL; // Excel表格
break;
default:
utdType = utd.UniformDataType.FILE; // 普通文件
break;
}
const utdTypeId = utd.getUniformDataTypeByFilenameExtension("." + ext, utdType);
return new systemShare.SharedData({
utd: utdTypeId,
uri: fileUri.getUriFromPath(resourcePath),
title: title,
description: summary
});
}
最后一步,弹出分享框
数据都准备好了,现在可以叫出系统的分享面板了:
export function share(
type: string,
title: string,
summary: string,
href: string,
imageUrl: string,
success: () => void,
fail: (error: string) => void
): void {
// 先获取当前应用的上下文,这个是必须的
const uiContext: UIContext = UTSHarmony.getCurrentWindow()?.getUIContext();
const context: common.UIAbilityContext = uiContext.getHostContext() as common.UIAbilityContext;
// 根据不同类型创建对应的分享数据
let shareData: systemShare.SharedData | null = null;
let errorMsg = "";
switch (type) {
case ShareType.IMAGE:
shareData = createImageShareData(imageUrl, title, summary);
errorMsg = "图片路径不能为空";
break;
case ShareType.VIDEO:
shareData = createVideoShareData(imageUrl, title, summary);
errorMsg = "视频路径不能为空";
break;
case ShareType.LINK:
shareData = createLinkShareData(href, title, summary);
break;
default:
// 默认当文本处理
shareData = createTextShareData(title, summary);
break;
}
// 检查数据是否有效
if (shareData === null) {
fail(errorMsg);
return;
}
// 创建分享控制器
const controller: systemShare.ShareController = new systemShare.ShareController(shareData);
// 显示分享面板
controller
.show(context, {
selectionMode: systemShare.SelectionMode.SINGLE, // 单选模式
previewMode: systemShare.SharePreviewMode.DEFAULT // 默认预览
})
.then(() => {
success(); // 分享成功
})
.catch((error: BusinessError) => {
fail(error?.message ?? "分享失败"); // 分享失败
});
}
ETS开发小技巧
在写这个插件的过程中,我总结了一些ETS开发的小技巧:
1. 空值安全处理
ETS对空值检查很严格,要养成使用??操作符的习惯:
// 好习惯:使用空值合并操作符
const ext = filePath?.split(".")?.pop()?.toLowerCase() ?? "";
// 而不是这样(可能报错)
const ext = filePath.split(".").pop().toLowerCase();
2. 类型断言要谨慎
尽量避免使用as进行强制类型转换,多用类型检查:
// 推荐的写法
if (context instanceof common.UIAbilityContext) {
// 安全地使用context
}
// 而不是直接断言
const context = uiContext.getHostContext() as common.UIAbilityContext;
3. 错误处理要完整
鸿蒙的异步操作都是Promise,记得处理catch:
controller
.show(context, options)
.then(() => {
success();
})
.catch((error: BusinessError) => {
// 一定要处理错误情况
const errorMessage = error?.message ?? "未知错误";
fail(errorMessage);
});
4. 文件路径处理
鸿蒙对文件路径很敏感,一定要用正确的API获取路径:
// 正确的做法
const filePath = UTSHarmony.getResourcePath(imageUrl);
const uri = fileUri.getUriFromPath(filePath);
// 而不是直接拼接路径
我踩过的那些坑
1. 分享框死活出不来
刚开始的时候,代码写好了,点击分享按钮啥反应都没有。搞了半天才发现是context获取有问题:
// 错误的做法 - 可能获取不到context
const context = UTSHarmony.getUniActivity();
// 正确的做法
const uiContext = UTSHarmony.getCurrentWindow()?.getUIContext();
const context = uiContext.getHostContext() as common.UIAbilityContext;
2. 文件跟我玩捉迷藏
本地文件分享老是失败,我还以为是代码逻辑有问题。结果折腾了一晚上才发现,是文件路径搞错了:
// 错误:直接使用相对路径
imageUrl: "./static/image.jpg";
// 正确:使用绝对路径或者让UTSHarmony处理
imageUrl: "/static/image.jpg";
const realPath = UTSHarmony.getResourcePath(imageUrl);
3. 文件类型识别翻车了
有时候文件类型识别不对,分享到微信QQ就会出现奇怪的问题:
// 保险的做法:先判断扩展名,再设置UTD类型
let utdType = utd.UniformDataType.FILE; // 默认类型
switch (ext) {
case "jpg":
case "jpeg":
case "png":
utdType = utd.UniformDataType.IMAGE;
break;
// 其他类型...
}
4. 异步操作坑死人
Promise的错误处理千万别偷懒,不然出了问题你都不知道哪里错了,我就吃过这个亏:
controller
.show(context, options)
.then(() => {
console.log("分享成功"); // 调试信息很重要
success();
})
.catch((error: BusinessError) => {
console.error("分享失败:", error); // 打印错误信息
fail(error?.message ?? "分享失败");
});
5. 权限这茬儿
有时候会遇到权限不够的情况,特别是读取文件的时候。记得检查app的权限配置,不然用户点了分享啥反应都没有。
一些常见问题
Q: 分享面板怎么是空白的?
A: 检查这几个地方:
- context获取对了没
- ShareData有没有创建成功(别返回null)
- 文件路径对不对
- UTD类型匹配不
Q: 为啥有些app收不到我分享的东西?
A: 估计是UTD类型不匹配,或者那个app不认这种数据格式。试试用通用一点的类型,比如utd.UniformDataType.FILE。
Q: 网络上的图片能直接分享吗?
A: 不行,得先下载到本地,然后分享本地文件。
Q: 分享大文件会卡吗?
A: 会的,特别是视频文件。建议加个转圈圈的loading,或者提前压缩一下。
Q: 能知道用户选了哪个app分享吗?
A: 目前鸿蒙的API不支持,只能知道用户是分享成功了还是取消了。
Q: 能自己做个分享面板吗?
A: 不行,只能用系统提供的。不过可以通过selectionMode和previewMode参数稍微调整一下样式。
测试这块儿
真机测试的时候发现了不少问题,建议你们也多试试:
- 各种文件类型:图片、视频、文档啥的都试试
- 文件大小:小图片秒传,大视频可能要等一会儿
- 不同app:微信、QQ、邮箱的接收效果都不一样
- 网络状况:网不好的时候网络图片可能加载不出来
调试的时候多打印,鸿蒙的报错信息有时候说得不够清楚。
总结
折腾了好几天,总算把这个分享功能搞定了。整体感觉鸿蒙的API还挺人性化的,比我想象中好用多了。
几个心得:
- 多翻文档:鸿蒙官方文档写得还可以,遇到问题先去翻翻
- 类型别偷懒:ETS的类型检查确实严格,但写出来的代码更稳定
- 错误要处理好:异步操作的错误处理千万别省,不然出了bug找都找不到
- 真机多测试:模拟器和真机表现可能不一样,都试试保险点
现在这个小插件基本能满足日常需要了。如果你也在搞类似的东西,希望我这些踩坑经验能帮到你。有问题咱们可以一起交流。
所有代码都在uni_modules/cool-share目录里,有兴趣的朋友可以看看。
顺便安利个东西
我们团队还做了个 Cool Unix 组件库,也是基于 Uni-App X 的,完全免费开源。里面有 Tailwind CSS、多主题、国际化这些实用功能,还有挺多现成的组件和页面模板。
最有意思的是,这个组件库配合AI生成鸿蒙页面效果特别好,以后还准备加上后端接口自动生成,真正做到一句话就能搞出个app。如果你也想快速开发鸿蒙应用,可以试试看。
最后感谢一下
感谢 uni-app x 团队做出这么棒的跨平台框架,让我们这些普通开发者也能轻松搞定多端开发。特别是UTS语言和鸿蒙支持,真的降低了不少门槛。
收起阅读 »手贱,离线基座打包编译错误MAMapKit
动态创建web-view加载本地html 页面通讯
我们日常使用web-view最常见的方法是新建一个页面,然后放一个web-view并配置上我们的html页面地址
这里有另外一种动态创建web-view的方法,更加的灵活
let wvPath = '/hybrid/html/webview.html'
let wv= plus.webview.create(
wvPath,
'map-view',
{
'uni-app': 'none',
top: systemInfo.statusBarHeight,
left: 0,
width: systemInfo.screenWidth,
height: systemInfo.screenHeight - systemInfo.statusBarHeight,
background: '#ffffff',
// 启用手势返回
// popGesture: 'close',
},
{
// 这里携带web-view的额外参数
key
}
)
// 一定要记得在不需要的时候 关闭掉web-view
wv.close()
监听动态创建的web-view发送的消息,切记plus.globalEvent.addEventListener('plusMessage', messageEvent)只需要添加一次,不要每次都添加监听
plus.globalEvent.addEventListener('plusMessage', messageEvent)
function messageEvent(e) {
// 检查数据结构
if (!e.data || !e.data.args || !e.data.args.data || !e.data.args.data.arg) {
console.error('接收到的消息格式不正确:', e)
return
}
let info = e.data.args.data.arg
}
竟然用到了plusMessage方法,本来直接使用的message监听发现怎么都不生效
很多人的使用习惯,既然我监听了消息,那么在我不需要的时候是需要把这个监听移除掉的,否则不停的监听影响APP性能,于是写了下面的移除方法:
plus.globalEvent.removeEventListener('plusMessage',messagEvent)
但是出乎意料,移除后整个APP的所有事件都失效!点击哪里都没有反应,切记这个方法是不可以移除的!
可以看到plus.globalEvent监听的是全局的plusMessage事件,移除后影响到了全局的事件,导致APP无响应。
大家会考虑到这样不能移除的话,不是一个好的监听消息方案,频繁监听消息各种类型的消息都会监听到,严重影响APP性能。
那么接下来我们考虑更换页面通信的方案。
第二种 消息传输方式 Webview url拦截
plus.globalEvent监听uni.postMessage推送的消息会出现重复推送等问题,建议改为Webview url拦截的方式获取html文件数据。
// html中跳转自定义url,会被拦截,不会进行跳转
let str = encodeURIComponent(JSON.stringify(obj))
location.href = `push://?${str}`
// 接收webview发送的通知消息
mapwv.overrideUrlLoading({ mode: 'reject'},(e) => {
let obj = JSON.parse(decodeURIComponent(e.url.split('?')[1]))
removeEvent()
})
参考文章:
https://www.html5plus.org/doc/zh_cn/webview.html
url拦截更实时,准确率更高,不会重复接收消息,只有App支持,H5 文档参考:https://www.html5plus.org/doc/zh_cn/webview.html#plus.webview.WebviewObject.overrideUrlLoading
https://ask.dcloud.net.cn/article/35083
我们日常使用web-view最常见的方法是新建一个页面,然后放一个web-view并配置上我们的html页面地址
这里有另外一种动态创建web-view的方法,更加的灵活
let wvPath = '/hybrid/html/webview.html'
let wv= plus.webview.create(
wvPath,
'map-view',
{
'uni-app': 'none',
top: systemInfo.statusBarHeight,
left: 0,
width: systemInfo.screenWidth,
height: systemInfo.screenHeight - systemInfo.statusBarHeight,
background: '#ffffff',
// 启用手势返回
// popGesture: 'close',
},
{
// 这里携带web-view的额外参数
key
}
)
// 一定要记得在不需要的时候 关闭掉web-view
wv.close()
监听动态创建的web-view发送的消息,切记plus.globalEvent.addEventListener('plusMessage', messageEvent)只需要添加一次,不要每次都添加监听
plus.globalEvent.addEventListener('plusMessage', messageEvent)
function messageEvent(e) {
// 检查数据结构
if (!e.data || !e.data.args || !e.data.args.data || !e.data.args.data.arg) {
console.error('接收到的消息格式不正确:', e)
return
}
let info = e.data.args.data.arg
}
竟然用到了plusMessage方法,本来直接使用的message监听发现怎么都不生效
很多人的使用习惯,既然我监听了消息,那么在我不需要的时候是需要把这个监听移除掉的,否则不停的监听影响APP性能,于是写了下面的移除方法:
plus.globalEvent.removeEventListener('plusMessage',messagEvent)
但是出乎意料,移除后整个APP的所有事件都失效!点击哪里都没有反应,切记这个方法是不可以移除的!
可以看到plus.globalEvent监听的是全局的plusMessage事件,移除后影响到了全局的事件,导致APP无响应。
大家会考虑到这样不能移除的话,不是一个好的监听消息方案,频繁监听消息各种类型的消息都会监听到,严重影响APP性能。
那么接下来我们考虑更换页面通信的方案。
第二种 消息传输方式 Webview url拦截
plus.globalEvent监听uni.postMessage推送的消息会出现重复推送等问题,建议改为Webview url拦截的方式获取html文件数据。
// html中跳转自定义url,会被拦截,不会进行跳转
let str = encodeURIComponent(JSON.stringify(obj))
location.href = `push://?${str}`
// 接收webview发送的通知消息
mapwv.overrideUrlLoading({ mode: 'reject'},(e) => {
let obj = JSON.parse(decodeURIComponent(e.url.split('?')[1]))
removeEvent()
})
参考文章:
https://www.html5plus.org/doc/zh_cn/webview.html
url拦截更实时,准确率更高,不会重复接收消息,只有App支持,H5 文档参考:https://www.html5plus.org/doc/zh_cn/webview.html#plus.webview.WebviewObject.overrideUrlLoading
https://ask.dcloud.net.cn/article/35083
































