news 2026/8/21 1:54:53

uni-app实战:动态生成5:4比例小程序分享封面图(附Canvas优化技巧)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
uni-app实战:动态生成5:4比例小程序分享封面图(附Canvas优化技巧)

1. 为什么你的小程序分享图总是不清晰?

大家好,我是老张,一个在uni-app和前端领域摸爬滚打了十年的老码农。今天咱们不聊虚的,直接上干货,解决一个让无数开发者头疼的问题:用uni-app开发的App,分享到微信时,那个小程序卡片封面图为什么总是糊成一片?

我猜你肯定遇到过这种情况:在App里用Canvas精心绘制的海报,预览时明明清晰锐利,可一旦通过uni.share分享到微信聊天或朋友圈,生成的卡片封面图就变得又糊又糙,简直没法看。用户点开分享链接,第一眼看到的就是这个模糊的封面,体验感瞬间掉到谷底。

这背后的“罪魁祸首”主要有两个。第一是微信平台的限制:微信对小程序分享卡片的封面图有严格的体积要求,通常建议不超过128KB(实际测试中,超过120KB就容易被压缩)。你的高清大图一旦超过这个阈值,微信后台就会进行无情的压缩,导致画质严重损失。第二是Canvas绘制和转换过程中的细节处理不当,比如没有适配高分屏、绘制尺寸不对、图片压缩参数没调好等等。

所以,今天这篇文章,我就手把手带你搞定这个难题。我们的目标很明确:在uni-app中,动态生成一张严格符合5:4比例清晰度高体积小巧的分享封面图。我会把每一步的代码、原理,还有我踩过的那些“坑”都掰开揉碎了讲给你听,保证你跟着做一遍就能彻底解决。

2. 核心思路:从截图到分享的完整流程

在开始写代码之前,咱们先把整个技术流程理清楚。这样你才知道每一步在干什么,为什么要这么干。整个流程可以概括为以下五个核心步骤,我画了个简单的示意图帮你理解:

[当前App页面] → [1. 页面截图] → [2. 创建Canvas画布] → [3. 裁剪与绘制] → [4. 压缩与转换] → [5. 调用分享]

第一步:获取页面截图。在App端,我们不能直接用网页那套html2canvas。uni-app提供了更原生的方式:通过plus.nativeObj.Bitmap来捕获当前Webview的视图。这一步得到的是整个屏幕的完整图片,包含了顶部的状态栏和你可能有的自定义导航栏。

第二步:准备一个“隐身”的Canvas。为了绘制最终5:4的封面,我们需要一个Canvas画布。但这个画布不能显示给用户看,所以我们会把它设置为透明、固定定位、并移到屏幕可视区域之外(比如opacity: 0; position: fixed; top: -1000px;)。画布的宽高要提前设定好,比如750rpx宽,600rpx高,正好是5:4的比例。

第三步:在Canvas上进行“二次创作”。这一步是关键。我们把第一步得到的全屏截图,按照5:4的比例,裁剪出中间最核心、最美观的部分,绘制到第二步准备的Canvas上。这里有个重要技巧:因为截图包含了状态栏和导航栏,我们需要计算出要裁剪掉的部分,只保留页面主体内容。

第四步:Canvas转图片并压缩。使用uni.canvasToTempFilePath把Canvas内容导出为临时图片文件。然后,立刻检查这个图片文件的大小。如果它超过了微信的“警戒线”(比如120KB),就需要调用uni.compressImage进行压缩,循环压缩直到达标为止。记住,压缩要在分享前完成,让微信收到一张已经符合要求的“小图”,它就不会再动手了。

第五步:调用分享接口。最后,使用uni.share,将压缩好的图片路径、小程序的路径等信息传给微信,完成分享。

整个流程的核心思想就是:主动控制图片的尺寸、质量和体积,把一切不可控因素都在分享前解决掉,不给微信任何“帮倒忙”的机会。

3. 实战开始:一步步搭建分享功能

理论说再多不如一行代码。咱们现在就动手,从零开始构建这个功能。我会用Vue 3的Composition API来写,如果你用的是Options API,思路也是一样的。

首先,在页面模板里,我们需要两个触发按钮和一个“隐身”的Canvas。

<template> <view> <!-- 你的页面内容 --> <view class="content">这是你要分享的精彩页面内容...</view> <!-- 分享按钮 --> <view class="share-buttons"> <button @click="handleShare('WXSceneSession')">分享给微信好友</button> <button @click="handleShare('WXSceneTimeline')">分享到朋友圈</button> </view> <!-- 核心:隐藏的Canvas画布 --> <!-- 注意:canvas-id是必须的,样式让它不可见 --> <canvas style="width: 750rpx; height: 600rpx; position: fixed; top: -1000rpx; left: -1000rpx; pointer-events: none; opacity: 0;" canvas-id="shareCanvas" id="shareCanvas" ></canvas> </view> </template>

接下来,在<script setup>里,我们引入工具函数并开始编写核心的分享逻辑。我们先写一个图片压缩函数,这是保证清晰度的第一道关卡。我把它放在一个单独的utils.js文件里,方便复用。

// utils/imageCompress.js /** * 递归压缩图片至目标大小以下 * @param {Object} file - 图片文件对象,包含 path/tempFilePath, size * @param {number} targetKB - 目标大小,单位KB,默认120 * @param {number} quality - 初始压缩质量 (0-100) * @returns {Promise} 返回压缩后的文件对象 */ export const compressImageRecursively = (file, targetKB = 120, quality = 80) => { return new Promise((resolve, reject) => { // 如果已经小于目标大小,直接返回 if (file.size <= targetKB * 1024) { resolve(file); return; } uni.compressImage({ src: file.tempFilePath || file.path, quality: quality, // 压缩质量 width: '80%', // 宽度压缩为80%,高度等比缩放 height: 'auto', success: async (compressedRes) => { // 获取压缩后图片的信息 uni.getFileInfo({ filePath: compressedRes.tempFilePath, success: async (info) => { console.log(`压缩后大小: ${(info.size / 1024).toFixed(2)}KB`); // 构造新的文件对象,递归压缩直到达标 const newFile = { ...file, size: info.size, path: compressedRes.tempFilePath, tempFilePath: compressedRes.tempFilePath }; // 如果还是太大,就降低质量继续压,这里每次减5 const nextQuality = info.size > targetKB * 1024 ? Math.max(quality - 5, 60) : quality; resolve(await compressImageRecursively(newFile, targetKB, nextQuality)); }, fail: (err) => reject(err) }); }, fail: (err) => { console.error('图片压缩失败:', err); reject(err); } }); }); };

这个函数的设计有几个小心思:1.递归压缩:一次压不到目标大小就再压一次,避免单次压缩过度导致严重失真。2.渐进降低质量:每次递归将质量降低5点,有一个下限(比如60),防止无限循环。3.记录日志:方便调试,知道每次压缩的效果。

4. 核心代码解析:截图、绘制与裁剪逻辑

现在,回到我们的页面组件,编写最核心的shareScreenshot函数。这个函数串联了从截图到分享的所有步骤。

// 在页面组件的script中 import { compressImageRecursively } from '@/utils/imageCompress.js'; import { ref } from 'vue'; const posterUrl = ref(''); // 用于存储最终生成的封面图临时路径 const shareTitle = '快来查看这个精彩内容!'; // 分享标题 const miniProgramId = 'gh_你的小程序原始id'; // 在微信小程序后台获取 const handleShare = (scene) => { // #ifdef APP-PLUS uni.showLoading({ title: '生成分享图中...', mask: true }); const url = '/pages/index/index?id=123'; // 小程序落地页路径,可带参数 // 调用核心截图分享函数 generateShareCover(url, scene, shareTitle).finally(() => { uni.hideLoading(); }); // #endif // #ifdef MP-WEIXIN // 如果是小程序环境,直接使用小程序的分享API,逻辑不同 uni.showToast({ title: '请在App中体验', icon: 'none' }); // #endif }; const generateShareCover = async (pagePath, scene, title) => { return new Promise((resolve, reject) => { // 1. 获取当前页面实例进行截图 const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; const currentWebview = currentPage.$getAppWebview(); const bitmap = new plus.nativeObj.Bitmap('screenshot_bitmap'); // 生成一个唯一的临时文件名 const tempImagePath = `_downloads/share_${Date.now()}.png`; // 2. 截图当前Webview currentWebview.draw(bitmap, () => { // 截图成功,保存到临时文件 bitmap.save(tempImagePath, {}, async (saveRes) => { // 3. 开始Canvas绘制 const ctx = uni.createCanvasContext('shareCanvas', this); const systemInfo = uni.getSystemInfoSync(); const canvasWidth = systemInfo.windowWidth; // Canvas宽度设为屏幕宽 const canvasHeight = (systemInfo.windowWidth * 4) / 5; // 根据5:4比例计算高度 // 关键计算:裁剪掉顶部导航栏部分 // 假设你的自定义导航栏高度是120rpx,需要转换成px参与计算 const navbarHeightRpx = 120; const navbarHeightPx = uni.upx2px(navbarHeightRpx); const statusBarHeight = systemInfo.statusBarHeight; const totalTopOffset = statusBarHeight + navbarHeightPx; // 需要裁剪掉的顶部总高度 // 获取截图图片的详细信息 uni.getImageInfo({ src: saveRes.target, // 刚才保存的截图路径 success: (imgInfo) => { // 核心裁剪公式:计算在原始截图中,需要裁剪的起始Y坐标 // 原理:截图宽度与屏幕宽度一致,需要裁剪的像素高度 = (总偏移高度 / 屏幕高度) * 图片实际高度 const cropStartY = (totalTopOffset / systemInfo.windowHeight) * imgInfo.height; // 4. 在Canvas上绘制裁剪后的图片 // drawImage参数详解: // 参数1-4: 源图片的裁剪区域 (sx, sy, sWidth, sHeight) // 参数5-8: 在画布上放置的位置和尺寸 (dx, dy, dWidth, dHeight) ctx.drawImage( saveRes.target, // 源图片 0, // 从源图片X=0开始 cropStartY, // 从源图片Y=cropStartY开始(跳过顶部) imgInfo.width, // 裁剪的宽度 = 图片原宽 (imgInfo.width * 4) / 5, // 裁剪的高度,按5:4比例计算 0, // 画布上从X=0开始绘制 0, // 画布上从Y=0开始绘制 canvasWidth, // 绘制宽度 = 画布宽 canvasHeight // 绘制高度 = 画布高 ); // 5. 可选:在图片上添加Logo、文字等叠加元素 // 例如在右下角加一个半透明Logo // ctx.globalAlpha = 0.7; // ctx.drawImage('/static/logo.png', canvasWidth - 60, canvasHeight - 60, 50, 50); // ctx.globalAlpha = 1.0; // 执行绘制 ctx.draw(false, async () => { // 6. 将Canvas转换为临时图片文件 uni.canvasToTempFilePath({ canvasId: 'shareCanvas', fileType: 'jpg', // 用jpg格式,体积更小 quality: 0.92, // 初始质量可以设高一点,后面会统一压缩 success: async (canvasRes) => { const tempCanvasPath = canvasRes.tempFilePath; // 7. 检查并压缩图片 uni.getFileInfo({ filePath: tempCanvasPath, success: async (fileInfo) => { const fileToCompress = { path: tempCanvasPath, tempFilePath: tempCanvasPath, size: fileInfo.size }; try { const compressedFile = await compressImageRecursively(fileToCompress, 120, 85); posterUrl.value = compressedFile.tempFilePath; // 8. 调用分享 uni.share({ provider: 'weixin', scene: scene, // 'WXSceneSession' 或 'WXSceneTimeline' type: 5, // 分享小程序卡片 imageUrl: compressedFile.tempFilePath, title: title, miniProgram: { id: miniProgramId, path: pagePath, type: 0, // 正式版 webUrl: 'https://你的备用H5域名.com' // 低版本微信备用链接 }, success: (shareRes) => { console.log('分享成功', shareRes); uni.showToast({ title: '分享成功', icon: 'success' }); // 分享成功后,清理临时文件 cleanupTempFile(tempImagePath); bitmap.clear(); // 释放Bitmap内存 resolve(shareRes); }, fail: (shareErr) => { console.error('分享失败', shareErr); cleanupTempFile(tempImagePath); bitmap.clear(); reject(shareErr); } }); } catch (compressErr) { console.error('压缩失败', compressErr); reject(compressErr); } }, fail: (fileErr) => reject(fileErr) }); }, fail: (canvasErr) => reject(canvasErr) }); }); }, fail: (imgErr) => reject(imgErr) }); }, (saveErr) => { console.error('保存截图失败', saveErr); reject(saveErr); }); }, (drawErr) => { console.error('截图绘制失败', drawErr); reject(drawErr); }); }); }; // 清理临时文件的辅助函数 const cleanupTempFile = (filePath) => { plus.io.resolveLocalFileSystemURL(filePath, (entry) => { entry.remove(() => { console.log('临时文件已删除'); }, (removeErr) => { console.warn('删除临时文件失败', removeErr); }); }, (resolveErr) => { console.warn('未找到临时文件', resolveErr); }); };

这段代码有点长,但每一步我都加了详细注释。你把它复制到你的项目里,替换掉小程序ID和路径,基本就能跑起来。这里我重点解释几个容易出错的点:

  1. drawImage的参数顺序:这是Canvas的难点,参数多容易记混。记住一个口诀:“先源后目”。前四个参数定义从源图片上裁剪哪一块(起点X,起点Y,裁多宽,裁多高)。后四个参数定义裁剪下来的这一块要放到画布的什么位置,以及缩放成多大(画布起点X,画布起点Y,显示宽度,显示高度)。
  2. 裁剪起点Y的计算cropStartY = (totalTopOffset / screenHeight) * imageHeight。这是一个比例换算。因为截图时,我们截取的是整个屏幕(高度为screenHeight),而我们想裁掉顶部totalTopOffset高度的部分(状态栏+导航栏)。那么,在截图这张图片上,对应的像素位置就是按这个比例算出来的。
  3. ctx.draw(false, callback):这里的false表示不保留上一次的绘制。一定要在它的回调函数里执行canvasToTempFilePath,因为Canvas的绘制是异步的,必须等绘制真正完成后再转换,否则导出的图片可能是空白。

5. 高级优化:让你的分享图又快又清晰

上面的代码已经能跑了,但要做到“优秀”,我们还得进行一些优化。这些技巧是我在多个项目中总结出来的,能显著提升成功率和用户体验。

5.1 解决“模糊”问题的关键:DPI适配

你有没有发现,在Retina屏(高清屏)手机上,Canvas绘制的图片特别容易模糊?这是因为Canvas的默认逻辑像素和设备的物理像素不一致。我们需要根据设备的pixelRatio(像素比)来放大Canvas的绘制尺寸。

修改Canvas的创建和绘制逻辑:

const generateShareCover = async (pagePath, scene, title) => { const systemInfo = uni.getSystemInfoSync(); const dpr = systemInfo.pixelRatio || 1; // 获取设备像素比,通常是2或3 // 设置Canvas的实际渲染尺寸(物理像素) const canvasWidth = systemInfo.windowWidth * dpr; const canvasHeight = (systemInfo.windowWidth * 4 / 5) * dpr; // 但CSS样式仍用逻辑像素,防止画布过大影响布局 // 这部分需要在模板中动态绑定style,或者用JS创建Canvas时设置 const ctx = uni.createCanvasContext('shareCanvas', this); // 关键一步:缩放上下文,让后续的所有绘制操作都基于放大后的尺寸 ctx.scale(dpr, dpr); // ... 后续的drawImage等绘制操作,坐标和尺寸仍然使用逻辑像素值 ... // 例如,绘制区域还是 (0, 0, systemInfo.windowWidth, systemInfo.windowWidth*4/5) // 在转换图片时,指定目标尺寸为物理像素尺寸 uni.canvasToTempFilePath({ canvasId: 'shareCanvas', destWidth: canvasWidth, // 指定目标宽度为物理像素宽 destHeight: canvasHeight, // 指定目标高度为物理像素高 fileType: 'jpg', quality: 0.92, success: (res) => { // 得到的图片就是高清的 } }, this); };

简单来说,就是用更高的分辨率(物理像素)去绘制,然后压缩成标准尺寸输出。这样图片的细节信息更多,即使被压缩,清晰度也远胜于直接低分辨率绘制。

5.2 性能优化:预加载与缓存

如果你的分享图包含网络图片(比如用户头像、商品图),在绘制时再去下载肯定会卡顿。我的建议是提前预加载

// 在页面加载时,或用户可能触发分享前,预加载所需图片 const preloadImages = async (urls) => { const loadPromises = urls.map(url => { return new Promise((resolve, reject) => { uni.getImageInfo({ src: url, success: resolve, fail: reject }); }); }); try { await Promise.all(loadPromises); console.log('所有图片预加载完成'); } catch (e) { console.warn('部分图片预加载失败', e); } }; // 在onLoad或合适的时机调用 onLoad(() => { const imagesNeeded = [ 'https://example.com/avatar.jpg', 'https://example.com/product.png' ]; preloadImages(imagesNeeded); });

对于分享结果,我们也可以做简单的缓存。比如,用户在同一页面短时间内多次分享,我们没必要每次都重新截图、绘制、压缩。可以缓存最终生成的图片路径,设置一个短暂的过期时间(比如10秒)。

let cachedPoster = { url: '', timestamp: 0 }; const getCachedOrGeneratePoster = async (generateFn) => { const now = Date.now(); const cacheValidTime = 10 * 1000; // 缓存10秒 if (cachedPoster.url && (now - cachedPoster.timestamp) < cacheValidTime) { console.log('使用缓存的分享图'); return cachedPoster.url; } const newUrl = await generateFn(); cachedPoster = { url: newUrl, timestamp: now }; return newUrl; }; // 在分享函数中这样调用 const finalImageUrl = await getCachedOrGeneratePoster(() => generateShareCover(...));

5.3 错误处理与降级方案

网络环境复杂,任何一步都可能出错。一个健壮的程序必须有完善的错误处理和降级方案。

  1. 截图失败:可能是页面过于复杂或内存不足。可以准备一张默认的、符合5:4比例的占位图作为后备。
  2. 图片加载失败:在drawImage前,对网络图片使用uni.getImageInfo,失败时用本地默认图片替换。
  3. 压缩失败:如果递归压缩多次仍失败(可能是图片本身格式问题),可以尝试直接使用原始截图,或者将质量降到极低(如50)做最后一次尝试,并给出友好提示。
  4. 分享接口调用失败:检查是否安装了微信,网络是否通畅。可以引导用户“点击右上角分享”或“保存图片后手动分享”。
// 一个增强版的drawImage,包含错误处理 const drawImageSafe = (ctx, imgSrc, x, y, width, height) => { return new Promise((resolve) => { uni.getImageInfo({ src: imgSrc, success: (res) => { ctx.drawImage(res.path, x, y, width, height); resolve(true); }, fail: () => { console.warn(`图片加载失败: ${imgSrc},使用默认图`); // 绘制一个灰色的默认矩形,或者加载本地默认图片 ctx.setFillStyle('#f0f0f0'); ctx.fillRect(x, y, width, height); ctx.setFontSize(12); ctx.setFillStyle('#999'); ctx.fillText('图片加载失败', x + 10, y + height / 2); resolve(false); // 标记绘制失败,但流程继续 } }); }); }; // 在绘制循环中使用 await drawImageSafe(ctx, productImageUrl, 50, 50, 100, 100);

6. 避坑指南:我踩过的那些“雷”

最后这部分,是我用真金白银(和无数头发)换来的经验,希望能帮你省下大量调试时间。

坑一:安卓分享特别模糊,iOS却正常。这个问题折磨了我很久。后来发现,安卓系统对canvasToTempFilePath导出的JPG图片质量处理与iOS有差异。解决方案是:在安卓上,将quality参数稍微调高(比如0.95),并且确保destWidthdestHeight参数明确指定,且与Canvas的绘制尺寸(考虑DPI后)一致。有时候,在安卓上使用PNG格式反而更清晰,但体积会大,需要权衡。

坑二:分享到朋友圈的图片不显示。微信分享到朋友圈(WXSceneTimeline)和分享给好友(WXSceneSession)对图片的检测机制略有不同。朋友圈分享更严格。务必确保你最终传给imageUrl的图片路径是本地临时路径tempFilePath),并且这个文件确实存在。分享完成后,要及时清理这些临时文件,避免占用过多存储。

坑三:自定义导航栏导致裁剪位置错位。如果你的页面使用了自定义导航栏,计算cropStartY时,navbarHeightPx一定要算对。最好在项目里定义一个全局变量或工具函数来获取准确的导航栏高度,因为不同机型、不同状态栏高度下,这个值可能需要动态计算。

// 一个更稳健的获取导航栏高度的方法 const getNavBarHeight = () => { const systemInfo = uni.getSystemInfoSync(); // 这里假设你使用uni-app的默认导航栏,并设置了navigationBarHeightStyle为‘custom’ // 如果你用了完全自定义的View做导航栏,需要加上你的组件高度 let height = 44; // iOS导航栏常见高度 if (systemInfo.platform === 'android') { height = 48; } // 加上状态栏高度 return systemInfo.statusBarHeight + height; };

坑四:分享卡片的标题和封面图不匹配。uni.sharetitle参数是分享卡片的标题,而imageUrl是封面图。有时候你会发现封面图更新了,但标题还是旧的。这是因为微信客户端有缓存。解决方法是:在分享的path参数里加上一个随机查询参数,比如?id=123&t=,强制微信识别为新的分享。同时,确保小程序的onShareAppMessage生命周期里返回的imageUrl也是动态生成的。

好了,关于uni-app动态生成5:4比例小程序分享封面的所有实战经验和优化技巧,我都毫无保留地分享出来了。从核心原理、完整代码到深度优化和避坑指南,相信足以帮你打造出体验一流的分享功能。技术细节虽多,但一步步拆解实现,其实并不复杂。最关键的还是理解整个流程,并耐心调试。如果你在实现过程中遇到任何新问题,欢迎随时交流。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/14 16:31:10

博士学位过剩危机:学术界的供需失衡与职业出路探索

1. 当“博士帽”不再等于“铁饭碗”&#xff1a;我们正面临什么&#xff1f; 十年前&#xff0c;如果你告诉我&#xff0c;一个手握顶尖大学博士学位的年轻人&#xff0c;会为了一个普通的研发工程师岗位而挤破头&#xff0c;我可能觉得你在开玩笑。但今天&#xff0c;这已经是…

作者头像 李华
网站建设 2026/7/14 16:31:21

从入门到精通:SIM4LIFE Light在人体电磁场仿真中的实战指南

1. 从零开始&#xff1a;认识SIM4LIFE Light与你的第一个仿真项目 如果你是生物医学工程、生物电磁学或者相关领域的研究生或工程师&#xff0c;第一次听到“人体电磁场仿真”这个词&#xff0c;可能会觉得它离自己很远&#xff0c;充满了复杂的数学公式和晦涩的专业术语。别担…

作者头像 李华
网站建设 2026/7/14 16:31:23

Super Qwen Voice World实战手册:语音文件批量重命名与目录结构规范

Super Qwen Voice World实战手册&#xff1a;语音文件批量重命名与目录结构规范 1. 项目概述与价值 Super Qwen Voice World是一个基于Qwen3-TTS语音合成模型构建的复古像素风语音设计平台。它将复杂的语音合成过程转化为有趣的游戏化体验&#xff0c;让用户能够通过直观的界…

作者头像 李华
网站建设 2026/7/14 16:31:22

深入解析Cadence中的PXF与PAC仿真技术:应用场景与选择策略

1. 从“听声辨位”到“顺藤摸瓜”&#xff1a;PXF与PAC的本质区别 刚接触Cadence SpectreRF仿真套件的时候&#xff0c;看到PXF和PAC这两个分析选项&#xff0c;很多朋友可能和我当初一样&#xff0c;有点懵。它们看起来都和小信号分析、频率转换有关&#xff0c;菜单也挨着&am…

作者头像 李华
网站建设 2026/7/14 16:31:24

matplotlib中文显示异常:glyph缺失问题排查与解决指南

1. 问题重现&#xff1a;那个让人头疼的“Glyph Missing”警告 如果你刚开始用matplotlib做数据可视化&#xff0c;尤其是想给自己的图表加上一个漂亮的中文标题时&#xff0c;大概率会撞上这个经典的“拦路虎”。屏幕上突然蹦出一堆黄得刺眼的警告&#xff0c;什么“Glyph 242…

作者头像 李华