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
的原理,以及图片解码器等。