ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android Loader 技术简单实用:从 LoaderManager 到 CursorLoader 的配置与验证

Android Loader 技术简单实用:从 LoaderManager 到 CursorLoader 的配置与验证

1. 为什么页面一切换,数据就没了:Loader 要解决的真实问题

Android 里做异步数据加载,最容易踩的坑不是「查不出来」,而是「查出来了但界面已经没了」。比如你在 Activity 里开一个线程查数据库,用户手快按了返回键,Activity 走 onDestroy,线程还在跑,跑完回调里拿着一个已经失效的 Context 去更新 UI,轻则空指针,重则窗口泄漏。再比如横竖屏切换,Activity 重建,你之前查了一半的数据全丢,用户看到列表闪一下变空再重新加载。

Loader 就是 Android 官方给这套场景准备的答案。它是什么:一套带生命周期感知的异步数据加载框架,从 Android 3.0(API 11)开始进入 SDK。它能做什么:把「后台查数据」和「界面消费数据」解耦,让数据加载跟着 Activity/Fragment 的生命周期走,配置变更时自动把结果重新投递,不用你手动缓存。适合谁:还在维护传统 View 体系、用 ListView/RecyclerView 展示数据库或 ContentProvider 数据的开发者,尤其是做联系人、短信、媒体库这类系统数据读取的场景。

核心就四个东西。LoaderManager 是管理者,一个 Activity 或 Fragment 只有一个,通过 getLoaderManager() 或 getSupportLoaderManager() 拿到。LoaderManager.LoaderCallbacks 是交互接口,你在里面创建 Loader、接收结果、处理重置。AsyncTaskLoader 是异步加载的抽象基类,内部用线程池干活,你继承它写自己的加载逻辑。CursorLoader 是 AsyncTaskLoader 的子类,专门对接 ContentProvider 查询,返回 Cursor。

我试过在没引入 Loader 之前用「线程 + Handler + 弱引用」硬扛生命周期,代码量翻倍还容易漏。换成 Loader 之后,onCreateLoader 里只管发查询,onLoadFinished 里只管刷 Adapter,中间的生命周期判断全交给框架。下面从环境准备开始,一步步把配置、回调、验证和排错讲清楚。

2. 接入前的准备:依赖、权限与 TaoToken 配置

Loader 本身是 framework 层的东西,不需要额外引依赖,但如果你要用 support 包版本(低版本兼容),得在 build.gradle 里加 appcompat 或 loader 相关库。现在主流项目 minSdk 基本都在 21 以上,直接用 android.app.LoaderManager 或 androidx.loader.app.LoaderManager 都行。我建议统一用 androidx 版本,避免和 support 包混用导致类冲突。

// app/build.gradle dependencies { implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.loader:loader:1.1.0" implementation "androidx.recyclerview:recyclerview:1.3.2" }

读联系人需要权限,Android 6.0 以后是运行时权限,别只在 Manifest 里声明就完事。

<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.READ_CONTACTS" />

运行时申请:

if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CONTACTS}, 1001); }

如果你在开发过程中需要调用大模型接口来辅助生成 Loader 模板代码、排查报错,或者做代码补全,可以先把 API Key 配好。TaoToken 的接入地址是 https://taotoken.net/api,控制台里创建 Key 的入口在 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 。拿到 Key 之后,在项目里配置 Base URL 和 Model ID,比如用 Claude Code 或 Cline 这类工具时,settings 里填的格式大致是这样:

{ "baseUrl": "https://taotoken.net/api", "apiKey": "sk-你的Key", "model": "claude-sonnet-4-5" }

注意 Base URL 后面不要多加 /v1,具体以接入文档为准,文档地址 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 。这一步和 Loader 本身没直接关系,但你在写 Loader 回调、查 Cursor 字段名的时候,有个能对话的模型帮你确认 API 会省不少时间,模型对话入口在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 。

环境准备好之后,重点来了:Loader 的初始化配置和回调实现。很多人卡在「initLoader 调了但 onCreateLoader 不执行」,其实多半是 LoaderManager 拿错了对象,或者回调没实现全。

3. 可复制的 Loader 初始化配置与回调实现

先给结论:一个 Loader 的完整生命周期由三件事驱动——initLoader 触发创建、onCreateLoader 返回 Loader 实例、onLoadFinished 接收数据。restartLoader 用于数据源变化时刷新。下面这段是可直接抄的 Activity 骨架,用 CursorLoader 查联系人,带 SearchView 过滤。

public class ContactActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<Cursor> { private static final int LOADER_ID_CONTACTS = 0; private SimpleCursorAdapter adapter; private String filterName = null; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_contact); adapter = new SimpleCursorAdapter( this, android.R.layout.simple_list_item_2, null, new String[]{ContactsContract.Contacts.DISPLAY_NAME}, new int[]{android.R.id.text1}, 0); ListView listView = findViewById(R.id.list_view); listView.setAdapter(adapter); // 关键:用 getSupportLoaderManager 兼容低版本 getSupportLoaderManager().initLoader(LOADER_ID_CONTACTS, null, this); } @NonNull @Override public Loader<Cursor> onCreateLoader(int id, @Nullable Bundle args) { Uri uri; String[] projection = new String[]{ ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME }; if (TextUtils.isEmpty(filterName)) { uri = ContactsContract.Contacts.CONTENT_URI; } else { uri = Uri.withAppendedPath( ContactsContract.Contacts.CONTENT_FILTER_URI, Uri.encode(filterName)); } // 返回 CursorLoader,框架自动在后台线程执行查询 return new CursorLoader(this, uri, projection, null, null, null); } @Override public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) { // 用 swapCursor 而不是 changeCursor,避免框架关闭旧 Cursor adapter.swapCursor(data); } @Override public void onLoaderReset(@NonNull Loader<Cursor> loader) { // Loader 被销毁或重置,必须断开对旧 Cursor 的引用 adapter.swapCursor(null); } }

几个容易写错的点。第一,onCreateLoader 里不要做耗时操作,它是在主线程调用的,真正的查询由 CursorLoader 内部搬到后台。第二,onLoadFinished 里用 swapCursor,changeCursor 会关掉旧 Cursor,而框架可能还在用它。第三,onLoaderReset 必须把 Adapter 的 Cursor 置空,否则会持有已释放的 Cursor 导致崩溃。

如果你要自定义加载逻辑,比如从网络拉 JSON 再解析,就继承 AsyncTaskLoader:

public class UserLoader extends AsyncTaskLoader<List<User>> { private List<User> cached; public UserLoader(@NonNull Context context) { super(context); } @Override protected void onStartLoading() { if (cached != null) { deliverResult(cached); // 有缓存直接投递,避免重复加载 } if (takeContentChanged() || cached == null) { forceLoad(); } } @Override public List<User> loadInBackground() { // 这里在后台线程执行,可以放心做网络/数据库操作 return ApiClient.fetchUsers(); } @Override public void deliverResult(@Nullable List<User> data) { if (isReset()) { return; } cached = data; if (isStarted()) { super.deliverResult(data); } } @Override protected void onReset() { super.onReset(); cached = null; } }

AsyncTaskLoader 的坑在于 onStartLoading 和 deliverResult 的配合。如果你不重写 onStartLoading,默认行为是每次都 forceLoad,缓存就白做了。deliverResult 里要先判断 isReset,否则 Loader 已经重置了你还投递数据,会触发 IllegalStateException。

数据更新用 restartLoader。比如搜索框文字变化:

searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String query) { return false; } @Override public boolean onQueryTextChange(String newText) { filterName = newText; // 复用同 ID 的 Loader,清空旧数据重新查询 getSupportLoaderManager().restartLoader(LOADER_ID_CONTACTS, null, ContactActivity.this); return false; } });

restartLoader 的行为要记牢:如果同 ID 的 Loader 已存在,它会先销毁旧的(触发 onLoaderReset),再走一遍 onCreateLoader。所以你的 onLoaderReset 一定要能安全处理「Cursor 变 null」的情况。

4. 验证请求是否成功:日志、断点与结果确认

配置写完,怎么确认 Loader 真的在按预期工作?别只看界面有没有数据,要分三层验证。

第一层,确认 Loader 被创建。在 onCreateLoader 里打日志:

@Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { Log.d("LoaderCheck", "onCreateLoader id=" + id + ", filter=" + filterName); // ... }

启动 Activity,Logcat 过滤 LoaderCheck,应该看到一次 onCreateLoader。如果没看到,说明 initLoader 没执行到,或者 LoaderManager 拿的是 null。

第二层,确认数据回调。在 onLoadFinished 里打印 Cursor 行数:

@Override public void onLoadFinished(Loader<Cursor> loader, Cursor data) { int count = (data == null) ? -1 : data.getCount(); Log.d("LoaderCheck", "onLoadFinished count=" + count); adapter.swapCursor(data); }

正常情况你会看到 count 等于联系人数量。如果 count 一直是 0,先检查权限有没有真的授予,再检查 ContentProvider 的 URI 拼得对不对。

第三层,验证生命周期行为。这是 Loader 的核心价值,必须测。操作步骤:打开列表页,等数据加载完,旋转屏幕。观察 Logcat,你应该看到 Activity 重建、onCreateLoader 可能再次被调用,但 onLoadFinished 会立刻带着已有数据回来,界面不会长时间空白。再测一个:加载过程中按返回键退出,Logcat 里不应该出现崩溃或「Cursor already closed」的异常。

用 adb 也能辅助验证:

adb logcat -s LoaderCheck:D

如果你在验证接口调用(比如 Loader 里顺带请求了后端),可以用 curl 先确认服务端通不通:

curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"ping"}]}'

返回 200 且 body 里有 choices 字段,说明 Key 和 Base URL 没问题。如果这里就报 401,那 Loader 里的网络请求也一定失败,先解决鉴权再回头看 Loader。

验证通过的标准很简单:旋转屏幕数据不丢、退出页面不崩、搜索时列表能刷新。三条都满足,Loader 就算接对了。

5. 常见报错排查:从 401 到 Cursor already closed

这一节按真实报错来对。你在接 Loader 和调接口的过程中,大概率会遇到下面几个。

报错一:401 Unauthorized / invalid api key。这个通常出现在 Loader 里做网络请求、或者你用外部工具调模型接口时。原因就三类:Key 没填、Key 填错、Base URL 写错。检查顺序:先确认请求头是Authorization: Bearer sk-xxx,注意 Bearer 后面有空格;再确认 Base URL 是 https://taotoken.net/api 而不是别的路径;最后去控制台重新生成一个 Key 对比。如果工具里报的是 OAuth 相关错误,说明你用的是需要 OAuth 的客户端但填了 API Key 模式,换成 API Key 鉴权即可。

报错二:local proxy failed / connection refused。这类多半是本地代理配置问题。如果你在 IDE 或命令行工具里设了 http_proxy 环境变量,但代理服务没起来,请求就会失败。排查:echo $http_proxy看有没有值,有的话临时unset http_proxy https_proxy再试。注意不要用任何非正规的网络中转手段,直连官方 API 地址即可。

报错三:reading choices 时 null pointer / 解析失败。说明请求发出去了,但返回体结构和你解析的字段对不上。常见于模型名写错,服务端返回了 error 对象而不是 choices 数组。先打印原始响应体:

Log.d("ApiCheck", "raw response=" + responseBody);

确认里面是{"choices":[...]}还是{"error":{...}}。如果是 error,看 message 字段,一般是 model not found,把 Model ID 换成控制台里列出的可用模型。

报错四:Cursor already closed / StaleDataException。这是 Loader 专属坑。原因是你手动调用了 cursor.close(),或者用了 changeCursor 而不是 swapCursor。修复:onLoadFinished 里统一用 swapCursor,onLoaderReset 里 swapCursor(null),永远不要自己 close 框架给你的 Cursor。

报错五:onCreateLoader 不执行。检查三件事:initLoader 的 ID 是否和 onCreateLoader 里判断的一致;回调对象是不是传的 this 且 this 实现了 LoaderCallbacks;Fragment 里是不是用了 getLoaderManager 而不是 getActivity().getLoaderManager()。Fragment 有自己的 LoaderManager,用错了就不会回调。

报错六:Codex auth.json 配置后仍提示未登录。如果你用 Codex 类工具,auth.json 里需要同时有 Base URL、Key、Model ID 三件套,缺一个都会失败。格式参考:

{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model": "claude-sonnet-4-5" }

改完记得重启工具进程,很多工具是启动时读一次配置。

排错的核心思路是分层:先确认网络和鉴权通不通,再确认 Loader 生命周期回调有没有触发,最后确认数据消费端(Adapter)有没有正确接收。哪一层断了,日志都会告诉你。

6. 把 Loader 用顺手的几个实战建议

Loader 这套 API 现在看确实有点老,Google 后来推了 ViewModel + LiveData + Room 的组合,新项目我更建议用后者。但如果你在维护存量代码,或者就是要读系统 ContentProvider 的数据,Loader 依然是最省事的选择,尤其是 CursorLoader 对 ContentProvider 的变更监听是内建的,数据一变自动重新查询,这点自己写很难做得这么干净。

几个实战经验。第一,Loader ID 用常量管理,别到处写魔法数字,一个页面多个 Loader 时特别容易串。第二,AsyncTaskLoader 的缓存逻辑一定要写,否则每次 onStartLoading 都 forceLoad,和不用 Loader 没区别。第三,onLoaderReset 里除了 swapCursor(null),如果你还有别的引用(比如成员变量存了 Cursor),也要一起置空。第四,测试一定要覆盖配置变更和快速退出两个场景,这两个是内存泄漏的高发区。

如果你在写 Loader 回调时需要快速确认某个 API 的签名,或者想让人帮你 review 一段 Cursor 查询逻辑,可以用模型对话快速过一遍,入口 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 。长期做 Android 开发、需要频繁生成模板代码和排查报错的,可以看下 Coding Plan,地址 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite ,配合 Claude Code 这类工具用起来比较顺。Key 的管理还是回到控制台 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite ,接入细节以文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 为准。

最后留一个我踩过的坑:在 Fragment 的 onCreateView 里调 initLoader,如果 Fragment 被 detach 再 attach,LoaderManager 的行为和 Activity 不完全一样,建议放在 onActivityCreated 或 onViewCreated 之后,确保宿主 Activity 已经就绪。这个坑当时查了半天日志才定位到,你遇到「回调时有时无」可以先往这个方向想。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进