Android列表小部件(Widget)開發
原文: https://blog.csdn.net/qq_20521573/article/details/79174481
好久沒博客更新了,本篇文章來學習一下如何實現一個Android列表小部件,效果可以參看下圖:
這個頁面如果是在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控件,因此直至近日纔開始動筆寫這篇文章。所以文章中避免不了有錯誤和不合理的地方,歡迎留言指正。