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

Разработка под android

Android Camera2 API от чайника, часть 6. Стрим видео сначала кодировали, теперь декодируем

20.06.2020 18:10:45 | Автор: admin

Итак, в предыдущем посте мы занимались кодированием живого видео формата H.264 на Android устройстве, которое затем отправляли для просмотра на персональный компьютер под виндой. Там наш видеопоток успешно раскодировывался и лицезрелся с помощью VLC плеера. А так же с помощью библиотеки VLCJ CAPRICA благополучно впихивался и в окошки JAVA приложения. Правда, каким именно образом он (VLC плеер) всё это проделывал, так и осталось загадкой. Но с другой стороны работает, да и ладно.

Подстольный настольный компьютер, ноутбук, лэптоп всё это прекрасно, но тем не менее, всё больше народа смотрит видео и управляет разными девайсами не из-за стола, а чаще валяясь на диване, со смартфоном в руках. И соответственно, к примеру, даже нашей роботележкой ныне удобней управлять именно оттуда. Поэтому настало время выяснить, как наш закодированный видео поток принять и отобразить на экране такого же Android устройства. Естественно, как и раньше мы проделаем всё через Camera2 API, концепцию Surface, да ещё и асинхронно!

Кому интересно вперёд.

Собственно, для чего мне это понадобилось, кроме как обычно стремления, по выражению ослика Иа, к обалдеванию новыми знаниями? Откровенно говоря, про роботележку я упомянул только для примера (я думаю за такое количество статей она уже всем успела изрядно надоесть). На самом деле, кодируя-декодируя мы соберём незамысловатый видеотелефон для домашней сети.

Но это будет:
в следующей статье. Поскольку надо ещё и аудио канал прикрутить.

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

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

image

Второй телефон можно отобрать у своей девицы и использовать её (девицу) в качестве подопытного кролика второго участника эксперимента.

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

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

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

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

А находится он, как известно, в классе:

MEDIA CODEC


В пред пред предыдущем посте с его помощью мы кодировали в формате H.264 видео поток полученный с камеры и отправляли его по UDP каналу на нужный адрес. Поэтому, чтобы начать работу, нам весьма пригодится код из того самого поста. Чтобы не парить голову с отладкой программы на двух телефонах сразу, для начала мы сделаем всё на одном. Просто для понимания принципа работы. В одном окошке мы будем снимать видео, кодировать и отправлять его по домашней сетке самим себе во второе окошко. Всё по-честному, никаких localhost и адресов 127.0.0.1. Только настоящий сетевой адрес, только хардкор!

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

  1. Изменение UI, то есть добавление второго окна и ассоциированного с ним Surface
  2. Блок кода для получения видео потока по UDP каналу
  3. Процедуру самого декодирования.

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

Итак, сначала модифицируем макет добавляем второе окно.

activity_main.xml
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:layout_width="match_parent"    android:layout_height="match_parent"    tools:context=".MainActivity">    <TextureView        android:id="@+id/textureView"        android:layout_width="320dp"        android:layout_height="240dp"        android:layout_marginTop="88dp"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintHorizontal_bias="0.494"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toTopOf="parent" />    <TextureView        android:id="@+id/textureView3"        android:layout_width="320dp"        android:layout_height="240dp"        android:layout_marginTop="24dp"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintHorizontal_bias="0.494"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toBottomOf="@+id/textureView" />    <LinearLayout        android:layout_width="165dp"        android:layout_height="40dp"        app:layout_constraintBottom_toBottomOf="parent"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toBottomOf="@+id/textureView3"        app:layout_constraintVertical_bias="0.838">        <Button            android:id="@+id/button1"            android:layout_width="wrap_content"            android:layout_height="36dp"            android:text="вкл" />        <Button            android:id="@+id/button3"            android:layout_width="wrap_content"            android:layout_height="37dp"            android:text="выкл" />    </LinearLayout></androidx.constraintlayout.widget.ConstraintLayout>


Эта часть вообще объяснений не требует. Идем дальше.

Пишем код для получения дэйтаграмм


Тут есть пара тонкостей. Когда мы дэйтаграммы отправляли, то действовали совсем незамысловато. Мы дожидались, когда сработает коллбэк буфера выходных данных onOutputBufferAvailable, а затем легко и просто пихали полученный от него байтовый массив в UDP пакет. Дальше он уже без всякой помощи с нашей стороны уезжал по указанному адресу. Единственное, мы его ещё рубили на килобайтовые блоки (чтобы гарантированно влез в размер MTU), но сейчас это совершенно излишне (и даже, как мы увидим потом, вредно).

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

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

В итоге, код оказался несложным:

        Udp_recipient()        {            start();            mNewFrame = false;        }        public void run()        {                         try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                     synchronized (outDataForEncoder)                     {                         for (int i = 0; i < outDataForEncoder.length; i++)                         {                             outDataForEncoder[i] = bBuffer[i];                         }                     }                    mNewFrame = true;                } catch (Exception e) {                    Log.i(LOG_TAG, e + "  ");                }             }        }

Итак, поток в наличии. После получения дэйтаграммы устанавливаем флаг NewFrame, чтобы декодер не путался и сбрасываем флаг перед прибытием нового UDP пакета. Здесь неявно предполагается, что декодер скуривает пакеты быстрее, чем они приходят. А это действительно, так и оказалось.

Переходим теперь к самому декодеру


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

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

То ли считается, что там всё тривиально (а оно по сравнению с синхронным вариантом действительно так), то ли правда, оно у всех не работает. Разбираться опять придётся самим.

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

       try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture = mImageViewDown.getSurfaceTexture();        texture.setDefaultBufferSize(480, 640);        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");

Далее обращаемся к коллбэкам. Нужных, как известно, два:

void onInputBufferAvailable(MediaCodec mc, int inputBufferId)void onOutputBufferAvailable(MediaCodec mc, int outputBufferId, )

То есть, при срабатывании первого коллбэка мы в него кладём данные, а при вызове второго вынимаем готовенькое и отправляем на Surface. Может показаться странным, что когда мы делали кодирование (то есть, наоборот из Surface в кодек), то почему-то InputBuffer мы не использовали, а сразу волшебным образом доставали байтовые данные из OutputBuffer. Это мне тоже казалось странным, пока я не прочитал:
When using an input Surface, there are no accessible input buffers, as buffers are automatically passed from the input surface to the codec. Calling dequeueInputBuffer will throw an IllegalStateException, and getInputBuffers() returns a bogus ByteBuffer[] array that MUST NOT be written into.
Короче, автоматически это делается. Ну, и сделали бы при декодировании также. Но нет, придётся самим.

Итак, в метод void (MediaCodec mc, int inputBufferId) я ничтоже сумняшеся прописал:

 private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {                  decoderInputBuffer = codec.getInputBuffer(index);                  decoderInputBuffer.clear();                             if(mNewFrame)                             {                               synchronized (outDataForEncoder)                               { b=outDataForEncoder;  }                             }                             decoderInputBuffer.put(b);                 codec.queueInputBuffer(index, 0, b.length,0, 0);              if(mNewFrame)              new Udp_recipient();        }

Ну, по аналогии, как мы делали в прошлый раз в кодере.

То есть, когда декодер вдруг ощущает, что ему срочно нужны данные, он вызывает этот коллбэк и кладёт наш прибывший udp-пакет (который уже доступен в виде байтового массива) в один из своих буферов под номером index. Там их вроде четыре. Естественно, ничего не заработало. Я ж забыл про onOutputBufferAvailable.

Туда тоже необходимо вставить две строчки:

    @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                  {                      decoderOutputBuffer = codec.getOutputBuffer(index);                      codec.releaseOutputBuffer(index, true);                  }            }        }

Причем значение True/False отвечает за то, будет ли содержимое буфера рендерится на Surface или выкинется на помойку.

Программа заработала, но как-то странно. Коллбэк onInputBufferAvailable переваривал с несколько десятков пакетов, а потом молча помирал, не выбрасывая ни одной ошибки. Но на экране смартфона в это время появлялась какая-то рябь, в которой при большом воображении угадывались детали того, что снимала камера.



А в логах обнаружились какие-то таинственные:

buffer descriptor with invalid usage bits 0x2000
A resource failed to call release.


Но большой ясности они не прибавляли. Некоторое понимание пришло из прошлых наблюдений. Дело в том, что я сначала не использовал флаг NewFrame (просто руки ещё не дошли) и декодер мог обратится к одному и тому же пришедшему пакету несколько раз (данные-то обрабатываются гораздо быстрее, чем приходят, как уже говорилось). На экране была, понятное дело, совершенная каша, но onInputBufferAvailable работал и работал без перебоев!

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

Как только поправки были произведены, на Output Surface наконец-то появилось полученное через сеть видео.



Правда, как видно, на нём присутствуют некоторые недостатки. Посмотрев на них какое-то время, я стал в душе догадываться, что это все опять из-за onInputBufferAvailable. Ему снова что-то не нравилось. Оказалось, был не по вкусу байтовый массив. Я тогда про это не догадывался, мне лично не нравилась концепция флага NewFrame. Как-то она не сочеталась с реактивным программированием. Поэтому я решил полученные пакеты не просто класть в массив, а заворачивать этот массив в итоге в байтовый поток ByteArrayOutputStream. И пускай коллбэк, что ему надо, сам оттуда забирает, а конкретно каким образом, это его проблема.

Идея сработала блестяще и на экране я увидел это:

image

Согласитесь, прогресс существенный. Но опять чего-то не хватает. Или чего лишнее?

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

Решение было простым:

поток.reset();

И всё заработало как надо.



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

Листинг main_activity
package com.example.encoderdecoder;import androidx.annotation.RequiresApi;import androidx.appcompat.app.AppCompatActivity;import androidx.core.content.ContextCompat;import android.Manifest;import android.content.Context;import android.content.pm.ActivityInfo;import android.content.pm.PackageManager;import android.graphics.SurfaceTexture;import android.hardware.camera2.CameraAccessException;import android.hardware.camera2.CameraCaptureSession;import android.hardware.camera2.CameraDevice;import android.hardware.camera2.CameraManager;import android.hardware.camera2.CaptureRequest;import android.media.MediaCodec;import android.media.MediaCodecInfo;import android.media.MediaFormat;import android.os.Build;import android.os.Bundle;import android.os.Handler;import android.os.HandlerThread;import android.os.StrictMode;import android.util.Log;import android.view.Surface;import android.view.TextureView;import android.view.View;import android.widget.Button;import android.widget.Toast;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.net.DatagramPacket;import java.net.DatagramSocket;import java.net.InetAddress;import java.net.SocketException;import java.nio.ByteBuffer;import java.util.Arrays;public class MainActivity extends AppCompatActivity {    public static final String LOG_TAG = "myLogs";    public static Surface surface = null;    CameraService[] myCameras = null;    private CameraManager mCameraManager = null;    private final int CAMERA1 = 0;    private Button mButtonOpenCamera1 = null;    private Button mButtonTStopStreamVideo = null;    public static TextureView mImageViewUp = null;    public static TextureView mImageViewDown = null;    private HandlerThread mBackgroundThread;    private Handler mBackgroundHandler = null;    private MediaCodec encoder = null; // кодер    private MediaCodec decoder = null;    byte [] b;    Surface mEncoderSurface; // Surface как вход данных для кодера    Surface mDecoderSurface; // Surface как прием данных от кодера    ByteBuffer outPutByteBuffer;    ByteBuffer decoderInputBuffer;    ByteBuffer decoderOutputBuffer;    byte outDataForEncoder [];    DatagramSocket udpSocket;    DatagramSocket udpSocketIn;    String ip_address = "your target address";// сюда пишете IP адрес телефона куда шлете //видео, но можно и себе    InetAddress address;    int port = 40002;    ByteArrayOutputStream  out;    private void startBackgroundThread() {        mBackgroundThread = new HandlerThread("CameraBackground");        mBackgroundThread.start();        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());    }    private void stopBackgroundThread() {        mBackgroundThread.quitSafely();        try {            mBackgroundThread.join();            mBackgroundThread = null;            mBackgroundHandler = null;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    @RequiresApi(api = Build.VERSION_CODES.M)    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build();        StrictMode.setThreadPolicy(policy);        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);        setContentView(R.layout.activity_main);        Log.d(LOG_TAG, "Запрашиваем разрешение");        if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED                ||                (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)        ) {            requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);        }        mButtonOpenCamera1 = findViewById(R.id.button1);        mButtonTStopStreamVideo = findViewById(R.id.button3);        mImageViewUp = findViewById(R.id.textureView);        mImageViewDown = findViewById(R.id.textureView3);        mButtonOpenCamera1.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                setUpMediaCodec();// инициализируем Медиа Кодек                if (myCameras[CAMERA1] != null) {// открываем камеру                    if (!myCameras[CAMERA1].isOpen()) myCameras[CAMERA1].openCamera();                }            }        });        mButtonTStopStreamVideo.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                if (encoder != null) {                    Toast.makeText(MainActivity.this, " остановили стрим", Toast.LENGTH_SHORT).show();                    myCameras[CAMERA1].stopStreamingVideo();                }            }        });        try {            udpSocket = new DatagramSocket();            udpSocketIn = new DatagramSocket(port);// we changed it to DatagramChannell becouse UDP packets may be different in size            try {            }            catch (Exception e){                Log.i(LOG_TAG, "  создали udp канал");            }            new Udp_recipient();            Log.i(LOG_TAG, "  создали udp сокет");        } catch (                SocketException e) {            Log.i(LOG_TAG, " не создали udp сокет");        }        try {            address = InetAddress.getByName(ip_address);            Log.i(LOG_TAG, "  есть адрес");        } catch (Exception e) {        }        mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);        try {            // Получение списка камер с устройства            myCameras = new CameraService[mCameraManager.getCameraIdList().length];            for (String cameraID : mCameraManager.getCameraIdList()) {                Log.i(LOG_TAG, "cameraID: " + cameraID);                int id = Integer.parseInt(cameraID);                // создаем обработчик для камеры                myCameras[id] = new CameraService(mCameraManager, cameraID);            }        } catch (CameraAccessException e) {            Log.e(LOG_TAG, e.getMessage());            e.printStackTrace();        }    }    public class CameraService {        private String mCameraID;        private CameraDevice mCameraDevice = null;        private CameraCaptureSession mSession;        private CaptureRequest.Builder mPreviewBuilder;        public CameraService(CameraManager cameraManager, String cameraID) {            mCameraManager = cameraManager;            mCameraID = cameraID;        }        private CameraDevice.StateCallback mCameraCallback = new CameraDevice.StateCallback() {            @Override            public void onOpened(CameraDevice camera) {                mCameraDevice = camera;                Log.i(LOG_TAG, "Open camera  with id:" + mCameraDevice.getId());                startCameraPreviewSession();            }            @Override            public void onDisconnected(CameraDevice camera) {                mCameraDevice.close();                Log.i(LOG_TAG, "disconnect camera  with id:" + mCameraDevice.getId());                mCameraDevice = null;            }            @Override            public void onError(CameraDevice camera, int error) {                Log.i(LOG_TAG, "error! camera id:" + camera.getId() + " error:" + error);            }        };        private void startCameraPreviewSession() {            SurfaceTexture texture = mImageViewUp.getSurfaceTexture();            texture.setDefaultBufferSize(480, 640);            surface = new Surface(texture);            try {                mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);                mPreviewBuilder.addTarget(surface);                mPreviewBuilder.addTarget(mEncoderSurface);                mCameraDevice.createCaptureSession(Arrays.asList(surface, mEncoderSurface),                        new CameraCaptureSession.StateCallback() {                            @Override                            public void onConfigured(CameraCaptureSession session) {                                mSession = session;                                try {                                    mSession.setRepeatingRequest(mPreviewBuilder.build(), null, mBackgroundHandler);                                } catch (CameraAccessException e) {                                    e.printStackTrace();                                }                            }                            @Override                            public void onConfigureFailed(CameraCaptureSession session) {                            }                        }, mBackgroundHandler);            } catch (CameraAccessException e) {                e.printStackTrace();            }        }        public boolean isOpen() {            if (mCameraDevice == null) {                return false;            } else {                return true;            }        }        public void openCamera() {            try {                if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {                    mCameraManager.openCamera(mCameraID, mCameraCallback, mBackgroundHandler);                }            } catch (CameraAccessException e) {                Log.i(LOG_TAG, e.getMessage());            }        }        public void closeCamera() {            if (mCameraDevice != null) {                mCameraDevice.close();                mCameraDevice = null;            }        }        public void stopStreamingVideo() {            if (mCameraDevice != null & encoder != null) {                try {                    mSession.stopRepeating();                    mSession.abortCaptures();                } catch (CameraAccessException e) {                    e.printStackTrace();                }                encoder.stop();                encoder.release();                mEncoderSurface.release();                decoder.stop();                decoder.release();                closeCamera();            }        }    }    private void setUpMediaCodec() {        try {            encoder = MediaCodec.createEncoderByType("video/avc"); // H264 кодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету кодека");        }        {            int width = 480; // ширина видео            int height = 640; // высота видео            int colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; // формат ввода цвета            int videoBitrate = 2000000; // битрейт видео в bps (бит в секунду)            int videoFramePerSecond = 30; // FPS            int iframeInterval = 1; // I-Frame интервал в секундах            MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);            format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);            format.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate);            format.setInteger(MediaFormat.KEY_FRAME_RATE, videoFramePerSecond);            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iframeInterval);            encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); // конфигурируем кодек как кодер            mEncoderSurface = encoder.createInputSurface(); // получаем Surface кодера        }        encoder.setCallback(new EncoderCallback());        encoder.start(); // запускаем кодер        Log.i(LOG_TAG, "запустили кодек");        try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture = mImageViewDown.getSurfaceTexture();        texture.setDefaultBufferSize(480, 640);        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");    }    //CALLBACK FOR DECODER    private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {                  decoderInputBuffer = codec.getInputBuffer(index);                  decoderInputBuffer.clear();                                    synchronized (out)                    {                            b =  out.toByteArray();                        out.reset();                          }                            decoderInputBuffer.put(b);                   codec.queueInputBuffer(index, 0, b.length,0, 0);        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                  {                      decoderOutputBuffer = codec.getOutputBuffer(index);                      codec.releaseOutputBuffer(index, true);                  }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    private class EncoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            Log.i(LOG_TAG, " входные буфера готовы" );            //        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            outPutByteBuffer = encoder.getOutputBuffer(index);            byte[] outDate = new byte[info.size];            outPutByteBuffer.get(outDate);            try {                DatagramPacket packet = new DatagramPacket(outDate, outDate.length, address, port);                udpSocket.send(packet);            } catch (IOException e) {                Log.i(LOG_TAG, " не отправился UDP пакет");            }            encoder.releaseOutputBuffer(index, false);        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {          //  Log.i(LOG_TAG, "encoder output format changed: " + format);        }    }    @Override    public void onPause() {        if (myCameras[CAMERA1].isOpen()) {            myCameras[CAMERA1].closeCamera();        }        stopBackgroundThread();        super.onPause();    }    @Override    public void onResume() {        super.onResume();        startBackgroundThread();    }    public class Udp_recipient extends Thread {        Udp_recipient()        {            out = new ByteArrayOutputStream(50000);            start();        }        public void run()        {            while (true)            {                try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                     synchronized (outDataForEncoder)                     {                         for (int i = 0; i < outDataForEncoder.length; i++)                         {                             outDataForEncoder[i] = bBuffer[i];                         }                     }                     synchronized (out)                            {out.write(outDataForEncoder);}                } catch (Exception e) {                    Log.i(LOG_TAG, e + "  ");                }            }        }        }    }


Как видно из листинга, мы выкинули из кодера фрагмент, где готовый байтовый массив рубился на кусочки длиной не более килобайта из-за опасения, что они могут не влезть в MTU. Опасения, как уже говорилось, оказались напрасными и даже вредными. Дело в том, (как мне показалось ) что кодер лепит эти массивы уже как некие смысловые единицы и соответственно декодер таким же порядком кладёт их в свои входные буферы. А если у нас килобайт попадает в один буфер, а хвостик в другой? Теперь-то скрывать уже нечего, но на самом деле, видео у меня красиво не показывало даже тогда, когда я учредил ByteArrayOutputStream. Не показывало до тех пор, пока я не выкинул этот злосчастный фрагмент кода.

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

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



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

Для полноты счастья оставалось только провести эксперименты с доступными разрешением и битрейтом. Я сначала хотел было поставить вполне приличные 1280 х 960, но для этого в кодеке H.264 нужен битрейт 5000-6000 Кбит / с. А при установке такой скорости мой декодер, к сожалению, со своей работой уже не справлялся. Пришлось ограничиться разрешением 640 х 480 (тем, что и так уже было) и подходящим для этого битрейтом 2000 Кбит / с. Потому что при приближении к скорости света к 3 000 000 бит/c, декодер начинает иметь бесконечную массу, валять дурака и отваливаться.

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

Сама программа особо не поменяется. Макет вообще трогать не будем. Нам всего лишь надо:

  1. Отключить первую Surface от камеры
  2. Подключить к ней декодер. Грубо говоря, скопировать окно.

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

TextView1.setText(Int +  );TextView2.setText(Int +  );

Но видимо, поток данных в Surface не так прост как просто вывод текста. Если пробовать решить вопрос таким путем, то у вас будет выводится окно, которое было инициализировано последним. А первая инициализация исчезнет.

Чего я только не делал, даже textureView окна в макете пытался обозвать одинаково, ничего не помогало. На stockoverflow было на этот счёт мало чего (действительно, кому нафиг надо дублировать видео поток в два одинаковых окна на смартфоне), а в единственном обсуждении, что я нашёл, высказывалось туманное предположение, что один экземпляр Mediac Codec может связываться только с одной Surface.

Выход оказался совсем не элегантным. Надо скопировать видео поток во второе окно? Создавай второй экземпляр декодера, не больше ни меньше. Хорошо, что ещё не заставили второй раз данные по UDP каналу пересылать.

Но, тем тем не менее, при всей своей дубовости метод работает.

Листинг кода main_activity
package com.example.twovideosurfaces;import androidx.annotation.RequiresApi;import androidx.appcompat.app.AppCompatActivity;import androidx.core.content.ContextCompat;import android.Manifest;import android.content.Context;import android.content.pm.ActivityInfo;import android.content.pm.PackageManager;import android.graphics.SurfaceTexture;import android.hardware.camera2.CameraAccessException;import android.hardware.camera2.CameraCaptureSession;import android.hardware.camera2.CameraDevice;import android.hardware.camera2.CameraManager;import android.hardware.camera2.CaptureRequest;import android.media.MediaCodec;import android.media.MediaCodecInfo;import android.media.MediaFormat;import android.os.Build;import android.os.Bundle;import android.os.Handler;import android.os.HandlerThread;import android.os.StrictMode;import android.util.Log;import android.view.Surface;import android.view.TextureView;import android.view.View;import android.widget.Button;import android.widget.Toast;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.net.DatagramPacket;import java.net.DatagramSocket;import java.net.InetAddress;import java.net.SocketException;import java.nio.ByteBuffer;import java.util.Arrays;public class MainActivity extends AppCompatActivity  {    public static final String LOG_TAG = "myLogs";    CameraService[] myCameras = null;    private CameraManager mCameraManager = null;    private final int CAMERA1 = 0;    private Button mOn = null;    private Button mOff = null;    public static TextureView mImageViewUp = null;    public static TextureView mImageViewDown = null;    private HandlerThread mBackgroundThread;    private Handler mBackgroundHandler = null;    private MediaCodec encoder = null; // кодер    private MediaCodec decoder = null;    private MediaCodec decoder2 = null;    byte [] b;    byte [] b2;    Surface mEncoderSurface; // Surface как вход данных для кодера    Surface mDecoderSurface; // Surface как прием данных от кодера    Surface mDecoderSurface2; // Surface как прием данных от кодера    ByteBuffer outPutByteBuffer;    ByteBuffer decoderInputBuffer;    ByteBuffer decoderOutputBuffer;    ByteBuffer decoderInputBuffer2;    ByteBuffer decoderOutputBuffer2;    byte outDataForEncoder [];    static  boolean  mNewFrame=false;    DatagramSocket udpSocket;    DatagramSocket udpSocketIn;    String ip_address = "192.168.50.131";    InetAddress address;    int port = 40002;    ByteArrayOutputStream out =new ByteArrayOutputStream(50000);    ByteArrayOutputStream out2 = new ByteArrayOutputStream(50000);    private void startBackgroundThread() {        mBackgroundThread = new HandlerThread("CameraBackground");        mBackgroundThread.start();        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());    }    private void stopBackgroundThread() {        mBackgroundThread.quitSafely();        try {            mBackgroundThread.join();            mBackgroundThread = null;            mBackgroundHandler = null;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    @RequiresApi(api = Build.VERSION_CODES.M)    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build();        StrictMode.setThreadPolicy(policy);        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);        setContentView(R.layout.activity_main);        Log.d(LOG_TAG, "Запрашиваем разрешение");        if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED                ||                (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)        ) {            requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);        }        mOn = findViewById(R.id.button1);        mOff = findViewById(R.id.button3);        mImageViewUp = findViewById(R.id.textureView);        mImageViewDown = findViewById(R.id.textureView3);        mOn.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                setUpMediaCodec();// инициализируем Медиа Кодек                if (myCameras[CAMERA1] != null) {// открываем камеру                    if (!myCameras[CAMERA1].isOpen()) myCameras[CAMERA1].openCamera();                }            }        });        mOff.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                if (encoder != null) {                    Toast.makeText(MainActivity.this, " остановили стрим", Toast.LENGTH_SHORT).show();                    myCameras[CAMERA1].stopStreamingVideo();                }            }        });        try {            udpSocket = new DatagramSocket();            udpSocketIn = new DatagramSocket(port);// we changed it to DatagramChannell becouse UDP packets may be different in size            try {            }            catch (Exception e){                Log.i(LOG_TAG, "  создали udp канал");            }            new Udp_recipient();            Log.i(LOG_TAG, "  создали udp сокет");        } catch (                SocketException e) {            Log.i(LOG_TAG, " не создали udp сокет");        }        try {            address = InetAddress.getByName(ip_address);            Log.i(LOG_TAG, "  есть адрес");        } catch (Exception e) {        }        mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);        try {            // Получение списка камер с устройства            myCameras = new CameraService[mCameraManager.getCameraIdList().length];            for (String cameraID : mCameraManager.getCameraIdList()) {                Log.i(LOG_TAG, "cameraID: " + cameraID);                int id = Integer.parseInt(cameraID);                // создаем обработчик для камеры                myCameras[id] = new CameraService(mCameraManager, cameraID);            }        } catch (CameraAccessException e) {            Log.e(LOG_TAG, e.getMessage());            e.printStackTrace();        }    }    public class CameraService {        private String mCameraID;        private CameraDevice mCameraDevice = null;        private CameraCaptureSession mSession;        private CaptureRequest.Builder mPreviewBuilder;        public CameraService(CameraManager cameraManager, String cameraID) {            mCameraManager = cameraManager;            mCameraID = cameraID;        }        private CameraDevice.StateCallback mCameraCallback = new CameraDevice.StateCallback() {            @Override            public void onOpened(CameraDevice camera) {                mCameraDevice = camera;                Log.i(LOG_TAG, "Open camera  with id:" + mCameraDevice.getId());                startCameraPreviewSession();            }            @Override            public void onDisconnected(CameraDevice camera) {                mCameraDevice.close();                Log.i(LOG_TAG, "disconnect camera  with id:" + mCameraDevice.getId());                mCameraDevice = null;            }            @Override            public void onError(CameraDevice camera, int error) {                Log.i(LOG_TAG, "error! camera id:" + camera.getId() + " error:" + error);            }        };        private void startCameraPreviewSession() {            try {                mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);                mPreviewBuilder.addTarget(mEncoderSurface);                mCameraDevice.createCaptureSession(Arrays.asList(mEncoderSurface),                        new CameraCaptureSession.StateCallback() {                            @Override                            public void onConfigured(CameraCaptureSession session) {                                mSession = session;                                try {                                    mSession.setRepeatingRequest(mPreviewBuilder.build(), null, mBackgroundHandler);                                } catch (CameraAccessException e) {                                    e.printStackTrace();                                }                            }                            @Override                            public void onConfigureFailed(CameraCaptureSession session) {                            }                        }, mBackgroundHandler);            } catch (CameraAccessException e) {                e.printStackTrace();            }        }        public boolean isOpen() {            if (mCameraDevice == null) {                return false;            } else {                return true;            }        }        public void openCamera() {            try {                if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {                    mCameraManager.openCamera(mCameraID, mCameraCallback, mBackgroundHandler);                }            } catch (CameraAccessException e) {                Log.i(LOG_TAG, e.getMessage());            }        }        public void closeCamera() {            if (mCameraDevice != null) {                mCameraDevice.close();                mCameraDevice = null;            }        }        public void stopStreamingVideo() {            if (mCameraDevice != null & encoder != null) {                try {                    mSession.stopRepeating();                    mSession.abortCaptures();                } catch (CameraAccessException e) {                    e.printStackTrace();                }                encoder.stop();                encoder.release();                mEncoderSurface.release();                decoder.stop();                decoder.release();                decoder2.stop();                decoder2.release();                closeCamera();            }        }    }    private void setUpMediaCodec() {        try {            encoder = MediaCodec.createEncoderByType("video/avc"); // H264 кодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету кодека");        }        {            int width = 640; // ширина видео            int height = 480; // высота видео            int colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; // формат ввода цвета            int videoBitrate = 2000000; // битрейт видео в bps (бит в секунду)            int videoFramePerSecond = 30; // FPS            int iframeInterval = 1; // I-Frame интервал в секундах            MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);            format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);            format.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate);            format.setInteger(MediaFormat.KEY_FRAME_RATE, videoFramePerSecond);            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iframeInterval);            encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); // конфигурируем кодек как кодер           mEncoderSurface = encoder.createInputSurface(); // получаем Surface кодера        }        encoder.setCallback(new EncoderCallback());        encoder.start(); // запускаем кодер        Log.i(LOG_TAG, "запустили кодек");        try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture= mImageViewUp.getSurfaceTexture();        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");        try {            decoder2 = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width2 = 480; // ширина видео        int height2 = 640; // высота видео        MediaFormat format2 = MediaFormat.createVideoFormat("video/avc", width2, height2);        format2.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture2= mImageViewDown.getSurfaceTexture();        mDecoderSurface2 = new Surface(texture2);        decoder2.configure(format2, mDecoderSurface2, null,0);        decoder2.setOutputSurface(mDecoderSurface2);        decoder2.setCallback(new DecoderCallback2());        decoder2.start();        Log.i(LOG_TAG, "запустили декодер");    }    private class DecoderCallback2 extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            decoderInputBuffer2 = codec.getInputBuffer(index);            decoderInputBuffer2.clear();            synchronized (out2)            {                b2 =  out2.toByteArray();                out2.reset();            }            decoderInputBuffer2.put(b2);            codec.queueInputBuffer(index, 0, b2.length,0, 0);            if (b2.length!=0)            {                //   Log.i(LOG_TAG, b.length + " декодер вход  "+index );            }        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                {                    decoderOutputBuffer2 = codec.getOutputBuffer(index);                    codec.releaseOutputBuffer(index, true);                }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    //CALLBACK FOR DECODER    private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            decoderInputBuffer = codec.getInputBuffer(index);            decoderInputBuffer.clear();            synchronized (out)            {                b =  out.toByteArray();                out.reset();            }            decoderInputBuffer.put(b);            codec.queueInputBuffer(index, 0, b.length,0, 0);                 if (b.length!=0)            {               //  Log.i(LOG_TAG, b.length + " декодер вход  "+index );            }        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                {                    decoderOutputBuffer = codec.getOutputBuffer(index);                    codec.releaseOutputBuffer(index, true);                }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    private class EncoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            Log.i(LOG_TAG, " входные буфера готовы" );        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            outPutByteBuffer = encoder.getOutputBuffer(index);            byte[] outDate = new byte[info.size];            outPutByteBuffer.get(outDate);            try {                //  Log.i(LOG_TAG, " outDate.length : " + outDate.length);                DatagramPacket packet = new DatagramPacket(outDate, outDate.length, address, port);                udpSocket.send(packet);            } catch (IOException e) {                Log.i(LOG_TAG, " не отправился UDP пакет");            }            encoder.releaseOutputBuffer(index, false);        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            //  Log.i(LOG_TAG, "encoder output format changed: " + format);        }    }    @Override    public void onPause() {        if (myCameras[CAMERA1].isOpen()) {            myCameras[CAMERA1].closeCamera();        }        stopBackgroundThread();        super.onPause();    }    @Override    public void onResume() {        super.onResume();        startBackgroundThread();    }    public class Udp_recipient extends Thread {        Udp_recipient() {            start();            //    Log.i(LOG_TAG, "запустили прием данных по udp");        }        public void run() {            while (true) {                try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                    synchronized (outDataForEncoder) {                        for (int i = 0; i < outDataForEncoder.length; i++) {                            outDataForEncoder[i] = bBuffer[i];                        }                    }                    mNewFrame = true;                    synchronized (out)                    {out.write(outDataForEncoder);}                    synchronized (out2)                    {out2.write(outDataForEncoder);}                } catch (Exception e) {                    Log.i(LOG_TAG, e + "hggh ");                }            }        }    }}       



Теперь в таргет адресе первого смартофона:

String ip_address = " допустим 192.168.50.131";

указываем адрес его визави и наоборот. Запускаем приложения, надеваем VR-очки и вперёд, двигать науку нейробиологию!
Подробнее..

Дайджест интересных материалов для мобильного разработчика 348 (8 14 июня)

14.06.2020 16:43:19 | Автор: admin
Наш новый дайджест рассказывает про странный поиск вирусов в приложении для изучения иностранного языка, про применение Kotlin и автоматизацию локализации, про лучшие интерфейсы и вратарей напрокат.


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

Этот дайджест доступен в виде еженедельной рассылки. А ежедневно новости мы рассылаем в Telegram-канале.

iOS

Apple представляет программу WWDC20
Apple переведет компьютеры на собственные ARM-процессоры
Разработчики за пределами США жалуются на проблемы с оформлением и продлением подписки Apple Developer Program
Создаем iOS-приложение с позитивными новостями, используя возможности машинного обучения
Создаем собственный шаблон в Xcode и сокращаем времени разработки
SwiftUI: интеграция Mapbox SDK
Рисуем в iOS используя CAShapeLayer
Дополненная реальность на Swift 5 как начать
5 iOS-библиотек, которые сделают ваше приложение более стильным
Многоразовые всплывающие окна и оповещения в iOS
Создаем простой прогресс-бар, который можно добавить в любое приложении
Изучение SwiftUI сделает вас лучшим программистом
Анализ open source iOS-приложений
Как применять условные модификаторы View в SwiftUI
Обзор указателей в Swift
Улучшите ваш рабочий процесс с UIKit и Swift Live Previews
FSPagerView: элегантный слайдер

Android

(+26) Spring Boot, Hibernate и Kotlin для новичков шаг за шагом
(+4) Практическое применение Kotlin в стартапах и энтерпрайзе
Android Dev Podcast #115. Новости
Вышла первая бета Android 11
Google Play Asset Delivery открыли для всех
Mobile People Talks: Jetpack Compose взгляд изнутри
Как сделать приложение-календарь для Android
Как анимировать и строить графики с помощью Android Interpolator
Создаем приложения Scrum Poker с применением MotionLayout
7 главных языков программирования для разработки Android-приложений
Создание вращающийся ручки на Kotlin
Android Studio: Layout Inspector
Kotlin и Exceptions
Темный режим в Android-приложении с Kotlin
Инъекция зависимостей на Android с Hilt
Kotlin Symbol Processing: первые мысли
Важные изменения в Android Studio 4.0
Simple Dialer: звонилка для Android
Compose Academy: изучаем Jetpack Compose
Trinity: короткие видео для Android

Разработка

(+20) Phrase.com или как мы автоматизировали флоу локализации
(+16) Оптимизация рендера под Mobile. Часть 3. Шейдеры
(+12) 20 платформ для заработка на тестировании
(+11) QA-процесс в Miro: отказ от водопада и ручного тестирования, передача ответственности за качество всей команде
(+11) Великобритания запускает приложение, следящее за вашим кругом общения: как оно будет работать и когда будет доступным
(+10) Красивое удобнее, чем некрасивое? Обзор исследований
(+7) Как сделать заказной веб- или mobile-проект с нуля: процессы, правила и немного крови
(+5) SSL pinning во Flutter
(+5) Нативная разработка vs кросс-платформенная нужно ли выбирать?
(0) React Native: Push-уведомления с помощью AWS Amplify
Исследование The State of Developer Ecosystem 2020 от JetBrains
Snapchat запускает мини-приложения внутри чата
make sense: о связке продукт коммуникации, нарративе и воспринимаемой енности
Podlodka #167: Компиляторы
LOVEMOBILE #06: Издательство с 101XP
9 советов ля быстрого улучшения дизайна вашего пользовательского интерфейса
Дизайн приложений: примеры для вдохновения #4
Открылся прием навыков для Маруси
10 правил NASA для написания критически важного кода
Мобильное приложение на Flutter. Стоимость, сроки, подводные камни. Часть 1
Как правильно интегрировать исследования пользователей и рынка в вашу продуктовую команду
Дизайнеру приложений: как создать и передать в разработку тёмную тему
BindingX: нативная разработка без нативной разработки
Как работает шумоподавление в Google Meet
10 новых и многообещающих трендов в дизайне интерфейсов
Как записывать автоматизированные тесты для мобильных приложений

Аналитика, маркетинг и монетизация

(+4) Как продвигать мобильные игры и приложения в Японии, Корее и Китае
В Бразилии сделали приложение Вратарь напрокат
Как обстоит дело с ретаргетингом в риложениях. 2020 год: отчет AppsFlyer
Axiom: анализ данных на предприятиях
Сбербанк покупает 2ГИС
Сheckaso ищет приложения для бесплатного ASO аудита
Рост гиперказуальных игр в 1 квартале 2020: отчет djust и Unity
Drop привлек $13.3 млн. на платформу умной кухни
myTarget расширил инструменты атрибуции екламных кампаний
Влияние коронавируса на стоимость рекламы: исследование AB
AppsFlyer открывает бесплатный доступ к своим нструментам
Российский рынок ИТ-услуг сократится на треть
Tajir: онлайн-магазин для офлайн-магазинов
Гайд: как сделать текстовое ASO быстро и эффективно?
Тудурант менеджер задач, который заставляет примитивный мозг работать

AI, Устройства, IoT

(+35) Люди ломаются на логике, роботы на всем понемногу. Экзамены по русскому для NLP-моделей
(+30) Как мы отказались от нейросетей, а затем вернули их в прогноз осадков Яндекс.Погоды
(+17) Автоматизация квартиры
Snap пускает сторонние ML-модели в свои Линзы
Стоимость тренировки ИИ упала в 100 раз за 2 года
Как я сдал сертификационный экзамен разработчика TensorFlow

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

Из песочницы Кодовая база. Расширяем RecyclerView

26.06.2020 18:17:05 | Автор: admin
image
Всем привет!

Меня зовут Антон Князев, senior Android-разработчик компании Omega-R. В течение последних семи лет я профессионально занимаюсь разработкой мобильных приложений и решаю сложные проблемы нативной разработки.

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

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

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

Первая библиотека, которую мы выложили на GitHub, является простым расширением RecyclerView.

Начнем с проблем, которые она решала:

  1. Нет дефолтного layoutManager это неудобно, так как часто приходится выбирать один и тот же LinearLayoutManager;
  2. Нет возможности добавлять divider и item space через xml тоже неудобно, так как необходимо добавлять либо в item layout, либо через ItemDecorator;
  3. Нельзя просто добавить header и footer через xml это возможно только через отдельный ViewHolder.

Проблемы некритичные, но создают неудобства и увеличивают время разработки.

1. Проблема: нет дефолтного layoutManager


Разработчики RecyclerView не предусмотрели возможности выбора LayoutManager по умолчанию. Задать layoutManager можно следующими способами:

1. через XML в атрибуте app:layoutManager=LinearLayoutManager:

<?xml version="1.0" encoding="utf-8"?><androidx.recyclerview.widget.RecyclerView   ...    app:layoutManager="LinearLayoutManager"/>

2. через код:

recyclerView.layoutManager = LinearLayoutManager(this)

По нашему опыту, в большинстве случаев нужен именно LinearLayoutManager.

Вот несколько примеров таких списков из наших приложений ITProTV, Простой Мир и Dexen:

image

Решение: добавим дефолтный layoutManager


В OmegaRecyclerView добавляется лишь 3 строчки:

 if (layoutManager == null)  {            layoutManager = LinearLayoutManager(context, attrs, defStyleAttr, 0) }

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

<?xml version="1.0" encoding="utf-8"?><com.omega_r.libs.omegarecyclerview.OmegaRecyclerView    android:id="@+id/recyclerview"    android:layout_width="match_parent"    android:layout_height="match_parent" />

2. Проблема: нет возможности добавлять divider и item space через xml


Добавлять divider приходится довольно часто при использовании RecyclerView. Например, в проекте Простой Мир один из экранов был с таким нестандартным divider:

image

Из этого макета видно, что:

  • используются divider между элементами и в самом конце;
  • используется item space.

Каким образом это можно реализовать в Android стандартным путем?

Способ 1


Самый очевидный способ включить divider как элемент ImageView:

 <RelativeLayout   ...   android:paddingStart="20dp"   android:paddingTop="12dp"   android:paddingEnd="20dp"   android:paddingBottom="12dp">...   <ImageView       ...       android:layout_alignParentBottom="true"       android:src="@drawable/divider"/></RelativeLayout>

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

Способ 2


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

<?xml version="1.0" encoding="utf-8"?><layer-list xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android">    <item android:left="32dp">        <shape android:shape="rectangle">            <size                    android:width="1dp"                    android:height="1dp" />            <solid android:color="@color/gray_dark" />        </shape>    </item></layer-list>

Для добавления отступа требуется написать свой ItemDecoration:

class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int): RecyclerView.ItemDecoration {    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView,            state: RecyclerView.State) {        outRect.bottom = verticalSpaceHeight    }}

DividerItemDecoration прост: он рисует divider всегда под каждым элементом списка.
Но в случае изменения требований придется искать другое решение.

Решение: дополним возможностью добавлять divider и item space через xml


Итак, наш OmegaRecyclerView должен уметь добавлять divider с помощью следующих атрибутов:

  1. divider определяет drawable, может быть назначен и цвет напрямую;
  2. dividerShow (beginning, middle, end) флаги, которые определяют, где рисовать;
  3. dividerHeight задает высоту divider, в случае с цветом становится особенно нужным;
  4. dividerPadding, dividerPaddingStart, dividerPaddingEnd отступы: общий, с начала, с конца;
  5. dividerAlpha определяет прозрачность;
  6. itemSpace отступы между элементами списка.

Все эти атрибуты применяются непосредственно к нашему OmegaRecyclerView с помощью двух специальных ItemDecoration.

Один из ItemDecoration добавляет отступы между элементами, второй рисует сами divider. Необходимо учитывать, что при наличии отступа между элементами второй ItemDecoration рисует divider посередине отступа. Возможности поддерживать все возможные варианты LayoutManager нет, поэтому поддерживаем только LinearLayoutManager и GridLayoutManager. Также следует учитывать ориентацию списка для LinearLayoutManager.

Для того чтобы упростить код, выделим специальный DividerDecorationHelper, который будет читать и записывать относительные данные в Rect, зависящие от ориентации и порядка следования (обратный или прямой). В нем будут методы setStart, setEnd и getStart, getEnd.

Создадим базовый ItemDecoration для двух классов, в котором будет содержаться общая логика, а именно:

  1. проверка, что layoutManager является наследником LinearLayoutManager;
  2. вычисление текущей ориентации и порядка следования;
  3. определение подходящего DividerDecorationHelper.

В SpaceItemDecoration переопределим только один метод getItemOffset, который будет добавлять отступы:

override fun getItemOffset(outRect: Rect, parent: RecyclerView, helper: DividerDecorationHelper, position: Int, itemCount: Int) {        if (isShowBeginDivider() || countBeginEndPositions <= position) helper.setStart(outRect, space)        if (isShowEndDivider() && position == itemCount - countBeginEndPositions) helper.setEnd(outRect, space)    }

Следующий DividerItemDecoration будет непосредственно рисовать divider. Он должен учитывать отступ между элементами и рисовать divider посередине. Для начала переопределим метод getItemOffset для того случая, когда отступ не задан, но divider требуется для рисования.

    override fun getItemOffset(outRect: Rect, parent: RecyclerView, helper: DividerDecorationHelper, position: Int, itemCount: Int) {        if (position == 0 && isShowBeginDivider()) {            helper.setStart(outRect, dviderSize)        }        if (position != 0 && isShowMiddleDivider()) {            helper.setStart(outRect, dividerSize)        }        if (position == itemCount - 1 && isShowEndDivider()) {            helper.setEnd(outRect, dividerSize)        }    }

Также добавим такую опцию, которая позволит DividerItemDecoration спрашивать adapter, можно ли рисовать выше или ниже выбранного элемента. Для реализации такой возможности создадим свой адаптер, наследуемый от стандартного со следующими методами:

   open fun isDividerAllowedAbove(position: Int): Boolean {        return true    }    open fun isDividerAllowedBelow(position: Int): Boolean {        return true    }

Далее переопределим метод onDrawOver, чтобы рисовать divider поверх нарисованных элементов. В этом методе надо пройтись по всем элементам, видимым на экране (через getChildAt), и при необходимости нарисовать этот divider. Также надо учесть, что из атрибута dividerDrawable может прийти и цвет, у которого нет высоты. Для такого случая высоту можно взять из атрибута dividerHeight.

3. Проблема: нельзя напрямую добавить header и footer через xml


image

В RecyclerView невозможно добавлять view через xml, но есть другие способы сделать это.

Способ 1


Один из очевидных способ добавлении view через adapter. Причем необходимо отличать header и footer в adapter при введении своего идентификатора для viewType.

 fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder? {  val inflater = LayoutInflater.from(parent.context)        return when (viewType) { TYPE_HEADER -> {                val headerView: View = inflater.inflate(R.layout.item_header, parent, false)                HeaderViewHolder(itemView)            }            TYPE_ITEM -> {                val itemView: View = inflater.inflate(R.layout.item_view, parent, false)                ItemViewHolder(itemView)            }                       else -> null        }    }

Способ 2


Немного другой способ, но тоже через adapter. Начиная с recyclerview:1.2.0-alpha02, появился MergeAdapter, который позволяет соединять несколько адаптеров в один, делая код чище.

val mergeAdapter = MergeAdapter(headerAdapter, itemAdapter,  footerAdapter)recyclerView.adapter = mergeAdapter

Решение: дополним возможностью простого добавления header и footer через xml


Первое, что нужно сделать перехватить добавление view в нашем OmegaRecyclerView, когда идет процесс inflate. Для этого следует переопределить метод addView и добавить себе все header и footer view. Этот метод используется самим RecyclerView для дополнения видимыми элементами списка. Но view, добавленные через xml, не будут иметь ViewHolder, что в конечном итоге вызовет NullPointerException.

Итак, нам надо определить, когда view добавляется во время inflate. К счастью, существует protected метод onFinishInflate, который вызывается при завершении процесса inflate. Поэтому при вызове этого метода помечаем, что процесс inflate завершен.

  protected override fun onFinishInflate() {        super.onFinishInflate()        finishedInflate = true    }

Таким образом, метод addView будет выглядеть следующим образом:

  override fun addView(view: View, index: Int, params: ViewGroup.LayoutParams) {        if (finishedInflate) {            super.addView(view, index, params)        } else {            // save header and footer views        }    }

Далее необходимо запомнить все эти добавочные view и передать в специальный адаптер по типу MergeAdapter.

Также нам удалось решить еще одну проблему: при вызове метода findViewById наши view возвращаться не будут. Для решения этой проблемы переопределим метод findViewTraversal: в нем необходимо сравнить id найденных нами view и вернуть view при совпадении. Поскольку этот метод скрыт, просто пишем его, не указывая, что он override.

С этими и другими полезными фичами с подробным описанием вы можете познакомиться в нашей библиотеке OmegaRecyclerView:

  1. Мы уже в 2018 году создали свой ViewPager из RecyclerView. Причем, в нашем ViewPager есть бесконечный скролл;
  2. ExpandableRecyclerView специальный класс для добавления раскрывающего списка, с возможностью выбора анимации раскрытия;
  3. StickyHeader специфический элемент списка, который можно добавлять через адаптер.

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

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

Из песочницы MVP для Android преимущества использования Moxy в качестве вспомогательной библиотеки

15.06.2020 20:17:21 | Автор: admin

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


Важно: здесь не сравнивается MVP с другими архитектурными подходами типа MVVM, MVI и т.п. В статье описывается почему, если в качестве архитектуры UI-ной части приложения выбран MVP, то лучше не использовать самописную реализацию MVP, а использовать Moxy.


Библиотека Moxy позволяет избежать boilerplate кода для обработки lifecycle фрагментов и активитей, и работать с View как будто оно всегда активно.
Далее под View понимается имплементация View в виде фрагмента или активити.
Под интерактором понимается сущность бизнес-логики, т.е. класс, который лежит на более низком уровне абстракции, чем Presenter.


Общие преимущества Moxy


  • Активная поддержка и разработка библиотеки.
  • Поддержка фичей Kotlin вроде val presenter by moxyPresenter { component.myPresenter } и presenterScope для корутин.
  • Автоматическое восстановление состояния View.
  • Автоматическая увязка с жизненным циклом (а отсюда отсутствие утечек Активити и прочей подобной прелести).
  • Обращения к View происходят через не-nullable viewState. Нет риска, что какая-то команда View потеряется.
  • Весь lifecycle экрана сводится к двум коллбэкам презентера onFirstViewAttach() и onDestroy().
  • Время разработки экранов сокращается не нужно писать лишний код для обработки lifecycle и сохранения состояний.

Типичные задачи и решения


Рассмотрим, как решаются типичные задачи при разработке UI с использованием Moxy и без.


При решениях без Moxy предполагается следующая типичная реализация MVP. В presenter хранится nullable-ссылка на view. Presenter аттачится (передаётся ссылка на View) при создании View (в onCreate()) и детачится (зануляется ссылка на View) при уничтожении View (в onDestroy()).


Задача: асинхронный запрос данных и отображение результата на UI


Пусть у нас есть класс (MyListInteractor), который возвращает список данных. В presenter мы можем позвать его асинхронно для запроса данных.


class MyPresenter...// Функция запрашивает список и отображает его на UIoverride fun onDisplayListClicked() {        myListInteractor.requestList()            .subscribe { displayList(it) }

Решение с Moxy


private fun displayList(items: List<Item>) {    viewState.setListItems(items)}

Обращаемся к не-nullable viewState и передаём туда загруженные данные. Моху прикопает результат и отправит View, когда оно будет активно. При пересоздании View команда может быть повторена (зависит от стратегии) при этом заново данные не будут запрашиваться.


Решение без Moxy


private fun displayList(items: List<Item>) {    view?.setListItems(items)}

Обращаемся к View по nullable-ссылке. Эта ссылка зануляется при пересоздании View. Если к моменту завершения запроса view не приаттачена, то данные потеряются.
Возможное решение проблемы.


private fun displayList(items: List<Item>) {    view?.let { it.setListItems(items)         }?: let {             cachedListInteractor.saveList(items)          }}

Прикапывать данные в какой-то сущности, которая не связана с View (например, в интеракторе). При onResume() запрашивать прихранённые данные и отображать их.
Минусы решения.


  • Лишняя работа по сохранению результата из-за особенностей платформы (lifecycle Android активитей и фрагментов).
  • Прихранённые данные нужны только View, бизнес-логика будет перегружена проблемами сохранения результата.
  • Нужно следить за своевременной очисткой результата. Если объект, хранящий состояние, используется где-то ещё, то этот код также должен следить за его состоянием.
  • Presenter будет знать о View lifecycle, т.к. нужно уведомлять его об onResume(). Плохо, что Presenter знает об особенностях платформы.

Задача: сохранение состояния отображения


Часто возникает ситуация, когда нам нужно хранить какое-то состояние отображения.


Решение с Moxy


class MyPresenter...private var stateData: StateData = ...

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


Решения без Moxy


  • Можно хранить состояние в savedInstanceState или аргументах фрагмента.


    Минусы решения


    • View будет знать о state и, скорее всего, передавать его в presenter. Т.е. логика размазывается между View и presenter.
    • Возможно, понадобится явно из presenter обращаться к View с целью сохранить состояние, таким образом, в интерфейсе View будут лишние методы сохранения состояния (должны быть только методы для управления отображениям).
    • Presenter может не успеть обратиться к View для сохранения состояния, т.к. ссылка на View может занулиться и сам presenter может погибнуть.
    • Сохранить можно только примитивные и serializable/parcelable данные.
    • Boilerplate код для сохранения данных в Bundle и извлечения из Bundle.

  • Можно хранить данные на уровне бизнес-логики, в специальном классе (пусть этот класс будет отвечать только за state конкретной View и не будет использоваться где-то ещё).


    Минусы решения


    • Нужно следить за актуальностью данных.
    • Не просто разобраться, когда создавать и уничтожать класс, хранящий данные. Обычно его просто делают singleton'ом и он существует всегда, хотя нужен только одной View.


Задача: обмен данными между экранами


Часто бывает нужно результат действий на одном View отобразить на другом View.


Решение с Moxy


Обмен данными между экранами осуществляется так же как и асинхронный запрос. Разве что подписка на изменения идёт на subject или channel в интеракторе, в который presenter другой View кидает изменённые данные. Подписка в Presenter.onFirstViewAttach(), отписка в Presenter.onDestroy().


Решения без Moxy


  • Как и выше, через интерактор с subjectами или channelами. В этом случае подписываться/отписываться нужно в каждом onCreate()/onDestroy(). Так же есть риск потери данных, как в случае асинхронного запроса.
  • Через Broadcast или интент активити. Данные передаются через Bundle. Отсюда тот же Boilerplate с Bundle, как описано в разделе о сохранении состояния. Кроме того, в случае интента активити, логика по обмену данными ложится на View, хотя должна быть исключительно в presenter.

Задача: инициализация чего-либо, связанного с экраном


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


Решение с Moxy


Проинициализировать компонент можно в Presenter.onFirstViewAttach() и освободить в Presenter.onDestroy() это единственные коллбэки, о которых нам нужно задумываться.
Presenter.onFirstViewAttach() вызывается при самом первом создании View,
Presenter.onDestroy() вызывается при окончательном уничтожении View.


Решение без Moxy


Можно проинициализировать в onCreate() и освободить в onDestroy() активити или фрагмента.
Минусы решения


  • Постоянная переинициализация компонента.
  • Если компонент содержит коллбеки, то возможна утечка памяти (объекта presenter или активити/фрагмента).

Задача: показ AlertDialog


Особенностью использования AlertDialog является то, что он пропадает при пересоздании View. Поэтому при пересоздании View нужно заново отображать AlertDialog.


class MyFragment : ... {    private val myAlertDialog = AlertDialog.Builder(context)...    override fun switchAlertDialog(show: Boolean) {        if (show) myAlertDialog.show() else myAlertDialog.dismiss()    }

Решение с Moxy


@StateStrategyType(AddToEndSingleStrategy::class)fun switchAlertDialog(show: Boolean)

Выбрать правильную стратегию. Диалог сам перепокажется при пересоздании View.


Решения без Moxy


  • Можно в View хранить состояние отображения AlertDialog и перепоказывать при пересоздании View. Получается лишний boilerplate просто чтобы восстановить диалог.
  • Можно использовать DialogFragment. Для простых диалогов лишний overhead. И это добавляет проблемы с commit() фрагментов после onSaveInstanceState().

Особенности использования Moxy


Moxy позволяет избежать boilerplate кода при использовании MVP в android-приложении. Но, как и любой другой библиотекой, Moxy нужно научиться пользоваться. К счастью, использовать Moxy легко. Далее описаны моменты, на которые нужно обратить внимание.


  • Как команда View (т.е. вызов метода View) будет повторяться при пересоздании View зависит от стратегии над данным методом интерфейса View. Важно понимать, что означают стратегии. Неправильный выбор стратегии может негативно сказаться на UX. К счастью, стандартных стратегий не много (всего 5) и они хорошо документированы, а создание кастомных стратегий требуется не часто.
  • Moxy обращается к View с того потока, с которого обратился к нему presenter. Поэтому нужно самостоятельно следить в presenter за тем, чтобы методы viewState вызвались из главного потока.
  • Moxy не решает проблему commit() фрагментов после выполнения onSaveInstanceState(). Разработчики Moxy рекомендуют использовать commitAllowingStateLoss(). Однако, это не должно вызывать беспокойство, т.к. за состояние View полностью отвечает Moxy. То, что где-то внутри android потеряется состояние View нас не должно волновать.
  • Взаимно отменяющие команды view лучше объединять в один метод View. Например, скрытие и показ прогресса лучше сделать так:

@StateStrategyType(AddToEndSingleStrategy::class)fun switchProgress(show: Boolean)

а не так:


@StateStrategyType(AddToEndSingleStrategy::class)fun showProgress()@StateStrategyType(AddToEndSingleStrategy::class)fun hideProgress()

Либо можно использовать кастомную стратегию с тегами. Например, как описано тут: http://personeltest.ru/aways/habr.com/ru/company/redmadrobot/blog/341108/
Это нужно чтобы команда показа прогресса не вызвалась больше после команды скрытия прогресса.


  • Presenter должен по-особому инжектится через dagger.
    Например, может возникнуть желание сделать так:

class MyFragment : ... {    @Inject    lateinit var presenter: MyPresenter    @ProvidePresenter    fun providePresenter(): MyPresenter {        return presenter    }

Так делать нельзя. Нужно чтобы функция @ProvidePresenter гарантированно создавала новый инстанс presenter. Здесь при пересоздании фрагмента появится новый инстанс presenter. А Moxy будет работать со старым, т.к. функция providePresenter() вызывается только один раз.
Как вариант, можно в providePresenter() просто создать новый инстанс presenter:


@ProvidePresenterfun providePresenter(): MyPresenter {    return MyPresenterImpl(myInteractor, schedulersProvider)}

Это не очень удобно ведь придётся инжектить в фрагмент все зависимости этого presenter.
Можно из компонента dagger сделать метод для получения presenter и позвать его в providePresenter():


@Component(modules = ...)@Singletoninterface AppComponent {...    fun getMyPresenter(): MyPresenter}

class MyFragment : ... {...@ProvidePresenterfun providePresenter(): MyPresenter {    return TheApplication.getAppComponent().getMyPresenter()}

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


В последних версиях Moxy можно использовать делегаты kotlin:


private val myPresenter: MyPresenter by moxyPresenter {        MyComponent.get().myPresenter }

Ещё можно заинжектить через Provider:


@Injectlateinit var presenterProvider: Provider<MyPresenter>private val presenter by moxyPresenter { presenterProvider.get() }

Итог


Moxy замечательная библиотека, которая позволяет значительно упростить жизнь android-разработчика при использовании MVP.
Как и с любой новой технологией или библиотекой, в начале использования Moxy неизбежно возникают ошибки, например, не верный выбор стратегии или не правильный inject Presenter'а. Однако с опытом всё становится просто и понятно и уже сложно себе представить MVP без использования Moxy.
Выражаю благодарность сообществу Moxy за такой замечательный инструмент. А так же участникам telegram-чата Moxy за ревью и помощь в написании статьи.


Ссылки


Moxy реализация MVP под Android с щепоткой магии отличная статья от разработчиков Moxy с описанием того, для чего создавалась Moxy и как с ней работать.
Стратегии в Moxy (часть 1) статья хорошо описывает стандартные стратегии Moxy.
Стратегии в Moxy (Часть 2) руководство по созданию кастомных стратегий Moxy.
Об использовании популярных практик в разработке под Android высокоуровнево описывается MVP и Moxy.
Moxy. Как правильно пользоваться? / Юрий Шмаков (Arello Mobile) запись с конференции AppsConf, где разработчик Moxy рассказывает о том, как пользоваться библиотекой.

Подробнее..

Из песочницы Как отключить предупреждение о вреде долгого прослушивания аудио (Android)

16.06.2020 12:17:10 | Автор: admin
Наверное, многие, кто слушает музыку (и не только) с Android-устройства, сталкивались с таким предупреждением:


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

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

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

Почему оно возникает


Данное предупреждение не собственная инициатива авторов платформы. Всё дело в том, что существует WHO-ETU стандарт безопасного прослушивания (safe listening). В европейских и некоторых других странах его выполнение обязательно. В стандарте описывается, как долго можно прослушивать аудио в зависимости от громкости с минимальным риском снижения слуха. Например, для взрослого человека безопасная недельная доза звука 1.6 Pa2h, что эквивалентно 20 часам прослушивания на громкости 83 dB.


Реализация


В зависимости от mcc (mobile country code), режим safe listening может быть включен или выключен. Определяется это значением ресурса R.bool.config_safe_media_volume_enabled.

Если режим включен, то система считает время прослушивания на небезопасной громкости (выше 85 dB), и периодически сохраняет значение в переменную Settings.Secure.UNSAFE_VOLUME_MUSIC_ACTIVE_MS. Когда значение достигает 20 часов, выводится предупреждение. После согласия с предупреждением значение сбрасывается, и подсчёт начинается заново.

Такая реализация довольно простая и не учитывает, например, в течение какого времени пользователь прослушал эти 20 часов: возможно, за пару дней, а, может, слушал по 6-7 минут в течение полугода (в соответствии со стандартом это не является угрозой для слуха).

Логика safe listening сосредоточена в классе классе AudioService.java, в нём можно увидеть соответствующие поля:

// mMusicActiveMs is the cumulative time of music activity since safe volume was disabled.// When this time reaches UNSAFE_VOLUME_MUSIC_ACTIVE_MS_MAX, the safe media volume is re-enabled// automatically. mMusicActiveMs is rounded to a multiple of MUSIC_ACTIVE_POLL_PERIOD_MS.private int mMusicActiveMs;private static final int UNSAFE_VOLUME_MUSIC_ACTIVE_MS_MAX = (20 * 3600 * 1000); // 20 hoursprivate static final int MUSIC_ACTIVE_POLL_PERIOD_MS = 60000;  // 1 minute polling interval

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

Также есть поле mSafeMediaVolumeState, оно содержит текущее состояние системы safe listening:

  • DISABLED: отключена
  • ACTIVE: включена, и при этом лимит прослушивания достигнут, а значит нельзя разрешать пользователю увеличивать громкость, пока он не согласится с предупреждением
  • INACTIVE: включена, лимит пока не достигнут

// mSafeMediaVolumeState indicates whether the media volume is limited over headphones.// It is SAFE_MEDIA_VOLUME_NOT_CONFIGURED at boot time until a network service is connected// or the configure time is elapsed. It is then set to SAFE_MEDIA_VOLUME_ACTIVE or// SAFE_MEDIA_VOLUME_DISABLED according to country option. If not SAFE_MEDIA_VOLUME_DISABLED, it// can be set to SAFE_MEDIA_VOLUME_INACTIVE by calling AudioService.disableSafeMediaVolume()// (when user opts out).private static final int SAFE_MEDIA_VOLUME_NOT_CONFIGURED = 0;private static final int SAFE_MEDIA_VOLUME_DISABLED = 1;private static final int SAFE_MEDIA_VOLUME_INACTIVE = 2;  // confirmedprivate static final int SAFE_MEDIA_VOLUME_ACTIVE = 3;  // unconfirmedprivate Integer mSafeMediaVolumeState;

Метод проверки превышения лимита выглядит так:

private void onCheckMusicActive(String caller) {   synchronized (mSafeMediaVolumeState) {       if (mSafeMediaVolumeState == SAFE_MEDIA_VOLUME_INACTIVE) {           int device = getDeviceForStream(AudioSystem.STREAM_MUSIC);           if ((device & mSafeMediaVolumeDevices) != 0) {               sendMsg(mAudioHandler,                   MSG_CHECK_MUSIC_ACTIVE,                   SENDMSG_REPLACE,                   0,                   0,                   caller,                   MUSIC_ACTIVE_POLL_PERIOD_MS);               int index = mStreamStates[AudioSystem.STREAM_MUSIC].getIndex(device);               if (AudioSystem.isStreamActive(AudioSystem.STREAM_MUSIC, 0) &&                   (index > safeMediaVolumeIndex(device))) {                   // Approximate cumulative active music time                   mMusicActiveMs += MUSIC_ACTIVE_POLL_PERIOD_MS;                   if (mMusicActiveMs > UNSAFE_VOLUME_MUSIC_ACTIVE_MS_MAX) {                       setSafeMediaVolumeEnabled(true, caller);                       mMusicActiveMs = 0;                   }                   saveMusicActiveMs();               }           }       }   }}

Как отключить предупреждение


Чтобы выключить safe listening, нужно добиться того, чтобы переменной mSafeMediaVolumeState на этапе конфигурации было присвоено значение DISABLED.

Посмотрим, где изначально задаётся значение:

private void onConfigureSafeVolume(boolean force, String caller) {   ...   boolean safeMediaVolumeEnabled =           SystemProperties.getBoolean("audio.safemedia.force", false)                   || mContext.getResources().getBoolean(                 com.android.internal.R.bool.config_safe_media_volume_enabled);   boolean safeMediaVolumeBypass =           SystemProperties.getBoolean("audio.safemedia.bypass", false);   int persistedState;   if (safeMediaVolumeEnabled && !safeMediaVolumeBypass) {       persistedState = SAFE_MEDIA_VOLUME_ACTIVE;       /* Ещё код, присваивающий mSafeMediaVolumeState значение либо ACTIVE, либо INACTIVE */...   } else {       persistedState = SAFE_MEDIA_VOLUME_DISABLED;       mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_DISABLED;   }

Видим, что помимо значения ресурса R.bool.config_safe_media_volume_enabled, есть два свойства, позволяющих включать/выключать систему safe listening: audio.safemedia.force и audio.safemedia.bypass.

Чтобы отключить предупреждение, нужно установить значение audio.safemedia.bypass=true в файле system/build.properties. Но для этого нужны root-права. Если их нет, то нужно разбираться дальше и искать другой способ.

Как отключить предупреждение без root


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

@Override    public void onClick(DialogInterface dialog, int which) {        mAudioManager.disableSafeMediaVolume();    } 

Вызывается метод disableSafeMediaVolume у инстанса AudioManager.

/*** Only useful for volume controllers.* @hide*/public void disableSafeMediaVolume() {  }

Он помечен аннотацией @hide. Это означает, что метод не будет включён в public API несмотря на модификатор public. До Android 9 это легко можно было обойти используя рефлешн. Теперь же такой метод по-прежнему можно вызывать, но уже с помощью трюка под названием double-reflection:

val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManagerval getDeclaredMethod = Class::class.java.getDeclaredMethod("getDeclaredMethod", String::class.java, arrayOf<Class<*>>()::class.java)val disableSafeMediaVolumeMethod = getDeclaredMethod.invoke(AudioManager::class.java, "disableSafeMediaVolume", arrayOf<Class<*>>()) as MethoddisableSafeMediaVolumeMethod.invoke(audioManager)

Вызов заканчивается исключением
java.lang.SecurityException: Only SystemUI can disable the safe media volume: Neither user 10307 nor current process has android.permission.STATUS_BAR_SERVICE.
Разрешение STATUS_BAR_SERVICE имеет protectionLevel=signature|privileged, получить его не получится.

Что ж, тогда попробуем так. Мы будем следить за переменной Settings.Secure.UNSAFE_VOLUME_MUSIC_ACTIVE_MS, в которую периодически сохраняется текущее значение mMusicActiveMs. Когда значение начнёт приближаться к 20 часам, будем его сбрасывать. Затем нужно будет сделать так, чтобы AudioService прочитал новое значение из настроек.

Прочитать значение Settings.Secure.UNSAFE_VOLUME_MUSIC_ACTIVE_MS можно так:

val unsafeMs = Settings.Secure.getInt(contentResolver, "unsafe_volume_music_active_ms")

То же самое, используя adb:

 adb shell settings get secure unsafe_volume_music_active_ms

А чтобы записать значение, приложению потребуется разрешение android.permission.WRITE_SECURE_SETTINGS.

Оно имеет protectionLevel=signature|privileged|development, а значит его можно выдать приложению используя adb:

adb shell pm grant com.example.app android.permission.WRITE_SECURE_SETTINGS

Само значение записать можно так:

Settings.Secure.putInt(contentResolver, "unsafe_volume_music_active_ms", 1)

То же самое можно сделать с помощью adb:

adb shell settings put secure unsafe_volume_music_active_ms 1

Сбрасывать лучше в 1, как это сделано в AudioManager, а не в 0. Так как 0 соответствует состоянию ACTIVE.

Теперь нужно, чтобы AudioService прочитал новое значение, и обновил значение локальной переменной mMusicActiveMs.

Есть подходящий метод в AudioManager.java

/***  @hide*  Reload audio settings. This method is called by Settings backup*  agent when audio settings are restored and causes the AudioService*  to read and apply restored settings.*/public void reloadAudioSettings() {   }

Он инициирует вызов метода readAudioSettings в AudioService, где происходит загрузка mMusicActiveMs из настроек.

private void readAudioSettings(boolean userSwitch) {...synchronized (mSafeMediaVolumeStateLock) {        mMusicActiveMs = MathUtils.constrain(Settings.Secure.getIntForUser(mContentResolver,                Settings.Secure.UNSAFE_VOLUME_MUSIC_ACTIVE_MS, 0, UserHandle.USER_CURRENT),                0, UNSAFE_VOLUME_MUSIC_ACTIVE_MS_MAX);        if (mSafeMediaVolumeState == SAFE_MEDIA_VOLUME_ACTIVE) {            enforceSafeMediaVolume(TAG);        }}

Метод помечен аннотацией @hide. Его вызов с помощью double-reflection вызывает исключение:
java.lang.SecurityException: Permission Denial: get/set setting for user asks to run as user -2 but is calling from user 0; this requires android.permission.INTERACT_ACROSS_USERS_FULL
Да, аннотация @hide здесь тоже неспроста. Получить данное разрешение мы, конечно не можем. Оно имеет protectionLevel=signature|installer.

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

Теперь настало время проверить теорию.

Устанавливаем unsafe_volume_music_active_ms = 71 990 000 (останется 10 секунд, в течение которых можно прослушивать музыку на высокой громкости)

adb shell settings put secure unsafe_volume_music_active_ms 71990000


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

adb reboot

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

Теперь повторяем те же действия, но присваиваем unsafe_volume_music_active_ms = 1. Включаем музыку, ждём минуту. Диалог не появляется.

Итоги


Чтобы отключить предупреждение, можно сделать следующее:

При наличии root-прав

Установить значение audio.safemedia.bypass=true в файле system/build.properties

Без root-прав

Нужно следить за значением Settings.Secure.UNSAFE_VOLUME_MUSIC_ACTIVE_MS, и не давать ему подниматься выше 72 000 000 (20 часов). После сброса значения нужно перезапускать устройство (или переключаться на другого пользователя, а затем возвращаться обратно).

Я написала код простого приложения, которое делает эту работу, и напоминает о необходимости перезагрузить устройство/перелогиниться.
Подробнее..

Приглашаем на Mobile Meetup Innopolis

30.06.2020 18:10:31 | Автор: admin


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


Все ли вы знаете об Android Jetpack?



Кирилл Розов, Mobile Lead, Replika / Android Broadcast


Android Jetpack развивается невероятными темпами и уже есть в любом современном Android-приложении. Сейчас сложно представить разработку без этого набора библиотек. Уследить за всеми новинками непросто, поэтому я сделаю обзор последнего API и будущего библиотеки AndroidX.


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


Приглашаю послушать доклад практикующих Android-разработчиков. Вы узнаете, как сделать интеграцию Dagger 2 c Fragment (без Hilt) и какие API из KTX представляют опасность для использования.


Шаблоны проектирования Server Driven UI



Никита Русин, Platform lead, БюроБюро


Расскажу про подход к проектированию и реализации максимально гибкого клиент-серверного протокола с фокусом на API и кейсах его использования. На проектах часто встречаются задачи создать формы платежей или экраны чеков. Подобные экраны с динамическим контентом и логикой невозможно адекватно реализовать без SD UI.


Тема наиболее актуальна в разработке мобильных и десктопных приложений из-за более сложного и растянутого процесса обновления пользователей. Но доклад будет полезен всем разработчикам обоих концов, как новичкам так и тем, у кого есть серьёзный опыт в проектировании API (для вдохновения или приятного чувства, что в интернете кто-то неправ).


Разберём:


  • Что такое Hypermedia API и Server Driven UI.
  • Каким образом при небольшой подготовке можно клепать 100500 информационных экранов в день, не меняя клиентский код.
  • Способы реализации SD UI на сервере и на клиенте вместе с простыми примерами на Python и Kotlin.
  • Как подготовиться к тому, чтобы на SD UI реализовывать целые пользовательские сценарии/новые разделы без изменений кода клиента.

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





Вместе со зрителями обсуждать доклады будут модераторы Юрий Новак, ведущий системный аналитик в компании РТ Лабс (Иннополис) и Александр Симоненко, технический директор Технократии (Казань).


Начинаем 2 июля в 17:00


Регистрация напомним о встрече за пару часов


Ссылка на трансляцию


Телеграм-чат митапа


До встречи!

Подробнее..

Дайджест интересных материалов для мобильного разработчика 349 (15 21 июня)

21.06.2020 14:07:34 | Автор: admin
В новом дайджесте снова разбираемся с темными темами, с монополизмом Apple, с недавним релизом Android 11 и сложностями тестирования, с границами UX и масштабированием загрузок.


С точки зрения методологии в образовательных проектах есть интересная деталь: мы используем в обучении два подхода индивидуальный и командный. Одни преподаватели выстраивают программу курса, исходя из плотной командной работы, другие, наоборот, опираются на индивидуальную работу каждого студента. Но, оставив в стороне рассуждения об эстетике тёмной темы, так ли уж она полезна для глаз? На самом ли деле тёмная тема повышает продуктивность работы с текстом? Ралука Будиу (Raluca Budiu) из Nielsen Norman Group даёт исчерпывающие ответы.

Этот дайджест доступен в виде еженедельной рассылки. А ежедневно новости мы рассылаем в Telegram-канале.

iOS

(+9)simctl: управление симуляторами Apple через терминал
(+3)Sign in with Apple дедлайн уже 30 июня
(0)HorizontalList с помощью SwiftUI
(0)SwiftUI по полочкам: Анимация, часть 2 тайминги
Apple открыла новый форум для разработчиков
Создатели почтового клиента Hey обвинили Apple в вымогательстве +Match Group и Epic Games поддержали разбирательство с Apple
Оборот приложений в App Store в 2019 году превысил $500 млрд.
Объяснение CAGradientLayer
6 советов по повышению производительности Core Data
Как в реальном времени обрабатывать изображения с камеры iOS
Как реализовать Динамический загрузчик с Lottie и Firebase
Создайте свою собственную библиотеку кнопок с нуля в SwiftUI
MemoryLayout в Swift
Создаем потрясающие индикаторы загрузки с помощью SwiftUI
HorizonCalendar: календарь от Airbnb
MultiProgressView: анимированные прогресс-бары

Android

(+15)Android 11 Beta и обновления для разработчиков
(+18)Litho: лучшие практики для создания эффективного UI в Android
(+6)Как отключить предупреждение о вреде долгого прослушивания аудио (Android)
(+5)Android Camera2 API от чайника, часть 6. Стрим видео сначала кодировали, теперь декодируем
(+5)MVP для Android преимущества использования Moxy в качестве вспомогательной библиотеки
(+4)Настраиваем GitHub Actions для Android с последующим деплоем в PlayMarket
(+1)Как и зачем мы используем несколько движков карт в inDriver
(+1)Android-разработка: Карьерный обзор за май 2020
По следам Android 11 Beta
Исследуем новую Google Play Console: большой шаг вперед
Вышла Google Play Billing Library Version 3
Start в Android с Дмитрием Виноградовым
Выбор правильного лейаута для Android
AndroidX: App Startup
Изучение внедрение зависимостей в Android Dagger, Koin и Kodein
Практическое руководство для решения OutOfMemoryError в Android-приложении
Зачем нам нужен Jetpack Compose?
WebRTC на Android: как включить аппаратное кодирование на нескольких устройствах
Что нового в Android Studio System Trace
Делаем снукер для Android с анимацией на основе физики
Представляем Pixel: новую Kotlin-библиотеку загрузки изображений для Android
Amaz Timer: таймер для умных часов
Meow Framework: MVVM и материальный дизайн

Разработка

(+28)Как мы обвесили механику баллистического расчета для мобильного шутера алгоритмом компенсации сетевой задержки
(+21)Многократное использование UI-компонентов в масштабах организации
(+15)Как сократить оформление ущерба по ОСАГО с нескольких дней до 60 минут
(+14)Оля, тесты и фабрика путь к красивой архитектуре и чистоте кода
(+5)Async/await в Unity
(+3)Хочешь, чтобы тебе поставили корректную дизайн-задачу? Помоги продакту ее поставить
(+2)Как устранить слепые зоны с помощью визуального тестирования
(+2)Обучение умных игровых соперников в Unity методом игра с самим собой средствами ML-Agents
(+1)Маски тестировщика (вопросы для успешного перехода к тестерскому расстройству личности)
Podlodka #168: геймификация процессов
Дизайн приложений: примеры для вдохновения #5
5 сервисов для управления мобильными подписками
Вы не Google
Чрезмерно сложно? Слишком просто? Эффективная граница UX
8 советов, как по-быстрому улучшить свой интерфейс
Как сделать ui анимацию естественной и приятной глазу: физические законы в анимации интерфейсов на практике
Swift или Kotlin что лучше?
Добавление облачной функции обнаружения объектов к системе домашних камер
Классические ошибки, которые совершил каждый разработчик
Как скрыть ваши API ключи
10 идей из руководства Apple по разработке пользовательского интерфейса
Редизайн банковского приложения с неоморфизмом

Аналитика, маркетинг и монетизация

(0)Быстрый лайфхак для роста приложений ASO на других языках
Как я масштабировал приложение с 0 до 100,000 загрузок без единого потраченного доллара
Мобильные магазины показывают невероятный уровень вовлечения продажи в 2020 году выросли на 40%
Google запустил аналог Pinterest социальную сеть Keen
ФАС отказалась от смягчения предустановки российских приложений
Исследование: объем российского рынка мобильных игр вырос на 49% в 2019 году
ASO аудит приложений и универсальные рекомендации
myTarget добавили новые метрики в аналитике рекламных кампаний
The Pokemon Company учит детей чистить зубы с Pokemon Smile
Spike получил $8 млн. на то, чтобы сделать почту похожей на чат
Мультиформат, квадратное и вертикальное видео: лучшие форматы мобильной рекламы для получения установок
Антикейс: почему расходится количество конверсий в Google Ads и в аналитике для iOS-приложения
Что влияет на позиции приложения в App Store и Google Play

AI, Устройства, IoT

(+123)Самая сложная задача в Computer Vision
(+46)ABBYY NeoML: как мы делали библиотеку машинного обучения и зачем она нужна
(+37)Как понять, что нейросеть решит вашу проблему. Прагматичное руководство
(+37)Полный цикл создания устройства и работа сфабриками в Китае. Доклад Яндекса
(+19)Древности: десятилетие Apple iPad
(+14)Event2Mind для русского языка. Как мы обучили модель читать между строк и понимать намерения собеседника
(+8)Умный дом в умном городе
Huawei впервые стал 1 на рынке смартфонов
Можно ли удалить 99% нейронной сети без потери точности?

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

Дайджест интересных материалов для мобильного разработчика 350 (22 28 июня)

28.06.2020 14:08:19 | Автор: admin
В этом выпуске, конечно, у нас много материалов с WWDC App Clips, виджеты, новый Xcode, macOS Big Sur, изменения в StoreKit, UIKit и SwiftUI. Кроме них стилизация, полезные инновации, логистика, анимации и многое другое.


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

Этот дайджест доступен в виде еженедельной рассылки. А ежедневно новости мы рассылаем в Telegram-канале.

iOS

(+16)Тестирование StoreKit в Xcode 12 и iOS 14
(+15)SwiftUI 2020. Что изменилось?
(+10)Как и к чему готовиться на собеседование начинающему iOS-разработчику и не только
(+6)iOS in-app purchases: Конфигурация и добавление в проект
(+5)Выступления и презентации в стиле Apple: на примере WWDC20
iOS 14 позволит получать уведомления о важных звуках
Core ML теперь можно шифровать и обновлять независимо от приложения
Загрузка данных в iOS в Background-режиме
Что нового в Xcode 12
Тестирование покупок и семейная подписка: обновление IAP с WWDC
Apple пропустила Hey в App Store и меняет политику модерации
Создавайте виджеты с помощью WidgetKit
Apple представила мини-приложения App Clips
Apple запустила программу поддержки разработки универсальных приложений
Apple представляет macOS Big Sur
watchOS 7: новые настройки и функции для тех, кто следит за здоровьем
Apple раскрывает новые возможности iPhone с iOS 14
iPadOS 14: новые функции, созданные специально для iPad
Что iOS-разработчики думают о переходе Mac на процессоры Apple и других анонсах WWDC 2020
Рисуем в 3D, используя SwiftUI
iOS 14: важные изменения в UIKit
Автоматизация создания скриншотов в iOS с Bitrise и Fastlane
Цепная анимация в Swift
Создаем SwiftUI + Core ML игру для iOS
Как анимировать изображения в Swift
Как Apple делает soft-UI будущим

Android

(+9)Стилизация Android-приложений и дизайн-система: как это сделать и подружить одно с другим
(+3)Android и 3D камера. Распознавание лиц с защитой от Fraud
(0)Кодовая база. Расширяем RecyclerView
ARCore Depth API открыли для всех
Победители конкурса Полезные Инновации от Google
Android Broadcast: все тайны MVI
Huawei выпустил HMS Core 5.0
Создание безопасных Android-приложений
Мигрируем с Retrofit на Ktor
Внедрение темной темы в ваше Android-приложение
Как создать REST API для вашего приложения с помощью Spring Boot, Kotlin и Gradle
Создание масштабируемой навигационной системы в Android
Отточенный и гибкий Progress View для Android
Шаблон моего Android-проекта
Бифуркация Android
Merge Adapter: объединяйте списки в Android
Освоение шаблонов дизайна в Android с Kotlin
Создание адаптера RecyclerView, который можно использовать с любыми данными и любым представлением
Wizard Camera: эффекты для фото на OpenGL
Checked Android App: ToDo на Kotlin
CornerSheet: расширяемое окно

Разработка

(+54)Как мы сэкономили время курьерам. Логистика в Яндекс.Еде
(+26)Создание шейдерной анимации в Unity
(+21)Мобильные антивирусы не работают
(+15)Разбор UI/UX на примере прототипа в Figma и основные принципы
(+7)Создаем прогрессивное веб-приложение на ReactJS и размещаем его в Netlify и PWA Store
(+7)Как устроен Selenium: Эпизоды 3 5
(+6)Usability Testing от А до Я: подробный гид
(+3)Разработка мобильных приложений: как формируется цена?
(+3)UX/UI-ДИЗАЙН: нельзя просто взять и нарисовать экран
Podlodka #169: увольнения
В AWS запустили конструктор приложений Amazon Honeycode
Unity сделала все Premium курсы бесплатными
Дизайн приложений: примеры для вдохновения #6
ML Kit становится отдельным продуктом
Серьезные ошибки в UX, которые могут снижать ваши продажи
Мое 10-летнее путешествие в разработке игр
13 моих любимых UI/UX ресурсов
Быстрая навигация во Flutter с Get

Аналитика, маркетинг и монетизация

(+1)Локализация мобильных приложений: основные сложности и лайфхаки
(0)Получение данных Amplitude через API
Одноклассники выплатили создателям мобильных игр более 360 млн. рублей
myTarget расширил возможности закупки видеорекламы в форматах Rewarded и Interstitial видео
9 способов повысить точность прогноза дохода
Kaia Health: физиотерапия под присмотром приложения
TikTok вкладывает $50 млн. в образовательный контент
Приложение для детей: от идеи до запуска
Пять лайфхаков по оптимизации приложения в App Store и Google Play

AI, Устройства, IoT

(+19)От AI до VR: как промышленность и ритейл используют новые технологии
(+15)Управление Яндекс.Станцией и другими колонками с Алисой из Home Assistant
(+10)Опыт построения умного дома на Raspberry Pi и открытой платформе OpenHAB. Часть 1
(+9)HMI на основе Node-red и Scadavis.io
(+3)Из чего состоит набор для разработчиков NB-IoT DevKit?

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

Дайджест интересных материалов для мобильного разработчика 351 (29 июня 05 июля)

06.07.2020 06:04:37 | Автор: admin
В новом дайджесте разбираемся с последствиями WWDC, выпускаем приложения одной кнопкой, сравниваем быстродействие кроссплатформенных фреймворков, проводим нагрузочное тестирование, растим доходы приложений и занимаемся многими другими интересными вещами!


Сегодня я подведу некоторые итоги: расскажу, к чему мы пришли за это время. Long story short: любой причастный к процессу сотрудник может зарелизить хоть все наши приложения на обеих платформах в несколько кликов без головной боли, больших затрат времени, регистрации и СМС. Так, наш отдел релиз-инженеров за 2019 год сэкономил около 830 часов. inVerita и ее команда разработчиков мобильных приложений постоянно изучают производительность кроссплатформенных мобильных фреймворков, доступных на рынке, чтобы ответить на вопрос, какая технология лучше всего подходит для вашего продукта.

Этот дайджест доступен в виде еженедельной рассылки. А ежедневно новости мы рассылаем в Telegram-канале.

iOS

(+27)Бюджетный DI на антипаттернах
(+21)Почему разработчики отказываются от авторизации через Apple с фейковым email
(+6)BoxView удобный autolayout для iOS
(+4)Как смотреть WWDC 2020, если ты не разработчик
(+3)Apple WWDC 2020: что нового в тестировании iOS
(+3)Развитие ARKit в этом году и новая возможность в ARKit 4: Location Anchors
Презентации WWDC и Platforms State of the Union доступны с субтитрами на русском языке
Apple пытается улучшить вовлеченность игр Arcade
Apple назвала 8 лауреатов ежегодной премии Apple Design Awards
Погружаемся в мир дополненной реальности с ARKit
Когда вам нужно сообщить о применении шифрования в приложении?
Новое в iOS 14: определение контуров
iOS 14 App Clips
Ваше первое сложное приложение на SwiftUI
Внедряем чистую VIP-архитектуру в Swift 5
Создайте свою собственную библиотеку CocoaPods
Как создавать виджеты в iOS 14
Новый жизненный цикл и замены для AppDelegate и SceneDelegate в SwiftUI в iOS 14
NewYorkAlert: красивые предупреждения для iOS

Android

(+12)Редактор кода на Android: часть 1
(+5)Приручая MVI
(+5)Блокировка двойного клика. Велосипед?
Huawei объявляет конкурс приложений с призовым фондом в 1 млн. долларов
Представляем RainbowCake
JetPack Compose с Server Driven UI
Новый способ передачи данных между Фрагментами
Динамическое изменение цвета градиента в Android
Создаем приложение с новостями для Android за 5 простых шагах
MVVM с Hilt, RxJava 3, Retrofit, Room, Live Data и View Binding
Быстрое тестирование на Android с Mobile Test Orchestrator
Кастомные Android View: Drag and Drop
Дилемма Kotlin: Extension или Member
Рендеринг PDF-файлов на Android: простой способ
Укрепление безопасности системы в Android 11
Юнит-тестирование кастомных View в Android
Оптимизация работы с батареей для избежания Doze Mode и App Standby
Видимость пакетов в Android 11
RainbowCake: новая Android-архитектура
Разработка с Actions Builder и Actions SDK

Разработка

(+21)NewNode децентрализованная CDN от разработчика FireChat
(+9)Как мы решаем проблему отсутствия UI\UX дизайна в 1С с помощью Java Script и React.js
(+6)Godot, 1000 мелочей
(+5)Оптимизация SQL запросов или розыск опасных преступников
(+4)6 советов по нагрузочному тестированию к Черной пятнице
Podlodka #170: искусство простых иллюстраций
Flutter Dev Podcast #17: Flutter Day 2020
Думай, как CEO: самый важный навык, который выделит тебя среди разработчиков
Dfinity открывает платформу Internet Computer для разработчиков
Дизайн приложений: примеры для вдохновения #7
AWS запускает CodeGuru для автоматического анализа кода
7 подходов к тестированию
Мы упростили сайт до приложения с действием в один клик и провалились
8 правил, которые помогут вам спроектировать лучший дизайн карточки
Возврат скевоморфизма
14 популярных программ для создания анимации, прототипирования и дизайна интерфейсов
Полгода ежемесячного создания игр
Простой игровой движок с Flutter Animations
47 ключевых уроков для UI и UX дизайнеров
Возглавляя команду разработчиков программного обеспечения
20 лучших идей для дизайна пользовательского интерфейса
Мой опыт создания приложения с no-code инструментами
Давайте сделаем мобильную многопользовательскую игру на Unity
Делаем музыкальный плеер, играющий в фоновом режиме, на Flutter
7 инструментов для удаленных команд 2020
GetStorage: быстрое key-value хранилище
Fluent System Icons: мобильные иконки от Microsoft

Аналитика, маркетинг и монетизация

(+6)Как работать с Google Analytics и Яндекс Метрикой?
(+6)Сколько стоит сделать ролик об игре своими силами
(+3)Материалы с митапа для аналитиков: модель роста, A/B-тесты, управление стоком и доставкой товаров
Яндекс дарит подключившимся к РСЯ до 600,000 рублей на продвижение приложений
Рейтинг доступности банковских приложений 2020 от UsabilityLab
Расходы на мобильные приложения в 1 половине 2020 выросли на 23.4%
TikTok заподозрили в шпионаже
В Индии забанили TikTok и десятки других китайских приложений
Сигнал от звёзд: Газпром-медиа запустил приложение с персональным гороскопом
Как работать с восточными языками в App Store и Google Play

AI, Устройства, IoT

(+8)9 ключевых алгоритмов машинного обучения простым языком
(+1)Опыт создания облачного решения по мониторингу цифрового киоска на Azure IoT Central
Niantic делает AR-проект по настольной игре Колонизаторы
Машинное обучение поможет спасать людей на пляжах

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

Android 11 Beta и обновления для разработчиков

17.06.2020 08:10:07 | Автор: admin

Привет, Хабр!
10 июня вышла бета-версия Android 11! В этой статье мы хотели бы коротко рассказать о главных новинках в Android 11 Beta, Google Play и инструментах для разработчиков. А также, хотели бы пригласить всех читателей на специальное онлайн мероприятие посвященное выходу Android 11 Beta, которое состоится сегодня, 17 июня в 19:00 по Московскому времени. Специальный гость мероприятия, известный каждому Android разработчику Чет Хас! Присоединяйтесь и читайте подробности под катом.


Бета-версия Android 11 уже доступна


Благодаря обратной связи, которую мы получили от сообщества разработчиков на Android 11 Developer Preview, мы смогли подготовить бета-версию Android 11, которая фокусирается на 3 ключевых вещах: люди, элементы управления и приватность.


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


  • Conversation notifications или уведомления о разговорах будут появляться в специальном разделе, с улучшенным дизайном и предложениями действий, такими как открытие беседы в виде всплывающей подсказки, создание отдельного ярлыка беседы на главном экране или установка напоминания.
  • Bubbles, они же Пузыри. Надоело переключаться между мессенджерами и другими приложениями во время переписки? Пузыри помогут держать переписку в поле зрения и останутся легко доступны во время многозадачности. Для того чтобы задействовать данный функционал, мессенджеры и другие приложение поддерживающие обмен сообщениями могут использовать новый Bubbles API.
  • Consolidated keyboard suggestions или консолидированные подсказки от клавиатуры. Теперь приложения c функционалом Autofill и редакторы метода ввода (IMEs), такие как клавиатура, смогут безопасно предлагать контекстно правильные подсказки непосредственно в полосе предложений IME, где они наиболее удобны для пользователей.
  • Голосовой доступ, он же Voice Access. Для людей, которые управляют своим телефоном с помощью голоса.Android 11 теперь понимает содержимое экрана и контекст, а также генерирует метки и точки доступа для голосовых команд.
    image

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


  • Device Controls или управление устройствами позволит быстрее и проще получать доступ к подключенным устройствам. Теперь, просто нажав и удерживая кнопку питания, пользователи смогут мгновенно получить доступ к управлению устройствами в одном месте. Разработчики смогут использовать новый API для отображения элементов управления. Подробнее здесь.
  • Media Controls или управления мультимедиа позволит быстро и удобно переключаться между устройствами вывода для аудио или видеоконтента, будь то наушники, динамики или даже телевизор. Подробнее здесь.
    image

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


  • One-time permission или единовременные разрешения позволят пользователям предоставлять приложениям доступ к микрофону, камере или местоположению устройства только один раз. Приложению нужно будет запросить разрешение заново при следующем использовании приложения. Подробнее здесь.
  • Permissions auto-reset или автоматический сброс разрешений: если пользователь, по какой-то причине, не использовал приложение в течение длительного времени, Android 11 автоматически сбросит runtime разрешения, связанные с приложением, и уведомит об этом пользователя. Приложение нужно будет запросить разрешения снова при следующем запуске. Подробнее здесь.
  • Background location или локация в фоновом режиме. В феврале этого года мы анонсировали, что разработчикам необходимо будет получить отдельное разрешение на доступ к локации в фоновом режиме, чтобы предотвратить злоупотребление таким доступом. Мы даем разработчикам больше времени для внесения изменений и не будем применять политику для существующих приложений до 2021 года. Подробнее здесь.
  • Google Play System Updates или обновления системы Google Play. Запущенные в прошлом году, обновления системы Google Play позволяют нам ускорить доставку и обновление основных компонентов ОС на устройствах. В Android 11 мы более чем удвоили количество обновляемых модулей, и эти 12 новых модулей помогут улучшить приватность, безопасность, и консистентность для пользователей и разработчиков.
    image

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


  • Самые важные (ломающие) изменения не вступят в силу до тех пор, пока вы не явно не укажете Android 11 в своем манифесте
  • Добавили новый интерфейс в настройках для разработчиков, позволяющий включать/выключать многие из этих изменений
  • добавили Platform Stability майлстоун в таймлайн, чтобы вы могли приступить к обновлению своего приложения, точно зная что платформа стабильна.

Android 11 также включает в себя ряд других улучшений для разработчиков, таких как поддержка беспроводной отладки по ADB, ADB incremental для быстрой установки больших APK и больше nullability аннотаций в API платформы (для выявления проблем во время сборки, а не во время выполнения) и многое другое.


Бета-версия Android 11 доступна уже сейчас, с финальными API SDK и NDK, которые можно попробовать в своих приложениях.


Если у вас есть устройство Pixel 2, 3, 3a или 4, вы можете зарегистрироваться здесь, чтобы получать обновления Android 11 Beta по воздуху. Также можно отдельно скачать образы для смартфонов Google Pixel и эмулятора Android. Подробно узнать обо всех изменениях вы можете на специальной странице для Android разработчиков.


Также узнать о новинках в Android 11 вы можете узнать из нашего специального мероприятия с русскоговорящими экспертами и специальным гостем Четом Хасом, которое пройдет сегодня в 19-00 по Московскому времени.


Современная Android разработка


В течение последних лет, команда Android усердно работала над улучшением опыта разработчиков мобильных приложений, чтобы сделать их более продуктивными. Это включает в себя улучшения Android Studio, отличный язык (Kotlin), Библиотеки Jetpack для упрощения общих задач и Android App Bundles для улучшения распространения приложений. Мы называем всё это современной Android разработкой и предлагаем вниманию Android разработчиков лучшее из мира Android, чтобы сделать их ещё более эффективным и продуктивным.


image

Android Studio


Новые функции в Android Studio 4.1 Beta и 4.2 Canary доступны уже сегодня. При разработке, мы ориентировались на ряд важных вопросов для разработчиков:


  • Отладка приложений стала проще с поддержкой беспроводной отладки по ADB на устройствах с Android 11. Мы также добавили инспектор баз данных и инструменты Dependency Injection(Dagger);
  • Тестирование устройств стало проще благодаря тому, что эмулятор Android теперь находится непосредственно в IDE. Разработчикам смогут видеть результаты выполнения тестов с нескольких устройств одновременно, а также мы улучшили и сам диспетчер виртуальных устройств.
  • Машинное обучение стало проще (почти). По крайней мере,теперь вы сможете импортировать свои модели для ML Kit и TensorFlow Lite прямо в Android Studio.
  • Сборка и deployment приложений стали быстрее благодаря Kotlin Symbol Processing API, кэшированию графа задач в Gradle и более быстрой установке и запуску приложений на всех устройствах с Android 11. А новый анализатор сборки может помочь вам определить, где в вашей сборке могут быть узкие места.
  • Инструменты для разработки игр стали лучше благодаря обновленному пользовательскому интерфейсу профилировщика производительности, переработанному инструменту System Trace и поддержке встроенного профилирования памяти.

image

Попробовать последнюю версию: Android Studio 4.1 Beta и Android Studio 4.2 Canary вы можете уже сейчас!


Kotlin, Jetpack и новинки


Языки и библиотеки являются одной из основных областей инвестирования для нас в современную Android разработку. Мы сосредоточены на том, чтобы сделать разработчиков более продуктивными с Kotlin и Jetpack.



С ростом популярности Kotlin, а на данный момент, более 70% из 1000 лучших приложений в Google Play используют Kotlin, и таким большим количеством разработчиков, использующих Kotlin, мы можем использовать весь его потенциал, чтобы улучшить опыт разработки под Android новыми способами.


Корутины Kotlin это особенность языка Kotlin, которая упрощает написание и понимание конкурентных вызовы в коде. Теперь мы официально рекомендуем использовать корутины Kotlin и поэтому мы встроили поддержку корутин в наиболее часто используемые библиотек Jetpack Lifecycle, WorkManager и Room.


Сам Kotlin также продолжает улучшаться с каждым выпуском благодаря потрясающей команде Jetbrains. Kotlin 1.4 обеспечивает более быстрый code completion, более мощный вывод типов, включенные по умолчанию функциональные интерфейсы, а также полезные другие улучшения, такие как смешивание именованных и позиционных аргументов.


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


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


Мы также рады представить ещё одну новую библиотеку App Startup. Она призвана помочь разработчикам приложений и библиотек оптимизировать инициализацию библиотек в приложении.


У нас много обновлений для существующих библиотек, в том числе серьезное обновление для Paging 3. Теперь оно Kotlin-first с полной поддержкой Корутин!


Compose


Есть еще одна вещь, которая необходима для продуктивности это мощный набор инструментов, позволяющий легко и быстро создавать красивые пользовательские интерфейсы на Android со встроенным доступом к API платформы. Именно поэтому мы развиваем Jetpack Compose наш новый набор инструментов для разработки пользовательских интерфейсов, который позволяет оживить ваши приложения с помощью меньшего количества кода и интуитивно понятных API на Kotlin. Мы рады Jetpack Compose Developer Preview 2, наполненный новыми фичами и улучшениями, о которых нас просили разработчики:


  • Interoperability with Views
  • Animations
  • Testing
  • Constraint Layout
  • Adapter list
  • Material UI components
  • Text and editable Text
  • Theming and Graphics
  • Window management
  • Input and Gestures

Работая в тесном сотрудничестве с командой Kotlin из Jetbrains, мы добавили ряд новых возможностей в Android Studio 4.2, призванных помочь создавать приложения с помощью Compose:


  • плагин компилятора Kotlin для генерации кода
  • Compose Preview Аннотации
  • Предварительный просмотр Compose в режиме реального времени
  • Деплой отдельных composable объектов на устройство
  • Compose Code completion
  • Пример Data API для Compose

Compose еще не готов к использованию в продакшене. В данный момент, мы заканчиваем оптимизацию производительности, и будем рады услышать вашу обратную связь. Мы планируем запустить Alpha этим летом и 1.0 в следующем году.


Абсолютно новая консоль Google Play


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


  • Найти, обнаружить и понять фичи которые помогут вам процветать в Google Play
  • Легко найти новые руководства по изменениям политиках, статус релизов, и обратную связь от пользователей
  • Лучше понять результативность продвижения, с новыми отчетами о новых пользователях
  • Позволить всем членам вашей команды использовать функции Play Console с новыми опциями управления пользователями

Подробнее о новой консоли Google Play читайте в этом посте или присоединяйтесь к бета-версии прямо сейчас на play.google.com/console.



Вместо заключения


Специально для вас, в связи с выходом бета версии Android 11, сегодня, 17 июня, в 19:00 по Московскому времени, мы встречаемся с русскоязычными экспертами на специальном онлайн мероприятии, чтобы обсудить все в подробностях. Специальный гость мероприятия, известный каждому Android разработчику Чет Хас! Присоединяйтесь, будет интересно!


Расписание:
19:00 19:20 Знакомство с экспертами и гостем Chet Haase
19:20 19:30 Квиз с возможностью выиграть подарки с логотипом Android 11
19:30 20:30 Обсуждение новинок экспертами
20:30 21:00 Ответы на ваши вопросы (пишите в комментариях с #askandroid), авторы самых интересных обязательно получат подарки


Наши эксперты:



P.S.


  1. У нас есть 12 докладов, которые мы разместили на канале Android разработчиков на YouTube.
  2. Мы запускаем 11 недель Android с новым контентом для разработчиков каждую неделю! Темы, которые вы просили: UI, Jetpack, Machine Learning и многое другое. Посмотреть расписание можно здесь.
Подробнее..

Litho лучшие практики для создания эффективного UI на Android

18.06.2020 20:20:38 | Автор: admin
Litho UI-фреймворк от Facebook, который отвечает за быстрый рендеринг тяжелого UI в топовых приложения с миллиардами загрузок.

Как его использовать, что происходит под капотом, и действительно ли с UI можно работать только из одного потока?


Cookbook по Litho в расшифровке моего доклада с конференции Mobius 2019 Moscow под катом.

С вами Сергей Рябов разработчик из Лондона, который поставил на паузу свой кочевой образ жизни, чтобы проверить, такой ли Альбион туманный, как о нём любят говорить. И я работаю в команде Native UI Frameworks над декларативным фреймворком Litho.

Структура поста:




Что такое Litho?




Litho это UI фреймворк, которому уже четыре года. Два года он разрабатывался внутри компании, затем его вывели в опенсорс, и вот уже два с половиной года он растет и развивается как опенсорсный проект. Изначально Litho разрабатывался для основного приложения Facebook, хотя если взять три самых популярных приложения, которые используют Litho в настоящий момент, то только одно из них будет иметь отношение к Facebook. Остальные же продукты Google.

Когда мы работаем с интерфейсом, у нас есть следующие этапы работы: Inflate, Measure, Layout, Draw. Все эти этапы построения UI должны поместиться в 16 миллисекунд, чтобы приложение не тормозило. Предположим, что у нас тяжелый UI, который не укладывается в 16 миллисекунд: все оптимизации проведены, мы попытались выжать максимум производительности из наших вьюшек, но программа все равно лагает. Можно постараться что-нибудь вынести в фоновый поток, например, Inflate, поскольку это достаточно тяжелая операция. Для этого у нас есть AsyncLayoutInflater, из набора библиотек Android Jetpack.

Заметим, что AsyncLayoutInflater имеет некоторые нюансы: мы не можем создавать вьюшки, которые работают внутри с Handler, нам нужно, чтобы LayoutParams были thread-safe и некоторые другие. Но в целом использовать его можно.

Допустим, в процессе развития приложения на главном экране появляются еще более сложные элементы, и даже оставшиеся три стадии уже не влезают в 16 миллисекунд. В целях оптимизации хочется максимальное количество работы отправить в фоновый поток сразу и Measure, и Layout, поскольку теоретически это в основном математические расчеты, и они сильно отвязаны от Android. Практически же сделать так нельзя, потому что в Android UI-фреймворке Measure и Layout непосредственно реализованы в классе View, поэтому с ними можно работать только в UI-потоке.

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



Litho дает возможность абстрагироваться от View в фазах Measure и Layout благодаря тому, что под капотом для измерений используется движок Yoga, который тоже разрабатывается в команде Native UI Frameworks. И как следствие, это позволяет всю математику вынести в бэкграунд-поток.

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

Декларативный API в Litho


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



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

fun f(    title: String,    subtitle: String): UI {  return Column.create()    .child(        Text.create()            .text(title))    .child(        Text.create()            .text(subtitle))    .build()}

По входным параметрам мы создаем элементы для title, subtitle и помещаем их в Column, то есть вертикально друг относительно друга.
В Litho эта же функция будет выглядеть следующим образом:

@LayoutSpecobject ListItemSpec {  @OnCreateLayout  fun onCreateLayout(      c: ComponentContext,      @Prop title: String,      @Prop subtitle: String  ): Component {    return Column.create(c)        .child(            Text.create(c)                .text(title))        .child(            Text.create(c)                .text(subtitle))        .build()   }}

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

Этот класс написан на Kotlin, поэтому используется object, но если бы он был на Java, то метод был бы статическим. Это обусловлено вышеупомянутым упором на потокобезопасность. Мы описываем только то, как должен выглядеть UI, а то, что для этого будет происходить под капотом, генерируется фреймворком, на это нельзя повлиять прямым образом, а потому шансы случайно ошибиться, например, плохо обработав локальное состояние (state) UI-компонента, сокращаются.


Комбинирование UI-элементов


У нас уже есть одна функция описывающая простой UI, а как теперь создать на ее основе что-то более сложное? Например, добавить картинку слева от текста, чтобы в итоге получился такой header с данными человека, который написал комментарий: его аватар, имя и дата комментария.



Решение похоже на использование горизонтального LinearLayout, но в данном случае мы горизонтально располагаем картинку и предыдущий UI-компонент ListItem, заворачивая их в Row. Также добавился еще один входной параметр @Prop image, который отвечает за саму картинку, а те параметры, которые отвечают за текстовые данные, просто прокидываются в компонент ListItem.

Стоит отметить, что описываются UI-компоненты в классах с суффиксом Spec, а для создания инстансов этих компонентов используются классы без этого суффикса. Всё потому, что Litho по декларативным Spec-файлам генерирует настоящие реализации компонентов с правильной обработкой многопоточности и удобным API в виде Builder-ов, которые и используются для задания входных параметров, объявленных в UI-спеке.

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



В публичном API Litho есть только одна View LithoView. Это контейнер, в котором идет отрисовка всех Litho-компонентов. Чтобы отобразить на экране заданный компонент, нужно создать ComponentContext, передав ему Android Context, и создать LithoView, передав в метод create контекст и сам отображаемый компонент. С LithoView можно делать всё, что вы привыкли делать с другими вьюшками, например, передать в метод setContentView у Activity.

С API на основе Builder-ов работать легко, механика создания UI-компонента похожа на описание вью в XML. Разница лишь в том, что вместо проставления XML-атрибутов вы вызываете методы Builder-а. Но раз уж это все так сильно отвязано от Android-системы, то что же происходит под капотом?


Litho: под капотом


Возьмем ListItemWithImageSpec, с которым мы уже встречались ранее. В нём три компонента: Row, Image и кастомный ListItem:

@LayoutSpecobject ListItemWithImageSpec {     // ...    Row.create(c)        .child(            Image.create(c)                  .drawable(image))        .child(            ListItem.create(c)                  .title(title)                  .subtitle(subtitle))        .build()  }}

И чтобы отобразить его на экране, добавим его в LithoView таким образом:

setContentView(LithoView.create(c,    ListItemWithImage.create(c)        .image(user.avatar)        .title(user.name)        .subtitle(comment.formatDate())        .build()))

Рендеринг UI-компонента проходит в три основных шага:

  1. Построение Internal Tree внутреннего представления UI.
  2. Получение LayoutState набора Drawable и View, которые будут отрисованы на экране
  3. Отрисовка LayoutState на Canvas-е

Построение Internal Tree


Начинаем с корневого компонента ListItemWithImage:

  • Создаем корневой элемент дерева, InternalNode, ассоциируем с ним компонент ListItemWithImage. Так как ListItemWithImage это фактически просто обертка, то смотрим на его содержимое.
  • Внутри метода onCreateLayout в ListItemWithImageSpec мы первым делом создаем Row. Ассоциируем его с той же самой нодой.
  • У Row 2 потомка: Image и ListItem для обоих создаются отдельные InternalNode-ы. Image это листовой элемент дерева компонентов, на этом обработка его поддерева окончена.
  • ListItem в свою очередь тоже компонент-обертка, чтобы добраться до сути смотрим в метод onCreateLayout его спеки. Там мы видим Column, ассоциируем её с той же нодой.
  • У Column есть 2 потомка: Text и Text создаём для них две новые ноды. Оба элемента листовые построение Internal Tree окончено.

Получилось следующее дерево:



Тут наглядно видно, что в результате ноды создаются только для листовых компонентов, таких как Image и Text, или для компонентов-контейнеров, таких как Row и Column. Так мы упростили иерархию: было три уровня относительно корня, осталось два.

Получение LayoutState


Следующим шагом нам нужно создать LayoutState. Для этого сначала измерим Internal Tree с помощью Yoga. Yoga присвоит координаты х, y, а также ширину и высоту каждому узлу дерева. Затем, с учетом этой полной информации, мы создадим список того, что будет непосредственно отрисовано на экране (уже зная, где оно будет отрисовано и какого размера).

Происходит это следующим образом: мы снова обходим Internal Tree, и смотрим, нужно ли отрисовывать каждую следующую ноду. Row не отрисовывается, он фактически нужен был только для измерения и размещения дочерних элементов. Image отрисовывается, поэтому добавляем его LayoutOutput в список. Column тоже нужен был только для того, чтобы померять и расположить элементы и отрисовывать там нечего, а вот Text-ы, как и Image, тоже важны для отрисовки.

Получившийся в итоге список LayoutOutput-ов это наш LayoutState.

Отрисовка LayoutState


Теперь полученный LayoutState осталось нарисовать на экране. И тут важно подчеркнуть, что в данном примере элементы Image и два Text-a будут отрисованы не с помощью View, а с помощью Drawable. Если мы можем не использовать сложные элементы Android UI Toolkit, если можно обойтись простыми и легковесными примитивами типа Drawable, то эффективнее использовать именно их. Если же какие-то элементы должны уметь реагировать, например, на клики, то они будут отрисованы с помощью View, чтобы переиспользовать всю непростую логику обработки UI-событий.



Шаг отрисовки это единственный этап, который должен выполняться на UI-потоке, все остальные шаги могут быть выполнены в фоне.

На рассмотренном примере мы познакомились с несколькими ключевыми элементами:

  • @LayoutSpec компоненты, комбинирующие другие компоненты. В итоге они превращаются в поддеревья в Internal Tree. Аналог кастомных ViewGroup.
  • Row и Column компоненты-контейнеры, служащие для задания расположения UI-элементов на экране. Это примитивы Flexbox основного Layout Engine в Litho. А Yoga это его кроссплатформенная реализация, которая используется не только в Litho, но также и в других библиотеках под Android, под iOS и в Web.
  • @MountSpec это те самые листовые ноды в Internal Tree Image, Text и другие. Это второй тип Spec, который описывает примитивные элементы, которые будут отрисованы на экране с помощью Drawable или View.

Как будет выглядеть код кастомной @MountSpec-и? Примерно так:

@MountSpecobject GradientSpec {  @OnCreateMountContent  fun onCreateMountContent(context: Context): GradientDrawable {      return GradientDrawable()  }  @OnMount  fun onMount(      c: ComponentContext,      drawable: GradientDrawable,      @Prop @ColorInt colors: IntArray) {      drawable.colors = colors  } }

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

Напомню, что компонент, описанный как MountSpec, может отрисовывать всего два типа контента: View или Drawable. В данном простом случае нам достаточно легковесного Drawable. Кроме операции создания контента мы также должны определить метод с аннотацией @OnMount для биндинга с данными перед тем, как компонент станет видимым. В нашем случае данными является тот массив цветов, который мы получаем на вход. Всё остальное Litho берет на себя и отрисовывает GradientDrawable c заданными цветами на экран. Для облегчения понимания можно сравнить методы, помеченные @OnCreateMountContent и @OnMount, с методами RecyclerView.Adapter onCreateViewHolder и onBindViewHolder соответственно.


Аннотация @MountSpec


В аннотации @MountSpec есть два параметра:

  1. poolSize параметр, который отвечает за то, сколько инстансов данного компонента будет создано заранее и помещено в пул, чтобы потом быстро использовать их при рендеринге интерфейса. По умолчанию этот параметр равен 3.
  2. isPureRender это булевый флаг, показывающий, что при пересоздании компонента с неизменными значениями Prop-параметров, результат его отрисовки всегда будет оставаться прежним. При обновлении UI-дерева компонентов это позволяет не пересоздавать и не перерисовывать такие чистые компоненты.

Конкретные значения будут зависеть от того, что за компонент вы описываете. Рассмотрим в качестве примера ImageSpec компонент для показа картинки:

@MountSpec (poolSize = 30, isPureRender = true)class ImageSpec {  @ShouldUpdate  static boolean shouldUpdate(...) {}}public @interface MountSpec {  int poolSize() default 3;  boolean isPureRender() default false;}

У него очень большой poolSize (30). Cегодня типичная ситуация, когда приложение нагружено картинками, поэтому UI-компоненты для них лучше подготовить заранее в достаточном количестве. В то же время, если входной параметр Drawable, не меняется, то и вывод на экран такого компоненты тоже не поменяется, и, чтобы не делать лишних действий, можно установить флаг isPureRender В этом случае решение об обновлении компонента принимается на основании сравнения Prop-параметров с помощью equals(), если же вы хотите использовать кастомную логику сравнения, то её нужно поместить в функцию с аннотацией @ShouldUpdate.


Оптимизации в Litho


В Litho есть две ключевые оптимизации при построении Layout:

  1. Layout/Mount Diffing позволяет переиспользовать размеры (measurements) элементов с предыдущего рендеринга и переиспользовать LayoutOutput-ы (то, что выводится на экран) с предыдущего рендеринга.
  2. IncrementalMount позволяет превратить ваш UI в RecyclerView на стероидах без каких-либо дополнительных усилий.

Layout/Mount Diffing


Как это работает? При построении Internal Tree для нового компонента, также учитывается оставшееся с предыдущего рендеринга Diff Tree с готовыми размерами и LayoutOutput-ами всех узлов дерева.



Если входящие параметры для некоторого поддерева не изменились, то размеры и LayoutOutput для него просто копируются из Diff Tree в новый Internal Tree, минуя шаг измерения с помощью Yoga. Таким образом, LayoutState готов уже к концу построения Internal Tree.

IncrementalMount


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

mount
Mount процесс получения контента для рендеринга (View или Drawable) и его добавление в текущую иерархию View

такого сложного элемента не уложится в 16мс, то мы будем видеть дерганый UI, особенно при быстрой прокрутке. IncrementalMount в этом случае позволяет рендерить новостной пост не целиком, а постепенно, выполняя mount только для тех дочерних примитивных элементов, которые действительно попадают в видимую область экрана. А для тех же элементов, которые покидают её, выполнять unmount и возвращать их в пул, не дожидаясь, пока весь пост скроется за краем экрана, таким образом экономя память. Скролл существенно ускоряется за счёт того, что отрисовка тяжёлого поста разбивается на несколько фреймов. Всё это напоминает работу RecyclerView, но в Litho вам не надо как-то по-особому менять UI или использовать другие компоненты это работает из коробки.

Выводы на заметку:


Если вы определяете кастомную MountSpec-у, то:

  • можно использовать параметр isPureRender и метод @ShouldUpdate, чтобы не делать лишнюю работу при обновлении UI компонента.
  • зная, в каком объёме компонент будет использован в приложении, вы можете подготовить нужное количество инстансов заранее, настроив размер пула с помощью poolSize.


Управление Состоянием


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

Рассмотрим простой пример компонент со счётчиком и кнопкой для его увеличения:



Чтобы реализовать такой компонент, в первую очередь нам необходим State-параметр count. По нему не создаются Builder-методы, потому что вы не можете предоставить его снаружи, в отличие от значений Prop-ов, и система управляет им изнутри. В данном случае мы используем этот стейт для создания текста с текущим значением.

Затем нам нужен метод для изменения стейта. Он помечается аннотацией @OnUpdateState и на вход принимает все тот же самый стейт, но не в виде неизменяемого значения, а завернутый в холдер StateValue, в котором стейт реально можно поменять.

Наконец, нам надо связать все это с нажатием на кнопку. Для этого есть event-хендлеры: метод с аннотацией @OnEvent определяет обработчик событий определённого типа (в данном случае, кликов), и в нём вызывается сгенерированный метод для изменения стейта увеличения счётчика.



В данном примере видно, что наборы параметров в описании метода и в месте его вызова не совпадают. Это происходит, потому что вы вызываете метод, определенный в Spec-классе не руками, а неявно, через сгенерированный метод в сгенерированном Component-классе, и все значения, необходимые для Spec-метода (в данном случае, StateValue), Litho подставит сам.

Каждое обновление стейта вызывает тот же эффект, что и передача новых значений Prop-параметров: снова нужно построить Internal Tree, получить LayoutState и отрисовать его на экран.

А что если у нас есть пара переменных для состояния и разные для них методы обновления? Допустим, у нас есть профиль супергероя, в котором нам надо поменять цвет и имя. Мы хотим сменить зеленого Халка на красного Железного Человека. У нас есть две переменные состояния color и name, и мы делаем два обновления путем присвоения переменных.

@OnUpdateStatefun changeColor(color: StateValue<Color>, @Param newColor: Color) {  color.set(newColor)}@OnUpdateStatefun changeName(name: StateValue<String>, @Param newName: String) {  name.set(newName)}...RootComponent.changeColor(c, Color.RED)RootComponent.changeName(c, "IronMan")

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

красного Халка не существует!
окей-окей, все знают, что на самом деле существует, но вы поняли мысль.



Отсюда Best Practice: важно помнить про группировку State-апдейтов. Если у вас есть несколько State-переменных, которые вы обычно меняете отдельно в разных методах, но есть ситуации, когда необходимо поменять их вместе, то стоить завести отдельный метод для их модификации целой группой (или несколько методов для разных комбинаций переменных).

@OnUpdateStatefun changeHero(    color: StateValue<Color>, name: StateValue<String>,    @Param newColor: Color, @Param newName: String) {  color.set(newColor)  name.set(newName)}...RootComponent.changeHero(c, Color.RED, "IronMan")

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

Отложенное обновление состояния


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



Для шага заведем отдельный State-параметр step, в котором будем хранить текущее значение, и сделаем возможность вводить его с клавиатуры в поле TextInput. Так как при изменении этого значения в поле ввода новое число мы увидим сразу, то обновлять UI с новым значением step не надо, но запомнить его необходимо. Для этого надо выставить флаг canUpdateLazily, давая Litho понять, что этот State можно изменять без перестроения UI, лениво. В этом случае, помимо всех явно определенных @UpdateState методов, которые отвечают за обычные обновления состояния, сгенерируется ещё один метод lazyUpdateStep, выполняющий как раз такое ленивое обновление step. Префикс lazyUpdate общий для всех таких методов, а суффикс (Step) однозначно соответствует имени State-переменной.

@State(canUpdateLazily = true) step: IntRootComponent.lazyUpdateStep(c, value)

Выводы на заметку



  • не забывайте группировать стейт апдейты, когда вы знаете, какие наборы State-переменных будут меняться вместе.
  • используйте Lazy State-апдейты для State-переменных, которые не влияют на отображение UI.


Анимация в Litho


Давайте теперь перейдем от статического отображения UI к динамическому как в декларативном API Litho будет выглядеть описание анимации?



Рассмотрим простой пример (видео доклада 28:33-28:44) с кнопкой, которая меняет своё расположение при клике. Она прижимается то к правому, то к левому краю, но происходит это моментально, скачкообразно в этом случае пользователю не очевидно, что произошло.

Однако мы можем это исправить, добавить контекста и анимировать кнопку. Для этого надо сделать две вещи: надо пометить то, ЧТО мы анимируем, и описать, КАК надо анимировать. Мы двигаем кнопку, поэтому задаем ей свойство transitionKey.

Button.create(c)    .text(Catch me!)    .transitionKey(button)    .alignSelf(align)    .clickHandler(RootComponent.onClick(c))

Затем реализуем метод с аннотацией @OnCreateTransition, который создаёт описание анимации изменений между двумя отрисовками этого компонента, Transition между предыдущим и следующим состоянием UI. В данном случае Transition простой: мы создаём его с тем же transitionKey, которым пометили цель анимации (в данном случае, кнопку), и просим проанимировать только изменения горизонтальной координаты цели координаты X кнопки. В результате оно действительно анимируется (видео доклада 29:25-29.33).

@OnCreateTransitionfun onCreateTransition(c: ComponentContext): Transition {  return Transition.create("button")      .animate(AnimatedProperties.X)}

Такое описание анимации отлично, если вы четко знаете, что нужно анимировать и хотите полного контроля над тем, как это анимировать, но может стать многословным в сложных компонентах. Если же вы хотите проанимировать любые изменения в layout-е и сделать это автоматически, то в Litho есть специальный Transition.allLayout() для этих целей. Это нечто похожее на установку animateLayoutChanges = true для анимации всех изменений в нативной ViewGroup.

@OnCreateTransitionfun onCreateTransition(c: ComponentContext): Transition {  return Transition.allLayout()}

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

Чтобы самим определять, надо или нет анимировать конкретное изменение компонента, можно использовать Diff для Prop и State-параметров.

Diff это такой холдер, который содержит одновременно и текущее, и предыдущее значение для конкретного Prop/State, давая возможность их сравнить произвольным методом и сделать вывод, стоит анимировать или нет.



И если вернуть null из @OnCreateTransition, то анимироваться ничего не будет. Более того, будет пропущен весть этап подготовки анимации, что положительно скажется на производительности.

@OnCreateTransitionfun onCreateTransition(c: ComponentContext,    @Prop prop: Diff<String>,    @State state: Diff<Boolean>): Transition? {  return if (canAnimate(prop, state)) Transition.allLayout() else null}

Обратите внимание, что и аннотации, и имена соответствующих Prop/State остаются такими же, как в @OnCreateLayout, меняется лишь тип с T на Diff.

Выводы на заметку


Используйте Diff параметры для более тонкой настройки анимации изменения значений Prop и State.


Пошаговое внедрение


Вряд ли в существующем проекте кто-то решится в одночасье переписать весь UI на Litho. Поэтому возникаю логичные вопросы: можно ли осуществлять внедрение по частям? Могут ли Litho-компоненты жить бок о бок с нативным Android UI? И тут у меня для вас хорошие новости!

Да, ваш сложный UI можно портировать на Litho по частям:

  • С одной стороны можно использовать Litho-компоненты внутри существующего UI на View. Можно последовательно заменять сложные UI-поддеревья в вашей разметке на LithoView с аналогичной иерархией компонентов. Таким образом вы упростите изначальный UI и уменьшите глубину дерева элементов.
  • С другой стороны можно использовать кастомные View сложные графики, анимированные круговые диаграммы, видео-плееры, которые нелегко переписать на компоненты, в Litho-интерфейсах. Для этого View нужно обернуть в MountSpec-у (помните, что метод с @OnCreateMountContent может возвращать не только Drawable, но и View?), которую потом легко можно будет включать в иерархию компонентов.


Дебаггинг и тулы в Litho


А что же нам делать, если вдруг что-то не будет работать? Если будут вопросы, то где смотреть примеры? Как отладить интерфейс на Litho? Как быстро верстать и тюнить UI? Обо всём этом ниже.

Yoga playground


Litho использует концепцию и терминологию Flexbox для задания расположения элементов. Если вы с ней не знакомы, то для вас есть Yoga Playground. Это интерактивная песочница, где на схематичном представлении UI с виде прямоугольников можно поиграться с параметрами, подготовить макет вашего UI и даже экспортировать его в виде Litho-кода на Java.

Flipper + Litho plugins


Для Litho, к сожалению, нет поддержки в UI Preview. Стандартный Layout Inspector тоже не сможет показать иерархию компонентов Litho. Всё потому, что эти инструменты работают только с Android View. Но к счастью коллеги из команды UI Tools разрабатывают замечательный инструмент для разносторонней отладки мобильных приложений Flipper. Layout-плагин для Flipper умеет отображать иерархию UI-элементов интерфейса, который отображается на экране телефона, и распознаёт не только обычные View, но и компоненты Litho. Кроме того, при выделении какого-либо компонента, в боковой панели можно увидеть список свойств Props компонента, большую часть из которых можно менять в реальном времени и проверять изменения на устройстве. Это сильно упрощает финальную подстройку UI, во многом заменяя UI Preview.



Для демонстрации работы плагина давайте посмотрим демку из доклада. Справа сэмпл приложение с простым списком, а слева Flipper.

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



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

Litho IntelliJ plugin


В Litho сильно отличающийся от стандартного подход к написанию UI, свои аннотации и lifecycle-методы много нового. Есть, конечно, документация, но чтобы при написании каждой новой Spec-и не обращаться к ней для уточнения любых вопросов, а стартовать быстро, наша команда также предоставляет IntelliJ / Android Studio плагин. Он добавляет шаблоны для создания LayoutSpec и MountSpec, шаблоны для генерации отдельных lifecycle-методов, а также возможность навигации между Spec-классом и сгенерированным по нему классом компонента.



Плагин можно установить через IntelliJ Plugin Marketplace.

Lithography Sample app


Ну а кроме всего вышеназванного, конечно же в репозитории есть sample-приложение Lithography. В нём можно посмотреть рецепты по реализации каких-то реальных примеров: создать UI карточки, загрузить картинку из интернета, реализовать Fast Scroll. Есть целые секции по работе со списками, различным способам анимации и так далее.

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


Резюме


Ключевые достоинства Litho в том, что обработку UI можно частично проводить на фоновом-потоке, его декларативный API позволяет проще описывать все возможные состояния UI, а при рендеринге получаются более плоские иерархии View. Несмотря на то, что мы все оборачиваем в Row, Column и прочие компоненты, на самом деле рисоваться будут только листовые элементы дерева и каждый пиксель как правило будет рисоваться по одному разу. Incremental Mount предоставляет возможность более гранулярного переиспользования отдельных атомарных MountSpec, а не только целых LayoutSpec компонентов элементов списка.


Бонус: Litho и Kotlin


С учётом завязки Litho на процессинг аннотаций и кодогенерацию, использование его с Kotlin может дать некоторое замедление сборки, так как KAPT печально известен своей неторопливостью. Ну и чего скрывать, для такого модного молодежного языка, как Kotlin, обилие аннотаций в API не выглядит очень удобно, когда везде правят разнообразные DSL-и. А хотелось бы вот как-то так просто создать UI в одной функции, да может даже прямо в Activity, и там же его в Activity и отрендерить, без плясок с LithoView:

class PlaygroundActivity : Activity() {  override fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    setContent {      val counter by useState { 1 }      Clickable(onClick = { updateState { counter.value = counter.value + 1 } }) {        Padding(all = 16.dp) {          Column {            +Text(text = "Hello, Kotlin World!", textSize = 20.sp)            +Text(                text = "with ${"".repeat(counter.value)} from London",                textStyle = Typeface.ITALIC)          }           }      }    }  }}

Так вот всё это реальный код! Пока что Kotlin API находится в активной разработке, но экспериментировать с ним можно уже сейчас Kotlin-артефакты выкладываются с каждым релизом Litho, а кроме того доступны их Snapshot-версии. Также, вы можете следить за развитием проекта на Github-е.

Настоятельно рекомендую ознакомиться с материалами по ссылкам:


Уже на следующей неделе состоится Mobius 2020 Piter. Там для Android-разработчиков тоже будет много интересного: например, выступит хорошо знакомый им Chet Haase из Google. Многие помнят его по выступлениям на Google I/O, а в этом году I/O отменили, но благодаря Mobius есть шанс всё равно увидеть Чета и даже лично задать ему вопрос.
Подробнее..

Litho лучшие практики для создания эффективного UI в Android

18.06.2020 22:05:19 | Автор: admin
Litho UI-фреймворк от Facebook, который отвечает за быстрый рендеринг тяжелого UI в топовых приложения с миллиардами загрузок.

Как его использовать, что происходит под капотом, и действительно ли с UI можно работать только из одного потока?


Cookbook по Litho в расшифровке моего доклада с конференции Mobius 2019 Moscow под катом.

С вами Сергей Рябов разработчик из Лондона, который поставил на паузу свой кочевой образ жизни, чтобы проверить, такой ли Альбион туманный, как о нём любят говорить. И я работаю в команде Native UI Frameworks над декларативным фреймворком Litho.

Структура поста:




Что такое Litho?




Litho это UI фреймворк, которому уже четыре года. Два года он разрабатывался внутри компании, затем его вывели в опенсорс, и вот уже два с половиной года он растет и развивается как опенсорсный проект. Изначально Litho разрабатывался для основного приложения Facebook, хотя если взять три самых популярных приложения, которые используют Litho в настоящий момент, то только одно из них будет иметь отношение к Facebook. Остальные же продукты Google.

Когда мы работаем с интерфейсом, у нас есть следующие этапы работы: Inflate, Measure, Layout, Draw. Все эти этапы построения UI должны поместиться в 16 миллисекунд, чтобы приложение не тормозило. Предположим, что у нас тяжелый UI, который не укладывается в 16 миллисекунд: все оптимизации проведены, мы попытались выжать максимум производительности из наших вьюшек, но программа все равно лагает. Можно постараться что-нибудь вынести в фоновый поток, например, Inflate, поскольку это достаточно тяжелая операция. Для этого у нас есть AsyncLayoutInflater, из набора библиотек Android Jetpack.

Заметим, что AsyncLayoutInflater имеет некоторые нюансы: мы не можем создавать вьюшки, которые работают внутри с Handler, нам нужно, чтобы LayoutParams были thread-safe и некоторые другие. Но в целом использовать его можно.

Допустим, в процессе развития приложения на главном экране появляются еще более сложные элементы, и даже оставшиеся три стадии уже не влезают в 16 миллисекунд. В целях оптимизации хочется максимальное количество работы отправить в фоновый поток сразу и Measure, и Layout, поскольку теоретически это в основном математические расчеты, и они сильно отвязаны от Android. Практически же сделать так нельзя, потому что в Android UI-фреймворке Measure и Layout непосредственно реализованы в классе View, поэтому с ними можно работать только в UI-потоке.

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



Litho дает возможность абстрагироваться от View в фазах Measure и Layout благодаря тому, что под капотом для измерений используется движок Yoga, который тоже разрабатывается в команде Native UI Frameworks. И как следствие, это позволяет всю математику вынести в бэкграунд-поток.

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

Декларативный API в Litho


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



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

fun f(    title: String,    subtitle: String): UI {  return Column.create()    .child(        Text.create()            .text(title))    .child(        Text.create()            .text(subtitle))    .build()}

По входным параметрам мы создаем элементы для title, subtitle и помещаем их в Column, то есть вертикально друг относительно друга.
В Litho эта же функция будет выглядеть следующим образом:

@LayoutSpecobject ListItemSpec {  @OnCreateLayout  fun onCreateLayout(      c: ComponentContext,      @Prop title: String,      @Prop subtitle: String  ): Component {    return Column.create(c)        .child(            Text.create(c)                .text(title))        .child(            Text.create(c)                .text(subtitle))        .build()   }}

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

Этот класс написан на Kotlin, поэтому используется object, но если бы он был на Java, то метод был бы статическим. Это обусловлено вышеупомянутым упором на потокобезопасность. Мы описываем только то, как должен выглядеть UI, а то, что для этого будет происходить под капотом, генерируется фреймворком, на это нельзя повлиять прямым образом, а потому шансы случайно ошибиться, например, плохо обработав локальное состояние (state) UI-компонента, сокращаются.


Комбинирование UI-элементов


У нас уже есть одна функция описывающая простой UI, а как теперь создать на ее основе что-то более сложное? Например, добавить картинку слева от текста, чтобы в итоге получился такой header с данными человека, который написал комментарий: его аватар, имя и дата комментария.



Решение похоже на использование горизонтального LinearLayout, но в данном случае мы горизонтально располагаем картинку и предыдущий UI-компонент ListItem, заворачивая их в Row. Также добавился еще один входной параметр @Prop image, который отвечает за саму картинку, а те параметры, которые отвечают за текстовые данные, просто прокидываются в компонент ListItem.

Стоит отметить, что описываются UI-компоненты в классах с суффиксом Spec, а для создания инстансов этих компонентов используются классы без этого суффикса. Всё потому, что Litho по декларативным Spec-файлам генерирует настоящие реализации компонентов с правильной обработкой многопоточности и удобным API в виде Builder-ов, которые и используются для задания входных параметров, объявленных в UI-спеке.

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



В публичном API Litho есть только одна View LithoView. Это контейнер, в котором идет отрисовка всех Litho-компонентов. Чтобы отобразить на экране заданный компонент, нужно создать ComponentContext, передав ему Android Context, и создать LithoView, передав в метод create контекст и сам отображаемый компонент. С LithoView можно делать всё, что вы привыкли делать с другими вьюшками, например, передать в метод setContentView у Activity.

С API на основе Builder-ов работать легко, механика создания UI-компонента похожа на описание вью в XML. Разница лишь в том, что вместо проставления XML-атрибутов вы вызываете методы Builder-а. Но раз уж это все так сильно отвязано от Android-системы, то что же происходит под капотом?


Litho: под капотом


Возьмем ListItemWithImageSpec, с которым мы уже встречались ранее. В нём три компонента: Row, Image и кастомный ListItem:

@LayoutSpecobject ListItemWithImageSpec {     // ...    Row.create(c)        .child(            Image.create(c)                  .drawable(image))        .child(            ListItem.create(c)                  .title(title)                  .subtitle(subtitle))        .build()  }}

И чтобы отобразить его на экране, добавим его в LithoView таким образом:

setContentView(LithoView.create(c,    ListItemWithImage.create(c)        .image(user.avatar)        .title(user.name)        .subtitle(comment.formatDate())        .build()))

Рендеринг UI-компонента проходит в три основных шага:

  1. Построение Internal Tree внутреннего представления UI.
  2. Получение LayoutState набора Drawable и View, которые будут отрисованы на экране
  3. Отрисовка LayoutState на Canvas-е

Построение Internal Tree


Начинаем с корневого компонента ListItemWithImage:

  • Создаем корневой элемент дерева, InternalNode, ассоциируем с ним компонент ListItemWithImage. Так как ListItemWithImage это фактически просто обертка, то смотрим на его содержимое.
  • Внутри метода onCreateLayout в ListItemWithImageSpec мы первым делом создаем Row. Ассоциируем его с той же самой нодой.
  • У Row 2 потомка: Image и ListItem для обоих создаются отдельные InternalNode-ы. Image это листовой элемент дерева компонентов, на этом обработка его поддерева окончена.
  • ListItem в свою очередь тоже компонент-обертка, чтобы добраться до сути смотрим в метод onCreateLayout его спеки. Там мы видим Column, ассоциируем её с той же нодой.
  • У Column есть 2 потомка: Text и Text создаём для них две новые ноды. Оба элемента листовые построение Internal Tree окончено.

Получилось следующее дерево:



Тут наглядно видно, что в результате ноды создаются только для листовых компонентов, таких как Image и Text, или для компонентов-контейнеров, таких как Row и Column. Так мы упростили иерархию: было три уровня относительно корня, осталось два.

Получение LayoutState


Следующим шагом нам нужно создать LayoutState. Для этого сначала измерим Internal Tree с помощью Yoga. Yoga присвоит координаты х, y, а также ширину и высоту каждому узлу дерева. Затем, с учетом этой полной информации, мы создадим список того, что будет непосредственно отрисовано на экране (уже зная, где оно будет отрисовано и какого размера).

Происходит это следующим образом: мы снова обходим Internal Tree, и смотрим, нужно ли отрисовывать каждую следующую ноду. Row не отрисовывается, он фактически нужен был только для измерения и размещения дочерних элементов. Image отрисовывается, поэтому добавляем его LayoutOutput в список. Column тоже нужен был только для того, чтобы померять и расположить элементы и отрисовывать там нечего, а вот Text-ы, как и Image, тоже важны для отрисовки.

Получившийся в итоге список LayoutOutput-ов это наш LayoutState.

Отрисовка LayoutState


Теперь полученный LayoutState осталось нарисовать на экране. И тут важно подчеркнуть, что в данном примере элементы Image и два Text-a будут отрисованы не с помощью View, а с помощью Drawable. Если мы можем не использовать сложные элементы Android UI Toolkit, если можно обойтись простыми и легковесными примитивами типа Drawable, то эффективнее использовать именно их. Если же какие-то элементы должны уметь реагировать, например, на клики, то они будут отрисованы с помощью View, чтобы переиспользовать всю непростую логику обработки UI-событий.



Шаг отрисовки это единственный этап, который должен выполняться на UI-потоке, все остальные шаги могут быть выполнены в фоне.

На рассмотренном примере мы познакомились с несколькими ключевыми элементами:

  • @LayoutSpec компоненты, комбинирующие другие компоненты. В итоге они превращаются в поддеревья в Internal Tree. Аналог кастомных ViewGroup.
  • Row и Column компоненты-контейнеры, служащие для задания расположения UI-элементов на экране. Это примитивы Flexbox основного Layout Engine в Litho. А Yoga это его кроссплатформенная реализация, которая используется не только в Litho, но также и в других библиотеках под Android, под iOS и в Web.
  • @MountSpec это те самые листовые ноды в Internal Tree Image, Text и другие. Это второй тип Spec, который описывает примитивные элементы, которые будут отрисованы на экране с помощью Drawable или View.

Как будет выглядеть код кастомной @MountSpec-и? Примерно так:

@MountSpecobject GradientSpec {  @OnCreateMountContent  fun onCreateMountContent(context: Context): GradientDrawable {      return GradientDrawable()  }  @OnMount  fun onMount(      c: ComponentContext,      drawable: GradientDrawable,      @Prop @ColorInt colors: IntArray) {      drawable.colors = colors  } }

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

Напомню, что компонент, описанный как MountSpec, может отрисовывать всего два типа контента: View или Drawable. В данном простом случае нам достаточно легковесного Drawable. Кроме операции создания контента мы также должны определить метод с аннотацией @OnMount для биндинга с данными перед тем, как компонент станет видимым. В нашем случае данными является тот массив цветов, который мы получаем на вход. Всё остальное Litho берет на себя и отрисовывает GradientDrawable c заданными цветами на экран. Для облегчения понимания можно сравнить методы, помеченные @OnCreateMountContent и @OnMount, с методами RecyclerView.Adapter onCreateViewHolder и onBindViewHolder соответственно.


Аннотация @MountSpec


В аннотации @MountSpec есть два параметра:

  1. poolSize параметр, который отвечает за то, сколько инстансов данного компонента будет создано заранее и помещено в пул, чтобы потом быстро использовать их при рендеринге интерфейса. По умолчанию этот параметр равен 3.
  2. isPureRender это булевый флаг, показывающий, что при пересоздании компонента с неизменными значениями Prop-параметров, результат его отрисовки всегда будет оставаться прежним. При обновлении UI-дерева компонентов это позволяет не пересоздавать и не перерисовывать такие чистые компоненты.

Конкретные значения будут зависеть от того, что за компонент вы описываете. Рассмотрим в качестве примера ImageSpec компонент для показа картинки:

@MountSpec (poolSize = 30, isPureRender = true)class ImageSpec {  @ShouldUpdate  static boolean shouldUpdate(...) {}}public @interface MountSpec {  int poolSize() default 3;  boolean isPureRender() default false;}

У него очень большой poolSize (30). Cегодня типичная ситуация, когда приложение нагружено картинками, поэтому UI-компоненты для них лучше подготовить заранее в достаточном количестве. В то же время, если входной параметр Drawable, не меняется, то и вывод на экран такого компоненты тоже не поменяется, и, чтобы не делать лишних действий, можно установить флаг isPureRender В этом случае решение об обновлении компонента принимается на основании сравнения Prop-параметров с помощью equals(), если же вы хотите использовать кастомную логику сравнения, то её нужно поместить в функцию с аннотацией @ShouldUpdate.


Оптимизации в Litho


В Litho есть две ключевые оптимизации при построении Layout:

  1. Layout/Mount Diffing позволяет переиспользовать размеры (measurements) элементов с предыдущего рендеринга и переиспользовать LayoutOutput-ы (то, что выводится на экран) с предыдущего рендеринга.
  2. IncrementalMount позволяет превратить ваш UI в RecyclerView на стероидах без каких-либо дополнительных усилий.

Layout/Mount Diffing


Как это работает? При построении Internal Tree для нового компонента, также учитывается оставшееся с предыдущего рендеринга Diff Tree с готовыми размерами и LayoutOutput-ами всех узлов дерева.



Если входящие параметры для некоторого поддерева не изменились, то размеры и LayoutOutput для него просто копируются из Diff Tree в новый Internal Tree, минуя шаг измерения с помощью Yoga. Таким образом, LayoutState готов уже к концу построения Internal Tree.

IncrementalMount


Допустим, у вас есть своя социальная сеть с новостной лентой со сложными UI-элементами, например, постами с большим количеством, деталей. Не хотелось бы, чтобы при прокрутке экрана mount и отрисовка выполнялись для всего тяжелого UI поста, сразу как только первый пиксель покажется из-за края экрана. Если mount такого сложного элемента не уложится в 16мс, то мы будем видеть дерганый UI, особенно при быстрой прокрутке.
mount
Mount процесс получения контента для рендеринга (View или Drawable) и его добавление в текущую иерархию View

IncrementalMount в этом случае позволяет рендерить новостной пост не целиком, а постепенно, выполняя mount только для тех дочерних примитивных элементов, которые действительно попадают в видимую область экрана. А для тех же элементов, которые покидают её, выполнять unmount и возвращать их в пул, не дожидаясь, пока весь пост скроется за краем экрана, таким образом экономя память. Скролл существенно ускоряется за счёт того, что отрисовка тяжёлого поста разбивается на несколько фреймов. Всё это напоминает работу RecyclerView, но в Litho вам не надо как-то по-особому менять UI или использовать другие компоненты это работает из коробки.

Выводы на заметку:


Если вы определяете кастомную MountSpec-у, то:

  • можно использовать параметр isPureRender и метод @ShouldUpdate, чтобы не делать лишнюю работу при обновлении UI компонента.
  • зная, в каком объёме компонент будет использован в приложении, вы можете подготовить нужное количество инстансов заранее, настроив размер пула с помощью poolSize.


Управление Состоянием


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

Рассмотрим простой пример компонент со счётчиком и кнопкой для его увеличения:



Чтобы реализовать такой компонент, в первую очередь нам необходим State-параметр count. По нему не создаются Builder-методы, потому что вы не можете предоставить его снаружи, в отличие от значений Prop-ов, и система управляет им изнутри. В данном случае мы используем этот стейт для создания текста с текущим значением.

Затем нам нужен метод для изменения стейта. Он помечается аннотацией @OnUpdateState и на вход принимает все тот же самый стейт, но не в виде неизменяемого значения, а завернутый в холдер StateValue, в котором стейт реально можно поменять.

Наконец, нам надо связать все это с нажатием на кнопку. Для этого есть event-хендлеры: метод с аннотацией @OnEvent определяет обработчик событий определённого типа (в данном случае, кликов), и в нём вызывается сгенерированный метод для изменения стейта увеличения счётчика.



В данном примере видно, что наборы параметров в описании метода и в месте его вызова не совпадают. Это происходит, потому что вы вызываете метод, определенный в Spec-классе не руками, а неявно, через сгенерированный метод в сгенерированном Component-классе, и все значения, необходимые для Spec-метода (в данном случае, StateValue), Litho подставит сам.

Каждое обновление стейта вызывает тот же эффект, что и передача новых значений Prop-параметров: снова нужно построить Internal Tree, получить LayoutState и отрисовать его на экран.

А что если у нас есть пара переменных для состояния и разные для них методы обновления? Допустим, у нас есть профиль супергероя, в котором нам надо поменять цвет и имя. Мы хотим сменить зеленого Халка на красного Железного Человека. У нас есть две переменные состояния color и name, и мы делаем два обновления путем присвоения переменных.

@OnUpdateStatefun changeColor(color: StateValue<Color>, @Param newColor: Color) {  color.set(newColor)}@OnUpdateStatefun changeName(name: StateValue<String>, @Param newName: String) {  name.set(newName)}...RootComponent.changeColor(c, Color.RED)RootComponent.changeName(c, "IronMan")

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

красного Халка не существует!
Поймали пасхалку? С меня стикер ;)



Отсюда Best Practice: важно помнить про группировку State-апдейтов. Если у вас есть несколько State-переменных, которые вы обычно меняете отдельно в разных методах, но есть ситуации, когда необходимо поменять их вместе, то стоить завести отдельный метод для их модификации целой группой (или несколько методов для разных комбинаций переменных).

@OnUpdateStatefun changeHero(    color: StateValue<Color>, name: StateValue<String>,    @Param newColor: Color, @Param newName: String) {  color.set(newColor)  name.set(newName)}...RootComponent.changeHero(c, Color.RED, "IronMan")

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

Отложенное обновление состояния


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



Для шага заведем отдельный State-параметр step, в котором будем хранить текущее значение, и сделаем возможность вводить его с клавиатуры в поле TextInput. Так как при изменении этого значения в поле ввода новое число мы увидим сразу, то обновлять UI с новым значением step не надо, но запомнить его необходимо. Для этого надо выставить флаг canUpdateLazily, давая Litho понять, что этот State можно изменять без перестроения UI, лениво. В этом случае, помимо всех явно определенных @UpdateState методов, которые отвечают за обычные обновления состояния, сгенерируется ещё один метод lazyUpdateStep, выполняющий как раз такое ленивое обновление step. Префикс lazyUpdate общий для всех таких методов, а суффикс (Step) однозначно соответствует имени State-переменной.

@State(canUpdateLazily = true) step: IntRootComponent.lazyUpdateStep(c, value)

Выводы на заметку



  • не забывайте группировать стейт апдейты, когда вы знаете, какие наборы State-переменных будут меняться вместе.
  • используйте Lazy State-апдейты для State-переменных, которые не влияют на отображение UI.


Анимация в Litho


Давайте теперь перейдем от статического отображения UI к динамическому как в декларативном API Litho будет выглядеть описание анимации?



Рассмотрим простой пример (видео доклада 28:33-28:44) с кнопкой, которая меняет своё расположение при клике. Она прижимается то к правому, то к левому краю, но происходит это моментально, скачкообразно в этом случае пользователю не очевидно, что произошло.

Однако мы можем это исправить, добавить контекста и анимировать кнопку. Для этого надо сделать две вещи: надо пометить то, ЧТО мы анимируем, и описать, КАК надо анимировать. Мы двигаем кнопку, поэтому задаем ей свойство transitionKey.

Button.create(c)    .text(Catch me!)    .transitionKey(button)    .alignSelf(align)    .clickHandler(RootComponent.onClick(c))

Затем реализуем метод с аннотацией @OnCreateTransition, который создаёт описание анимации изменений между двумя отрисовками этого компонента, Transition между предыдущим и следующим состоянием UI. В данном случае Transition простой: мы создаём его с тем же transitionKey, которым пометили цель анимации (в данном случае, кнопку), и просим проанимировать только изменения горизонтальной координаты цели координаты X кнопки. В результате оно действительно анимируется (видео доклада 29:25-29.33).

@OnCreateTransitionfun onCreateTransition(c: ComponentContext): Transition {  return Transition.create("button")      .animate(AnimatedProperties.X)}

Такое описание анимации отлично, если вы четко знаете, что нужно анимировать и хотите полного контроля над тем, как это анимировать, но может стать многословным в сложных компонентах. Если же вы хотите проанимировать любые изменения в layout-е и сделать это автоматически, то в Litho есть специальный Transition.allLayout() для этих целей. Это нечто похожее на установку animateLayoutChanges = true для анимации всех изменений в нативной ViewGroup.

@OnCreateTransitionfun onCreateTransition(c: ComponentContext): Transition {  return Transition.allLayout()}

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

Чтобы самим определять, надо или нет анимировать конкретное изменение компонента, можно использовать Diff для Prop и State-параметров.

Diff это такой холдер, который содержит одновременно и текущее, и предыдущее значение для конкретного Prop/State, давая возможность их сравнить произвольным методом и сделать вывод, стоит анимировать или нет.



И если вернуть null из @OnCreateTransition, то анимироваться ничего не будет. Более того, будет пропущен весть этап подготовки анимации, что положительно скажется на производительности.

Обратите внимание, что и аннотации, и имена соответствующих Prop/State остаются такими же, как в @OnCreateLayout, меняется лишь тип с T на Diff<T>.

Выводы на заметку


Используйте Diff параметры для более тонкой настройки анимации изменения значений Prop и State.


Пошаговое внедрение


Вряд ли в существующем проекте кто-то решится в одночасье переписать весь UI на Litho. Поэтому возникаю логичные вопросы: можно ли осуществлять внедрение по частям? Могут ли Litho-компоненты жить бок о бок с нативным Android UI? И тут у меня для вас хорошие новости!

Да, ваш сложный UI можно портировать на Litho по частям:

  • С одной стороны можно использовать Litho-компоненты внутри существующего UI на View. Можно последовательно заменять сложные UI-поддеревья в вашей разметке на LithoView с аналогичной иерархией компонентов. Таким образом вы упростите изначальный UI и уменьшите глубину дерева элементов.
  • С другой стороны можно использовать кастомные View сложные графики, анимированные круговые диаграммы, видео-плееры, которые нелегко переписать на компоненты, в Litho-интерфейсах. Для этого View нужно обернуть в MountSpec-у (помните, что метод с @OnCreateMountContent может возвращать не только Drawable, но и View?), которую потом легко можно будет включать в иерархию компонентов.


Дебаггинг и тулы в Litho


А что же нам делать, если вдруг что-то не будет работать? Если будут вопросы, то где смотреть примеры? Как отладить интерфейс на Litho? Как быстро верстать и тюнить UI? Обо всём этом ниже.

Yoga playground


Litho использует концепцию и терминологию Flexbox для задания расположения элементов. Если вы с ней не знакомы, то для вас есть Yoga Playground. Это интерактивная песочница, где на схематичном представлении UI с виде прямоугольников можно поиграться с параметрами, подготовить макет вашего UI и даже экспортировать его в виде Litho-кода на Java.

Flipper + Litho plugins


Для Litho, к сожалению, нет поддержки в UI Preview. Стандартный Layout Inspector тоже не сможет показать иерархию компонентов Litho. Всё потому, что эти инструменты работают только с Android View. Но к счастью коллеги из команды UI Tools разрабатывают замечательный инструмент для разносторонней отладки мобильных приложений Flipper. Layout-плагин для Flipper умеет отображать иерархию UI-элементов интерфейса, который отображается на экране телефона, и распознаёт не только обычные View, но и компоненты Litho. Кроме того, при выделении какого-либо компонента, в боковой панели можно увидеть список свойств Props компонента, большую часть из которых можно менять в реальном времени и проверять изменения на устройстве. Это сильно упрощает финальную подстройку UI, во многом заменяя UI Preview.



Для демонстрации работы плагина взгляните на демку из доклада. Справа сэмпл приложение с простым списком, а слева Flipper.

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



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

Litho IntelliJ plugin


В Litho сильно отличающийся от стандартного подход к написанию UI, свои аннотации и lifecycle-методы много нового. Есть, конечно, документация, но чтобы при написании каждой новой Spec-и не обращаться к ней для уточнения любых вопросов, а стартовать быстро, наша команда также предоставляет IntelliJ / Android Studio плагин. Он добавляет шаблоны для создания LayoutSpec и MountSpec, шаблоны для генерации отдельных lifecycle-методов, а также возможность навигации между Spec-классом и сгенерированным по нему классом компонента.



Плагин можно установить через IntelliJ Plugin Marketplace.

Lithography Sample app


Ну а кроме всего вышеназванного, конечно же в репозитории есть sample-приложение Lithography. В нём можно посмотреть рецепты по реализации каких-то реальных примеров: создать UI карточки, загрузить картинку из интернета, реализовать Fast Scroll. Есть целые секции по работе со списками, различным способам анимации и так далее.

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


Резюме


Ключевые достоинства Litho в том, что обработку UI можно частично проводить на фоновом-потоке, его декларативный API позволяет проще описывать все возможные состояния UI, а при рендеринге получаются более плоские иерархии View. Несмотря на то, что мы все оборачиваем в Row, Column и прочие компоненты, на самом деле рисоваться будут только листовые элементы дерева и каждый пиксель как правило будет рисоваться по одному разу. Incremental Mount предоставляет возможность более гранулярного переиспользования отдельных атомарных MountSpec, а не только целых LayoutSpec компонентов элементов списка.


Бонус: Litho и Kotlin


С учётом завязки Litho на процессинг аннотаций и кодогенерацию, использование его с Kotlin может дать некоторое замедление сборки, так как KAPT печально известен своей неторопливостью. Ну и чего скрывать, для такого модного молодежного языка, как Kotlin, обилие аннотаций в API не выглядит очень удобно, когда везде правят разнообразные DSL-и. А хотелось бы вот как-то так просто создать UI в одной функции, да может даже прямо в Activity, и там же его в Activity и отрендерить, без плясок с LithoView:

class PlaygroundActivity : Activity() {  override fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    setContent {      val counter by useState { 1 }      Clickable(onClick = { updateState { counter.value = counter.value + 1 } }) {        Padding(all = 16.dp) {          Column {            +Text(text = "Hello, Kotlin World!", textSize = 20.sp)            +Text(                text = "with ${"".repeat(counter.value)} from London",                textStyle = Typeface.ITALIC)          }           }      }    }  }}

Так вот всё это реальный код! Пока что Kotlin API находится в активной разработке, но экспериментировать с ним можно уже сейчас Kotlin-артефакты выкладываются с каждым релизом Litho, а кроме того доступны их Snapshot-версии. Также, вы можете следить за развитием проекта на Github-е.

Настоятельно рекомендую ознакомиться с материалами по ссылкам:


Уже на следующей неделе состоится Mobius 2020 Piter. Там для Android-разработчиков тоже будет много интересного: например, выступит хорошо знакомый им Chet Haase из Google. Многие помнят его по выступлениям на Google I/O, а в этом году I/O отменили, но благодаря Mobius есть шанс всё равно увидеть Чета и даже лично задать ему вопрос.
Подробнее..

Android и 3D камера. Распознавание лиц с защитой от Fraud

25.06.2020 18:18:39 | Автор: admin
Привет! Меня зовут Владимир Шальков, я Android-разработчик в Surf.

Не так давно нам необходимо было реализовать систему распознавания лиц на Android с защитой от мошенничества (fraud). В этой статье я поделюсь самыми интересными аспектами реализации с примерами кода и ссылками. Уверен, вы найдёте что-то новое и интересное для себя, поэтому усаживайтесь поудобнее, начинаем.



Системы распознавания лиц сейчас становятся всё более и более востребованными: количество устройств с функцией разблокировки по лицу растёт, так же как и количество инструментов для разработчиков.

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

Недавно, класс FingerprintManager, который использовался для разблокировки приложений по отпечатку пальцев, задепрекейтили на API 28 и выше, и разработчикам предлагается использовать BiometricPrompt. Это класс, содержит логику, связанную с биометрией, в том числе по идентификации лиц. Однако использовать его в каждом смартфоне не получится, потому что согласно информации от Google, устройство должно иметь высокий рейтинг безопасности.

Некоторые устройства не имеют встроенного сканера отпечатка пальцев, от него отказались ввиду высокого уровня защиты от мошенничества при распознавании лица и всё благодаря фронтальному ToF(Time-of-flight) датчику. С его помощью можно построить карту глубины, тем самым увеличить устойчивость системы к взлому.

Требования


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

Основной целью мы ставили обеспечение максимального уровня безопасности: необходимо было минимизировать возможность обхода системы распознавания лиц, например, с помощью фотографии, которую поднесли к видоискателю. Для этого решили использовать 3D-камеру Intel RealSense (модель D435i), которая имеет встроенный ToF датчик, благодаря ему можно получить все необходимые данные для построения карты глубины.

image

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

Ещё одно не менее важное ограничение работа в оффлайн режиме. Из-за этого мы не могли применять облачные сервисы для распознавания лиц. Кроме этого писать алгоритмы распознавания лиц с нуля неразумно, с учётом ограничения времени и трудозатрат. Возникает вопрос: зачем изобретать велосипед, если уже есть готовые решения? Исходя из всего выше сказанного, решили использовать библиотеку Face SDK от 3DiVi.

Получение изображения с камеры Intel RealSense


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

Чтобы начать работать с камерой Intel RealSense в Android-проекте, необходимо добавить зависимость RealSense SDK for Android OS: она является оберткой над официальной C++ библиотекой. В официальных семплах можно найти как произвести инициализацию и отобразить картинку с камер, на этом останавливаться не будем, там всё достаточно просто. Перейдём сразу к коду получения изображений:

private val pipeline = Pipeline()private val streamingHandler = Handler()private var streamRunnable: Runnable = object : Runnable {    override fun run() {        try {            FrameReleaser().use { fr ->                val frames = pipeline.waitForFrames(1000).releaseWith(fr)                val orgFrameSet = frames.releaseWith(fr)                val processedFrameSet = frames.applyFilter(align).releaseWith(fr)                val orgFrame: Frame = orgFrameSet.first(StreamType.COLOR, StreamFormat.RGB8).releaseWith(fr)                // Получаем фрейм цветного изображения                val videoFrame: VideoFrame = orgFrame.`as`(Extension.VIDEO_FRAME)                val processedDepth: Frame = processedFrameSet.first(StreamType.DEPTH, StreamFormat.Z16).releaseWith(fr)                // Получаем фрейм глубины изображения                val depthFrame: DepthFrame = processedDepth.`as`(Extension.DEPTH_FRAME)                upload(orgFrame) // Выводим на экран цветное изображение            }            streamingHandler.post(this)        } catch (e: Exception) {            Logger.d("Streaming, error: " + e.message)        }    }}streamingHandler.post(streamRunnable) // Запуск

С помощью FrameReleaser() мы получаем отдельные кадры с видеопотока, которые имеют тип Frame. К фреймам можно применять различные фильтры через applyFilter().

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

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

Преобразование фреймов в изображение


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

Формат изображений:

  • Цветное, с расширением .bmp, получаемое из VideoFrame
  • С картой глубины, имеющее расширение .tiff и получаемое из DepthFrame

Чтобы осуществить преобразования нам понадобится библиотека компьютерного зрения с открытым исходным кодом OpenCV. Вся работа заключается в формировании объекта Mat и конвертировании его в нужный формат:

fun videoFrameToMat(videoFrame: VideoFrame): Mat {    val colorMat = Mat(videoFrame.height, videoFrame.width, CvType.CV_8UC3)    val returnBuff = ByteArray(videoFrame.dataSize)    videoFrame.getData(returnBuff)    colorMat.put(0, 0, returnBuff)    val colorMatNew = Mat()    Imgproc.cvtColor(colorMat, colorMatNew, Imgproc.COLOR_RGB2BGR)    return colorMatNew}

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

fun VideoFrame.saveToFile(path: String): Boolean {    val colorMat = videoFrameToMat(this)    return Imgcodecs.imwrite(path + COLOR_IMAGE_FORMAT, colorMat)}

Тоже самое необходимо проделать для DepthFrame с тем лишь отличием, что матрица должна быть с типом CvType.CV_16UC1, так как изображение будет строиться из кадра, который содержит данные с датчика глубины:

fun depthFrameToMat(depthFrame: DepthFrame): Mat {    val depthMat = Mat(depthFrame.height, depthFrame.width, CvType.CV_16UC1)    val size = (depthMat.total() * depthMat.elemSize()).toInt()    val returnBuff = ByteArray(size)    depthFrame.getData(returnBuff)    val shorts = ShortArray(size / 2)    ByteBuffer.wrap(returnBuff).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(shorts)    depthMat.put(0, 0, shorts)    return depthMat}

Сохранение изображения с картой глубины:

fun DepthFrame.saveToFile(path: String): Boolean {    val depthMat = depthFrameToMat(this)    return Imgcodecs.imwrite(path + DEPTH_IMAGE_FORMAT, depthMat)}

Работа с библиотекой Face SDK


Face SDK имеет большой объём программных компонентов, но большая часть из них нам не нужна. Библиотека так же, как и RealSense SDK написана на C++ и имеет обёртку, чтобы было удобно работать под Android. Face SDK не бесплатна, но если вы разработчик, то вам выдадут тестовую лицензию.

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

Далее, используя этот сервис, нужно создать объекты классов FacerecService.Config и Capturer:

private val service: FacerecService = FacerecService.createService(                dllPath,                confDirPath,                onlineLicenseDir        )private val confManual: FacerecService.Config = service.Config("manual_capturer.xml")private val capturerManual: Capturer = service.createCapturer(confManual)

Класс Capturer используется для распознавания лиц. Конфигурация manual_capturer.xml означает, что мы будем использовать алгоритмы из библиотеки OpenCV это детектор фронтальных лиц Viola-Jones, для распознавания используются признаки Хаара. Библиотека предоставляет готовое множество XML файлов с конфигурациями, отличающихся по характеристикам качества распознавания и времени работы. Менее быстрые методы имеют лучшие показатели по качеству распознавания. Если нам нужно распознавать лица в профиль, то следует использовать другой конфигурационный XML файл common_lprofile_capturer.xml. Конфигов достаточно много, с ними можно подробнее ознакомиться в документации. В нашем случае необходимо было использовать конфиг common_capturer4_singleface.xml это конфигурация с пониженным порогом качества в результате использования которой, всегда будет возвращаться не более одного лица.

Чтобы найти лицо на изображении применяется метод capturerSingleFace.capture(), в который передаётся массив байтов картинки, которая содержит лицо человека:

fun createRawSample(imagePath: String): RawSample? {    val imageColorFile = File(imagePath)    val originalColorByteArray = ImageUtil.readImage(imageColorFile)    return capturerSingleFace.capture(originalColorByteArray).getOrNull(0)}

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

Принадлежность лица реальному человеку


Чтобы определить реальный ли человек находится в кадре, а не фотография, приставленная к камере детекции лиц, библиотека Face SDK, предоставляет модуль DepthLivenessEstimator, он возвращает enum с одним из четырех значений:

  • NOT_ENOUGH_DATA слишком много отсутствующих значений на карте глубины
  • REAL наблюдаемое лицо принадлежит живому человеку
  • FAKE наблюдаемое лицо является фотографией
  • NOT_COMPUTED не удалось произвести вычисления

Инициализация модуля:

val depthLivenessEstimator: DepthLivenessEstimator = service.createDepthLivenessEstimator(           "depth_liveness_estimator_cnn.xml"   )

Определение принадлежности лица реальному человеку:

fun getLivenessState(            rgbPath: String,            depthPath: String    ): DepthLivenessEstimator.Liveness {        val imageColorFile = File(rgbPath + COLOR_IMAGE_FORMAT)        val originalColorByteArray = readImage(imageColorFile)        val originalRawSimple = capturerSingleFace.capture(originalColorByteArray).getOrNull(0)        val originalRawImage = RawImage(                SCREEN_RESOLUTION_WIDTH,                SCREEN_RESOLUTION_HEIGHT,                RawImage.Format.FORMAT_BGR,                originalColorByteArray        )        val originalDepthPtr = Natives().readDepthMap(depthPath + DEPTH_IMAGE_FORMAT)// параметры камеры        val hFov = 69.4f         val vFov = 42.5f         val depthMapRaw = DepthMapRaw()        with(depthMapRaw) {            depth_map_rows = originalRawImage.height            depth_map_cols = originalRawImage.width            depth_map_2_image_offset_x = 0f            depth_map_2_image_offset_y = 0f            depth_map_2_image_scale_x = 1f            depth_map_2_image_scale_y = 1f            horizontal_fov = hFov            vertical_fov = vFov            depth_unit_in_millimeters = 1f            depth_data_ptr = originalDepthPtr            depth_data_stride_in_bytes = (2 * originalRawImage.width)        }        return depthLivenessEstimator.estimateLiveness(originalRawSimple, depthMapRaw)}

Метод getLivenessState() в качестве параметров получает ссылки на изображения: цветное и с картой глубины. Из цветного мы формируем объект RawImage, этот класс предоставляет данные изображения в сыром виде и опциональной информации для обрезки. Из карты глубины формируется DepthMapRaw карта глубины, отрегистрированная в соответствии с исходным цветным изображением. Это необходимо сделать, чтобы использовать метод estimateLiveness(originalRawSimple, depthMapRaw), который вернёт нам enum с информацией реальное ли лицо было в кадре.

Стоит обратить внимание на формирование объекта DepthMapRaw. Одна из переменных имеет наименование depth_data_ptr это указатель на данные глубины, но как известно в Java нет указателей. Для получения указателя надо воспользоваться JNI функцией, которая в качестве аргумента принимает ссылку на изображение с картой глубины:

extern "C" JNIEXPORT jlong JNICALL Java_ru_face_detect_Natives_readDepthMap(JNIEnv *env, jobject obj, jstring jfilename){    const char * buf = env->GetStringUTFChars(jfilename, NULL);    std::string filename = buf;    env->ReleaseStringUTFChars(jfilename, buf);    cv::Mat depth_map = cv::imread(filename, -1);    unsigned char * data = new unsigned char[depth_map.rows * depth_map.cols * depth_map.elemSize()];    memcpy(data, depth_map.data, depth_map.rows * depth_map.cols * depth_map.elemSize());    return (jlong) data;}

Для вызова кода написанного на C в Kotlin, необходимо создать класс такого типа:

class Natives {    init {        System.loadLibrary("native-lib")    }    external fun readDepthMap(fileName: String): Long}

В System.loadLibrary() передаётся наименование файла .cpp, где содержится метод readDepthMap(), в нашем случае это native-lib.cpp. Также необходимо поставить модификатор external, который означает, что метод реализован не в Kotlin.

Идентификация лица


Не менее важная функция определение личности найденного лица в кадре. Face SDK позволяет реализовать это с помощью модуля Recognizer. Инициализация:

val recognizer: Recognizer = service.createRecognizer(        "method8v7_recognizer.xml",        true,        true,        true)

Мы используем конфигурационный файл method8v7_recognizer.xml, который имеет самую высокую скорость распознавания, но при этом качество распознавания ниже, чем у методов 6v7 и 7v7.

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

var templates = Vector<Template>()val rawSample = createRawSample(imageUrl)val template = recognizer.processing(rawSample)templates.add(template)

Для создания Template используется метод recognizer.processing(), в качестве параметра передаётся RawSample. После того, как список с шаблонами лиц сформирован, его необходимо добавить в Recognizer и сохранить полученный TemplatesIndex, который нужен для быстрого поиска в больших базах:

val templatesIndex = recognizer.createIndex(templates, SEARCH_THREAD_COUNT)

На этом этапе, нами был сформирован объект Recognizer, который содержит всю необходимую информацию, чтобы произвести идентификацию:

fun detectFaceSearchResult(rgbPath: String): Recognizer.SearchResult {    val rawSample = createRawSample(rgbPath + COLOR_IMAGE_FORMAT)    val template = recognizer.processing(rawSample)    val searchResult = recognizer.search(            template,            templateIndex,            searchResultCount,            Recognizer.SearchAccelerationType.SEARCH_ACCELERATION_1    ).firstElement()    return searchResult}

Функция recognizer.search() вернёт нам результат, где мы можем получить индекс найденного элемента, сопоставить его со списком лиц из базы данных и идентифицировать персону. Кроме этого, мы можем узнать величину сходства, действительное число от 0 до 1. Данная информация предоставлена в классе Recognizer.MatchResult, переменная scope:

val detectResult = detectFaceSearchResult(rgbPath)// Величина сходства шаблонов - действительное число от 0 до 1.val scoreResult = detectResult.matchResult.score

Заключение


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

В Android SDK, постепенно добавляется API, который позволяет разработчику работать с системой идентификации лиц, однако сейчас всё находится на начальном этапе развития. А если говорить о системе контроля доступа с использованием планшета на Android, библиотеки Face SDK и 3D камеры Intel RealSense, хочется отметить большую гибкость и расширяемость. Нет привязки к устройству, камеру можно подключить к любому современному смартфону. Можно расширить линейку поддерживаемых 3D камер, а также подключить несколько штук к одному устройству. Есть возможность адаптировать написанное приложение под Android Things, и использовать его в своем умном доме. Если посмотреть на возможности библиотеки Face SDK, то с её помощью мы можем добавить идентификацию лиц в непрерывном видеопотоке, определять пол, возраст и эмоции. Эти возможности дают простор для множества экспериментов. А мы на своём опыте можем сказать: не бойтесь экспериментов и бросайте вызов себе!
Подробнее..

Релиз мобильных приложений одной кнопкой

02.07.2020 18:23:23 | Автор: admin


Всем привет! Меня зовут Михаил Булгаков (нет, не родственник), я работаю релиз-инженером в Badoo. Пять лет назад я занялся автоматизацией релизов iOS-приложений, о чём подробно рассказывал в этой статье. А после взялся и за Android-приложения.

Сегодня я подведу некоторые итоги: расскажу, к чему мы пришли за это время. Long story short: любой причастный к процессу сотрудник может зарелизить хоть все наши приложения на обеих платформах в несколько кликов без головной боли, больших затрат времени, регистрации и СМС. Так, наш отдел релиз-инженеров за 2019 год сэкономил около 830 часов.

За подробностями добро пожаловать под кат!

Что стоит за мобильным релизом


Выпуск приложения в Badoo состоит из трёх этапов:

  1. Разработка.
  2. Подготовка витрины в магазине приложений: тексты, картинки всё то, что видит пользователь в App Store или Google Play.
  3. Релиз, которым занимается команда релиз-инжиниринга.

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

Большая часть времени уходит на подготовку витрины приложения в App Store или Google Play: необходимо залить красивые скриншоты, сделать завлекающее описание, оптимизированное для лучшей индексации, выбрать ключевые слова для поиска. От качества этой работы напрямую зависит популярность приложения, то есть по факту результат деятельности разработчиков, тестировщиков, дизайнеров, продакт-менеджеров, маркетологов всех причастных к созданию продукта.

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

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

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



Первые шаги на пути к автоматизации: загрузка метаданных


Как это работало в самом начале: для каждого релиза создавалась таблица в Google Sheets, в которую продакт-менеджер заливал выверенный мастер-текст на английском, после чего переводчики адаптировали его под конкретную страну, диалект и аудиторию, а затем релиз-инженер переносил всю информацию из этой таблицы в App Store или Google Play.

Первый шаг к автоматизации, который мы сделали, интегрировали перевод текстов в наш общий процесс переводов. Останавливаться на этом не буду это отдельная большая система, про которую можно прочитать в нашей недавней статье. Основной смысл в том, что переводчики не тратят время на таблички и работают с интерфейсом для удобной загрузки руками (читай: ctrl+c ctrl+v) переведённых вариантов в стор. Кроме того, присутствуют задатки версионирования и фундамент для Infrastructure-as-Code.

Одновременно с этим мы добавили выгрузку уже готовых переводов из базы данных и внедрение их в собирающийся IPA-файл (расширение файла iOS-приложения). Сборка приложения у нас происходит в TeamCity. Таким образом, каждая версия приложения всегда имела свежий перевод без ручного вмешательства в процесс сборки.

Какое-то время мы жили так, и в целом нас всё устраивало. Но количество приложений увеличивалось, а вместе с ним и время на подготовку каждого релиза.


Наша реальность по состоянию на 2015 год

В среднем на релиз одного приложения при наличии актуальной версии скриншотов уходило около полутора-двух часов работы релиз-инженера в случае с iOS и около получаса в случае с Android. Разница обусловлена тем, что iOS-приложения должны пройти так называемый Processing, который занимает некоторое время (отправить приложение на Review до успешного завершения Processing невозможно). Кроме того, App Store сам по себе по большинству операций в тот момент работал гораздо медленнее, чем Google Play.

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

Скажу о нём несколько слов, чтобы было понятнее, о чём пойдёт речь дальше.

Коротко о Fastlane


Сегодня Fastlane это продукт, который способен практически полностью автоматизировать все действия от момента окончания разработки до релиза приложения в App Store и Google Play. И речь не только о загрузке текстов, скриншотов и самого приложения здесь и управление сертификатами, и бета-тестирование, и подписывание кода, и многое другое.

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

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

Со временем мы внедрили большинство предоставляемых Fastlane возможностей в системы сборки, подписания, заливки и т. д. наших приложений. И несказанно этому рады. Зачем изобретать колесо, да ещё и поддерживать его правильную форму, когда можно один раз написать унифицированный сценарий, который будет сам крутиться в CI/CD-системе?

Автоматизация iOS-релизов


По причине того, что Google Play более дружелюбен к разработчикам, на релиз Android-приложения уходило очень мало времени: без обновления текстов, видео и скриншотов пара минут. Отсюда и отсутствие необходимости в автоматизации. А вот с App Store проблема была очень даже осязаемой: слишком много времени уходило на отправку приложений на Review. Поэтому было решено начать автоматизацию именно с iOS.

Подобие своей системы автоматизации взаимодействия с App Store мы обдумывали (и даже сделали прототипы), но у нас не было ресурсов на допиливание и актуализацию. Также не было никакого мало-мальски адекватного API от Apple. Ну и последний гвоздь в гроб нашего кастомного решения вбили регулярные обновления App Store и его механизмов. В общем, мы решили попробовать Fastlane тогда ещё версии 2015 года.

Первым делом был написан механизм выгрузки переведённых текстов для приложений в нужную структуру как компонент нашей общей внутренней системы AIDA (Automated Interactive Deploy Assistant). Эта система своеобразный хаб, связующее звено между всеми системами, технологиями и компонентами, используемыми в Badoo. Работает она на самописной системе очередей, реализованной на Golang и MySQL. Поддерживает и совершенствует её в основном отдел Release Engineering. Подробнее о ней мы рассказывали в статье ещё в 2013 году, с тех пор многое изменилось. Обещаем рассказать про неё снова AIDA классная!

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

Это сократило время подготовки релиза с пары часов до примерно 30 минут, из которых только полторы минуты надо было что-то делать руками! Остальное время ждать. Ждать окончания Processing. Механизм стал прорывом на тот момент как раз потому, что почти полностью избавил нас от ручной работы при подготовке AppStore к релизу. Под скрипт мы сделали репозиторий, к которому дали доступ людям, имеющим непосредственное отношение к релизам (проджект-менеджерам, релиз-инженерам).

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

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

  1. Нужно было идти в TeamCity за свежей сборкой, скачивать оттуда IPA-файл, загружать его в App Store через Application Manager.
  2. Потом идти в интерфейс с переводами в AIDA, смотреть, готовы ли все переводы, запускать скрипт, убеждаться, что он правильно сработал (всё-таки на тот момент Fastlane был ещё сыроват).
  3. После этого залезать в App Store и обновлять страницу с версией до того момента, пока не завершится Processing.
  4. И только после этого отправлять приложение на Review.

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

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

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


AIDA молодец, будь как AIDA!

Всё это привело к сокращению времени с получаса до полутора минут на всё про всё: IPA-файл успевал пройти Processing ещё до того момента, когда команда QA-инженеров давала отмашку на запуск релиза. Тем не менее нам всё равно приходилось идти в App Store, выбирать нужную версию и отправлять её на Review.

Плюс, был нарисован простенький интерфейс: мы же все любим клац-клац.


Вот так, вкладка за вкладкой, Ctrl+C Ctrl+V...

Автоматизация Android-релизов


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

  1. Заходить в консоль Google Play, чтобы убедиться, что предыдущая версия раскатана на 100% пользователей или заморожена.
  2. Создавать новую версию релиза с обновлёнными текстами и скриншотами (при наличии).
  3. Загружать APK-файл (Android Package), загружать Mapping-файл.
  4. Идти в HockeyApp (использовался в то время для логирования крашей), загружать туда APK-файл и Mapping-файл.
  5. Идти в чат и отписываться о статусе релиза.

И так с каждым приложением.

Да, у Google Play есть свой API. Но зачем делать обёртку, следить за изменениями в протоколе, поддерживать её и плодить сущности без необходимости, если мы уже используем Fastlane для iOS-релизов? К тому же он комфортно существует на нашем сервере, варится в своём соку и вообще обновляется. А к тому времени он ещё и научился адекватно релизить Android-приложения. Звёзды сошлись!

Первым делом мы выпилили отовсюду всё старое, что было: отдельные скрипты, наброски автоматизации, старые обёртки для API это создавалось когда-то в качестве эксперимента и не представляло особой ценности. Сразу после этого мы добавили команду в AIDA, которая уже умела забирать что нужно из TeamCity, загружать что надо куда надо в HockeyApp, отправлять оповещения, логировать активность, и вообще она член команды.

Заливкой APK- и Mapping-файлов в Google Play занимался Fastlane. Надо сказать, что по проторенной тропе идти гораздо проще: реализовано это было достаточно быстро с минимальным количеством усилий.

На определённом этапе реализации автоматизации случился переход с APK-архивов на AAB (Android App Bundle). Опять же, нам повезло, что по горячим следам довольно быстро получилось всё поправить, но и развлечений добавилось в связи с этим переходом. Например, подгадил HockeyApp, который не умел использовать AAB-архивы в связи с подготовкой к самовыпиливанию. Так что для того чтобы комфортно продолжать его использовать, нужно было после сборки AAB разобрать собранный архив, доставать оттуда Mapping-файл, который полетит в HockeyApp, а из AAB нужно было отдельно собрать APK-файл и только потом загружать его в тот же HockeyApp. Звучит весело. При этом сам Google Play отлично раскладывает AAB, достаёт оттуда Mapping-файл и вставляет его куда нужно. Так что мы избавились от одного шага и добавили несколько, но от этого было никуда не деться.

Был написан интерфейс (опять же, по аналогии с iOS), который умел загружать новую версию, проверять релиз вдоль и поперёк, управлять текущим активным релизом (например, повышать rollout percentage). В таком виде мы отдали его ответственным за релизы членам команды Android QA, стали собирать фидбэк, исправлять недочёты, допиливать логику (и что там ещё бывает после релиза 1.0?).

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

Унификация флоу мобильных релизов


К моменту автоматизации Android-релизов Fastlane наконец-то научился отправлять версии iOS-приложений на ревью. А мы немного усовершенствовали систему проверки версий в AIDA.

Пришла пора отдать iOS-релизы на откуп команде QA-инженеров. Для этого мы решили нарисовать красивую формочку, которая бы полностью покрывала потребности, возникающие в процессе релиза iOS-приложений: давала бы возможность выбирать нужный билд в TeamCity по предопределённым параметрам, выбирать вариант загружаемых текстов, обновлять или нет опциональные поля (например, Promotional Text).

Сказано сделано. Формочка получилась очень симпатичная и полностью удовлетворяет все запросы. Более того, с её внедрением появилась возможность выбирать сразу все необходимые приложения со всеми требуемыми параметрами, так что и взаимодействие с интерфейсом свелось к минимуму. AIDA по команде присылает ссылку на build log, по которому можно отслеживать возникающие ошибки, убеждаться, что всё прошло хорошо, получать какую-то debug-информацию вроде версии загружаемого IPA-файла, версии релиза и т. д. Вот так красиво iOS-релизы и были переданы команде iOS QA.


Ну симпатично же?

Идея с формочкой понравилась нам настолько, что мы решили сделать аналогичную и для Android-релизов. Принимая во внимание то, что у нас есть приложение, полностью написанное на React Native, и что команда QA-инженеров этого приложения отвечает как за iOS-, так и за Android-релизы.

Уже используемый командой Android QA интерфейс был интегрирован с изменениями в аналогичную формочку, процесс был адаптирован под новые реалии всё было максимально приближено к процессам iOS-команды. Это дало стимул наконец набросать более-менее конечный вариант документации для обеих платформ (в процессе постоянных изменений делать это категорически не хотелось), а ещё отвязать процесс релиза от всех искусственных ограничений, которые сложились исторически и приводили к лишним телодвижениям в нестандартных ситуациях, требуя действий команды релиз-инженеров.

Вывод


Вот таким нескучным путём на протяжении примерно пяти лет (c момента, когда мы начали развивать мобильное направление, до сегодняшнего дня) мы полностью автоматизировали процессы сборки, тестирования и релиза, сделали их максимально эффективными и передали ответственность за релизы членам команды QA, которые и принимают решение о степени готовности приложения.

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

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

Пять лет. Почему так долго? Во-первых, мобильные релизы далеко не единственная зона ответственности нашей небольшой команды. Во-вторых, конечно же, требовалось время на развитие нового open-source-проекта Fastlane; наша система релизов развивалась вместе с ним.

Мы прошли свой долгий путь в этой области. Возможно, он не самый эффективный, возможно, некоторые грабли можно было предвидеть и обойти. Но было как было. Когда мы его начинали, аналогичных этой статей не было мы сами прокладывали себе дорогу. И если перед вами сейчас стоит подобная задача и эта статья вам чем-то поможет, я буду несказанно рад. Но даже если вы не почерпнули кардинально новой информации, надеюсь, что хотя бы было интересно почитать на досуге. А, возможно, и сопоставить со своим опытом. И если вам есть что сказать на данную тему, добро пожаловать в комментарии!
Подробнее..

Настраиваем GitHub Actions для Android с последующим деплоем в PlayMarket

15.06.2020 12:08:41 | Автор: admin
Привет, Хаброжители! На днях начал изучать GitHub Actions для Android. Ранее у меня была удачная попытка настройки данного функционала для проекта на Flutter, но без деплоя, для которого полно информации и гайдов как на англоязычных ресурсах, так и на русскоязычных, а вот с нативным андроидом не всё так прозаично. И решил записать основные проблемы и их решение.

Этап первый: настройка для автоматической подписи готового apk


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

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

apply plugin: ...def keystorePropertiesFile = rootProject.file("keystore.properties")def keystoreProperties = new Properties()keystoreProperties.load(new FileInputStream(keystorePropertiesFile))android {  ...  signingConfigs {    release {        storeFile file("../MyKey.jks")        storePassword keystoreProperties['RELEASE_STORE_PASSWORD']        keyAlias keystoreProperties['RELEASE_KEY_ALIAS']        keyPassword keystoreProperties['RELEASE_KEY_PASSWORD']    }    debug {        storeFile file('../debug.keystore')    }  }  buildTypes {    release {        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'        minifyEnabled false        signingConfig signingConfigs.release        buildConfigField "String", "PIN_ALIAS", keystoreProperties['PIN_ALIAS']        buildConfigField "String", "DB_PASS_ALIAS", keystoreProperties['DB_PASS_ALIAS']    }    debug {        minifyEnabled false        signingConfig signingConfigs.debug        buildConfigField "String", "PIN_ALIAS", keystoreProperties['PIN_ALIAS']        buildConfigField "String", "DB_PASS_ALIAS", keystoreProperties['DB_PASS_ALIAS']    }  }}dependencies {  ...}

И тут возникла проблема, как сделать так, что бы мы могли взять ключи из ${{ secrets.MY_KEY }} и при этом градл понимал, если у нас есть keystore.properties, то берём из него, если нет то берём из секретов? Решение нашлось на одном из гайдов для флаттера, где для этого они использую окружения (Кстати, здесь классный подход, чтобы не светить нашим ключём разработчика), но проблему это не решило. Перепробовав несколько вариантов с введением дополнительных файлов и т.п., остановился на самом простом: мы вводим дополнительно несколько переменных(в зависимости от нужного нам количества), и проверяем наличие файла keystore.properties:

def release_store_passworddef release_key_passworddef release_key_aliasdef pin_aliasdef db_pass_aliasdef keystoreProperties = new Properties()if (rootProject.file("keystore.properties").exists()) {    keystoreProperties.load(new FileInputStream(rootProject.file("keystore.properties")))    release_store_password = keystoreProperties['RELEASE_STORE_PASSWORD']    release_key_password = keystoreProperties['RELEASE_KEY_PASSWORD']    release_key_alias = keystoreProperties['RELEASE_KEY_ALIAS']    pin_alias = keystoreProperties['PIN_ALIAS']    db_pass_alias = keystoreProperties['DB_PASS_ALIAS']} else {    release_store_password = System.env.RELEASE_STORE_PASSWORD    release_key_password = System.env.RELEASE_KEY_PASSWORD    release_key_alias = System.env.RELEASE_KEY_ALIAS    pin_alias = System.env.PIN_ALIAS    db_pass_alias = System.env.DB_PASS_ALIAS}android {   signingConfigs {        release {            storeFile file("../my_key.jks")            storePassword = release_store_password            keyAlias = release_key_alias            keyPassword = release_key_password        }    buildType{       release {          buildConfigField "String", "PIN_ALIAS", "\"$pin_alias\"" //если вам нужно ввести некоторые           buildConfigField "String", "DB_PASS_ALIAS", "\"$db_pass_alias\"" // дополнительные данны.      }    }}

Итак, теперь наш сборщик умеет собирать и сразу подписывать наш apk.

Этап второй: версия сборки.


Тут нет ничего сверх естественного, хотелось получить какой-то, достаточно универсальный вариант, минимальной сложности. Погуглив, присмотрелся, сколько разработчиков столько и вариантов и каждый извращается как может. Мне какие-то сверх сложные подходы не нужны и я уже хотел было использовать BUILD_NUMBER, но тут я наткнулся на параметр у для GitHub actions: ${{ github.run_number }}.

${{ github.run_number }}
Уникальный номер для каждого запуска определенного рабочего процесса в хранилище. Это число начинается с 1 для первого запуска рабочего процесса и увеличивается с каждым новым запуском. Это число не изменится, если вы повторно запустите рабочий процесс. (Запуском здесь подразумевается когда вы пушите в ветку).

По этому взвесив все за и против имеем следующее решение:

def versionPropsFile = rootProject.file('version.properties')Properties versionProps = new Properties()versionProps.load(new FileInputStream(versionPropsFile))def verCode = versionProps['VERSION_CODE'].toInteger()android {  defaultConfig {    versionCode verCode    versionName "1.1.$verCode"  }}//version.properties файлVERSION_CODE=1

В рабочем процессе делаем так:

- name: Output version code        run: echo VERSION_CODE=${{ github.run_number }} > ./version.properties

Этап третий: развертывание (deploy)


На данный момент я нашел два готовых решения: Gradle Play Publisher и Upload Android release to the Play Store

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

- name: Upload to PlayMarket        uses: r0adkll/upload-google-play@v1        with:          serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}          packageName: com.guralnya.notification_tracker          releaseFile: app/build/outputs/apk/release/notification_tracker.release.apk          track: beta          userFraction: 0.33          whatsNewDirectory: distribution/whatsnew

Но некоторые моменты у меня всё же возникли:

  • serviceAccountJson и serviceAccountJsonPlainText с первым я так и не разобрался в каком виде его нужно положить в секреты, второй же просто берём содержимое файла и кладём в наш секрет.
  • releaseFile использовал самый простой подход, когда мы берём готовый файл из папки с проектом, но вариант со звёздочкой не прокатил: notification_tracker.release.*.apk, где у меня стоит время сборки. Хотя в другом экшене, который у меня используется для загрузки файла (actions/upload-artifact@v2), такой подход работал отлично.
  • whatsNewDirectory внимательнее к языковым кодам. Если английский я взял из гугл-консоли при добавлении новой версии (en-IN), а Русский как (ru-RU), то логично предположить что все языки работают по том же принципу, но нет Украинский я не доглядел, а он там помечен как (uk), потому если не хотите лишний раз комититься и видеть красный крестик, лучше свериться с той же консолью.

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

Итоговый рабочий процесс будет оптимизироваться и улучшаться вместе с файлом градла


Android CI.yaml:

name: Android_CIon:  push:    branches:      - beta_releasejobs:  build:    runs-on: ubuntu-latest    name: Build release-apk and deploy to PlayMarket    steps:      - uses: actions/checkout@v2      - name: set up JDK 1.8        uses: actions/setup-java@v1        with:          java-version: 1.8      # Without NDK not compile and not normal error message. NDK is required      - name: Install NDK        run: echo "y" | sudo ${ANDROID_HOME}/tools/bin/sdkmanager --install "ndk;20.0.5594570" --sdk_root=${ANDROID_SDK_ROOT}      # Some times is have problems with permissions for ./gradle file. Then uncommit it code      #    - name: Make gradlew executable      #      run: chmod +x ./gradlew      - name: Output version code        run: echo VERSION_CODE=${{ github.run_number }} > ./version.properties      - name: Build with Gradle        run: ./gradlew assemble        env:          RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}          RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}          RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}          PIN_ALIAS: ${{ secrets.PIN_ALIAS }}          DB_PASS_ALIAS: ${{ secrets.DB_PASS_ALIAS }}      - name: Upload APK        uses: actions/upload-artifact@v2        with:          name: notification_tracker          path: app/build/outputs/apk/release/notification_tracker.release.apk      - name: Upload to PlayMarket        uses: r0adkll/upload-google-play@v1        with:          serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}          packageName: com.guralnya.notification_tracker          releaseFile: app/build/outputs/apk/release/notification_tracker.release.apk          track: beta          userFraction: 0.33          whatsNewDirectory: distribution/whatsnew

Важный момент


необходимость NDK. Без установленного NDK у вас не соберётся проект, по крайней мере релизный. Можно долго гадать в чём проблема и искать решение, так как нормального сообщения ошибки нет. Иногда можно отловить вот это: Task :app:stripDebugDebugSymbols FAILED. После гуглинга и экспериментов, оказалось что нет NDK. Делаем так:

- name: Install NDK        run: echo "y" | sudo ${ANDROID_HOME}/tools/bin/sdkmanager --install "ndk;20.0.5594570" --sdk_root=${ANDROID_SDK_ROOT}

P.S. Для Gradle использовал подсветку кода от Kotlin. Для YAML от JSON. Конечно немного не то, но лучше мне найти не удалось, если есть лучшие варианты, сообщите мне пожалуйста и я исправлю.

P.S.S. Может быть у кого есть лучшее решение или предложения по улучшения, напишите их в комментариях, так как по первому этапу вопрос провисел на StackOverflow больше 10-ти дней, но ответа так и не последовало.
Подробнее..

Android-разработка Карьерный обзор за май 2020

16.06.2020 18:07:02 | Автор: admin


Мы с HR-командой подготовили исследование российского рынка вакансий и попросили поделиться наблюдениями наших преподавателей: Антона Казакова, руководящего направлением Android-разработки в Альфа-Банке, и Дениса Журавлева, Android Team Lead в Mediapark.



За май 2020 в Москве появилось 230 новых вакансий по Android-разработке


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

Всего по России на конец мая было открыто 844 вакансии по Android-разработке с требованием от 1 до 3 лет опыта, из них 230 вакансий в Москве.



Специалистам предлагают совокупный доход от 120 тыс. рублей. На графике изображено распределение зарплат, которые предлагают работодатели в вакансиях, в зависимости от уровня должности.



В требованиях в вакансиях чаще всего указывают следующие ключевые навыки:

Знание Java и Android SDK
Опыт разработки клиент-серверных и многопоточных приложений
Знание классических алгоритмов и структур данных.
Знание принципов ООП, SOLID, шаблонов проектирования
Опыт разработки приложений под Android
Знание HTTP/S, REST API, XML, JSON
Знание Google Play Services (GCM, GM, GA, FIREBASE)
Владение технологиями веб-сервисов
Знание Git, Gradle
Дает конкурентное преимущество знание Kotlin

С другой стороны, специальность уже давно нельзя назвать редкой. Мы нашли 8 835 соискателей с подходящим опытом в резюме. 67% из них указывают зарплатные ожидания в диапазоне 100 300 тыс. рублей. Помимо hard-скиллов работодатели смотрят и на навык командной разработки, поэтому опыт работы в индустриальных проектах зачастую помогает выделиться среди конкурентов.

Антон Казаков, руководитель направления Android-разработки в Альфа-Банке, руководитель онлайн-курса Android-разработчик. Продвинутый уровень.

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

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

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




Денис Журавлев, Android Team Lead в Mediapark, руководитель онлайн-курса Android-разработчик. Базовый уровень.

Часто задают вопрос, можно ли поменять специальность и стать разработчиком в N лет. Я считаю, что возраст никак не влияет на возможность стать разработчиком. Например, недавно к нам в компанию на джуниор-позицию устроился специалист, который начал свою карьеру разработчика в 35 лет. И мы были очень довольны его результатами, и ему нравилась новая сфера.



Как OTUS помогает получить конкурентное преимущество?


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

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

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

Продвинутый курс Android-разработки рассчитан на разработчиков с опытом разработки от 1 года. В течение курса мы пройдем полный цикл разработки приложения, начиная с проектирования многомодульной архитектуры и заканчивая написанием CI/CD пайплайна для сборки и публикации. Многие темы занятий совпадают с базовым курсом, но рассматриваются глубже. Например, рассмотрим как Dagger2 генерирует код, как Gradle собирает проекты, и что можно сделать чтобы это не было таким мучительным процессом.

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


Андроид разработка стремительно развивается появляются новые фреймворки, новые подходы к разработке. Первый запуск курса состоялся в июне 2019 года. За это время программа курса несколько раз корректировалась с учетом новых тенденций в разработке, а также пожеланий студентов. Так, в пятый запуск, который состоится 29 июня мы добавили несколько новых тем: Android Internals, Single Activity Application и MotionLayout.


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


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

Если у вас появились вопросы по карьерным перспективам в области Android-разработки или по курсам, их можно задать на ближайших бесплатных уроках:


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

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

Хочу в IT. Рекомендации на тему того как научиться чему-то новому

26.06.2020 02:23:27 | Автор: admin

image


Довольно часто на разных ресурсах вроде Stack Overflow я вижу вопросы в стиле: Как стать разработчиком?, Какой язык программирования лучше учить?, А не поздно ли в IT после 30. И хотя в интернете уже вероятно огромное количество подобных статей, я все же хочу поделиться своей историей и советами, которые как мне кажется, могут помочь ребятам которые хотят в своей жизни попробовать что-то новое, но по разным причинам боятся.


Вкратце расскажу о себе, 3 года назад я понятия не имел, что такое программирование и что это за черная магия за которую готовы создавать такие условия для разработчиков. Я был обычном рабочим в автомастерской. Мне повезло и я познакомился с парнишкой который предложил мне научиться. Я имел упаковку комплексов: Это не мое, Я уже слишком стар для того чтоб войти в IT (мне тогда было 28), Для этого надо было заканчивать универ и т.д. в том же духе. Сейчас я Android разработчик в Tinkoff, участвую в разработке основного приложения банка. В свободное время помогаю некоторым ребятам учиться и в целом доволен своим выбором.


Наверное в любой сфере есть куча страшных слов которыми пугают все вокруг: Сопромат в инженерном деле, Каллоидная Химия, Сольфеджо в музыке, Матанализ и т.д. Так же и в программировании есть Алгоритмы и структуры данных, какие-то Паттерны проектирования от которых человека незначащего бросает в пот, а еще в добавок все эти мифы о том, что для того чтоб программировать надо знать чуть ли не высшую математику. Короче все эти вещи дико отпугивают людей когда они начинают читать форумы. А еще есть целая гора людей которые это все приправят тем, что вот тебе надо прочесть вот эти 20 томов по 1000 листов нудной теории, рассказы про то что надо уметь писать код чуть ли не в блокноте. В общем большое количество страшных историй которые отбивают все желание начинать.


После такого может показаться что для того чтоб начать кодить вообще почти ничего не надо. Нет конечно, надо, и надо очень много. Да действительно хорошему программисту надо знать и паттерны и структуры данных и алгоритмы, а в некоторых сферах таких как Machine Learning еще и очень плотно дружить с математикой. Безусловно придется прочесть тысячи страниц, безусловно придется перечитать сотни статей и просмотреть не меньше всяких лекций и видео. Но для того чтоб начать программировать ничего этого не нужно знать, а то что нужно знать придет со временем, со временем придет и понимание. Если уж быть честным, чтоб найти свою первую работу достаточно знать о нескольких структурах данных и то общие знания, знать что есть такая штука как алгоритмы и где их подсмотреть, а из математики достаточно понимать что такое переменные и может быть функции. В остальном программирование это как конструктор. Когда у тебя есть некое заранее определенное количество деталей которые можно соединить с другими деталями, а если нужной тебе детали нет, то можно скомбинировать несколько уже существующих во что-то нужное тебе и создать программу. Никакой магии и седых волос.


Наверное самое сложное это начать. Это действительно так. Когда ты сидишь 3 дня пишешь программу которая только и делает то, что выводит на черном экране количество счастливых билетов в одной партии из 999999 штук, а потом показываешь друзьям, а тебе в ответ: И чо?, это конечно дизморалит. Но если этот шаг переступить, а он обычно длится несколько месяцев, в зависимости от того насколько интенсивно вы учитесь (мне понадобилось месяца 2 примерно по 2 часа в день). Но когда ты можешь впервые запустить свою программу на реальном устройстве и добавить в реальный интерфейс что-то, пощупать это, создать что-то хотя бы малость интерактивное, то в этот момент начинаешь получать некое удовольствие. И с каждым разом открываешь для себя все более и более интересные штуки. А в какой-то момент и вообще можешь создать свое, хоть и маленькое, но полноценное приложение, которое обращается в сеть за реальными данными. Вот тут и понимаешь, что это очень круто, а еще за это и платят неплохие деньги.


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


Какой язык программирования учить?


image
Это частый вопрос. И ответ тут прост. Язык это инструмент, не больше не меньше. Учите тот язык который вам по душе, то чем хотите заниматься. Интересно машинное обучение учите Python. Хотите писать крутые мобильные приложения Swift, Java/Kotlin. Интересна Web разработка JavaScript. И т.д. Определитесь, чем именно в принципе вы хотите заниматься. Языки очень многим похожи друг на друга, да и принципы программирования не меняются уже лет 60. Да есть языки с более низким порогом вхождения, есть с более высоким, но разница тут не велика если задуматься. Вы потратите на 2 месяца меньше на изучения более простого языка, но потом поймете, что в целом хотели бы заниматься другими вещами. Стоит ли оно того? Думаю нет. Учите то, что вам ближе.


Ментор.


image
Ментор этот тот человек который тебе скажет, что действительно важно, что можно отложить на потом. Я не раз видел ребят которые покупали себе курсы за большие деньги учились там делать вещи которые в реальной разработке мало где используются, а если и используются, то это что-то специфическое и в целом в определенный момент своего развития выучить не проблема. Ментор это тот человек который тебе искренне скажет: Димон, ну что за говно ты написал?. И объяснит почему так делать не надо. Я очень много времени сижу на StackOverflow и очень часто вижу когда ребята городят такие костыли и велосипеды просто по той причине, что никто не может им подсказать. Ментор реально сокращает время. Мне повезло, я его нашел изначально, но прекрасно понимаю, что не всем так везет. Но я знаю и тех кто его находил. Вот тут есть несколько советов:


  • Ходите на митапы которые хоть как-то связаны с вашей отраслью. Просто на все, что есть у вас в городе и вы можете посетить. Если вы живете в Москве, то тут вообще бесплатно можно ходить по 2-4 раза в месяц. Знакомьтесь там со всеми. Задавайте вопросы. Не бойтесь показаться глупым, не бойтесь спрашивать. IT-шники в большинстве своем (а особенно те кто посещают подобные мероприятия) люди дружелюбные и очень любят делиться знаниями. Худший вопрос это незаданный вопрос. Так вы примелькаетесь там, вас запомнят, а может и кто-то что-то интересное предложит. Я вот так нашел одного своего ученика.


  • Ищите группы в соцсетях, телеграмм каналы, любые тематические движухи. Задавайте там ЛЮБЕ вопросы. Просите кого-то смотреть ваш код, но без фанатизма, люди часто готовы помочь, но смотреть 2000 строк кода и пытаться там разобраться мало кому захочется, даже вашему другу сенсею=) Не исключено, что там вы сможете с кем-то познакомиться, а если и не найдете персонального ментора, то просто всегда можно спросить что-то, чего не понимаешь. Это дико экономит время и мотивацию. А еще там можно узнать о всех событиях которые происходят в вашем городе. К примеру именно там я узнал об Android Academy MSK, которая мне дала мне очень большой буст знаний и полезные знакомства.



Пишите код.


Постоянно. Не надо себе ставить цель через 3 месяца обучения написать что-то большое и полноценное. У вас не получится. Почему? Да просто потому, что сегодня вы напишете какой-то код, а через 2 месяца, вам будет противно на него смотреть и не будет никакого желания к нему возвращаться. Это нормально. Говнокод пишут ВСЕ. Разница только в том, что кто-то говнокод пишет только в начале своего пути, а кто-то и спустя годы работы. Если вы открыли свой минипроект который написали 2 месяца назад и видите как там все ужасно радуйтесь, это значит, что вы стали лучше. Хуже когда вы смотрите на свой старый код и не видите разницы между ним и текущим. Второй момент который надо понимать, что почти все приложения в PlayMarket/AppStore (а так же любые другие сферы) пишет скорее всего не один, а порой и десятки разработчиков в течении многих месяцев. Так что пишите маленькие проекты. На примере мобильной разработки приложение в котором есть 3-5 экранов более чем достаточное. Пишите, бросайте, начинайте новый, изучайте новые инструменты, снова бросайте.


Что делать если долго не можешь что-то понять?


image
Это ситуации более чем нормальная. На реально работе очень часто бывают ситуации когда не можешь решить какую-то проблему 8 часов. Потом идешь домой, ложишься спать. Приходишь утром на работу и за пол часа решаешь эту проблему. Программирование это не та сфера где количество решенных задач пропорционально количеству часов написания кода. Тут надо мыслить. И очень часто нужно просто отдохнуть. Когда я учился моему сыну было меньше года. И вот часто были ситуации когда ты сидишь, пробуешь понять как работает тот или иной инструмент и у тебя ничего не получается, потом слышишь как просыпается ребенок, идешь к нему. Проводишь с ним 10 минут пока он уснет и в эти 10 минут внезапно понимаешь: Да вот же оно решение!!!. Бывали вещи которые я не мог понять месяц или больше. В таких ситуациях нужно уметь переключаться. Отвлекитесь на другую тему, попробовать решить более простые проблемы или вообще пойти посмотреть сериальчик. Я в процессе своего обучения (1.5 года) раза 3 делал перерывы на 1-2 недели когда я тупо смотрел сериалы. Это нормально.


Как понять что уже готов идти на собеседования?


image
Открываете вакансии на HeadHunter или в любом другом месте. Смотрите минимальный набор требований. Если вы имеет представления о чем там написано (именно имеет представление, а не знаете) то можно начинать слать резюме. Естественно вы получите отказ, а потом еще, а потом еще 2 десятка. Но взамен вы получите несколько крайне полезных штук:


  • Умение чувствовать себя уверенно на собеседовании. Когда я пришел на первый собес, у меня потели руки и я не мог ответить даже на те вопросы на которые уверенно знал ответ. Когда я шел на 20+ собес (примерно после 25ого я получил 2 оффера сразу), я вообще не парился в плане своего морального состояния.


  • Вы выучиваете самые частые вопросы. Штука в том, что информация лучше всего запоминается тогда когда тебя из-за ее незнания натычут в нее носом. Проверено много раз. К тому же есть много крутых ребят которые на собесе дают фидбек по своим вопросам и объясняют те вещи на которые ты не смог ответить, а некоторые еще и шлют в письме с отказом темы которые надо подтянуть. Единственное правило этого всего: Вышел с собеса, отошел на 10 метров от здания и сразу же записал все вопросы на которые не смог ответить. Потом нашел на них ответ. Почему сразу? Потому, что через 10 минут вы половины вопросов не вспомните. А как я уже писал выше, после того как в какие-то вещи натыкали носом они запоминаются на порядок лучше.


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


  • Я уже писал отдельную статью: Почему нужно ходить на собеседования. Там еще много чего полезного.



Как искать работу? Несколько советов:


  • Отправляйте резюме на ВСЕ вакансии вашей области. Не важно какой там требуется опыт, не важно какие требования. Я к примеру слал свое резюме на все вакансии где было слово Android. Большинство приглашений на собеседования приходит с вакансий где требуются люди с опытом (по итогу я и получил свои 2 оффера по вакансиям с требованиями 1-3 года опыта). Во-первых люди часто готовы рассмотреть людей которые чего-то не умеют, но имеют большой потенциал. Во-вторых требуемый опыт это некий фильтр, чтоб отфильтровать неуверенных в себе ребят.


  • Заведите себе профиль LinkedIn. Заполните его полностью и попросите знакомых дать фидбек. Добавьте себе в контакты 500+ друзей из вашей сферы (HR-ы, разработчики и т.д.). LinkedIn реально работает, особенно когда вы будете уже крепким джуном. Я вот ничего не делаю сейчас и мне в среднем 2 предложения в неделю о работе прилетает. Хотя на старте он и не даст большого профита, но это очень мощный инструмент.


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


  • Научитесь говорит правду на собесах. Если чего-то не знаете скажите прямо: Я плохо в этой тебе разбираюсь, это намного более ценно чем пытаться выкрутиться там где вы профан. Если человек это спросил вероятно он разбирается в этой теме и поймет уровень ваших знаний. В резюме пишите только то, за что реально готовы ответить (но никто не мешает написать, что с каким-то инструментом ознакамливался). Потому, что по каждой строчке в вашем резюме вам могут задать вопрос, и вранье это очень большой минус вам. У меня был случай когда я пришел на собес и меня спросили: А вы знаете чем мы занимаемся?, а я отправлял резюме без разбору всем и смотрел более подробно только те вакансии по которым меня звали на собеседование. И вот тут вышло так, что я забыл глянуть, чем же эти ребята занимаются. Я пытался было как-то выкрутиться, но HR поняла и спросила: Лишь бы куда-то взяли?, на что я ответил: Ну да, я же джун и ищу первую работу. Мы посмеялись, а спустя несколько дней они мне прислали оффер. Так что все люди и все все понимают и совершенно нормально в чем-то не разбираться, а вот вранье не любит никто.


  • Неважно какая будет работа. Вот вообще не имеет значение. Конечно круто получить первый оффер от компании вроде Mail.ru или HeadHunter, но такого скорее всего не будет. Поэтому как только дают оффер берите. В IT есть некий порог, это 1 год. После этого ваши возможности вырастают в разы. Это конечно совершенно не означает, что год можно сидеть на попе ровно и ничего не делать. Но это тот самый порог на котором большинство кандидатов HR-ами отсеивается даже не читая резюме. Я пришел в первую компанию где кроме меня был 1 Android разработчик, который ко всему прочему вообще не хотел никак мне помогать в обучении. А спустя несколько месяцев я и вовсе понял, что и учиться то у него мне нечему. Но весь этот год нужно продолжать поглощать знания и получать опыт. Я поставил себе цель, что второй работой у меня будет серьезная IT компания. Так и случилось и во многом мне помогло то что я к тому времени прошел порядка 25+ собесов.



Хотел немного поделиться мыслями, но что-то длинная получилась статья. Какой вывод этого всего? Хотите начать программировать начинайте. Никому не интересен ваш возраст (хотя в 65 найти работу джуном наверное будет сложно, но в 30 точно нет). Никому не интересно ваще образование вы либо можете решать проблемы либо нет. В первом случае вас берут на работу хоть вы будете из села в драных штанах, во втором случае вас не возьмут даже если у вас будет 3 образования. Ставьте себе цель и идите к ней. Многие крутые компании готовы взять и научить человека с потенциалом.


Здесь я накидаю полезные по моему мнению ресурсы в сфере Android.


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


Android Academy MSK статья на тему того как прошла Android Academy в москве. В конце можно найти ссылки на все лекции которые были во время академии.


Google Codelabs Кодлабы от Google, в основном по Android. Очень полезная штука. Есть много очень интересных уроков и понятных уроков.


Mosdroid Telegramm Channel телеграм канал. Очень позитивный коллектив. Ребята постоянно проводят митапы в Москве, а иногда и Питер.


Материалы и книги для обучения Ответ на Stack Overflow созданные общими усилиями сообщества. Здесь не только Android но и другие языки программирования.


Так же стоит помнить что в Москве и других крупных городах ребята из Tinkoff и Sberbank пару раз в год запускают бесплатные курсы для тех кто хочет войти в IT.

Подробнее..

Из песочницы Редактор кода на Android часть 1

02.07.2020 20:17:59 | Автор: admin

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

Вступление


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

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

Для начала составим список того, что наш редактор должен уметь:

  • Подсвечивать синтаксис
  • Отображать нумерацию строк
  • Показывать варианты автодополнения (расскажу во второй части)
  • Подсвечивать синтаксические ошибки (расскажу во второй части)

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

MVP простой текстовый редактор


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

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

Подсветка синтаксиса


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

Очевидно, чтобы контролировать весь процесс реагировать на ввод, отрисовывать номера строк, нам придется писать CustomView наследуясь от EditText. Накидываем TextWatcher чтобы слушать изменения в тексте и переопределяем метод afterTextChanged, в котором и будем вызывать метод отвечающий за подсветку:

class TextProcessor @JvmOverloads constructor(    context: Context,    attrs: AttributeSet? = null,    defStyleAttr: Int = R.attr.editTextStyle) : EditText(context, attrs, defStyleAttr) {    private val textWatcher = object : TextWatcher {        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}        override fun afterTextChanged(s: Editable?) {            syntaxHighlight()        }    }    private fun syntaxHighlight() {        // Тут будем подсвечивать текст    }}

Вопрос: Почему мы используем TextWatcher как переменную, ведь можно реализовать интерфейс прямо в классе?

Ответ: Так уж получилось, что у TextWatcher есть метод который конфликтует c уже существующим методом у TextView:

// Метод TextWatcherfun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int)// Метод TextViewfun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int)

Оба этих метода имеют одинаковое название и одинаковые аргументы, да и смысл вроде у них тот же, но проблема в том что метод onTextChanged у TextView вызовется вместе с onTextChanged у TextWatcher. Если проставить логи в тело метода, то увидим что onTextChanged вызовется дважды:


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

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

fun processText(newText: String) {    removeTextChangedListener(textWatcher)    // undoStack.clear()    // redoStack.clear()    setText(newText)    addTextChangedListener(textWatcher)}

Но вернёмся к подсветке.

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

Сейчас нам важно знать только две вещи:

  1. Pattern определяет что конкретно нам нужно найти в тексте
  2. Matcher будет пробегать по всему тексту в попытках найти то, что мы указали в Pattern

Может не совсем корректно описал, но принцип работы такой.

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

private val KEYWORDS = Pattern.compile(    "\\b(function|var|this|if|else|break|case|try|catch|while|return|switch)\\b")

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

Далее с помощью Matcher мы пройдёмся по всему тексту и установим спаны:

private fun syntaxHighlight() {    val matcher = KEYWORDS.matcher(text)    matcher.region(0, text.length)    while (matcher.find()) {        text.setSpan(            ForegroundColorSpan(Color.parseColor("#7F0055")),            matcher.start(),            matcher.end(),            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE        )    }}

Поясню: мы получаем объект Matcher у Pattern, и указываем ему область для поиска в символах (Соответственно с 0 по text.length это весь текст). Далее вызов matcher.find() вернёт true если в тексте было найдено совпадение, а с помощью вызовов matcher.start() и matcher.end() мы получим позиции начала и конца совпадения в тексте. Зная эти данные, мы можем использовать метод setSpan для раскраски определённых участков текста.

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

Итак, запускаем!


Результат соответствует ожиданиям ровно до того момента, пока мы не начнём редактировать большой файл (на скриншоте файл в ~1000 строк)

Дело в том что метод setSpan работает медленно, сильно нагружая UI Thread, а учитывая что метод afterTextChanged вызывается после каждого введенного символа, писать код становится одним мучением.

Поиск решения


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

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

Точно! Так и сделаем! Вот только как?

Оптимизация


Хоть я и упомянул что нас заботит только производительность метода setSpan, всё же рекомендую выносить работу RegEx в фоновой поток чтобы добиться максимальной плавности.

Нам нужен класс, который будет в фоне обрабатывать весь текст и возвращать список спанов.
Конкретной реализации приводить не буду, но если кому интересно то я использую AsyncTask работающий на ThreadPoolExecutor. (Да-да, AsyncTask в 2020)

Нам главное, чтобы выполнялась такая логика:

  1. В beforeTextChanged останавливаем Task который парсит текст
  2. В afterTextChanged запускаем Task который парсит текст
  3. По окончанию своей работы, Task должен вернуть список спанов в TextProcessor, который в свою очередь подсветит только видимую часть

И да, спаны тоже будем писать свои собственные:

data class SyntaxHighlightSpan(    private val color: Int,    val start: Int,    val end: Int) : CharacterStyle() {    // можно заморочиться и добавить italic, например, только для комментариев    override fun updateDrawState(textPaint: TextPaint?) {        textPaint?.color = color    }}

Таким образом, код редактора превращается в нечто подобное:

Много кода
class TextProcessor @JvmOverloads constructor(    context: Context,    attrs: AttributeSet? = null,    defStyleAttr: Int = R.attr.editTextStyle) : EditText(context, attrs, defStyleAttr) {    private val textWatcher = object : TextWatcher {        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {            cancelSyntaxHighlighting()        }        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}        override fun afterTextChanged(s: Editable?) {            syntaxHighlight()        }    }    private var syntaxHighlightSpans: List<SyntaxHighlightSpan> = emptyList()    private var javaScriptStyler: JavaScriptStyler? = null    fun processText(newText: String) {        removeTextChangedListener(textWatcher)        // undoStack.clear()        // redoStack.clear()        setText(newText)        addTextChangedListener(textWatcher)        // syntaxHighlight()    }    private fun syntaxHighlight() {        javaScriptStyler = JavaScriptStyler()        javaScriptStyler?.setSpansCallback { spans ->            syntaxHighlightSpans = spans            updateSyntaxHighlighting()        }        javaScriptStyler?.runTask(text.toString())    }    private fun cancelSyntaxHighlighting() {        javaScriptStyler?.cancelTask()    }    private fun updateSyntaxHighlighting() {        // подсветка видимой части будет тут    }}


Т.к конкретной реализации обработки в фоне я не показал, представим что мы написали некий JavaScriptStyler, который в фоне будет делать всё тоже самое что мы делали до этого в UI Thread пробегать по всему тексту в поисках совпадений и заполнять список спанов, а в конце своей работы вернёт результат в setSpansCallback. В этот момент запустится метод updateSyntaxHighlighting, который пройдётся по списку спанов и отобразит только те, что видны в данный момент на экране.

Как понять, какой текст попадает в видимую область?


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

val topVisibleLine = scrollY / lineHeightval bottomVisibleLine = topVisibleLine + height / lineHeight + 1 // height - высота Viewval lineStart = layout.getLineStart(topVisibleLine)val lineEnd = layout.getLineEnd(bottomVisibleLine)

И он работает! Теперь вынесем topVisibleLine и bottomVisibleLine в отдельные методы и добавим пару дополнительных проверок, на случай если что-то пойдёт не так:

Новые методы
private fun getTopVisibleLine(): Int {    if (lineHeight == 0) {        return 0    }    val line = scrollY / lineHeight    if (line < 0) {        return 0    }    return if (line >= lineCount) {        lineCount - 1    } else line}private fun getBottomVisibleLine(): Int {    if (lineHeight == 0) {        return 0    }    val line = getTopVisibleLine() + height / lineHeight + 1    if (line < 0) {        return 0    }    return if (line >= lineCount) {        lineCount - 1    } else line}


Последнее что остаётся сделать пройтись по полученному списку спанов и раскрасить текст:

for (span in syntaxHighlightSpans) {    val isInText = span.start >= 0 && span.end <= text.length    val isValid = span.start <= span.end    val isVisible = span.start in lineStart..lineEnd            || span.start <= lineEnd && span.end >= lineStart    if (isInText && isValid && isVisible)) {        text.setSpan(            span,            if (span.start < lineStart) lineStart else span.start,            if (span.end > lineEnd) lineEnd else span.end,            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE        )    }}

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

Ну что, работает?


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

// Примечание: метод getSpans из библиотеки core-ktxval textSpans = text.getSpans<SyntaxHighlightSpan>(0, text.length)for (span in textSpans) {    text.removeSpan(span)}

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

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {    super.onSizeChanged(w, h, oldw, oldh)    updateSyntaxHighlighting()}

Главное не забыть указать adjustResize в манифесте.

Скроллинг


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

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

Достаточно вызывать метод отвечающий за обновление подсветки:

override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) {    super.onScrollChanged(horiz, vert, oldHoriz, oldVert)    updateSyntaxHighlighting()}

Нумерация строк


Если мы добавим в разметку ещё один TextView то будет проблематично их между собой связать (например, синхронно обновлять размер текста), да и если у нас большой файл то придется полностью обновлять текст с номерами после каждой введенной буквы, что не очень круто. Поэтому будем использовать стандартные средства любой CustomView рисование на Canvas в onDraw, это и быстро, и не сложно.

Для начала определим что будем рисовать:

  • Номера строк
  • Вертикальную линию, отделяющую поле ввода от номеров строк

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

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

Обновление отступа
private var gutterWidth = 0private var gutterDigitCount = 0private var gutterMargin = 4.dpToPx() // отступ от разделителя в пикселях...private fun updateGutter() {    var count = 3    var widestNumber = 0    var widestWidth = 0f    gutterDigitCount = lineCount.toString().length    for (i in 0..9) {        val width = paint.measureText(i.toString())        if (width > widestWidth) {            widestNumber = i            widestWidth = width        }    }    if (gutterDigitCount >= count) {        count = gutterDigitCount    }    val builder = StringBuilder()    for (i in 0 until count) {        builder.append(widestNumber.toString())    }    gutterWidth = paint.measureText(builder.toString()).toInt()    gutterWidth += gutterMargin    if (paddingLeft != gutterWidth + gutterMargin) {        setPadding(gutterWidth + gutterMargin, gutterMargin, paddingRight, 0)    }}


Пояснение:

Для начала мы узнаем кол-во строк в EditText (не путать с кол-вом "\n" в тексте), и берем кол-во символов от этого числа. Например, если у нас 100 строк, то переменная gutterDigitCount будет равна 3, потому что в числе 100 ровно 3 символа. Но допустим, у нас всего 1 строка а значит отступ в 1 символ будет визуально казаться маленьким, и для этого мы используем переменную count, чтобы задать минимально отображаемый отступ в 3 символа, даже если у нас меньше 100 строк кода.

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

Далее вычисляем и устанавливаем отступ на основе имеющихся widestNumber и widestWidth.

Приступим к рисованию


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

setHorizontallyScrolling(true)

Ну а теперь можно приступать к рисованию, объявим переменные с типом Paint:

private val gutterTextPaint = Paint() // Нумерация строкprivate val gutterDividerPaint = Paint() // Отделяющая линия

Где-нибудь в init блоке установим цвет текста и цвет разделителя. Важно помнить, что если вы поменяйте шрифт текста, то шрифт Paint'а придется применять вручную, для этого советую переопределить метод setTypeface. Аналогично и с размером текста.

После чего переопределяем метод onDraw:

override fun onDraw(canvas: Canvas?) {    updateGutter()    super.onDraw(canvas)    var topVisibleLine = getTopVisibleLine()    val bottomVisibleLine = getBottomVisibleLine()    val textRight = (gutterWidth - gutterMargin / 2) + scrollX    while (topVisibleLine <= bottomVisibleLine) {        canvas?.drawText(            (topVisibleLine + 1).toString(),            textRight.toFloat(),            (layout.getLineBaseline(topVisibleLine) + paddingTop).toFloat(),            gutterTextPaint        )        topVisibleLine++    }    canvas?.drawLine(        (gutterWidth + scrollX).toFloat(),        scrollY.toFloat(),        (gutterWidth + scrollX).toFloat(),        (scrollY + height).toFloat(),        gutterDividerPaint    )}

Смотрим на результат




Выглядит круто.

Что же мы сделали в методе onDraw? Перед вызовом super-метода мы обновили отступ, после чего отрисовали номера только в видимой области, ну и под конец провели вертикальную линию, визуально отделяющую нумерацию строк от редактора кода.

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

Заключение


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

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

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

Спасибо!
Подробнее..

Категории

Последние комментарии

© 2006-2020, personeltest.ru