UniApp水印相机进阶:如何用canvas实现动态多行文本水印(附完整代码)

uniapp安卓app实现水印相机(附完整代码) 在移动应用开发中,实时相机功能结合水印的需求十分常见。uniapp提供的uni.chooseImage(OBJECT)可以直接调用相机进行拍照,但部分机型中调用 uniapp提供的chooseImage API进行拍照后会出现应用闪退的bug(应用重启),出现该问题的原因。如果打卡拍照水印正常显示,背景是白屏,则是相机权限问题(1.manifest.json勾选;,是安卓系统回收资源,结束了应用进程导致的(调用系统相机进行拍摄,应用处于后台)。1.水印相机组件(这里创建页面时要选择nvue, 阅读详情

UniApp水印相机进阶:用Canvas实现动态多行文本水印的深度实践

在移动应用开发中,水印相机早已超越了简单的“拍照+文字”模式,演变为一种融合了身份验证、信息追溯和品牌展示的复合型功能。无论是企业内部用于记录工作进度的巡检拍照,还是电商平台要求卖家上传带有时效和地理位置证明的商品实拍,动态、可定制、信息量丰富的水印都成为了刚需。然而,当水印内容从简单的单行日期,扩展到包含长地址、多行描述、动态参数时,许多开发者会发现,市面上常见的解决方案开始捉襟见肘。

UniApp凭借其跨端优势,成为实现这类功能的热门选择。但原生camera组件在App端的局限性,迫使我们必须另辟蹊径。live-pusher直播推流组件因其强大的实时画面捕获能力,成为了在App端构建自定义相机的基石。而真正让水印变得“智能”和“优雅”的关键,则在于Canvas对动态多行文本的渲染能力。这不仅仅是文本的堆叠,更涉及到自动换行算法、样式继承、性能优化以及与原生相机视图的无缝融合。本文将带你深入这一技术组合的核心,从原理剖析到实战编码,构建一个真正可用的、支持复杂动态水印的UniApp相机解决方案。

1. 架构选型:为何是 live-pusher + Canvas?

在UniApp生态中,实现相机功能主要有几种路径:调用系统相机API、使用camera组件(小程序端)、或采用live-pusher组件。对于需要在App端实现应用内实时预览并**叠加复杂UI元素(水印)**的场景,live-pusher几乎是唯一成熟的选择。

live-pusher的核心优势在于:

  • 应用内集成:无需跳转到系统相机应用,用户体验流程完整、可控。
  • 实时预览层:它提供了一个真实的相机预览画面,我们可以在其上层通过cover-view、cover-image等组件叠加静态的水印指示元素(如提示文字、Logo图标)。
  • 快照能力:通过snapshot方法,可以捕获当前预览画面的图像。但请注意,这里捕获的仅仅是相机画面,不包含叠加的cover-view水印。这是很多初学者容易误解的关键点。

那么,最终带水印的图片是如何产生的?这就引出了双阶段水印架构:

  1. 预览阶段水印(UI层):在live-pusher上层,使用cover-view显示水印文本(如用户名、实时更新的时间、地址)。这部分是给用户“看”的,用于确认拍摄内容和信息,但不会直接进入最终图片。
  2. 合成阶段水印(Canvas层):用户点击拍照后,我们获得一张纯背景图。随后,在一个离屏的Canvas上,重新绘制这张背景图,并调用Canvas的2D API,将水印信息(文本、图标等)精确地绘制到图像上,最终导出一张合成后的新图片。

这个架构清晰地将“预览”和“合成”解耦,带来了极大的灵活性。我们可以在Canvas合成阶段,实现远比cover-view更复杂的水印效果,例如多行文本自动换行、文字旋转、半透明背景、复杂字体样式等。

注意:live-pusher在nvue页面中的表现通常更稳定,尤其是在Android平台上。nvue的渲染机制与原生的契合度更高,能有效避免一些层级和性能问题。因此,本文的示例将基于nvue页面构建相机部分。

2. 构建实时预览相机页面(watermarkCamera.nvue)

我们首先构建相机预览页面。这个页面的核心是提供拍摄界面,并收集所有需要绘制的水印动态数据。

<template>
  <view class="live-camera" :style="{ width: windowWidth + 'px', height: windowHeight + 'px' }">
    <!-- 相机预览区域 -->
    <view class="preview">
      <live-pusher
        id="livePusher"
        ref="livePusher"
        class="livePusher"
        mode="HD"
        :beauty="0"
        :whiteness="0"
        :aspect="aspect"
        :min-bitrate="800"
        :audio-quality="'16KHz'"
        :device-position="cameraPosition"
        :auto-focus="true"
        :muted="true"
        :enable-camera="true"
        :enable-mic="false"
        :zoom="false"
        @statechange="onLivePusherStateChange"
        :style="{
          width: windowWidth + 'px',
          height: windowHeight + 'px'
        }"
      ></live-pusher>

      <!-- 预览层水印 (仅用于显示,不参与最终合成) -->
      <cover-view class="preview-watermark">
        <cover-text class="watermark-line">{{ watermarkData.userName }}</cover-text>
        <cover-text class="watermark-line">{{ watermarkData.location }}</cover-text>
        <cover-text class="watermark-line">{{ watermarkData.time }}</cover-text>
        <cover-text class="watermark-line">{{ watermarkData.customText }}</cover-text>
      </cover-view>
    </view>

    <!-- 底部操作栏 -->
    <view class="control-bar">
      <cover-image class="btn-flip" src="/static/camera/flip.png" @tap="switchCamera"></cover-image>
      <cover-image class="btn-capture" src="/static/camera/shutter.png" @tap="takeSnapshot"></cover-image>
      <cover-image class="btn-close" src="/static/camera/close.png" @tap="navigateBack"></cover-image>
    </view>
  </view>
</template>

<script>
let pusherContext = null;
export default {
  data() {
    return {
      windowWidth: 375,
      windowHeight: 667,
      aspect: '3:4', // 常用拍照比例
      cameraPosition: 'back',
      watermarkData: {
        userName: '访客',
        location: '定位中...',
        time: this.formatTime(new Date()),
        customText: '内部资料,严禁外传'
      },
      locationTimer: null,
      timeTimer: null
    };
  },
  onLoad() {
    this.initCameraParams();
    this.startDynamicDataUpdate();
  },
  onReady() {
    // 创建 live-pusher 上下文
    pusherContext = uni.createLivePusherContext('livePusher', this);
    this.startCameraPreview();
  },
  onUnload() {
    // 清理定时器
    if (this.locationTimer) clearInterval(this.locationTimer);
    if (this.timeTimer) clearInterval(this.timeTimer);
  },
  methods: {
    initCameraParams() {
      const sysInfo = uni.getSystemInfoSync();
      this.windowWidth = sysInfo.windowWidth;
      this.windowHeight = sysInfo.windowHeight;
      // 计算并设置合适的画面比例,避免拉伸
      const gcd = this.calcGCD(this.windowWidth, this.windowHeight);
      this.aspect = `${this.windowWidth / gcd}:${this.windowHeight / gcd}`;
    },
    // 开始动态更新水印数据(位置、时间)
    startDynamicDataUpdate() {
      // 获取初始位置
      this.updateLocation();
      // 每30秒更新一次位置(根据业务需求调整频率)
      this.locationTimer = setInterval(() => {
        this.updateLocation();
      }, 30000);

      // 时间每秒更新一次
      this.timeTimer = setInterval(() => {
        this.watermarkData.time = this.formatTime(new Date());
      }, 1000);
    },
    updateLocation() {
      uni.getLocation({
        type: 'gcj02',
        geocode: true,
        success: (res) => {
          if (res.address) {
            const addr = res.address;
            // 组装一个简洁的地址信息,避免过长
            this.watermarkData.location = `${addr.city || ''}${addr.district || ''}${addr.street || ''}`.substring(0, 20);
          }
        },
        fail: () => {
          this.watermarkData.location = '无法获取位置';
        }
      });
    },
    startCameraPreview() {
      if (pusherContext) {
        pusherContext.startPreview({
          success: () => console.log('相机预览启动成功'),
          fail: (err) => console.error('相机预览启动失败:', err)
        });
      }
    },
    takeSnapshot() {
      if (!pusherContext) return;
      // 1. 停止预览,准备捕获静态帧
      pusherContext.stopPreview();
      // 2. 执行快照
      pusherContext.snapshot({
        success: (res) => {
          const tempImagePath = res.tempImagePath;
          console.log('快照临时路径:', tempImagePath);
          // 3. 将快照图片和水印数据传递回上一个页面进行合成
          this.sendDataToPreviousPage(tempImagePath);
        },
        fail: (err) => {
          console.error('快照失败:', err);
          // 失败后重新开启预览
          this.startCameraPreview();
        }
      });
    },
    sendDataToPreviousPage(imagePath) {
      const pages = getCurrentPages();
      const prevPage = pages[pages.length - 2]; // 获取上一个页面实例
      if (prevPage && prevPage.$vm.watermarkImage) {
        // 调用上一个页面的方法,传入图片路径和完整水印数据
        prevPage.$vm.watermarkImage({
          src: imagePath,
          watermark: { ...this.watermarkData }
        });
      }
      // 返回上一页
      uni.navigateBack();
    },
    switchCamera() {
      this.cameraPosition = this.cameraPosition === 'back' ? 'front' : 'back';
    },
    navigateBack() {
      uni.navigateBack();
    },
    onLivePusherStateChange(e) {
      const code = e.detail.code;
      // 可以在这里处理不同的状态码,例如相机启动成功、失败等
      if (code === 1007) {
        console.log('相机连接成功');
      } else if (code === -1301) {
        console.warn('相机连接断开,尝试重连...');
      }
    },
    formatTime(date) {
      const opt = {
        "Y+": date.getFullYear().toString(),
        "m+": (date.getMonth() + 1).toString().padStart(2, '0'),
        "d+": date.getDate().toString().padStart(2, '0'),
        "H+": date.getHours().toString().padStart(2, '0'),
        "M+": date.getMinutes().toString().padStart(2, '0'),
        "S+": date.getSeconds().toString().padStart(2, '0')
      };
      let fmt = "YYYY-mm-dd HH:MM:SS";
      for (let k in opt) {
        const ret = new RegExp("(" + k + ")").exec(fmt);
        if (ret) {
          fmt = fmt.replace(ret[1], opt[k].padStart(ret[1].length, ret[1].length == 1 ? "" : "0"));
        }
      }
      return fmt;
    },
    // 计算最大公约数,用于简化比例
    calcGCD(a, b) {
      return b === 0 ? a : this.calcGCD(b, a % b);
    }
  }
};
</script>

<style scoped>
.live-camera {
  position: relative;
  background-color: #000;
}
.preview {
  position: relative;
}
.livePusher {
  display: block;
}
.preview-watermark {
  position: absolute;
  top: 40rpx;
  left: 20rpx;
  z-index: 100;
}
.watermark-line {
  display: block;
  color: rgba(255, 255, 255, 0.85);
  font-size: 32rpx;
  line-height: 1.5;
  text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.control-bar {
  position: absolute;
  bottom: 60rpx;
  left: 0;
  width: 100%;
  display: flex;
  justify-content: space-around;
  align-items: center;
  z-index: 101;
}
.btn-capture {
  width: 120rpx;
  height: 120rpx;
}
.btn-flip,
.btn-close {
  width: 80rpx;
  height: 80rpx;
}
</style>

这个页面完成了实时预览、动态数据收集和快照捕获的工作。接下来,真正的挑战——在Canvas上实现动态多行文本水印——将在接收页面上展开。

3. Canvas核心:动态多行文本绘制算法

快照图片和水印数据已经传递到了上一个页面(例如一个takePhoto.vue页面)。这个页面需要完成核心的Canvas绘制工作。多行文本绘制的难点在于:如何根据设定的最大宽度,将一段长文本自动拆分成多行,并确保拆分点不会在单词中间(对于英文)或字符中间(对于中文),同时还要处理行数限制和溢出省略。

下面是一个健壮的多行文本绘制函数,它考虑了中英文混合的情况,并提供了良好的可配置性。

// 在 takePhoto.vue 页面的 methods 中
methods: {
  /**
   * 在Canvas上绘制多行文本
   * @param {Object} ctx - Canvas 2D上下文
   * @param {String} text - 要绘制的文本
   * @param {Number} x - 起始x坐标
   * @param {Number} y - 起始y坐标
   * @param {Number} maxWidth - 单行最大宽度(像素)
   * @param {Number} lineHeight - 行高(像素)
   * @param {Number} maxLines - 最大行数(超出部分省略)
   * @param {String} fontStyle - 字体样式字符串,如 'bold 16px sans-serif'
   * @param {String} textAlign - 文本对齐方式 'left'|'center'|'right'
   * @return {Number} - 返回绘制完成后,下一行的起始y坐标
   */
  drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines, fontStyle = 'normal 14px sans-serif', textAlign = 'left') {
    ctx.font = fontStyle;
    ctx.textAlign = textAlign;
    ctx.textBaseline = 'top';

    const words = text.split('');
    let line = '';
    let currentLine = 1;
    let drawY = y;

    for (let i = 0; i < words.length; i++) {
      const testLine = line + words[i];
      const metrics = ctx.measureText(testLine);
      const testWidth = metrics.width;

      if (testWidth > maxWidth && i > 0) {
        // 绘制当前行
        ctx.fillText(line, x, drawY);
        // 换行
        drawY += lineHeight;
        currentLine++;
        // 检查是否超过最大行数
        if (currentLine > maxLines) {
          // 绘制省略号
          const ellipsis = '...';
          const ellipsisWidth = ctx.measureText(ellipsis).width;
          // 尝试从当前行去掉一些字符以容纳省略号
          while (ctx.measureText(line + ellipsis).width > maxWidth && line.length > 0) {
            line = line.substring(0, line.length - 1);
          }
          ctx.fillText(line + ellipsis, x, drawY - lineHeight);
          return drawY; // 返回最终的y坐标
        }
        // 重置新行
        line = words[i];
      } else {
        line = testLine;
      }
    }
    // 绘制最后一行
    ctx.fillText(line, x, drawY);
    return drawY + lineHeight;
  }
}

这个函数是绘制多行文本的基础。但在实际的水印相机场景中,水印往往是多组信息(如姓名、地址、时间、自定义标语)的集合,每组信息可能有独立的样式和位置要求。我们需要一个更高级的“水印配置器”。

4. 高级水印配置与批量绘制

让我们设计一个水印配置系统,允许开发者灵活地定义多个水印元素。

// 在 takePhoto.vue 的 data 或 methods 中定义水印配置
getWatermarkConfig(watermarkData) {
  // watermarkData 是从相机页面传过来的数据对象
  return [
    {
      type: 'text',
      content: `拍摄人:${watermarkData.userName}`,
      position: { x: 20, y: 30 },
      style: {
        font: 'bold 18px "PingFang SC", sans-serif',
        color: '#FFFFFF',
        textAlign: 'left',
        shadow: '2px 2px 4px rgba(0,0,0,0.8)'
      },
      wrap: {
        maxWidth: 300, // 最大宽度,超出自动换行
        lineHeight: 24,
        maxLines: 2
      }
    },
    {
      type: 'text',
      content: `地点:${watermarkData.location}`,
      position: { x: 20, y: 80 }, // y坐标可以动态计算,这里为示例
      style: {
        font: 'normal 16px "PingFang SC", sans-serif',
        color: 'rgba(255,255,255,0.9)',
        textAlign: 'left'
      },
      wrap: {
        maxWidth: 280,
        lineHeight: 22,
        maxLines: 3 // 地址可能较长,允许3行
      }
    },
    {
      type: 'text',
      content: `时间:${watermarkData.time}`,
      position: { x: 20, y: 160 }, // 需要根据上一个水印的结束位置动态计算
      style: {
        font: 'normal 15px monospace',
        color: '#CCCCCC',
        textAlign: 'left'
      },
      wrap: null // 不换行,单行显示
    },
    {
      type: 'text',
      content: watermarkData.customText,
      position: { x: 'center', y: 'bottom-50' }, // 支持相对定位
      style: {
        font: 'italic 20px sans-serif',
        color: '#FF9900',
        textAlign: 'center',
        shadow: '0 0 10px rgba(255,153,0,0.5)'
      },
      wrap: {
        maxWidth: 250,
        lineHeight: 26,
        maxLines: 2
      }
    }
    // 可以继续添加更多元素,如图片水印(type: 'image')、矩形背景等
  ];
}

有了配置,我们需要一个“渲染引擎”来解析这些配置并绘制到Canvas上。

// 核心绘制方法
async watermarkImage(options) {
  const { src, watermark } = options;
  // 1. 获取图片信息
  const imageInfo = await this.getImageInfoAsync(src);
  // 2. 创建离屏Canvas,尺寸与图片一致
  const canvasWidth = imageInfo.width;
  const canvasHeight = imageInfo.height;
  this.canvasSize = { width: canvasWidth, height: canvasHeight };

  // 使用Promise和setTimeout确保Canvas尺寸已更新
  await new Promise(resolve => setTimeout(resolve, 50));

  const ctx = uni.createCanvasContext('watermarkCanvas', this);
  
  // 3. 绘制原图作为背景
  ctx.drawImage(src, 0, 0, canvasWidth, canvasHeight);

  // 4. 获取水印配置
  const configs = this.getWatermarkConfig(watermark);
  let currentYOffset = 0; // 用于动态计算Y坐标

  // 5. 遍历配置并绘制
  for (const config of configs) {
    if (config.type === 'text') {
      // 解析位置(支持绝对像素值和相对值如 'center', 'bottom-xx')
      let x = config.position.x;
      let y = config.position.y;

      if (x === 'center') {
        x = canvasWidth / 2;
        ctx.textAlign = 'center';
      } else if (x === 'right') {
        x = canvasWidth - 20; // 假设右边距20
        ctx.textAlign = 'right';
      } else {
        ctx.textAlign = config.style.textAlign || 'left';
      }

      if (y === 'bottom') {
        y = canvasHeight - 20;
      } else if (typeof y === 'string' && y.startsWith('bottom-')) {
        const offset = parseInt(y.replace('bottom-', ''), 10);
        y = canvasHeight - offset;
      } else if (y === 'auto') {
        // 自动接在上一个元素后面
        y = 30 + currentYOffset; // 基础顶部间距 + 偏移
      }

      // 设置样式
      ctx.font = config.style.font;
      ctx.fillStyle = config.style.color;
      if (config.style.shadow) {
        // 简单解析阴影,实际项目可用更复杂的解析器
        const shadowParts = config.style.shadow.split(' ');
        ctx.shadowOffsetX = parseInt(shadowParts[0]);
        ctx.shadowOffsetY = parseInt(shadowParts[1]);
        ctx.shadowBlur = parseInt(shadowParts[2]);
        ctx.shadowColor = shadowParts[3];
      }

      // 绘制文本
      if (config.wrap) {
        // 多行绘制
        currentYOffset = this.drawWrappedText(
          ctx,
          config.content,
          x,
          y,
          config.wrap.maxWidth,
          config.wrap.lineHeight,
          config.wrap.maxLines,
          config.style.font,
          ctx.textAlign
        ) - y; // 计算本次绘制占用的高度
      } else {
        // 单行绘制
        ctx.fillText(config.content, x, y);
        currentYOffset = config.style.lineHeight || 20; // 单行默认高度
      }
      // 重置阴影,避免影响下一个元素
      ctx.shadowOffsetX = 0;
      ctx.shadowOffsetY = 0;
      ctx.shadowBlur = 0;
    }
    // 这里可以扩展处理 type === 'image' 等
  }

  // 6. 执行绘制并导出图片
  ctx.draw(false, () => {
    uni.canvasToTempFilePath({
      canvasId: 'watermarkCanvas',
      destWidth: canvasWidth,
      destHeight: canvasHeight,
      quality: 0.95, // 输出质量
      fileType: 'jpg',
      success: (res) => {
        const watermarkedPath = res.tempFilePath;
        this.saveWatermarkedImage(watermarkedPath);
        // 可以预览或上传
        this.previewImage = watermarkedPath;
      },
      fail: (err) => {
        console.error('Canvas导出失败:', err);
      }
    }, this);
  });
},

// 异步获取图片信息的辅助方法
getImageInfoAsync(src) {
  return new Promise((resolve, reject) => {
    uni.getImageInfo({
      src,
      success: resolve,
      fail: reject
    });
  });
}

通过这种配置化的方式,水印的样式、位置、换行规则都变得高度可定制。你可以轻松地调整getWatermarkConfig函数来生成不同的水印效果,而无需修改核心绘制逻辑。

5. 性能优化与平台兼容性实战

在真实项目中,性能和多端兼容性是必须跨越的坎。以下是我在实践中总结的几个关键点和解决方案:

1. Canvas尺寸与图片分辨率: live-pusher的snapshot输出的图片分辨率与设备屏幕分辨率相关。在高端手机上,图片可能非常大(例如 1440x2560)。直接在如此大的Canvas上绘制文本,然后导出,可能会导致内存压力增大和操作缓慢。

  • 优化策略:根据实际显示需求,对Canvas输出尺寸进行限制。例如,如果水印图片最终只是在App内展示或上传到服务器供移动端查看,那么宽度控制在1080px以内通常足够了。
// 在 watermarkImage 方法中,获取图片信息后
const MAX_OUTPUT_WIDTH = 1080;
let outputWidth = imageInfo.width;
let outputHeight = imageInfo.height;

if (outputWidth > MAX_OUTPUT_WIDTH) {
  const scale = MAX_OUTPUT_WIDTH / outputWidth;
  outputWidth = MAX_OUTPUT_WIDTH;
  outputHeight = Math.round(outputHeight * scale);
}
// 然后在 canvasToTempFilePath 时使用 destWidth, destHeight 参数进行缩放导出

2. 字体加载与回退: 在Canvas中指定了特殊字体(如"PingFang SC"),但用户设备上可能没有。这会导致字体回退到默认字体,可能影响水印的视觉设计和文本宽度计算(导致换行错位)。

  • 解决方案:
    • 使用安全字体栈:font: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif。
    • 动态测量:如果对排版要求极高,可以在绘制前用ctx.measureText测试关键字符串在目标字体下的实际宽度,如果发现与预期偏差过大(说明字体可能未加载),则动态调整布局或使用备用字体。
    • 预加载字体:在UniApp中,可以将字体文件放入static目录,并使用uni.loadFontFace API进行加载,但这会增加包体积和复杂度。

3. Android机型兼容性: 部分Android机型上,live-pusher的snapshot方法可能返回的图片方向不正确(横屏拍摄时),或者Canvas的绘制在低端机上较慢。

  • 方向处理:快照图片的orientation信息可以在uni.getImageInfo的成功回调中获得。如果发现方向值不是1(默认),可能需要先使用Canvas的rotate和translate进行旋转校正,然后再绘制水印。这是一个相对复杂的主题,需要结合EXIF信息处理。
  • 操作反馈:在低端机上,从拍照到生成水印图片可能需要1-2秒。务必提供明确的等待提示(如uni.showLoading),并在操作结束后关闭。

4. 权限与真机调试: 这是最容易“踩坑”的地方。

  • 必须配置的权限:在manifest.json的“App模块权限配置”中,务必勾选“LivePusher(直播推流)”。否则,在打包后live-pusher组件根本无法使用。
  • 相机权限:Android的相机权限是运行时申请的。如果打开页面后黑屏,首先检查是否已授权。可以在onLoad中调用uni.authorize提前申请。
  • 真机调试基座:开发时,务必使用“标准基座”运行,而不是“自定义基座”,除非你确定自定义基座包含了所有必要的原生模块。很多相机权限问题在标准基座上更容易复现和排查。

下表总结了关键步骤的检查点:

步骤可能的问题排查与解决方案
页面打开黑屏1. live-pusher模块未勾选
2. 相机权限未授权
3. 使用了不支持的自定义基座
1. 检查manifest.json配置
2. 调用uni.authorize申请权限
3. 改用HBuilderX标准基座运行
拍照后无反应1. snapshot成功回调未执行
2. 页面通信失败
1. 检查live-pusher的snapshot API兼容性
2. 确认getCurrentPages()能正确获取上一页实例
生成的水印图片模糊Canvas导出尺寸小于原图尺寸检查canvasToTempFilePath的destWidth和destHeight参数,确保其值合理
水印位置错乱1. 坐标计算错误
2. 图片存在EXIF旋转信息
1. 使用console.log调试坐标值
2. 获取图片的orientation并进行校正
多行文本中间被切断换行算法在单词/汉字中间断开优化drawWrappedText函数,对于英文可以按空格分词,对于中文按字符处理

5. 更优雅的文本换行算法进阶: 之前提供的drawWrappedText函数是一个基础版本。对于中英文混合、标点符号避头尾等更专业的排版需求,算法需要进一步优化。例如,我们可以引入一个“分词器”来更好地处理英文单词:

// 改进版:优先在空格处换行
drawWrappedTextAdvanced(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
  ctx.textBaseline = 'top';
  const paragraphs = text.split('\n');
  let currentLine = 0;
  let drawY = y;

  for (const para of paragraphs) {
    const words = para.split(' ');
    let currentLineText = '';

    for (let i = 0; i < words.length; i++) {
      const testLine = currentLineText ? `${currentLineText} ${words[i]}` : words[i];
      const testWidth = ctx.measureText(testLine).width;

      if (testWidth > maxWidth && i > 0) {
        // 绘制当前行
        ctx.fillText(currentLineText, x, drawY);
        drawY += lineHeight;
        currentLine++;
        if (currentLine >= maxLines) {
          // 添加省略号并返回
          const ellipsisWidth = ctx.measureText('...').width;
          while (ctx.measureText(currentLineText + '...').width > maxWidth && currentLineText.length > 0) {
            currentLineText = currentLineText.substring(0, currentLineText.length - 1);
          }
          ctx.fillText(currentLineText + '...', x, drawY - lineHeight);
          return drawY;
        }
        currentLineText = words[i];
      } else {
        currentLineText = testLine;
      }
    }
    // 绘制段落最后一行
    if (currentLineText) {
      ctx.fillText(currentLineText, x, drawY);
      drawY += lineHeight;
      currentLine++;
      if (currentLine >= maxLines) return drawY;
    }
    // 段落间可加空行
    // drawY += lineHeight * 0.5;
  }
  return drawY;
}

这个版本优先在空格处换行,使英文文本的排版更加美观。对于纯中文文本,按字符拆分(split(''))的方式通常已经足够。

6. 扩展思路:从水印到自定义相机生态

掌握了动态多行文本水印的核心技术后,你的自定义相机可以进一步扩展,形成一个功能丰富的拍摄工具集:

  • 模板化水印:预定义多种水印模板(如“工程巡检”、“客户签约”、“库存盘点”),用户拍摄时一键选择。每个模板对应一套预配置的watermarkConfig,包括固定的文字、Logo和布局。
  • 可视化水印编辑器:允许用户在拍摄前或拍摄后,在预览图上直接拖拽、旋转、缩放水印文字或图标,并实时看到效果。这需要更复杂的Canvas交互逻辑。
  • 多图层混合与滤镜:除了文字,还可以在Canvas上绘制半透明色块、渐变背景作为水印衬底,或者对原图应用简单的滤镜(灰度、怀旧)后再添加水印。
  • 与UniCloud结合:将拍摄完成的水印图片直接上传到UniCloud云存储,并关联相应的业务数据(订单号、巡检ID),实现端到端的数据闭环。

实现这些高级功能,其基石依然是本文详细剖析的live-pusher捕获画面、Canvas合成图像这一核心技术路径。当你透彻理解了这两个核心组件如何协同工作,并解决了多行文本绘制、性能、兼容性等深层问题后,任何关于自定义相机的创意想法,都将拥有坚实可靠的实现基础。

uniapp制作水印相机给图片添加水印并且保存图片至本地 1.代码包文件:代码包文件指的是在项目目录中添加的文件。2.本地文件:通过调用接口本地产生,或通过网络下载下来,存储到本地的文件。1.本地临时文件:临时产生,随时会被回收的文件。运行时最多存储 4GB,结束运行后,如果已使用超过 2GB,会以文件为维度按照最近使用时间从远到近进行清理至少于2GB。 2.本地缓存文件:小程序通过接口把本地临时文件缓存后产生的文件,不能自定义目录和文件名。跟本地用户文件共计,小程序(含小游戏)最多可存储 200MB。 3.本地用户文件:小程序通过接口把本地临时文件缓存后产生的文 阅读详情

相关推荐

UniApp水印相机开发:跨平台解决方案与性能优化

水印技术是移动应用开发中的常见需求,通过在图片上叠加特定信息实现版权保护、信息追溯等功能。其核心原理是通过Canvas或WebGL实时渲染文字/图形到图像数据流。在跨平台开发场景下,UniApp的`<camera>`组件与Canvas API组合能有效解决Android/iOS/小程序的兼容性问题。实测表明,经过图片压缩、GPU加速等优化手段,水印生成耗时可从2.3秒降至0.8秒,内存占用减少40%。该方案特别适合考勤打卡、工程巡检等需要防篡改的业务场景,其中动态水印和防盗水印模块的开发经验值得重点关注。

weixin_30235225的博客 533

uni-app实现水印相机(仅小程序端)

本文详细的讲解了在使用uni-app时,如何利用官方api来实现自定义水印相机的需求,为业务开发提供了有效的解决方案

鹏北海的博客 4154

UniApp水印相机实战:如何优雅处理相机权限、图片压缩与iOS/Android兼容性问题

本文详细介绍了在UniApp框架下开发水印相机功能的完整解决方案,涵盖相机权限管理、图片压缩优化、Canvas水印绘制及iOS/Android跨平台兼容性处理等关键技术点。通过实战代码示例,帮助开发者解决权限申请、本地存储和性能优化等常见问题,实现高效稳定的水印相机功能。

weixin_30352645的博客 457

uniapp实现水印相机

水印相机 livePusher与canvas

qq_43352505的博客 3169

uniapp实战:从零封装一个高定制化水印相机组件

本文详细介绍了如何在uniapp中从零封装一个高定制化水印相机组件,涵盖组件设计、核心功能实现、性能优化及实际应用案例。通过组件化封装,开发者可以快速集成时间、地点等水印功能,提升政务、医疗等场景下的数据真实性和开发效率。

weixin_33712987的博客 421

UniApp跨平台水印相机开发实战与性能优化

相机功能作为移动应用开发的核心组件,其性能优化与定制化开发一直是技术难点。跨平台框架UniApp结合原生插件机制,有效解决了Web方案性能不足的问题,实现接近原生体验的水印相机功能。水印技术通过实时叠加元数据(如时间、位置)到图像中,在工程巡检、保险勘察等场景中确保数据真实性,同时满足品牌营销的视觉需求。关键技术涉及CameraX/AVFoundation原生API调用、纹理共享机制和预渲染优化,实测性能较纯Web方案提升7倍。针对不同业务场景,还需考虑防篡改策略(如频域水印)和跨平台一致性处理(Andro

weixin_34094282的博客 183

uniapp水印相机开发:跨平台方案与性能优化

水印技术是移动应用开发中的常见需求,尤其在巡检、打卡等场景中至关重要。其实现原理主要基于图像处理技术,通过将文本或图形信息叠加到原始图像上实现身份认证和版权保护。在跨平台开发框架uniapp中,开发者面临的核心挑战是如何平衡多端兼容性与性能表现。主流技术方案包括基于Canvas的前端渲染、原生插件调用以及UTS编译方案,每种方案在性能边界和适用场景上各有特点。对于企业级应用,还需考虑防篡改水印、离线模式等扩展功能,同时通过分包策略和原生插件优化控制包体积。合理的技术选型可使应用在保持功能完整性的同时显著提升

weixin_33769125的博客 413

UniApp水印相机开发指南:从live-pusher配置到canvas动态水印(含GPS定位集成)

本文详细介绍了在UniApp中开发安卓端水印相机的完整方案。通过巧妙利用live-pusher组件实现应用内实时相机预览,结合canvas进行动态水印绘制,并集成GPS定位信息,为巡检、外勤等业务场景提供了一套高效、防篡改的拍照解决方案。

weixin_28285943的博客 340

uniapp自定义水印相机

uniapp水印相机,照片水印

abs625的博客 3232

uniapp|实现多端图片上传、拍照上传自定义插入水印内容及拖拽自定义水印位置,实现水印相机、图片下载保存等功能

本文以基础视角,详细讲解如何在uni-app中实现图片上传→水印动态编辑→图片下载的全流程功能。

商务合作 / 项目定制 / 学习交流。个人vx:lovely_wml 1万+

uniapp [全端兼容] - 详细实现拍照或相册选取图片后插入水印功能,手机拍照或相册上传图像并添加自定义水印及指定位置显示水印,uniApp给图片加水印后上传到后端服务器或保存到本机相册详细教程!

uni-app,h5移动端网页,微信小程序,支付宝小程序,Android安卓,苹果ios,app,nvue,图片水印,上传照片带水印,从相册选择图片加水印功能,拍完照片给图片加水印,添加照片水印,uniapp图片加水印,为图片添加自定义水印,uni-app上传图片并添加水印,上传图片加水印,uni-app如何对拍摄的照片加水印,uni-app实现图片上传添加水印操作,给上传的图片添加上水印,图片加完水印后如何上传到服务器,水印相机,拍照图片加水印,相册选取后给图片加上水印,渲染失败,无法保存,文字水印,日期

🏆 前端领域优质创作者 1万+

uniapp安卓端实时水印相机开发指南:live-pusher与canvas深度整合

本文详细介绍了使用uniapp开发安卓端实时水印相机的完整指南,重点解析了live-pusher与canvas的深度整合技术。通过实战案例展示如何实现高性能相机预览、动态水印绘制及机型兼容性处理,帮助开发者快速构建跨平台水印相机应用,显著提升开发效率。

elastic6hunter的博客 857

uniapp [安卓苹果App端] - 详细实现自定义水印相机功能,调用手机摄像头打开相机并在周围悬挂自定义水印,使用相机拍照后添加水印文字或样式,uniapp APP端水印相机完整源码,兼容强大可靠

uni-app,安卓Android,苹果ios,安卓app端,苹果App端,uniapp苹果app,水印相机,uniapp水印相机,Uniapp安卓app实现水印相机,uniapp苹果ios水印相机,打开系统相机拍照添加水印,地点打卡相机,水印相机(水印照片,图片加水印),uniapp自定义水印相机,制作水印相机给图片添加水印并且保存图片至本地,uniapp拍照图片加水印,水印相机功能,手机调起摄像头拍照时自动插入文字、图片动态水印效果,支持水印显示拍摄者当前城市定位、自由调整水印大小和位置,手机拍照时

🏆 前端领域优质创作者 1万+

UniApp水印相机组件开发实战与性能优化

水印技术是数字内容保护的重要手段,通过在图像中嵌入不可见或可见的标识信息,实现版权声明、防伪溯源等功能。其核心原理是通过Canvas绘图API将文本或图形与原始图像进行像素级合成。在工程实践中,实时水印渲染需要解决性能瓶颈与多端兼容性问题。以UniApp跨平台框架为例,通过分层渲染技术和离屏Canvas方案,可实现在微信小程序等平台的高效水印合成。该技术特别适用于外勤打卡、商品溯源等需要防伪的场景,其中硬编码水印方案相比后期处理具有更高的安全性。通过预加载策略和内存优化,可将拍摄延迟控制在300ms内,大幅

weixin_32247165的博客 276

uniapp(vue3):实现手机拍照或相册图片上传后给图片添加水印,uniapp实现多端图片上传、拍照上传自定义插入水印内容,调用手机摄像头打开相机并在周围悬挂自定义水印(支持文字水印/图片水印等)

uni-app,h5移动端网页,微信小程序,支付宝小程序,Android安卓,苹果ios,app,nvue,uni-app为图片添加自定义水印(解决生成图片不全问题),uniapp上传图片添加水印,uniapp H5实现手机拍照或相册上传图像并添加自定义水印,uniapp水印相机,uni-app为图片添加自定义水印,怎么在图片上加水印,图片水印,上传照片带水印,从相册选择图片加水印功能,拍完照片给图片加水印,添加照片水印,uniapp图片加水印,为图片添加自定义水印,uni-app上传图片并添加水印,上传图

✅ 精选推荐文章 3265

自定义相机

能够实现拍照,添加水印,预览窗口自适应屏幕

UniApp跨平台智能水印相机开发实战

Canvas动态渲染技术是现代Web和移动应用开发中的核心图形处理方案,通过离屏渲染和分层合成实现高效图像处理。其技术价值在于平衡性能与视觉效果,特别适用于需要实时处理的场景如版权水印添加。在移动端开发中,结合UniApp框架的跨平台能力,开发者可以高效实现多端一致的水印解决方案。本文介绍的智能水印相机方案,通过波纹加密算法和智能避让技术,解决了传统水印易篡改和遮挡内容的问题。该方案在电商、政务等场景日均处理超200万张图片,显著提升了版权保护效果和用户体验。

weixin_32244597的博客 210

Windows环境Oracle19C安装与配置全攻略

本文详细介绍了在Windows环境下安装与配置Oracle19C数据库的完整流程,包括环境准备、安装步骤、网络配置、客户端工具设置及常见问题解决方案。特别针对Windows 10和Windows Server 2016及以上版本提供了优化建议,帮助用户高效完成Oracle19C的部署与维护。

weixin_28455801的博客 487

uniapp+canvas实现图片水印功能踩坑

uniapp + canvas 实现图片上传水印功能

zhang_gt的博客 571

【DSP】【工程实战】从零构建C6678多核工程:CCS环境配置与启动模式详解

本文详细介绍了在CCS开发环境中从零构建TMS320C6678多核DSP工程的完整流程。内容涵盖KeyStone架构解析、工程创建、基础代码编写、链接命令文件配置、仿真器调试,并重点深入讲解了包括I2C EEPROM在内的多种一级启动模式原理与配置方法,为开发者提供了一套清晰、可复用的实战指南。

weixin_29164185的博客 421

Cursor概述及环境配置

Cursor 是一款基于 AI 的代码编辑器,支持 VS Code 配置迁移和 Java 开发环境配置。主要功能分为 Cursor Settings(AI 相关设置)和 Editor Settings(基础编辑器设置)。用户可通过一键导入或手动方式迁移 VS Code 配置,配置包括主题、扩展和快捷键等。对于 Java 开发,需配置 JDK 和 Maven 路径,并安装 Java 扩展插件以获得完整开发体验,如代码补全、调试和 Maven 项目管理。此外,Cursor 支持汉化配置,用户可安装中文语言包切换

Knight 3560
上一篇: AD9361寄存器配置避坑指南:Fast Lock模式下的VCO频率计算与参数查表技巧
下一篇: RobotStudio 2019+新功能揭秘:ABB机器人Profinet主站配置不再依赖独立工具
github5actions
博客等级 码龄1天 0粉丝 1824原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值