好玩系列:让项目中的相册支持Heif格式图片

前言

目前市面上的成熟的APP,其用户体系中均存在 设置头像 的功能,考虑到尺寸规范问题,一般会加入 图片裁剪 功能; 考虑到页面UI统一度问题,甚至会在应用内实现 相册功能。据此推断:各位的项目中,会遇到 Heif格式图片 需要兼容的需求。

笔者目前参与的商业项目,也被市场要求对Heif图片进行适配。这篇文章,记录了我在这件事情上 折腾 的过程。

好玩系列是我进行 新事物实践尝试创造 的记录,了解更多

背景

HEIF格式的全名为 High Efficiency Image File Format(高效率图档格式),是由动态图像专家组(MPEG)在2013年推出的新格式,了解更多

了解Heif整个项目

测试文件

笔者注:印象中,iOS系统大约在16年就全面支持这一类型的文件了,而Android大约是三年前,在Android P推出的时候,宣布原生支持Heif文件

随着市场上的Android机器已经大面积过渡到 Android Q,从这一点看,确实到了该适配的阶段了。

目标,至少实现Android P及其以上的适配,尝试向更低版本适配

ISO Base Media File Format

HEIF格式是基于 ISO Base Media File Format格式衍生出来的图像封装格式,所以它的文件格式同样符合ISO Base Media File Format (ISO/IEC 14496-12)中的定义( ISOBMFF)。

文件中所有的数据都存储在称为Box的数据块结构中,每个文件由若干个Box组成,每个Box有自己的类型和长度。在一个Box中还可以包含子Box,最终由一系列的Box组成完整的文件内容,结构如下图所示,图中每个方块即代表一个Box。

我们常见的MP4文件同样是ISOBMFF结构,所以HEIF文件结构和MP4文件结构基本一致,只是用到的Box类型有区别。

HEIF文件如果是单幅的静态图片的话,使用item的形式保存数据,所有item单独解码;如果保存的为图片序列的话,使用track的方式保存。

作者:金山视频云 链接:https://www.jianshu.com/p/b016d10a087d 来源:简书 著作权归作者所有

通过ContentResolver查询Heif格式文件

系统通过ContentProvider向其他应用暴露图片等内容信息。目前 尚未查询相关文档 ,未确定 Android相册向其他应用提供了Heif文件查询支持

通过查询我们得到Heif文件的 主要的 扩展名为 heicheif.

ContentResolver contentResolver = context.getContentResolver();
String sort = MediaStore.Images.Media.DATE_MODIFIED + " desc ";
String selection = MediaStore.Images.Media.MIME_TYPE + "=?";
String[] selectionArgs = new String[]{"image/heic"};

String[] projection = {MediaStore.Images.Media._ID, MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME,
        MediaStore.Images.ImageColumns.DATE_MODIFIED};

Cursor cursor = contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        projection,
        selection,
        selectionArgs,
        sort
);

我们在测试文件中只找到了 heic, 就先只测一种。

我们发现,系统支持的情况下,是可以查询到数据的。PS,导入数据到手机后,最好重启下

解码与图片显示

我们忽略掉Android版本相应的适配问题,假定已经得到了相应文件的 Uri, 项目中Glide-4.12.0版本已经处理了适配。

我们去探索一下,是自行添加的解码器,还是依赖于系统API

ExifInterfaceImageHeaderParser 提及内容

/**
 * Uses {@link ExifInterface} to parse orientation data.
 *
 * <p>ExifInterface supports the HEIF format on OMR1+. Glide's {@link DefaultImageHeaderParser}
 * doesn't currently support HEIF. In the future we should reconcile these two classes, but for now
 * this is a simple way to ensure that HEIF files are oriented correctly on platforms where they're
 * supported.
 */

文档中提到,系统版本 O_MR1+ 中已经支持了 HEIF,但是目前的 DefaultImageHeaderParser 还不支持,未来会综合考虑这两个类(系统Exif相关类和DefaultImageHeaderParser),但目前,这是一个简单的方式,确保HEIF在受支持的平台上被正确处理图片方向。

Glide 类中提及的内容

// Right now we're only using this parser for HEIF images, which are only supported on OMR1+.
// If we need this for other file types, we should consider removing this restriction.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
  registry.register(new ExifInterfaceImageHeaderParser());
}

目前仅用于解析HEIF文件的头信息。

我们知道,Glide加载是先获取流,解析头信息,利用对应的解码器处理。而加载此类性质的图片时,是先解码为Bitmap,在进行 装饰 , 而Bitmap的解码是利用了系统API,见BitmapFactory.

所以,如果项目中使用了Glide(似乎高于4.10.0即具有功能,没有仔细查阅),而手机也支持了HEIF,那么应用就可以支持Heif显示了。

Glide官方对于 自定义解码器 还是持保守态度的。但是我们要试一下,尝试在Glide中接入Heif解码器。


至此,我们已经完成了基本目标:

  • 借用平台自身兼容性(当然也可以自己根据版本适配查询语句),利用 ContentResolver 获取 Heif格式的图片
  • 借助Glide已有的实现,直接在支持的平台版本上解码、构建Bitmap、构建相应Drawable、呈现。

Glide官方提供了支持,是一件值得庆幸的事情。

因为项目中仅使用了Glide,笔者没有继续对Fresco展开调研。而Fresco作为一款优秀的图片加载框架,并且有庞大的社区支持,盲目推测其亦实现了内部支持。

接下来展开向更低版本适配的尝试。当然,这 仅限于 解码、呈现环节,并不考虑 ContentProviderContentResolver 在低版本上对于Heif格式文件的适配。


尝试向Glide接入Heif解码器

将官方测试数据集导入 支持Heif 的小米、华为部分机型后,我发现部分图片未被系统支持,提示文件损毁或者不受支持。

另外,冲着好玩,我值得折腾一下。

必须申明:下面的实践只是从好玩角度出发的,并未考虑 健壮性和全场景覆盖。

我计划将Heif文件放入Assets资源,按照我们对Glide的了解,其解码路径起始点是:android.content.res.AssetManager$AssetInputStream

@GlideModule
class CustomGlideModule : AppGlideModule() {
    override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
            registry.register(object : ImageHeaderParser {
                override fun getType(`is`: InputStream): ImageHeaderParser.ImageType {
                    return ImageHeaderParser.ImageType.UNKNOWN
                }

                override fun getType(byteBuffer: ByteBuffer): ImageHeaderParser.ImageType {
                    return ImageHeaderParser.ImageType.UNKNOWN
                }

                override fun getOrientation(`is`: InputStream, byteArrayPool: ArrayPool): Int {
                    return ImageHeaderParser.UNKNOWN_ORIENTATION
                }

                override fun getOrientation(byteBuffer: ByteBuffer, byteArrayPool: ArrayPool): Int {
                    return ImageHeaderParser.UNKNOWN_ORIENTATION
                }

            })
        }

        registry.prepend(
                Registry.BUCKET_BITMAP,
                InputStream::class.java, Bitmap::class.java, CustomBitmapDecoder(context, glide.bitmapPool)
        )
    }

}

这样,我们会得到这样一条解码路径:

DecodePath{ 
	dataClass=class android.content.res.AssetManager$AssetInputStream, 
	decoders=[
		osp.leobert.android.heifdemo.CustomBitmapDecoder@5c4ee9e,
		com.bumptech.glide.load.resource.bitmap.StreamBitmapDecoder@1c1ed7f
	],
	transcoder=com.bumptech.glide.load.resource.transcode.BitmapDrawableTranscoder@529014c
}

接下来我们需要考虑解码器的接入。

Nokia的SDK

Nokia的Heif库:链接

草率了,经过一番源码研读,发现只有读写过程封装,相当于只有 最基础 的协议封包、拆包,想要真正在Android上使用,还有很多事情要处理。

看下Android P

我们知道,Android P原生支持了Heif,查一下资料,其底层支持如下:


一番思考后,发现 成本过大

再附上 Glide 适用的Decoder:

class CustomBitmapDecoder(val context: Context, val bitmapPool: BitmapPool) : ResourceDecoder<InputStream, Bitmap> {
    override fun handles(source: InputStream, options: Options): Boolean {
        return true
    }

    @Throws(IOException::class)
    fun toByteArray(input: InputStream): ByteArray? {
        val output = ByteArrayOutputStream()
        copy(input, output)
        return output.toByteArray()
    }

    @Throws(IOException::class)
    fun copy(input: InputStream, output: OutputStream): Int {
        val count = copyLarge(input, output)
        return if (count > 2147483647L) {
            -1
        } else count.toInt()
    }

    @Throws(IOException::class)
    fun copyLarge(input: InputStream, output: OutputStream): Long {
        val buffer = ByteArray(4096)
        var count = 0L
        var n = 0
        while (-1 != input.read(buffer).also { n = it }) {
            output.write(buffer, 0, n)
            count += n.toLong()
        }
        return count
    }

    override fun decode(source: InputStream, width: Int, height: Int, options: Options): Resource<Bitmap>? {

        val heif = HEIF()
        try {
            val byteArray = toByteArray(source)
            // Load the file
            heif.load(ByteArrayInputStream(byteArray))

            // Get the primary image
            val primaryImage = heif.primaryImage

            // Check the type, assuming that it's a HEVC image
            if (primaryImage is HEVCImageItem) {
//                val decoderConfig = primaryImage.decoderConfig.config

                val imageData = primaryImage.itemDataAsArray
                // Feed the data to a decoder

                // FIXME: 2021/3/23 find a decoder to generate Bitmap when not upon Android P
                return BitmapResource.obtain(
                        BitmapFactory.decodeByteArray(imageData, 0, imageData.size),
                        bitmapPool
                )
            }
        } // All exceptions thrown by the HEIF library are of the same type
        // Check the error code to see what happened
        catch (e: Exception) {
            e.printStackTrace()
        } finally {
            heif.release()
        }
        return null
    }
}

如果找到了一个解码器,在Android P以下支持解码或转码,封装为Bitmap,就 可以在低版本上适配 了。当然还需要完成:适配所有可能的解码路径头信息处理 工作。

这次尝试, 以失败告终

居然翻车了,

遐想

力大砖飞?集成ImageMagick之类的库,直接实现图片转码,成本有点过大了,先不折腾。

本次实践,我们实现了基本目标,高级目标因为初步调研不充分以失败告终,但是也增长了知识。