Pull to refresh

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

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

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

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


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

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

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

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

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

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


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

После того как окружение настроено, создаём SBT проект и добавляем android-sdk-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.

Начнём с азов


Интерфейс в 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.

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


В рамках одного 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.

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


Наверняка при написании приложения нам понадобится вывести какой-нибудь список. Посмотрим, как 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.

Фрагменты


Если мы хотим использовать фрагменты в нашем приложении, то и тут 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

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


В 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.

Какая же 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.

Заключение


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

Спасибо за внимание.
Tags:
Hubs:
+9
Comments 15
Comments Comments 15

Articles