- Mastering Android Game Development
- Raul Portales
- 1247字
- 2021-07-16 13:59:11
Using GameView
Until now, we have been using standard views and translating them to render the different elements of the game. While this is an easy way to draw elements on the screen, it is far from being efficient. We are relying on the system layout to do the drawing.
While this technique is fine for a turn-based game or any non-real-time game in general, it cannot render enough frames per second for a real-time game.
Note
Working with standard Views is fine for non-real-time games.
We are going to create a custom View
that we are going to call GameView
. This view will be responsible for drawing the sprites.
We already noted the duplication of code and mentioned the concept of sprite in the previous chapter. We will now move forward and create a Sprite
class that will take care of drawing an image at specific coordinates inside the GameView
.
There are two ways of drawing at low level on Android. They are:
- Extending
View
and overridingonDraw
- Extending
SurfaceView
and usingSurfaceHolder
In both cases, we will get a Canvas
and draw our GameObjects
on it. The main difference is that the onDraw
method of the View
is executed on the UIThread
, while SurfaceView
and SurfaceHolder
are designed to perform the draw on a separate thread.
Note
Low-level drawing on Android is always done using a canvas.
According to the official documentation, it is more efficient to use SurfaceView
. But, since Android 4.0, view rendering is hardware-accelerated (while SurfaceView
is not). In the case of modern phones with high-resolution screens and faster processors, this may not always be the case.
Note
SurfaceView
is not hardware-accelerated and may perform worse than normal View
.
Anyway, you should know both and be able to swap them easily, even if it is just for testing purposes. We will create an interface named GameView
, which will be implemented by the two classes, so they can be changed easily. The classes we will make are:
StandardGameView
: This will extend fromView
SurfaceGameView
: This will extend fromSurfaceView
The GameView interface
The GameView
interface will have all the methods that are needed by the GameEngine
to handle the View
:
public interface GameView { void draw(); void setGameObjects(List<GameObject> gameObjects); // Generic methods from View int getWidth(); int getHeight(); int getPaddingLeft(); int getPaddingRight(); int getPaddingTop(); int getPaddingBottom(); Context getContext(); }
There are basically two methods we need, one to trigger the drawing and one to pass the list of game objects to the GameView
, so they can be drawn there:
draw
: This will trigger a draw on theGameView
setGameObjects
: This will set the list ofGameObjects
for theView
The rest of the methods are implemented in the View
. We need to declare them, because we are using them on the GameEngine
.
Let's explore each implementation in detail.
StandardGameView
The StandardGameView
class extends View
. We just provide the basic constructors for View
, override the onDraw
method, and then implement the methods from GameView
:
public class StandardGameView extends View implements GameView { private List<GameObject> mGameObjects; public GameView(Context context) { super(context); } public GameView(Context context, AttributeSet attrs) { super(context, attrs); } public GameView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); synchronized (mGameObjects) { int numObjects = mGameObjects.size(); for (int i = 0; i < numObjects; i++) { mGameObjects.get(i).onDraw(canvas); } } } @Override public void draw() { postInvalidate(); } @Override public void setGameObjects(List<GameObject> gameObjects) { mGameObjects = gameObjects; } }
Basically, setGameObjects
stores a reference to the game objects. The adding and removing of GameObjects
are done in the GameEngine
. When we draw the view, we iterate over the list of game objects, calling onDraw
on all of them and passing the Canvas
object on which we are drawing.
Note that the method is synchronized using the mGameObjects
variable. This is important because, as we mentioned in Chapter 1, Setting Up the Project, the contents of the list can change during onUpdate
and we do not want this to happen while we are iterating over the list.
The other important point is that the list of GameObjects
is a reference to the one inside the GameEngine
and not a copy, so whenever the list gets modified, the latest values are accessible from both places. This is also why synchronization is required.
Note
The list of GameObjects is shared between the GameEngine and the GameView.
Performance-wise, it would not make sense to copy all the elements in the list to a new one in each execution of onDraw
.
To trigger a draw, we just need to call postInvalidate
. Remember that invalidating a view has to be done on the UIThread
. This is why we need to call postInvalidate
. This method will post a Runnable
to be run on the UIThread
that will then invalidate the View
.
As we mentioned in the earlier chapters, once the view gets invalidated, Android makes sure that the onDraw
method of the View
is called and then the UI is updated. This is the connection between invalidating the view and the onDraw
method, where we draw the game objects.
The onDraw
method is obviously time-critical. We should avoid all unnecessary operations. In particular, lint shows a warning if you create an object inside onDraw
. This is, to reiterate, a best practice for game developers: to always create the objects in advance.
Note
Never do object creation inside onDraw
.
Also, it is worth remembering that Android has a fallback mechanism to avoid overload on the drawing. If a view has been invalidated but not yet redrawn, the call to invalidate will be ignored (the view is already going to be redrawn).
SurfaceGameView
To implement a GameView
that extends SurfaceView
, we need to define a Callback
for SurfaceHolder
—the class used to access the SurfaceView
—and then, whenever we want to draw, we lock the canvas, draw on it, and unlock it again so it can be rendered by SurfaceView
.
Let's see the code of SurfaceGameView
:
public class SurfaceGameView extends SurfaceView implements SurfaceHolder.Callback, GameView { private List<GameObject> mGameObjects; private boolean mReady; public SurfaceGameView(Context context) { super(context); getHolder().addCallback(this); } public SurfaceGameView(Context context, AttributeSet attrs) { super(context, attrs); getHolder().addCallback(this); } public SurfaceGameView(Context c, AttributeSet attrs, int defStyleAttr) { super(c, attrs, defStyleAttr); getHolder().addCallback(this); } @Override public void surfaceCreated(SurfaceHolder holder) { mReady = true; } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { } @Override public void surfaceDestroyed(SurfaceHolder holder) { mReady = false; } @Override public void setGameObjects(List<GameObject> gameObjects) { mGameObjects = gameObjects; } @Override public void draw() { if (!mReady) { return; } Canvas canvas = getHolder().lockCanvas(); if (canvas == null) { return; } canvas.drawRGB(0,0,0); synchronized (mGameObjects) { int numObjects = mGameObjects.size(); for (int i = 0; i < numObjects; i++) { mGameObjects.get(i).onDraw(canvas); } } getHolder().unlockCanvasAndPost(canvas); } }
First, we have three constructors with different arguments that are intrinsic to SurfaceView
. Note that they all include a call to set a Callback
to the SurfaceHolder
, which is also implemented by SurfaceGameView
. This callback will inform us of when SurfaceView
is ready or when things have changed.
The next methods are the implementation of the Callback
interface. Those are the methods that are called when the SurfaceView
is created, modified, or destroyed. We store the status of the view to know whether it is ready or not, so that it can be used to draw. A View
is ready any time after it is created until it is destroyed.
Then, we move into implementing the methods from GameView
.
To set the GameObjects
, we do exactly as we did for StandardGameView
, also with the same implications when it comes to handling a reference.
The draw
method is where things are a bit different. We have to check whether the view is ready. If so, we lock the Canvas
so we can draw on it.
Once we have the canvas, we need to clean it before we draw each frame. The canvas will have the previous image on it. (If we do not clean it, we will get rendering artifacts as shown in the following screenshot.) This cleaning is done by filling the canvas with a solid color using drawRGB
.

Once we have cleaned the canvas, we take the same drawing as for StandardGameView
and just iterate over the game objects.
Finally, we unlock the canvas and post it. This is the point when we pass the Canvas
back to the SurfaceView
and post it to the UIThread
. Note that all of the drawing has been done outside the UIThread
. Only once the Canvas
is fully rendered will it be passed back for drawing.
Note
SurfaceView
performs the drawing on the Canvas
outside the UIThread
.
As mentioned before, SurfaceView
is supposed to give better performance. But, since it is only software accelerated, in modern phones a standard View
—with hardware acceleration—may be more efficient in some cases. A particular situation when SurfaceView
performance is impacted is if we put other views on top of it (like the pause button), since a full alpha-blended composite will be performed each time the surface changes.
Updating GameEngine
The use of GameView
has some implications from the GameEngine
point of view. It means that it has to initialize the GameView
and then trigger draws using the generic interface.
The GameView
will be a parameter of the constructor of GameEngine
. It will be initialized, passing a reference to the list of game objects. The updated constructor of GameEngine
is like this:
public GameEngine (Activity a, GameView gameView) { mActivity = a; mGameView = gameView; mGameView.setGameObjects(mGameObjects); mWidth = gameView.getWidth() - gameView.getPaddingRight() - gameView.getPaddingRight(); mHeight = gameView.getHeight() - gameView.getPaddingTop() - gameView.getPaddingBottom(); mPixelFactor = mHeight / 400d; }
From now on, we will also calculate the pixelFactor
inside the GameEngine
. We will store it in a public variable so it can be read by the game objects. This has several advantages, such as:
- If we decide to change the number of units of the screen, this is done in a single place
- Removing code duplications is always good for maintenance
On the other hand, the onDraw
method of the GameEngine
becomes extremely simple:
public void onDraw() { mGameView.draw(); }
Updating the game layout
Of course, we have to modify the fragment_game.xml
layout to include the GameView
. We will take this chance to do some other modifications to it, such as removing the TextView
and changing the padding of the layout to be the margins on the pause button instead. This makes sure that the GameView
is fullscreen while keeping the button margins as they were.
It is important to remember that in a FrameLayout
, the order in the XML specifies the order in which the items are drawn (the z-index). We will put the GameView
at the beginning of the layout to have the pause button drawn on top of it.
The new version of fragment_game.xml
is as follows:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context="com.example.yass.counter.GameFragment"> <com.example.yass.engine.SurfaceGameView android:id="@+id/gameView" android:layout_width="match_parent" android:layout_height="wrap_content" /> <Button android:layout_gravity="top|right" android:id="@+id/btn_play_pause" android:layout_marginTop="@dimen/activity_vertical_margin" android:layout_marginRight="@dimen/activity_vertical_margin" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/pause" /> <include layout="@layout/view_vjoystick" /> </FrameLayout>
Note that this is the place where we decide which variant of the GameView
we are going to use. The rest of the code will access the methods via the GameView
interface, so nothing else needs to be changed. We are going to use the SurfaceGameView
from now on, but feel free to experiment with StandardGameView
as well.
Note
The layout is where we set which variant of GameView
we are going to use.
Finally, inside GameFragment
, we update the creation of the GameEngine
by adding the GameView
parameter:
GameView gameView = (GameView) getView().findViewById(R.id.gameView); mGameEngine = new GameEngine(getActivity(), gameView);
Now we have a GameEngine
that relies on a GameView
to do the rendering. We still need to update the GameObject
class to make use of it.
Before we get to the GameObject
class, let's take a moment to improve DrawThread
as well.