Не так давно закончили разработку приложения, в котором пришлось обрабатывать одинаковые действия (actions) в различных местах приложения. Казалось бы, стандартная ситуация, и, как всегда, разработчики — ленивые, клиент — сделайте все вчера, у нашего клиента есть свой клиент, и ему — всё нужно позавчера. А значит, сделать все нужно просто, красиво и, главное — меньше лишних строк кода.
Особенностью проекта было то, что большая часть экранов содержала одинаковые или очень похожие сущности и позволяла пользователям выполнять над ними ряд одинаковых действий. О том, как мы решили данную задачу, и будет рассказано в этой статье.
Исходные данные
Если вы знаете, что такое Instagram, то, добавив слова «закрытый, корпоративный» сможете четко представить суть нашего проекта. Если вкратце, то это закрытая социальная сеть с возможностью публиковать короткие заметки с фото или видео контентом, просматривать их, комментировать, «лайкать», добавлять авторов в список избранных и отдельно отслеживать их публикации, искать друзей, искать публикации и т.п. Каждая публикация может принадлежать разным регионам и быть в одной или нескольких категориях.
Итого имеем несколько экранов («скринов»): список публикаций для конкретной категории, детализация публикации, список комментариев, список отслеживаемых пользователей (following), их публикации, список тех, кто следит уже за тобой (followers), форму профиля пользователей с кучей рейтингов, счетчиков, аватаркой и т.п.
Почти на каждом скрине есть аватарки пользователей (либо собственно список пользователей, либо публикация с аватаркой и именем автора, либо комментарий конкретного пользователя). Также, на разных скринах есть кнопки Follow/Unfollow, Like/Dislike, теги категорий и прочие.
Кликнув по аватарке нужно открыть профиль пользователя. Кликнув по статье — открыть ее детализацию. Кликнув по иконке «Like» или «Follow» — ну вы поняли…
Применяемый подход
Подходя к делу привычным способом, дело решается не так и сложно. К примеру, по клику открываем профиль пользователя:
findViewById(R.id.some_id).setOnClickListener((v) -> openUserProfile());
void openUserProfile(){
Intent = new Intent(this, ProfileActivity.class);
intent.putExtra(USER_ID, userId);
startActivity();
}
В случае «Like» или «Follow» уже посложнее, нужно делать запрос на сервер, ждать его ответа и, в зависимости от результата, менять представление кнопки в конкретном элементе списка. В принципе, тоже ничего сложного. Но, поскольку и «Like» и «Follow» могут быть во многих местах приложения, для облегчения повторного использования логично делегировать их обработку отдельным классам, что в итоге и было сделано. Такие обработчики действий назвали «Action» (FollowUserAction, LikeAction, OpenProfileAction, ...). Все action, обрабатываемые на конкретном скрине собираются и запускаются через некий менеджер ActionHandler. В итоге, открытие того-же экрана профиля пользователя будет выглядеть таким образом:
mActionHandler = new ActionHandler.Builder()
.addAction(ActionType.PROFILE, new OpenProfileAction())
.build();
findViewById(R.id.some_id).setOnClickListener((v) -> openUserProfile());
...
void openUserProfile(){
mActionHandler.fireAction(ActionType.PROFILE, user);
}
Хорошо, идем дальше. Чтобы еще уменьшить количество лишнего кода — подключаем поддержку Android Data Binding и в коде бизнес логики оставляем только ActionHandler. То, какое действие выполнять и по клику на какую кнопку пропишем в самом layout файле. Например, для экрана со списком публикаций, имеем:
mBinding = DataBindingUtil.inflate(..., R.layout.item_post);
mBinding.setActionHandler(getActionHandler());
mBinding.setPost(getPost());
void initActionHandler() {
mActionHandler = new ActionHandler.Builder()
.addAction(ActionType.PROFILE, new OpenProfileAction())
.addAction(ActionType.COMMENTS, new OpenCommentsAction())
.addAction(ActionType.POST_DETAILS, new OpenPostDetailsAction())
.addAction(ActionType.FOLLOW, new FollowUserAction())
.addAction(ActionType.LIKE, new LikeAction())
.addAction(ActionType.MENU, new CompositeAction((TitleProvider)(post) -> post.getTitle(),
new ActionItem(ActionType.SOME_ACTION_1, new SomeMenuAction(), "Title 1"),
new ActionItem(ActionType.SOME_ACTION_2, new SomeMenuAction(), "Title 2"),
new ActionItem(ActionType.SOME_ACTION_3, new SomeMenuAction(), "Title 3"),
.build();
}
item_post.xml
<layout>
<data>
<variable name="actionHandler" type="com.example.handler.ActionHandler" />
<variable name="post" type="com.example.model.Post" />
</data>
<FrameLayout>
<ImageView
android:id="@+id/avatar"
...
app:actionHandler="@{actionHandler}"
app:actionType="@{ActionType.PROFILE}"
app:model="@{post}" />
<FrameLayout
android:id="@+id/post_container"
...
app:actionHandler="@{actionHandler}"
app:actionType="@{ActionType.POST_DETAILS}"
app:actionTypeLongClick="@{ActionType.MENU}"
app:model="@{post}">
...
</FrameLayout>
<TextView
android:id="@+id/comments"
...
app:actionHandler="@{actionHandler}"
app:actionType="@{ActionType.COMMENTS}"
app:model="@{post}" />
<ImageView
android:id="@+id/like"
...
app:actionHandler="@{actionHandler}"
app:actionType="@{ActionType.LIKE}"
app:model="@{post}" />
...
</FrameLayout>
</layout>
Теперь, если, например, на каком-то экране нужно будет заблокировать открытие профиля по клику, или добавить/убрать пункт меню, отображаемый по длинному нажатию (actionTypeLongClick="@{ActionType.MENU}), все что нужно сделать — добавить или удалить в одном месте соответствующую Action.
Использование Data Binding также позволяет из самой Action поменять модель (например, добавить «лайк») и сразу-же увидеть изменения на экране без каких-либо дополнительных коллбеков, вызывающих notifyDataSetChanged() для RecyclerView.
Вот примеры некоторых action:
public class OpenProfileAction extends IntentAction<IUserHolder> {
@Override
public boolean isModelAccepted(Object model) {
return model instanceof IUserHolder;
}
@Nullable @Override
public Intent getIntent(@Nullable View view, Context context, String actionType, IUserHolder model) {
return ProfileActivity.getIntent(context, model);
}
@Override
protected ActivityOptionsCompat prepareTransition(Context context, View view, Intent intent) {
// Prepare shared element transition
Activity activity = getActivityFromContext(context);
if (activity == null) return null;
return ActivityOptionsCompat
.makeSceneTransitionAnimation(activity, view, ProfileActivity.TRANSITION_NAME);
}
}
public class LikeAction extends RequestAction<ModelResponse<Like>, Post> {
@Override
public boolean isModelAccepted(Object model) {
return model instanceof Post;
}
@Override
protected Observable<ModelResponse<Like>> getRequest(Context context, RestApiClient apiClient, Post model) {
return apiClient.setLike(model.postId, !model.isLiked);
}
protected void onSuccess(Context context, View view, String actionType, Post oldModel, ModelResponse<Like> response) {
oldModel.setLiked(response.getModel().isLiked); // automatically rebind icon for "like" button
}
}
Итоги
Таким образом, получилось организовать очень гибкую и, надеюсь, довольно понятную логику обработки действий в приложении.
В итоге идея развивалась, прошла еще два других проекта, и, в конце-концов, воплотилась в небольшую библиотеку — action-handler.
Сейчас в ней есть заготовки для таких часто встречаемых action:
- .IntentAction — для запуска Activity, старта Service, или отправки Broadcast;
- .DialogAction — для любых действий, требующих сперва показать диалог для подтверждения;
- .RequestAction — для выполнения запросов в интернет;
- .CompositeAction — Составная, может содержать другие action и отображать их в виде меню/списка.
Ссылки
Библиотека с простенькими примерами — https://github.com/drstranges/ActionHandler
Автор: d_romka