Назад до статей
Мобільна розробка13 хв читання

Від Android Native до Kotlin Multiplatform: як SDAI був підготовлений до iOS

Інженерна історія про те, як SDAI перейшов від зрілого Android-додатка до спільного Kotlin Multiplatform продукту без втрати глибини провайдерів, локальної генерації та наявного Android-досвіду.

17 червня 2026 р.
автор Дмитро Мороз
#sdai#kotlin#kmp#ios#android#architecture#ai-image-generation

SDAI починався як практична Android-відповідь на дуже просту проблему: генерація зображень розвивалася швидко, але мобільний workflow навколо неї залишався розірваним. Якщо у вас був сервер AUTOMATIC1111, інсталяція SwarmUI, ключ до хмарного провайдера або експеримент із локальною моделлю, все одно бракувало чистого місця на телефоні, де це можна налаштувати, запустити, переглянути, зберегти й продовжити роботу.

Так з'явилася ідея Stable Diffusion AI, або коротко SDAI: не ще одна модель і не ще один web UI, а мобільний клієнт, який поважає вибір провайдера.

Android-first роки

Перша публічна Android-лінійка з'явилася у 2023 році. За час 0.x релізів SDAI став значно більшим, ніж тонка обгортка над API. Він виріс у повноцінний мобільний продукт: self-hosted Stable Diffusion сервери, AI Horde, Hugging Face, OpenAI Images, Stability AI, локальна генерація через ONNX, локальна генерація через Google AI MediaPipe, prompt controls, вибір моделей, LoRA та embeddings discovery, деталі галереї, нативні save і share flows, Google Play builds, F-Droid builds і nightly channels.

До лінійки 0.6.x Android-версія дійшла до стану, знайомого багатьом довгоживучим indie-продуктам: корисна, перевірена реальним використанням і водночас уже досить важка від власної історії. Вона вирішила достатньо проблем, щоб довести цінність ідеї. Але в ній було достатньо платформних припущень, щоб відповідь "просто зробити ще iOS-додаток" була неправильною.

Потім розробка на певний час сповільнилася. Ця пауза виявилася важливою. Повернення до проєкту через час добре перевіряє архітектуру. Якщо межі нечіткі, кожна фіча перетворюється на археологію. Якщо межі чисті, проєкт сам підказує, куди має лягти наступна зміна.

Нова мета

Наступна віха була більшою, ніж "випустити той самий застосунок на iOS". Мета полягала в тому, щоб зробити SDAI справжнім cross-platform продуктом:

  • випустити нативний iOS-додаток;
  • зберегти наявний Android-продукт;
  • не підтримувати дві окремі кодові бази для бізнес-логіки;
  • залишити спільну модель remote providers на обох платформах;
  • додати локальну генерацію там, де платформа має переконливий native runtime;
  • не втратити Android local generation заради найменшого спільного знаменника.

Останній пункт сильно вплинув на рефакторинг. Kotlin Multiplatform не використовувався як чарівна кнопка "поділитися всім". Він використовувався як спосіб поділитися тим, що вже було продуктовою логікою, і явно винести платформні відмінності туди, де їм місце.

Чому стара архітектура допомогла

SDAI не був одним Android-екраном із мережею всередині ViewModel. Android-версія вже мала clean multi-module structure навколо domain use cases, repositories, data sources, provider modules, local storage, presentation state та platform services.

Це багато вирішило. KMP-рефакторинг міг переносити код у commonMain, бо значна частина коду вже поводилася як shared code. Проєкту не потрібно було спочатку пережити концептуальний rewrite. Потрібно було акуратно витягнути Android-залежності з місць, де вони просочилися в загалом переносиму логіку.

Нову структуру добре видно зі списку модулів: domain, data, network, storage, presentation, core і фокусні feature-модулі на кшталт auth, work, onnx, mediapipe, sdxl, coreml, bonsai та benchmark.

Таймлайн рефакторингу

Ключовий рефакторинг відбувся у червні 2026 року як глобальний перехід до KMP і підготовка iOS-релізу. Він відділив Android app shell від shared modules, додав iOS target, переніс domain і presentation код ближче до common source sets та замінив Android-only services на platform contracts.

Далі робота стала поступовою:

  • shared provider і configuration logic переїхали в common code;
  • Android-specific storage, permissions, wake locks, MediaStore та local model execution залишилися за Android-реалізаціями;
  • iOS отримав власне persistence, file handling і platform bridges;
  • background generation було винесено за common domain contracts;
  • local generation providers додавалися як capabilities, а не як глобальні припущення;
  • provider catalog став багатшим: type, readiness, feature tags, version і build availability.

Provider catalog - це лише видима частина. Глибша зміна в тому, що local providers отримали runtime-specific modules зі своїми правилами model layout. iOS Bonsai runtime, наприклад, не припускає одну-єдину форму архіву. Він може знайти вже розпаковану модель, wrapper із Resources або model.zip, а потім перевірити, що MLX resources справді на місці.

static func resolve(modelPath: String) throws -> BonsaiModelLayout {
    let modelURL = URL(fileURLWithPath: modelPath, isDirectory: true)
    if let layout = find(in: modelURL) {
        return layout
    }
 
    let archiveURL = modelURL.appendingPathComponent("model.zip", isDirectory: false)
    guard FileManager.default.fileExists(atPath: archiveURL.path) else {
        throw BonsaiRuntimeError.modelResourcesNotFound(modelPath)
    }
 
    let extractedURL = modelURL.appendingPathComponent("extracted", isDirectory: true)
    if find(in: extractedURL) == nil {
        if FileManager.default.fileExists(atPath: extractedURL.path) {
            try FileManager.default.removeItem(at: extractedURL)
        }
        try FileManager.default.createDirectory(
            at: extractedURL,
            withIntermediateDirectories: true
        )
        do {
            try FileManager.default.unzipItem(at: archiveURL, to: extractedURL)
        } catch {
            throw BonsaiRuntimeError.invalidModelArchive
        }
    }
 
    if let layout = find(in: extractedURL) {
        return layout
    }
 
    throw BonsaiRuntimeError.invalidModelLayout(
        "expected transformer-packed-mflux, text_encoder or text_encoder-mlx-4bit, tokenizer, vae, and scheduler directories"
    )
}
 
private static func find(in rootURL: URL) -> BonsaiModelLayout? {
    let candidates = [
        rootURL,
        rootURL.appendingPathComponent("Resources", isDirectory: true),
        rootURL.appendingPathComponent("extracted", isDirectory: true),
        rootURL
            .appendingPathComponent("extracted", isDirectory: true)
            .appendingPathComponent("Resources", isDirectory: true),
    ]
 
    if let direct = candidates.first(where: isBonsaiRoot) {
        return layout(rootURL: direct)
    }
 
    guard let enumerator = FileManager.default.enumerator(
        at: rootURL,
        includingPropertiesForKeys: [.isDirectoryKey],
        options: [.skipsHiddenFiles]
    ) else {
        return nil
    }
 
    let rootDepth = rootURL.pathComponents.count
    for case let url as URL in enumerator {
        if url.pathComponents.count - rootDepth > 4 {
            enumerator.skipDescendants()
            continue
        }
        if isBonsaiRoot(url), let layout = layout(rootURL: url) {
            return layout
        }
    }
    return nil
}

Саме така межа робить рефакторинг корисним на практиці. Shared app не має знати, як вкладений MLX model archive, де лежить tokenizer або чи модель уже розпакована. Native runtime володіє цією складністю, а shared product layer зберігає стабільний provider contract.

Виклик 1: один продукт, багато форм провайдерів

Найскладніше в SDAI не намалювати форму для prompt. Найскладніше - зберегти одну продуктову модель поверх провайдерів, які поводяться по-різному.

AUTOMATIC1111 має одну batch-семантику. Fal.ai та ArliAI мають власні batch flows. Local ONNX, MediaPipe, stable-diffusion.cpp, Core ML і PrismML Bonsai мають device-specific constraints. Одні провайдери підтримують image-to-image. Інші підтримують inpainting. Десь є model lists, LoRA, embeddings, hypernetworks або sampler discovery. Десь цього немає.

Shared domain layer тепер ховає ці відмінності за use cases і repositories, але головне тут не when, який вибирає provider. Головне - контракт навколо цього вибору: кожен provider має вписатися в один продуктовий workflow configuration, generation, progress, interruption, persistence і result inspection.

Саме цей контракт тримає UI близько до задачі користувача: вибрати provider, ввести prompt, налаштувати параметри, згенерувати, переглянути. Domain layer бере на себе той факт, що провайдери не еквівалентні.

Виклик 2: один контракт, різні execution models

Background generation став одним із найгостріших прикладів того, що KMP тут був архітектурним інструментом, а не просто способом "поділитися кодом".

Shared product хоче одного: запланувати generation, повторити останній payload, скасувати work і спостерігати status. Android та iOS потребують дуже різних реалізацій, щоб виконати цю обіцянку.

Common contract навмисно лишається малим. Він описує продуктову поведінку, а не конкретний scheduler.

interface BackgroundTaskManager {
    fun scheduleTextToImageTask(payload: TextToImagePayload)
    fun scheduleImageToImageTask(payload: ImageToImagePayload)
    fun retryLastTextToImageTask(): Result<Unit>
    fun retryLastImageToImageTask(): Result<Unit>
    fun cancelAll(): Result<Unit>
}

На Android реалізація може спиратися на WorkManager. SDAI серіалізує payload у private work cache, скасовує попередню unique generation work і ставить наступну задачу з ExistingWorkPolicy.REPLACE.

override fun scheduleTextToImageTask(payload: TextToImagePayload) {
    runWork<TextToImageTask>(payload.toByteArray(), Constants.FILE_TEXT_TO_IMAGE)
}
private inline fun <reified W : ListenableWorker> runWork(bytes: ByteArray, fileName: String) {
    val workManager: WorkManagerProvider by inject(WorkManagerProvider::class.java)
    val workRequest = OneTimeWorkRequestBuilder<W>()
        .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
        .addTag(Constants.TAG_GENERATION)
        .build()
 
    writePayload(bytes, fileName)
    workManager().cancelUniqueWork(Constants.TAG_GENERATION)
    workManager().enqueueUniqueWork(
        Constants.TAG_GENERATION,
        ExistingWorkPolicy.REPLACE,
        workRequest,
    )
}

На iOS той самий контракт перетворюється на coroutine-owned generation job. Реалізація має запустити iOS background execution, підписатися на progress stream активного provider і закрити background task у finally.

override fun scheduleTextToImageTask(payload: TextToImagePayload) {
    lastTextToImagePayload = payload
    runGenerationTask {
        textToImageUseCase(payload)
    }
}
private fun runGenerationTask(block: suspend () -> List<AiGenerationResult>) {
    activeJob?.cancel()
    clearStatusSubscriptions()
    backgroundWorkObserver.dismissResult()
 
    lateinit var generationJob: Job
    generationJob = coroutineScope.launch(start = CoroutineStart.LAZY) {
        val backgroundExecution = IosBackgroundExecution(
            name = "SDAI Generation",
            activeJob = { generationJob },
        )
        try {
            backgroundExecution.begin()
            listenSourceStatus()
            postRunningMessage()
            val result = block()
            if (isActiveJob(generationJob)) {
                backgroundWorkObserver.postSuccessSignal(result)
            }
        } catch (e: CancellationException) {
            if (isActiveJob(generationJob)) {
                backgroundWorkObserver.postCancelSignal()
            }
        } catch (t: Throwable) {
            if (isActiveJob(generationJob)) {
                backgroundWorkObserver.postFailedSignal(t)
            }
        } finally {
            if (isActiveJob(generationJob)) {
                clearStatusSubscriptions()
                activeJob = null
            }
            backgroundExecution.end()
        }
    }
    activeJob = generationJob
    generationJob.start()
}

Android може зберегти serialized payload і передати його в WorkManager із replace policy. iOS має володіти coroutine job, обгорнути його в background execution, підписатися на provider-specific status streams і явно завершити background task. Shared layer не має знати, який механізм використано. Йому важливо, щоб generation поводилася як один продукт.

Виклик 3: локальні AI runtimes - це не просто ще один API call

Remote providers здебільшого означають HTTP, payload mapping, authentication, errors і response normalization. Local generation - це інший клас роботи.

На Android SDAI вже мав локальні шляхи генерації через Microsoft ONNX Runtime і Google AI MediaPipe. Новіша робота додала local SDXL через stable-diffusion.cpp. На iOS локальна генерація потребувала окремої runtime story: Silicon Diffusion Core ML і PrismML Bonsai.

Це означає, що model manifests, downloads, storage locations, progress reporting, interruption, memory behavior і user-facing availability стають частиною продукту. Local provider не можна трактувати як "той самий repository, тільки offline". Йому потрібен власний runtime module, власне file handling і власна модель відмов.

Найцікавіше професійно тут - межа між shared product logic і native runtime code. Shared app має знати, що local provider може генерувати, повідомляти прогрес і перериватися. Він не має знати, чи всередині ONNX Runtime, MediaPipe, C++, Core ML або MLX-backed Bonsai.

Bonsai path добре це показує. Shared Kotlin side бачить repository. Під ним є три різні шари: Kotlin bridge, який перетворює callback-based Swift на cancellable suspend call; Swift adapter, який мапить Kotlin/Native bridge objects у native generator; і власне MLX inference pipeline.

Kotlin bridge тримає product-level semantics: request mapping, progress emission, cancellation і перетворення Swift completion callbacks на exception або base64 image.

override suspend fun process(
    payload: TextToImagePayload,
    modelPath: String,
): String {
    statusFlow.tryEmit(LocalDiffusionStatus(0, payload.samplingSteps))
    return SiliconDiffusionBonsaiRuntimeRegistry.generate(
        request = payload.toBonsaiRequest(modelPath),
        onProgress = { progress ->
            statusFlow.tryEmit(
                LocalDiffusionStatus(
                    current = progress.current,
                    total = progress.total,
                ),
            )
        },
    )
}
internal suspend fun generate(
    request: SiliconDiffusionBonsaiRequest,
    onProgress: (SiliconDiffusionBonsaiProgress) -> Unit,
): String = kotlinx.coroutines.suspendCancellableCoroutine { continuation ->
    val bridge = runtime
    if (bridge == null) {
        continuation.resumeWithException(
            IllegalStateException("Bonsai Image runtime is not registered."),
        )
        return@suspendCancellableCoroutine
    }
 
    bridge.generate(
        request = request,
        onProgress = onProgress,
        completion = { response ->
            val image = response.imageBase64
            val error = response.errorMessage
            when {
                continuation.isActive && image != null -> continuation.resume(image)
                continuation.isActive -> continuation.resumeWithException(
                    IllegalStateException(error ?: "Bonsai Image generation failed."),
                )
            }
        },
    )
    continuation.invokeOnCancellation { bridge.interrupt() }
}

Swift adapter навмисно тонкий. Його задача - перекласти bridge DTOs у native generator request і повернути native progress та completion у Kotlin-visible objects.

@available(iOS 17.0, *)
final class SiliconDiffusionBonsaiRuntimeAdapter: NSObject, SiliconDiffusionBonsaiRuntime {
    private let generator = SiliconDiffusionBonsaiGenerator()
 
    func generate(
        request: SiliconDiffusionBonsaiRequest,
        onProgress: @escaping @Sendable (SiliconDiffusionBonsaiProgress) -> Void,
        completion: @escaping @Sendable (SiliconDiffusionBonsaiResponse) -> Void
    ) {
        generator.generate(
            request: SiliconDiffusionBonsaiGenerator.Request(
                modelPath: request.modelPath,
                prompt: request.prompt,
                negativePrompt: request.negativePrompt,
                samplingSteps: request.samplingSteps,
                cfgScale: request.cfgScale,
                width: request.width,
                height: request.height,
                seed: request.seed,
                allowNsfw: request.allowNsfw
            ),
            onProgress: { progress in
                onProgress(
                    SiliconDiffusionBonsaiProgress(
                        current: progress.current,
                        total: progress.total
                    )
                )
            },
            completion: { response in
                let byteCount = response.imageBase64.flatMap { Data(base64Encoded: $0)?.count } ?? 0
                completion(
                    SiliconDiffusionBonsaiResponse(
                        imageBase64: response.imageBase64,
                        errorMessage: response.errorMessage
                    )
                )
            }
        )
    }
 
    func interrupt() {
        generator.interrupt()
    }
}

Inference layer - це частина, яку KMP не має намагатися абстрагувати. Це native code, який працює з MLX memory pressure, scheduler steps, interruption, tensor shapes і VAE decoding.

final class BonsaiMlxPipeline {
    private let layout: BonsaiModelLayout
 
    init(layout: BonsaiModelLayout) {
        self.layout = layout
    }
 
    func generate(
        input: BonsaiGenerationInput,
        onProgress: (SiliconDiffusionBonsaiGenerator.Progress) -> Void,
        shouldContinue: () -> Bool
    ) throws -> String {
        guard shouldContinue() else {
            throw BonsaiRuntimeError.interrupted
        }
 
        let encodedPrompts = try Self.encodePrompts(input: input, layout: layout)
        BonsaiMlxMemory.reclaimCache()
 
        var latents = BonsaiLatentCreator.preparePackedLatents(
            seed: input.seed,
            height: input.height,
            width: input.width
        )
        let scheduler = BonsaiFlowMatchEulerScheduler(
            imageSeqLen: (input.height / 16) * (input.width / 16),
            steps: input.steps
        )
        let total = Int32(scheduler.timesteps.count)
 
        try autoreleasepool {
            let transformer = try BonsaiFluxTransformer(layout: layout)
            BonsaiMlxMemory.reclaimCache()
 
            for index in scheduler.timesteps.indices {
                guard shouldContinue() else {
                    throw BonsaiRuntimeError.interrupted
                }
 
                let condNoise = transformer(
                    hiddenStates: latents.values,
                    encoderHiddenStates: encodedPrompts.prompt.embeddings,
                    timestep: scheduler.timesteps[index],
                    imageIds: latents.ids,
                    textIds: encodedPrompts.prompt.textIds
                )
                let noise: MLXArray
                if let negativePrompt = encodedPrompts.negativePrompt {
                    let uncondNoise = transformer(
                        hiddenStates: latents.values,
                        encoderHiddenStates: negativePrompt.embeddings,
                        timestep: scheduler.timesteps[index],
                        imageIds: latents.ids,
                        textIds: negativePrompt.textIds
                    )
                    noise = uncondNoise + MLXArray(input.guidance, dtype: condNoise.dtype) * (condNoise - uncondNoise)
                } else {
                    noise = condNoise
                }
                latents = BonsaiLatents(
                    values: scheduler.step(noise: noise, timestep: index, latents: latents.values),
                    ids: latents.ids,
                    latentHeight: latents.latentHeight,
                    latentWidth: latents.latentWidth
                )
                eval(latents.values)
                BonsaiMlxMemory.reclaimCache()
                onProgress(
                    SiliconDiffusionBonsaiGenerator.Progress(
                        current: Int32(index + 1),
                        total: total
                    )
                )
            }
        }
        BonsaiMlxMemory.reclaimCache()
 
        let packed = latents.values
            .reshaped(1, latents.latentHeight, latents.latentWidth, latents.values.shape[2])
            .transposed(0, 3, 1, 2)
        return try autoreleasepool {
            let vae = try BonsaiFluxVAE(layout: layout)
            BonsaiMlxMemory.reclaimCache()
            let decoded = vae.decodePacked(packed)
            eval(decoded)
            BonsaiMlxMemory.reclaimCache()
            return try BonsaiImageEncoder.base64Jpeg(from: decoded)
        }
    }
 
    private static func encodePrompts(
        input: BonsaiGenerationInput,
        layout: BonsaiModelLayout
    ) throws -> (prompt: BonsaiPromptEmbeddings, negativePrompt: BonsaiPromptEmbeddings?) {
        try autoreleasepool {
            let textEncoder = try BonsaiTextEncoder(layout: layout)
            let prompt = try textEncoder.encode(prompt: input.prompt)
            let negativePrompt: BonsaiPromptEmbeddings? = input.guidance > 1.0
                ? try textEncoder.encode(prompt: input.negativePrompt.isEmpty ? " " : input.negativePrompt)
                : nil
            if let negativePrompt {
                eval(prompt.embeddings, prompt.textIds, negativePrompt.embeddings, negativePrompt.textIds)
            } else {
                eval(prompt.embeddings, prompt.textIds)
            }
            return (prompt, negativePrompt)
        }
    }
}

Саме це розділення робить роботу технічно цікавою. KMP не вдає, що Swift MLX code є Kotlin-кодом. Він робить native runtime доступним зі shared product layer і не дозволяє inference details протекти в screens, use cases або provider selection.

Виклик 4: shared UI без удавання, що платформи однакові

Compose Multiplatform дозволив зберегти цілісний mobile experience на Android та iOS. Але метою не була піксельна однаковість. Метою була продуктова безперервність.

SDAI все одно має platform-specific behavior там, де користувачі цього очікують: save flows, share sheets, file pickers, background execution, storage permissions і local network behavior. Рефакторинг сховав ці деталі за contracts, щоб shared screens могли залишатися сфокусованими на state та intent.

Саме тут стара clean architecture окупилася найбільше. ViewModels і use cases уже були близькі до platform-neutral форми. KMP просто зробив цю дисципліну видимою.

Що змінилося для продукту

Після рефакторингу SDAI більше не є "Android-додатком із планами на iOS". Це cross-platform AI image generation client зі shared product core і platform-specific runtime depth.

Android зберігає свою наявну дистрибуцію та local generation paths. iOS отримує native release path, shared remote providers і власну local generation роботу через Apple-oriented runtimes. Provider model має достатньо структури, щоб рости далі без перетворення setup на набір special cases.

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

Для кодової бази результат ще важливіший: майбутні providers можна додавати як capabilities, а не як mini-rewrites.

Висновки

Головний висновок: KMP найкраще працює тоді, коли зустрічає кодову базу, у якої вже є межі. Якби Android-додаток змішував UI, storage, HTTP, model execution і platform APIs в одному шарі, iOS-реліз перетворився б на rewrite, замаскований під refactor.

Натомість наявна архітектура дала проєкту leverage. KMP перетворив цей leverage на shared product core. Native layers залишилися native там, де це потрібно. Shared layers стали shared там, де це вже мало сенс.

Саме такий refactor має сенс: не тому, що це модно, а тому, що він змінює вартість наступного продуктового рішення.

SDAI тепер може розвиватися як provider-independent мобільний клієнт для AI image generation, де Android та iOS рухаються разом, а не розходяться в окремі продукти.

Продуктовий showcase можна переглянути тут: SDAI case study.