simpleperf、Flame Graph使用简介

目录

背景

simpleperf简介

Simpleperf使用

将Simpleperf工具的可执行程序 push 到手机上

启动手机上的被测程序,ps 出该程序的进程ID

记录运行结果数据perf.data

报告结果数据:将data转为txt

将手机的文件pull到电脑指定路径

使用脚本report_html.py将data转为html文件

下载FlameGraph

生成火焰图

火焰图(Flame Graph)

简介

3.2-火焰图查看方法

3.2.1-鼠标悬浮

3.2.2-点击放大

3.2.3-搜索

demo参考

参考

拓展


背景

在实际的工程中,可以使用simpleperf分析camera gpu内存泄露和GPU内存拆解。一般camera gpu泄露问题总体不太容易定位,在代码层面上,gpu memory的分配和消费涉及到camera app、camera provider和camera server 众多业务逻辑及gpu驱动的memory management。但是如果我们抓住内存分配和回收在内核中的末端接口,从下往上dump 出所有alloc和free的调用,通过对比是可以定位出在哪里出现了alloc但是没有free的情况,也就是可以找到泄漏点,如果能找到泄露点,自然距离解决问题就不远了。

simpleperf简介

        为了达成上述的目的,我们需要一个工具可以追踪gpu驱动kgsl 中定义的关于内存的tracepoints,也就是kgsl_mem_alloc、kgsl_mem_free、kgsl_mem_map,使用这个工具dump出所有调用这三个接口的调用,这种工具有很多,但是如果需要带上用户空间的backtrace可选项就少了。介于kgsl有现成的静态tracepoint以及工具易用度,这里选择了simpleperf。

simpleperf 是原linux平台perf的简化android版本,现在已经收录在google source code中,具体的google 官方代码路径为:https://cs.android.com/android/platform/superproject/+/master:system/extras/simpleperf/

Simpleperf是一个命令行工具,它的工具集包涵client端和host端。

-Client端:运行在Android系统上的可执行文件,负责收集性能数据;shell中端中直接使用simpleperf record或者simpleperf report命令。

-Host端:则运行开发机上,负责对数据进行分析和可视化;可以使用脚本report_html.py进行可视化。

Simpleperf还包含其他脚本:

用于记录事件的脚本(脚本或可执行文件均可记录):app_profiler.py、run_simpleperf_without_usb_connection.py

用于报告的脚本:report.py、report_html.py

用于分析 profiling data 的脚本:simpleperf_report_lib.py

Simpleperf使用

将Simpleperf工具的可执行程序 push 到手机上

adb push ~/system/extras/simpleperf/scripts/bin/android/arm64/simpleperf  /data/local/tmp

adb shell chmod 777 /data/local/tmp/simpleperf

启动手机上的被测程序,ps 出该程序的进程ID

adb shell ps -A | grep "需要分析的应用程序包名"

例如:

  • adb shell ps -A|grep camera
    • 查询provider_pid_1273

记录运行结果数据perf.data

# -p <进程号># --duration <持续的时间(秒为单位)># -o <输出文件名称># --call-graph dwarf 用在32位系统中,64位则采用--call-graph fpadb shell simpleperf record -p 11555 --duration 10 -o /sdcard/perf.data --call-graph fp

报告结果数据:将data转为txt

adb shell simpleperf report -i /sdcard/perf.data -o /sdcard/perf.txt

将手机的文件pull到电脑指定路径

adb pull /sdcard/perf.txt ./
adb pull /sdcard/perf.data ./

使用脚本report_html.py将data转为html文件

perf.data是一个文本文件,如果嵌套过深,基本就看不懂了。

可通过其中一个脚本(python report.py -g)启动GUI显示可视化结果。

还可以通过如下转化为HTML文件。

python3 ~/system/extras/simpleperf//report_html.py -i perf.data -o simpleperf.html

下载FlameGraph

https://github.com/brendangregg/FlameGraph.git

# 将FlameGraph下载到指定目录simpleperf-demo,并将simpleperf复制到该目录
git clone https://github.com/brendangregg/FlameGraph.git
chmod 777 FlameGraph/flamegraph.pl
chmod 777 FlameGraph/stackcollapse-perf.pl
cp -r /home/hqb/Android/Sdk/ndk/21.3.6528147/simpleperf /home/mi/simpleperf-demo

生成火焰图

cd /home/mi/simpleperf-demo
python ./simpleperf/report_sample.py > out.perf
./FlameGraph/stackcollapse-perf.pl out.perf > out.folded
./FlameGraph/flamegraph.pl out.folded > out.svg

火焰图(Flame Graph)

生成的perf-*.html文件,使用Google浏览器打开后,能看到:各个线程占用的CPU时钟周期 & 火焰图(Flame Graph)。

简介

官网:Flame Graphs

在线案例:mysqld`page_align (97 samples, 0.03%)

火焰图是基于 stack 信息生成的 SVG 图片, 用来展示 CPU 的调用栈,用浏览器打开可以与用户互动。

火焰图中的每一个方框是一个函数,方框的长度,代表了它的执行时间,所以越宽的函数,执行越久。火焰图的楼层每高一层,就是更深一级的函数被调用,最顶层的函数,是叶子函数。

  • y 轴表示调用栈,每一层都是一个函数。调用栈越深,火焰就越高,顶部就是正在执行的函数,下方都是它的父函数。
  • x 轴表示抽样数,如果一个函数在 x 轴占据的宽度越宽,就表示它被抽到的次数多,即执行的时间长。
  • 注意,x 轴不代表时间,而是所有的调用栈合并后,按字母顺序排列的。

火焰图就是看顶层的哪个函数占据的宽度最大。只要有"平顶"(plateaus),就表示该函数可能存在性能问题。

颜色没有特殊含义,因为火焰图表示的是 CPU 的繁忙程度,所以一般选择暖色调。

3.2-火焰图查看方法

3.2.1-鼠标悬浮

火焰的每一层都会标注函数名,鼠标悬浮时会显示完整的函数名、抽样抽中的次数、占据总抽样次数的百分比。下面是一个例子。

3.2.2-点击放大

在某一层点击,火焰图会水平放大,该层会占据所有宽度,显示详细信息。

左上角会同时显示"Reset Zoom",点击该链接,图片就会恢复原样。

3.2.3-搜索

按下 Ctrl + F 会显示一个搜索框,或点击右上角半透明的Search按钮,输入关键词或正则表达式,所有符合条件的函数名会高亮显示。

demo参考

perf_kgsl_get.sh

#!/bin/bashperfdata="/data/local/tmp/perf.data"
ultis_dir="../utils/simpleperf/"function initialize()
{adb root  >> /dev/null 2>&1adb remount  >> /dev/null 2>&1#if you need show allocation and release details of function, set show_func_detail as true pls.show_func_detail=false   #true#get pidpid=$(adb shell ps | grep camera.provider | awk '{print $2}')#pid=$(adb shell ps | grep com.android.camera | awk '{print $2}')len=$(echo "$pid" | wc -l)if [[ $len -gt 1 ]];thenecho "ERROR:pid is Greater than one!!!"exit 1fiproduct=`adb shell getprop ro.product.name`local_path=${product}_$(date +%F_%H%M%S)mkdir $local_pathadb shell rm $perfdata >> /dev/null 2>&1
}function get_perfdata()
{echo "Begin to catch perf data..."if [ ! -n "$1" ] ;then#here are some other events we can use to debug: kgsl:kgsl_context_create,kgsl:kgsl_context_destroy,kgsl:kgsl_pool_free_page,kgsl:kgsl_pool_get_page,kgsl:kgsl_pool_add_pageadb shell "simpleperf record -e fastrpc:fastrpc_dma_alloc,fastrpc:fastrpc_dma_free,fastrpc:fastrpc_dma_map,fastrpc:fastrpc_dma_unmap -a --call-graph dwarf -o $perfdata" &elseadb shell "simpleperf record -e fastrpc:fastrpc_dma_alloc,fastrpc:fastrpc_dma_free,fastrpc:fastrpc_dma_map,fastrpc:fastrpc_dma_unmap -a --call-graph dwarf --duration $1 -o $perfdata" &fi
}#由perf.data解析出gpu result
function get_fastrpc_result()
{echo ""ps -aux | grep -Pai "simpleperf[ ].+" | awk '{print $2}' | xargs kill -9#由于写perf.data文件需要比较久的时间,所以需要判断文件是否写完while :do#获取结束标志adb shell cat $perfdata | head -n 1 |  grep -Pia "PERFILE2h"  >> /dev/null 2>&1if [ $? -eq 0 ];thenbreakfisleep 1adb shell ls -al '/data/local/tmp/' | grep -Pia "perf.data" >> /dev/null 2>&1if [ $? -eq 1 ];thenecho "------------------------------------"echo "Do not generate perf.data, exit!!!"echo "------------------------------------"exit 1fidoneadb shell "simpleperf report -i $perfdata" > $local_path/perf.txtadb shell "simpleperf report -i $perfdata -g --full-callgraph" > $local_path/perf_callgraph.txtadb pull $perfdata $local_pathpython3 ${ultis_dir}simpleperf/scripts/report_sample.py -i $local_path/perf.data  --show_tracing_data > $local_path/perf_trace_report.txtif [ "$show_func_detail" == "true" ]; thenpython2 gpu_get_result_detail.py $local_path/perf_trace_report.txt gpu >$local_path/perf_trace_result.txtelsepython2 gpu_get_result.py $local_path/perf_trace_report.txt gpu >$local_path/perf_trace_result.txtfiecho "Output file is $local_path/perf_trace_result.txt"exit 0
}trap 'get_fastrpc_result' INTfunction main()
{initializeget_perfdata $1while :dosleep 10doneget_fastrpc_result
}main $1

gpu_get_result.py

# -*- coding: utf-8 -*-
import sys
import string
import copy#Total dict structure
# total_dict = {pid:list(total_info_dict,thread_dict,func_dict),}# stats_dict = {'alloc': 0, 'map': 0, 'free': 0, 'diff': 0, 'max': 0, 'alloc_time': 0, 'free_time': 0}
# thread_dict = {thread_tid:thread_stats_dict}
# func_dict = {func:func_stats_dict}list_pid = []
list_tid = []
list_func_so = []gpu_commonlib_list = ['libc.so', 'libgsl.so', 'libCB.so', 'libOpenCL.so']
func_flag = 0
so_flag = 0
gpu = Falsedef stats_calc(stats_dict, entry_size, entry_flag ):if entry_flag == '+':stats_dict['alloc'] += entry_sizestats_dict['alloc_time'] += 1stats_dict['diff'] += entry_sizeelif entry_flag == '++':stats_dict['map'] += entry_sizestats_dict['map_time'] += 1stats_dict['diff'] += entry_sizeelse:stats_dict['free'] += entry_sizestats_dict['free_time'] += 1stats_dict['diff'] -= entry_sizeif stats_dict['diff'] > stats_dict['max']:stats_dict['max'] = stats_dict['diff']returnif len(sys.argv) < 3:print "Please input file and type( gpu),such as: python gpu_get_result.py a.log gpu"sys.exit()f=open(sys.argv[1], "r")
if sys.argv[2] == "gpu":event_type = ("kgsl:kgsl_mem_free", "kgsl:kgsl_mem_alloc", "kgsl:kgsl_mem_map")so = []entry_done = 0entry_tgid = 0entry_func_name = "other"entry_usage = "other"init_stats_dict = {'alloc': 0, 'map': 0, 'free': 0, 'diff': 0, 'max': 0, 'alloc_time': 0, 'map_time': 0, 'free_time': 0}init_dict_list = [init_stats_dict, {}, {}]total_dict = {}for line in f:# entry startif any(event in line for event in event_type):tid_name = line.split('\t')[0].strip()tid = line.split('\t')[1].strip().split()[0]entry_tid_id = tid_name + '_' + tidgpu = True#if ("kgsl:kgsl_mem_alloc" in line or "kgsl:kgsl_mem_map" in line):   # Start of each eventif ("kgsl:kgsl_mem_alloc" in line ):entry_flag = "+"elif ("kgsl:kgsl_mem_map" in line):entry_flag = "++"else:entry_flag = "-"list_tid.append(tid_name)continueif gpu == True:if so_flag == 0 and len(line.split('\t')) > 0 and ("tracing data:\n" not in line):if line.split()[1].startswith("lib"):if len(line.split()[1].split('[')):so.append(line.split()[1].split('[')[0])   # Remove addr off info,such as [+8596c]    ']'))']'continueso_flag = 1for so_name in so:if any(gpu_commonlib in so_name for gpu_commonlib in gpu_commonlib_list) == False:entry_func_name = so_namefunc_flag = 1breakif (func_flag == 0):entry_func_name = "other"if ("size" == line.split(':')[0].strip()):entry_size = int(line.split(':')[1].strip())continueif ("tgid" == line.split(':')[0].strip()) :entry_tgid = line.split(':')[1].strip()continueif ("usage" == line.split(':')[0].strip()):entry_usage = line.split(':')[1].strip()entry_done = 1if entry_done == 1:entry_done = 0gpu = Falseso_flag = 0func_flag = 0so = []stats_dict = {}thread_dict = {}func_dict = {}dict_list = []#  PID overviewif total_dict.has_key(entry_tgid) == False:total_dict[entry_tgid] = copy.deepcopy(init_dict_list)list_pid.append(entry_tgid)dict_list = total_dict[entry_tgid]stats_dict = dict_list[0]if entry_flag == '+':stats_dict['alloc'] += entry_sizestats_dict['alloc_time'] += 1stats_dict['diff'] += entry_sizeelif entry_flag == '++':stats_dict['map'] += entry_sizestats_dict['map_time'] += 1stats_dict['diff'] += entry_sizeelse:stats_dict['free'] += entry_sizestats_dict['free_time'] += 1stats_dict['diff'] -= entry_sizeif stats_dict['diff'] > stats_dict['max']:stats_dict['max'] = stats_dict['diff']thread_dict = dict_list[1]if thread_dict.has_key(entry_tid_id) == False:thread_dict[entry_tid_id] = copy.deepcopy(init_stats_dict)stats_dict = thread_dict[entry_tid_id]if entry_flag == '+':stats_dict['alloc'] += entry_sizestats_dict['alloc_time'] += 1stats_dict['diff'] += entry_sizeelif entry_flag == '++':stats_dict['map'] += entry_sizestats_dict['map_time'] += 1stats_dict['diff'] += entry_sizeelse:stats_dict['free'] += entry_sizestats_dict['free_time'] += 1stats_dict['diff'] -= entry_sizeif stats_dict['diff'] > stats_dict['max']:stats_dict['max'] = stats_dict['diff']func_dict = dict_list[2]if func_dict.has_key(entry_func_name) == False:func_dict[entry_func_name] = copy.deepcopy(init_stats_dict)list_func_so.append(entry_func_name)stats_dict = func_dict[entry_func_name]stats_calc(stats_dict, entry_size, entry_flag)print "All pid: ", list(set(list_pid))
print "All tid: ", list(set(list_tid))
print "All func: ", list(set(list_func_so))
print "======================================================================================================="
print "Memory cost and revert distribution:"
print "(diff means gpu memory usage, if the diff still exists after completing the case and closing the camera, it is likely to be a gpu leak)"
for key in total_dict:print "pid:",keystats_dict = {}stats_dict=total_dict[key]# output the pid kgsl total messagefor message in stats_dict[0]:if message == "alloc" or message == "map" or message == "max"or message == "free"or message == "diff":if message == "diff" and stats_dict[0][message] != 0:print("%10s:%12s MB"%(message,stats_dict[0][message]/1024./1024))else:print('%10s:%12s MB'%(message,stats_dict[0][message]/1024./1024))else:print('%10s:%12s MB'%(message,stats_dict[0][message]))print "*****tid message*****"for message in stats_dict[1]:tid_dict = {}tid_dict = stats_dict[1][message]print('%25s'%message),for tismessage in tid_dict:if tismessage == "alloc" or tismessage == "map" or tismessage == "max"or tismessage == "free" or tismessage == "diff":if tismessage == "diff" and tid_dict[tismessage] != 0:print("%10s:%12s MB"%(tismessage,tid_dict[tismessage]/1024./1024)),else:print('%10s:%12s MB'%(tismessage,tid_dict[tismessage]/1024./1024)),else:print('%10s:%5s'%(tismessage,tid_dict[tismessage])),print ""print "*****func message*****"for message in stats_dict[2]:func_dict = {}func_dict = stats_dict[2][message]print('%25s'%message),for funmessage in func_dict:if funmessage == "alloc" or funmessage == "map" or funmessage == "max"or funmessage == "free"or funmessage == "diff":if funmessage == "diff" and func_dict[funmessage] != 0:print("%10s:%12s MB"%(funmessage,func_dict[funmessage]/1024./1024)),else:print('%10s:%12s MB'%(funmessage,func_dict[funmessage]/1024./1024)),else:print('%10s:%5s'%(funmessage,func_dict[funmessage])),print ""print "======================================================================================================="# print (total_dict)

参考

如何读懂火焰图?

火焰图(FlameGraph)的使用

拓展

用off-cpu火焰图进行Linux性能分析

动态追踪技术漫谈

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/644312.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

nginx 解决tensorflow-serving 跨域代理问题

在nginx conf.d/目录下新建一个main.conf 配置该文件 进行代理 upstream rec{server 127.0.0.1:19356 ;keepalive 20000;}upstream rcv-module{server 10.0.2.198:8511 ;keepalive 20000;} server {listen 80;server_name **.**.com;#access_log /var/log/nginx/h…

docker-compose搭建redis集群

这里用docker-compose在一台机器搭建三主三从&#xff0c;生产环境肯定是在多台机器搭建&#xff0c;否则一旦这台宿主机挂了&#xff0c;redis集群全挂了&#xff0c;依然是单点故障。同时&#xff0c;受机器性能极限影响&#xff0c;其并发也上不去&#xff0c;算不上高并发。…

2024年深圳市软件产业高质量发展应用推广体系扶持计划人工智能软件应用示范项目申请指

​一、资助的项目类别 企业实施的通过应用人工智能软件对现有生产、服务和管理方式进行智能化升级&#xff0c;且技术水平先进、市场前景广阔、带动效应明显的人工智能软件应用示范项目。 二、设定依据 &#xff08;一&#xff09;《深圳市人民政府关于印发推动软件产业高质…

web开发学习笔记(14.mybatis基于xml配置)

1.基本介绍 2.基本使用 在mapper中定义 在xml中定义&#xff0c;id为方法名&#xff0c;resultType为实体类的路径 在测试类中写 3. 动态sql&#xff0c;if和where关键字 动态sql添加<where>关键字可以自动产生where和过滤and或者or关键字 where关键字可以动态生成whe…

go-carbon v2.3.6 发布,轻量级、语义化、对开发者友好的 golang 时间处理库

carbon 是一个轻量级、语义化、对开发者友好的 golang 时间处理库&#xff0c;支持链式调用。 目前已被 awesome-go 收录&#xff0c;如果您觉得不错&#xff0c;请给个 star 吧 github.com/golang-module/carbon gitee.com/golang-module/carbon 安装使用 Golang 版本大于…

kafka(一)快速入门

一、kafka&#xff08;一&#xff09;是什么&#xff1f; kafka是一个分布式、支持分区、多副本&#xff0c;基于zookeeper协调的分布式消息系统&#xff1b; 二、应用场景 日志收集&#xff1a;一个公司可以用Kafka收集各种服务的log&#xff0c;通过kafka推送到各种存储系统…

Zabbix 整合 Prometheus:案例分享与操作指南

一、简介 Zabbix 和 Prometheus 都是流行的开源监控工具&#xff0c;它们各自具有独特的优势。Zabbix 主要用于网络和系统监控&#xff0c;而 Prometheus 则专注于开源的分布式时间序列数据库。在某些场景下&#xff0c;将这两个工具整合在一起可以更好地发挥它们的优势&#…

vue3源码(二)reactiveeffect

一.reactive与effect功能 reactive方法会将对象变成proxy对象&#xff0c; effect中使用reactive对象时会进行依赖收集&#xff0c;稍后属性变化时会重新执行effect函数。 <div id"app"></div><script type"module">import {reactive,…

从零学Java MySQL

MySQL 文章目录 MySQL初识数据库思考&#xff1a;1 什么是数据库&#xff1f;2 数据库管理系统 初识MySQLMySQL卸载MySQL安装1 配置环境变量2 MySQL目录结构及配置文件 连接MySQL数据库基本命令MySQL基本语法&#xff1a;1 查看MySQL服务器中所有数据库2 创建数据库3 查看数据库…

决策树(Python)

决策树&#xff08;Decision Tree&#xff09; 为达到目标&#xff0c;根据一定的条件进行选择的过程&#xff0c;就是决策树&#xff0c;常用于分类 构成元素是结点和边 结点&#xff1a;根据样本的特征作出判断&#xff0c;根节点、叶节点。边&#xff1a;指示方向。 衡量…

leetcode—课程表 拓扑排序

1 题目描述 你这个学期必须选修 numCourses 门课程&#xff0c;记为 0 到 numCourses - 1 。 在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出&#xff0c;其中 prerequisites[i] [ai, bi] &#xff0c;表示如果要学习课程 ai 则 必须 先学习课程 …

《WebKit 技术内幕》学习之五(2): HTML解释器和DOM 模型

2.HTML 解释器 2.1 解释过程 HTML 解释器的工作就是将网络或者本地磁盘获取的 HTML 网页和资源从字节流解释成 DOM 树结构。 这一过程中&#xff0c;WebKit 内部对网页内容在各个阶段的结构表示。 WebKit 中这一过程如下&#xff1a;首先是字节流&#xff0c;经过解码之…

ORBSLAM3安装

0. C11 or C0x Compiler sudo apt-get install gccsudo apt-get install gsudo apt-get install build-essentialsudo apt-get install cmake1. 依赖 在该目录终端。 1. 1.Pangolin git clone https://github.com/stevenlovegrove/Pangolin.git sudo apt install libglew-d…

Python基础第九篇(Python可视化的开发)

文章目录 一、json数据格式&#xff08;1&#xff09;.转换案例代码&#xff08;2&#xff09;.读出结果 二、pyecharts模块介绍三、pyecharts模块入门&#xff08;1&#xff09;.pyecharts模块安装&#xff08;2&#xff09;.pyecharts模块操作&#xff08;1&#xff09;.代码…

C++力扣题目509--斐波那契数 70--爬楼梯 746--最小花费爬楼梯

509. 斐波那契数 力扣题目链接(opens new window) 斐波那契数&#xff0c;通常用 F(n) 表示&#xff0c;形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始&#xff0c;后面的每一项数字都是前面两项数字的和。也就是&#xff1a; F(0) 0&#xff0c;F(1) 1 F(n) F(n -…

了解WPF控件:PrintDialog常用属性与用法(八)

掌握WPF控件&#xff1a;熟练常用属性&#xff08;八&#xff09; PrintDialog -一个对话框&#xff0c;用于在打印文档时显示打印设置参数供用户选择并确认。通过该控件&#xff0c;用户可以选择打印机、打印的范围、打印的份数、打印质量等。 常用属性描述CurrentPageEnab…

制作编写使用说明书:在结构、风格与内容方面需要注意什么?

如今&#xff0c;一个清晰、简洁、易于理解的使用说明书不仅能够帮助用户正确地使用产品&#xff0c;还能提升用户体验并树立品牌形象。而制作编写一份优质的使用说明书需要我们在结构、风格与内容三个方面下功夫。那么在制作编写使用说明书时需要注意哪些关键要素呢&#xff1…

【JavaWeb】日程管理系统 项目搭建 第二期

文章目录 一、数据库准备二、导入依赖 与 JDBC工具类三、pojo包处理四、daodao包工具类 五、service六、controllerservlet 基类 反射 七、加密工具类 MD5八、页面文件九、业务代码9.1 注册业务处理9.2 登录业务处理 总结 一、数据库准备 创建数据库&#xff1a; SET NAMES …

vue折叠展开transition动画使用keyframes实现

需求&#xff0c;我正常的菜单功能有隐藏与显示功能&#xff0c;需要增加动画 打开的时候宽度从0到300&#xff0c;关闭的时候&#xff0c;宽度从300到0 <template> <div id"app"> <button click"toggleLength">Toggle Length</bu…

骨传导耳机综评:透视南卡、韶音和墨觉三大品牌的性能与特点

在当前的蓝牙音频设备领域中&#xff0c;骨传导蓝牙运动耳机以其出色的安全特性和舒适的体验&#xff0c;受到了健身爱好者们的广泛好评。这类耳机不同于我们常见的入耳式耳机&#xff0c;它的工作方式是直接通过振动将声音传递到用户的耳骨中&#xff0c;这样既可以享受音乐&a…