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

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

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 的更新時間,后面再講為什么。

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


實現一個桌面計數器

先上效果圖:

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的空間。
  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 也不例外,它有幾個方法來管理自己的生命周期。
  1. 同一個小部件是可以添加多次的,所以更新控件的時候,要把所有的都更新。

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

然后我們再來看代碼。
新建一個 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,代碼只是實現。

最后放上項目地址:
https://github.com/Wing-Li/Widget

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,117評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,860評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,128評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,291評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,025評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,421評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,477評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,642評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,177評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,970評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,157評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,717評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,410評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,821評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,053評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,896評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,157評論 2 375

推薦閱讀更多精彩內容