把你的程式放到桌面——Android桌面部件Widget

Wing_Li發表於2018-12-12

如果本文幫助到你,本人不勝榮幸,如果浪費了你的時間,本人深感抱歉。 希望用最簡單的大白話來幫助那些像我一樣的人。如果有什麼錯誤,請一定指出,以免誤導大家、也誤導我。 本文來自:www.jianshu.com/u/320f9e8f7… 感謝您的關注。

Android 桌面小部件是我們經常看到的,比如時鐘、天氣、音樂播放器等等。 它可以讓 App 的某些功能直接展示在桌面上,極大的增加了使用者的關注度。

首先糾正一個誤區: 當 App 的小部件被放到了桌面之後,並不代表你的 App 就可以一直在手機後臺執行了。該被殺,它還是會被殺掉的。 所以如果你做小部件的目的是為了讓程式常駐後臺,那麼你可以死心了。

但是!!! 雖然它還是能被殺掉,但是使用者能看的見它了啊,使用者可以點選就開啟我們的 APP,所以還是很不錯的。


Android 桌面小部件可以做什麼?

小部件可以做什麼呢?也就是我們需要實現什麼功能。

  1. 展示。每隔 N 秒/分鐘,重新整理一次資料;
  2. 互動。點選操作 App 的資料;
  3. 開啟App。開啟主頁或指定頁面。

這三個功能,大概就能滿足我們絕大部分需求了吧。

實現桌面小部件需要什麼?

如果你從來沒有做過桌面部件,那肯定總是感覺有點慌,無從下手,毫無邏輯。 所以,實現它到底需要什麼呢?

  1. 先宣告 Widget 的一些屬性。 在 res 新建 xml 資料夾,建立 appwidget-provider 標籤的 xml 檔案。
  2. 建立桌面要顯示的佈局。 在 layout 建立 app_widget.xml。
  3. 然後來管理 Widget 狀態。 實現一個繼承 AppWidgetProvider 的類。
  4. 最後在 AndroidManifest.xml 裡,將 AppWidgetProvider類 和 xml屬性 註冊到一塊。
  5. 通常我們會加一個 Service 來控制 Widget 的更新時間,後面再講為什麼。

做完這些,如果不出錯,就完成了桌面部件。 其實挺簡單的,下面就讓我們來看看具體的實現吧。


實現一個桌面計數器

先上效果圖:

把你的程式放到桌面——Android桌面部件Widget

1. 宣告 Widget 的屬性

在 res 新建 xml 資料夾,建立一個 app_widget.xml 的檔案。 如果 res 下沒有 xml 檔案,則先建立。

app_widget.xml 內容如下:

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
					android:initialLayout="@layout/app_widget"
					android:minHeight="110dp"
					android:minWidth="110dp"
					android:previewImage="@mipmap/ic_launcher"
					android:resizeMode="horizontal|vertical"
					android:widgetCategory="home_screen|keyguard">

	<!--
	android:minWidth : 最小寬度
	android:minHeight : 最小高度
	android:updatePeriodMillis : 更新widget的時間間隔(ms),"86400000"為1個小時,值小於30分鐘時,會被設定為30分鐘。可以用 service、AlarmManager、Timer 控制。
	android:previewImage : 預覽圖片,拖動小部件到桌面時有個預覽圖
	android:initialLayout : 載入到桌面時對應的佈局檔案
	android:resizeMode : 拉伸的方向。horizontal表示可以水平拉伸,vertical表示可以豎直拉伸
	android:widgetCategory : 被顯示的位置。home_screen:將widget新增到桌面,keyguard:widget可以被新增到鎖屏介面。
	android:initialKeyguardLayout : 載入到鎖屏介面時對應的佈局檔案
	 -->
</appwidget-provider>
複製程式碼

屬性的註釋在上面寫的很清楚了,這裡需要說兩點。

  1. 關於寬度和高度的數值定義是很有講究的,在桌面其實是按照“格子”排列的。 看 Google 給的圖。上面我們程式碼定義 110dp 也就是說,它佔了2*2的空間。

把你的程式放到桌面——Android桌面部件Widget

  1. 第二點很重要。有個 updatePeriodMillis 屬性,更新widget的時間間隔(ms)。 官方給提供了小部件的自動更新時間,但是卻給了限制,你更新的時間必須大於30分鐘,如果小於30分鐘,那預設就是30分鐘。 可以我們就是要5分鐘更新啊,怎麼辦呢? 所以就不能使用這個預設更新,我們要自己來通過傳送廣播控制更新時間,也就是一開始總步驟裡面第4步,加一個 Service 來控制 Widget 的更新時間,這個在最後一步新增。

2. 建立佈局檔案

在 layout 建立 app_widget.xml 檔案。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:gravity="center_horizontal"
              android:orientation="vertical">

    <TextView
        android:id="@+id/widget_txt"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="0"
        android:textSize="36sp"
        android:textStyle="bold"/>

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <Button
            android:id="@+id/widget_btn_reset"
            style="@style/Widget.AppCompat.Toolbar.Button.Navigation"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="恢復"/>

        <Button
            android:id="@+id/widget_btn_open"
            style="@style/Widget.AppCompat.Toolbar.Button.Navigation"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:text="開啟頁面"/>
    </LinearLayout>
</LinearLayout>
複製程式碼

這裡要注意的就是 桌面部件並不支援 Android 所有的控制元件。 支援的控制元件如下:

App Widget支援的佈局:
       FrameLayout
       LinearLayout
       RelativeLayout
       GridLayout
App Widget支援的控制元件:
       AnalogClock
       Button
       Chronometer
       ImageButton
       ImageView
       ProgressBar
       TextView
       ViewFlipper
       ListView
       GridView
       StackView
       AdapterViewFlipper
複製程式碼

3. 管理 Widget 狀態

這裡程式碼看起來可能有點多,先聽我講幾個邏輯,再來看程式碼。

  1. Android 的各種東西都有自己的生命週期,Widget 也不例外,它有幾個方法來管理自己的生命週期。

把你的程式放到桌面——Android桌面部件Widget

  1. 同一個小部件是可以新增多次的,所以更新控制元件的時候,要把所有的都更新。

  2. onReceive() 用來接收廣播,它並不在生命週期裡。但是,其實 onReceive() 是掌控生命週期的。 如下是 onReceive() 父類的原始碼,右邊是每個廣播對應的方法。 上面我畫的生命週期的圖,也比較清楚。

把你的程式放到桌面——Android桌面部件Widget

然後我們再來看程式碼。 新建一個 WidgetProvider 類,繼承 AppWidgetProvider。 主要邏輯在 onReceive() 裡,其他的都是生命週期切換時,所處理的事情。 我們在下面分析 onReceive()。

public class WidgetProvider extends AppWidgetProvider {

	// 更新 widget 的廣播對應的action
	private final String ACTION_UPDATE_ALL = "com.lyl.widget.UPDATE_ALL";
	// 儲存 widget 的id的HashSet,每新建一個 widget 都會為該 widget 分配一個 id。
	private static Set idsSet = new HashSet();

	public static int mIndex;

	/**
	 * 接收視窗小部件點選時傳送的廣播
	 */
	@Override
	public void onReceive(final Context context, Intent intent) {
		super.onReceive(context, intent);
		final String action = intent.getAction();

		if (ACTION_UPDATE_ALL.equals(action)) {
			// “更新”廣播
			updateAllAppWidgets(context, AppWidgetManager.getInstance(context), idsSet);
		} else if (intent.hasCategory(Intent.CATEGORY_ALTERNATIVE)) {
			// “按鈕點選”廣播
			mIndex = 0;
			updateAllAppWidgets(context, AppWidgetManager.getInstance(context), idsSet);
		}
	}

	// 更新所有的 widget
	private void updateAllAppWidgets(Context context, AppWidgetManager appWidgetManager, Set set) {
		// widget 的id
		int appID;
		// 迭代器,用於遍歷所有儲存的widget的id
		Iterator it = set.iterator();

		// 要顯示的那個數字,每更新一次 + 1
		mIndex++; // TODO:可以在這裡做更多的邏輯操作,比如:資料處理、網路請求等。然後去顯示資料

		while (it.hasNext()) {
			appID = ((Integer) it.next()).intValue();

			// 獲取 example_appwidget.xml 對應的RemoteViews
			RemoteViews remoteView = new RemoteViews(context.getPackageName(), R.layout.app_widget);

			// 設定顯示數字
			remoteView.setTextViewText(R.id.widget_txt, String.valueOf(mIndex));

			// 設定點選按鈕對應的PendingIntent:即點選按鈕時,傳送廣播。
			remoteView.setOnClickPendingIntent(R.id.widget_btn_reset, getResetPendingIntent(context));
			remoteView.setOnClickPendingIntent(R.id.widget_btn_open, getOpenPendingIntent(context));

			// 更新 widget
			appWidgetManager.updateAppWidget(appID, remoteView);
		}
	}

	/**
	 * 獲取 重置數字的廣播
	 */
	private PendingIntent getResetPendingIntent(Context context) {
		Intent intent = new Intent();
		intent.setClass(context, WidgetProvider.class);
		intent.addCategory(Intent.CATEGORY_ALTERNATIVE);
		PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, 0);
		return pi;
	}

	/**
	 * 獲取 開啟 MainActivity 的 PendingIntent
	 */
	private PendingIntent getOpenPendingIntent(Context context) {
		Intent intent = new Intent();
		intent.setClass(context, MainActivity.class);
		intent.putExtra("main", "這句話是我從桌面點開傳過去的。");
		PendingIntent pi = PendingIntent.getActivity(context, 0, intent, 0);
		return pi;
	}

	/**
	 * 當該視窗小部件第一次新增到桌面時呼叫該方法,可新增多次但只第一次呼叫
	 */
	@Override
	public void onEnabled(Context context) {
		// 在第一個 widget 被建立時,開啟服務
		Intent intent = new Intent(context, WidgetService.class);
		context.startService(intent);
		Toast.makeText(context, "開始計數", Toast.LENGTH_SHORT).show();
		super.onEnabled(context);
	}

	// 當 widget 被初次新增 或者 當 widget 的大小被改變時,被呼叫
	@Override
	public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle
			newOptions) {
		super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
	}

	/**
	 * 當小部件從備份恢復時呼叫該方法
	 */
	@Override
	public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) {
		super.onRestored(context, oldWidgetIds, newWidgetIds);
	}

	/**
	 * 每次視窗小部件被點選更新都呼叫一次該方法
	 */
	@Override
	public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
		super.onUpdate(context, appWidgetManager, appWidgetIds);
		// 每次 widget 被建立時,對應的將widget的id新增到set中
		for (int appWidgetId : appWidgetIds) {
			idsSet.add(Integer.valueOf(appWidgetId));
		}
	}

	/**
	 * 每刪除一次視窗小部件就呼叫一次
	 */
	@Override
	public void onDeleted(Context context, int[] appWidgetIds) {
		// 當 widget 被刪除時,對應的刪除set中儲存的widget的id
		for (int appWidgetId : appWidgetIds) {
			idsSet.remove(Integer.valueOf(appWidgetId));
		}
		super.onDeleted(context, appWidgetIds);
	}

	/**
	 * 當最後一個該視窗小部件刪除時呼叫該方法,注意是最後一個
	 */
	@Override
	public void onDisabled(Context context) {
		// 在最後一個 widget 被刪除時,終止服務
		Intent intent = new Intent(context, WidgetService.class);
		context.stopService(intent);
		super.onDisabled(context);
	}
}
複製程式碼
onReceive(Context context, Intent intent)

它傳了兩個值回來,Context 是跳轉、發廣播用的。 我們用來判斷的是 Intent ,這裡用到了 Intent 的兩種方式。

Intent 作為資訊傳遞者。 它要把資訊傳給誰,可以有三個匹配依據:一個是action,一個是category,一個是data。

String ACTION_UPDATE_ALL = "com.lyl.widget.UPDATE_ALL"; 這個最後會在 AndroidManifest.xml 裡面註冊時寫進去。 當每隔 N 秒/分鐘,就傳送一次這個廣播,更新所有UI。

intent.hasCategory(Intent.CATEGORY_ALTERNATIVE) 是廣播事件裡攜帶的 Intent 裡設定的,用來匹配。 點選“恢復”按鈕,計數器清零。

然後是 updateAllAppWidgets() 這個方法,更新 UI。 更新 UI 用到了一個新東西——RemoteViews。

怎麼來理解 RemoteViews 呢?

因為,桌面部件並不像平常佈局直接展示,它需要通過某種服務去更新UI。但是我們的App怎麼能去控制桌面上的佈局呢?

所以就需要有一箇中間人,類似傳遞者。

我告訴傳遞者,你讓他把我的 R.id.widget_txt ,更新成 “hello world”。 你讓他把我的 R.id.widget_btn_open 按鈕點選之後去響應 PendingIntent 這件事。

RemoteViews 就是承擔著一個這樣的角色。

然後再去理解程式碼,是不是稍微好一點了?

4. 最後就是 Service 控制 Widget 的更新時間

說好的 當每隔 N 秒/分鐘,就傳送一次這個廣播。 那到底在哪發呢?也就是我們剛開始說的,用 Service 來控制時間。

新建一個 WidgetService 類,繼承 Service。程式碼如下:

/**
 * 控制 桌面小部件 更新
 * Created by lyl on 2017/8/23.
 */
public class WidgetService extends Service {

    // 更新 widget 的廣播對應的 action
    private final String ACTION_UPDATE_ALL = "com.lyl.widget.UPDATE_ALL";
    // 週期性更新 widget 的週期
    private static final int UPDATE_TIME = 1000;

    private Timer mTimer;
    private TimerTask mTimerTask;


    @Override
    public void onCreate() {
        super.onCreate();

        // 每經過指定時間,傳送一次廣播
        mTimer = new Timer();
        mTimerTask = new TimerTask() {
            @Override
            public void run() {
                Intent updateIntent = new Intent(ACTION_UPDATE_ALL);
                sendBroadcast(updateIntent);
            }
        };
        mTimer.schedule(mTimerTask, 1000, UPDATE_TIME);
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        mTimerTask.cancel();
        mTimer.cancel();
    }

    /*
     *  服務開始時,即呼叫startService()時,onStartCommand()被執行。
     *
     *  這個整形可以有四個返回值:start_sticky、start_no_sticky、START_REDELIVER_INTENT、START_STICKY_COMPATIBILITY。
     *  它們的含義分別是:
     *  1):START_STICKY:如果service程式被kill掉,保留service的狀態為開始狀態,但不保留遞送的intent物件。隨後系統會嘗試重新建立service,
     *     由於服務狀態為開始狀態,所以建立服務後一定會呼叫onStartCommand(Intent,int,int)方法。如果在此期間沒有任何啟動命令被傳遞到service,那麼引數Intent將為null;
     *  2):START_NOT_STICKY:“非粘性的”。使用這個返回值時,如果在執行完onStartCommand後,服務被異常kill掉,系統不會自動重啟該服務;
     *  3):START_REDELIVER_INTENT:重傳Intent。使用這個返回值時,如果在執行完onStartCommand後,服務被異常kill掉,系統會自動重啟該服務,並將Intent的值傳入;
     *  4):START_STICKY_COMPATIBILITY:START_STICKY的相容版本,但不保證服務被kill後一定能重啟。
     */
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        super.onStartCommand(intent, flags, startId);
        return START_STICKY;
    }
}
複製程式碼

在 onCreate 開啟一個計時執行緒,每1秒傳送一個廣播,廣播就是我們自己定義的型別。

5. 在 AndroidManifest.xml 註冊 桌面部件 和 服務

然後就只剩最後一步了,註冊相關資訊

<!-- 宣告widget對應的AppWidgetProvider -->
<receiver android:name=".WidgetProvider">
    <intent-filter>
        <!--這個是必須要有的系統規定-->
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
        <!--這個是我們自定義的 action ,用來更新UI,還可以自由新增更多 -->
        <action android:name="com.lyl.widget.UPDATE_ALL"/>
    </intent-filter>
    <!--要顯示的佈局-->
    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/app_widget"/>
</receiver>

<!-- 用來計時,傳送 通知桌面部件更新 -->
<service android:name=".WidgetService" >
    <intent-filter>
        <!--用來啟動服務-->
        <action android:name="android.appwidget.action.APP_WIDGET_SERVICE" />
    </intent-filter>
</service>
複製程式碼

相應的註釋都在上面,如果我們的App程式被殺掉,服務也被關掉,那就沒辦法更新UI了。 也可以再建立一個 BroadcastReceiver 監聽系統的各種動態,來喚醒我們的通知服務,這就屬於程式保活了。

至此,以上程式碼寫完,如果不出問題,執行之後直接去桌面看小工具,我們的App就在裡面了,可以新增到桌面。


對於需要定時更新的桌面部件,保證自己的服務在後臺執行也是一件比較重要的事情。 這個我們還是可以好好做一下,畢竟使用者都已經願意把我們的程式放到桌面上,所以只要友好的引導使用者給你一定的許可權,存活概率還是很大。 再不濟,讓使用者主動點開App,也不失為一種辦法。

好的創意才能造就好的App,程式碼只是實現。

最後放上專案地址: github.com/Wing-Li/Wid…

相關文章