内存泄漏之TextLine.recycle

本文提供了一个工具类用于解决Android TextLine组件中存在的内存泄漏问题。该工具类通过反射访问并清空TextLine内部缓存及Span集合,适用于Android 4.0以上版本。
Issue: https://code.google.com/p/android/issues/detail?id=59310

android.text.TextLine has memory leak on mSpanned and SpanSet.

使用下面的工具类移除泄漏:
public class TextLineRecycler {
private static Field sCached;
private static Field sCharacterStyleSpanSet;
private static Field sMetricAffectingSpanSpanSet;
private static Field sReplacementSpanSpanSet;
private static Field sSpans;
private static Field sSpanned;
private static Field sText;

static {
try {
Class clazz = Class.forName("android.text.TextLine");
sCached = clazz.getDeclaredField("sCached");
sSpanned = clazz.getDeclaredField("mSpanned");
sText = clazz.getDeclaredField("mText");
sCached.setAccessible(true);
sSpanned.setAccessible(true);
sText.setAccessible(true);
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
sCharacterStyleSpanSet = clazz.getDeclaredField("mCharacterStyleSpanSet");
sMetricAffectingSpanSpanSet = clazz.getDeclaredField("mMetricAffectingSpanSpanSet");
sReplacementSpanSpanSet = clazz.getDeclaredField("mReplacementSpanSpanSet");
Class spanSetClass = Class.forName("android.text.SpanSet");
sSpans = spanSetClass.getDeclaredField("spans");
sCharacterStyleSpanSet.setAccessible(true);
sMetricAffectingSpanSpanSet.setAccessible(true);
sReplacementSpanSpanSet.setAccessible(true);
sSpans.setAccessible(true);
}
} catch (Exception e) {
Log.e("TextLineRecycler", "", e);
}
}

/**
* recycle system TextLine
*/
public static void recycle() {
try {
Object cached = sCached.get(null);
int length = Array.getLength(cached);
if (cached != null) {
synchronized (cached) {
for (int i = 0; i < length; i++) {
Object textLine = Array.get(cached, i);
if (textLine != null) {
Object text = sText.get(textLine);
// mText has been recycled, recycle mSpanned and other SpanSet here
if (null == text && null != sSpanned.get(textLine)) {
sSpanned.set(textLine, null);
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
recycleSpan(sCharacterStyleSpanSet, textLine);
recycleSpan(sMetricAffectingSpanSpanSet, textLine);
recycleSpan(sReplacementSpanSpanSet, textLine);
}
}
}
}
}
}
} catch (Exception e) {
Log.e("TextLineRecycler", "", e);
}
}

private static <T extends CharacterStyle> void recycleSpan(Field field, Object textLine)
throws IllegalArgumentException, IllegalAccessException {
Object spanSet = field.get(textLine);
T[] spans = (T[]) TextLineRecycler.sSpans.get(spanSet);
if (spans != null) {
for (int i = 0; i < spans.length; i++) {
spans[i] = null;
}
}
}
}
// ======== 1. 初始化 ========== auto.waitFor(); device.keepScreenDim(); setScreenMetrics(1236, 2676); if (!requestScreenCapture()) { toast("❌ 请求截图失败"); exit(); } toast("✅ 脚本启动,请进入列表页..."); // ======== 2. 定义变量 ======== var processedKeys = {}; // 去重缓存 var LIST_PAGE_ID = "recyclerview"; // 列表 RecyclerView ID var TARGET_TEXT_ID = "min_deposit_tv"; // 押金字段 ID var MAX_WAIT_SECONDS = 20; // 等待超时时间 var CSV_FILE_PATH = "/sdcard/AIM/Aac_report.csv"; var successCount = 0; // 成功写入条数 // Java 文件系统支持 var File = java.io.File; var parentDir = new File(CSV_FILE_PATH).getParentFile(); if (!parentDir.exists()) parentDir.mkdirs(); console.info("📁 CSV 日志路径: " + CSV_FILE_PATH); // ======== 3. 所有工具函数定义(必须前置!)======== // ✅ extractNumber —— 提取数字 function extractNumber(text) { if (typeof text === 'undefined' || text === null) return null; var clean = String(text).replace(/[^\d.]/g, ''); var match = clean.match(/^\d*\.?\d+$/); var num = match ? parseFloat(match[0]) : null; return isNaN(num) ? null : num; } // ✅ round —— 四舍五入 function round(num, digits) { if (typeof digits === 'undefined') digits = 2; var factor = Math.pow(10, digits); return Math.round(num * factor) / factor; } // ✅ normalizeString —— 字符串归一化(用于唯一键) function normalizeString(str) { if (typeof str !== 'string') str = String(str); return str.trim().toLowerCase() .replace(/\s+/g, ' ') .replace(/[^\w\u4e00-\u9fa5]/g, ''); // 移除非字母/汉字/数字字符 } // ✅ waitForListPage —— 等待列表页出现 function waitForListPage(timeout) { if (typeof timeout === 'undefined') timeout = MAX_WAIT_SECONDS * 1000; var start_time = new Date().getTime(); while ((new Date().getTime() - start_time) < timeout) { try { if (id(LIST_PAGE_ID).exists()) { var widget = id(LIST_PAGE_ID).findOnce(); if (widget && widget.visibleToUser && widget.visibleToUser()) { log("✅ 成功返回列表页"); return true; } } } catch (err) {} sleep(500); } log("❌ 等待列表页超时"); return false; } // ✅ getTaskName —— 获取物品名称(从详情页获取) function getTaskName() { try { var w = id("tv_order_details_title").findOne(3000); if (!w) { log("❌ 未找到标题控件"); return "未知物品"; } var txt = (w.text && w.text()) || (w.desc && w.desc()) || "未命名"; setClip(txt); // 复制到剪贴板备用 log("📋 名称: " + txt); return txt; } catch (e) { log("⚠️ 获取名称异常: " + e.message); return "异常"; } } // ✅ getSellingPrice —— 获取我的售价 function getSellingPrice() { try { var w = id("tv_selling_price_value").findOne(5000); if (!w) { log("❌ 售卖价控件不存在"); return null; } var rawText = w.text ? w.text() : ""; var price = extractNumber(rawText); if (price === null) { log("❌ 解析失败: " + rawText); } else { log("✅ 我的售价: " + price); } return price; } catch (e) { log("⚠️ 获取售价出错: " + e.message); return null; } } // ✅ getCurrentCategoryContainer —— 获取选中的分类容器 function getCurrentCategoryContainer() { var list = id("item_category_ll").className("android.view.ViewGroup").find(); for (var i = 0; i < list.size(); i++) { var c = list.get(i); try { if (c.clickable() && c.selected()) { return c; } } catch (e) {} } return null; } // ✅ extractPricingFrom —— 从分类项中提取价格 function extractPricingFrom(parent) { try { var children = parent.children(); for (var i = 0; i < children.size(); i++) { var ch = children.get(i); var t = (ch.text && ch.text()) || (ch.desc && ch.desc()) || ""; var num = extractNumber(t); if (num !== null) { return { value: num, rawText: t }; } } } catch (e) { log("⚠️ 提取定价失败: " + e.message); } return null; } // ✅ getWearLevel —— 获取磨损区间 function getWearLevel() { try { var w = id("tv_abrade_filter").findOne(2000); return w ? (w.text() || "未获取") : "未获取"; } catch (e) { return "获取失败"; } } // ✅ swipeUp —— 上滑加载更多 function swipeUp() { var h = device.height; var w = device.width; try { gesture(500, [w / 2, h * 0.7], [w / 2, h * 0.3]); return true; } catch (e) { return false; } } // ===================================== // 🔍 scanAndClickItem —— 扫描并点击符合条件的商品 // ===================================== function scanAndClickItem() { var rv = id(LIST_PAGE_ID).findOne(2000); if (!rv) { log("❌ 找不到 RecyclerView"); return false; } var items = rv.children(); var itemCount = items.size(); for (var i = 0; i < itemCount; i++) { var child = items.get(i); // 🔍 查找押金控件 var depositWidget = child.findOne(id(TARGET_TEXT_ID)); if (!depositWidget) continue; var rawText = depositWidget.text(); var deposit = extractNumber(rawText); if (isNaN(deposit) || deposit <= 500) continue; // 🔍 查找名称控件(列表页上的 name_tv) var nameWidget = child.findOne(id("name_tv")); var itemName = "未知物品"; if (nameWidget) { itemName = (nameWidget.text() || "").trim(); if (!itemName) itemName = (nameWidget.desc() || "").trim(); } if (!itemName || itemName.length === 0) itemName = "未命名-" + deposit; // ✅ 构造唯一键:归一化名称 + 押金(保留两位小数精度) var key = normalizeString(itemName) + "@" + Math.round(deposit * 100); // 检查是否已处理 if (processedKeys[key]) { log("🔁 跳过已处理项: " + itemName + " | ¥" + deposit); continue; } // ✅ 尝试点击 log("🎯 发现新目标: " + itemName + " | 押金¥" + deposit); if (child.click()) { log("✅ 成功点击 → 进入详情页"); processedKeys[key] = true; return true; // 成功点击一个就退出 } else { log("❌ 点击失败,可能被遮挡或不可见"); } } return false; // 未找到可点击项 } // ===================================== // ✅ B阶段:图像识别点击(磨损区间 & 磨损度) // ===================================== function performBitmapClicks() { log("🖼️ B阶段:执行图像识别操作"); sleep(2000); // B1: 点击“磨损区间” if (text("磨损区间").exists()) { var cap = captureScreen(); var tem = images.read("/sdcard/脚本/小图/磨损区间.png"); if (!tem) { log("❌ 无法读取模板图片:磨损区间.png"); } else { var p = images.findImage(cap, tem, { region: [206, 987, 142, 177], threshold: 0.9 }); if (p) { click(p.x + tem.getWidth() / 2, p.y + tem.getHeight() / 2); log("🖱️ 已点击:磨损区间"); sleep(1000); } else { log("🔍 未匹配到:磨损区间"); } cap.recycle(); tem.recycle(); } } // B2: 点击“磨损度” sleep(1000); var cap1 = captureScreen(); var tem1 = images.read("/sdcard/脚本/小图/磨损度.png"); if (!tem1) { log("❌ 无法读取模板图片:磨损度.png"); } else { var p1 = images.findImage(cap1, tem1, { region: [27, 1089, 93, 184], threshold: 0.9 }); if (p1) { click(p1.x + tem1.getWidth() / 2 + 47, p1.y + tem1.getHeight() / 2 + 136); log("🖱️ 已点击:磨损度"); } else { log("🔍 未匹配到:磨损度"); } cap1.recycle(); tem1.recycle(); } } // ===================================== // ✅ C阶段:数据采集与 CSV 记录 // ===================================== function collectAndRecordData() { log("📝 C阶段:采集数据并比较差价"); var taskName = getTaskName(); var sellingPrice = getSellingPrice(); if (!sellingPrice) { toast("⚠️ 售卖价获取失败,跳过"); return false; } var container = getCurrentCategoryContainer(); if (!container) { toast("🚫 未找到选中分类容器"); return false; } var pricing = extractPricingFrom(container); if (!pricing) { toast("⚠️ 分类中无有效定价"); return false; } var categoryPrice = pricing.value; var diff = round(Math.abs(sellingPrice - categoryPrice), 2); var wearLevel = getWearLevel(); // 当差价 > 5时才记录 if (diff > 5) { try { var file = new File(CSV_FILE_PATH); var needsHeader = !file.exists() || file.length() === 0; var writer = open(CSV_FILE_PATH, "a"); if (needsHeader) { writer.write("时间,物品名称,磨损程度,定价,售价,差价\n"); log("📊 创建新 CSV 文件并写入表头"); } // 转义双引号 var escapedName = taskName.replace(/"/g, '""'); var line = [ '"' + escapedName + '"', '"' + wearLevel + '"', categoryPrice.toFixed(2), sellingPrice.toFixed(2), diff.toFixed(2), new Date().toLocaleString(), ].join(",") + "\n"; writer.write(line); writer.close(); successCount++; toastLog( "📌 物品: " + taskName + "\n" + "🔺 差额: " + diff + "\n" + "📈 累计: " + successCount + " 条" ); } catch (e) { log("❌ 写入 CSV 失败: " + (e.message || e.toString())); } } else { log("📉 差价 ≤1,不记录。累计仍为: " + successCount + " 条"); } return true; } // ======== 4. 主循环开始 ========= while (true) { log("🔄 ========== 新一轮循环开始 =========="); // A: Wait for List Page if (!waitForListPage()) { back(); sleep(2000); continue; } // D: Scan and Click var clicked = scanAndClickItem(); if (!clicked) { log("📭 未发现新目标,执行 E 阶段:滑动加载"); if (swipeUp()) { sleep(1500); continue; } else { toastLog("🔚 已到底部,脚本结束"); break; } } // B: Bitmap-based Click performBitmapClicks(); // C: Collect & Compare collectAndRecordData(); // 返回列表 back(); log("🔙 返回列表页..."); if (!waitForListPage()) { toastLog("⏳ 返回超时,脚本终止"); break; } sleep(1000); } 用Es语法修改以上auto脚本,修改写入模式为追加写入
11-11
// ==Auto.js== // @name 【精简版】列表扫描 + 磨损筛选 + CSV记录(ES5) // @desc 去除所有重复代码,仅保留必要逻辑 auto.waitFor(); console.show(); device.keepScreenDim(); setScreenMetrics(1236, 2676); // 请按实际设备修改 // ================ 配置常量 ================ var LIST_PAGE_ID = "recyclerview"; // 列表 RecyclerView ID var DEPOSIT_ID = "min_deposit_tv"; // 在售字段 ID var NAME_ID = "name_tv"; // 商品名称 ID var WEAR_FILTER_ID = "tv_abrade_filter"; // 磨损过滤器 ID var SORT_LIST_ID = "rv_sort_list"; // 排序面板列表 ID var SELLING_PRICE_ID = "tv_selling_price_value"; // 售卖价 ID var CATEGORY_CONTAINER_ID = "item_category_ll"; // 分类容器 ID var MAX_WAIT_MS = 5000; // 控件等待超时 var CSV_PATH = "/sdcard/AIM/Aa1.csv"; // CSV 输出路径 var successCount = 0; // 创建目录 var File = java.io.File; var parentDir = new File(CSV_PATH).getParentFile(); if (!parentDir.exists()) parentDir.mkdirs(); // ================ 工具函数(ES5,仅定义一次) ================ function round(num, digits) { var d = digits || 0; var f = Math.pow(10, d); return Math.round((num + '') * f) / f; } function extractNumber(text) { if (!text) return null; var clean = String(text).replace(/[^\d.]/g, ''); var m = clean.match(/^\d*\.\d+$|^\d+$/); if (!m) return null; var n = parseFloat(m[0]); return isNaN(n) ? null : n; } function normalizeString(str) { if (typeof str !== 'string') str = String(str); return str.trim().toLowerCase() .replace(/\s+/g, ' ') .replace(/[^\w\u4e00-\u9fa5]/g, ''); } function formatDateTime() { var now = new Date(); function pad(n) { return n < 10 ? '0' + n : n; } return now.getFullYear() + " " + pad(now.getHours()) + ":" + pad(now.getMinutes()) + ":" + pad(now.getSeconds()); } function clickCenter(bounds) { if (!bounds) return false; click(bounds.centerX(), bounds.centerY()); sleep(800); return true; } function waitFor(query, timeout) { var start = new Date().getTime(); var t = timeout || MAX_WAIT_MS; while ((new Date().getTime() - start) < t) { try { var w = query.findOne(1000); if (w) return w; } catch (e) {} sleep(300); } return null; } function waitForVisible(idStr) { var start = new Date().getTime(); while ((new Date().getTime() - start) < MAX_WAIT_MS) { try { var w = id(idStr).findOne(1000); if (w && w.visibleToUser && w.visibleToUser()) return w; } catch (e) {} sleep(300); } return null; } // ================ 数据采集函数 ================ function getTaskName() { try { var w = id("tv_order_details_title").findOne(3000); var txt = w ? (w.text() || w.desc() || "未知") : "未知"; log("📋 名称: " + txt); return txt; } catch (e) { log("⚠️ 获取名称失败: " + e.message); return "异常"; } } function getSellingPrice() { var w = waitFor(id(SELLING_PRICE_ID), 3000); if (!w) { log("❌ 未找到售卖价控件"); return null; } var price = extractNumber(w.text()); if (price === null) log("❌ 解析售价失败: " + w.text()); else log("✅ 我的售价: " + price); return price; } function getCurrentCategoryPrice() { var list = id(CATEGORY_CONTAINER_ID).find(); for (var i = 0; i < list.size(); i++) { var c = list.get(i); try { if (c.selected && typeof c.selected === 'function' && c.selected()) { var pricing = extractPricingFrom(c); if (pricing !== null) return pricing; } } catch (e) {} } return null; } function extractPricingFrom(parent) { try { var children = parent.children(); for (var i = 0; i < children.size(); i++) { var ch = children.get(i); var text = (ch.text && ch.text()) || (ch.desc && ch.desc()) || ''; var num = extractNumber(text); if (num !== null && num > 1) return num; } } catch (e) {} return null; } function getWearLevel() { var w = waitFor(id(WEAR_FILTER_ID), 2000); return w ? (w.text() || w.desc() || "未获取") : "未获取"; } function getAbraidFilterRange() { var w = waitFor(id(WEAR_FILTER_ID).className("android.widget.TextView"), 2000); if (!w) return null; var txt = (w.text && w.text()) || (w.desc && w.desc()) || ''; txt = String(txt).trim(); var m = txt.match(/(\d+\.\d+)\s*[-~\u81F3]\s*(\d+\.\d+)/); if (!m) return null; var minW = parseFloat(m[1]), maxW = parseFloat(m[2]); if (isNaN(minW) || isNaN(maxW) || minW >= maxW) return null; return { min: minW, max: maxW }; } // ================ 滑动 & 图像识别辅助 ================ function swipeUp() { var h = device.height, w = device.width; try { gesture(500, [w/2, h*0.7], [w/2, h*0.3]); sleep(1000); return true; } catch (e) { sleep(800); return false; } } function performBitmapClicks() { var cap = captureScreen(); var tem = images.read("/sdcard/脚本/小图/筛选.png"); if (cap && tem) { var p = images.findImage(cap, tem, { region: [957, 293, 242, 67], threshold: 0.9 }); if (!p) { device.setMusicVolume(7); media.playMusic("/sdcard/Music/My love 请别让爱凋落.mp3"); sleep(media.getMusicDuration() || 5000); engines.myEngine().forceStop(); } cap.recycle(); if (tem) tem.recycle(); } } // ================ 核心:磨损筛选逻辑 ================ function processWearFilter() { log("🔧 开始处理磨损筛选..."); // Step 1: 点击【磨损区间】 if (text("磨损区间").exists()) { var widget = text("磨损区间").findOne(2000); if (widget && widget.bounds) { clickCenter(widget.bounds()); log("🖱️ 已点击【磨损区间】"); sleep(1000); } } // Step 2: 等待 rv_sort_list 加载,并点击索引1 var list = waitFor(id(SORT_LIST_ID), 5000); if (!list) { toast("❌ 未找到磨损列表"); return false; } var items = list.children(); if (items.size() <= 5) { toast("❌ 选项不足,无法点击索引1"); return false; } var target1 = items[1]; if (!clickCenter(target1.bounds())) return false; log("✅ 成功点击索引1"); sleep(1000); // Step 3: 循环 step=2 to 4 var initialMinWear = null; var finalMaxWear = null; var categoryPrice = null; var diff = null; for (var step = 2; step <= 4; step++) { var sellingPrice = getSellingPrice(); if (!sellingPrice) break; categoryPrice = getCurrentCategoryPrice(); if (!categoryPrice) break; diff = round(sellingPrice - categoryPrice, 2); log("📊 当前差价: " + diff.toFixed(2)); if (diff <= 5) { log("🔴 差价≤5,终止推进"); break; } if (initialMinWear === null) { var range = getAbraidFilterRange(); if (range) initialMinWear = range.min; } // 重新打开面板 var filter = waitFor(id(WEAR_FILTER_ID), 3000); if (!filter || !clickCenter(filter.bounds())) break; list = waitFor(id(SORT_LIST_ID), 4000); if (!list) break; items = list.children(); if (step >= items.size()) break; var target = items[step]; if (!clickCenter(target.bounds())) break; log("✅ 已点击第 %d 档", step); sleep(1000); var currentRange = getAbraidFilterRange(); if (currentRange) finalMaxWear = currentRange.max; } // 返回是否成功完成且差价 > 5 return finalMaxWear !== null && diff > 5; } // ================ CSV 记录 ================ function writeToCSV(data) { try { var file = new File(CSV_PATH); var needsHeader = !file.exists() || file.length() === 0; var fw = new java.io.FileWriter(file, true); // 追加模式 var bw = new java.io.BufferedWriter(fw); if (needsHeader) { bw.write("物品名称,最小磨损,最大磨损,定价,售价,差价,时间\n"); } var escapedName = data.name.replace(/"/g, '""'); var line = [ '"' + escapedName + '"', '"' + data.wear + '"', data.category.toFixed(2), data.selling.toFixed(2), data.diff.toFixed(2), data.time ].join(",") + "\n"; bw.write(line); bw.close(); fw.close(); successCount++; toastLog("📌 已记录: " + data.name + " | 差价:" + data.diff); } catch (e) { log("❌ CSV写入失败: " + e.message); } } // ================ 主循环 ================ toast("✅ 脚本启动,请进入列表页..."); var processedKeys = {}; while (true) { log("🔄 ========== 新一轮循环开始 =========="); // A: 等待列表页 if (!waitForVisible(LIST_PAGE_ID)) { log("🔙 返回中..."); back(); sleep(2000); continue; } // B: 扫描并点击符合条件的商品 var rv = id(LIST_PAGE_ID).findOne(2000); if (!rv) { log("❌ 未找到商品列表"); continue; } var found = false; var children = rv.children(); for (var i = 0; i < children.size(); i++) { var item = children.get(i); var depositWidget = item.findOne(id(DEPOSIT_ID)); if (!depositWidget) continue; var deposit = extractNumber(depositWidget.text()); if (isNaN(deposit) || deposit <= 500) continue; var nameWidget = item.findOne(id(NAME_ID)); var name = (nameWidget && (nameWidget.text() || nameWidget.desc())) || "未命名-" + deposit; var key = normalizeString(name) + "@" + Math.round(deposit * 100); if (processedKeys[key]) { log("🔁 跳过已处理: " + name); continue; } log("🎯 发现目标: " + name + " | 在售¥" + deposit); if (item.click()) { processedKeys[key] = true; found = true; break; } else { log("❌ 点击失败"); } } if (!found) { log("📭 无新目标,上滑加载"); performBitmapClicks(); // 图像检测音乐提醒 if (swipeUp()) { sleep(1500); continue; } else { toastLog("🔚 已到底部,脚本结束"); break; } } // C: 进入详情页后执行磨损筛选 sleep(2000); var shouldRecord = processWearFilter(); if (shouldRecord) { var name = getTaskName(); var selling = getSellingPrice(); var category = getCurrentCategoryPrice(); var wear = getWearLevel(); var diff = round(selling - category, 2); if (selling && category && diff > 5) { writeToCSV({ name: name, wear: wear, category: category, selling: selling, diff: diff, time: formatDateTime() }); } } else { log("🟡 条件不满足,跳过记录"); } // 返回列表 back(); sleep(1500); } 用ES5语法修改auto脚本
11-11
package com.github.mikephil.charting.renderer; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Paint.Align; import android.graphics.Paint.Style; import android.graphics.Path; import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Layout; import android.text.StaticLayout; import android.text.TextPaint; import com.github.mikephil.charting.animation.ChartAnimator; import com.github.mikephil.charting.charts.LineChart; import com.github.mikephil.charting.charts.PieChart; import com.github.mikephil.charting.data.Entry; import com.github.mikephil.charting.data.PieData; import com.github.mikephil.charting.data.PieDataSet; import com.github.mikephil.charting.data.PieEntry; import com.github.mikephil.charting.formatter.ValueFormatter; import com.github.mikephil.charting.highlight.Highlight; import com.github.mikephil.charting.interfaces.datasets.IPieDataSet; import com.github.mikephil.charting.utils.ColorTemplate; import com.github.mikephil.charting.utils.MPPointF; import com.github.mikephil.charting.utils.Utils; import com.github.mikephil.charting.utils.ViewPortHandler; import java.lang.ref.WeakReference; import java.util.List; public class PieChartRenderer extends DataRenderer { protected PieChart mChart; /** * paint for the hole in the center of the pie chart and the transparent * circle */ protected Paint mHolePaint; protected Paint mTransparentCirclePaint; protected Paint mValueLinePaint; /** * paint object for the text that can be displayed in the center of the * chart */ private TextPaint mCenterTextPaint; /** * paint object used for drwing the slice-text */ private Paint mEntryLabelsPaint; private StaticLayout mCenterTextLayout; private CharSequence mCenterTextLastValue; private RectF mCenterTextLastBounds = new RectF(); private RectF[] mRectBuffer = {new RectF(), new RectF(), new RectF()}; /** * Bitmap for drawing the center hole */ protected WeakReference<Bitmap> mDrawBitmap; protected Canvas mBitmapCanvas; public PieChartRenderer(PieChart chart, ChartAnimator animator, ViewPortHandler viewPortHandler) { super(animator, viewPortHandler); mChart = chart; mHolePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mHolePaint.setColor(Color.WHITE); mHolePaint.setStyle(Style.FILL); mTransparentCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mTransparentCirclePaint.setColor(Color.WHITE); mTransparentCirclePaint.setStyle(Style.FILL); mTransparentCirclePaint.setAlpha(105); mCenterTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); mCenterTextPaint.setColor(Color.BLACK); mCenterTextPaint.setTextSize(Utils.convertDpToPixel(12f)); mValuePaint.setTextSize(Utils.convertDpToPixel(13f)); mValuePaint.setColor(Color.WHITE); mValuePaint.setTextAlign(Align.CENTER); mEntryLabelsPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mEntryLabelsPaint.setColor(Color.WHITE); mEntryLabelsPaint.setTextAlign(Align.CENTER); mEntryLabelsPaint.setTextSize(Utils.convertDpToPixel(13f)); mValueLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mValueLinePaint.setStyle(Style.STROKE); } public Paint getPaintHole() { return mHolePaint; } public Paint getPaintTransparentCircle() { return mTransparentCirclePaint; } public TextPaint getPaintCenterText() { return mCenterTextPaint; } public Paint getPaintEntryLabels() { return mEntryLabelsPaint; } @Override public void initBuffers() { // TODO Auto-generated method stub } @Override public void drawData(Canvas c) { int width = (int) mViewPortHandler.getChartWidth(); int height = (int) mViewPortHandler.getChartHeight(); Bitmap drawBitmap = mDrawBitmap == null ? null : mDrawBitmap.get(); if (drawBitmap == null || (drawBitmap.getWidth() != width) || (drawBitmap.getHeight() != height)) { if (width > 0 && height > 0) { drawBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_4444); mDrawBitmap = new WeakReference<>(drawBitmap); mBitmapCanvas = new Canvas(drawBitmap); } else return; } drawBitmap.eraseColor(Color.TRANSPARENT); PieData pieData = mChart.getData(); for (IPieDataSet set : pieData.getDataSets()) { if (set.isVisible() && set.getEntryCount() > 0) drawDataSet(c, set); } } private Path mPathBuffer = new Path(); private RectF mInnerRectBuffer = new RectF(); protected float calculateMinimumRadiusForSpacedSlice( MPPointF center, float radius, float angle, float arcStartPointX, float arcStartPointY, float startAngle, float sweepAngle) { final float angleMiddle = startAngle + sweepAngle / 2.f; // Other point of the arc float arcEndPointX = center.x + radius * (float) Math.cos((startAngle + sweepAngle) * Utils.FDEG2RAD); float arcEndPointY = center.y + radius * (float) Math.sin((startAngle + sweepAngle) * Utils.FDEG2RAD); // Middle point on the arc float arcMidPointX = center.x + radius * (float) Math.cos(angleMiddle * Utils.FDEG2RAD); float arcMidPointY = center.y + radius * (float) Math.sin(angleMiddle * Utils.FDEG2RAD); // This is the base of the contained triangle double basePointsDistance = Math.sqrt( Math.pow(arcEndPointX - arcStartPointX, 2) + Math.pow(arcEndPointY - arcStartPointY, 2)); // After reducing space from both sides of the "slice", // the angle of the contained triangle should stay the same. // So let's find out the height of that triangle. float containedTriangleHeight = (float) (basePointsDistance / 2.0 * Math.tan((180.0 - angle) / 2.0 * Utils.DEG2RAD)); // Now we subtract that from the radius float spacedRadius = radius - containedTriangleHeight; // And now subtract the height of the arc that's between the triangle and the outer circle spacedRadius -= Math.sqrt( Math.pow(arcMidPointX - (arcEndPointX + arcStartPointX) / 2.f, 2) + Math.pow(arcMidPointY - (arcEndPointY + arcStartPointY) / 2.f, 2)); return spacedRadius; } /** * Calculates the sliceSpace to use based on visible values and their size compared to the set sliceSpace. * * @param dataSet * @return */ protected float getSliceSpace(IPieDataSet dataSet) { if (!dataSet.isAutomaticallyDisableSliceSpacingEnabled()) return dataSet.getSliceSpace(); float spaceSizeRatio = dataSet.getSliceSpace() / mViewPortHandler.getSmallestContentExtension(); float minValueRatio = dataSet.getYMin() / mChart.getData().getYValueSum() * 2; float sliceSpace = spaceSizeRatio > minValueRatio ? 0f : dataSet.getSliceSpace(); return sliceSpace; } protected void drawDataSet(Canvas c, IPieDataSet dataSet) { float angle = 0; float rotationAngle = mChart.getRotationAngle(); float phaseX = mAnimator.getPhaseX(); float phaseY = mAnimator.getPhaseY(); final RectF circleBox = mChart.getCircleBox(); final int entryCount = dataSet.getEntryCount(); final float[] drawAngles = mChart.getDrawAngles(); final MPPointF center = mChart.getCenterCircleBox(); final float radius = mChart.getRadius(); final boolean drawInnerArc = mChart.isDrawHoleEnabled() && !mChart.isDrawSlicesUnderHoleEnabled(); final float userInnerRadius = drawInnerArc ? radius * (mChart.getHoleRadius() / 100.f) : 0.f; final float roundedRadius = (radius - (radius * mChart.getHoleRadius() / 100f)) / 2f; final RectF roundedCircleBox = new RectF(); final boolean drawRoundedSlices = drawInnerArc && mChart.isDrawRoundedSlicesEnabled(); int visibleAngleCount = 0; for (int j = 0; j < entryCount; j++) { // draw only if the value is greater than zero if ((Math.abs(dataSet.getEntryForIndex(j).getY()) > Utils.FLOAT_EPSILON)) { visibleAngleCount++; } } final float sliceSpace = visibleAngleCount <= 1 ? 0.f : getSliceSpace(dataSet); for (int j = 0; j < entryCount; j++) { float sliceAngle = drawAngles[j]; float innerRadius = userInnerRadius; Entry e = dataSet.getEntryForIndex(j); // draw only if the value is greater than zero if (!(Math.abs(e.getY()) > Utils.FLOAT_EPSILON)) { angle += sliceAngle * phaseX; continue; } // Don't draw if it's highlighted, unless the chart uses rounded slices if (mChart.needsHighlight(j) && !drawRoundedSlices) { angle += sliceAngle * phaseX; continue; } final boolean accountForSliceSpacing = sliceSpace > 0.f && sliceAngle <= 180.f; mRenderPaint.setColor(dataSet.getColor(j)); final float sliceSpaceAngleOuter = visibleAngleCount == 1 ? 0.f : sliceSpace / (Utils.FDEG2RAD * radius); final float startAngleOuter = rotationAngle + (angle + sliceSpaceAngleOuter / 2.f) * phaseY; float sweepAngleOuter = (sliceAngle - sliceSpaceAngleOuter) * phaseY; if (sweepAngleOuter < 0.f) { sweepAngleOuter = 0.f; } mPathBuffer.reset(); if (drawRoundedSlices) { float x = center.x + (radius - roundedRadius) * (float) Math.cos(startAngleOuter * Utils.FDEG2RAD); float y = center.y + (radius - roundedRadius) * (float) Math.sin(startAngleOuter * Utils.FDEG2RAD); roundedCircleBox.set(x - roundedRadius, y - roundedRadius, x + roundedRadius, y + roundedRadius); } float arcStartPointX = center.x + radius * (float) Math.cos(startAngleOuter * Utils.FDEG2RAD); float arcStartPointY = center.y + radius * (float) Math.sin(startAngleOuter * Utils.FDEG2RAD); if (sweepAngleOuter >= 360.f && sweepAngleOuter % 360f <= Utils.FLOAT_EPSILON) { // Android is doing "mod 360" mPathBuffer.addCircle(center.x, center.y, radius, Path.Direction.CW); } else { if (drawRoundedSlices) { mPathBuffer.arcTo(roundedCircleBox, startAngleOuter + 180, -180); } mPathBuffer.arcTo( circleBox, startAngleOuter, sweepAngleOuter ); } // API < 21 does not receive floats in addArc, but a RectF mInnerRectBuffer.set( center.x - innerRadius, center.y - innerRadius, center.x + innerRadius, center.y + innerRadius); if (drawInnerArc && (innerRadius > 0.f || accountForSliceSpacing)) { if (accountForSliceSpacing) { float minSpacedRadius = calculateMinimumRadiusForSpacedSlice( center, radius, sliceAngle * phaseY, arcStartPointX, arcStartPointY, startAngleOuter, sweepAngleOuter); if (minSpacedRadius < 0.f) minSpacedRadius = -minSpacedRadius; innerRadius = Math.max(innerRadius, minSpacedRadius); } final float sliceSpaceAngleInner = visibleAngleCount == 1 || innerRadius == 0.f ? 0.f : sliceSpace / (Utils.FDEG2RAD * innerRadius); final float startAngleInner = rotationAngle + (angle + sliceSpaceAngleInner / 2.f) * phaseY; float sweepAngleInner = (sliceAngle - sliceSpaceAngleInner) * phaseY; if (sweepAngleInner < 0.f) { sweepAngleInner = 0.f; } final float endAngleInner = startAngleInner + sweepAngleInner; if (sweepAngleOuter >= 360.f && sweepAngleOuter % 360f <= Utils.FLOAT_EPSILON) { // Android is doing "mod 360" mPathBuffer.addCircle(center.x, center.y, innerRadius, Path.Direction.CCW); } else { if (drawRoundedSlices) { float x = center.x + (radius - roundedRadius) * (float) Math.cos(endAngleInner * Utils.FDEG2RAD); float y = center.y + (radius - roundedRadius) * (float) Math.sin(endAngleInner * Utils.FDEG2RAD); roundedCircleBox.set(x - roundedRadius, y - roundedRadius, x + roundedRadius, y + roundedRadius); mPathBuffer.arcTo(roundedCircleBox, endAngleInner, 180); } else mPathBuffer.lineTo( center.x + innerRadius * (float) Math.cos(endAngleInner * Utils.FDEG2RAD), center.y + innerRadius * (float) Math.sin(endAngleInner * Utils.FDEG2RAD)); mPathBuffer.arcTo( mInnerRectBuffer, endAngleInner, -sweepAngleInner ); } } else { if (sweepAngleOuter % 360f > Utils.FLOAT_EPSILON) { if (accountForSliceSpacing) { float angleMiddle = startAngleOuter + sweepAngleOuter / 2.f; float sliceSpaceOffset = calculateMinimumRadiusForSpacedSlice( center, radius, sliceAngle * phaseY, arcStartPointX, arcStartPointY, startAngleOuter, sweepAngleOuter); float arcEndPointX = center.x + sliceSpaceOffset * (float) Math.cos(angleMiddle * Utils.FDEG2RAD); float arcEndPointY = center.y + sliceSpaceOffset * (float) Math.sin(angleMiddle * Utils.FDEG2RAD); mPathBuffer.lineTo( arcEndPointX, arcEndPointY); } else { mPathBuffer.lineTo( center.x, center.y); } } } mPathBuffer.close(); mBitmapCanvas.drawPath(mPathBuffer, mRenderPaint); angle += sliceAngle * phaseX; } MPPointF.recycleInstance(center); } @Override public void drawValues(Canvas c) { MPPointF center = mChart.getCenterCircleBox(); // get whole the radius float radius = mChart.getRadius(); float rotationAngle = mChart.getRotationAngle(); float[] drawAngles = mChart.getDrawAngles(); float[] absoluteAngles = mChart.getAbsoluteAngles(); float phaseX = mAnimator.getPhaseX(); float phaseY = mAnimator.getPhaseY(); final float roundedRadius = (radius - (radius * mChart.getHoleRadius() / 100f)) / 2f; final float holeRadiusPercent = mChart.getHoleRadius() / 100.f; float labelRadiusOffset = radius / 10f * 3.6f; if (mChart.isDrawHoleEnabled()) { labelRadiusOffset = (radius - (radius * holeRadiusPercent)) / 2f; if (!mChart.isDrawSlicesUnderHoleEnabled() && mChart.isDrawRoundedSlicesEnabled()) { // Add curved circle slice and spacing to rotation angle, so that it sits nicely inside rotationAngle += roundedRadius * 360 / (Math.PI * 2 * radius); } } final float labelRadius = radius - labelRadiusOffset; PieData data = mChart.getData(); List<IPieDataSet> dataSets = data.getDataSets(); float yValueSum = data.getYValueSum(); boolean drawEntryLabels = mChart.isDrawEntryLabelsEnabled(); float angle; int xIndex = 0; c.save(); float offset = Utils.convertDpToPixel(5.f); for (int i = 0; i < dataSets.size(); i++) { IPieDataSet dataSet = dataSets.get(i); final boolean drawValues = dataSet.isDrawValuesEnabled(); if (!drawValues && !drawEntryLabels) continue; final PieDataSet.ValuePosition xValuePosition = dataSet.getXValuePosition(); final PieDataSet.ValuePosition yValuePosition = dataSet.getYValuePosition(); // apply the text-styling defined by the DataSet applyValueTextStyle(dataSet); float lineHeight = Utils.calcTextHeight(mValuePaint, "Q") + Utils.convertDpToPixel(4f); ValueFormatter formatter = dataSet.getValueFormatter(); int entryCount = dataSet.getEntryCount(); mValueLinePaint.setColor(dataSet.getValueLineColor()); mValueLinePaint.setStrokeWidth(Utils.convertDpToPixel(dataSet.getValueLineWidth())); final float sliceSpace = getSliceSpace(dataSet); MPPointF iconsOffset = MPPointF.getInstance(dataSet.getIconsOffset()); iconsOffset.x = Utils.convertDpToPixel(iconsOffset.x); iconsOffset.y = Utils.convertDpToPixel(iconsOffset.y); for (int j = 0; j < entryCount; j++) { PieEntry entry = dataSet.getEntryForIndex(j); if (xIndex == 0) angle = 0.f; else angle = absoluteAngles[xIndex - 1] * phaseX; final float sliceAngle = drawAngles[xIndex]; final float sliceSpaceMiddleAngle = sliceSpace / (Utils.FDEG2RAD * labelRadius); // offset needed to center the drawn text in the slice final float angleOffset = (sliceAngle - sliceSpaceMiddleAngle / 2.f) / 2.f; angle = angle + angleOffset; final float transformedAngle = rotationAngle + angle * phaseY; float value = mChart.isUsePercentValuesEnabled() ? entry.getY() / yValueSum * 100f : entry.getY(); String formattedValue = formatter.getPieLabel(value, entry); String entryLabel = entry.getLabel(); final float sliceXBase = (float) Math.cos(transformedAngle * Utils.FDEG2RAD); final float sliceYBase = (float) Math.sin(transformedAngle * Utils.FDEG2RAD); final boolean drawXOutside = drawEntryLabels && xValuePosition == PieDataSet.ValuePosition.OUTSIDE_SLICE; final boolean drawYOutside = drawValues && yValuePosition == PieDataSet.ValuePosition.OUTSIDE_SLICE; final boolean drawXInside = drawEntryLabels && xValuePosition == PieDataSet.ValuePosition.INSIDE_SLICE; final boolean drawYInside = drawValues && yValuePosition == PieDataSet.ValuePosition.INSIDE_SLICE; if (drawXOutside || drawYOutside) { final float valueLineLength1 = dataSet.getValueLinePart1Length(); final float valueLineLength2 = dataSet.getValueLinePart2Length(); final float valueLinePart1OffsetPercentage = dataSet.getValueLinePart1OffsetPercentage() / 100.f; float pt2x, pt2y; float labelPtx, labelPty; float line1Radius; if (mChart.isDrawHoleEnabled()) line1Radius = (radius - (radius * holeRadiusPercent)) * valueLinePart1OffsetPercentage + (radius * holeRadiusPercent); else line1Radius = radius * valueLinePart1OffsetPercentage; final float polyline2Width = dataSet.isValueLineVariableLength() ? labelRadius * valueLineLength2 * (float) Math.abs(Math.sin( transformedAngle * Utils.FDEG2RAD)) : labelRadius * valueLineLength2; final float pt0x = line1Radius * sliceXBase + center.x; final float pt0y = line1Radius * sliceYBase + center.y; final float pt1x = labelRadius * (1 + valueLineLength1) * sliceXBase + center.x; final float pt1y = labelRadius * (1 + valueLineLength1) * sliceYBase + center.y; if (transformedAngle % 360.0 >= 90.0 && transformedAngle % 360.0 <= 270.0) { pt2x = pt1x - polyline2Width; pt2y = pt1y; mValuePaint.setTextAlign(Align.RIGHT); if(drawXOutside) mEntryLabelsPaint.setTextAlign(Align.RIGHT); labelPtx = pt2x - offset; labelPty = pt2y; } else { pt2x = pt1x + polyline2Width; pt2y = pt1y; mValuePaint.setTextAlign(Align.LEFT); if(drawXOutside) mEntryLabelsPaint.setTextAlign(Align.LEFT); labelPtx = pt2x + offset; labelPty = pt2y; } if (dataSet.getValueLineColor() != ColorTemplate.COLOR_NONE) { if (dataSet.isUsingSliceColorAsValueLineColor()) { mValueLinePaint.setColor(dataSet.getColor(j)); } c.drawLine(pt0x, pt0y, pt1x, pt1y, mValueLinePaint); c.drawLine(pt1x, pt1y, pt2x, pt2y, mValueLinePaint); } // draw everything, depending on settings if (drawXOutside && drawYOutside) { drawValue(c, formattedValue, labelPtx, labelPty, dataSet.getValueTextColor(j)); if (j < data.getEntryCount() && entryLabel != null) { drawEntryLabel(c, entryLabel, labelPtx, labelPty + lineHeight); } } else if (drawXOutside) { if (j < data.getEntryCount() && entryLabel != null) { drawEntryLabel(c, entryLabel, labelPtx, labelPty + lineHeight / 2.f); } } else if (drawYOutside) { drawValue(c, formattedValue, labelPtx, labelPty + lineHeight / 2.f, dataSet.getValueTextColor(j)); } } if (drawXInside || drawYInside) { // calculate the text position float x = labelRadius * sliceXBase + center.x; float y = labelRadius * sliceYBase + center.y; mValuePaint.setTextAlign(Align.CENTER); // draw everything, depending on settings if (drawXInside && drawYInside) { drawValue(c, formattedValue, x, y, dataSet.getValueTextColor(j)); if (j < data.getEntryCount() && entryLabel != null) { drawEntryLabel(c, entryLabel, x, y + lineHeight); } } else if (drawXInside) { if (j < data.getEntryCount() && entryLabel != null) { drawEntryLabel(c, entryLabel, x, y + lineHeight / 2f); } } else if (drawYInside) { drawValue(c, formattedValue, x, y + lineHeight / 2f, dataSet.getValueTextColor(j)); } } if (entry.getIcon() != null && dataSet.isDrawIconsEnabled()) { Drawable icon = entry.getIcon(); float x = (labelRadius + iconsOffset.y) * sliceXBase + center.x; float y = (labelRadius + iconsOffset.y) * sliceYBase + center.y; y += iconsOffset.x; Utils.drawImage( c, icon, (int)x, (int)y, icon.getIntrinsicWidth(), icon.getIntrinsicHeight()); } xIndex++; } MPPointF.recycleInstance(iconsOffset); } MPPointF.recycleInstance(center); c.restore(); } @Override public void drawValue(Canvas c, String valueText, float x, float y, int color) { mValuePaint.setColor(color); c.drawText(valueText, x, y, mValuePaint); } /** * Draws an entry label at the specified position. * * @param c * @param label * @param x * @param y */ protected void drawEntryLabel(Canvas c, String label, float x, float y) { c.drawText(label, x, y, mEntryLabelsPaint); } @Override public void drawExtras(Canvas c) { drawHole(c); c.drawBitmap(mDrawBitmap.get(), 0, 0, null); drawCenterText(c); } private Path mHoleCirclePath = new Path(); /** * draws the hole in the center of the chart and the transparent circle / * hole */ protected void drawHole(Canvas c) { if (mChart.isDrawHoleEnabled() && mBitmapCanvas != null) { float radius = mChart.getRadius(); float holeRadius = radius * (mChart.getHoleRadius() / 100); MPPointF center = mChart.getCenterCircleBox(); if (Color.alpha(mHolePaint.getColor()) > 0) { // draw the hole-circle mBitmapCanvas.drawCircle( center.x, center.y, holeRadius, mHolePaint); } // only draw the circle if it can be seen (not covered by the hole) if (Color.alpha(mTransparentCirclePaint.getColor()) > 0 && mChart.getTransparentCircleRadius() > mChart.getHoleRadius()) { int alpha = mTransparentCirclePaint.getAlpha(); float secondHoleRadius = radius * (mChart.getTransparentCircleRadius() / 100); mTransparentCirclePaint.setAlpha((int) ((float) alpha * mAnimator.getPhaseX() * mAnimator.getPhaseY())); // draw the transparent-circle mHoleCirclePath.reset(); mHoleCirclePath.addCircle(center.x, center.y, secondHoleRadius, Path.Direction.CW); mHoleCirclePath.addCircle(center.x, center.y, holeRadius, Path.Direction.CCW); mBitmapCanvas.drawPath(mHoleCirclePath, mTransparentCirclePaint); // reset alpha mTransparentCirclePaint.setAlpha(alpha); } MPPointF.recycleInstance(center); } } protected Path mDrawCenterTextPathBuffer = new Path(); /** * draws the description text in the center of the pie chart makes most * sense when center-hole is enabled */ protected void drawCenterText(Canvas c) { CharSequence centerText = mChart.getCenterText(); if (mChart.isDrawCenterTextEnabled() && centerText != null) { MPPointF center = mChart.getCenterCircleBox(); MPPointF offset = mChart.getCenterTextOffset(); float x = center.x + offset.x; float y = center.y + offset.y; float innerRadius = mChart.isDrawHoleEnabled() && !mChart.isDrawSlicesUnderHoleEnabled() ? mChart.getRadius() * (mChart.getHoleRadius() / 100f) : mChart.getRadius(); RectF holeRect = mRectBuffer[0]; holeRect.left = x - innerRadius; holeRect.top = y - innerRadius; holeRect.right = x + innerRadius; holeRect.bottom = y + innerRadius; RectF boundingRect = mRectBuffer[1]; boundingRect.set(holeRect); float radiusPercent = mChart.getCenterTextRadiusPercent() / 100f; if (radiusPercent > 0.0) { boundingRect.inset( (boundingRect.width() - boundingRect.width() * radiusPercent) / 2.f, (boundingRect.height() - boundingRect.height() * radiusPercent) / 2.f ); } if (!centerText.equals(mCenterTextLastValue) || !boundingRect.equals(mCenterTextLastBounds)) { // Next time we won't recalculate StaticLayout... mCenterTextLastBounds.set(boundingRect); mCenterTextLastValue = centerText; float width = mCenterTextLastBounds.width(); // If width is 0, it will crash. Always have a minimum of 1 mCenterTextLayout = new StaticLayout(centerText, 0, centerText.length(), mCenterTextPaint, (int) Math.max(Math.ceil(width), 1.f), Layout.Alignment.ALIGN_CENTER, 1.f, 0.f, false); } //float layoutWidth = Utils.getStaticLayoutMaxWidth(mCenterTextLayout); float layoutHeight = mCenterTextLayout.getHeight(); c.save(); if (Build.VERSION.SDK_INT >= 18) { Path path = mDrawCenterTextPathBuffer; path.reset(); path.addOval(holeRect, Path.Direction.CW); c.clipPath(path); } c.translate(boundingRect.left, boundingRect.top + (boundingRect.height() - layoutHeight) / 2.f); mCenterTextLayout.draw(c); c.restore(); MPPointF.recycleInstance(center); MPPointF.recycleInstance(offset); } } protected RectF mDrawHighlightedRectF = new RectF(); @Override public void drawHighlighted(Canvas c, Highlight[] indices) { /* Skip entirely if using rounded circle slices, because it doesn't make sense to highlight * in this way. * TODO: add support for changing slice color with highlighting rather than only shifting the slice */ final boolean drawInnerArc = mChart.isDrawHoleEnabled() && !mChart.isDrawSlicesUnderHoleEnabled(); if (drawInnerArc && mChart.isDrawRoundedSlicesEnabled()) return; float phaseX = mAnimator.getPhaseX(); float phaseY = mAnimator.getPhaseY(); float angle; float rotationAngle = mChart.getRotationAngle(); float[] drawAngles = mChart.getDrawAngles(); float[] absoluteAngles = mChart.getAbsoluteAngles(); final MPPointF center = mChart.getCenterCircleBox(); final float radius = mChart.getRadius(); final float userInnerRadius = drawInnerArc ? radius * (mChart.getHoleRadius() / 100.f) : 0.f; final RectF highlightedCircleBox = mDrawHighlightedRectF; highlightedCircleBox.set(0,0,0,0); for (int i = 0; i < indices.length; i++) { // get the index to highlight int index = (int) indices[i].getX(); if (index >= drawAngles.length) continue; IPieDataSet set = mChart.getData() .getDataSetByIndex(indices[i] .getDataSetIndex()); if (set == null || !set.isHighlightEnabled()) continue; final int entryCount = set.getEntryCount(); int visibleAngleCount = 0; for (int j = 0; j < entryCount; j++) { // draw only if the value is greater than zero if ((Math.abs(set.getEntryForIndex(j).getY()) > Utils.FLOAT_EPSILON)) { visibleAngleCount++; } } if (index == 0) angle = 0.f; else angle = absoluteAngles[index - 1] * phaseX; final float sliceSpace = visibleAngleCount <= 1 ? 0.f : set.getSliceSpace(); float sliceAngle = drawAngles[index]; float innerRadius = userInnerRadius; float shift = set.getSelectionShift(); final float highlightedRadius = radius + shift; highlightedCircleBox.set(mChart.getCircleBox()); highlightedCircleBox.inset(-shift, -shift); final boolean accountForSliceSpacing = sliceSpace > 0.f && sliceAngle <= 180.f; mRenderPaint.setColor(set.getColor(index)); final float sliceSpaceAngleOuter = visibleAngleCount == 1 ? 0.f : sliceSpace / (Utils.FDEG2RAD * radius); final float sliceSpaceAngleShifted = visibleAngleCount == 1 ? 0.f : sliceSpace / (Utils.FDEG2RAD * highlightedRadius); final float startAngleOuter = rotationAngle + (angle + sliceSpaceAngleOuter / 2.f) * phaseY; float sweepAngleOuter = (sliceAngle - sliceSpaceAngleOuter) * phaseY; if (sweepAngleOuter < 0.f) { sweepAngleOuter = 0.f; } final float startAngleShifted = rotationAngle + (angle + sliceSpaceAngleShifted / 2.f) * phaseY; float sweepAngleShifted = (sliceAngle - sliceSpaceAngleShifted) * phaseY; if (sweepAngleShifted < 0.f) { sweepAngleShifted = 0.f; } mPathBuffer.reset(); if (sweepAngleOuter >= 360.f && sweepAngleOuter % 360f <= Utils.FLOAT_EPSILON) { // Android is doing "mod 360" mPathBuffer.addCircle(center.x, center.y, highlightedRadius, Path.Direction.CW); } else { mPathBuffer.moveTo( center.x + highlightedRadius * (float) Math.cos(startAngleShifted * Utils.FDEG2RAD), center.y + highlightedRadius * (float) Math.sin(startAngleShifted * Utils.FDEG2RAD)); mPathBuffer.arcTo( highlightedCircleBox, startAngleShifted, sweepAngleShifted ); } float sliceSpaceRadius = 0.f; if (accountForSliceSpacing) { sliceSpaceRadius = calculateMinimumRadiusForSpacedSlice( center, radius, sliceAngle * phaseY, center.x + radius * (float) Math.cos(startAngleOuter * Utils.FDEG2RAD), center.y + radius * (float) Math.sin(startAngleOuter * Utils.FDEG2RAD), startAngleOuter, sweepAngleOuter); } // API < 21 does not receive floats in addArc, but a RectF mInnerRectBuffer.set( center.x - innerRadius, center.y - innerRadius, center.x + innerRadius, center.y + innerRadius); if (drawInnerArc && (innerRadius > 0.f || accountForSliceSpacing)) { if (accountForSliceSpacing) { float minSpacedRadius = sliceSpaceRadius; if (minSpacedRadius < 0.f) minSpacedRadius = -minSpacedRadius; innerRadius = Math.max(innerRadius, minSpacedRadius); } final float sliceSpaceAngleInner = visibleAngleCount == 1 || innerRadius == 0.f ? 0.f : sliceSpace / (Utils.FDEG2RAD * innerRadius); final float startAngleInner = rotationAngle + (angle + sliceSpaceAngleInner / 2.f) * phaseY; float sweepAngleInner = (sliceAngle - sliceSpaceAngleInner) * phaseY; if (sweepAngleInner < 0.f) { sweepAngleInner = 0.f; } final float endAngleInner = startAngleInner + sweepAngleInner; if (sweepAngleOuter >= 360.f && sweepAngleOuter % 360f <= Utils.FLOAT_EPSILON) { // Android is doing "mod 360" mPathBuffer.addCircle(center.x, center.y, innerRadius, Path.Direction.CCW); } else { mPathBuffer.lineTo( center.x + innerRadius * (float) Math.cos(endAngleInner * Utils.FDEG2RAD), center.y + innerRadius * (float) Math.sin(endAngleInner * Utils.FDEG2RAD)); mPathBuffer.arcTo( mInnerRectBuffer, endAngleInner, -sweepAngleInner ); } } else { if (sweepAngleOuter % 360f > Utils.FLOAT_EPSILON) { if (accountForSliceSpacing) { final float angleMiddle = startAngleOuter + sweepAngleOuter / 2.f; final float arcEndPointX = center.x + sliceSpaceRadius * (float) Math.cos(angleMiddle * Utils.FDEG2RAD); final float arcEndPointY = center.y + sliceSpaceRadius * (float) Math.sin(angleMiddle * Utils.FDEG2RAD); mPathBuffer.lineTo( arcEndPointX, arcEndPointY); } else { mPathBuffer.lineTo( center.x, center.y); } } } mPathBuffer.close(); mBitmapCanvas.drawPath(mPathBuffer, mRenderPaint); } MPPointF.recycleInstance(center); } /** * This gives all pie-slices a rounded edge. * * @param c */ protected void drawRoundedSlices(Canvas c) { if (!mChart.isDrawRoundedSlicesEnabled()) return; IPieDataSet dataSet = mChart.getData().getDataSet(); if (!dataSet.isVisible()) return; float phaseX = mAnimator.getPhaseX(); float phaseY = mAnimator.getPhaseY(); MPPointF center = mChart.getCenterCircleBox(); float r = mChart.getRadius(); // calculate the radius of the "slice-circle" float circleRadius = (r - (r * mChart.getHoleRadius() / 100f)) / 2f; float[] drawAngles = mChart.getDrawAngles(); float angle = mChart.getRotationAngle(); for (int j = 0; j < dataSet.getEntryCount(); j++) { float sliceAngle = drawAngles[j]; Entry e = dataSet.getEntryForIndex(j); // draw only if the value is greater than zero if ((Math.abs(e.getY()) > Utils.FLOAT_EPSILON)) { float x = (float) ((r - circleRadius) * Math.cos(Math.toRadians((angle + sliceAngle) * phaseY)) + center.x); float y = (float) ((r - circleRadius) * Math.sin(Math.toRadians((angle + sliceAngle) * phaseY)) + center.y); mRenderPaint.setColor(dataSet.getColor(j)); mBitmapCanvas.drawCircle(x, y, circleRadius, mRenderPaint); } angle += sliceAngle * phaseX; } MPPointF.recycleInstance(center); } /** * Releases the drawing bitmap. This should be called when {@link LineChart#onDetachedFromWindow()}. */ public void releaseBitmap() { if (mBitmapCanvas != null) { mBitmapCanvas.setBitmap(null); mBitmapCanvas = null; } if (mDrawBitmap != null) { Bitmap drawBitmap = mDrawBitmap.get(); if (drawBitmap != null) { drawBitmap.recycle(); } mDrawBitmap.clear(); mDrawBitmap = null; } } } 解释这个代码
12-01
请作为资深开发工程师,解释我给出的代码。请逐行分析我的代码并给出你对这段代码的理解。 我给出的代码是: import android.animation.ValueAnimator import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.RectF import android.util.AttributeSet import android.view.MotionEvent import android.view.View import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.ro.common.PrefUtils.getSharedPreferences import com.ro.settings.R import kotlin.math.pow import kotlin.math.sqrt /** * @author: Karl * @date: 2025/12/5 * @description:风扇智能模式温控曲线图,用户可以自定义。 */ class FanTempControlCurveView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { companion object { const val FAN_TEMP_CONTROL_CURVE_POINT_KEY = "fan_temp_control_curve_point_key" const val FAN_TEMP_CONTROL_CURVE_UNIT = "fan_temperature_control_curve_unit" val fixedXTemps = listOf(0, 25, 45, 65, 85, 105, Int.MAX_VALUE) @JvmStatic fun getLocalTempFanSpeedConfiguration() : List<FanPoint> { val sharedPreferences = getSharedPreferences() val pointsJson = sharedPreferences.getString(FAN_TEMP_CONTROL_CURVE_POINT_KEY, null) pointsJson?.let { val type = object : TypeToken<List<FanPoint>>() {}.type val points = Gson().fromJson<List<FanPoint>>(it, type) return points } //返回默认值 return mutableListOf<FanPoint>().apply { fixedXTemps.forEachIndexed { i, temp -> // add(FanPoint(temp, (i + 1) * 20)) when (temp) { 0 -> add(FanPoint(temp, 20)) 25 -> add(FanPoint(temp, 20)) 45 -> add(FanPoint(temp, 30)) 65 -> add(FanPoint(temp, 45)) 85 -> add(FanPoint(temp, 75)) in 105..Int.MAX_VALUE -> add(FanPoint(temp, 100)) } } } } /** * 根据温度获取对应的风扇速度 * @param temp 温度值(摄氏度) * @return 风扇速度百分比(0-100) */ @JvmStatic fun getFanSpeed(temp: Float, points: List<FanPoint>): Float { // 边界处理:低于25°按0°处理 if (temp < points[1].x) return 0f // 105°以上使用水平直线段 if (temp > 105) return points[points.size - 2].y.toFloat() // 确定温度所在的区间 (0-25, 25-45, 45-65, 65-85, 85-105) val segment = when { temp <= 25 -> 0 temp <= 45 -> 1 temp <= 65 -> 2 temp <= 85 -> 3 else -> 4 } // 获取当前区间的两个端点 val startPoint = points[segment] val endPoint = points[segment + 1] // 计算当前温度在区间内的归一化位置 (0-1) val segmentStartTemp = startPoint.x.toFloat() val segmentEndTemp = endPoint.x.toFloat() val t = (temp - segmentStartTemp) / (segmentEndTemp - segmentStartTemp) // 使用三次贝塞尔曲线公式计算风扇速度 return calculateBezierValue( startPoint.y.toFloat(), startPoint.y.toFloat(), // 控制点1使用起点Y值 endPoint.y.toFloat(), // 控制点2使用终点Y值 endPoint.y.toFloat(), t ) } /** * 计算三次贝塞尔曲线上的值 * @param y0 起点Y值 * @param y1 控制点1Y值 * @param y2 控制点2Y值 * @param y3 终点Y值 * @param t 曲线参数 (0-1) * @return 曲线上对应t的Y值 */ private fun calculateBezierValue(y0: Float, y1: Float, y2: Float, y3: Float, t: Float): Float { val u = 1 - t val tt = t * t val uu = u * u val uuu = uu * u val ttt = tt * t // 三次贝塞尔曲线公式: // B(t) = (1-t)^3 * P0 + 3(1-t)^2*t * P1 + 3(1-t)*t^2 * P2 + t^3 * P3 return (uuu * y0) + (3 * uu * t * y1) + (3 * u * tt * y2) + (ttt * y3) } } // 温度点定义 (0,25,45,65,85,105,∞) private var points = mutableListOf<FanPoint>().apply { fixedXTemps.forEachIndexed { i, temp -> add(FanPoint(temp, /*(i + 1) * 20*/ 20)) // 默认Y值 } } private var isCelsius = false private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val path = Path() private var pointRadius = 24f private var textSize = 36f private val animators = mutableMapOf<Int, ValueAnimator>() // 坐标系统参数 private var marginLR = 140f private var marginTB = 70f private var axisWidth = 8f private var arrowSize = 15f private var scaleX = 1f private var scaleY = 1f private var maxY = 100 // 最大风扇转速百分比 private var activePointIndex = -1 private val minY = 20 // 最小Y值限制 private var axisColor = 0 private var curveColor = 0 private var pointColor = 0 private var pointColorPressed = 0 var fanTempControlCurveViewListener : FanTempControlCurveViewListener? = null init { context.obtainStyledAttributes(intArrayOf(android.R.attr.colorAccent, android.R.attr.textColorPrimary, android.R.attr.colorPressedHighlight)).apply { pointColor = getColor(0, 0) pointColorPressed = getColor(2, 0) curveColor = getColor(1, 0) axisColor = getColor(1, 0) recycle() } } private val valueTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK textAlign = Paint.Align.CENTER } private val textBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE style = Paint.Style.FILL } private val textBackgroundRect = RectF() private val yAxesMeans = context.getString(R.string.text_fan_speed) private val xAxesMeans = context.getString(R.string.text_temperature) private fun initSizeParam(width : Int, height : Int) { pointRadius = height * 0.035f textSize = height * 0.0525f marginLR = height * 0.2f marginTB = height * 0.15f axisWidth = height * 0.0115f arrowSize = height * 0.0218f valueTextPaint.textSize = height * 0.04f } fun setTemperatureUnit(isFahrenheit: Boolean) { isCelsius = !isFahrenheit invalidate() } fun setPoints(newPoints: List<FanPoint>) { if (newPoints.size != points.size) return // 停止所有进行中的动画 animators.values.forEach { it.cancel() } animators.clear() // 创建新动画 newPoints.forEachIndexed { index, newPoint -> if (index != 0 && index != points.lastIndex) { // 跳过0和∞ val animator = ValueAnimator.ofFloat(points[index].y.toFloat(), newPoint.y.toFloat()) animator.duration = 300 animator.addUpdateListener { animation -> points[index] = points[index].copy(y = (animation.animatedValue as Float).toInt()) invalidate() } animator.start() animators[index] = animator } else { points[index] = newPoint } } invalidate() } fun getPoints() = points.toList() override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) initSizeParam(measuredWidth, measuredHeight) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) scaleX = (width - 2 * marginLR) / fixedXTemps.lastIndex.toFloat() scaleY = (height - 2 * marginTB) / maxY } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) drawAxes(canvas) drawCurve(canvas) drawPoints(canvas) drawValueOfYForPoints(canvas) } private fun drawAxes(canvas: Canvas) { // 绘制X轴 paint.color = axisColor paint.strokeWidth = axisWidth paint.textSize = textSize val extensionLengthOfTheYArrow = marginTB * 0.6f // 绘制Y轴 canvas.drawLine(marginLR, height - marginTB, marginLR, extensionLengthOfTheYArrow, paint) val yAxesMeansTextLength = paint.measureText(yAxesMeans) val yAxesMeansTextHeight = paint.descent() - paint.ascent() val space = paint.textSize * 0.35f //绘制Y轴表示 canvas.drawText(yAxesMeans, (marginLR * 2 + yAxesMeansTextLength) / 2, extensionLengthOfTheYArrow - yAxesMeansTextHeight / 2 - (paint.ascent() + paint.descent()) / 2 - space, paint) // 绘制Y轴箭头 canvas.drawLine(marginLR, extensionLengthOfTheYArrow, marginLR - arrowSize, extensionLengthOfTheYArrow + arrowSize, paint) canvas.drawLine(marginLR, extensionLengthOfTheYArrow, marginLR + arrowSize, extensionLengthOfTheYArrow + arrowSize, paint) // 绘制X轴 canvas.drawLine(marginLR, height - marginTB, width - marginLR, height - marginTB, paint) val xAxesMeansTextLength = paint.measureText(xAxesMeans) //绘制X轴表示 canvas.drawText(xAxesMeans, width - marginLR + xAxesMeansTextLength / 2, height - marginTB / 2 + space, paint) // 绘制X轴箭头 canvas.drawLine(width - marginLR, height - marginTB, width - marginLR - arrowSize, height - marginTB - arrowSize, paint) canvas.drawLine(width - marginLR, height - marginTB, width - marginLR - arrowSize, height - marginTB + arrowSize, paint) // 绘制刻度 paint.textAlign = Paint.Align.CENTER fixedXTemps.forEachIndexed { i, temp -> if (i != 0 && i != fixedXTemps.lastIndex) { val xPos = marginLR + i * scaleX val label = if (isCelsius) "$temp°C" else "${celsiusToFahrenheit(temp)}°F" canvas.drawText(label, xPos, height - marginTB / 2, paint) } } // 绘制Y轴标签 paint.textAlign = Paint.Align.RIGHT for (i in 0..5) { val yValue = i * 20 val yPos = height - marginTB - yValue * scaleY canvas.drawText("$yValue%", marginLR - 10, yPos + textSize / 3, paint) } } private fun drawCurve(canvas: Canvas) { path.reset() paint.style = Paint.Style.STROKE paint.strokeCap = (Paint.Cap.ROUND) paint.strokeJoin = Paint.Join.ROUND paint.strokeWidth = 5f paint.color = curveColor // 绘制0°到105°的曲线(前5个点) for (i in 0..(points.size - 3)) { // 0-25, 25-45, 45-65, 65-85, 85-105 val p0 = points[i] val p1 = points[i + 1] val x0 = marginLR + i * scaleX val y0 = height - marginTB - p0.y * scaleY val x1 = marginLR + (i + 1) * scaleX val y1 = height - marginTB - p1.y * scaleY if (i == 0) { path.moveTo(marginLR + 1 * scaleX, height - marginTB) path.lineTo(x1, y1) } else { // 计算控制点 val ctrlX = (x0 + x1) / 2 path.cubicTo( ctrlX, y0, ctrlX, y1, x1, y1 ) } } // 从105°到+∞绘制水平直线 val lastY = height - marginTB - points[points.size - 2].y * scaleY // 105°对应的Y坐标 val infinityX = marginLR + (points.size - 1) * scaleX // +∞的位置 path.lineTo(infinityX, lastY) // 水平直线 canvas.drawPath(path, paint) } private fun drawPoints(canvas: Canvas) { paint.style = Paint.Style.FILL points.forEachIndexed { i, point -> if (i != 0 && i != points.lastIndex) { // 跳过0和∞ val x = marginLR + i * scaleX val y = height - marginTB - point.y * scaleY paint.color = if (i == activePointIndex) pointColorPressed else pointColor canvas.drawCircle(x, y, pointRadius, paint) } } } private fun drawValueOfYForPoints(canvas: Canvas) { // 为每个可拖动点绘制Y值 for (i in 1..(points.size - 2)) { // 只绘制25°-105°的点 val point = points[i] val x = marginLR + i * scaleX val y = height - marginTB - point.y * scaleY // 绘制文本背景 val text = "${point.y}%" val textWidth = valueTextPaint.measureText(text) val textHeight = valueTextPaint.descent() - valueTextPaint.ascent() val textPadding = textHeight * 0.3f textBackgroundRect.set( x - textWidth / 2 - textPadding, y - pointRadius - textHeight - textPadding * 2, x + textWidth / 2 + textPadding, y - pointRadius - textPadding ) // 绘制圆角矩形背景 canvas.drawRoundRect(textBackgroundRect, 8f, 8f, textBackgroundPaint) // 绘制文本 canvas.drawText(text, x, (textBackgroundRect.bottom - textBackgroundRect.height() / 2) - (valueTextPaint.ascent() + valueTextPaint.descent()) / 2, valueTextPaint) } } override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { activePointIndex = findNearestPoint(event.x, event.y) return activePointIndex != -1 } MotionEvent.ACTION_MOVE -> { if (activePointIndex in 1..5) { // 只允许拖动25°-105°的点 // 计算新的Y值,并限制最小值为20% var newY = maxY - ((event.y - marginTB) / scaleY).toInt() newY = newY.coerceAtLeast(minY).coerceAtMost(maxY) points[activePointIndex] = points[activePointIndex].copy(y = newY) // 同步更新+∞点的Y值(与105°相同) if (activePointIndex == 5) { points[6] = points[6].copy(y = newY) } invalidate() } return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { activePointIndex = -1 invalidate() fanTempControlCurveViewListener?.let { it.onControlPointChanged(points) } return true } } return super.onTouchEvent(event) } private fun findNearestPoint(x: Float, y: Float): Int { var minDist = Float.MAX_VALUE var foundIndex = -1 for (i in 1 until points.lastIndex) { val pointX = marginLR + i * scaleX val pointY = height - marginTB - points[i].y * scaleY val dist = sqrt((x - pointX).pow(2) + (y - pointY).pow(2)) if (dist < minDist && dist < pointRadius * 2) { minDist = dist foundIndex = i } } return foundIndex } fun celsiusToFahrenheit(c: Int) = (c * 9 / 5) + 32 data class FanPoint(val x: Int, val y: Int) interface FanTempControlCurveViewListener{ fun onControlPointChanged(points : List<FanPoint>) } }
最新发布
12-11
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值