Robust and readable architecture for an Android app, Part 2

Note: This blog post assume you already read the first part.

I received a lot of comments and feedbacks about it, mostly thanks to the Android Weekly community, so thank you all. Some of you noticed some weak spots in the architecture I described, or alternative solutions, others asked for working code, and a lot of people asked for a second part on the topics I evoked at the end.

A few weeks after my first article, which was a vague description of how I started the developement of the Candyhop app, at Redmill Lab we pivoted from Candyshop to Midpic. It gave me the perfect opportunity to turn this architecture into a library: AsyncService.
AsyncService Logo

Basics

Here is a basic AsyncService with a single method which retrieves a user from his name.

@AsyncService
public class UserService {  
   public User getUser(String name) {
      return ...;
   }
}

When you inject this UserService into your activity, you can call getUser() from any thread, it will immediately return null while the execution starts asynchronously. The result is then returned as a message that you can catch using a 1-arg method with appropriate type:

public class MyActivity extends Activity {

   @InjectService public UserService userService;

   public void onCreate(Bundle savedInstanceState){
      // Runs injections and detect @OnMessage callback methods
      AsyncService.inject(this);

      // Starts method asynchronously (never catch the result here, would be null)
      userService.getUser("Joan");
   }

   // Callback method for getUser
   @OnMessage void onUser(User e) {
      // Runs on UI thread.
   }
}

Note: AsyncService is based on compile-time code generation. A MyActivityInjector class is created at compile-time, the only bit of reflection is done to instantiate this injector when you call inject(this). That's an important point on Android because reflection is slow.

As you can see, it looks a lot like what I did in the first part of the article, with the event bus. This time you don't have to register and unregister from the event bus, it's all handled. On the service part you don't have to send the User through the event bus either, only to return it. So the code is already a little bit more concise.

Now, some people agreed that this usage of an event bus had a huge drawback: if you have multiple actors calling getUser at the same time, onUser will get called multiple times, at unexpected moments. AsyncService solve the problem: AsyncService.inject binds the injected service with the callbacks. That means you only receive the messages you asked for on your own instance of UserService.

You only receive the messages you asked for.

If, however, you need to receive messages emitted from anywhere in the app, which is very useful to manage notifications for example, you can use @OnMessage(from=ALL) on your callback.

Cache then call

My main concern when I wrote my first article was not to make the user wait. So I explained how to immediately display something to the user. The concept was illustrated like this:
schema

Quick reminder: the service immediately sends the cached value through the event bus, then it makes the API call and sends the updated result. By using a specific thread (serial=CACHE) to deal with new requests, I make sure the cached result is sent immediately. And by using a specific thread (serial=NETWORK) for network requests, I deal more easily with get-after-post troubles.

Using AndroidAnnotations' @Serial annotation, EventBus and SnappyDB, the code looked like this:

@Background(serial = CACHE)
public void getUser() {  
   postIfPresent(KEY_USER, UserFetchedEvent.class);
   getUserAsync();
}

@Background(serial = NETWORK)
private void getUserAsync() {  
   cacheThenPost(KEY_USER, new UserFetchedEvent(candyshopApi.fetchUser()));
}

That's still quite a lot of boilerplate code to write for each request. AsyncService has an annotation dedicated for this:

@CacheThenCall
public User getUser(){  
   return ...;
}

Smarter, right? And it still works the same way behind the scenes.

If getUser() has arguments, for example getUser(String name), you can use it to specify the key you want to use for caching. This key must be unique at the application level:

@CacheThenCall(key="UserService.getUser({name})")
public User getUser(String name){  
   return ...;
}

The default value of the cache key is "<ClassName>.<MethodName>({arg1}, {arg2},...)", so here we don't actually need to specify it.

Error management

One topic I didn't talk about in the last post is error management. AsyncService has an error handling mechanism:

@AsyncService(errorMapper = StatusCodeMapper.class)
@ErrorManagement({
   @Mapping(on = 0,  send = NoNetworkError.class),
   @Mapping(on = 500,  send = ServerInternalError.class),
   @Mapping(on = 503,  send = ServerMaintenanceError.class), 
   ...})
public class UserService {

   @ErrorManagement({
      @Mapping(on = 404, send = UserNotFoundError.class),
      ...})
   public User getUser(String username){
      return ...;
   }

}

As you can see the @AsyncService defines an ErrorMapper. It's an interface that transforms a Throwable to an int, for example extracting the HTTP error status code of the exception. We'll see this in a minute.

If an exception occurs in getUser(), the ErrorMapper will be used to translate it to an int, then if a match is found in one of the @Mapping annotations, the given class is instantiated and sent as a message.

One basic implementation of the ErrorMapper can be:

public class StatusCodeMapper implements ErrorMapper {

   @Override
   public int mapError(Throwable throwable) {
      if (throwable instanceof HttpStatusCodeException)
           return ((HttpStatusCodeException) throwable).getStatusCode().value();
      if (isConnectivityError(throwable))
         return 0;
      return SKIP;
   }
}

SKIP means that the exception cannot be handled, and will be sent to the UncaughtExceptionHandler. For those who never heard of it, it's where all your uncaught exception go. Crash report tools like ACRA, Crashlytics, etc... replace it to catch and report them.

It's quite annoying to write at first, but you only need to do this once. After that, just declare what error can occur on each method. In Midpic, my ErrorMapper is a little bigger, because our server responses contain things like { code: '1002', message: 'blah blah blah' }, so I read it to map the exception with the 1002 code, this way my code perfectly mirrors the server API.

One last thing about error management. On the getUser(String username) above, 404 is mapped to UserNotFoundError. So, on the Activity side you can catch it this way:

@OnMessage 
void onError(UserNotFoundError e){  
    Toast.makeText(this, "User does not exist.", LENGTH_LONG).show();
}

But you can actually go further by capturing the username that was used to call getUser(String username) where the exception occured. For this, you can define a constructor param in the error message:

public class UserNotFoundError {  
   public UserNotFound(@ThrowerParam("username") String username){
      this.username = username;
   }
   ...

Now your toast can be much more explicit:

@OnMessage 
void onError(UserNotFoundError e){  
    Toast.makeText(this, String.format("User %s does not exist.", e.getUsername()), LENGTH_LONG).show();
}

Conclusion

In this article I introduced some features of AsyncService, like caching and error management. For the full list of features, please read the wiki. As we've seen, it's an improvement in every way compared to the manual combination of AndroidAnnotations, EventBus and SnappyDB. It's currently used in production in the Midpic app and it raised no related bug so far.

AsyncService already served its purpose on Midpic, but I hope it'll help someone else as I'm now releasing it to the community. I'll be glad to receive comments and feedbacks!

comments powered by Disqus