26 декабря 2016 в 17:14

Разработка виджета под Android для отображения курса bitcoin tutorial

Привет, хаброчитатели! Предыстория: с недавних пор отслеживаю рост курса криптовалюты bitcoin(BTC). Раньше для просмотра котировок заглядывал на сайт какой-нибудь биржи, но гораздо удобнее иметь на рабочем экране смартфона небольшой виджет, который отобразит актуальную информацию.

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

Для понимания материала статьи желательно начальное знакомство с Java и Android-разработкой в IDE Android Studio. Если нет — Вам сюда: Android developers.

Мой небольшой виджет будет отображать цену биткоина в долларах и время, на которое актуальна данная цена. Информация будет загружаться с русскоязычной биржи BTC-e, поскольку эта площадка отдает курс в удобном JSON-формате в ответ на get-запрос по url btc-e.nz/api/3/ticker/btc_usd.

Пример ответа биржи:

{"btc_usd":{"high":880,"low":836,"avg":858,"vol":3774491.17766,"vol_cur":4368.01172,"last":871.999,"buy":872,"sell":870.701,"updated":1482754417}}

Итак, для начала разработки создаем в IDE новый проект с помощью подходящего шаблона. Выбираем «Start a new Android Studio project», затем вводим имя и расположение проекта, выбираем целевое устройство и версию API, далее нужен пункт «Add no activity».

Скриншоты
image
image
image

После того, как откроется IDE workspace, создадим пустой виджет с помощью встроенных шаблонов. Для этого нужно в дереве файлов проекта вызвать контекстное меню на папке app и выбрать пункт New → Widget → App Widget.

Скриншот
image

Будут созданы несколько файлов, из которых особенно интересны три.

Первый — xml-файл (res → xml → btcwidget_info.xml) с основными параметрами виджета:

<source lang="xml"><?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialKeyguardLayout="@layout/btcwidget"
    android:initialLayout="@layout/btcwidget"
    android:minHeight="40dp"
    android:minWidth="110dp"
    android:previewImage="@drawable/example_appwidget_preview"
    android:resizeMode="horizontal|vertical"
    android:updatePeriodMillis="180000"
    android:widgetCategory="home_screen"></appwidget-provider>

Параметр initialLayout задает имя xml-файла с визуальной разметкой виждета. minHeight и min-Width — минимальные размеры добавляемого виджета, updatePeriodMillis — время обновления информации в мс, но не чаще раза в полчаса (параметр 10 мс все равно воспринимается как минимальные 30 мин).

Второй xml-файл (res → layout → btcwidget.xml) содержит параметры визуального отображения виджета (разметка визуальных элементов).

В нем находится описание одного визуального элемента TextView внутри разметки RelativeLayout (Layouts):

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#09C"
    android:padding="@dimen/widget_margin">
    <TextView
        android:id="@+id/appwidget_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:layout_margin="8dp"
        android:background="#09C"
        android:contentDescription="@string/appwidget_text"
        android:text="@string/appwidget_text"
        android:textColor="#ffffff"
        android:textSize="20sp"
        android:textStyle="bold|italic"
        />
</RelativeLayout>

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

Шаблон кода виджета
/**
 * Implementation of App Widget functionality.
 */
public class BTCWidget extends AppWidgetProvider {

      static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
                                int appWidgetId) {

        CharSequence widgetText = context.getString(R.string.appwidget_text);
        // Construct the RemoteViews object
        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.btcwidget);
        views.setTextViewText(R.id.appwidget_text, widgetText);

        // Instruct the widget manager to update the widget
        appWidgetManager.updateAppWidget(appWidgetId, views);
    }

    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        // There may be multiple widgets active, so update all of them
        for (int appWidgetId : appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId);
        }
    }

    @Override
    public void onEnabled(Context context) {
        // Enter relevant functionality for when the first widget is created
    }

    @Override
    public void onDisabled(Context context) {
        // Enter relevant functionality for when the last widget is disabled
    }
}


Стоит сразу сказать, что ОС Android позволяет создавать большое количество экземпляров нашего виджета и их поведение будет целиком описываться приведенным выше кодом. Для более глубокого понимания поведения и жизненного цикла виджетов рекомендую ознакомиться со статьей Android Widget. Ниже в комментариях кода я объясню лишь некоторые моменты.

Столкнулся со следущей сложностью: система позволяет виджету обновляться (методом updateAppWidget) не чаще чем раз в 30 минут по соображениям экономии батареи. Но мне хотелось иметь возможность обновлять данные в нужный момент и я нашел способ обойти это ограничение. Для этого виджет был запрограммирован к принудительному обновлению по клику на него. Реализовал такое действие следующим образом: по нажатию на виджет в систему отправляется интент (Intent), который ловится самим же виджетом и обрабатывается запуском обновления данных. Если кто-то знает способ проще — буду рад советам в комментариях.

Исходный код виджета с добавленной функциональностью

/**
 * Implementation of App Widget functionality.
 */
public class BTCwidget extends AppWidgetProvider {

    private static final String SYNC_CLICKED    = "btcwidget_update_action";
    private static final String WAITING_MESSAGE = "Wait for BTC price";
    public static final int httpsDelayMs = 300;

    //этот метод выполняется, когда пора обновлять виджет
    static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
                                int appWidgetId)  {

        //Объект RemoteViews дает нам доступ к отображаемым в виджете элементам:
        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.btcwidget);
        //в данном случае - к TextView
        views.setTextViewText(R.id.appwidget_text, WAITING_MESSAGE);
        appWidgetManager.updateAppWidget(appWidgetId, views);

        String output;
        //запускаем отдельный поток для получения данных с сайта биржи
        //в основном потоке делать запрос нельзя - выбросит исключение
        HTTPRequestThread thread = new HTTPRequestThread();
        thread.start();
        try {
            while (true) {
                Thread.sleep(300);
                if(!thread.isAlive()) {
                    output = thread.getInfoString();
                    break;
                }
            }

        } catch (Exception e) {
            output = e.toString();
        }
       //выводим в виджет результат
        views.setTextViewText(R.id.appwidget_text, output);

        // Instruct the widget manager to update the widget
        appWidgetManager.updateAppWidget(appWidgetId, views);
    }

    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {

        RemoteViews remoteViews;
        ComponentName watchWidget;

        remoteViews = new RemoteViews(context.getPackageName(), R.layout.btcwidget);
        watchWidget = new ComponentName(context, BTCwidget.class);

        //при клике на виджет в систему отсылается вот такой интент, описание метода ниже
        remoteViews.setOnClickPendingIntent(R.id.appwidget_text,   getPendingSelfIntent(context, SYNC_CLICKED));
        appWidgetManager.updateAppWidget(watchWidget, remoteViews);

        //обновление всех экземпляров виджета
         for (int appWidgetId : appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId);
        }

    }

   //этот метод ловит интенты, срабатывает когда интент создан нажатием на виджет и
  //запускает обновление виджета 
   @Override
    public void onReceive(Context context, Intent intent) {

        super.onReceive(context, intent);

        if (SYNC_CLICKED.equals(intent.getAction())) {

            AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);

            RemoteViews remoteViews;
            ComponentName watchWidget;

            remoteViews = new RemoteViews(context.getPackageName(), R.layout.btcwidget);
            watchWidget = new ComponentName(context, BTCwidget.class);

            remoteViews.setTextViewText(R.id.appwidget_text, WAITING_MESSAGE);

            //updating widget
            appWidgetManager.updateAppWidget(watchWidget, remoteViews);

            String output;
            HTTPRequestThread thread = new HTTPRequestThread();
            thread.start();
            try {
                while (true) {
                    Thread.sleep(httpsDelayMs);
                    if(!thread.isAlive()) {
                        output = thread.getInfoString();
                        break;
                    }
                }

            } catch (Exception e) {
                output = e.toString();
            }

            remoteViews.setTextViewText(R.id.appwidget_text, output);

            //widget manager to update the widget
            appWidgetManager.updateAppWidget(watchWidget, remoteViews);

        }
    }
   
    //создание интента
    protected PendingIntent getPendingSelfIntent(Context context, String action) {
        Intent intent = new Intent(context, getClass());
        intent.setAction(action);
        return PendingIntent.getBroadcast(context, 0, intent, 0);
    }
}


Содержимое класса HTTPRequestThread.java:

Посмотреть:
package com.hakey.btcwidget;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Calendar;

class HTTPRequestThread extends Thread{
    private static final String urlString = "https://btc-e.nz/api/3/ticker/btc_usd";

    String getInfoString() {
        return output;
    }

    private String output = "";

    private void requestPrice() {

        try {
            URL url = new URL(urlString);
            HttpURLConnection con = (HttpURLConnection) url.openConnection();
            con.setRequestMethod("GET");
            BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
            String inputLine;
            StringBuilder response = new StringBuilder();

            while ((inputLine = in.readLine()) != null) {
                response.append(inputLine);
            }
            in.close();

            output = "Price: " + JSONParser.getPrice(response.toString())
                    + "\n" + getTimeStamp();

        } catch (Exception e) {
            output = e.toString();
        }
    }

    @Override
    public void run() {
        requestPrice();
    }

    private String getTimeStamp() {
        Calendar calendar = Calendar.getInstance();
        if(calendar.get(Calendar.MINUTE)>9) {

            return "Time: " + calendar.get(Calendar.HOUR_OF_DAY)
                    + ":" + calendar.get(Calendar.MINUTE);
        } else {
            return "Time: " + calendar.get(Calendar.HOUR_OF_DAY)
                    + ":0" + calendar.get(Calendar.MINUTE);
        }

    }
}


Парсер ответа с сервера — JSONParser.java:

Смотреть:

package com.hakey.btcwidget;

import org.json.JSONException;
import org.json.JSONObject;

class JSONParser {

    static String getPrice(String s) throws JSONException {
        String price;
        JSONObject obj = new JSONObject(s);
        JSONObject pairObj = obj.getJSONObject("btc_usd");
        price = pairObj.getString("last");

        return price;
    }
}


Вот так выглядит описанный выше виджет:

Скриншот
image

Полный исходный код доступен здесь: github.com/hakeydotom/BTCPriceWidget
@hakey
карма
7,0
рейтинг 0,0
Программист, исследователь

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

  • +1
    Столкнулся со следущей сложностью: система позволяет виджету обновляться (методом updateAppWidget) не чаще чем раз в 30 минут по соображениям экономии батареи. Но мне хотелось иметь возможность видеть данные в реальном времени и я нашел способ обойти это ограничение. Для этого виджет был запрограммирован к принудительному обновлению по клику на него.

    Вы серьёзно? Нет вы серьёзно назвали реал тайм обновление, обновлением по клику? =)
    Виджет это BroadcastReceiver, поэтому нет никаких проблем по его обновлению. Вот кусок моего кода
         	@Override
        public void onReceive(Context context, Intent intent) {
            super.onReceive(context, intent);
    
            int widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
            AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
    
            switch (intent.getAction()) {
                case ACTION_CHECKPOINT_LIST_CLICKED:
                    if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
                        return;
                    }
                    updateWidget(context, appWidgetManager, widgetId, WidgetPreference.LIST_TYPE_CHECKPOINTS);
                    appWidgetManager.notifyAppWidgetViewDataChanged(widgetId, R.id.list_view);
                    break;
    		}
    	}
    

    У меня виджет обновляется с помощью оповещениия сервисом о том, что данные обновились. Эти данные могут и каждую секунду у меня обновляться.

    Делать логику работы с сетью в бродкастресивере (виджете), не самая лучшая идея. Куда правильней сделать для этого какой-нибудь сервис, к примеру IntentService.

    В JSON парсере нет проверки на валидность.

    Учитывайте что BroadcastReceiver живёт 10 секунд. Это опять к слову о том, что не надо работать с сетью тут.
    • +1
      Добрый день, Михаил! Принимаю первое замечание, исправил «обновление в реальном времени» на «обновление по клику».
      Решил не выносить механизм обновления в отдельный сервис, так как посчитал это излишне затратным с точки зрения ресурсов. Тестирование показало, что поток интернет-активности в худшем случае отрабатывет в секунду. В общем случае Ваше предложение, конечно, правильно.
      По поводу проверки на корректность JSON: программа в принципе мало что проверяет, не в этом была цель. Возможные исключения ловятся и выводятся в TextView виджета.

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