Пишем комикс-приключение на Kotlin
Всем привет! Сегодня вас ждет легкая статья, которая расскажет как написать простую мобильную игру-викторину на Kotlin. Здесь я наглядно покажу как выглядит Kotlin для мобильной разработки и предложу свои идеи о том, как можно структурировать подобный проект. Что же, не буду томить вас графоманией, вперед!
Сейчас вы увидите остов идеи, которая ярко воспылала, но быстро прогорела. Мы с моим другом-дизайнером придумали сделать простую мобильную игру в текстовом формате. Жанр планировался приключенческий, а скупой текст должен был быть подогрет уникальным картинками в определенном стиле. К сожалению, дальше скелета приложения дело не продвинулось, поэтому я решил вынести его на публику. Вдруг кто-то найдет новые мысли. Сразу оговорюсь, вряд ли проект можно назвать серьезным решением, и для действительно больших приложений, возможно, стоит рассмотреть более сложные абстракции. Приложение стоит воспринимать как некий MVP.
Структура папок
Для начала поговорим о структуре папок. Вряд ли здесь будет что-то инновационное, но я считаю структуру папок в проекте одной из самых важных и интересных вещей в программировании.
Структура папок в приложенииВ корне проекта лежит 2 Activity, которые у нас будут использоваться в приложении.
StartActivity отвечает за стартовый экран приложения, где можно начать игру и потенциально разместить какие-то глобальные элементы управления (настройки, кнопочки «поделиться» и т.п.).
MainActivity, которую корректнее было бы назвать как-то вроде GameActivity, будет отвечать за рендер вопросов викторины, вариантов ответов и соответствующих картинок.
Папка Entity содержит некоторые доменные сущности. Файл json с самим сюжетом игры, о котором я расскажу чуть позже, будет превращаться как раз в набор моделей из папки Entity.
Папка dao (от сокращения data access object) в проекте нужна для содержания сущностей, которые предоставляют доступ к данным. Именно здесь будут лежать классы, которые будут отвечать за хранение данных в Runtime, и классы, которые смогут превратить набор структурированных данных в объекты.
Папка core будет содержать объекты, которые относятся непосредственно к ходу игры. По итогу таких объектов вышло достаточно мало, однако здесь кроется потенциал для расширения.
Папка UI отвечает, как многие уже догадались, за какие-то интерфейсные сущности. В частности, сюда стоит помещать presenter-ы для наших activity.
Модель данных
В нашем MVP сами данные представляют собой json-файл, который содержит массив объектов. Вот пример одного из таких объектов:
{
"id": 2,
"type": "Q",
"question": "После получаса ходьбы вы наткнулись на затушенное кострище. Здесь были люди!",
"answers": [
{
"next_question_id": 5,
"answer": "Попытаться обнаружить следы"
},
{
"next_question_id": 3,
"answer": "Потрогать, вдруг кострище еще теплое"
}
]
}
Давайте разберем, что здесь для чего:
Id — это некоторый уникальный идентификатор. Не буду вновь повторяться и рассказывать для чего вообще существуют уникальные идентификаторы. Лишь скажу, что в приложении он будет использоваться для поиска следующего вопроса
type — строка состоящая из одного символа. Литера Q означает, что это шаг типа «question» и он имеет возможность перехода к следующему вопросу. Существуют еще типы F (Fail) и S (Success). Это особые типы вопросов, которые говорят нам о том, что игра закончена поражением или победой
question — просто текст вопроса, который будет выведен на экране
answers — массив ответов на текущий вопрос. Он может содержать от 0 до 4 ответов. Массив пуст, когда текущий вопрос имеет тип F или S. В объектах ответов мы имеем поля, указывающие на следующий вопрос, и текст ответа, который будет отображаться на экране
JSON файл с игрой содержится в папочке assests. Подразумевалось, что изначально данные игры будут храниться непосредственно в приложении. Однако, теоретически, мы можем получать этот JSON по сети, сохраняя его локально или в sqllite. Либо же мы можем организовать общение приложения по сети с некоторым сервером, сохраняя этот же протокол.
Тут же, в модели данных, можно прикрепить ссылки на картинки, если мы их хотели бы менять. Пока это логика не реализована, она лишь планировалась. Однако ассоциацию между картинкой и вопросом хранить лучше именно здесь.
Управление игрой и ее состоянием
Для начала, давайте разберемся как мы будем хранить модель данных в runtime. Для этого был придуман интерфейс Store.
public interface Store {
fun getAllQuestions(): List
fun getQuestionById(id: Int): Question
fun init(context: Context): Store
}
Этот интерфейс расширяем и содержит набор методов, которые позволяют нам работать с нашими данными. Используя фабричный метод, мы можем получить какую-то определенную реализацию хранилища. В нашем MVP реализована версия хранилища с локальным JSON-ом, который превратится в коллекцию объектов, содержащихся в оперативной памяти. Теоретически, мы можем создать еще несколько реализаций, в которых, например, класс будет обращаться в sqllite или же на сервер за следующим вопросом. Все, что нам потребуется изменить в приложении — лишь написать новую реализацию хранилища.
class StoreFactory {
companion object {
fun getStore(ctx: Context): Store {
return LocalStore().init(ctx)
}
}
}
Выше я немного слукавил. Если мы хотим работать с сервером по сети или же доставать объекты из локальной базы данных, то нам определенно потребуются асинхронные операции. В противном случае, наш интерфейс будет блокироваться, и мы будем получать ошибки ANR. Для обхода этой проблемы нам нужно выполнять все IO — операции не в главном потоке. Поскольку я не очень много занимался production разработкой под андроид, то для решения этой проблемы я посоветую популярный RX. Надеюсь, более опытные разработчики под мобильные устройства предложат альтернативы.
Дальше мы создаем класс игры:
class Game {
private lateinit var store: Store
private lateinit var question: Question
fun init(context: Context) {
this.store = StoreFactory.getStore(context)
question = store.getQuestionById(1)
}
fun isGameEnd(): Boolean {
return isSuccess() || isFail()
}
fun isSuccess(): Boolean {
return question.isSuccess()
}
fun isFail(): Boolean {
return question.isFail()
}
fun chooseAnswer(numberOfAnswer: Int) {
val answer: Answer = question.getAnswers()[numberOfAnswer - 1]
question = store.getQuestionById(answer.getNextQuestionId())
}
fun getQuestion(): Question {
return question
}
}
Класс содержит в себе 2 поля: экземпляр класса Store, о котором мы уже успели поговорить, и экземпляр текущего вопроса, на котором находится пользователь. Помимо этого, игра может закончится (когда type текущего вопрос F или S). Также в данном классе существует метод, который получит выбранный ответ и поменяет текущий вопрос на следующий.
Достаточно много логики уже содержится в модели данных. Например информация о том, закончилась ли игра, как она закончилась. При таком подходе нам остается просто отобразить информацию на экране.
Слегка неочевидный пункт содержится в модели вопроса (Question.kt). Давайте обратим внимание на реализацию геттера ответов:
fun getAnswers(): List {
val list: MutableList = ArrayList(this.answers)
val shouldAdd: Int = 4 - list.size
for (i in 1..shouldAdd) {
list.add(Answer("", -1))
}
return list
}
Из этого метода мы всегда возвращаем List с 4 вариантами ответа, даже если вопрос предполагает, что их 2. Мы просто добиваем нашу коллекцию пустыми объектами с невалидными id. На слое представления они просто не будут отображены, это не изменит логику поведения экрана. Это небольшой костыль для того, чтобы нам было проще рендерить объекты.
Рендер игры
Тут все достаточно прозаично, мы просто отображаем вопрос и 4 варианта ответа, вешаем обработчики кликов на них и даем пользователю играть. При каждом выборе ответа мы обновляем страницу с текущим вопросом, отвечает у нас за это presenter:
private fun updateView() {
if (game.isGameEnd()) {
showEndGame()
return
}
activity.setQuestion(game.getQuestion().getText())
val answers: List = game.getQuestion().getAnswers()
answers.forEachIndexed {idx, answer -> activity.setAnswer(idx + 1, answer.getText())}
}
fun chooseAnswer(number: Int) {
game.chooseAnswer(number)
updateView()
}
Ну и когда игра закончена, мы возвращаем пользователя на StartActivity, передавая результат игры (текст текущего вопроса) в Intent:
private fun showEndGame() {
val intent = Intent(activity, StartActivity::class.java).apply {
putExtra(StartActivity.RESULT_MESSAGE, game.getQuestion().getText())
}
activity.startActivity(intent)
}
Вместо заключения
Как я уже говорил, вряд ли это приложение можно назвать максимально production-ready. Здесь много допущений и мест, где срезаются углы. Это скорее MVP, скелет, который можно развить. Ниже я напишу то, что можно было бы улучшить или добавить:
Дизайн. Его тут вообще нет, есть пространство для маневра
Текущие UI-элементы неплохо отображаются на моем эмуляторе, однако на реальном смартфоне есть проблемы и конфликты с темой. Над этим можно поработать
Добавить различные картинки в модель данных, заменять картинки на экране
Стоило бы перенести тяжелые операции парсинга json — файла в фон, добавить loader на время этого процесса
Добавить возможность сохранять данные из json в sqllite и работать с этим
Ну и ссылка на полные исходники для тех, кто хочет посмотреть на проект комплексно.