心血来潮,由于小米记账组件都需要收费,因此使用google的gemini-cli开发了一个记账app,在此记录下桌面小组件开发流程。
一,创建组件布局
注意,RemoteView目前只支持如下布局

java
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/ll_widget_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp"
android:background="@drawable/rounded_corner_background">
<!-- Top Section: Date and Total Expense -->
<RelativeLayout
android:paddingStart="16dp"
android:paddingTop="16dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_date_expense_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/daily_expense_format"
android:textColor="@color/dark_on_surface"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_total_expense"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/tv_date_expense_title"
android:layout_marginTop="4dp"
android:text="@string/total_expense_format"
android:textColor="#FF5722"
android:textSize="24sp"
android:textStyle="bold" />
<!-- Income and Balance -->
<TextView
android:id="@+id/tv_income_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/tv_total_expense"
android:layout_marginTop="18dp"
android:text="@string/income_label"
android:textColor="@color/dark_on_surface"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_income_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/tv_income_label"
android:text="@string/total_expense_format"
android:textColor="@color/dark_on_surface"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_balance_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@id/tv_income_label"
android:layout_marginStart="26dp"
android:layout_toEndOf="@id/tv_income_label"
android:text="@string/balance_label"
android:textColor="@color/dark_on_surface"
android:textSize="12sp" />
<TextView
android:layout_alignStart="@id/tv_balance_label"
android:id="@+id/tv_balance_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@id/tv_income_value"
android:layout_toEndOf="@id/tv_income_value"
android:text="@string/total_expense_format"
android:textColor="@color/dark_on_surface"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_monthly_balance_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/tv_income_label"
android:layout_marginTop="16dp"
android:text="@string/monthly_balance_label"
android:textColor="@color/dark_on_surface"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_monthly_balance_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/tv_monthly_balance_label"
android:text="@string/monthly_balance_format"
android:textColor="@color/dark_on_surface"
android:textSize="14sp" />
<!-- Balance Status Icon -->
<ImageView
android:id="@+id/iv_balance_status"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_balance_positive"
android:contentDescription="@string/balance_label" />
<!-- Legend Items -->
<LinearLayout
android:id="@+id/ll_legend_placeholder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toStartOf="@id/iv_balance_status"
android:layout_marginEnd="8dp"
android:orientation="vertical"
android:layout_centerVertical="true">
<!-- Food Expense -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@drawable/circle_food_color"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/tv_food_expense_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/expense_category_food"
android:textColor="@color/dark_on_surface"
android:textSize="14sp" />
</LinearLayout>
<!-- Transport Expense -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@drawable/circle_transport_color"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/tv_transport_expense_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/expense_category_transport"
android:textColor="@color/dark_on_surface"
android:textSize="14sp" />
</LinearLayout>
<!-- Shopping Expense -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@drawable/circle_shopping_color"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/tv_shopping_expense_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/expense_category_shopping"
android:textColor="@color/dark_on_surface"
android:textSize="14sp" />
</LinearLayout>
<!-- Other Expense -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@drawable/circle_other_color"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/tv_other_expense_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/expense_category_other"
android:textColor="@color/dark_on_surface"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</RelativeLayout>
<!-- Bottom Navigation/Action Bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="20dp"
android:layout_marginTop="8dp">
<ImageButton
android:id="@+id/fab_add_record"
android:layout_width="0dp"
android:layout_height="36dp"
android:layout_weight="2"
android:background="@drawable/rounded_button_background"
android:src="@drawable/ic_add_white_24dp"
android:contentDescription="@string/add_record"
android:tint="@color/dark_on_primary" />
</LinearLayout>
</LinearLayout>
二,声明组件xml
在res目录下新建一个xml文件夹,AI自动创建了account_widget_info.xml文件,内容如下
这里面声明了组件的最小宽高、更新频率、初始化布局、组件分类等信息
XML
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:updatePeriodMillis="86400000"
android:initialLayout="@layout/account_widget"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:label="@string/account_widget_name">
</appwidget-provider>
三,实现AccountWidget类
此类中,在onUpdate方法中,可以通过appWidgetId对指定的组件进行更新
java
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
// There may be multiple widgets active, so update all of them
for (int appWidgetId : appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId);
}
}
java
package com.zjw.weight;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.widget.RemoteViews;
import java.util.Calendar;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Implementation of App Widget functionality.
*/
public class AccountWidget extends AppWidgetProvider {
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {
// Construct the RemoteViews object
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.account_widget);
// Get current date
Calendar calendar = Calendar.getInstance();
String currentDate = String.format(Locale.US, "%04d-%02d-%02d",
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH) + 1,
calendar.get(Calendar.DAY_OF_MONTH));
// Load data for the current date
Map<String, List<ExpenseItem>> dailyExpenses = AccountDataUtil.loadDailyExpenses(context, currentDate);
// Calculate totals
float totalFood = AccountDataUtil.getCategoryTotal(context, currentDate, AccountDataUtil.getFoodKey());
float totalTransport = AccountDataUtil.getCategoryTotal(context, currentDate, AccountDataUtil.getTransportKey());
float totalShopping = AccountDataUtil.getCategoryTotal(context, currentDate, AccountDataUtil.getShoppingKey());
float totalOther = AccountDataUtil.getCategoryTotal(context, currentDate, AccountDataUtil.getOtherKey());
float totalExpense = totalFood + totalTransport + totalShopping + totalOther;
// 获取每月预算并计算每日计划金额
float monthlyBudget = SettingsActivity.getMonthlyBudget(context);
// 获取当月天数
Calendar cal = Calendar.getInstance();
int daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
// 计算每日计划金额
float dailyPlan = daysInMonth > 0 ? monthlyBudget / daysInMonth : 0;
// 计算结余 = 每日计划 - 当日总支出
float balance = dailyPlan - totalExpense;
// 计算当月结余
// 获取当月已过天数
int dayOfMonth = cal.get(Calendar.DAY_OF_MONTH);
// 计算当月总预算
float monthlyTotalBudget = dailyPlan * dayOfMonth;
// 计算当月总支出
float monthlyTotalExpense = 0;
for (int i = 1; i <= dayOfMonth; i++) {
String dateStr = String.format(Locale.US, "%04d-%02d-%02d",
cal.get(Calendar.YEAR),
cal.get(Calendar.MONTH) + 1,
i);
monthlyTotalExpense += AccountDataUtil.getTotalExpenseForDate(context, dateStr);
}
// 计算当月结余 = 当月总预算 - 当月总支出
float monthlyBalance = monthlyTotalBudget - monthlyTotalExpense;
// Update text fields with real data
int greenColor = Color.parseColor("#4CAF50");
int redColor = Color.parseColor("#F44336");
views.setTextViewText(R.id.tv_date_expense_title, String.format(context.getString(R.string.daily_expense_format), String.valueOf(calendar.get(Calendar.DAY_OF_MONTH))));
views.setTextViewText(R.id.tv_total_expense, String.format(context.getString(R.string.total_expense_format), totalExpense));
views.setTextViewText(R.id.tv_income_value, String.format(context.getString(R.string.daily_plan_format), dailyPlan));
views.setTextColor(R.id.tv_income_value,greenColor);
views.setTextViewText(R.id.tv_balance_value, String.format(context.getString(R.string.total_expense_format), balance));
views.setTextViewText(R.id.tv_monthly_balance_value, String.format(context.getString(R.string.monthly_balance_format), monthlyBalance));
if (monthlyBalance >= 0) {
views.setTextColor(R.id.tv_monthly_balance_value,greenColor);
} else {
views.setTextColor(R.id.tv_monthly_balance_value, redColor);
}
views.setTextViewText(R.id.tv_income_label, context.getString(R.string.daily_plan_label));
views.setTextViewText(R.id.tv_balance_label, context.getString(R.string.balance_label));
views.setTextViewText(R.id.tv_monthly_balance_label, context.getString(R.string.monthly_balance_label));
// 根据结余状态设置图标
if (balance >= 0) {
views.setImageViewResource(R.id.iv_balance_status, R.drawable.ic_balance_positive);
views.setTextColor(R.id.tv_balance_value, greenColor);
} else {
views.setImageViewResource(R.id.iv_balance_status, R.drawable.ic_balance_negative);
views.setTextColor(R.id.tv_balance_value, redColor);
}
views.setContentDescription(R.id.fab_add_record, context.getString(R.string.add_record));
// Update individual expense category totals
views.setTextViewText(R.id.tv_food_expense_widget,
String.format(context.getString(R.string.expense_category_food) + " %.1f", totalFood));
views.setTextViewText(R.id.tv_transport_expense_widget,
String.format(context.getString(R.string.expense_category_transport) + " %.1f", totalTransport));
views.setTextViewText(R.id.tv_shopping_expense_widget,
String.format(context.getString(R.string.expense_category_shopping) + " %.1f", totalShopping));
views.setTextViewText(R.id.tv_other_expense_widget,
String.format(context.getString(R.string.expense_category_other) + " %.1f", totalOther));
// Set up click listeners for buttons
Intent addRecordIntent = new Intent(context, AccountEditActivity.class);
PendingIntent addRecordPendingIntent = PendingIntent.getActivity(context, 0, addRecordIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
views.setOnClickPendingIntent(R.id.fab_add_record, addRecordPendingIntent);
// Set up click listener for the entire widget to launch AccountEditActivity
Intent launchEditIntent = new Intent(context, AccountEditActivity.class);
PendingIntent launchEditPendingIntent = PendingIntent.getActivity(context, 0, launchEditIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
views.setOnClickPendingIntent(R.id.ll_widget_container, launchEditPendingIntent);
// 只保留上一天、下一天和添加记录按钮的点击事件
Intent prevDayIntent = new Intent(context, AccountWidget.class);
prevDayIntent.setAction("ACTION_PREV_DAY_CLICK");
PendingIntent prevDayPendingIntent = PendingIntent.getBroadcast(context, 4, prevDayIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Intent nextDayIntent = new Intent(context, AccountWidget.class);
nextDayIntent.setAction("ACTION_NEXT_DAY_CLICK");
PendingIntent nextDayPendingIntent = PendingIntent.getBroadcast(context, 5, nextDayIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views);
}
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
// There may be multiple widgets active, so update all of them
for (int appWidgetId : appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId);
}
}
@Override
public void onReceive(Context context, Intent intent) {
super.onReceive(context, intent);
if (intent != null) {
String action = intent.getAction();
if (action != null) {
// Handle button clicks (for now, just update the widget)
AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
ComponentName thisWidget = new ComponentName(context, AccountWidget.class);
int[] appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget);
onUpdate(context, appWidgetManager, appWidgetIds);
// 只处理上一天和下一天按钮的点击事件
switch (action) {
case "ACTION_PREV_DAY_CLICK":
// Handle previous day
break;
case "ACTION_NEXT_DAY_CLICK":
// Handle next day
break;
}
}
}
}
@Override
public void onEnabled(Context context) {
// Enter relevant functionality for when the first widget is created
}
@Override
public void onDisabled(Context context) {
// Enter relevant functionality for when the last widget is disabled
}
}
总结下,AppWidgetProvider主要逻辑如下
1,使用RemoteViews传入布局
2,根据数据对view进行自定义更新
3,调用appWidgetManager.updateAppWidget方法传入id和remoteView,即可刷新组件
4,设计点击事件,这通过PendingIntent触发
四,Androidmanifest中声明组件
AI自动在Androidmanifest中创建了一个静态receiver,其AccountWidget继承AppWidgetProvider,
可以选择性实现如下模版方法

XML
<receiver
android:name=".AccountWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/account_widget_info" />
</receiver>
以上,即创建完毕了一个桌面组件。
五,原理
1,launcher通过PKMS查询声明了action是"android.appwidget.action.APPWIDGET_UPDATE"的receiver,
2,解析Metadata,这样launcher就可以解析到目标应用的xml声明
XML
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/account_widget_info" />
3,解析appwidget xml,保存此组件的基本信息
XML
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:updatePeriodMillis="86400000"
android:initialLayout="@layout/account_widget"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:label="@string/account_widget_name">
</appwidget-provider>
4,通过RemoteView保存一个update的全部action行为,action保存了view id和行为name,主要用于反射
以setTextColor为例,


这样就将viewId,methodName通过binder传递给了launcher,launcher再通过遍历action列表调用ReflectionAction#apply方法,即可实现行为传递,本质是反射调用。

5,组件应用可通过发送广播,强行更新指定id的组件,id可通过AppWidgetManager获取,如下
java
// Trigger widget update
Intent intent = new Intent(this, AccountWidget.class);
intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE);
int[] ids = AppWidgetManager.getInstance(getApplication())
.getAppWidgetIds(new ComponentName(getApplication(), AccountWidget.class));
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids);
sendBroadcast(intent);
随后,发送一个AppWidgetManager.ACTION_APPWIDGET_UPDATE广播,即可触发AppWidgetProvider#onReceiver,如下,进而调用到AccountWidget,

6,组件应用更新组件参数后,通过appWidgetManager.updateAppWidget(appWidgetId, views);即触发组件的实际更新
内部通过一个Service,将packageName,view和appWeightId传递给组件Service,即实现更新