news 2026/11/5 2:47:03

大文件分片上传实战:Flask+WebUploader实现与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大文件分片上传实战:Flask+WebUploader实现与避坑指南

简介:面向Python Web开发者的分片上传与下载演示项目,后端基于Flask框架,前端采用WebUploader组件,可实现大文件分段上传、服务器端合并以及低内存流式下载,适合学习文件传输处理、内存控制和并发场景的工程师。压缩包共26个文件,包含两处Python源码(服务入口与上传模块)、两个HTML模板页面,以及Bootstrap、WebUploader所需的CSS、JavaScript、字体和映射文件,整体仅427KB,目录结构清晰,能快速定位前后端代码和静态资源。该项目已有651人浏览学习,示例代码完整展示了分片上传、分片合并、流式下载等关键逻辑,还处理了多用户同时上传互不干扰的可靠机制;说明文档还特别指出未来可优化方向,即下载时按序直接重新合并分片回传,而不先在服务端拼接全部分片,从而进一步降低内存占用。对研究大文件传输场景、希望复用成熟前后端交互方案的开发者有直接参考价值。

1. 为什么大文件上传总翻车:分片不是可选项,是必选项

折腾过文件上传的人应该都有过这种血压拉满的时刻:一个 2GB 的压缩包,进度条稳如老狗地爬到 97%,然后浏览器给你弹一个“网络错误”,后端日志里连个影子都没有。一怒之下重传,服务器又开始新一轮的磁盘和带宽消耗。这不是网络玄学,而是单次 HTTP 请求承载大文件时,稳定性天然脆弱——连接超时、代理断开、内存溢出,任何一个环节抖动,整趟上传就白搭。分片上传(fragmentation / chunked upload)就是冲着这个痛点去的:把大文件切成若干个 2MB~5MB 的小块,失败时只重传失败的那片,而不是从头再来。这份upload-demo做的事情很纯粹——用 Flask 做后端接收分片,用 WebUploader 做前端切片和并发控制,再用 Bootstrap 套一层能直接点着玩的前端页面。适合正在做私有网盘、内部系统、后台管理项目里需要接收大文件的人,也适合想搞懂“分段上传前后端到底怎么对齐”的人。下面我会把它的工作流、三个核心接口、前端参数权衡和常见坑拆开讲清楚。

2. 选型与工作流:WebUploader 如何与 Flask 配合处理分段上传

2.1 为什么是 Flask + WebUploader,而不是其他组合

我没选 Django 系或者 Spring 系来拆这个 Demo,不是它们不行,而是 Flask 在处理分片上传这类“路由少、逻辑集中”的场景里,性价比高得离谱。你不需要 ORM、不需要 Admin 后台、不需要中间件体系,只要几个路由接收上传请求、读写文件目录就够了。Flask 的request.files和request.form天然就能解析 WebUploader 提交的 multipart 表单,几乎没有磨合成本。

WebUploader 这边,它的价值在于把前端分片这件事封装成了“配置项”而不是“算法”。你不用自己写 FileReader 切片的逻辑,也不用自己管理 Blob 的合并,它默认就支持:

  • 按chunkSize自动切割文件
  • 多个分片并发上传(threads控制并发数)
  • 失败分片单独重试
  • 通过formData追加自定义参数(后端要的标识符就靠它传递)

Bootstrap 只承担最外层的页面样式,让你打开 demo 页面时不至于对着裸 HTML 怀疑人生。它的存在不参与上传逻辑,但能让整个 demo 看起来像个能交付的东西。这是这类“演示项目”里很实用的取舍——把精力放在上传链路上,UI 用现成框架搭个壳。

2.2 一次完整的分片上传,数据是怎么流的

我习惯先把数据流向梳理清楚,再去看代码。整个流程是这样一条链路:

  1. 用户在页面选中文件,WebUploader 读取文件基本信息(文件名、大小、类型),计算总大小。
  2. 前端按chunkSize把文件切成chunk个数个分片,每个分片就是一个独立的 Blob。
  3. 第一个分片开始上传时,通过formData携带自定义参数identifier(我习惯用文件名的 MD5 值或者时间戳加随机串),以及 WebUploader 自动带上来的chunk(当前分片序号)和chunks(总分片数)。
  4. 后端收到每个分片后,不直接写进目标文件,而是先落到以identifier命名的临时目录里,存成chunk_0、chunk_1这样的分片文件。
  5. 当前端最后一个分片上传完成后,触发merge接口,后端读取该identifier对应临时目录下的所有分片,按顺序拼接成完整文件。
  6. 拼接完成后,校验文件大小是否等于前端上报的size,通过则删除临时分片,返回成功。

我拆过不少上传组件,这里最容易翻车的点是第 4 步和第 5 步:后端怎么命名分片文件、合并时怎么排序,前后端必须达成默契。WebUploader 的formData里不会给你排序用的fileIndex,只有chunk字段,所以后端必须用chunk作为文件名的一部分,合并时按它的数值排序,而不是按字典序。这个细节我在第 5 章会单独当坑讲。

3. 后端三个接口与合并逻辑:路由设计、参数含义和状态机

3.1 接收分片的 upload 路由:临时目录与分片落盘

后端是整个分片上传的核心承重墙,前端切得再漂亮,后端接不住全白搭。我拆这份 demo 时,把后端逻辑拆成了三个独立的接口:/upload接收分片、/merge合并文件、/check查询状态。先看接收分片的实现:

import os import uuid from flask import Flask, request, jsonify app = Flask(__name__) UPLOAD_DIR = os.path.join(os.getcwd(), "uploads") CHUNK_DIR_BASE = os.path.join(os.getcwd(), "chunks") os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(CHUNK_DIR_BASE, exist_ok=True) @app.route("/upload", methods=["POST"]) def upload_chunk(): # WebUploader 分片上传时,文件字段名默认是 file,可通过 fileVal 修改 file = request.files.get("file") if file is None: return jsonify({"status": "error", "message": "no file part"}), 400 # identifier 是自定义上传标识,我习惯用 uuid 或前端算出的 md5 identifier = request.form.get("identifier", str(uuid.uuid4())) chunk_index = int(request.form.get("chunk", 0)) total_chunks = int(request.form.get("chunks", 1)) # 每个 identifier 独立临时目录,避免不同文件的切片互相污染 chunk_dir = os.path.join(CHUNK_DIR_BASE, identifier) os.makedirs(chunk_dir, exist_ok=True) # chunk 字段是当前分片的序号,必须按它来命名,合并时才能正确排序 chunk_path = os.path.join(chunk_dir, f"chunk_{chunk_index}") file.save(chunk_path) return jsonify({ "status": "success", "identifier": identifier, "chunk_index": chunk_index, "total_chunks": total_chunks }), 200

这段代码里有两个点值得关注。第一是identifier的生成策略:demo 里默认用uuid.uuid4()生成,但真实场景我一般会让前端把文件名哈希或用户 ID 传过来,方便后续做“秒传”,后面进阶部分会展开。第二是分片文件的命名,chunk_{chunk_index}看起来简单,但它是合并环节排序正确与否的关键。

分片存储用file.save(chunk_path)这个 Flask 自带的保存方式,它会把上传流完整写到磁盘,不会出现只写一半的情况。这里不需要你手动做read()和write(),因为file.save内部已经处理了流式读取,对内存占用很友好。

3.2 合并文件的 merge 路由:排序与拼接的正确姿势

分片全部到齐后,前端会调用/merge接口触发合并。这个接口决定最终文件能不能用,我见过太多在这里把sorted()字典序排序导致文件损坏的例子。正确实现:

@app.route("/merge", methods=["POST"]) def merge_chunks(): identifier = request.form.get("identifier") if not identifier: return jsonify({"status": "error", "message": "identifier missing"}), 400 original_name = request.form.get("original_name", "download.bin") chunk_dir = os.path.join(CHUNK_DIR_BASE, identifier) if not os.path.isdir(chunk_dir): return jsonify({"status": "error", "message": "chunk dir not found"}), 404 chunk_files = os.listdir(chunk_dir) # 按 chunk_ 后的数字排序,而不是按文件名排序! # sorted(["chunk_0", "chunk_10", "chunk_2"]) 默认结果 0/10/2,顺序是错的 def chunk_sort_key(name): return int(name.split("_")[1]) chunk_files.sort(key=chunk_sort_key) # 拼接所有分片到目标文件,使用二进制追加模式 final_path = os.path.join(UPLOAD_DIR, f"{identifier}_{original_name}") with open(final_path, "wb") as final_file: for chunk_name in chunk_files: chunk_path = os.path.join(chunk_dir, chunk_name) with open(chunk_path, "rb") as chunk_file: final_file.write(chunk_file.read()) chunk_file.close() # 合并完成后清理临时分片目录,释放磁盘空间 shutil.rmtree(chunk_dir) return jsonify({ "status": "success", "file_path": final_path, "size": os.path.getsize(final_path) }), 200

排序这块是血泪经验。os.listdir返回的文件名是字符串,"chunk_10"排在"chunk_2"前面,如果直接按字符串排,合并出来的文件大概率是尾部数据跑到前面,解压直接报错。chunk_sort_key这个函数把chunk_10转成数字10,再按数字排序,才能保证顺序正确。

合并时用chunk_file.read()把整个分片读进内存再写入,对单分片 5MB 以下没问题;但如果你的分片调大到了 50MB,建议换成shutil.copyfileobj(chunk_file, final_file, length=1024*1024)或者手动while True: data = chunk_file.read(8192)去循环读写,避免内存被大分片撑爆。

3.3 状态查询路由 check:再传一个完整文件时怎么识别

/check路由不是 WebUploader 默认会调用的,但我在实际项目里一定会加它,用途是让前端在开始上传之前先问一句:“这个文件我是不是传过了?”实现逻辑:

@app.route("/check", methods=["POST"]) def check_file(): identifier = request.form.get("identifier") if not identifier: return jsonify({"status": "error", "message": "identifier missing"}), 400 # 在目标目录里查找是否有这个标识符对应的完整文件 for filename in os.listdir(UPLOAD_DIR): # 文件名格式是 {identifier}_{original_name},这里用前缀匹配 if filename.startswith(identifier): target_file = os.path.join(UPLOAD_DIR, filename) return jsonify({ "status": "success", "existing": True, "file_name": filename, "size": os.path.getsize(target_file) }), 200 return jsonify({"status": "success", "existing": False}), 200

这个路由的价值在于:当前端在formData里把identifier固定为文件内容的哈希值时,同一个文件再次被选中,前端可以先请求/check,后端发现已经存在同名文件,直接告诉前端“无需上传”,前端就假装上传完成。这就是大文件上传组件里常见的“秒传”逻辑。demo 里如果没有这一步,你可以在上面代码的基础上自己补上,成本非常低。

4. 前端接入与并发控制:chunkSize、threads 和重试的权衡

4.1 初始化 WebUploader:核心参数逐一拆解

后端三个接口准备好之后,前端的关键是把参数对齐。WebUploader 的初始化代码里,chunkSize、threads、fileVal这三个参数决定了上传行为,我逐个拆给你看:

var uploader = WebUploader.create({ // 上传接口地址 server: '/upload', // 选择文件的按钮 pick: '#picker', // 文件上传表单名,后端用 request.files.get('file') 取的字段 fileVal: 'file', // 是否开启分片上传,大文件必开 chunked: true, // 每个分片的大小,单位是字节,这里设为 5MB chunkSize: 5 * 1024 * 1024, // 并发上传线程数,根据服务器承受能力调整 threads: 3, // 开启文件去重,同一文件不重复上传 duplicate: true, // 客户端内存只保留分片数据,不整个文件驻留 formData: { 'identifier': '' } });

参数层面,chunkSize是最值得斟酌的。5MB 是我在多数部署环境下的默认值,太大了(比如 50MB)分片上传退化成单文件上传,一遇到网络抖动整个分片重传;太小了(比如 512KB)会导致 HTTP 请求数量暴增,对后端文件读写压力反而更大。我的经验是:局域网内部系统可以调到 10MB,公网环境建议 2MB~5MB。threads设置成 3 是 WebUploader 的默认值,并发意味着同时会有 3 个分片在飞,对 Flask 这类同步框架来说已经有一定压力,不要一上来就调成 10。

formData里的identifier在这里是个占位符,真正的值需要在文件被添加之后动态灌入。这就是下一部分要处理的事件逻辑。

4.2 事件回调里的秘密:identifier 赋值与分片进度

WebUploader 的事件机制是你理解它的第二把钥匙。fileQueued在文件进入队列时触发,这时候我们能拿到文件对象,算好identifier并塞进formData。uploadProgress控制进度条,uploadAccept判断后端是否接受当前分片:

uploader.on('fileQueued', function (file) { // 用文件的名称加时间戳生成一个唯一标识 // 如果想要秒传,可以在这里改用 SparkMD5 计算文件哈希 var identifier = file.name + '_' + new Date().getTime(); uploader.options.formData.identifier = identifier; // 把这个 identifier 塞给当前文件,后续所有分片请求都会携带 file.identifier = identifier; // 显示文件信息到页面上 $('#file-name').text(file.name); $('#file-size').text(file.size); }); uploader.on('uploadProgress', function (file, percentage) { // 这个接口的值范围是 0 到 1,直接赋给 Bootstrap 进度条 $('#progress-bar').css('width', percentage * 100 + '%'); }); uploader.on('uploadAccept', function (file, response) { // 后端返回 status 不是 success 时,返回 false 让该分片进入重试队列 if (response.status !== 'success') { return false; } }); uploader.on('uploadComplete', function (file) { // 所有分片传完后,手动触发合并请求 $.ajax({ url: '/merge', method: 'POST', data: { identifier: file.identifier, original_name: file.name }, success: function (res) { $('#status').text('上传成功,合并文件大小:' + res.size); } }); });

注意uploadAccept这个回调的返回值,是 WebUploader 用来判断当前分片要不要重试的关键。回调里return false会让该分片进入失败队列并自动重试;返回true才认为这片上传成功。不少开发者忽略了这个回调,后端返回 500 时前端还一脸茫然地继续传下一个分片,等全部传完去 merge,才发现分片缺胳膊少腿。

uploadComplete触发的时机是“所有分片都上传成功”,注意这里是前端视角,后端的分片可能还没完全落盘。稳妥做法是在uploadComplete里加个setTimeout延时 500ms 再调/merge,给后端一点处理时间。虽然我这个写法直接调,但实际部署时建议加延时。

4.3 Bootstrap 页面的拼装:静态资源位置别放错

demo 的页面结构是三件套:Bootstrap 的 CSS、WebUploader 的 JS 和 CSS、自己写的上传业务逻辑。目录结构我一般这样摆:

upload-demo/ ├── app.py # Flask 后端,上面三个路由 ├── uploads/ # 合并完成的文件目录 ├── chunks/ # 分片临时目录 ├── static/ │ ├── bootstrap/ │ │ ├── css/bootstrap.min.css │ │ └── js/bootstrap.min.js │ ├── webuploader/ │ │ ├── webuploader.css │ │ ├── webuploader.js │ │ └── Uploader.swf # 兼容老浏览器的 Flash 模式,可忽略 │ └── jquery/ │ └── jquery.min.js ├── templates/ │ └── index.html

HTML 里引用静态资源时,WebUploader要求swf路径单独配置,如果你不在 HTML5 模式下工作,必须把它指向正确的Uploader.swf位置。我拆过很多类似项目,最常见的问题是:开发者把webuploader.css漏了,导致选择按钮的样式完全错乱,明明功能正常但界面看起来像没加载成功。

Bootstrap 在这里只负责撑场面,把按钮、进度条、状态文字这些元素摆得整齐一些。真正干活的是webuploader.js和 jQuery。如果后期你想要文件列表、上传历史、拖拽上传,Bootstrap 的组件生态也能顺手补齐,但它不是分片上传的必要条件——WebUploader 本身不依赖 Bootstrap。

5. 避坑清单:WebUploader + Flask 组合的六个常见翻车点

5.1 合并后的文件损坏:祸根是分片文件名排序

现象:所有分片上传都提示成功,合并接口也返回了 200,但下载下来的文件打不开,图片只显示上半截,压缩包提示“文件已损坏”。

原因:合并时用了sorted()对文件名做字典序排序,chunk_10排在chunk_2前面,分片被错误拼接。

解决:按数字值排序。代码里用int(name.split("_")[1])作为排序 key。这是我在第 3.2 节里反复强调的点,也是我见过翻车率最高的一处。

5.2 同一个文件上传两次,第二次全是脏数据

现象:上传同一个文件,第一次成功,第二次选中后还没传完,后端就报文件已存在或者合并出来的文件大小不对。

原因:没有动态生成identifier,第二次用了相同的标识,临时目录里残留了上次的分片,新分片和旧分片混在一起。

解决:前端在fileQueued里用“文件名 + 时间戳”生成identifier,或者更好的是用文件内容的哈希值,结合后端/check路由实现秒传。临时目录在合并完成后要立即删除(shutil.rmtree),避免磁盘被废弃分片堆满。

5.3 前端页面和 Flask 不在一个端口,跨域把你卡死在“待上传”

现象:页面跑在 127.0.0.1:8080,后端是 127.0.0.1:5000,点完上传按钮后,浏览器控制台一堆 CORS 错误,WebUploader 的队列状态变成 error。

原因:Flask 默认不允许跨域资源共享,浏览器的同源策略拦截了分片上传请求。

解决:后端加一个简单的 CORS 中间件,我一般用flask-cors扩展,两三行搞定:

from flask_cors import CORS CORS(app, supports_credentials=True)

如果不想引入扩展,也可以用 Flask 的after_request钩子手动添加三个响应头。这个问题的陷阱在于,前端请求能发出去,后端能收到,但浏览器不把响应交给 JS,所以uploadAccept收不到response.status,你会看到一种“请求成功但回调无效”的诡异现象——绝大多数是 CORS。

5.4 所有分片上传成功,但服务器上一个分片都没存下来

现象:页面显示上传进度到了 100%,后端/upload接口也打到了 200,但chunks/目录是空的,或者只看到几个 0 字节的文件。

原因:request.files.get("file")取不到文件,说明前端传的字段名和后端期望的不一致。WebUploader 默认fileVal是'file',如果后端写成了request.files.get('upload_file'),分片数据被丢弃。

解决:把后端的字段名改成'file',或者在前端初始化时把fileVal改成和后端一样的名字。我每次排查这类问题时,会先在后端加一行print(request.files.keys()),看一眼实际收到的字段名是什么,而不是凭记忆猜。

5.5 合并时后端内存飙升,文件一大就崩溃

现象:上传 4GB 的文件,合并到一半,后端进程内存占用冲到 2GB 以上,程序直接 OOM 崩溃。

原因:合并逻辑用了chunk_file.read()把整个分片读进内存,分片如果设置得太大(比如 50MB),加上 Flask 的调试模式缓存,内存瞬间吃满。

解决:改用流式复制。Python 标准库的shutil.copyfileobj就是干这个的:

with open(final_path, "wb") as final_file: for chunk_name in chunk_files: with open(os.path.join(chunk_dir, chunk_name), "rb") as cf: shutil.copyfileobj(cf, final_file, length=1024 * 1024)

每次只读 1MB、写 1MB,内存占用会稳定在极低水平。这条在任何处理大文件的系统里都适用。

5.6 分片全传完了,Nginx 却报 413 Request Entity Too Large

现象:后端直连的时候上传一切正常,一放到 Nginx 反代后面,分片上传第一次就失败,错误码 413。

原因:Nginx 默认client_max_body_size是 1MB,单个分片超过这个值就会被拦截。

解决:在 Nginx 配置里调大这个参数:

server { client_max_body_size 20m; }

注意这里设置的是单个分片的最大体积,不是整个文件的最大体积。如果chunkSize是 5MB,把client_max_body_size设成 20MB 已经留足余量。

6. 进阶技巧:用 MD5 指纹替换随机 identifier,顺带实现秒传

这份 demo 的核心链路已经完整,但如果你要把它用到真实项目中,还缺一个重要拼图:文件唯一标识目前用的是“文件名 + 时间戳”,它只能保证每次上传的 identifier 不同,却没法识别同一个文件重复上传。要让系统具备秒传能力,需要把 identifier 换成文件内容的哈希值(通常是 MD5),让相同内容的文件在首次上传后直接返回“已存在”。

WebUploader 官方对 MD5 计算是这样做:开启calculateHash后,组件会逐块读取文件内容算 MD5,在md5回调里拿到值。代码层面:

uploader.on('md5', function (file, md5) { // 这个回调在所有分片上传前触发,拿到的 md5 就是文件内容指纹 uploader.options.formData.identifier = md5; file.identifier = md5; }); uploader.on('fileQueued', function (file) { // 先请求后端 /check 接口 $.ajax({ url: '/check', method: 'POST', data: { identifier: file.name + '_' + file.size }, success: function (res) { if (res.existing) { // 后端已存在相同 identifier 的文件,直接标记为完成 uploader.skipFile(file); $('#status').text('文件已存在,秒传完成'); } else { uploader.options.formData.identifier = file.name + '_' + file.size; } } }); });

这里有个权衡要讲清楚:计算一个 2GB 文件的 MD5,需要完整读取一遍磁盘,耗时可能在十几秒到一分钟不等。这是“秒传”付出的前置成本——省去了上传时间,但增加了计算时间。我的习惯是:文件小于 200MB 直接算 MD5,大于 200MB 时退化为“文件名 + 大小”作为 identifier,秒传命中率略降,但避免了用户选完文件还要等一分钟才看到进度条。

后端/check路由里,我第 3.3 节给的是“按 identifier 前缀匹配目标文件名”,在秒传场景下需要再确认一点:合并后的文件名规则必须是{identifier}_{original_name},这样当前端传来的 identifier 是 MD5 时,后端能直接通过文件名定位到已存在的文件,然后返回existing: true。

这套补全逻辑做完后,整个 upload-demo 才算从一个“能跑的分片上传界面”变成一个“具备断点续传和秒传能力的大文件传输系统”。回头看我拆过的那些项目,凡是出现“目标文件损坏”又找不到原因的,九成

本文还有配套的精品资源,点击获取

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

JSP房产中介系统实战:环境配置、数据库设计与部署调试全解析

1. 项目概述与需求拆解:这个JSP房产中介系统到底在解决什么问题先聊聊这个项目本身。JSP房产中介管理系统,典型的Java Web课程设计/毕业设计项目,标题里带个“j32r6”——这类编号一般是开发者内部的项目版本标识,不用太关心它具体…

作者头像 李华
网站建设 2026/10/18 20:58:08

WebUploader与Flask实现大文件分片上传实战

简介:upload-demo 是一份演示大文件分段上传与流式下载的完整项目,基于 Flask 与 WebUploader 搭建。前端采用分片方式将大文件逐一上传,后端接收全部片段后合并还原,支持多用户同时上传互不干扰,并能以低内存方式流式…

作者头像 李华
网站建设 2026/10/18 12:36:56

量化交易从入门到精通 02:如何挑选真正适合你的那一个

量化交易从入门到精通 02:如何挑选真正适合你的那一个一、灵感遍地开花:策略想法的各类来源二、策略没有绝对好坏,只有适不适合你1、你的可支配时间2、编程技能3、资金规模:是绕不开的现实门槛4、你的投资目标三、写在最后很多刚踏…

作者头像 李华
网站建设 2026/10/18 13:06:59

视频时间轴音频插入与可视化拖拽条实现指南

做音视频处理的人都有这种体会:真正能把人逼疯的,不是某个功能“能不能实现”,而是实现出来以后“总觉得差那么一点意思”。我之前接手过一个需求,标题很简单——在视频时间轴的指定时间处添加一段音频,并且展示一个可…

作者头像 李华
网站建设 2026/10/18 23:08:26

AI输出JSON格式错误修复指南:从正则清洗到括号配平实战

1. 先说说这事的起因:AI输出JSON,到底有多不靠谱做后端和数据处理的朋友,这两年大概都有一个共同感受:让大模型返回结构化数据,省心的时候是真省心,糟心的时候也是真糟心。我最近在做一个内部工具&#xff…

作者头像 李华
网站建设 2026/10/18 7:00:05

RocketMQ核心机制与生产实践:从消息队列原理到高可用部署

先说明一点,这个标题看着简单,但"学习总结"四个字最容易写成流水账。我这边梳理了完整的学习路径和实战心得,重点放在那些官方文档里不会写的"为什么"和"怎么做"上,希望对正在啃RocketMQ的朋友有点…

作者头像 李华