安卓13 Launcher3深度定制:从源码到实战,打造优雅的文件夹预览图布局方案
最近在做一个平板Launcher的深度定制项目,客户反馈文件夹预览图的图标布局有点别扭——两个图标时贴边太紧,三个图标时顶部图标直接“越狱”了。这问题看似不大,但实际体验很影响质感。我花了几天时间把Launcher3的文件夹绘制逻辑彻底梳理了一遍,发现这背后其实是一套相当精巧的几何布局算法。
如果你也在做Launcher定制,特别是安卓13的版本,这篇文章应该能帮你少走不少弯路。我会从源码分析入手,给出两种不同层级的解决方案,最后还会分享一些参数化设计的思路,让你的修改更加健壮。
1. 理解Launcher3文件夹预览图的绘制架构
要调整文件夹预览图的布局,首先得搞清楚它是怎么画出来的。Launcher3的文件夹图标绘制是个典型的分层架构,理解这个架构比直接改代码更重要。
1.1 核心类的作用与协作关系
Launcher3中负责文件夹图标绘制的核心类主要有三个:FolderIcon、PreviewItemManager和ClippedFolderIconLayoutRule。它们的分工很明确:
- FolderIcon:文件夹图标的View容器,负责整体的绘制调度
- PreviewItemManager:管理预览项(就是那些小图标)的绘制参数和动画
- ClippedFolderIconLayoutRule:计算每个预览项的位置和大小
这个架构的设计思路很清晰——计算与绘制分离。ClippedFolderIconLayoutRule负责“怎么摆”,PreviewItemManager负责“怎么画”,FolderIcon负责“什么时候画”。
// FolderIcon的初始化过程
private void init() {
mLongPressHelper = new CheckLongPressHelper(this);
// 创建布局规则和预览项管理器
mPreviewLayoutRule = new ClippedFolderIconLayoutRule();
mPreviewItemManager = new PreviewItemManager(this);
mDotParams = new DotRenderer.DrawParams();
}
这里有个关键点:ClippedFolderIconLayoutRule在初始化时就被创建,但它的参数配置是在PreviewItemManager.recomputePreviewDrawingParams()中完成的。这种延迟初始化的设计是为了适应不同设备尺寸的动态调整。
1.2 绘制流程的完整调用链
当文件夹需要绘制时,整个调用链是这样的:
FolderIcon.dispatchDraw()
↓
PreviewItemManager.recomputePreviewDrawingParams()
↓
PreviewItemManager.computePreviewDrawingParams()
↓
ClippedFolderIconLayoutRule.init() // 初始化布局参数
↓
PreviewItemManager.updatePreviewItems()
↓
PreviewItemManager.buildParamsForPage()
↓
PreviewItemManager.computePreviewItemDrawingParams()
↓
ClippedFolderIconLayoutRule.computePreviewItemDrawingParams() // 核心计算
↓
PreviewItemManager.draw() // 实际绘制
这个流程中,计算位置和实际绘制是两个独立的阶段。理解这一点很重要,因为我们的修改方案也会基于这两个不同的切入点。
提示:在调试时,可以在
FolderIcon.dispatchDraw()方法开头加Log,观察绘制频率。你会发现文件夹图标的绘制是相当频繁的,特别是在拖拽、动画过程中。
1.3 预览项的数据结构
每个预览项的信息都封装在PreviewItemDrawingParams对象中:
class PreviewItemDrawingParams {
float transX; // X轴平移量
float transY; // Y轴平移量
float scale; // 缩放比例
Drawable drawable; // 图标Drawable
WorkspaceItemInfo item; // 应用信息
// 构造函数和更新方法
PreviewItemDrawingParams(float transX, float transY, float scale) {
this.transX = transX;
this.transY = transY;
this.scale = scale;
}
}
这个类的设计体现了Android图形系统的典型模式:变换参数(transX, transY, scale)与绘制内容(drawable)分离。调整布局本质上就是调整这些变换参数。
2. 布局计算的核心算法解析
现在进入最核心的部分——ClippedFolderIconLayoutRule如何计算每个图标的位置。这部分涉及一些几何知识,但我会用尽量直观的方式解释。
2.1 圆形布局的基本原理
Launcher3的文件夹预览图采用了一种圆形布局算法。简单说,就是把图标均匀分布在一个虚拟的圆环上。这个设计的巧妙之处在于,无论图标数量是2个、3个还是4个,都能保持视觉上的平衡。
关键参数定义:
public static final int MAX_NUM_ITEMS_IN_PREVIEW = 4;
private static final int MIN_NUM_ITEMS_IN_PREVIEW = 2;
private static final float MIN_SCALE = 0.44f;
private static final float MAX_SCALE = 0.51f;
private static final float MAX_RADIUS_DILATION = 0.25f;
private static final float ITEM_RADIUS_SCALE_FACTOR = 1.15f;
这些常量决定了布局的基本行为:
MAX_NUM_ITEMS_IN_PREVIEW:最多显示4个预览图标MIN_SCALE/MAX_SCALE:图标的缩放范围ITEM_RADIUS_SCALE_FACTOR:控制图标分布半径的系数
2.2 getPosition()方法的数学逻辑
getPosition()方法是整个布局计算的核心,它的计算过程可以分为几个步骤:
第一步:确定起始角度
// 从左往右布局时,起始角度为π(180度)
double theta0 = mIsRtl ? 0 : Math.PI;
int direction = mIsRtl ? 1 : -1;
double thetaShift = 0;
// 根据图标数量调整起始角度
if (curNumItems == 3) {
thetaShift = Math.PI / 2; // 90度
} else if (curNumItems == 4) {
thetaShift = Math.PI / 4; // 45度
}
theta0 += direction * thetaShift;
这个调整是为了让布局在不同数量下都看起来自然。比如3个图标时,起始角度设为90度,这样第一个图标就在正上方。
第二步:计算每个图标的角度
// 将圆环等分,计算每个图标的角度
double theta = theta0 + index * (2 * Math.PI / curNumItems) * direction;
这里用到了极坐标的思想。如果curNumItems=3,那么每个图标间隔120度(2π/3)。
第三步:计算分布半径
// 半径随图标数量线性增加
float radius = mRadius * (1 + MAX_RADIUS_DILATION *
(curNumItems - MIN_NUM_ITEMS_IN_PREVIEW) /
(MAX_NUM_ITEMS_IN_PREVIEW - MIN_NUM_ITEMS_IN_PREVIEW));
半径的计算公式很有意思:图标越多,半径越大。这是为了给更多图标腾出空间。
第四步:转换为直角坐标
// 计算图标左上角坐标
float halfIconSize = (mIconSize * scaleForItem(curNumItems)) / 2;
result[0] = mAvailableSpace / 2 + (float) (radius * Math.cos(theta) / 2) - halfIconSize;
result[1] = mAvailableSpace / 2 + (float) (-radius * Math.sin(theta) / 2) - halfIconSize;
这里有几个关键点:
radius * Math.cos(theta)得到的是图标中心相对于圆心的X坐标- 除以2是为了让图标更靠近中心,避免贴边
- 减去
halfIconSize是因为Canvas绘制时用的是左上角坐标
2.3 缩放系数的动态调整
图标的大小不是固定的,而是根据数量动态调整:
public float scaleForItem(int numItems) {
final float scale;
if (numItems <= 3) {
scale = MAX_SCALE; // 2-3个图标用最大缩放
} else {
scale = MIN_SCALE; // 4个及以上用最小缩放
}
return scale * mBaselineIconScale;
}
这个设计很合理:图标少时可以大一些,图标多时就得小一些,否则放不下。
2.4 问题定位:为什么会出现布局异常
通过分析源码,我发现布局问题的根源在于几何约束条件被破坏。以3个图标为例,理想情况下它们应该构成一个等边三角形,且都在文件夹边界内。
但实际计算时,如果图标尺寸(mIconSize * scale)过大,或者分布半径(radius)不合适,就会导致图标超出边界。具体来说:
- 顶部图标溢出:Y坐标
result[1]为负值,说明图标跑到了文件夹上方 - 左右图标贴边:X坐标
result[0]接近0或接近mAvailableSpace - iconWidth
用数学公式表示约束条件:
图标半径 = (mIconSize * scale) / 2
最大允许半径 = mAvailableSpace / 2 - 图标半径 - 边距
当计算出的radius大于最大允许半径时,就会出问题。
3. 方案一:绘制时的动态调整(快速修复)
如果你需要快速解决问题,或者改动范围要尽量小,可以在绘制阶段进行调整。这种方法的优点是改动集中、风险可控,但缺点是治标不治本。
3.1 理解绘制阶段的坐标变换
在PreviewItemManager.drawPreviewItem()方法中,Canvas经历了多次变换:
private void drawPreviewItem(Canvas canvas, PreviewItemDrawingParams params,
PointF offset, boolean shouldClipPath, Path clipPath) {
canvas.save();
// 第一次平移:offset是文件夹背景的偏移,params.transX/Y是图标相对偏移
canvas.translate(offset.x + params.transX, offset.y + params.transY);
// 第一次缩放:图标的整体缩放
canvas.scale(params.scale, params.scale);
Drawable d = params.drawable;
if (d != null) {
Rect bounds = d.getBounds();
canvas.save();
// 第二次平移和缩放:调整Drawable的绘制位置
canvas.translate(-bounds.left, -bounds.top);
canvas.scale(mIntrinsicIconSize / bounds.width(),
mIntrinsicIconSize / bounds.height());
d.draw(canvas);
canvas.restore();
}
canvas.restore();
}
关键点:offset是固定的文件夹背景偏移,params.transX/Y才是决定图标位置的关键。
3.2 实现绘制时的自适应调整
我创建了一个新的绘制方法,专门处理2个和3个图标的特殊情况:
/**
* 自定义绘制方法,处理2-3个图标时的边界问题
* @param size 当前文件夹中的图标数量
*/
private void drawPreviewItemAdjusted(Canvas canvas, PreviewItemDrawingParams params,
PointF offset, boolean shouldClipPath,
Path clipPath, int size) {
canvas.save();
if (shouldClipPath) {
canvas.clipPath(clipPath);
}
float adjustedX = params.transX;
float adjustedY = params.transY;
// 根据图标数量和应用位置进行调整
switch (size) {
case 2:
// 两个图标:向中心靠拢
if (params.transX > 0) {
adjustedX = params.transX - 8f; // 右边图标左移
} else {
adjustedX = params.transX + 8f; // 左边图标右移
}
break;
case 3:
// 三个图标:重新分布
if (params.transY < 0) {
// 顶部图标下移
adjustedY = params.transY + 15f;
} else {
// 底部图标整体调整
adjustedY = params.transY + 5f;
if (params.transX > 0) {
adjustedX = params.transX - 6f; // 右下图标左移
} else {
adjustedX = params.transX + 6f; // 左下图标右移
}
}
break;
}
// 应用调整后的坐标
canvas.translate(offset.x + adjustedX, offset.y + adjustedY);
canvas.scale(params.scale, params.scale);
// 剩余绘制逻辑不变
Drawable d = params.drawable;
if (d != null) {
Rect bounds = d.getBounds();
canvas.save();
canvas.translate(-bounds.left, -bounds.top);
canvas.scale(mIntrinsicIconSize / bounds.width(),
mIntrinsicIconSize / bounds.height());
d.draw(canvas);
canvas.restore();
}
canvas.restore();
}
然后在drawParams()方法中根据图标数量选择绘制方式:
public void drawParams(Canvas canvas, ArrayList<PreviewItemDrawingParams> params,
PointF offset, boolean shouldClipPath, Path clipPath) {
int size = params.size();
// 只有2-3个图标时使用调整后的绘制
if (size == 2 || size == 3) {
for (int i = params.size() - 1; i >= 0; i--) {
PreviewItemDrawingParams p = params.get(i);
if (!p.hidden) {
boolean isExiting = p.index == EXIT_INDEX;
drawPreviewItemAdjusted(canvas, p, offset,
isExiting | shouldClipPath, clipPath, size);
}
}
} else {
// 4个及以上图标使用原逻辑
for (int i = params.size() - 1; i >= 0; i--) {
PreviewItemDrawingParams p = params.get(i);
if (!p.hidden) {
boolean isExiting = p.index == EXIT_INDEX;
drawPreviewItem(canvas, p, offset,
isExiting | shouldClipPath, clipPath);
}
}
}
}
3.3 这种方案的优缺点
优点:
- 改动范围小,只影响绘制逻辑
- 可以快速验证效果
- 对原有计算逻辑无侵入
缺点:
- 硬编码的调整值可能不适用于所有分辨率
- 如果图标尺寸或边距改变,需要重新调整
- 没有从根本上解决问题
注意:这种方案中的调整值(8f、15f、6f等)需要根据实际UI效果微调。建议通过资源文件配置,方便不同设备适配。
4. 方案二:计算时的参数化优化(推荐方案)
更优雅的解决方案是在计算阶段就修正问题。这需要修改ClippedFolderIconLayoutRule的计算逻辑,但好处是一劳永逸。
4.1 重新设计布局参数
首先,我们需要定义一套可配置的参数:
// 在ClippedFolderIconLayoutRule中添加配置参数
public class ClippedFolderIconLayoutRule {
// 原有的常量
private static final float MIN_SCALE = 0.44f;
private static final float MAX_SCALE = 0.51f;
// 新增:针对不同图标数量的配置
private static final float RADIUS_FACTOR_2_ITEMS = 0.85f; // 2个图标时的半径系数
private static final float RADIUS_FACTOR_3_ITEMS = 0.90f; // 3个图标时的半径系数
private static final float RADIUS_FACTOR_4_ITEMS = 1.00f; // 4个图标时的半径系数
private static final float SCALE_FACTOR_2_ITEMS = 0.48f; // 2个图标时的缩放系数
private static final float SCALE_FACTOR_3_ITEMS = 0.46f; // 3个图标时的缩放系数
private static final float SCALE_FACTOR_4_ITEMS = 0.44f; // 4个图标时的缩放系数
// 边距安全系数,确保图标不贴边
private static final float MARGIN_SAFETY_FACTOR = 0.1f;
}
4.2 优化getPosition()计算方法
修改getPosition()方法,加入边界检查和自适应调整:
private void getPosition(int index, int curNumItems, float[] result) {
curNumItems = Math.max(curNumItems, 2);
// 原有的角度计算逻辑保持不变
double theta0 = mIsRtl ? 0 : Math.PI;
int direction = mIsRtl ? 1 : -1;
double thetaShift = 0;
if (curNumItems == 3) {
thetaShift = Math.PI / 2;
} else if (curNumItems == 4) {
thetaShift = Math.PI / 4;
}
theta0 += direction * thetaShift;
if (curNumItems == 4 && index == 3) {
index = 2;
} else if (curNumItems == 4 && index == 2) {
index = 3;
}
double theta = theta0 + index * (2 * Math.PI / curNumItems) * direction;
// 优化1:根据图标数量使用不同的半径系数
float radiusFactor;
switch (curNumItems) {
case 2:
radiusFactor = RADIUS_FACTOR_2_ITEMS;
break;
case 3:
radiusFactor = RADIUS_FACTOR_3_ITEMS;
break;
default:
radiusFactor = RADIUS_FACTOR_4_ITEMS;
}
// 优化2:动态计算半径,考虑图标大小
float baseRadius = mAvailableSpace / 2f;
float iconSize = mIconSize * scaleForItem(curNumItems);
float maxRadius = baseRadius - iconSize / 2 - (mAvailableSpace * MARGIN_SAFETY_FACTOR);
float radius = Math.min(baseRadius * radiusFactor, maxRadius);
// 优化3:重新计算坐标,确保在边界内
float halfIconSize = iconSize / 2;
float centerX = mAvailableSpace / 2;
float centerY = mAvailableSpace / 2;
// 计算理论坐标
float theoreticalX = centerX + (float) (radius * Math.cos(theta) / 2);
float theoreticalY = centerY + (float) (-radius * Math.sin(theta) / 2);
// 边界检查与修正
float minX = halfIconSize + mAvailableSpace * MARGIN_SAFETY_FACTOR;
float maxX = mAvailableSpace - halfIconSize - mAvailableSpace * MARGIN_SAFETY_FACTOR;
float minY = halfIconSize + mAvailableSpace * MARGIN_SAFETY_FACTOR;
float maxY = mAvailableSpace - halfIconSize - mAvailableSpace * MARGIN_SAFETY_FACTOR;
float finalX = Math.max(minX, Math.min(maxX, theoreticalX));
float finalY = Math.max(minY, Math.min(maxY, theoreticalY));
result[0] = finalX - halfIconSize;
result[1] = finalY - halfIconSize;
}
4.3 优化scaleForItem()方法
同时调整缩放系数的计算:
public float scaleForItem(int numItems) {
final float scale;
switch (numItems) {
case 2:
scale = SCALE_FACTOR_2_ITEMS;
break;
case 3:
scale = SCALE_FACTOR_3_ITEMS;
break;
default:
scale = SCALE_FACTOR_4_ITEMS;
}
// 确保缩放后图标不会太大
float maxScale = (mAvailableSpace * 0.8f) / mIconSize;
return Math.min(scale * mBaselineIconScale, maxScale);
}
4.4 添加参数验证和日志
为了方便调试和验证,可以添加一些日志输出:
private void getPosition(int index, int curNumItems, float[] result) {
// ... 前面的计算逻辑
// 验证计算结果
if (BuildConfig.DEBUG) {
Log.d("FolderLayout", String.format(
"Item %d/%d: radius=%.2f, scale=%.3f, pos=(%.1f, %.1f), iconSize=%.1f",
index, curNumItems, radius,
scaleForItem(curNumItems),
result[0], result[1],
mIconSize * scaleForItem(curNumItems)
));
// 检查是否越界
if (result[0] < 0 || result[0] + iconSize > mAvailableSpace ||
result[1] < 0 || result[1] + iconSize > mAvailableSpace) {
Log.w("FolderLayout", "Icon out of bounds!");
}
}
// ... 返回结果
}
4.5 配置化的参数管理
为了让布局参数更容易调整,可以创建一个配置类:
public class FolderLayoutConfig {
// 不同图标数量的配置
public static class ItemCountConfig {
public final float radiusFactor; // 半径系数
public final float scaleFactor; // 缩放系数
public final float marginFactor; // 边距系数
public ItemCountConfig(float radiusFactor, float scaleFactor, float marginFactor) {
this.radiusFactor = radiusFactor;
this.scaleFactor = scaleFactor;
this.marginFactor = marginFactor;
}
}
private static final SparseArray<ItemCountConfig> CONFIGS = new SparseArray<>();
static {
// 2个图标的配置
CONFIGS.put(2, new ItemCountConfig(0.85f, 0.48f, 0.12f));
// 3个图标的配置
CONFIGS.put(3, new ItemCountConfig(0.90f, 0.46f, 0.10f));
// 4个图标的配置
CONFIGS.put(4, new ItemCountConfig(1.00f, 0.44f, 0.08f));
}
public static ItemCountConfig getConfig(int itemCount) {
ItemCountConfig config = CONFIGS.get(itemCount);
if (config == null) {
// 默认使用4个图标的配置
config = CONFIGS.get(4);
}
return config;
}
}
然后在ClippedFolderIconLayoutRule中使用这个配置:
private void getPosition(int index, int curNumItems, float[] result) {
// 获取配置
FolderLayoutConfig.ItemCountConfig config =
FolderLayoutConfig.getConfig(curNumItems);
// 使用配置参数进行计算
float radius = mAvailableSpace / 2f * config.radiusFactor;
float scale = config.scaleFactor * mBaselineIconScale;
float margin = mAvailableSpace * config.marginFactor;
// ... 后续计算
}
5. 实战:处理文件夹展开后的内部布局
文件夹预览图只是问题的一部分,文件夹展开后的内部布局同样重要。在Launcher3中,这部分由DeviceProfile类管理。
5.1 DeviceProfile的布局计算
DeviceProfile负责计算所有与设备相关的布局参数,包括文件夹内部格子的尺寸:
private void updateFolderCellSize(float scale, Resources res) {
// 获取基础图标尺寸(来自device_profiles.xml配置)
float invIconSizeDp = inv.iconSize[mTypeIndex];
// 计算图标像素尺寸
folderChildIconSizePx = Math.max(1, pxFromDp(invIconSizeDp, mMetrics, scale));
// 计算文字尺寸
folderChildTextSizePx = pxFromSp(inv.iconTextSize[mTypeIndex], mMetrics, scale);
// 计算文字高度
int textHeight = Utilities.calculateTextHeight(folderChildTextSizePx);
// 计算单元格尺寸
if (isScalableGrid) {
folderCellWidthPx = pxFromDp(getCellSize().x, mMetrics, scale);
folderCellHeightPx = pxFromDp(getCellSize().y, mMetrics, scale);
} else {
int cellPaddingX = (int) (res.getDimensionPixelSize(R.dimen.folder_cell_x_padding) * scale);
int cellPaddingY = (int) (res.getDimensionPixelSize(R.dimen.folder_cell_y_padding) * scale);
folderCellWidthPx = folderChildIconSizePx + 2 * cellPaddingX;
folderCellHeightPx = folderChildIconSizePx + 2 * cellPaddingY + textHeight;
}
// 计算图标与文字的间距
folderChildDrawablePaddingPx = Math.max(0,
(folderCellHeightPx - folderChildIconSizePx - textHeight) / 3);
}
5.2 常见问题与解决方案
在实际项目中,我遇到过几种典型的布局问题:
问题1:图标太小,留白太多
// 解决方案:调整图标尺寸和单元格尺寸的比例
private void updateFolderCellSize(float scale, Resources res) {
// 原逻辑...
// 自定义调整:增大图标,减小单元格
float iconScaleFactor = 1.2f; // 图标放大20%
float cellScaleFactor = 0.9f; // 单元格缩小10%
folderChildIconSizePx *= iconScaleFactor;
folderCellWidthPx *= cellScaleFactor;
folderCellHeightPx *= cellScaleFactor;
// 重新计算间距
folderChildDrawablePaddingPx = Math.max(0,
(folderCellHeightPx - folderChildIconSizePx - textHeight) / 3);
}
问题2:不同分辨率适配
// 解决方案:根据屏幕密度动态调整
private void updateFolderCellSize(float scale, Resources res) {
// 原逻辑...
// 根据DPI调整参数
float density = mMetrics.density;
float adjustedIconSize;
if (density >= 3.0f) {
// 高DPI设备(如4K平板)
adjustedIconSize = folderChildIconSizePx * 0.9f;
} else if (density >= 2.0f) {
// 中DPI设备
adjustedIconSize = folderChildIconSizePx;
} else {
// 低DPI设备
adjustedIconSize = folderChildIconSizePx * 1.1f;
}
// 确保调整后的尺寸合理
float maxIconSize = folderCellWidthPx * 0.8f; // 图标不超过单元格宽度的80%
folderChildIconSizePx = Math.min(adjustedIconSize, maxIconSize);
}
5.3 配置文件的优化建议
除了代码修改,还可以通过device_profiles.xml配置文件进行调整:
<grid-option
launcher:name="custom_6_by_5"
launcher:numFolderRows="3"
launcher:numFolderColumns="3">
<display-option
launcher:name="Tablet"
launcher:iconImageSize="72" <!-- 增大图标尺寸 -->
launcher:iconTextSize="16" <!-- 增大文字尺寸 -->
launcher:minCellWidth="90" <!-- 减小单元格宽度 -->
launcher:minCellHeight="90" <!-- 减小单元格高度 -->
launcher:borderSpaceHorizontal="12" <!-- 调整水平边距 -->
launcher:borderSpaceVertical="12" <!-- 调整垂直边距 -->
/>
</grid-option>
关键参数说明:
| 参数 | 说明 | 建议值 |
|---|---|---|
iconImageSize | 图标尺寸(dp) | 根据屏幕密度调整,60-80dp |
iconTextSize | 文字尺寸(sp) | 14-18sp,确保可读性 |
minCellWidth | 最小单元格宽度 | 图标宽度 + 适当边距 |
minCellHeight | 最小单元格高度 | 图标高度 + 文字高度 + 间距 |
borderSpaceHorizontal | 水平边距 | 8-16dp,避免太拥挤 |
borderSpaceVertical | 垂直边距 | 8-16dp,保持视觉平衡 |
5.4 测试与验证策略
修改布局参数后,需要进行全面的测试:
- 不同图标数量测试:2个、3个、4个、更多图标
- 不同屏幕密度测试:mdpi、hdpi、xhdpi、xxhdpi、xxxhdpi
- 不同屏幕方向测试:横屏、竖屏
- 动态变化测试:拖拽图标进出文件夹时的动画
- 边界情况测试:文件夹在屏幕边缘时的显示
可以创建一个测试工具类:
public class FolderLayoutTester {
public static void testAllConfigurations(Context context) {
int[] iconCounts = {2, 3, 4, 5, 6};
float[] densities = {1.0f, 1.5f, 2.0f, 3.0f, 4.0f};
for (int count : iconCounts) {
for (float density : densities) {
testConfiguration(context, count, density);
}
}
}
private static void testConfiguration(Context context, int iconCount, float density) {
// 模拟不同配置下的布局计算
DisplayMetrics metrics = new DisplayMetrics();
metrics.density = density;
metrics.scaledDensity = density;
// 创建测试用的布局规则
ClippedFolderIconLayoutRule rule = new ClippedFolderIconLayoutRule();
rule.init(165, 180, false); // 假设可用空间165px,图标尺寸180px
// 计算每个图标的位置
for (int i = 0; i < iconCount; i++) {
float[] position = new float[2];
rule.getPositionForTest(i, iconCount, position);
// 验证位置是否在边界内
if (position[0] < 0 || position[0] > 165 ||
position[1] < 0 || position[1] > 165) {
Log.e("LayoutTest", String.format(
"FAIL: %d icons, density=%.1f, icon%d at (%.1f, %.1f)",
iconCount, density, i, position[0], position[1]
));
} else {
Log.i("LayoutTest", String.format(
"PASS: %d icons, density=%.1f, icon%d at (%.1f, %.1f)",
iconCount, density, i, position[0], position[1]
));
}
}
}
}
6. 高级技巧:创建自适应布局系统
对于需要高度定制的项目,可以考虑实现一个完整的自适应布局系统。
6.1 定义布局策略接口
首先定义一个布局策略接口,支持不同的布局算法:
public interface FolderLayoutStrategy {
/**
* 初始化布局参数
*/
void init(int availableSpace, float iconSize, boolean isRtl);
/**
* 计算图标位置
*/
void getPosition(int index, int totalItems, float[] result);
/**
* 获取图标缩放比例
*/
float getScale(int totalItems);
/**
* 验证布局是否有效
*/
boolean validateLayout(int totalItems);
}
6.2 实现不同的布局策略
圆形布局策略(默认):
public class CircularLayoutStrategy implements FolderLayoutStrategy {
private float mRadius;
private float mAvailableSpace;
private float mIconSize;
private boolean mIsRtl;
@Override
public void init(int availableSpace, float iconSize, boolean isRtl) {
mAvailableSpace = availableSpace;
mIconSize = iconSize;
mIsRtl = isRtl;
mRadius = availableSpace * 0.4f; // 经验值
}
@Override
public void getPosition(int index, int totalItems, float[] result) {
// 圆形布局算法
double angle = 2 * Math.PI * index / totalItems;
float center = mAvailableSpace / 2;
float iconRadius = mIconSize * getScale(totalItems) / 2;
result[0] = center + (float) (mRadius * Math.cos(angle)) - iconRadius;
result[1] = center + (float) (mRadius * Math.sin(angle)) - iconRadius;
}
// ... 其他方法实现
}
网格布局策略:
public class GridLayoutStrategy implements FolderLayoutStrategy {
@Override
public void getPosition(int index, int totalItems, float[] result) {
// 网格布局算法
int cols = (int) Math.ceil(Math.sqrt(totalItems));
int rows = (int) Math.ceil((float) totalItems / cols);
int col = index % cols;
int row = index / cols;
float cellWidth = mAvailableSpace / cols;
float cellHeight = mAvailableSpace / rows;
float iconSize = mIconSize * getScale(totalItems);
result[0] = col * cellWidth + (cellWidth - iconSize) / 2;
result[1] = row * cellHeight + (cellHeight - iconSize) / 2;
}
// ... 其他方法实现
}
6.3 动态策略选择
可以根据图标数量动态选择布局策略:
public class AdaptiveFolderLayout {
private FolderLayoutStrategy mStrategy;
public void updateStrategy(int itemCount) {
if (itemCount <= 1) {
mStrategy = new SingleItemStrategy();
} else if (itemCount == 2) {
mStrategy = new TwoItemStrategy();
} else if (itemCount == 3) {
mStrategy = new TriangularStrategy();
} else if (itemCount <= 6) {
mStrategy = new CircularLayoutStrategy();
} else {
mStrategy = new GridLayoutStrategy();
}
}
// 代理方法
public void getPosition(int index, int totalItems, float[] result) {
mStrategy.getPosition(index, totalItems, result);
}
}
6.4 性能优化考虑
布局计算虽然不复杂,但在快速滑动或动画时可能会频繁调用。可以考虑以下优化:
- 缓存计算结果:对于相同的参数组合,缓存位置计算结果
- 预计算常用布局:在初始化时预计算2-6个图标的布局
- 使用对象池:避免频繁创建
float[]数组
public class LayoutCalculator {
private static final int MAX_CACHE_SIZE = 20;
private final LruCache<String, float[]> mPositionCache;
public LayoutCalculator() {
mPositionCache = new LruCache<>(MAX_CACHE_SIZE);
}
public float[] getPosition(int index, int totalItems,
int availableSpace, float iconSize) {
String key = String.format("%d-%d-%d-%.1f",
index, totalItems, availableSpace, iconSize);
float[] position = mPositionCache.get(key);
if (position == null) {
position = calculatePosition(index, totalItems, availableSpace, iconSize);
mPositionCache.put(key, position);
}
return position.clone(); // 返回副本,避免修改缓存
}
private float[] calculatePosition(int index, int totalItems,
int availableSpace, float iconSize) {
// 实际计算逻辑
float[] result = new float[2];
// ... 计算过程
return result;
}
}
6.5 可视化调试工具
开发一个简单的调试界面,实时查看布局效果:
public class FolderLayoutDebugView extends View {
private FolderLayoutStrategy mStrategy;
private int mItemCount = 3;
private Paint mPaint;
public FolderLayoutDebugView(Context context) {
super(context);
init();
}
private void init() {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(Color.BLUE);
mStrategy = new CircularLayoutStrategy();
mStrategy.init(300, 60, false);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制背景
canvas.drawColor(Color.LTGRAY);
// 绘制每个图标
for (int i = 0; i < mItemCount; i++) {
float[] pos = new float[2];
mStrategy.getPosition(i, mItemCount, pos);
float scale = mStrategy.getScale(mItemCount);
float radius = 30 * scale;
canvas.drawCircle(pos[0], pos[1], radius, mPaint);
// 绘制编号
mPaint.setColor(Color.WHITE);
mPaint.setTextSize(20);
canvas.drawText(String.valueOf(i), pos[0] - 5, pos[1] + 5, mPaint);
mPaint.setColor(Color.BLUE);
}
}
public void setItemCount(int count) {
mItemCount = count;
invalidate();
}
}
这个调试工具可以帮助你直观地看到不同参数下的布局效果,快速调整到最佳状态。
7. 实际项目中的经验总结
在多个Launcher定制项目中,我积累了一些实用的经验:
经验1:优先修改计算逻辑,而非绘制逻辑
- 计算阶段的修改更彻底,适配性更好
- 绘制阶段的修改适合快速修复或A/B测试
经验2:参数化配置优于硬编码
- 将关键参数提取到配置类或资源文件中
- 便于不同设备、不同分辨率的适配
- 方便产品经理或设计师调整视觉效果
经验3:考虑动画效果
- 修改布局时要注意图标进出文件夹的动画
- 确保位置变化是连续的,避免跳跃
- 可以适当调整动画插值器,使移动更自然
经验4:测试要充分
- 测试不同图标数量(1-9个)
- 测试不同屏幕密度和尺寸
- 测试横竖屏切换
- 测试拖拽、长按等交互操作
经验5:性能监控
- 在
getPosition()方法中添加性能日志 - 监控布局计算是否成为性能瓶颈
- 对于频繁调用的方法,考虑缓存优化
最后,记住一个原则:好的布局应该是自适应的。不要为了某个特定情况而过度优化,要确保在各种条件下都能正常工作。Launcher3的源码虽然复杂,但它的设计思路很值得学习——通过参数化的计算来实现灵活的布局,而不是写死的坐标值。
1571



被折叠的 条评论
为什么被折叠?



