【Android】桌面小组件开发

心血来潮,由于小米记账组件都需要收费,因此使用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,即实现更新

相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
7177771 天前
研发平台选哪家比较好:2026年主流平台对比与Gitee选型指南
人工智能·gitee
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas