Статьи

Обеспечьте стабильное использование памяти вашего Flash-проекта с помощью пула объектов

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


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

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


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


Изображение ниже — действительно отличный пример плохого управления памятью. Это из прототипа игры. Вы должны заметить две важные вещи: большие всплески использования памяти и пик использования памяти. Пик почти на 540Мб! Это означает, что один только этот прототип достиг уровня использования 540 МБ оперативной памяти компьютера пользователя — и этого вы определенно хотите избежать.

Плохое использование памяти

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

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

Хорошее использование памяти

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


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

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

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

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


Откройте FlashDevelop (см. Это руководство ) и создайте новый проект AS3. Мы будем использовать простой маленький цветной квадрат в качестве изображения частицы, которое будет нарисовано с кодом и будет перемещаться в соответствии со случайным углом. Создайте новый класс с именем Particle, расширяющий Sprite. Я предполагаю, что вы можете справиться с созданием частицы и просто выделить аспекты, которые будут отслеживать время жизни частицы и ее удаление с экрана. Вы можете получить полный исходный код этого руководства в верхней части страницы, если у вас возникли проблемы с созданием частицы.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
private var _lifeTime:int;
 
public function update(timePassed:uint):void
{
    // Making the particle move
    x += Math.cos(_angle) * _speed * timePassed / 1000;
    y += Math.sin(_angle) * _speed * timePassed / 1000;
     
    // Small easing to make movement look pretty
    _speed -= 120 * timePassed / 1000;
     
    // Taking care of lifetime and removal
    _lifeTime -= timePassed;
     
    if (_lifeTime <= 0)
    {
        parent.removeChild(this);
    }
}

Код выше — это код, отвечающий за удаление частицы с экрана. Мы создаем переменную с именем _lifeTime будет содержать количество миллисекунд, в течение которых частица будет отображаться на экране. По умолчанию мы инициализируем его значение в конструкторе до 1000. Функция update() вызывается каждый кадр и получает количество миллисекунд, прошедших между кадрами, так что это может уменьшить значение времени жизни частицы. Когда это значение достигает 0 или меньше, частица автоматически просит своего родителя удалить его с экрана. Остальная часть кода заботится о движении частицы.

Теперь мы создадим несколько таких при создании щелчка мышью. Перейти на Main.as:

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
private var _oldTime:uint;
private var _elapsed:uint;
 
private function init(e:Event = null):void
{
    removeEventListener(Event.ADDED_TO_STAGE, init);
    // entry point
    stage.addEventListener(MouseEvent.CLICK, createParticles);
    addEventListener(Event.ENTER_FRAME, updateParticles);
     
    _oldTime = getTimer();
}
 
private function updateParticles(e:Event):void
{
    _elapsed = getTimer() — _oldTime;
    _oldTime += _elapsed;
     
    for (var i:int = 0; i < numChildren; i++)
    {
        if (getChildAt(i) is Particle)
        {
            Particle(getChildAt(i)).update(_elapsed);
        }
    }
}
 
private function createParticles(e:MouseEvent):void
{
    for (var i:int = 0; i < 10; i++)
    {
        addChild(new Particle(stage.mouseX, stage.mouseY));
    }
}

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

1
2
3
import flash.events.Event;
import flash.events.MouseEvent;
import flash.utils.getTimer;

Теперь вы можете протестировать свое приложение и профилировать его с помощью встроенного в FlashDevelop профилировщика. Нажмите несколько раз на экране. Вот как выглядело использование моей памяти:

Необработанное использование памяти

Я нажимал, пока не начал работать сборщик мусора. Приложение создало более 2000 частиц, которые были собраны. Это начинает выглядеть как использование памяти этого прототипа? Похоже, и это определенно не хорошо. Чтобы упростить профилирование, добавим утилиту, упомянутую на первом шаге. Вот код, который нужно добавить в Main.as:

01
02
03
04
05
06
07
08
09
10
11
private function init(e:Event = null):void
{
    removeEventListener(Event.ADDED_TO_STAGE, init);
    // entry point
    stage.addEventListener(MouseEvent.CLICK, createParticles);
    addEventListener(Event.ENTER_FRAME, updateParticles);
     
    addChild(new Stats());
     
    _oldTime = getTimer();
}

Не забудьте импортировать net.hires.debug.Stats и он готов к использованию!


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

Наш первый шаг к хорошему решению — подумать о том, как можно без проблем объединить объекты. В пуле объектов нам всегда нужно убедиться, что созданный объект готов к использованию и что возвращаемый объект полностью «изолирован» от остальной части приложения (т.е. не содержит ссылок на другие объекты). Чтобы заставить каждый объект пула быть в состоянии сделать это, мы собираемся создать интерфейс . Этот интерфейс определит две важные функции, которые должен иметь объект: renew() и destroy() . Таким образом, мы всегда можем вызывать эти методы, не беспокоясь о том, есть ли объект у них (потому что он будет иметь). Это также означает, что каждый объект, который мы хотим объединить, должен будет реализовать этот интерфейс. Итак, вот оно:

01
02
03
04
05
06
07
08
09
10
package
{
    public interface IPoolable
    {
        function get destroyed():Boolean;
         
        function renew():void;
        function destroy():void;
    }
}

Поскольку наши частицы будут объединяться, нам нужно, чтобы они реализовали IPoolable . По сути, мы перемещаем весь код из их конструкторов в функцию renew() и удаляем любые внешние ссылки на объект в функции destroy() . Вот как это должно выглядеть:

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
/* INTERFACE IPoolable */
 
public function get destroyed():Boolean
{
    return _destroyed;
}
 
public function renew():void
{
    if (!_destroyed)
    {
        return;
    }
     
    _destroyed = false;
     
    graphics.beginFill(uint(Math.random() * 0xFFFFFF), 0.5 + (Math.random() * 0.5));
    graphics.drawRect( -1.5, -1.5, 3, 3);
    graphics.endFill();
     
    _angle = Math.random() * Math.PI * 2;
     
    _speed = 150;
     
    _lifeTime = 1000;
}
 
public function destroy():void
{
    if (_destroyed)
    {
        return;
    }
     
    _destroyed = true;
     
    graphics.clear();
}

Конструктор также не должен больше требовать аргументов. Если вы хотите передать какую-либо информацию объекту, вам придется делать это через функции сейчас. В связи с тем, что функция renew() теперь работает, нам также нужно установить _destroyed в true в конструкторе, чтобы эту функцию можно было запустить.

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


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

Для простоты пул объектов будет Singleton . Таким образом, мы можем получить к нему доступ в любом месте нашего кода. Начните с создания нового класса с именем «ObjectPool» и добавления кода, чтобы сделать его Singleton:

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
package
{
    public class ObjectPool
    {
        private static var _instance:ObjectPool;
        private static var _allowInstantiation:Boolean;
         
        public static function get instance():ObjectPool
        {
            if (!_instance)
            {
                _allowInstantiation = true;
                _instance = new ObjectPool();
                _allowInstantiation = false;
            }
             
            return _instance;
        }
         
        public function ObjectPool()
        {
            if (!_allowInstantiation)
            {
                throw new Error(«Trying to instantiate a Singleton!»);
            }
        }
         
    }
 
}

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

Теперь мы должны решить, как держать бассейны внутри этого класса. Поскольку он будет глобальным (т. Е. Может объединять любые объекты в вашем приложении), нам нужно сначала найти способ всегда иметь уникальное имя для каждого пула. Как это сделать? Есть много способов, но лучшее, что я нашел, — это использование собственных имен классов объектов в качестве имени пула. Таким образом, у нас может быть пул «Частиц», пул «Враг» и так далее … но есть другая проблема. Имена классов должны быть уникальными только в их пакетах, поэтому, например, класс «BaseObject» в пакете «враги» и класс «BaseObject» в пакете «структуры» будут разрешены. Это может вызвать проблемы в бассейне.

Идея использования имен классов в качестве идентификаторов для пулов по-прежнему велика, и именно здесь нам помогает flash.utils.getQualifiedClassName() . По сути, эта функция генерирует строку с полным именем класса, включая любые пакеты. Так что теперь мы можем использовать квалифицированное имя класса каждого объекта в качестве идентификатора для их соответствующих пулов! Это то, что мы добавим на следующем шаге.


Теперь, когда у нас есть способ идентифицировать пулы, пришло время добавить код, который их создает. Наш пул объектов должен быть достаточно гибким, чтобы поддерживать как статические, так и динамические пулы (мы говорили об этом на шаге 3, помните?). Нам также нужно иметь возможность хранить размер каждого пула и количество активных объектов в каждом. Хорошим решением для этого является создание частного класса со всей этой информацией и сохранение всех пулов в 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package
{
    public class ObjectPool
    {
        private static var _instance:ObjectPool;
        private static var _allowInstantiation:Boolean;
         
        private var _pools:Object;
         
        public static function get instance():ObjectPool
        {
            if (!_instance)
            {
                _allowInstantiation = true;
                _instance = new ObjectPool();
                _allowInstantiation = false;
            }
             
            return _instance;
        }
         
        public function ObjectPool()
        {
            if (!_allowInstantiation)
            {
                throw new Error(«Trying to instantiate a Singleton!»);
            }
             
            _pools = {};
        }
         
    }
 
}
 
class PoolInfo
{
    public var items:Vector.<IPoolable>;
    public var itemClass:Class;
    public var size:uint;
    public var active:uint;
    public var isDynamic:Boolean;
     
    public function PoolInfo(itemClass:Class, size:uint, isDynamic:Boolean = true)
    {
        this.itemClass = itemClass;
        items = new Vector.<IPoolable>(size, !isDynamic);
        this.size = size;
        this.isDynamic = isDynamic;
        active = 0;
         
        initialize();
    }
     
    private function initialize():void
    {
        for (var i:int = 0; i < size; i++)
        {
            items[i] = new itemClass();
        }
    }
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public function registerPool(objectClass:Class, size:uint = 1, isDynamic:Boolean = true):void
{
    if (!(describeType(objectClass).factory.implementsInterface.(@type == «IPoolable»).length() > 0))
    {
        throw new Error(«Can’t pool something that doesn’t implement IPoolable!»);
        return;
    }
     
    var qualifiedName:String = getQualifiedClassName(objectClass);
     
    if (!_pools[qualifiedName])
    {
        _pools[qualifiedName] = new PoolInfo(objectClass, size, isDynamic);
    }
}

Этот код выглядит немного сложнее, но не паникуйте. Здесь все объяснено. Первое утверждение if выглядит действительно странно. Возможно, вы никогда не видели эти функции раньше, поэтому вот что это делает:

  • Функция описываетType () создает XML, содержащий всю информацию об объекте, который мы передали ему.
  • В случае с классом все в нем содержится внутри тега factory .
  • Внутри этого XML описывает все интерфейсы, которые класс реализует с тегом ImplementsInterface.
  • Мы делаем быстрый поиск, чтобы увидеть, есть ли среди них интерфейс IPoolable . Если это так, то мы знаем, что можем добавить этот класс в пул, потому что мы сможем успешно привести его как IObject .

Код после этой проверки просто создает запись в _pools если она еще не существует. После этого конструктор PoolInfo вызывает функцию initialize() в этом классе, эффективно создавая пул с PoolInfo размером. Теперь он готов к использованию!


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

Вот getObj() :

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
public function getObj(objectClass:Class):IPoolable
{
    var qualifiedName:String = getQualifiedClassName(objectClass);
     
    if (!_pools[qualifiedName])
    {
        throw new Error(«Can’t get an object from a pool that hasn’t been registered!»);
        return;
    }
     
    var returnObj:IPoolable;
     
    if (PoolInfo(_pools[qualifiedName]).active == PoolInfo(_pools[qualifiedName]).size)
    {
        if (PoolInfo(_pools[qualifiedName]).isDynamic)
        {
            returnObj = new objectClass();
             
            PoolInfo(_pools[qualifiedName]).size++;
            PoolInfo(_pools[qualifiedName]).items.push(returnObj);
        }
        else
        {
            return null;
        }
    }
    else
    {
        returnObj = PoolInfo(_pools[qualifiedName]).items[PoolInfo(_pools[qualifiedName]).active];
         
        returnObj.renew();
    }
     
    PoolInfo(_pools[qualifiedName]).active++;
     
    return returnObj;
}

В функции сначала мы проверяем, что пул действительно существует. Предполагая, что это условие выполнено, мы проверяем, является ли пул пустым: если он есть, но он динамический, мы создаем новый объект и добавляем в пул. Если пул не динамический, мы останавливаем код и просто возвращаем ноль. Если в пуле все еще есть объект, мы получаем объект, ближайший к его началу, и вызываем renew() . Это важно: причина, по которой мы вызываем renew() для объекта, который уже был в пуле, состоит в том, чтобы гарантировать, что этот объект будет передан в «пригодном для использования» состоянии.

Вы, вероятно, задаетесь вопросом: почему бы вам также не использовать эту классную проверку с describeType() в этой функции? Что ж, ответ прост: describeType() создает XML каждый раз, когда мы его вызываем, поэтому очень важно избегать создания объектов, которые используют много памяти и которые мы не можем контролировать. Кроме того, достаточно проверить, действительно ли существует пул: если переданный класс не реализует IPoolable , это означает, что мы даже не сможем создать пул для него. Если для этого нет пула, то мы определенно уловим этот случай в нашем операторе if в начале функции.

Теперь мы можем изменить наш класс Main и использовать пул объектов! Проверьте это:

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
private function init(e:Event = null):void
{
    removeEventListener(Event.ADDED_TO_STAGE, init);
    // entry point
    stage.addEventListener(MouseEvent.CLICK, createParticles);
    addEventListener(Event.ENTER_FRAME, updateParticles);
     
    _oldTime = getTimer();
     
    ObjectPool.instance.registerPool(Particle, 200, true);
}
 
private function createParticles(e:MouseEvent):void
{
    var tempParticle:Particle;
     
    for (var i:int = 0; i < 10; i++)
    {
        tempParticle = ObjectPool.instance.getObj(Particle) as Particle;
        tempParticle.x = e.stageX;
        tempParticle.y = e.stageY;
         
        addChild(tempParticle);
    }
}

Хит компилировать и профилировать использование памяти! Вот что я получил:

Очень хорошее использование памяти

Это круто, не правда ли?


Мы успешно реализовали пул объектов, который дает нам объекты. Это восхитительно! Но это еще не конец. Мы по-прежнему только получаем объекты, но никогда не возвращаем их, когда они нам больше не нужны. Время добавить функцию для возврата объектов внутри ObjectPool.as :

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
public function returnObj(obj:IPoolable):void
{
    var qualifiedName:String = getQualifiedClassName(obj);
     
    if (!_pools[qualifiedName])
    {
        throw new Error(«Can’t return an object from a pool that hasn’t been registered!»);
        return;
    }
     
    var objIndex:int = PoolInfo(_pools[qualifiedName]).items.indexOf(obj);
     
    if (objIndex >= 0)
    {
        if (!PoolInfo(_pools[qualifiedName]).isDynamic)
        {
            PoolInfo(_pools[qualifiedName]).items.fixed = false;
        }
         
        PoolInfo(_pools[qualifiedName]).items.splice(objIndex, 1);
         
        obj.destroy();
         
        PoolInfo(_pools[qualifiedName]).items.push(obj);
         
        if (!PoolInfo(_pools[qualifiedName]).isDynamic)
        {
            PoolInfo(_pools[qualifiedName]).items.fixed = true;
        }
         
        PoolInfo(_pools[qualifiedName]).active—;
    }
}

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

Далее мы получаем индекс элемента в пуле. Если его нет в бассейне, мы просто игнорируем это. Как только мы проверим, что объект находится в пуле, мы должны разбить пул в том месте, где объект находится в данный момент, и повторно вставить объект в его конец. И почему? Поскольку мы считаем использованные объекты с начала пула, нам нужно реорганизовать пул, чтобы все возвращенные и неиспользованные объекты были в конце этого пула. И это то, что мы делаем в этой функции.

Для статических пулов объектов мы создаем объект Vector с фиксированной длиной. Из-за этого мы не можем splice() это и push() объекты обратно. Обходной путь к этому состоит в том, чтобы изменить fixed свойство этих Vector на false , удалить объект и добавить его обратно в конец, а затем изменить свойство обратно на true . Нам также нужно уменьшить количество активных объектов. После этого мы закончили возвращать объект.

Теперь, когда мы создали код для возврата объекта, мы можем заставить наши частицы возвращаться в пул, как только они достигнут конца своей жизни. Внутри Particle.as :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
public function update(timePassed:uint):void
{
    // Making the particle move
    x += Math.cos(_angle) * _speed * timePassed / 1000;
    y += Math.sin(_angle) * _speed * timePassed / 1000;
     
    // Small easing to make movement look pretty
    _speed -= 120 * timePassed / 1000;
     
    // Taking care of lifetime and removal
    _lifeTime -= timePassed;
     
    if (_lifeTime <= 0)
    {
        parent.removeChild(this);
         
        ObjectPool.instance.returnObj(this);
    }
}

Обратите внимание, что мы добавили вызов ObjectPool.instance.returnObj() . Вот что заставляет объект возвращаться в пул. Теперь мы можем протестировать и профилировать наше приложение:

Потрясающее использование памяти

И там мы идем! Стабильная память, даже когда были сделаны сотни кликов!


Теперь вы знаете, как создавать и использовать пул объектов для обеспечения стабильного использования памяти вашего приложения. Созданный нами класс можно использовать где угодно, и адаптировать к нему свой код очень просто: в начале вашего приложения создавайте пулы объектов для каждого типа объектов, которые вы хотите объединить, и всякий раз, когда появляется new ключевое слово (означающее создание экземпляра), замените его вызовом функции, которая получает объект для вас. Не забудьте реализовать методы, необходимые для интерфейса IPoolable !

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

Также обратите внимание, что хотя это руководство было предназначено для Flash, концепции, разработанные в нем, носят глобальный характер: его можно использовать в приложениях AIR, мобильных приложениях и везде, где оно подходит. Спасибо за прочтение!