当前位置: 首页 > java >正文

【Android】桌面小组件开发

心血来潮,由于小米记账组件都需要收费,因此使用google的gemini-cli开发了一个记账app,在此记录下桌面小组件开发流程。

一,创建组件布局

注意,RemoteView目前只支持如下布局

<?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 --><RelativeLayoutandroid:paddingStart="16dp"android:paddingTop="16dp"android:layout_width="match_parent"android:layout_height="wrap_content"><TextViewandroid: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" /><TextViewandroid: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 --><TextViewandroid: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" /><TextViewandroid: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" /><TextViewandroid: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" /><TextViewandroid: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" /><TextViewandroid: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" /><TextViewandroid: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 --><ImageViewandroid: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 --><LinearLayoutandroid: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 --><LinearLayoutandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:orientation="horizontal"android:gravity="center_vertical"><ImageViewandroid:layout_width="14dp"android:layout_height="14dp"android:src="@drawable/circle_food_color"android:layout_marginEnd="4dp" /><TextViewandroid: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 --><LinearLayoutandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:orientation="horizontal"android:gravity="center_vertical"><ImageViewandroid:layout_width="14dp"android:layout_height="14dp"android:src="@drawable/circle_transport_color"android:layout_marginEnd="4dp" /><TextViewandroid: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 --><LinearLayoutandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:orientation="horizontal"android:gravity="center_vertical"><ImageViewandroid:layout_width="14dp"android:layout_height="14dp"android:src="@drawable/circle_shopping_color"android:layout_marginEnd="4dp" /><TextViewandroid: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 --><LinearLayoutandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:orientation="horizontal"android:gravity="center_vertical"><ImageViewandroid:layout_width="14dp"android:layout_height="14dp"android:src="@drawable/circle_other_color"android:layout_marginEnd="4dp" /><TextViewandroid: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 --><LinearLayoutandroid:layout_width="match_parent"android:layout_height="wrap_content"android:orientation="horizontal"android:gravity="center_vertical"android:layout_marginBottom="20dp"android:layout_marginTop="8dp"><ImageButtonandroid: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 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对指定的组件进行更新

 @Overridepublic void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {// There may be multiple widgets active, so update all of themfor (int appWidgetId : appWidgetIds) {updateAppWidget(context, appWidgetManager, appWidgetId);}}
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 objectRemoteViews views = new RemoteViews(context.getPackageName(), R.layout.account_widget);// Get current dateCalendar 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 dateMap<String, List<ExpenseItem>> dailyExpenses = AccountDataUtil.loadDailyExpenses(context, currentDate);// Calculate totalsfloat 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 dataint 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 totalsviews.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 buttonsIntent 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 AccountEditActivityIntent 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 widgetappWidgetManager.updateAppWidget(appWidgetId, views);}@Overridepublic void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {// There may be multiple widgets active, so update all of themfor (int appWidgetId : appWidgetIds) {updateAppWidget(context, appWidgetManager, appWidgetId);}}@Overridepublic 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 daybreak;case "ACTION_NEXT_DAY_CLICK":// Handle next daybreak;}}}}@Overridepublic void onEnabled(Context context) {// Enter relevant functionality for when the first widget is created}@Overridepublic 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,

可以选择性实现如下模版方法

<receiverandroid:name=".AccountWidget"android:exported="true"><intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter><meta-dataandroid: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声明

<meta-dataandroid:name="android.appwidget.provider"android:resource="@xml/account_widget_info" />

3,解析appwidget 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获取,如下

// Trigger widget updateIntent 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,即实现更新

http://www.xdnf.cn/news/16255.html

相关文章:

  • RAG面试内容整理-3. 向量检索原理与常用库(ANN、FAISS、Milvus 等)
  • 三坐标和激光跟踪仪的区别
  • 【源力觉醒 创作者计划】ERNIE-4.5-VL-28B-A3B 模型详解:部署、测试与 Qwen3 深度对比测评
  • OmoFun网页版官网入口,动漫共和国最新地址|官方下载地|打不开
  • Cacti命令执行漏洞分析(CVE-2022-46169)
  • qt5静态版本对应的pcre编译
  • 数据标注与AI赋能出版行业数智化转型|光环云张鹏出席“智启未来”沙龙并作主题分享
  • 输电线路观冰精灵在线监测装置:科技赋能电网安全的新利器
  • 「源力觉醒 创作者计划」文心一言VSDeepSeek
  • 从数据脱敏到SHAP解释:用Streamlit+XGBoost构建可复现的川崎病诊断系统
  • 用SSD实现AI RAG性能升级,铠侠发布AiSAQ新版本
  • Ubuntu22 上,用C++ gSoap 创建一个简单的webservice
  • 【硬件-笔试面试题】硬件/电子工程师,笔试面试题-24,(知识点:二极管基础知识,单向导电性)
  • 【09】C#入门到精通——C# 结构体对齐 与 常用数据 对应关系
  • CF每日5题(1500-1600)
  • node.js中的path模块
  • 技嘉z370主板开启vtx
  • windows11通过wsl安装Ubuntu到D盘,安装docker及宝塔面板
  • 【STM32】FreeRTOS 任务的删除(三)
  • 《 java 随想录》| 数组
  • Java学习日记_廖万忠
  • 支持OCR和AI解释的Web PDF阅读器:解决大文档阅读难题
  • uni-appDay02
  • #来昇腾学AI 【十天成长计划】大模型LLM Prompt初级班
  • Java学习----工厂方法模式
  • 深入理解 eMMC RPMB 与 OP-TEE 在 Linux 系统中的应用开发
  • day62-可观测性建设-全链路监控zabbix+grafana
  • 爬虫算法原理解析
  • Windows环境下 Go项目迁移至Ubuntu(WSL) 以部署filebeat为例
  • MinIO 版本管理实践指南(附完整 Go 示例)