天天看點

Android清單小部件(Widget)開發詳解

好久沒部落格更新了,本篇文章來學習一下如何實作一個Android清單小部件,效果可以參看下圖:

Android清單小部件(Widget)開發詳解

這個頁面如果是在App内部實作,相信隻要有一點Android基礎的童鞋都能很輕松寫出來。但是如果放到Widget中可能就不是那麼簡單了。因為Widget并沒有運作在我們App的程序中,而是運作在系統的SystemServer程序中。你可能會驚訝,Whf!竟然不在我們App程序中!那麼是不是意味着我們也不能像在App中那樣操作View控件了?答案确實如此。不過不必過于擔心,為了我們能在遠端程序中更新界面,Google爸爸專門為我們提供了一個RemoteViews類。從名字上看,可能會覺得RemoteViews就是一個View。但事實并非如此,RemoteViews僅僅表示的是一個View結構。它可以在遠端程序中展示和更新界面。今天我們要實作的清單小部件就是基于RemoteVeiw實作的。

那麼接下來我們來學習如何實作一個桌面Widget,我們先列出要實作Widget的幾個核心步驟:

  • widget頁面布局
  • 小部件配置資訊
  • 了解AppWidgetProvider
  • RemoteViewsFactory實作清單适配
  • 點選的事件處理

一. 實作Widget界面

1.widget頁面布局 首先建立一個布局檔案layout_widget.xml,内容如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/ll_right"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/bg_widget"
    android:orientation="vertical">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="40dp"
        android:background="#ccc">

        <ImageView
            android:id="@+id/iv_icon"
            android:layout_width="30dp"
            android:layout_height="30dp"
            android:layout_centerVertical="true"
            android:layout_marginEnd="5dp"
            android:layout_marginStart="5dp"
            android:background="@mipmap/ic_launcher_round" />

        <TextView
            android:id="@+id/tv_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:layout_toEndOf="@id/iv_icon"
            android:text="Widget" />

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:layout_alignParentEnd="true"
            android:gravity="center_vertical"
            android:orientation="horizontal">

            <ProgressBar
                android:id="@+id/progress_bar"
                android:layout_width="20dp"
                android:layout_height="20dp"
                android:indeterminateTint="@color/colorAccent"
                android:indeterminateTintMode="src_atop"
                android:visibility="gone" />

            <TextView
                android:id="@+id/tv_refresh"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="15dp"
                android:text="重新整理"
                android:padding="5dp"
                android:textSize="12sp" />
        </LinearLayout>

    </RelativeLayout>

    <ListView
        android:id="@+id/lv_device"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:columnWidth="80dip"
        android:gravity="center"
        android:horizontalSpacing="4dip"
        android:numColumns="auto_fit"
        android:verticalSpacing="4dip" />

</LinearLayout>
           

看到布局中的ListView控件,你可能會不屑一笑,都什麼年代了還在用ListView?RecyclerView才是王道吧?可是我隻能說句抱歉,Widget不支援RecyclerView。對,你沒看錯,真的不支援。在Widget中我們沒辦法做到想用什麼就用什麼,甚至覺得原生用着不爽,自己撸一個控件出來。對不起,Widget都不支援。是以Widget也有很大的局限性。我們來看下支援在Widget中運作的有哪些控件:

A RemoteViews object (and, consequently, an App Widget) can support the following layout classes:

FrameLayout

LinearLayout

RelativeLayout

GridLayout

And the following widget classes:

AnalogClock

Button

Chronometer

ImageButton

ImageView

ProgressBar

TextView

ViewFlipper

ListView

GridView

StackView

AdapterViewFlipper

Descendants of these classes are not supported.

除了上述列出的幾個View,其它的包括Android原生View和自定義View是都不支援在Widget中運作的。是以基于Widget頁面限制我們基本就可以告别炫酷的動畫效果了。

二.小部件配置資訊

配置資訊主要是設定小部件的一些屬性,比如寬高、縮放模式、更新時間間隔等。我們需要在res/xml目錄下建立widget_provider.xml檔案,檔案名字可以任意取。檔案内容如下(可做參考):

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:minHeight="180dp"
    android:minWidth="300dp"
    android:previewImage="@drawable/ic_launcher_background"
    android:initialLayout="@layout/layout_widget"
    android:updatePeriodMillis="50000"
    android:resizeMode="horizontal|vertical"
    android:widgetCategory="home_screen"> 
    
</appwidget-provider>
           

針對上述檔案中的配置資訊來做下介紹。

  • minHeight、minWidth 定義Widget的最小高度和最小寬度(Widget可以通過拉伸來調整尺寸大小)。
  • previewImage 定義添加小部件時顯示的圖示。
  • initialLayout 定義了小部件使用的布局。
  • updatePeriodMillis 定義小部件自動更新的周期,機關為毫秒。
  • resizeMode 指定了 widget 的調整尺寸的規則。可取的值有: “horizontal”, “vertical”, “none”。“horizontal"意味着widget可以水準拉伸,“vertical”意味着widget可以豎值拉伸,“none”意味着widget不能拉伸;預設值是"none”。
  • widgetCategory 指定了 widget 能顯示的地方:能否顯示在 home Screen 或 lock screen 或 兩者都可以。它的取值包括:“home_screen” 和 “keyguard”。Android 4.2 引入。

    最後,需要我們在AndroidManifest中注冊AppWidgetProvider時引用該檔案,使用如下:

<receiver android:name=".widget.ListWidgetProvider">
     ...
    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/widget_provider" />
</receiver>
           

三.了解AppWidgetProvider類

我們來簡單了解下AppWidgetProvider這個類。Widget的功能均是通過AppWidgetProvider來實作的。我們跟進源碼可以發現它是繼承自BroadcastReceiver類,也就是一個廣播接收者。上面我們提到過RemoteViews是運作在SystemServer程序中的,再結合此處我們應該可以推測小部件的事件應該是通過廣播來實作的。像小部件的添加、删除、更新、啟用、禁用等均是在AppWidgetProvider中通過接受廣播來完成的。看AppWidgetProvider中的幾個方法:

  • onUpdate() 當Widget被添加或者被更新時會調用該方法。上邊我們提到通過配置updatePeriodMillis可以定期更新Widget。但是當我們在widget的配置檔案中聲明了android:configure的時候,添加Widget時則不會調用onUpdate方法。
  • onEnable() 這個方法會在使用者首次添加Widget時調用。
  • onAppWidgetOptionsChanged() 這個方法會在添加Widget或者改變Widget的大小時候被調用。在這個方法中我們還可以根據Widget的大小來選擇性的顯示或隐藏某些控件。
  • onDeleted(Context, int[]) 當控件被删除的時候調用該方法
  • onEnabled(Context) 當第一個Widget被添加的時候調用。如果使用者添加了兩個這個小部件,那麼隻有第一個添加時才會調用onEnabled.
  • onDisabled(Context) 當最後一個Widget執行個體被移除的時候調用這個方法。在這個方法中我們可以做一些清除工作,例如删掉臨時的資料庫等。
  • onReceive(Context, Intent) 當接收到廣播的時候會被調用。

上述方法中,我們需要着重關心一下onUpdate()方法和onReceive()方法。因為onUpdate()方法會在Widget被添加時候調用,我們可以在此時為Widget添加一View的些互動事件,例如點選事件。由于本篇我們要實作的是一個清單小部件。是以我們還需要RemoteViewsFactory這個類來适配清單資料。

先來看下ListWidgetProvider這個類中的代碼:

public class ListWidgetProvider extends AppWidgetProvider {

    private static final String TAG = "WIDGET";

    public static final String REFRESH_WIDGET = "com.oitsme.REFRESH_WIDGET";
    public static final String COLLECTION_VIEW_ACTION = "com.oitsme.COLLECTION_VIEW_ACTION";
    public static final String COLLECTION_VIEW_EXTRA = "com.oitsme.COLLECTION_VIEW_EXTRA";
    private static Handler mHandler=new Handler();
    private Runnable runnable=new Runnable() {
        @Override
        public void run() {
            hideLoading(Utils.getContext());
            Toast.makeText(Utils.getContext(), "重新整理成功", Toast.LENGTH_SHORT).show();
        }
    };

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

        Log.d(TAG, "ListWidgetProvider onUpdate");
        for (int appWidgetId : appWidgetIds) {
            // 擷取AppWidget對應的視圖
            RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.layout_widget);

            // 設定響應 “按鈕(bt_refresh)” 的intent
            Intent btIntent = new Intent().setAction(REFRESH_WIDGET);
            PendingIntent btPendingIntent = PendingIntent.getBroadcast(context, 0, btIntent, PendingIntent.FLAG_UPDATE_CURRENT);
            remoteViews.setOnClickPendingIntent(R.id.tv_refresh, btPendingIntent);

            // 設定 “ListView” 的adapter。
            // (01) intent: 對應啟動 ListWidgetService(RemoteViewsService) 的intent
            // (02) setRemoteAdapter: 設定 gridview的擴充卡
            //    通過setRemoteAdapter将ListView和ListWidgetService關聯起來,
            //    以達到通過 ListWidgetService 更新 ListView的目的
            Intent serviceIntent = new Intent(context, ListWidgetService.class);
            remoteViews.setRemoteAdapter(R.id.lv_device, serviceIntent);


            // 設定響應 “ListView” 的intent模闆
            // 說明:“集合控件(如GridView、ListView、StackView等)”中包含很多子元素,如GridView包含很多格子。
            //     它們不能像普通的按鈕一樣通過 setOnClickPendingIntent 設定點選事件,必須先通過兩步。
            //        (01) 通過 setPendingIntentTemplate 設定 “intent模闆”,這是比不可少的!
            //        (02) 然後在處理該“集合控件”的RemoteViewsFactory類的getViewAt()接口中 通過 setOnClickFillInIntent 設定“集合控件的某一項的資料”
            Intent gridIntent = new Intent();

            gridIntent.setAction(COLLECTION_VIEW_ACTION);
            gridIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
            PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, gridIntent, PendingIntent.FLAG_UPDATE_CURRENT);
            // 設定intent模闆
            remoteViews.setPendingIntentTemplate(R.id.lv_device, pendingIntent);
            // 調用集合管理器對集合進行更新
            appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
        }
        super.onUpdate(context, appWidgetManager, appWidgetIds);
    }


    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
        if (action.equals(COLLECTION_VIEW_ACTION)) {
            // 接受“ListView”的點選事件的廣播
            int type = intent.getIntExtra("Type", 0);
            int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
                    AppWidgetManager.INVALID_APPWIDGET_ID);
            int index = intent.getIntExtra(COLLECTION_VIEW_EXTRA, 0);
            switch (type) {
                case 0:
                    Toast.makeText(context, "item" + index, Toast.LENGTH_SHORT).show();
                    break;
                case 1:
                    Toast.makeText(context, "lock"+index, Toast.LENGTH_SHORT).show();
                    break;
                case 2:
                    Toast.makeText(context, "unlock"+index, Toast.LENGTH_SHORT).show();
                    break;
            }
        } else if (action.equals(REFRESH_WIDGET)) {
            // 接受“bt_refresh”的點選事件的廣播
            Toast.makeText(context, "重新整理...", Toast.LENGTH_SHORT).show();
            final AppWidgetManager mgr = AppWidgetManager.getInstance(context);
            final ComponentName cn = new ComponentName(context,ListWidgetProvider.class);
            ListRemoteViewsFactory.refresh();
            mgr.notifyAppWidgetViewDataChanged(mgr.getAppWidgetIds(cn),R.id.lv_device);
            mHandler.postDelayed(runnable,2000);
            showLoading(context);
        }
        super.onReceive(context, intent);
    }

    /**
     * 顯示加載loading
     *
     */
    private void showLoading(Context context) {
        RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.layout_widget);
        remoteViews.setViewVisibility(R.id.tv_refresh, View.VISIBLE);
        remoteViews.setViewVisibility(R.id.progress_bar, View.VISIBLE);
        remoteViews.setTextViewText(R.id.tv_refresh, "正在重新整理...");
        refreshWidget(context, remoteViews, false);
    }

    /**
     * 隐藏加載loading
     */
    private void hideLoading(Context context) {
        RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.layout_widget);
        remoteViews.setViewVisibility(R.id.progress_bar, View.GONE);
        remoteViews.setTextViewText(R.id.tv_refresh, "重新整理");
        refreshWidget(context, remoteViews, false);
    }



    /**
     * 重新整理Widget
     */
    private void refreshWidget(Context context, RemoteViews remoteViews, boolean refreshList) {
        AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
        ComponentName componentName = new ComponentName(context, ListWidgetProvider.class);
        appWidgetManager.updateAppWidget(componentName, remoteViews);
        if (refreshList)
            appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetManager.getAppWidgetIds(componentName), R.id.lv_device);
    }
}
           

針對以上代碼,我們着重來看onUpdate()方法。在onUpdate()中我們主要實作了兩個功能,第一個功能ListView以外的事件點選,例如點選“重新整理”來更新小部件。第二個功能是适配ListView并實作ListView内部Item控件的點選事件。在這個方法中我們首先擷取到了一個RemoteView的執行個體,這個RemoteView對應的就是我們Widget布局的View。關于點選事件的實作代碼中注釋寫的也比較詳細,在這裡就不做過多解釋了。重點是需要了解如何實作并适配ListView,具體實作請看下節。

四.RemoteViewsFactory實作清單适配

上面我們提到了RemoteViewsFactory,這個類其實可以類比為ListView的Adapter,該類存在的意義就是為了适配ListView的資料。隻不過這裡是把Adapter換成RemoteViews來實作的。看下ListRemoteViewsFactory中的代碼:

class ListRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
    private final static String TAG="Widget";
    private Context mContext;
    private int mAppWidgetId;

    private static List<Device> mDevices;

    /**
     * 構造GridRemoteViewsFactory
     */
    public ListRemoteViewsFactory(Context context, Intent intent) {
        mContext = context;
        mAppWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
                AppWidgetManager.INVALID_APPWIDGET_ID);
    }

    @Override
    public RemoteViews getViewAt(int position) {
        //  HashMap<String, Object> map;

        // 擷取 item_widget_device.xml 對應的RemoteViews
        RemoteViews rv = new RemoteViews(mContext.getPackageName(), R.layout.item_widget_device);

        // 設定 第position位的“視圖”的資料
        Device device = mDevices.get(position);
        //  rv.setImageViewResource(R.id.iv_lock, ((Integer) map.get(IMAGE_ITEM)).intValue());
        rv.setTextViewText(R.id.tv_name, device.getName());

        // 設定 第position位的“視圖”對應的響應事件
        Intent fillInIntent = new Intent();
        fillInIntent.putExtra("Type", 0);
        fillInIntent.putExtra(ListWidgetProvider.COLLECTION_VIEW_EXTRA, position);
        rv.setOnClickFillInIntent(R.id.rl_widget_device, fillInIntent);


        Intent lockIntent = new Intent();
        lockIntent.putExtra(ListWidgetProvider.COLLECTION_VIEW_EXTRA, position);
        lockIntent.putExtra("Type", 1);
        rv.setOnClickFillInIntent(R.id.iv_lock, lockIntent);

        Intent unlockIntent = new Intent();
        unlockIntent.putExtra("Type", 2);
        unlockIntent.putExtra(ListWidgetProvider.COLLECTION_VIEW_EXTRA, position);
        rv.setOnClickFillInIntent(R.id.iv_unlock, unlockIntent);

        return rv;
    }


    /**
     * 初始化ListView的資料
     */
    private void initListViewData() {
        mDevices = new ArrayList<>();
        mDevices.add(new Device("Hello", 0));
        mDevices.add(new Device("Oitsme", 1));
        mDevices.add(new Device("Hi", 0));
        mDevices.add(new Device("Hey", 1));
    }
    private static int i;
    public static void refresh(){
        i++;
        mDevices.add(new Device("Refresh"+i, 1));
    }

    @Override
    public void onCreate() {
        Log.e(TAG,"onCreate");
        // 初始化“集合視圖”中的資料
        initListViewData();
    }

    @Override
    public int getCount() {
        // 傳回“集合視圖”中的資料的總數
        return mDevices.size();
    }

    @Override
    public long getItemId(int position) {
        // 傳回目前項在“集合視圖”中的位置
        return position;
    }

    @Override
    public RemoteViews getLoadingView() {
        return null;
    }

    @Override
    public int getViewTypeCount() {
        // 隻有一類 ListView
        return 1;
    }

    @Override
    public boolean hasStableIds() {
        return true;
    }

    @Override
    public void onDataSetChanged() {
    }

    @Override
    public void onDestroy() {
        mDevices.clear();
    }
}
           

有了RemoteViewsFactory 還需要有RemoteViewsService才能與ListView關聯起來。來看RemoteViewsService的實作類ListWidgetService,很簡單,隻重寫了onGetViewFactory方法:

public class ListWidgetService extends RemoteViewsService {

    @Override
    public RemoteViewsService.RemoteViewsFactory onGetViewFactory(Intent intent) {
        return new ListRemoteViewsFactory(this, intent);
    }
}
           

至此我們可以再次回到ListWidgetProvider中的onUpdate()方法,來看ListWidgetService 是如何與ListView關聯到一起的了。

//  設定 “ListView” 的adapter。
 // (01) intent: 對應啟動 ListWidgetService(RemoteViewsService) 的intent
 // (02) setRemoteAdapter: 設定 ListView的擴充卡
 //  通過setRemoteAdapter将ListView和ListWidgetService關聯起來,
 //  以達到通過 ListWidgetService 更新 ListView 的目的
  Intent serviceIntent = new Intent(context, ListWidgetService.class);
  remoteViews.setRemoteAdapter(R.id.lv_device, serviceIntent);
           

五.點選事件處理

Widget中事件點選以及适配ListView,想必大家都有所了解了。那麼對于事件的處理我們還沒有提到,例如在Widget中點選了重新整理後我們不能像在App中那樣給控件設定一個事件監聽來在回掉方法中處理。在文章開頭我們就提到了Widget是依賴廣播來實作,是以我們點選了重新整理後其實僅僅是發送出來一個廣播。如果我們不去處理廣播那麼點選事件其實是沒有任何意義的。是以,來看ListWidgetProvider中第二個比較重要的方法onReceive()。這個方法比較簡單,隻要我們對特定的廣播來做相應的處理就可以了。

@Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
	        if (action.equals(COLLECTION_VIEW_ACTION)) {//處理清單中的事件
            // 接受“ListView”的點選事件的廣播
            int type = intent.getIntExtra("Type", 0);
            int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
                    AppWidgetManager.INVALID_APPWIDGET_ID);
            int index = intent.getIntExtra(COLLECTION_VIEW_EXTRA, 0);
            switch (type) {
                case 0:
                    Toast.makeText(context, "item" + index, Toast.LENGTH_SHORT).show();
                    break;
                case 1:
                    Toast.makeText(context, "lock"+index, Toast.LENGTH_SHORT).show();
                    break;
                case 2:
                    Toast.makeText(context, "unlock"+index, Toast.LENGTH_SHORT).show();
                    break;
            }
        } else if (action.equals(REFRESH_WIDGET)) {//處理重新整理事件
            // 接受“bt_refresh”的點選事件的廣播
            Toast.makeText(context, "重新整理...", Toast.LENGTH_SHORT).show();
            final AppWidgetManager mgr = AppWidgetManager.getInstance(context);
            final ComponentName cn = new ComponentName(context,ListWidgetProvider.class);
            ListRemoteViewsFactory.refresh();
            mgr.notifyAppWidgetViewDataChanged(mgr.getAppWidgetIds(cn),R.id.lv_device);
            mHandler.postDelayed(runnable,2000);
            showLoading(context);
        }
        super.onReceive(context, intent);
    }
           

最後,别忘了ListWidgetProvider是廣播,ListWidgetService是服務,都需要我們在AndroidManifest檔案中來注冊:

<receiver android:name=".widget.ListWidgetProvider">
            <intent-filter>
                <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />

                <!-- ListWidgetProvider接收點選ListView的響應事件 -->
                <action android:name="com.oitsme.COLLECTION_VIEW_ACTION" />
                <!-- ListWidgetProvider接收點選bt_refresh的響應事件 -->
                <action android:name="com.oitsme.REFRESH_WIDGET" />
                <action android:name="com.oitsme.LOCK_ACTION"/>
                <action android:name="com.oitsme.UNLOCK_ACTION"/>
            </intent-filter>
            <meta-data android:name="android.appwidget.provider"
                android:resource="@xml/widget_provider"/>
        </receiver>

        <service
            android:name=".widget.ListWidgetService"
            android:permission="android.permission.BIND_REMOTEVIEWS" />
           

六.小結

至此關于清單小部件的講解就完成了。隻是自我感覺文章的邏輯有點亂。如果沒明白,大家可以參考下面Demo源碼。其實關于Widget的這個Demo其實早在幾個月前就已經寫好了,但由于最近項目緊再加上本身也是第一次接觸Widget控件,是以直至近日才開始動筆寫這篇文章。是以文章中避免不了有錯誤和不合理的地方,歡迎留言指正。

參考

https://developer.android.com/guide/topics/appwidgets/

源碼下載下傳

開源庫推薦

BannerViewPager

一個基于ViewPager2實作的具有強大功能的無限輪播庫。支援多種頁面切換效果和訓示器樣式。

ViewPagerIndicator

一個适用于ViewPager和ViewPager2的訓示器,支援多種滑塊樣式及滑動模式