UIWidgets源码系列:图片组件的原理
这篇文章介绍UIWidgets中图片组件Image的原理。首先简要介绍图片组件的使用方法,包括使用Unity Asset图片时需要注意的点。然后介绍图片组件的实现机制,包括ImageStream,ImageProvider以及图片解码器等。
使用Image组件
在UIWidgets中,Image组件用来展示图片的内容。Image组件的常用参数有width,height,用来设定图片的大小。此外,Image组件需要一个ImageProvider类型的参数image,用来指定图片的来源。如:
string url = "https://github.com/flutter/website/blob/master/" +
"src/images/homepage/reflectly-hero-600px.png";
var imageWidget = new Image(
image: new NetworkImage(url),
width: 100,
height: 100
) 其中NetworkImage为一个网络来源的ImageProvider。
Unity提供了许多ImageProvider。
- AssetImage:用来加载Unity Asset中的图片
- NetworkImage:用来获取指定URL的网络图片
- FileImage:用来读取指定路径的本地文件中的图片
- MemoryImage:用来从字节数组读取图片
Image类还为这些不同的ImageProvider封装了对应的静态工厂函数,如Image.asset,Image.network,Image.file,Image.memory等,以更方便地创建不同来源的Image组件。如上例中的代码就等价于:
string url = "https://github.com/flutter/website/blob/master/" +
"src/images/homepage/reflectly-hero-600px.png";
var imageWidget = Image.network(
url,
width: 100,
height: 100
) 使用Unity Asset来源的图片
Unity将所有Resources目录下所有能够识别的文件加载为Asset,其中图片文件加载为纹理图(Texture)类型的Asset。在UIWidgets中,可以根据Asset的名字(图片文件名去后缀)访问这些图片资源。在UIWidgets中,可以用Image.asset(assetName)从Unity Asset创建图片组件。
使用Unity Asset来源的图片时,有以下几点需要注意:
UIWidgets可以根据屏幕像素比(devicePixelRatio)选择不同大小的Asset图片。屏幕像素比是指设备物理像素和设备独立像素的比例,即一个设备独立的像素代表多少物理像素。这个值越大,设备就能够支持更多的细节,就可以使用更大的图片来增加图片的清晰度。开发者并不知道自己的程序会在什么样的设备上运行,不过可以为同一图片准备不同大小的版本,由程序在运行时根据设备的屏幕像素比来选择最合适的图片。在UIWidgets中,需要将这些图片的文件名增加后缀
@2或@3,表示2倍或3倍大小的图片,如myphoto@2.png。UIWidgets会优先选择倍率不大于屏幕像素比的最大的图片。需要在图片的Import Settings中将Non Power of 2选项修改为None (默认是To Nearest,意味着将图片的高和宽缩放到最近的2的幂)。
Unity中采用这种默认设置的原因是图片被Unity加载为纹理图,而纹理图的宽和高都为2的幂时,可以优化渲染性能。
如果图片是GIF动画,则需要将文件添加后缀.bytes,使Unity将其加载为纯字节数据的Asset。原因是到目前为止(Unity 2019.1),Unity不支持多帧图片资源。对于.bytes后缀的文件,UIWidgets会自己负责解码,不再走Unity的解码流程。
如果图片看上去有一些奇怪的效果,也有可能是Unity将其加载为纹理图时做了特殊处理导致的。同GIF情况处理(加.bytes)即可。
Image的原理
这一节讲述Image组件的实现原理。为了方便理解,本文中对UIWidgets中节选的代码段进行了删减,只保留能体现主要思路的语句。特别地,所有涉及异步的地方全部改为了同步以简化代码。完整的实现请参考源代码。
Image的实现用到了两个重要的工具,ImageProvider和ImageStream。下面的代码节选出了_ImageState的实现的主要思路。
// Runtime/widgets/image.cs
public class _ImageState : State<Image> {
ImageStream _imageStream;
ImageInfo _imageInfo;
public override void didUpdateWidget(StatefulWidget oldWidget) {
if(this.widget.image != oldWidget.image) {
this._resolveImage();
}
}
void _resolveImage() {
ImageStream newStream = this.widget.image.resolve();
this._updateSourceStream(newStream);
}
void _updateSourceStream(ImageStream newStream) {
this._imageStream = newStream;
this._imageStream.addListener(this._handleImageChanged);
}
void _handleImageChanged(ImageInfo imageInfo) {
this.setState(() => {
this._imageInfo = imageInfo;
});
}
public override Widget build(BuildContext context) {
RawImage image = new RawImage(
image: this._imageInfo?.image,
width: this.widget.width,
height: this.widget.height
);
return image;
}
} 上述代码的思路可以简述如下:
- 在
build()函数中,用_imageInfo中的图片数据构建RawImage;_imageInfo是怎么来的呢? - 调用
this._handleImageChanged(ImageInfo)函数传进来;这个函数是谁调用的呢? _updateSourceStream()更新ImageStream时,会把this._handleImageChanged()作为***加到这个stream上,等这个stream解码好图片后调用***;谁来更新ImageStream呢?- 每次这个
Image组件发送变化,更新ImageProvider时,就调用这个ImageProvider的resolve函数,获取一个ImageStream进行更新。
ImageStream的实现
可以把ImageStream想象成一个图片产生器,创建之后,就默默在底层解析图片数据,解析完成后,就向所有监听它的人通知一声。它的代码实现框架如下:
// Runtime/painting/image_stream.cs
public class ImageStream : Diagnosticable {
List<ImageListener> _listeners;
ImageStreamCompleter _completer;
public ImageStreamCompleter completer {
get { return this._completer; }
}
public void setCompleter(ImageStreamCompleter value) {
this._completer = value;
foreach (ImageListener listener in this._listeners) {
this._completer.addListener(listener);
}
}
public void addListener(ImageListener listener) {
this._completer.addListener(listener);
this._listeners.Add(listener);
}
public void removeListener(ImageListener listener) {
this._completer.removeListener(listener);
this._listeners.Remove(listener);
}
} 可以看到,ImageStream是对ImageStreamCompleter做一层包装。后者真正负责解码,并在图形数据准备好时调用***。
// Runtime/painting/image_stream.cs
public abstract class ImageStreamCompleter {
List<ImageListener> _listeners = new List<ImageListener>();
public ImageInfo currentImage;
public virtual void addListener(ImageListener listener) {
this._listeners.Add(listener);
listener(this.currentImage, true);
}
public virtual void removeListener(ImageListener listener) {
this._listeners.Remove(listener);
}
protected void setImage(ImageInfo image) {
this.currentImage = image;
foreach (var listener in this._listeners) {
listener(image, false);
}
}
}
ImageStreamCompleter是一个抽象类,只实现了管理***,以及setImage()。真正的解码工作交给具体实现的子类来完成。子类在完成解码后调用setImage()方法。
ImageStreamCompleter有两个子类,不过唯一有用的是MultiFrameImageStreamCompleter。构造一个MultiFrameImageStreamCompleter,需要传入图片解码器Codec作为参数。
// Runtime/painting/image_stream.cs
public class MultiFrameImageStreamCompleter : ImageStreamCompleter {
public MultiFrameImageStreamCompleter(IPromise<Codec> codec) {
codec.Then(this._decodeNextFrameAndSchedule);
}
void _decodeNextFrameAndSchedule() {
var frame = this._codec.getNextFrame();
this._nextFrame = frame;
if (this._codec.frameCount == 1) {
this.setImage(new ImageInfo(this._nextFrame.image));
return;
}
this._handleAppFrame();
}
void _handleAppFrame(TimeSpan timestamp) {
if (this._isFirstFrame() || this._hasFrameDurationPassed(timestamp)) {
this.setImage(new ImageInfo(this._nextFrame.image));
this._decodeNextFrameAndSchedule();
return;
}
TimeSpan delay = // ...;
this._timer = Window.instance.run(delay, this._handleAppFrame);
}
} 当图片解码器Codec准备好(已经解析出图片的帧数和第一帧的图片数据)之后,调用_decodeNextFrameAndSchedule()函数。此时分为两种情况:
图片只有一帧。这种情况下, 直接
_emitFrame(),结束,不再有任何后续的事情图片有多帧。这种情况下,需要调用
_handleAppFrame(),后者回过头来再调用_decodeNextFrameAndSchedule()函数,陷入循环,实现图片的动画播放。注意
_handleAppFrame()函数的最后两行,它是为了处理当前时间还没到下一帧时间的情况。它首先计算到下一帧还需要多久,然后使用计时器来拖过这段时间,再调用一遍_handleAppFrame()自己。
ImageProvider
之前提到,Image组件每次换ImageProvider时,就调用后者的resolve()方法。ImageProvider是一个接口,只有resolve()这一个接口函数。
在接口ImageProvider和其具体的实现(如NetworkImage)之间,还隔了一层抽象类ImageProvider<T>。这个类实现了resolve()方法,而把resolve()方法中需要用到的两个函数交由具体的子类去实现。如下图所示:
这两个未实现的函数是obtainKey()和load()。它们是负责为将要构造的ImageStream准备ImageStreamCompleter的。
// Runtime/painting/image_provider.cs
public abstract class ImageProvider {
public abstract ImageStream resolve(ImageConfiguration configuration);
}
public abstract class ImageProvider<T> : ImageProvider {
public ImageStream resolve() {
var stream = new ImageStream();
var key = this.obtainKey();
var completer = ImageCache.putIfAbsent(key, () => this.load(key))
stream.setCompleter(completer);
return stream;
}
protected abstract ImageStreamCompleter load(T key);
protected abstract T obtainKey();
} ImageProvider<T>的参数T表示用来访问缓存的关键字key的类型。缓存中保存的是许多completer。函数obtainKey()就负责计算key。
注意ImageCache.putIfAbsent()函数,它的作用是:
- 若缓存中不存在,就调用
load()函数创造一个新的对象并存入缓存 - 若缓存中已经存在,就不调用
load()函数,而从缓存中取出对象 - 返回得到的对象
所以load()函数虽然传入了key作为参数,却并不负责访问缓存。这个key的作用,仅仅是传递创建新的completer所需的必要信息。
图片解码器
在介绍具体的ImageProvider如何实现load()和obtainKey()函数之前,先介绍一些图片解码器Codec的原理。因为load()函数的要创造一个completer并返回,而completer的构造函数又需要一个Codec。
Codec接口提供两个只读属性,frameCount是指图片有多少帧,repetitionCount表示重复次数。另外提供一个接口getNextFrame(),返回下一帧的图片数据。
// Runtime/ui/painting/codec.cs
public class FrameInfo {
public Image image;
public TimeSpan duration;
}
public interface Codec {
int frameCount { get; }
int repetitionCount { get; }
FrameInfo getNextFrame();
} Codec有两个实现类,一个是ImageCodec,用来解码绝大多数单帧的图片。另一个是GifCodec,用来解码GIF图片。
// Runtime/ui/painting/codec.cs
public class ImageCodec : Codec {
Image _image;
public ImageCodec(Image image) {
this._image = image;
}
public int frameCount {
get { return 1; }
}
public int repetitionCount {
get { return 0; }
}
public FrameInfo getNextFrame() {
D.assert(this._image != null);
return new FrameInfo {
duration = TimeSpan.Zero,
image = this._image
};
}
} 可以看到,ImageCodec在构造时就已经将图片内容作为参数传递进来并存储起来了,因而getNextFrame()的实现不过是将存好的图片返回,走一个解码的过场。换句话说,这个图片生产工厂的原料就是产品。为了方便,不妨称其为假解码器。
这个假解码器有什么存在的必要呢?是为了形式上的统一。实际上,Unity自身已经提供了大多数图片格式的解码功能,可以调用一些Unity的内置函数,从网络中、文件中直接读取出来Texture2D类型的对象,或者通过Texture2D.LoadImage(byte[])函数解析绝大多数Unity支持的图片格式。所以,对这些格式的图片,都是用ImageCodec包装成一个解码器的样子,真正的解析在构造解码器之前就已经完成了。
静态类CodecUtils提供了两个方法,分别针对两种情况创建其Codec:1) 从网络和文件中直接读出来Texture2D对象;2) 只能先获取字节数组byte[]的情况。
public static class CodecUtils {
public static Codec getCodec(byte[] bytes) {
if (GifCodec.isGif(bytes)) {
return new GifCodec(bytes);
}
var texture = new Texture2D(2, 2);
texture.hideFlags = HideFlags.HideAndDontSave;
texture.LoadImage(bytes);
return new ImageCodec(new Image(texture));
}
public static Codec getCodec(Image image) {
return Codec.Resolved(new ImageCodec(image));
}
} 对于拿到的是Texture2D的情况,直接返回假解码器。
对于拿到的是字节数组的情况,要再分两种情况处理:是GIF图片,则返回UIWidgets自己实现的GIF解码器;其他情况,则使用Texture2D.LoadImage()进行解析,解析完成后返回一个假解码器。
GifCodec简单来讲就是对GifDecoder做了一层封装,以实现Codec接口。GIF图片的编码和解码的细节这里不作展开讨论。
所有的ImageProvider的实现,其load方法返回的都是一个MultiFrameImageStreamCompleter,只不过图片解码器的来源不同而已。
AssetImage和ExactAssetImage
对于从Unity Asset中获取图片,UIWidgets实际上实现了两个ImageProvider,都继承自AssetBundleImageProvider。这个基类实现了load()方法,而这两个子类提供了各自的obtainKey()方法。对应的key的类型T为AssetBundleImageKey。
// Runtime/painting/image_provider.cs
public abstract class AssetBundleImageProvider : ImageProvider<AssetBundleImageKey> {
protected override ImageStreamCompleter load(AssetBundleImageKey key) {
return new MultiFrameImageStreamCompleter(
codec: this._loadAsync(key)
);
}
Codec _loadAsync(AssetBundleImageKey key) {
var result = this._loadAssetAsync(key);
if (result is Texture2D texture) {
return CodecUtils.getCodec(new Image(texture));
} else if (result is TextAsset text) {
var bytes = text.bytes;
return CodecUtils.getCodec(bytes);
}
}
Asset _loadAssetAsync(AssetBundleImageKey key) {
ResourceRequest request = Resources.LoadAsync(key.name);
return request.asset;
}
} AssetBundleImageProvider实现load()方法的核心是调用Unity的Resources.LoadAsync()方法获取一个ResouceRequest,得到其asset。
获取到的asset,可能是Texture2D类型的或者是TextAsset类型的。前者可以直接传递给getCodec(Image)得到假解码器。后者调用其bytes属性得到byte数组,并传递给getCodec(byte[])得到假解码器或者GIF解码器。
ExactAssetImage实现obtainKey比较简单,直接用resolve()函数传入的参数中的Asset名构造AssetBundleImageKey。
// Runtime/painting/image_provider.cs
public class ExactAssetImage : AssetBundleImageProvider {
public ExactAssetImage(
string assetName
) {
this.assetName = assetName;
}
public readonly string assetName;
protected override AssetBundleImageKey obtainKey() {
return new AssetBundleImageKey(name: this.assetName);
}
} AssetImage和ExactAssetImage相比,额外实现了查找不同倍率图片的功能,查找的方法是从设备像素比开始,从上往下找,对每个倍率尝试加载Asset,如果加载成功,就认为找到了这个倍率的图片,并将这个倍率填在key中。
// Runtime/painting/image_resolution.cs
public class AssetImage : AssetBundleImageProvider, IEquatable<AssetImage> {
public AssetImage(string assetName,
AssetBundle bundle = null) {
this.assetName = assetName;
this.bundle = bundle;
}
public readonly string assetName;
public readonly AssetBundle bundle;
protected override AssetBundleImageKey obtainKey() {
AssetBundleImageKey key;
var devicePixelRatio = Window.instance.devicePixelRatio;
key = this._loadAsset(devicePixelRatio);
return key;
}
AssetBundleImageKey _loadAsset(float devicePixelRatio) {
var extension = Path.GetExtension(this.assetName);
var name = Path.GetFileNameWithoutExtension(this.assetName);
var upper = Mathf.Min(3, devicePixelRatio.ceil());
for (var scale = upper; scale >= 1; scale--) {
var assetName = name + "@" + scale + extension;
Object asset = Resources.Load(assetName);
if (asset != null) {
return new AssetBundleImageKey(
bundle,
assetName,
scale: scale
);
}
}
return new AssetBundleImageKey(
bundle,
this.assetName,
scale: 1.0f
);
}
} NetworkImage
直接以自身作为key,从obtainKey()函数返回this。
实现load()方法的方式是调用Unity提供的UnityEngine.Networking.UnityWebRequestTexture.GetTexture方法得到UnityWebRequest对象,然后调用这个对象的SendWebRequest方法。
// Runtime/painting/image_provider.cs
public class NetworkImage : ImageProvider<NetworkImage> {
public NetworkImage(string url) {
this.url = url;
}
public readonly string url;
protected override NetworkImage obtainKey() {
return this;
}
protected override ImageStreamCompleter load(NetworkImage key) {
return new MultiFrameImageStreamCompleter(
codec: this._loadAsync(key)
);
}
Codec _loadAsync(NetworkImage key) {
var obj = this._loadBytes(key);
if (obj is byte[] bytes) {
return CodecUtils.getCodec(bytes);
}
return CodecUtils.getCodec(new Image((Texture2D) obj));
}
Object _loadBytes(NetworkImage key) {
var uri = new Uri(key.url);
if (uri.LocalPath.EndsWith(".gif")) {
using (var www = UnityWebRequest.Get(uri)) {
return www.SendWebRequest();
var data = www.downloadHandler.data;
return data;
}
return null;
}
using (var www = UnityWebRequestTexture.GetTexture(uri)) {
return www.SendWebRequest();
var data = ((DownloadHandlerTexture) www.downloadHandler).texture;
return data;
}
}
} 如果URL末端的文件名以.gif结尾,UnityWebRequestTexture就无法正确解码,此时就需要改为读取原始字节的UnityWebRequest.Get()方法,其余流程类似。
FileImage
和NetworkImage相同的是,obtainKey()函数返回this作为key。
其load()方法的实现方式,是将文件路径前附上协议前缀file://得到一个URL,接下来就和NetworkImage完全相同。
MemoryImage
和NetworkImage相同的是,obtainKey函数返回this作为key。
其load方法的实现非常简单,直接将构造函数时传入并存储下来的bytes数组交给CodecUtils.getCodec(byte[])。
// Runtime/painting/image_provider.cs
public class MemoryImage : ImageProvider<MemoryImage> {
public MemoryImage(byte[] bytes) {
D.assert(bytes != null);
this.bytes = bytes;
}
public readonly byte[] bytes;
protected override MemoryImage obtainKey() {
return this;
}
protected override ImageStreamCompleter load(MemoryImage key) {
return new MultiFrameImageStreamCompleter(this._loadAsync(key));
}
Codec _loadAsync(MemoryImage key) {
return CodecUtils.getCodec(this.bytes);
}
} 总结
这篇文章介绍了UIWidgets中图片组件Image的使用方法以及实现原理。首先介绍了图片组件的使用方法,包括一般的使用方法和使用Unity Asset图片时需要注意的事项等。此外还介绍了图片组件的实现机制,包括ImageStream,ImageProvider的原理,以及图片解码器等。

京公网安备 11010502036488号