Як я створив MuBarometer — неочікувано успішний pet-проєкт
Привіт. Мене звуть Вадим Хохлов, я працюю мобільним розробником, а також викладаю в Херсонському національному технічному університеті. А в якості pet-проєктів я розробляю невеличкі мобільні застосунки. Про один такий проєкт, який став неочікувано для мене доволі успішним, я й хочу розповісти.
Виникнення ідеї
Треба сказати, що розробляти мобільні застосунки я почав задовго до того, як це стало мейнстрімом, ще з 1989 року, коли батьки подарували мені програмований калькулятор МК-61. В цій статті: dev.ua/...posts/khokhlov-1695328040 я розповідав про свій досвід. Там також є посилання на емулятор калькулятора, тому бажаючі можуть спробувати свої сили (мій внутрішній старий дід зараз бурчіть: «ех молодьожь, молодьожь, от раніше були часи, не те, що зараз).
Якось я побачив програму для цього калькулятора, що по показникам з барометра намагалася робити прогноз погоди. На той час фізичного барометра в мене не було, тому перевірити я її не міг. Історія продовжилася кілька років тому. Одного разу телефон показав мені рекламу програми-барометра для Android. Я зацікавився і встановив її. Вона була цікава, але мала ряд недоліків: мало функціоналу, часті покази реклами і великий розмір. Мені захотілося перевірити, чи зможу я зробити щось подібне, і я вирішив зробити свій барометр. Але, на відміну від робота Бендера, без зайвих шахів та художниць. Майже відразу з’явилась назва проєту «Мікробарометр» чи «μБарометр» та слоган — «Простий, гарний та корисний барометр».
Можливості μБарометр
Я постійно розвиваю і вдосконалюю проєкт. Наразі мій барометр має такі можливості:
- джерела даних: як вбудований сенсор тиску, так і отримання даних з інтернету;
- одиниці виміру тиску: мілліБар, мм рт.ст., д рт.ст., атмосфери, гПа;
- трекінг даних з інтервалом від однієї хвилини до одного дня;
- альтиметр, що може вимірювати як абсолютну, так і відносну висоту;
- одиниці виміру висоти: метри та фути;
- представлення даних в табличній формі чи на графіку;
- можливість анотацій точок заміру;
- нотифікації про поточне значення тиску і попередження про різку зміну тиску;
- експорт/імпорт даних в csv-форматі;
- три теми оформлення;
- кілька віджетів для home screen.
Джерела даних
Спочатку я працював лише з вбудованим сенсором тиску, але потім вирішив розширити аудиторію застосунку і зробив можливість отримувати дані про атмосферний тиск з інтернету. Звісно, такі дані, на відміну від сенсора, містять інформацію про тиск в найближчому аеропорту, але для багатьох випадків достатньо й цього. Для підтримки кількох джерел даних я створив кілька відповідних класів.
Базовий клас виглядає наступним чином (я не показую деякі допоміжні поля і методи для простішого сприйняття коду):
public abstract class DataSource { public interface DataListener { void onValueChanged(double pressure, double temperature); void onError(int messageId, String message); } public DataSource(Context context, DataListener valueListener) { mContext = context; mValueListener = valueListener; } … public abstract void start(); public void stop(Context context) {} … protected Context mContext; protected final DataListener mValueListener; }
Реалізація класу, що працює з сенсором, не дуже складна:
public class SensorDataSource extends DataSource implements SensorEventListener { public SensorDataSource(Context context, DataListener valueListener) { super(context, valueListener); mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE); } @Override public void start() { mSensorManager.registerListener(this, mSensorManager.getDefaultSensor(Sensor.TYPE_PRESSURE), SensorManager.SENSOR_DELAY_UI); } @Override public void onSensorChanged(SensorEvent event) { if (Sensor.TYPE_PRESSURE == event.sensor.getType()) { float value = event.values[0]; mValueListener.onValueChanged((float) value, 0); } } ... }
Реалізація класу для отримання даних з інтернету більш складна. В якості джерела використовується сервіс www.weatherapi.com. Для мережевих запитів я використовую Retrofit2.
public class NetworkDataSource extends DataSource { ... @Override public void start() { startWithCoord(mLat, mLong); } public void startWithCoord(double latitude, double longitude) { setCoords(latitude, longitude); Locale nullLocale = null; final String query = String.format(nullLocale, "%f,%f", latitude, longitude); mCall = NetworkService.getInstance() .api() .weatherInfoByQuery(query, mAppId); enqueue(); } ... private void enqueue() { if (mCall != null) { mCall.enqueue(new Callback<JsonElement>() { @Override public void onResponse(@NonNull Call<JsonElement> call, @NonNull Response<JsonElement> response) { processResponse(response); } @Override public void onFailure(@NonNull Call<JsonElement> call, @NonNull Throwable t) { if (!call.isCanceled()) { processFailure(t); } } }); } } private void processResponse(@NonNull Response<JsonElement> response) { JsonElement respBody = response.body(); ... parseBody(respBody.getAsJsonObject()); } private void parseBody(JsonObject jsonObj) { JsonObject mainObj = jsonObj.getAsJsonObject("current"); double temp = mainObj.getAsJsonPrimitive("temp_c").getAsDouble(); double pressure = mainObj.getAsJsonPrimitive("pressure_mb").getAsDouble(); ... mValueListener.onValueChanged(pressure, temp); } }
Тут я знов-таки пропустив обробку помилок та несуттєві фрагменти коду. Який з цих класів використовувати, визначає об’єкт класу DataSourceManager:
public class DataSourceManager { ... public boolean setup(SharedPreferences settings) { int dsType = Integer.parseInt(settings.getString(PreferencesActivity.PR_KEY_DATA_SOURCE, "-1")); mDataSourceType = dsType; if (mDataSourceType == kDataSourceSensor) { SensorDataSource dataSource = new SensorDataSource(mContext, mValueListener); ... mDataSource = dataSource; } else { NetworkDataSource dataSource = new NetworkDataSource(mContext, mValueListener); final float lat = settings.getFloat(PreferencesActivity.PR_KEY_DATA_SOURCE_LAT, PreferencesActivity.DATA_SOURCE_WRONG_COORD); final float lon = settings.getFloat(PreferencesActivity.PR_KEY_DATA_SOURCE_LON, PreferencesActivity.DATA_SOURCE_WRONG_COORD); dataSource.setCoords(lat, lon); mDataSource = dataSource; } return true; } public void start() { mDataSource.start(); } }
Сервісу www.weatherapi.com необхідно вказувати широту та довготу точки, для якої визначається значення тиску. Ці координати визначаються в налаштуваннях програми при виборі інтернету в якості джерела даних. Тому при використанні NetworkDataSource необхідно додатково ініціалізувати створений об’єкт. Програма може з заданою періодичністю визначати поточне значення тиску й зберігати його в БД. Робиться це за допомогою сервісів. Запуск сервісу виконується наступним чином:
public class ServicesUtils { ... @RequiresApi(24) private static void runServiceL(Context context, int aServiceId, String pKeyInterval, boolean isForce) { long when = nearestTimePoint(context, pKeyInterval, isForce); AlarmManager alarm = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); PendingIntent pendingIntent = getPendingIntent(context, aServiceId); SharedPreferences settings = XBAUtility.getDefaultSharedPreferences(context); if (settings.getBoolean(PreferencesActivity.PR_KEY_USE_ALARM_CLOCK, false)) { alarm.setAlarmClock(new AlarmManager.AlarmClockInfo(when, pendingIntent), pendingIntent); } else { alarm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, when, pendingIntent); } } private static PendingIntent getPendingIntent(Context ctx, int aServiceId) { Intent alarmIntent; if (aServiceId == LOGGER_SERVICE_ID) { alarmIntent = new Intent(ctx, LoggerBroadcastReceiver.class); alarmIntent.setAction(ctx.getString(R.string.logger_intent)); } else { alarmIntent = new Intent(ctx, AlertsBroadcastReceiver.class); alarmIntent.setAction(ctx.getString(R.string.alerts_intent)); } return PendingIntent.getBroadcast(ctx, 0, alarmIntent, PendingIntent.FLAG_ONE_SHOT | Utils.intentFlags()); } }
Де клас LoggerBroadcastReceiver, зокрема, має наступний вигляд:
public class LoggerBroadcastReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { Intent intentSave = new Intent(context, LoggerJobIntentService.class); intentSave.setAction(ServicesUtils.CMD_SAVE); context.startForegroundService(intentSave); } }
Я прибрав частину коду, що виконує запуск сервісів на старих версіях Android, який скоро можна буде взагалі видалити з проєкту після підняття мінімальної версії Android SDK. Клас LoggerJobIntentService виглядає наступним чином:
public class LoggerJobIntentService extends BarometerJobIntentService { ... @Override protected void processValue(float mbars) { PressureDataManager.saveValue(this, mbars); SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this); if (settings.getBoolean(BarometerNotificationManager.PR_USE_NOTIF_KEY, true)) { BarometerNotificationManager.sendNotification(this, mbars); BaseBarometerAppWidget.updateWidgets(this); } } }
Цей клас записує отримані данні в БД а також показує нову нотифікацію та оновлює віджети. Аналогічний клас AlertsJobIntentService використовується для обробки даних про різку зміну тиску. Їх базовий клас виглядає наступним чином:
public class BarometerJobIntentService extends JobIntentService implements DataSource.DataListener { @Override protected void onHandleWork(@NonNull Intent intent) { ... registerListener(); } private boolean registerListener() { SharedPreferences settings = XBAUtility.getDefaultSharedPreferences(this); return registerDSMListener(settings); } private boolean registerDSMListener(SharedPreferences settings) { ... mDataSourceManager = new DataSourceManager(this, this); if (!mDataSourceManager.setup(settings)) { return false; } else { mDataSourceManager.start(); return true; } } @Override public void onValueChanged(double pressure, double temperature) { ... processValue((float) pressure); finish(); } }
Теми застосунку
Спочатку я був схильний до скеворморфізму. Це не те, про що ви могли подумати, а просто спроба імітувати вигляд реальних барометрів. Я переглянув безліч фотографій реальних барометрів, щоб зімітувати аналогічний вигляд в своїй програмі. Тому перші версії використовували лише одну тему оформлення з бронзовим (чи латунним) циферблатом на дерев’яному столі. Я навіть розмістив іконки з варіантами погоди в тих самих місцях, де вони розміщуються на реальних пристроях. Теми в основному налаштовуються в
<?xml version="1.0" encoding="utf-8"?> <resources> ... <attr name="altimeterIcon" format="reference" /> <attr name="barometerIcon" format="reference" /> ... </resources>
Самі теми налаштовуються в файлі styles.xml:
<style name="LightWoodTheme" parent="Theme.AppCompat.Light.NoActionBar"> ... <item name="altimeterIcon">@drawable/altimeter</item> <item name="barometerIcon">@drawable/barometer</item> ... </style> ... <style name="DarkSimpleTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> ... <item name="altimeterIcon">@drawable/altimeter_white</item> <item name="barometerIcon">@drawable/barometer_white</item> ... </style>
Ці зображення використовуються в
<ImageView android:id="@+id/butAltimeter" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:src="?attr/altimeterIcon" android:contentDescription="@null" />
Таким чином в залежності від вибраної теми буде використовуватися темна або біла картинка для кнопки. Сама тема встановлюється наступним чином:
public class ThemeableActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setupEdgeToEdge(); SharedPreferences settings = XBAUtility.getDefaultSharedPreferences(this); setTheme(ThemeManager.themeId(this, settings)); ... } } class ThemeManager { private static final int[] sThemeIds = { R.style.LightWoodTheme, R.style.DarkWoodTheme, R.style.DarkSimpleTheme }; static int themeId(Context context, SharedPreferences settings) { int idx = themeIndex(settings); return sThemeIds[idx]; } }
Головний екран μБарометр:
Альтиметр
Як відомо, існує доволі багато способів визначити висоту маяку за допомогою барометра. Але найпростіший варіант — по різниці тиску у двох точках. Спочатку я обчислював висоту просто по різниці в даний точці і умовного значення на рівні моря. Для визначення відносної висоти цього достатньо. Я використовував цей функціонал, наприклад, на екскурсіях — перевіряв слова екскурсовода на кшталт: «Висота цієї вежі 63 метри». Зараз можна використовувати барометричну формулу, що враховує поточну температуру, однак значення температури поки що треба вводити вручну.
Екран альтиметра:
Детальний графік
Затрекані дані зберігаються в БД. Також до записаних значень можна додавати свої анотації, які можуть бути нейтральними, негативними чи позитивними. Наприклад: «Боліла голова», «Крутило ноги» чи «Почувався добре». Цю інформацію потім можна експортувати, відображати у вигляді таблиці чи графіку. На головному екрані або віджеті у Pro-версії можна розмістити міні-графік. Також є окремий екран з детальним графіком.
Екран детального графіку:
Піки на цьому графіку — це нагадування про той період, коли русня в 2022 втекла з Херсону, але перед цим підірвала лінії електропередач. Тому в місті кілька тижнів не було електрики і як результат тепла і води. Тому доводилося з району, де я жив, який знаходиться доволі високо, їздити в низину на берег Дніпра набрати хоча б річкової води.
Ще однією корисною можливістю є відправка нотифікації про поточний тиск. З одного боку це просто інформативно — бачити значення в області нотифікацій, а з іншого є можливість налаштування критичних нотифікацій про різку зміну тиску. Мої метеозалежні користувачі пишуть, що це дуже корисна функція, яка їм допомагає.
Проблеми, виклики й помилки
Зчитати дані з вбудованого сенсору тиску виявилося найпростішою задачею в цьому проєкті. Найбільше проблем виникало і виникає з належною роботою трекінгу тиску. Фактично вся історія цього проєкту — це історія боротьби з все новими й новими обмеженнями Google на фонові процеси в Android. Спочатку я запускав періодичний таймер, який з заданим інтервалом трекінгу спрацьовував і записував данні про тиск. Але відразу стало помітно, що Android не є real-time operating system (RTOS) — з часом накопичувалася помилка в часових інтервалах. Тому зараз я використовую більш складний підхід.
Запускається one-shot таймер, який спрацьовує, записує значення тиску і запускає наступний таймер. При цьому інтервал може трохи корегуватися. Наприклад, якщо для інтервалу в одну годину перший таймер спрацював в 14:00, а другий в 14:59, то в БД запишеться час 15:00, але для наступного таймеру буде встановлений інтервал 61 хвилина. Це дозволяє доволі точно витримувати інтервали трекінгу. Я якось намагався для запуску таких тасків використовувати WorkManager, але результати були дуже погані, тому зараз для підвищення точності є можливість використовувати системний будильник. Однак для цього мені довелося після чергового оновлення версії Android додати необхідні атрибути до опису сервісу в AndroidManifest.xml:
<service android:name=".tasks.LoggerJobIntentService" android:foregroundServiceType="specialUse" android:exported="false" android:permission="android.permission.BIND_JOB_SERVICE" > <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="This service tracks the air pressure value for the specific periods" /> </service>
А при публікації нової версії барометра пояснювати, навіщо цей застосунок використовує foreground services. На щастя, моє пояснення задовольнило ревьюверів. Ще цікавою проблемкою було відображення значення тиску в іконці нотифікації. На той момент в Android не було можливості створювати такі іконки програмно. Тому я згенерував багато зображень на різні значення тиску і одиниці виміру і вибираю те чи інше зображення залежно від поточного значення тиску.
Також одного разу я зробив доволі дурну помилку. Спочатку я хотів лише відмальовувати мініграфік за пару днів на головному екрані, тому вирішив зберігати дані просто в текстовому файлі. Не знаю, чому тоді я вирішив так зробити. Очевидно, що доволі скоро мені довелося зробити по-нормальному і мігрувати на SQLite. Банальна порада: якщо працюєте з даними, зберігайте їх в БД.
Різні цікавинки і неочікувані проблеми
Я починав цей проєкт просто з бажання погратися сенсором тиску. Однак дуже скоро він перетворився на цікаву і корисну програму. Можливо, гарні коментарі від користувачів — це найприємніше, що можна отримати. Це дуже приємно, коли бачиш лист з текстом: «Дякую за програму, я страждаю метеозалежністю, а вона мені допомагає підготуватися до різких змін погоди й простіше їх пережити». Для відстежування аналітики я інтегрував Google Anlytics і час від часу спостерігаю, як програма захоплює світ :).
Якось бачив, що цим барометром користувалися в Сомалі. А трохи пізніше майже в тій самій точці — іншою моєї програмою «Картковий Гольф». В мене є припущення, що це були сомалійські пірати, які спочатку перевіряли, чи варто виходити в море, а після успішного рейду за допомогою гральних карт ділили награбоване :). Неочікуваним наслідком цього проєкту стало те, що зараз я, по-перше, змушений купляти телефони обов’язково з вбудованим сенсором тиску. А по-друге, купляти новий телефон не тоді, коли мій вже стане застарілим, а коли для нього перестають виходити нові версії Android, бо треба ж тестувати застосунок на останній версії цієї ОС. На щастя, Google заявляє про продовжену підтримку своїх Pixel. Сподіваюся, тепер мені не треба буде надо часто оновлювати телефон.
Плани на майбутнє
Я продовжую потроху розвивати проєкт. Є кілька нових функцій, які б я хотів додати в майбутньому. Наприклад, альтиметр може автоматично визначати температуру повітря за допомогою даних з інтернету. Також є сенс додати можливість генерувати різні попередження в залежності від значення зміни тиску. Ще одна майбутня функція — розрахунок MSL — the average pressure at mean sea level, але для цього треба буде трекати не лише тиск, а й поточну температуру. Час від часу я роздумую додати можливість працювати в парі з розумними годинниками й отримувати з них данні про тиск. Необхідна інфраструктура вже підготовлена і додавання ще одного DataSource вже не буде складною задачею. Але я все ж намагаюся слідувати початковій ідеї — утримувати розмір програми якомога меншим.
Де взяти
Трохи додаткової інформації інформації можна прочитати ще на xvadim.github.io/...mubarometer/index_uk.html. Там же можна знайти посилання на Google Play та Samsung Apps. Або відразу скачати з Google Play play.google.com/...d=org.xbasoft.mubarometer і почати експериментувати. Сподіваюся, μБарометр стане в нагоді комусь з читачів цієї статті.
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів