记一次 kotlin 在 MutableList 中使用 remove 引发的问题

背景

我开发的APP,隐云图解制作 中有一个功能是拼接多个 gif 动图到一张动图上。

这段代码是使用 FFmpeg 来实现。

测试时发现直接水平拼接和直接横向拼接都没有问题,唯独方形拼接(类似九宫格)在开启按比例缩放后总是出现问题。

而这段代码的核心逻辑是先遍历输入文件列表,读取每个 gif 的尺寸信息并将其保存下来,同时按照特定拼接规则计算出缩放尺寸并保存。

然后将计算得到的尺寸生成 FFmpeg 命令。

排查过程

使用四张分辨率分别为: 640x368, 1080x1920, 640x368, 800x1280 的动图经过缩放计算后得到如下 MutableList:

[[640, 368], [640, 1137], [640, 368], [640, 1024]]

生成的 FFmpeg 命令为:

-y -i jointBg.png -i 2022-07-20-13-44-41-by_EL.gif -i 2022-07-20-13-44-18-by_EL.gif -i 2022-07-13-15-51-07-by_EL.gif -i 2022-07-14-14-28-57-by_EL.gif -filter_complex [0:v]pad=1280:2161[bg];[1:v]scale=640:1137[gif0];[2:v]scale=640:368[gif1];[3:v]scale=640:1024[gif2];[4:v]scale=640:368[gif3];[bg][gif0] overlay=0:0[over0];[over0][gif1] overlay=640:0[over1];[over1][gif2] overlay=0:1137[over2];[over2][gif3] overlay=640:368 2022-07-20-14-03-17-by_EL.gif

把上述命令中的其他参数移除,仅保留缩放和拼接命令,并适当的加点缩进:

# 缩放
[0:v]pad=1280:2161[bg];
[1:v]scale=640:1137[gif0];
[2:v]scale=640:368[gif1];
[3:v]scale=640:1024[gif2];
[4:v]scale=640:368[gif3];

# 拼接
[bg][gif0] overlay=0:0[over0];
[over0][gif1] overlay=640:0[over1];
[over1][gif2] overlay=0:1137[over2];
[over2][gif3] overlay=640:368

稍微解释一下上述命令:

上述缩放中的代码表示把输入的第 n 个文件按照 x:y 分辨率缩放后取别名为 xxx,如:

[0:v]pad=1280:2161[bg] 表示把输入的第 0 个文件当成画板,并扩展分辨率为 1280x2161,然后取别名为 bg 以供后续使用。

[1:v]scale=640:1137[gif0] 表示把第 1 个文件缩放为 640x1137 并取别名为 gif0.

而拼接中的代码也很好理解,如:

[bg][gif0] overlay=0:0[over0] 表示将别名为 gif0 的文件覆盖到 别名为 bg 的文件上,并且起点坐标为 0:0 ,最后将处理后的文件取别名为 over0 以供后续使用。

解释完,各位有没有发现问题?没有?哈哈,没有就对了,等我画个图就理解了。

按照预想情况,拼接后的动图应该是这样排列的:

1.png

(哈哈哈,因为这里只是用极端个例来说明这个现象,所以拼出来的是这么一个奇怪的形状)

但是实际情况确实这样的:

2.png

哈哈,发现问题了吧。

拼接的方向居然反了,如果只是反了到也还能看,关键是连同缩放尺寸一起反了,导致 16:9 的动图被强行拉伸成了 9:16,而原本 9:16 的动图又被强行压缩成了 16:9 ,那观感,简直不要太惨不忍睹。

那么造成这种情况的原因是什么呢?

先看代码:

val gifResolution = getJointGifResolution(context, jointMode, gifUris)
Log.i(TAG, "jointGif: gifResolution = $gifResolution")


val totalResolution = gifResolution[gifResolution.size - 2]
val minResolution = gifResolution[gifResolution.size - 1]

gifResolution.remove(totalResolution)
gifResolution.remove(minResolution)

val cmdBuilder = FFMpegArgumentsBuilder.Builder()
cmdBuilder.setOverride(true)
        .setInput(jointBg.absolutePath)  //输入背景

for (uri in gifUris) {  //输入GIF
    cmdBuilder.setInput(FileUtils.getMediaAbsolutePath(context, uri))
}

cmdBuilder.setArg("-filter_complex")


var cmdFilter = ""
//设置背景并扩展分辨率到 total
cmdFilter += "[0:v]pad=${totalResolution[0]}:${totalResolution[1]}[bg];"

//将输入文件缩放并取别名为 gifX (X为索引)
gifResolution.forEachIndexed { index, mutableList ->
    cmdFilter += "[${index+1}:v]scale=${mutableList[0]}:${mutableList[1]}[gif$index];"
}
cmdFilter += "[bg][gif0] overlay=0:0[over0];"

//开始叠加动图
cmdFilter += getCmdFilterOverlaySquare(gifUris, gifResolution)

....

上述代码中,val gifResolution = getJointGifResolution(context, jointMode, gifUris) 表示计算输入文件的分辨率信息,计算完毕后打印日志输出的分辨率顺序还是正确的。

但是此处,为了方便返回数据,我会在原有 list 末尾再添加两条数据,并且在遍历前将这两条数据通过 remove 移除。

此时再去遍历这个 list 就出现了上述所说的顺序错乱。

所以合理猜测是由于调用了 remove 导致顺序被重新排列了?

我们写一段 demo 来尝试一下:

val list: MutableList<MutableList<Int>> = mutableListOf()

// 添加 ”正常数据“
list.add(mutableListOf(640, 368))
list.add(mutableListOf(640, 1137))
list.add(mutableListOf(640, 368))
list.add(mutableListOf(640, 1024))

// 添加 ”额外数据“
list.add(mutableListOf(1280, 2161))
list.add(mutableListOf(640, 368))

// 先输出处理前的 list
println("处理前:$list")

// 模拟处理数据
val value1 = list[list.size - 2]
val value2 = list[list.size - 1]

list.remove(value1)
list.remove(value2)

// 输出处理后数据
println("处理后:$list")

不出意外,输出结果为:

处理前:[[640, 368], [640, 1137], [640, 368], [640, 1024], [1280, 2161], [640, 368]]
处理后:[[640, 1137], [640, 368], [640, 1024], [640, 368]]

果然 list 被重新排序了。

那么问题来了,为什么呢?又该怎么解决呢?

错误原因及解决方法

既然已经知道了问题出自于 remove 方法,那么自然是从它下手。

先来看看 remove 的源码:

    /**
     * Removes a single instance of the specified element from this
     * collection, if it is present.
     *
     * @return `true` if the element has been successfully removed; `false` if it was not present in the collection.
     */
    public fun remove(element: E): Boolean

嗯,是个接口,问题不大,找到它的实现:

    override fun remove(element: E): Boolean {
        checkIsMutable()
        val i = indexOf(element)
        if (i >= 0) removeAt(i)
        return i >= 0
    }

代码很简单,核心就在第3-4行,使用 indexOf 查找到元素位置后调用 removeAt 删除。

哈哈,看出问题没有?

其实去我在刚写完背景时就突然发现了问题所在,但是想着写都写了,还是假装不知道继续写下去吧。

什么?还是没看出来问题所在?

那么我们来看看 indexOf 的源码……算了,源码都不用看了,直接看注释:

    /**
     * {@inheritDoc}
     *
     * @implSpec
     * This implementation first gets a list iterator (with
     * {@code listIterator()}).  Then, it iterates over the list until the
     * specified element is found or the end of the list is reached.
     *
     * @throws ClassCastException   {@inheritDoc}
     * @throws NullPointerException {@inheritDoc}
     */

看到了吗?这个方法返回的是“指定元素第一次出现的索引位置”

也就是说,调用 remove 后删除的是第一个该元素。

我们再来看看上述代码返回的删除前后的 list 变化:

处理前:[[640, 368], [640, 1137], [640, 368], [640, 1024], [1280, 2161], [640, 368]]
处理后:[[640, 1137], [640, 368], [640, 1024], [640, 368]]

发现了吗?并不是删除元素后 list 被重排了,只是好巧不巧的,我想删除最后一个 [640, 368] 但是,前面也有一个一样的 list 导致删除的是前面的第一个元素,而非我想要删除的最后一个元素。

那么问题来了,它凭什么就说这两个 [640, 368] 是同一个对象?

再看一段代码:

val a = listOf(100, 100)
val b = listOf(100, 100)

println(a==b)

// 输出
true

这又是为什么???

哈哈哈,这个问题留给各位自己想了。

总之,最后的解决方法也很简单,因为我需要删除的元素索引是固定的,所以只需要把代码中的:

gifResolution.remove(totalResolution)
gifResolution.remove(minResolution)

改为:

// 别看了,没写错,就是两个 size-1 ,为啥?你猜
gifResolution.removeAt(gifResolution.size - 1)
gifResolution.removeAt(gifResolution.size - 1)

参考资料

  1. Equality
  2. 揭秘 Kotlin 中的 == 和 ===