Як я створив 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 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів