Make Your Custom View 60fps in Android
How often did you have to work with 2D graphics creating your own custom View? I’m sure in a majority of projects 2D drawing comes down to a dozen of drawBitmap()-s, drawRect()-s or drawText()-s which are handled by the system in less than 5 ms. And what if a hundred of math and drawing operations should be performed without a hitch? The task is not an easy one and requires that one examine both the Android solutions already in place and the hardware capabilities of devices. So, the main goal of this article is to help you find a UI component to render an arbitrary complex scene (view).
Who is your painter?
As much as any other modern OS, Android can exploit GPU and CPU to render graphics. It might actually seem that GPU is the most efficient for that and you will be right. Due to the properties of the OpenGL ES architecture and the concurrently executed tasks, one can get at the maximum flexibility and performance. Yet, in this very article I’m not going to talk about OpenGL ES because of considerably high development expenses (cost, debugging, support).
A second option — rendering with the help of CPU using Canvas. The basic task of this tool is to render basic 2D objects and apply transformations and filters to them. Since our View will not contain any exotic conversions with pixels, performance capabilities of Canvas will be enough to draw almost any complexity. Jumping ahead a bit, I want to say that Canvas which renders a View has an access to some GPU capabilities through Hardware Acceleration mode
It’s time to choose a View to draw. There are three of them only.
To be async or not to be async — here is the question
All View(s) can be divided into 2 groups: allowing to draw through invoking onDraw(Canvas) method synchronously or asynchronously using lockCanvas() method. Let’s consider the most obvious option first.
Draw everything absolutely in sync
A redraw lifecycle of a usual View looks the following.
To control an update of our View, one has to invoke invalidate() or postInvalidate() methods. To prevent a too frequent update of View the system reduces a number of invalidate() calls to 60 times per second at its discretion which means that not all invalidate() calls will be accompanied with invoking onDraw(). Due to this there is a 1% loss of frames possible
Another thing one has to pay attention to is the type of Canvas. It can be both Hardware and Non-Hardware Accelerated. If you look into the documentation, you will see that this option allows to carry out a major part of 2D operations using GPU. To understand its efficiency let’s give a closer look to the table below which contains the data as to the rendering speed of 10000 elements.
It’s obvious that the rendering speed of primitives is way higher with HA. But it does not mean that one can always use HA and get a maximum performance. There is a significant detail here. Every time we change the object to be rendered Android assigns storage for each new texture mask and place it in the drawing buffer. It’s a pretty costly (GPU resources) operation given a great number of elements.
One question remains: how one should deliver frames if onDraw() method is invoked by the system or when invalidate() is being called. In this very article I’ve chosen an option demonstrating minimum storage with a minimum of process time. Let’s turn to the scheme which describes an operating principle of this very model.
You can play with the example in our GitHub repository while reading this article.
The model consists of the following entities:
SceneFrame contains information about objects and the order of their rendering.
RectShape is a rendered object with the information as to what and how to draw.
To prevent an endless object generation, let’s create a pool of SceneFrame(s) ready to be drawn as well as the pool of SceneFrame(s) already drawn. Let’s assume there will be 120 of them to ensure a smooth change on Canvas. It’s clear that this situation is quite rare and in fact we will use only 60 frames per second, but we’d better be on the safe side.
To provide for a synchrony of the two SceneFrame pools it would be just enough to supplement onDraw method with a synchro unit to delete the last frame and enqueueing a ready-made frame. Ideally the objects to be drawn must also be reused.
Now let’s turn to the GPU Profiling tool to understand that HA is not efficient when we have a great number of objects .
At the left one can see the columns of lesser height, and the most of the time is taken up by Sync and Upload whereas at the right the most of the time is taken by Swap buffers and Command issue. In both cases the horizontal green line shows that we’ve enormously exceeded the barrier of 16 ms. Now let’s choose a number of primitives in such a way as to meet the requirement of 60 fps.
The screen on your left shows that 60 fps with Hardware Acceleration is achieved at 400 primitives max which is enough to implement a pretty complex representation. But if you look at the results without Hardware Acceleration, they will leave much to be desired. Summarizing my whole experience of drawing using onDraw, there is one rule to follow: if the number of elements to be drawn is counted in tens, one can use onDraw() without Hardware Acceleration, otherwise this function shall be implemented. If the number of elements in the drawSomething representation is counted in hundreds or even thousands, one should opt out of the above method in full.
Draw async as fast as it can be
SurfaceView and TextureView allow for drawing by Canvas in a separate thread DrawerThread with lockCanvas() and unlockCanvasAndPost() methods applied. At the time View becomes visible, a user can gain access to Surface to start drawing. To have the real state of Canvas during the drawing process one has to monitor the lifecycle of View: to start and stop the DrawerThread (see the code sample below) in order to evade an inconsistent state.
All fun and dandy but there are some nuances as to working with such an interface.
First off, I would like to point out that you can’t always get Canvas through invoking lockCanvas() method. It’s possible thanks to different reasons but it’s worth solving this problem in the only way: through Thread.sleep(1ms) in DrawingThread. Here we are coming to the following issue: if we invoke lockCanvas() oftener than 16 ms, then the system might lower FPS to save resources. Which is why, one has to control the time used for rendering a frame calculated as sleepTime = endDrawTime — startDrawTime. If the given value is less than 16 ms — invoke sleep, in the contrary case — don’t. Is that all? Not really. The issue is that lockCanvas() and unlockCanvasAndPost() are not instant operations and can take as much as 3ms in the worst case scenario!!! It’s too much as we have only 12ms for the rest.
After going through the documentation as well as reading a dozen of articles, I’ve found out that SurfaceView lacks HA whereas TextureView has it. That’s all very well but there is one more nuance here: it’s not related to lockCanvas() interface. Which is why we have to draw confining ourselves to the CPU capabilities and it’s the first limitation resulting from the asynchronous drawing. The second one is the lack of capability for SurfaceView to interact with other View(s) because the area where we are drawing is out of the app context and can not be transformed (moved, scaled, rotated).
What shall we do if you can’t go without transforming and changing the alpha-channel? We should use TextureView! This component lets us have all the advantages of SurfaceView and is inside the window of our app. Cool! Not that cool as we wanted to. TextureView also has some limitations. One of them is the loss of 2–3 frames per second. You can’t help it due to operational aspects. Another one is an obligatory requirement to Hardware Acceleration Window which is actually more an advantage rather than a disadvantage.
Now let’s again get back to GPU Profiling and see how efficient and convenient rendering is with the help of SurfaceView and TextureView.
After two tests with 10000 elements (on the left screen) and 1600 elements (on the right screen) the results do not differ much. But the View on your left lags much. How can it be? The problem is that SurfaceView is outside the app window and is measured by the GPU utility only at the moment of frame posting. And a frame is a usual Bitmap. When you measure manual drawing time the system gives 75ms in the first case, and in the second one 16ms, just what we need. Now let’s turn to analyzing the last component — TextureView.
As we see the situation seems a bit better in comparison with SurfaceView: fewer columns and they are not so tall. The time to draw a frame has also significantly improved: 25ms for the grid of 100x100 and 16ms for the grid of 50x50. But how can it be if Hardware Acceleration does not take part in framing (we can see it from the mode which highlights the areas drawn by GPU). I did not manage to find an adequate explanation of this behavior on the net. I can only assume that the availability of high fps is ensured by a rendering window which is within the app context.
So, you have to choose between three View(s) to place a complex 2D representation with the help of Canvas.
First thing, you will have to define a maximum number of primitives you want to draw. Measure the time to draw all types of primitives (Rect, Bitmap, Text etc).
Find in the documentation if it’s possible to draw them with the help of HW for the target Android API. Further on, one has to carry out a range of trivial arithmetic operations to calculate the time for forming the heaviest frame with provision for the effort needed to calculate the rendering model (width, height, color of primitives)
If you don’t absolutely fit in 15ms, then with all other conditions being equal use TextureView. But there are situations when it’s not possible to use TextureView: when a window lacks HA and the stability of 60fps is really important to us — then your choice is SurfaceView. Even though the option as to using the onDraw(Canvas) method is the slowest one, it fits only simple representations and widgets.
Below are a couple of recommendations one has to follow working with 2D graphics.
- Always reuse objects within the frame rendering cycle especially when it comes to such components as Canvas, Bitmap, Paint, Path
- Invoke Paint class methods (setColor(), setStrokeWidth() etc) as rare as possible, they are pretty time-consuming.
- Give preference to rendering a same-type array of primitives (drawPoints(), drawLines() etc), the system will get through with it much faster.
- Given the thousands of calls of drawSomething() per frame most of the time is spent on the model calculation rather than on drawing. Try your best to avoid the terms and data manipulations during framing.
That seems to be all relating to choosing a View for drawing 2D graphics I wanted to tell you about in this article . Always analyze the components you’re going to work with as the consistency level of the implementation of your idea depends on them a lot.
If you have any other questions, leave your comments! Thanks for your time!