1. 项目背景:为什么“全端兼容视频上传”成了绕不过去的坎

先交代一个背景。这两年接手的App项目里,几乎没有一个能绕过视频上传的需求:用户拍一段产品演示、上传一段课程回放、提交一条短视频动态,看起来都是“选个文件、传上去、拿回URL”,但真在UniApp里做一遍,你会发现远没有想象中简单。

UniApp本身的定位是“一套代码,多端发布”,这个特性在做列表页、表单页、扫码页这类业务时优势确实明显,但只要一碰到视频这种“重资源”,问题就全冒出来了。最典型的几个坑:微信小程序里拿不到完整的视频文件路径,只能拿到临时文件;App端用plus.io读取本地文件时路径格式跟H5完全不一样;iOS上从相册选出来的视频,经常带着奇怪的格式或超大的体积,直接甩给服务器会被拒收;更别提上传过程中断网、切后台、进度回调丢失这些体验问题。再加上要把文件稳定地送进阿里云OSS,你还需要处理STS临时凭证、签名、上传策略、回调通知这一整套流程——单端做尚且繁琐,全端兼容的复杂度直接翻倍。

这个方案要解决的,其实就是三件事: 让一套UniApp代码在App(iOS/Android)、H5、微信小程序里都能选视频、传视频、拿回OSS地址;让每个端都走“客户端直传OSS”这条最优路径,避免文件经过业务服务器中转;同时把上传进度、取消、重试、失败补偿这些交互细节补齐,保证真实用户场景下不掉链子。

顺带说一句,不少刚入门的朋友会问:都有uni.uploadFile了,直接传给自己服务器,再由服务器转存OSS不就行了?技术上确实能通,但代价是你得为视频流量额外付一份服务器带宽费,上传速度还受限于单台机器的出口带宽。更合理的做法是客户端拿着OSS临时凭证直传,服务器只负责“发凭证、收回调”,压力小、速度快、成本低,这也是公司里带我的老哥当初反复强调的一点。下面整个方案就围绕这条主线展开。如果你正被“视频上传在某个端上就是传不上去”折磨,或者想把自己的上传模块从功能型做到生产级,这篇文章应该能帮你把思路彻底理清。

2. 整体设计与技术选型:为什么是STS直传,而不是一把梭

开始写代码之前,先把设计层面的几个决策讲清楚。这些决策直接决定了后面你要踩多少坑。

2.1 全端统一用uni.uploadFile,还是分端写原生?

UniApp的一大优势是uni.uploadFile这个API在App、H5、小程序三端都有实现,而且参数大体一致。所以方案的第一步就是确认:非特殊情况,不允许分端写独立上传逻辑。很多人写到一半发现端上行为不一致,就想“要不H5用xhr、小程序用wx.uploadFile、App用plus.uploader各写一套”,这样固然能解决眼前问题,但后续维护成本极高——你等于同时维护了三套代码,改一个公共逻辑要同步三处,上线后出问题还得逐个端排查。从工程角度讲,能用框架统一API解决的问题,就不要为“省事”引入分端实现。

当然,全端统一不代表完全没差异。差异点集中在三块:一是文件路径的获取方式(下面会专门讲),二是请求头的设置方式(小程序里自定义header字段受限),三是上传任务对象的生命周期管理(App端要处理退到后台等场景)。这三块差异我会在第3章和第4章分别说明,它们算是UniApp上传模块“全端兼容”的真正难点。

2.2 为什么客户端直传OSS,而不是服务器中转

视频文件的典型特征是“大”。一段60秒的1080p视频,压缩后普遍在5MB到20MB,不压缩的甚至能到几百MB。如果走服务器中转:

  • 业务服务器需要承担上行带宽成本,按阿里云按量计费,每GB流量都是钱;
  • 上传速度受限于服务器带宽(通常5Mbps),一个20MB文件可能要等半分钟以上,用户早就烦躁了;
  • 服务器在接收文件的过程中,还需要处理并发连接数、磁盘临时文件清理、超时断开后的孤儿文件等问题,纯属给自己找事。

阿里云OSS本身提供了直传方案:客户端直接把文件传到OSS的Bucket,OSS返回URL给客户端,客户端再把URL提交给业务服务器完成“登记”。这个模式下OSS的带宽和吞吐能力被充分利用,速度快且稳定,业务服务器只做两件事:签发上传凭证、接收上传完成后的回调。这种架构也被称为“客户端直传”,是当前视频类App的标准做法。

2.3 为什么不直接拿AccessKey硬编码,要引入STS临时凭证

如果仅仅是想“把文件传到OSS”,理论上可以在客户端代码里写死一组AccessKey Id和AccessKey Secret。但这等于把服务器最高权限的钥匙发给所有人——任何人反编译你的App或抓包你的H5,都能拿到这组密钥,然后想怎么操作你的Bucket就怎么操作,轻则帮你“清空”存储,重则用你的Bucket做私服。这种事故在圈内并不罕见。

所以正经生产项目必须走STS(Security Token Service)。流程是:客户端请求自己的业务服务器,业务服务器使用阿里云RAM用户的AccessKey调用STS接口,换取一个临时凭证(包括临时AccessKeyId、临时AccessKeySecret和SecurityToken),再把凭证返回给客户端。客户端拿着临时凭证去直传OSS。这个临时凭证的特点是: 有有效期(可设最短15分钟,最长几小时)、权限可控(只允许上传到指定Bucket的指定目录)、用完即弃 。即使被泄露,风险窗口很小,而且权限被限定在“上传文件”这个最小范围,不会殃及Bucket里已有文件的管理操作。

我在项目里一般把有效期设置为30分钟——太短用户还没选完视频凭证就过期了,太长又扩大泄露风险。30分钟覆盖从选视频、裁剪、压缩到上传完成的正常操作节奏,比较合适。如果用户操作超时,前端要捕获凭证过期错误,引导用户重新获取凭证再传,这个逻辑后面会在错误处理里具体讲。

2.4 服务端签发的具体内容

业务服务器的STS签发接口,返回给前端的数据结构大概是这样的:

{
  "accessKeyId": "STS.xxxxxxxx",
  "accessKeySecret": "xxxxxxxxxxxx",
  "securityToken": "xxxxxxxxxxxx",
  "expiration": "2025-01-01T00:00:00Z",
  "bucket": "your-app-video",
  "region": "oss-cn-hangzhou",
  "objectKeyPrefix": "video/2025/01/01/"
}

其中 accessKeySecret 和 securityToken 是临时凭证的核心; bucket 和 region 告诉前端往哪个区域的哪个Bucket传; objectKeyPrefix 是服务端规定的前缀目录,比如 video/2025/01/01/ ,前端在生成最终文件名时拼在后面。这个前缀的设计能起到“隔离业务”的作用——不同业务线的视频分目录存放,后续做生命周期管理(比如自动清理30天前的临时视频)会很方便。

提示:千万不要在服务端把临时凭证的权限设成 oss:* ,应该精确到 oss:PutObject 和 oss:PostObject ,资源范围限定到 acs:oss:*:*:bucketName/video/* 。这个最小权限原则,既能满足业务需要,又能避免因凭证泄露导致更大的损失。

3. 基础设施搭建:从视频选择到上传凭证的完整链路

这一章我们实际动手。假设你已经有一个UniApp项目,阿里云OSS Bucket也已创建好。这一步咱们把基础设施逐一打通。

3.1 在manifest.json里配置好权限和SDK

首先检查manifest.json的应用权限声明。视频上传相关的核心权限有两个:相册读取权限和相机权限(如果支持拍摄)。

在App端,往 App模块配置 里找到“相机和相册”相关配置;在小程序端,需要在 mp-weixin 节点下配置 permission 字段,声明 scope.album 和 scope.camera 的用途说明。H5端不需要特殊权限,但如果是HTTPS页面,要确保上传目标地址(OSS的Endpoint)支持HTTPS,否则浏览器会拦截混合内容。

一个容易被忽略的点:微信小程序在 manifest.json 里的 mp-weixin 节点下,要显式加上 "requiredPrivateInfos": ["chooseMedia"] (用旧版 chooseImage 选视频时可能不需要,但新版 chooseMedia 需要)。不加的话,在开发者工具里可能正常,真机上调用会直接报错。

3.2 封装“获取STS临时凭证”的统一函数

在项目的 utils/oss.js 里,写一个获取临时凭证的函数。这个函数会被所有端复用:

// utils/oss.js

const BASE_URL = 'https://api.yourdomain.com'; // 你的业务服务器地址

export function getStsToken() {
  return new Promise((resolve, reject) => {
    uni.request({
      url: BASE_URL + '/api/oss/sts',
      method: 'POST',
      data: {},
      success: (res) => {
        if (res.statusCode === 200 && res.data.code === 0) {
          resolve(res.data.data);
        } else {
          reject(new Error('获取STS凭证失败:' + JSON.stringify(res.data)));
        }
      },
      fail: (err) => {
        reject(new Error('网络请求失败:' + JSON.stringify(err)));
      }
    });
  });
}

注意这里 res.statusCode 的判断不能省。很多人习惯直接判断 res.data.code ,但在某些端上网络层错误(比如域名没配白名单)会先触发 fail ,在另一些端上会返回非200的 statusCode 。两层判断都写,出问题时信息才够全。

3.3 选择视频并获取本地文件路径

这是全端兼容最容易“翻车”的环节,各端的返回结构差异比较大。

H5端 :通常用 uni.chooseVideo 或 uni.chooseMedia ,拿到的文件对象里有 tempFilePath (Blob URL)和 size 字段。但H5端有个特殊点: uni.uploadFile 内部会把Blob URL转换后上传,所以不需要额外处理。

App端 :同样用 uni.chooseVideo ,但返回的 tempFilePath 可能是一个以 file:// 开头的本地路径,也可能是一个 _doc/uniapp_temp_xxx/xxx.mp4 这样的相对路径。上传前建议转成绝对路径: plus.io.convertLocalFileSystemURL ,或者直接用 tempFilePath 试传——大多数情况下 uni.uploadFile 能自己处理。如果遇到路径解析失败,再考虑手动转。

微信小程序端 :新版基础库推荐用 wx.chooseMedia ,在UniApp里通过 uni.chooseMedia 来调用。返回结构是 res.tempFiles 数组,每个元素里有 tempFilePath 、 size 、 duration 等字段。严格说这个 tempFilePath 是个临时文件,只在本次App生命周期内有效,所以必须尽快上传,不能存起来下次用。

我建议封装一个统一的选择函数,抹平端差异:

// utils/videoPicker.js

export function chooseVideo() {
  return new Promise((resolve, reject) => {
    // #ifdef MP-WEIXIN
    uni.chooseMedia({
      count: 1,
      mediaType: ['video'],
      sourceType: ['album', 'camera'],
      maxDuration: 60,
      success: (res) => {
        const file = res.tempFiles[0];
        resolve({
          tempFilePath: file.tempFilePath,
          duration: file.duration,
          size: file.size,
          width: file.width,
          height: file.height
        });
      },
      fail: reject
    });
    // #endif

    // #ifndef MP-WEIXIN
    uni.chooseVideo({
      sourceType: ['album', 'camera'],
      maxDuration: 60,
      success: (res) => {
        resolve({
          tempFilePath: res.tempFilePath,
          duration: res.duration,
          size: res.size,
          width: res.width,
          height: res.height,
          thumbTempFilePath: res.thumbTempFilePath
        });
      },
      fail: reject
    });
    // #endif
  });
}

重点解释为什么小程序端用 uni.chooseMedia 而不是 uni.chooseVideo :微信官方从基础库2.10.0开始主推 chooseMedia ,它支持同时选择图片和视频,返回信息更完整,而且能拿到 duration 字段(视频时长),这在后续做时长校验时非常有用。如果你的项目还在用很老的基础库,才需要退回 chooseVideo 。

3.4 计算文件名和对象Key

文件名最好不要用用户原始文件名直接上传,否则会带来两个问题:一是中文名或特殊字符在URL里要转义,二是不同用户上传同名文件会互相覆盖。推荐做法是服务端生成或前端拼接一个“时间戳+随机串+扩展名”的文件名:

function generateObjectKey(fileName, ext) {
  const now = new Date();
  const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
  const randomStr = Math.random().toString(36).slice(2, 10);
  // ext 需要处理:xxx.mp4 -> mp4
  const safeExt = (ext || 'mp4').replace('.', '').toLowerCase();
  return `${dateStr}/${randomStr}.${safeExt}`;
}

注意:如果服务端返回了 objectKeyPrefix ,最终的对象Key应该是 objectKeyPrefix + generatedKey 。如果你的STS权限只允许上传到 video/* 目录,那对象Key必须以 video/ 开头,否则服务端即使签发了凭证,OSS也会拒绝写入。

4. 核心实操:封装一个全端可用的OSS直传上传器

这一章是本方案的核心,也是整个工程里投入时间最多的地方。我会把完整代码拆成几块讲清楚,每块对应一个关键环节。

4.1 构造一个“传OSS专用”的uni.uploadFile请求

先给出一个核心版本的上传函数,它会接收第3章获取到的临时凭证和本地文件路径,然后往OSS发起直传:

// utils/ossUploader.js
import { getStsToken } from './oss.js';
import { chooseVideo } from './videoPicker.js';

function getFileExt(path) {
  // 从路径里取扩展名
  const match = path.match(/\.(\w+)$/);
  return match ? match[1].toLowerCase() : 'mp4';
}

function getOssHost(bucket, region) {
  // 例如:https://your-app-video.oss-cn-hangzhou.aliyuncs.com
  return `https://${bucket}.${region}.aliyuncs.com`;
}

export function uploadVideoToOss(options) {
  const {
    filePath,          // 本地视频文件路径
    sts,               // { accessKeyId, accessKeySecret, securityToken, bucket, region, objectKeyPrefix }
    objectKey,         // 对象Key
    onProgress,        // 回调:进度
  } = options;

  const uploadUrl = getOssHost(sts.bucket, sts.region);

  return new Promise((resolve, reject) => {
    const uploadTask = uni.uploadFile({
      url: uploadUrl,
      filePath: filePath,
      name: 'file',
      formData: {
        key: objectKey,
        policy: sts.policy,           // 如果有policy字段就带上,一般由服务端STS返回
        OSSAccessKeyId: sts.accessKeyId,
        signature: sts.signature,     // 如果走签名URL直传(非STS方式),需要这个字段
        'x-oss-security-token': sts.securityToken
      },
      success: (res) => {
        if (res.statusCode === 200 || res.statusCode === 204) {
          resolve({
            url: `https://${sts.bucket}.${sts.region}.aliyuncs.com/${objectKey}`,
            objectKey: objectKey
          });
        } else {
          reject(new Error('OSS上传返回错误:' + res.statusCode + ' ' + res.data));
        }
      },
      fail: (err) => {
        reject(new Error('OSS上传请求失败:' + JSON.stringify(err)));
      }
    });

    if (uploadTask && typeof uploadTask.onProgressUpdate === 'function') {
      uploadTask.onProgressUpdate((res) => {
        if (typeof onProgress === 'function') {
          onProgress(res.progress, res.totalBytesExpectedToSend);
        }
      });
    }
  });
}

这个函数有几个细节要专门说明。

第一, name: 'file' 是阿里云OSS规定的表单字段名吗?严格说不是。OSS的PostObject表单上传要求文件字段名是 file 。但实测中,把文件字段名设成其他值偶尔也能成功——那只是因为阿里云的兼容处理。为了避免奇奇怪怪的问题,统一用 file 最稳。

第二, formData 里的字段是OSS PostObject必须的: key 是最终对象名, policy 和 signature 是签名信息, x-oss-security-token 是STS临时凭证的token。如果你走的是“直接传整个临时凭证+签名”的方式,那 policy 和 signature 通常不需要 securityToken ,但既然项目用了更安全的STS,那 x-oss-security-token 这一步不能少。补充一下: 如果你在业务服务器上采用“STS方式下发签名”,服务端返回的通常不是 policy + signature ,而是 accessKeyId / accessKeySecret / securityToken 三元组。此时OSS服务器在收到上传请求时,是依靠文件表单里的 OSSAccessKeyId 和 x-oss-security-token 来识别身份的,而不是 signature 。 这一点初学者特别容易搞混——以为STS凭证也要像长期AccessKey那样做Base64签名,其实不需要。

如果服务端下发的是 policy 和 signature (PostObject签名直传方式),那么前端逻辑又不一样。在项目实践中, STS临时凭证直传 是更通用的方式:客户端功能只依赖 accessKeySecret 和 securityToken ,不用管签名算法;服务端签发逻辑也更清晰。所以下面的完整示例统一走STS。

为了兼容两种后端实现,我在示例代码里保留了 policy 和 signature 字段的注释,供需要时可以查漏补缺。

4.2 把“选择+上传”串成一条丝滑的链路

选择视频后,用户可能等很久才点“上传”,所以凭证不应该在选择视频时才去获取,而应该在点击“选择并上传”按钮后立刻获取——这样可以最大程度减少凭证过期的概率。流程是:

  1. 用户点击“上传视频”按钮;
  2. 前端获取STS临时凭证(30分钟有效期);
  3. 调起系统相册/相机,让用户选择视频;
  4. 校验视频大小、时长(超限则提示);
  5. 构造对象Key;
  6. 调用 uni.uploadFile 直传OSS,展示进度条;
  7. 上传成功,拿回URL,调业务接口完成“视频与业务数据绑定”;
  8. 上传失败,根据错误码给用户可执行的提示。

我把这段串联逻辑写成独立函数。注意以下代码中错误提示的设计,每条错误都尽量给出用户能理解的文案,而不是笼统地“上传失败”:

// utils/videoFlow.js
import { chooseVideo } from './videoPicker.js';
import { getStsToken } from './oss.js';
import { uploadVideoToOss } from './ossUploader.js';

function checkVideo({ size, duration }) {
  // 常见的限制:最大200MB、最长5分钟
  // 这里只是示例,你可以结合实际情况修改
  const MAX_SIZE = 200 * 1024 * 1024;
  const MAX_DURATION = 300;
  if (size > MAX_SIZE) {
    return { valid: false, msg: '视频不能超过200MB' };
  }
  if (duration && duration > MAX_DURATION) {
    return { valid: false, msg: '视频时长不能超过5分钟' };
  }
  return { valid: true };
}

export async function selectAndUploadVideo({ onProgress }) {
  try {
    // 1. 先获取STS凭证(这个可能耗时几百毫秒,最好配合loading动画)
    const sts = await getStsToken();

    // 2. 选择视频
    const video = await chooseVideo();
    const checkResult = checkVideo(video);
    if (!checkResult.valid) {
      uni.showToast({ title: checkResult.msg, icon: 'none' });
      return null;
    }

    // 3. 生成对象Key
    const ext = getFileExt(video.tempFilePath);
    const objectKey = ... // 拼接:sts.objectKeyPrefix + generateObjectKey(...)

    // 4. 直传OSS
    const result = await uploadVideoToOss({
      filePath: video.tempFilePath,
      sts,
      objectKey,
      onProgress
    });

    return result;
  } catch (e) {
    // 这里要做统一错误处理。如果错误消息里包含“InvalidAccessKeyId”或“SecurityTokenExpired”,
    // 大概率是凭证过期,提示用户重试;如果包含“AccessDenied”,可能是权限配置有问题。
    throw e;
  }
}

在第3步生成 objectKey 时,要留个心眼:服务端可能返回前缀 video/2025/01/01/ ,你拿到的扩展名可能是 mov 、 mp4 、 avi 等。对于 mov 这种格式,我建议在上传前提醒用户“当前格式可能兼容性不佳”,但不强制拦截——总比一刀切不让传要好。

4.3 App端上传进度与“退后台”的特殊处理

在App端, uni.uploadFile 返回的 uploadTask 对象上挂有 onProgressUpdate ,这个在前面代码里已经用到了。如果你做过原生开发,可能会想到“App进后台后,上传会不会被挂起?”——实测下来,在iOS上如果App被用户主动上滑杀掉,网络请求会中断,这是系统级行为,前端代码无法拦截。但在Android上,如果只是“Home键退到后台”,没有杀死进程,很多机型的上传任务会继续跑,只是UI暂停刷新。

所以工程上更稳妥的做法是:把“上传进度”和“上传状态”存入全局状态(比如Vuex或Pinia),而不是组件内的局部变量。这样用户从后台切回来时,组件重新 onShow ,能从全局状态里读取当前进度,恢复进度条显示。

另外要提醒一点:App端 onProgressUpdate 返回的 res.progress 已经是百分比整数,不需要你手动再算。但小程序端 progress 在部分低版本基础库上可能缺失,稳妥的做法是拿 res.totalBytesExpectedToSend 和 res.totalBytesSent 自己算一遍:

if (typeof res.progress === 'number') {
  onProgress && onProgress(res.progress);
} else if (res.totalBytesExpectedToSend > 0) {
  const percent = Math.floor((res.totalBytesSent / res.totalBytesExpectedToSend) * 100);
  onProgress && onProgress(percent);
}

4.4 H5端上传的额外处理:避免“预检失败”和“跨域拦截”

H5端的直传OSS有一个特殊问题:浏览器在跨域POST请求前,会自动发起一个 OPTIONS 预检请求。OSS若没配置好CORS规则,预检失败, uni.uploadFile 会在 fail 里返回一个看不懂的“Network Error”。这个问题的排查顺序是:

  1. 登录OSS控制台,检查Bucket的“权限管理 -> 跨域设置”里是否允许来自你前端域名的 OPTIONS 请求;
  2. 允许的方法至少包含 POST 和 PUT ;Allowed Headers至少包含 * (因为我们会带 x-oss-security-token 这个自定义头);Expose Headers建议加上 ETag ;
  3. 如果你是在本地开发(比如 http://localhost:8080 ),CORS里也要把这个源加进去,否则本地调试永远失败。

还有一个小坑:有些浏览器对上传请求会做“内容类型检测”,如果你在 formData 里传的内容被浏览器识别为“非简单请求”,会要求服务器允许预检。OSS的CORS配置正确就能通过。如果配置正确但仍然报错,打开开发者工具Network面板,看 OPTIONS 请求的响应是否为200;如果响应非200,基本可以断定是CORS配置问题,把响应的错误信息贴给运维或自己进OSS后台检查即可。

5. 各端踩坑实录:从真机到小程序,问题比想象中多

第二章到第四章把主干逻辑基本写完了,但真实项目上线前,“恶魔都在细节里”。这一章把我在开发过程中遇到的典型问题整理成速查表,并给出解决思路。

5.1 问题速查表

问题现象 涉及端 可能原因 解决思路
上传返回403 AccessDenied 所有端 STS权限策略没限定到该Bucket或目录 检查服务端STS Policy的资源路径前缀与 objectKey 是否匹配
请求直接fail,无状态码 H5 CORS跨域预检失败 OSS控制台补跨域规则,允许自定义Header
上传成功但业务服务器收不到回调 所有端 OSS没有配置消息通知,或回调地址错误 在OSS控制台配置“上传回调”或业务侧轮询
视频选完直接崩溃 App-Android 极少数机型无法处理超长视频的采样 选择前限制时长,或后端做转码兜底
微信小程序选视频报错:“chooseMedia:fail api scope is not declared” 微信小程序 manifest里未声明 requiredPrivateInfos 在 mp-weixin 节点声明 requiredPrivateInfos: ["chooseMedia"]
上传进度条卡在99% 所有端 所有数据都传完了,但服务端响应还没返回 这是正常现象,多数情况下最终会成功;超过阈值可做超时提示
App退后台再回来,进度丢失 App 没有把上传状态持久化 改用全局状态管理,或在 onShow 时恢复

5.2 微信小程序真机测试里的两个隐藏坑

隐藏坑一:临时路径失效

小程序里 uni.chooseMedia 返回的 tempFilePath 只在本次会话内有效。如果用户选完视频切到后台,过了几分钟再回来点上传,这个路径很可能已经变成无效文件。遇到这种情况,重新执行一次选择即可,或者在上传前检查文件是否存在(小程序里没有同步的 fs.access ,只能try-catch一下 FileSystemManager 的API)。

隐藏坑二:视频过大的编译警告

微信开发者工具有时会对超过一定体积的素材文件进行提示,但那是指你 打包进代码包 里的素材,不是用户运行时上传的文件。别因为开发者工具报了一个“包体积超过2MB”的警告,就以为“视频不能传了”——这完全是两码事。真正影响小程序端上传的是“临时文件存储空间限制”,微信会给每个小程序分配一定的本地临时文件空间,如果用户不断地选大视频、传失败、再选,临时文件堆积过多,可能出现空间不足导致无法写入新文件。此时可以调 uni.getFileSystemManager 清理之前上传失败遗留的临时文件。

5.3 App端视频格式兼容:MOV、AVI这种“边录边传”的怪东西

从App相册选出来的视频,常见的格式是 mp4 或 mov 。其中 mov 是iOS原生相机经常输出的格式,它的编码通常是H.264/AAC,容器结构不同于mp4但码流本身是一致的。iOS端上传 mov 到OSS再在网页端用 <video> 播放,有时会播放失败——因为部分浏览器对 mov 容器的支持并不好。

这时两条路:

  1. 客户端在上传前先做一次转封装(remux),把 mov 封装成 mp4 。UniApp原生层可以借助插件市场里的“视频处理”插件实现,但注意转封装也需要时间,最好有进度提示;
  2. 服务端收到上传回调后,触发一次异步转码,把 mov 统一转成HLS或MP4。阿里云自带媒体处理服务,配置一个转码任务即可。视频最终面向的是网页端用户,我推荐方案2,因为它把兼容性处理集中在服务端,客户端代码不会越写越重。

如果你只是做内部工具型App,不要求所有平台都能预览,那上传 mov 也不会有太大问题——关键是提前明确“最终消费视频的场景是哪些”,再决定要不要加转码这层。

5.4 关于 renderjs 或 web-view 里播放OSS视频的提示

标题里提到的热词里有一条“uniapp renderjs 手机录得mp4无法播放”,这说明不少朋友传完视频后,在renderjs或web-view里播放遇到了问题。这往往不是上传模块本身的锅,而是视频的编码格式、分辨率或HTTP头不兼容导致的。我遇到过的情况有:

  • 视频编码是H.265/HEVC,而浏览器或renderjs里的播放器不支持;
  • OSS响应头里缺少 Content-Type: video/mp4 (上传时如果未显式指定,OSS可能默认 application/octet-stream );
  • 视频的moov元数据在文件尾部,导致在弱网下无法快速起播(需要“边下边播”时,moov应该在文件头部)。

前两点可以在OSS控制台或上传时设置 Content-Type 来修正;第三点可以通过服务端转码或使用 ffmpeg 做 -movflags +faststart 处理。上传只是第一步,播放链路里的这些细节其实更影响最终用户体验。

6. 进阶:取消、重试、秒传与队列上传

前面的内容已经能让一个“单视频上传”模块跑起来了。但如果你的业务需要支持“一次传多个视频”或者“断网后自动重试”,还有几个进阶问题值得展开。

6.1 取消上传

uni.uploadFile 返回的任务对象上有 abort() 方法,可以中断上传。在UI层,给进度条旁边放一个“取消”按钮即可。需要注意的是,中断上传后,OSS端已经接收的部分数据会被丢弃,不会产生完整文件——所以不会有“传了一半的脏文件残留”问题。但有些时候OSS会生成一个0字节的空对象(取决于服务端的PostObject策略)。

如果希望“取消时连空对象也清理掉”,可以在业务服务器上提供一个删除接口,前端在 abort() 后调用一次删除;但一般场景下,0字节文件不占用多少空间,影响不大,不必为了它额外增加接口调用。

6.2 断网重试

说实话,客户端直传OSS的断点续传,UniApp官方API并不直接支持。 uni.uploadFile 本身是一次性的请求,若网络中断,只能捕获失败后整文件重新上传。如果你做的是短视频工具类App、文件经常超过100MB,那强烈建议不要用原生的 uni.uploadFile 去啃大文件,而是换成“分片上传 + 断点续传”的方案:

  1. 客户端把文件切成固定大小(例如4MB或8MB)的分片;
  2. 逐个分片通过 uni.request 上传到自己的业务服务器,再由服务器子线程转存OSS;
  3. 或者通过OSS的Multipart Upload接口(需自行构造签名),逐个Part直传。

但UniApp官方API不支持直接操作OSS的分片上传接口。所以更现实的路径是: 中小视频走 uni.uploadFile 直传(本项目的默认方案),超大视频走服务端转存或分片上传。 大多数业务场景下,用户上传的视频在30秒到3分钟之间,压缩后普遍不超过50MB,直接用一次性上传已经够用。别为了“追求极限性能”把架构搞复杂,先判断业务是否真需要分片。

6.3 秒传逻辑

“秒传”的本质是文件哈希匹配:需要上传时,先计算文件的MD5(或SHA1),拿哈希去业务服务器查询OSS里是否存在相同文件。如果存在,直接复用已有的URL,不执行真正的上传。

在UniApp的小程序端,计算文件的MD5并不方便,基础库没有直接提供MD5接口,只能通过 FileSystemManager 读取文件内容,再引入一个MD5库来计算。对于几十MB的视频,在真机上计算MD5可能需要数秒到十几秒,所以“秒传”适合做,但不要对计算时长抱太大期望——用户会觉得按钮点了没反应。

折中方案是: 小视频(小于5MB)算MD5走秒传,大视频直接走上传。 这样既能省流量,又不让用户等待太久的计算过程。

7. 安全和性能优化:让方案经得起生产环境考验

最后这章聊的不再是“能不能用”,而是“能不能放心上生产”。以下优化点都是我实际经历过的场景,值得在开发阶段就提前设计进去。

7.1 防盗链与私有读

视频文件如果是公开可读的,任何人拿到URL都能看。很多项目早期为了省事,把Bucket设成“公共读”,结果URL一旦泄露,视频等于裸奔。更安全的做法是“私有读 + 业务侧签名URL”,即OSS文件不允许匿名访问,每次前端要播放视频时,由服务器生成一个带有效期的访问URL(比如10分钟有效),前端拿这个URL去播放。

在UniApp的 <video> 组件里, src 可以直接使用这个带签名的URL。需要留意的是:播放器可能会因为视频跳转、Range请求等原因为同一个URL发起多次请求,签名URL的有效期一定要覆盖整个播放时长预期。建议有效期设置15分钟到半小时。如果播放中断提示“签名过期”,多半是有效期设短了。

7.2 上传时压缩与转码的取舍

要不要在客户端做压缩?这取决于产品定位。如果你的用户上传的是“用户随手拍的短视频”,原视频可能1080p甚至4K,动不动几百MB,直接传OSS不仅慢,还浪费存储和CDN流量。可以考虑在客户端引入FFmpeg的wasm版或原生插件做压缩,但UniApp生态里压缩插件的稳定性参差不齐。

我的建议: 第一版先不做客户端压缩,靠服务端限制上传体积(比如最大200MB)来兜底,后续根据用户反馈和存储成本再决定是否引入压缩。 这样上线速度快,逻辑简单,不会因为压缩过程引入新的崩溃和卡顿问题。等数据积累到一定程度,再在服务端针对超大视频做转码和压缩处理——毕竟服务端的计算资源更可控、更容易扩展。

7.3 上传接口鉴权防刷

客户端直传OSS虽然叫“直传”,但并不意味着任何人都可以随便往你的Bucket里传文件。你必须在业务服务器的STS签发接口上做好用户鉴权——接口要校验当前用户是否登录、是否有上传权限、是否超出每日配额。否则,别有用心的人可以刷你的接口获取临时凭证,然后往你的Bucket里塞垃圾文件,塞满后产生大量存储费用。

我在生产项目里还会加两个限制:

  • 限制上传次数 :比如一个用户一天最多上传50次,防止脚本循环刷;
  • 限制文件大小 :服务端签发的POST Policy里带上最小/最大文件大小限制( content-length-range ),这样即使客户端被破解、绕过前端校验,OSS也可能拒绝超大文件。

这两点都加完后,基本可以防住绝大多数恶意刷量场景。

7.4 埋点与监控:别等用户投诉了才知道上传挂了

最后讲一个体验层面常被忽略的事——监控。视频上传涉及链路较长,任何一个环节出问题用户都会觉得“App坏了”。我的习惯是,在以下几个关键节点上报打点:

  1. 获取STS成功/失败(失败率异常时,优先查服务端接口);
  2. 用户点了上传但迟迟没选文件(可能卡在相册授权对话框);
  3. OSS上传 start 、 success 、 fail ;
  4. 上传耗时分段(选文件耗时、上传耗时、业务绑定耗时)。

用uni.report或自己接的埋点SDK上报到后台,配合告警,能在用户大面积投诉前提早发现问题。尤其是 success 率,如果某天突然从98%掉到80%,你打开埋点后台一查,往往能直接定位到是某个端、某个OSS区域、某种文件格式出问题,而不是像无头苍蝇一样问用户“你那边是什么手机什么网络”。这一套做下来,才真正算得上“生产级的全端上传方案”。

总的来说,UniApp全端兼容OSS视频上传,技术上并不存在“一种API打天下”的银弹。每个端的差异、每个平台的限制、每种网络环境的异常,都需要在实际开发中不断踩坑、记录、补丁。我这里分享的STS直传、路径获取、错误处理、CORS配置这些内容,既是方案的骨架,也是上线后运维的地基。你按章节一步步落地,大概率能少走很多弯路。

就我个人目前的使用体验来说,这套方案帮我解决了之前那个“每换一个端就要重写一遍上传逻辑”的窘境。最后再留下一个小建议:不管你现在是刚起步还是已经写到一半,都建议把“OSS上传”封装成一个独立于业务页面的模块,参数尽量精简,错误尽量语义化,这样后续任何页面需要传视频时,都只是调用一个函数的事,而不是再把上传代码从老页面里复制粘贴一遍。真正好的基础设施,就是业务开发的人完全感受不到它的存在,而你心里清楚它一直在稳稳地兜底。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐