UIWidgets源码系列:图片组件的原理

这篇文章介绍UIWidgets中图片组件Image的原理。首先简要介绍图片组件的使用方法,包括使用Unity Asset图片时需要注意的点。然后介绍图片组件的实现机制,包括ImageStreamImageProvider以及图片解码器等。

使用Image组件

在UIWidgets中,Image组件用来展示图片的内容。Image组件的常用参数有widthheight,用来设定图片的大小。此外,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.assetImage.networkImage.fileImage.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来源的图片时,有以下几点需要注意:

  1. UIWidgets可以根据屏幕像素比(devicePixelRatio)选择不同大小的Asset图片。屏幕像素比是指设备物理像素设备独立像素的比例,即一个设备独立的像素代表多少物理像素。这个值越大,设备就能够支持更多的细节,就可以使用更大的图片来增加图片的清晰度。开发者并不知道自己的程序会在什么样的设备上运行,不过可以为同一图片准备不同大小的版本,由程序在运行时根据设备的屏幕像素比来选择最合适的图片。在UIWidgets中,需要将这些图片的文件名增加后缀@2@3,表示2倍或3倍大小的图片,如myphoto@2.png。UIWidgets会优先选择倍率不大于屏幕像素比的最大的图片。

  2. 需要在图片的Import Settings中将Non Power of 2选项修改为None (默认是To Nearest,意味着将图片的高和宽缩放到最近的2的幂)。

    None Power Of Two

    Unity中采用这种默认设置的原因是图片被Unity加载为纹理图,而纹理图的宽和高都为2的幂时,可以优化渲染性能。

  3. 如果图片是GIF动画,则需要将文件添加后缀.bytes,使Unity将其加载为纯字节数据的Asset。原因是到目前为止(Unity 2019.1),Unity不支持多帧图片资源。对于.bytes后缀的文件,UIWidgets会自己负责解码,不再走Unity的解码流程。

  4. 如果图片看上去有一些奇怪的效果,也有可能是Unity将其加载为纹理图时做了特殊处理导致的。同GIF情况处理(加.bytes)即可。

Image的原理

这一节讲述Image组件的实现原理。为了方便理解,本文中对UIWidgets中节选的代码段进行了删减,只保留能体现主要思路的语句。特别地,所有涉及异步的地方全部改为了同步以简化代码。完整的实现请参考源代码。

Image的实现用到了两个重要的工具,ImageProviderImageStream。下面的代码节选出了_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;
   }
}

上述代码的思路可以简述如下:

  1. build()函数中,用_imageInfo中的图片数据构建RawImage_imageInfo是怎么来的呢?
  2. 调用this._handleImageChanged(ImageInfo)函数传进来;这个函数是谁调用的呢?
  3. _updateSourceStream()更新ImageStream时,会把this._handleImageChanged()作为***加到这个stream上,等这个stream解码好图片后调用***;谁来更新ImageStream呢?
  4. 每次这个Image组件发送变化,更新ImageProvider时,就调用这个ImageProviderresolve函数,获取一个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);
        }
    }
}

UIWidgets Image Stream

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()函数。此时分为两种情况:

  1. 图片只有一帧。这种情况下, 直接_emitFrame(),结束,不再有任何后续的事情

  2. 图片有多帧。这种情况下,需要调用_handleAppFrame(),后者回过头来再调用_decodeNextFrameAndSchedule()函数,陷入循环,实现图片的动画播放。

    注意_handleAppFrame()函数的最后两行,它是为了处理当前时间还没到下一帧时间的情况。它首先计算到下一帧还需要多久,然后使用计时器来拖过这段时间,再调用一遍_handleAppFrame()自己。

ImageProvider

之前提到,Image组件每次换ImageProvider时,就调用后者的resolve()方法。ImageProvider是一个接口,只有resolve()这一个接口函数。

在接口ImageProvider和其具体的实现(如NetworkImage)之间,还隔了一层抽象类ImageProvider<T>。这个类实现了resolve()方法,而把resolve()方法中需要用到的两个函数交由具体的子类去实现。如下图所示:

UIWidgets Image Provider

这两个未实现的函数是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()函数,它的作用是:

  1. 若缓存中不存在,就调用load()函数创造一个新的对象并存入缓存
  2. 若缓存中已经存在,就不调用load()函数,而从缓存中取出对象
  3. 返回得到的对象

所以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的类型TAssetBundleImageKey

// 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);
    }
}

AssetImageExactAssetImage相比,额外实现了查找不同倍率图片的功能,查找的方法是从设备像素比开始,从上往下找,对每个倍率尝试加载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图片时需要注意的事项等。此外还介绍了图片组件的实现机制,包括ImageStreamImageProvider的原理,以及图片解码器等。