简介:本资源是一份面向Android系统开发工程师与Launcher定制爱好者的完整负一屏(Google Feed屏)集成方案,基于Android 11.0平台深度适配Launcher3,解决原生Launcher缺乏可扩展负一屏模块的痛点,适用于定制ROM开发、厂商UI二次开发及系统级功能增强场景。压缩包共99个文件,涵盖51个XML布局与配置文件(定义界面结构与行为逻辑)、10个PNG资源图(含图标与状态指示)、8个二进制及构建相关文件(如outputFiles.bin、fileHashes等),以及Gradle构建脚本、AIDL接口定义、Java核心逻辑类等关键组件,整体仅387KB,轻量且聚焦。已有2394人学习下载,说明其在实战中经受了广泛验证。读者可直接复用客户端Launcher3修改逻辑与服务端OverlayScreen模块(支持替换为自定义View),获得开箱即用的Feed屏能力,并通过内置GIF动效演示直观理解交互效果,同时借助清晰的Gradle依赖管理与模块化目录结构快速定位定制入口。
1. 负一屏不是“多加一页”,而是 Launcher3 在 Android 11 上的系统级行为重构
很多人以为给 Launcher3 加个负一屏,就是往workspace里塞个新Page——结果滑到最左卡死、手势冲突、桌面图标错位、甚至重启后负一屏消失。根本原因在于:Android 11(API 30)起,系统对 Launcher 的启动器协议、Activity 生命周期、窗口类型和输入事件分发做了结构性收紧,负一屏不再是 UI 层的视觉扩展,而是必须通过Intent协议与LauncherActivity深度协同的系统服务入口。谷歌 Feed 屏(即 Google Discover)在 Android 11 上已完全剥离为独立com.google.android.googlequicksearchbox进程,Launcher3 无法直接渲染其内容,只能通过Intent.ACTION_VIEW+Intent.EXTRA_REFERRER触发并接管返回路径。本方案不依赖任何预装 APK 或云端服务,仅修改 Launcher3 源码中Workspace、Launcher和AllAppsController三处核心逻辑,适配 AOSP 11.0_r49 及后续稳定分支,实测在 Pixel 4a(AOSP)、小米 11(LineageOS 18.1)等设备上可稳定唤起、手势滑动、状态栏联动与后台保活。
2. 从 Intent 协议切入:为什么 Android 11 必须用ACTION_VIEW而非startActivity()直接跳转
2.1 Android 11 对隐式 Intent 的强制限制与packageManager.resolveActivity()的失效场景
Android 11 引入了Package Visibility机制,默认禁止应用通过PackageManager.queryIntentActivities()查询未在AndroidManifest.xml中显式声明的其他应用 Activity。若仍沿用旧版 Launcher3 中常见的startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://www.google.com/")));方式,将直接抛出ActivityNotFoundException,且 Logcat 中仅显示No activity found to handle Intent,无更详细堆栈。关键修复点在于:必须提前声明<queries>元素,并指定目标包名与 intent-filter 匹配规则。
2.1.1 在AndroidManifest.xml中声明 Google Quick Search Box 的可见性
<!-- frameworks/base/packages/Launcher3/AndroidManifest.xml --> <queries> <package android:name="com.google.android.googlequicksearchbox" /> <intent> <action android:name="android.intent.action.VIEW" /> <data android:scheme="https" android:host="www.google.com" /> </intent> </queries>提示:
<queries>必须位于<application>外层,且不能嵌套在<application>内;android:name值必须与目标 APK 的package完全一致,大小写敏感;<intent>声明用于支持resolveActivity()动态匹配,而非仅静态包名白名单。
2.1.2 验证 Intent 是否可解析的健壮写法
// packages/Launcher3/src/com/android/launcher3/Launcher.java private boolean canStartDiscover() { Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://www.google.com/")); intent.setPackage("com.google.android.googlequicksearchbox"); // 注意:Android 11+ 必须使用 resolveActivity() 而非 queryIntentActivities() ResolveInfo resolveInfo = getPackageManager().resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); return resolveInfo != null && resolveInfo.activityInfo != null; }参数说明:
MATCH_DEFAULT_ONLY表示只匹配android.intent.category.DEFAULT的 Activity,避免匹配到 Service 或 Broadcast Receiver;resolveActivity()返回ResolveInfo对象,比queryIntentActivities()更轻量且符合 Android 11 权限模型;若返回null,说明该包未安装或未声明对应 intent-filter。
2.2 负一屏的生命周期绑定:如何让 Launcher3 在 Discover 返回时恢复焦点与状态
单纯startActivity()启动 Discover 会导致 Launcher3 进入onPause()状态,但 Discover 退出后不会自动触发onResume(),造成桌面冻结、图标点击无响应。正确做法是使用startActivityForResult()(兼容 API 29+)或registerActivityForResult()(AndroidX Activity 1.2.0+),并在onActivityResult()中主动恢复 Workspace 状态。
2.2.1 使用 Activity Result API 替代已废弃的startActivityForResult
// packages/Launcher3/src/com/android/launcher3/Launcher.java private final ActivityResultLauncher<Intent> mDiscoverLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() == RESULT_OK || result.getResultCode() == RESULT_CANCELED) { // Discover 退出后,强制刷新 workspace 并重置当前页 mWorkspace.resetCurrentPage(); mWorkspace.scrollToPage(0); // 回到第 0 页(即负一屏位置) mWorkspace.requestLayout(); } }); // 触发 Discover 的方法 public void startDiscover() { Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://www.google.com/")); intent.setPackage("com.google.android.googlequicksearchbox"); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); mDiscoverLauncher.launch(intent); }逻辑说明:
registerForActivityResult()是 AndroidX 推荐方式,避免onActivityResult()被 Fragment 或其他组件拦截;FLAG_ACTIVITY_NEW_TASK确保 Discover 在新任务栈运行,不干扰 Launcher 栈;FLAG_ACTIVITY_CLEAR_TOP防止多次启动累积栈帧;回调中resetCurrentPage()重置滑动状态,scrollToPage(0)强制回到负一屏位置,解决手势滑动后页面错位问题。
3. Workspace 扩展:实现可滑动的负一屏容器与手势边界控制
3.1 修改Workspace类以支持 0 号页作为负一屏占位符
Launcher3 的Workspace默认页索引从 0 开始,代表主屏。要实现“滑到最左为负一屏”,需将mCurrentPage初始值设为 1,并预留 page 0 为负一屏。但直接修改索引会破坏所有getPageAt()逻辑,正确做法是重载getChildCount()、getChildAt()和scrollToPage(),使外部逻辑仍认为 page 0 是主屏,而内部将 page 0 映射为负一屏 View。
3.1.1 在Workspace.java中注入负一屏 View 并重写关键方法
// packages/Launcher3/src/com/android/launcher3/workspace/Workspace.java private View mNegativeScreenView; public void setNegativeScreenView(View view) { mNegativeScreenView = view; // 添加到 ViewGroup 最前端,确保滑动时最先出现 addView(view, 0); } @Override public int getChildCount() { // 实际子 View 数 = 负一屏 + 原有页数 return super.getChildCount() + (mNegativeScreenView != null ? 1 : 0); } @Override public View getChildAt(int index) { if (index == 0 && mNegativeScreenView != null) { return mNegativeScreenView; } // 其他页索引整体后移一位 return super.getChildAt(index - (mNegativeScreenView != null ? 1 : 0)); } @Override public void scrollToPage(int page) { if (page == 0 && mNegativeScreenView != null) { // 滚动到负一屏:实际滚动到第一个子 View(即 mNegativeScreenView) super.scrollTo(0, 0); setCurrentPage(0); return; } // 其他页按原逻辑处理 super.scrollToPage(page - (mNegativeScreenView != null ? 1 : 0)); }参数说明:
setNegativeScreenView()由Launcher初始化时调用,传入一个空FrameLayout作为容器;getChildAt()重写确保index=0时返回负一屏 View,而非原有第一个 workspace page;scrollToPage(0)被重定向为滚动到mNegativeScreenView,避免super.scrollToPage(0)错误滚动到主屏。
3.2 手势滑动边界控制:禁用向右滑出负一屏、启用向左滑入主屏
默认Workspace支持无限循环滑动,但负一屏必须是“死端”——用户无法从负一屏再向左滑,且从主屏向左滑应平滑进入负一屏。关键在于重写canScrollHorizontally()和onInterceptTouchEvent()中的滑动方向判断逻辑。
3.2.1 限制水平滑动边界
// packages/Launcher3/src/com/android/launcher3/workspace/Workspace.java @Override public boolean canScrollHorizontally(int direction) { if (direction < 0 && mCurrentPage == 0 && mNegativeScreenView != null) { // 向左滑:当前在负一屏,禁止继续向左 return false; } if (direction > 0 && mCurrentPage == 0) { // 向右滑:从负一屏向右,允许进入主屏(即 page 1) return true; } return super.canScrollHorizontally(direction); } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { // 记录初始触摸位置,用于后续方向判断 mLastMotionX = ev.getX(); } return super.onInterceptTouchEvent(ev); } @Override public boolean onTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_MOVE) { float deltaX = ev.getX() - mLastMotionX; if (mCurrentPage == 0 && deltaX > 0) { // 在负一屏向右滑动:允许,但需确保滑动距离不过大导致卡顿 mLastMotionX = ev.getX(); return super.onTouchEvent(ev); } else if (mCurrentPage == 0 && deltaX < 0) { // 在负一屏向左滑动:直接消费事件,不传递给父类 return true; } } return super.onTouchEvent(ev); }逻辑说明:
canScrollHorizontally(-1)返回false表示禁止向左滑,这是系统级手势拦截;onTouchEvent()中对ACTION_MOVE的deltaX判断,是 UI 层兜底防护,防止某些 ROM 修改了canScrollHorizontally()行为;mLastMotionX需在Workspace类中声明为private float mLastMotionX;。
4. 负一屏入口集成:在 AllApps 与搜索框中统一触发点
4.1 在 AllApps 页面顶部添加 Discover 快捷入口卡片
AllApps列表默认无顶部区域,需扩展AllAppsContainerView并插入DiscoverCardView。该卡片不参与RecyclerView的 ViewHolder 复用,而是作为固定 header 插入。
4.1.1 创建DiscoverCardView并绑定点击事件
<!-- packages/Launcher3/res/layout/discover_card.xml --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="80dp" android:orientation="horizontal" android:padding="16dp" android:background="?android:attr/selectableItemBackground" android:clickable="true"> <ImageView android:layout_width="40dp" android:layout_height="40dp" android:src="@drawable/ic_discover" /> <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_gravity="center_vertical" android:text="@string/discover_title" android:textSize="16sp" android:textColor="?android:attr/textColorPrimary" /> <ImageView android:layout_width="24dp" android:layout_height="24dp" android:src="@drawable/ic_chevron_right" /> </LinearLayout>// packages/Launcher3/src/com/android/launcher3/allapps/AllAppsContainerView.java private void addDiscoverHeader() { LayoutInflater inflater = LayoutInflater.from(getContext()); View discoverCard = inflater.inflate(R.layout.discover_card, this, false); discoverCard.setOnClickListener(v -> ((Launcher) getContext()).startDiscover()); addView(discoverCard, 0); // 插入最顶部 }注意:
addView(..., 0)确保卡片位于RecyclerView之前;setOnClickListener()直接调用Launcher.startDiscover(),复用已注册的 Activity Result Launcher;ic_discover图标需放入res/drawable,建议使用 Material Design 的ic_round_explore_24。
4.2 将搜索框长按事件映射为 Discover 启动
Android 11 的 Launcher 搜索框(QSBSearchBar)默认长按无响应。通过重写onLongClick()并注入startDiscover()调用,实现“长按搜索框即唤起 Feed”。
4.2.1 修改QSBSearchBar.java的长按监听
// packages/Launcher3/src/com/android/launcher3/qsb/QSBSearchBar.java public QSBSearchBar(Context context, AttributeSet attrs) { super(context, attrs); setOnLongClickListener(v -> { ((Launcher) getContext()).startDiscover(); return true; // 消费事件,防止触发其他长按行为 }); }提示:
return true是关键,否则长按可能同时触发文字选择或语音搜索;此行为符合 Google Pixel 原生 Launcher 的交互习惯,用户教育成本低。
5. 编译与验证:AOSP 11.0 下的构建参数与真机调试技巧
5.1mm编译时必须启用WITH_DEXPREOPT := false避免负一屏类被提前优化剔除
Launcher3 在 AOSP 11.0 中默认开启DEXPREOPT,会对Workspace等核心类做静态分析,若mNegativeScreenView被判定为未使用,可能被整个移除。编译前必须在build/envsetup.sh后执行lunch前设置环境变量:
# 在 AOSP 根目录执行 export WITH_DEXPREOPT=false lunch aosp_arm64-userdebug m -j$(nproc) Launcher3参数说明:
WITH_DEXPREOPT=false禁用 dex 预优化,确保所有自定义字段与方法保留;aosp_arm64-userdebug是推荐 target,支持 adb root 与 logcat 过滤;-j$(nproc)启用全部 CPU 核心加速编译。
5.2 真机验证四步法:从日志过滤到手势压测
| 步骤 | 命令/操作 | 预期输出 | 故障定位点 |
|---|---|---|---|
| 1. 检查 Intent 可解析性 | adb shell am start -a android.intent.action.VIEW -d "https://www.google.com/" -n "com.google.android.googlequicksearchbox/.SearchActivity" | Starting: Intent {...}或Error: Activity not found | AndroidManifest.xml<queries>声明错误或目标 APK 未安装 |
| 2. 抓取 Launcher 生命周期日志 | `adb logcat -s Launcher:V Workspace:V | grep -E "(onResume | onPause |
| 3. 验证负一屏滑动边界 | 手动滑到最左页,连续向左滑动 5 次 | 屏幕无位移,logcat无scrollToPage日志 | canScrollHorizontally(-1)返回值未生效,检查mCurrentPage值是否为 0 |
| 4. 压测手势连贯性 | 在负一屏快速左右滑动 10 次 | 页面切换流畅,无卡顿、无黑屏、无图标错位 | Workspace的onLayout()或dispatchDraw()中存在耗时操作,需检查mNegativeScreenView是否含复杂 ViewTree |
提示:
logcat过滤-s参数可精准捕获 Launcher 自定义 tag;grep -E支持正则匹配多个关键词;压测时建议关闭开发者选项中的窗口动画缩放和过渡动画缩放,排除系统动画干扰。
5.3 关键参数速查表:Android 11 负一屏适配必须调整的 5 个值
| 参数位置 | 默认值 | 推荐值 | 作用说明 |
|---|---|---|---|
AndroidManifest.xml<queries> | 无 | 必须声明com.google.android.googlequicksearchbox包及ACTION_VIEWintent | 绕过 Android 11 Package Visibility 限制 |
Launcher.javastartDiscover()中Intent.FLAG | 无 | FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP | 确保 Discover 独立任务栈,避免栈污染 |
Workspace.javamCurrentPage初始值 | 0 | 0(但逻辑映射为负一屏) | 保持索引兼容性,避免修改getPageAt()全局调用 |
Workspace.javacanScrollHorizontally(-1) | true | false(当mCurrentPage==0) | 硬性阻止负一屏向左滑出 |
build/envsetup.shWITH_DEXPREOPT | true | false | 防止自定义 View 字段被 DEX 预优化移除 |
负一屏的滑动阻尼系数可通过Workspace中mScrollFactor字段微调,值越小滑动越“沉”,建议设为0.7f(默认0.85f),提升向左滑入负一屏的手感反馈。
本文还有配套的精品资源,点击获取