SVGA 内存高居?适配困难?规范缺失?🔱🐭👉🏻我来助你!SVGA 内存占用的主要来源还是其动画过程中使用到的各
SVGA 是一种使用简单、性能卓越的跨平台开源动画格式。它的出现,使原本复杂的动画开发流程分工明确,所有人都只需专注于自身领域,大大减少了动画交互的沟通成本。
典型的 SVGA 动画开发流程
-
动画设计师
- 使用 AE、Animate(Flash) 等工具制作动画;
- 使用 SVGAConverter 将动画源文件转换为 SVGA 文件;
-
开发工程师
- 根据不同平台的需要集成相应平台的 SVGAPlayer;
- 调用 SVGAParser 从本地资源或远程服务器解析 SVGA 文件;
- 将解析出的内容交给相应平台的 SVGAPlayer 播放。
SVGA 与 GIF、Lottie 孰优孰劣?
SVGA 文件的本质就是一个采用了 ProtoBuf + Zlib 方式打包的压缩文件,其最大的特点在于,创新性地将一个动画拆分成了以下两个部分:
- 动画元素:指的是动画过程中运用到的各种切图素材(包含位图、矢量图等)。
- 动画帧:代表所有元素在某一时刻的 位置、大小、旋转角度、透明度 等信息。
与 GIF 相比
由于 GIF 的每一帧都是一张完整的图片,因此无论是文件大小,还是播放时占用的资源,都不太符合生产的要求,且由于 GIF 只支持 8 bit 的色深,实际的动画表现效果也不够理想。
而 SVGA 所包含的动画元素是有限的,且在动画过程中也在不断复用,因而可以有效地降低文件大小和播放时占用的资源, 且由于 PNG 格式的动画元素可以支持 32 bit 的色深,因而在动画表现效果上也更胜 GIF 一筹。
与 Lottie 相比
Lottie 与 SVGA 其实是有点类似的,只不过它导出的内容是「动画元素」与「动画脚本」,它实际上是把完整的动画逻辑在相应平台上重新实现了一次。这也就意味着,如果遇上了较为复杂的动画效果,如需要进行一些高阶的插值运算(二次线性方程、贝塞尔曲线方程等)时,Lottie 同样会面临性能问题的困扰。
而 SVGA 则是在导出动画时,把动画逻辑封装在了每一个动画帧的帧信息中,播放器只需读取帧信息,然后粗暴地把每一个动画元素,丝毫不差地渲染到屏幕上即可,无需进行任何的插值运算,从而提高了性能。
所以我们才会说,“SVGA 不关心关键帧,因为 SVGA 里面的每一帧都是关键帧”。
如果你还是无法理解二者的区别,我们可以用一个生活化的例子来进行类比。
假设你想拍摄一部低成本的科幻片,有一幕是主角用念力驱使桌子上的一个杯子移动一段距离。
毫无疑问,这一幕里的动画元素就是一个杯子、一张桌子和一个便秘表情的主角。
如果用 Lottie 实现,就相当于你需要构思如何在不接触杯子的情况下让杯子移动。你可能需要在桌子底下安上一块磁铁,或者同时调整桌子角度和拍摄角度,你需要在拍摄脚本中写一遍,然后在实际拍摄中操作一遍。拍摄脚本就相当于 Lottie 的动画脚本,越复杂的场景拍摄方式就越困难。
如果用 SVGA 实现,就相当于你只需要在桌子上每隔1厘米做上标记,这些标记就相当于 SVGA 的动画帧,记录着杯子在某一时刻的位置,然后我们只需要固定镜头角度,然后每移动杯子到下一个标记就拍摄一次,之后将所有镜头串联起来,即可实现相同的效果,非常简单粗暴。
现有 SVGA 使用场景存在的问题
近年来,随着 VAP 等动画特效库的兴起,MP4 逐渐取代 SVGA 成为了直播间礼物的主流格式。只不过,凭借其自身独特的优势,SVGA 仍在小型动画(诸如头像框、声波特效等)领域保有一席之地。
在对现有的 SVGA 使用场景展开了一轮考察之后,我们发现了以下几个颇为显著的问题:
问题1:内存占用高且长期驻留
虽然 SVGA 支持动态绘制矢量图,然而在实际的应用中,动画设计师们更多地会使用提前做好的位图来制作动画,且往往会出于对动画效果的极致追求,在动画中使用了大量的位图素材。
诸如此类的原因,最终致使 SVGA 成为了仅次于 MP4 礼物的内存消耗大户,而且相较于仅播放 1 次的 MP4 礼物,某些 SVGA 是需要重复播放的(如头像框),这就导致只要页面不销毁,SVGA 就会长期驻留在内存当中。
问题2:不同尺寸适配成本较大
同一个 SVGA 可能会以不同的尺寸展示于多个地方(如特效商品的预览与正常展示),如果要求生成不同的尺寸,一来会增加动效制作人员的工作量,二来也会增大包体大小或磁盘缓存大小;而如果仅产出单一的尺寸,在小尺寸的控件上使用大尺寸的 SVGA 显然性价比不高。
问题3:前期规范缺失难以修正
某些项目的前期没有为 SVGA 制定产出规范,导致某些 SVGA 的素材资源不符合要求,全部进行修改的话成本过高,并且某些 SVGA 资源出于成本与效率的考量,采用了外部购买的方式,规范也难以覆盖。
SVGA 总内存占用估算方式
SVGA 内存占用的主要来源还是其动画过程中使用到的各种切图素材,因此,可以通过单独计算并汇总其中每个切图素材的内存占用,来估算 SVGA 的总内存占用。
Android/iOS/Flutter 默认都是以 32 bit 的色深解码图片,1 byte = 8 bit,因此每个切图素材的内存占用的计算公式为:图片分辨率 * 4 bytes。
按照这个计算方法得出的 SVGA 总内存占用,与其在 SVGA 官网预览时所得到的内存占用数值是一致的:
内存占用:2.7479171752929688 MB
Dian---{"width":30, "height":30, memoryUsage: 0.0034332275390625 MB}
GuangHuan---{"width":169, "height":95, memoryUsage: 0.061244964599609375 MB}
Ren01---{"width":174, "height":293, memoryUsage: 0.19448089599609375 MB}
ShanGuang---{"width":154, "height":154, memoryUsage: 0.0904693603515625 MB}
Shou01---{"width":125, "height":78, memoryUsage: 0.03719329833984375 MB}
tou---{"width":126, "height":135, memoryUsage: 0.06488800048828125 MB}
...
有了 SVGA 动画实现原理作为支撑,也梳理出了现有 SVGA 使用场景问题,同时获知了 SVGA 内存计算方式,接下来我们便以 Flutter 平台为例,给出相应的内存优化方案,方案是通用的,其他平台均可借鉴其思路。
Flutter 是如何揪出尺寸过大的图片的?
在 Flutter inspector 工具里有一个「高亮尺寸过大的图片」的选项,启用该选项之后,Flutter会将尺寸过大的图片进行「垂直翻转」与「色调反转」:
同时,在 控制台中打印图片的「显示尺寸」、「解码尺寸」以及「额外内存消耗」:
dash.png has a display size of 213×392 but a decode size of 2130×392, which uses an additional 2542KB.
dash.png 的显示尺寸为 213×392,但解码尺寸为 2130×392,这额外使用了 2542KB 。
这部分逻辑的相关代码实现位于 decoration_image.dart 源文件当中:
/// 仅在 Debug 模式或 Profile 模式下执行
if (!kReleaseMode) {
// 我们假设所有图像都针对具有最高设备像素比的视图进行解码,并将其用作图像显示尺寸的上限。
final double maxDevicePixelRatio = PaintingBinding.instance.platformDispatcher.views.fold(
0.0,
(double previousValue, ui.FlutterView view) => math.max(previousValue, view.devicePixelRatio),
);
final ImageSizeInfo sizeInfo = ImageSizeInfo(
source: debugImageLabel ?? '<Unknown Image(${image.width}×${image.height})>',
imageSize: Size(image.width.toDouble(), image.height.toDouble()),
displaySize: outputSize * maxDevicePixelRatio,
);
assert(() {
/// 启用了「高亮尺寸过大的图片」选项 & 解码尺寸大于显示尺寸
if (debugInvertOversizedImages &&
sizeInfo.decodedSizeInBytes > sizeInfo.displaySizeInBytes + debugImageOverheadAllowance) {
final int overheadInKilobytes = (sizeInfo.decodedSizeInBytes - sizeInfo.displaySizeInBytes) ~/ 1024;
final int outputWidth = sizeInfo.displaySize.width.toInt();
final int outputHeight = sizeInfo.displaySize.height.toInt();
/// 打印显示尺寸 & 解码尺寸 & 额外内存消耗
FlutterError.reportError(FlutterErrorDetails(
exception: 'Image $debugImageLabel has a display size of '
'$outputWidth×$outputHeight but a decode size of '
'${image.width}×${image.height}, which uses an additional '
'${overheadInKilobytes}KB (assuming a device pixel ratio of '
'$maxDevicePixelRatio).\n\n'
'Consider resizing the asset ahead of time, supplying a cacheWidth '
'parameter of $outputWidth, a cacheHeight parameter of '
'$outputHeight, or using a ResizeImage.',
library: 'painting library',
context: ErrorDescription('while painting an image'),
));
// 色调反转
canvas.saveLayer(
destinationRect,
Paint()..colorFilter = const ColorFilter.matrix(<double>[
-1, 0, 0, 0, 255,
0, -1, 0, 0, 255,
0, 0, -1, 0, 255,
0, 0, 0, 1, 0,
]),
);
// 垂直翻转
final double dy = -(rect.top + rect.height / 2.0);
canvas.translate(0.0, -dy);
canvas.scale(1.0, -1.0);
canvas.translate(0.0, dy);
invertedCanvas = true;
}
return true;
}());
}
这一类尺寸过大的图片会导致应用性能低下,在低端设备上这一情况尤为显著。而当列表中存在大量此类图片时,性能下降的影响还会叠加。
- 可能的情况下,最好的办法还是直接调整图片资源的大小,让它变得更小;
- 如果调整困难,还有一种办法,那就是在构造 Image Widget 时传递参数「cacheHeight」、「cacheWidth」;
- 这两个参数可以让 Flutter 引擎按照指定的大小来解析图片,从而减少内存使用量;
- 只不过相较于直接缩小图像资源本身,解码和存储图像的开销仍然是相对比较昂贵的。
我们从源码的角度来看一下「cacheHeight」与「cacheWidth」参数让 Image Widget 做了什么事情。
- 当任一参数不为空时,Image 就会包装成 ResizeImage:
static ImageProvider<Object> resizeIfNeeded(int? cacheWidth, int? cacheHeight, ImageProvider<Object> provider) {
if (cacheWidth != null || cacheHeight != null) {
return ResizeImage(provider, width: cacheWidth, height: cacheHeight);
}
return provider;
}
2. ResizeImage 在 loadImage 方法内会根据图像缩放策略来调整目标宽高,进行精确调整或等比缩放。
return decode(buffer, getTargetSize: (int intrinsicWidth, int intrinsicHeight) {
switch (policy) {
case ResizeImagePolicy.exact:
int? targetWidth = width;
int? targetHeight = height;
/// 指定了目标宽高,但目标宽高大于图像本身宽高,
/// 且不允许缩放时,仍会调整为图像本身宽高
if (!allowUpscaling) {
if (targetWidth != null && targetWidth > intrinsicWidth) {
targetWidth = intrinsicWidth;
}
if (targetHeight != null && targetHeight > intrinsicHeight) {
targetHeight = intrinsicHeight;
}
}
return ui.TargetImageSize(width: targetWidth, height: targetHeight);
case ResizeImagePolicy.fit:
/// 图像本身宽高缩放比
final double aspectRatio = intrinsicWidth / intrinsicHeight;
/// 最大宽高,根据外部传值限定
final int maxWidth = width ?? intrinsicWidth;
final int maxHeight = height ?? intrinsicHeight;
/// 目标宽高,默认为图像本身宽高
int targetWidth = intrinsicWidth;
int targetHeight = intrinsicHeight;
/// 图像本身宽度超过限制时,以宽度为基准进行缩放
if (targetWidth > maxWidth) {
targetWidth = maxWidth;
targetHeight = targetWidth ~/ aspectRatio;
}
/// 图像本身宽度没超过限制而本身高度超过限制,
/// 或者调整后的图像高度仍超过限制时,以高度为基准再进行缩放
if (targetHeight > maxHeight) {
targetHeight = maxHeight;
targetWidth = (targetHeight * aspectRatio).floor();
}
/// 允许缩放
if (allowUpscaling) {
if (width == null) {
/// 只指定了高度,高度直接调整为指定高度,宽度等比例缩放
assert(height != null);
targetHeight = height!;
targetWidth = (targetHeight * aspectRatio).floor();
} else if (height == null) {
/// 只指定了宽度,宽度直接调整为指定宽度,高度等比例缩放
targetWidth = width!;
targetHeight = targetWidth ~/ aspectRatio;
} else {
/// 宽高都指定了,取不超过最大限制的较长边,另一边等比例缩放
final int derivedMaxWidth = (maxHeight * aspectRatio).floor();
final int derivedMaxHeight = maxWidth ~/ aspectRatio;
targetWidth = math.min(maxWidth, derivedMaxWidth);
targetHeight = math.min(maxHeight, derivedMaxHeight);
}
}
return ui.TargetImageSize(width: targetWidth, height: targetHeight);
}
});
3. 两种图像缩放策略的差异:
exact
将图像大小调整为 ResizeImage.width 和 ResizeImage.height 指定的精确宽度和高度。
(即无视容器,指示多大就显示多大)
fit
根据需要缩放图像,以确保其适合 ResizeImage.width 和 ResizeImage.height 指定的边界框,同时保持其纵横比。
(即无论如何都保持其纵横比,宽度优先,allowUpscaling=false时最大显示原图大小,allowUpscaling=true时才会缩放到指定宽高)
https://api.flutter.dev/flutter/painting/ResizeImagePolicy.html
为什么解码成更小尺寸可以节省内存?
图片解码的原理,简单来讲,就是将压缩后的图片数据还原成未压缩的像素矩阵的过程。
图片在存储和传输时,为了节省空间,通常会进行压缩。压缩的方式有很多种,例如JPEG、PNG、WebP等。不同的压缩算法,其原理和压缩效率也不尽相同。
在解码时,解码器会根据压缩算法的规则,将压缩后的数据还原成原始的像素矩阵。
当我们要求解码成更小的尺寸时,解码器并不是简单地丢弃像素来缩小图像尺寸,而是会通过调整采样比例来生成不同尺寸的图像,例如,可以将每2x2的像素块合并成一个像素,从而将图像缩小一半。
调整采样比例是缩小图片尺寸的关键步骤,也是在信息保留和尺寸缩减之间取得平衡的核心操作。
由于相邻像素通常颜色值比较接近,将它们合并成一个像素,虽然损失了一些细节信息,但人眼对这种变化的感知并不敏感。 在很多情况下,这种信息损失是可以接受的,并不会显著影响对图像内容的整体理解。
而当我们成功解码成更小的尺寸后,根据我们前面的提到的图片内存占用计算公式,由于图像的分辨率显著减小了,其内存占用自然而然也就随之降低了。
那么,在了解完了前面这些内容之后,现在我们面临的一个最关键的问题就是——
如何指定 SVGA 切图素材的解码大小?
SVGA 官方专门为 Flutter 平台提供了一个 SVGAPlayer-Flutter 库,这个库是通过 Flutter CustomPainter 以原生 API 的方式来渲染动画的。
其内部实际使用了 Flutter 的 PaintingBinding.instance.instantiateImageCodecWithSize 方法来解码图片,该方法有一个「getTargetSize」参数,该参数包含一个指定宽度与和一个指定高度,用以结合图像本身大小来进一步判断,最终确定解码图像的大小。
/// 实例化图像编解码器。
///
/// buffer 参数是二进制图像数据(例如 PNG 或 GIF 二进制数据)。
///
/// 如果指定 getTargetSize 参数,则将调用该参数并传递图像的固有大小以确定图像解码后的大小。
/// 当仅指定宽度和高度之一时,省略的尺寸将被缩放以保持原始尺寸的纵横比。
/// 当两者都为空或省略时,图像将以其原始分辨率进行解码。
Future<Codec> instantiateImageCodecWithSize(
ImmutableBuffer buffer, {
TargetImageSizeCallback? getTargetSize,
}) async {
getTargetSize ??= _getDefaultImageSize;
final ImageDescriptor descriptor = await ImageDescriptor.encoded(buffer);
try {
final TargetImageSize targetSize = getTargetSize(descriptor.width, descriptor.height);
assert(targetSize.width == null || targetSize.width! > 0);
assert(targetSize.height == null || targetSize.height! > 0);
return descriptor.instantiateCodec(
targetWidth: targetSize.width,
targetHeight: targetSize.height,
);
} finally {
buffer.dispose();
}
}
那这个「getTargetSize」参数又应怎么传值呢?
首先,第一步我们需要确立一个「目标解码尺寸」,一般我们都会要求至少要贴合 SVGA 容器的尺寸,也即 SVGA 的显示尺寸最大不能超过 SVGA 容器的尺寸。
但是,如果我们真的直接以 SVGA 容器的尺寸作为目标解码尺寸,就会发现 SVGA 动画变得很模糊:
这是因为,我们还有一个维度没有考虑进来,那就是「设备像素密度」。
设备像素密度指的是设备屏幕单位面积内的像素数,称为dpi(dots per inch,每英寸点数)。当两个设备的尺寸相同而像素密度不同时,图像的效果呈现如下:
为了优化不同屏幕配置下的用户体验,确保图像能在所有屏幕上显示最佳效果,Android 和 iOS 都建议应针对常见的不同的屏幕尺寸和屏幕像素密度,提供对应的图片资源。
那么同样,为了确保 SVGA 也能在所有屏幕上显示最佳效果,在传值时我们需要在 SVGA 容器尺寸的基础上乘以设备像素比,以比实际传值更大的解码尺寸去解码切图素材。
这里我们采用与常见的 .dp、.sp 等类似的扩展方法实现:
extension DimenExtension on double {
/// 用来乘以设备像素比得出适当的图片解码大小,否则在高设备像素密度的设备上可能会显示模糊
double get dpr => this * Get.window.devicePixelRatio;
}
SVGAWidget(
animationController,
fit: BoxFit.contain,
clearsAfterStop: true,
allowDrawingOverflow: true,
filterQuality: FilterQuality.low,
decodeWidth: containerWidth.dpr,
decodeHeight: containerHeight.dpr
),
但是话说回来,如果一味地追求在所有屏幕上显示最佳效果,又与我们减少 SVGA 内存占用的初衷相违背,所以更合理的做法应该是在效果展示和内存占用之间取得平衡。
为此,我们可以参考 Image.asset 方法的 scale 参数,增加一个缩放因子,当此值不为空时,则忽略设备像素比直接取该值。
SVGAWidget(
animationController,
fit: BoxFit.contain,
clearsAfterStop: true,
allowDrawingOverflow: true,
filterQuality: FilterQuality.low,
decodeWidth: containerWidth,
decodeHeight: containerHeight,
scale: 2
),
那这个缩放因子取什么值合适呢?这个没有一个统一的定论,在实际操作中,当发现一个问题资源后,我们可以拿一个设备像素密度最高的设备,从较低值开始逐步调整缩放因子的值,在保证 SVGA 显示效果可接受的情况下,尽可能提升 SVGA的内存压缩比例。
接下来,第二步我们需要获取 SVGA 的「画布尺寸」。在解析 SVGA 文件后得到的 MovieEntity 类中,包含了许多 SVGA 动画的关键信息,其中就包含了我们需要的画布尺寸 viewBoxWidth x viewBoxHeight。
/// 从缓冲区下载动画文件,并对其进行解码。
Future<MovieEntity> decodeFromBuffer(List<int> bytes,
{double? decodeWidth, double? decodeHeight, double? scale}) {
...
}
message MovieEntity {
string version = 1; // SVGA 格式版本号
MovieParams params = 2; // 动画参数
map<string, bytes> images = 3; // Key 是位图键名,Value 是位图文件名或二进制 PNG 数据。
repeated SpriteEntity sprites = 4; // 元素列表
repeated AudioEntity audios = 5; // 音频列表
}
message MovieParams {
float viewBoxWidth = 1; // 画布宽
float viewBoxHeight = 2; // 画布高
int32 fps = 3; // 动画每秒播放帧数,合法值是 [1, 2, 3, 5, 6, 10, 12, 15, 20, 30, 60] 中的任意一个。
int32 frames = 4; // 动画总帧数
}
得到所有的这些计算因子后,如何指定 SVGA 切图素材的解码大小就很清晰了——
- 首先通过对比目标解码尺寸与 SVGA 画布尺寸,得到应缩放的比例;
- 再预读取每一个切图素材的尺寸,乘以这个比例得到实际应解码的尺寸;
- 作为 getTargetSize 参数传递给解码器,从而实现以指定的大小解码切图素材。
最终敲定的 SVGA 内存优化方案
步骤1: 检查 SVGA 资源使用
当应用以 Debug 或 Profile 模式运行,并启用了「高亮尺寸过大的图片」选项后,检查应用中使用了 SVGA 动画资源的地方。
步骤2: 高亮显示问题资源
当发现某个 SVGA 的画布尺寸大于其显示尺寸时,对 SVGA 进行色调反转,以方便开发者定位问题资源。
需要注意的是,这里同样参考 Flutter 的做法,假设所有 SVGA 都针对具有最高设备像素比的视图进行解码,并将其用作 SVGA 显示尺寸的上限。
步骤3: 以更小尺寸解码 SVGA
当定位到问题资源后,即可修改代码,增加「decodeWidth 」与「decodeHeight 」参数,用以传递目标解码尺寸、计算缩放比例,从而让解码器以指定的大小解码切图素材,减少内存的消耗。
默认情况下会取设备像素比作为缩放因子,确保 SVGA 能在所有设备的屏幕上显示最佳效果。
步骤4: 调整缩放因子,取得最佳平衡
对比修改前后的内存占用有无明显改善,如果不太理想,则需要增加 scale 参数,手动调整缩放因子。
缩放因子 | 1 | 1.5 |
---|---|---|
![]() | ![]() | |
内存占用 | 0.3019561767578125 MB | 0.6835441589355469 MB |
压缩比例 | 89% | 75% |
缩放因子 | 2 | 3 |
---|---|---|
![]() | ![]() | |
内存占用 | 1.2151031494140625 MB | 2.7479171752929688 MB |
压缩比例 | 56% | 0 |
可以看到,当缩放因子调整为1.5时,单靠肉眼已经很难觉察到与2、3的区别了,所以在这个例子中,1.5就是在效果展示和内存占用之间取得平衡的最佳数值,其压缩比例可以达到75%。
而在Profile模式下也可以从Memory View上看到,优化后的内存增长曲线高度相对于优化前的更加平缓:
Demo运行初始内存 | 播放瞬间内存增长曲线 | 运行一分钟后内存情况 |
---|---|---|
![]() | ![]() | ![]() |
![]() | ![]() | ![]() |
测试设备参数:
目标解码尺寸 | 无 | =容器尺寸 | =容器尺寸 | =容器尺寸 | =容器尺寸 |
---|---|---|---|---|---|
缩放因子 | 无 | 1 | 1.5 | 2 | 3 |
SVGA 总解码时长 | 180ms202ms197ms | 167ms174ms176ms | 185ms183ms184ms | 196ms192ms192ms | 228ms229ms240ms |
最大尺寸图像解码时长 | 70ms88ms81ms | 60ms64ms63ms | 72ms71ms72ms | 81ms83ms79ms | 115ms116ms121ms |
最小尺寸图像解码时长 | 12ms12ms12ms | 9ms12ms12ms | 12ms12ms11ms | 12ms12ms10ms | 12ms11ms12ms |
转载自:https://juejin.cn/post/7412810199227138074