вторник, 12 июня 2012 г.

Мой DSL для проектов на Android


Пару месяцев назад перепала мне задачка пилить в одиночку довольно объемный проект на Android. Сам по себе проект был несложный, но количество повторяющегося кода зашкаливало. При том самое обидное, что стандартными средствами как-то избавиться от повторений не представляется возможным. Тогда я и наваял первоначальную версию небольшой генерилки андроид-кода.

Больше всего, конечно, в андроиде меня раздражает xml. Логично, что формочки лучше описывать в декларативном стиле; понятно, что java для этого не годится; разумеется, xml здесь уместен. Но, блин, как же отстойно все это выглядит! Элементарного цикла в нем не сделать, а уж про реюзабельность вообще молчу. Она там, конечно, есть в виде подключаемых пользовательских компонентов. Но все эти механизмы страшно сосут даже по сравнению с инструментами разработки под iPhone!

Самое главное, чего я хотел добиться созданием своего DSL, – это избавиться от богомерзкого xml. Я хотел описывать UI в декларативном стиле на языке общего назначения. Оказалось, что на Clojure – это очень даже просто, достаточно воспользоваться библиотекой hiccup.

Например, вместо этого
<Button android:id="@+id/helloWorldBtn" 
    android:background="@drawable/blue_button_slt"    
    android:layout_height="wrap_content"
    android:layout_width="wrap_content"
    android:text="Push me!"
    android:textColor="#fff"
    android:textSize="10dp"
    android:textStyle="bold"/>

писать такой код:

(Button "helloWorldBtn" wc wc
 [:text "Push me!"
  :textColor "#fff"
  :textSize "10dp"
  :textStyle "bold"
  :background (drawable "blue_button_slt")])

На данном этапе выигрыша в коде почти никакого нет. Зато уже можно сделать, например, так:

(defn large-button [id attributes image & [ txt]]
  (VLayout nil wc wc (conj attributes :gravity "center")
           (ImageButton id "60dp" "70dp"
                        [:background (drawable image)])
           (if-not (empty? txt)
             (TextView nil "125dp" "18dp" 
                        [:text txt
                         :textSize "16dp"
                         :gravity "center"
                         :textColor "#0096c1"]))))

Как видите, введение языка общего назначения вместо xml позволяет легко создавать функции. Напротив, создание реюзабельного компонента под андроид – процедура более долгая и трудоемкая. Еще очень удобно использовать if-ы, loop-ы и прочие возможности языка. Кроме того, код на Clojure писать проще, ведь можно заранее задать для компонентов сокращенные варианты атрибутов либо рациональные настройки. Например, в приведенном выше исходнике wc означает "wrap_content".

Кроме генерации xml на основе приведенного выше примере, можно сгенерировать и код на java. Это позволит автоматизировать многие, часто повторяемые операции.

Рассмотрим пример формы с текстовой строкой и двумя кнопками.

(defn layout []
  (html
   (VLayoutFP nil [:background (drawable "home_bgr")]
    (HLayoutFP nil []
     (VLayoutFP nil []
      (TextView nil wc wc [:text "Demo text"
                 :textColor "#09bbaa"])
      (Button "helloWorldBtn" wc wc [:text "Push me!"
                       :textColor "#fff"
                       :textSize "10dp"
                       :textStyle "bold"
                       :background (drawable "blue_button_slt")
                       :code (->java {}
                                (def ^:String hello "Hello")
                                (def world "world")
                                (System.out.println (+ hello " " world)))])
      (Button "demoBtn" wc wc [:text "Push me!"
                     :textColor "#fff"
                     :textSize "10dp"
                     :textStyle "bold"
                     :background (drawable "blue_button_slt")
                     :code (->java {}  
                              (startActivity
                                (new android.content.Intent this OrderScreen.class)))])
      )))))

(generate-activity layout "com.adsl.demo" "MyDemoActivity")
 
Будет создана связка файлов MVC. Генерятся классы MyDemoActivityModel, MyDemoActivityView, MyDemoActivityController и связываются таким образом, что они сразу готовы к использованию как единая сущность. Будет создана заготовка метода, который вызывается во вьюхе при изменении модели.

public void update(Observable modelObj, Object arg) {
    initialize();
    MyDemoActivityModel model = (MyDemoActivityModel)modelObj;
}
 
В классе MyDemoActivityView будут созданы атрибуты:

private Button _helloWorldBtn;
private Button _demoBtn;

Позже они инициализируются таким образом:

_helloWorldBtn = (Button) findViewById(R.id.helloWorldBtn);
_demoBtn = (Button) findViewById(R.id.demoBtn);


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

View.OnClickListener listener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (v.equals(_helloWorldBtn)) {
            controller.helloWorldBtnActionPerformed(MyDemoActivityView.this);
            return;
            }
            if (v.equals(_demoBtn)) {
            controller.demoBtnActionPerformed(MyDemoActivityView.this);
            return;
            }
        }
        };

_helloWorldBtn.setOnClickListener(listener);
_demoBtn.setOnClickListener(listener); 
 
Еще одна забавная штука, которую я добавил скорее по приколу, – это компиляция из моего псевдо-лиспа в java. Кнопке можно задать атрибут :code и прописать туда такое выражение:

(->java {}
         (def ^:String hello "Hello")                  ; Объявить стринговую переменную
         (def world "world")                           ; Объявить нетипизированную переменную
         (System.out.println (+ hello " " world)))   ; Вызвать функцию с параметрами

Генератор преобразует эти выражения в java-код и вставит его в тело метода:

public void helloWorldBtnActionPerformed(MyDemoActivityView view) {
       String hello = "Hello";
       Object world = "world";
       System.out.println(hello + " " + world);
}
 
Вообще, такой транслятор с псевдо-лиспа в java мне понадобился в основном затем, чтобы можно было по нажатию кнопки сделать простую инициализацию объекта либо показать другой активити.

Например:

(->java {} (startActivity (new android.content.Intent this OrderScreen.class)))
 
На выходе будет:

public void demoBtnActionPerformed(MyDemoActivityView view) {
       startActivity(new android.content.Intent(this, OrderScreen.class));
} 

Сам транслятор содержится в файле clj2java.clj, а главная функция выглядит так:
(defn process
  ([data raw-code]
     (let [code (enrich-with-data data raw-code)]
       (if (list? code)
         (let [elem1 (first code)]
           (cond
            (= elem1 'def) (process-definition code)
            (= elem1 'defn) (process-func-definition code)
            (= elem1 'do) (process-command-sequence (next code))
            (= elem1 'set!) (process-assignment (next code))
            (= elem1 'if) (process-if (next code))
            (= elem1 'return) (process-return (next code))
            (= elem1 '->) (process-method-call (next code))
            (= elem1 'lambda-interface) (process-lambda-interface)
            (= elem1 'lambda) (process-lambda code)
            (= elem1 'call) (process-call (next code))
            (= elem1 'loop) (process-loop (next code))
            (= elem1 'new) (process-new (next code))
            :else (process-function-call code)))
         (process-single-value code))))
  ([raw-code] (process {} raw-code)))

Как видите, транслятор умеет не так уж и мало и позволяет писать более краткий, по сравнению с java, код.

Сам же язык описания интерфейсов построен с применением барьеров абстракции, как об этом рассказывается в SICP и On Lisp. Весь код содержится в файле util.clj.

На нижнем уровне абстракции находятся различные служебные функции и ядро всех будущих компонентов – функция виджет:
(defn widget [type id width height params-raw & more] . . . )  

Все прочие виджеты создаются на основе этой функции, например, так:

(defn Button [id width height & [params]]
  (widget "Button" id width height (apply android params)))

(defn HLayoutWC [id & [params & more]]
  (widget "LinearLayout" id "wrap_content" "wrap_content" 
          (merge {"xmlns:android" "http://schemas.android.com/apk/res/android"
                  (andr-attr "orientation") "horizontal"}
                 (apply android params))
          more))

Верхний уровень абстракции – кастомные виджеты и хелперы, которые создаются на основе стандартных виджетов. Например, синяя кнопка выглядит так: 
 
(defn BlueButton [id width height & [params]]
  (Button id width height (flatten (conj params [:background (drawable "blue_button_slt")
                                                   :textColor "#fff"
                                                   :textSize "10dp"
                                                   :textStyle "bold"]))))  

А хелперы для отступа выглядят так:

(defn padding-top [top & more]
  (HLayout nil wc wc [:paddingTop top]
           more))

(defn padding-left [left & more]
  (HLayout nil wc wc [:paddingLeft left]
           more))
  
Саму генерилку описывать особо нет смысла, т.к. она достаточно простая.

Плюсы подхода:
  1. Сокращение кода описания активити в три-четыре раза. Чем больше и сложнее описываемая форма – тем больше выигрыш.
  2. Гораздо более простой способ создания абстракций при помощи обыкновенных функций. В результате значительно повышается реюзабельность кода.
  3. Значительно облегчается чтение и модификация кода UI по сравнению с использованием xml.

Минусы:
  1. Сгенеренный код выглядит, порой, написанным copy-paste-ом.
  2. При перегенерации кода теряется код, написанный вручную в сгенеренных ранее файлах.

Вообще, я с минусами придумал бороться следующим образом. Во-первых, не лазить в сгенеренные файлы вообще. Если нужно добавить тело метода или еще какую функциональность, то нужно наследоваться от сгенеренных классов. Это избавляет от необходимости переносить вручную набитый код в новую версию нагенеренных классов. Кроме того, раз не нужно больше лазить в сгенеренные классы, то отпадает требование о красоте их кода, и автоматический copy-paste становится допустимым. Главное, чтобы его не было на уровне самого DSL.

2 комментария: