Kollidierende Kugeln mit SurfaceView und SurfaceHolder in Android

1. Aufgabe

 

Mit Hilfe der View-Klasse lassen sich einfache grafische Ausgaben auf einem Android-Device durchführen. Die zur Verfügung stehenden Ausgabemöglichkeiten werden letzten Endes durch die Klasse Canvas vorgegeben. Die Canvas-Klasse ist für Java-Entwickler nicht unbekannt, sie stellt eine Reihe elementarer Methoden wie drawLine, drawCircle, drawBitmap, drawText etc. zur Verfügung. Das Zeichnen selbst findet in einer Methode onDraw statt. Diese Methode wird dann aufgerufen, wenn das Android-Betriebssystem der Meinung ist, die Oberfläche der App hat sich geändert, sprich es ist neu zu zeichnen. Man kann einen Aufruf der Methode onDraw auch softwaregesteuert in die Wege leiten (Aufruf von invalidate am View-Objekt), da ein direkter Aufruf von onDraw nicht zulässig ist. Für Android-Animationen mit vielen Änderungen an der grafischen Oberfläche ist diese Vorgehensweise nicht ganz ideal. Die Aufrufe von onDraw finden ausschließlich im Kontext des UI-Threads der App statt. Nimmt man für die animatorischen Berechnungen weitere Threads des Betriebssystems in Anspruch, so stehen die zahlreichen Threadwechsel einer performanten Ausführung des Programms eher im Wege als nützlich zu sein.

Als Folge dieser Beobachtungen haben die Android-Entwickler mit der Klasse SurfaceView eine Alternative zum Zeichnen ins Leben gerufen. Diese Klasse ist eine Spezialisierung der View-Klasse und kann damit ebenfalls zur Gestaltung der Oberfläche einer Activity eingesetzt werden. Die folgenden zwei Code-Fragmente mögen der Veranschaulichung dienen, wie Instanzen der SurfaceView-Klasse zum Einsatz gelangen:

public class SampleAnimation extends Activity {
    private SampleView view;

    @Override
    public void onCreate (Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
 
        view = new SampleView(this);
        this.setContentView(view);
    }
}

Den Namen der Klasse SampleView aus dem letzten Codefragment haben wir exemplarisch gewählt:

import android.view.SurfaceView;
 
public class SampleView extends SurfaceView {
 
    public AnimView(Context context) {
        super(context);
    }
}

Auch bei Verwendung der Klasse SurfaceView zeichnen wir letzten Endes mit Hilfe eines Canvas-Objekts. Unterschiedlich ist allerdings die Art und Weise, wie wir den Zugang zum Canvas-Objekt erhalten. Zu diesem Zweck kommt eine zweite Klasse SurfaceHolder ins Spiel. Die gesuchte Instanz der SurfaceHolder-Klasse erhalten wir wiederum mit Hilfe der getHolder-Methode des SurfaceView-Objekts. Damit sind wir leider noch nicht ganz am Ziel angekommen. Um auf ein SurfaceView-Objekt zeichnen zu dürfen, muss dieses kreiert worden sein. Umgekehrt dürfen wir nicht mehr zeichnen, wenn dieses zerstört worden ist. Folglich müssen wir auf das Eintreffen der drei Ereignisse surfaceChanged, surfaceCreated und surfaceDestroyed genau achten.

Bemerkung: Die onDraw-Methode der View-Klasse besitzt ihre Stärken in der vergleichsweise einfachen Handhabung. Allerdings ist sie nicht thread-safe und kann daher nur indirekt über einen invalidate-Methodenaufruf aktiviert werden (in der Regel mit einem Thread-Wechsel verbunden). Die SurfaceView-Klasse hingegen ist etwas sperriger, was den unmittelbaren Zugang anbelangt. Dafür dürfen die Methoden des dazugehörigen Canvas-Objekts, wenn wir dieses denn schließlich erhalten haben, im Kontext eines beliebigen Threads aufgerufen werden!

Die drei Methoden werden bzgl. ihrer Definition durch eine Schnittstelle Callback festgezurrt – sie ist eine innere Schnittstelle der Klasse SurfaceHolder (Listing 1):

01: public interface Callback {
02: 
03:     /**
04:      * This is called immediately after the surface is first created.
05:      * Implementations of this should start up whatever rendering code
06:      * they desire.  Note that only one thread can ever draw into
07:      * a Surface, so you should not draw into the Surface here
08:      * if your normal rendering will be in another thread.
09:      */
10:     public void surfaceCreated(SurfaceHolder holder);
11: 
12:     /**
13:      * This is called immediately after any structural changes (format or
14:      * size) have been made to the surface.  You should at this point update
15:      * the imagery in the surface.  This method is always called at least
16:      * once
17:      * 
18:      * holder: The SurfaceHolder whose surface has changed.
19:      * format: The new PixelFormat of the surface.
20:      * width:  The new width of the surface.
21:      * height: The new height of the surface.
22:      */
23:     public void surfaceChanged(SurfaceHolder holder, int format, int width, int height);
24: 
25:     /**
26:      * This is called immediately before a surface is being destroyed. After
27:      * returning from this call, you should no longer try to access this
28:      * surface.  If you have a rendering thread that directly accesses
29:      * the surface, you must ensure that thread is no longer touching the 
30:      * Surface before returning from this function.
31:      * 
32:      */
33:     public void surfaceDestroyed(SurfaceHolder holder);
34: }

Beispiel 1. Schnittstelle Callback als Element von Klasse SurfaceHolder.


Ein konzeptionelles Grobgerüst einer Klasse SampleView, die einen Kontrakt mit der SurfaceHolder.Callback-Schnittstelle eingeht, könnte folglich so aussehen:

import android.content.Context;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class SampleView extends SurfaceView implements SurfaceHolder.Callback {

    private SurfaceHolder holder;

    public AnimView(Context context) {
        super(context);
        this.holder = this.getHolder();
        this.holder.addCallback(this);
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        // ...
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        // ...
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        // ...
    }
}

Damit wären die prinzipiellen Vorbereitungen zum Animieren gegeben. Typischerweise finden die Berechnungen der Animation in separaten Threads statt. Die Details hierzu können sehr unterschiedlicher Natur sein – und spielen für die Betrachtung der SurfaceView/SurfaceHolder-Klassen eine eher untergeordnetere Natur. Um den möglichen Zugriff eines Threads auf ein SurfaceView-Objekt studieren zu können, gehen wir vom folgenden Fragment eines Animationsthreads aus:

public class AnimationThread extends Thread {
 
    private SurfaceHolder holder;
    private boolean running;
 
    public AnimationThread (SurfaceHolder holder) {
        this.holder = holder;
        this.running = true;
    }
    public void setRunning(boolean b) {
        this.running = b;
    }
 
    @Override
    public void run() {
        // this is where the animation occurs
    }
}

Damit der Animationsthread Zugriff auf das SurfaceView-Objekt (genauer: ein Canvas-Objekt) hat, benötigt dieser ein zugeordnetes SurfaceHolder-Objekt. Die Animation selbst findet in der Threadprozedur run statt. Eine Endlosschleife führt die notwendigen Berechnungen durch. Damit die Schleife nicht wirklich endlos ist, wird sie durch eine boolean-Variable geeignet kontrolliert:

public void run() {
    while (running) {
        // update the model
        // get a canvas
        // draw to the canvas
    }
}

Fast wären wir schon am Ziel angekommen. Das letzte Bindeglied – der Zugriff auf ein Canvas-Objekt mit Hilfe eines SurfaceHolder-Objekts fehlt noch. Wir betrachten den Pseudo-Code des Animationsthreads deshalb noch ein zweites Mal in einer Verfeinerung:

01: public class AnimationThread extends Thread {
02: 
03:     private SurfaceHolder holder;
04:     private boolean running;
05: 
06:     public AnimationThread (SurfaceHolder holder) {
07:         this.holder = holder;
08:         this.running = true;
09:     }
10: 
11:     @Override
12:     public void run() {
13:         while(this.running) {
14:             Canvas canvas = null;
15:             try {
16:                 canvas = this.holder.lockCanvas();
17: 
18:                 synchronized (holder) {
19:                     // draw
20:                     canvas.drawColor(Color.BLACK);
21:                     canvas.drawCircle(...);
22:                 }
23:             }
24:             finally {
25:                 if (canvas != null) {
26:                     this.holder.unlockCanvasAndPost(canvas);
27:                 }
28:             }
29:         }
30:     }
31:     ...
32: }

Beispiel 2. Pseudocode eines Animationsthreads.


Die lockCanvas-Methode am SurfaceHolder-Objekt (Zeile 16, Listing 2) liefert eine Referenz des gesuchten Canvas-Objekts zurück. Die beiden Methodenaufrufe drawColor und drawLine stehen exemplarisch für das Zeichnen. Wenn wir mit dem Zeichnen fertig sind, gibt der unlockCanvasAndPost-Methodenaufruf das Objekt wieder frei (Zeile 26). Das „Post“ im Namen symbolisiert, dass das Zeichnen nicht synchron im Lock/Unlock-Abschnitt durchgeführt wird, sondern asynchron. Die drawXYZ-Methodenaufrufe werden nur in eine Warteschlange eingereiht, ihre tatsächliche Ausführung findet zu einem späteren Zeitpunkt statt.

Der Pseudo-Code weist auf zwei wichtige Programmieraspekte hin. Zum Ersten wird der lockCanvas-Aufruf innerhalb eines try-catch-Blocks ausgeführt. Zusammen mit dem finally-Block wird auf diese Weise sichergestellt, dass im Falle von Abstürzen beim Zeichnen Zugriff und Freigabe des Canvas-Objekts symmetrisch verlaufen. Zum Zweiten erkennen wir, dass das Zeichnen selbst (genauer: es wird in einen Doppelpuffer gezeichnet) mittels synchronized threadsafe erfolgt. Auf diese Weise könnten mehrere Animationsthreads parallel ihre Berechnungen durchführen, solange sichergestellt ist, dass diese jegliche Zeichnenausgaben „zwangs“-serialisiert auf dem Canvas-Objekt durchführen.

Damit genug der Theorie. Die Animation von Kugeln – quasi eine Art von Billardtisch mit beliebig vielen bewegten Kugeln, die nach den Gesetzen der Mechanik zusammenstoßen und wieder voneinander abprallen – hatten wir bereits in hier betrachtet. Was liegt näher, als diesen Softwarestapel mittels des zuvor gezeigten Pseudocodes in eine Android-App zu gießen? Das Ergebnis finden Sie in Abbildung 1 vor:

Android-App Bouncing Balls

Abbildung 1. Android-App „Bouncing Balls

2. Lösung

Quellcode: Siehe auch github.com/peterloos/Android_BouncingBalls.git.

Im Gegensatz zur üblichen Erstellung einer Android-Anwendung mit (deklarativer) XML-Datei zur Beschreibung der Oberfläche und einer Code-Behind-Datei (in Java erstellt) für die Anwendungslogik stellen wir in dieser Fallstudie einen rein imperativen Lösungsansatz vor. Den Einstiegspunkt der App bildet in gewohnter Weise die Klasse MainActivity (Listing 3) – aber dieses Mal ohne ergänzende XML-Datei:

01: import android.content.Context;
02: import android.graphics.Point;
03: import android.support.v7.app.AppCompatActivity;
04: import android.os.Bundle;
05: import android.view.Display;
06: 
07: public class MainActivity extends AppCompatActivity {
08: 
09:     private BouncingBallsView view;
10: 
11:     @Override
12:     protected void onCreate(Bundle savedInstanceState) {
13:         super.onCreate(savedInstanceState);
14: 
15:         // retrieve a Display object to access screen details
16:         Display display = getWindowManager().getDefaultDisplay();
17:         Point size = new Point();
18:         display.getSize(size);
19:         Globals.setDisplaySize(size);
20: 
21:         Context context = this.getApplicationContext();
22:         this.view = new BouncingBallsView(context);
23:         this.setContentView(this.view);
24:     }
25: 
26:     @Override
27:     public void onPause() {
28:         super.onPause();
29:         this.view.pause();
30:     }
31: 
32:     @Override
33:     protected void onResume() {
34:         super.onResume();
35:         this.view.resume();
36:     }
37: }

Beispiel 3. Klasse MainActivity: Haupt-Aktivität.


Zentral in Listing 3 ist der Einsatz einer SurfaceView-Klasse. Den prinzipiellen Aufbau einer solchen Klasse haben wir in der Aufgabenstellung bereits beleuchtet – alles Weitere entnehmen Sie bitte Listing 4:

01: import android.content.Context;
02: import android.graphics.PointF;
03: import android.graphics.RectF;
04: import android.view.MotionEvent;
05: import android.view.SurfaceHolder;
06: import android.view.SurfaceView;
07: import android.view.View;
08: 
09: public class BouncingBallsView extends SurfaceView implements SurfaceHolder.Callback, View.OnTouchListener {
10: 
11:     private SurfaceHolder holder;
12:     private BouncingBallsAnimationThread thread;
13: 
14:     public BouncingBallsView(Context context) {
15:         super(context);
16: 
17:         this.holder = getHolder();
18:         this.holder.addCallback(this);
19: 
20:         this.setOnTouchListener(this);
21:     }
22: 
23:     @Override
24:     public void surfaceCreated(SurfaceHolder holder) {
25:     }
26: 
27:     @Override
28:     public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
29: 
30:         if (this.thread == null) {
31:             this.thread = new BouncingBallsAnimationThread(holder);
32:             this.thread.setRunning(true);
33:             RectF size = new RectF(0, 0, width, height);
34:             this.thread.setSurfaceSize(size);
35:             this.thread.start();
36:         }
37:     }
38: 
39:     @Override
40:     public void surfaceDestroyed(SurfaceHolder holder) {
41: 
42:         boolean retry = true;
43:         this.thread.setRunning(false);
44:         while (retry) {
45:             try {
46:                 this.thread.join();
47:                 retry = false;
48:             } catch (InterruptedException e) {
49:             }
50:         }
51:         this.thread = null;
52:     }
53: 
54:     @Override
55:     public boolean onTouch(View v, MotionEvent event) {
56:         switch (event.getAction()) {
57:             case MotionEvent.ACTION_DOWN:
58:                 PointF center = new PointF(event.getX(), event.getY());
59:                 BouncingBall ball = new BouncingBall(center);
60:                 this.thread.addBall(ball);
61:                 break;
62:         }
63:         return true;
64:     }
65: 
66:     public void pause() {
67:         if (this.thread != null) {
68:             this.thread.setPaused(true);
69:         }
70:     }
71: 
72:     public void resume() {
73:         if (this.thread != null) {
74:             this.thread.setPaused(false);
75:         }
76:     }
77: }

Beispiel 4. Klasse BouncingBallsView: Ausprägung der SurfaceView-Klasse.


In der surfaceChanged-Methode (Zeile 28 von Listing 4) kreieren wir den Animationsthread der App. Die surfaceChanged-Methode zählt mit den beiden weiteren Methoden surfaceCreated und surfaceDestroyed zum Vertrag der SurfaceHolder.Callback-Schnittstelle. Beachten Sie in Listing 5 vor allem die Interaktionen zwischen der BouncingBallsAnimationThread-Klasse (Implementierung eines Threads) und der SurfaceHolder-Klasse (Verwaltung der Oberfläche):

001: import android.graphics.Canvas;
002: import android.graphics.Color;
003: import android.graphics.Paint;
004: import android.graphics.PointF;
005: import android.graphics.RectF;
006: import android.view.SurfaceHolder;
007: 
008: import java.util.ArrayList;
009: 
010: public class BouncingBallsAnimationThread extends Thread {
011: 
012:     private RectF size;
013: 
014:     private final SurfaceHolder holder;
015:     private boolean isRunning;
016:     private Paint paintBorder;
017: 
018:     private ArrayList<BouncingBall> balls;
019: 
020:     private boolean paused;
021: 
022:     public BouncingBallsAnimationThread(SurfaceHolder holder) {
023: 
024:         this.holder = holder;
025:         this.isRunning = true;
026: 
027:         this.paintBorder = new Paint();
028:         this.paintBorder.setStyle(Paint.Style.STROKE);
029:         this.paintBorder.setStrokeWidth(10);
030:         this.paintBorder.setColor(Color.BLUE);
031: 
032:         this.balls = new ArrayList<>();
033:         this.paused = false;
034: 
035:         this.size = new RectF(0, 0, 0, 0);
036:     }
037: 
038:     // getter / setter
039:     public void setRunning(boolean b) {
040:         this.isRunning = b;
041:     }
042: 
043:     public void setPaused(boolean paused) {
044:         this.paused = paused;
045:     }
046: 
047:     public void setSurfaceSize(RectF size) {
048:         synchronized (holder) {
049:             this.size = size;
050:         }
051:     }
052: 
053:     // public interface
054:     public void run() {
055: 
056:         Timer frameTimer = new Timer();
057: 
058:         while (this.isRunning) {
059: 
060:             // store start time of this frame
061:             frameTimer.setStart();
062: 
063:             if (!this.paused) {
064:                 this.update();
065:                 this.draw();
066:             }
067: 
068:             // calculate frame update time and sleep, if necessary
069:             long rest = Timer.SleepTime - frameTimer.getTimeElapsed();
070:             if (rest > 0) {
071:                 this.pause(rest);
072:             }
073:         }
074:     }
075: 
076:     private void update() {
077: 
078:         synchronized (holder) {
079:             for (BouncingBall ball : this.balls) {
080:                 ball.move();
081:                 ball.checkBoundaries();
082:                 ball.checkCollisions(this.balls);
083:             }
084:         }
085:     }
086: 
087:     private void draw() {
088:         Canvas canvas = null;
089:         try {
090:             canvas = this.holder.lockCanvas();
091:             if (canvas != null) {
092:                 // surface can be edited
093:                 canvas.drawColor(Color.BLACK);
094:                 this.drawBalls(canvas);
095:             }
096:         } finally {
097:             if (canvas != null) {
098:                 this.holder.unlockCanvasAndPost(canvas);
099:             }
100:         }
101:     }
102: 
103:     private void drawBalls(Canvas canvas) {
104: 
105:         synchronized (this.holder) {
106:             for (BouncingBall ball : this.balls) {
107:                 PointF center = ball.getCenter();
108:                 this.paintBorder.setColor(ball.getColor());
109:                 canvas.drawCircle(center.x, center.y, Globals.getBallRadius(), this.paintBorder);
110:             }
111:         }
112:     }
113: 
114:     private void pause(long pause) {
115:         try {
116:             Thread.sleep(pause);
117:         } catch (InterruptedException e) {
118:             e.printStackTrace();
119:         }
120:     }
121: 
122:     public void addBall(BouncingBall ball) {
123:         synchronized (holder) {
124:             ball.setSurfaceSize(this.size);
125:             this.balls.add(ball);
126:         }
127:     }
128: }

Beispiel 5. Klasse BouncingBallsAnimationThread-Klasse: Animationsklasse.


Hinweis: Die run-Methode der Thread-Prozedur sollte ab und wann ihre Rechenleistung an andere Threads abgeben. Hierzu wurde eine kleine Klasse Timer bereitgestellt (Listing 6), die – soweit möglich – auf Millisekundenbasis eine zeitscheibengesteuerte Animation ermöglicht. Vor allem lassen sich mit Hilfe der Methode getTimeElapsed (Zeile 12) die Zeitscheiben möglichst gleichmäßig in Bezug auf ihre zeitliche Verteilung gestalten:

01: public class Timer {
02: 
03:     public final static long PreferredFrameRate = 40;                // frames per second
04:     public final static long SleepTime = 1000 / PreferredFrameRate;  // 25 msecs, if 40 frames per second
05: 
06:     private long start;
07: 
08:     public Timer () {
09:         this.setStart();
10:     }
11: 
12:     public long getTimeElapsed() {
13:         return System.currentTimeMillis() - this.start;
14:     }
15: 
16:     public void setStart() {
17:         this.start = System.currentTimeMillis();
18:     }
19: }

Beispiel 6. Klasse Timer


Zwei Hilfsklassen für eine Kugel (Klasse BouncingBall) und einen zwei-dimensionalen Vektor (Klasse GeoVector) runden die Implementierung ab. In der Klasse BouncingBall (Listing 7) sind alle Methoden angesiedelt, um eine Kugel in der 2-dimensionalen Ebene zu bewegen und Kollisionen mit anderen Kugeln zu bewältigen:

001: import android.graphics.Color;
002: import android.graphics.PointF;
003: import android.graphics.RectF;
004: import java.util.ArrayList;
005: import java.util.Random;
006: import utils.GeoVector;
007: 
008: public class BouncingBall {
009: 
010:     private static Random random = new Random();
011: 
012:     private PointF center;       // center of the circle to be drawn
013:     private RectF size;          // size of view
014:     private GeoVector direction; // direction vector to move circle
015: 
016:     private int color;
017: 
018:     public BouncingBall(PointF center) {
019: 
020:         this.center = center;
021:         this.size = new RectF();
022: 
023:         this.color = Color.BLUE;
024: 
025:         // setup direction vector
026:         float x = (random.nextBoolean()) ? random.nextFloat() : (-1) * random.nextFloat();
027:         float y = (random.nextBoolean()) ? random.nextFloat() : (-1) * random.nextFloat();
028:         this.direction = new GeoVector(x, y);
029:         this.direction.normalize();
030:         this.direction.scale(Globals.BallVelocity);
031:     }
032: 
033:     // getter / setter
034:     public void setSurfaceSize(RectF size) {
035:         this.size = size;
036:     }
037: 
038:     public PointF getCenter() {
039:         return this.center;
040:     }
041: 
042:     public GeoVector getDirection() {
043:         return this.direction;
044:     }
045: 
046:     public int getColor() {
047:         return color;
048:     }
049: 
050:     // public interface
051:     public void move() {
052: 
053:         this.center.x = this.center.x + this.direction.getX();
054:         this.center.y = this.center.y + this.direction.getY();
055:     }
056: 
057:     public void checkBoundaries() {
058: 
059:         if (this.center.y < Globals.getBallRadius()) {
060:             // top wall collision, invert direction vertical
061:             if (this.direction.getY() < 0) {
062:                 this.direction.setY(-this.direction.getY());
063:             }
064:         } else if (this.center.y > (this.size.height() - Globals.getBallRadius())) {
065:             // bottom wall collision, invert direction vertical
066:             if (this.direction.getY() > 0) {
067:                 this.direction.setY(-this.direction.getY());
068:             }
069:         } else if (this.center.x < Globals.getBallRadius()) {
070:             // left wall collision, invert direction horizontal
071:             if (this.direction.getX() < 0) {
072:                 this.direction.setX(-this.direction.getX());
073:             }
074:         } else if (this.center.x > (this.size.width() - Globals.getBallRadius())) {
075:             // left wall collision, invert direction horizontal
076:             if (this.direction.getX() > 0) {
077:                 this.direction.setX(-this.direction.getX());
078:             }
079:         }
080:     }
081: 
082:     public void checkCollisions(ArrayList<BouncingBall> balls) {
083: 
084:         boolean colorswap = false;
085: 
086:         for (int i = 0; i < balls.size(); i++) {
087:             if (this == balls.get(i))
088:                 continue;
089: 
090:             GeoVector difference = GeoVector.diff(this.getCenter(), balls.get(i).getCenter());
091:             if (difference.length() <= (Globals.getBallRadius() * 2)) {
092:                 this.getDirection().add(difference);
093:                 this.direction.normalize();
094:                 this.direction.scale(Globals.BallVelocity);
095: 
096:                 colorswap = true;
097:             }
098:         }
099: 
100:         if (colorswap) {
101:             if (this.color == Color.BLUE)
102:                 this.color = Color.RED;
103:             else if (this.color == Color.RED)
104:                 this.color = Color.GREEN;
105:             else if (this.color == Color.GREEN)
106:                 this.color = Color.YELLOW;
107:             else if (this.color == Color.YELLOW)
108:                 this.color = Color.WHITE;
109:             else if (this.color == Color.WHITE)
110:                 this.color = Color.BLUE;
111:         }
112:     }
113: }

Beispiel 7. Klasse BouncingBall


Zwei-dimensionale Vektoren sind an der einen oder anderen Stelle in den Android-Klassenbibliotheken vorhanden – aber nicht mit den von mir gewünschten Eigenschaften und Methoden. Aus diesem Grund habe ich mich für eine minimalistische Realisierung von meiner Seite entschieden – auch wenn das DRY-Prinzip (Don’t repeat yourself) an dieser Stelle geflissentlich übersehen wurde (Listing 8):

01: import android.graphics.PointF;
02: 
03: public class GeoVector {
04: 
05:     private float x;
06:     private float y;
07: 
08:     // c'tor
09:     public GeoVector() {
10:         this (0F, 0F);
11:     }
12: 
13:     public GeoVector(float x, float y) {
14:         this.x = x;
15:         this.y = y;
16:     }
17: 
18:     // getter/setter
19:     public float getX() {
20:         return this.x;
21:     }
22: 
23:     public void setX(float x) {
24:         this.x = x;
25:     }
26: 
27:     public float getY() {
28:         return this.y;
29:     }
30: 
31:     public void setY(float y) {
32:         this.y = y;
33:     }
34: 
35:     // public interface
36:     public float length() {
37:         return (float) Math.sqrt(this.x * this.x + this.y * this.y);
38:     }
39: 
40:     public void scale(float f) {
41:         this.x = f * this.x;
42:         this.y = f * this.y;
43:     }
44: 
45:     public void normalize() {
46:         this.scale((float) 1.0 / this.length());
47:     }
48: 
49:     public void add(GeoVector v) {
50:         this.x += v.x;
51:         this.y += v.y;
52:     }
53: 
54:     public static GeoVector diff(PointF p1, PointF p2) {
55:         return new GeoVector(p1.x - p2.x, p1.y - p2.y);
56:     }
57: }

Beispiel 8. Klasse GeoVector


Die im bisherigen Verlauf vorgestellte Lösung weist ein interessantes Phänomen auf. Bei Betrachtung des Speicherverbrauchs mit dem im Android Studio integrierten Speicherverbrauchs-Monitor erhalten Sie das in Abbildung 2 darstellte „Sägezahnbild“:

Android Studio mit integrierter Speicherverbrauchs-Anzeige.

Abbildung 2. Android Studio mit integrierter Speicherverbrauchs-Anzeige.


Auf den ersten Blick hat es den Anschein, dass die Realisierung der App unverantwortlich mit Speicher umgeht. Die „Sägezähne“ in Abbildung 2 bedeuten nichts anderes, als dass die App ununterbrochen Speicher allokiert, der in kurzen zeitlichen Abständen wieder freigegeben wird. Positiv kann man zumindest vermerken, dass der maximal benötigte Speicherbedarf immer konstant bleibt (am Beispiel von Abbildung 2: ca. 16 MB). Oder anders formuliert: Die vielen mit new allokierten Objekte werden vom Garbage Collector erfasst und auch freigegeben. Die App hat also kein Out of memory Problem!

Nichtsdestotrotz sollten derartige Verläufe in den Anforderungen an den Hauptspeicher gut überlegt sein, denn jede aktive Garbage Collection kann – gerade bei einer Animationsanwendung – unangenehme „Ruckeleffekte“ in der grafischen Visualisierung nach sich ziehen. Haben Sie eine Idee, an welcher Stelle in den vorgestellten Programmfragmenten die Schwachstelle liegen könnte. Ich helfe Ihnen ein klein wenig bei der Suche: Sehen Sie sich doch bitte einmal Listing 8 genauer an.

Ja, richtig gesehen: Es ist diese so unscheinbar wirkende Realisierung der Hilfsklasse GeoVector, die die Speicherverwaltung der App so gewaltig zum Schwitzen bringt. Sieht man von den Konstruktoren der Klasse ab, gibt es eine einzige Methode, die gewissermaßen im laufenden Betrieb den new-Operator benutzt – und dieses während einer Animation sogar sehr häufig: Es handelt sich in Zeile 54 bzw. 55 um die diff-Methode, die zum Berechnen des Differenz-Vektors pro Aufruf ein neues GeoVector-Objekt erzeugt.

Bei genauem Hinsehen erkennt man, dass es nicht zwingend notwendig ist, pro Aufruf der diff-Methode ein neues GeoVector-Objekt zu erzeugen. Alternativ könnte der Aufrufer der diff-Methode ein einziges GeoVector-Objekt zum Programmstart anlegen und dieses für jeden Aufruf von diff wiederverwenden. Natürlich wäre dann die Schnittstelle und Implementierung der diff-Methode geringfügig abzuändern, zum Beispiel wie folgt:

public static void diff(PointF p1, PointF p2, GeoVector result) {
    result.setX(p1.x - p2.x);
    result.setY(p1.y - p2.y);
}

Sie werden es nicht für möglich halten: Mit dieser Überladung der diff-Methode haben Sie während der Laufzeit der App keine überflüssigen Garbage Collections mehr; in Abbildung 3 erkennt man deutlich, dass zur Laufzeit der App keine Anforderungen an die Hauptspeicherverwaltung erfolgen:

App-Ausführung ohne Anforderungen an die Hauptspeicherverwaltung.

Abbildung 3. App-Ausführung ohne Anforderungen an die Hauptspeicherverwaltung.


Verwendet wird die diff-Methode in der Klasse BouncingBall und hier in Methode checkCollisions. Ein minimalistisches Redesign der Klasse BouncingBall – so wie eben andiskutiert – könnte so aussehen:

public class BouncingBall {

    private GeoVector difference; // helper object
    ...

    private int color;
    ...

    public BouncingBall(PointF center) {

        this.difference = new GeoVector();
        ...
    }

    public void checkCollisions(ArrayList<BouncingBall> balls) {

        boolean colorswap = false;

        for (int i = 0; i < balls.size(); i++) {
            if (this == balls.get(i))
                continue;

            GeoVector.diff(this.getCenter(), balls.get(i).getCenter(), this.difference);

            if (this.difference.length() <= (Globals.getBallRadius() * 2)) {
                this.getDirection().add(this.difference);
                this.direction.normalize();
                this.direction.scale(Globals.BallVelocity);

                colorswap = true;
            }
        }
        ...
    }
}

Wir erkennen in dem Code-Fragment, dass in der Klasse BouncingBall eine Instanzvariable difference ergänzt wurde. Dieses einzige GeoVector-Objekt wird für jeden Aufruf der checkCollisions-Methode herangezogen. Damit konnten wir in dieser App das Ziel erreichen, dass zur Laufzeit des Programms keine new-Aufrufe mehr vonnöten sind. Kleine Änderungen können große Auswirkungen nach sich ziehen!