
1. 项目背景与需求拆解1.1 Hotseat 到底是什么做 Android 系统定制的人对 Launcher3 应该都不陌生。在 Launcher3 里Hotseat 指的是主屏幕底部那一排固定的应用图标区域理解为 Dock 栏就行。它跟普通工作区Workspace最大的区别在于不管用户滑到第几屏Hotseat 里的图标永远固定在当前页不会被翻页带走。在 Android 13 的源码里Hotseat 对应的类是com.android.launcher3.Hotseat它继承自一个类似 Folder 的容器类内部维护一个CellLayout用来承载图标。系统默认的 Hotseat 布局是横向一排固定在屏幕底部。默认可以放 4 到 6 个图标具体数量取决于设备配置右边或中间通常会跟着一个 All Apps 按钮也就是那个圆圈里带几个小点的抽屉入口。这次项目的核心需求就是要把这个默认的横向 Hotseat 改成竖向排列。听起来不复杂但实际上踩进去之后你会发现Launcher3 这套代码对 Hotseat 的方向、尺寸、落点区域、横竖屏切换、RTL 文本方向都有很强的既有假设随便动一处很容易牵扯出一串连锁问题。这篇文章就是我这次改动的完整记录内容包括方案选型、源码改动点、问题排查和几个很实用的避坑技巧适合正在做 Android 桌面定制、车机或行业终端设备的开发同学参考。1.2 这次要改的“布局方向”具体指什么需求最原始的描述就一句话“把 Hotseat 竖过来。”这其实包含了好几层意思不仔细拆解的话后面做出来很容易返工。第一层是视觉方向。默认是 1 行 N 列横着排在底部目标场景是 N 行 1 列竖着贴在屏幕左侧或右侧。想清楚这一点决定了你在代码里把 CellLayout 的网格怎么设置。第二层是 Hotseat 整体在屏幕中的位置。横向 Hotseat 是贴着屏幕底部的宽度充满整个屏幕竖向 Hotseat 则是一个窄条高度可以充满屏幕宽度只够放一个图标加一点内边距。这会直接影响布局文件里宽高属性写法。第三层是交互区域。Hotseat 同时也是拖拽的落点区DropTarget你把图标拖到 Hotseat 上它会吸附进去。如果只改了显示方向没改拖拽区域的计算逻辑就会出现“图标明明显示在右侧竖条里拖上去却没反应”的诡异问题。第四层是文本方向布局。这一点很多人容易忽略。Android 系统支持 RTL从右到左语言阿拉伯语、希伯来语环境下整个水平布局会被镜像Hotseat 的排布顺序、图标排列方向都会自动反过来。我这次做的竖排方向定制如果不处理 RTL实际效果就是你写死让它靠右到了阿拉伯语环境它突然跑到左边去了顺序还反着。后面我会专门说这个问题的处理方式。1.3 什么设备才会需要这种改动可能有人觉得这需求很偏实际并不是。我这次改完以后总结了一下主要有四类场景会碰到车机中控。现在不少车机的桌面是横屏的但有些竖屏车机或者特殊形态的中控面板会把 Dock 栏设计在屏幕右侧方便驾驶员右手操作。行业终端。POS 收银机、自助查询机、教育平板这类设备桌面经常需要高度定制竖排常驻栏在很多 UI 方案里都有出现。折叠屏/大屏手机。折叠屏展开后宽度很大底部横向 Hotseat 会让最右侧的图标离拇指太远有些定制 ROM 会把 Hotseat 调整成侧边竖排或者做成可切换模式。主管要求“跟某某系统长得一样”的定制需求。这个不解释做定制的都懂。总之只要 Launcher 不是纯原生形态Hotseat 的方向早晚要被折腾一遍。提前把原理弄清楚后面遇到类似需求心里就有底了。2. 方案选型与整体设计思路2.1 先看 Android 13 Launcher3 的布局树动手改代码之前我习惯先把布局树摸一遍。Launcher3 最外层的根视图是LauncherRootView里面主要包含一个DragLayer。DragLayer是一个扩展版的 FrameLayout它既负责承载各个子界面又负责处理拖拽事件和落点判断。在默认配置下DragLayer的子视图大致是这样组织起来的Workspace主工作区也就是用户放图标、放 widget 的那几屏页面。Hotseat底部常驻栏id 固定是hotseat。DropTargetBar通常在顶部包含删除、卸载等拖拽目标。WidgetsBottomSheetwidget 选择面板默认收缩在底部拖拽时才会展开。Hotseat 在布局文件res/layout/launcher.xml里默认是用android:layout_alignParentBottomtrue贴底的宽度是match_parent高度是wrap_content。也就是它占满了底部一整条横向空间高度取决于图标尺寸加上边距。理解了这棵树的组织方式就能明白为什么“直接改 XML 把 Hotseat 换个方向”这条路不好走。因为DragLayer的子视图默认是按照垂直方向堆叠排布的你要把 Hotseat 挪到右侧变成竖条本质上不是在调整一个控件的位置而是改变了整个层级结构里的空间分配关系。2.2 两条实现路线改布局文件还是走官方竖条逻辑动手之前我还真纠结过一阵。当时摆在我面前有两条路。路线一自己重新搭布局。把Workspace和Hotseat塞进一个垂直方向的LinearLayout或ConstraintLayout让 WorkSpace 占左侧区域Hotseat 占右侧窄条。这个思路很直观而且视觉效果可控性最强。但问题是DragLayer内部对子视图有大量基于坐标的计算比如拖拽落点判断、图标拖出来的起始坐标、壁纸滑动偏移等。你在 XML 层面把结构改成重排等于把所有这些坐标逻辑全部打破需要连带去改Workspace、Hotseat、DropTarget的坐标换算。我评估了一下改动面非常大而且拖拽这种高频交互出 bug 的几率极高真要把这个方案磨稳定没个两三周下不来。路线二去源码里找官方已经写好的竖排逻辑。我翻了 Android 13 的DeviceProfile.java发现在源码里其实已经有isVerticalBar()这个判断它是在小屏设备横屏或者多窗口模式下把 Hotseat 从底部横条切换成右侧竖条的官方实现。官方已经处理了布局、拖拽落点、图标排列顺序等一系列问题。唯一的问题是这个模式默认只有在特定场景才触发我们需要把它变成“无条件启用”或者“根据我们的配置启用”。我最终选了路线二。理由很直接官方代码路径是经过大量测试的坐标、落点、动画这些细节都已经处理过了我们只是在它基础上做开关控制风险要小得多。两条路线的差异我整理成了下面的表格对比项路线一重排布局结构路线二启用官方 isVerticalBar 逻辑改动范围XML、Workspace、Hotseat、拖拽坐标DeviceProfile、Hotseat 少量代码拖拽落点正确性需要自己重算风险高官方已处理风险低横竖屏切换适配需要额外适配官方原有适配可复用RTL 兼容性需要自己处理官方逻辑本身已兼容 RTL维护成本高后续升级 Launcher 又得重来一遍低改动点集中2.3 为什么要专门关注文本方向布局我前面提到过 RTL这里展开说一下。Android 系统本身是按照 LTR从左到右设计 UI 的但为了兼容阿拉伯语、希伯来语这些从右往左读的语言Android 提供了一套镜像机制当系统语言切换成 RTL 语言时所有基于start/end对齐的布局都会自动水平翻转。这本来是 Android 的贴心设计但对我们这种强制改方向的定制需求来说就是个不小的坑。比如我在布局文件里把 Hotseat 写成了android:layout_alignParentEndtrue在中文环境下它正常贴屏幕右侧但一旦切成阿拉伯语End自动变成左边缘Hotseat 就整个跑到左侧去了。不只是位置Hotseat 内部图标的排列顺序也会被镜像。默认横排时排在第一位的图标在 LTR 下是最左边在 RTL 下会变到最右边。竖排的时候影响相对小因为竖向本身不参与水平镜像但如果你在竖排 Hotseat 里还做了水平方向的层级或位移那就得留意。所以这里我给所有做类似定制的朋友一个忠告改动涉及方向的时候先把 RTL 问题想清楚是跟随系统还是强制 LTR提前决定不要等到测试反馈“阿拉伯语下面界面乱了”才去补救。3. 核心实现与实操过程3.1 打开竖条模式DeviceProfile 的改动我这次的改动入口是DeviceProfile。在 Android 13 Launcher3 的源码里DeviceProfile是一个非常重要的配置类它承载了横竖屏、图标尺寸、Hotseat 图标数量、workspace 网格大小等等一堆设备相关的参数。Hotseat 的很多布局逻辑都是通过DeviceProfile来驱动的所以在这里加开关最合适。我加的代码大致思路是这样public class DeviceProfile { // 新增一个字段控制是否强制竖排 Hotseat public final boolean isVerticalHotseat; // 在构造器里接收这个参数 public DeviceProfile(... , boolean isVerticalHotseat) { ... this.isVerticalHotseat isVerticalHotseat; } // 官方已有 isVerticalBar()我们直接把它扩展一下 public boolean isVerticalBar() { return isVerticalHotseat || super.isVerticalBar(); } }构造器里那个参数通常是在InvariantDeviceProfile或者对应的设备配置生成处传入的。如果你是直接用DeviceProfile没有继承结构那就在自己项目里仿照同样方式加成员变量然后在读配置的地方解析一个自定义属性比如从config.xml里读一个config_forceVerticalHotseat的 bool 值。这里有一个比较容易栽的坑有些定制项目会把DeviceProfile整个拷贝出去改然后 Launcher 里其它模块通过接口回调拿到的是修改版实例。如果改动只加在了拷贝版里而其它类调用的还是原始版本的isVerticalBar()就会出现“配置改了但界面没反应”的情况。所以我建议改完后全局搜一遍isVerticalBar()的调用点确认所有相关类拿到的都是同一个改造后的实例。3.2 launcher.xml 布局结构调整打开竖条模式只是第一步布局参数还得跟着调整。默认的 Hotseat 在launcher.xml里长这个样子com.android.launcher3.Hotseat android:idid/hotseat android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_alignParentBottomtrue android:paddingTop4dp android:paddingBottom4dp android:orientationhorizontal /注意这个orientationhorizontal它决定了 Hotseat 内部 View 的摆放方向。竖排模式下最直观的改法是让整个 Hotseat 变成一个窄条指定为垂直方向并让它靠屏幕右边缘com.android.launcher3.Hotseat android:idid/hotseat android:layout_widthwrap_content android:layout_heightmatch_parent android:layout_alignParentEndtrue android:layout_alignParentToptrue android:orientationvertical /不过这里我要提醒一下layout_width和layout_height改成这样之后Hotseat 的测量逻辑可能会出问题。因为官方竖条模式下具体宽度和高度在代码里会根据CellLayout的尺寸重新计算你在 XML 里写的wrap_content只是一个兜底。真正决定 Hotseat 宽高的是Hotseat.java里onMeasure相关的代码它往往会直接使用DeviceProfile提供的参数来覆盖 XML 的值。所以改完 XML 一定不要觉得完事了接下来才是重点。3.3 Hotseat.java 与 CellLayout 适配Hotseat.java是这次改动的核心。先理解它内部的结构Hotseat 里面包含一个CellLayout这个CellLayout的网格行列数定义了 Hotseat 能放多少个图标以及它们怎么排列。在官方实现里Hotseat 启动时会调用类似resetLayout的方法根据DeviceProfile去设置 CellLayout 的网格private void resetLayout() { ... int numCells mDeviceProfile.numHotseatIcons; if (mDeviceProfile.isVerticalBar()) { mCellLayout.setGridSize(1, numCells); } else { mCellLayout.setGridSize(numCells, 1); } }也就是说横向模式设置的是numCells行 1 列竖条模式设置的是 1 行numCells列。这里顺序别搞反了setGridSize(row, column)第一个参数是行数第二个是列数。竖排模式在视觉上是一列下来但网格定义里其实是 1 行、多列不对这里要注意视觉上一列下去对应的是“1 列、多行”但 CellLayout 的列数决定的是水平方向有多少个格子行数决定垂直方向有多少个格子。如果你想从上到下排 5 个图标那应该是 5 行 1 列代码写法是setGridSize(5, 1)。我特意在代码注释里把行列和视觉方向对应写清楚了这种低级错误调试起来非常烦人。除了网格定义图标的索引顺序也需要调整。在横排默认逻辑里Hotseat 中每个 app 的位置通过 rank 来索引主要跟allAppsButtonRank有关。All Apps 按钮在横排时通常排在最后一个位置但在竖排场景下你可能希望它排在最顶部或最底部这需要重新计算 rankint allAppsRank mDeviceProfile.isVerticalBar() ? 0 : numIcons - 1;这里没有统一标准完全看你的 UI 设计需求。我这次是放在了最顶部方便单手点按。3.4 图标尺寸、边距与圆角背景调整竖排模式下 Hotseat 的宽度变得很窄如果还用原本为底部横条设计的图标尺寸画面会显得特别笨重。所以在DeviceProfile里还需要调整这几个参数hotseatIconSizeHotseat 图标的边长我这次从默认的 48dp 降到了 42dp。hotseatCellPadding格子内边距横排模式用的是一组值竖排模式可以单独给一组更紧凑的值。hotseatBottomPadding原本是底部横条的参数竖排后这个 padding 其实用不上了真正起作用的是水平方向的 padding。改DeviceProfile的时候我建议不要直接改默认值而是新增一套竖排专用的 getter。原因是这个类通过onConfigurationChanged在横竖屏切换时会重新创建实例如果默认值被写死成竖排参数那么其它模式也会被波及。给个简化的例子public float getHotseatIconSize() { if (isVerticalBar()) { return resources.getDimension(R.dimen.hotseat_icon_size_vertical); } return resources.getDimension(R.dimen.hotseat_icon_size); }如果 Hotseat 有圆角背景比如很多定制方案会在底部横条上加一个椭圆形的半透明背景竖排之后这个背景的形状也要改成竖向的胶囊形或圆角矩形。对应的 shape drawable 资源记得放到res/drawable下方向相关的背景可以做成两套资源横竖分别引用避免代码里写死。3.5 All Apps 按钮与预测图标的处理还有一个我差点忽略的地方预测图标。如果你开启了 Android 13 Launcher 中基于系统预测的“推荐应用”Hotseat 会在用户图标后面动态追加一两个预测图标。竖排模式下这些预测图标会继续往下排如果你的 gridSize 没有预留足够的行数布局会被顶出屏幕。做法是在外部计算总行数时把预测图标的配额也加进去int iconCount mPredictedApps.size() mUserFixedIcons.size(); mCellLayout.setGridSize(Math.max(iconCount, 1), 1);All Apps 按钮如果用的是自定义 View竖排时最好也重新检查它的旋转角度。有些 Launcher 定制方案会把 All Apps 按钮做成一个旋转了 90 度的图标方便在竖条里阅读这个旋转是基于 Hotseat 的 recycle 视图实现的。如果你不做这个处理图标就会保持横向图标的方向在竖条里显得不协调。4. 常见问题与排查技巧实录4.1 图标挤成一团、宽高计算不对改完代码第一次跑起来的时候我遇到的第一个问题就是图标全挤在一起看起来像是 CellLayout 的宽高根本没被正确计算。排查下来发现问题出在Hotseat的onMeasure流程里。Hotseat 在测量时会读取mDeviceProfile里的参数但我在launcher.xml里改的layout_widthwrap_content导致它在测量时走上了一条错误的路径它把自己当成一个“宽度不确定”的容器然后在getCellLayout()里拿到的CellLayout也没有正确更新网格参数。解决办法是确保resetLayout()在onMeasure之前被调用或者在onMeasure里强制重新计算一次网格Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (mDeviceProfile.isVerticalBar() !mIsGridUpdated) { resetLayout(); mIsGridUpdated true; } super.onMeasure(widthMeasureSpec, heightMeasureSpec); }这个标志位很重要因为onMeasure在一次布局过程中会被调用很多次每次都重新 setGridSize 会导致测量卡顿和布局抖动。4.2 上滑手势和返回 Home 触发错误落点区Hotseat 变成竖条之后我遇到一个特别隐蔽的 bug拖拽图标到右侧竖条区域图标总是不能成功吸附进 Hotseat。后来排查发现是DragLayer在计算落点目标时用的 Hotseat 落点区域还是旧的“底部整行”矩形。Launcher3 的拖拽逻辑里每个 DropTarget 会提供一个可接受拖拽的区域通过getDropTargetDelegate或者类似接口来判断触点是否在该区域内。Hotseat 的落点区域默认是从它的布局参数推出来的。既然我们在 XML 里改了 Hotseat 的位置和大小落点区域理论上也应该跟着变。但如果改了布局参数还是不对那就得手动设置落点区域可以参考这样public boolean isDropEnabled() { ... Rect hitRect new Rect(); getHitRect(hitRect); return hitRect.contains(ev.getX(), ev.getY()); }这里我踩坑的教训是不要以为 Hotseat 作为 View 移动了位置拖拽落点就会自动跟着移动。在 Launcher3 的代码里拖拽落点区域的计算往往带有缓存特别是从键盘或其它界面切换回来后很容易拿到旧坐标。如果发现区域不对优先检查缓存其次是检查 Hotseat 是否有被记录在mDropTargets列表里以及它的可见性是否是VISIBLE。4.3 阿拉伯语/希伯来语环境下 Hotseat 跑到左边去了这个问题我在前面铺垫了实际就是 RTL 镜像导致的。测试团队切阿拉伯语跑了一遍Hotseat 从屏幕右侧直接蹦到左侧而且图标顺序也反了。处理方式有两个方向。方向一让 Hotseat 在这个场景下跟随 RTL也就是说在阿拉伯语里面保持右侧不对阿拉伯语是 RTL布局镜像后最自然的“靠近拇指”位置应该是左侧所以如果你想要更贴合 RTL 用户的习惯那就不需要特殊处理让它镜像过去就行。方向二产品不做多语言适配要求所有语言下 Hotseat 都固定在屏幕右边缘。那么你就需要在布局文件或者代码里强制 LTRcom.android.launcher3.Hotseat android:idid/hotseat android:layoutDirectionltr ... /不过实际测试下来只给 Hotseat 设置layoutDirection还不够图标排列的 rank 顺序可能还是会被 RTL 干扰。这个时候需要在Hotseat的onLayout或图标绑定逻辑里对 rank 做一层显式处理if (mDeviceProfile.isVerticalBar()) { // 竖向模式不受 RTL 影响直接用 rank 作为 index int index rank; ... }说白了竖排模式下我们的物理方向是自上而下这个方向在 RTL 语言下不会被镜像只要 rank 计算没有依赖 LTR 顺序就不会出问题。如果你是从系统原版代码拷过来的逻辑注意检查那些用到layoutDirection或isLayoutRtl()的代码段。4.4 锁屏后解锁回到桌面Hotseat 偶发错位这个问题的关键词是“android 13 锁屏流程”。在 Android 13 里锁屏和桌面之间的切换会触发 Launcher 的onStart、onResume生命周期很多时候 Launcher 并不会被销毁重建而是重新 attach 到窗口。如果你在onResume里没有主动重新触发 Hotseat 的布局就会出现 Hotseat 使用旧尺寸布局解锁后界面错位的情况。我的排查思路是这样先在 logcat 里看关键日志确认解锁返回桌面时有没有走DeviceProfile.onConfigurationChanged。如果走了说明配置文件已经更新问题在 Hotseat 没收到通知如果没走说明DeviceProfile实例还是旧的需要在 Launcher 的onResume里强制触发一次deviceProfileChanged回调并在回调里调hotseat.requestLayout()。让我想起一个经验Hotseat 这种常驻控件最怕在生命周期往返之后还持有旧的测量结果。与其到处加回调不如在onResume里统一加一个hotseat.invalidate()加requestLayout()的安全操作成本极低但能解决很多偶发错位的问题。4.5 横竖屏切换导致 IndexOutOfBounds 崩溃最后这个崩溃是在快速旋转设备时出现的。把手机从竖屏转到横屏再转回来偶尔会崩在Hotseat的图标索引越界。原因很简单竖排模式下单屏能显示的图标数量变少但 Launcher3 里有些缓存数组还是按横向模式的数量初始化的旋转过程中出现了一个短暂的不一致窗口。处理方式是加一个保护在访问图标索引位置时先判断索引是否小于当前CellLayout的格子总数。另外旋转导致的 Activity 重建会重新走整条初始化流程所以你也可以考虑的方案是强制 Launcher 处理旋转时不做 Activity 重建改成只更新配置。不过这个改动比较大如果只是单纯修崩溃先加保护判断就够了。这类问题写成速查表大概是这样的问题现象根本原因解决办法图标挤成一团网格尺寸未刷新onMeasure 前重新 setGridSize拖拽无法吸附进 Hotseat落点区域缓存了旧矩形手动更新 DropTarget 区域RTL 语言下位置反了镜像机制生效视需求强制 LTR 或适配 rank解锁回桌面后错位生命周期重绘未触发onResume 统一 requestLayout旋转崩溃索引越界缓存数组尺寸不匹配访问前加格子总数保护5. 一个更省事的扩展思路做这种系统级定制我的习惯是先在社区和源码里找现成的参考版本不急着动刀。比如搜索“Vertical Hotseat Launcher3”就能找到不少开源方案Pixel Launcher 相关的分支里也有人做类似的事。很多方案本质上都是同一个思路复用官方isVerticalBar逻辑再在外面包装一层开关。另外Android Go 或低内存设备上其实也有一些简化 Hotseat 的代码路径虽然最终形态跟我们的目标不完全一样但里面关于如何限制图标数量、如何精简预测应用、如何处理小屏空间紧张的思路很值得借鉴。说到底Hotseat 方向定制看着是个小改动真正做完你会发现它牵扯到布局、测量、拖拽、生命周期、RTL 这一整条链路。如果项目周期紧建议直接找一款开源 Launcher 做过侧边栏的方案抄作业比从原生 Android 13 源码一点点调要快得多代价是后续升级源码时合并代码比较痛苦需要自己权衡。6. 个人实际操作的体会这次改动前后大概花了两天时间大头其实不是写代码而是排查那几个看起来莫名其妙的问题。我的最终心得可以总结成三条第一条改动前一定把isVerticalBar()的全部调用点收一遍。我在做的时候发现这个判断不仅在Hotseat里用某些版本的Workspace、PageIndicator甚至是WallpaperOffset相关的代码也会引用。漏掉任何一个调用点就会出现局部正常、局部错乱的诡异状态。第二条测试的时候把“强制 RTL 模式”打开跑一遍。开发者选项里有一个“强制使用从右到左布局”的开关打开这个开关可以模拟阿拉伯语环境再配合切系统语言实测一次能提前发现很多和文本方向布局相关的问题别等 QA 来报 bug。第三条如果你只是想把 Hotseat 竖过来而不是做完整的方向自定义其实还有一个更简单的思路不用改 Java 代码用系统自带的语言资源限定符比如在layout-land或特定分辨率目录里放一套竖排 Hotseat 的 XML再通过values里的尺寸资源配合调整。这种方案适合快速出效果但灵活性差不能动态切换。如果需求明确只有一种形态倒是可以考虑。最后再补充一点小经验改完这类布局问题后建议在主屏幕上手动拖一遍图标从 Hotseat 拖到 Workspace再从 Workspace 拖回 Hotseat循环十几次。拖拽落点这类逻辑光看代码是看不出问题的必须实际拖过、动过、撤回过才能发现隐藏的坐标计算问题。这一步虽然浪费时间但真的能帮你省下后续更多的测试返工。