I wrote a simple byte to decimal converter app less than two months into starting to learn Jetpack Compose. Now that I have more experience with Compose — in developing a real app and by participating on the #compose channel on Slack (login required) — I wanted to update this demo app to reflect my current understanding of best practices.
Overview
The impetus for updating the app was to change how I represented and handled state. In the original code, the observable state is a mutableState byte value and an array of mutableState, one element for each of the eight bits in a byte. I passed the array as a parameter of type Array<MutableState<Boolean>>. Google, however, recommends passing the State’s value and a lambda to set it instead.
Also, as it turns out, as conceptually clean as I found the bit-to-mutable-state mapping, it was overkill; one mutableState for the whole byte does not impact performance (of a tiny app like this one at least). And it lends itself to a better solution, one based on an immutable state object containing an array of bits (Booleans) and an integer value representing them.
The Code
All the code needed for the app, except for the imports, is listed in segments below. (If you copy and paste all the segments into an Android Studio Compose project, the IDE will suggest the required imports.) The essential structure and purpose of the composable functions is unchanged, so you can refer to the original article for details I don’t describe here.
App State
I created an additional, app-level state object, acting kind of like a “view model”. It holds the newly-defined byte object (type UiByte) as mutableState, so recomposition is triggered whenever the mutableState is set to a new object. A new object is created with each bit flip, from a copy of the bit array, but with the changed bit.
When constructing UiByte, the byte’s value is calculated with the specified bit values. This has the vibe of Compose’s “declarative” philosophy, which feels more appropriate than mutating a separate state value with each bit flip. (I also made the calculation more efficient, although that was for aesthetics and not observed performance.)
/** Rick Regan, https://www.exploringbinary.com/ */ class AppState { var byte by mutableStateOf(UiByte()) fun bitFlip(bitPosition: Int) { byte = byte.bitFlip(bitPosition) } } val appState = AppState() class UiByte( private val bits: Array<Boolean> = Array(8) { false } // MSB is index 0 (bit 7); LSB is index 7 (bit 0) ) { val size = bits.size val value = bits.fold(0) { value, bit -> value * 2 + (if (bit) 1 else 0) } // Horner's method fun getBit(bitPosition: Int) = bits[bits.size - 1 - bitPosition] fun getBitPlaceValue(bitPosition: Int) = bitPlaceValues[bitPosition] private companion object { val bitPlaceValues = intArrayOf(1, 2, 4, 8, 16, 32, 64, 128) } fun bitFlip( bitPosition: Int ): UiByte { val newBits = bits.copyOf() val bitIndex = bits.size - 1 - bitPosition newBits[bitIndex] = !newBits[bitIndex] return UiByte(bits = newBits) } }
App()
App() is the new top-level composable function that I added to access the global app state and “launch” the app’s screen:
@Composable fun App() { ByteToDecimal( appState.byte, appState::bitFlip ) }
ByteToDecimal()
ByteToDecimal() is the top composable of the app proper; I changed it to pass in the state and lambda, rather than access them globally.
@Composable fun ByteToDecimal( byte: UiByte, bitFlip: (bitPosition: Int) -> Unit ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth() ) { Byte( byte, bitFlip ) Spacer(modifier = Modifier.padding(top = 20.dp)) Decimal("${byte.value}") } }
Byte()
I changed Byte() to pass in parameter byte as type UiByte instead of type Array<MutableState<Boolean>> (the motivation for updating the app).
@Composable fun Byte( byte: UiByte, bitFlip: (bitPosition: Int) -> Unit ) { Row { for (bitPosition in byte.size - 1 downTo 0) { Bit( bitPosition = bitPosition, bit = byte.getBit(bitPosition), bitPlaceValue = byte.getBitPlaceValue(bitPosition), bitFlip = bitFlip ) } } }
Bit()
I made an incidental change to Bit(), passing in bitPlaceValue instead of computing it.
@Composable fun Bit( bitPosition: Int, bit: Boolean, bitPlaceValue: Int, bitFlip: (bitPosition: Int) -> Unit ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = bitPlaceValue.toString() ) val colors = if (bit) { ButtonDefaults.buttonColors( backgroundColor = Color.White, contentColor = Color.Blue ) } else { ButtonDefaults.buttonColors( backgroundColor = Color(240, 240, 240), contentColor = Color.Blue ) } Button( onClick = { bitFlip(bitPosition) }, modifier = Modifier .padding(2.dp) .size(45.dp), colors = colors, border = BorderStroke(1.dp, color = Color.Blue) ) { Text( text = if (bit) "1" else "0", modifier = if (!bit) Modifier.alpha(0.4f) else Modifier ) } Text( text = bitPosition.toString(), color = Color.Gray ) } }
Decimal()
Decimal() is unchanged from the original code.
@Composable fun Decimal( decimal: String ) { Text( text = decimal, Modifier .width(70.dp) .border(BorderStroke(1.dp, color = Color.Black)) .padding(4.dp), textAlign = TextAlign.Center, color = Color(0, 180, 0), fontSize = 5.em ) }
An Alternative Implementation for UiByte
I wrote an alternative implementation for UiByte, using Kotlin’s unsigned byte type (UByte). In this case, the bits and the value are one and the same; the value is the integer value of the UByte, and the bits within it are accessed with masks and bitwise operations. (It felt a little too low-level to use as the primary implementation, at least for the purposes of this article.)
class UiByte( val value: UByte = 0u ) { val size = 8 fun getBit(bitPosition: Int) = (value and bitPlaceValues[bitPosition].toUByte()) != (0u.toUByte()) fun getBitPlaceValue(bitPosition: Int) = bitPlaceValues[bitPosition] private companion object { val bitPlaceValues = intArrayOf(1, 2, 4, 8, 16, 32, 64, 128) } fun bitFlip( bitPosition: Int ): UiByte { val newValue = value xor bitPlaceValues[bitPosition].toUByte() return UiByte(value = newValue) } }
Notes
-
I wanted to address a few things I said in my original article:
- “The app’s state lives outside of the composable functions so that it can survive configuration changes.”: rememberSaveable within ByteToDecimal() would be an alternative.
- “My call to Decimal() passes the string representation of byteValue, which triggers recomposition as I want. However, I was expecting to have to pass byteValue itself, since it is the mutable state.”: This works because anything based on reading the state is also updated appropriately.
- “I would have liked to have used property delegate syntax in declaring byte but I don’t know if that’s possible in an array…”: I don’t think it’s possible, but it is irrelevant to this app with its redesign.
- “I probably could have made Bit() and Decimal() more stateless by hoisting the styling information and hardcoded button labels (if those are considered state) but I’m not sure yet where that line is.”: I am in no better position to answer this, certainly not in the context of this tiny app.
Summary
The app worked perfectly fine as coded. And even though I wrote it to a pre 1.0 release alpha, it compiles and runs unchanged on the latest version of Compose/Material2 (Compose UI 1.3.0-alpha03, Compose compiler 1.3.0).
I updated the app because I did not want to pass a State type to my composable functions; I wanted to follow Google’s recommendations. In the process, I consolidated the app’s state into one immutable object.
As newly coded, the app still recomposes efficiently; only the changed bits are recomposed. This either reflects that I didn’t fully understand back then how recomposition worked, or Compose has since added optimizations (or both).
I have learned much more about how to code Compose apps, and how to code better in Kotlin itself. A lot of that can’t be demonstrated with this little demo app though (and getting into those things would be beyond the scope of this blog in any case).
After publishing this article I thought of initializing value in functional style, using the fold() function (which is really just a built-in way to do the same loop under the covers):
I edited the code in the article to reflect this.
This was the original code it replaced: