Spritesheets für Android animieren

21. Juni 2018

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.