Podcast
Videos
September 6, 2022
Nov 2022
8 Min

Spritesheets für Android animieren

Vor Kurzem musste ich für ein Projekt einige Spritesheets animieren. Nach einigem Rumprobieren und Testen habe ich schließlich eine SpritesheetView entwickelt, welche alle meine Probleme löst. Die erste Iteration basierte auf der von Android bereitgestellten SurfaceView. Typischerweise nutzt man eine SurfaceView für einfache 2D-Animationen mit sich bewegenden Elementen, wie z.B. ein 2D-RPG oder andere 2D-Gaming-Apps. Da es ziemlich einfach ist auf einem SurfaceView mit einem Canvas zu zeichnen, habe ich diesen Ansatz gewählt, um eine leicht nutzbare Custom-View zu implementieren.

Während des Arbeitens mit meiner SpritesheetView traten allerdings gerade auf älteren und nicht besonders leistungsstarken Geräten Probleme mit der SurfaceView auf. Da es keine Option ist diese Geräte zu vernachlässigen, musste ich meine SpriteSheetView überarbeiten. Nach einigem Hin und Her kam ich letztendlich auf die Lösung: Die SpriteSheetView basiert nun auf einem RelativeLayout, welches eine einzelne ImageView beinhaltet. Durch die Eigenschaften der ImageView ist es nun auch möglich das Seitenverhältnis(engl. aspect ratio) der einzelnen Sprites beizubehalten.

Aber genug geschrieben, jetzt zum Quellcode der SpritesheetView. Natürlich in Kotlin geschrieben.

  class SpritesheetView(context: Context, attributeSet: AttributeSet) : RelativeLayout(context, attributeSet) {

       var typedArray: TypedArray = context.theme.obtainStyledAttributes(attributeSet, R.styleable.SpritesheetView, 0, 0)

       val rows = typedArray.getInteger(R.styleable.SpritesheetView_rows, 0)
       val columns = typedArray.getInteger(R.styleable.SpritesheetView_columns, 0)
       val frameWidth = typedArray.getInteger(R.styleable.SpritesheetView_frame_width, 0)
       val frameHeight = typedArray.getInteger(R.styleable.SpritesheetView_frame_height, 0)
       val frameDuration = typedArray.getInteger(R.styleable.SpritesheetView_frame_duration, 50)
       val spritesheetResId = typedArray.getResourceId(R.styleable.SpritesheetView_spritesheet, 0)
       val bitmapConfigId = typedArray.getInteger(R.styleable.SpritesheetView_bitmap_config, 0)
       val colorResId = typedArray.getResourceId(R.styleable.SpritesheetView_sprite_background_color, 0)

       var options = BitmapFactory.Options().apply {
           inPreferredConfig = getBitmapConfig()
           inScaled = false
       }

       var currentColumn = 0
       var currentRow = 0

       var frameOnSheet: Rect? = null

       var isAnimating = false

       init {
           View.inflate(context, R.layout.spritesheetview, this)
       }

       var spritesheet: Bitmap? = null

       lateinit var positionOnScreen: RectF
       var color: Int = Color.BLACK

       override fun onAttachedToWindow() {
           super.onAttachedToWindow()
           typedArray.recycle()
       }

       override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
           super.onLayout(changed, left, top, right, bottom)

           if (columns <= 0 || rows <= 0 || frameWidth <= 0 || frameHeight <= 0 || spritesheetResId <= 0) {
               Timber.e("Not all necessary arguments are passed to the view. At least you need to pass 'columns', 'rows', 'frame_width', 'frame_height' and 'spritesheet'.")
           } else {

               if (colorResId != 0) {
                   color = ContextCompat.getColor(context, colorResId)
               }

               imageView.setBackgroundColor(color)
               spritesheet = BitmapFactory.decodeResource(context.resources, spritesheetResId, options)
               positionOnScreen = RectF(0f, 0f, frameWidth.toFloat(), frameHeight.toFloat())

               draw()
           }
       }

       val spriteAnimationHandler = Handler()

       val runnable = object : Runnable {
           override fun run() {
               draw()
               spriteAnimationHandler.postDelayed(this, frameDuration.toLong())
           }
       }

       fun getBitmapConfig(): Bitmap.Config {
           when (bitmapConfigId) {
               0 -> return Bitmap.Config.ALPHA_8
               1 -> return Bitmap.Config.ARGB_8888
               else -> return Bitmap.Config.ALPHA_8
           }
       }

       /**
       * Calculates the next frame on the spritesheet depending on the amount of columns and rows
       */
       private fun getCurrentFrame() {
           val xTop = currentColumn * frameWidth
           val yTop = currentRow * frameHeight
           val xBot = xTop + frameWidth
           val yBot = yTop + frameHeight

           frameOnSheet = Rect(xTop, yTop, xBot, yBot)
           currentColumn++

           if (currentColumn == columns) {
               currentColumn = 0
               currentRow++
               if (currentRow == rows) {
                   currentRow = 0
               }
           }
       }

       /**
       * Draws the frame to the given ImageView
       */
       private fun draw() {
           val tempBitmap = Bitmap.createBitmap(frameWidth, frameHeight, Bitmap.Config.ARGB_8888)
           val tempCanvas = Canvas(tempBitmap)

           tempCanvas.drawColor(color)
           tempCanvas.drawBitmap(spritesheet, frameOnSheet, positionOnScreen, null)

           val drawable = BitmapDrawable(resources, tempBitmap)
           imageView.setImageDrawable(drawable)
           getCurrentFrame()
       }

       fun startSpriteAnimation() {
           if (!isAnimating) {
               spritesheet?.let{
                   if(it.isRecycled){
                       spritesheet = BitmapFactory.decodeResource(context.resources, spritesheetResId, options)
                   }
               }

               spriteAnimationHandler.postDelayed(runnable, frameDuration.toLong())
               isAnimating = true
           }
       }

       fun stopSpriteAnimation() {
           spriteAnimationHandler.removeCallbacks(runnable)
           Timber.e("recycle")
           spritesheet?.recycle()
           isAnimating = false
       }

       override fun onDetachedFromWindow() {
           super.onDetachedFromWindow()
           spriteAnimationHandler.removeCallbacks(runnable)
           spritesheet?.recycle()
       }
}

Wie ihr seht, erbt die SpritesheetView von einem RelativeLayout, benutzt aber ihr eigenes Layout, welches wie folgt aussieht:

   <?xml version="1.0" encoding="utf-8"?
       xmlns:android="http://schemas.android.com/apk/res/android">

   <RelativeLayout
     android:layout_width="match_parent"
     android:layout_height="match_parent">

     <ImageView
         android:id="@+id/imageView"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:scaleType="fitCenter"
         android:background="@android:color/transparent"/>

   </RelativeLayout>

Den Arbeitsspeicher schonen

Ein sehr herausforderndes Problem war das Spritesheet als Bitmap in den Arbeitsspeicher zu laden, da eben dieser auf einigen älteren Geräten sehr klein ist und ständig überlastet war. Die Lösung für dieses Problem war schließlich, die folgenden zwei Einstellungsmöglichkeiten zu verwenden, um die Bitmap effizient zu laden:

  • inPreferredConfig: Diese Option bestimmt den Farbmodus, in welchem das Spritesheet in ein Bitmap geladen wird. Weil mein Spritesheet nur 8 Farben und den Alpha-Channel benötigt, habe ich Bitmap.Config.ALPHA_8 benutzt. Dies ist die bestmögliche Konfiguration, weil sie nur 1 Byte Arbeitsspeicher pro Pixel benötigt. Hat man zum Beispiel ein Spritesheet mit dem vollen Farbspekturm, muss man Bitmap-Config.ALPHA_8888 benutzen, was 4 Bytes pro Pixel beansprucht. Wie man sieht, würde dies zu einem viel höheren Bedarf an Arbeitsspeicher durch die App führen.
  • inScaled: Die nächste Option, die mir sehr geholfen hat, war inScaled auf false zu setzen, also zu deaktivieren. Android skaliert Bilder während des Ladens in Abhängigkeit vom Gerät, auf dem die App läuft. Zum Beispiel wurde die Breite auf meinem Nexus 5X von den ursprünglichen 6.744 Pixeln auf 17.000 (!) Pixel skaliert. Wer den vorangegangenen Abschnitt sorgfältig gelesen hat, weiß inzwischen, dass jeder einzelne Pixel in der Bitmap bis zu 4 Bytes des Arbeitsspeichers belegt. Bei vollem Farbspektrum sind das bei 17.000 Pixeln ganze 68.000 Bytes.

Goodbye Memoryleaks!

Wie immer, wenn man mit einem Context Objekt arbeitet besteht die Gefahr von Memoryleaks. Diese Sorge hatte ich anfangs auch beim Verwenden meiner SpritesheetView. Doch das Memorymanagement ist recht einfach. Das typedArray für die Parameter wird recycelt, sobald alle Attribute geladen sind. Es gibt also keinen Grund, sich darüber Gedanken zu machen. Viel entscheidender ist die Bitmap für die Animation. In onDetachedFromWindow stoppt die View die Animation und recycelt die Bitmap selbst. Aber wenn Activity oder das Fragment pausiert werden, ist die View nicht detached vom Window und die Animation läuft weiter. In diesem Fall muss man das selbst steuern: Einfach während onPause spritesheetView.stopAnimation() und während onResume spritesheetView.startAnimation() aufrufen.

Verwendung der SpritesheetView

Um die View zu nutzen, muss man Folgendes zur attrs.xml hinzufügen:

<declare-styleable name="SpritesheetView">
    <attr name="rows" format="integer" />
    <attr name="columns" format="integer" />
    <attr name="frame_width" format="integer"/>
    <attr name="frame_height" format="integer"/>
    <attr name="spritesheet" format="reference"/>
    <attr name="sprite_background_color" format="integer"/>
    <attr name="frame_duration" format="integer"/>
    <attr name="bitmap_config" format="enum">
        <enum name="ALPHA_8" value="0" />
        <enum name="ARGB_8888" value="1" />
        <!-- enough for this project, you can extend it with things like "RGB_565" -->  
    </attr>
</declare-styleable>

Anschließend müsst ihr in eurem Layout nur noch Folgendes einbauen:

<com.example.SpritesheetView
    android:id="@+id/spritesheetView"
    android:layout_width="@dimens/spriteWidth"
    android:layout_height="@dimens/spriteheight"
    app:bitmap_config="ALPHA_8"
    app:columns="8"
    app:rows="4"
    app:frame_duration="75"
    app:frame_height="665"
    app:frame_width="641"        
    app:spritesheet="@drawable/here_goes_your_spritesheet.png" />

Wie ihr seht ist die Verwendung der SpritesheetView und somit das Einbauen von Spritesheet-Animationen sehr einfach. Ihr müsst euch nicht mehr um das Animieren kümmern sondern gebt einfach an, wieviele Zeilen und Spalten euer Spritesheet hat, wie lange ein Frame angezeigt werden soll, wie groß euer Frame ist und letztlich das Spritesheet selbst.

Beachtet aber folgenden wichtigen Hinweis:

Achtung: Diese View ist noch nicht bis ins letzte Detail getestet und kann sich in der Zukunft noch ändern. Alle Änderungen findet ihr in unserer Best-Practice-Sammlung.

Andreas Link
Andreas Link
Anh Dung Pham
Anh Dung Pham
Cihat Gündüz
Cihat Gündüz
Andreas Link
Ekrem Sentürk
Eva Maria Stock
Eva-Marie Stock
Andreas Link
Giulia Maier
Inken Marei Kolthoff
Inken Marei Kolthoff
Janina Baumann
Janina Baumann
Janina Bokeloh
Janina Bokeloh
Jeanette Schmidt
Jeanette Schmidt
Jens Krug
Jens Krug
Kajorn Pathomkeerati
Kajorn Pathomkeerati
Karl Barth
Karl Barth
Kay Dollt
Kay Dollt
Murat Yilmaz
Murat Yilmaz
Thorsten Hack
Thorsten Hack
Thorsten Hack
Thorsten Hack
Inken Marei Kolthoff
Cynthia Murat
Inhaltsverzeichnis

Weitere Artikel

Welche Off-Page-Faktoren sollte ich tracken?
Inken Marei Kolthoff
26.11.2022
4 Min

Welche Off-Page-Faktoren sollte ich tracken?

"Das Wesentliche ist für die Augen unsichtbar." Diese Weisheit gilt auch bei der App Store Optimization.

Artikel lesen
Wie bekommt meine App gute Ratings?
Inken Marei Kolthoff
26.11.2022
5 Min

Wie bekommt meine App gute Ratings?

Trotz aller technologischen Fortschritte: Mund-zu-Mund-Propaganda ist nach wie vor die effektivste Form der Werbung.

Artikel lesen
App erfinden
Kay Dollt
26.11.2022
5 Min

App erfinden

Eine App zu erfinden ist eine komplexe Angelegenheit.

Artikel lesen

Jetzt kostenloses Strategiegespräch sichern!

Die Beratungen sind grundsätzlich schnell ausgebucht, deshalb fülle jetzt in 2 Minuten das kurze Formular aus.

Jetzt Strategiegespräch sichern