The Mojo Application takes a JSON layout file and renderes those layouts via an Android custom View.
The Project contains the following package structure:
The data package holds:
Layout
, is the kotlin representation of the JSON object.LayoutFetcher
, is responsible to fetch & parse the JSON string.
The render package contains only the MojoView
class.
MojoView
is a lightweight Android View. MojoView
renders the List<RenderObject>
onto the screen. MojoView
simply iterates the List<RenderObject>
and calls drawBackground
or drawBitmap
.
To keep the MojoView
lightweight the List<RenderObject>
has all positions/bounds, colors, and bitmaps pre-calculated.
The engine package contains the classes related to converting/adapting the Layout into a List. The engine package includes the following class's:
LayoutAdapter
, follows the Adapter pattern to adapt/convert a Layout object into a List. Before we render the Layout objects on screen, we need to calculate the positions and bounds for the Layout. TheLayoutAdapter
calculates the positions, bounds, and fetches media for the Layout and flattens the Layout into aList<RenderObject>
. AfterLayoutAdapter
pre-calculates positions, bounds, and media, theList<RenderObject>
can render onto theMojoView
.media.MediaAdapter
, is the same as LayoutManager, only it takes care of the media field on Layout. The media field tells the Adapter that theRenderObject
needs to include an image. The Layout media fields adapt to the MediaObject. Picasso is used to fetch images, scale and fit/fill those images in the pre-calculated bounds.media.ImageFetcher
, follows the Builder pattern and is a wrapper on Picasso builder functions.ImageFetcher
wraps Picasso to enable unit-testing theMediaAdapter
RenderObject
andMediaObject
contain the bounds, color, and bitmap that theMojoView
uses to draw/render the objects on the screen.Anchor
andPadding
, contain the calculations for the bounds depending on the anchor_x/anchor_y, width/height, and padding of the Layout.
The di(Dependency Injection) package contains:
AppDispatchers
. We use coroutines so that Picasso loads images on the background thread. In Screenshot tests, we can switch the background IO thread for image loading. We'll later see how Espresso tests need to wait for Picasso before asserting if the image is on screen.LayoutFetcherLocator
follows the Locator pattern. In Screenshot and Unit tests, we need to swap the implementation details ofLayoutFetcher
so that in tests, we load the layout JSON files from a different location than the real app.MojoApplication
, is the Android Application class.MojoApplication
implementsInjector
from whereMainActivity
fetches its dependencies. We have aTestMojoApplication
for screenshot tests, in which we mock/fake out the dependencies fromInjector
.
Below are the app's steps from reading the JSON to rendering on screen:
- The app reads the JSON from resources.
LayoutFetcher
fetches and parses the JSON into aLayout
object - The
Layout
is sent to theLayoutAdapter
. - The
LayoutAdapter
flattens and calculates the bounds of all items(and children) in theLayout
, including fetching media. - The
LayoutAdapter
calculates thebounds(left, top, right, bottom)
positions for the object based on the anchors, width/height, and x/y. - If the layout item contains a
media
field, theMediaAdapter
loads the bitmap via Picasso, calculates the bounds and scales the image(bitmap). - After the
LayoutAdapter
flattens theLayout
, theList<RenderObject>
is sent to theMojoView
. - The
MojoView
iterates theList<RenderObject>
and directly draws each item on the screen. No position calculations are necessary.
Screenshot tests are in the ScreenshotTests.kt
file.
The test application(TestMojoApplication), loads the JSON layout from the androidTest/assets/ folder.
The androidTest/assets folder contains the test JSON layouts and test PNG images. Those assets do not compile for the actual application, only for the test application, which lets us add as many test images or test JSON layout files as we like, and the actual application does not increase in size(MB).
Each test needs 3 inputs:
- The test JSON layout
- a PNG file of how the JSON layout on the screen renders
- The size of the MojoView that we are testing, for example: 1024x1024
Inside the androidTest/assets folder, there are test JSON layouts and their corresponding rendered images(PNG format), for example: the-layout-from-the-test.json the-layout-from-the-test.png The screenshot tests follow the steps below to perform a test:
- The test sets up the
TestMojoApplication
with the test JSON layout. - The app renders the test JSON layout.
- The test catches(screenshots) the
MojoView
by converting theMojoView
to a bitmap. - The test compares the screenshot(Bitmap of the
MojoView
) with the test PNG(also a Bitmap) that we initially provided.
When running the screenshot test, use a Pixel 5 emulator. Otherwise, the rendered images might be slightly different from the test PNGs. We would have a list of test PNGs per device in a real test environment.
Below is a test example:
@Test
fun testTheRealLayoutFromTheTest() {
val layoutFileName = "the-layout-from-the-test" // the JSON layout file name from "androidTest/assets/"
application.layout = readLayoutFrom(context, layoutFileName) // sets the JSON layout on the app
launch(Size(1024, 1024)) { // we can test different screen sizes
onView(withId(R.id.mojoView))
.check(matches(hasBitmap(context, layoutFileName))) // compares the MojoView Bitmap with the test PNG from "androidTest/assets/"
}
}
The LayoutAdapterTest
tests the JSON parsing, calculating bounds, and flattening the JSON layout input into List<RenderObject>
.
The JSON layout files are in the test/resources/ folder.
In the real app, we use image loading via ImageFetcher
, but in unit tests, we can mock the ImageFetcher
and pass in a mock for the Bitmap
.
For example, JSON:
{
"background_color": "#000000",
"padding": 0.25,
"children": [
{
"background_color": "#ff0000",
"anchor_x": "left",
"anchor_y": "bottom",
"media": "mojo",
"media_content_mode": "fill"
}
]
}
The above JSON is adapted(flattened) into a kotlin List<RenderObject>
:
listOf(
RenderObject(Bounds(0,0, 1000, 1000), "#000000"),
RenderObject(Bounds(250, 250, 750, 750), "#000000"),
MediaObject(
bitmap,
bitmapBounds=Bounds(left=100.0f, top=100.0f, right=500.0f, bottom=500.0f)
)
)
Thus, we can easily assert the result from LayoutAdapter
and the fake test data. Below is an example of a unit test:
val bitmap = mock()
@Test
fun `should adapt 3 nested children`() = runBlocking {
val subject = LayoutAdapter(
tolayout("/test-layout-1.json"),
imageFetcher
)
subject.adapt(1000, 1000).collect { result ->
val fakeAdaptedResult = listOf(
RenderObject(Bounds(0,0, 1000, 1000), "#000000"),
RenderObject(Bounds(250, 250, 750, 750), "#000000"),
MediaObject(
bitmap,
bitmapBounds=Bounds(left=100.0f, top=100.0f, right=500.0f, bottom=500.0f)
)
)
assertThat(result).isEqualTo(fakeAdaptedResult)
}
}