Site icon AppTractor

Подсветка синтаксиса на Android — интеграция движка Shiki в Compose

Однажды на выходных я задумался, как бы выглядела подсветка синтаксиса в Jetpack Compose, если бы я создал её с нуля сегодня — без WebView, без HTML-шаблонов. В 2020 году я использовал подход WebView + PrismJS/highlight.js, и он работал, но всегда казалось немного неудобным встраивать браузерный движок только для отображения цветного текста. Теперь, когда Compose стал стандартом для создания пользовательского интерфейса, мне хотелось чего-то, что органично вписывалось бы в общую концепцию.

Это любопытство привело меня к исследованию Shiki, ограничений WebAssembly, Cloudflare Workers, к созданию небольшого микросервиса и, в конечном итоге, ко второму подходу с использованием грамматик TextMate, работающих полностью на устройстве. Вот как в итоге заработали оба варианта.

Почему Shiki?

Если вы раньше не сталкивались с Shiki, то это подсветка синтаксиса, основанная на грамматиках и темах TextMate — том же движке, который используется для подсветки синтаксиса в VS Code. Это не одно и то же, но у них один и тот же базовый подход, а это значит, что качество вывода действительно превосходное. Цвета соответствуют тому, что вы видите в своем редакторе.

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

В чем подвох? Shiki использует Oniguruma, движок регулярных выражений, который компилируется в WebAssembly во время выполнения. WebAssembly на Android — это не то, что можно просто добавить в приложение, и нет нативной версии Shiki для Android. Поэтому запуск самого Shiki на устройстве невозможен, но грамматический движок TextMate, на котором он построен, может работать на Android, что оказалось отдельным путем, достойным изучения.

Запуск Shiki на сервере

Первый подход: если Shiki не работает на Android, запустите его где-нибудь ещё и просто отправьте результат в приложение.

Я создал небольшой микросервисShiki Token Service — который принимает исходный код и возвращает выделенные токены в формате JSON. Приложение никогда не выполняет парсинг самостоятельно, оно просто получает список токенов, каждый со своим текстом и цветом, и отображает их.

Сервис создан с помощью Hono и работает на Cloudflare Workers. Одна интересная особенность: движок Shiki по умолчанию динамически загружает бинарный файл Oniguruma WASM во время выполнения, что не работает в изолированной среде Workers. К тому же сам WASM-файл Oniguruma довольно крупный (около ~1.5 МБ), из-за чего можно упереться в лимит размера скрипта на бесплатном тарифе Cloudflare. Решение заключалось в переходе на JavaScript-движок регулярных выражений Shiki (shiki/engine/javascript), который использует нативные JS RegExp через транспиляцию Oniguruma-To-ES вместо загрузки WASM. WASM не требуется, все работает в Workers, и холодные запуски происходят быстро.

API имеет три конечные точки для подсветки синтаксиса:

Я разместил сервис по адресу https://syntax-highlight.gohk.xyz/docs. Он находится на бесплатном уровне Cloudflare, поэтому задержка низкая, и его работа бесплатна.

Поскольку это всего лишь REST API, он работает для любого клиента — Android, iOS или веб. Я также сделал демонстрационное iOS-приложение, используя тот же сервис, на базе SwiftUI. Там используется тот же подход: вызываем endpoint /highlight/dual, получаем токены и рендерим их через AttributedString.

Примечание

Сервис является прототипом. Формат ответа намеренно упрощен — если вы разрабатываете что-то готовое к использованию в продакшене, вам, вероятно, потребуется оптимизировать полезную нагрузку (например, сжать повторяющиеся цвета, обрабатывать токены по-разному), чтобы уменьшить размер ответа для больших фрагментов кода.

Android-часть

Реализация на Jetpack Compose получилась довольно чистой.

Endpoint /highlight/dual возвращает HighlightDualResponse, который содержит двумерный массив токенов (по сути, строки с токенами). Каждый токен включает darkColor и lightColor в виде hex-строк.

Дальше всё просто: на их основе собирается AnnotatedString в Compose, используя вспомогательную функцию parseHexColor.

private fun buildAnnotatedStringFromDualResponse(
    response: HighlightDualResponse,
    isDark: Boolean,
): AnnotatedString = buildAnnotatedString {
    response.tokens.forEachIndexed { lineIndex, line ->
        line.forEach { token ->
            val hex = if (isDark) token.darkColor else token.lightColor
            val color = parseHexColor(hex)
            withStyle(SpanStyle(color = color)) {
                append(token.text)
            }
        }
        if (lineIndex < response.tokens.lastIndex) {
            append("\n")
        }
    }
}

private fun parseHexColor(hex: String): Color {
    val clean = hex.trimStart('#')
    return when (clean.length) {
        6, 8 -> Color(android.graphics.Color.parseColor("#$clean"))
        else -> Color.Unspecified
    }
}

После этого рендеринг это просто:

Text(
    text = annotated,
    style = MaterialTheme.typography.bodySmall.copy(
        fontFamily = FontFamily.Monospace
    )
)

Никакого WebView, никакого JavaScript-бриджа, никаких шаблонных HTML-строк. Просто AnnotatedString в композабл Text — с цветом фона, приблизительно соответствующим теме. Я был приятно удивлен, насколько мало кода в итоге получилось.

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

Kotlin SDK

Вместо того чтобы напрямую вызывать REST API с помощью Retrofit или Ktor, доступен Android SDK через JitPack. В репозитории, ShikiRepositoryImpl оборачивает ShikiClient и предоставляет единственную suspend функцию highlightDual:

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        maven("https://jitpack.io")
    }
}

// build.gradle.kts
dependencies {
    implementation("com.github.hossain-khan.shiki-token-service:sdk-android:sdk-1.0.5")
}

Использование простое — все методы клиента являются suspend функциями и возвращают kotlin.Result<T>:

private val client = ShikiClient(baseUrl = "https://syntax-highlight.gohk.xyz")

suspend fun highlightDual(
    code: String,
    language: String,
    darkTheme: String,
    lightTheme: String,
): Result<HighlightDualResponse> =
    client.highlightDual(
        HighlightDualRequest(
            code = code,
            language = language,
            darkTheme = darkTheme,
            lightTheme = lightTheme,
        ),
    )

И вот как идет вызов из презентера:

shikiRepository
    .highlightDual(
        code = selectedSample.code,
        language = selectedSample.language,
        darkTheme = selectedThemePair.dark,
        lightTheme = selectedThemePair.light,
    ).onSuccess { response ->
        // response is HighlightDualResponse
        // pass to UI state, build AnnotatedString on the UI side
    }.onFailure { errorMessage = it.message ?: "Unknown error" }

SDK использует Ktor с OkHttp под капотом на Android, а для работы с JSON — kotlinx.serialization. Это модуль на базе Kotlin Multiplatform, поэтому он также работает и в JVM-проектах.

Откат на простой текст

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

В демо-приложении это реализовано через состояние Error: в таком случае отображается исходный код как есть — используется тот же Text-компонент, но без раскраски через AnnotatedString.

when (state) {
    is State.Success -> {
        val isDark = isSystemInDarkTheme()
        val annotated = buildAnnotatedStringFromDualResponse(state.response, isDark)
        Text(
            text = annotated,
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = FontFamily.Monospace
            )
        )
    }
    is State.Error -> {
        // plain text fallback when highlighting is unavailable
        Text(
            text = state.selectedSample.code,
            style = MaterialTheme.typography.bodySmall.copy(
                fontFamily = FontFamily.Monospace
            ),
            modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant),
        )
    }
}

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

On-device вариант с kotlin-textmate

Пока я делал серверный подход, наткнулся на kotlin-textmate — это порт библиотеки vscode-textmate для Kotlin/JVM, то есть используется тот же самый токенизатор, что и в Visual Studio Code. Поскольку Shiki построен на тех же грамматиках и темах TextMate, качество вывода практически идентичное. Главное отличие — всё работает прямо на устройстве, без сетевых запросов.

Настройка немного отличается от подхода с Shiki. Файлы грамматик (.tmLanguage.json) и темы кладутся в папку assets/ приложения. Во время выполнения вы загружаете их оттуда, передаёте в CodeHighlighterи и вызываете highlight().

// Load grammar and theme from assets (do this on a background thread)
val grammar = GrammarReader.readGrammar(
    assets.open("grammars/kotlin.tmLanguage.json")
)
val theme = ThemeReader.readTheme(
    assets.open("themes/dark_vs.json"),
    assets.open("themes/dark_plus.json"),
)

// Tokenize entirely on-device - no network call
val annotated = CodeHighlighter(grammar, theme).highlight(code)

// Render exactly the same way as the server approach
Text(
    text = annotated,
    style = MaterialTheme.typography.bodySmall.copy(
        fontFamily = FontFamily.Monospace
    )
)

Вызов highlight() сразу возвращает AnnotatedString для Jetpack Compose — такой же, как и в варианте с Shiki. То есть слой рендеринга полностью одинаковый, меняется только источник данных.

Честный компромисс здесь — размер APK. Файлы грамматик и тем добавляют по несколько сотен килобайт на язык, и это может накапливаться, если нужна широкая поддержка языков. Но для нескольких популярных языков это вообще не проблема.

Что важно учитывать перед использованием: Injection-грамматики не поддерживаются (вложенные языки, например HTML внутри JSX, не будут подсвечиваться как отдельные подъязыки), библиотека не потокобезопасна (нельзя шарить Grammar между корутинами без синхронизации),
при каждом вызове происходит полная ретокенизация файла без инкрементального кеша. Для отображения статичных сниппетов это не критично — всё работает отлично. А вот для полноценного редактора кода стоит добавить собственное кеширование поверх tokenizeLine().

💡 kotlin-textmate подключается в демо-приложении как локальные JAR-файлы (app/libs/), так как библиотека пока не опубликована в Maven Central или JitPack. JAR-файлы можно найти в директории app/libs/ репозитория.

Сравнение с предыдущим подходом

Подход с WebView (примерно уровня 2020 года) всё ещё работает — и если нужно поддерживать старые layout’ы на View-системе, это нормальный вариант. Но после сравнения трёх подходов становится заметно, что нативные решения на Compose чище. Нет overhead’а WebView, рендеринг полностью в Compose,
результат — обычный AnnotatedString, с которым можно делать что угодно: выделять текст, накладывать дополнительные спаны, использовать в LazyColumn и т.д.

Сравнение двух Compose-подходов

Оба варианта выдают одинаковый AnnotatedString и используют один и тот же Text-компонент, поэтому переключение между ними — это по сути замена слоя данных.

Все три проекта доступны на GitHub. В репозитории token-сервиса также есть KMP SDK в папке sdk/, если хочешь посмотреть, как он устроен.

Источник

Exit mobile version