- PVSM.RU - https://www.pvsm.ru -

Дружим Scala и Android с помощью Macroid

imageТак как на работе я пишу на старой доброй Enterprise Java, меня периодически тянет попробовать что-то новое и интересное. Так получилось, что в один момент этим новым и интересным оказалась Scala. И вот однажды, просматривая доклады со Scala Days, я наткнулся на доклад Ника Станченко о библиотеке под названием Macroid, которую он написал. В этой статье я попробую написать маленькое приложение для демонстрации её возможностей и рассказать об основных фишках этой библиотеки. Код приложения целиком доступен на Github [1].

Если вам захотелось узнать, как эта библиотека помогает подружить Scala и Android, добро пожаловать под кат.

Что же такое Macroid?

Macroid — это DSL на Scala макросах для работы с Android-интерфейсом. Она позволяет избавиться от традиционных проблем XML разметки, таких как:

  • Отсутствие пространства имён, так как все файлы разметки лежат в одном каталоге;
  • Избыток файлов в проекте из-за того, что у каждой модели должен быть свой файл, а также из-за дублирования моделей для разных размеров экранов и прочего;
  • Даже с разметкой в XML файле, все равно нужен код (для присвоения тех же обработчиков событий).

Macroid позволяет описывать разметку на Scala и делать это там, где удобно. А так же:

  • Добавляются абстракции, позволяющие вынести дублирующиеся куски интерфейсного кода и переиспользовать их.
  • Macroid различает AppContext и ActivityContext, хранит их отдельно друг от друга и передаёт в виде implicit значений. ActivityContext при этом хранится в виде слабой ссылки, что позволяет избежать проблем с утечкой памяти.
  • Благодаря UI Action, в который оборачиваются любые действия с интерфейсом, улучшается потокобезопасность, а также появляется возможность комбинировать эти действия и разом отправлять в поток UI на выполнение.

Ну и конечно, все плюсы Scala тоже здесь: паттерн-матчинг, функции высшего порядка, трейты, кейс классы, автоматический вывод типов и так далее.

Приступим к приложению

Первым делом нам нужно настроить окружение для разработки под Android. Процедура подробно описана на developer.android.com [2], так что тут её приводить особого смысла нет.

После того как окружение настроено, создаём SBT проект и добавляем android-sbt-plugin.

build.sbt:

import android.Keys._

//задаём версию Android
android.Plugin.androidBuild
platformTarget in Android := "android-23"

packagingOptions in Android := PackagingOptions(
Seq.empty[String],
Seq("reference.conf"),
Seq.empty[String])

name := "macroid-for-habr"

scalaVersion := "2.11.7"
javacOptions ++= Seq("-target", "1.7", "-source", "1.7")

// упростим команду сборки чтобы не писать каждый раз Android:run
run <<= run in Android

resolvers ++= Seq(
Resolver.sonatypeRepo("releases"),
"jcenter" at "http://jcenter.bintray.com"
)

// добавим linter
scalacOptions in (Compile, compile) ++=
(dependencyClasspath in Compile).value.files.map("-P:wartremover:cp:" + _.toURI.toURL) ++
Seq("-P:wartremover:traverser:macroid.warts.CheckUi")

libraryDependencies ++= Seq(
aar("org.macroid" %% "macroid" % "2.0.0-M4"),
"com.android.support" % "support-v4" % "23.1.1",
compilerPlugin("org.brianmckenna" %% "wartremover" % "0.11")
)

// Включим proguard для удаления неиспользуемого кода из библиотек при сборке
proguardScala in Android := true

plugin.sbt:

addSbtPlugin("com.hanhuy.sbt" % "android-sdk-plugin" % "1.5.13")

Добавляем простенький AndroidManifest.xml:

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="tutorial.macroidforhabr"
android:versionCode="0"
android:versionName="0.1">

<uses-sdk
android:minSdkVersion="9"
android:targetSdkVersion="23"/>

<application
android:label="Macroid for Habr"
android:icon="@drawable/android:star_big_on">

<activity
android:label="Macroid for Habr"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>

</application>
</manifest>

Осталось лишь создать класс MainActivity и подключить трейт с контекстами:

class MainActivity extends Activity with Contexts[Activity]

Получившийся проект доступен по тегу Step1 [3].

Начнём с азов

Интерфейс в Macroid складывается при помощи «Brick». Brick — это кирпичик интерфейса, представляющий собой разметку или отдельный виджет. Разметка обозначается как «layout» или просто «l», а виджеты — «widget» или «w». Например, простой LinearLayout с текстовым полем и кнопкой будет выглядеть вот так:

l[LinearLayout](
  w[TextView],
  w[Button]
) 

Ничего не мешает присваивать такие куски разметки переменным и компоновать их друг с другом там и тогда, когда это требуется:

val view = l[LinearLayout](
              w[TextView],
              w[Button]
           )

Конечно же, виджеты нужно настроить, прописать им какие-то свойства и значения, иначе в них нет никакого смысла.
В этом помогает штука под названием Tweak. Для краткости твики обозначаются оператором <~. К примеру, можно выставить текст полю и кнопке:

w[TextView] <~ text("Просто надпись"),
w[Button] <~ text("Нажми меня")

Также можно для точности прописать, что наш виджет обязательно должен быть вертикальным, при помощи твика vertical:

val view = l[LinearLayout](
              w[TextView] <~ text("Просто надпись"),
              w[Button] <~ text("Нажми меня")
           ) <~ vertical

Наконец, какая же кнопка без onClickListener. К примеру, можно изменить текст в поле по нажатию кнопки:

w[Button] <~ text("Нажми меня") <~ On.click(changeText)

Специальный макрос On, попытается вывести имя Listener из того, что написано после точки, и найти его у виджета, к которому он применён. В нашем случае, он попытается найти onClickListener у виджета Button и прописать в нём функцию changeText.

Для того чтобы поменять текст, нужно будет как-то передать виджет поля в функцию changeText. В этом нам поможет метод slot, который оборачивает виджет в Option, что позволяет безопасно привязать результат к переменной и использовать его в коде.

var textView = slot[TextView]

Конкретный виджет привязывается к слоту при помощи твика wire:

w[TextView] <~ wire(textView)

Теперь можно вернуться к написанию метода changeText:

def changeText : Ui[Any] = {
    textView <~ text("Другая надпись")
}

Метод состоит из одного твика и возвращает тот самый Ui Action, представляющий собой действие, которое будет выполнено в потоке UI.

Для того чтобы полученная разметка применилась к MainActivity, вызовем setContentView(getUi(view)) в методе onCreate. Метод getUi(view) выполнит UI код в текущем потоке и вернёт нам получившийся View, который мы и установим в ContentView нашей Activity.

Получившийся код доступен по тегу Step2 [4].

Меняем разные части одновременно

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

А что делать, если нужно запустить несколько изменений интерфейса одновременно? Тут поможет альтернатива твику, которая называется Snail и обозначается оператором <~~. Snail работает по принципу «выстрелил и забыл». К примеру, можно запустить анимацию затухания для текстового поля:

textView <~~ fadeOut(500)

Для последовательного объединения нескольких Snail в один, применяется оператор ++. Вот так может выглядеть Snail для мигания любым View:

def flashElement : Snail[View] = {
    fadeOut(500) ++ fadeIn(500) ++ fadeOut(500) ++ fadeIn(500)
}

С твиками можно проделывать аналогичные действия при помощи оператора +.

Добавим небольшой вложенный кусок линейной разметки с ещё одним текстовым полем и привяжем его к слоту:

var layout = slot[LinearLayout]

val view = l[LinearLayout](
    w[TextView] <~ text("Просто надпись") <~ wire(textView),
    w[Button] <~ text("Нажми меня") <~ On.click(changeText),
    l[LinearLayout](
        w[TextView] <~ text("Мигающий лэйаут")
    ) <~ wire(layout)
  ) <~ vertical

А теперь дадим нашей кнопке больше влияния на окружающее:

def changeText : Ui[Any] = {
    (textView <~ text("Помигаем?")) ~ (layout <~~ flashElement) ~~ (textView <~ text("Ну и хватит"))
}

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

Получившийся код доступен по тегу Step3 [5].

Списки, списки, списки

Наверняка при написании приложения нам понадобится вывести какой-нибудь список. Посмотрим, как Macroid управляется с этим.
Если требуется создать простой список, мы можем воспользоваться Listable. Трейт Listable[A, W <: View] указывает, как именно должен отображаться объект типа A при помощи виджета W, и делается это в два простых шага:

  1. Создать пустой View
  2. Заполнить его данными

Добавим метод basicListable, который будет создавать пустой TextView, заполнять его текстом при помощи твика text() и возвращать нам объект типа Listable[String, TextView]:

def basicListable(implicit appCtx: AppContext): Listable[String, TextView] = {
    Listable[String].tw { w[TextView] } { text(_) }
}

Осталось лишь добавить ListView в нашу разметку, применить к нему твик Listable.listAdapterTweak и передать этому твику простенький список из строк:

w[ListView] <~ basicListable.listAdapterTweak(contactList)

Получившийся код доступен по тегу Step4 [6].

Фрагменты

Если мы хотим использовать фрагменты в нашем приложении, то и тут Macroid есть что предложить. Для начала нам нужно переделать нашу Activity во FragmentActivity:

class MainActivity extends FragmentActivity

Создадим фрагмент и вынесем туда все, что относится к ListView:

class ListableFragment extends Fragment with Contexts[Fragment]{

def basicListable(implicit appCtx: AppContext): Listable[String, TextView] = {
    Listable[String].tw { w[TextView] } { text(_) }
}
override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle): View = getUi {
  l[LinearLayout](
    w[ListView] <~ basicListable.listAdapterTweak(contactList) <~ matchWidth
  )
}

Заодно приберёмся немного в коде и вынесем список контактов в отдельный трейт Contacts, а твик flashElement в трейт Tweaks.

Фрагмент вставляется в разметку при помощи «fragment» или «f» и оборачивается во FrameLayout методом framed.

Фрагменту нужны Id и Tag, и для их создания предлагается использовать трейт IdGeneration и классы IdGen и TagGen. Достаточно лишь прописать желаемые tag и id после точки в Tag и Id и передать их методу framed:

f[ListableFragment].framed(Id.contactList, Tag.contactList)

Получившийся код доступен по тегу Step5 [7]

И снова списки

В Android при прокручивании списка с элементами, состоящими из кучки виджетов, можно столкнуться с проблемами производительности из-за того, что адаптер часто использует поиск по id(findViewById()). В качестве средства борьбы с этой проблемой предлагается шаблон View Holder.

В Macroid есть трейт SlottedListable, который воплощает этот шаблон, и для списков со сложными элементами автор библиотеки советует использовать именно его. Для использования этого трейта нужно переопределить один тип и два метода.

Создадим класс ContactListable внутри которого переопределим:

  1. class Slots, который и является тем самым View Holder и содержит в себе слоты для всех нужных View
  2. метод makeSlots, в котором создаётся разметка
  3. метод fillSlots, в котором разметка заполняется переданными значениями

Создадим кейс класс Contact(name:String, phone: String, photo: Int), который представляет собой минималистичный контакт с именем, телефоном и фото.

Создадим метод fullContactList, который вернёт нам список из трёх контактов.

Для того чтобы список контактов был больше похож на настоящий, сделаем фотографии в виде RoundedBitmapDrawable при помощи твика:

def roundedDrawable(id: Int)(implicit appCtx: AppContext) = Tweak[ImageView]({
  val res = appCtx.app.getResources
  val rounded = RoundedBitmapDrawableFactory.create(res,BitmapFactory.decodeResource(res, id))
  rounded.setCornerRadius(Math.min(rounded.getMinimumWidth(),rounded.getMinimumHeight))
  _.setImageDrawable(rounded)
})

А также добавим пару твиков для всяческих визуальных улучшений вроде ImageView.ScaleType.CENTER_INSIDE, уменьшающего изображение до нужного размера.

Осталось лишь создать фрагмент SlottedListableFragment, который будет показывать этот список контактов.

Два списка контактов на одном экране это многовато, поэтому я предлагаю заменить старый ListableFragment, на новенький и блестящий SlottedListableFragment. Для этого напишем новый твик, заменяющий один фрагмент, используя трейт FragmentApi, любезно предоставленный Macroid:

def replaceListableWithSlotted = Ui {
  activityManager(this).beginTransaction().replace(Id.contactList,new SlottedListableFragment,Tag.slottedList).commit()
}

А затем добавим этот твик в конец нашего многострадального метода changeText, который теперь будет называться changeTextAndShowFragment.

Получившийся код доступен по тегу Step6 [8].

Какая же Scala без Akka

Говоря о Scala, нельзя обойти стороной акторы и библиотеку Akka.

Macroid предлагает использовать акторы для передачи сообщений между фрагментами и предоставляет для этого трейт AkkaFragment. На каждый AkkaFragment создаётся свой актор. Акторы живут пока живёт Activity, фрагменты же живут в своём обычном жизненном цикле. Таким образом, акторы могут присоединяться к контролируемому ими интерфейсу и отделяться от него.

Для начала необходимо добавить зависимости macroid-akka и akka-actor в build.sbt. А также прописать в нём правила для Proguard:

proguard

proguardOptions in Android ++= Seq(
"-keep class akka.actor.Actor$class { *; }",
"-keep class akka.actor.LightArrayRevolverScheduler { *; }",
"-keep class akka.actor.LocalActorRefProvider { *; }",
"-keep class akka.actor.CreatorFunctionConsumer { *; }",
"-keep class akka.actor.TypedCreatorFunctionConsumer { *; }",
"-keep class akka.dispatch.BoundedDequeBasedMessageQueueSemantics { *; }",
"-keep class akka.dispatch.UnboundedMessageQueueSemantics { *; }",
"-keep class akka.dispatch.UnboundedDequeBasedMessageQueueSemantics { *; }",
"-keep class akka.dispatch.DequeBasedMessageQueueSemantics { *; }",
"-keep class akka.dispatch.MultipleConsumerSemantics { *; }",
"-keep class akka.actor.LocalActorRefProvider$Guardian { *; }",
"-keep class akka.actor.LocalActorRefProvider$SystemGuardian { *; }",
"-keep class akka.dispatch.UnboundedMailbox { *; }",
"-keep class akka.actor.DefaultSupervisorStrategy { *; }",
"-keep class macroid.akka.AkkaAndroidLogger { *; }",
"-keep class akka.event.Logging$LogExt { *; }"
)

Создадим простенький фрагмент с одной кнопкой, цвет текста в которой можно изменить, вызвав метод receiveColor. Имя актора будет передаваться в этот фрагмент при помощи аргументов:

class TweakerFragment extends AkkaFragment with Contexts[AkkaFragment]{

  lazy val actorName = getArguments.getString("name")
  lazy val actor = Some(actorSystem.actorSelection(s"/user/$actorName"))

  var button = slot[Button]

  def receiveColor(textColor: Int) = button <~ color(textColor)

  def tweak = Ui(actor.foreach(_ ! TweakerActor.TweakHim))

  override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle) = getUi {
    l[FrameLayout](
      w[Button] <~ wire(button) <~ text("TweakHim") <~ On.click(tweak)
    )
  }
}

Создадим актор, который будет управлять этим фрагментом:

class TweakerActor(var tweakTarget:Option[ActorRef]) extends FragmentActor[TweakerFragment]{
  import TweakerActor._

  // receiveUi обрабатывает отсоединение/присоединение к интерфейсу. После этого мы можем делать все что хотим
  def receive = receiveUi andThen {
    case TweakHim => tweakTarget.foreach(_ ! TweakYou)

    case TweakYou => 
      val chosenColor = randomColor
      tweakTarget = Some(sender)

      //withUi предоставляет нам возможность взаимодействовать с управляемым фрагментом
      withUi(f => f.receiveColor(chosenColor))

    case SetTweaked(target) => tweakTarget = Some(target)

    // можно добавить своё поведение для присоединения/отсоединения в дополнение к стандартному
    case AttachUi(_) =>
    case DetachUi =>
  }

  def randomColor: Int = {
    val random = new Random()
    val red = random.nextInt(255)
    val green = random.nextInt(255)
    val blue = random.nextInt(255)
    Color.rgb(red, green, blue)
  }
}

В MainActivity добавим трейт AkkaActivity. Добавим переменные для пары одинаковых акторов и системы:

val actorSystemName = "tutorialsystem"
lazy val tweakerOne = actorSystem.actorOf(TweakerActor.props(None), "tweakerOne")
lazy val tweakerTwo = actorSystem.actorOf(TweakerActor.props(Some(tweakerOne)), "tweakerTwo")

Инициализируем акторы в методе onCreate и добавим фрагменты для акторов в разметку:

l[LinearLayout](
  f[TweakerFragment].pass("name" -> "tweakerOne").framed(Id.tweakerOne, Tag.tweakerOne),
  f[TweakerFragment].pass("name" -> "tweakerTwo").framed(Id.tweakerTwo, Tag.tweakerTwo)
) <~ horizontal

В методе onStart передадим первому актору сообщение, а в onDestroy пропишем закрытие системы:

override def onStart() = {
  super.onStart()

  tweakerOne ! TweakerActor.SetTweaked(tweakerTwo)
}

override def onDestroy() = {
  actorSystem.shutdown()
}

В результате мы получаем два фрагмента с кнопками, которые могут скомандовать друг другу сменить цвет текста.

Получившийся код доступен по тегу Step7 [9].

Заключение

На этом я заканчиваю свою статью. Конечно, какие-то возможности Macroid остались за кадром, но я надеюсь, что мне удалось пробудить желание попробовать на зуб эту библиотеку или хотя бы присмотреться к ней. В данный момент библиотека развивается ребятами из команды 47 Degrees и доступна на Github [10].

Спасибо за внимание.

Автор: cheshirrrr

Источник [11]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/android/115865

Ссылки в тексте:

[1] Github: https://github.com/cheshirrrr/macroid-for-habr

[2] developer.android.com: http://developer.android.com/intl/ru/training/basics/firstapp/index.html

[3] Step1: https://github.com/cheshirrrr/macroid-for-habr/tree/Step1

[4] Step2: https://github.com/cheshirrrr/macroid-for-habr/tree/Step2

[5] Step3: https://github.com/cheshirrrr/macroid-for-habr/tree/Step3

[6] Step4: https://github.com/cheshirrrr/macroid-for-habr/tree/Step4

[7] Step5: https://github.com/cheshirrrr/macroid-for-habr/tree/Step5

[8] Step6: https://github.com/cheshirrrr/macroid-for-habr/tree/Step6

[9] Step7: https://github.com/cheshirrrr/macroid-for-habr/tree/Step7

[10] Github: https://github.com/47deg/macroid

[11] Источник: https://habrahabr.ru/post/279973/