MIUI Widget适配建议及示例

1.MIUI Widget布局自适应方案建议及示例

1.1.概述

背景见:《MIUI小部件技术规范与系统能力说明》中「小部件技术规范和要求」第八项。

本模块介绍了常用的几类Widget布局实现自适应的方案。

1.2.方案与示例

1.2.1.快捷卡片的自适应方案实践

快捷功能卡片中每个icon的大小是固定的,但是在widget中无法获取具体显示大小,因此如果只写死一套尺寸,在某些模式下,icon会显示不全。

目前采用的方案是,使用2套布局资源,在布局中写死大小,根据Google小部件开发指南中提到的,使用OPTION_APPWIDGET_MIN_WIDTH或者OPTION_APPWIDGET_MAX_WIDTH返回的数值决定使用哪套布局:

先定义2套不同尺寸的layout资源:

//layout_shortcut_large
<?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="wrap_content"
    android:orientation="vertical">

    <ImageView
        android:id="@+id/shortcut_icon"
        android:layout_width="53dp"
        android:layout_height="53dp"
        android:layout_gravity="center"
        android:scaleType="centerCrop" />

    <TextView
        android:id="@+id/shortcut_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="@dimen/dp_4"
        android:textSize="24sp" />
</LinearLayout>


//layout_shortcut_small
<?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="wrap_content"
    android:orientation="vertical">

    <ImageView
        android:id="@+id/shortcut_icon"
        android:layout_width="28dp"
        android:layout_height="28dp"
        android:layout_gravity="center"
        android:scaleType="centerCrop" />

    <TextView
        android:id="@+id/shortcut_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="@dimen/dp_4"
        android:textSize="14sp"/>
</LinearLayout>

在代码中动态判断使用哪一套布局:

class SimpleWidgetProvider : AppWidgetProvider() {

    override fun onUpdate(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetIds: IntArray) {
        appWidgetIds.forEach { appWidgetId ->
        updateAppWidget(context, appWidgetManager, appWidgetId)
        }
    }

    fun updateAppWidget(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetId: Int,
    ) {
        val appWidgetManager = AppWidgetManager.getInstance(context)
        val options = appWidgetManager.getAppWidgetOptions(appWidgetId)
        if (options == null) {
            return null
        }
        val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
        val widgetLayoutRes = if (minWidth >= 300)R.layout.layout_shortcut_large else R.layout.layout_shortcut_small
        val remoteViews = RemoteViews(context.packageName, widgetLayoutRes)
        appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
    }
    
    /**
     * 任何option的更改都会触发,无法通过newOptions来判断是否是大小发生了变化
     * 需要将本次的MAX_HEIGHT/MAX_WIDHT记录下来,在下一次触发时比对
     */
    @CallSuper
    override fun onAppWidgetOptionsChanged(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetId: Int,
        newOptions: Bundle
    ) {
        //需要在options change通知里判断,min和max是否发生了变化,如果发生了变化,需要更新布局
    }
}

1.2.2.股票卡片的自适应方案实践

RemoteViews无法动态设置宽高,但是可以在布局中设置宽高,为了实现卡片的自适应,我们不建议在布局中给layout_width和layout_height设置固定值,但是可以通过设置最大和最小宽高来达到自适应的效果;

例如股票通过调整ListView中每个item的maxHeight和maxHeight,来实现自适应(以下按1080*2340分辨率为例):

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/stock_item"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:maxHeight="43.64dp"
    android:minHeight="40.97dp"
    android:background="@drawable/pa_bg_stock_widget_item"
    android:paddingHorizontal="@dimen/dp_7">

    <include layout="@layout/pa_widget_stock_item" />
</RelativeLayout>
  • android:minHeight计算:(418-40*2)/ 3 / 2.75=40.97
  • android:maxHeight计算:(440-40*2)/ 3/ 2.75 = 43.64

计算过程:

在MIUI小部件设计规范中,列出了Widget Host在不同模式下的尺寸:

如表格所示,4*2规格的卡片,其最小显示高度是418px,最大显示高度是440px,我们要求内容区距离卡片外边距最小是40px,因此,对于4*2widget的内容区的最小显示高度为418-40*2;

股票4*2最多显示3条数据,因此每条数据的最小显示高度为(418-40*2)/ 3,换算成dp为40.97dp;最大显示高度类似;

1.2.3.其他方案建议

使用LinearLayout等分布局:

疫情卡片的一种自适应布局实现:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="40px">

    <TextView
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

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

        <LinearLayout
            android:id="@+id/item1"
            android:layout_width="0dp"
            android:layout_height="100dp"
            android:layout_marginEnd="10dp"
            android:layout_weight="1">

        </LinearLayout>

        <LinearLayout
            android:id="@+id/item2"
            android:layout_width="0dp"
            android:layout_height="100dp"
            android:layout_marginEnd="10dp"
            android:layout_weight="1">

        </LinearLayout>

        <LinearLayout
            android:id="@+id/item3"
            android:layout_width="0dp"
            android:layout_height="100dp"
            android:layout_marginEnd="10dp"
            android:layout_weight="1">

        </LinearLayout>

        <LinearLayout
            android:id="@+id/item4"
            android:layout_width="0dp"
            android:layout_height="100dp"
            android:layout_weight="1">

        </LinearLayout>
    </LinearLayout>

</FrameLayout>

利用LinearLayout的android:layout_height属性,动态分配每个item的尺寸;

注:股票卡片也可以采用该方案进行适配,事实上,所有使用ListView或者GridView的卡片布局,都可以用该方案代替,来达到自适应效果,但是相应地需要进行更多自定义的封装,开发成本更高,但是效果比较理想;

使用RelativeLayout相对布局:

手机管理卡片的一种自适应布局实现:

<?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:orientation="horizontal"
    android:padding="40px">

    <RelativeLayout
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1">

        <ImageView
            android:id="@+id/icon"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <TextView
            android:id="@+id/score_description"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentStart="true"
            android:layout_alignParentBottom="true"
            android:text="手机很安全,继续保持"/>

        <TextView
            android:id="@+id/score"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="100"/>
    </RelativeLayout>


    <LinearLayout
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:text="手机垃圾"/>

        <TextView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:text="内存占用"/>
    </LinearLayout>

</LinearLayout>

1.3.总结

  • 以上各个方案只是目前为止,可能的部分自适应实践建议,业务需要根据自身情况来判断如何适配
  • 以上各个方案,部分方案可以通过组合的方式,来实现更多效果
  • 以上示例代码中的数值和布局,只具有演示效果,业务需要根据具体情况调整,请勿原样复制

2.小部件加载态建议及示例

2.1.概述

背景见:《MIUI小部件规范》中「无内容场景」。没有配置加载态手机重启或者桌面/负一屏重启会呈现空白卡片,影响整体体验,本模块介绍了推荐的加载态实现方案。

2.2.方案

默认展示加载态(initialLayout指定加载态布局资源),当收到update广播时更新为实际的布局

# 工程结构
.
└── src
    ├── main
    │   └── java
    └── res
        ├── drawable
        │   └── loading.webp
        ├── drawable-night
        │   └── loading.webp
        ├── layout
        │   ├── layout_example.xml
        │   └── layout_loading.xml
        └── xml
            └── app_widget_simple.xml

layout_example.xml 正常的布局资源

...

layout_loading.xml 加载态布局资源

<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:scaleType="fitXY"
    android:src="@drawable/loading.webp" />

app_widget_simple.xml 小部件配置资源

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:minWidth="xxx"
    android:minHeight="xxx"
    ...
    android:initialLayout="@layout/layout_loading.xml"/>

AppWidgetProvider 使用正常布局文件创建RemoteViews

public class ExampleWidget extends AppWidgetProvider {

    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        super.onUpdate(context, appWidgetManager, appWidgetIds);
        for (int appWidgetId : appWidgetIds) {          
            // 正常布局文件创建RemoteViews 
            RemoteViews remoteViews = new RemoteViews(context.getPackageName(), 
            R.layout.example);
            ...        
        }
    }
}

备注:以上方案仅做参考。

小米MIUI 小部件系统能力说明

小米MIUI 小部件系统能力说明

1.调起 MIUI Widget 商店里的详情页,添加应用的 MIUI Widget

1.1.描述

启动 Widget 详情页,详情页会包含该 App 通过审核的Widget。用户可以左右滑动预览,并选择其中一个添加到桌面。

1.2.调用方式

关键方法
appWidgetManager.requestPinAppWidget(myProvider, extras, null)
使用 extras 携带参数 
addType: appWidgetDetail
widgetName: 小部件name,可选参数,用来指定打开详情页后定位到的组件。如果不填,默认定位到第一个。
widgetExtraData: 用于携带自定义参数,携带自定义参数类型只能是String,最多携带5个,超过5个所有携带的自定义参数都将被抛弃。

注意:该方法仅支持 Android 8.0 及以上系统。不支持 MIUI Widget 的手机调用requestPinAppWidget 方法不会调起 Widget 商店里的详情页。

1.3.示例

public void startAppWidgetPage(Context context){
    AppWidgetManager appWidgetManager =  
    AppWidgetManager.getInstance(context);
    ComponentName myProvider = new ComponentName(this,  
    ExampleWidgetProvider.class);
    if (appWidgetManager.isRequestPinAppWidgetSupported()) {
        Bundle extras = new Bundle();
        extras.putString("addType","appWidgetDetail");
        // packageName 为应用真实包名
⁣        extras.putString("widgetName",
            "packageName/com.miui.ExampleWidgetProvider");
        Bundle dataBundle = new Bundle();
        dataBundle.putString("dataKey1","data1xxx");
        dataBundle.putString("dataKey2","data2xxx");
        dataBundle.putString("dataKey3","data3xxx");
        dataBundle.putString("dataKey4","data4xxx");
        dataBundle.putString("dataKey5","data5xxx");
        extras.putBundle("widgetExtraData", dataBundle);
        appWidgetManager.requestPinAppWidget(myProvider, extras, null);
    }
}

// 获取自定义参数
public class ExampleWidgetProvider extends AppWidgetProvider {
 
   @Override
   public void onAppWidgetOptionsChanged(Context context, AppWidgetManager
     appWidgetManager, int appWidgetId, Bundle newOptions) {
         super.onAppWidgetOptionsChanged(context, appWidgetManager, 
         appWidgetId, newOptions);
        if(extras != null){
            Bundle dataBundle = extras.getBundle("widgetExtraData");
            dataBundle.getString("xxxkey");
        }   
    }    
}

2.设置 MIUI Widget卡片状态

2.1.描述

MIUI Widget 也可以在智能助理(负一屏)进行展示。应用可以选择向系统发送卡片的优先级状态,MIUI智能助理(负一屏)会根据卡片状态进行动态的排序。

2.2.调用方式

key:    miuiWidgetEventCode     
类型:    String
描述:   事件code命名规则请参考code事件表
key:    miuiWidgetTimestamp
类型:   String
描述:   状态变化时的时间戳 
appWidgetManager.updateAppWidgetOptions(widgetId, options);
appWidgetManager.updateAppWidget(widgetId, views);

2.3.code 事件表

事件code命名规则请参考下表,具体事件与code对应关系以双方沟通结果为准
*新增或修改事件需与MIUI商务同学联系,上报未确认的事件code不会生效,恶意错报被系统识别后会降低推荐权重
核心场景事件事件code备注
金融
股票证券
小部件内有股票/基金在交易opening
小部件内无股票/基金在交易closing
出行通勤时间commuting
非通勤时间other
直播/电台小部件内展示内容“直播中”live1不同的事件可用live1、live2…区分
小部件内展示内容“重播中”replay
小部件内展示内容“未开播”other
连接状态已连接connected
未连接disconnect
通知提醒小部件内展示有通知提醒提醒notice1不同的事件可用notice1、notice2…区分
进度状态如“打车进度”“下单进度”等progress1不同的事件可用progress1、progress2…区分
内容资讯如“今日热门”“话题榜”“热搜榜”等info1不同的事件可用info1、info2…区分
功能跳转小部件为纯工具类的功能跳转state1不同的事件可用state1、state2…区分
其他小部件的兜底展示方案或其他状态other

2.4.示例

  • 普通 Widget :在 AppWidgetProvider 的 onUpdate 方法,调用 updateAppWidgetOptions 方法。
public class ExampleWidgetProvider extends AppWidgetProvider {
    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        // todo 准备待更新数据
        ....
        // 更新数据
        for (int appWidgetId : appWidgetIds) {
            RemoteViews views = new RemoteViews(context.getPackageName(), 
            R.layout.widget_ui);
            Bundle options = new Bundle();
            options.putString("miuiWidgetEventCode", "notice1");
            long currentTimeMillis = System.currentTimeMillis();
            options.putString("miuiWidgetTimestamp",
            String.valueOf(currentTimeMillis));
            appWidgetManager.updateAppWidgetOptions(widgetId, options);
            appWidgetManager.updateAppWidget(widgetId, views);
        }
    }
}
  • 列表 Widget : notifyAppWidgetViewDataChanged 更新后调用 updateAppWidget 和updateAppWidgetOptions 方法
public class ExampleWidgetProvider extends AppWidgetProvider {
    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        for (int appWidgetId : appWidgetIds) {
            // todo 准备待更新数据
            ...
            appWidgetManager.notifyAppWidgetViewDataChanged (mAppWidgetId,R.id.content);
            // 更新数据后进行状态更新
            Bundle options = new Bundle();
            options.putString("miuiWidgetEventCode", "notice1");
            long currentTimeMillis = System.currentTimeMillis();
            options.putString("miuiWidgetTimestamp",
            String.valueOf(currentTimeMillis));
            appWidgetManager.updateAppWidgetOptions(mAppWidgetId, options);
            RemoteViews remoteViews = new RemoteViews(mContext.getPackageName(),             R.layout.widget_list_example);
            appWidgetManager.updateAppWidget(mAppWidgetId, remoteViews);
        }
    }
}

注意:updateAppWidgetOptions 需要在 updateAppWidget ()方法前,并且两者需要同一线程。

3.设置编辑页

3.1.描述

MIUI Widget 额外提供进入编辑页的入口。用户点击编辑按钮,可以进入编辑页进行相应的设置。

3.2.调用方法与参数

Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId);
String uriPath = "widgetdemo://com.miui.widgetdemo/widget/WidgetEditActivity";
options.putString("miuiEditUri", uriPath);
appWidgetManager.updateAppWidgetOptions(appWidgetId, options);
appWidgetManager.updateAppWidget(widgetId, views);

3.3.示例

public class ExampleWidgetProvider extends AppWidgetProvider {
    @Override
    public void onUpdate(Context context, AppWidgetManager 
        appWidgetManager, int[] appWidgetIds) {
        for (int appWidgetId : appWidgetIds) {          
            Bundle options =   
            appWidgetManager.getAppWidgetOptions(appWidgetId);
            String uriPath =            "widgetdemo://com.miui.widgetdemo/widget/WidgetEditActivity";
            options.putString("miuiEditUri", uriPath);
            appWidgetManager.updateAppWidgetOptions(appWidgetId, options);
            RemoteViews views =   
            new RemoteViews(context.getPackageName(), 
            R.layout.widget_ui);
            appWidgetManager.updateAppWidget(widgetId, views);
        }
        ....
    }
}
// 通过 intent 可以获取点击的appWidgetId
public class WidgetEditActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        Intent intent = getIntent();
        int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1);
    }
}

4.同功能 MIUI Widget 聚合

4.1.描述

MIUI Widget 可以把不同尺寸相同功能的Widget 在详情页中聚合在一块显示,用户可以根据需求添加相应尺寸的Widget。

4.2.调用方式

在AndroidManifest.xml 文件中 AppWidgetProvider 对应的receiver 如果label 的名称相同的话将会被认为是同一功能的不同尺寸。

4.3.示例

<receiver
    android:name="com.miui.widgetdemo.provider.ExampleWidgetProvider1"
    android:label="@string/app_widget_group">
    ...
</receiver>

<receiver
    android:name="com.miui.widgetdemo.provider.ExampleWidgetProvider2"
    android:label="@string/app_widget_group">
   ...
</receiver>

5.点击  MIUI Widget 跳转应用页面

5.1.描述

在 AppWidgetProvider 的onUpdate方法中,使用 RemoteViews 的 setOnClickPendingIntent 设置启动的 Intent 。使用 AppWidgetManager 的 updateAppWidget方法关联RemoteViews 和Widget。

5.2.参数

// viewId Widget 布局 id
// PendingIntent Intent的封装
// appWidgetId Widget 的 id,可在 onUpdate 方法参数中获取
// appWidgetManager widget 的管理对象,可在 onUpdate 方法参数获取
remoteViews.setOnClickPendingIntent(int viewId, PendingIntent pendingIntent)
appWidgetManager.updateAppWidget(int appWidgetId, RemoteViews views)

5.3.示例

// step1: 构建跳转页面的 PendingIntent
Intent intent = new Intent(context, ExampleActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
// step2: 生成 RemoteViews 关联 PendingIntent
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget_provider_layout);
views.setOnClickPendingIntent(R.id.button, pendingIntent);
// step3: 关联 widget 和 RemoteViews
appWidgetManager.updateAppWidget(appWidgetId, views);

6.判断 MIUI Widget 是否已经添加

6.1.描述

使用系统 AppWidgetManager 的 getAppWidgetIds方法判断某个Widget是否已添加。

6.2.示例

ComponentName componentName = new ComponentName(getPackageName(), "com.miui.ExampleAppWidgetProvider");
int[] appWidgetIds = AppWidgetManager.getInstance(getApplicationContext()).getAppWidgetIds(componentName);
if(appWidgetIds.length > 0){
    // 已添加
} else {
    // 未添加
}

7.判断是否支持MIUI Widget

7.1.描述

开发者可以通过示例方式判断当前手机是否支持MIUI Widget。

7.2.示例

@WorkerThread
public boolean isMiuiWidgetSupported() {
    Uri uri = 
    Uri.parse("content://com.miui.personalassistant.widget.external");
    boolean isMiuiWidgetSupported = false;
    try {
        Bundle bundle = getContentResolver().call(uri, 
        "isMiuiWidgetSupported", null, null);
        if (bundle != null) {
            isMiuiWidgetSupported = bundle.getBoolean("isMiuiWidgetSupported");
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return isMiuiWidgetSupported;
}

8.判断是否支持MIUI Widget 小部件详情页

8.1.描述

开发者可以通过示例方式判断当前手机是否支持MIUI Widget 详情页。为了提升用户体验,部分机型支持 MIUI Widget(包含MIUI Widget 特性,例如曝光刷新等), 但不支持调起MIUI Widget 详情页。这部分机型添加小部件的方式与旧版系统一致。

8.2.示例

@WorkerThread
public boolean isMiuiWidgetDetailPageSupported() {
    Uri uri = 
    Uri.parse("content://com.miui.personalassistant.widget.external");
    boolean isMiuiWidgetDetailPageSupported = false;
    try {
        Bundle bundle = getContentResolver().call(uri, 
        "isMiuiWidgetDetailPageSupported", null, null);
        if (bundle != null) {
            isMiuiWidgetDetailPageSupported = bundle.getBoolean("isMiuiWidgetDetailPageSupported");
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return isMiuiWidgetDetailPageSupported;
}

9.MIUI Widget 与 Activity 切换动画

9.1.描述

MIUI 系统为MIUI Widget 和 Activity 切换时增加了过渡动画。开发者可根据自身业务场景决定是否使用过渡动画。动画默认开启,如果不使用,设置”miuiWidgetTransitionAnimation”为false。

9.2.示例

<receiver android:name="com.miui.ExampleWidgetProvider" >
    <meta-data
    android:name="miuiWidget"
    android:value="true" />
    // 关闭动画
    <meta-data
    android:name="miuiWidgetTransitionAnimation"
    android:value="false" />

    ...
</receiver>

10.App主动刷新小部件

10.1.描述

当应用在前台使用或在后台存活时,可调用AppWidgetManager. updateAppWidget()方法,主动刷新小部件。这样可以提高小部件内容的实时性和准确性。

10.2.示例

// 通过组件名更新
public void updateWidget(Context context) {
    // R.layout.demo_appwidget_normal_example 为小部件布局文件,这里仅示例
    RemoteViews remoteViews = new RemoteViews(context.getPackageName(),
            R.layout.demo_appwidget_normal_example);
    AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
    // NormalExampleWidgetProvider 为小部件组件名字,这里仅示例
    ComponentName componentName = new ComponentName(context, NormalExampleWidgetProvider.class);
    appWidgetManager.updateAppWidget(componentName, remoteViews);
}
// 通过widgetId更新
public void updateWidgetByWidgetId(Context context, int widgetId) {
    // R.layout.demo_appwidget_normal_example 为小部件布局文件,这里仅示例
    RemoteViews remoteViews = new RemoteViews(context.getPackageName(),
            R.layout.demo_appwidget_normal_example);
    AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
    appWidgetManager.updateAppWidget(widgetId, remoteViews);
}

11.Push透传刷新服务

11.1.描述

当MIUI Widget状态发生改变且Widget不可见、主应用未启动时,开发者可调用小部件服务端提供的更新API,经由MI PUSH发送透传消息给负一屏/桌面客户端,由负一屏/桌面拉起Widget独立进程(不唤醒应用主进程),以实现MIUI Widget实时刷新的目的。

流程图:

11.2.接口规范

域名: https://developer.assistant.miui.com
路径: /openapi/widget/{cpCode}/refresh
     cpCode值由小部件开发人员提供
参数:需包含Header以及Body
Header参数表
头域名称描述是否必选类型取值范围
Content-Type固定值,填application/jsonStringapplication/json
app-id在小米开放平台注册的程序编号String
access-token应用级token(校验请求权限,通过帐号服务获取)String最大长度:259
sign签名(验证请求完整性,下方有签名生成方法)String
timestamp当前时间戳(防止请求重放,会根据该字段判断请求有效期)long13位 毫秒时间戳
trace-id请求的唯一标识(用来定位每次请求)String只能包含数字和大小写字母,最大长度64
Body参数表
字段名描述必须类型
oaid设备oaidString
widgetId设备 widgetId (安卓生成的id)String
widgetProviderName小部件providerNameString
extra额外透传到小部件内容String

注意:

  • 获取access-token:access-token的获取方式请联系相关技术支持。
  • 处理签名
签名生成: 
    假设: appId = "111111111" , 当前时间戳是 1606206667013 ,秘钥是 "testSecret"
    step 1: 先将body进行md5,再转化为16进制小写字符串
            公式:md5Body= Lowercase(hexEncode(md5(body)))
    step 2: 将appId、毫秒时间戳和body的md5Body值,按照参数名称进行字典排序,用‘&’连接获得strToDigest
            如:strToDigest="appId=111111111&body=md5Body&timestamp=1606206667013"          
    step 3: 将获得到的摘要字符串(strToDigest)进行HMAC_SHA_256,再转化为16进制小写字符串得到签名
            公式:Lowercase(hexEncode(HMAC_SHA_256("testSecret", strToDigest)))
// 签名java demo: 
// demo引用了apache的工具类进行处理,若不想引用可以参考其逻辑自行实现
<!-- https://mvnrepository.com/artifact/commons-codec/commons-codec -->
<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.14</version>
</dependency>

@Slf4j
public class SignatureUtils {
    
    /**
     * 生成签名
     *
     * @param appId     应用id
     * @param secret    秘钥
     * @param timestamp 请求的时间戳
     * @param body      请求的body内容
     * @return 签名信息
     */
    public static String generateSignature(String appId, String secret, String timestamp, String body) {
        String sign = null;
        try {
            String md5Body = DigestUtils.md5Hex(body);
            Map<String, String> paramMap = new TreeMap<>();
            paramMap.put("appId", appId);
            paramMap.put("timestamp", timestamp);
            paramMap.put("body", md5Body);
            String strToDigest = paramMap.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining("&"));
            sign = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secret).hmacHex(strToDigest);
        } catch (Exception e) {
            log.error("generate signature exception !", e);
        }
        return sign;
    }
    
    /**
     * 验证签名
     *
     * @param appId     应用id
     * @param secret    秘钥
     * @param timestamp 请求的时间戳
     * @param body      请求的body内容
     * @param sign      请求的签名
     * @return 签名信息
     */
    public static boolean verifySignature(String appId, String secret, String timestamp, String body, String sign) {
        return sign.equals(generateSignature(appId, secret, timestamp, body));
    }
}
  • 返回状态码定义
状态码描述
成功0
服务异常1000
无效参数1001
缺失参数1002
token验证失败3000
签名验证失败4000
流量限制5000
  • 开发者MIUI Widget可从刷新广播的Intent中得到extra信息。

11.3.示例

// Curl示例:
curl --location --request POST 'http://developer.assistant.miui.com/openapi/widget/testCp/refresh' 
    --header 'app-id: 111111111' 
    --header 'access-token: A1_oKs19iiWArgij6qFYaEAooAMqG3bhXUgS0MZQf63KQTgWju-oj9YuccqR1EhbugrqnFmooNr6mKdkfEdKN740fUYpxk0o9ZHE5QpFvR1fhaoJ7xELYD1byNnYZb-tB-y5DPXRLIp8ikod5lUZGnayuLVPePa7uB1LlVzw-qPS-U' 
    --header 'sign: 664d528f398af20f23b6e4ec43d4e9662476ee8fc9c5cee42dd897b1af0152e7' 
    --header 'timestamp: 1636341480000' 
    --header 'trace-id: 123123' 
    --header 'Content-Type: application/json' 
    --data-raw '{
        "oaid": "f29eb7d12222fb6b",
        "widgetId": "666",
        "widgetProviderName": "com.miui.test",
        "extra": "{"test":123}"
    }'

小米MIUI小部件技术规范与系统能力说明

MIUI Widget 数据恢复适配

5.1.描述

Android 系统提供了 onRestored 方法用于数据从云端备份恢复时的 Widget 配置迁移。在此基础之上,MIUI Widget 新增了一个 Widget 配置迁移的时机。开发者无需关心何时回调,只需将新的WidgetId与数据绑定并更新UI。

注意:当 MIUI Widget 卡片有配置信息且存在多张卡片的配置信息不一致时需要进行相应适配(例如股票卡片,用户可以添加多张卡片,且每张卡片展示不同的股票),其他情况无需适配。

5.2.示例

public class ExampleWidgetProvider extends AppWidgetProvider {
  @Override
  public void onRestored(Context context, int[] oldWidgetIds, int[] 
    newWidgetIds) {
       super.onRestored(context, oldWidgetIds, newWidgetIds);
       onIdRemap(oldWidgetIds, newWidgetIds, null);
    }

   @Override
   public void onAppWidgetOptionsChanged(Context context, AppWidgetManager
     appWidgetManager, int appWidgetId, Bundle newOptions) {
         super.onAppWidgetOptionsChanged(context, appWidgetManager, 
         appWidgetId, newOptions);
         // MIUI Widget 新增配置迁移时机
         if (newOptions.getBoolean("miuiIdChanged") &&  
                !newOptions.getBoolean("miuiIdChangedComplete")) {
             onIdRemap(newOptions.getIntArray("miuiOldIds"),
             newOptions.getIntArray("miuiNewIds"), newOptions);              
         }
    }

    private void onIdRemap(int[] oldWidgetIds, int[] newWidgetIds, Bundle options) {
        int length = oldWidgetIds.length;
        for (int i = 0; i < length; i++) {   
            int newWidgetId= newWidgetIds[i];
            RemoteViews views = new RemoteViews(context.getPackageName(),    
            R.layout.appwidget_provider_layout);
            //开发者进行数据迁移,并完成新的数据获取
            ...
            //以上操作完成后,先调用updateOptions,再调用updateAppWidget
            if(options != null) {
                //这一步必须执行
                options.putBoolean("miuiIdChangedComplete", true);                             
    AppWidgetManager.getInstance(context)
                .updateAppWidgetOptions(newWidgetId, options);
            }
            AppWidgetManager.getInstance(context)
            .updateAppWidget(newWidgetId, views);
        }
    }
}

6.页面跳转规范

6.1.描述

点击MIUI Widget 跳转应用页面时,推荐使用 PendingIntent 设置相应的 Activity 进行跳转。若业务有分发逻辑可以使用Activity进行中转。

不建议使用PendingIntent 启动BroadcastReceiver/Service,然后在BroadcastReceiver/Service里面启动 Activity。

6.2.示例

// step1: 构建跳转页面的 PendingIntent
Intent intent = new Intent(context, ExampleActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
// step2: 生成 RemoteViews 关联 PendingIntent
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget_provider_layout);
views.setOnClickPendingIntent(R.id.button, pendingIntent);
// step3: 关联 widget 和 RemoteViews
appWidgetManager.updateAppWidget(appWidgetId, views);

7.MIUI Widget 布局规范

7.1.描述

系统通过固定的ID找到相应控件并添加圆角,保证所有MIUI Widget圆角统一。因此开发者需要在小部件的根布局上声明ID为”@android:id/background”。由于MIUI Widget 切换页面的动画使用到了背景色,因此根布局

必须有背景色且不能为全透明

7.2.示例

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    ... 
    // 颜色值为示例
    android:background="#fff"
    android:id="@android:id/background">
    ...
</LinearLayout>

8.MIUI Widget 布局兼容适配

8.1.描述

为了让用户有更好的体验,MIUI Widget 需要保证以下场景正常显示:

  • 负一屏、默认布局下的桌面正常显示
  • 桌面图标行列数变化后正常显示 需保证桌面4×6、5×6网格体系都能正常显示(设置方式:设置—桌面—桌面布局)
  • 桌面搜索框变化时正常显示 需保证有搜索框、无搜索框都能正常显示(设置方式:设置—桌面—桌面搜索框)
  • 桌面虚拟键变化时正常显示 需保证有虚拟键、无虚拟键都能正常显示(设置方式:设置—桌面—系统导航方式)
  • 1k、2k屏幕手机都能正常显示

为了使卡片在各种场景下都有较好的展现,需要做以下适配:

  • MIUI Widget 根布局宽高必须使用match_parent, 内容区需要在根布局里居中
  • 内容区控件尽量不要使用绝对尺寸和绝对位置,可以使用RelativeLayout以及LinearLayout的layout_weight等控制控件的位置和尺寸

8.2.示例

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@android:id/background"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    <!--widget_root_padding 与设计图保持一致,如示例图为40px-->
    android:padding="@dimen/widget_root_padding"
    android:gravity="center">

     <!--内容区-->
    <LinearLayout
        android:id="@+id/widget_content"
        android:layout_width="match_parent"
        android:orientation="vertical"
        android:layout_height="match_parent">
    </LinearLayout>   
</FrameLayout>

9.应用清除数据适配

9.1.描述

用户在使用过程中可能存在清除应用数据的行为,用户清除数据时MIUI系统会给 MIUI Widget 发送刷新广播,此时应用处在无数据或未授权状态,开发者需要在该时机将小部件恢复成默认视图或授权视图。

9.2.示例

<receiver android:name="com.miui.ExampleWidgetProvider" >
    ...
    // MIUI Widget 标识
    <meta-data
    android:name="miuiWidget"
    android:value="true" />
    <intent-filter>   
        //MIUI展现刷新
        <action android:name="miui.appwidget.action.APPWIDGET_UPDATE" />    
    </intent-filter>
</receiver>

public class ExampleWidgetProvider extends AppWidgetProvider {
    ...
    @Override
    public void onReceive(Context context, Intent intent) {
        if ("miui.appwidget.action.APPWIDGET_UPDATE".equals(intent.getAction())) {
            int[] appWidgets = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS);
            // 根据应用自身逻辑更新视图 
            ...
        } else {
            super.onReceive(context, intent);
        }
    }
}

10.小部件版本号

10.1.描述

MIUI小部件增加版本号,方便小部件版本迭代管理。小部件版本号针对应用内所有小部件,而非单个。当应用内任一小部件发生功能变动或新增小部件时需升级小部件版本号。若小部件较前一应用版本未发生变动,则无需更改小部件版本号。

注意:

  • 名称为”miuiWidgetVersion” 的meta-data标签,应放置在application下而不是某个小部件provider下。
  • 小部件发生变动或新增时一定要修改小部件版本,否则存在应用所有小部件被下线的风险。
  • 小部件版本号为整数,从1开始,同应用版本号的升级一致,都只能升高。

10.2.示例

AndroidManifest.xml 文件参考如下配置:

<application
...
<meta-data
    android:name="miuiWidgetVersion"
    android:value="1" />
...
</application>

11.MIUI Widget 大屏适配

11.1.描述

为了使MIUI Widget可以在大屏设备(折叠屏,平板)上提供更好的用户体验,需要对MIUI Widget在大屏幕设备上进行适配。

11.2.编辑页适配

若开发者在MIUI Widget中设置了编辑页,大屏设备上适配后的展示效果如下图所示:

适配方法:

  • 对于小部件视图树上,所有可以通过点击调起编辑页面的视图控件,需要在代码中进行以下调用:
public class ExampleWidgetProvider extends AppWidgetProvider {
    ...
    @Override
    public void onUpdate(Context context, AppWidgetManager 
        appWidgetManager, int[] appWidgetIds) {
        for (int appWidgetId : appWidgetIds) {          
            RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_ui);
            // 编辑页适配
            val widgetOptions = AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
            //由host提供,业务直接读取,以判断当前设备是否支持大屏预览模式
            if (widgetOptions != null && widgetOptions.getBoolean("miuiLargeScreenDevice", false)) {
                //如果当前设备支持大屏预览模式,则先生成一个Bundle实例,再分别执行步骤1,2
                val largeScreenOptions = Bundle().apply {
                    //1. 传入widgetId
                    putInt("miuiWidgetId", widgetId)                                      
                }
                //2. 通过setBundle方法,调用控件的supportLargeScreenEditPreviewMode方法,并传入以上生成的Bundle
                //R.id.item_container_1 为一个示例,所有点击事件为调起编辑页的控件,都必须进行类似的调用
                remoteViews.setBundle(R.id.item_container_1, "supportLargeScreenEditPreviewMode", largeScreenOptions)
            }    
            appWidgetManager.updateAppWidget(widgetId, views);
        }
        ....
    }
}
  • 如果业务小部件有长按编辑入口,则在生成编辑页面链接时,需要新增miuiWidgetId参数:
//示例:
public class ExampleWidgetProvider extends AppWidgetProvider {
    @Override
    public void onUpdate(Context context, AppWidgetManager 
        appWidgetManager, int[] appWidgetIds) {
        for (int appWidgetId : appWidgetIds) {          
            Bundle options =   
            appWidgetManager.getAppWidgetOptions(appWidgetId);
            String path = 
            "widgetdemo://com.miui.widgetdemo/widget/WidgetEditActivity";
            Uri.Build uriPath = Uri.parse(path).buildUpon();
            //本次新增参数,host依据此参数来判断,业务编辑页是否支持大屏预览模式
            uriPath.appendQueryParameter("miuiWidgetId", widgetId.toString());
            options.putString("miuiEditUri", uriPath);
            appWidgetManager.updateAppWidgetOptions(appWidgetId, options);
            RemoteViews views =   
            new RemoteViews(context.getPackageName(), 
            R.layout.widget_ui);
            appWidgetManager.updateAppWidget(widgetId, views);
        }
        ....
    }
}
  • 业务侧在编辑页面Activity的onCreate方法中,需要从Intent中解析对应参数,判断是否以大屏预览模式展示,并对大屏预览模式下的编辑页面进行改造:
public class WidgetEditActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        val intent = getIntent()
        //是否以大屏预览模式展示
        if (intent.getBooleanExtra("isLargeScreenMode", false)) {
            //该字段返回一个矩形区域,代表小部件在屏幕坐标系上的具体位置
            val rect = intent.getParcelableExtra("miuiWidgetScreenBound")
            if (rect != null) {
                //业务需要根据rect提供的小部件位置信息,来计算业务内容视图的具体展示位置:
                //1、业务内容视图应当始终展示在小部件的左侧,或者右侧,距离小部件固定间距
                //2、业务内容视图应当在小部件的左侧和右侧中,选择空间较大的一侧展示
            }
        }
    }
}

需要注意的事项:

  • 不要在生成PendingIntent时,put自定义的Parcelable对象或者Serializable对象,这样会导致Host处理intent时因找不到类而抛异常。请转换成基础数据类型进行传递。
  • 请不要在生成PendingIntent时,添加FLAG_IMMUTABLE,否则业务将无法解析到Host添加的参数,android s以上可以换成FLAG_MUTABLE。
  • 编辑页面打开时,不允许横竖屏旋转(需要支持横竖屏,只是不允许动态旋转)。
  • 业务只需要负责编辑页面内容区域(如上图示中的白色背景覆盖区域)的展示,背景高斯以及小部件的预览图部分,均由Host实现。

11.3.布局适配

在大屏设备上,负一屏和桌面对业务小部件布局进行了全局缩放,因此一般情况下,不需要业务再对小部件进行额外适配。

如果业务希望自行对小部件进行更完美,精细的适配,可以选择将全局缩放能力关闭,只需要在小部件对应的provider里,加上如下所示的配置即可

<receiver
    android:name=".service.normal.widget.NormalExampleWidgetProvider">
    <meta-data
        android:name="miuiAutoScale"
        android:value="false"/>
</receiver>

提示: 对于使用了GridView或者ListView的小部件,该方法不能使用,如遇到问题,请联系相关技术支持。

12.注意事项

12.1.圆角

MIUI Widget 虽然会在MIUI13上统一裁切圆角,但为了能够兼容旧版系统和非MIUI 手机,开发者仍需为MIUI Widget添加圆角 ,圆角大小与设计规范保持一致。

12.2.刷新兼容

曝光刷新仅存在支持MIUI Widget的系统中(可通过“MIUI 小部件系统能力说明–是否支持MIUI Widget”判断),在不支持MIUI Widget 的系统以及非MIUI手机上,MIUI Widget刷新机制与原生Widget保持一致,各项目需要根据原生刷新机制以及业务需求进行适配。

12.3.Activity 进程

系统会对 Widget 进程进行优化,系统资源紧张时,Widget 进程容易被回收。为了保证 Activity 的正常显示,Activity所在进程不能是Widget 进程

12.4.小部件名称

MIUI Widget必须配置小部件名称。开发者可以根据每个小部件的功能,为小部件撰写一个简洁的名字(2~8个汉字),帮助用户理解小部件的用途。在小部件中心里,小部件名称会展示为“应用名称·小部件名称”,为了展示体验更佳,小部件名称与应用名称不得相同。小部件名称对应代码AndroidManifest.xml中receiver的label。设置方法如下:

<receiver
    android:name="com.miui.widgetdemo.provider.ExampleWidgetProvider"
    android:label="@string/app_widget_example"
    android:process=":widgetProvider">
    ...
</receiver>    

12.5.深色模式

MIUI Widget 只能在xml中静态适配深色模式(通过配置drawable-night、values-night等资源文件适配),不支持在RemoteViews通过代码动态设置深色模式。

12.6.禁止随意修改 MIUI Widget的receiver类名

Android系统会根据receiver类名标识 MIUI Widget。一旦改名,用户在旧版添加的小部件升级后就会消失。因此 MIUI Widget 一旦审核通过,禁止随意修改receiver类名。

12.7.小部件通过审核后,禁止随意移除 MIUI Widget 标识

通过审核的小部件移除MIUI Widget 标识,会导致系统无法识别,造成一些严重的错误,因此小部件一旦审核通过,禁止随意移除MIUI Widget 标识。