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

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

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

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

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

Не Google решение

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

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

Кажется выбранный DI фреймворк не особо облегчил ситуацию. В приложении также много зависимостей, разделенной логики, дополнительно могут быть различные реализации пересылки событий и сообщений по приложению. Может EventBus еще кто нибудь любит.

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

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

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

Тайник без ключа

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

Hidden text

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

Общаясь с ребятами, как-то неожиданно я для себя понял, что никто почти не задумается о смысле одного из основополагающих инструментов в jvm: о ссылках различных видов. Однажды меня даже прямо спросили, для чего же мы используем кроме Weak ссылки еще и Soft ссылки. Что ж после небольшого рассказа, он даже устроился к нам работать.

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

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

Ну зачем ты гугл, зачем ты так.

Решение на ладони

Добро пожаловать в Америку, ну а кто еще плывет, немного расскажем про работу jvm. Точнее про сборщик мусора.

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

  • Локальные переменные и объекты стека вызова

  • Активные треды

  • Статические переменные

  • Загруженные классы classloader'ом

  • Удерживаемые в jni локальные и глобальные переменные

Самое интересное начинается при использовании специальных ссылок. WeakReference к примеру можно использовать, когда объект особо не нужен, но его не хотелось бы потерять. Он будет хранить ссылку на объект вплоть до самого уничтожения сборщиком мусора. И никак мешать этой сборке мусора не будет. SoftReference позволяет использовать объект вплоть до конца, но не умирать за него. Если у jvm будет стоять вопрос памяти ребром, то ты как самый ответственный программист легко откажешься от любого нужного компонента, дабы не встретиться с "Out of Memory".

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

class ShowCaseViewModel : ViewModel {
    fun loadCatalog(){
        scope.launch { 
            // long request with caching. 
            // request prices
            // Collect data from few interactors
            // merge lists 
            // filter list 
            // sort list 
        }
    }
}

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

Stone, как система

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

Hidden text

Если быть детальнее, то Activity удерживается в ActivityThread и в ApplicationThread.

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

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

class ShowCaseFragment : Fragment() {

    @Inject
    lateinit var viewModel: ShowCaseViewModel 

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        DI.inject(this)
    }

}

Неожиданно, правда. Не часто видишь, что ViewModel предоставляется без ViewProvider. Да, именно так все и должно быть. Нам не нужны больше дополнительные фабрики, провайдеры, менеджеры компонентов. Все потому, что DI это все предоставляет.

@Component
interface AppComponent {

    fun viewModelsModule(): ViewModelsModule

    fun inject(showCaseFragment: ShowCaseFragment)

}

@Module
interface ViewModelsModule {

    @Provide(cache = Provide.CacheType.Weak)
    fun provideShowCaseViewModel(): ShowCaseViewModel {
        return ShowCaseViewModel()
    }

}

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

Сложности применения

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

Первая, и самая важная - потеря состояния. Сразу переехать на новую идею не получится, даже наскоком с надеждой, что все протестируется - нет. Раньше все, что вы делали в компоненте, будем говорить о ViewModel, чудесным образом исчезнет с новым открытием экрана. Вы могли оставить там все что хотели, сейчас нет. Та же ViewModel вам может вернуться заново, и все что вы там повыставляли в переменных, все может сохраниться (а может и нет). Так что будте бдительны, теперь вам надо самостоятельно управлять ЖЦ своих компонентов и не надеятся, что за вас это сделает DI.

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

Сухой остаток

Мне кажется Kotlin Native еще не скоро завоюет рынок Android. Из мобильных операционных систем, предоставляющих нативную разработку сохранилась только одна. Windows Phone повяз в болотах. Отечественные ОС только пробиваются на свет.

У нас еще как минимум 10 лет насладится миром jvm, так давайте насладимся им сполна и не будем страдать постоянными утечками памяти. Имея мощные инструменты jit, резервного выделения памяти, сборщика мусора и возможности переиспользовать память мы на самом деле может поставить на лопатки нашего злого конкурента, так давайте сделаем это.

Заходи ко мне на проект Stone. Оставляй лайки и комментарии, и я продолжу рассказывать про DI, в работе.

Комментарии (21)


  1. navferty
    14.08.2023 21:33
    +1

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

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


    1. BugM
      14.08.2023 21:33
      +3

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


  1. panzerfaust
    14.08.2023 21:33

    Я джавист обычный, под андроид не пишу. Но там вроде уже есть Koin, Kodein, Dagger. На бэкенде есть Spring и Guice. Тут и компайлтайм и райнтайм решения. Что инновационного вы предлагаете?


    1. klee0kai Автор
      14.08.2023 21:33

      Идея библиотеки проста - прежде всего это избавить разработчика от необходимости определять скопы DI, заставлять джависта решать проблемы памяти, будто он использует не джаву, а плюсы.
      Сборщик мусора никогда не уничтожит обьект, если вы его хоть в одном месте явно используете. А значит скоупы жизни компонентов (и ЖЦ) определяются именно тем как они используются в конкретных кейсах и экранах.
      Более того не нужно создавать сложные скоупы для компонентов приложения, которые должны доступны с нескольких экранов, но не должны существовать весь ЖЦ приложения


  1. Sabirman
    14.08.2023 21:33
    +1

    А много ли памяти занимают кэшированные данные ? Так-то томик Войны и Мира - это всего порядка мегабайта.


  1. Rusrst
    14.08.2023 21:33
    -1

    Теперь я знаю кто всю эту муть про gc и ссылки спрашивает...


  1. headliner1985
    14.08.2023 21:33

    Есть такая штука как spring web flow для веб приложений для таких случаев, где в сессии хранятся данные для нескольких страниц. В крайнем случае можно сделать что-то похожее для андроида через кастомный скоуп в spring и хранить данные в памяти/сессии когда это нужно. Ту же сессию можно сохранять в базу, ну либо саму модель данных в базу пихать. Хотя конечно довольно странно получать проблемы с памятью в современном мире.


  1. old_merman
    14.08.2023 21:33

    SoftReference позволяет использовать объект вплоть до конца, но не умирать за него. Если у jvm будет стоять вопрос памяти ребром, то ты как самый ответственный программист легко откажешься от любого нужного компонента, дабы не встретиться с "Out of Memory".

    В Android SDK написано несколько другое:

    Avoid Soft References for Caching

    In practice, soft references are inefficient for caching. The runtime doesn't have enough information on which references to clear and which to keep. Most fatally, it doesn't know what to do when given the choice between clearing a soft reference and growing the heap.

    Из личного опыта: в доисторические времена (году ~в 2015) пробовал в одном, тогда довольно известном, Android проекте делать свой кэш картинок на SoftReference-ах; оказалось, SDK не врёт и живут эти ссылки совсем не так долго, как хотелось бы - так что пришлось от них отказаться, хотя идея выглядела красивой. Но может быть, к 2023 сборщик мусора стал умнее?...


    1. klee0kai Автор
      14.08.2023 21:33

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


      1. klee0kai Автор
        14.08.2023 21:33

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


    1. Rusrst
      14.08.2023 21:33

      del


    1. Rusrst
      14.08.2023 21:33

      А почему не lru cache? Он же тоже доисторический. Вполне можно настроить на работу же.


      1. klee0kai Автор
        14.08.2023 21:33

        LruCache это больше про картинки, статья же про DI


        1. Rusrst
          14.08.2023 21:33

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


          1. klee0kai Автор
            14.08.2023 21:33
            +1

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

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


            1. Rusrst
              14.08.2023 21:33

              Справедливо. Спасибо за ответы!


            1. klee0kai Автор
              14.08.2023 21:33

              Касательно DI - lru cache удалить обьект может по любым свои алгоритмам, не вдаваясь в подробности ЖЦ этого самого объекта.
              По итогу мы можем получить для обьект будет пересоздан там где не надо.
              Все эти кэши могут работать хорошо с однотипными обьектами, но если кэшируются различные обьекты ViewModel, Presenter, Repository, то этому самому кэшу нужно будет знать - когда можно удалять этот самый обьект, а когда нет.

              Конечно идею осуществить можно, но к сожалению ее развитие зависит только от вас)


      1. old_merman
        14.08.2023 21:33

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


        1. Rusrst
          14.08.2023 21:33

          Такое себе на JVM полагаться, а вдруг кто из вендоров нахимичит что-то? Они могут...


          1. klee0kai Автор
            14.08.2023 21:33

            Вопрос довольно открытый. Однако насколько я понимаю dalvik является частью AOSP, которая в свою очередь является открытой и обязывает ее модификации тоже делать открытыми. Для вендоров предоставляются точки интеграции (один, два и др.), которые ограничены и не позволяют ломать AOSP основы.

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

            Все эти кейсы уникальны и думаю на общую картину не влияют.


          1. old_merman
            14.08.2023 21:33

            Такое себе на JVM полагаться

            Так я цитату из SDK выше и привёл именно потому что идея, когда-то мне казавшаяся красивой, оказалась "такое себе" :)