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

如果本文幫助到你,本人不勝榮幸,如果浪費(fèi)了你的時(shí)間,本人深感抱歉。
希望用最簡(jiǎn)單的大白話來(lái)幫助那些像我一樣的人。如果有什么錯(cuò)誤,請(qǐng)一定指出,以免誤導(dǎo)大家、也誤導(dǎo)我。
本文來(lái)自:http://www.itdecent.cn/u/320f9e8f7fc9
感謝您的關(guān)注。

Android 桌面小部件是我們經(jīng)??吹降?,比如時(shí)鐘、天氣、音樂(lè)播放器等等。
它可以讓 App 的某些功能直接展示在桌面上,極大的增加了用戶(hù)的關(guān)注度。

首先糾正一個(gè)誤區(qū):
當(dāng) App 的小部件被放到了桌面之后,并不代表你的 App 就可以一直在手機(jī)后臺(tái)運(yùn)行了。該被殺,它還是會(huì)被殺掉的。
所以如果你做小部件的目的是為了讓程序常駐后臺(tái),那么你可以死心了。

但是?。?!
雖然它還是能被殺掉,但是用戶(hù)能看的見(jiàn)它了啊,用戶(hù)可以點(diǎn)擊就打開(kāi)我們的 APP,所以還是很不錯(cuò)的。


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

小部件可以做什么呢?也就是我們需要實(shí)現(xiàn)什么功能。

  1. 展示。每隔 N 秒/分鐘,刷新一次數(shù)據(jù);
  2. 交互。點(diǎn)擊操作 App 的數(shù)據(jù);
  3. 打開(kāi)App。打開(kāi)主頁(yè)或指定頁(yè)面。

這三個(gè)功能,大概就能滿(mǎn)足我們絕大部分需求了吧。

實(shí)現(xiàn)桌面小部件需要什么?

如果你從來(lái)沒(méi)有做過(guò)桌面部件,那肯定總是感覺(jué)有點(diǎn)慌,無(wú)從下手,毫無(wú)邏輯。
所以,實(shí)現(xiàn)它到底需要什么呢?

  1. 先聲明 Widget 的一些屬性。在 res 新建 xml 文件夾,創(chuàng)建 appwidget-provider 標(biāo)簽的 xml 文件。
  2. 創(chuàng)建桌面要顯示的布局。 在 layout 創(chuàng)建 app_widget.xml。
  3. 然后來(lái)管理 Widget 狀態(tài)。實(shí)現(xiàn)一個(gè)繼承 AppWidgetProvider 的類(lèi)。
  4. 最后在 AndroidManifest.xml 里,將 AppWidgetProvider類(lèi) 和 xml屬性 注冊(cè)到一塊。
  5. 通常我們會(huì)加一個(gè) Service 來(lái)控制 Widget 的更新時(shí)間,后面再講為什么。

做完這些,如果不出錯(cuò),就完成了桌面部件。
其實(shí)挺簡(jiǎn)單的,下面就讓我們來(lái)看看具體的實(shí)現(xiàn)吧。


實(shí)現(xiàn)一個(gè)桌面計(jì)數(shù)器

先上效果圖:

1. 聲明 Widget 的屬性

在 res 新建 xml 文件夾,創(chuàng)建一個(gè) app_widget.xml 的文件。
如果 res 下沒(méi)有 xml 文件,則先創(chuàng)建。

app_widget.xml 內(nèi)容如下:

<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的時(shí)間間隔(ms),"86400000"為1個(gè)小時(shí),值小于30分鐘時(shí),會(huì)被設(shè)置為30分鐘??梢杂?service、AlarmManager、Timer 控制。
    android:previewImage : 預(yù)覽圖片,拖動(dòng)小部件到桌面時(shí)有個(gè)預(yù)覽圖
    android:initialLayout : 加載到桌面時(shí)對(duì)應(yīng)的布局文件
    android:resizeMode : 拉伸的方向。horizontal表示可以水平拉伸,vertical表示可以豎直拉伸
    android:widgetCategory : 被顯示的位置。home_screen:將widget添加到桌面,keyguard:widget可以被添加到鎖屏界面。
    android:initialKeyguardLayout : 加載到鎖屏界面時(shí)對(duì)應(yīng)的布局文件
     -->
</appwidget-provider>

屬性的注釋在上面寫(xiě)的很清楚了,這里需要說(shuō)兩點(diǎn)。

  1. 關(guān)于寬度和高度的數(shù)值定義是很有講究的,在桌面其實(shí)是按照“格子”排列的。
    看 Google 給的圖。上面我們代碼定義 110dp 也就是說(shuō),它占了2*2的空間。
  1. 第二點(diǎn)很重要。有個(gè) updatePeriodMillis 屬性,更新widget的時(shí)間間隔(ms)。
    官方給提供了小部件的自動(dòng)更新時(shí)間,但是卻給了限制,你更新的時(shí)間必須大于30分鐘,如果小于30分鐘,那默認(rèn)就是30分鐘。
    可以我們就是要5分鐘更新啊,怎么辦呢?
    所以就不能使用這個(gè)默認(rèn)更新,我們要自己來(lái)通過(guò)發(fā)送廣播控制更新時(shí)間,也就是一開(kāi)始總步驟里面第4步,加一個(gè) Service 來(lái)控制 Widget 的更新時(shí)間,這個(gè)在最后一步添加。

2. 創(chuàng)建布局文件

在 layout 創(chuàng)建 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="恢復(fù)"/>

        <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="打開(kāi)頁(yè)面"/>
    </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 狀態(tài)

這里代碼看起來(lái)可能有點(diǎn)多,先聽(tīng)我講幾個(gè)邏輯,再來(lái)看代碼。

  1. Android 的各種東西都有自己的生命周期,Widget 也不例外,它有幾個(gè)方法來(lái)管理自己的生命周期。
  1. 同一個(gè)小部件是可以添加多次的,所以更新控件的時(shí)候,要把所有的都更新。

  2. onReceive() 用來(lái)接收廣播,它并不在生命周期里。但是,其實(shí) onReceive() 是掌控生命周期的。
    如下是 onReceive() 父類(lèi)的源碼,右邊是每個(gè)廣播對(duì)應(yīng)的方法。
    上面我畫(huà)的生命周期的圖,也比較清楚。

然后我們?cè)賮?lái)看代碼。
新建一個(gè) WidgetProvider 類(lèi),繼承 AppWidgetProvider。
主要邏輯在 onReceive() 里,其他的都是生命周期切換時(shí),所處理的事情。
我們?cè)谙旅娣治?onReceive()。

public class WidgetProvider extends AppWidgetProvider {

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

    public static int mIndex;

    /**
     * 接收窗口小部件點(diǎn)擊時(shí)發(fā)送的廣播
     */
    @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)) {
            // “按鈕點(diǎn)擊”廣播
            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();

        // 要顯示的那個(gè)數(shù)字,每更新一次 + 1
        mIndex++; // TODO:可以在這里做更多的邏輯操作,比如:數(shù)據(jù)處理、網(wǎng)絡(luò)請(qǐng)求等。然后去顯示數(shù)據(jù)

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

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

            // 設(shè)置顯示數(shù)字
            remoteView.setTextViewText(R.id.widget_txt, String.valueOf(mIndex));

            // 設(shè)置點(diǎn)擊按鈕對(duì)應(yīng)的PendingIntent:即點(diǎn)擊按鈕時(shí),發(fā)送廣播。
            remoteView.setOnClickPendingIntent(R.id.widget_btn_reset, getResetPendingIntent(context));
            remoteView.setOnClickPendingIntent(R.id.widget_btn_open, getOpenPendingIntent(context));

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

    /**
     * 獲取 重置數(shù)字的廣播
     */
    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;
    }

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

    /**
     * 當(dāng)該窗口小部件第一次添加到桌面時(shí)調(diào)用該方法,可添加多次但只第一次調(diào)用
     */
    @Override
    public void onEnabled(Context context) {
        // 在第一個(gè) widget 被創(chuàng)建時(shí),開(kāi)啟服務(wù)
        Intent intent = new Intent(context, WidgetService.class);
        context.startService(intent);
        Toast.makeText(context, "開(kāi)始計(jì)數(shù)", Toast.LENGTH_SHORT).show();
        super.onEnabled(context);
    }

    // 當(dāng) widget 被初次添加 或者 當(dāng) widget 的大小被改變時(shí),被調(diào)用
    @Override
    public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle
            newOptions) {
        super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
    }

    /**
     * 當(dāng)小部件從備份恢復(fù)時(shí)調(diào)用該方法
     */
    @Override
    public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) {
        super.onRestored(context, oldWidgetIds, newWidgetIds);
    }

    /**
     * 每次窗口小部件被點(diǎn)擊更新都調(diào)用一次該方法
     */
    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        super.onUpdate(context, appWidgetManager, appWidgetIds);
        // 每次 widget 被創(chuàng)建時(shí),對(duì)應(yīng)的將widget的id添加到set中
        for (int appWidgetId : appWidgetIds) {
            idsSet.add(Integer.valueOf(appWidgetId));
        }
    }

    /**
     * 每刪除一次窗口小部件就調(diào)用一次
     */
    @Override
    public void onDeleted(Context context, int[] appWidgetIds) {
        // 當(dāng) widget 被刪除時(shí),對(duì)應(yīng)的刪除set中保存的widget的id
        for (int appWidgetId : appWidgetIds) {
            idsSet.remove(Integer.valueOf(appWidgetId));
        }
        super.onDeleted(context, appWidgetIds);
    }

    /**
     * 當(dāng)最后一個(gè)該窗口小部件刪除時(shí)調(diào)用該方法,注意是最后一個(gè)
     */
    @Override
    public void onDisabled(Context context) {
        // 在最后一個(gè) widget 被刪除時(shí),終止服務(wù)
        Intent intent = new Intent(context, WidgetService.class);
        context.stopService(intent);
        super.onDisabled(context);
    }
}
onReceive(Context context, Intent intent)

它傳了兩個(gè)值回來(lái),Context 是跳轉(zhuǎn)、發(fā)廣播用的。
我們用來(lái)判斷的是 Intent ,這里用到了 Intent 的兩種方式。

Intent 作為信息傳遞者。
它要把信息傳給誰(shuí),可以有三個(gè)匹配依據(jù):一個(gè)是action,一個(gè)是category,一個(gè)是data。

String ACTION_UPDATE_ALL = "com.lyl.widget.UPDATE_ALL";
這個(gè)最后會(huì)在 AndroidManifest.xml 里面注冊(cè)時(shí)寫(xiě)進(jìn)去。
當(dāng)每隔 N 秒/分鐘,就發(fā)送一次這個(gè)廣播,更新所有UI。

intent.hasCategory(Intent.CATEGORY_ALTERNATIVE)
是廣播事件里攜帶的 Intent 里設(shè)置的,用來(lái)匹配。
點(diǎn)擊“恢復(fù)”按鈕,計(jì)數(shù)器清零。

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

怎么來(lái)理解 RemoteViews 呢?
因?yàn)?,桌面部件并不像平常布局直接展示,它需要通過(guò)某種服務(wù)去更新UI。但是我們的App怎么能去控制桌面上的布局呢?
所以就需要有一個(gè)中間人,類(lèi)似傳遞者。
我告訴傳遞者,你讓他把我的 R.id.widget_txt ,更新成 “hello world”。
你讓他把我的 R.id.widget_btn_open 按鈕點(diǎn)擊之后去響應(yīng) PendingIntent 這件事。
RemoteViews 就是承擔(dān)著一個(gè)這樣的角色。

然后再去理解代碼,是不是稍微好一點(diǎn)了?

4. 最后就是 Service 控制 Widget 的更新時(shí)間

說(shuō)好的 當(dāng)每隔 N 秒/分鐘,就發(fā)送一次這個(gè)廣播。
那到底在哪發(fā)呢?也就是我們剛開(kāi)始說(shuō)的,用 Service 來(lái)控制時(shí)間。

新建一個(gè) WidgetService 類(lèi),繼承 Service。代碼如下:

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

    // 更新 widget 的廣播對(duì)應(yīng)的 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();

        // 每經(jīng)過(guò)指定時(shí)間,發(fā)送一次廣播
        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();
    }

    /*
     *  服務(wù)開(kāi)始時(shí),即調(diào)用startService()時(shí),onStartCommand()被執(zhí)行。
     *
     *  這個(gè)整形可以有四個(gè)返回值:start_sticky、start_no_sticky、START_REDELIVER_INTENT、START_STICKY_COMPATIBILITY。
     *  它們的含義分別是:
     *  1):START_STICKY:如果service進(jìn)程被kill掉,保留service的狀態(tài)為開(kāi)始狀態(tài),但不保留遞送的intent對(duì)象。隨后系統(tǒng)會(huì)嘗試重新創(chuàng)建service,
     *     由于服務(wù)狀態(tài)為開(kāi)始狀態(tài),所以創(chuàng)建服務(wù)后一定會(huì)調(diào)用onStartCommand(Intent,int,int)方法。如果在此期間沒(méi)有任何啟動(dòng)命令被傳遞到service,那么參數(shù)Intent將為null;
     *  2):START_NOT_STICKY:“非粘性的”。使用這個(gè)返回值時(shí),如果在執(zhí)行完onStartCommand后,服務(wù)被異常kill掉,系統(tǒng)不會(huì)自動(dòng)重啟該服務(wù);
     *  3):START_REDELIVER_INTENT:重傳Intent。使用這個(gè)返回值時(shí),如果在執(zhí)行完onStartCommand后,服務(wù)被異常kill掉,系統(tǒng)會(huì)自動(dòng)重啟該服務(wù),并將Intent的值傳入;
     *  4):START_STICKY_COMPATIBILITY:START_STICKY的兼容版本,但不保證服務(wù)被kill后一定能重啟。
     */
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        super.onStartCommand(intent, flags, startId);
        return START_STICKY;
    }
}

在 onCreate 開(kāi)啟一個(gè)計(jì)時(shí)線程,每1秒發(fā)送一個(gè)廣播,廣播就是我們自己定義的類(lèi)型。

5. 在 AndroidManifest.xml 注冊(cè) 桌面部件 和 服務(wù)

然后就只剩最后一步了,注冊(cè)相關(guān)信息

<!-- 聲明widget對(duì)應(yīng)的AppWidgetProvider -->
<receiver android:name=".WidgetProvider">
    <intent-filter>
        <!--這個(gè)是必須要有的系統(tǒng)規(guī)定-->
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
        <!--這個(gè)是我們自定義的 action ,用來(lái)更新UI,還可以自由添加更多 -->
        <action android:name="com.lyl.widget.UPDATE_ALL"/>
    </intent-filter>
    <!--要顯示的布局-->
    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/app_widget"/>
</receiver>

<!-- 用來(lái)計(jì)時(shí),發(fā)送 通知桌面部件更新 -->
<service android:name=".WidgetService" >
    <intent-filter>
        <!--用來(lái)啟動(dòng)服務(wù)-->
        <action android:name="android.appwidget.action.APP_WIDGET_SERVICE" />
    </intent-filter>
</service>

相應(yīng)的注釋都在上面,如果我們的App進(jìn)程被殺掉,服務(wù)也被關(guān)掉,那就沒(méi)辦法更新UI了。
也可以再創(chuàng)建一個(gè) BroadcastReceiver 監(jiān)聽(tīng)系統(tǒng)的各種動(dòng)態(tài),來(lái)喚醒我們的通知服務(wù),這就屬于進(jìn)程?;盍恕?/p>

至此,以上代碼寫(xiě)完,如果不出問(wèn)題,運(yùn)行之后直接去桌面看小工具,我們的App就在里面了,可以添加到桌面。


對(duì)于需要定時(shí)更新的桌面部件,保證自己的服務(wù)在后臺(tái)運(yùn)行也是一件比較重要的事情。
這個(gè)我們還是可以好好做一下,畢竟用戶(hù)都已經(jīng)愿意把我們的程序放到桌面上,所以只要友好的引導(dǎo)用戶(hù)給你一定的權(quán)限,存活概率還是很大。
再不濟(jì),讓用戶(hù)主動(dòng)點(diǎn)開(kāi)App,也不失為一種辦法。

好的創(chuàng)意才能造就好的App,代碼只是實(shí)現(xiàn)。

最后放上項(xiàng)目地址:
https://github.com/Wing-Li/Widget

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容