Рубрики
Без рубрики

Не забудьте создать это приложение 📲

В первой части этой серии я описываю, как и почему я начал создавать приложения под названием Call Mom a… С тегом android, java, приложение, учебное пособие.

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

Для этой задачи я решил написать приложение в Java . Изучение методов, которые были совершенно новыми для меня, как Kotlin или React Native , в то время не были для меня главным фокусом, хотя позже я занялся обоими из них. Итак, я установил Android Studio , запустил его и начал делать маленькие шаги вперед.

Выход из мира современной Java и C# и переход на Java 7 (версия Java по умолчанию в Android Studio, когда я начинал) казался огромным шагом назад во времени.

К счастью, я смог исправить некоторые из них, активировав совместимость с Java 8 в Android Studio и установив minSdkVersion на уровень API 24 в файле манифеста .

Ориентируясь в неизвестном

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

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

Библиотеки поддержки

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

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

Android Реактивный ранец

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

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

Все это очень мило, но верхний результат поиска для RecyclerView , например, на момент написания этой статьи, по-прежнему приводит к более старой версии . Это то, о чем нужно знать.

Если вы работаете над приложением, которое зависит от старых библиотек поддержки, есть способы легко и автоматически перенести на AndroidX. По моему опыту, автоматическая миграция работает нормально. Кроме того, более новые версии Android Studio пытаются принудить (и даже силой) вы должны использовать более новые библиотеки совместимости.

Чтобы обеспечить согласованный пользовательский интерфейс в нескольких версиях Android, вот несколько советов, которые следует учитывать:

  • Позвольте вашим действиям расширяться AppCompatActivity вместо Деятельность :
import androidx.appcompat.app.AppCompatActivity;

public class MyActivity extends AppCompatActivity {
}
  • Используйте ContextCompat вместо прямого вызова методов Context , когда существуют подходящие методы:
import androidx.core.content.ContextCompat;

// Start other activities from inside an activity like this:
ContextCompat.startActivity(this, intent, options);
// And not like this:
this.startActivity(intent, options)

// Get some resources from inside an activity like this:
Drawable picture = ContextCompat.getDrawable(this, R.drawable.pic);
// And not like this:
Drawable picture = getDrawable(R.drawable.pic);

// Check permissions like this:
int permissionState = ContextCompat.checkSelfPermission(this, CALL_PHONE);
// And not like this:
int permissionState = checkSelfPermission(CALL_PHONE);
  • Используйте NotificationManagerCompat вместо NotificationManager :
import androidx.core.app.NotificationManagerCompat;

// Get the Notification Manager like this:
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
// And not like this:
NotificationManager = getSystemService(NOTIFICATION_SERVICE);

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

Сначала я использовал базу данных Room для хранения всего, и в итоге создал множество решений AsyncTask для активного чтения данных при необходимости или записи данных после ввода пользователем. Этот подход был тем, что я смог найти, когда искал ответы. Однако использование подхода Live Data гораздо эффективнее и проще для подписки на измененные данные во всем приложении. Кроме того, база данных комнат может быть не лучшим хранилищем для каждого бита данных, которые необходимо хранить вашему приложению.

Общие ссылки

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

API-интерфейсы SharedPreferences предоставляют хранилище значений ключей, которое вы можете использовать непосредственно из своего кода Activity , не создавая рабочих потоков и не беспокоясь о проблемах синхронизации. Чтобы прочитать данные, начните с вызова метода getSharedPreferences .

// First open the preferences file called "prefs"
// If it doesn't exist, it gets created automatically
SharedPreferences prefs = getSharedPreferences("prefs", MODE_PRIVATE);

// Read an integer named "launches" from the preferences
// If that values doesn't exist, let it return zero
int numberOfAppLaunches = prefs.getInt("launches", 0);

// Read a string named "username"
// If that value doesn't exist, let it return null
String username = prefs.getString("username", null);

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

Чтобы обновить SharedPreferences вашего приложения, вам сначала нужно создать объект Editor , поместить новое значение в редактор и вызвать apply() , который асинхронно сохраняет изменения в файле настроек, не нарушая поток пользовательского интерфейса.

// Open the preferences file called "prefs"
SharedPreferences prefs = getSharedPreferences("prefs", MODE_PRIVATE);

// Create an Editor
SharedPreferences.Editor prefsEditor = prefs.edit();

// Update the value
numberOfAppLaunches += 1;
prefsEditor.putInt("launches", numberOfAppLaunches);

// Save changes to the preferences file
prefsEditor.apply();

Базы данных комнат

Для хранения более сложных данных вам следует рассмотреть библиотеку Room persistence library . Это даст вам доступ к облегченному движку базы данных SQLite , скрытый за уровнем абстракции, который помогает вам сосредоточиться на разработке вашей модели данных вместо того, чтобы отвлекаться на более сложные вещи, такие как соединения и синтаксис SQL-запросов, помимо простых запросов SELECT . В сочетании с архитектурой Live Data architecture вы получаете полностью реактивный поток данных, основанный на шаблоне Observer .

Начните с определения ваших классов данных. Каждый класс данных аннотируется как @Entity и преобразуется в единую таблицу в вашей базе данных SQLite. Вот как мог бы выглядеть простой класс сущностей Mom Data entity:

import androidx.room.Entity;
import androidx.room.PrimaryKey;

@Entity
public class MomData {
    @PrimaryKey(autoGenerate = true)
    public long id;

    public String name;
    public String number;
}

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

import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;

@Dao
public interface MomDao {
    @Query("SELECT * FROM MomData")
    MomData[] getAllMoms();

    @Query("SELECT * FROM MomData WHERE id = :id")
    MomData getMomById(long id);

    @Query("SELECT * FROM MomData WHERE name = :whatName")
    MomData[] getAllMomsWithName(String whatName);

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void addOrUpdate(MomData mom);
}

Этот интерфейс доступа к данным позволяет вашему приложению:

  • Перечислите всех мам в базе данных с помощью получить всех мам метод
  • Получите одну конкретную маму, используя ее идентификатор с помощью метода getmobbyid
  • Перечислите всех мам с определенным именем с помощью метода получить всех мам с именем
  • Добавьте новый mom или обновите существующий с помощью того же метода AddOrUpdate ; параметр onConflict аннотации @Insert указывает Room заменить строку в базе данных, если идентификатор совпадает с существующей строкой, или создать новую строку если объект MomData является новым

Как вы можете видеть, для создания запросов требуются некоторые знания SQL, и если у вас возникнет необходимость в более сложном JOIN или WHERE предложения, вы можете захотеть изучить другие решения ORM, такие как greenDAO , которые имеют сложную концепцию QueryBuilder .

Наконец, вы создаете абстрактный класс, который расширяет класс Room Database , который правильно обрабатывает соединения для вас:

import androidx.room.Database;
import androidx.room.RoomDatabase;

// Add all your app's entity classes to the entities array
@Database(entities = { MomData.class }, version = 1)
public abstract class CallMomDatabase extends RoomDatabase {
    // Create an abstract DAO getter for each DAO class
    public abstract MomDao getMomDao();
}

Как, чтобы использовать базу данных, вам необходимо создать базу данных Room. Builder объект, который создаст базу данных, если она еще не существует, и установит с ней соединение:

// From inside a method in an Activity:
RoomDatabase.Builder builder =
    Room.databaseBuilder(this, CallMomDatabase.class, "callmomdb");
CallMomDatabase db = builder.build();

// Get a list of all moms
MomData[] allMoms = db.getMomDao().getAllMoms();

// Close the connection to clean up
db.close();

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

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

LiveData – Комната с видом

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

К счастью, Android Jetpack предоставляет концепцию Компонентов, учитывающих жизненный цикл , которая решает большую часть этой проблемы. Одним из таких компонентов является Текущие данные , который используется для переноса изменяемого значения (простое значение или объект) в наблюдаемом с учетом жизненного цикла. Любой наблюдатель, такой как Activity или Fragment , получит обновленные значения именно тогда, когда им это необходимо, в нужное время в их жизненном цикле. Даже несмотря на то, что Объекты Live Data могут использоваться с любым типом данных из любого источника, они особенно полезны для работы с объектами, живущими в базе данных Room .

Во-первых, вам нужно провести рефакторинг Dao интерфейс для использования механизма Live Data . Вам нужно будет обернуть возвращаемый тип любых данных, которые вам нужно наблюдать, в LiveData<> универсальный класс.

import androidx.lifecycle.LiveData;

@Dao
public interface MomDao {
    @Query("SELECT * FROM MomData")
    LiveData getAllMoms();

    // ...
}

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

import androidx.lifecycle.LiveData;
import androidx.lifecycle.ViewModel;

public class MainActivityViewModel extends ViewModel {
    private LiveData allMoms;
    private final CallMomDatabase database;

    public MainActivityViewModel() {
        RoomDatabase.Builder builder =
            Room.databaseBuilder(this, CallMomDatabase.class, "callmomdb");
            database = builder.build();
    }

    public LiveData getAllMoms() {
        if (allMoms == null) {
            allMoms = database.getMomDao().getAllMoms();
        }
        return allMoms;
    }
}

Обратите внимание, что database.close() больше не вызывается. Это связано с тем, что Live Data требует, чтобы подключение к базе данных оставалось открытым. Наконец, в вашем Activity вам нужно создать Observer для прослушивания изменений в ваших данных и соответствующего обновления вашего представления. Ориентируясь на Java 8, наиболее удобный способ сделать это – использовать Ссылку на метод , в данном случае this::all: Changed ссылка:

import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProviders;

public class MainActivity extends AppCompatActivity {
    private MainActivityViewModel model;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Get an instance of the view model
        model = ViewModelProviders.of(this).get(MainActivityViewModel.class);

        // Start observing the changes by telling what method to call
        // when data is first received and when data changes
        model.getAllMoms().observe(this, this::allMomsChanged);
    }

    private void allMomsChanged(@Nullable final MomData[] allMoms) {
        // This is where you update the views using the new data
        // passed into this method.
    }
}

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

Приложение-напоминание, например Позвони маме |/и Call Dad , должны иметь возможность предупреждать пользователя в определенное время, даже если его устройство находится в спящем режиме, и оповещения должны работать правильно, даже если устройство перезагружено. В Android есть механизм, называемый Alarm Manager , который вы можете использовать для пробуждения приложения и запуска кода по расписанию. Класс AlarmManager имеет множество различных методов для установки этих сигналов тревоги, а AlarmManagerCompat может помочь вам установить сигналы тревоги таким образом, чтобы они были согласованы между версиями Android. Вам нужно быть осторожным при выборе метода использования, потому что, если вы неправильно спроектируете свой будильник, ваше приложение может разрядить батарею устройства.

Установка будильника

Я решил использовать метод AlarmManager Compat.set Alarm Clock для этих приложений, потому что основная цель сигналов тревоги – уведомить пользователя о запланированном вызове. Метод set Alarm Clock ограничивает количество сигналов тревоги максимум одним за 15 минут, поэтому, если вашему приложению необходимо запланировать запуск кода, который не уведомляет пользователя, или который должен выполняться чаще, чем каждые 15 минут, вам следует использовать какой-либо другой метод AlarmManager или AlarmManager Compat classes, или используйте какой-то другой подход.

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.core.app.AlarmManagerCompat;

public class MyAlarms extends BroadcastReceiver
    private AlarmManager alarmManager;
    private Context appContext;
    private final static int REQUEST_CODE = 1;

    // The current application context must be passed into this constructor
    public MyAlarms(Context appContext) {
        this.appContext = appContext;

        // Get the AlarmManager
        alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
    }

    public void setAlarm(long timeInMilliseconds) {
        // Create an intent that references this class
        Intent intent = new Intent(context, getClass());

        // Create a pending intent (an intent to be used later)
        // If an identical pending intent already exists, the FLAG_UPDATE_CURRENT
        // flag ensures to not create duplicates
        PendingIntent pendingIntent = PendingIntent.getBroadcast(
            appContext, REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT);
        );

        // Set the alarm to call the onReceive method at the selected time
        AlarmManagerCompat.setAlarmClock(alarmManager, timeInMilliseconds, pendingIntent, pendingIntent);
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        // This method will get called when the alarm clock goes off
        // Put the code to execute here
    }
}

Чтобы установить будильник, создайте экземпляр My Alarms и вызовите метод setAlarm , передав миллисекундную временную метку для желаемого времени срабатывания будильника:

// From inside an Activity or a Service:
MyAlarms myAlarms = new MyAlarms(this);

// Set the alarm to go off after an hour
// An hour = 60 minutes * 60 seconds * 1000 milliseconds
long afterAnHour = System.currentTimeMillis() + 60 * 60 * 1000;
myAlarms.setAlarm(afterAnHour);

Устройство обнаружения перезагружается

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

Операционная система отправляет широковещательные сообщения всем приложениям, которые прослушивают действия BOOT_COMPLETED . Чтобы ваше приложение получало уведомления, начните с объявления разрешения RECEIVE_BOOT_COMPLETED и добавления intent-filter к вашему получателю в AndroidManifest.xml |/файл:


   />
  ...

  
    ...

    
      
        
      
    

  

Затем в вашей реализации BroadcastReceiver разверните метод onReceive , чтобы проверить, какой тип сообщения получен, и при необходимости перепланируйте сигнал тревоги. Кроме того, при планировании будильника сохраняйте время будильника в SharedPreferences.

public class MyAlarms extends BroadcastReceiver
    private AlarmManager alarmManager;
    private Context appContext;
    private final static int REQUEST_CODE = 1;
    private final static long TIME_NOT_SET = 0;

    public MyAlarms(Context appContext) {
        this.appContext = appContext;
        alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
    }

    public void setAlarm(long timeInMilliseconds) {
        Intent intent = new Intent(context, getClass());
        PendingIntent pendingIntent = PendingIntent.getBroadcast(
            appContext, REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT);
        );
        AlarmManagerCompat.setAlarmClock(alarmManager, timeInMilliseconds, pendingIntent, pendingIntent);

        // Open shared preferences and save the alarm time
        SharedPreferences prefs = appContext.getSharedPreferences("alarms", Context.MODE_PRIVATE);
        SharedPreferences.Editor prefsEditor = prefs.edit();
        prefsEditor.putLong("alarmtime", timeInMilliseconds);
        prefsEditor.apply();
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        // Check if this broadcast message is about a device reboot
        if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
            // Yes it is! Get the last saved alarm time from shared preferences
            SharedPreferences prefs = appContext.getSharedPreferences("alarms", Context.MODE_PRIVATE);
            long savedAlarmTime = prefs.getLong("alarmtime", TIME_NOT_SET);

            // Is there a saved alarm time?
            if (savedAlarmTime != TIME_NOT_SET) {
                // Reschedule the alarm!
                setAlarm(savedAlarmTime);
            }
        }
        else {
            // This is not a device reboot, so it must be the alarm
            // clock going off. Do what your app needs to do.
        }
    }
}

Основная цель этих приложений – уведомить пользователя, когда пришло время звонить. Прежде всего, вам нужно будет создать хотя бы один Канал уведомлений , чтобы ваше приложение работало в Android Oreo (версия 26) и позже. Создавая каналы, пользователи могут разрешать или запрещать уведомления в зависимости от их содержимого. Обязательно укажите хорошие названия и описания для ваших каналов.

Уведомлениеcompat

Уведомления – это одна из тех концепций, которые сильно изменились за историю Android, поэтому существует довольно много причуд, с которыми нужно обращаться по-разному, в зависимости от того, какая версия Android работает на устройстве вашего пользователя. К счастью, Android содержит Уведомлениеcompat и NotificationManagerCompat классы, которые избавляют от некоторых из этих проблем.

public class MyNotifications {
    private final static String CHANNEL_ID = "MAIN";
    private final static int ID = 12345;
    private final static int IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT;

    // You should definitely get the NAME and DESCRIPTION from resources!
    private final static String NAME = "Call reminders";
    private final static String DESCRIPTION = "These notifications remind you to call your mom";

    public void createChannel(Context context) {
        // Only do this if running Android Oreo or later
        if (Build.VERSION.SDK_INT <>= Build.VERSION_CODES.O) return;

        // Get the NotificationManager
        NotificationManager notificationManager = context.getSystemService(NotificationManager.class);

        // Create and configure the channel
        NotificationChannel channel = new NotificationChannel(CHANNEL_ID, NAME, IMPORTANCE);
        channel.setDescription(DESCRIPTION);
        channel.setShowBadge(true);
        channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC);

        // Create the channel
        notificationManager.createNotificationChannel(channel);
    }

    // When a channel has been created, call this method to show the
    // notification, and pass a PendingIntent that will get started
    // when the user clicks the notification; preferably you will
    // pass an Activity intent to start.
    public void showNotification(Context context, String title, String text, PendingIntent intentToStart) {
        NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.my_notification_icon)
            .setCategory(NotificationCompat.CATEGORY_REMINDER)
            .setContentTitle(title)
            .setContentText(text)
            .setContentIntent(intentToStart)
            .setOnlyAlertOnce(true)
            .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL);

        NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
        notificationManager.notify(ID, builder.build());
    }
}
  • Для обеспечения совместимости и современных элементов пользовательского интерфейса игнорируйте старую библиотеку поддержки и используйте Android
  • Реализовать простое постоянное хранилище ключ-значение с помощью SharedPreferences
  • Выполните более сложное постоянное хранение данных с помощью Room
  • Используйте объекты комнаты и другие состояния приложения с Оперативными данными
  • Чтобы аварийные сигналы сохранялись при перезагрузке устройства, прослушайте сообщение BOOT_COMPLETED
  • Показывать уведомления правильно с помощью NotificationCompat

Фотография на обложке от Дарья Непряхина на Unsplash

Оригинал: “https://dev.to/atornblad/don-t-forget-to-build-that-app-2md6”