Привет! Мы написали свою систему диплинков на основе кодогенерации. В этой статье поговорим, как мы упростили работу с диплинками и смогли отловить устаревшие, добавили мониторинг и как собрали все диплинки в одной статье в конфлюенсе.
Диплинк — это uri на конкретный ресурс в приложении. Они нужны бизнесу, чтобы упрощать пользовательский опыт. Так вместо нескольких переходов внутри приложения диплинки позволяют направить пользователя на определённый экран в один клик (польза для пользователя) и снимать статистику (польза для бизнеса). Например: на внешней площадке компания разместила баннер, в котором предлагается заказать виртуальную карту, пользователь может кликнуть по баннеру и сразу попасть на экран заказа карты, а бизнес сможет оценить, какая из площадок более эффективна.
Самая большая проблема — это проблема безопасности. Объясню на примере активити, которая открывает веб-страницы. Активити открывается по диплинку, в нём указывается URL в качестве параметра. Один из вариантов атаки — когда злоумышленник может заставить пользователя пройти по диплинку с URL на вредоносный сайт и таким образом провести атаку. Ещё одна из возможных проблем — на некоторых экранах нам нужно валидировать параметры, а это иногда занимает значительную часть активити. Было бы хорошо вынести валидацию в отдельное место.
Ещё у нас бывали случаи, когда маркетинг запускал промо-кампании либо с диплинками, в которых содержались ошибки, либо с устаревшими диплинками, которые уже не поддерживались, и мы могли об этом даже и не узнать. А при заведении новой кампании маркетинг обращался к разработчиками за диплинками и поиск занимал некоторое время. Если разработчик помнил название экрана и диплинк без параметров, то его можно было быстро найти, а если название экрана сразу не вспомнилось, то алгоритм поиска примерно такой: сбилдить проект -> пройти на нужный экран -> посмотреть в логах, какая активити открылась -> пойти в манифест для получения диплинка -> открыть исходных код активити для сбора входных параметров.
Посмотрев на это, мы поняли, что нам нужна единая точка обработки, анализа, мониторинга, и решили выбрать инструмент аннотаций и кодогенерации. Сейчас объявление диплинка у нас выглядит следующим образом.
Пример объявления диплинка для экрана бонуса.
@DeepLink(
Uri("qiwi", "bonus"),
description = Description("открыть раздел бонус(кэшбек)")
)
class BonusHandler : ProxyDeepLinkHandler() {
override val activityClass: Class<*> = BonusShowcaseActivity::class.java
}
С помощью аннотации @Deeplink
помечаем обработчик и задаем диплинк, в данном примере диплинк qiwi://bonus
откроет BonusShowcaseActivity
.
Во втором примере показано, как производим проверки перед открытием диплинка.
@DeepLink(
Uri("qiwi", "bonus"),
description = Description("открыть раздел бонус(кэшбек)")
)
class BonusHandler : DeepLinkHandler() {
@Inject
lateinit var bonusShowcaseFeature: BonusShowcaseFeature
override fun deeplinkData(context: Context, intent: Intent): DeepLinkData {
QiwiApplication.get(context)
.appComponent.inject(this)
val clazz = BonusShowcaseActivity::class.java
val startIntent = Intent(context, claszz).copyIntent(intent)
return bonusShowcaseFeature.deepLinkData(DeepLinkData(startIntent, claszz))
}
}
В обработчике проверяем фича флаг BonusShowcaseFeature
и открываем экран, если флаг включен. Про наши фича флаги можно почитать в этом посте.
Аннотации
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class DeepLink(
vararg val deepLinks: Uri,
val description: Description = Description(),
val commonParams: Array<Parameter> = [],
val addToDoc: Boolean = true
)
@Retention(AnnotationRetention.SOURCE)
annotation class Uri(
val scheme: String,
val hostPath: String,
val description: Description = Description(),
val parameters: Array<Parameter> = [],
val examples: Array<Example> = [],
val addToDoc: Boolean = true
)
@Retention(AnnotationRetention.SOURCE)
annotation class Description(
val description: String = "",
val group: String = Groups.OTHER
)
@Retention(AnnotationRetention.SOURCE)
annotation class Parameter(
val key: String = "",
val value: String = "",
val description: String = ""
)
@Retention(AnnotationRetention.SOURCE)
annotation class Example(
val description: String,
val parameters: Array<Parameter> = []
)
Сейчас в системе у нас пять аннотаций.
Deeplink — используется для описания одно или нескольких uri, также есть возможность добавить описание, общие параметры для всех uri и можно указать, попадёт ли данный диплинк в документацию.
Uri — позволяет установить диплинк(scheme://hostPath), дополнительно можно оставить описание, параметры, с которыми используется диплинк, и, если необходимо, добавить примеры.
Description— используется для описания. Можно указать группу, в которой содержится диплинк, это необходимо для сортировки в итоговой документации.
Parameter— аннотация задаёт query-параметры в формате key-value, в value описываем возможные значения.
Example — используется для примеров.
Аннотация Deeplink
указывает, какой обработчик будет обрабатывать диплинк из аннотации Uri. Все обработчики являются наследниками класса DeepLinkHandler
.
class DeepLinkData(
val startIntent: Intent?,
val activityClazz: Class<*>?
)
abstract class DeepLinkHandler {
var analytics: DeepLinkHandlerAnalytics? = null
abstract fun deeplinkData(context: Context, intent: Intent): DeepLinkData
fun process(context: Context, intent: Intent) {
val deepLinkData = deeplinkData(context, intent)
deepLinkData.startIntent?.let { startIntent ->
context.startActivity(startIntent)
}
}
}
Абстрактный класс, который умеет открывать активити по интенту и отправлять аналитику. При наследовании от этого класса нужно реализовать метод deeplinkData
и в нём можно проводить обработку диплинка.
В случае, когда не нужно проводить проверку диплинка, мы используем ProxyDeeplinkHandler
.
abstract class ProxyDeepLinkHandler : DeepLinkHandler() {
protected abstract val activityClass: Class<*>
override fun deeplinkData(context: Context, intent: Intent): DeepLinkData {
val startIntent = Intent(context, activityClass).copyIntent(intent)
return DeepLinkData(startIntent, activityClass)
}
}
При наследовании от этого класса в дочернем классе достаточно определить поле activityClass
, который должен возвращать ту активити, которую нужно открыть.
Архитектура диплинков
Под капотом всё работает немного сложнее. Точкой входа у нас является SplashActivity
. Она с помощью DeepLinkDelegate
и остальных компонентов достаёт из реестра нужный обработчик для того диплинка, который пришёл в систему.
Манифест для SplashActivity
выглядит следующим образом.
<activity
android:name=".splashScreen.view.SplashScreenActivity"
android:theme="@style/QiwiSplashScreenTheme">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="qiwi" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="qiwi.com" />
<data android:host="qiwi.ru" />
<data android:path="/main.action" />
...
<data android:pathPattern="/p/.*" />
<data android:path="/feed" />
</intent-filter>
</activity>
В манифесте есть два интент-фильтра для диплинков. Первый для диплинков — со схемой qiwi
, что позволяет открывать любые диплинки с этой схемой. Во втором интент-фильтр используется для url-диплинков. Мы редко добавляем url-диплинки, поэтому манифест практически не редактируем.
Кодогенерация
Во время кодогенерации происходит поиск всех классов, помеченных аннотацией Deeplink
, потом с помощью Kotlin Poet создаются несколько DeeplinkRegistry
и json-файл с описанием диплинков для документации.
DeeplinkRegistry
— это интерфейс с методом registry()
, возвращающий мапу, где ключ - диплинк, а значение — класс обработчика.
Пример сгенерированного DeeplinkRegistry
для диплинков uri:
public class OuterDeepLinkRegistryGenerated : DeepLinkRegistry {
public override fun registry(): Map<String, Class<out Any>> {
val map = mutableMapOf<String, Class<out Any>>()
map["qiwi://bonus"] = BonusHandler::class.java
map["qiwi://main.action"] = MainHandler::class.java
map["qiwi://cards/detail"] = CardDetailActivityHandler::class.java
...
map["qiwi://help"] = ProfileActivityHandler::class.java
return map
}
}
Json-файл с описанием диплинков выглядит так:
{
"qiwi://bonus": {
"description": "открыть раздел бонус(кэшбек)"
},
"qiwi://qvc/order": {
"group": "QIWI-карты",
"description": "заказ qvc или qvc_mir карты",
"parameters": [
{
"name": "alias",
"value": "qvc | qvc-mir"
}
]
},
"qiwi://cards/detail": {
"group": "QIWI-карты",
"description": "детали карты",
"parameters": [
{
"name": "id",
"value": "*",
"description": "id карты"
}
]
}
}
Из этого файла создается документация.
Документация — это большая таблица, в которой перечислены все диплинки с описанием и параметрами из json-файла.
Таблица всегда актуальная, так как выгрузка json-файла встроена в релизный пайплайн. С помощью таски файл загружается на статик-сервер. А в конфлюенсе настроена таблица, которая подгружает и парсит json-файл.
Таблица в первую очередь полезна отделу маркетинга, так как позволяет быстро получить инфу по диплинкам.
В системе диплинков мы добавили мониторинг и аналитику. И получаем события о необработанных диплинках. Алерты настроены на почту и в телеграм-канал, что позволяет быстро реагировать на инциденты.
Проблему с безопасностью в WebView, о которой писал выше, мы решили следующим образом: в обработчике для активити с WebView проверяем по белому списку урл, который приходит с диплинком, если урл содержится в списке, то открываем веб-страницу.
Итоги
Упростили работу с диплинками, например, в обработчике проверяем фича флаг, проводим валидацию параметров.
Повысили безопасность активити с WebView, завели whitelist, по которому определяется, будет ли открыта веб-страничка или нет, если приходит диплинк с урлом не из белого списка, то кидаем событие в мониторинг.
Добавили мониторинг в систему диплинков.
Улучшили жизнь специалистам из маркетинга, они получили всегда актуальную таблицу с диплинками.
Отловили все устаревшие и кривые диплинки, которые использовались в промо-материалах.