1. 从记账小组件说起:AppWidgetProvider 与 RemoteViews 到底怎么配合
桌面小组件这个东西,很多人第一次做都会卡在同一个地方:代码写完了,onUpdate也跑了,但桌面上那个方块要么不刷新,要么点了没反应。我这次拿一个记账类小组件当例子,把从AppWidgetProvider声明到RemoteViews刷新的整条链路走一遍,顺带把组件内网络请求的鉴权配置用 TaoToken 统一管起来,避免 Key 散落在BuildConfig、SharedPreferences、local.properties里到处复制。
先说清楚这套东西是什么、能做什么、适合谁。AppWidgetProvider是 Android 提供给桌面小组件的广播接收器基类,它继承自BroadcastReceiver,系统在组件被添加、更新、删除、启用、禁用时会回调对应方法。RemoteViews则是一个跨进程的 View 描述结构,因为小组件真正渲染在 Launcher 进程里,你的 App 进程不能直接持有那些 View 对象,只能把「把 id 为 X 的 TextView 设置成 Y 文本」这类动作打包,通过 Binder 传给 Launcher 执行。PendingIntent负责点击事件,它把一个 Intent 的触发权交给 Launcher,用户点一下,Launcher 用你的身份把 Intent 发出去。
适合谁看:已经会写 Activity、对广播和 Intent 有基本概念,但没做过桌面小组件,或者做过但刷新链路总是断的 Android 开发者。整篇的目标很明确——在真机上完成一次可复现的组件数据刷新与点击响应验证,并且组件里如果要去请求后端拿数据,鉴权配置走 TaoToken 统一通道。
我先把整条链路拆成几个可验证的节点,后面每一节对应一个节点:
- 布局:
RemoteViews只支持有限布局,写错了不会崩,但会静默显示不出来。 - 声明:
res/xml/xxx_widget_info.xml描述最小宽高、更新周期、初始布局。 - 逻辑:
AppWidgetProvider.onUpdate里构造RemoteViews,填数据,挂PendingIntent,调updateAppWidget。 - 注册:
AndroidManifest.xml里静态注册 receiver,带APPWIDGET_UPDATEaction 和 meta-data。 - 刷新:主动发广播触发
onReceive,或由系统按updatePeriodMillis触发。 - 鉴权:组件内网络请求的 Base URL、Key、Model ID 统一从 TaoToken 配置读取。
这里有个容易忽略的点:updatePeriodMillis最小生效间隔是 30 分钟,你写60000(1 分钟)系统也会按 30 分钟来。所以真正需要秒级刷新的场景,得靠AlarmManager、WorkManager或者用户点击后主动发广播,不能指望这个字段。我这次记账组件写的是86400000,一天一次,够用,用户点「添加记录」回来后再手动触发一次刷新。
再补一个概念上的坑:RemoteViews的更新不是「你改了 View 它就变」,而是「你往一个动作列表里塞了一条反射调用记录」。setTextViewText(viewId, text)内部会生成一个ReflectionAction,记录viewId、方法名setText、参数text。Launcher 收到后遍历动作列表,用反射调用对应 View 的方法。所以任何RemoteViews不支持的方法,你调了也不会生效,甚至可能抛异常。这也是为什么布局里只能用LinearLayout、RelativeLayout、FrameLayout、GridLayout这几种,ConstraintLayout在小组件里是不支持的。
理解了「跨进程 + 反射动作列表」这两点,后面所有报错基本都能自己推出来。比如「组件显示空白」多半是布局用了不支持的控件;「点击没反应」多半是PendingIntent的 flag 或 requestCode 冲突;「数据不刷新」多半是没发广播或updateAppWidget没调到。
2. TaoToken 前置:把组件内网络请求的 Key 统一管起来
小组件如果只是读本地数据库,其实用不上网络鉴权。但记账类组件经常要同步云端账单、拉汇率、或者调用模型做智能分类,这时候组件进程里就得发 HTTP 请求。问题来了:AppWidgetProvider是广播接收器,生命周期极短,onUpdate执行完进程可能就被回收,你没法在里面弹登录框让用户输 Key。所以 Key 必须提前配置好,组件只负责读。
我试过把 Key 硬编码在BuildConfig里,结果每次换 Key 都要重新打包;也试过塞进SharedPreferences,但多台设备、多个环境同步起来很乱。后来改成用 TaoToken 统一管理 API 通道,组件里只存一个读取逻辑,Base URL、Key、Model ID 三件套从一处来。
TaoToken 在这里扮演的角色是统一的 API 接入通道。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api (这个不加 UTM)。你需要先去控制台创建 Key,控制台地址 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
为什么组件场景特别需要统一 Key?因为组件更新是系统触发的,你没法保证用户在哪个时间点、哪个网络环境下被唤醒。如果 Key 过期或写错,组件只会显示旧数据或空白,用户根本不知道发生了什么。统一通道的好处是:换 Key 只改一处,组件、App 主进程、后台任务读的是同一份配置。
具体到 Android 侧,我建议把配置放在local.properties(不进版本库)或者一个独立的secrets.gradle,构建时注入BuildConfig,运行时组件从BuildConfig读。这样组件进程启动时不需要任何用户交互就能拿到鉴权信息。
三件套的取值约定如下,后面配置片段会用到:
| 配置项 | 取值来源 | 示例 |
|---|---|---|
| Base URL | TaoToken API 地址 | https://taotoken.net/api |
| API Key | 控制台创建的 Key | sk-xxxxxxxx |
| Model ID | 文档里的模型标识 | 按文档填写 |
注意,Base URL 用https://taotoken.net/api,不要自己拼/v1之类的路径,具体路径以文档为准。Key 不要提交到 Git,local.properties默认在.gitignore里,正好合适。
如果你还想在开发阶段用对话方式验证 Key 是否可用,可以打开模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 先发一条测试消息,确认通道通了再写进 App。长期做编码和 Agent 类任务的话,Coding Plan 页 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 有对应的套餐说明。
这里要强调一点:TaoToken 是 API 接入通道,不是让你拿它替代编辑器或 IDE。组件代码还是在你自己的 Android Studio 里写,TaoToken 只负责网络请求那一层的鉴权和路由。
3. 可复制配置:从 widget_info.xml 到 settings 片段
这一节给可直接复制的配置。先声明组件信息,res/xml/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>minWidth和minHeight决定组件在桌面上占几格,updatePeriodMillis是系统级刷新周期,initialLayout指向RemoteViews布局,resizeMode允许用户拉伸,widgetCategory声明放桌面还是锁屏。
布局文件res/layout/account_widget.xml只能用受支持的控件,下面是一个精简版,保留了标题、金额、分类和按钮:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 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"> <TextView android:id="@+id/tv_date_expense_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="18sp" android:textStyle="bold" /> <TextView android:id="@+id/tv_total_expense" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="24sp" android:textStyle="bold" /> <TextView android:id="@+id/tv_food_expense_widget" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="14sp" /> <ImageButton android:id="@+id/fab_add_record" android:layout_width="match_parent" android:layout_height="36dp" android:background="@drawable/rounded_button_background" android:src="@drawable/ic_add_white_24dp" android:contentDescription="@string/add_record" /> </LinearLayout>然后是AndroidManifest.xml里的 receiver 声明,注意exported="true"和 meta-data 的 name 必须完全一致:
<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>接下来是鉴权配置。在local.properties里加三行(这个文件不进 Git):
taotoken.baseUrl=https://taotoken.net/api taotoken.apiKey=sk-你的Key taotoken.modelId=你的ModelID在app/build.gradle里读取并注入BuildConfig:
def localProps = new Properties() def localFile = rootProject.file("local.properties") if (localFile.exists()) { localProps.load(new FileInputStream(localFile)) } android { defaultConfig { buildConfigField "String", "TAOTOKEN_BASE_URL", "\"${localProps['taotoken.baseUrl'] ?: ''}\"" buildConfigField "String", "TAOTOKEN_API_KEY", "\"${localProps['taotoken.apiKey'] ?: ''}\"" buildConfigField "String", "TAOTOKEN_MODEL_ID", "\"${localProps['taotoken.modelId'] ?: ''}\"" } buildFeatures { buildConfig true } }如果你用的是 Kotlin DSL,等价写法是buildConfigField("String", "TAOTOKEN_BASE_URL", "\"...\"")。这样组件进程里直接读BuildConfig.TAOTOKEN_BASE_URL、BuildConfig.TAOTOKEN_API_KEY、BuildConfig.TAOTOKEN_MODEL_ID就行,三件套齐全。
注意:
BuildConfig里的 Key 会被打进 APK,能被反编译看到。生产环境更稳妥的做法是组件只拿一个短期 token,由 App 主进程或后端换取。这里为了演示链路完整性,先用BuildConfig直读。
配置片段给完了,下一节看怎么在onUpdate里把这些串起来并验证。
4. 验证请求与成功结果:onUpdate 刷新 + adb 触发
AccountWidget继承AppWidgetProvider,核心是onUpdate。下面这段可以直接放进项目,重点是RemoteViews构造、数据填充、PendingIntent挂载、updateAppWidget四步:
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.Locale; public class AccountWidget extends AppWidgetProvider { static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) { RemoteViews views = new RemoteViews( context.getPackageName(), R.layout.account_widget); 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)); float totalExpense = AccountDataUtil.getTotalExpenseForDate(context, currentDate); float monthlyBudget = SettingsActivity.getMonthlyBudget(context); int daysInMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH); float dailyPlan = daysInMonth > 0 ? monthlyBudget / daysInMonth : 0; float balance = dailyPlan - totalExpense; 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.setTextColor(R.id.tv_total_expense, balance >= 0 ? greenColor : redColor); 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); Intent launchEditIntent = new Intent(context, AccountEditActivity.class); PendingIntent launchEditPendingIntent = PendingIntent.getActivity( context, 1, launchEditIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); views.setOnClickPendingIntent(R.id.ll_widget_container, launchEditPendingIntent); appWidgetManager.updateAppWidget(appWidgetId, views); } @Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { for (int appWidgetId : appWidgetIds) { updateAppWidget(context, appWidgetManager, appWidgetId); } } @Override public void onReceive(Context context, Intent intent) { super.onReceive(context, intent); if (intent == null || intent.getAction() == null) { return; } AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); ComponentName thisWidget = new ComponentName(context, AccountWidget.class); int[] appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget); onUpdate(context, appWidgetManager, appWidgetIds); } }注意两个PendingIntent的 requestCode 我用了 0 和 1,如果都用 0,FLAG_UPDATE_CURRENT会让后一个覆盖前一个,导致点击整个组件和点击按钮触发同一个 Intent。这是很常见的坑。
主动刷新用广播,代码片段如下:
Intent intent = new Intent(context, AccountWidget.class); intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE); int[] ids = AppWidgetManager.getInstance(context.getApplicationContext()) .getAppWidgetIds(new ComponentName(context.getApplicationContext(), AccountWidget.class)); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids); context.sendBroadcast(intent);真机验证分三步。第一步,安装后长按桌面添加组件,确认组件出现且显示初始布局。第二步,用 adb 触发更新:
adb shell am broadcast \ -a android.appwidget.action.APPWIDGET_UPDATE \ -n com.zjw.weight/.AccountWidget执行后看 logcat:
adb logcat -s AccountWidget:V如果onUpdate被调到,说明广播链路通了。第三步,点击组件上的按钮,确认跳转到AccountEditActivity。如果没跳,检查PendingIntent的 flag 和 requestCode。
组件内如果要发网络请求,用BuildConfig三件套构造请求头:
String baseUrl = BuildConfig.TAOTOKEN_BASE_URL; String apiKey = BuildConfig.TAOTOKEN_API_KEY; String modelId = BuildConfig.TAOTOKEN_MODEL_ID; // 请求头示例 // Authorization: Bearer <apiKey> // Content-Type: application/json成功结果应该是:组件显示当天日期和金额,点击按钮跳转编辑页,adb 广播后组件数据刷新。如果这三条都满足,链路就通了。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节对照真实报错来排。小组件本身不涉及 OAuth 登录流程,但如果你在组件里调模型接口,鉴权错误会以各种形式冒出来。
401 Unauthorized。最常见的原因是 Key 没注入成功。先确认local.properties里的taotoken.apiKey有值,再确认build.gradle里buildConfigField拼对了。可以在onUpdate里打一行日志:
android.util.Log.d("AccountWidget", "key len=" + BuildConfig.TAOTOKEN_API_KEY.length());如果长度是 0,说明注入失败。另外检查请求头是不是Authorization: Bearer <key>,少了Bearer前缀也会 401。
local proxy failed。这个报错通常出现在网络层,意思是本地代理连接失败。先检查设备网络是否正常,再检查 Base URL 是否写成了https://taotoken.net/api而不是别的路径。如果用了 OkHttp,确认没有配置错误的Proxy。组件进程和 App 主进程的网络配置要一致,别一个走直连一个走代理。
reading choices 相关报错。这类报错一般出现在解析响应体时,比如JsonSyntaxException或reading choices字段失败。原因是接口返回结构和你的解析模型不匹配。先打印原始响应:
android.util.Log.d("AccountWidget", "resp=" + responseBody);确认返回的是 JSON 对象还是数组,字段名是否和你的data class一致。模型接口的响应结构以文档为准,别凭记忆写。
OAuth 相关报错。如果你在组件里走了 OAuth 流程,会卡在「组件无法弹登录页」上。正确做法是 OAuth 在 App 主进程完成,把 token 存到SharedPreferences或DataStore,组件只读 token。组件里不要发起需要用户交互的鉴权流程。
组件显示空白。检查布局是否用了ConstraintLayout或自定义 View,RemoteViews不支持。换成LinearLayout或RelativeLayout。
点击没反应。检查PendingIntent的 requestCode 是否重复,flag 是否包含FLAG_IMMUTABLE(Android 12+ 必须)。再检查setOnClickPendingIntent的 viewId 是否和布局里一致。
数据不刷新。检查是否调了appWidgetManager.updateAppWidget(appWidgetId, views),以及广播是否带上了EXTRA_APPWIDGET_IDS。
如果你在配置里用到了 CC Switch、Cline MCP 或 Codex 的auth.json,三件套必须写全:Base URL、Key、Model ID。缺一个都会导致鉴权失败。比如auth.json里只写了 Key 没写 Base URL,请求就会打到默认地址上,报 401 或 404。
排障时优先看 logcat,组件进程的日志 tag 用AccountWidget过滤。adb 命令:
adb logcat -s AccountWidget:V AndroidRuntime:EAndroidRuntime:E能抓到崩溃堆栈,组件里任何未捕获异常都会让onUpdate中断,表现为组件不刷新。
6. 继续往下走:把 Key 和刷新链路固定成模板
到这里,AppWidgetProvider声明、RemoteViews更新、PendingIntent点击、adb 验证、鉴权配置这几块都串起来了。最后说几个实用技巧,都是踩过的坑换来的。
第一,updatePeriodMillis别写太小,系统最小按 30 分钟算,写小了也没用,还费电。需要高频刷新就用WorkManager或用户点击后主动发广播。
第二,RemoteViews的每个set方法都是一次跨进程调用,别在onUpdate里循环几百次setTextViewText,能合并就合并,能一次填完就别拆。
第三,PendingIntent的 requestCode 用常量管理,别用魔法数字。两个不同的点击目标用不同 requestCode,避免覆盖。
第四,Key 统一从 TaoToken 读,组件、主进程、后台任务共用一份配置。换 Key 只改local.properties,重新构建即可。API 地址固定用 https://taotoken.net/api ,Key 在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 管理,接入细节看 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
第五,真机验证时先用 adb 广播手动触发,确认onUpdate逻辑没问题,再依赖系统周期刷新。这样能把「系统没触发」和「代码有 bug」两类问题分开。
如果你后面要把这套链路用到更复杂的 Agent 场景,比如组件里跑模型分类,Coding Plan 页 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 有对应的通道说明。开发阶段想快速验证模型返回,模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 可以直接发消息测试。
最后一步,把AccountWidget的onUpdate里那段updateAppWidget调用保留,把 adb 广播命令存成一个 shell 脚本,每次改完代码跑一遍,组件刷新和点击响应都能复现。链路固定下来之后,再往组件里加网络请求、加数据同步,就只是往updateAppWidget里填内容的事了。