Meta Programming in Android
Ahmed Adel Ismail
Engineering Manager @ Yassir | x-SadaPay | x-Swvl | x-Talabat | x-TryCarriage | x-Vodafone | More than a decade of experience in Android development and teams leadership
Year after year we are getting rid of the boilerplate code that we need to write for small and simple tasks in Android, and in this article, we will go through old but powerful technique to cut down our efforts ... in fact, it is there since the very first Android version, but after introducing features like LifecycleObserver interface in Android, it became much more effective and can suite most of the use cases we need
No need for Base classes
We usually start any android project with putting the BaseActivity and BaseFragment classes to put there the common operations we need to use in all of our Activities and Fragments, those operations can be divided into 2 categories
- operations that are executed based on life cycle events in multiple Activities/Fragments, like drawing some stuff in onCreate(), register and unregister stuff in onStart() and onPause(), etc...
- operations that we need to invoke from any Activity/Fragment, like showDialog(), showToast(), etc...
For the operations of the second category, it is preferred to declare them as stand alone functions that we invoke when we need, no need to keep them in a Base class, they can be functions that take an Activity/Fragment in there parameter, and do what ever you need, and put them in there own package in the UI layer
For the first category, we face a challenge, is that we need the Base class to call them for us in the proper life cycle events, instead of we keep calling them in every Activity/Fragment, and here comes the solution in this article
ActivityLifecycleCallbacks and FragmentLifecycleCallbacks
Although there are some differences in how to register each one, but the end result is the same for both, when our Activity or Fragment life cycle methods call there super counterparts, they invoke the code inside those callbacks
let us start with Activities, this is the ActivityLifecycleCallbacks interface declaration :
public interface ActivityLifecycleCallbacks { void onActivityCreated(Activity activity, Bundle savedInstanceState); void onActivityStarted(Activity activity); void onActivityResumed(Activity activity); void onActivityPaused(Activity activity); void onActivityStopped(Activity activity); void onActivitySaveInstanceState(Activity activity, Bundle outState); void onActivityDestroyed(Activity activity); }
So now, to hook up any operation in a life cycle method for an Activity, all we need to do is to implement the desired life cycle method in the ActivityLifecycleCallbacks, and register it through the Application class of our App
So for example, if we need to show a Toast with the Activity title in it's onCreate(), we will do the following
1- Implement the interface :
class ToastDisplayer : Application.ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle) { Toast.makeText(activity, activity.title, Toast.LENGTH_SHORT).show() } ... // leave the rest of not needed methods empty }
2- Register the implementer
class MyApplication : Application() { override fun onCreate() { super.onCreate() ... registerActivityLifecycleCallbacks(ToastDisplayer()) } }
And this is how we execute code among all of our Activities, but in real life we use Base class because we want to pass parameters to the code that will be executed in the life cycle events, like for example, we do not want to show the title toast except for activities with title, so here we have multiple ways, and one of them is Annotations, which is the cleanest way for them as we will see
Working with Annotations
1- Declare the Annotation
It should be visible at Runtime to our JVM, yes we will use reflections, which you already use with ViewModelProviders and many other stuff and you will see the performance your self
@Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.CLASS) annotation class ShowTitleToast
2- Scan for the Annotation
Now in our ActivityLifecycleCallbacks implementer, we will check if the Activity is annotated with this annotation (which means it wants to show title toast) or not
class ToastDisplayer : Application.ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle) { if(activity.javaClass.isAnnotationPresent(ShowTitleToast::class.java)) { Toast.makeText(activity, activity.title, Toast.LENGTH_SHORT).show() } } ... }
3- Annotate the desired classes
Now that we have our ActivityLifecycleCallbacks implementer handling the annotation, we can annotate our Activity class that we want it to show the toast :
@ShowTitleToast class MainActivity : AppCompatActivity()
Passing parameters through Annotations
Let's be more practical, in our example, we will still need to set the Activity title for all the annotated Activities, while we can make the annotation itself pass this parameter to our ActivityLifecycleCallbacks implementer
1- Declare Annotation with parameter
@Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.CLASS) annotation class ShowTitleToast(val title: String)
2- Pass the parameter to the Annotations from Activities
@ShowTitleToast("Home") class MainActivity : AppCompatActivity() @ShowTitleToast("Settings") class SettingsActivity : AppCompatActivity()
3- Handle parameter from ActivityLifecycleCallbacks implementer
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle){ val annotation = activity.javaClass.getAnnotation(ShowTitleToast::class.java) ?: return activity.title = annotation.title Toast.makeText(activity, activity.title, Toast.LENGTH_SHORT).show() }
Now in our simple scenario, we made a custom action and passed to it the parameters through annotation, this can also apply to things like setting the content view, drawing the Toolbar or applying some flags to the window which makes the Activity a full screen or what ever
Real Life example
In the following example, the requirement is to make the user notice when the internet is disconnected from the device, so when this happens, an image should be grayed out (displayed in a gray color), once the internet is back, it should return back to the normal color, and all of this is achieved through Annotation
Because this is code from real project, it is in Java and contains RxJava code, with some custom methods as well, so for each class I will explain what it is doing in the process
1- Declare OnNetworkChangedListener
This class will be responsible to listen for the Network state, and report to it's caller weather we are connected or not through a Boolean value ... although it is a Class, technically it is a function but in terms of Java (a functional class), as it is a class with one public method, and to make sure it is dealt with as a function in Java code, it implement's one of the functional interfaces of Java, and this approach is very good if you want your classes to follow the Single Responsibility Principle, Open Closed Principle and Dependency Inversion Principle as well
public class OnNetworkChangedListener implements Function<AppCompatActivity, Observable<Boolean>>, Logger { @Override public Observable<Boolean> apply(AppCompatActivity context) { BehaviorSubject<Boolean> connection = BehaviorSubject.create(); registerNetworkBroadcast(context, connection); return connection; } private void registerNetworkBroadcast(AppCompatActivity context, Subject<Boolean> connection) { ConnectivityManager manager = connectivityManager(context); if (manager == null) return; context.getLifecycle().addObserver(receiverObserver(context, receiver(connection, manager))); } private ConnectivityManager connectivityManager(AppCompatActivity context) { return Objects .maybe(context.getSystemService(Context.CONNECTIVITY_SERVICE)) .cast(ConnectivityManager.class) .blockingGet(); } @NotNull private DefaultLifecycleObserver receiverObserver(AppCompatActivity context, BroadcastReceiver receiver) { return new DefaultLifecycleObserver() { @Override public void onStart(@NonNull LifecycleOwner owner) { context.registerReceiver(receiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); } @Override public void onStop(@NonNull LifecycleOwner owner) { context.unregisterReceiver(receiver); } }; } @NotNull private BroadcastReceiver receiver(Subject<Boolean> connection, ConnectivityManager manager) { return new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { connection.onNext(isOnline(manager)); } }; } private Boolean isOnline(ConnectivityManager connectivityManager) { return Objects.maybe(connectivityManager.getActiveNetworkInfo()) .map(NetworkInfo::isConnected) .doOnError(this::error) .onErrorReturnItem(false) .blockingGet(false); } }
- Objects.maybe() is a function that checks if it's parameter is not null, it will return as Maybe.just(), else it will return Maybe.empty() ... this function is there because RxJava does not accept null parameters even for it's Maybe type
- error() is a default method in Logger interface, which safely logs errors to Crashlytics
- This function takes an Activity in it's parameter, and it creates the BroadcastReceiver that listens to network status, and using the LifecycleObserver interface, it registers that broadcast receiver in Activity's onStart(), and unregister it in onStop()
- The return of the Function is an Rx Subject, an Object that holds one item, in our case a Boolean, and it puts in that Subject "true" if we are connected, or "false" if we are disconnected ... this Subject will be observed by some one else and they will take action every time a new value is put in it
2- Declare the Annotation
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface NetworkStateListenerImage { int value(); }
This is how we declare the annotation in Java, ElementType.TYPE in Java is similar to AnnotationTarget.CLASS in Kotlin, this annotation expects an Integer value to be passed to it when it is declared
3- Declare the Annotation Scanner - NetworkStateScanner
Since the logic to handle this Annotation is more complex than just showing a toast, it is put in it's own Function (Functional Class in Java), which is as follows :
public class NetworkStateScanner implements Consumer<Activity>, Logger, Resources { @Override public void accept(Activity activity) { Disposable disposable = bindImageToNetworkState(activity); ((AppCompatActivity) activity).getLifecycle().addObserver(new DefaultLifecycleObserver() { @Override public void onDestroy(@NonNull LifecycleOwner owner) { if (!disposable.isDisposed()) disposable.dispose(); } }); } private Disposable bindImageToNetworkState(Activity activity) { ImageView imageView = imageView(activity); if (imageView == null) return Disposables.disposed(); return new OnNetworkChangedListener() .apply((AppCompatActivity) activity) .map(connected -> connected ? R.color.colorAccent : R.color.v3_gray_accents) .map(this::parseColor) .map(ColorStateList::valueOf) .observeOn(AndroidSchedulers.mainThread()) .subscribe(tint -> ImageViewCompat.setImageTintList(imageView, tint), this::error); } private ImageView imageView(Activity activity) { return AnnotationRetriever.with(NetworkStateListenerImage.class) .apply(activity) .flatMapMaybe(annotation -> Objects.maybe(annotation.value())) .firstElement() .map(activity::findViewById) .cast(ImageView.class) .doOnError(this::error) .onErrorResumeNext(Maybe.empty()) .blockingGet(); } }
- AnnotationRetriever is a Functional Class that checks for the Availability of the annotation and returns an Observable holding that annotation if present, or an empty Observable if it is not there ... it is a way to chain the if /else calls into an RxJava Stream, no magic here ... just consider that if there is no annotation present, all the written code will not execute
- parseColor() is a default method in Resources interface, which takes care of parsing color resources
- error() is a default method in Logger interface, which safely logs errors to Crashlytics
- Objects.maybe() is a function that checks if it's parameter is not null, it will return as Maybe.just(), else it will return Maybe.empty() ... this function is there because RxJava does not accept null parameters even for it's Maybe type
- This function first finds the ImageView with the ID that is passed to the @NetworkStateListenerImage annotation, and then it listens to the Rx Subject returned by the OnNetworkChangedListener, if it returned true, it changes the tint of the image to the accent color tint, else it changes the tint to light gray
- And also using LifecycleObserver interface, this function stops listening to changes when the Activity is Destroyed
- Notice that if the Annotation is not present, nothing will happen
4- Use the Annotation Scanner into an ActivityLifecycleCallbacks implementer
Now we need to execute our code in the Annotated Activity's onCreate(), so we should invoke it in an ActivityLifecycleCallback's Implemnter
public class AnnotationScanners implements Application.ActivityLifecycleCallbacks { private static final NetworkStateScanner NETWORK_STATE_SCANNER = new NetworkStateScanner(); @Override public void onActivityCreated(Activity activity, Bundle savedInstanceState) { NETWORK_STATE_SCANNER.accept(activity); } ... }
And Register it in our Application's onCreate()
class MyApplication extends Application { @Override public void onCreate() { super.onCreate(); ... registerActivityLifecycleCallbacks(new AnnotationScanners()); } }
5- You are good to go
Now for Any Activity that you want to make it Network aware, you need to put the annotation with a valid ImageView ID, and that's it
@NetworkStateListenerImage(R.id.mainToolbarImageView) class MainActivity extends AppCompatActivity { ... } @NetworkStateListenerImage(R.id.settingsToolbarImageView) class SettingsActivity extends AppCompatActivity { ... }
No Base classes, no boiler plate code, no interfaces, just putting a single line annotation can execute as complex logic as you want
I did not talk about FragmentLifecycleCallbacks, they are very similar to ActivityLifecycleCallbacks, but they are registered in the Fragment Manager, not in the Application class, so to register them you need to do this in the ActivityLifecycleCallbacks implementer and do it in the onActivityCreated() method, and invoke the following line :
activity.getFragmentManager().registerFragmentLifecycleCallbacks(...);
That way, every Activity will register the FragmentLifecycleCallbacks implementer in it's Fragment Manager, so all the fragments will be affected by your implementer
Staff Software Engineer - Mobile at Talabat | Delivery Hero
4 年Great article Ahmed Adel Ismail . We can experiment with newrelic screen name. I have a question, examples in the article mention compile-time values like activity title, image view id. What about dynamic values like user id, selected list item position,.. etc? Will annotation help in that scenario or there is a technical limitation?
Mobile Software Engineer | Open Source Contributor
5 年Great article ??