вторник, 2 апреля 2019 г.

Android Studio 3.3 и «ароматы». Создание разных вариантов программ на одном исходнике.

На днях у меня возникла задача сделать три одинаковых андроид-приложения, которые различались бы только одним строковым ресурсом и иконкой, и которые можно было бы одновременно поставить на одном смартфоне.Можно было бы просто написать в коде все три строчки, и комментируя каждый раз ненужные, компилировать три варианта приложения. Ну, конечно не забывать при этом делать тоже самое в android:label в манифесте, чтобы менять название приложений и applicationId в build.gradle модуля app, чтобы менять название пакета приложения. Иначе одна программа, будет при установке затирать другую. Ну и иконку, конечно, каждый раз придётся переделывать.

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

Делаем варианты приложений на основе единого кода и ресурсов.Такие варианты в терминологии Android Studio называются «ароматами».

Создаём структуру ароматов


Нажимаем «File → Project Structure...» или просто сочетание клавиш Ctrl+Alt+Shift+S. Слева выбираем модуль «app» а справа вкладку «Flavors». В первом столбце у нас прописана наша основная конфигурация, называемая «drfaultConfig». Пока она одна, она и является основным и единственным вариантом приложения. Теперь добавляем в этот столбец столько конфигураций, сколько вариантов приложений нам нужно. При этом основная конфигурация становится базой для всех остальных, и на её основе проект строится уже не будет. Теперь проект будет строится на добавленных вариантах (ароматах).

Справа для каждого аромата отображаются поля, которые можно поменять относительно базовой конфигурации. Если их оставить незаполненными, их значения при сборке проекта будут подхватываться из базовой конфигурации «drfaultConfig». В моём случае для каждого из ароматов я просто прописал свой applicationId, чтобы смартфон видел все полученные в итоги приложения, как разные.

Затем открываем build.gradle модуля app, и видим в нём вновь созданную конструкцию:

productFlavors {
    flavor {
    }
    flavor1 {
    }
    flavor2 {
    }
}

Здесь Android Studio автоматизировал построение этого шаблона настроек, но не до конца. Доработаем шаблон следующим образом:

flavorDimensions "default"

productFlavors {
    flavor {
        flavorDimensions "default"
    }
    flavor1 {
        flavorDimensions "default"
    }
    flavor2 {
        flavorDimensions "default"
    }
}

Здесь мы указали, что все три аромата будут находиться в одной группе default. Это простейший вариант. Количество ароматов определятся количеством возможных отношений разных групп друг с другом. Это не просто группы, это как бы «измерения». Об этом вы можете почитать в первоисточнике.

Создаём наполнение ароматов


Открываем обозреватель проекта в режиме Project. Открываем директорию app → src. В этой директории видим директорию main. Это директория содержит директории кода и ресурсов (java и res) базовой конфигурации «drfaultConfig», о которой мы говорили выше. Теперь, рядом с директорией базовой конфигурации main нужно создать директории с именами наших ароматов, в которые длбавить только те ресурсы и код, которые будут отличаться от базовых.

Например, в директорию flavor я не помещаю ничего (можно её вообще не делать), и тогда этот аромат будет полностью повторять исходную конфигурацию. А в две оставшиеся директории flavor1 и flavor2 я кидаю только файлы, которые отличаются. Структуру вложенных директорий сохраняю.

Необязательно всё делать вручную. Например, отличающуюся иконку можно сделать следующим образом, даже предварительно не создавая никаких директорий. Нажимаем в обозревателе проекта правой кнопкой мыши на директории app и из контекстного меню выбираем New → Image Asset. Создаём иконку, нажимаем Next и в поле Res Directory выбираем имя того аромата, для которого эта иконка предназначена. После этого нажимаем Finish. Директория выбранного аромата создаётся автоматически и в неё помещается наша иконка.

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

Компиляция


Теперь можно осуществить компиляцию и запуск каждого из ароматов, переключаясь между ними через вкладку Build Variants, расположенную в левом нижнем углу Android Studio. Для запуска в эмуляторе или на реальном подключенном к компьютеру устройстве используем Debug-версии.

Для чистовой компиляции apk-файла того или иного аромата с подписью эту вкладку не используем, а выбор аромата осуществляем в окне «Build → Generated Signed Bundle or APK» на заключительной стадии в поле Build Variants. Подписанный apk-файл создастся по указанному в поле выше пути, в подпапке с именем аромата.

Строки, не подлежащие переводу


Ну и, в заключение небольшой нюанс про строковые ресурсы, которые не нужно переводить на другие языки. Например, строки с адресами сайтов или именами приложений. По умолчанию, если строка оставлена без перевода, Android Studio выдаёт ошибку. Чтобы этого не происходило, такие строки надо помечать, как не подлежащие переводу. Это можно сделать так:

<resources>
    <string name="url" translatable="false">https://site.ru</string>
    <string name="labelapp" translatable="false">CoolApp</string>
</resources>

Или даже так:

<resources translatable="false">
    <string name="url">https://site.ru</string>
    <string name="labelapp">CoolApp</string>
</resources>
 

Смена имени подписанного apk-файла


Для того, чтобы переименовать apk-файл после компиляции, добавим в build.gradle модуля app перед блоком ароматов следующий код:

applicationVariants.all { variant ->
        if (variant.productFlavors[0].name.equals("flavor") && variant.buildType.name.equals("release")) {
            variant.outputs.all { output ->
                outputFileName = "myapp01.apk"
            }
        } else if (variant.productFlavors[0].name.equals("flavor1") && variant.buildType.name.equals("release")) {
            variant.outputs.all { output ->
                outputFileName = "myapp02.apk"
            }
        } else if (variant.productFlavors[0].name.equals("flavor2") && variant.buildType.name.equals("release")){
            variant.outputs.all { output ->
                outputFileName = "myapp03.apk"
            }
        }
    }

Он заставит переименовывать все релизные варианты соответствующих ароматов согласно указанным именам.

среда, 7 марта 2018 г.

Язык Котлин. Часть 5. Архитектурные компоненты. LiveData и ViewModel

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

Об одном компоненте (LiveData) уже было упомянуто а конце прошлой части. Это компонент, который сам следит за изменениями данных в базе и предоставляет классическое событие onChange (как обычно в виде функции обратного вызова) для прописывания в нём кода реакции на изменения (например, обновить данные в интерфейсе).

Ещё один компонент (ViewModel) будет являться вспомогательным. Он организует объект, находящийся между активностью и базой данных, который не пропадает при поворотах экрана, когда активность уничтожается и пересоздаётся вновь в другой ориентации. Всё взамодействие с базой прописывается внутри этого объекта, а активность в любой ориентации, когда будет готова, может сразу получить доступ к этому объекту и прочитать из него готовые данные, запрошенные, например, ещё в прошлом своём жизненном цикле при другой ориентации.

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

class ListViewModel(application: Application) : AndroidViewModel(application) {
    // Инициализация переменных при создании класса
    val zhkhDatabase = ZhkhDatabase.getDatabase(application)         // Создать или получить базу данных
    var readoutList = zhkhDatabase?.readoutDAO()?.allReadoutItems    // Получить все данные из БД

    // Удалить запись
    fun deleteItem(readoutTable: ReadoutTable) {
        launch(CommonPool){ 
            zhkhDatabase?.readoutDAO()?.deleteReadout(readoutTable)  // Удалить указанную запись
        }
    }
}

А затем, в методе активности onCreate прописываем получение нашего объекта через провайдер этих самых объектов:

lateinit var viewModel: ListViewModel

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    viewModel = ViewModelProviders.of(this).get(ListViewModel::class.java)  // Создаём объект класса ListViewModel, в него считываются данные из БД
    ...
}

Если объекта нет (при первом запуске приложения), он создаётся и возвращается. Если есть, он просто возвращается, и через него мы получаем доступ ко всем тем процессом, которые начали в прошлом жизненном цикле активности. Всё.

Теперь вернёмся к основному нашему компоненту —  LiveData.

Room уже сделал за нас половину работы — в нём потенциально реализованы механизмы слежения за теми наборами данных, которые мы прописали в виде функций в интерфейсе DAO. Чтобы воспользоваться данной возможностью, надо просто указать Room, за какими конкретно наборами данных ему надо следить. Это делается всего лишь указанием скорректированного типа возвращаемых такими функциями данных, например:

// Было
@get:Query("select * from ReadoutTable")          
val allReadoutItems: List<readouttable>

// Стало
@get:Query("select * from ReadoutTable")          
val allReadoutItems: LiveData<List<ReadoutTable>>       // Обёртываем возвращаемое значение LiveData<...> чтобы отслеживать изменения в базе. При изменении данных будут рассылаться уведомления

Теперь Room знает, что надо следить, например, за всеми данными, и при изменениях в них возвращать список записей, обёрнутый в класс LiveData<T>.

На этом работа со стороны базы заканчивается. При изменении данных Room будет готов вызвать событие onChange, которое мы позже реализуем в основном коде в наблюдателе. Затем мы подпишем наблюдатель на это изменение, и Room будет готов предоставить ему на вход изменённые данные, как только активность будет готова.

Итак, нам осталось только создать наблюдателя и подписать его на конкретные данные. Делаем это в основном коде активности:

obs = Observer { readoutsList -> readoutsList?.let { recyclerViewAdapter.addItems(it) } }  // Создаём наблюдатель, который при изменении данных будет заполнять адаптер списка RecyclerView.
viewModel.readoutList?.observe(this@MainActivity, obs )                                    // Устанавливаем наблюдение и указываем наблюдателя, который будет получать данные

В этом коде создаётся наблюдатель, в котором прописывается реакция на изменение данных. Внутри класса наблюдателя запрятан единственный метод onChange, фактически находящийся внутри Room, который вызывается при изменении данных. Ему на вход поступает обновлённый набор данных. Поскольку метод единственный, мы можем описать его лямбда-выражением, что мы и делаем. Данные передаются в метод addItems адаптера, который мы напишем чуть позже. Адаптер примет в себя данные и отошлёт сообщение о том, что RecyclerView должен обновиться новыми данными.

Второй строчкой мы ставим наблюдатель на данные, завёрнутые в класс LeavData, которые нам возвращает интерфейс DAO. Объект readoutList этого класса имеет метод observe, позволяющий это сделать. В первом параметре передаётся контекст активности, потому что активность реализует метод getLifecycle, благодаря чему компонент LeavData знает, когда можно запускать onChange, а когда следует дождаться старта нового жизненного цикла, например, после поворота экрана. Вторым же параметром передаётся собственно наблюдатель, сформированный нами в предыдущей строчке.

И напоследок, пишем класс адаптера (привожу код из реального приложения):

class RecyclerViewAdapter(private var readoutTableList: List<readouttable>?, private val ctx: Context ) : RecyclerView.Adapter<recyclerviewadapter.recyclerviewholder="">() {

    // Создать вью элемента списка
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerViewHolder {
        val viewHolder = RecyclerViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.recycler_item, parent, false))
        return viewHolder
    }

    // Заполнение текущего вью
    override fun onBindViewHolder(holder: RecyclerViewHolder, position: Int) {
        val readoutModel = readoutTableList?.get(position) // Забрать из переданного в адаптер массива записей
        holder.tvAddrNum.text = readoutModel?.addr
        holder.tvHvsNum.text = readoutModel?.hvs
        holder.tvGvsNum.text = readoutModel?.gvs
        holder.tvT1Num.text = readoutModel?.t1
        holder.tvT2Num.text = readoutModel?.t2
        holder.tvT3Num.text = readoutModel?.t3
        holder.tvDate.text = DateFormat.getDateInstance(DateFormat.SHORT).format(readoutModel?.curDate)
        holder.tvComment.text = readoutModel?.comment
        holder.itemView.tag = readoutModel
        holder.itemView.setOnLongClickListener(ctx as View.OnLongClickListener) // Присвоить вьюхе интерфейс OnLongClickListener из основной активности
        holder.itemView.setOnClickListener(ctx as View.OnClickListener)         // Присвоить вьюхе интерфейс OnClickListener из основной активности
    }

    // Получить количество записей
    override fun getItemCount(): Int {
        return readoutTableList?.size ?: -1
    }

    // Переданные данные залить в адаптер и отправить сообщение, что данные изменились, чтобы заметил Observer и обновил recyclerView
    fun addItems(readoutTableList: List<readouttable>?) {
            this.readoutTableList = readoutTableList
            notifyDataSetChanged()
    }

    // Класс
    class RecyclerViewHolder(view: View) : RecyclerView.ViewHolder(view) {
            // Добавить в класс переменные и присвоить им элементы текущего вью
            val tvHvsNum = view.tvHvsNum
            val tvGvsNum = view.tvGvsNum
            val tvT1Num = view.tvT1Num
            val tvT2Num = view.tvT2Num
            val tvT3Num = view.tvT3Num
            val tvAddrNum = view.tvAddrNum
            val tvDate = view.tvDate
            val tvComment = view.tvComment
    }
}

В адаптере RecyclerView реализован классический вариант оптимизированного использования элементов списка, которые не пересоздаются при прокрутке, если в наличии имеются уже созданные (но уже ненужные, уехавшие за край экрана) элементы. Точно так же, как вы это, наверное, уже делали в адаптерах ListView, когда хотели ускорить прокрутку.

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

Адаптер подключаем в методе onCreate активности так:

recyclerViewAdapter = RecyclerViewAdapter(ArrayList(), this)    // Создать адаптер
recyclerView.adapter = recyclerViewAdapter                      // Присвоить адаптер списку
recyclerView.layoutManager = LinearLayoutManager(this)          // Создать и присвоить списку менеджер разметки
recyclerView.addItemDecoration(DividerItemDecoration(recyclerView.getContext(), (recyclerView.layoutManager as LinearLayoutManager).getOrientation())) // Добавить разделитель

Заодно добавляем полоску разделения между элементами списка. Теперь данные отслеживаются. Например, при вызове deleteItem запись будет удалена, что будет сразу отражено в интерфейсе.

На этом пока всё.

вторник, 6 марта 2018 г.

Язык Котлин. Часть 4. Архитектурные компоненты. Room, работа с БД.

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

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

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

Room

Компонент Room представляет собой обёртку над базой данных SQLite для упрощения работы с ней. Она позволяет избежать собственноручного написания тонн кода, когда мы реализовывали своё взаимодействие с базой с использованием SQLiteOpenHelper, который, в свою очередь, тоже является некой обёрткой над ней. Фактически Room — это обёртка над обёрткой SQLiteOpenHelper.

Room действительно лихо упрощает работу с базой данных в плане автоматизации её создания и реализации запросов, хотя отслеживание изменений в ней (для этого используется другой компонент LiveData) вызывает много вопросов, когда дело касается работы не с одним, полным, а с разными возвращаемыми наборами данных из разных запросов, отображаемых в одном и том же RecyclerView. Рекомендуемой практики я не нашёл, поэтому придумал аж три варианта такой работы, но ни один из них не оставил у меня ощущения правильности. Но сейчас не об этом. Разберём работу с Room в отдельности от всего остального.

Для работы с базой данных достаточно подключить библиотеку в build.gradle модуля в разделе dependencies:

    // Room
    implementation "android.arch.persistence.room:runtime:$room_version"
    kapt "android.arch.persistence.room:compiler:$room_version"

где $room_version прописана в build.gradle проекта в разделе buildscript, например:

    ext {
        kotlin_version = '1.2.30'
        room_version = '1.1.0-alpha3'
        coroutines_version = '0.22.2'
        support_version = '27.1.0'
    }

Теперь создаём файл, например, DB.kt, в котором прописываем два класса и один интерфейс. Один класс описывает таблицу, второй — базу, в которой эта таблица содержится, а интерфейс описывает запросы, которые будут сформированы к этой базе. В качестве примеров я буду демонстрировать реальный код моего приложения по учёту показаний счётчиков ЖКХ.

Класс, описывающий таблицу (я добавил сюда дополнительный класс конвертера для поля даты, пусть он вас не смущает):

@Entity
class ReadoutTable(
        @ColumnInfo var addr: String?,  // String? - Поля могут иметь значения NULL
        @ColumnInfo var hvs: String?,
        @ColumnInfo var gvs: String?,
        @ColumnInfo var t1: String?,
        @ColumnInfo var t2: String?,
        @ColumnInfo var t3: String?,
        @ColumnInfo var comment: String?,
        @field:TypeConverters(DateConverter::class)   // Добавим дополнительные преобразователи типов (а именно, написанный нами класс DateConverter),                                                  
        @ColumnInfo var curDate: Date?                // которые может использовать поле даты. "field:" - применение аннотации TypeConverters к полю.
){

    @PrimaryKey(autoGenerate = true) var id: Long = 0 // Long - без вопросительного знака, значит поле NOT NULL
}

/*
 * Класс конвертеров Long в Date и обратно для TypeConverters() в Room, чтобы автоматически сохранять в базе типы, на которые она не рассчитана
 * Та или иная функция выбирается автоматически исходя из входного типа данных
 */
class DateConverter {
    @TypeConverter  // Указать, что метод является конвертером
    fun toDate(timestamp: Long?) = timestamp?.let { Date(it) }
    @TypeConverter  // Указать, что метод является конвертером
    fun toTimestamp(date: Date?) = date?.time
}

Класс, описывающий взаимодействие с базой:

/*
 * Интерфейс доступа к базе данных (DAO - Data Access Object)
 */
@Dao
@TypeConverters(DateConverter::class)                   // Указываем, что при доступе к некоторым полям будет задействован наш конвертер DateConverter
interface ReadoutModelDao {

    @get:Query("select * from ReadoutTable")            // "get:" означает применение аннотации "Query" к геттеру (функцию геттера для переменной allReadoutItems вручную не пишем)
    val allReadoutItems: List<ReadoutTable>             // Обёртываем возвращаемое значение LiveData<...> чтобы отслеживать изменения в базе. При изменении данных будут рассылаться уведомления

    @Query("select * from ReadoutTable where id = :id")
    fun getReadoutById(id: Long): ReadoutTable

    @Query("select * from ReadoutTable where addr = :addr")
    fun getReadoutByAddr(addr: String): List<ReadoutTable>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun addReadout(readoutModel: ReadoutTable)

    @Update
    fun updateReadout(readoutModel: ReadoutTable)

    @Delete
    fun deleteReadout(readoutModel: ReadoutTable)
}

Класс, описывающий саму базу данных. Достаточно одной строчки внутри класса, а всё остальное, что внутри объекта companion, добавлено мной для реализации синглтона (чтобы при запросе базы больше одного объекта базы не создавалось) и двух апдейтов базы (напомню, что это код из реального приложения):

/*
 * Описание базы данных.
 */
@Database(entities = [ReadoutTable::class], version = 3)  // Перечисляем в entities, какие классы будут использоваться для создания таблиц.
abstract class ZhkhDatabase : RoomDatabase() {

    abstract fun readoutDAO(): ReadoutModelDao           // Описываем абстрактные методы для получения объектов интерфейса BorrowModelDao, которые вам понадобятся

    // Сопутствующий объект для получения базы данных (фактически синглтон). Можно не использовать.
    companion object {

        private var INSTANCE: ZhkhDatabase? = null

        fun getDatabase(context: Context): ZhkhDatabase? {
            if (INSTANCE == null) {
                INSTANCE = Room.databaseBuilder<zhkhdatabase>(context, ZhkhDatabase::class.java, "readout_db")
                        .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
                        .build()
            }
            return INSTANCE
        }

        fun destroyInstance() {
            INSTANCE = null
        }

        // Апдейт базы с 1 на 2 версию, добавление поля
        val MIGRATION_1_2: Migration = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE ReadoutTable ADD COLUMN comment TEXT NOT NULL DEFAULT ''")
            }
        }

        // Апдейт базы с 2 на 3 версию, переделка всех полей из NOT NULL в NULL
        val MIGRATION_2_3: Migration = object : Migration(2, 3) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE ReadoutTable RENAME TO ReadoutTable_old")
                database.execSQL("CREATE TABLE ReadoutTable (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 0, addr TEXT NULL, hvs TEXT NULL, gvs TEXT NULL, t1 TEXT NULL, t2 TEXT NULL, t3 TEXT NULL, comment TEXT NULL, curDate INTEGER)")
                database.execSQL("INSERT INTO ReadoutTable(id, addr, hvs, gvs, t1, t2, t3, comment, curDate) SELECT id, addr, hvs, gvs, t1, t2, t3, comment, curDate FROM ReadoutTable_old")
                database.execSQL("DROP TABLE ReadoutTable_old")
            }
        }
    }
}

Получение объекта базы данных будет выглядеть так:

val zhkhDatabase = ZhkhDatabase.getDatabase(this.getApplication())  // Создать или получить базу данных

И теперь читаем набор данных из таблицы (вернётся только одна запись с id = 0, а из неё читаем поле addr) с помощью прописанной в интерфейсе БД функции. Поле addr выводим в интерфейс:

val r = async(CommonPool) {                           // Асинхронно считаем данные из базы. Получаем запись с идентификатором ноль.
    zhkhDatabase?.readoutDAO()?.getReadoutById(0)     // Считаем нулевую запись. Результат последнего выражения потока возвращается в переменную r
}
launch (UI) {                                         // Запустить и забыть. Поток в потоке интерфейса UI. Ждём данные и выводим их в интерфейс.
    var rec = r.await()                               // Подождём результата.
    editText.setText(rec?.addr)                       // Выведем в интерфейс.
}

Апдейт нулевой записи:

var gorod = etAddr.text.toString()
launch(CommonPool) {
    val rec = zhkhDatabase?.readoutDAO()?.getReadoutById(0) // Асинхронно считаем данные из базы. Получаем запись с идентификатором ноль.
    if (rec != null) {
        rec.addr = gorod                                    // Заносим новые данные в поле адреса.
        zhkhDatabase?.readoutDAO()?.updateReadout(rec)      // Обновляем запись в базе.
    }                                    
}

Аналогично производится удаление и добавление записей. Вот, примерно так работает Room.

Следующий вопрос, который сразу встаёт, это как осуществить автосинхронизацию данных в таблице БД и в интерфейсе. А вот для этого Room умеет возвращать свои записи не только в голом виде в виде простых списков, но и внутри специального класса, который рассылает сообщения своим подписчикам при изменении заключенных в него данных. Возвращаемые данные внутри такого класса становятся наблюдаемыми. Для этого применяется ещё один архитектурный компонент LiveData.

Часть логики работы компонента LiveData в связке с Room будет скрыта внутри Room. Надо понимать, что со стороны Room в интерфейсе DAO компонент LiveData будет лишь синтаксически указывать, что он ожидает получить от Room данные, завёрнутые в класс LiveData. Всю остальную работу по наблюдению за таким способом указанными для наблюдения данными, а также по рассылке собщений об изменении данных и по заворачиванию этих данных в LiveData перед отправкой, Room будет осуществлять сам, непрозрачно внутри себя. 

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

четверг, 1 марта 2018 г.

Язык Котлин. Часть 3. Многопоточность. Сопрограммы (корутины).

В Котлин появились удобные и компактные инструменты для организации многопоточности — сопрограммы (или, как калька с английского, корутины). Для их использования в файле build.gradle модуля app в раздел dependencies (зависимости) дописываем строчки:

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:0.22.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:0.22.2" // Поддержка UI

Они подключат библиотеки сопрограмм. Далее, проверяем, чтобы вначале этого же файла был подключен плагин обработчика Котлиновских аннотаций:

apply plugin: 'kotlin-kapt' // Обработчик аннотаций для Kotlin (работает и с java-аннотациями).

А разделе dependencies везде annotationProcessor замените на kapt. Это должно быть сделано во всех Котлин-проектах, иначе при компиляции могут вылезать ошибки.

Например, без плагина kotlin-kapt при использовании библиотеки Room при компиляции возникает ошибка в @Dao: Error:Each bind variable in the query must have a matching method parameter. Cannot find method parameters for :id. Часто эту ошибку обходят использованием arg0 вместо id, но это не совсем правильно.

Пример использования сопрограмм

Давайте рассмотрим использование сопрограмм на примере получения данных и вывода их в интерфейс. Инициируем действие нажатием на кнопку, код которой разместим в тестовой активности:

override fun onClick(v: View?) {
    Log.i("TEST","1.Нажали кнопку")   // Информируем в лог
    textView.text = "1.Нажали кнопку" // Информируем в интерфейс, тут можно обновлять интерфейс

    // ЗАПУСК «ТЯЖЁЛОЙ РАБОТЫ»
    // Запускаем первую сопрограмму (в фоновом пуле потоков CommonPool) и присваиваем её переменной myThread
    val myThread = async(CommonPool) {                 // Запустить сопрограмму и присвоить её переменной myThread.
        Log.i("TEST","3.Первая сопрограмма запущена")  // Информируем только в лог. Интерфейс обновлять нельзя.
        sleep(2000)                                    // Типа что-то делаем длительное
        Log.i("TEST","6.Первая сопрограмма завершена") // Информируем только в лог. Интерфейс обновлять нельзя.
        "Это типа результат работы первой сопрограммы" // Результат работы сопрограммы - результат последнего действия
    }
    Log.i("TEST","2.Запустили первую сопрограмму и продолжаем")
    // Тут можем продолжать работу, а сопрограмма выполняется в фоне.

    // ПРИЁМ И ОБРАБОТКА РЕЗУЛЬТАТА
    // Запустим вторую сопрограмму (теперь в контексте пользовательского интерфейса)
    // Она просто будет ожидать результат и выведет его в интерфейс
    launch (UI) {  // Запустить и забыть. Владелец сопрограммы - пользовательский интерфейс UI.
        Log.i("TEST","5.Вторая сопрограмма запущена")  // Информируем в лог, но можно и в интерфейс
        val myResult = myThread.await()                // Подождём результата
        Log.i("TEST","7.Вторая сопрограмма завершена, результат: $myResult")   // Информируем в лог, но можно и в интерфейс
        textView.text = "7.Вторая сопрограмма завершена, результат: $myResult" // Выводим результат в интерфейс
    }
    Log.i("TEST","4.Запустили вторую сопрограмму и продолжаем") // Информируем в лог, но можно и в интерфейс
    // Тут можем продолжать работу, а сопрограмма дождётся результата, и сама выведет его в интерфейс.
}

В область импорта добавим библиотеку контекста пользовательского интерфейса:

import kotlinx.coroutines.experimental.android.UI

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

Итак, при нажатии на кнопку мы выводим сообщение об этом в лог и в textView. Затем запускаем асинхронную сопрограмму, и присваиваем её переменной myThread. Присваивается не результат выполнения сопрограммы, а именно живая сопрограмма. Через метод await этой сопрограммы, выраженной переменной, мы сможем в дальнейшем получить и сам результат.

Асинхронная сопрограмма запущена в фоновом пуле потоков CommonPool, и мы не можем напрямую выводить оттуда информацию в поток интерфейса. Поэтому сообщаем о запуске сопрограммы только в лог. Напомню, что эти сообщения мы можем читать во время выполнения программы на вкладке Logcat в нижней части Android Studio.

В сопрограмме мы имитируем длительную операцию двухсекундной блокирующей задержкой sleep(2000). Она держит фоновый поток, но не оказывает никакого влияния на отзывчивость пользовательского интерфейса. После задержки выводим в лог сообщение о завершении сопрограммы, а последней строкой прописываем результат её выполнения. Вообще, результат можно вернуть строкой

return@async "Это типа результат работы первого потока"

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

После запуска первой асинхронной сопрограммы мы можем продолжить другие операции, а сопрограмма будет в фоне выполнять свою работу. Но как нам узнать, когда она завершится, и как забрать из неё результат? Для этого у нас есть переменная сопрограммы, которой мы эту сопрограмму присвоили ранее. Ожидание завершения сопрограммы и получение результата осуществляется через ожидающую функцию, которой является метод потока myThread.await().

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

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

После того, как первая сопрограмма завершится, вторая сопрограмма сама считает её результат и выведет в интерфейс.

Такая организация распараллеливания задач выглядит гораздо проще, чем работа с дополнительным классом в Java с реализацией его абстрактных методов. Если убрать вывод сообщений в лог, код будет выглядеть ещё компактнее и проще:

override fun onClick(v: View?) {
    val myThread = async(CommonPool) {                 // Запустить сопрограмму и присвоить её переменной myThread.
        sleep(2000)                                    // Типа что-то делаем длительное
        "Это типа результат работы первой сопрограммы" // Результат работы сопрограммы - результат последнего действия
    }
    launch (UI) {                                      // Запустить и забыть. 
        val myResult = myThread.await()                // Подождём результата
        textView.text = "Вторая сопрограмма завершена, результат: $myResult" // Выводим результат в интерфейс
    }
}

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

Помимо сопрограмм acync и launch имеются ещё один вид сопрограммы для системы Андроид. Это сопрограмма runBlocking, позволяющая дождаться выполнение первой фоновой сопрограммы в основном коде с блокированием интерфейса. Используется для написания кода в блокирующем стиле.

вторник, 27 февраля 2018 г.

Язык Котлин. Часть 2. Анонимные функции, лямбда-выражения и функции высшего порядка

В конце предыдущей части в примере использовалось лямбда-выражение. Напомню, что лямбда-выражение — это сокращённая запись анонимной, то есть необъявленной функции, не имеющей имени. Анонимная функция — это функция, не имеющая имени, тело которой непосредственно используется в качестве аргумента другой функции.

Пример анонимной функции:

fun(x: Int, y: Int): Int { 
    return x + y
}

Функции с одной строкой кода в своём теле лучше записывать вообще в одну строку:

fun(x: Int, y: Int): Int = x + y

Использование анонимной функции:

operationSum(7, 8, fun(x: Int, y: Int): Int = x + y) 

Использование сокращённой записи анонимной функции в виде лямбда-выражения:

operationSum(7, 8, {x, y -> x + y}) 

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

Лямбда-выражения заключаются в фигурные скобки и могут быть многострочными. Функции, принимающие в качестве параметра другие функции или лямбды называются функциями высшего порядка.

В случае, если в лямбда-выражение передаётся только один параметр,  запись можно сократить ещё больше, убрав оператор -> и всё, что слева от него. В этом случае доступ к переданному параметру внутри тела лямбда-выражения осуществляется через автоматически созданную переменную it:

operationInc(7, {it + 1})

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

Лямбда-выражения можно присваивать переменным:

val sum = { x: Int, y: Int -> x + y }
val s = sum(2, 4)     // s будет равно 6

Помимо функций высшего порядка, лямбда-выражения можно передавать и в интерфейсы с одним абстрактным методом для автоматической реализации этого метода переданным кодом, если типы параметров передаваемой функции или лямбда-выражения совпадают с типами параметров абстрактного метода интерфеса. Пока это относится только к Java-интерфейсам, но, возможно, в будущем это будет относиться и к Котлин-интерфейсам. Реальный пример кода (использующего стандартную библиотеку архитектурных компонентов LiveData) с передачей лямбда-выражения в интерфейс наблюдателя за данными:

  /*
   * Подключение наблюдателя за изменениями данных.
   * Подлежащие наблюдению данные заключены в тип LiveData<t> и находятся в переменной этого типа leavDataList.
   * Для наблюдения за ними вызываем LiveData-метод observe, и передаём ему контекст владельца наблюдателя (это основная активность)
   * и реализацию интерфейса наблюдателя.
   */
  leavDataList.observe(this@MainActivity, Observer { newLeavDataList -> newLeavDataList?.let { recyclerViewAdapter.addItems(it) } })

В вышеприведённом примере мы реализуем интерфейс Observer, передав в него лямбда-выражение readoutsList -> readoutsList?.let { recyclerViewAdapter.addItems(it) }.

Интерфейс Observer имеет единственный абстрактный метод onChanged, принимающий данные любого типа (можно открыть Java-код интерфейса и посмотреть). Поэтому переданное интерфейсу лямбда-выражение автоматически преобразуются в реализацию единственного абстрактного метода интерфейса. Такое преобразование называется SAM-преобразованием (Single Abstract Method Conversions).

В лямбда-выражение передаются данные newLeavDataList. Они же оказываются на входе реализованного таким образом метода onChanged. Метод, в случае, если данные не null, добавляет их в адаптер. Кстати, для выполнения добавления только в том случае, если данные не null, реализовано с помощью метода данных let с использованием другого лямбда-выражения. О таком способе реализации null-безопасности рассказывалось в конце предыдущей части.

понедельник, 26 февраля 2018 г.

Язык Котлин. Часть 1. Null-безопасность.

17 мая 2017 года на конференции Google I/O было объявлено об официальной поддержке языка Kotlin для разработки Android-приложений. Первый релиз языка вышел 15 февраля 2016 года. Плагин для поддержки Kotlin теперь входит в поставку Android Studio 3.0 (релиз этой версии студии состоялся 25 октября 2017 года). Так что теперь, очевидно, Котлин стал наиболее перспективным языком программирования под Android.

Основное преимущество Котлина над Java — компактность исходного кода. Объёмы исходников сокращаются почти наполовину, что не может не радовать, поскольку Java-запись всегда напрягала своей размашистостью.

Кое-что про Котлин я уже недавно писал в своём основном блоге. Теперь же я хочу остановиться на некоторых особенностях языка.

Переход с Java на Котлин можно осуществлять постепенно. В одном проекте можно совмещать файлы на обоих языках. Кроме того, писать на Котлин можно в стиле Java. Правда, тогда теряется его основное преимущество — компактность исходника, поэтому я бы рекомендовал сразу привыкать к новому синтаксису.

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

Null-безопасность

Обращение к полю null-объекта

Что сразу бросается в глаза в нижеприведённом примере?

val phone = phoneBook?.presidents?.putin?.phone 

Да, знаки вопроса перед операторами вызова (точками), а вовсе не фамилия putin. Знаки вопроса — это общее проявление null-безопасности языка. В приведённом примере без знака вопроса Android Studio укажет на ошибку, потому что если в одном из явно не инициализированных полей будет null, то программа выбросит исключение NullPointerException.

Оператор безопасного вызова ?. позволяет перед обращением к следующему полю проверить значение текущего поля на null, и обратиться дальше только в случае непустого его значения. В противном же случае вся конструкция просто возвращает null и не выбрасывает никакого исключения.

Тем не менее, если нужно вернуться к исключениям, достаточно вместо ? поставить !!

val phone = phoneBook!!.presidents!!.putin!!.phone

Оператор !! — это как бы обещание программиста, что на момент вызова поле точно не будет иметь значение null. Иначе, как уже было сказано, в процессе выполнения программы возникнет исключение NullPointerException.

Типы данных и null

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

val str String? = null 

Оператор «Элвис» 

Присваивание заведомо непустого значения переменной:

val len = b?.length ?: -1 

В случае пустого поля b в переменную len возвращается -1. Иначе возвращается длина объекта b. Для этого служит оператор «Элвис» ?: названный так за схожесть получившегося смайлика с причёской Элвиса Пресли.

Null-безопасность оператора as

В случае невозможности привести типы исключение ClassCastException не возникает и возвращается null:

val integerVar: Int? = unknownTypeVar as? Int 

Если unknownTypeVar можно привести к типу Int, то integerVar = unknownTypeVar, иначе integerVar = null.

Коллекции. Фильтрация null-значений

Из одной коллекции, имеющей среди значений null, можно перенести в другую коллекцию только не-null значения. Для этого у коллекции есть метод фильтра filterNotNull:

val nullableList: List<int> = [1, 2, null, 4]
val intList: List<int> = nullableList.filterNotNull()

Метод let. Быстрая проверка на null

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

val fruitBasket = ...

apple?.let {
  println("Добавление яблока в корзину!")
  fruitBasket.add(it)
}

Если apple не null, выполнить т.н. «лямбда-выражение» в фигурных скобках, куда значение apple передано через it. Про лямбда-выражение читайте в следующей части.

понедельник, 24 ноября 2014 г.

Жизненный цикл фрагмента

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



Список перехватываемых методов фрагмента

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

onCreate — инициализация внутренних компонентов фрагмента сохранёнными данными.

onCreateView — формирование компонента для отображения.

onPause — обработка ситуации, когда фрагмент теряет фокус.

Описание всех методов по порядку их вызова:

onAttach — первое подсоединение фрагмента к активности. Сюда передаётся активность, к которой происходит подсоединение.

onCreate — инициализация фрагмента. Сюда передаются данные класса Bundle о последнем состоянии фрагмента в его предыдущей жизни, если таковая была, сохранённые ранее, например, в методе onSaveInstanceState, для восстановления этого состояния. На этот момент активность ещё находится в процессе создания.

onCreateView — формирование вида для отображения. Возвращает вид фрагмента. Может возвращать null для невизуальных компонентов. Сюда передаются данные класса Bundle о последнем состоянии фрагмента, а также контейнер активности, куда будет подключаться фрагмент и «надуватель» разметки.

onViewCreated — вызывается, когда вид сформирован. Сюда передаётся сформированный вид и данные класса Bundle о последнем состоянии фрагмента. Используется для окончательной инициализации вида перед восстановлением сохранённого состояния. В этой точке вид ещё не прикреплён к фрагменту.

onActivityCreated — окончательная инициализация. Вызывется когда метод onCreate() активности был возвращен. Активность создана, фрагмент в неё вставлен. Используется, например, для восстановления состояния фрагмента. Сюда передаются данные класса Bundle о последнем состоянии фрагмента.

onViewStateRestored — инициализация вида на основе сохранённого состояния. Вызывается, когда сохранённое состояние вида восстановлено.

onStart — запуск фрагмента. Метод привязан к одноимённому методу жизненного цикла активности. Здесь обычно создаются объекты, которые затем разрушаются в методе onStop.

onResume — пуск в работу. Метод привязан к одноимённому методу жизненного цикла активности. Здесь обычно запускают анимацию, открывают устройства с эксклюзивным доступом (такие, как камера) и т.п.

onPause — обработка ситуации, когда фрагмент теряет фокус. Метод привязан к одноимённому методу жизненного цикла активности. Здесь останавливают анимацию и другие процессы, грузящие процессор.

onSaveInstanceState — сохранение состояния фрагмента. Срабатывает только в том случае, если фрагмент останавливается и может быть убит системой, но фактически ещё нужен. Это происходит, например, при вызове следующей активности, при нажатии кнопки «домой» а также в случае полного разрушения активности и создания её заново в результате изменения конфигурации устройства (смена языка, устройства ввода, ориентации экрана и т.п.). Объект класса Bundle, хранящий состояние активности, передаётся в методы onCreate, onPostCreate и onRestoreInstanceState. Внимание! Метод может быть вызван в любое время до onDestroy!

onStop — вызывается перед остановкой фрагмента. Метод привязан к одноимённому методу жизненного цикла активности. Здесь освобождаются объекты, созданные в методе onStart.

onDestroyView — вызывается при отсоединении вида от фрагмента. При следующем отображении фрагмента будет сформирован новый вид.

onDestroy — выполнение окончательной очистки перед уничтожением фрагмента. Здесь освобождают все занятые ресурсы (потоки и пр., созданные в onCreate). Вызывается перед тем, как фрагмент уничтожается.

onDetach — вызывается перед отсоединением фрагмента от активности.