In this page I will try to explain why you should use ActivityResultEventBus instead of onActivityResult or GreenRobot's EventBus for passing results from one activity to another.
EDIT: This library and article was written some time ago, before AndroidX ActivityResult API became a thing. You can find notes about ActivityResultEventBus vs ActivityResult API at the end of this page. Although ActivityResult API is a step forward from the vanilla onActivityResult method, the ActivityResultEventBus is more suitable on defining complex UI navigation flow logic, as described below. Starting with version 2.*.*, ActivityResultEventBus is based on AndroidX ActivityResult API, extending it to fix such complex navigation needs.
I believe the vanilla way is broken because it uses Intents and Bundles in order to store the data. Sure, Intents are great when you want to communicate with another app, start an activity from another app and then get the result from that activity (for instance, taking an image with the camera, choosing a file, choosing a photo from gallery and so on).
BUT most of the times, your app is NOT starting an activity from the outside of the app, but rather an activity from the app. You are already using some model classes all around the code. These classes are static typed, compile-time checked. Intents, on the other hand, are weakly typed. They're more like a HashMap<String, Object>. You can write anything in an intent and read anything from an intent, and nothing is compile time checked. Ugh.
class CatChooserActivity : AppCompatActivity
{
fun showCat(cat : Cat)
{
catButton.setOnClickListener {
val resultIntent = Intent()
resultIntent.putExtra("cat", cat)
setResult(RESULT_OK, resultIntent)
finish()
}
}
}
class MainActivity : BaseActivity()
{
companion object
{
val REQUEST_CHOOSE_CAT = 1234
}
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
catLabel.setOnClickListener {
val intent = Intent(this, CatChooserActivity::class.java)
startActivityForResult(intent, REQUEST_CHOOSE_CAT)
}
}
override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?)
{
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_CHOOSE_CAT && resultCode == Activity.RESULT_OK&&
data != null && data.hasExtra("cat"))
{
val cat = data.getSerializableExtra("cat") as Cat
println(cat)
}
}
}
Sure, it looks okish now because the example is trivial. As the project grow, it will get harder and harder to mantain this serialization / deserialization code, request codes, result codes. Furthermore, the compiler doesn't help you at all!!
The code also looks very verbose. Verbosity and boilerplate is evil on the long run, on big projects. We should write code as simple as possible. Because the project can always get a lot of features and can get very complicated, why shall we do trivial tasks using complicated approaches?
The vanilla way is also not suitable for navigation flow logic. But this also applies to GreenRobot EventBus. We'll discuss this in the next section.
The GR EventBus is great because with it, we can get rid of Intents. Thus, our code will become compile-time checked :)
class OnCatChoosedEvent
(
val cat : Cat
)
class CatChooserActivity : BaseActivity()
{
fun showCat(cat : Cat)
{
catButton.setOnClickListener {
EventBus.getDefault().post(OnCatChoosedEvent(cat))
finish()
}
}
}
class MainActivity : BaseActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
catLabel.setOnClickListener {
startActivity(Intent(this, CatChooserActivity::class.java))
}
}
@Subscribe
fun onCatChoosed(event : OnCatChoosedEvent)
{
println(event.cat)
}
}
This looks so nice! But still, we can identify two problems with this approach.
The onCatChoosed(event)
method from MainActivity
is called before activity's onResume
. This is ok, but it can lead to some subtle bugs, which can be easily avoided. But I believe it's not a big deal, IMHO we should put code cleanness before everything else, including bugs.
The second problem is that forcing to write a class-level method to handle the event is not quite suitable for complex navigation flow logic. This problem also applies to the vanilla onActivityResult. Let's look at the following example:
class OnQRCodeScannedEvent
(
val url : String = ""
)
class QRCodeScannerActivity : BaseActivity()
{
fun onScanned(url : String)
{
EventBus.getDefault().post(OnQRCodeScannedEvent(url))
finish()
}
}
@BundleBuilder
class RestaurantDetailsActivity : BaseActivity()
{
@Arg @JvmField
var restaurantId : Int = 0
override fun onCreate(savedInstanceState : Bundle?)
{
super.onCreate(savedInstanceState)
RestaurantDetailsActivityBundleBuilder.inject(intent.extras, this)
}
}
Note: here I am using the BundleArgs library. This library generates intent / bundle serialization / deserialization code, thus replacing the weakly typed intent / bundle key-value stores with nice compile-time checked Builders. I highly recommend it to pass input arguments to activities / fragments, instead of using the vanilla way.
class MainActivity : BaseActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
scanRestaurantQRCodeButton.setOnClickListener {
startActivity(Intent(this, QRCodeScannerActivity::class.java))
}
}
@Subscribe
fun onQRCodeScanned(event : OnQRCodeScannedEvent)
{
if (event.url.startsWith("http://example.com/restaurant/"))
{
event.url.split("/").last().toIntOrNull()?.let { restaurantId ->
RestaurantDetailsActivityBundleBuilder()
.restaurantId(restaurantId)
.startActivity(this)
}
}
}
}
This code looks completely ok for now. But months pass by, and the client wants to add a restaurant reviewing system. The user should now have two buttons on this screen:
- a scan QR code / restaurant details button. When the user scans the QR code, the restaurant details screen is opened.
- a scan QR code / review button. When the user clicks this button, a dialog asking for rating will pop up. The user would choose a rating between 1 star, 2 stars, ... 5 stars, then will scan the QR code, and then, the restaurant review will be submitted
object ReviewDialog
{
class ReviewOption
(
val description : String,
val rating : Int
)
{
override fun toString() = description
}
fun show(title : String,
reviewOptions : List<ReviewOption>,
onReviewOptionSelected : (ReviewOption) -> (Unit))
{
//todo show dialog with title and a list of items.map { it.toString }
//todo when user clicks an item from the list, call onItemSelected
onReviewOptionSelected(reviewOptions.first())
}
}
class MainActivity : BaseActivity()
{
var shouldOpenRestaurantDetailsOnQRCodeScanned = false
var shouldAssignRatingOnQRCodeScanned = false
var ratingToAssignOnQrCodeScanned : Int? = null
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
scanRestaurantQRCodeButton.setOnClickListener {
Intent(this, QRCodeScannerActivity::class.java)
shouldOpenRestaurantDetailsOnQRCodeScanned = true
}
addReviewButton.setOnClickListener {
ReviewDialog.show(
title = "Please select a rating",
reviewOptions = (1..5).toList().map { rating ->
ReviewDialog.ReviewOption(description = "$rating stars", rating = rating)
},
onReviewOptionSelected = { reviewOption ->
startActivity(Intent(this, QRCodeScannerActivity::class.java))
shouldAssignRatingOnQRCodeScanned = true
ratingToAssignOnQrCodeScanned = reviewOption.rating
})
}
}
@Subscribe
fun onQRCodeScanned(event : OnQRCodeScannedEvent)
{
if (event.url.startsWith("http://example.com/restaurant/"))
{
event.url.split("/").last().toIntOrNull()?.let { restaurantId ->
if (shouldOpenRestaurantDetailsOnQRCodeScanned)
{
RestaurantDetailsActivityBundleBuilder()
.restaurantId(restaurantId)
.startActivity(this)
}
else if (shouldAssignRatingOnQRCodeScanned && ratingToAssignOnQrCodeScanned!=null)
{
//todo presenter.assignRating(restaurantId = restaurantId,
//rating = ratingToAssignOnQrCodeScanned)
}
}
}
shouldOpenRestaurantDetailsOnQRCodeScanned = false
shouldAssignRatingOnQRCodeScanned = false
ratingToAssignOnQrCodeScanned = null
}
}
This is not quite ok because it violates some basic functional programming principles. We keep these mutable states, shouldOpenRestaurantDetailsOnQRCodeScanned
and shouldAssignRatingOnQRCodeScanned
. Maybe it's ok for now, but these states can cause bugs in the future. We have to keep these states because the event is handled by another class method.
A correct and also cleaner approach would be to use lambdas:
class ID<T>(val value : Int)
class MainActivity : BaseActivity()
{
var onRestaurantQrCodeScanned : ((ID<Restaurant>) -> (Unit))? = null
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
scanRestaurantQRCodeButton.setOnClickListener {
Intent(this, QRCodeScannerActivity::class.java)
onRestaurantQrCodeScanned = { restaurantId ->
RestaurantDetailsActivityBundleBuilder()
.restaurantId(restaurantId.value)
.startActivity(this)
}
}
addReviewButton.setOnClickListener {
ReviewDialog.show(
title = "Please select a rating",
reviewOptions = (1..5).toList().map { rating ->
ReviewDialog.ReviewOption(description = "$rating stars", rating = rating)
},
onReviewOptionSelected = { reviewOption ->
startActivity(Intent(this, QRCodeScannerActivity::class.java))
onRestaurantQrCodeScanned = { restaurantId ->
//todo presenter.assignRating(restaurantId = restaurantId,
//rating = reviewOption.rating)
}
})
}
}
@Subscribe
fun onQRCodeScanned(event : OnQRCodeScannedEvent)
{
if (event.url.startsWith("http://example.com/restaurant/"))
{
event.url.split("/").last().toIntOrNull()?.let { restaurantId ->
onRestaurantQrCodeScanned?.invoke(ID<Restaurant>(restaurantId))
}
}
onRestaurantQrCodeScanned = null
}
override fun onDestroy()
{
onRestaurantQrCodeScanned = null
super.onDestroy()
}
}
Now the event is no longer handled inside another class method (onQRCodeScanned
), but rather handled inline with the actual navigation flow code. We won't ever need a mutable state, saved on the wrong scope, because lambdas are also closures. Thus, we can access immutable states (variables) from the outside of the lambda, without having to save them as class fields (saving them as class fields would be a hack, right?).
ActivityResultEventBus solves all these problems:
- Events are compile-time checked POJOs, not Intents or Bundles
- Events are received after onResume
- Events are received via lambda expressions. Not using a class method avoids side effects and spaghetti code.
- If activity A starts activity B, events sent by B can be only received by activity A, after activity B is destoryed and after activity A resumes.
The last example using ActivityResultEventBus:
class QRCodeScannerActivity : BaseActivity()
{
fun onScanned(url : String)
{
ActivityResultEventBus.post(OnQRCodeScannedEvent(url))
finish()
}
}
class ID<T>(val value : Int)
object QRCodeRestaurantUrlParser
{
fun parse(url : String) : ID<Restaurant>?
{
if (url.startsWith("http://example.com/restaurant/"))
{
val restaurantIdValue = url.split("/").last().toIntOrNull()
if (restaurantIdValue != null)
return ID<Restaurant>(restaurantIdValue)
}
return null
}
}
class MainActivity : BaseActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
scanRestaurantQRCodeButton.setOnClickListener {
onScanRestaurantQRCodeButtonClicked()
}
addReviewButton.setOnClickListener {
onAddReviewButtonClicked()
}
}
private fun onScanRestaurantQRCodeButtonClicked()
{
Intent(this, QRCodeScannerActivity::class.java)
OnActivityResult<OnQRCodeScannedEvent> { event ->
QRCodeRestaurantUrlParser.parse(event.url)?.let { restaurantId ->
RestaurantDetailsActivityBundleBuilder()
.restaurantId(restaurantId.value)
.startActivity(this)
}
}
}
private fun onAddReviewButtonClicked()
{
ReviewDialog.show(
title = "Please select a rating",
reviewOptions = (1..5).toList().map { rating ->
ReviewDialog.ReviewOption(description = "$rating stars", rating = rating)
},
onReviewOptionSelected = { reviewOption ->
Intent(this, QRCodeScannerActivity::class.java)
OnActivityResult<OnQRCodeScannedEvent> { event ->
QRCodeRestaurantUrlParser.parse(event.url)?.let { restaurantId ->
//todo presenter.assignRating(restaurantId = restaurantId,
//rating = reviewOption.rating)
}
}
})
}
}
ActivityResultEventBus disadvantages:
- ActivityResultEventBus should be used only as an onActivityResult replacement. On any other use cases (for instance, sending a
UpdateBackgroundColorEvent
to all background activities), please use a general-purpose event bus, such as GreenRobot EventBus. - Composing code blocks and lambdas can lead to "callback hell". Still, this is completely manageable by organising methods: not having a gigantic method, but splitting it into smaller methods. For instance, in the last example, the code is splitted into three methods:
onCreate
,onScanRestaurantQRCodeButtonClicked
andonAddReviewButtonClicked
, instead of keeping all the code insideonCreate
The new AndroidX ActivityResult API takes a step further in reducing the boilerplate, however I think it has serious API design problems. Why?
- You have to call
registerForActivityResult
with the event listener lambda, inonCreate
, long before the need to start the intent and await the result. - Thus you can't take advantage of closures, as describe above closures are very useful at writing complex navigation logic, without the need of extra irrelevant class fields. Closures makes navigation declarative, and the most readable way to write code is the declarative way.
- You still have to work with Intents, deserialize and serialize. It's boilerplate and not typesafe.
- It promotes saving the
launcher
objects as fields in the activity class. Something we must avoid at all costs is adding more fields to our activity classes, since they already are massive God objects, since they inherit huge amount of methods and fields. - While one may argue that promoting composition of lambdas / closures will lead to callback hell, I strongly believe that callback hell is way more manageable than having an activity class with tons of fields.