【Launcher延伸】窗口小部件Widget及时钟实例

    前言
    App Widget意为应用程序窗口小部件,可作为AppWidgetHostView(内部根据RemoteViews进行了重现)出现在其它应用程序(该程序中使用AppWidgetHost管理)中,作为其视图的一部分展示。且系统会定期发出自动更新信息更新Widget,当然,用户也可使用AppWidgetManager手动进行更新。Widget作为交互的一部分可满足应用在后台时占据部分窗口大小与用户互动,我们常见的时钟、音乐、天气等应用常使用Widget提高用户互动率。
    Widget创建步骤

    1.继承AppWidgetProvider,并在清单文件中声明此组件。其虽名为Provider,其实是一个广播接收者,负责接收系统定期发送的Widget广播。

    2.在res/xml目录定义Widget初始化信息文件(该信息最终会被读取到载体AppWidgetProviderInfo中),并将该定义文件指定到AndroidManifest.xml中1所声明的AppWidgetProvider继承者中,以便为AppWidgetProvider提供窗口小部件的布局等信息。

    3.实现指定Widget所加载的布局文件,该布局文件在步骤2所定义文件中有所声明。该布局文件正是作为Widget展示在其它应用程序(如Launcher)的具体视图文件。用户可利用RemoteViews远程操作展示在其它程序中Widget布局内的内容、点击事件等。

    4.实现AppWidgetProvider周期方法如onEnabled、onUpdate、onDeleted、onDisabled。在onUpdate中我们可以更新Widget,但由于onUpdate调用时长的原因,我们常在onUpdate中启动专用Service负责更新Widget操作。

    5.如果还想再创建Widget时进行一些配置操作,我们可以在步骤2定义的文件中配置configure指定创建Widget时弹出的Activity进行Widget基础性配置。

    步骤1

    我们自定义广播组件继承自AppWidgetProvider,然后在清单文件中声明。此广播组件需要通过意图过滤选择想要接收的广播,此处我们定义的过滤action为android.appwidget.action.APPWIDGET_UPDATE,此action为过滤系统Widget相关的广播。同时该组件作为Widget提供者,还需要指定Widget的元信息(该元信息中指定Widget的初始化信息文件,在该文件中指出了Widget的最小宽高、刷新频率、视图布局、预览图、Widget类型等信息)。

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

            <meta-data
                android:name="android.appwidget.provider"
                android:resource="@xml/widget_info"/>
        </receiver>
    步骤2

    在res/xml目录下定义Widget初始化信息文件widget_info(此文件名需要与步骤1中清单文件中所定义元信息的resource一致)。

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
                    android:configure="com.why.widgetdemo.WidgetConfigureActivity"
                    android:initialKeyguardLayout="@layout/widget_layout"
                    android:initialLayout="@layout/widget_layout"
                    android:minHeight="50dp"
                    android:minWidth="200dp"
                    android:previewImage="@mipmap/ic_launcher"
                    android:resizeMode="horizontal|vertical"
                    android:updatePeriodMillis="86400000"
                    android:widgetCategory="home_screen"
    >

</appwidget-provider>

    属性解释:

        1)minWidth & minHeight:定义了 Widget 的最小宽高,当 minWidth 和 minHeight 不是桌面 cell 的整数倍时,Widget 的宽高会被阔至与其最接近的 cells 大小。Google 官方给出了一个大致估算 minWidth & minHeight 的公式,根据 Widget 所占的 cell 数量来计算宽高:70 * n − 30,n 是所占的 cell 数量。

        2)previewImage:指的是Launcher中所示Widget list列表中的预览效果图。

        3)resizeMode:当Widget添加到应用程序窗口中后,长按Widget时可选择的拉伸模式。horizontal表示可横向伸缩,vertical表示纵向伸缩,但不论横向纵向拉伸最小值都不能小于minWidth、minHeight。

        4)updatePeriodMillis:用于指定Widget更新频率,系统根据此值定时回调AppWidgetProvider中的onUpdate方法。Android系统默认的最小Widget更新周期为30分钟,也就是说除了新增Widget会回调onUpdate之外,onUpdate回调周期至少30分钟,即使updatePeriodMillis指定更小也无用。

        5)widget_category:指定当前Widget的显示位置类型。home_screen类型表示Widget位于桌面,keyguard类型表示Widget位于锁屏页。

        6)initialLayout:表示当显示类型为home_screen即桌面时Widget视图布局。

        7)initialKeyguardLayout:表示当显示类型为keyguard即锁屏页时Widget的视图布局。

        8)configure:值为用户添加Widget时弹出的Widget基础性配置Activity。用户可在此Activity中对Widget内容等进行定制。

    步骤3
    实现Widget指定的视图布局文件。此视图文件一般根据应用需求来,像时钟应用可能会在视图中显示形象的时分秒日期信息,天气应用可能会在Widget视图文件中显示天气、温度、风力等信息,音乐应用除了显示音乐信息,可能还会在布局中加入一些可点击按钮。
    步骤4

    在步骤1中我们定义了继承自AppWidgetProvider的一个广播接收者,此接收者会根据不同的广播事件回调不同的生命周期方法。我们可以去AppWidgetProvider的源码中看看:

public void onReceive(Context context, Intent intent) {
        // Protect against rogue update broadcasts (not really a security issue,
        // just filter bad broacasts out so subclasses are less likely to crash).
        String action = intent.getAction();
        if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(action)) {
            Bundle extras = intent.getExtras();
            if (extras != null) {
                int[] appWidgetIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS);
                if (appWidgetIds != null && appWidgetIds.length > 0) {
                    this.onUpdate(context, AppWidgetManager.getInstance(context), appWidgetIds);
                }
            }
        } else if (AppWidgetManager.ACTION_APPWIDGET_DELETED.equals(action)) {
            Bundle extras = intent.getExtras();
            if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)) {
                final int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID);
                this.onDeleted(context, new int[] { appWidgetId });
            }
        } else if (AppWidgetManager.ACTION_APPWIDGET_OPTIONS_CHANGED.equals(action)) {
            Bundle extras = intent.getExtras();
            if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)
                    && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_OPTIONS)) {
                int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID);
                Bundle widgetExtras = extras.getBundle(AppWidgetManager.EXTRA_APPWIDGET_OPTIONS);
                this.onAppWidgetOptionsChanged(context, AppWidgetManager.getInstance(context),
                        appWidgetId, widgetExtras);
            }
        } else if (AppWidgetManager.ACTION_APPWIDGET_ENABLED.equals(action)) {
            this.onEnabled(context);
        } else if (AppWidgetManager.ACTION_APPWIDGET_DISABLED.equals(action)) {
            this.onDisabled(context);
        } else if (AppWidgetManager.ACTION_APPWIDGET_RESTORED.equals(action)) {
            Bundle extras = intent.getExtras();
            if (extras != null) {
                int[] oldIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_OLD_IDS);
                int[] newIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS);
                if (oldIds != null && oldIds.length > 0) {
                    this.onRestored(context, oldIds, newIds);
                    this.onUpdate(context, AppWidgetManager.getInstance(context), newIds);
                }
            }
        }
    }

    作为继承类,我们没必要重写广播接收者的onReceive方法(此方法父类AppWidgetProvider已经为我们实现),只需要重写onReceive中扩展出来的周期方法即可。

    以下是周期方法的解释:

        1)onEnabled:当 Widget 第一次被添加时调用,例如用户添加了两个你的 Widget,那么只有在添加第一个 Widget 时该方法会被调用。

        2)onUpdate:每次添加Widget都会被调用,且会根据用户在步骤2定义文件中的updatePeriodMillis值周期性被调用。一般我们在此回调中启动一个Service,在Service内部启动周期性定时Timer负责实时更新Widget。

        3)onDeleted:每次Widget被删除时调用此方法。我们如果在onUpdate中启动有Service,那么需要在此处根据appWidgetId来停止掉Service中的更新。

        4)onDisabled:当最后一个Widget被删除时调用。

        5)onAppWidgetOptionsChanged:当 Widget 第一次被添加或者大小发生变化时调用该方法,可以在此控制 Widget 元素的显示和隐藏。

    步骤5(ifNeed)

    如果我们需要在创建Widget时进行一些基础性配置,那么我们可通过在步骤2中定义的文件中添加configure属性,值指定一个Activity,此Activity会在创建Widget时弹出方便用户配置Widget信息。如下所示,我们在清单文件中声明此Activity同时还需要指定action为android.intent.action.APPWIDGET_CONFIGURE的意图过滤器。系统判断此intentFilter进行创建Widget时弹出配置Activity操作。

<activity android:name=".WidgetConfigureActivity">
            <intent-filter>
                <action android:name="android.intent.action.APPWIDGET_CONFIGURE"/>
            </intent-filter>
        </activity>
    在WidgetConfigureActivity中我们可以通过intent获取到当前需要配置的appWidgetId,并可以对此appWidgetId对应的视图内容进行不同的初始化操作。当然,如果想使得此配置生效,别忘了在finish此Activity时setResult(RESULT_OK)。
    时钟小案例

    首先我们定义WidgetProvider继承于AppWidgetProvider。重写其主要周期方法onUpdate、onDeleted以在Service控制各个Widget内部实时时钟线程。

public class WidgetProvider extends AppWidgetProvider {
	private static final String TAG = "WidgetProvider";

	/**
	 *更新操作,每当用户添加一个当前widget到桌面就会回调此方法一次,
	 * 默认情况下根据widget_info中的updatePeriodMillis时间更新,如果updatePeriodMillis最小值不可小于30分钟
	 * 小于30系统也只会按30min/次更新
	 *
	 * @param context 当前应用所在的上下文环境
	 * @param appWidgetManager manager,如果不是需要实时更新widget,则可以直接在方法中使用manager进行更新
	 * @param appWidgetIds 一般此处都为1个,当应用更新版本时,当前数组中可能为多个
	 */
	@Override
	public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
		super.onUpdate(context, appWidgetManager, appWidgetIds);
		Log.i(TAG, "onUpdate: "+Arrays.toString(appWidgetIds)+", context:"+context.getPackageName());
		Intent intent = new Intent(context, UpdateWidgetService.class);
		//把更改的appWidgetIds
		intent.putExtra("appWidgetIds",appWidgetIds);
		intent.setAction(UpdateWidgetService.ACTION_UPDATE);
		context.startService(intent);
	}

	@Override
	public void onDeleted(Context context, int[] appWidgetIds) {
		super.onDeleted(context, appWidgetIds);
		Log.i(TAG, "onDeleted: "+ Arrays.toString(appWidgetIds));
		Intent intent = new Intent(context, UpdateWidgetService.class);
		//把更改的appWidgetIds
		intent.putExtra("appWidgetIds",appWidgetIds);
		intent.setAction(UpdateWidgetService.ACTION_DELETE);
		context.startService(intent);

	}


	@Override
	public void onEnabled(Context context) {
		super.onEnabled(context);
		Log.i(TAG, "onEnabled: ");
	}

	@Override
	public void onDisabled(Context context) {
		super.onDisabled(context);
		Log.i(TAG, "onDisabled: ");
	}
}

    可以看见我们在onUpdate和onDeleted中使用startService启动服务(多次调用只会执行Service的onStartCommand周期方法)。同时把appWidgetIds作为intent携带数据传给Service,这样方便我们在Service中对不同appWidgetId进行更新。当然,我们也应该在清单文件中声明此组件,代码详情正如步骤1所示。

    在清单文件中我们制定了元数据resource的文件为widget_info.xml,即步骤2所示文件。在其中我们声明widget视图布局为widget_layout.xml,此布局如下所示:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:background="@android:color/holo_red_dark"
        >
    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/time"
        android:text="time"
        android:gravity="center"
        android:textSize="20sp"
        android:textColor="@android:color/white"
        android:layout_centerInParent="true"/>

</RelativeLayout>
    可以看见,widget视图布局中我们指定了一个TextView居中显示在红色背景的容器中。而对此视图的内容操作则被放入了WidgetProvider启动的UpdateWidgetService中了。

    细心的读者可能已经发现,我们在步骤2所示的widget_info文件中指定了在创建Widget时需要打开的配置Activity页面WidgetConfigureActivity。而在WidgetConfigureActivity中则指定了视图布局中TextView初始化创建时显示时钟的时间。    

public class WidgetConfigureActivity extends AppCompatActivity implements View.OnClickListener {
	private static final String TAG = "WidgetConfigureActivity";
	private int appWidgetId;
	private Button btn_choose_date;
	private Button btn_choose_time;
	private Button btn_confirm;
	private Calendar calendar;
	private TextView txt_time;
	private SimpleDateFormat format;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_widget_configure);
		checkWidgetId();
		bindViews();

	}

	private void checkWidgetId() {
		Bundle extras = getIntent().getExtras();
		if (extras!=null){
			appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID,AppWidgetManager.INVALID_APPWIDGET_ID);
		}

		if (appWidgetId==AppWidgetManager.INVALID_APPWIDGET_ID){
			//当前没有传递appWidgetId
			Log.i(TAG, "onCreate: appWidgetId is invalid");
			finish();
		}
	}

	private void bindViews() {
		btn_choose_date = findViewById(R.id.btn_choose_date);
		btn_choose_time = findViewById(R.id.btn_choose_time);
		btn_confirm = findViewById(R.id.btn_confirm);
		txt_time = findViewById(R.id.txt_time);
		btn_choose_date.setOnClickListener(this);
		btn_choose_time.setOnClickListener(this);
		btn_confirm.setOnClickListener(this);
		calendar = Calendar.getInstance();
		format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
		txt_time.setText(format.format(calendar.getTime()));
	}

	@Override
	public void onClick(View v) {
		switch (v.getId()){
			case R.id.btn_choose_date:
				DatePickerDialog datePickerDialog = new DatePickerDialog(this, new DatePickerDialog.OnDateSetListener() {
					@Override
					public void onDateSet(DatePicker view, int year, int month, int dayOfMonth) {

						Log.i(TAG,"year:"+year+",month:"+month+",day:"+dayOfMonth);
						calendar.set(year,month,dayOfMonth);
						txt_time.setText(format.format(calendar.getTime()));
					}
				}, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH));
				datePickerDialog.show();
				break;
			case R.id.btn_choose_time:
				TimePickerDialog timePickerDialog = new TimePickerDialog(this, new TimePickerDialog.OnTimeSetListener() {
					@Override
					public void onTimeSet(TimePicker view, int hourOfDay, int minute) {
						Log.i(TAG,"hour:"+hourOfDay+",minute:"+minute);
						calendar.set(Calendar.HOUR_OF_DAY,hourOfDay);
						calendar.set(Calendar.MINUTE,minute);
						txt_time.setText(format.format(calendar.getTime()));
					}
				}, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true);
				timePickerDialog.show();
				break;
			case R.id.btn_confirm:
				Intent intent = new Intent(getApplicationContext(), UpdateWidgetService.class);
				intent.setAction(UpdateWidgetService.ACTION_WIDGET_CONFIGURE);
				intent.putExtra("calendar",calendar);
				intent.putExtra("appWidgetId",appWidgetId);
				startService(intent);
				setResult(RESULT_OK);
				finish();
				break;
		}

	}
}

    在WidgetConfigureActivity中我们定义了修改日期、修改时间、确定三个按钮。

    通过点击'选择日期',可弹出弹框给用户自行选择时钟的开始日期,通过点击'选择时间',可弹出弹框给用户自行选择时钟的开始时间。同时,从onClick方法中我们可以看到,点击‘确认’按钮时我们会把选择的时钟时间通过UpdateWidgetService设置给新创建的Widget中(别忘了在finish时调用setResult方法使此初始化配置生效哦)。

    OK!!!说了那么多,重头戏Widget更新操作其实是在接下来的UpdateWidgetService中。

    在UpdateWidgetService中我们通过在onStartCommand中判断intent不同的action得知当前需要进行update还是delete操作。

@Override
	public int onStartCommand(Intent intent, int flags, int startId) {
		String action = intent.getAction();
		if (!TextUtils.isEmpty(action)){
			switch (action){
				case ACTION_UPDATE:
					//更新某个widget
					int[] updates = intent.getIntArrayExtra("appWidgetIds");
					if (updates!=null&&updates.length>0){
						for (int appWidgetId :
								updates) {
							updateWidget(appWidgetId, getCalendarFromMap(generateName(appWidgetId)));
						}
					}
					break;
				case ACTION_DELETE:
					//删除某个widget
					int[] deletes = intent.getIntArrayExtra("appWidgetIds");
					if (deletes!=null&&deletes.length>0){
						for (int appWidgetId :
								deletes) {
							deleteWidget(appWidgetId);
						}
					}
					break;
				case ACTION_WIDGET_CONFIGURE:
					int appWidgetId = intent.getIntExtra("appWidgetId",AppWidgetManager.INVALID_APPWIDGET_ID);
					if (appWidgetId!=AppWidgetManager.INVALID_APPWIDGET_ID){
						Calendar calendar = (Calendar) intent.getSerializableExtra("calendar");
						if (calendar!=null){
							putCalendarToMap(generateName(appWidgetId),calendar);
							updateWidget(appWidgetId,calendar);
						}
					}
					break;
			}
		}
		return START_NOT_STICKY;
	}

    如果是ACTION_UPDATE,我们则需要对不同的appWidgetId进行实时时钟更新操作。可以看见在updateWidget中我们根据appWidgetId的不同从map集合中获取到不同的Calendar作为参数传入。此时我们就需要思考,如果用户创建了多个同一视图的Widget,都需要进行实时更新操作,那么我们该怎么把控各个Widget的更新?

    显然,我们应该针对各个appWdigetId开启不同的线程,既保证各个Widget中数据不相互干扰,也能保证对各Widget进行不同的实时更新操作。

private void updateWidget(final int appWidgetId, final Calendar calendar) {

		final Handler handler = ThreadManager
				.getInstance(getApplicationContext())
				.getHandlerByName(generateName(appWidgetId));
		handler.removeCallbacksAndMessages(null);
		handler
				.post(new Runnable() {
					@Override
					public void run() {
						handler.removeCallbacks(this);
						/**
						 * 两种获取Manager方式
						 * AppWidgetManager manager = (AppWidgetManager) getSystemService(Context.APPWIDGET_SERVICE);
						 */
						AppWidgetManager manager = AppWidgetManager.getInstance(getApplicationContext());
						AppWidgetProviderInfo appWidgetInfo = manager.getAppWidgetInfo(appWidgetId);
						RemoteViews remoteViews = new RemoteViews(getPackageName(),R.layout.widget_layout);
						calendar.setTimeInMillis(calendar.getTimeInMillis()+100);
						remoteViews.setTextViewText(R.id.time,generateName(appWidgetId)+"\n"+mFormat.format(calendar.getTime()));
						manager.updateAppWidget(appWidgetId,remoteViews);
						handler.postDelayed(this,100);
						Log.i(TAG,"updateWidget-"+appWidgetId);
					}
				});
	}

    可以看见我们每100ms就对各个Widget进行了一次时钟更新操作。

    此处我们为了保证线程不冗余创建,使用了map集合进行了管理。而各个线程我们为了效率和CPU考虑,使用的是封装后的WidgetThread(继承自HandlerThread)。以上getHandlerByName方法首先会获取我们的Widget线程,再从Widget线程中获取到线程专属Handler。

public  Handler getHandlerByName(String name){

		return getWidgetThread(name).getHandler();
	}

	private synchronized WidgetThread getWidgetThread(String name) {
		WidgetThread thread;HashMap map = getThreadMap();
		if (map.containsKey(name)){
			//取出
			thread = (WidgetThread) map.get(name);
		}else {
			//创建HandlerThread
			thread = new WidgetThread(name);
			thread.start();
			putWidgetThread(name,thread);

		}
		return thread;
	}

    以下是WidgetThread封装,关于HandlerThread用法读者可移步这篇文章,这里不做详细介绍。

public class WidgetThread extends HandlerThread{
		private Handler mHandler;

		public WidgetThread(String name) {
			super(name);
		}
		@Override
		protected void onLooperPrepared() {
			getHandler();
		}

		public Handler getHandler() {
			if (mHandler==null){
				synchronized (WidgetThread.class){
					if (mHandler==null)
						mHandler = new Handler(getLooper());
				}
			}
			return mHandler;
		}
		@Override
		public boolean quitSafely() {
			getHandler().removeCallbacksAndMessages(null);
			return super.quitSafely();
		}

		@Override
		public boolean quit() {
			getHandler().removeCallbacksAndMessages(null);
			return super.quit();
		}
	}

    在WidgetProvider的onDeleted方法回调时,我们使用ACTION_DELETE启动UpdateWidgetService,并在Service中针对appWidgetId的实时工作线程需要退出处理。

private void deleteWidget(int appWidgetId) {
		ThreadManager
			.getInstance(getApplicationContext())
			.stopWidgetThread(generateName(appWidgetId));
	}

    最后,附上效果图。图1展示了创建Widget过程。图2展示了多Widget实时更新过程。


    附github源码 WidgetDemo

猜你喜欢

转载自blog.csdn.net/qq_33859911/article/details/80904166