安卓TextureView / 绘制 / 绘画性能

6
我正在尝试使用Android上的TextureView制作绘画应用程序。我希望支持多达4096x4096像素的绘图表面,这对于我的最低目标设备(我用于测试)——Google Nexus 7 2013是合理的,该设备配备了一个不错的四核CPU和2GB内存。
我的要求之一是,我的视图必须位于允许缩放和平移的视图内,这些都是我编写的自定义代码(类似于iOS中的UIScrollView)。
我尝试使用常规的View(而不是TextureView)和OnDraw,但性能非常差 - 不到1帧每秒。即使我只调用更改后的rect参数的Invalidate(rect)方法,这也会发生。我尝试关闭视图的硬件加速,但没有渲染出来,我想是因为4096x4096对于软件来说太大了。
然后我尝试使用TextureView,性能稍微好了一点 - 大约是5-10帧每秒(仍然很糟糕但还好)。用户将绘制到位图中,然后后台线程将其绘制到纹理中。我正在使用Xamarin,但希望Java人员也能理解这段代码。
private void RunUpdateThread()
{
    try
    {
        TimeSpan sleep = TimeSpan.FromSeconds(1.0f / 60.0f);
        while (true)
        {
            lock (dirtyRect)
            {
                if (dirtyRect.Width() > 0 && dirtyRect.Height() > 0)
                {
                    Canvas c = LockCanvas(dirtyRect);
                    if (c != null)
                    {
                        c.DrawBitmap(bitmap, dirtyRect, dirtyRect, bufferPaint);
                        dirtyRect.Set(0, 0, 0, 0);
                        UnlockCanvasAndPost(c);
                    }
                }
            }
            Thread.Sleep(sleep);
        }
    }
    catch
    {
    }
}

如果我将lockCanvas更改为传递null而不是矩形,则性能非常好,可以达到60 fps,但是TextureView的内容会闪烁和损坏,这令人失望。我本以为它只是在底层使用OpenGL帧缓冲区/渲染纹理或至少有一个选项来保留内容。
除了在Android中使用原始OpenGL进行高性能绘图和绘画的一切工作以外,还有其他选择吗?这样可以在绘制调用之间保留表面。

4096x4096相当大。我并不惊讶你遇到了性能问题。你一定需要这么大的图片吗?你可以查看一下图形架构:https://source.android.com/devices/graphics/architecture.html。 - David M
@DavidM 是的,那是一个要求。我已经使用核心图形、UIScrollView在iOS上构建了一个类似的应用程序,在iPad Air上,性能在4096x4096下非常好。2013年的Nexus 7应该比iPad Air表现更好,或者至少我认为应该差不多。 - jjxtra
@DavidM 真正让我困扰的是,如果我在lockCanvas调用中传递null,性能就非常好,但纹理内容似乎会变得损坏...很奇怪的是,lockCanvas(rect)比lockCanvas(null)慢... - jjxtra
2个回答

22
首先,如果您想了解底层发生的情况,您需要阅读Android Graphics Architecture document。尽管篇幅很长,但如果您真正想了解“为什么”,这是开始的地方。
关于TextureView:
TextureView的工作原理如下:它有一个Surface,这是具有生产者-消费者关系的缓冲区队列。如果您使用软件(Canvas)渲染,则锁定Surface,这会给您一个缓冲区;然后您在其上绘制;然后解锁Surface,这将缓冲区发送到消费者。在这种情况下,消费者位于同一进程中,并称为SurfaceTexture或(内部更恰当的)GLConsumer。它将缓冲区转换为OpenGL ES纹理,然后将其呈现到View上。
如果关闭硬件加速,GLES将被禁用,而TextureView无法执行任何操作。这就是为什么您关闭硬件加速时什么也没有得到的原因。文档非常明确:“TextureView只能在启用硬件加速的窗口中使用。在软件中呈现时,TextureView将不会绘制任何内容。”
如果您指定了一个脏矩形,软件渲染器将在渲染完成后将先前的内容memcpy到帧中。我不认为它设置了剪辑矩形,因此如果调用drawColor(),则会填充整个屏幕,然后这些像素将被覆盖。如果您目前没有设置剪辑矩形,则可能会看到一些性能优势。 (尽管我没有检查过代码。)
脏矩形是一个输入输出参数。您在调用lockCanvas()时传递要使用的矩形,系统允许在调用返回之前修改它。(实际上,它会这样做的唯一原因是如果没有先前的帧或Surface被调整大小,那么它会扩展它以覆盖整个屏幕。我认为这应该更直接地处理为“我拒绝你的矩形”信号。)您需要更新返回的矩形内的每个像素。您不允许更改矩形,在您的示例中似乎正在尝试这样做 - 在lockCanvas()成功后,脏矩形中的任何内容都是您需要绘制的。

我怀疑脏区域处理不当是你闪烁问题的根源。可悲的是,这是一个容易犯的错误,因为lockCanvas() dirtyRect参数的行为只在Surface类中有记录。

表面和缓冲

所有表面都是双或三重缓冲的。无法避免这种情况 - 您无法同时读取和写入而不会出现撕裂。如果您想要一个可以在需要时修改并推送的单个缓冲区,那么该缓冲区将需要被锁定、复制和解锁,从而在合成管道中创建停顿。为了获得最佳吞吐量和延迟,翻转缓冲区更好。

如果你想要锁定-复制-解锁的行为,你可以自己编写代码(或者寻找一个相关库),这样效率和系统自带的一样高(假设你能熟练使用blit循环)。将内容绘制到离屏Canvas上并blit位图,或者绘制到OpenGL ES FBO上并blit缓冲区。你可以在Grafika的"record GL app"活动中找到后一种方法的示例,该活动有一个模式,用于一次离屏渲染,然后blit两次(一次用于显示,一次用于录制视频)。
更多速度与性能
在Android上绘制像素有两种基本方式:使用Canvas或OpenGL。Canvas渲染到Surface或Bitmap总是使用软件实现,而OpenGL渲染则使用GPU进行。唯一的例外是,在渲染到自定义视图时,您可以选择使用硬件加速,但是当渲染到SurfaceView或TextureView的Surface时,这不适用。
一种绘画或绘图应用程序可以记住绘图命令,也可以仅将像素投射到缓冲区并将其用作内存。前者允许更深入的“撤消”,后者则更简单,并且随着要渲染的内容量增加,性能越来越好。听起来你想做后者,因此从屏幕外面传输数据是有意义的。
大多数移动设备的GLES纹理硬件限制为4096x4096或更小,因此您将无法使用单个纹理进行任何较大的操作。您可以查询大小限制值(GL_MAX_TEXTURE_SIZE),但最好使用一个内部缓冲区,该缓冲区大小与您想要的相同,并仅呈现适合屏幕的部分。我不知道Skia(Canvas)限制是多少,但我相信您可以创建更大的位图。
根据您的需求,SurfaceView可能比TextureView更可取,因为它避免了中间的GLES纹理步骤。您在Surface上绘制的任何内容直接进入系统合成器(SurfaceFlinger)。这种方法的缺点是,由于Surface的消费者不在进程中,因此没有机会让视图系统处理输出,因此Surface是一个独立的层。(对于绘图程序可能有益--正在绘制的图像在一个层上,您的UI在另一个层上方。)
顺便说一句,我还没有看过代码,但Dan Sandler的Markers应用程序可能值得一看(源代码在此处)。
更新:损坏已被确定为错误在'L'中修复

1
SurfaceView不是一个好的选择,因为我正在将绘制视图嵌入到自己的自定义ViewGroup中,该ViewGroup充当UIScrollView以进行缩放和平移。 - jjxtra
1
我不确定为什么您在使用多个TextureView时会看到不良行为。这并没有固有的限制。Grafika的“双重解码”活动可以将视频播放到一对TextureView上,而没有奇怪的效果。根据我的经验,这种问题通常是由于多线程渲染代码缺少同步引起的,但如果您是单线程,则不是问题。 - fadden
1
无论你采取什么方法,都需要避免在软件中每帧复制64MB的数据。这意味着不要向lockCanvas()传递一个脏矩形,并且不要在每帧中将整个位图绘制到TextureView中。最好的方法是渲染到一个GLES FBO上,由GPU进行blit操作,这样可以完全避免慢速路径。对于Canvas渲染来说,你必须选择将64MB复制到一个巨大的TextureView中(绘制慢,滚动快)或者只在每帧中将可见部分blit到一个小的TextureView中(绘制快,滚动慢)。你可以采用混合方法,在运行时进行切换。 - fadden
1
我找到了问题。显然,如果您正在更新任何TextureView视图,则必须在当前运行循环周期内更新所有TextureView视图。这可以通过lockCanvas(较慢)或通过Invalidate(只需重新呈现上次显示的内容,但避免了损坏问题)来完成。 - jjxtra
1
太棒了!我可以在我的破旧MotoG上以60 FPS绘制一个4096x4096的图像 :) - jjxtra
显示剩余8条评论

1

更新我放弃了TextureView,现在使用OpenGL视图,在其中调用glTexSubImage2D来更新渲染纹理的更改部分。

旧回答最终我将TextureView平铺在4x4网格中。根据每帧的脏矩形,我刷新相应的TextureView视图。任何未更新的视图都会被我调用Invalidate方法。

一些设备(如Moto G手机)存在一个问题,即双缓冲在一帧中被破坏。您可以通过在父视图的onLayout方法被调用时两次调用lockCanvas方法来修复此问题。

private void InvalidateRect(int l, int t, int r, int b)
{
    dirtyRect.Set(l, t, r, b);

    foreach (DrawSubView v in drawViews)
    {
        if (Rect.Intersects(dirtyRect, v.Clip))
        {
            v.RedrawAsync();
        }
        else
        {
            v.Invalidate();
        }
    }

    Invalidate();
}

protected override void OnLayout(bool changed, int l, int t, int r, int b)
{
    for (int i = 0; i < ChildCount; i++)
    {
        View v = GetChildAt(i);
        v.Layout(v.Left, v.Top, v.Right, v.Bottom);
        DrawSubView sv = v as DrawSubView;
        if (sv != null)
        {
            sv.RedrawAsync();

            // why are we re-drawing you ask? because of double buffering bugs in Android :)
            PostDelayed(() => sv.RedrawAsync(), 50);
        }
    }
}

顺便提一下,http://stackoverflow.com/questions/31124496/ 有一个类似的问题,并且也能通过调用invalidate来解决。我已经提交了http://b.android.com/178525。 - fadden
@fadden 看起来是这样,太好了!我最终在我的网格中分层软件视图,因为它们的性能比TextureView和硬件层都要好。 - jjxtra

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接