Android курс в Технополисе 2022

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

Устройство проекта

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

Build скрипты

Стандартная система сборки Android приложений основана на Gradle (https://gradle.org/) – опенсорнсом инструменте для сборки общего назначения – и плагине для Gradle, который знает, как собирать Android проекты. Вам не нужно устанавливать эти инструменты, потому что система сборки устроена так, что всё необходимое скачивается и устанавливается во время сборки (удобно, но требует подключения к Интернету во время сборки). Однако, если по каким-то причинам, вы не хотите использовать предустановленный дистрибутив Gradle, вы можете указать путь к нему в настройках Android Studio (Use gradle from в разделе Build, Execution, Deployment / Gradle настроек).

Стандартная конфигурация системы сборки включает использование Gradle Wrapper. Для этого в проекте есть специальные файлы:

<project_dir>/
	gradlew
    gradlew.bat
    gradle/wrapper/
    	gradle-wrapper.jar
        gradle-wrapper.properties

gradlew и gradlew.bat – это скрипты для запуска Gradle Wrapper для Linux подобных систем и для Windows. Gradle Wrapper является Java приложением и находится в файле gradle-wrapper.jar. Очевидно, что для запуска сборки необходима установленная Java – она входит в комплект установки Android Studio.

Файл gradle-wrapper.properties содержит самые общие параметры запуска Gradle, и, наверно, единственный параметр, который вам может понадобиться менять – это версия Gradle в параметре distributionUrl. Если вы долго работаете над одним проектом, то за время работы может появиться более новая версия Gradle, и тогда, чтобы перейти на неё, вам придется подредактировать gradle-wrapper.properties.

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

<project_dir>/
	build.gradle
    settings.gradle
    gradle.properties
    local.properties
    app/
    	build.gradle

Начнем с local.properties – в этом файле определены свойства, значения которых имеют смысл только на вашей машине, на которой вы запускаете сборку. Это, прежде всего, sdk.dir – путь к установленному Android SDK. Файл local.properties должен быть свой у каждого разработчика, который работает над проектом, и его не надо коммитить в общий репозиторий. Если в вашем проекте нет других настроек, зависящих от машины, на которой запускается сборка, то проще определить переменную окружения ANDROID_HOME – тогда файл local.properties вообще не будет нужен.

Файл gradle.properties содержит настройки, которые используется при сборке проекта. В новом проект там обычно определено свойство org.gradle.jvmargs, в котором прописан параметр -Xmx для запуска JVM. Сборка Android приложений требует много памяти, особенно если проект большой (а большим проект может стать очень быстро), поэтому, когда заметите, что сборка стала сильно тормозить, проверьте – не упирается ли она в память, и не надо ли увеличить -Xmx. Впрочем, оптимизация и ускорение сборки Android проектов это отдельная сложная тема, и одним параметром -Xmx вопрос не ограничивается.

Файл settings.gradle обычно определяет структуру проекта. В новом проекте приложения типа Hello World, содержимое этого файла выглядит так:

include ':app'

Здесь указан единственный модуль, из которого состоит проект. Если модулей больше – они перечисляются через запятую: include ':app', ':module1', 'module2'.

Файл build.gradle в корне проекта уже содержит какое-то “мясо” – здесь описывается сборка всего проекта на уровне, общем для всех модулей. Файл начинается со следующего блока:

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    id 'com.android.application' version '7.1.1' apply false
    id 'com.android.library' version '7.1.1' apply false
    id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

Файл app/build.gradle, который лежит в папке модуля приложения app, описывает сборку этого модуля, а так как этот модуль содержит само приложение, в этом билд скрипте содержится всё самое интересное. Первая его строчка

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
}

определяет, что перед нами модуль, содержащий Android приложение, и для его сборки будет использовать Android Gradle плагин. Другой возможный вариант – это com.android.library для библиотечных модулей. Обычно Android проект содержит один модуль приложения и любое количество библиотечных модулей.

Затем идет блок android, в котором определены основные параметры приложения:

android {
    compileSdk 31

    defaultConfig {
        applicationId "ru.ok.technopolis"
        minSdk 21
        targetSdk 31
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

applicationId – это ID, по которому приложения идентифицируются в операционной системе Android и в магазинах приложений вроде Google Play. Этот ID мы указывали при создании проекта в Android Studio.

versionName – это версия приложения, как её будут видеть пользователи, например, на странице приложения в Google Play или в системных настройках Android устройства в информации о приложении. Значение versionName может быть любым, но обычно это числа, разделенные точками: 1.0, 1.1, 2.0, 2.0.1 – это традиционная семантическая система нумерации версий. Иногда в versionName кодируют дату релиза приложения: 19.1.22 (22 января 2019 года). Могут присутствовать буквы, например: 5.1-alpha, 19.2.13-debug и пр. Вообще, versionName используется исключительно как текст для отображения пользователям.

versionCode – это тоже версия приложения, но, в отличие от versionName, имеет значение типа int и используется для алгоритмической обработки и в бизнеc-логике приложения. Допустимые значения: положительные целые числа, обязательно возрастающие с каждой версией приложения. Если вы публикуете приложение в магазине приложений, то в каждом следующем обновлении должно быть большее значение versionCode.

Далее идут три похожих свойства: compileSdkVersion, minSdkVersion и targetSdkVersion – это всё про версии Android.

Разные версии Android…

compileSdkVersion определяет версию Android, которая будет использоваться для того, чтобы скомпилировать код приложения. Для каждой версии Android в Android SDK есть свой файл android.jar, содержащий все классы и методы, имеющиеся в этой версии Android. Когда Java код приложения компилируется при помощи javac, в classpath добавляется этот android.jar и таким образом коду приложения становятся доступны все API из этой версии Android. При просмотре Android API Reference обратите внимание – для каждого класса или метода есть указание, в какой версии Android этот класс или метод появился. Например, метод View.setTranslationZ(float) появился в API Level 21:

Если метод появился в API Level 21, это значит, что для того, чтобы использовать его в коде приложения, нужно установить значение compileSdkVersion 21 или выше.

minSdkVersion определяет минимальную версию Android, на которой приложение может быть установлено и запущено. Этот параметр вы указывали при создании приложения в Android Studio. С точки зрения простоты разработки, чем выше minSdkVersion, тем лучше – тогда разработчикам не придется заботиться о том, чтобы приложение правильно работало на старых версиях Android (обычно приложения лучше работают на более новых версиях Android – там меньше багов, меньше технических ограничений, чаще более мощные процессоры с большим объемом памяти и т.п.). Однако, увеличивая minSdkVersion, вы ограничиваете количество устройств, на которых будет работать приложение и уменьшаете его потенциальную аудиторию – а это плохо для бизнеса, в котором используется приложение. Поэтому приходится искать баланс между стоимостью поддeржки старых версий Android и потенциальной выгодой для бизнеса от расширения аудитории.

targetSdkVersion – это версия Android, для которой “предназначено” ваше приложение. Это значит, в общих чертах, что в процессе разработки вы продумывали работу приложения на этой версии Android, тестировали на ней, и гарантируете, что на targetSdkVersion версии Android ваше приложение работает хорошо, без багов – так, как задумывалось. Это нужно для того, чтобы в будущих версиях Android (которые еще не вышли, и про которые мы ничего не можем знать во время разработки приложения) наше приложение продолжало работать так, как мы задумывали, несмотря на то, что технологии могли измениться, поведение операционной системы могло измениться и, вообще говоря, по меркам будущих версий Android, наше сегодняшнее приложение может считаться написанным неправильно. Когда Android запускает приложение со старым targetSdkVesion, он может принять дополнительные меры для того, чтобы приложение работало правильно – запустить его в особом compatibility режиме. Указывая targetSdkVersion, мы фиксируем набор правил и поведение операционной системы, которые действительны для этой версии Android, и таким образом мы можем больше не заботиться о поддержке более новых версий Android. Впрочем, Google может не дать нам расслабиться – иногда в магазине приложений Google Play появляются ограничения на использование старых версий в targetSdkVersion. Например, c 1 авгутста 2018 года в Google Play нельзя публиковать новые приложения с targetSdkVersion меньше 26 (а с 1 ноября 2018 – и обновления старых приложений). Это заставило всех разработчиков оптимизировать их приложения под Android 8.0 (самое сложное – пришлось переписать работу фоновых сервисов).

Build Types

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

buildTypes {
    release {
        minifyEnabled false
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
}

В Android проекте есть два стандартных типа сборки: release и debug.

По умолчанию в Android Studio используется тип сборки debug. Эта сборка предназначена для того, чтобы отлаживать её – в ней может делаться меньше оптимизаций, добавляться больше отладочной информации, могут включаться специальные режимы работы, удобные для тестирования, логирование и пр. Debug сборки не предназначены для пользователей, и их нельзя публиковать и распространять через магазины приложений. Внутри buildTypes дефолтного build скрипта блок debug отсутствует – просто потому, что для debug сборки используются все значения по умолчанию.

release сборка, наоборот, предназначена для пользователей. Она максимально оптимизирована, из неё удаляется всё лишнее, её нельзя отлаживать при помощи дебаггера. Кроме того, release сборка подписывается сертификатом разработчика для удостоверения её происхождения и обеспечения целостности (чтобы злоумышленники не могли распространять свои зловреды под видом популярных приложений). Настройки minifyEnabled и proguardFiles относятся к процессу минификации: при сборке релизной версии приложения, её код проходит стадию минификации – лишний неиспользуемый код удаляется, Java имена сокращаются. Это позволяет уменьшить размер кода и немного ускорить его загрузку, но сильно усложняет отладку.

Сборка проекта

После того как вы собрали приложение в Android Studio, в проекте появляется папка app/build (если бы было несколько модулей, то в каждом модуле появилась бы папка build). В ней содержатся результаты сборки и промежуточные файлы.

APK файл

Собранный APK файл приложения находится в app/build/intermidiates/apk/debug/app-debug.apk (для дебажной сборки) – именно этот файл устанавливается на устройство, когда вы запускаете приложение из Android Studio. Его можно даже открыть и посмотреть его содержимое:

Внутри APK файла можно найти:

Все XML файлы, которые можно увидеть внутри APK файла при помощи Android Studio, на самом деле хранятся в оптимизированном бинарном формате, который занимает меньше места и быстрее парсится в рантайме.

Внутри app/build особый интерес представляет папка generated – здесь находятся исходники, которые были автоматически сгенерированы во время сборки приложения. Мы эти исходники не писали, но мы можем их использовать в своем коде, и часто это даже необходимо.

BuildConfig файл

В классе BuildConfig определены константы с информацией о сборке приложения, взятые из build.gradle во время сборки.

Так выглядит BuildConfig для дебажной сборки в app/build/generated/source/buildConfig/debug/ru/ok/technopolis/:

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

if (что-то неправильное случилось) {
	if (BuildConfig.DEBUG) {
        throw RuntimeException("Передайте Пете из команды Васи, что его API не работает, как он обещал");
    } else {
        Log.e("API", "Unexpected data format returned from API user.getFriends")
    }
}

В BuildConfog можно добавлять свои собственные константы. Для этого нужно добавить определение константы в build.gradle, и они будут добавлены статическими полями в класс BuildGradle во время сборки:

android {

	defaultConfig {
    	buildConfigField "boolean", "FEATURE_X_ENABLED", "true"
    }

    buildTypes {
    	release {
        	buildConfigField "int", "MAX_VCALL_PARTICIPANTS", "100"
            buildConfigField "String", "PORTAL_ADDRESS", '"https://www.portal.info"'
        }

        debug {
        	buildConfigField "int", "MAX_VCALL_PARTICIPANTS", "3"
            buildConfigField "String", "PORTAL_ADDRESS", '"http://127.0.0.1:4000"'
        }
    }
}

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

Логирование

В Android есть единый системный лог, в который попадают сообщения от всех компонентов системы и от всех приложений. Инструмент для просмотра логов называется Logcat – он встроен в Android Studio, и для него есть одноименное окно, в котором можно просматривать логи:

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

Приложения могут писать в логи при помощи стандартного класса android.util.Log, в котором есть набор методов для печати сообщений в лог с разным приоритетом. Вот базовый список методов в порядке убывания приоритета:

Первый параметр – всегда тэг. Обычно это строковая константа, по которой потом можно найти интересующие нас сообщения в логах. Использование Log в коде может выглядеть так:

    private val log = true
    private val tag = "Hello"
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_hello_world)
        if (log) Log.d(tag, "HelloWorldActivity.onCreate")
    }

При выполнении этого кода в момент старта activity HelloWorldActivity в лог будет напечатано такое сообщение:

2021-03-06 00:59:25.246 28880-28880/ru.ok.technopolis D/Hello: HelloWorldActivity.onCreate

Оно содержит точное время, ID юзера (28880), процесса (28880) и приложения (ru.ok.technopolis), из которого пришел лог, метка приоритета D, тэг Hello и собственно сообщение. В окне Logcat в Android Studio можно осуществлять поиск по логам, фильтровать по произвольной подстроке и по приоритету и таким образом видеть только те логи, которые вас интересуют в данный момент.

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

Обратите внимание на то, как метод логирования вызывается под условием:

if (log) Log.d(...)

Использование константы log необходимо по двум причинам:

Удобнее всего определять константу log при помощи BuildConfig. Для этого надо написать следующее в build.gradle приложения:

buildTypes {

    release {
    	// ...
    	buildConfigField "boolean", "log", "false"
    }

    debug {
    	// ...
    	buildConfigField "boolean", "log", "true"
    }

}

и в коде использовать BuildConfig.log – это будет одна константа на все приложение:

if (BuildConfig.log) Log...

Падение приложения

Когда при выполнении кода приложения выбрасывается исключение, которое никто не ловит, приложение падает – выполнение кода прекращается, виртуальная машина останавливается и процесс приложения завершается. Это называется крэш (crash). Пользователь при этом видит системное сообщение о том, что приложение упало:

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

Для примера попробуем изменить код HelloWorldActivity так, чтобы он упал: при вызове setContentView из метода onCreate замените идентификатор файла верстки R.layout.activity_hello_world на идентификатор строки R.string.hello_world:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.string.hello_world)
    }

Это неправильное использование метода setContentView – строка совершенно не подходит для того, чтобы из нее загрузили верстку – поэтому приложение упадет при старте. В логе мы увидим следующее:

2021-03-06 01:05:44.789 19227-19227/ru.ok.technopolis E/AndroidRuntime: FATAL EXCEPTION: main
    Process: ru.ok.technopolis, PID: 19227
    java.lang.RuntimeException: Unable to start activity ComponentInfo{ru.ok.technopolis/ru.ok.technopolis.HelloWorldActivity}: android.content.res.Resources$NotFoundException: File Hello, World! from xml type layout resource ID #0x7f0e0028
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3449)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
     Caused by: android.content.res.Resources$NotFoundException: File Hello, World! from xml type layout resource ID #0x7f0e0028
        at android.content.res.ResourcesImpl.loadXmlResourceParser(ResourcesImpl.java:1264)
        at android.content.res.Resources.loadXmlResourceParser(Resources.java:2426)
        at android.content.res.Resources.loadXmlResourceParser(Resources.java:2402)
        at android.content.res.Resources.getLayout(Resources.java:1252)
        at android.view.LayoutInflater.inflate(LayoutInflater.java:530)
        at android.view.LayoutInflater.inflate(LayoutInflater.java:479)
        at androidx.appcompat.app.AppCompatDelegateImpl.setContentView(AppCompatDelegateImpl.java:696)
        at androidx.appcompat.app.AppCompatActivity.setContentView(AppCompatActivity.java:170)
        at ru.ok.technopolis.HelloWorldActivity.onCreate(HelloWorldActivity.kt:11)
        at android.app.Activity.performCreate(Activity.java:8000)
        at android.app.Activity.performCreate(Activity.java:7984)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601) 
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85) 
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135) 
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95) 
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066) 
        at android.os.Handler.dispatchMessage(Handler.java:106) 
        at android.os.Looper.loop(Looper.java:223) 
        at android.app.ActivityThread.main(ActivityThread.java:7656) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947) 
     Caused by: java.io.FileNotFoundException: Hello, World!
        at android.content.res.AssetManager.nativeOpenXmlAsset(Native Method)
        at android.content.res.AssetManager.openXmlBlockAsset(AssetManager.java:1092)
        at android.content.res.ResourcesImpl.loadXmlResourceParser(ResourcesImpl.java:1248)
        at android.content.res.Resources.loadXmlResourceParser(Resources.java:2426) 
        at android.content.res.Resources.loadXmlResourceParser(Resources.java:2402) 
        at android.content.res.Resources.getLayout(Resources.java:1252) 
        at android.view.LayoutInflater.inflate(LayoutInflater.java:530) 
        at android.view.LayoutInflater.inflate(LayoutInflater.java:479) 
        at androidx.appcompat.app.AppCompatDelegateImpl.setContentView(AppCompatDelegateImpl.java:696) 
        at androidx.appcompat.app.AppCompatActivity.setContentView(AppCompatActivity.java:170) 
        at ru.ok.technopolis.HelloWorldActivity.onCreate(HelloWorldActivity.kt:11) 
        at android.app.Activity.performCreate(Activity.java:8000) 
        at android.app.Activity.performCreate(Activity.java:7984) 
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309) 
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422) 
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601) 
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85) 
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135) 
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95) 
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066) 
        at android.os.Handler.dispatchMessage(Handler.java:106) 
        at android.os.Looper.loop(Looper.java:223) 
        at android.app.ActivityThread.main(ActivityThread.java:7656) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947) 

Первое, что мы видим – это строка со словами FATAL EXCEPTION и идентификатором упавшего приложения. Так (почти) всегда начинается сообщение о падении приложения, и если вам надо быстро найти в логе крэш, то проще всего искать его по этим словам.

Затем идет сообщение о непойманном исключении со стэк трейсом – то, что позволит нам найти причину падения. Обычно в стек трейсе можно найти ссылки на код приложения, который привел к ошибке, и в первую очередь нам надо их найти по имени Java пакета, который мы используем в нашем приложении (ru.ok.technopolis). Часто стек трейс состоит из нескольких частей, каждая из которых начинается со слов Caused by – это говорит о том, что исключение несколько раз ловилось в различных местах в коде, но не было обработано, а было обернуто в новый тип исключения и проброшено дальше. Вы быстро научитесь ориентироваться в стек трейсах, но если поиск нужной строчки вызывает затруднения, то можно действовать так:

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

at ru.ok.technopolis.HelloWorldActivity.onCreate(HelloWorldActivity.kt:11)

Это то самое место, в котором мы сделали неправильный вызов setContentView.