Как стать автором
Обновить
525.89
YADRO
Тут про железо и инженерную культуру

Обновляем AOSP-приложение «Контакты», или Чем обернулось «приключение на 20 минут» с legacy-кодом

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров1.8K

Привет, Хабр! Меня зовут Дмитрий, я старший инженер-программист в департаменте разработки мобильных приложений YADRO. В этой статье я расскажу о нашем опыте работы со стандартным AOSP-приложением «Контакты». Это базовая версия телефонной книги, разработанная в рамках Android Open Source Project (AOSP).

AOSP-приложение «Контакты» предлагает минималистичный интерфейс для управления контактами и звонками. Оно реализовано на стеке Java + XML, и его архитектура очень далека от подхода clean architecture.

Приложение было частью платформы kvadraOS, но имело крайне «уставший» вид. Дизайнеры подготовили более современные макеты, которые гармоничнее вписывались в стилистику ОС. На этапе ревью дизайна показалось, что можно улучшить UI, не трогая бизнес-логику: надо всего лишь заменить устаревший XML на современный Compose в экране редактирования контакта, сохранив изначальную кодовую базу на Java. Еще одним аргументом в пользу перехода стало то, что наша библиотека базовых UI элементов уже была реализована на Compose. Так началась увлекательная история.

Старый и новый дизайн приложения
Старый и новый дизайн приложения

ContentProvider и RawContactDelta: как устроено хранение контактов в AOSP-приложении и почему это стало проблемой

В основе приложения «Контакты» лежит ContentProvider, в котором для каждого поля контакта объявлен свой MIME-тип. Каждый контакт можно сохранить, например, в Kvadra- или Яндекс-аккаунте пользователя, за это отвечает класс AccountManager. По умолчанию же контакты сохраняются в локальном аккаунте. 

В базовой реализации контакт хранится как набор MIME-типов, соответствующих полям контакта. MIME-тип определяет тип данных. Возможные типы данных для контакта можно посмотреть в android.provider.ContactsContract.CommonDataKinds. Контакты хранятся в базе данных SQLite, доступ к которой осуществляется через ContentProvider.

Имя

MIME-тип

Значение

Аватар

Photo.CONTENT_ITEM_TYPE

vnd.android.cursor.item/photo

Номер телефона

Phone.CONTENT_ITEM_TYPE

vnd.android.cursor.item/phone_v2

Имя

StructuredName.CONTENT_ITEM_TYPE

vnd.android.cursor.item/name

Никнейм

Nickname.CONTENT_ITEM_TYPE

vnd.android.cursor.item/nickname

E-mail

Email.CONTENT_ITEM_TYPE

vnd.android.cursor.item/email_v2

Место работы

Organization.CONTENT_ITEM_TYPE

vnd.android.cursor.item/organization

Адрес

StructuredPostal.CONTENT_ITEM_TYPE

vnd.android.cursor.item/postal-address_v2

Веб-сайт

Website.CONTENT_ITEM_TYPE

vnd.android.cursor.item/website

Заметка

Note.CONTENT_ITEM_TYPE

vnd.android.cursor.item/note

В стандартной реализации поля контакта находятся в классе RawContactDelta. Он включает множество полей и хранит два контакта — исходный и контакт с измененными полями пользователем. Поля при этом хранятся как MIME-типы.

Опишем исходную архитектуру целиком. У нас есть map, где ключ — это строка, а значение — это поле, что хранит класс ValuesDelta. В классе хранятся поля «до» и «после» — ContentValues mBefore и ContentValues mAfter. А уже внутри класса лежит map ArrayMap<String, Object> mMap: она хранит и MIME-тип, и значение, которое нужно скастить к нужному типу. Звучит довольно запутанно. 

Экран редактирования: костыли наступают

Реализация RawContactDelta нас не устроила из-за своей сложности. Работа над логикой получения полей контакта не входила в наши изначальные планы, но этим пришлось заняться. В итоге мы написали свою бизнес-логику: в ней все поля контакта собираются в единый data class и потом отображаются в UI.

Синие — блоки стандартного приложения, зеленые — блоки, добавленные нами
Синие — блоки стандартного приложения, зеленые — блоки, добавленные нами

В стандартном приложении проблема решалась сохранением всего в Bundle. Мы же решили добавить ViewModel специально для нашего экрана. Получился мини-костыль:

public class ContactEditorViewModel extends ViewModel {
    private Contact mContact;

    public void setContact(Contact contact) {
        mContact = contact;
    }

    public Contact getContact() {
        return mContact;
    }
}


Далее мы перешли к логике работы с полями на экранах редактирования/создания контакта. Здесь поджидал новый сюрприз — необходимость кастомизации добавления/удаления/обновления текущего поля. 

Логика обновления поля контакта. Квадрат — действие, ромб — условие
Логика обновления поля контакта. Квадрат — действие, ромб — условие

После первого этапа на код-ревью попало 3000+ строк кода — заметно больше ожидаемого. Уже здесь начали вскрываться проблемы. После изменения стейта (смена темы/языка/поворота экрана) все заполненные поля на экране очищались и информация пользователя не сохранялась. Другая проблема: пользователь мог сохранить контакт с пустыми полями. Проблем добавляли и рекомпозиции от Compose.

Мы решили проблему с восстановлением значений в полях после смены стейта экрана, но шедевром это не назовешь:

@Override
protected void onResume() {
    super.onResume();
    // Update compose elements after rotate screen
    if (sContactEditorViewModel.getContact() != null) {
        composeContactEditorScreen(mActivity, sContactEditorViewModel.getContact());
    }
}

Зато после внедрения такой логики ViewModel экрана стала настолько живучей, что при создании нового контакта она доставала ID сохраненного контакта, который ранее был на редактировании, и подставляла его значения в поля нового.

Безрадостно пишем новый костыль внутри Activity:

public void onContactLoaded(Contact contact) {
    if (mActionBarTitleResId == R.string.contact_editor_title_new_contact) {
        sContactEditorViewModel.setContact(null);
        setPhotoUri(null);
        mPhotoUri = null;
        composeContactEditorScreen(mActivity, null);
    } else {
        sContactEditorViewModel.setContact(contact);
        try {
            mPhotoUri = Uri.parse(contact.getPhotoUri());
        } catch (Exception ex) {
            mPhotoUri = null;
        }
        setPhotoUri(mPhotoUri);
        composeContactEditorScreen(mActivity, contact);
    }
}

Здесь мы добавили условие. Если заголовок экрана — «Создать новый контакт», то нужно обнулить все параметры. В противном случае — передать сохраненный контакт и его аватар. Профессиональную гордость это условие тоже не вызывает, но пришлось прибегнуть к такой конструкции, чтобы не править уже существующий код, а продолжать по принципу добавления нового без изменения старого.

Постепенно код начал обрастать непонятными конструкциями. В итоге после правки десятка багов и решения проблем с пересозданием экрана нам удалось получить приложение, которое работало довольно стабильно. Но разбираться в коде стало очень тяжело, нарастало ощущение, что мы пилим велосипед. Тогда мы еще верили, что вариант рабочий и осталось совсем чуть-чуть до заветного успеха.

Кастомные типы полей: как аналитики добавили нам работы

У многих полей контакта есть типы. Например, у номеров телефонов или адресов электронной почты по умолчанию есть типы «рабочий» и «домашний». Аналитики сказали, что нужно реализовать дополнительные типы и дать пользователю возможность создавать свои. Звучит легко, но на деле оказалось совсем непросто.

Чтобы заменить стандартные типы полей, нужно добавить новую базу данных с доступными типами полей, а также реализовать отображение и возможность выбора поля. Для этого пришлось во многом переписать код редактирования полей контакта, где и без этого логика была непростой. Теперь стало совсем сложно: при добавлении или обновлении поля в contentProvider нужно указывать тип поля, так что мы начали указывать «custom».

После добавления кастомных типов приложение потеряло стабильность, появились кейсы, в которых кастомные типы работали неправильно. Один из них приводил к крашу: база данных не успевала создаваться при открытии интента на редактирование контакта.

Изначальный путь пользователя — это открыть приложение и увидеть список контактов с аватаркой, именем и номером телефона. Пока этот список подгружался на экране, база данных обычно успевала создаться. Но мы не учли, что при открытии интента на редактирование контакта из другого приложения сразу появляется экран редактирования — в этом случае база данных не успевала инициализироваться.

Создание схемы БД и инициализация стандартными значениями происходила в отдельном потоке, так что у нас получилась небольшая гонка потоков. UI готов отобразить экран и идет в БД за данными, но БД не успевает инициализироваться, так что запрос приводил к критической ошибке. Баг воспроизводился при попытке обратиться к БД только при первом холодном запуске на устройстве или после очистки данных, так как здесь приложению приходится заново создавать схему базы данных.

Пришлось опять добавлять костыль: в отдельной корутине ожидать инициализации БД и только потом начинать рисовать экран.

private val scope by lazy { CoroutineScope(dispatcher + SupervisorJob()) }
private val scopeUI by lazy { CoroutineScope(dispatcherUI + SupervisorJob()) }
private val dispatcher: CoroutineDispatcher = Dispatchers.Default
private val dispatcherUI: CoroutineDispatcher = Dispatchers.Main

internal fun composeContactEditorScreen(
    activity: ContactEditorActivity,
    contactData: Contact?,
) {
    scopeUI.launch {
        scope.launch {
            // Wait while DB will be created,
            // if user list is empty and user clicked 'create new contact'
            initDB(activity.applicationContext)
        }.join()
        scope.launch {
            if (contactData != null) {
                rawContactId = contactData.nameRawContactId
                currentContact = parseContact(
                    contactData = contactData,
                    context = activity.applicationContext,
                )
            } else {
                currentContact = getAvailableTypes(activity.applicationContext)
                rawContactId = null
            }
        }.join()
        activity.findViewById<ComposeView>(R.id.compose_contact_editor).setContent {
            // AppTheme + Compose code
        }
    }
}

После очередного костыля скорость загрузки экрана значительно снизилась, особенно в случае первого запуска. Это было логично: у нас выполняется запрос к получению контакта от старого кода, плюс наш код выполняет такую же логику еще и со своей БД. Загрузка экрана редактирования контакта при первом запуске и так занимала 0,7–0,8 секунды, а теперь — целых 1,5–2.

Со всеми нашими надстройками порядок работы получился следующим:

  1. Нажатие.

  2. Отображение белого экрана.

  3. Инициализация базы данных.

  4. Загрузка данных контакта из java-кода.

  5. Загрузка тех же данных контакта из kotlin-кода.

  6. Отображение UI.

Это 7.
Это 7.

Бонус: скругление уголков в диалоге

Реализовать диалоги мы также решили с использованием ComposeView. Планировали вот такую схему отображения:

Думаете, проблем быть не может? Не тут-то было. При отрисовке диалога WindowManager рисовал подложку, поверх которой появлялся диалог. При попытке скруглить углы белый фон вылезал за пределы диалога.

Для решения проблемы пришлось использовать более сложный путь отрисовки. Мы дополнительно создали Fragment, который внутри себя создает Dialog с указанием стиля из XML и версткой самого диалога через ComposeView:

 Вызов диалога из первого блока ComposeView
 Вызов диалога из первого блока ComposeView

Метод создания диалога внутри фрагмента PhotoSourceDialogFragment:

ButtonSecondary(
  onClick = {
      PhotoSourceDialogFragment.show(
          activity,
          if (avatar.value == null) {
              1
          } else {
              0
          },
      )
  },
  text = stringResource(id = R.string.editor_add_photo_content_description),
  iconResId = ComRes.drawable.ic_add,
  enabled = true,
)

Для создания диалога использовался AlertDialog:

@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
    // Get the available options for changing the photo
    final int photoMode = getArguments().getInt(ARG_PHOTO_MODE);

    // Build the AlertDialog
    final AlertDialog.Builder builder = new AlertDialog.Builder(
            requireContext(),
            R.style.DatePickerDialog);
    View view = View.inflate(getActivity(), R.layout.dialog_photo_source, null);
    final Listener listener = (Listener) getActivity();
    if (listener != null) {
        attachPhotoSourceDialogComposeView(
                view,
                photoMode == 0,
                () -> {
                    listener.onTakePhotoChosen();
                    dismiss();
                    return Unit.INSTANCE;
                },
                () -> {
                    listener.onPickFromGalleryChosen();
                    dismiss();
                    return Unit.INSTANCE;
                },
                () -> {
                    if (photoMode == 0) {
                        listener.onRemovePictureChosen();
                        dismiss();
                    } else {
                        dismiss();
                    }
                    return Unit.INSTANCE;
                });
    }
    builder.setView(view);
    Dialog dialog = builder.create();
    dialog.getWindow().setBackgroundDrawable(
            getActivity().getResources().getDrawable(
                    R.drawable.dialog_background, null));
    return dialog;
}

Победа: диалог отобразился в точности так, как мы хотели.

Дело за малым: добавить диалог создания новой группы для контактов. Повторяем описанные выше действия за исключением смены Compose-верстки. К диалогу добавляется поле для ввода TextField.

Сам диалог выглядит так:

В очередной раз можно задаться вопросом: «Ну хоть что-нибудь тут может пойти не так?». Может. При нажатии на поле ввода клавиатура просто не отображается. Оказалось, это известная проблема, уже описанная на IssueTracker (Regression: Keyboard not shown when in DialogFragment).

Это была последняя капля. Мы отказались от нашей затеи.

Итоги

Лучше было написать приложение с нуля, чем вносить столько изменений в legacy-код. Мы дважды раздумывали отправить «Контакты» в релиз и дважды решали этого не делать. На третий раз решили уже писать новое приложение под новый дизайн, который все равно не ложился на старую кодовую базу. Сейчас пишем «Контакты» на стеке Kotlin + Jetpack Compose с чистой архитектурой и многомодульностью, что точно упрощает и разработку, и дальнейшую поддержку.

Сегодня многие компании начинают свою разработку на базе AOSP. Стоит заранее оценить, насколько ваш новый дизайн отличается от старого, какие требования и фичи предполагается добавлять в приложение и уже затем решать — дорабатывать текущее или создавать новое. У нас, например, в первом нашем случае визуальных отличий было немного, а во втором они были критичные. Что можем сказать точно: использовать два стека в рамках одних классов — плохая идея.

Надеюсь, наша история поможет оценить, стоит ли трогать legacy-код или лучше начать разработку заново. Порой кажется, что «раз, два и готово», но с объемными приложениями все не так просто.

Теги:
Хабы:
+18
Комментарии0

Публикации

Информация

Сайт
yadro.com
Дата регистрации
Дата основания
Численность
5 001–10 000 человек
Местоположение
Россия
Представитель
Ульяна Соловьева