Бесподобный виджет с Realm'ом
iOSDeviantСтартовый код проекта можно скачать тут:
https://github.com/NiFilonov/AppWithWidget
Итоговый, соответственно, там же :)
О приложении
Приложение достаточно простое.
Данное приложение подсчитывает сколько вам осталось выпить воды до достижения суточной нормы.
Есть поле ввода, в котором мы пишем сколько мы выпили воды сейчас (в миллилитрах).
Есть информационное окно, которое показывает сколько осталось выпить воды (также в миллилитрах) до достижения суточной нормы.
Ежедневная норма - 2000 мл.
Если мы выпили больше 2000 мл воды, то счетчик обновляется и цель снова становится 2000 мл.
Easy? Вот и я так думаю.
ВНИМАНИЕ! Мне было лень добавлять обработку появления клавиатуры, поэтому, отключите клавиатуру в симуляторе и вводите данные через физическую клавиатуру.
Супер realm'щикам и архитектурным гуру - проект учебный, можно было бы сделать и гораздо практичнее, но я автор и на мой взгляд выразить основную мысль статьи проще через такой код.
Спасибо, поехали!
Преднастройка
Для начала надо добавить Realm в проект.
Ваш покорный слуга уже все сделал за вас и вам осталось только установить его.
- Переходим в папку с проектом и открываем Podfile.
- Снимаем комментарий с pod 'RealmSwift'.
- Открываем terminal (или что там у вас), переходим в папку с нашим проектом и запускаем команду pod install.
Realm вещь большая, может придется чуток подождать

Готово!
Можно запустить приложение и посмотреть как оно работает :)
Добавляем виджет
Конечно, прежде всего, надо добавить виджет.
Делается это просто:
Жмем File->New->Target...

Далее выбираем Widget Extension (как ни странно):

Задаем имя для виджета.
Я немного косячнул с именем проекта. Представим, что все-таки проект называется WaterApp.
Назовем WaterWidget.
Также снимаем галочку с Include Configuration Intent:

Надо ли объяснять на что жмем далее?
Ну на всякий случай - Activate:

Готово!
Теперь давайте глянем что за виджет у нас, что за красавец, как он поживает и о чем хочет сказать миру:
- В поле с target'ами выбираем WaterWidgetExtension.
- Запускаем виджет.
- Радуемся нашему виджету, сверяем часы и переходим далее.

Подключаем виджет к Realm
На захлестывающих эмоциях возвращаемся обратно в проект.
Самые внимательные заметили, что в дереве проекта появилась новая папочка - WaterWidget. Как следует из названия там и лежит наш виджет.
Нас интересует файл WaterWidget.swift.
Открываем его и пролистываем до самой структуры виджета. Его можно узнать по тому факту, что он наследуется от View:

Теперь, вроде бы просто создаем объект RealmHelper (который работает с базой данных Realm).
Осади коней, лихой iOS-ник!
Что-то идет не так. Почему-то Xcode говорит, что нет такого типа в нашем scope:

По-правде, так и есть.
Чуть отмотаем время назад и вспомним, что, когда мы запускали виджет на симуляторе, мы меняли target перед запуском.
Если выберем любой файл и в правом боковом меню выберем вкладку Show the file inspector (самая первая, белый листик), то увидим, что у файла есть список target'ов с которыми он может работать (в разделе Target Membership).
А теперь давайте откроем RealmHelper и посмотрим на его Target Membership. Как видим, галочка стоит только для AppWithWidget.
Исправляем это, ставя галочку и для WaterWidgetExtension:

Если посмотреть внутрь RealmHelper'а можно увидеть что он работает с DayEntry. Поэтому, вызывая в виджете RealmHelper, мы как бы априори вызываем и DayEntry.
Поэтому надо проделать этот же ход конем и для файла DayEntry.
Вечеринка с target'ами продолжается!
Попытка сбилдить приложение приведет нас в разочарование фразой "RealmSwift не найдет в WaterWidgetExtension" (примерный перевод).
Ну чтож, добавим и RealmSwift в WaterWidgetExtension.
Тут надо вернуться к корням и открыть файл Podfile.
В Podfile прописываем отдельную установку Realm'a для виджета.
Выглядит это так:

После этого надо опять через terminal перейти в папку проекта и выполнить команду pod install.
На этом все :)
Открываем наш WaterWidgetEntryView и видим, что все хорошо, ошибка пропала:

Теперь надо это протестить.
У RealmHelper есть функция getValue(), которая возвращает значение - сколько осталось выпить воды до суточной нормы.
Закинем это прямо в Text:

Запускаем и видим:

Отлично!
Данные подтягиваются из Realm'а в виджет и отображаются.
Если думаешь, что это все, то видимо кони так и не осажены.
Чуть поработав мы видим баг.
Поработали в приложении, попили водички и видим значение 191:

Теперь глянем что виджет думает об этом:

Виджет так не считает, он все также думает, что мы ни сделали ни глотка.
Почему идет рассинхрон данных?
Разберемся, почему в виджет приходят неверные данные.
Система iOS устроена таким образом, что у каждого приложения есть свой контейнер, где он может хранить файлы (это относится и к компонентам приложения, например к виджетам).
Получается, на данный момент у нас 2 контейнера:
- первый для приложения
- второй для виджета.
А в каком из них тогда лежит база данных?
А вот тут интересно :)
База данных создает файл default.realm (собственно физически сама БД), когда происходит первая инициализация Realm'a. Если этот файл уже есть в системе, то тогда Realm просто подключается к этому файлу.
И когда мы запускаем приложение - Realm создает файл базы данных внутри контейнера приложения. Затем, когда мы обращаемся к БД из виджета, Realm создает еще один файл внутри контейнера виджета.
Но что вам мои голые слова? Давайте глянем, что об этом говорит сам Realm, спросим у него где лежит БД?
Для этого поставим в init RealmHelper'a команду, которая печатает в консоль расположение файла default.realm:
print(realm.configuration.fileURL?.absoluteString ?? "error")

Запускаем приложение и видим:

Теперь запускаем виджет:

При деббаге виджета может ничего не выводиться, нужно переустановить виджет, и в левом меню Xcode открыть Debug navigator (иконка баллончик со спреем), там выбрать процесс для виджета.
Либо не париться и поверить мои скринам.
Вернемся к проблеме.
Когда мы добавляем записи о выпитой воде - мы добавляем их в базу данных приложения. Но когда отображаем данные из виджета, мы отображаем их из базы данных виджета. Это 2 разных файла, которые лежат в разных местах системы.
Синхронизируем данные
Чтобы виджет отображал данные, которые мы заносим в Realm через приложение - и приложение и виджет должны работать с одним файлом.
Для таких целей Apple содали AppGroup.
AppGroup - это то, что позволит нам шарить данные между приложением и виджетом. AppGroup создает общий контейнер и у наших компонентов приложения будет доступ к этому контейнеру.
Для добавления AppGroup:
- Переходим в файл проекта.
- Открываем вкладку Signing & Capabilities.

Теперь выбираем target - AppWithWidget:

Под надписью Signing & Capabilities находим + Capability, жмем и выбираем AppGroup:

Теперь у нас появилась новая секция с AppGroup. Жмем на плюс:

Задаем имя для AppGroup, например такое:

И все тоже самое проделываем для второго target'a (WaterWidgetExtension):

Ничего страшного, что имя группы выделяется красным. При тестовых запусках на симуляторе это будет работать, но если будете выкладывать приложуху в AppStore, то надо будет добавить такой же AppGroup в настройках AppStoreConnect.
А теперь покодим!
Помним, что мы хотим иметь 1 файл базы данных и чтобы он лежал в общем контейнере. Увы, Realm это не может сделать сам, поэтому эта ноша ложится на наши плечи.
- Получаем экземпляр FileManager (название говорит за себя) в fileManager.
- Создаем путь до default.realm в общей папке (его там может и не быть, это именно путь в виде строки, точнее URL'a) и сохраняем его в appGroupURL.
- Проверяем, можем ли мы открыть файл по этому пути (appGroupURL).
- Если не можем открыть, значит файла нет в общей группе, значит мы его еще не перенесли. Далее, переносим файл.
- Получаем текущее расположение файла default.realm (когда он еще в контейнере приложения) в originalPath.
- Создаем экземпляр Realm'a, а это значит, что в этот момент создастся сам файл default.realm.
- Теперь вспоминаем про fileManager и переносим файл базы данных в общий контейнер.
- В переменную realm присваиваем объект Realm с указанием пути до файла базы данных (если не указывать путь, то Realm по умолчанию попробует достать его из контейнера приложения, а если там его не будет, то он просто заново создаст файл default.realm).

Фух, готово!
Теперь давайте проверим.
Удаляем приложение с симулятора, устанавливаем заново, меняем значение.
Затем идем в меню и добавляем виджет:


Мы почти у цели. Почему почти?
Если попробовать снова изменить значение в приложении, то на виджете отобразится старое значение.
А все потому что сейчас виджет получает данные из БД, только когда он добавляется на экран.
Но тут все проще.
Когда мы меняем значение в RealmHelper'e в функции addValue(), в самом конце добавим WidgetCenter.shared.reloadAllTimelines() - эта команда будет обновлять виджет.
Таким образом, каждый раз, сразу после обновления значения в БД у нас будет вызываться обновление виджета:

Если сейчас запустим и проверим, то увидим, что после добавления значения в БД они сразу же обновляются и на виджете!
Кто дочитал, тому желаю завтра найти 1.000 руб. в старых джинсах, всем удачи!