最近在学习android小组件开发,记之以便日后温习。桌面小组件可以方便使用app的隐藏功能,实现一键触达,提升用户体验。这样可以极大便捷家里的老人使用应用,享受到互联网带来的便捷。最近给家里的老人添加了追剧的小组件,这样她不需要学习怎么搜索、怎么选择,通过桌面小组件便可以快速触达。下面将分别介绍基础小组件开发、列表小组件开发、开发小组件的注意事项。
一、基础小组件的开发
实现android的小组件需要按照一定的范式,主要包括四步,下面将以扫一扫的小组件入口为例分别介绍:
第一步:创建ScanWidgetProvider.java类,继承自android的AppWidgetProvider。小组件的核心逻辑包括点击、展示都在此处。后文会详细介绍。目前此类可以保持空实现。
第二步:在AndroidManifest中配置小组件的receiver,了解android的都知道receiver配置的是广播接收器,难道小组件是一个广播接收器?通过查看AppWidgetProvider的继承类发现,其继承了BroadcastReceiver,所以小组件实际上就是一个广播接收器。
<receiver
android:name="com.demo.widget.ScanWidgetProvider" //第一步中创建的小组件类名称
android:exported="true" //此处需要配置true
android:label="扫一扫"> //添加引导处展示的标签名称
<meta-data
android:name="android.appwidget.provider" //固定格式
android:resource="@xml/widget_scan_resource" /> //配置文件
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
</receiver>
第三步:创建第二步中用到的resource文件widget_scan_resource.xml,创建在res/xml文件下,示例如下。
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/scan_widget_layout" //布局文件
android:minHeight="110dp" //最小高度
android:minWidth="110dp" //最小宽度
android:previewImage="@drawable/scan_demo_image" //示例图,往往为我们组件的样式图
android:updatePeriodMillis="3600000" /> //更新时间间隔,单位是毫秒,
上述代码需重点介绍minHeight和minWidth。手机将桌面划分成了一个个独立网格,然后桌面内容都展示在网格中,一个应用图标就占一格。小组件在桌面同样展示在网格中,当占一个网格时就是1X1的组件;横向两个网格就是2X1的组件,那么横纵各两个网格自然就是2X2的组件。此处的minHeight和minWidth是指需要占用的网格的总长和宽,常用经验公式计算:(m为横向网格数,n为竖向网格数)
minWidth = 70 x m - 30(dp)
minWidth = 70 x n - 30(dp)
此时你可能不禁会问,如果设置的最小宽高和手机网格的宽高不匹配怎么办。没关系,系统会帮取整找到最近的网格倍数设置给小组件。这也是为什么此参数需要加上min(最小)了。
第四步:创建小组件的布局文件scan_widget_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/ll_widget_scan_guide"
android:layout_width="148dp"
android:layout_height="148dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/scan_widget_bg">
<!--图片-->
<ImageView
android:id="@+id/iv_scan"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_centerHorizontal="true"
android:layout_marginTop="10dp"
android:scaleType="fitXY"
android:src="@drawable/demo_scan" />
<TextView
android:id="@+id/tv_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/iv_scan"
android:layout_centerHorizontal="true"
android:layout_marginTop="10dp"
android:text="扫一扫"
android:textSize="24dp"
android:textStyle="bold" />
</RelativeLayout>
</FrameLayout>
至此一个简单的小组件就开发完成了。将以上代码放入项目中,手机中就可以添加扫一扫小组件了。如下图所示
目前上述小组件只有空壳,还没有灵魂,所以需要为他注入灵魂。想必你已经猜到,就是在第一步创建的ScanWidgetProvider.java类中注入小组件的“灵魂”。那么小组件的灵魂是什么呢?当然是曝光更能吸引用户的信息,点击后能拉起app到指定落地页。废话不多说,先上代码。
public class ScanWidgetProvider extends AppWidgetProvider {
private static final String TAG = "ScanWidgetProvider";
private static RemoteViews remoteViews;
@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "onReceive");
super.onReceive(context, intent);
}
//小组件更新时回调,所以加载小组件的逻辑都在此处
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
Log.d(TAG, "onUpdate");
super.onUpdate(context, appWidgetManager, appWidgetIds);
if (appWidgetManager == null || appWidgetIds == null) {
Log.d(TAG, "onUpdate error, appWidgetManager=" + appWidgetManager + " appWidgetIds=" + appWidgetIds);
return;
}
//小组件是运行在独立进程中,所以涉及到跨进程的渲染,故只能使用remoteViews加载和设置ui
createRemoteViews(context);
updateDefaultWidget(context, appWidgetManager, appWidgetIds);
}
private static void updateDefaultWidget(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
int roundCorner = Utils.dip2px(context, 14); //dp转化为px,工具类代码不贴了,网上很多
int topWidth = Utils.dip2px(context, 80);
int topHeight = Utils.dip2px(context, 80);
//将图片压缩加载
Bitmap topBitmap = decodeSampleBitmap(context, R.drawable.demo_scan, topWidth, topHeight);
if (topBitmap!= null) {
//使用remoteViews设置图片视图
remoteViews.setImageViewBitmap(R.id.iv_scan, topBitmap);
}
//使用remoteViews设置展示文字和颜色
remoteViews.setTextViewText(R.id.tv_scan, "AR扫一扫");
remoteViews.setTextColor(R.id.tv_scan, Color.RED);
updateWidgetClickEvent(context, appWidgetManager, appWidgetIds);
}
private static void updateWidgetClickEvent(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
DebugLog.log(TAG, "updateWidgetClickEvent");
Intent intent = new Intent("com.demo.scan.main");
intent.setPackage(context.getPackageName());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.putExtra("WIDGET_NAME", "ScanWidgetProvider");
//remoteViews设置点击事件,只能使用PendingIntent
intent.putExtra("CLICK_ACTION", "scan_demo_image_click");
PendingIntent pendingIntent = PendingIntent.getActivity(context, R.id.iv_scan, intent, PendingIntent.FLAG_UPDATE_CURRENT);
remoteViews.setOnClickPendingIntent(R.id.iv_scan, pendingIntent);
//渲染UI
submitRender(appWidgetManager, appWidgetIds, remoteViews);
}
//渲染小组件
private static void submitRender(AppWidgetManager appWidgetManager, int[] appWidgetIds, RemoteViews remoteViews) {
for (int appWidgetId : appWidgetIds) {
Log.d(TAG, "submitRender,widgetId:" + appWidgetId);
appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
}
}
private void createRemoteViews(Context context) {
if (remoteViews == null) {
DebugLog.log(TAG, "createRemoteViews");
remoteViews = new RemoteViews(context.getPackageName(), R.layout.scan_widget_layout);
}
}
public static Bitmap decodeSampleBitmap(Context context, int resId, int reqWidth, int reqHeight) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(context.getResources(), resId, options);
//计算采样比例
options.inSampleSize = calculateSampleSize(options, reqWidth, reqHeight);
DebugLog.log(TAG,"options.inSampleSize:" + options.inSampleSize);
options.inJustDecodeBounds = false;
return bitmap = BitmapFactory.decodeResource(context.getResources(), resId, options);
}
}
public static int calculateSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
//获得图片的原宽高
int height = options.outHeight;
int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
// 计算出实际宽高和目标宽高的比率
final int heightRatio = Math.round((float) height / (float) reqHeight);
final int widthRatio = Math.round((float) width / (float) reqWidth);
inSampleSize = Math.min(heightRatio, widthRatio);
}
return inSampleSize;
}
小组件的更新代码都在onUpdate中实现,widget中只能使用RemoteViews提供的一些列set方法更新小组件的内容,例如使用setImageViewBitmap设置图片,使用setTextViewText设置文字,使用setOnClickPendingIntent设置点击事件等。
至此一个基础的小组件开发就完成了,由于小组件展示在“寸土寸金”的桌面,使用基础组件就可以完成绝大部分需求。但是如果碰到需要展示列表形式,则需要使用ListView、GridView。
二、列表组件的开发
通过上文介绍知道小组件并不能像普通安卓应用开发那样,找到指定view,然后对view进行操作。小组件需要通过RemoteViews提供的一些列的set方法对视图渲染更新。那么listview和gridview也是一样的。回忆一下列表视图的正常开发流程,先创建listview的视图,然后为视图设置layoutmanager设置布局样式,最后创建列表的adapter进行数据填充。widget采用同样的实现流程,只是需要使用符合widget规范的特有方式实现。接下来将在上文案例的基础上进行修改:
第一步:修改widget_scan_resource.xml的最小宽高,设置为4X4的组件
android:minHeight="250dp" //最小高度
android:minWidth="250dp" //最小宽度
第二步:修改布局文件scan_widget_layout.xml,在命名为tv_scan的Textview下增加girdlist
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/ll_widget_vip_guide"
android:layout_width="296dp"
android:layout_height="296dp">
.....
<GridView
android:id="@+id/scan_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/tv_scan"
android:layout_alignParentBottom="true"
android:horizontalSpacing="4dp"
android:numColumns="auto_fit"
android:verticalSpacing="4dp" />
......
</FrameLayout >
第三步:创建更新列表视图需要的“adapter”,此处打了双引号,就是说需要按照小组件的方式实现,下面两个类按照固定范式实现。
public class ListWidgetService extends RemoteViewsService {
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
return new ListWidgetFactory(this);
}
}
public class ListWidgetFactory implements RemoteViewsService.RemoteViewsFactory {
private final List<String> data = new ArrayList<>();
private final Context context;
public ListWidgetFactory(Context context) {
this.context = context;
}
@Override
public void onCreate() {
initData();
}
private void initData() {
data.clear();
for (int i = 0; i < 10; i++) {
Random random = new Random();
String msg = "扫一扫" + random.nextInt(101);
data.add(msg);
}
}
@Override
public void onDataSetChanged() {
}
@Override
public void onDestroy() {
data.clear();
}
@Override
public int getCount() {
return data.size();
}
//重点实现
@Override
public RemoteViews getViewAt(int position) {
RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.list_scan_item);
remoteViews.setTextViewText(R.id.list_item_tv, data.get(position));
remoteViews.setImageViewResource(R.id.list_item_image, R.drawable.demo_scan);
Intent intent = new Intent();
intent.putExtra("CLICK_POSITION", position);
remoteViews.setOnClickFillInIntent(R.id.list_item_root, intent);
return remoteViews;
}
@Override
public RemoteViews getLoadingView() {
return null;
}
@Override
public int getViewTypeCount() {
return 1;
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public boolean hasStableIds() {
return true;
}
}
这一步代码有点长,但是逻辑很简单。就是先创建RemoteViewsService ,通过此service返回一个RemoteViewsFactory ,RemoteViewsFactory 就是通常理解的列表更新的adapter,其实从内部的继承方法也可以看出。getItemId、getViewTypeCount、getCount是不是都似曾相识?没错,就是开发列表adapter常复写的方法。
此处重点介绍一下getViewAt,内部同样需要使用RemoteViews实现单个列表的view,列表内元素的信息更新需要使用RemoteViews提供的每个set方法。list_scan_item视图布局代码不再提供,很简单就是上图下文的形式。
第四步:更新ScanWidgetProvider,主要在onUpdate方法中设置列表的点击事件和“adapter”
// 设置列表的adapter
Intent listIntent = new Intent(context, ListWidgetService.class);
remoteViews.setRemoteAdapter(R.id.scan_list, listIntent);
//设置列表的点击事件
Intent intent1 = getCommenIntent(context);
intent1.putExtra("CLICK_ACTION", "scan_demo_list_click");
PendingIntent pendingIntent1 = PendingIntent.getActivity(context, R.id.scan_list, intent1, PendingIntent.FLAG_UPDATE_CURRENT);
remoteViews.setPendingIntentTemplate(R.id.scan_list, pendingIntent1);
上述代码需重点关注列表的点击事件,setPendingIntentTemplate是设置列表的通用点击Intent,然后单个item的点击如果需要增加参数,则需使用setOnClickFillInIntent设置补充信息。接收方收到的intent中包含这两部分的内容,于是便可以区分出具体是哪个item的点击了。至此一个列表的小组件便实现完成了。
三、小组件的开发原理和注意事项
1、小组件实际上是运行在系统进程中,所以和app进程涉及到跨进程通信,只能使用RemoteViews更新数据,使用PendingIntent执行延时意图。
2、RemoteViews支持的布局只有以下这些,开发的小组件布局不能超出这些范围。
布局:FrameLayout、LinearLayout、RelativeLayout、GridLayout。
View:Button、TextView、Chronometer、ImageButton、ImageView、ProgressBar、ViewFlipper、AnalogClock、AdapterViewFlipper、ViewStub、ListView、GridView、StackView
3、小组件类继承自AppWidgetProvider,实际上就是一个广播,可以通过发送广播和接受广播进行通信更新,广播接受在onReceive中实现。
onUpdate :小组件创建时和达到更新周期时触发。
onEnabl:第一个改类型小组件添加时触发。
onDeleted:每次删除该小组件时触发。
onDisabled:最后一个该类型小组件添加时触发。
4、在AndroidManifest注册小组件必须要有android.appwidget.action.APPWIDGET_UPDATE这个action,否则将无法安装,其他的action和自定义的action可根据需要添加。