Work Better Than Yesterday!
持续花了5,6个小时最终把这个问题解决,虽然最后很简单的解决了,但是分析的过程却非常复杂,而且很有意思,最后解决了也觉得大快人心,哈哈,于是乎想记录一下这个过程。
这是公司项目里面的弹幕问题,采用OpenGLES来绘制的,测试人员和用户都发现过这个问题,偶然性的出现,弹幕出现错乱,样式被改变,或拉伸,或压缩了,出现的概率非常低,极其难重现,而且测试也没有找到方法使其较大的概率重现。
因为项目跑起来花的时间比较长,而且在demo上无法重现和试验,只能较为缓慢的解决,不过也好,正是这个时间空隙,让我充分去思考,也逐渐能适应这种方式了。
我一直都没有机会重现,都是别人发现了,然后告诉我马上观察,或者看他们的截图。这在他们看来,调侃说这重现是需要缘分的,有时候还要看人品如何。我之所以看不到是因为概率问题,而一直处于低概率区域,是因为我的操作路径不接近问题的路径,所以,尽可能的模仿出现者的操作,长时间观察,可惜都没有发现。
这种低概率的较难重现问题,除非是触发条件难以模拟,如果不是特殊环境导致的,那么一般都是多线程并发导致的,这种情况就真的是完全看cpu的线程调度了。例如一条线程正在处理一些事情,然而并没有处理完毕,然后某些操作触发另外一个线程进入临界区,这时候就出现了数据的混乱了。出现这个问题,基本上就是我们编码习惯不好,没有考虑到这会出现多线程问题,通用的解决方法当然是加锁控制,也可以有其他方法解决,都是根据实际情况而定。
刚开始并没有想到那么多线程并发导致的问题,因为在整个项目里面,提供的弹幕模块供各个业务方使用,涉及到的东西太多了,而且不好调试,也不清楚是哪里导致的并发互斥问题,根本不清楚原因,不确定是不是业务方的问题。只是想简单解决这个问题,或者规避一下,如果纹理出现错乱了就丢弃不绘制这个纹理即可。
先简单说说弹幕绘制的原理,弹幕模块对外提供的接口是以bitmap为参数的,业务方把弹幕内容画在bitmap里面,我们拿到bitmap以后把bitmap映射绑定到OpenGLES的纹理,然后回收这个bitmap,再计算顶点坐标,纹理坐标等等事情,最后绘制。详细实现看这里:Android OpenGLES学习之实现弹幕渲染
刚开始我以为问题较简单,不就是纹理的比例错了吗?那么我们以bitmap的比例为准,拿到bitmap的时候就计算它的比例,在绘制的时候也计算纹理的比例,两者比较,不等即发生了错误,不进行绘制了。
bitmap的比例计算如下:
float bitmapRatio = (float)bitmap.getWidth() / bitmap.getHeight();
然而换算成为纹理坐标的时候,得到的纹理宽高如下:
float danmakuHeight = (float) bitmap.getHeight() / mViewHeight * 2.0f;
float danmakuWidth = (float) bitmap.getWidth() / mViewWidth * 2.0f;
于是,计算纹理的比例如下:
float textureRatio = danmakuWidth / danmakuHeight = ((float)bitmap.getWidth() / bitmap.getHeight()) * ((float)mViewWidth / mViewHeight);
可以看出,这本来就不会相等的,原因是OpenGLES的坐标系归一化了,这比例本来就已经发生了变化。
最初的想法是简单的,可是并没有得到解决。
没办法判断比例是否发生了变化,只能放弃这个方法。继续猜测也许是业务方提供的bitmap就是不对的,bitmap里面除了有文字,还有icon等等,很有可能他们绘制就出错了,又或许这个bitmap它们回收了,重用,别的乱七八糟的逻辑导致这个错误。这我没法验证,但是还是放弃了这个想法,原因有:
bitmap错乱并不会导致变形,这在demo测试了bitmap的宽高比例错误,只会导致绘制不完整,不会变形。
观察到变形的纹理里面,不仅有图片的,单纯是文字的,或者其他的任何都有可能变形,这就说明问题不在bitmap那里。
排除了bitmap的错误导致,只能继续怀疑是自家的问题,那么会不会是坐标计算错误了呢?在3.1中计算danmaku的宽高需要view的宽高,这里的计算如果是正确的,纹理如何都是不会发生变形的,排除了bitmap的错误,那么就是view的宽高变了!可是哪里会导致改变?在代码上也没有修改view的宽高的地方,除了是横竖屏切换,那时候会触发重新计算坐标,并不会变形的。而且根据描述,并不是在横竖屏切换的时候发生变形的。那就在第一次的时候计算比例,然后在每一帧绘制的时候也计算这个比例,如果两者不等就打印一条日志。根据发现者描述,变形的弹幕视乎是突然出现的。
经过长时间的测试,终于重现了,可是,并没有日志打印,也就继续排除了这个猜测了。
有了上面的错误尝试以后,思考的范围逐渐变小了,分析的思路也逐渐趋向正确。
上面并不是所有的错误尝试都是没用的,纹理发生变形,并不是截取等问题,而一定是坐标错误了,上面说了,bitmap肯定没错,只有view的宽高错了,导致view发生错误的只有横竖屏切换,既然如此,就不断从这个入口看看能否发现什么。
不断的进行横竖屏切换,多次以后,几乎变形就重现了,而且是必现,这个时候就可以打印很多日志信息分析问题了。可以观察变形,很具体发现了重要的一点是,纹理滚动到一半的时候才会发生变形,而且是把后面的弹幕内容绘制到了前面的纹理了,出现了两条同样的纹理。
为了分析原因,简单重新整理了一下绘制的关键点:
可以看到,首先会把bitmap映射到纹理,然后,把纹理绑定到对应的坐标数据进行绘制。上面AB两个弹幕分别是两条线下来进行绘制的;那么出现错误就有上面的两种可能性:
为什么Bitmap会映射到错误的纹理上去了?获取纹理的代码如下:
难道OpenGLES分配错误了?生成纹理本质就是申请一块显存,如果生成错误了,id通常返回的是负值,这一点要处理;另外猜测,难道我刚生成了一块纹理,然后并没有使用,这时候并发情况,马上有线程来请求生成第二块纹理,会拿到同样的id?这无法验证,按正常逻辑来说是不应该的,不过处理方法是这里修改为一次生成的纹理个数为4个,因为我们的弹幕最多是4行。
实际上,我们写了一个纹理池,每次首先从池里面获取纹理,如果没有再调用接口生成纹理;当弹幕跑完以后,即回收纹理放到纹理池供下次使用。池当然是并发互斥的!
为了验证这个问题,加了不少日志,发现多次使用到了同一个纹理进行映射,而且这个纹理ID有一个特点,就都是0;一般来说生成纹理id拿到的都是大于0的。
解决错误的纹理
解决方法是纹理池不能存放负值id,每次生成4个纹理,纹理池改用set实现(之前用队列),这样就不会出现重复纹理id了。如果拿到的是负值纹理id,证明现在没有足够的显存了,那么丢弃这个弹幕,不绘制。
出现这个问题的原因
反复思考深究产生这个问题的原因,基本重复的纹理id都是0;原来0是int的默认值,问题在这里,api生成的id不会大于0,因此存放在池里面的id如果出现了0就肯定是回收弹幕的时候放进去的;而且出现了多个0,开始我怀疑是多线程进行回收,把同一个弹幕多次回收了,于是加锁控制;实际上并不是这个原因!
当生产了多个弹幕的时候,纹理id默认都是0,很短的时间内,还没有为这些弹幕分配纹理id,然后业务方的操作,如切换频道,切换播放,或者横竖屏切换,他们就触发了清空弹幕池的操作,把这些弹幕都回收了,导致有多个0id。
另外,使用这个0有点危险,我们调用api生成的纹理id都是大于0的,如果使用这个0不知道会出现什么问题,或许它有特别的用处所以被占用,就像指针一样,不能随便使用系统的地址。
我们一定要把纹理id的默认值修改为负值,并且在回收textureid的时候,在每一个弹幕对象里面都把纹理id重新设置为负值,在绘制的方法里面,如果是负值就直接返回不要绘制了。
首先简单说说怎么使用坐标的:初始化着色器的时候,会拿到着色器里面“变换矩阵引用id”,“顶点位置属性引用id”和“顶点纹理坐标属性引用id”,然后在每一帧绘制的时候,把矩阵数据,坐标数据传递到这些引用id下去。详细了解看这里:Android OpenGLES学习之入门理解
在bitmap是正确的前提下,坐标等矩阵数据都是属于每一个弹幕对象的,不是临界区数据,不会出现纹理拿到别的坐标数据情况,而“程序引用id”和“属性矩阵等等的引用id”是全局唯一的,每次只会绘制一个弹幕,不会并发绘制弹幕,也不会出现互斥的情况。总结就是排除了这个错误的可能性。
最终解决问题的时候看似简单,实际上难在了分析的过程,我们要学会的不是记住了这个问题最终的解决方案,而是解决这个问题的过程,它是一种能力的体现,好比学会了钓鱼技能比直接获取鱼更有价值。这也是为什么我要记录这篇blog的原因。这是一个非常有意思的过程!