鲁棒性、可读的android架构(II)
Note: This blog post assume you already read the first part.
I received a lot of comments and feedbacks about it, mostly thanks to theAndroid 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 wepivoted from Candyshop to Midpic. It gave me the perfect opportunity to turn this architecture into a library: AsyncService.
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 callinject(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
.
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:
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 theUncaughtExceptionHandler
. For those who never heard of it, it's where all your uncaught exception go. Crash report tools likeACRA
,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!