Статьи

Практический параллелизм на Android с HaMeR

В « Понимании параллелизма на Android» Используя HaMeR , мы поговорили об основах инфраструктуры HaMeR ( Handler , Message и Runnable ). Мы рассмотрели его варианты, а также когда и как его использовать.

Сегодня мы создадим простое приложение для изучения изученных концепций. С практическим подходом мы увидим, как применять различные возможности HaMeR в управлении параллелизмом на Android.

  • Android SDK
    Понимание параллелизма на Android с помощью HaMeR
    Жестяная мегали

Давайте приступим к работе и Runnable несколько Runnable и отправим объекты Message в примере приложения. Чтобы сделать его максимально простым, мы рассмотрим только самые интересные части. Все файлы ресурсов и стандартные вызовы активности здесь будут игнорироваться. Поэтому я настоятельно советую вам проверить исходный код примера приложения с его обширными комментариями.

Пример приложения HaMeR

Приложение будет состоять из:

  • Два действия, одно для Runnable другое для Message
  • Два объекта HandlerThread :
    • WorkerThread для приема и обработки вызовов из пользовательского интерфейса
    • CounterThread для приема Message от WorkerThread
  • Некоторые служебные классы (для сохранения объектов во время изменений конфигурации и для макета)

Давайте начнем экспериментировать с Handler.post(Runnable) и его вариациями, которые добавляют runnable в MessageQueue связанный с потоком. Мы создадим действие с именем RunnableActivity , которое будет взаимодействовать с фоновым потоком с именем WorkerThread .

RunnableActivity создает фоновый поток с именем WorkerThread , передавая Handler и WorkerThread.Callback качестве параметров. Действие может WorkerThread для асинхронной загрузки растрового изображения и показа тоста в определенное время. Результаты задач, выполненных рабочим потоком, передаются в RunnableActivity запускаемыми объектами, размещенными в Handler полученном WorkerThread .

В RunnableActivity мы создадим Handler для передачи в WorkerThread . uiHandler будет связан с Looper из потока пользовательского интерфейса, так как он uiHandler из этого потока.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public class RunnableActivity extends Activity {
 
    // Handler that allows communication between
    // the WorkerThread and the Activity
    protected Handler uiHandler;
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        // prepare the UI Handler to send to WorkerThread
        uiHandler = new Handler();
    }
}

WorkerThread — это фоновый поток, в котором мы будем запускать различные виды задач. Он связывается с пользовательским интерфейсом, используя responseHandler и интерфейс обратного вызова, полученный во время его создания. Ссылки, полученные из действий, имеют WeakReference<> , поскольку действие может быть уничтожено, а ссылка потеряна.

Класс предлагает интерфейс, который может быть реализован с помощью пользовательского интерфейса. Он также расширяет HandlerThread , вспомогательный класс, построенный поверх Thread который уже содержит Looper и MessageQueue . Следовательно, он имеет правильную настройку   использовать платформу HaMeR.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class WorkerThread extends HandlerThread {
    /**
     * Interface to facilitate calls on the UI.
     */
    public interface Callback {
        void loadImage(Bitmap image);
        void showToast(String msg);
    }
     
    // This Handler will be responsible only
    // for posting Runnables on this Thread
    private Handler postHandler;
 
    // Handler is received from the MessageActivity and RunnableActivity
    // responsible for receiving Runnable calls that will be processed
    // on the UI.
    private WeakReference<Handler> responseHandler;
     
    // Callback from the UI
    // it is a WeakReference because it can be invalidated
    // during «configuration changes» and other events
    private WeakReference<Callback> callback;
     
    private final String imageAUrl =
            «https://pixabay.com/static/uploads/photo/2016/08/05/18/28/mobile-phone-1572901_960_720.jpg»;
             
    /**
     * The constructor receives a Handler and a Callback from the UI
     * @param responseHandler in charge of posting the Runnable to the UI
     * @param callback works together with the responseHandler
     * allowing calls directly on the UI
     */
    public WorkerThread(Handler responseHandler, Callback callback) {
        super(TAG);
        this.responseHandler = new WeakReference<>(responseHandler);
        this.callback = new WeakReference<>(callback);
    }
}

Нам нужно добавить метод в WorkerThread который будет вызываться действиями, которые подготавливают postHandler для использования. Метод необходимо вызывать только после запуска потока.

1
2
3
4
5
6
7
8
9
public class WorkerThread extends HandlerThread {
    /**
     * Prepare the postHandler.
     * It must be called after the thread has started
     */
    public void prepareHandler() {
        postHandler = new Handler(getLooper());
    }
}

В RunnableActivity мы должны реализовать WorkerThread.Callback и инициализировать поток, чтобы его можно было использовать.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class RunnableActivity extends Activity
        implements WorkerThread.Callback {
         
    // BackgroundThread responsible for downloading the image
    protected WorkerThread workerThread;
     
    /**
     * Initialize the {@link WorkerThread} instance
     * only if it hasn’t been initialized yet.
     */
    public void initWorkerThread(){
        if ( workerThread == null ) {
            workerThread = new WorkerThread(uiHandler, this);
            workerThread.start();
            workerThread.prepareHandler();
        }
    }
     
    /**
     * set the image downloaded on bg thread to the imageView
     */
    @Override
    public void loadImage(Bitmap image) {
        myImage.setImageBitmap(image);
    }
     
    @Override
    public void showToast(final String msg) {
        // to be implemented
    }
}

Метод WorkerThread.downloadWithRunnable( ) загружает растровое изображение и отправляет его в RunnableActivity для отображения в представлении изображения. Он иллюстрирует два основных использования команды Handler.post(Runnable run) :

  • Чтобы разрешить потоку публиковать объект Runnable в MessageQueue, связанном с самим собой, когда вызывается .post() для обработчика, связанного с лупером потока.
  • Чтобы разрешить связь с другими потоками, когда .post() вызывается для обработчика, связанного с Looper другого потока.
  1. Метод WorkerThread.downloadWithRunnable() Runnable в WorkerThread с помощью postHandler , Handler связанного с Looper WorkThread .
  2. Когда исполняемый файл обрабатывается, он загружает Bitmap в WorkerThread .
  3. После загрузки растрового изображения responseHandler , обработчик, связанный с потоком пользовательского интерфейса, используется для публикации запускаемого объекта в RunnableActivity содержащем растровое изображение.
  4. Обрабатываемый объект обрабатывается, а WorkerThread.Callback.loadImage используется для отображения загруженного изображения в ImageView .
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public class WorkerThread extends HandlerThread {
 
    /**
     * post a Runnable to the WorkerThread
     * Download a bitmap and sends the image
     * to the UI {@link RunnableActivity}
     * using the {@link #responseHandler} with
     * help from the {@link #callback}
     */
    public void downloadWithRunnable() {
        // post Runnable to WorkerThread
        postHandler.post(new Runnable() {
            @Override
            public void run() {
                try {
                    // sleeps for 2 seconds to emulate long running operation
                    TimeUnit.SECONDS.sleep(2);
                    // Download image and sends to UI
                    downloadImage(imageAUrl);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
    }
     
    /**
     * Download a bitmap using its url and
     * send to the UI the image downloaded
     */
    private void downloadImage(String urlStr){
        // Create a connection
        HttpURLConnection connection = null;
        try {
            URL url = new URL(urlStr);
            connection = (HttpURLConnection) url.openConnection();
 
            // get the stream from the url
            InputStream in = new BufferedInputStream(connection.getInputStream());
            final Bitmap bitmap = BitmapFactory.decodeStream(in);
            if ( bitmap != null ) {
                // send the bitmap downloaded and a feedback to the UI
                loadImageOnUI( bitmap );
            } else {
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if ( connection != null )
                connection.disconnect();
        }
    }
     
    /**
     * sends a Bitmap to the ui
     * posting a Runnable to the {@link #responseHandler}
     * and using the {@link Callback}
     */
    private void loadImageOnUI(final Bitmap image){
        Log.d(TAG, «loadImageOnUI(«+image+»)»);
        if (checkResponse() ) {
            responseHandler.get().post(
                    new Runnable() {
                        @Override
                        public void run() {
                            callback.get().loadImage(image);
                        }
                    }
            );
        }
    }
     
    // verify if responseHandler is available
    // if not the Activity is passing by some destruction event
    private boolean checkResponse(){
        return responseHandler.get() != null;
    }
}

The WorkerThread.toastAtTime()   планирует задачу, которая будет выполнена в определенное время, выставляя пользователю Toast . Метод иллюстрирует использование Handler.postAtTime() и Activity.runOnUiThread() .

  • Handler.postAtTime(Runnable run, long uptimeMillis) публикует runnable в определенный момент времени.
  • Activity.runOnUiThread(Runnable run) использует обработчик пользовательского интерфейса по умолчанию для публикации runnable в главном потоке.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class WorkerThread extends HandlerThread {
 
    /**
     * show a Toast on the UI.
     * schedules the task considering the current time.
     * It could be scheduled at any time, we’re
     * using 5 seconds to facilitates the debugging
     */
    public void toastAtTime(){
        Log.d(TAG, «toastAtTime(): current — » + Calendar.getInstance().toString());
 
        // seconds to add on current time
        int delaySeconds = 5;
 
        // testing using a real date
        Calendar scheduledDate = Calendar.getInstance();
        // setting a future date considering the delay in seconds define
        // we’re using this approach just to facilitate the testing.
        // it could be done using a user defined date also
        scheduledDate.set(
                scheduledDate.get(Calendar.YEAR),
                scheduledDate.get(Calendar.MONTH),
                scheduledDate.get(Calendar.DAY_OF_MONTH),
                scheduledDate.get(Calendar.HOUR_OF_DAY),
                scheduledDate.get(Calendar.MINUTE),
                scheduledDate.get(Calendar.SECOND) + delaySeconds
        );
        Log.d(TAG, «toastAtTime(): scheduling at — » + scheduledDate.toString());
        long scheduled = calculateUptimeMillis(scheduledDate);
 
        // posting Runnable at specific time
        postHandler.postAtTime(
                new Runnable() {
            @Override
            public void run() {
                if ( callback != null ) {
                    callback.get().showToast(
                            «Toast called using ‘postAtTime()’.»
                    );
                }
            }
        }, scheduled);
    }
 
    /**
     * Calculates the {@link SystemClock#uptimeMillis()} to
     * a given Calendar date.
     */
    private long calculateUptimeMillis(Calendar calendar){
        long time = calendar.getTimeInMillis();
        long currentTime = Calendar.getInstance().getTimeInMillis();
        long diff = time — currentTime;
        return SystemClock.uptimeMillis() + diff;
    }
}
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
public class RunnableActivity extends Activity
        implements WorkerThread.Callback {
    /**
     * Callback from {@link WorkerThread}
     * Uses {@link #runOnUiThread(Runnable)} to illustrate
     * such method
     */
    @Override
    public void showToast(final String msg) {
        Log.d(TAG, «showToast(«+msg+»)»);
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_LONG).show();
            }
        });
    }
}

Далее давайте рассмотрим несколько различных способов использования MessageActivity для отправки и обработки объектов Message . MessageActivity создает экземпляр WorkerThread , передавая Handler в качестве параметра. WorkerThread имеет несколько открытых методов с задачами, которые должны вызываться действием для загрузки растрового изображения, загрузки случайного растрового изображения или демонстрации Toast через некоторое время с задержкой. Результаты всех этих операций отправляются обратно в MessageActivity с использованием объектов Message отправляемых responseHandler .

Как и в RunnableActivity , в MessageActivity нам нужно будет создать и инициализировать WorkerThread отправляющий Handler для получения данных из фонового потока. Однако на этот раз мы не будем реализовывать WorkerThread.Callback ; вместо этого мы получим ответы от WorkerThread исключительно от объектов Message .

Поскольку большая часть MessageActivity и RunnableActivity в основном одинакова, мы сосредоточимся только на подготовке uiHandler , которая будет отправлена ​​в WorkerThread для получения сообщений от него.

Сначала давайте предоставим некоторые int ключи, которые будут использоваться в качестве идентификаторов для объектов Message.

1
2
3
4
5
6
public class MessageActivity extends Activity {
    // Message identifier used on Message.what() field
    public static final int KEY_MSG_IMAGE = 2;
    public static final int KEY_MSG_PROGRESS = 3;
    public static final int KEY_MSG_TOAST = 4;
}

В реализации MessageHandler нам нужно будет расширить Handler и реализовать метод handleMessage(Message) , где будут обрабатываться все сообщения. Обратите внимание, что мы Message.what для идентификации сообщения, и мы также получаем различные виды данных из Message.obj . Давайте быстро рассмотрим наиболее важные свойства Message прежде чем углубляться в код.

  • Message.what : int идентифицирующий Message
  • Message.arg1 : произвольный аргумент int
  • Message.arg2 : произвольный аргумент int
  • Message.obj : Object для хранения различных видов данных
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class MessageActivity extends Activity {
    /**
     * Handler responsible to manage communication
     * from the {@link WorkerThread}.
     * back to the {@link MessageActivity} and handle
     * those Messages
     */
    public class MessageHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                // handle image
                case KEY_MSG_IMAGE:{
                    Bitmap bmp = (Bitmap) msg.obj;
                    myImage.setImageBitmap(bmp);
                    break;
                }
                // handle progressBar calls
                case KEY_MSG_PROGRESS: {
                    if ( (boolean) msg.obj )
                        progressBar.setVisibility(View.VISIBLE);
                    else
                        progressBar.setVisibility(View.GONE);
                    break;
                }
 
                // handle toast sent with a Message delay
                case KEY_MSG_TOAST:{
                    String msgText = (String)msg.obj;
                    Toast.makeText(getApplicationContext(), msgText, Toast.LENGTH_LONG ).show();
                    break;
                }
            }
        }
    }
     
    // Handler that allows communication between
    // the WorkerThread and the Activity
    protected MessageHandler uiHandler;
}

Теперь вернемся к классу WorkerThread . Мы добавим некоторый код для загрузки определенного растрового изображения, а также код для загрузки случайного. Чтобы выполнить эти задачи, мы отправим объекты Message из WorkerThread себе и отправим результаты обратно в MessageActivity используя точно такую ​​же логику, которая применялась ранее для RunnableActivity .

Сначала нам нужно расширить Handler для обработки загруженных сообщений.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class WorkerThread extends HandlerThread {
    // send and processes download Messages on the WorkerThread
    private HandlerMsgImgDownloader handlerMsgImgDownloader;
     
    /**
     * Keys to identify the keys of {@link Message#what}
     * from Messages sent by the {@link #handlerMsgImgDownloader}
     */
    private final int MSG_DOWNLOAD_IMG = 0;
    private final int MSG_DOWNLOAD_RANDOM_IMG = 1;
     
    /**
     * Handler responsible for managing the image download
     * It send and handle Messages identifying then using
     * the {@link Message#what}
     * {@link #MSG_DOWNLOAD_IMG} : single image
     * {@link #MSG_DOWNLOAD_RANDOM_IMG} : random image
     */
    private class HandlerMsgImgDownloader extends Handler {
        private HandlerMsgImgDownloader(Looper looper) {
            super(looper);
        }
 
        @Override
        public void handleMessage(Message msg) {
            showProgressMSG(true);
            switch ( msg.what ) {
                case MSG_DOWNLOAD_IMG: {
                    // receives a single url and downloads it
                    String url = (String) msg.obj;
                    downloadImageMSG(url);
                    break;
                }
                case MSG_DOWNLOAD_RANDOM_IMG: {
                    // receives a String[] with multiple urls
                    // downloads a image randomly
                    String[] urls = (String[]) msg.obj;
                    Random random = new Random();
                    String url = urls[random.nextInt(urls.length)];
                    downloadImageMSG(url);
                }
            }
            showProgressMSG(false);
        }
    }
}

Метод downloadImageMSG(String url) в основном совпадает с методом downloadImage(String url) . Единственное отличие состоит в том, что первый отправляет загруженный растровый рисунок обратно в пользовательский интерфейс, отправляя сообщение с использованием responseHandler .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class WorkerThread extends HandlerThread {
    /**
     * Download a bitmap using its url and
     * display it to the UI.
     * The only difference with {@link #downloadImage(String)}
     * is that it sends the image back to the UI
     * using a Message
     */
    private void downloadImageMSG(String urlStr){
        // Create a connection
        HttpURLConnection connection = null;
        try {
            URL url = new URL(urlStr);
            connection = (HttpURLConnection) url.openConnection();
 
            // get the stream from the url
            InputStream in = new BufferedInputStream(connection.getInputStream());
            final Bitmap bitmap = BitmapFactory.decodeStream(in);
            if ( bitmap != null ) {
                // send the bitmap downloaded and a feedback to the UI
                loadImageOnUIMSG( bitmap );
            }
             
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if ( connection != null )
                connection.disconnect();
        }
    }
}

loadImageOnUIMSG(Bitmap image) отвечает за отправку сообщения с загруженным растровым изображением в MessageActivity .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
    * sends a Bitmap to the ui
    * sending a Message to the {@link #responseHandler}
    */
   private void loadImageOnUIMSG(final Bitmap image){
       if (checkResponse() ) {
           sendMsgToUI(
                   responseHandler.get().obtainMessage(MessageActivity.KEY_MSG_IMAGE, image)
           );
       }
   }
    
   /**
    * Show/Hide progressBar on the UI.
    * It uses the {@link #responseHandler} to
    * send a Message on the UI
    */
   private void showProgressMSG(boolean show){
       Log.d(TAG, «showProgressMSG()»);
       if ( checkResponse() ) {
           sendMsgToUI(
                   responseHandler.get().obtainMessage(MessageActivity.KEY_MSG_PROGRESS, show)
           );
       }
   }

Обратите внимание, что вместо создания объекта Message с нуля, мы используем метод Handler.obtainMessage(int what, Object obj) для извлечения Message из глобального пула, сохраняя некоторые ресурсы. Также важно отметить, что мы вызываем obtainMessage() для responseHandler , получая Message связанное с Looper MessageActivity . Есть два способа получить Message из глобального пула: Message.obtain() и Handler.obtainMessage() .

Единственное, что остается сделать в задаче загрузки образа, — это предоставить методы для отправки Message WorkerThread чтобы начать процесс загрузки. Обратите внимание, что на этот раз мы вызовем Message.obtain(Handler handler, int what, Object obj) для handlerMsgImgDownloader , связывая сообщение с WorkerThread .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
    * sends a Message to the current Thread
    * using the {@link #handlerMsgImgDownloader}
    * to download a single image.
    */
   public void downloadWithMessage(){
       Log.d(TAG, «downloadWithMessage()»);
       showOperationOnUIMSG(«Sending Message…»);
       if ( handlerMsgImgDownloader == null )
           handlerMsgImgDownloader = new HandlerMsgImgDownloader(getLooper());
       Message message = Message.obtain(handlerMsgImgDownloader, MSG_DOWNLOAD_IMG,imageBUrl);
       handlerMsgImgDownloader.sendMessage(message);
   }
 
   /**
    * sends a Message to the current Thread
    * using the {@link #handlerMsgImgDownloader}
    * to download a random image.
    */
   public void downloadRandomWithMessage(){
       Log.d(TAG, «downloadRandomWithMessage()»);
       showOperationOnUIMSG(«Sending Message…»);
       if ( handlerMsgImgDownloader == null )
           handlerMsgImgDownloader = new HandlerMsgImgDownloader(getLooper());
       Message message = Message.obtain(handlerMsgImgDownloader, MSG_DOWNLOAD_RANDOM_IMG, imagesUrls);
       handlerMsgImgDownloader.sendMessage(message);
   }

Другая интересная возможность — отправка объектов Message для последующей обработки командой Message.sendMessageDelayed(Message msg, long timeMillis) .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
 * Show a Toast after a delayed time.
 *
 * send a Message with delayed time on the WorkerThread
 * and sends a new Message to {@link MessageActivity}
 * with a text after the message is processed
 */
public void startMessageDelay(){
    // message delay
    long delay = 5000;
    String msgText = «Hello from WorkerThread!»;
 
    // Handler responsible for sending Message to WorkerThread
    // using Handler.Callback() to avoid the need to extend the Handler class
    Handler handler = new Handler(new Handler.Callback() {
        @Override
        public boolean handleMessage(Message msg) {
            responseHandler.get().sendMessage(
                    responseHandler.get().obtainMessage(MessageActivity.KEY_MSG_TOAST, msg.obj)
            );
            return true;
        }
    });
 
    // sending message
    handler.sendMessageDelayed(
            handler.obtainMessage(0,msgText),
            delay
    );
}

Мы создали Handler специально для отправки отложенного сообщения. Вместо того чтобы расширять класс Handler , мы пошли по пути создания экземпляра Handler с помощью интерфейса Handler.Callback , для которого мы реализовали метод handleMessage(Message msg) для обработки отложенного Message .

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

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

  • Не забывайте учитывать жизненный цикл Android-активности при работе с HaMeR и Threads в целом. В противном случае ваше приложение может завершиться ошибкой, когда поток попытается получить доступ к действиям, которые были уничтожены из-за изменений конфигурации или по другим причинам. Распространенным решением является использование RetainedFragment для хранения потока и заполнения фонового потока ссылкой на действие каждый раз, когда действие уничтожается. Взгляните на решение в финальном проекте на GitHub .
  • Задачи, выполняемые из-за объектов Runnable и Message обрабатываемых в Handlers , не работают асинхронно. Они будут работать синхронно в потоке, связанном с обработчиком. Чтобы сделать его асинхронным, вам нужно создать другой поток, отправить / опубликовать на нем объект Message / Runnable и получить результаты в соответствующее время.

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

До скорого!