Окей Гугл, гайд по AIDL

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Вступление

Привет хабр!

Совсем недавно у меня появилась необходимость разработать функционал для взаимодействия между двумя приложениями одной компании, и недолго гуглив я открыл для себя мир межпроцессного взаимодействия в андройд - AIDL.

О существовании AIDL я знал довольно давно, однако до этого момента с IPC в андройде не сталкивался, и этот опыт был для меня первым.

В этой статье не будет каких-либо низкоуровневых подробностей о работе IPC в андройд, здесь я расскажу об AIDL с точки зрения обычного разработчика - о трудностях с которыми пришлось столкнуться и об их решении. А ещё поделюсь полезными кусочками кода, которые помогут вам сохранить пару часов кодинга.

Итак, приступим.

Как это работает на примере калькулятора

В основе взаимодействия лежит bound-сервис, предоставляющий сторонним приложениям доступ к определенному набору методов. Другими словами, одно приложение выступает в роли "клиента" а другое в роли "сервера", причем общаются они по строго заданному интерфейсу, который идентичен для обоих приложений.

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

Структура проекта
Структура проекта

Итак, имеем два модуля-приложения: :app-server и :app-client, из названия понятно кто за что отвечает.

Для связи между двумя процессами необходимо иметь одинаковые AIDL интерфейсы в обоих приложениях, поэтому дабы не дублировать код, создадим общий модуль :aidl и подключим его к обоим приложениям.

Далее в приложении-сервере нам необходимо написать тот самый bound-сервис, через который и будет происходить общение между нашими приложениями. В единственном методе сервиса onBind пока-что будем возвращать null.

Теперь перейдем к самой работе с AIDL. В модуле :aidl в папке src/main/aidl/*пакет приложения* создадим файл Calculator.aidl, в котором напишем интерфейс имеющий всего один метод сложения чисел:

package com.example.aidl;

// Calculator.aidl
interface Calculator {
    int sum(int first, int second);
}

Синтаксис в AIDL такой же как в Java, за исключением некоторых особенностей о которых мы поговорим по ходу статьи.

Однако первое что вы заметите при написании AIDL-интерфейса, так это его плохую поддержку в Android Studio - забудьте про автодополнение кода, автоматическую подмену импортов и пакета при перемещении файла, да черт, там даже подсветка для JavaDoc-комментариев отсутствует! Очень разочаровало.

Во время сборки проекта у нас запустится gradle-таск, который сгенерирует java-интерфейс содержащий в себе логику IPC транзакций. О его внутренностях нам знать не обязательно, нас интересует абстрактный класс Calculator.Stub который является наследником android.os.IBinder, его-то мы и должны будем вернуть в методе onBind нашего bound-сервиса. Теперь сервис будет выглядеть следующим образом:

class BoundService : Service() {

    override fun onBind(intent: Intent?): IBinder? {
        return object : Calculator.Stub() {
            override fun sum(first: Int, second: Int): Int {
                return first + second // реализация метода
            }
        }
    }
}

Осталось только подключиться к нему на стороне клиента и вызвать метод sum(first, second).

Чтобы мы смогли подключиться к сервису он должен быть доступен сторонним приложениям, поэтому добавим ему в манифест интент-фильтр:

<service
    android:name=".BoundService"
    android:process=":remote"
    android:exported="true">
    <intent-filter>
        <action android:name="com.example.aidl.REMOTE_CONNECTION" />
    </intent-filter>
</service>

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

Также для подключения требуется отправлять явный (explicit) Intent, т.е он обязан содержать имя пакета и полный путь к классу сервиса, для этого напишем следующий метод:

Создание явного интента
private fun createExplicitIntent(): Intent {
    val intent = Intent("com.example.aidl.REMOTE_CONNECTION")
    val services = packageManager.queryIntentServices(intent, 0)
    if (services.isEmpty()) {
        throw IllegalStateException("Приложение-сервер не установлено")
    }
    return Intent(intent).apply {
        val resolveInfo = services[0]
        val packageName = resolveInfo.serviceInfo.packageName
        val className = resolveInfo.serviceInfo.name
        component = ComponentName(packageName, className)
    }
}

Также, начиная с Android 11 для подключения к сервису в клиентском приложении требуется указать в манифесте тег <queries> и прописать туда <action> сервиса, к которому мы будем подключаться.

Queries tag
<queries>
    <intent>
        <action android:name="com.example.aidl.REMOTE_CONNECTION" />
    </intent>
</queries>

Ну и последнее что осталось сделать - реализовать интерфейс ServiceConnection, в котором полученный объект типа android.os.IBinder мы приведём к нашему типу com.example.aidl.Calculator:

private var calculator: Calculator? = null

private val serviceConnection = object : ServiceConnection {
    override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
        calculator = Calculator.Stub.asInterface(service)
    }
    override fun onServiceDisconnected(name: ComponentName?) {
        calculator = null
    }
}

override fun onStart() {
    super.onStart()
    bindService(createExplicitIntent(), serviceConnection, Context.BIND_AUTO_CREATE)
}

override fun onStop() {
    super.onStop()
    unbindService(serviceConnection)
}

Всё! Теперь у нас есть объект calculator, методы которого выполняются в другом приложении. Вызвав метод calculator.sum(2, 2) на выходе получим 4.

Помните о том, что вместо сложения чисел тут могут быть абсолютно любые задачи, например отправка запросов на сервер или чтение базы данных, всё ограничивается лишь вашей фантазией.

Кейс по-сложнее №1 - модель данных

С простым кейсом разобрались, но что если на выходе нужно возвращать не int, а свой тип? Тут мы начинаем сталкиваться с первыми ограничениями.

В AIDL поддерживаются следующие типы данных:
• Все примитивы из Java (int, long, char, boolean и т.д)
• Массивы примитивов (int[] и т.д.)
• Строковые типы CharSequence и String
List<T> содержащий данные типа из этого списка
Map<*, *> содержащий данные типа из этого списка, но без параметризации! Т.е не получится написать Map<String, Integer> - компилятор выдаст ошибку
Parcelable классы, в том числе Bundle
• Другие AIDL-интерфейсы (рассмотрим чуть позже)

Поддержка Parcelable меня очень порадовала, ведь можно использовать обычные data классы, помечать их аннотацией @Parcelize и сразу же иметь возможность передавать между процессами, это ли не чудо?!

Давайте усложним наш пример с калькулятором, и будем возвращать не int напрямую, а модель с результатом:

@Parcelize
data class Sum(val sum: Int) : Parcelable
Описание Parcelable прямо в AIDL

В официальной документации сказано, что начиная с Android 10 появилась возможность описания Parcelable классов напрямую в AIDL, однако по какой-то мистической причине у меня это не работает.

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

Чтобы использовать этот класс в AIDL, необходимо создать для него файл-заглушку, причем пакет файла .aidl должен совпадать с пакетом файла .kt. Выглядит это так:

Меня это сильно насторожило - неужели обязательно создавать ещё один файл с таким же названием и пакетом, в дополнение к тому файлу что мы уже создали?
- Да, обязательно. Однако есть один "лайфхак" для ленивых вроде меня, его мы рассмотрим в конце статьи

Теперь наш калькулятор будет выглядеть следующим образом:

package com.example.aidl;

import com.example.aidl.Sum;

// Calculator.aidl
interface Calculator {
    Sum sum(int first, int second);
}

Самое главное - не забывайте про импорты, ведь их придется писать руками, и даже автодополнение вам не поможет.

Кейс по-сложнее №2 - асинхронные вызовы

Если выполнение метода sum(first, second) будет занимать много времени, в конечном счете мы получим зависания на UI треде, а что ещё хуже - ANR. Для избежания такой ситуации можно вынести выполнение метода на другой поток, а результат выполнения возвращать через Callback.

Напишем AsyncCallback.aidl в котором будем получать результат Sum:

package com.example.aidl;

import com.example.aidl.Sum;

// AsyncCallback.aidl
interface AsyncCallback {
    void onSuccess(in Sum sum);
}
Про ключевые слова in/out/inout

Ключевые слова in/out/inout позволяют Binder'у пропустить определенный этап маршаллинга данных, повышая производительность IPC транзакций.

Рассмотрим следующий пример - у нас есть метод требующий на вход массив строк помеченный ключевым словом in:

// AidlExample.aidl
interface AidlExample {
    void fillArray(in String[] array);
}

На стороне клиента мы заполняем этот массив какими-либо данными, например "Cat" и "Dog", а на стороне сервера изменяем значения этих строк на "Apple" и "Strawberry".

Изменится-ли массив на стороне клиента после манипуляций на стороне сервера? Нет, не изменится, потому что in как бы "ограничивает" доступ к оригинальному массиву. Однако на стороне сервера массив будет изменён.

Как вы наверное уже догадались, чтобы массив обновился на стороне клиента мы должны пометить его ключевым словом inout - таким образом на стороне сервера будет доступ как на чтение массива, так и на запись.

Ещё есть ключевое слово out, его отличие от inout в том что массив на стороне клиента будет заполнен теми значениями, которые были установлены на стороне сервера. Пример - на стороне клиента передаем массив из "Cat" и "Dog", а на стороне сервера с массивом ничего не делаем. После выполнения метода оригинальный массив будет содержать null, потому что приложение-сервер его не заполнило.

Учтите что все примитивы в AIDL по-умолчанию помечены словом in, и вы не можете использовать для них свойство out или inout. Но если у вас всё же возникла такая необходимость, рассмотрите вариант использования списка или массива.

Теперь обновим основной класс калькулятора c использованием нового коллбека:

package com.example.aidl;

import com.example.aidl.AsyncCallback;

// Calculator.aidl
interface Calculator {
    void sum(int first, int second, AsyncCallback callback);
}

Как видим, созданные AIDL-интерфейсы можно использовать внутри других AIDL-интерфейсов, причем модификатор in указывать необязательно - он будет установлен по-умолчанию.

Далее останется лишь запустить в сервисе асинхронную операцию и вернуть результат в callback.onSuccess(sum), на этом всё.

Ключевое слово oneway

В предыдущем примере мы запускали задачу в отдельном потоке внутри bound-сервиса, но в AIDL также предусмотрена возможность выполнения самого метода sum(first, second) в другом потоке, для этого его нужно пометить ключевым словом oneway:

package com.example.aidl;

import com.example.aidl.AsyncCallback;

// Calculator.aidl
interface Calculator {
    // P.S слово oneway применимо только к void-методам
    oneway void sum(int first, int second, AsyncCallback callback);
}

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

Ещё тут присутствует один неочевидный момент - если клиент и сервер находятся в одном процессе, то такой метод будет выполнен синхронно.

Кейс с закрытием приложения

Официальная документация советует нам всегда отлавливать ошибки типа android.os.RemoteException которые могут возникать при потере соединения.

Если во время асинхронной операции клиентское приложение будет закрыто, то при вызове callback.onSuccess(sum) на стороне сервера мы получим ошибку android.os.DeadObjectException.

Лечится это банально оберткой в try-catch:

// BoundService.kt
override fun sum(first: Int, second: Int, callback: AsyncCallback?) {
    try {
        val sum = Sum(first + second)
        Thread.sleep(2000) // делаем что-то тяжелое
        callback?.onSuccess(sum)
    } catch (e: RemoteException) {
        Log.e("BoundService", e.message, e)
    }
}

Тоже самое работает и в обратную сторону - если на момент вызова приложение-сервер недоступно, то на стороне клиента получим android.os.DeadObjectException.

Кейс с обновлением приложения

Представим такую ситуацию - пользователь работает в клиентском приложении, проводит какие-то важные операции, и вдруг приложение-сервер начинает процесс обновления - соединение с сервисом обрывается, и следующий вызов метода не приводит к должной реакции. Как итог - пользователи недовольны, а тестировщики заводят баг.

Исправляется очень просто - в момент подключения к сервису регистрируем BroadcastReceiver и слушаем событие ACTION_PACKAGE_REPLACED, а в момент его получения переподключаемся к сервису:

private val appUpdateReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        if (intent?.data?.schemeSpecificPart == "com.example.appserver") {
            unregisterReceiver(this)
            reconnect()
        }
    }
}

private fun reconnect() {
    bindService(createExplicitIntent(), serviceConnection, Context.BIND_AUTO_CREATE)
    registerReceiver(appUpdateReceiver, IntentFilter().apply {
        addAction(Intent.ACTION_PACKAGE_REPLACED)
        addDataScheme("package")
    })
}

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

Кейс по-сложнее №3 - ошибки

Возьмём предыдущий пример с коллбеком и предположим что у нас появилась необходимость передавать ошибки клиенту, например ArithmeticException при делении на ноль, ну или собственные ошибки унаследованные от RuntimeException.

Добавим в наш коллбек новый метод onError, который будет вызван если на стороне сервера произойдёт ошибка:

package com.example.aidl;

import com.example.aidl.Sum;
import java.lang.RuntimeException;

// AsyncCallback.aidl
interface AsyncCallback {
    void onSuccess(in Sum sum);
    void onError(in RuntimeException exception);
}

Ну вот мы и встретили первую боль - мы не можем передавать ошибки т.к они не реализуют интерфейс Parcelable.

Первое решение которое приходит в голову - создать нечто вроде Parcelable-контейнера для ошибок, т.к всё-таки у нас есть возможность записать Serializable объект в Parcel.

@Parcelize
class ExceptionHolder(val exception: RuntimeException): Parcelable

Теперь наш коллбек будет выглядеть примерно так:

package com.example.aidl;

import com.example.aidl.ExceptionHolder;
import com.example.aidl.Sum;

// AsyncCallback.aidl
interface AsyncCallback {
    void onSuccess(in Sum sum);
    void onError(in ExceptionHolder exception);
}

Выглядит конечно так себе, да и опять нужно создавать файл-заглушку для ExceptionHolder'а, но другого способа нет. Однако запустив приложение увидим следующее:

Первая строчка прямо даёт понять что передавать ошибки между процессами не получится, значит нужно искать другой способ.

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

Таким образом, текущая реализация всего этого будет выглядеть примерно так:

Много кода

Класс, который будет использоваться для передачи ошибки и "воссоздании" её на стороне клиента:

@Parcelize
class AidlException(
    private val errorMessage: String?,
    private val errorCode: Int = RUNTIME_EXCEPTION
) : Parcelable {

    companion object {
        const val RUNTIME_EXCEPTION = 1000
        const val ARITHMETIC_EXCEPTION = 1001
        // TODO другие коды ошибок...
    }

    // конвертация на стороне клиента
    fun toException(): Exception {
        return when (errorCode) {
            RUNTIME_EXCEPTION -> RuntimeException(errorMessage)
            ARITHMETIC_EXCEPTION -> ArithmeticException(errorMessage)
            else -> RuntimeException(errorMessage)
        }
    }
}

Коллбек, ответственный за передачу результата между процессами:

package com.example.aidl;

import com.example.aidl.AidlException;
import com.example.aidl.Sum;

// AsyncCallback.aidl
interface AsyncCallback {
    void onSuccess(in Sum sum);
    void onError(in AidlException aidlException);
}

Отправка ошибки на стороне сервиса:

override fun sum(first: Int, second: Int, callback: AsyncCallback?) {
    try {
        val sum = Sum(first + second)
        Thread.sleep(2000) // делаем что-то тяжелое
        throw ArithmeticException("Unable to calculate the result") // не получилось :(
        callback?.onSuccess(sum)
    } catch (e: Throwable) {
        Log.e("BoundService", e.message, e)
        if (e is ArithmeticException) {
            val aidlException = AidlException(e.message, AidlException.ARITHMETIC_EXCEPTION)
            callback?.onError(aidlException)
        }
    }
}

Получение и конвертация ошибки на стороне клиента:

calculator?.sum(2, 2, object : AsyncCallback.Stub() {
    override fun onSuccess(sum: Sum?) {
        Toast.makeText(this@MainActivity, sum.toString(), Toast.LENGTH_SHORT).show()
    }
    override fun onError(aidlException: AidlException?) {
        val exception = aidlException?.toException()
        Toast.makeText(this@MainActivity, exception?.message, Toast.LENGTH_SHORT).show()
    }
})

Кейс по-сложнее №4 - дженерики

В один момент я осознал что слишком ленив, чтобы при создании каждого Parcelable класса создавать для него ещё и файл-заглушку формата .aidl, так что я решил что гораздо удобнее будет создать единый класс для всех транзакций в AIDL, а нужный мне объект хранить внутри такого контейнера.

Если вы думали что весь ужас закончится на передаче ошибок, вы ошибались - в AIDL нет дженериков. Формально они, конечно, есть - мы можем указать тип списка вроде List<String>, но указать тип у собственных Parcelable классов не получится.

Ладно – подумал я, пускай их не будет - исходники обоих приложений прямо передо мной, и я точно знаю какой тип данных ожидать на "той" стороне, поэтому мне не составит труда просто передать такой объект из одного приложения в другое. Таким образом можно будет избавится от дублирования .aidl файлов для каждого Parcelable класса.

Давайте напишем такой контейнер и опробуем нашу "технику":

@Parcelize
data class AidlResult<T : Parcelable>(val data: T) : Parcelable

Коллбек с результатом теперь присылает AidlResult вместо Sum:

package com.example.aidl;

// опять импорты вручную менять! >:(
import com.example.aidl.AidlException;
import com.example.aidl.AidlResult;

// AsyncCallback.aidl
interface AsyncCallback {
    void onSuccess(in AidlResult aidlResult);
    void onError(in AidlException aidlException);
}

На стороне сервера Sum оборачивается в AidlResult:

override fun sum(first: Int, second: Int, callback: AsyncCallback?) {
    try {
        val sum = Sum(first + second)
        val aidlResult = AidlResult(sum)
        Thread.sleep(2000) // делаем что-то тяжелое
        callback?.onSuccess(aidlResult)
    } catch (e: Throwable) {
        // ...
    }
}

А на стороне клиента приводим data из AidlResult к типу Sum:

calculator?.sum(2, 2, object : AsyncCallback.Stub() {
    override fun onSuccess(aidlResult: AidlResult<*>?) {
        val sum = aidlResult?.data as? Sum
        Toast.makeText(this@MainActivity, sum.toString(), Toast.LENGTH_SHORT).show()
    }
    override fun onError(aidlException: AidlException?) {
        val exception = aidlException?.toException()
        Toast.makeText(this@MainActivity, exception?.message, Toast.LENGTH_SHORT).show()
    }
})

Как думайте, заработает-ли оно с первого раза? Ответ будет отличаться в зависимости от версии kotlin-parcelize плагина.

Когда я только начинал изучать AIDL, в плагине kotlin-android-extensions был баг с типизацией для дженерик-классов который не позволял использовать их в AIDL, т.е аннотация @Parcelize генерировала неподдерживаемый для AIDL код.

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

Единственным решением которое было мной найдено, это использовать вот такой костыль:

Костыль для дженериков
/**
 * https://youtrack.jetbrains.com/issue/KT-25807
 */
data class AidlResult<T : Parcelable>(
    private var data: T?,
    private var classType: Class<T>?
) : Parcelable {

    @Suppress("UNCHECKED_CAST")
    constructor(parcel: Parcel) : this(null, null) {
        classType = parcel.readSerializable() as? Class<T>
        data = parcel.readParcelable(classType?.classLoader) as? T
    }

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeSerializable(classType)
        parcel.writeParcelable(data, flags)
    }

    override fun describeContents(): Int = 0

    companion object CREATOR : Parcelable.Creator<AidlResult<out Parcelable>> {

        override fun createFromParcel(parcel: Parcel): AidlResult<out Parcelable> {
            return AidlResult(parcel)
        }

        override fun newArray(size: Int): Array<AidlResult<out Parcelable>?> {
            return arrayOfNulls(size)
        }
    }
}

Т.е по сути мы динамически загружаем класс используя ClassLoader, да ещё и лишаемся возможности сгенерировать шаблонный код через @Parcelize.

Я не знаю кого за это благодарить Google или JetBrains, но с переходом на версии Kotlin 1.4.10 -> 1.4.21 и с плагина kotlin-android-extensions на kotlin-parcelize всё заработало и эти костыли не нужны - респект!

Заключение

Работа с IPC в андройде не идеальна - очень расстроила плохая поддержка Android Studio, отсутствие типизации в интерфейсах и механизма для передачи ошибок, но всё-же это был интересный опыт. Надеюсь что статья помогла вам разобраться с работой в AIDL и сэкономит немного времени на разработку приложения.

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

Спасибо!

Источник: https://habr.com/ru/post/537660/


Интересные статьи

Интересные статьи

SWAP (своп) — это механизм виртуальной памяти, при котором часть данных из оперативной памяти (ОЗУ) перемещается на хранение на HDD (жёсткий диск), SSD (твёрдотельный накоп...
Я начал заниматься сетями еще в школе, а работаю за деньги больше 16 лет. Я много куда устраивался, в большие компании и маленькие, потом открыл свой бизнес и регулярно сам нанимаю лю...
Статья про Ruby в блоге компании ДомКлик! Как так получилось, что в молодую компанию завезли мертвый язык? Секрет в том, что на Ruby можно быстро написать и протестировать бизнес-идею...
У некоторых бизнес-тренеров в области е-коммерса и консультантов по увеличению интернет-продаж на многие вопросы часто можно слышать универсальную отмазку — «надо тестировать» или другую (чтобы не...
В статье описаны необходимые параметры сервера для оптимальной работы сайта на платформе 1С-Битрикс.