【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,即实现更新

相关推荐
liosen2 小时前
【安卓笔记】OOM与内存优化
android·oom·内存优化·内存分析命令·内存分析工具
猿小蔡-Cool7 小时前
Android ADB命令之内存统计与分析
android·adb
Monkey-旭7 小时前
Android Handler 完全指南
android·java·handler
從南走到北8 小时前
JAVA东郊到家按摩服务同款同城家政服务按摩私教茶艺师服务系统小程序+公众号+APP+H5
android·java·开发语言·微信小程序·小程序
alexhilton9 小时前
学会用最优雅的姿式在Compose中显示富文本
android·kotlin·android jetpack
阿华的代码王国11 小时前
【Android】卡片式布局 && 滚动容器ScrollView
android·xml·java·前端·后端·卡片布局·滚动容器
双鱼大猫14 小时前
从传统播放器到AI智能体:Xplayer 2.0的技术革新之路
android
CYRUS_STUDIO14 小时前
动态篡改 so 函数返回值:一篇带你玩转 Android Hook 技术!
android·c++·逆向
xzkyd outpaper14 小时前
Android中主线程、ActivityThread、ApplicationThread的区别
android·面试