Android View Controllers - Revisited

I Changed My Mind (Because I Learned)

To be a good developer, it's important to regularly question your conventions. This week, that questioning has caused me to revise my approach to Android views and view controllers. And although I'll have to rewrite some of my code, I believe the final solution will be better, and it will more closely parallel my iOS development techniques.

To be fair, my initial approach wasn't bad; the code still worked. However, as my code got more complex, I could envision a time where it would be much more difficult to incorporate changes and fix bugs. So, before the code became burdensome, I made the decision to change things. This type of change fits under the category of "code refactoring": https://en.wikipedia.org/wiki/Code_refactoring.

So what's the topic of concern? So far, I've been calling my Android views view controllers when they've really just been views. In fact, they've been inheriting from views, so I've actually been including business logic in the view layer. This is not a horrible thing, but it won't win any ACM awards.

While reviewing my current approach, I was reminded of another software design principle: Composition over inheritance: http://en.wikipedia.org/wiki/Composition_over_inheritance. Basically, composition gives the designer / developer more flexibility, and helps keep the business domain cleaner. (If you're interested in the pros and cons, I am particularly fond of the following discussion on StackOverflow: http://stackoverflow.com/questions/49002/prefer-composition-over-inheritance.

The Original Code

Previously, my Android view controllers were implemented as follows:

First, create a custom class that inherits from LinearLayout:

HomeViewController.java:

  1. public class HomeViewController extends LinearLayout implements ViewLifecycle, EventNotifier {
  2.  
  3. private SparseIntArray events;
  4.  
  5. public HomeViewController(Context context, AttributeSet attrs) {
  6. super(context, attrs);
  7.  
  8. // Map each button ID to the associated event it triggers
  9. events = new SparseIntArray();
  10. events.put(R.id.btnPlanTrip, EventManager.EM_NAV_PLAN_TRIP);
  11. events.put(R.id.btnLogTrip, EventManager.EM_NAV_LOG_TRIP);
  12. events.put(R.id.btnCaptureCatch, EventManager.EM_NAV_CAPTURE_CATCH);
  13. events.put(R.id.btnLocalSpots, EventManager.EM_NAV_LOCAL_SPOTS_MAP);
  14. }
  15.  
  16. @Override
  17. protected void onFinishInflate() {
  18. super.onFinishInflate();
  19.  
  20. OnClickListener ocl = new OnClickListener() {
  21. @Override
  22. public void onClick(View v) {
  23. int iEvent = events.get(v.getId());
  24. if ((eventManager != null) && (iEvent != 0)) {
  25. eventManager.handleEvent(iEvent, null);
  26. }
  27. }
  28. };
  29.  
  30. // Set up the actions for each button
  31. ((Button)findViewById(R.id.btnPlanTrip)).setOnClickListener(ocl);
  32. ((Button)findViewById(R.id.btnLogTrip)).setOnClickListener(ocl);
  33. ((Button)findViewById(R.id.btnCaptureCatch)).setOnClickListener(ocl);
  34. ((Button)findViewById(R.id.btnLocalSpots)).setOnClickListener(ocl);
  35.  
  36. }
  37.  
  38. private EventManager eventManager = null;
  39. @Override
  40. public void setEventManager(EventManager eventManager) {
  41. this.eventManager = eventManager;
  42. }
  43.  
  44. @Override
  45. public void viewWillAppear() { }
  46.  
  47. @Override
  48. public void viewWillDisappear() { }
  49.  
  50. @Override
  51. public View getView() { return this; }
  52.  
  53. }

Next, create a layout which uses the custom view at the top level element:

home_vc.xml:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <com.crowleyworks.futilefishing.view.HomeViewController xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:layout_width="match_parent"
  4. android:layout_height="match_parent"
  5. android:background="@drawable/futilefishing"
  6. android:orientation="vertical" >
  7.  
  8. <Button
  9. android:id="@+id/btnPlanTrip"
  10. android:layout_marginTop="100dp"
  11. android:layout_marginLeft="30dp"
  12. android:layout_width="160dp"
  13. android:layout_height="40dp"
  14. android:background="@drawable/button3"
  15. android:text="@string/sBtnPlanTrip"/>
  16.  
  17. <Button
  18. android:id="@+id/btnLogTrip"
  19. android:layout_marginTop="10dp"
  20. android:layout_marginLeft="30dp"
  21. android:layout_width="160dp"
  22. android:layout_height="40dp"
  23. android:background="@drawable/button3"
  24. android:text="@string/sBtnLogTrip" />
  25.  
  26. <Button
  27. android:id="@+id/btnCaptureCatch"
  28. android:layout_marginTop="10dp"
  29. android:layout_marginLeft="30dp"
  30. android:layout_width="160dp"
  31. android:layout_height="40dp"
  32. android:background="@drawable/button3"
  33. android:text="@string/sBtnCaptureCatch" />
  34.  
  35. <Button
  36. android:id="@+id/btnLocalSpots"
  37. android:layout_marginTop="10dp"
  38. android:layout_marginLeft="30dp"
  39. android:layout_width="160dp"
  40. android:layout_height="40dp"
  41. android:background="@drawable/button3"
  42. android:text="@string/sBtnLocalSpots" />
  43.  
  44. </com.crowleyworks.futilefishing.view.HomeViewController>

Lastly, when initializing the (so-called) view controller, inflate the view, and set the event manager:

Main.java:

  1. //...
  2. homeVC = (HomeViewController)getLayoutInflater().inflate(R.layout.home_vc, null);
  3. homeVC.setEventManager(this);
  4. //...

Like I said, there's nothing *broken* about the code, but it could be better. Some of my concerns include:

  • In HomeViewController.java, the last three methods are required - even if they're not needed. This is because they're part of an interface, instead as part of a superclass. This concern helps reinforce the feeling that I'm inheriting from the wrong superclass.
  • Lines 16 - 36: I'm overriding view-specific methods, so I'm further intertwining the view and the view controller.
  • I've increased my dependencies when testing, and it will therefore make it more difficult to automate my tests.
  • The layout file is named home_vc.xml, when it's actually just a view. Even more, the custom class - HomeViewController - adds nothing to the layout beyond the base LinearLayout superclass.
  • The main activity - Main.java - contains code for inflating views. This seems like a separation of duties problem: The view controller should probably determine both how (and when) the view is inflated.

All of these items are cause for concern. However, I feel there's a far more important issue at hand: The coding techniques for Android don't match the coding techniques I'm using with my iOS code. It's okay to have the techniques diverge if there's a good reason, but in this case, there's no good reason at all.

A New (and Improved) Approach

After spending considerable time thinking about it, I changed my design. Some of the highlights include:

  1. I've created a new superclass named ViewController. This class supports basic view controller functionality, and alleviates the need to include "boilerplate" code in the subclasses. (It also provides naming consistency between the two platforms.)
  2. Instead of inheriting from a view, I altered the code to manage the view via composition. This is the same technique that the iOS ViewControllers use.
  3. Layouts are now "pure" views - they use the standard views (such as LinearLayout), and they aren't dependent upon custom code.

To demonstrate the changes, I'll provide the updated code below:

The new superclass - ViewController.java:

  1. package com.crowleyworks.futilefishing.view;
  2.  
  3. import com.crowleyworks.futilefishing.EventManager;
  4. import com.crowleyworks.futilefishing.EventNotifier;
  5.  
  6. import android.content.Context;
  7. import android.view.LayoutInflater;
  8. import android.view.View;
  9.  
  10. public class ViewController implements EventNotifier {
  11. protected View view;
  12. protected int layoutId;
  13. protected Context context;
  14.  
  15. public ViewController(Context context, int layoutId) {
  16. this.layoutId = layoutId;
  17. this.context = context;
  18. this.view = null;
  19. }
  20.  
  21. protected void inflateView() {
  22. LayoutInflater inflater = LayoutInflater.from(context);
  23. view = inflater.inflate(layoutId, null);
  24. }
  25.  
  26. protected void viewDidLoad() {
  27.  
  28. }
  29.  
  30. public void viewWillAppear() {
  31. // Don't inflate a view until it's needed for the first time
  32. if (view == null) {
  33. inflateView();
  34. viewDidLoad();
  35. }
  36. }
  37.  
  38. public void viewWillDisappear() {
  39.  
  40. }
  41.  
  42. public View getView() {
  43. return view;
  44. }
  45.  
  46. protected EventManager eventManager;
  47. @Override
  48. public void setEventManager(EventManager eventManager) {
  49. this.eventManager = eventManager;
  50. }
  51.  
  52. }

Comments:

  • Line 10: The ViewController class implements EventNotifier so that it's not necessary to write redundant code in each subclass. This is a slight deviation from iOS, but well worth the difference.
  • Lines 15 - 19: The constructor records the ID of the layout, and also saves a reference to the application context.
  • Lines 21 - 24: A helper function to inflate the view controller's view.
  • Lines 30 - 36: If the view is about to appear, make sure it's been inflated. Subclasses must call this method before continuing with a subclassed version of viewWillAppear().

The updated layout - home_view.xml:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:layout_width="match_parent"
  4. android:layout_height="match_parent"
  5. android:background="@drawable/futilefishing"
  6. android:orientation="vertical" >
  7.  
  8. <Button
  9. android:id="@+id/btnPlanTrip"
  10. android:layout_marginTop="100dp"
  11. android:layout_marginLeft="30dp"
  12. android:layout_width="160dp"
  13. android:layout_height="40dp"
  14. android:background="@drawable/button3"
  15. android:text="@string/sBtnPlanTrip"/>
  16.  
  17. <Button
  18. android:id="@+id/btnLogTrip"
  19. android:layout_marginTop="10dp"
  20. android:layout_marginLeft="30dp"
  21. android:layout_width="160dp"
  22. android:layout_height="40dp"
  23. android:background="@drawable/button3"
  24. android:text="@string/sBtnLogTrip" />
  25.  
  26. <Button
  27. android:id="@+id/btnCaptureCatch"
  28. android:layout_marginTop="10dp"
  29. android:layout_marginLeft="30dp"
  30. android:layout_width="160dp"
  31. android:layout_height="40dp"
  32. android:background="@drawable/button3"
  33. android:text="@string/sBtnCaptureCatch" />
  34.  
  35. <Button
  36. android:id="@+id/btnLocalSpots"
  37. android:layout_marginTop="10dp"
  38. android:layout_marginLeft="30dp"
  39. android:layout_width="160dp"
  40. android:layout_height="40dp"
  41. android:background="@drawable/button3"
  42. android:text="@string/sBtnLocalSpots" />
  43.  
  44. </LinearLayout>

The layout now has LinearLayout as the top level view; it's no longer necessary to have a custom class at the top level.

The revised view controller - HomeViewController.java

  1. package com.crowleyworks.futilefishing.view;
  2.  
  3. import com.crowleyworks.futilefishing.EventManager;
  4. import com.crowleyworks.futilefishing.R;
  5.  
  6. import android.content.Context;
  7. import android.util.SparseIntArray;
  8. import android.view.View;
  9. import android.view.View.OnClickListener;
  10. import android.widget.Button;
  11.  
  12. public class HomeViewController extends ViewController {
  13.  
  14. private SparseIntArray events;
  15.  
  16. public HomeViewController(Context context, int layoutId) {
  17. super(context, layoutId);
  18.  
  19. // Map each button ID to the associated event it triggers
  20. events = new SparseIntArray();
  21. events.put(R.id.btnPlanTrip, EventManager.EM_NAV_PLAN_TRIP);
  22. events.put(R.id.btnLogTrip, EventManager.EM_NAV_LOG_TRIP);
  23. events.put(R.id.btnCaptureCatch, EventManager.EM_NAV_CAPTURE_CATCH);
  24. events.put(R.id.btnLocalSpots, EventManager.EM_NAV_LOCAL_SPOTS_MAP);
  25. }
  26.  
  27. @Override
  28. protected void viewDidLoad() {
  29. super.viewDidLoad();
  30. OnClickListener ocl = new OnClickListener() {
  31. @Override
  32. public void onClick(View v) {
  33. int iEvent = events.get(v.getId());
  34. if ((eventManager != null) && (iEvent != 0)) {
  35. eventManager.handleEvent(iEvent, null);
  36. }
  37. }
  38. };
  39. // Set up the actions for each button
  40. ((Button)view.findViewById(R.id.btnPlanTrip)).setOnClickListener(ocl);
  41. ((Button)view.findViewById(R.id.btnLogTrip)).setOnClickListener(ocl);
  42. ((Button)view.findViewById(R.id.btnCaptureCatch)).setOnClickListener(ocl);
  43. ((Button)view.findViewById(R.id.btnLocalSpots)).setOnClickListener(ocl);
  44.  
  45. }
  46.  
  47. }

The view controller is now much cleaner: Instead of accessing all of the view's details in onFinishInflate(), it's now properly located in viewDidLoad. This removes the dependency on the view specifics, and also makes the code far more similar to the approach in iOS.

Instantiation of the view controller in Main.java:

  1. // homeVC = (HomeViewController)getLayoutInflater().inflate(R.layout.home_vc, null);
  2. homeVC = new HomeViewController(this, R.layout.home_view);
  3. homeVC.setEventManager(this);

There's not really a significant change here, other that removing the dependency on prematurely inflating views. And, since inflating a view can be resource intensive, the new approach defers the loading until it's required.

Summary

Making the above changes took time and effort, but over time, I believe it will pay off significantly. In fact, it's important to take regular breaks to ensure that design decisions will hold up over time. It's also a good thing to critique prior development and be honest about what does (and doesn't) work.

It would have been easy to ignore these upgrades. In fact, as I previously mentioned, there's nothing completely "broken" with my initial approach. However, if this code base grows to be thousands and thousands of lines of code, I'll end up spending significantly more time maintaining it. So in this case, a small investment now will pay off down the road.

To keep the coding consistent, I plan to go back and revise prior articles. (There's no need to demonstrate a lesser technique, only to improve upon it later.) However, I plan to keep the revision in the appendix, so that I can highlight the importance of refactoring code over time.