HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

用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 -->

注意几个细节:

  1. 鸿蒙不用写type="2d"
  2. 鸿蒙用disable-scroll="true",其他平台用@touchmove.prevent
  3. 两个平台都得有canvas-idid

初始化时机很关键

不能在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 };  
};

绘制流程

整个绘制流程是这样的:

  1. 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'  
    };  
    };
  2. 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(); // 实时重绘  
    };
  3. 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();  
}

箭头的原理:

  1. 先算出线的角度:Math.atan2(dy, dx)
  2. 箭头两边各偏离30度(Math.PI / 6)
  3. 用三角函数算出两条边的终点坐标

绘制曲线(虚线)

曲线用来表示跑位,所以用虚线:

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);  
};

这个算法的核心思路:

  1. 把点投影到直线上
  2. 判断投影点是不是在线段范围内
  3. 算出点到投影点的距离

然后在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会清空画布

导出图片 - 生成分享海报

这个功能挺实用的,步骤是这样的:

  1. 先截取战术板:把球场、球员、战术线都画到临时canvas上
  2. 再画海报:在另一个canvas上画标题、战术板图片、底部信息
  3. 导出为图片:用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);  
    });  
  });  
};

注意destWidthdestHeight,设置成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. 图片清晰度问题

症状:导出的图片很模糊

原因:没设置destWidthdestHeight

解决:设置成实际尺寸的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编译到鸿蒙还是有不少坑的。不过搞定之后还挺有成就感的。

开发建议

  1. 先在H5上调试,等逻辑都对了再适配鸿蒙
  2. 鸿蒙的真机调试很慢,多用console.loguni.showToast
  3. 坐标问题一定要用实际设备测试,模拟器不准
  4. 多保存代码,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 -->

注意几个细节:

  1. 鸿蒙不用写type="2d"
  2. 鸿蒙用disable-scroll="true",其他平台用@touchmove.prevent
  3. 两个平台都得有canvas-idid

初始化时机很关键

不能在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 };  
};

绘制流程

整个绘制流程是这样的:

  1. 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'  
    };  
    };
  2. 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(); // 实时重绘  
    };
  3. 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();  
}

箭头的原理:

  1. 先算出线的角度:Math.atan2(dy, dx)
  2. 箭头两边各偏离30度(Math.PI / 6)
  3. 用三角函数算出两条边的终点坐标

绘制曲线(虚线)

曲线用来表示跑位,所以用虚线:

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);  
};

这个算法的核心思路:

  1. 把点投影到直线上
  2. 判断投影点是不是在线段范围内
  3. 算出点到投影点的距离

然后在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会清空画布

导出图片 - 生成分享海报

这个功能挺实用的,步骤是这样的:

  1. 先截取战术板:把球场、球员、战术线都画到临时canvas上
  2. 再画海报:在另一个canvas上画标题、战术板图片、底部信息
  3. 导出为图片:用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);  
    });  
  });  
};

注意destWidthdestHeight,设置成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. 图片清晰度问题

症状:导出的图片很模糊

原因:没设置destWidthdestHeight

解决:设置成实际尺寸的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编译到鸿蒙还是有不少坑的。不过搞定之后还挺有成就感的。

开发建议

  1. 先在H5上调试,等逻辑都对了再适配鸿蒙
  2. 鸿蒙的真机调试很慢,多用console.loguni.showToast
  3. 坐标问题一定要用实际设备测试,模拟器不准
  4. 多保存代码,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请求库,解决几个问题:

  1. 统一的请求配置

    • baseUrl配置
    • 默认请求头
    • 超时时间
  2. 请求拦截

    • 自动添加token
    • 自动添加版本号
    • 添加平台标识
  3. 响应处理

    • 统一的错误处理
    • 日志记录

代码结构大概这样:

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)

首页是个瀑布流展示热门表情的页面,核心功能:

  1. 瀑布流加载

    • 首次加载热门表情
    • 滚动到底部自动加载更多
  2. 返回顶部

    • 滚动超过500px显示返回顶部按钮
  3. 用户协议

    • 首次打开显示用户协议弹窗

关键代码片段:

// 获取热门表情  
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);  
});

技巧点:

  • loadinghasMore标记来避免重复请求
  • isLoadMore参数区分首次加载和追加加载
  • onReachBottom是uni-app提供的触底钩子

4.2 详情页(pages/detail/detail.vue)

详情页展示单个表情包,可以下载保存,还会推荐相关的表情。

关键功能:

  1. 接收参数

    onLoad((options) => {  
     if (options.item) {  
       emojiData.value = JSON.parse(decodeURIComponent(options.item))  
     }  
    })  
  2. 下载保存

    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' })  
             }  
           })  
         }  
       }  
     })  
    }  
  3. 相关推荐

    • 调用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 环境准备

  1. 安装HBuilderX

    • 去DCloud官网下载最新版HBuilderX
    • 必须是支持鸿蒙的版本
  2. 准备证书

    • 去华为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 运行项目

  1. 在HBuilderX中打开项目
  2. 点击工具栏的"运行"
  3. 选择"运行到鸿蒙"
  4. 选择设备
  5. 等待编译完成,APP会自动安装到设备上

6.4 打包发布

  1. 配置发布证书

    • signingConfigs中配置release证书
    • 发布证书要在AGC控制台单独申请
  2. 本地打包

    • 点击HBuilderX的"发行"菜单
    • 选择"鸿蒙本地打包"
    • 生成.app文件
  3. 上架应用市场

    • 登录华为应用市场控制台
    • 上传.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开发鸿蒙应用的整套流程都走通了。

关键点就这几个:

  1. uni-app框架 - 跨平台的基础
  2. harmony-configs - 鸿蒙配置的核心
  3. 证书签名 - 打包发布的关键

只要搞懂这几个点,你也能把自己的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请求库,解决几个问题:

  1. 统一的请求配置

    • baseUrl配置
    • 默认请求头
    • 超时时间
  2. 请求拦截

    • 自动添加token
    • 自动添加版本号
    • 添加平台标识
  3. 响应处理

    • 统一的错误处理
    • 日志记录

代码结构大概这样:

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)

首页是个瀑布流展示热门表情的页面,核心功能:

  1. 瀑布流加载

    • 首次加载热门表情
    • 滚动到底部自动加载更多
  2. 返回顶部

    • 滚动超过500px显示返回顶部按钮
  3. 用户协议

    • 首次打开显示用户协议弹窗

关键代码片段:

// 获取热门表情  
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);  
});

技巧点:

  • loadinghasMore标记来避免重复请求
  • isLoadMore参数区分首次加载和追加加载
  • onReachBottom是uni-app提供的触底钩子

4.2 详情页(pages/detail/detail.vue)

详情页展示单个表情包,可以下载保存,还会推荐相关的表情。

关键功能:

  1. 接收参数

    onLoad((options) => {  
     if (options.item) {  
       emojiData.value = JSON.parse(decodeURIComponent(options.item))  
     }  
    })  
  2. 下载保存

    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' })  
             }  
           })  
         }  
       }  
     })  
    }  
  3. 相关推荐

    • 调用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 环境准备

  1. 安装HBuilderX

    • 去DCloud官网下载最新版HBuilderX
    • 必须是支持鸿蒙的版本
  2. 准备证书

    • 去华为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 运行项目

  1. 在HBuilderX中打开项目
  2. 点击工具栏的"运行"
  3. 选择"运行到鸿蒙"
  4. 选择设备
  5. 等待编译完成,APP会自动安装到设备上

6.4 打包发布

  1. 配置发布证书

    • signingConfigs中配置release证书
    • 发布证书要在AGC控制台单独申请
  2. 本地打包

    • 点击HBuilderX的"发行"菜单
    • 选择"鸿蒙本地打包"
    • 生成.app文件
  3. 上架应用市场

    • 登录华为应用市场控制台
    • 上传.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开发鸿蒙应用的整套流程都走通了。

关键点就这几个:

  1. uni-app框架 - 跨平台的基础
  2. harmony-configs - 鸿蒙配置的核心
  3. 证书签名 - 打包发布的关键

只要搞懂这几个点,你也能把自己的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鸿蒙开发实践 鸿蒙next 鸿蒙征文

就在其他开发者还在为鸿蒙适配焦头烂额时,我的多款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大证书分别是:

  1. p12:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
  2. csr:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
  3. cer:打开AGC平台需要登陆华为账号进行申请。在证书、APPID 和Profile菜单下操作,需要区分开发和生产证书类型。
  4. p7b:打开AGC平台需要登陆华为账号进行申请。该证书是根据应用来申请的。

申请p12和csr证书截图:

申请cer和p7b证书截图:

在HBX里面的完整配置

证书部分到此结束,还是非常简单滴!主要我这边是从注册账户到注册应用,再到注册证书,所以耗时多一点,如果再添加一个新应用的配置,那就是分分钟钟的事情了。

第三阶段:代码适配(核心8小时)

这里可能是大家最关心的部分——到底要改多少代码?

答案是:少得惊人!

我以一个物流类型的应用为案例进行举例,主要修改点包括:

  1. 登录模块:用华为账户替换以前的注册,华为账户和业务账户的绑定。
  2. 权限模块:调用华为的API获取手机号
  3. 账户注销:所有上架的应用都需要支持用户自主取消关联和账户注销。
  4. 分享功能:适配华为分享

一、登录模块主要调整的原因是:华为要求统一使用华为账户进行静默登录,那么如果以前已经有账户了,那就需要进行做关联即可。使用华为提供的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天时间就能解决所有兼容的关键。在此一并感谢整个团队!

五、避坑指南:实战中遇到的坑

当然,过程并非完全一帆风顺。记录几个可能帮到大家的坑:

  1. 图片路径问题:鸿蒙对绝对路径更敏感,建议使用相对路径
  2. CSS兼容性:部分CSS3特性需要添加鸿蒙前缀
  3. API异步处理:鸿蒙的API调用更强调异步编程
  4. 代码运行不生效:如果感觉代码没问题,热更新不生效,记得重启整个应用,甚至重启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+

七、给开发者同仁的真诚建议

  1. 立即行动:鸿蒙生态红利期就在眼前
  2. 从简单应用开始:先拿一个功能简单的应用试水
  3. 关注元服务:这是鸿蒙的独特优势,不要错过
  4. 利用uni-app社区:遇到的问题基本都能找到解决方案

结语:一个人的速度,一个时代的变革

有开发者朋友问我:"现在入场是不是太晚了?"

我的回答是:"当你知道的时候,就是最好的时机。"

uni-app + 鸿蒙的组合,让我在技术变革的浪潮中抓住了先机。 从焦虑观望到全面上架,只用了不到一周时间。这种效率,在过去的移动开发史上是从未有过的。

现在,轮到你了。

在这里顺带解释一下奖励金,华为最近的活动,只要上架成功,有活跃用户,或者参加uni-app的活动,都是可以拿到奖励金的。大家加油!

在得瑟下我们的用户量;


【实战资源分享】

欢迎在评论区交流适配经验,我会第一时间回复大家的问题!

继续阅读 »

就在其他开发者还在为鸿蒙适配焦头烂额时,我的多款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大证书分别是:

  1. p12:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
  2. csr:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
  3. cer:打开AGC平台需要登陆华为账号进行申请。在证书、APPID 和Profile菜单下操作,需要区分开发和生产证书类型。
  4. p7b:打开AGC平台需要登陆华为账号进行申请。该证书是根据应用来申请的。

申请p12和csr证书截图:

申请cer和p7b证书截图:

在HBX里面的完整配置

证书部分到此结束,还是非常简单滴!主要我这边是从注册账户到注册应用,再到注册证书,所以耗时多一点,如果再添加一个新应用的配置,那就是分分钟钟的事情了。

第三阶段:代码适配(核心8小时)

这里可能是大家最关心的部分——到底要改多少代码?

答案是:少得惊人!

我以一个物流类型的应用为案例进行举例,主要修改点包括:

  1. 登录模块:用华为账户替换以前的注册,华为账户和业务账户的绑定。
  2. 权限模块:调用华为的API获取手机号
  3. 账户注销:所有上架的应用都需要支持用户自主取消关联和账户注销。
  4. 分享功能:适配华为分享

一、登录模块主要调整的原因是:华为要求统一使用华为账户进行静默登录,那么如果以前已经有账户了,那就需要进行做关联即可。使用华为提供的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天时间就能解决所有兼容的关键。在此一并感谢整个团队!

五、避坑指南:实战中遇到的坑

当然,过程并非完全一帆风顺。记录几个可能帮到大家的坑:

  1. 图片路径问题:鸿蒙对绝对路径更敏感,建议使用相对路径
  2. CSS兼容性:部分CSS3特性需要添加鸿蒙前缀
  3. API异步处理:鸿蒙的API调用更强调异步编程
  4. 代码运行不生效:如果感觉代码没问题,热更新不生效,记得重启整个应用,甚至重启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+

七、给开发者同仁的真诚建议

  1. 立即行动:鸿蒙生态红利期就在眼前
  2. 从简单应用开始:先拿一个功能简单的应用试水
  3. 关注元服务:这是鸿蒙的独特优势,不要错过
  4. 利用uni-app社区:遇到的问题基本都能找到解决方案

结语:一个人的速度,一个时代的变革

有开发者朋友问我:"现在入场是不是太晚了?"

我的回答是:"当你知道的时候,就是最好的时机。"

uni-app + 鸿蒙的组合,让我在技术变革的浪潮中抓住了先机。 从焦虑观望到全面上架,只用了不到一周时间。这种效率,在过去的移动开发史上是从未有过的。

现在,轮到你了。

在这里顺带解释一下奖励金,华为最近的活动,只要上架成功,有活跃用户,或者参加uni-app的活动,都是可以拿到奖励金的。大家加油!

在得瑟下我们的用户量;


【实战资源分享】

欢迎在评论区交流适配经验,我会第一时间回复大家的问题!

收起阅读 »

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 智慧医疗
三等奖 项目模板 小疯子呵 福袋抽奖-看广告变现

鼓励奖

因很多插件值得推荐,所以增加了贡献奖的名额。

奖项 分类 获奖作者 获奖插件
贡献奖 UTS插件 鑫时代 xsd-request
贡献奖 UTS插件 1530948626 android ios 原生gps前后台定位,系统定位
贡献奖 UTS插件 shmily121314 rs232-serial
贡献奖 UTS插件 kaka_ 蓝牙 USB Wi-Fi 打印机UTS插件.支持安卓 iOS 支持TSPL CPCL ESC
贡献奖 UTS插件 1530948626 安卓悬浮窗插件 可自定义内容 悬浮窗 画中画 可拖拽,可切换浮窗大小
贡献奖 UTS插件 csr_hb read-nfc
贡献奖 UTS插件 康爱公社 kux-dayjs
贡献奖 UTS插件 1530948626 android mqtt 消息推送
贡献奖 UTS插件 kux kux-crypto
贡献奖 UTS插件 Face_AI FaceAISDK
贡献奖 UTS插件 李子 【Android+iOS+HarmonyNext】腾讯云即时通讯(IM)UTS插件
贡献奖 UTS插件 billkes_bg 高德定位 UTS-API 插件 - 全平台定位解决方案
贡献奖 UTS插件 陌上华年 lime-sqlite
贡献奖 UTS插件 Greetty Telegram分享,WA分享,Twitter分享,Instagram分享
贡献奖 UTS插件 文艺程序猿 facebook登录
贡献奖 UTS插件 文艺程序猿 OneSignal海外App推送服务 支持iOS和Andriod
贡献奖 UTS插件 倜傥 苹果登录
贡献奖 UTS插件 UxFrame UxFrame 微信SDK
贡献奖 UTS插件 叶柳垂杨 TTS(安卓、ios、鸿蒙、web)将文字转成语音播报
贡献奖 UTS插件 文若不是苟或 文若IM
贡献奖 UTS插件 early_Summer es-paypal
贡献奖 UTS插件 天生DR UTS 苹果登录
贡献奖 uniCloud 希语 有奖猜歌游戏--改猜字谜答题游戏
贡献奖 uniCloud eric_peng 【精品】拍照算数批改
贡献奖 HBuilderX 智谱AI CodeGeeX: AI Code AutoComplete, Chat, Auto Comment
贡献奖 HBuilderX luhaoyu_ ucoder-AI编程助手
贡献奖 HBuilderX raise 日志桥,让cursor自动识别HBuilderX运行报错日志,自动修复错误代码
贡献奖 JS SDK Blue_ 安卓权限申请、权限申请的使用目的、华为上架、小米上架
贡献奖 JS SDK 一泽 request uni-ajax 请求
贡献奖 JS SDK jones2000 K线 分时 通达信指标 深度图 报价列表 订单流 筹码分布 hqchart.v2
贡献奖 前端组件 zerojs zero-markdown-view(markdown解析)
贡献奖 前端组件 lishanjun 全能文件选择上传3.0-纯前端
贡献奖 前端组件 ikun_ui 【ikun-qrcode】极简的二维码生成组件,使用view而非canvas避免层级问题
贡献奖 前端组件 193***@qq.com sse 客户端组件,支持兼容:v2、v3、安卓、ios、浏览器、微信小程序
贡献奖 前端组件 101***@qq.com 好用视频播放器
贡献奖 前端组件 月上柳梢 解决软件在运行时,未见向用户告知权限申请的目的,华为等上架被拒问题
贡献奖 前端组件 119***@qq.com 3D模型展示、动画播放、模型点击 - three.js(threeJs)
贡献奖 前端组件 FreeAlive 图片编辑器
贡献奖 前端组件 不如摸鱼去 微信小程序隐私保护弹出框 隐私协议弹出框 隐私弹框
贡献奖 前端组件 前端组件开发 基于原生input增强选择picker插件用于地图定位选择位置 页面跳转选择数据
贡献奖 前端组件 100***@qq.com rn-signature 电子签名、兼容小程序、H5、APP、鸿蒙 横屏展示
贡献奖 前端组件 SingmyAaronLan SinleUI - uni-app x UI 框架
贡献奖 项目模板 SheepJS Shopro商城 vue3+pinia+vite前端项目模板
贡献奖 项目模板 张宇凡 免费、商用数字人再开源,UI美得一塌糊涂!
贡献奖 项目模板 森林君 上门按摩专业版(仿东郊到家、上门预约类皆适用)
贡献奖 项目模板 jcodeapp 充电桩扫码充电小程序纯前端模板
贡献奖 项目模板 优雅草科技 优雅草蜻蜓hr人才招聘系统-前端模板文件开源-企业人才招聘系统
贡献奖 项目模板 tangniyuqi UniPet宠物领养平台前端开源项目
贡献奖 项目模板 用户2737414 社区论坛、校园论坛系统、圈子模板
贡献奖 项目模板 billkes_bg 考试答题系统模版

奖项设置:

本次大赛与往届有一个差别,就是更加普惠。本次没有特等奖,三等奖也发放价值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超大鼠标垫

奖品说明:

  1. “插件包销”,是指获奖插件通过插件市场销售,DCloud兜底包销。以1等奖的1万元包销为例,如果获奖插件在插件市场1年内销售额没有达到1万元,则由DCloud付差额给获奖者进行兜底。包销只针对付费插件,如免费插件获得二等奖及以上奖励,其中的包销奖励无效。包销插件需持续迭代,如插件作者放弃维护,则包销无效。

  2. “HBuilderX预置”,是在HBuilderX新建项目界面,可直接选择该项目模板。这为插件带来大量的流量。不适合预置的插件类型,无法领取此奖项。

  3. 本次插件大赛,目标是普惠更广大开发者,解决很多工程师缺少鸿蒙真机的困境,故本次奖励的鸿蒙手机为统一型号为nova 14 256GB

  4. 鸿蒙手机的奖励需满足两个条件:

    • 插件兼容鸿蒙平台
    • 插件作者通过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 智慧医疗
三等奖 项目模板 小疯子呵 福袋抽奖-看广告变现

鼓励奖

因很多插件值得推荐,所以增加了贡献奖的名额。

奖项 分类 获奖作者 获奖插件
贡献奖 UTS插件 鑫时代 xsd-request
贡献奖 UTS插件 1530948626 android ios 原生gps前后台定位,系统定位
贡献奖 UTS插件 shmily121314 rs232-serial
贡献奖 UTS插件 kaka_ 蓝牙 USB Wi-Fi 打印机UTS插件.支持安卓 iOS 支持TSPL CPCL ESC
贡献奖 UTS插件 1530948626 安卓悬浮窗插件 可自定义内容 悬浮窗 画中画 可拖拽,可切换浮窗大小
贡献奖 UTS插件 csr_hb read-nfc
贡献奖 UTS插件 康爱公社 kux-dayjs
贡献奖 UTS插件 1530948626 android mqtt 消息推送
贡献奖 UTS插件 kux kux-crypto
贡献奖 UTS插件 Face_AI FaceAISDK
贡献奖 UTS插件 李子 【Android+iOS+HarmonyNext】腾讯云即时通讯(IM)UTS插件
贡献奖 UTS插件 billkes_bg 高德定位 UTS-API 插件 - 全平台定位解决方案
贡献奖 UTS插件 陌上华年 lime-sqlite
贡献奖 UTS插件 Greetty Telegram分享,WA分享,Twitter分享,Instagram分享
贡献奖 UTS插件 文艺程序猿 facebook登录
贡献奖 UTS插件 文艺程序猿 OneSignal海外App推送服务 支持iOS和Andriod
贡献奖 UTS插件 倜傥 苹果登录
贡献奖 UTS插件 UxFrame UxFrame 微信SDK
贡献奖 UTS插件 叶柳垂杨 TTS(安卓、ios、鸿蒙、web)将文字转成语音播报
贡献奖 UTS插件 文若不是苟或 文若IM
贡献奖 UTS插件 early_Summer es-paypal
贡献奖 UTS插件 天生DR UTS 苹果登录
贡献奖 uniCloud 希语 有奖猜歌游戏--改猜字谜答题游戏
贡献奖 uniCloud eric_peng 【精品】拍照算数批改
贡献奖 HBuilderX 智谱AI CodeGeeX: AI Code AutoComplete, Chat, Auto Comment
贡献奖 HBuilderX luhaoyu_ ucoder-AI编程助手
贡献奖 HBuilderX raise 日志桥,让cursor自动识别HBuilderX运行报错日志,自动修复错误代码
贡献奖 JS SDK Blue_ 安卓权限申请、权限申请的使用目的、华为上架、小米上架
贡献奖 JS SDK 一泽 request uni-ajax 请求
贡献奖 JS SDK jones2000 K线 分时 通达信指标 深度图 报价列表 订单流 筹码分布 hqchart.v2
贡献奖 前端组件 zerojs zero-markdown-view(markdown解析)
贡献奖 前端组件 lishanjun 全能文件选择上传3.0-纯前端
贡献奖 前端组件 ikun_ui 【ikun-qrcode】极简的二维码生成组件,使用view而非canvas避免层级问题
贡献奖 前端组件 193***@qq.com sse 客户端组件,支持兼容:v2、v3、安卓、ios、浏览器、微信小程序
贡献奖 前端组件 101***@qq.com 好用视频播放器
贡献奖 前端组件 月上柳梢 解决软件在运行时,未见向用户告知权限申请的目的,华为等上架被拒问题
贡献奖 前端组件 119***@qq.com 3D模型展示、动画播放、模型点击 - three.js(threeJs)
贡献奖 前端组件 FreeAlive 图片编辑器
贡献奖 前端组件 不如摸鱼去 微信小程序隐私保护弹出框 隐私协议弹出框 隐私弹框
贡献奖 前端组件 前端组件开发 基于原生input增强选择picker插件用于地图定位选择位置 页面跳转选择数据
贡献奖 前端组件 100***@qq.com rn-signature 电子签名、兼容小程序、H5、APP、鸿蒙 横屏展示
贡献奖 前端组件 SingmyAaronLan SinleUI - uni-app x UI 框架
贡献奖 项目模板 SheepJS Shopro商城 vue3+pinia+vite前端项目模板
贡献奖 项目模板 张宇凡 免费、商用数字人再开源,UI美得一塌糊涂!
贡献奖 项目模板 森林君 上门按摩专业版(仿东郊到家、上门预约类皆适用)
贡献奖 项目模板 jcodeapp 充电桩扫码充电小程序纯前端模板
贡献奖 项目模板 优雅草科技 优雅草蜻蜓hr人才招聘系统-前端模板文件开源-企业人才招聘系统
贡献奖 项目模板 tangniyuqi UniPet宠物领养平台前端开源项目
贡献奖 项目模板 用户2737414 社区论坛、校园论坛系统、圈子模板
贡献奖 项目模板 billkes_bg 考试答题系统模版

奖项设置:

本次大赛与往届有一个差别,就是更加普惠。本次没有特等奖,三等奖也发放价值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超大鼠标垫

奖品说明:

  1. “插件包销”,是指获奖插件通过插件市场销售,DCloud兜底包销。以1等奖的1万元包销为例,如果获奖插件在插件市场1年内销售额没有达到1万元,则由DCloud付差额给获奖者进行兜底。包销只针对付费插件,如免费插件获得二等奖及以上奖励,其中的包销奖励无效。包销插件需持续迭代,如插件作者放弃维护,则包销无效。

  2. “HBuilderX预置”,是在HBuilderX新建项目界面,可直接选择该项目模板。这为插件带来大量的流量。不适合预置的插件类型,无法领取此奖项。

  3. 本次插件大赛,目标是普惠更广大开发者,解决很多工程师缺少鸿蒙真机的困境,故本次奖励的鸿蒙手机为统一型号为nova 14 256GB

  4. 鸿蒙手机的奖励需满足两个条件:

    • 插件兼容鸿蒙平台
    • 插件作者通过DCloud专属链接到鸿蒙开发者平台注册一个新的开发者账号

除上述奖品外:

  • 二等奖及以上获奖插件作者,都将进入DCloud VIP技术支持群,享受优先的技术支付、问题反馈。
  • 所有获奖插件的集锦页面,还将通过HBuilderX工具、论坛、IM/QQ/微信群进行全量推广,给予优秀插件充分的曝光。

HBuilderX预置窗体界面如下:

奖牌照片如下:

奖品领取

请各位获奖作者尽快提交自己的邮寄地址,我们会陆续联系获奖人员发放奖品;
邮寄地址提交方式:登录ask社区,点击右上角个人头像,进入设置界面,设置界面下方补充快递邮寄地址。

已获奖的插件作者请继续升级迭代插件;

未获奖的今年还有机会,官方会继续为建设更好的uni-app x生态及更好的鸿蒙支持,推出其他计划。

不管是为了下次大赛获奖,还是为了把握uni-app x及鸿蒙替代的新浪潮,或者在插件市场通过售卖插件变现,都是值得期待的好事。

收起阅读 »

iOS 上架应用市场全流程指南,App Store 审核机制、证书管理与跨平台免 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 上架发布。
app store connect


七、跨平台团队的免 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 上架发布。
app store connect


七、跨平台团队的免 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,我们只需要写一套,然后在不同平台运行时做些微调

小程序先行策略

我们决定先做微信小程序版本,原因很简单:

  1. 校园推广最方便,扫码就能用
  2. 不需要用户下载安装
  3. 开发调试快,适合快速验证功能

写完核心功能后,在 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,我们只需要写一套,然后在不同平台运行时做些微调

小程序先行策略

我们决定先做微信小程序版本,原因很简单:

  1. 校园推广最方便,扫码就能用
  2. 不需要用户下载安装
  3. 开发调试快,适合快速验证功能

写完核心功能后,在 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 这样优秀的跨平台框架,让我们这些非科班出身的学生也能实现技术梦想。

感谢华为鸿蒙团队的开放生态,给了我们展示作品的舞台。

感谢所有支持和使用"校园人人帮"的同学,你们的每一个订单、每一条反馈,都是我们坚持下去的动力。

也感谢那些还在为技术门槛发愁的学弟学妹们——如果我们四个"技术小白"都能做出产品并获奖,你们一定也可以。

真正的创新,不在于掌握多么高深的技术,而在于用合适的工具,解决真实的问题,创造真实的价值。

愿每一个有梦想的大学生,都能找到属于自己的技术工具,在年轻的时候勇敢试错,在国产生态的浪潮中留下自己的足迹。

星光不负,码向未来。我们的故事还在继续,你的故事也即将开始。

最后,再附上几张效果图

收起阅读 »

朋友不在多少,在于真心交往

AMAP

生活中,时常惦记你,才是心里有你的人,一向陪着你,才是最爱你的人,待你忽冷忽热的,也许只是寂寞,离你时远时近的,也许只是需要,伪装不出的担心,是真诚,掩饰不住的思念,是感情,每个人的心,都难免有寂寞,并非喜欢安静,是没有人能分担内心的脆弱,谁都害怕孤独,若有人能懂谁愿意寂寞,坚强不代表所有伤都能扛,而是假装着不难过,沉默不代表心里没感觉,而是现实你别无选取,不敢让自己太过在乎,是怕别人根本不在乎;不敢让自己不顾一切,因为没人会把你当成全世界,寂寞,或许很可怜;走不出寂寞,才是最可怜,缘分里有无数的擦肩而过,不属于自己的永久只是过客。生命中有那么多的难以割舍,无法挽留的终归还是错过,不是每一个相识,都能触动心灵,不是每一份相知,都能解读心音,人的一生,只求有一道风景令自己流连,自然心安,便是倾心的驿站。

继续阅读 »

生活中,时常惦记你,才是心里有你的人,一向陪着你,才是最爱你的人,待你忽冷忽热的,也许只是寂寞,离你时远时近的,也许只是需要,伪装不出的担心,是真诚,掩饰不住的思念,是感情,每个人的心,都难免有寂寞,并非喜欢安静,是没有人能分担内心的脆弱,谁都害怕孤独,若有人能懂谁愿意寂寞,坚强不代表所有伤都能扛,而是假装着不难过,沉默不代表心里没感觉,而是现实你别无选取,不敢让自己太过在乎,是怕别人根本不在乎;不敢让自己不顾一切,因为没人会把你当成全世界,寂寞,或许很可怜;走不出寂寞,才是最可怜,缘分里有无数的擦肩而过,不属于自己的永久只是过客。生命中有那么多的难以割舍,无法挽留的终归还是错过,不是每一个相识,都能触动心灵,不是每一份相知,都能解读心音,人的一生,只求有一道风景令自己流连,自然心安,便是倾心的驿站。

收起阅读 »

【鸿蒙征文】折腾鸿蒙分享功能的那些事儿

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: 检查这几个地方:

  1. context获取对了没
  2. ShareData有没有创建成功(别返回null)
  3. 文件路径对不对
  4. UTD类型匹配不

Q: 为啥有些app收不到我分享的东西?

A: 估计是UTD类型不匹配,或者那个app不认这种数据格式。试试用通用一点的类型,比如utd.UniformDataType.FILE

Q: 网络上的图片能直接分享吗?

A: 不行,得先下载到本地,然后分享本地文件。

Q: 分享大文件会卡吗?

A: 会的,特别是视频文件。建议加个转圈圈的loading,或者提前压缩一下。

Q: 能知道用户选了哪个app分享吗?

A: 目前鸿蒙的API不支持,只能知道用户是分享成功了还是取消了。

Q: 能自己做个分享面板吗?

A: 不行,只能用系统提供的。不过可以通过selectionModepreviewMode参数稍微调整一下样式。

测试这块儿

真机测试的时候发现了不少问题,建议你们也多试试:

  • 各种文件类型:图片、视频、文档啥的都试试
  • 文件大小:小图片秒传,大视频可能要等一会儿
  • 不同app:微信、QQ、邮箱的接收效果都不一样
  • 网络状况:网不好的时候网络图片可能加载不出来

调试的时候多打印,鸿蒙的报错信息有时候说得不够清楚。

总结

折腾了好几天,总算把这个分享功能搞定了。整体感觉鸿蒙的API还挺人性化的,比我想象中好用多了。

几个心得:

  1. 多翻文档:鸿蒙官方文档写得还可以,遇到问题先去翻翻
  2. 类型别偷懒:ETS的类型检查确实严格,但写出来的代码更稳定
  3. 错误要处理好:异步操作的错误处理千万别省,不然出了bug找都找不到
  4. 真机多测试:模拟器和真机表现可能不一样,都试试保险点

现在这个小插件基本能满足日常需要了。如果你也在搞类似的东西,希望我这些踩坑经验能帮到你。有问题咱们可以一起交流。

所有代码都在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: 检查这几个地方:

  1. context获取对了没
  2. ShareData有没有创建成功(别返回null)
  3. 文件路径对不对
  4. UTD类型匹配不

Q: 为啥有些app收不到我分享的东西?

A: 估计是UTD类型不匹配,或者那个app不认这种数据格式。试试用通用一点的类型,比如utd.UniformDataType.FILE

Q: 网络上的图片能直接分享吗?

A: 不行,得先下载到本地,然后分享本地文件。

Q: 分享大文件会卡吗?

A: 会的,特别是视频文件。建议加个转圈圈的loading,或者提前压缩一下。

Q: 能知道用户选了哪个app分享吗?

A: 目前鸿蒙的API不支持,只能知道用户是分享成功了还是取消了。

Q: 能自己做个分享面板吗?

A: 不行,只能用系统提供的。不过可以通过selectionModepreviewMode参数稍微调整一下样式。

测试这块儿

真机测试的时候发现了不少问题,建议你们也多试试:

  • 各种文件类型:图片、视频、文档啥的都试试
  • 文件大小:小图片秒传,大视频可能要等一会儿
  • 不同app:微信、QQ、邮箱的接收效果都不一样
  • 网络状况:网不好的时候网络图片可能加载不出来

调试的时候多打印,鸿蒙的报错信息有时候说得不够清楚。

总结

折腾了好几天,总算把这个分享功能搞定了。整体感觉鸿蒙的API还挺人性化的,比我想象中好用多了。

几个心得:

  1. 多翻文档:鸿蒙官方文档写得还可以,遇到问题先去翻翻
  2. 类型别偷懒:ETS的类型检查确实严格,但写出来的代码更稳定
  3. 错误要处理好:异步操作的错误处理千万别省,不然出了bug找都找不到
  4. 真机多测试:模拟器和真机表现可能不一样,都试试保险点

现在这个小插件基本能满足日常需要了。如果你也在搞类似的东西,希望我这些踩坑经验能帮到你。有问题咱们可以一起交流。

所有代码都在uni_modules/cool-share目录里,有兴趣的朋友可以看看。

顺便安利个东西

我们团队还做了个 Cool Unix 组件库,也是基于 Uni-App X 的,完全免费开源。里面有 Tailwind CSS、多主题、国际化这些实用功能,还有挺多现成的组件和页面模板。

最有意思的是,这个组件库配合AI生成鸿蒙页面效果特别好,以后还准备加上后端接口自动生成,真正做到一句话就能搞出个app。如果你也想快速开发鸿蒙应用,可以试试看。

最后感谢一下

感谢 uni-app x 团队做出这么棒的跨平台框架,让我们这些普通开发者也能轻松搞定多端开发。特别是UTS语言和鸿蒙支持,真的降低了不少门槛。

收起阅读 »

手贱,离线基座打包编译错误MAMapKit

打包失败

离线基座打包
pointer not aligned in '_dbl_lnds_data_TileDataRespMsg_fields'+0x32 ~SDK/Libs/MAMapKit.framework/MAMapKit[arm64]2
老是提示弹框版本问题,我手贱,去下了一个SDK包,然后把工程的SDK换掉了;

然后就报这个错误了

找了半天,还好之前有过记录,不然怎么解决都不知道;

高德的旧版sdk,我放到网盘了
链接:https://pan.quark.cn/s/75bba91c6ec2

只要替换掉,就可以了

继续阅读 »

离线基座打包
pointer not aligned in '_dbl_lnds_data_TileDataRespMsg_fields'+0x32 ~SDK/Libs/MAMapKit.framework/MAMapKit[arm64]2
老是提示弹框版本问题,我手贱,去下了一个SDK包,然后把工程的SDK换掉了;

然后就报这个错误了

找了半天,还好之前有过记录,不然怎么解决都不知道;

高德的旧版sdk,我放到网盘了
链接:https://pan.quark.cn/s/75bba91c6ec2

只要替换掉,就可以了

收起阅读 »

动态创建web-view加载本地html 页面通讯

web_view

我们日常使用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

收起阅读 »