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

Modules

Создание модуля на фреймворке Htmlix

23.09.2020 12:10:47 | Автор: admin
В данной статье будет описаны базовые принципы создания модулей на javascript фреймворке htmlix.

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

Модули в htmlix полностью автономны и не требуют добавления в основной (core) участок кода. Поэтому их можно создать на отдельном js скрипте и подключить когда это потребуется или не подключать вовсе. Информацию об изменении состояния приложения и новые данные они получают на основе пользовательских событий см. Работа с пользовательскими событиями, поэтому их легко добавить или удалить не меняя основной код приложения. Можно также выключить прослушивание данных событий из самого модуля, а потом снова включить.

Далее будет рассмотрен модуль addDrawCirclePane приложения Collage_n который рисует круги на канвас с помощью кликов мыши. Перед рисованием модуль принимает два параметра цвет и диаметр круга с помощью свойств с типом inputvalue. Далее после нажатия кнопки рисовать вызывает emiter событие emiter-operation-with со значением draw-circle чтобы включить активное состояние модуля, и выключить другие модули приложения.

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

(function(){//разметка модуля// создаем контейнер для модуля data-draw_circle_panel="container"//все используемые в js поля и кнопки обозначены именами (для удобства)// name="draw_circle_btn", name="draw_sircle_radius", name="draw_sircle_color"  var html = `   <div data-draw_circle_panel="container"  class="form-group" name="draw_circle_panel"><label for="exampleFormControlInput1" style="font-size: 15px;">                       Рисовать окружность        </label><div class="form-row"><div class="form-group col-md-4">                     <button type="button" name="draw_circle_btn" class="btn btn-success btn-sm">                            Рисовать                      </button></div><div class="form-group col-md-4">   <input name="draw_sircle_radius" type="text" class="form-control form-control-sm"></div><div class="form-group col-md-4">     <input name="draw_sircle_color" type="text" class="form-control form-control-sm"></div></div> </div>`  ;  //динамическое добавление разметки модуля в общую разметку приложения.  var div = document.createElement("div");  div.innerHTML = html;  div = div.querySelector("div");  var parent = document.querySelector("[data-main_form]");  var insert_before = document.querySelector("[name='common_btns_class']")  var insertedElement = parent.insertBefore(div, insert_before);    //js код модуля  var draw_circle_panel = {    container: "draw_circle_panel", //контейнер модуля  props: [               ///свойства модуля["draw_circle_btn", "click", "[name='draw_circle_btn']"], ["draw_sircle_radius", "inputvalue", "[name='draw_sircle_radius']"],["draw_sircle_color", "inputvalue", "[name='draw_sircle_color']"], ///два свойства-события основного core приложения: клики по канвас и событие смены операции["canvas_click", "emiter-mousedown-canvas", ""], ["operation_with", "emiter-operation-with", ""],  ],  methods: { //отключает слушателей canvas событий ( mousedown) если модуль находится в пассивном состоянии  operation_with: function(){    if(this.emiter.prop != "draw-circle"){     this.parent.props.canvas_click.disableEvent();    }else{    this.parent.props.canvas_click.enableEvent();    }    },//при нажатии на кнопку рисовать - вызывает событие "emiter-operation-with" и устанавливает свойство prop = "draw-circle" чтобы другие модули отключили прослушивание событий и скрыли ненужные кнопки.  draw_circle_btn: function(){  this.$$("emiter-operation-with").set("draw-circle");       },//слушает событие приложения  "emiter-mousedown-canvas" и в активном состоянии рисует круги при кликах мышью. canvas_click: function(){if(this.$$("emiter-operation-with").prop == "draw-circle"){//данные из свойств модуля  var props = this.parent.props;  var radius = props.draw_sircle_radius.getProp();  var color = props.draw_sircle_color.getProp();                       var point = this.emiter.prop;//данные из события основного (core) приложения с координатами точки на канвас             saveStep(saveImg, this.$props().commonProps.area_1);  //обычная функции из глобальной области для сохранения шагов, редактирования ctx.save();            ctx.putImageData(saveImg, 0, 0);ctx.beginPath();ctx.arc(point[0], point[1], radius, 0, 2*Math.PI, false);ctx.fillStyle =  color;ctx.fill();ctx.lineWidth = 1;ctx.strokeStyle =  color;ctx.stroke();                               //переменная из глобальной области для сохранения картинки после рисования saveImg = ctx.getImageData(0,0, srcWidth, srcHeight);ctx.restore();}}    }    }//добавляем описание модуля в общее описание приложения  HM.description.draw_circle_panel  = draw_circle_panel;//создаем контейнер модуля  HM.containerInit(div , HM.description, "draw_circle_panel");  HM.eventProps["emiter-operation-with"].emit(); //вызываем чтобы отключить слушателей canvas событий при старте модуля})()

В примере выше мы подключили контейнер с помощью функции: HM.containerInit(htmlLink, HM.description, module_name);
где HM ссылка на экземпляр приложения.

Для подключения массива нужно использовать функцию HM.arrayInit(htmlLink, HM.description, module_name);

В редакторе Collage_n модули подключаются в панели Загрузить модуль, изменить настройки.

Это был краткий обзор основных принципов создания и подключения htmlix модулей.
Подробнее..

Namespaces в JavaScript (часть II, заключительная)

17.11.2020 22:21:17 | Автор: admin

В своей прошлой статье я прикидывал, какие namespace'ы мне нужны для упорядочивания кода в ES6-модулях. В этой статье я описываю, какие namespace'ы у меня получились и как их использовать при порождении объектов и разрешении зависимостей (dependency injection).

Для чего нужны namespace'ы

Для тех, кто не видит важность namespace'ов, могу привести в пример почтовые адреса:

  • страна / область / город / улица / дом / квартира

или адресацию в файловой системе:

  • /var/lib/dpkg/status

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

Дуальный характер JavaScript'а

Отличие JavaScript'а от остальных языков программирования в том, что JS-программы исполняются в двух очень различных средах:

  • браузер: с ориентацией преимущественно на сетевые ресурсы;

  • сервер: с ориентацией преимущественно на файловые ресурсы;

Один и тот же ES6-модуль может быть адресован в абсолютной нотации таким образом (браузер и сервер):

http://demo.com/node_modules/@vendor/package/src/module.mjs/home/alex/demo/node_modules/@vendor/package/src/module.mjs

Текущая проблема адресации ES6-модулей

В nodejs-приложениях относительная адресация элементов кода идёт относительно каталога ./node_modules/:

import ModuleDefault from '@vendor/module/src/Module.mjs';

а в браузерных приложениях такая адресация приводит к ошибке:

Uncaught (in promise) TypeError: Failed to resolve module specifier "@vendor/module/src/Module.mjs". Relative references must start with either "/", "./", or "../".

Если же изменить адрес на ./@vendor/module/src/Module.mjs, как того требует браузерная среда, то в nodejs-приложениях получим ошибку:

internal/process/esm_loader.js:74    internalBinding('errors').triggerUncaughtException(                              ^Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/.../@vendor/module/src/Module.mjs' imported from /.../test.mjs    at finalizeResolution (internal/modules/esm/resolve.js:276:11)    at moduleResolve (internal/modules/esm/resolve.js:699:10)

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

Подробнее

Т.е., если у нас есть модуль ClassA.mjs в пакете packageA:

export default class ClassA {}

И есть модуль ClassB.mjs в пакете packageB, в котором мы импортируем содержимое из модуля packageA/ClassA.mjs:

import ClassA from 'packageA/ClassA.mjs'export default class ClassB {}

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

import ClassA from './packageA/ClassA.mjs'export default class ClassB {}

в nodejs-приложении.

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

import ClassA from '/packageA/ClassA.mjs'export default class ClassB {}

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

Адресация ES6-модуля на основе путей

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

@vendor/package1/src/module1.js

к виду:

@vendor/package1/module1

У этого преобразования есть существенный недостаток - адрес содержит разделитель "/", который не может присутствовать (без экранирования) в идентификаторах JS-кода (именах переменных, функций, классов):

const obj = {name, version, Path/Like/Id}

Независимая адресация ES6-модуля

В PHP namespace'ы появились с версии 5.3, а до этого программисты использовали подход, применяемый, например, в Zend1 (old style):

class My_Long_NestedComponent_ClassName {}

Этот подход неплохо зарекомендовал себя в PHP (до появления встроенных в язык namespace'ов), поэтому его вполне можно перенести на JS, используя при именовании пространств символы, разрешённые для идентификаторов JS-кода:

const obj = {name, version, Zend_Like_Id}

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

Имя простого npm-пакета (package) не может содержать разделителей (/), максимум, что можно - поместить пакет в scope (@vendor/package). Если в процессе разработки наш пакет разросся и мы захотим разделить его на несколько пакетов, то нам придётся переделывать адреса для всех элементов кода:

package/module1/region/area/path/to/module // old 'package'package_module1/region/area/path/to/module // new 'package_module1'package_module1_region/area/path/to/module // new 'package_module1_region'

Адресация в стиле Zend1 лишена этого недостатка. Нам нужно подставить правила маппинга для соответствующих узлов идентификатора:

Package => ./node_modules/packagePackage_Module1 => ./node_modules/package_mod1Package_Module1_Region => ./node_modules/package_mod1_reg

после чего путь к искомому модулю определяется по наибольшей длине имеющегося маппинга:

Package_Module2_Mod => ./node_modules/package/Module2/Mod.jsPackage_Module1_Sub_Mod => ./node_modules/package_mod1/Sub/Mod.jsPackage_Module1_Region_Area => ./node_modules/package_mod1_reg/Area.js

В общем, решение, проверенное временем, и гораздо лучше адресации на основе путей.

Адресация кода внутри ES6-модуля

ES6-модуль предоставляет доступ к своему содержимому через инструкцию export. Допустим, что у нас есть пространство Ns_App_Plugin c маппингом на путь ./node_modules/@vendor/app_plugin/src, в котором определён ES6-модуль ./Shared/Util.mjs с таким экспортом:

export function fn() {};export class Clazz {};export default {fn, Clazz};

Используя namespace с независимой от пути адресацией, можно адресовать сам модуль как:

Ns_App_Plugin_Shared_Util

а используя символ "#" (по аналогии с подобным символом в URL), можно адресовать соответствующий экспорт внутри модуля:

Ns_App_Plugin_Shared_Util#fnNs_App_Plugin_Shared_Util#ClazzNs_App_Plugin_Shared_Util#default

Символ "#" так же, как и символ "/", не может использоваться в идентификаторах JS-кода без экранирования. Вместо него можно было бы использовать символ "$", но я его приберёг для другого (об этом чуть ниже).

Default export

В Java каждый класс располагается в отдельном java-файле. В PHP можно в одном php-файле можно разместить несколько классов, но, как правило, придерживаются аналогичного подхода: один класс - один файл.

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

export default function Ns_App_Plugin_Shared_Util() {}

Если придерживаться этого правила - "один файл - один экспорт", то у нас пропадает необходимость в использовании символа "#". В зависимости от контекста адрес Ns_App_Plugin_Shared_Util может означать как ES6-модуль, так и default экспорт из этого модуля.

Порождение объектов

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

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

const cfg1 = container.get('dbConfig');const cfg2 = container.get('dbConfig');const trn1 = container.get('dbTransaction$$');const trn2 = container.get('dbTransaction$$');cfg === cfg2;   // truetrn1 === trn2;   // false

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

const singleton = container.get('dbConfig');const newInstance = container.get('dbTransaction$$');const module = container.get('Ns_App_Plugin_Shared_Util');const defExport = container.get('Ns_App_Plugin_Shared_Util#');const defExportSingleton = container.get('Ns_App_Plugin_Shared_Util$');const defExportNewInstance = container.get('Ns_App_Plugin_Shared_Util$$');

Dependency Injection

Пространство имён с независимой от файловой структуры адресацией позволяет отвязать логическую структуру кода от его файлового представления и перейти от импорта файлов к декларации зависимостей.

Чуть больше года назад я уже делал реализацию DI-контейнера и даже опубликовал его описание на Хабре. Но на тот момент я не понимал значения ES6-модуля как элемента JS-кода. Такое понимание пришло только при написании статьи "Javascript: исходный код и его отображение при отладке" (это понимание и есть практическая ценность статьи, о которой интересовался коллега @Zenitchik). Предыдущая реализация моего контейнера позволяла вставлять только одиночные зависимости и зависимости на основе фабрик (вновь порождённые объекты). В текущей реализации контейнер позволяет получать доступ к следующими элементам:

  • singleton, добавленный в контейнер вручную: dbConfig;

  • новый объект, созданный при помощи фабрики, добавленной в контейнер вручную: dbTransaction$$;

  • ES6-модуль: Ns_App_Module;

  • отдельный экспорт: Ns_App_Module#name (именованный) и Ns_App_Module# (default);

  • singleton, созданный из default-экспорта модуля: Ns_App_Module$;

  • singleton, созданный из именованного экспорта модуля: Ns_App_Module#name$;

  • новый объект, созданный при помощи фабрики, полученной из default-экспорта модуля: Ns_App_Module$$;

  • новый объект, созданный при помощи фабрики, полученной из именованного экспорта модуля: Ns_App_Module#name$$;

Таким образом, на данным момент я могу использовать в своих JS-приложениях namespace'ы, аналогичные тем, которые есть в PHP и Java, а типовой модуль моего приложения выглядит примерно так:

export default function Ns_App_Plugin_Fn(spec) {    const singleton = spec.dbConfig;    const newInstance = spec.dbTransaction$$;    const module = spec.Ns_App_Plugin_Shared_Util;    const defExport = spec['Ns_App_Plugin_Shared_Util#'];    const defExportSingleton = spec.Ns_App_Plugin_Shared_Util$;    const defExportNewInstance = spec.Ns_App_Plugin_Shared_Util$$;    // ...}

или так:

export default class Ns_App_Plugin_Class {    constructor(spec) {        const singleton = spec.dbConfig;        // ...    }}

И этот код может использоваться без изменений как в nodejs, так и в браузере.

Недостатки

Наследование

При данном подходе не получается использовать наследование:

export default class Ns_App_Plugin_Mod extends Ns_App_Plugin_Base {}

вместо этого приходится использовать композицию, что для моих задач считаю приемлемым.

Расширение

Не всегда получается загрузить ES6-модули в виде файлов с расширением *.js. Появляется ошибка:

(node:506150) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension./home/alex/.../src/Server.js:2import $path from 'path';^^^^^^

Для стабильности лучше размещать все ES6-модули в файлах с расширением *.mjs. В этом случае загрузчик принудительно использует схему ES6.

Демо

Я подготовил небольшой демо-проект, в котором есть два модуля:

@flancer64/demo_teqfw_di_mod_main@flancer64/demo_teqfw_di_mod_plugin

В каждом из которых по три скрипта:

./src/Front.mjs./src/Server.mjs./src/Shared.mjs

Front-скрипты и server-скрипты используют shared-скрипты, а скрипты main-модуля используют скрипты plugin-модуля.

В README проекта расписаны обе стороны, а здесь даю краткое описание взаимодействия элементов со стороны фронта.

Загрузка контейнера, его настройка, получение из контейнера объекта для запуска на фронте:

<script type="module">    const baseUrl = location.href;    // load DI container as ES6 module (w/o namespaces)    import(baseUrl + 'node_modules/@teqfw/di/src/Container.mjs').then(async (modContainer) => {        // init container and setup namespaces mapping        /** @type {TeqFw_Di_Container} */        const container = new modContainer.default();        const pathMain = baseUrl + 'node_modules/@flancer64/demo_teqfw_di_mod_main/src';        const pathPlugin = baseUrl + 'node_modules/@flancer64/demo_teqfw_di_mod_plugin/src';        container.addSourceMapping('Demo_Main', pathMain, true, 'mjs');        container.addSourceMapping('Demo_Main_Plugin', pathPlugin, true, 'mjs');        // get main front as singleton        /** @type {Demo_Main_Front} */        const frontMain = await container.get('Demo_Main_Front$');        frontMain.out('#main', '#plugin');    });</script>

Код основного фронт-объекта с подтягиванием зависимостей в конструкторе через spec объект:

// frontend code cannot use traditional npm packages but can use browser APIexport default class Demo_Main_Front {    /** @type {Demo_Main_Shared} */    singleMainShared    /** @type {Demo_Main_Plugin_Front} */    instPluginFront    constructor(spec) {        // get default export as singleton (class)        this.singleMainShared = spec.Demo_Main_Shared$;        // get default export as new instance (class)        this.instPluginFront = spec.Demo_Main_Plugin_Front$$;    }    //...}

Код shared-объекта из plugin-пакета:

// shared code cannot use traditional npm packages and cannot use browser APIexport default function Demo_Main_Plugin_Shared(param) {    return {pluginShared: param};}

Резюме

Вот он - @teqfw/di

Подробнее..

Еще раз про многомодульность Android-приложений

30.09.2020 10:08:50 | Автор: admin

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


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


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


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


Когда начали выделять первые модули, у нас использовалась только Java, а значит, не было полезного модификатора доступа internal. Каждый модуль делился на два: api + impl. Это позволяло более явно обозначить контракт, доступный внешним пользователям. Об этой первоначальной концепции более подробно написано в статье Евгения Мацюка.


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


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


Основные причины для использования многомодульности


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


Во-первых, скорость сборки. Мобильные приложения перестают быть тонким клиентом, решающим одну конкретную проблему пользователя. Вместо этого наблюдается тенденция к превращению в супераппы. Если при этом оставить весь код в одном модуле, а все зависимости в одном AppComponent, время сборки (включая работу KAPT) будет улетать в космос. Также не будем забывать, что многомодульность не единственный выход, и стоит периодически обновлять версии Gradle и Android Gradle Plugin, включая различные оптимизационные конфиги, а также обновлять железо.


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


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


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


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


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


Контракты и структура модулей


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


  1. App-модуль остатки монолита, который связывает в себе все модули и который имеет зависимости на Feature-модули.


  2. Feature-модуль модуль, содержащий конкретную фичу, изолированную от остальных в соответствии с бизнес-логикой. В общем случае он включает в себя все слои вашей архитектуры, но может быть и вырожденным, без какой-то части слоев (например, когда это чисто UI-фича либо, наоборот, фича без UI). Feature-модуль может иметь зависимости только на API других Feature-модулей либо на Core-модули.
    Зависимость одного Feature-модуля от API другого может быть достаточно спорной, когда API и имплементация фичи объединены в одном модуле. Разработчики могут забывать использовать internal для всех классов, не входящих в API, и они могут утечь в другие Feature-модули. С другой стороны, частое изменение имплементации этой фичи будет вести к пересборке всех зависимых модулей. Для решения этих проблем можно подумать о разделении API и Impl фичи, от которой зависят другие, в два модуля.
    Пример такого разделения будет ниже.


  3. Core-модуль модуль, содержащий вспомогательный код, необходимый для нескольких Feature-модулей. Это может быть логгер или полезные обертки над используемыми библиотеками, или иные утилиты. Core-модули ни от кого не зависят. Но есть исключение: module-injector.


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



Второе отличие склейка API и Impl в Feature-модулях, если они не используются другими Feature-модулями и нет проблемы регулярно утекающих деталей имплементации. Это стало возможным благодаря internal-видимости в Kotlin.


Также бывает полезно иметь в приложении отдельные Example-модули для фич, которые по сути являются App-модулями. Они также предоставляют в себе зависимости (обычно моки) для конкретной фичи и позволяют разрабатывать ее изолированно, без необходимости пересобирать всё приложение. Это сильно экономит время и нервы при разработке.


Схематично эту структуру можно изобразить так:


Module-Injector


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


interface ComponentHolder<C : BaseAPI, D : BaseDependencies> {     fun init(dependencies: D)     fun get(): C    fun reset() } interface BaseDependenciesinterface BaseAPI

Данный модуль не содержит ничего больше. В каждом Feature-модуле нужно имплементировать эти интерфейсы. При этом все остальное содержимое (кроме классов, используемых в интерфейсе) можно пометить модификатором internal, так как про них остальные модули знать не должны.


Для иллюстрации работы этого приема я не стал придумывать что-то новое, а просто взял пример приложения из статьи Жени, форкнул, перевел на Kotlin (как же без этого в 2020-м) и применил module-injector. Теперь оно выглядит так. В этом же репозитории по коммитам можно восстановить весь путь трансформации.


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


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


Здесь можно увидеть пример фичи покупок, про API которой должны знать два других Feature-модуля. Поэтому фича разбита на два Feature-модуля: :feature_purchase_api и :feature_purchase_impl. API-модуль не знает ни про какие другие модули, кроме :module-injector. Фичи сканера и антивора знают только про API-модуль.


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


Теперь предлагаю посмотреть на имплементацию одного из ComponentHolder-ов:


object PurchaseComponentHolder : ComponentHolder<PurchaseFeatureApi, PurchaseFeatureDependencies> {    private var purchaseComponentHolder: PurchaseComponent? = null    override fun init(dependencies: PurchaseFeatureDependencies) {        if (purchaseComponentHolder == null) {            synchronized(PurchaseComponentHolder::class.java) {                if (purchaseComponentHolder == null) {                    purchaseComponentHolder = PurchaseComponent.initAndGet(dependencies)                }            }        }    }    override fun get(): PurchaseFeatureApi {        checkNotNull(purchaseComponentHolder) { "PurchaseComponent was not initialized!" }        return purchaseComponentHolder!!    }    override fun reset() {        purchaseComponentHolder = null    }}

Этот объект состоит из четырех частей:


  • Нулабельная переменная для хранения компонента.
  • Функция init(), которая принимает на вход интерфейс с зависимостями этого модуля и инициализирует переменную с компонентом.
  • Функция get(), которую остальные модули могут использовать после инициализации для доступа к реализации API модуля.
  • Функция reset(), которая зануляет компонент, когда он больше не нужен.

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


Код классов PurchaseFeatureApi и PurchaseFeatureDependencies достаточно простой, и его можно посмотреть в репозитории.


Внутри модуля ComponentHolder может включать в себя непосредственно Dagger-компонент:


@Component(dependencies = [PurchaseFeatureDependencies::class], modules = [PurchaseModule::class])@PerFeatureinternal abstract class PurchaseComponent : PurchaseFeatureApi {    companion object {        fun initAndGet(purchaseFeatureDependencies: PurchaseFeatureDependencies): PurchaseComponent {            return DaggerPurchaseComponent.builder()                    .purchaseFeatureDependencies(purchaseFeatureDependencies)                    .build()        }    }}

Здесь нам важна только функция initAndGet(), которую использует ComponentHolder. Еще раз отмечу, что Dagger тут только для примера. Вместо него может быть любой DI-фреймворк или вообще ручное предоставление зависимостей для несложных модулей.


Наконец, модули склеиваются внутри app следующим образом:


@Moduleclass AppModule {    @Singleton    @Provides    fun provideScannerFeatureDependencies(featurePurchase: PurchaseFeatureApi): ScannerFeatureDependencies {        return object : ScannerFeatureDependencies {            override fun dbClient(): DbClient = CoreDbComponent.get().dbClient()            override fun httpClient(): HttpClient = CoreNetworkComponent.get().httpClient()            override fun someUtils(): SomeUtils = CoreUtilsComponent.get().someUtils()            override fun purchaseInteractor(): PurchaseInteractor = featurePurchase.purchaseInteractor()        }    }    // Тут не должно быть скоупа - об этом дальше    @Provides    fun provideFeatureScanner(dependencies: ScannerFeatureDependencies): ScannerFeatureApi {        ScannerFeatureComponentHolder.init(dependencies)        return ScannerFeatureComponentHolder.get()    }    ...}

Здесь первая функция provideScannerFeatureDependencies() используется для заполнения интерфейса ScannerFeatureDependencies, который применяется внутри второй функции provideFeatureScanner() для инициализации ComponentHolder-а фичи.


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


Осталось рассмотреть более детально, как обнуляются компоненты.


Как было сказано выше, в ComponentHolder каждого модуля есть функция reset(), которая зануляет ссылку на компонент внутри него. Если время жизни модуля должно быть связано с UI, можно вызвать reset() внутри соответствующей функции условного Lifecycle Observer-а (который может быть также презентером или непосредственно Activity, как в нашем примере):


public override fun onPause() {   super.onPause()           ...   if (isFinishing) {       AntitheftFeatureComponentHolder.reset()   }}

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


Подробнее про утечку
// get() в Feature-модуле:object PurchaseComponentHolder : ComponentHolder<PurchaseFeatureApi, PurchaseFeatureDependencies> {    private var purchaseComponentHolder: PurchaseComponent? = null...    override fun get(): PurchaseFeatureApi {        checkNotNull(purchaseComponentHolder) { "PurchaseComponent was not initialized!" }        return purchaseComponentHolder!!    }    override fun reset() {        purchaseComponentHolder = null    }// get() в app-модуле:// если тут поставить @Singleton или другой скоуп и не использовать Provider, то ссылка, возвращаемая в get() закешируется Dagger-ом@Provides   fun provideFeatureScanner(dependencies: ScannerFeatureDependencies): ScannerFeatureApi {       ScannerFeatureComponentHolder.init(dependencies)       return ScannerFeatureComponentHolder.get()   }}

Поэтому важно, чтобы DI-фреймворк в app-модуле каждый раз пытался проинициализировать и получить ссылку на компонент вместо ее кеширования (например, при использовании Singleton в Dagger). Функция init() проверяет, был ли инициализирован компонент, поэтому до вызова reset() будет возвращаться одна и та же ссылка.


Второе важное условие для корректного освобождения ссылки на API-компонент использование Provider<T> в месте инжекта:


class GlobalNavigator @Inject constructor(       // применяем Provider с последующим вызовом get() в месте использования для проверки актуальности ссылки на компонент              private val featureScanner: Provider<ScannerFeatureApi>,       private val featureAntitheft: Provider<AntitheftFeatureApi>,       private val context: Context) : Navigator {   ...   featureScanner.get().scannerStarter().start(context) // использование компонента   ...}

Склейка UI


На этом этапе мы научились формировать модули, выделять их API и зависимости, а также склеивать их в единое приложение. И если с вынесением обычных классов в API все должно быть понятно, то остается нетронутым вопрос навигации между UI-компонентами, такими как Activity, фрагменты, или отдельные View.


Для старта Activity можно вынести в API интерфейс такого типа:


interface AntitheftStarter {    fun start(context: Context)}

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


internal class AntitheftStarterImpl @Inject constructor() : AntitheftStarter {    override fun start(context: Context) {        val intent = Intent(context, AntitheftActivity::class.java)        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)        context.startActivity(intent)    }}

В случае с фрагментами можно либо передать внутрь модуля FragmentManager и уже внутри создать и запустить нужный фрагмент, либо вынести в API функцию, возвращающую готовый фрагмент. Такой подход позволит использовать любой инструмент для навигации: будь то Cicerone, Navigation Component либо ручная работа с FragmentManager.


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


Лайфхаки по работе с модулями


Core-ui


Мы повторно используем некоторые общие UI-компоненты между приложениями, а также общий набор стилей и тем, соответствующих UI-гайдлайнам компании. Для этого задействуем внутреннюю библиотеку UIKit. В проекте эта тема может кастомизироваться, а затем применяется на уровне Application.


Мы заметили, что с выделением модулей стали дублироваться файлы theme.xml, в которых кастомизируется та самая корневая тема (например, для Example-модулей, чтобы выглядеть, как основное приложение). Поэтому решили выделить core-ui модуль с темами, стилями, цветами, которые относятся ко всему проекту. Он подключает UIKit как транзитивную зависимость (api вместо implementation) и подключается ко всем Feature-модулям, содержащим UI. Сам по себе этот модуль вообще не содержит кода.


Core-strings


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


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


Core-native


Для шаринга общего кода между платформами в компании мы используем нативные C++-библиотеки. Для работы с ними нужны JNI-обертки, которые вызываются из Java-кода. Те уже собираются и линкуются вместе с нативными библиотеками, приходящими в составе антивирусного SDK. Весь этот код достаточно редко изменяется, но при это занимает значительное время при сборке, особенно если не ограничить локально ABI. Поэтому сейчас мы планируем вынести эти компоненты и тонкие обертки над ними в отдельный модуль, который будет редко пересобираться и позволит ускорить время сборки.




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


Хочу также поблагодарить за помощь в создании статьи Евгения Мацюка, Мансура Бирюкова и Павла Сидякина.

Подробнее..

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Dependency Holder

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

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

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

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

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

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

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

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

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

Что не так с Activity?

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

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

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

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

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

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

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

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

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

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

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

Заключение

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

Пример рабочего приложения с использованием этого подхода можно посмотреть здесь: https://github.com/PavelSidyakin/WeatherForecast/tree/refactortomultimodule_structure

Выражаю благодарность за ревью статьи, ценные замечания и просто информацию к размышлению: Михаилу Емельянову, Евгению Мацюку, Андрею Берюхову, Тимуру Алмаметову, Мансуру Бирюкову, Степану Гончарову, Александру Блинову, Сергею Боиштяну.

Подробнее..

Модуляризация iOS-приложения Badoo борьба с последствиями

21.01.2021 20:07:17 | Автор: admin

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

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

В этой статье я расскажу:

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

  • как спасли CI от чрезмерной нагрузки;

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

  • мониторинг каких показателей стоит предусмотреть и почему это необходимо.

Сложный граф зависимостей

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

Так выглядел граф зависимостей Badoo к моменту, когда у нас было около 50 модулей:

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

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

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

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

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

Основные характеристики утилиты:

  • это консольное Swift-приложение;

  • работает с xcodeproj-файлами с помощью фреймворка XcodeProj;

  • понимает сторонние зависимости (мы не очень активно и охотно принимаем их в проект, но некоторые всё же используем; загружаются и собираются они через Carthage);

  • включена в процессы непрерывной интеграции;

  • знает о требованиях к нашему графу зависимостей и работает в соответствии с ними.

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

  • статическая или динамическая линковка;

  • инструменты поддержки сторонних зависимостей (Carthage, CocoaPods, Swift Package Manager);

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

  • и другие.

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

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

  1. Doctor. Команда проверяет, все ли зависимости корректно связаны и встроены в приложение. После исполнения мы либо получаем список ошибок в графе (например, отсутствие чего-либо в фазе Link with binaries или Embedded frameworks), либо скрипт говорит, что всё хорошо и можно двигаться дальше.

  2. Fix. Развитие команды doctor. Эта команда в автоматическом режиме исправляет проблемы, найденные командой doctor.

  3. Add. Добавляет зависимость между модулями. Пока у вас простое небольшое приложение, добавление зависимости между двумя фреймворками кажется простой задачей. Но когда граф сложный и многоуровневый, а вы работаете с включёнными явными зависимостями, добавление нужных зависимостей становится задачей, которую вы не захотите из раза в раз делать руками. Благодаря команде add разработчики могут просто указать два названия фреймворков (зависимый и зависящий) и все фазы сборки заполнятся необходимыми зависимостями в соответствии с графом.

Впоследствии скрипт создания нового модуля по шаблону также стал частью утилиты deps. Что мы получили в итоге?

  1. Автоматизированную поддержку графа. Мы находим ошибки прямо в pre-commit hook, сохраняя стабильность и правильность графа и давая возможность разработчику в автоматическом режиме эти ошибки исправлять.

  2. Упрощённое редактирование. Добавить новый модуль или связи между модулями можно одной командой, что сильно упрощает их разработку.

Непрерывная интеграция не справлялась

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

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

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

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

Очевидным решением было перестать собирать и проверять всё и всегда. Нужно было, чтобы CI проверял только то, что нужно проверить. Что не сработало:

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

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

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

    1. Те, кто на всякий случай проверяет всё.

    2. Те, кто уверен, что не мог ничего сломать.

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

В итоге мы вернулись к идее автоматизации вычисления изменений, но немного с другой стороны. У нас была утилита deps, которая знала про граф зависимостей и файлы проекта. А Git позволяла получить список изменённых файлов. Мы расширили deps командой affected, с помощью которой можно было получить список затронутых модулей, исходя из изменений, отражаемых системой контроля версий. Ещё более важно то, что она учитывала зависимости между модулями (если от затронутого модуля зависят другие модули, их тоже необходимо проверить, чтобы, например, в случае изменения интерфейса более низкого модуля верхний не перестал собираться).

Пример: изменения в блоках Регистрация и Аналитика на нашей схеме указывают на необходимость проверить также модули Чат, Sign In with Apple, Видеостриминг и само приложение.

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

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

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

  2. Продолжительность CI-проверок перестала линейно зависеть от количества модулей.

  3. Разработчик понимает, что его изменения могут затронуть и где нужно быть осторожным.

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

Ждём завершения Е2Е-тестов

Для приложения Badoo у нас есть более 2000 сквозных (end-to-end) тестов, которые его запускают и проходят по сценариям использования для проверки ожидаемых результатов. Если запустить все эти тесты на одной машине, то прогон всех сценариев займёт около 60 часов. Поэтому на CI все тесты запускаются параллельно насколько это позволяет количество свободных агентов.

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

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

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

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

  1. Нагрузка на CI существенно снизилась. Чтобы не быть голословным, привожу график времени, которое задача на прогон сквозных тестов провела в очереди:

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

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

Медленный запуск приложения

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

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

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

Стоит сказать, что статическая линковка несёт с собой и ряд ограничений:

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

  2. После перехода на статическую линковку нужно хорошенько протестировать приложение на предмет рантайм-падений. Чтобы исправить многие из них, вам просто придётся использовать не самые оптимальные параметры оптимизаций. Например, почти для всех Objective-C-модулей придётся включить флаг -all-load. Отмечу ещё раз, что решение всех этих проблем с вынесенными xcconfigами (про xcconfig в первой части) не было таким мучительным, каким могло бы быть.

Итак, мы побороли две основные проблемы, вынесли ресурсы в отдельные бандлы, поправили конфигурации сборки. В результате:

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

  • размер приложения уменьшился примерно на 30%;

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

Цифры подскажут, куда двигаться дальше

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

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

  • уменьшение нагрузки на CI за счёт фильтрации проверяемых модулей и умных тестов: не попадайтесь в ловушку прямой зависимости продолжительности CI-проверок от количества модулей;

  • статическая линковка: скорее всего, вам придётся перейти на статическое связывание, так как уже к 50-60 модулям регресс в скорости запуска приложения станет заметен не только вам, но и вашим менеджерам.

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

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

Поэтому у нас есть график среднего времени сборки наших приложений на компьютерах разработчиков:

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

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

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

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

Например, видно, что iMac Pro 5K 2017 года выпуска не лучшее железо для сборки Badoo, в то время как MacBook Pro 15 2018 года ещё вполне неплох.

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

Измеряем время сборки

Чтобы получать данные о продолжительности сборки на компьютерах разработчиков, мы создали специальное macOS-приложение Zuck. Оно сидит в статус-баре и следит за всеми xcactivitylog-файлами в DerivedData. xcactivitylog файлы, которые содержат ту же информацию, которую мы видим в билд-логах Xcode в непростом для парсинга формате от Apple. По ним можно понять, когда началась и закончилась сборка отдельного модуля и в какой последовательности они собирались.

В утилите есть white- и black-листы, так что мы отслеживаем только рабочие проекты. Если разработчик скачал демо-проект какой-то библиотеки с GitHub, мы не будем отправлять данные о её сборке куда-либо.

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

P. S. Мы дорабатываем Zuck, чтобы выпустить его в open source.

В целом измерение локального времени сборки даёт важные результаты:

  • мы измеряем влияние изменений на разработчиков;

  • имеем возможность сравнивать чистые и инкрементальные сборки;

  • знаем, что надо улучшить;

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

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

  1. Время запуска приложения. Последние версии Xcode предоставляют эту информацию в разделе Organizer. Метрика быстро укажет на появившиеся проблемы.

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

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

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

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

Заключение

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

  1. Всего сейчас у нас работают 43 iOS-разработчика.

  2. Четыре из них в Core-команде.

  3. Сейчас у нас два основных приложения и N экспериментальных.

  4. Около 2 миллионов строк кода.

  5. Около 78% из них находятся в модулях.

Последняя цифра постепенно растёт: старые фичи и переписанный legacy-код потихоньку перетекают из основного таргета приложений в модули.

В двух статьях я рекламировал вам модуляризацию, но, конечно, у неё есть свои минусы:

  • усложнение процессов: вам придётся решить ряд вопросов в процессах как вашего департамента и рядовых iOS-разработчиков, так и во взаимодействии с другими департаментами: QA, CI, менеджерами продуктов и т. д.;

  • в процессе перехода на модуляризацию вскроются проблемы, которых вы не ждали, всё не предусмотреть: возможно, потребуются дополнительные ресурсы и других команд;

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

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

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

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

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

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

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

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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru