Выходим за пределы JVM. Объясняем на крестиках-ноликах чем хорош Kotlin Multiplatform

8ff3a183ff37c4260b21ff531faa56b4.jpg

Какой есть общий недостаток у мобильной, front-end и back-end разработки и иногда распила микросервисов? Дублирование логики. Очень часто я видел статьи или новости, где одна команда мобильных разработчиков ждет другую, чтобы выкатить релиз. И если с мобильными версиями более-менее все понятно и есть решения, то что делать с браузером? Очень часто логику работы приложения нужно поддержать и там.

Что делать, если хочется писать нативный код и при этом не иметь дублей реализации — на этот вопрос я постараюсь ответить подробно в данной статье. В процессе чтения можно будет познакомиться с технологией Kotlin Multiplatform и создать полноценный проект всем известной игры «Крестики-нолики» на трех самых популярных платформах Browser (JS), iOS (Swift) и Android (Java) с общей логикой на Kotlin.

Содержание

Об авторе

Для начала представлюсь и расскажу про некоторый мой опыт работы с технологией, о которой пишу. Меня зовут Устинов Тихон и я работаю в Ростелеком ИТ, а если быть точнее — на проекте «Интеграция ЕПК (Единого продуктового каталога)».

С помощью этой core-системы в прошлом году Ростелеком запустил гибкие пакеты услуг. Для этого мы разработали SDK, который обрабатывает всю бизнес-логику: от конфигурирования до просчета цены.

Скриншот ниже — это пример того, как одна из витрин отображает работу этого SDK.

bf65f8b84db61464bbe3a96e334f8299.jpeg

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

Почему именно Kotlin Multiplatform

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

Если рассматривать проблему, ограничиваясь мобильными устройствами и браузером, то первое, что приходит в голову — это React Native и Progressive Web Applications (PWA). Однако, мы уже имели некоторую базу нативных приложений, от которых не хотелось отказываться, и webview выглядел тоже не лучшим вариантом. По этой же причине мы отмели идею писать на flutter, который помимо запуска на ios и android дает возможность скомпилировать код в js. Наличие нативных приложений сократило наш выбор до двух вариантов:

a) C/C++ библиотека с подключением к нативным приложениям и к js как WebAssembly

b) неизвестный тогда Kotlin Multiplatform.

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

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

60a20c7f3edfcb0ba038f1c5a7b0d883.jpeg

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

Если своими словами, то Kotlin Multiplatform — это технология, которая принадлежит компании JetBrains, она позволяет писать общий код на Kotlin и компилировать его в нативное представление более чем на 10 платформ. Это означает, что после компиляции мы будем иметь для JVM *.jar файл, для JS — *.js модуль с возможностью создания *.d.ts, и, простите за тавтологию, фреймворк типа *.framework для Swift. Для некоторых других платформ так же могут быть созданы нативные для платформы артефакты.

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

6c794305a82c90c17b2f92639fbd4055.jpeg

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

3d2760b080a4ca1c8987e60d44e34ad1.jpeg

В процессе работы мы выявили для себя такие очевидные и не очень плюсы:

  • Один код, одна модель. Мы больше не имеем проблем с тем, что одна команда ждет другую, чтобы реализовать то же самое — это существенно уменьшает количество затрат на разработку и, соответственно, повышает скорость доведения хотелок бизнеса до конечного потребителя;

  • Одна команда. Вынесение логики в единый SDK позволило выделить команду разработки, которая занимается его развитием. Имея одну общую доменную модель разработчики, аналитики и продуктовые менеджеры лучше понимают друг друга. Не стоит упускать из виду немаловажный bus factor, который будет значительно ниже у трёх разработчиков, разрабатывающих один проект на одном языке, чем аналогичный проект у 3 разработчиков на 3 проектах на 3 языках;

  • Статическая типизация. Очень актуально для JS. Конечно есть TypeScript, но учитывая, что типизация просто работает из коробки, однозначно записываем это в плюс;

  • Тестирование. Когда вы просто пишете код без возможности посмотреть результат без компиляции, вы рано или поздно начнете писать тесты. С помощью теста вы, как минимум, сможете быстро проверять корректность кода, плюс тест будет поднимать покрытие функционала и повышать надежность кода. Написав один тест, вы покрываете сразу все ваши целевые платформы, что также экономит ресурс тестировщиков;

  • Язык. Кто бы что ни говорил, но лично для меня Kotlin — лучший язык для разработки. По мере погружения в него возникает ощущение, что язык создавался людьми для людей. Это очень важно, когда вы имеете сложную бизнес-логику вам важно написать понятный код, чтобы её [бизнес-логику] можно было легко читать, а не в каком-нибудь условном (без обид) Go: открываешь метод, видишь кучу примитивных операций, но не можешь без скролла понять, что они должны делать в совокупности.

f33aab25e266f0f520df3a45a3a08996.jpeg

К сожалению, у технологии имеются и минусы, такие как:

  • Потеря производительности. Это больше актуально для JS платформы, так как при компиляции не всегда создается идеальный код, который создает некоторый overhead. Однако потери не такие значительные, чтобы отказываться от этого;

  • Экспертиза разработчиков. Хоть разработчики и будут писать на одном языке, но вероятно иногда придется разбираться с задачами на стыке нескольких платформ. Соответственно, для хорошей команды необходимо знать помимо Kotlin средства разработки платформ, под которые они компилируют;

  • Ограничения. Когда вы пишете общий код, он должен работать везде одинаково. То, что может одна платформа, но не может другая, будет недоступно. Например, в Kotlin есть метод runBlocking, который может работать в коде Kotlin, Java и Swift, но он не работает в коде JS, потому что,  цитирую: «не может быть разумно реализован в JS. Реализация, перенесенная на JS, имеет слишком много ограничений, чтобы быть полезной». Тем не менее большинство уникального для конкретной платформы функционала можно реализовать через механизм ожидаемого и фактического объявления (expect/actual классы) при необходимости.

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

Практика

Далее я постараюсь продемонстрировать, как на практике можно применить эту технологию. Для этого я написал ядро игры крестики-нолики и реализовал отображение на трех языках JS (Browser), Swift (iOS), Java (Android). Весь изложенный код будет доступен в GitHub репозитории по ссылке.

Создание проекта

Окружение

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

5c6d9063e586b192b1fb959b519af40d.jpeg

Вы можете использовать любое удобное для вас окружение. Помните, что для разработки под iOS с использованием Kotlin Multiplatform нужен Command Line Tools (обычно ставится при установке xcode), а по политике Apple данный набор инструментов можно установить только на продукцию Apple. Можно попробовать поставить на Hackintosh, хотя, честно говоря, я не пробовал.

Скачать Command Line Tools можно с официального сайта Apple для разработчиков.

Создание проекта

Для создания проекта воспользуемся Kotlin Multiplatform Wizard. Выбираем следующие параметры:

  • Выбираем текущую стабильную версию Kotlin;

  • Для целевых сборок выбираем JVM,  JS,  iOS (при желании остальные можно будет добавить позже);

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

Должна получиться приблизительно такая конфигурация:

9e1bfbd7e3286882606d007d8a21fcd7.jpeg

Далее скачиваем и разархивируем в вашу рабочую область, и открываем в вашей любимой IDE, но лучше использовать IntelliJ IDEA, так как в ней есть некоторые вспомогательные функции для работы с Kotlin Multiplatform. Например, при реализации ожидаемого и фактического объявления классов IntelliJ IDEA поможет вам сгенерировать эти классы.

Kotlin Multiplatform Wizard по умолчанию сразу создает файлы с примерами классов и тестов: Platform.kt, CommonTest, PlatformTest. Нам они не понадобятся, поэтому эти файлы сразу удалим. По итогу должна получиться вот такая структура проекта:

876733718002ef906b09be2199c2f778.jpeg

Настройка сборки

Далее необходимо настроить параметры сборки gradle для корректной компиляции под выбранные нами платформы. Для этого немножко изменим три файла.

В первом файле ./settings.gradle.kts явно укажем версию kotlin multiplatform.

Код ./settings.gradle.kts

pluginManagement {
    repositories {
        google()
        gradlePluginPortal()
        mavenCentral()
    }
    // Укажем явно, так как в боле ранних версиях есть была ошибка при создании *d.ts компилятором IR
    plugins {
        kotlin("multiplatform") version "1.7.10"
    }
}

rootProject.name = "tictactoe"
include(":core")

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

Код ./gradle.properties

# Добавляем группу и версию остальное оставляем без изменений
group=ru.tikhon.tictactoe
version=1.0.0
# Остальное можно оставить так как есть
#Gradle
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
#Kotlin
kotlin.code.style=official
#MPP
kotlin.mpp.enableCInteropCommonization=true

B третьем файле ./core/build.gradle.kts настроим параметры сборки для конкретных платформ.

Для jvm указываем версию java, с которой должен быть совместим полученный на выходе *.jar-файл. Можно поставить и выше, это зависит от того где вы собираетесь подключать этот *.jar-файл.

Для js ставим тип компилятора IR. IR нужен для того, чтобы после компиляции мы получили *.d.ts, который необходим для поддержки типизации TypeScript. Он также нужен для того, чтобы ide проиндексировала его, и при написании кода вы могли импортировать классы автоматически средствами ide.

Укажем название фреймворка для iOS сборок, остальное оставляем без изменений.

Код ./core/build.gradle.kts

plugins {
    kotlin("multiplatform")
}

kotlin {
    /* JVM Target Configuration */
    jvm {
        compilations.all {
            kotlinOptions.jvmTarget = "1.8"
        }
        withJava()
    }
    /* JS Target Configuration */
    js(IR) {
        binaries.executable()
        browser()
    }
    /* iOS Target Configuration */
    iosX64 {
        binaries {
            framework {
                baseName = "GAMEFramework"
            }
        }
    }
    iosArm64 {
        binaries {
            framework {
                baseName = "GAMEFramework"
            }
        }
    }
    iosSimulatorArm64 {
        binaries {
            framework {
                baseName = "GAMEFramework"
            }
        }
    }

    sourceSets {
        /* Main source sets */
        val commonMain by getting
        val jvmMain by getting
        val jsMain by getting
        val iosMain by creating
        val iosX64Main by getting
        val iosArm64Main by getting
        val iosSimulatorArm64Main by getting

        /* Main hierarchy */
        jvmMain.dependsOn(commonMain)
        jsMain.dependsOn(commonMain)
        iosMain.dependsOn(commonMain)
        iosX64Main.dependsOn(iosMain)
        iosArm64Main.dependsOn(iosMain)
        iosSimulatorArm64Main.dependsOn(iosMain)

        /* Test source sets */
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test"))
            }
        }
        val jvmTest by getting
        val jsTest by getting
        val iosTest by creating
        val iosX64Test by getting
        val iosArm64Test by getting
        val iosSimulatorArm64Test by getting

        /* Test hierarchy */
        jvmTest.dependsOn(commonTest)
        jsTest.dependsOn(commonTest)
        iosTest.dependsOn(commonTest)
        iosX64Test.dependsOn(iosTest)
        iosArm64Test.dependsOn(iosTest)
        iosSimulatorArm64Test.dependsOn(iosTest)
    }
}

Написание кода

Если вас не интересует разработка ядра игры, вы можете написать простой класс с выводом сообщения по пути core/src/commonMain/kotlin/org/rubicon/game/SayHello.kt с таким содержанием…

package org.rubicon.game

import kotlin.js.JsExport

@JsExport
class SayHello {
    fun say() {
        println("Hello from Kotlin Multiplatform")
    }
}

…и перейти непосредственно к пункту «Реализация использования SDK на платформах», где описано, как скомпилировать код и добавить полученный в результате компиляции артефакт в нативные проекты платформ, которые мы ранее выбрали.

Либо вы можете рассмотреть пример реализации игры и заодно познакомиться с примерами взаимодействия SDK с платформами и некоторыми особенностями написания общего кода.

Проектирование

Для работы мультиплатформенности наш код должен удовлетворять следующим характеристикам:

  • Должен быть полный набор классов необходимый для реализации игры;

  • Этот набор классов не должен зависеть от какой-либо платформы.

Логика работы должна быть приблизительно такая:

  1. Пользователь кликает кнопку «Играть»;

  2. View создает экземпляр игры;

  3. Подписывается на изменения игры;

  4. Запускает игру;

  5. Обновляет представление;

  6. Начинается игра.

0c56b64f3be61d6a599e3043eaa300c8.jpeg

Реализация SDK

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

Далее вы увидите много кода, я не хотел весь его дублировать в статью и думал сделать проект, где меньше кода, но проект с условным hello world не показал бы кейса реального использования. Поэтому кто не хочет листать портянку лучше проследуйте на github в проект tictactoe.

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

События в нашей модели должны нести некоторую информацию. Например, если View подпишется на изменение состояния поля, то при срабатывании подписки оно должно знать где именно был поставлен крестик или нолик. Поэтому сначала объявим IEvent и реализуем его имплементацию Event.

interface core/src/commonMain/kotlin/org/rubicon/game/IEvent.kt

package org.rubicon.game

import kotlin.js.JsExport

/**
 * Событие, которое получит пользователь при срабатывании подписки
 * @property type Тип события
 * @property source Источник события
 */
@JsExport
interface IEvent, S> {
    val type: T
    val source: S

    /**
     * Данный метод при деструктуризации вернет тип события
     * */
    operator fun component1(): T = this.type

    /**
     * Данный метод при деструктуризации вернет источник события
     * */
    operator fun component2(): S = this.source
}

В данном листинге кода мы можем наблюдать аннотацию @Throws@JvmOverloads@JsName@JsExport — она нужна за тем, чтобы сущность, для которой указывается эта аннотация, была доступна на JS, и названия методов сохранялись. Так же наиболее часто можно встретить аннотацию @Throws@JvmOverloads@JsName. Она нужна для указания наименования в случаях, когда оно не может произойти автоматически — в JS нет перегрузки, поэтому для перегружаемых методов нужно давать разные названия.

Также нередко можно заметить аннотацию @Throws@JvmOverloads, которая позволяет реализовать аргументы по умолчанию посредством перегрузки в Java. Плюс в приведенных ниже листингах кода можно встретить аннотацию @Throws — она добавляет в целевую сборку информацию о том, что метод может быть завершен с ошибкой и стоит обработать исключение.

class core/src/commonMain/kotlin/org/rubicon/game/impl/events/Event.kt

package org.rubicon.game.impl.events

import org.rubicon.game.IEvent
import kotlin.js.JsExport

@JsExport
open class Event, S>(
    override val type: T,
    override val source: S
) : IEvent

После реализации события мы можем перейти к объявлению IEventEmitter и его реализации EventEmitter. Для более стандартизированной работы с данными объявим у IEventEmitter два обобщенных типа:

  • T — это тип события, который может быть только перечисляемым типом;

  • S — это источник события.

Interface ./core/src/commonMain/kotlin/org/rubicon/game/IEventEmitter.kt

package org.rubicon.game

import kotlin.js.JsExport


@JsExport
class DuplicateListenersException(override val message: String) : Throwable(message)

/**
 * Тип подписки
 * данная функция обратного вызова должна принимать на вход событие
 * с которым она и будет работать
 * */
typealias EventCallBack = (IEvent) -> Unit

/**
 * Создает возможность подписываться на события объекта
 * */
@JsExport
interface IEventEmitter, S> {
    /**
     * Создать подписку
     * */
    @Throws(DuplicateListenersException::class)
    fun on(eventType: T, callBack: EventCallBack)

    /**
     * Удалить подписку
     * */
    fun del(eventType: T, callBack: EventCallBack)

    /**
     * Создать подписку, которая будет удалена после срабатывания события
     * */
    fun once(eventType: T, callBack: EventCallBack)
}

Class core/src/commonMain/kotlin/org/rubicon/game/impl/events/EventEmitter.kt

package org.rubicon.game.impl.events

import org.rubicon.game.DuplicateListenersException
import org.rubicon.game.EventCallBack
import org.rubicon.game.IEvent
import org.rubicon.game.IEventEmitter
import kotlin.js.JsExport

@JsExport
abstract class EventEmitter, S> : IEventEmitter {
    private val listeners: LinkedHashMap>> = linkedMapOf()

    override fun on(eventType: T, callBack: EventCallBack) {
        val eventTypeCollection = listeners.getOrPut(eventType) {
            arrayListOf()
        }
        if (callBack in eventTypeCollection) {
            throw DuplicateListenersException("Данная подписка уже существует.")
        }
        eventTypeCollection.add(callBack)
    }

    override fun del(eventType: T, callBack: EventCallBack) {
        listeners[eventType]?.remove(callBack)
    }

    override fun once(eventType: T, callBack: EventCallBack) {
        this.on(eventType) {
            this.del(eventType, callBack)
            callBack(it)
        }
    }

    protected fun emit(event: IEvent) {
        listeners[event.type]?.forEach {
            it(event)
        }
    }
}

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

Соответственно перечисляем события, которые могут возникнуть во время игры:

  • GAME_OVER — окончание игры. Будет кидаться в случаях, если какая-то сторона выиграла, или все доступные ходы закончились;

  • CHANGE_CELL — событие изменения состояния ячейки игрового поля (того квадрата, где рисуется крестик или нолик). Будет кидаться, когда в ячейку ставится крестик, нолик или сбросится состояние.

Enum core/src/commonMain/kotlin/org/rubicon/game/impl/events/GameEventType.kt

package org.rubicon.game.impl.events

import kotlin.js.JsExport

/**
 * События, которые могут случиться во время игры
 * */
@JsExport
enum class GameEventType {
    /**
     * Игра окончена
     * */
    GAME_OVER,

    /**
     * Изменилось состояние поля
     * */
    CHANGE_CELL
}

Теперь у нас есть достаточный набор классов и интерфейсов чтобы, чтобы объявить интерфейс IGame и указать методы, которые можно будет вызывать на стороне представления

Interface core/src/commonMain/kotlin/org/rubicon/game/IGame.kt

package org.rubicon.game

import org.rubicon.game.impl.events.GameEventType
import kotlin.js.JsExport

/**
 * Игра
 * Реализует главную логику игры кидает события по окончанию игры или при изменении состояния игрового поля
 * */
@JsExport
interface IGame : IEventEmitter {
    /**
     * Запускает игру
     *
     * Кидает события об изменении каждой игровой ячейки для отрисовки поля
     * */
    fun play()

    /**
     * Сбрасывает состояние игры
     * */
    fun reset()
}

Но для непосредственной реализации самой имплементации IGame нам не хватает еще четырех сущностей:

  1. PlayerType — перечисляемый класс, обозначающий игрока, крестик, нолик или его отсутствие.

Enum core/src/commonMain/kotlin/org/rubicon/game/impl/PlayerType.kt

package org.rubicon.game.impl

import kotlin.js.JsExport

/**
 * Тип игрока
 * */
@JsExport
enum class PlayerType {
    /**
     * Игрок отсутствует
     * */
    NONE,

    /**
     * Игрок, который играет крестиками
     * */
    CROSS,

    /**
     * Игрок, который играет ноликами
     * */
    ZERO
}
  1. IFieldCell — ячейка игрового поля, которая будет отвечать за квадратик с крестиком или ноликом на игровом поле. Экземпляр реализации этого класса будет отдаваться представлению для отрисовки его состояния и для принятия клика.

Interface core/src/commonMain/kotlin/org/rubicon/game/IFieldCell.kt

package org.rubicon.game

import org.rubicon.game.impl.PlayerType
import kotlin.js.JsExport

/**
 * Элемент игрового где должны отображаться крестики и нолики
 * */
@JsExport
interface IFieldCell {
    /**
     * При клике меняется состояние кнопки на состояние активного игрока
     * */
    fun click()

    /**
     * Возвращает значение каким игроком было нажато
     * */
    fun getState(): PlayerType

    /**
     * Возвращает координату по оси X
     * */
    fun getX(): Int

    /**
     * Возвращает координату по оси Y
     * */
    fun getY(): Int
}

И, конечно же, реализуем имплементацию FieldCell.

Class core/src/commonMain/kotlin/org/rubicon/game/impl/FieldCell.kt

package org.rubicon.game.impl

import org.rubicon.game.IFieldCell
import org.rubicon.game.impl.events.Event
import org.rubicon.game.impl.events.EventEmitter
import kotlin.js.JsExport

@JsExport
enum class FieldCellEvents {
    CLICK
}

@JsExport
class FieldCell(
    private val x: Int,
    private val y: Int,
    private var state: PlayerType = PlayerType.NONE
) : EventEmitter(), IFieldCell {

    /**
     * Во время игры состояние может быть изменено либо сброшено
     * */
    internal fun changeState(state: PlayerType) {
        this.state = state
    }

    override fun click() {
        // Мы кинем событие, что пользователь кликнул только если уже не кликнуто раньше
        if (this.state == PlayerType.NONE) {
            this.emit(Event(FieldCellEvents.CLICK, this))
        }
    }

    override fun getState(): PlayerType = this.state

    override fun getX(): Int = this.x
    override fun getY(): Int = this.y
}
  1. GameCellEvent — событие обозначающее изменения состояния ячейки игрового поля с указанием этой самой ячейки.

Class core/src/commonMain/kotlin/org/rubicon/game/impl/events/GameCellEvent.kt

package org.rubicon.game.impl.events

import org.rubicon.game.IGame
import org.rubicon.game.impl.FieldCell
import kotlin.js.JsExport

@JsExport
class GameCellEvent(
    source: IGame,
    // Ячейка игрового поля
    val fieldCell: FieldCell
) : Event(GameEventType.CHANGE_CELL, source)
  1. GameOverEvent — событие, обозначающее окончание игры с информацией о победителе и выигрышной комбинации игровых ячеек.

Class core/src/commonMain/kotlin/org/rubicon/game/impl/events/GameOverEvent.kt

package org.rubicon.game.impl.events

import org.rubicon.game.IGame
import org.rubicon.game.impl.FieldCell
import org.rubicon.game.impl.PlayerType
import kotlin.js.JsExport

@JsExport
class GameOverEvent(
    game: IGame,
    winLine: List? = null
) : Event(GameEventType.GAME_OVER, game) {
    // Приводим к Array для того чтобы в JS этот метод вернул нативный массив
    val winLine = winLine?.toTypedArray()

    // Так как выигрышная конфигурация может быть только одной стороны мы можем получить победителя оттуда
    val winner: PlayerType = winLine?.first()?.getState() ?: PlayerType.NONE
}

И последний, самый интересный класс в нашем SDK, но не последний в этой статье — это класс Game. Этот класс реализует основную логику игры, суть должна быть понятна всем, кто играл в крестики-нолики. У нас есть поле 3×3, выигрывает та сторона, которая поставит свои знаки в трёх клетках подряд.

Логика работы такая, что мы:

  • создаем поле из ячеек;

  • подписываемся на событие клика этих ячеек;

  • при событии клика ставим им значение кто кликнул с уведомлением об этом;

  • проверяем законченность игры;

  • кидаем событие об окончании игры.

Class core/src/commonMain/kotlin/org/rubicon/game/impl/Game.kt

package org.rubicon.game.impl

import org.rubicon.game.IEvent
import org.rubicon.game.IGame
import org.rubicon.game.impl.events.EventEmitter
import org.rubicon.game.impl.events.GameCellEvent
import org.rubicon.game.impl.events.GameEventType
import org.rubicon.game.impl.events.GameOverEvent
import kotlin.js.JsExport

@JsExport
class Game : EventEmitter(), IGame {
    // Сразу инициализируем поле 3 x 3 и подписываемся на событие клика
    private val field: List> = List(3) { y ->
        List(3) { x ->
            FieldCell(x, y).also {
                it.on(FieldCellEvents.CLICK, this::onClickFieldCell)
            }
        }
    }

    // Содержит информацию о том, кто в данный момент времени ходит
    private var currentPlayer: PlayerType = PlayerType.CROSS

    // Все возможные выигрышные позиции
    private val winConditions: List> by lazy {
        this.getAllWinConditions()
    }

    /**
     * Обработчик события клика по ICell
     *
     * Проставляет ячейки кликнувшего игрока, переключает ход игрока и проверяет завершенность игры
     *
     * */
    private fun onClickFieldCell(event: IEvent) {
        if (this.currentPlayer != PlayerType.NONE) {
            val fieldCell: FieldCell = event.source
            this.changeFiledCellState(fieldCell, currentPlayer)
            this.turnMove()
            this.checkGameOver()
        }
    }

    /**
     * Меняет состояние ячейки и сообщает об этом событием
     * */
    private fun changeFiledCellState(fieldCell: FieldCell, state: PlayerType) {
        fieldCell.changeState(state)
        this.emit(GameCellEvent(this, fieldCell))
    }

    /**
     * Переключает текущего игрока на его противника или на сторону по умолчанию
     * */
    private fun turnMove() {
        this.currentPlayer = when (currentPlayer) {
            PlayerType.CROSS -> PlayerType.ZERO
            PlayerType.ZERO,
            PlayerType.NONE -> PlayerType.CROSS
        }
    }

    /**
     * Проверяет статус окончания игры
     * */
    private fun checkGameOver() {
        val winLine = this.winConditions.find { this.isMatchLine(it) }
        val event = when {
            winLine != null -> GameOverEvent(this, winLine)
            !hasFreeCells() -> GameOverEvent(this)
            else -> null
        }
        if (event != null) {
            this.currentPlayer = PlayerType.NONE
            this.emit(event)
        }
    }

    /**
     * Проверяет наличие ячеек на которые еще не кликали
     * */
    private fun hasFreeCells(): Boolean {
        return this.field.any { row ->
            row.any { it.getState() == PlayerType.NONE }
        }
    }

    /**
     * Проверяет что переданная комбинация ячеек выбрана одним игроком
     * */
    private fun isMatchLine(row: List): Boolean {
        if (row.isEmpty()) return false
        val firstState = row.first().getState()
        if (firstState == PlayerType.NONE) return false
        return row.all { it.getState() == firstState }
    }

    /**
     * Возвращает все возможные выигрышные комбинации
     * */
    private fun getAllWinConditions(): List> {
        val result: ArrayList> = arrayListOf()
        val diagonal: ArrayList = arrayListOf()
        val diagonalOpposite: ArrayList = arrayListOf()
        for (i in 0..2) {
            // Добавляем все строчки
            result.add(
                listOf(
                    this.field[i][0],
                    this.field[i][1],
                    this.field[i][2],
                )
            )
            // Добавляем все колонки
            result.add(
                listOf(
                    this.field[0][i],
                    this.field[1][i],
                    this.field[2][i],
                )
            )
            // Добавляем варианты по диагонали
            diagonal.add(this.field[i][i])
            diagonalOpposite.add(this.field[2 - i][i])
        }
        result.add(diagonal)
        result.add(diagonalOpposite)
        return result
    }

    /**
     * Сбрасывает игру чтобы представление получило обновление состояний и отрисовало поле
     * */
    override fun play() {
        // Суть метода заключается в том чтобы отрисовать ячейки игрового поля
        // Поэтому reset сюда подходит
        this.reset()
    }

    /**
     * Сбрасывает состояние ячеек на состояние ни кем не выбрано
     * */
    override fun reset() {
        this.currentPlayer = PlayerType.CROSS
        for (y in 0 until this.field.size) {
            for (x in 0 until this.field[y].size) {
                this.changeFiledCellState(this.field[y][x], PlayerType.NONE)
            }
        }
    }
}

Всё, SDK готов, а это значит, что мы можем начинать использовать его на всех платформах в которые может компилироваться Kotlin. Начнем с JS.

Реализация использования SDK на платформах

Реализация на JS

Чтобы получить итоговую сборку под платформу JS, нам нужно выполнить команду gradle jsBrowserProductionWebpack:

./gradlew jsBrowserProductionWebpack

После чего у вас должен появиться js-файл с названием модуля core/build/distributions/core.js.

d0af22e539310a494a1c73c461787dea.jpeg

Или если вы хотите получить профит от типизации, то тогда нужно брать директорию по пути build/js/packages/tictactoe-core, которая по своей структуре является обычным npm пакетом c исходным кодом на JS, файлом package.json и *.d.ts-файлом внутри.

Обе сборки будут содержать все написанные нами ранее классы только на js. Соответственно, мы можем добавить один из этих артефактов в любой js-проект. Что мы сейчас и сделаем.

Проект на js будет максимально простой — это одна index.html страница, к которой подключается собранный ранее SDK посредством добавления html-тега script. В этом же файле реализованы несложные стили игрового поля и процесс взаимодействия с SDK. Уверен, что можно было бы реализовать это красивее и чище, но это лишь демонстрация возможности работы с SDK, а не паттернов проектирования.

index.js



    
    Tic Tac Toe
    
    
    
    


Вот так просто (с точки зрения потребителя SDK) мы интегрировали написанный на Kotlin код в JS и получили вот такой работающий результат.

560e4eb708350f69bdd392019c0f3075.gif

Теперь сделаем то же самое для приложения под iOS.

Реализация на Swift

Как и для js нам нужно скомпилировать наш код на Kotlin в Framework, с которым будет дружить Xcode, прежде чем создавать приложение. Для этого нужно выполнить следующие команды:

./gradlew linkReleaseFrameworkIosArm64 linkReleaseFrameworkIosX64 &&
xcodebuild -create-xcframework 
-framework ./core/build/bin/iosArm64/releaseFramework/GAMEFramework.framework 
-framework ./core/build/bin/iosX64/releaseFramework/GAMEFramework.framework 
-output ./core/build/bin/core.xcframework

Где inkReleaseFrameworkIosArm64 и linkReleaseFrameworkIosX64 — это команды компиляции кода под целевые архитектуры iOS, а xcodebuild — склейка полученных от первой команды *.framework в один *.xcframework, который необходим нам для подключения его к проекту, чтобы получить возможность использовать классы написанные на kotlin в swift-приложении.

После того как получили .xcframework мы можем перейти к реализации приложения на swift. Для этого создадим простое iOS-приложение в Xcode.

6c3a099c911c8f4145b8379c2dc1b49c.jpeg

Указываем название приложения, наименование организации и выбираем интерфейс SwiftUI.

1a252ecd2225566aa93d3449364a5fc4.jpeg

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

  1. Кликаем на название проекта, чтобы перейти к его настройкам;

  2. Далее кликаем + в пункте Frameworks, Libraries and Embedded content. В открывшемся окне нажимаем Add other и выбираем созданную ранее папку ./core/build/bin/core.xcframework;

  3. Если все было сделано правильно — наш framework появится в таблице.

721004de4eb71434fed3bf3a5d452234.jpeg

После того как framework был успешно добавлен, мы можем импортировать его в swift коде. Название для импорта должно соответствовать baseName, который мы указали в ./core/build.gradle.kts. Далее нам осталось только написать код работы с SDK и отображением интерфей

© Habrahabr.ru