пятница, 25 мая 2012 г.

Кодогенерация - какая она?

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

Кодогенерация - какая она?


Не знаю как у Вас, но у меня вновь созданный, абсолютно пустой проект вызывает чувство необузданного первобытного ужаса :( Чтобы его пересилить и начать писать хоть что-то, проще всего начинать с самого простого и понятного. Например, мне абсолютно понятно, что наш проект реализует Content Provider и никак не сможет обойтись без трех служебных таблиц, описанных в последней диаграмме прошлой статьи. Опишем эти таблицы в Java-коде:

package com.WhiteRabbit.Codegen;

...
public final class Db {

    private final static String SCHEME              = "content://";
    public  final static String SITE                = "com.WhiteRabbit.";
    public  final static String APP                 = "Codegen";
    public  final static String AUTHORITY           = SITE + "provider." + APP;
    public  final static String DIR_TYPE            = "vnd.android.cursor.dir/vnd." + AUTHORITY + ".";
    public  final static String ITEM_TYPE           = "vnd.android.cursor.item/vnd." + AUTHORITY + ".";
   
    public  final static String DATA_PATH           = "data";
    public  final static Uri    DATA_CONTENT_URI    = Uri.parse(SCHEME + AUTHORITY + "/" + DATA_PATH);
    public  final static String DATA_ITEM_TYPE      = ITEM_TYPE + DATA_PATH;
    public  final static String ID_COLUMN_NAME      = "_id";
    public  final static String VERSION_COLUMN_NAME = "version";
   
    private Db() {}
   
    public final static class DataType {
        public final static int INTEGER = 1;
        public final static int TEXT = 2;
    }
   
    public final static class Table implements BaseColumns {
       
        public final static String PATH = "Table";
        public final static Uri    CONTENT_URI =  Uri.parse(SCHEME + AUTHORITY + "/" + PATH);
        public final static String CONTENT_DIR_TYPE = DIR_TYPE + PATH;
        public final static String CONTENT_ITEM_TYPE = ITEM_TYPE + PATH;
       
        private Table() {}
       
        public final static String TABLE_NAME = "_table";
       
        public final static String NAME_COLUMN = "table_name";
        public final static String ALIAS_COLUMN = "alias_name";
    }

    public final static class Version implements BaseColumns {
       
        public final static String PATH = "Version";
        public final static Uri    CONTENT_URI =  Uri.parse(SCHEME + AUTHORITY + "/" + PATH);
        public final static String CONTENT_DIR_TYPE = DIR_TYPE + PATH;
        public final static String CONTENT_ITEM_TYPE = ITEM_TYPE + PATH;
       
        private Version() {}
       
        public final static String TABLE_NAME = "_version";
       
        public final static String VER_DATE_COLUMN = "version_date";
    }

    public final static class Column implements BaseColumns {
       
        public final static String PATH = "Column";
        public final static Uri    CONTENT_URI =  Uri.parse(SCHEME + AUTHORITY + "/" + PATH);
        public final static String CONTENT_DIR_TYPE = DIR_TYPE + PATH;
        public final static String CONTENT_ITEM_TYPE = ITEM_TYPE + PATH;
       
        private Column() {}
       
        public final static String TABLE_NAME = "_column";
       
        public final static String TABLE_ID_COLUMN = "table_id";
        public final static String TYPE_ID_COLUMN = "data_type";
        public final static String IS_NOT_NULL = "is_not_null";
        public final static String NAME_COLUMN = "column_name";
        public final static String ALIAS_COLUMN = "alias_name";
    }
}


Далее тривиально создаем эти таблицы в ProviderMetadata:

package com.WhiteRabbit.Codegen;

...
public abstract class ProviderMetadata extends ContentProvider {
   
    protected DatabaseHelper dbHelper;
   
    static abstract class DatabaseHelper extends SQLiteOpenHelper {
       
        private int version;

        DatabaseHelper(Context context, String name, int version) {
            super(context, name, null, version);
            this.version = version;
        }

        @Override
        public void onCreate(SQLiteDatabase db) {
           
            db.execSQL("CREATE TABLE " + Db.Version.TABLE_NAME + " (" +
                       Db.Version._ID + " INTEGER PRIMARY KEY," +
                       Db.Version.VER_DATE_COLUMN + " TEXT);");
           
            db.execSQL("CREATE TABLE " + Db.Table.TABLE_NAME + " (" +
                       Db.Table._ID + " INTEGER PRIMARY KEY," +
                       Db.VERSION_COLUMN_NAME + " INTEGER NOT NULL," +
                       Db.Table.ALIAS_COLUMN + " TEXT NOT NULL," +
                       Db.Table.NAME_COLUMN + " TEXT NOT NULL);");
           
            db.execSQL("CREATE TABLE " + Db.Column.TABLE_NAME + " (" +
                       Db.Column._ID + " INTEGER PRIMARY KEY," +
                       Db.VERSION_COLUMN_NAME + " INTEGER NOT NULL," +
                       Db.Column.TABLE_ID_COLUMN + " INTEGER NOT NULL," +
                       Db.Column.TYPE_ID_COLUMN + " INTEGER NOT NULL," +
                       Db.Column.IS_NOT_NULL + " INTEGER NOT NULL," +
                       Db.Column.ALIAS_COLUMN + " TEXT NOT NULL," +
                       Db.Column.NAME_COLUMN + " TEXT NOT NULL);");
           
            onUpgrade(db, 0, version);
        }
    }
}


Теперь начинается самое интересное. Создаем файл, который впоследствии будем генерировать автоматически (соответственно, он должен быть построен таким образом, чтобы максимально облегчить эту кодогенерацию):

package com.WhiteRabbit.Codegen;

import android.content.ContentValues;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;

public abstract class ProviderPatches extends ProviderMetadata {

    protected static final int DATABASE_VERSION = 1;
   
    static class PatchedDatabaseHelper extends DatabaseHelper {

        PatchedDatabaseHelper(Context context, String name, int version) {
            super(context, name, version);
        }
       
        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, 
                              int newVersion) {
            if (oldVersion < 1)  {patch_1(db);}
        }
       
        private void patch_1(SQLiteDatabase db) {
           
            long tableId;
            addVersion(db, 1);
           
            tableId = addTable(db, 
                          Db.TestTable.TABLE_NAME, 
                         "Db.TestTable.TABLE_NAME", 1);
            addColumnNotNull(db, tableId, 
                          Db.DataType.TEXT, Db.TestTable.FIELD_COLUMN, 
                         "Db.TestTable.FIELD_COLUMN", 1);
            createTable(db, tableId);
           
            ContentValues values;
            values = new ContentValues();
            values.put(Db.TestTable.FIELD_COLUMN, "test");
            addData(db, Db.TestTable.TABLE_NAME, values, 1);
        }
    }
}

Класс этот абстрактный (от него будет наследоваться класс, реализующий функционал нашего Content Provider-а). Также можно заметить, что он определяет номер версии БД DATABASE_VERSION, но имя БД мы до сих пор пока нигде не указывали (поскольку весь ранее описанный код достаточно универсален и может использоваться в различных проектах).
Для создания таблиц и заполнения их данными мы не используем SQL-запросы непосредственно, а используя некое API, создаем описание таблиц или данных, после чего генерируем их в соответствии с этим описанием (такой подход существенно обегчит нам задачу кодогенерации). Определим функции, испольованные нами для генерации данных в ранее созданном классе ProviderMetadata:

package com.WhiteRabbit.Codegen;

...
public abstract class ProviderMetadata extends ContentProvider {

    ...
    static abstract class DatabaseHelper extends SQLiteOpenHelper {

        protected void addVersion(SQLiteDatabase db, long id) {
            Date now = new Date();
            SimpleDateFormat formatter = new SimpleDateFormat("MM/dd/yyyy");
            db.execSQL("insert into " + Db.Version.TABLE_NAME + "(" +
                    Db.Version._ID + "," +
                    Db.Version.VER_DATE_COLUMN + ") values " +
                    "(" + Long.toString(id) + ", '" + 
                    formatter.format(now) + "')");
        }
       
        protected long getVersion(SQLiteDatabase db) {
            Cursor q = db.rawQuery("select " + Db.Version._ID + " " +
                    "from " + Db.Version.TABLE_NAME + " " +
                    "where " + Db.Version.VER_DATE_COLUMN + " is null",
                    null);
            if (q.moveToFirst()) {
                int idColumn = q.getColumnIndex(Db.Version._ID);
                return q.getInt(idColumn);
            }           
            ContentValues values = new ContentValues();
            return db.insert(Db.Version.TABLE_NAME, Db.Version._ID, values);
        }
       
        public int closeVersions(SQLiteDatabase db) {
            Date now = new Date();
            SimpleDateFormat formatter = new SimpleDateFormat("MM/dd/yyyy");
            ContentValues values = new ContentValues();
            values.put(Db.Version.VER_DATE_COLUMN, formatter.format(now));
            return db.update(Db.Version.TABLE_NAME, values,
                        Db.Version.VER_DATE_COLUMN + " is null", null);
        }

        protected long addTable(SQLiteDatabase db, String name, 
            String alias, int ver) {
            ContentValues values = new ContentValues();
            values.put(Db.Table.NAME_COLUMN, name);
            values.put(Db.Table.ALIAS_COLUMN, alias);
            values.put(Db.VERSION_COLUMN_NAME, ver);
            return db.insert(Db.Table.TABLE_NAME, Db.Table._ID, values);
        }
       
        protected void addColumn(SQLiteDatabase db, long tableId, int typeId, 
            String name, String alias, int ver, boolean isNotNull, 
            boolean isDefNull) {
            ContentValues values = new ContentValues();
            values.put(Db.Column.TABLE_ID_COLUMN, tableId);
            values.put(Db.Column.TYPE_ID_COLUMN, typeId);
            values.put(Db.Column.NAME_COLUMN, name);
            values.put(Db.Column.ALIAS_COLUMN, alias);
            values.put(Db.Column.IS_NOT_NULL, isNotNull?1:0);
            values.put(Db.VERSION_COLUMN_NAME, ver);
            db.insert(Db.Column.TABLE_NAME, Db.Column._ID, values);
        }
       
        protected void addColumn(SQLiteDatabase db, long tableId, 
            int typeId, String name, String alias, int ver) {
            addColumn(db, tableId, typeId, name, alias, ver, false, false);
        }
        protected void addColumnNotNull(SQLiteDatabase db, long tableId, 
            int typeId, String name, String alias, int ver) {
            addColumn(db, tableId, typeId, name, alias, ver, true, false);
        }

        protected void addData(SQLiteDatabase db, String tableName, 
            ContentValues values, int ver) {
            values.put(Db.VERSION_COLUMN_NAME, ver);
            db.insert(tableName, "_id", values);
        }
       
        public String getTableName(SQLiteDatabase db, String tableId) {
            String r = "";
            Cursor q = db.rawQuery("select " + Db.Table.NAME_COLUMN + " " +
                    "from " + Db.Table.TABLE_NAME + " " +
                    "where " + Db.Table._ID + " = ?",
                    new String [] {tableId});
            if (q.moveToFirst()) {
                int nameColumn = q.getColumnIndex(Db.Table.NAME_COLUMN);
                r = q.getString(nameColumn);
            }           
            return r;
        }
       
        protected void createTable(SQLiteDatabase db, long tableId) {
            StringBuilder sb = new StringBuilder();
            sb.append("CREATE TABLE ");
            sb.append(getTableName(db, Long.toString(tableId)));
            sb.append(" (");
            sb.append(Db.ID_COLUMN_NAME);
            sb.append(" INTEGER PRIMARY KEY, ");
            sb.append(Db.VERSION_COLUMN_NAME);
            sb.append(" INTEGER NOT NULL ");
            Cursor q = db.rawQuery("select " + Db.Column.NAME_COLUMN + ", " +
                    Db.Column.TYPE_ID_COLUMN + ", " +
                    Db.Column.IS_NOT_NULL + " " +
                    "from " + Db.Column.TABLE_NAME + " " +
                    "where " + Db.Column.TABLE_ID_COLUMN + " = ?",
                    new String [] {Long.toString(tableId)});
            for (q.moveToFirst();!q.isAfterLast();q.moveToNext()) {
                sb.append(", ");
                int nameColumn = q.getColumnIndex(Db.Column.NAME_COLUMN);
                int typeColumn = q.getColumnIndex(Db.Column.TYPE_ID_COLUMN);
                int notNullColumn = q.getColumnIndex(Db.Column.IS_NOT_NULL);
                String name = q.getString(nameColumn);
                int type = q.getInt(typeColumn);
                int notNull = q.getInt(notNullColumn);
                sb.append(name);
                switch (type) {
                    case Db.DataType.INTEGER:
                        sb.append(" INTEGER");
                        break;
                    case Db.DataType.TEXT:
                        sb.append(" TEXT");
                        break;
                }
                if (notNull > 0) {
                    sb.append(" NOT NULL");
                }
            }
            sb.append(");");
            db.execSQL(sb.toString());
        }
        ...
       
    }
}

Реализация API описания метаданных достаточно прямолинейна. Некоторую сложность здесь представляет лишь реализация createTable, представляющая собой решение задачи кодогенерации "в миниатюре", создающая запрос create table и выполняющая его "на лету". Также, не забываем привязывать данные, создаваемые в addData к версии патча.

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

воскресенье, 20 мая 2012 г.

Кодогенерация - для чего она?

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

Кодогенерация - для чего она?

Давайте представим себе, что мы разрабатываем некоторое тиражируемое приложение (например игру), использующее БД для хранения своих данных. Android не балует нас разнообразием, и когда мы говорим "встроенная БД", мы имеем в виду SQLite и только её :)

Следует отдать должное разработчикам Android, использование SQLite на этой платформе весьма продуманно, особенно в том, что касается тиражирования изменений. Разработчику достаточно увеличить номер версии БД в исходном коде и внести необходимые дополнения в метод onUpgrade, чтобы, при последующем запуске обновленного приложения, необходимые изменения были внесены во встроенную БД каждого пользователя автоматически.

Это действительно очень удобно, но ... что делать если мы используем какой-то визуальный редактор (например при проектировании уровней)? Наш редактор уровней, в процессе работы, внесет изменения в ЛОКАЛЬНУЮ встроенную БД, используемого нами устройства, минуя исходный код нашего приложения! Как нам передать эти изменения пользователям?

Конечно, если мы используем эмулятор, не составляет труда скопировать с него сгенерированную нашим приложением БД, но, во первых, пользоваться эмулятором может быть неудобно, и во вторых, что нам делать с полученным файлом БД дальше?

В этом месте нам на помощь приходит кодогенерация. Действительно решение тривиально - если для тиражирования изменений БД необходимо внести новый КОД в метод onUpgrade, почему бы не сделать это автоматически, учтя при этом все изменения, внесенные в локальную БД приложением?
Android позволяет записать текстовый файл в файловую систему устройства (при наличии WRITE_EXTERNAL_STORAGE uses-permission в манифесте), вот мы и сформируем файл, содержащий код на языке Java. Впоследствии этот файл можно будет забрать с устройства и добавить его в исходный код проекта, решив, тем самым, проблему тиражирования изменений.

Мы уже почти наполовину решили нашу задачу :) Осталось только спроектировать как все это будет выглядеть.


Мы разделим реализацию нашего Content Provider-а на три Java-класса. ProviderMetadata будет содержать реализацию DatabaseHelper, предоставляющую базовую функциональность, ProviderPatches будет содержать тиражируемые изменения и формироваться кодогенератором, а CodegenProvider реализует API используемое приложением для взаимодействия с БД.

Здесь следует обратить внимание, что константу DATABASE_NAME содержащую имя используемой нами БД, мы определяем на самом верхнем уровне в CodegenProvider, а DATABASE_VERSION в классе, содержащем реализацию патчей. Класс ProviderMetadata свободен от какой-то конкретике и может повторно использоваться в любых наших приложениях.

Все это прекрасно, но с чем именно будет работать наш обобщенный и до предела отвязанный от конкретики (почти сферический) ProviderMetadata? Как бы смешно это не звучало, но для того чтобы программа могла писать программы, ей необходимы данные, которые описывают данные :) то есть МЕТАданные.

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


Таблица version будет содержать список патчей, сформированных для нашего приложения. В таблице table мы будем хранить описание таблиц, а в таблице column - столбцов (привязанные к патчам БД). Для таблиц мы будем сохранять их имена, а для столбцов, дополнительно описания типов данных (data_type) и признака NOT NULL (is_not_null). Для чего нам потребуется поле alias_name, я расскажу позже.

Подведем итог


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

понедельник, 14 мая 2012 г.

Ориентируемся в пространстве

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

Ориентируемся в пространстве


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

package com.WhiteRabbit.Sensors;

...
public class MainView extends View  {

    private int maxX = 10000;
    private int maxY = 10000;
    private int sz;
    private int x0;
    private int y0;
    private double alfa = Math.PI / 2;

    public MainView(Context context) {
        super(context);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if ((MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) ||
            (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.UNSPECIFIED)) {
            setMeasuredDimension(maxX, maxY);
        } else {
            int szX = MeasureSpec.getSize(widthMeasureSpec);
            int szY = MeasureSpec.getSize(heightMeasureSpec);
            if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST) {
                if (szX > maxX) {
                    szX = maxX;
                }
            }
            if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.AT_MOST) {
                if (szY > maxY) {
                    szY = maxY;
                }
            }
            setMeasuredDimension(szX, szY);
            x0 = szX / 2;
            y0 = szY / 2;
            if (szX < szY) {
                sz = szX / 3;
            } else {
                sz = szY / 3;
            }
        }
    }

    @Override
    protected void onDraw(Canvas c) {
        c.drawColor(Color.BLACK);
        Paint p = new Paint();
        p.setColor(Color.WHITE);
        p.setAntiAlias(true);
        c.drawCircle(x0, y0, sz, p);
        p.setColor(Color.BLACK);
        double x1 = x0 - (sz /2) * Math.cos(alfa);
        double y1 = y0 - (sz /2) * Math.sin(alfa);
        c.drawCircle((float)x1, (float)y1, sz / 2, p);
    }   
}


В этом коде, в переменной sz сохраняется радиус большого кругляшка, в x0 и y0 его координаты, а в alfa угол (в радианах), в направлении которого будет изображен маленький кругяшок. Добавим созданное View в Activity:

package com.WhiteRabbit.Sensors;

...
public class SensorsActivity extends Activity {

    MainView view;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        LinearLayout.LayoutParams containerParams =
                new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.WRAP_CONTENT, 0.0F);
        LinearLayout root = new LinearLayout(this);
        root.setOrientation(LinearLayout.VERTICAL);
        root.setBackgroundColor(Color.LTGRAY);
        root.setLayoutParams(containerParams);
        view = new MainView(this);
        root.addView(view);
        setContentView(root);
    }
}


и, запустив на выполнение, пронаблюдаем результат:



Далее, нам нужен какой-то инструмент, для изменения угла alfa при отображении маленького кружочка. От акселерометра мы будем получать три числовых значения, одно из которых (z) будем игнорировать. Судорожно вспоминаем тригонометрию и понимаем, что alfa = arctg(y/x), а также то, что его аргумент будет претерпевать особенности при x = 0. К счастью, он нас позаботились разработчики Java, определив в Math замечательную функцию atan2:

package com.WhiteRabbit.Sensors;

...
public class MainView extends View  {

    ...
    private double eps = 0.1;

    ...
    public void setXY(float x, float y) {
        if (Math.sqrt(x*x + y*y) < eps) {
            alfa = Math.PI / 2;
        } else {
            alfa = -Math.atan2(y, x);
        }
        invalidate();
    }
}


Константу eps определяем на случай получения очень уж маленьких значений x и y. После этого, проводим на эмуляторе небольшое тестирование, передавая в setXY различные значения для проверки корректности расчета alfa. Убедившись, что все считается правильно, переходим к самой интересной части, работе с акселерометром.
На самом деле, нам требуется всего лишь получить акселерометр из списка предоставляемых устройством сенсоров, написать обработчики его событий и добавить код подписки/отписки на события акселерометра. Попутно зафиксируем ориентацию экрана, чтобы они не вертелся и не мешал нам эстетически наслаждаться:

package com.WhiteRabbit.Sensors;

public class SensorsActivity extends Activity implements SensorEventListener {

    ...
    SensorManager mSensorManager = null;
    Sensor mSensor = null;

    @Override
    public void onCreate(Bundle savedInstanceState) {

    ...
        mSensorManager = (SensorManager)getSystemService(SENSOR_SERVICE);
        if (mSensorManager != null) {
            List<Sensor> sensors = mSensorManager.getSensorList(Sensor.TYPE_ALL);
            if(sensors.size() > 0) {
                for (Sensor sensor : sensors) {
                    switch(sensor.getType()) {
                        case Sensor.TYPE_ACCELEROMETER:
                            if(mSensor == null) mSensor = sensor;
                            break;
                        default:
                            break;
                    }
                }       
            }
        }
    }

    @Override
    public void onAccuracyChanged(Sensor event, int value) {}

    @Override
    public void onSensorChanged(SensorEvent event) {
        if (view == null) return;
        switch(event.sensor.getType()) {
            case Sensor.TYPE_ACCELEROMETER:
                view.setXY(event.values[0], event.values[1]);
                break;
        }
    }   
   
    @Override
    public void onStart() {
        super.onStart();
        if (mSensor != null) {
            mSensorManager.registerListener(this, mSensor, SensorManager.SENSOR_DELAY_GAME);
        }
    }
   
    @Override
    public void onPause() {
        super.onPause();
        if (mSensor != null) {
            mSensorManager.unregisterListener(this);
        }
    }   
}


Осталось добавить в манифест uses-feature, чтобы никто не запустил наше приложение при отсутствии акселерометра:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.WhiteRabbit.Sensors"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="15" />
    <uses-feature android:name="android.hardware.sensor.accelerometer"
              android:required="true" />

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name=".SensorsActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>


и можно экспортировать apk-ку.