首页
小游戏
壁纸
留言
视频
友链
关于
Search
1
上海市第八人民医院核酸检测攻略(时间+预约+报告)-上海
299 阅读
2
上海烟花销售点一览表2022-上海
241 阅读
3
新款的 Thinkbook 16+ 值不值得买?-知乎热搜
219 阅读
4
如何看待网传小米 MIUI 13 内置国家反诈中心 APP?-知乎热搜
214 阅读
5
窦唯到底厉害在哪里?-知乎热搜
192 阅读
免费代理IP
免费翻墙节点
文章聚合
掘金
知乎
IT之家
本地宝
观察者网
金山词霸
搜韵网
新华网
其他
登录
/
注册
Search
标签搜索
知乎热搜
IT之家热榜
广州
深圳
北京
观察者网头条
前端
上海
后端
知乎日报
Android
iOS
人工智能
阅读
工具资源
杭州
诗词日历
每日一句
郑州
设计
看啥
累计撰写
129,720
篇文章
累计收到
46
条评论
首页
栏目
免费代理IP
免费翻墙节点
文章聚合
掘金
知乎
IT之家
本地宝
观察者网
金山词霸
搜韵网
新华网
其他
页面
小游戏
壁纸
留言
视频
友链
关于
搜索到
1505
篇与
的结果
2022-10-20
高性能图片优化方案-掘金
目录介绍 01.图片基础概念介绍 1.1 图片占用内存介绍 1.2 加载网络图片流程 1.3 三方库加载图片逻辑 1.4 从网络直接拉取图片 1.5 加载图片的流程 1.6 Bitmap能直接存储吗 1.7 Bitmap创建流程 1.8 图片框架如何设计 02.图片内存计算方式 2.1 如何计算占用内存 2.2 上面计算内存对吗 2.3 一个像素占用内存 2.4 使用API获取内存 2.5 影响Bitmap内存因素 2.6 加载xhdpi和xxhdpi图片 2.7 图片一些注意事项 03.大图的内存优化 3.1 常见图片压缩 3.2 图片尺寸压缩 3.3 图片质量压缩 3.4 双线性采样压缩 3.5 高清图分片加载 3.6 图片综合压缩 04.色彩格式及内存优化 4.1 RGB颜色种类 4.2 ARGB色彩模式 4.3 改变色彩格式优化 05.缓存的使用实践优化 5.1 Lru内存缓存 5.2 Lru内存注意事项 5.3 使用Lru磁盘缓存 06.不同版本对Bitmap管理 6.1 演变进程 6.2 管理Bitmap内存 6.3 提高Bitmap复用 07.图片其他方面优化 7.1 减少PNG图片的使用 7.2 控件切割圆角优化 7.3 如何给图片置灰色 7.4 如何处理图片旋转呢 7.5 保存图片且刷相册 7.6 统一图片域名优化 7.7 优化H5图片加载 7.8 优化图片阴影效果 7.9 图片资源的压缩 01.图片基础概念介绍1.1 图片占用内存介绍 移动设备的系统资源有限,所以应用应该尽可能的降低内存的使用。 在应用运行过程中,Bitmap (图片)往往是内存占用最大的一个部分,Bitmap 图片的加载和处理,通常会占用大量的内存空间,所以在操作 Bitmap 时,应该尽可能的小心。 Bitmap 会消耗很多的内存,特别是诸如照片等内容丰富的大图。 例如,一个手机拍摄的 2700 * 1900 像素的照片,需要 5.1M 的存储空间,但是在图像解码配置 ARGB_8888 时,它加载到内存需要 19.6M 内存空间(2592 * 1936 * 4 bytes),从而迅速消耗掉该应用的剩余内存空间。 OOM 的问题也是我们常见的严重问题,OOM 的产生的一个主要场景就是在大图片分配内存的时候产生的,如果 APP 可用内存紧张,这时加载了一张大图,内存空间不足以分配该图片所需要的内存,就会产生 OOM,所以控制图片的高效使用是必备技能。 1.2 加载网络图片流程 这一部分压缩和缓存图片,在glide源码分析的文章里已经做出了比较详细的说明。在这里简单说一下图片请求加载过程…… 在使用App的时候,会经常需要加载一些网络图片,一般的操作步骤大概是这样的: 第一步从网络加载图片:一般都是通过网络拉取的方式去服务器端获取到图片的文件流后,再通过BitmapFactory.decodeStream(InputStream)来加载图片Bitmap。 第二步这种压缩图片:网络加载图片方式加载一两张图片倒不会出现问题,但是如果短时间内加载十几张或者几十张图片的时候,就很有可能会造成OOM(内存溢出),因为现在的图片资源大小都是非常大的,所以我们在加载图片之前还需要进行相应的图片压缩处理。 第三步变换图片:比如需要裁剪,切割圆角,旋转,添加高斯模糊等属性。 第四步缓存图片:但又有个问题来了,在使用移动数据的情况下,如果用户每次进入App的时候都会去进行网络拉取图片,这样就会非常的浪费数据流量,这时又需要对图片资源进行一些相应的内存缓存以及磁盘缓存处理,这样不仅节省用户的数据流量,还能加快图片的加载速度。 第五步异步加载:虽然利用缓存的方式可以加快图片的加载速度,但当需要加载很多张图片的时候(例如图片墙瀑布流效果),就还需用到多线程来加载图片,使用多线程就会涉及到线程同步加载与异步加载问题。 1.3 三方库加载图片逻辑 先说出结论,目前市面较为常用的大概是Glide,Picasso,Fresco等。大概的处理图片涉及主要逻辑有: 从网络或者本地等路径拉取图片;然后解码图片;然后进行压缩;接着会有图片常用圆角,模糊或者裁剪等处理;然后三级缓存加载的图片;当然加载图片过程涉及同步加载和异步加载;最后设置到具体view控件上。 1.4 从网络直接拉取图片 直接通过网络请求将网络图片转化成bitmap 在这将采用最原生的网络请求方式HttpURLConnection方式进行图片获取。 经过测试,请求8张图片,耗时毫秒值174。一般是通过get请求拉取图片的。这种方法应该是最基础的网络请求,大家也可以回顾一下,一般开发中很少用这种方式加载图片。具体可以看:ImageToolLib 如何加载一个图片呢? 可以看看BitmapFactory类为我们提供了四类方法来加载Bitmap:decodeFile、decodeResource、decodeStream、decodeByteArray;也就是说Bitmap,Drawable,InputStream,Byte[] 之间是可以进行转换。 1.5 加载图片的流程 搞清楚一个图片概念 在电脑上看到的 png 格式或者 jpg 格式的图片,png(jpg) 只是这张图片的容器。是经过相对应的压缩算法将原图每个像素点信息转换用另一种数据格式表示。 加载图片显示到手机 通过代码,将这张图片加载进内存时,会先解析(也就是解码操作)图片文件本身的数据格式,然后还原为位图,也就是 Bitmap 对象。 图片大小vs图片内存大小 一张 png 或者 jpg 格式的图片大小,跟这张图片加载进内存所占用的大小完全是两回事。 1.6 Bitmap能直接存储吗 Bitmap基础概念 Bitmap对象本质是一张图片的内容在手机内存中的表达形式。它将图片的内容看做是由存储数据的有限个像素点组成;每个像素点存储该像素点位置的ARGB值。每个像素点的ARGB值确定下来,这张图片的内容就相应地确定下来了。 Bitmap本质上不能直接存储 为什么?bitmap是一个对象,如果要存储成本地可以查看的图片文件,则必须对bitmap进行编码,然后通过io流写到本地file文件上。 1.7 Bitmap创建流程 对于图片OOM,可以发现一个现象。 heapsize(虚拟机的内存配置)越大越不容易 OOM,Android8.0 及之后的版本更不容易 OOM,这个该如何理解呢? Bitmap对象内存的变化: 在 Android 8.0 之前,Bitmap 像素占用的内存是在 Java heap 中分配的;8.0 及之后,Bitmap 像素占用的内存分配到了 Native Heap。 由于 Native heap 的内存分配上限很大,32 位应用的可用内存在 3~4G,64 位上更大,虚拟内存几乎很难耗尽,所以推测 OOM 时 Java heap 中占用内存较多的对象是 Bitmap” 成立的情况下,应用更不容易 OOM。 搞清楚Bitmap对象内存分配 Bitmap 的构造方法是不公开的,在使用 Bitmap 的时候,一般都是通过 Bitmap、BitmapFactory 提供的静态方法来创建 Bitmap 实例。 以 Bitmap.createBitmap 说明了 Bitmap 对象的主要创建过程分析,可以看到 Java Bitmap 对象是在 Native 层通过 NewObject 创建的。 allocateJavaPixelRef,是 8.0 之前版本为 Bitmap 像素从 Java heap 申请内存。其核心原理是Bitmap 的像素是保存在 Java 堆上。 allocateHeapBitmap,是 8.0 版本为 Bitmap 像素从 Native heap 申请内存。其核心原理主要是通过 calloc 为 Bitmap 的像素分配内存,这个分配就在 Native 堆上。 更多详细内容可以看:Bitmap对象内存分配 1.8 图片框架如何设计 大多数图片框架加载流程 概括来说,图片加载包含封装,解析,下载,解码,变换,缓存,显示等操作。 图片框架是如何设计的 封装参数:从指定来源,到输出结果,中间可能经历很多流程,所以第一件事就是封装参数,这些参数会贯穿整个过程; 解析路径:图片的来源有多种,格式也不尽相同,需要规范化;比如glide可以加载file,io,id,网络等各种图片资源 读取缓存:为了减少计算,通常都会做缓存;同样的请求,从缓存中取图片(Bitmap)即可; 查找文件/下载文件:如果是本地的文件,直接解码即可;如果是网络图片,需要先下载;比如glide这块是发起一个请求 解码:这一步是整个过程中最复杂的步骤之一,有不少细节;比如glide中解析图片数据源,旋转方向,图片头等信息 变换和压缩:解码出Bitmap之后,可能还需要做一些变换处理(圆角,滤镜等),还要做图片压缩; 缓存:得到最终bitmap之后,可以缓存起来,以便下次请求时直接取结果;比如glide用到三级缓存 显示:显示结果,可能需要做些动画(淡入动画,crossFade等);比如glide设置显示的时候可以添加动画效果 02.图片内存计算方式2.1 如何计算占用内存 如果图片要显示下Android设备上,ImageView最终是要加载Bitmap对象的,就要考虑单个Bitmap对象的内存占用了,如何计算一张图片的加载到内存的占用呢?其实就是所有像素的内存占用总和: bitmap内存大小 = 图片长度 x 图片宽度 x 单位像素占用的字节数 起决定因素就是最后那个参数了,Bitmap常见有2种编码方式:ARGB_8888和RGB_565,ARGB_8888每个像素点4个byte,RGB_565是2个byte,一般都采用ARGB_8888这种。那么常见的1080*1920的图片内存占用就是:1920 x 1080 x 4 = 7.9M 2.2 上面计算内存对吗 我看到好多博客都是这样计算的,但是这样算对吗?有没有哥们试验过这种方法正确性?我觉得看博客要对博主表示怀疑,论证别人写的是否正确。 说出我的结论:上面2.1这种说法也对,但是不全对,没有说明场景,同时也忽略了一个影响项:Density。接下来看看源代码。 inDensity默认为图片所在文件夹对应的密度;inTargetDensity为当前系统密度。 加载一张本地资源图片,那么它占用的内存 = width * height * nTargetDensity/inDensity 一个像素所占的内存。 @Nullable public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value, @Nullable InputStream is, @Nullable Rect pad, @Nullable Options opts) { validate(opts); if (opts == null) { opts = new Options(); } if (opts.inDensity == 0 && value != null) { final int density = value.density; if (density == TypedValue.DENSITY_DEFAULT) { opts.inDensity = DisplayMetrics.DENSITY_DEFAULT; } else if (density != TypedValue.DENSITY_NONE) { opts.inDensity = density; } } if (opts.inTargetDensity == 0 && res != null) { opts.inTargetDensity = res.getDisplayMetrics().densityDpi; } return decodeStream(is, pad, opts); } 正确说法,这个注意呢?计算公式如下所示 对资源文件:width * height * nTargetDensity/inDensity * nTargetDensity/inDensity * 一个像素所占的内存; 别的:width * height * 一个像素所占的内存; 2.3 一个像素占用内存 一个像素占用多大内存?Bitmap.Config用来描述图片的像素是怎么被存储的? ARGB_8888: 每个像素4字节. 共32位,默认设置。 Alpha_8: 只保存透明度,共8位,1字节。 ARGB_4444: 共16位,2字节。 RGB_565:共16位,2字节,只存储RGB值。 2.4 使用API获取内存 Bitmap使用API获取内存 getByteCount() getByteCount()方法是在API12加入的,代表存储Bitmap的色素需要的最少内存。API19开始getAllocationByteCount()方法代替了getByteCount()。 getAllocationByteCount() API19之后,Bitmap加了一个Api:getAllocationByteCount();代表在内存中为Bitmap分配的内存大小。 思考: getByteCount()与getAllocationByteCount()的区别? 一般情况下两者是相等的;通过复用Bitmap来解码图片,如果被复用的Bitmap的内存比待分配内存的Bitmap大,那么getByteCount()表示新解码图片占用内存的大小(并非实际内存大小,实际大小是复用的那个Bitmap的大小),getAllocationByteCount()表示被复用Bitmap真实占用的内存大小(即mBuffer的长度)。 在复用Bitmap的情况下,getAllocationByteCount()可能会比getByteCount()大。 2.5 影响Bitmap内存因素 影响Bitmap占用内存的因素: 图片最终加载的分辨率; 图片的格式(PNG/JPEG/BMP/WebP); 图片所存放的drawable目录; 图片属性设置的色彩模式; 设备的屏幕密度; 2.6 加载xhdpi和xxhdpi图片 提个问题,加载xhdpi和xxhdpi中相同的图片,显示在控件上会一样吗?内存大小一样吗?为什么? 肯定是不一样的。xhdpi:240dpi--320dpi,xxhdpi:320dpi--480dpi, app中设置的图片是如何从hdpi中查找的? 首先计算dpi,比如手机分辨率是1920x1080,5.1寸的手机。那么得到的dpi公式是(√ ̄1920² + 1080²)/5.1 =2202/5.1= 431dpi。这样优先查找xxhdpi 如果xxhdpi里没有查找图片,如果没有会往上找,遵循“先高再低”原则。如果xhdpi里有这个图片会使用xhdpi里的图片,这时候发现会比在xhdpi里的图片放大了。 为何要引入不同hdpi的文件管理? 比如:xxhdpi放94x94,xhdpi放74x74,hdpi放45x45,这样不管是什么样的手机图片都能在指定的比例显示。引入多种hdpi是为了让这个图片在任何手机上都是手机的这个比例。 2.7 图片一些注意事项 同样图片显示在大小不相同的ImageView上,内存是一样吗? 图片占据内存空间大小与图片在界面上显示的大小没有关系。 图片放在res不同目录,加载的内存是一样的吗? 最终图片加载进内存所占据的大小会不一样,因为系统在加载 res 目录下的资源图片时,会根据图片存放的不同目录做一次分辨率的转换,而转换的规则是:新图的高度 = 原图高度 * (设备的 dpi / 目录对应的 dpi ) 03.大图的内存优化3.1 常见图片压缩 常见压缩方法Api Bitmap.compress(),质量压缩,不会对内存产生影响; BitmapFactory.Options.inSampleSize,内存压缩; Bitmap.compress()质量压缩 质量压缩,不会对内存产生影响。它是在保持像素的前提下改变图片的位深及透明度等,来达到压缩图片的目的,不会减少图片的像素。进过它压缩的图片文件大小会变小,但是解码成bitmap后占得内存是不变的。 BitmapFactory.Options.inSampleSize内存压缩 解码图片时,设置BitmapFactory.Options类的inJustDecodeBounds属性为true,可以在Bitmap不被加载到内存的前提下,获取Bitmap的原始宽高。而设置BitmapFactory.Options的inSampleSize属性可以真实的压缩Bitmap占用的内存,加载更小内存的Bitmap。 设置inSampleSize之后,Bitmap的宽、高都会缩小inSampleSize倍。例如:一张宽高为2048x1536的图片,设置inSampleSize为4之后,实际加载到内存中的图片宽高是512x384。占有的内存就是0.75M而不是12M,足足节省了15倍。 备注:inSampleSize值的大小不是随便设、或者越大越好,需要根据实际情况来设置。inSampleSize比1小的话会被当做1,任何inSampleSize的值会被取接近2的幂值。 3.2 图片尺寸压缩3.2.1 如何理解尺寸压缩 通常在大多数情况下,图片的实际大小都比需要呈现的尺寸大很多。 例如,我们的原图是一张 2700 * 1900 像素的照片,加载到内存就需要 19.6M 内存空间,但是,我们需要把它展示在一个列表页中,组件可展示尺寸为 270 * 190,这时,我们实际上只需要一张原图的低分辨率的缩略图即可(与图片显示所对应的 UI 控件匹配),那么实际上 270 * 190 像素的图片,只需要 0.2M 的内存即可。 可以看到,优化前后相差了 98 倍,原来显示 1 张,现在可以显示 98 张图片,效果非常显著。 既然在对原图缩放可以显著减少内存大小,那么我们应该如何操作呢? 先加载到内存,再进行操作吗,可以如果先加载到内存,好像也不太对,这样只接占用了 19.6M + 0.2M 2份内存了,而我们想要的是,在原图不加载到内存中,只接将缩放后的图片加载到内存中,可以实现吗? BitmapFactory 提供了从不同资源创建 Bitmap 的解码方法: decodeByteArray()、decodeFile()、decodeResource() 等。但是,这些方法在构造位图的时候会尝试分配内存,也就是它们会导致原图直接加载到内存了,不满足我们的需求。我们可以通过 BitmapFactory.Options 设置一些附加的标记,指定解码选项,以此来解决该问题。 如何操作呢?答案来了:将 inJustDecodeBounds 属性设置为 true,可以在解码时避免内存的分配,它会返回一个 null 的 Bitmap ,但是可以获取 outWidth、outHeight 和 outMimeType 值。利用该属性,我们就可以在图片不占用内存的情况下,在图片压缩之前获取图片的尺寸。 怎样才能对图片进行压缩呢? 通过设置BitmapFactory.Options中inSampleSize的值就可以实现。其计算方式大概就是:计算出实际宽高和目标宽高的比率,然后选择宽和高中最小的比率作为inSampleSize的值,这样可以保证最终图片的宽和高。 3.2.2 设置BitmapFactory.Options属性 大概步骤如下所示 要将BitmapFactory.Options的inJustDecodeBounds属性设置为true,解析一次图片。注意这个地方是核心,这个解析图片并没有生成bitmap对象(也就是说没有为它分配内存控件),而仅仅是拿到它的宽高等属性。 然后将BitmapFactory.Options连同期望的宽度和高度一起传递到到calculateInSampleSize方法中,就可以得到合适的inSampleSize值了。这一步会压缩图片。 之后再解析一次图片,使用新获取到的inSampleSize值,并把inJustDecodeBounds设置为false,就可以得到压缩后的图片了。此时才正式创建了bitmap对象,由于前面已经对它压缩了,所以你会发现此时所占内存大小已经很少了。 具体的实现代码public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) { // 第一次解析将inJustDecodeBounds设置为true,来获取图片大小 final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeResource(res, resId, options); // 调用上面定义的方法计算inSampleSize值 options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); // 使用获取到的inSampleSize值再次解析图片 options.inJustDecodeBounds = false; return BitmapFactory.decodeResource(res, resId, options); } 思考:inJustDecodeBounds这个参数是干什么的? 如果设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数了。 为何设置两次inJustDecodeBounds属性? 第一次:设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数。 第二次:将inJustDecodeBounds设置为false再次调用decode函数时就能生成bitmap了。而此时的bitmap已经压缩减小很多了,所以加载到内存中并不会导致OOM。 3.3 图片质量压缩 在Android中,对图片进行质量压缩,通常我们的实现方式如下所示://quality 为0~100,0表示最小体积,100表示最高质量,对应体积也是最大 bitmap.compress(Bitmap.CompressFormat.JPEG, quality , outputStream); 在上述代码中,我们选择的压缩格式是CompressFormat.JPEG,除此之外还有两个选择: 其一,CompressFormat.PNG,PNG格式是无损的,它无法再进行质量压缩,quality这个参数就没有作用了,会被忽略,所以最后图片保存成的文件大小不会有变化; 其二,CompressFormat.WEBP,这个格式是google推出的图片格式,它会比JPEG更加省空间,经过实测大概可以优化30%左右。 Android质量压缩逻辑,函数compress经过一连串的java层调用之后,最后来到了一个native函数: 具体看:Bitmap.cpp,最后调用了函数encoder->encodeStream(…)编码保存本地。该函数是调用skia引擎来对图片进行编码压缩。 3.4 双线性采样压缩 双线性采样(Bilinear Resampling)在 Android 中的使用方式一般有两种:bm = Bitmap.createScaledBitmap(bitmap, bitmap.getWidth()/2, bitmap.getHeight()/2, true); //或者直接使用 matrix 进行缩放 Matrix matrix = new Matrix(); matrix.setScale(0.5f, 0.5f); bm = Bitmap.createBitmap(bitmap, 0, 0, bit.getWidth(), bit.getHeight(), matrix, true); 看源码可以知道createScaledBitmap函数最终也是使用第二种方式的matrix进行缩放 双线性采样使用的是双线性內插值算法,这个算法不像邻近点插值算法一样,直接粗暴的选择一个像素,而是参考了源像素相应位置周围2x2个点的值,根据相对位置取对应的权重,经过计算之后得到目标图像。 3.5 高清图分片加载 适用场景 : 当一张图片非常大 , 在手机中只需要显示其中一部分内容 , BitmapRegionDecoder 非常有用 。 主要作用 : BitmapRegionDecoder 可以从图像中 解码一个矩形区域 。相当于手在滑动的过程中,计算当前显示区域的图片绘制出来。 基本使用流程 : 先创建,后解码 。调用 newInstance 方法 , 创建 BitmapRegionDecoder 对象 ;然后调用 decodeRegion 方法 , 获取指定 Rect 矩形区域的解码后的 Bitmap 对象 3.6 图片综合压缩 一般情况下图片综合压缩的整体思路如下: 第一步进行采样率压缩; 第二步进行宽高的等比例压缩(微信对原图和缩略图限制了最大长宽或者最小长宽); 第三步就是对图片的质量进行压缩(一般75或者70); 第四步就是采用webP的格式。 关于图片压缩的综合案例如下 具体可以参考:CompressServer 04.色彩格式及内存优化4.1 RGB颜色种类 RGB 色彩模式是工业界的一种颜色标准 通过对红(R)、绿(G)、蓝(B)三个颜色通道的变化以及它们相互之间的叠加来得到各式各样的颜色的,RGB即是代表红、绿、蓝三个通道的颜色,这个标准几乎包括了人类视力所能感知的所有颜色,是运用最广的颜色系统之一。Android 中,像素的存储方式使用的色彩模式正是 RGB 色彩模式。 4.2 ARGB色彩模式 在 Android 中,我们常见的一些颜色设置,都是 RGB 色彩模式来描述像素颜色的,并且他们都带有透明度通道,也就是所谓的 ARGB。例如,我们常见的颜色定义如下://在代码中定义颜色值:蓝色 public final int blue=0xff0000ff; //或者在xml中定义: #ff0000ff 以上设置中,颜色值都是使用 16 进制的数字来表示的。以上颜色值都是带有透明度(透明通道)的颜色值,格式是 AARRGGBB,透明度、红色、绿色、蓝色四个颜色通道,各占有 2 位,也就是一个颜色通道,使用了 1 个字节来存储。 4.3 改变色彩格式优化 Android 中有多种 RGB 模式,我们可以设置不同的格式,来控制图片像素颜色的显示质量和存储空间。 Android.graphics.Bitmap 类里有一个内部类 Bitmap.Config 类,它定义了可以在 Android 中使用的几种色彩格式:public enum Config { ALPHA_8 (1), RGB_565 (3), @Deprecated ARGB_4444 (4), ARGB_8888 (5), RGBA_F16 (6), HARDWARE (7); } 解释一下这几个值分别代表了什么含义?我们已经知道了:A 代表透明度、R 代表红色、G 代表绿色、B 代表蓝色。 ALPHA_8:表示,只存在 Alpha 通道,没有存储色彩值,只含有透明度,每个像素占用 1 个字节的空间。 RGB_565:表示,R 占用 5 位二进制的位置,G 占用了6位,B 占用了 5 位。每个像素占用 2 个字节空间,并且不包含透明度。 ARGB_4444:表示,A(透明度)、R(红色)、G(绿色)、B(蓝色)4个通道各占用 4 个 bit 位。每个像素占用 2 个字节空间。 ARGB_8888:表示,A(透明度)、R(红色)、G(绿色)、B(蓝色)4个通道各占用 8 个 bit 位。每个像素占用 4 个字节空间。 RGBA_F16:表示,每个像素存储在8个字节上。此配置特别适合广色域和HDR内容。 HARDWARE:特殊配置,当位图仅存储在图形内存中时。 此配置中的位图始终是不可变的。 那么开发中一般选择哪一种比较合适呢 Android 中的图片在加载时,默认的色彩格式是 ARGB_8888,也就是每个像素占用 4 个字节空间,一张 2700 * 1900 像素的照片,加载到内存就需要 19.6M 内存空间(2592 * 1936 * 4 bytes)。 如果图片在 UI 组件中显示时,不需要太高的图片质量,例如显示一张缩略图(不透明图片)等场景,这时,我们就没必要使用 ARGB_8888 的色彩格式了,只需要使用 RGB_565 模式即可满足显示的需要。 那么,我们的优化操作就可以是: 将 2700 * 1900 像素的原图,压缩到原图的低分辨率的缩略图 270 * 190 像素的图片,这时需要 0.2M 的内存。也就是从 19.6M内存,压缩为 0.2 M内存。 我们还可以进一步优化色彩格式,由 ARGB_8888 改为 RGB_565 模式,这时,目标图片需要的内存就变为 270 * 190 * 2 = 0.1M 了。图片内存空间又减小了一倍。 05.缓存的使用实践优化5.1 Lru内存缓存 LruCache 类特别适合用来缓存 Bitmap,它使用一个强引用的 LinkedHashMap 保存最近引用的对象,并且在缓存超出设定大小时,删除最近最少使用的对象。 给 LruCache 确定一个合适的缓存大小非常重要,我们需要考虑几个因素: 应用剩余多少可用内存? 需要有多少张图片同时显示到屏幕上?有多少图片需要准备好以便马上显示到屏幕? 设备的屏幕大小和密度是多少?高密度的设备需要更大的缓存空间来缓存同样数量的图片。 Bitmap 的尺寸配置是多少,花费多少内存? 图片被访问的频率如何?如果其中一些比另外一些访问更频繁,那么我们可能希望在内存中保存那些最常访问的图片,或者根据访问频率给 Bitmap 分组,为不同的 Bitmap 组设置多个 LruCache 对象。 是否可以在缓存图片的质量和数量之间寻找平衡点?有时,保存大量低质量的 Bitmap 会非常有用,加载更高质量的图片的任务可以交给另外一个后台线程处理。 缓存太小会导致额外的花销却没有明显的好处,缓存太大同样会导致 java.lang.OutOfMemory 的异常,并且使得你的程序只留下小部分的内存用来工作(缓存占用太多内存,导致其他操作会因为内存不够而抛出异常)。所以,我们需要分析实际情况之后,提出一个合适的解决方案。 LruCache是Android提供的一个缓存类,通常运用于内存缓存 LruCache是一个泛型类,它的底层是用一个LinkedHashMap以强引用的方式存储外界的缓存对象来实现的。 为什么使用LinkedHashMap来作为LruCache的存储,是因为LinkedHashMap有两种排序方式,一种是插入排序方式,一种是访问排序方式,默认情况下是以访问方式来存储缓存对象的;LruCache提供了get和put方法来完成缓存的获取和添加,当缓存满时,会将最近最少使用的对象移除掉,然后再添加新的缓存对象。如下源码所示,底层是LinkedHashMap。 public LruCache(int maxSize) { if (maxSize
2022年10月20日
3 阅读
0 评论
0 点赞
2022-10-19
Android 搜索框架使用-掘金
App中搜索功能是必不可少的,搜索功能可以帮助用户快速获取想要的信息。对此,Android提供了一个搜索框架,本文介绍如何通过搜索框架实现搜索功能。搜索框架简介Android 搜索框架提供了搜索弹窗和搜索控件两种使用方式。 搜索弹窗:系统控制的弹窗,激活后显示在页面顶部,输入的内容提交后会通过Intent传递到指定的搜索Activity中处理,可以添加搜索建议。 搜索控件(SearchView):系统实现的搜索控件,可以放在任意位置(可以与Toolbar结合使用),默认情况下与EditText类似,需要自己添加监听处理用户输入的数据,通过配置可以达到与搜索弹窗一致的行为。 使用搜索框架实现搜索功能可搜索配置在res/xml目录下创建searchable.xml(必须用此命名),如下: android:label是此配置文件必须配置的属性,通常配置为App的名字,android:hint配置用户未输入内容时的提示文案,官方建议格式为“搜索${content or product}”。更多可搜索配置包含的语法和用法可以看官方文档。搜索页面配置一个单独的Activity用于显示搜索内容,用户可能会在搜索完一个内容后立刻搜索下一个内容,所以建议把搜索页面设置为SingleTop,避免重复创建搜索页面。在AndroidManifest中配置搜索页面,如下: 在Activity中处理搜索数据,代码如下:class SearchActivity : AppCompatActivity() { private lateinit var binding: LayoutSearchActivityBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.layout_search_activity) // 当搜索页面第一次打开时,获取搜索内容 getQueryKey(intent) } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) // 更新Intent数据 setIntent(intent) // 当搜索页面多次打开,并仍在栈顶时,获取搜索内容 getQueryKey(intent) } private fun getQueryKey(intent: Intent?) { intent?.run { if (Intent.ACTION_SEARCH == action) { // 用户输入的内容 val queryKey = getStringExtra(SearchManager.QUERY) ?: "" if (queryKey.isNotEmpty()) { doSearch(queryKey) } } } } private fun doSearch(queryKey: String) { // 根据用户输入内容执行搜索操作 } } 使用SearchViewSearchView可以放在页面的任意位置,本文与Toolbar结合使用,如何在Toolbar中创建菜单项在上一篇文章中介绍过,此处省略。要使SearchView与搜索弹窗保持一致的行为需要在代码中进行配置,如下:class SearchActivity : AppCompatActivity() { private lateinit var binding: LayoutSearchActivityBinding override fun onCreateOptionsMenu(menu: Menu?): Boolean { menuInflater.inflate(R.menu.example_seach_menu, menu) menu?.run { val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager val searchView = findItem(R.id.action_search).actionView as SearchView //设置搜索配置 searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName)) } return true } ... } 使用搜索弹窗在Activity中使用搜索弹窗,如果Activity已经配置为搜索页面则无需额外配置,否则需要在AndroidManifest中添加配置,如下: 在Activity中通过onSearchRequested方法来调用搜索弹窗,如下:class SearchExampleActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding: LayoutSearchExampleActivityBinding = DataBindingUtil.setContentView(this, R.layout.layout_search_example_activity) binding.btnSearchDialog.setOnClickListener { onSearchRequested() } } } 搜索弹窗对Activity生命周期的影响搜索弹窗的显示隐藏,不会像其他弹窗一样触发Activity的onPause、onResume方法。如果在搜索弹窗显示隐藏的同时需要对其他功能进行处理,可以通过onSearchRequested和OnDismissListener来实现,代码如下:class SearchExampleActivity : AppCompatActivity() { override fun onSearchRequested(): Boolean { // 搜索弹窗显示,可以在此处理其他功能 return super.onSearchRequested() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding: LayoutSearchExampleActivityBinding = DataBindingUtil.setContentView(this, R.layout.layout_search_example_activity) binding.btnSearchDialog.setOnClickListener { onSearchRequested() } val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager searchManager.setOnDismissListener { // 搜索弹窗隐藏,可以在此处理其他功能 } } } 附加额外的参数使用搜索弹窗时,如果需要附加额外的参数用于优化搜索查询的过程,例如用户的性别、年龄等,可以通过如下代码实现:// 配置额外参数 class SearchExampleActivity : AppCompatActivity() { override fun onSearchRequested(): Boolean { val appData = Bundle() appData.putString("gender", "male") appData.putInt("age", 24) startSearch(null, false, appData, false) // 返回true表示已经发起了查询 return true } ... } // 在搜素页面中获取额外参数 class SearchActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) intent?.run { if (Intent.ACTION_SEARCH == action) { // 用户输入的内容 val queryKey = getStringExtra(SearchManager.QUERY) ?: "" // 额外参数 val appData = getBundleExtra(SearchManager.APP_DATA) appData?.run { val gender = getString("gender") ?: "" val age = getInt("age") } } } } } 语音搜索语音搜索让用户无需输入内容就可进行搜索,要开启语音搜索,需要在searchable.xml增加配置,如下: 语音搜索必须配置showVoiceSearchButton用于显示语音搜索按钮,配置launchRecognizer指定语音搜索按钮启动一个语音识别程序用于识别语音转录为文本并发送至搜索页面。更多语音搜索配置包含的语法和用法可以看官方文档。注意,语音识别后的文本会直接发送至搜索页面,无法更改,需要进行完备的测试确保语音搜索功能适合你的App。搜索记录用户执行过搜索后,可以将搜索的内容保存下来,下次要搜索相同的内容时,输入部分文字后就会显示匹配的搜索记录。要实现此功能,需要完成下列步骤:创建SearchRecentSuggestionsProvider自定义RecentSearchProvider继承SearchRecentSuggestionsProvider,代码如下:class RecentSearchProvider : SearchRecentSuggestionsProvider() { companion object { // 授权方的名称(建议设置为文件提供者的完整名称) const val AUTHORITY = "com.chenyihong.exampledemo.search.RecentSearchProvider" // 数据库模式 // 必须配置 DATABASE_MODE_QUERIES // 可选配置 DATABASE_MODE_2LINES,为搜索记录提供第二行文本,可用于作为详情补充 const val MODE: Int = DATABASE_MODE_QUERIES or DATABASE_MODE_2LINES } init { // 设置搜索授权方的名称与数据库模式 setupSuggestions(AUTHORITY, MODE) } } 在AndroidManifest中配置Provider,如下: 修改可搜索配置在searchable.xml增加配置,如下: android:searchSuggestAuthority 的值与RecentSearchProvider中的AUTHORITY保持一致。android:searchSuggestSelection的值必须为" ?",该值为数据库选择参数的占位符,自动由用户输入的内容替换。在搜索页面中保存查询获取到用户输入的数据时保存,代码如下:class SearchActivity : BaseGestureDetectorActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) intent?.run { if (Intent.ACTION_SEARCH == action) { val queryKey = getStringExtra(SearchManager.QUERY) ?: "" if (queryKey.isNotEmpty()) { // 第一个参数为用户输入的内容 // 第二个参数为第二行文本,可为null,仅当RecentSearchProvider.MODE为DATABASE_MODE_QUERIES or DATABASE_MODE_2LINES时有效。 SearchRecentSuggestions(
[email protected]
, RecentSearchProvider.AUTHORITY, RecentSearchProvider.MODE) .saveRecentQuery(queryKey, "history $queryKey") } } } } } 清除搜索历史为了保护用户的隐私,官方的建议是App必须提供清除搜索记录的功能。请求搜索记录可以通过如下代码实现:class SearchActivity : BaseGestureDetectorActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) SearchRecentSuggestions(this, RecentSearchProvider.AUTHORITY, RecentSearchProvider.MODE) .clearHistory() } } 示例整合之后做了个示例Demo,代码如下:// 可搜索配置 // 清单文件 // 示例Activity class SearchExampleActivity : BaseGestureDetectorActivity() { override fun onSearchRequested(): Boolean { val appData = Bundle() appData.putString("gender", "male") appData.putInt("age", 24) startSearch(null, false, appData, false) return true } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding: LayoutSearchExampleActivityBinding = DataBindingUtil.setContentView(this, R.layout.layout_search_example_activity) binding.btnSearchView.setOnClickListener { startActivity(Intent(this, SearchActivity::class.java)) } binding.btnSearchDialog.setOnClickListener { onSearchRequested() } val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager searchManager.setOnDismissListener { runOnUiThread { Toast.makeText(this, "Search Dialog dismiss", Toast.LENGTH_SHORT).show() } } } } class SearchActivity : BaseGestureDetectorActivity() { private lateinit var binding: LayoutSearchActivityBinding private val textDataAdapter = TextDataAdapter() private val originData = ArrayList() private var lastQueryValue = "" override fun onCreateOptionsMenu(menu: Menu?): Boolean { menuInflater.inflate(R.menu.example_seach_menu, menu) menu?.run { val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager val searchView = findItem(R.id.action_search).actionView as SearchView searchView.setOnCloseListener { textDataAdapter.setNewData(originData) false } searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName)) if (lastQueryValue.isNotEmpty()) { searchView.setQuery(lastQueryValue, false) } } return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == R.id.action_clear_search_histor) { SearchRecentSuggestions(this, RecentSearchProvider.AUTHORITY, RecentSearchProvider.MODE) .clearHistory() } return super.onOptionsItemSelected(item) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.layout_search_activity) setSupportActionBar(binding.toolbar) supportActionBar?.run { title = "SearchExample" setHomeAsUpIndicator(R.drawable.icon_back) setDisplayHomeAsUpEnabled(true) } binding.rvContent.adapter = textDataAdapter originData.add("test data qwertyuiop") originData.add("test data asdfghjkl") originData.add("test data zxcvbnm") originData.add("test data 123456789") originData.add("test data /.,?-+") textDataAdapter.setNewData(originData) // 获取搜索内容 getQueryKey(intent, false) } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) // 更新Intent数据 setIntent(intent) // 获取搜索内容 getQueryKey(intent, true) } private fun getQueryKey(intent: Intent?, newIntent: Boolean) { intent?.run { if (Intent.ACTION_SEARCH == action) { val queryKey = getStringExtra(SearchManager.QUERY) ?: "" if (queryKey.isNotEmpty()) { SearchRecentSuggestions(
[email protected]
, RecentSearchProvider.AUTHORITY, RecentSearchProvider.MODE) .saveRecentQuery(queryKey, "history $queryKey") if (!newIntent) { lastQueryValue = queryKey } val appData = getBundleExtra(SearchManager.APP_DATA) doSearch(queryKey, appData) } } } } private fun doSearch(queryKey: String, appData: Bundle?) { appData?.run { val gender = getString("gender") ?: "" val age = getInt("age") } val filterData = originData.filter { it.contains(queryKey) } as ArrayList textDataAdapter.setNewData(filterData) } } ExampleDemo githubExampleDemo gitee效果如图:以上文章来自[掘金]-[ChenYhong]本程序使用github开源项目RSSHub提取聚合!
2022年10月19日
0 阅读
0 评论
0 点赞
2022-10-19
FlutterUnit 更新 | 拓展样式风格切换 - 标准风格-掘金
以上文章来自[掘金]-[张风捷特烈]本程序使用github开源项目RSSHub提取聚合!
2022年10月19日
3 阅读
0 评论
0 点赞
2022-10-19
这个表单打死我也不填!-掘金
本文为掘金社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!前言表单在应用内随处可见,注册、登录、完善个人资料、发表内容……在 B 端应用中,表单操作更是员工日常工作中使用最多的功能。好的表单体验能够让用户更加轻松地完成信息录入,从而让我们获知更多用户的信息、在应用内产生更多内容或者让员工的工作更为轻松。糟糕的表单,则会让用户感到绝望,有一种打死也不想填的感觉!那么,对于开发而言,如何提高表单操作的用户体验?本篇我们就来讲讲常见的提高表单用户体验的方式,推荐在封装表单组件的时候将这些因素考虑进去,形成公司内统一的规范,保持体验的一致性。错误提示表单输入不可避免会出错,及时、准确地给出错误提示能够让用户快速更正错误,提高表单录入的效率。错误提示有以下三种方式: 内联提示:当输入完成后,立即校验表单内容并给出错误提示; 提交时客户端提示:输入完成后,点击提交按钮时对整个表单进行错误校验和提示; 提交后服务端校验:提交到后端后,有服务端应用对表单进行校验。 对于输入表单内容较少的错误提示,推荐采用内联的方式实现。内联方式的错误提示就是将错误信息直接显示在表单附近(通常是下方),这样用户更容易察觉。我们来看看在 Flutter 中如何实现内联的错误提示。 Flutter 的TextField 组件有一个 decoration属性,类型为InputDecoration。通过 InputDecoration 类的errorText和errorStyle可以构建一个 Text 组件,当 errorText 不为空时就会默认在 TextField 下方显示错误指示,从而提示用户输入有误,示例代码和运行结果如下。需要注意的是,错误提示信息应该准确,避免泛泛的错误提示。我曾经遇到过的反面例子就是产品没有明确错误提示(很多产品因为见过不少标准的错误提示,就默认开发也知道如何提示错误,所以文档并不会给出错误提示文案),然后开发直接粗暴地显示一个“输入有误”的标准提示。TextField( autofocus: true, decoration: InputDecoration( label: const Text('邮箱'), hintText: '请输入电子邮箱地址', errorText: _errorText, errorStyle: TextStyle(color: Colors.red[400], fontSize: 14.0), ), onEditingComplete: () { if (!FormUtils.isValidEmail(_email)) { setState(() { _errorText = '请输入正确的邮箱地址'; }); } else { setState(() { _errorText = null; }); } }, onChanged: (text) { _email = text; }, ) 对于表单录入内容较多的情况来说,推荐使用提交时进行错误校验和提示。因为表单内容较多时,通常用户更希望专注输入内容,如果经常给出错误提示会转移用户注意力,打断用户填写表单的思路。标签表单的标签有三种,顶部标签、左侧标签和浮动标签,当然简洁的设计有时候会使用图标替代标签文字。研究表明,顶部标签的方式填写表单的效率更高,因为一路向下填写即可,视线不需要实现 Z 字形路线移动,但是顶部标签相对来说比较占空间,在移动端不太多见。大多数国外的应用顶部标签居多,例如下面是亚马逊的登录界面。 gmail 则别出新裁地在默认的时候省去了标签,一旦聚焦标签显示在输入框的上边框,可以说是将效率和空间做了充分的利用(这种效果在 Flutter 中默认就有,只要TextField 制定了边框,就会在聚焦后将标签显示在边框上)。 在 Flutter 中,就如同我们上面的例子那样,默认标签是不可见的,聚焦后才会以小字的形式显示上面的标签。我们来看看 Flutter 文本输入框的标签具体如何设置。Flutter 为设置标签样式和交互提供了3个属性: labelStyle:TextStyle 对象,用于设置默认的标签样式。 floatingLabelStyle:设置浮动状态(即标签在输入框上方)时的标签状态,这个属性可以是 TextStyle 对象也可以是MaterialStateTextStyle对象,如果是MaterialStateTextStyle对象的话,就可以设置不同状态下的标签样式,比如错误、聚焦和光标移入等。 floatingLabelBehavior:设置浮动标签的交互行为,默认是auto,即聚焦的时候才显示,也可以设置为 never 不显示标签,或者设置为 always 一直显示标签。 下面是对应的代码,我们来看看不同形式的区别:Column( children: [ TextField( autofocus: true, decoration: InputDecoration( label: const Text('邮箱'), hintText: '请输入电子邮箱地址', floatingLabelStyle: MaterialStateTextStyle.resolveWith( (Set states) { final Color color; if (states.contains(MaterialState.error)) { color = Colors.red[400]!; } else if (states.contains(MaterialState.focused)) { color = Colors.blue[400]!; } else { color = Colors.black54; } return TextStyle( color: color, ); }), floatingLabelBehavior: FloatingLabelBehavior.always, errorText: _errorText, errorStyle: TextStyle(color: Colors.red[400], fontSize: 14.0), ), onEditingComplete: () { if (!FormUtils.isValidEmail(_email)) { setState(() { _errorText = '请输入正确的邮箱地址'; }); } else { setState(() { _errorText = null; }); } }, onChanged: (text) { _email = text; }, ), TextField( decoration: InputDecoration( label: const Text('密码'), hintText: '请输入密码', floatingLabelBehavior: FloatingLabelBehavior.auto, ), obscureText: true, ), TextField( decoration: InputDecoration( label: const Text('确认密码'), hintText: '请再次输入密码', floatingLabelBehavior: FloatingLabelBehavior.never, ), obscureText: true, ), ], ), 第一个表单邮箱我们使用了固定显示标签的形式,同时通过floatingLabelStyle设置了不同状态的标签颜色; 第二个表单密码我们使用了auto 模式,可以看到标签一开始是在表单行内的,聚焦后标签移到了输入框上方,而输入框会展示占位文字; 第三个确认密码我们使用了never 模式,标签一开始是在表单行内的,聚焦后标签移到了输入框上方,而输入框会展示占位文字,但标签会消失不见。 从体验上来说,第一种和第二种会更好些,不过在国内大部分是直接将标签省略或者使用左侧标签。下面是阿里云的注册界面,没有标签的表单适用于表单项较少的场景。 对于 Flutter 的 TextField,如果要使用左侧标签,则需要自定义实现(可以参考之前的文章:)。这里需要注意,虽然 TextField 提供了一个 prefix 可以自定义前置组件,但是这个组件只会在文本框聚焦的时候才显示。键盘键盘处理在手机端非常常见了,建议根据输入类型来设置键盘类型,常见的键盘类型和用途如下: TextInputType.number:输入数字,带小数点; TextInputType.phone:输入数字,*和#字符,适合拨打电话或输入号码; TextInputType.email:输入电子邮箱地址; TextInputType.url:适合输入链接地址,会默认显示/和.字符; TextInputType.multiline:适合多行输入,带有回车符,回车后自动换行; 需要注意的是,安卓和 iOS 的键盘会有些区别,具体可以看看 TextInputType 的各个枚举值的说明。表单项过多当要录入的表单项很多的时候,如果表单没有条理性,会让用户望而生畏,忍受力强的用户发发闹骚,无法忍受的用户直接放弃。比如下面这样的注册表单,看到就会绝望,打死我也不会填的。 怎么处理表单项过多?推荐的做法有三种: 表单分组:比如将必填的表单分为一组,其他选填的表单分为一组;这种在 PC 端会更适用。 分步骤填写:还是表单分组,但是分多个步骤,每页展示的表单数量少,不会让用户一下子感觉要填很多东西。而且,从心理学上来说,前面填了一部分会让用户积累沉没成本,后面的表单会更愿意填。同时,对于非必填的分组提供跳过选项。 利用时间差:也就是一开始的时候让用户只填很少的信息,然后在之后的使用过程中逐步引导用户完善其他资料。比如在领英里,每次就会让我们填写一点点信息,最终将个人信息收集完整。 表单校验前面错误提示的时候,已经讲过一些校验的内容了。这里要说的是,我们开发一定要养成对表单进行校验的习惯。本人就遇到过这样的情况,产品没有特别说明校验规则,然后前后端都不校验,结果录入的数据导致各种各样的 bug。这种写 bug 的行为会极大降低我们程序员的段位!对于校验,建议是在内部形成统一的默认规则,比如下面几点: 文本输入的最大输入长度限制; 数值的最大输入范围; 小数默认保留的位数; 常用类型数据的校验规则统一,例如姓名、邮箱、手机号、身份证号、经纬度、日期、企业统一社会信用代码,密码强度、金额等。避免前后端校验规则不一致。 至于什么时候校验,建议是按表单数量来定,表单数量少可以在输入完成后进行校验;表单数量多推荐是在完成输入后整体校验。同时,前端务必保证提交给后端数据的基本合法性(除了需要后端通过业务逻辑校验的除外,例如唯一性检查,是否存在检查等)。总结如果评估一个前端开发做事情的细致程度,通常会通过他写的表单业务来评估。基本上,测试一遍表单的输入交互、错误验证就能够判断出来。一个好的前端,即便是产品不说,也会按照规范将交互和基本的校验做好。同时,保持与产品的沟通也很重要,如果对表单的校验规则有疑惑,那一定是需要二次确认的。虽然不确认锅可以甩给产品,但是 bug 却是留在自己头上的。以上文章来自[掘金]-[岛上码农]本程序使用github开源项目RSSHub提取聚合!
2022年10月19日
0 阅读
0 评论
0 点赞
2022-10-18
【Flutter 异步编程 -陆】 | 探索 Dart 消息处理与微任务循环-掘金
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!张风捷特烈 - 出品上一篇中,我们通过研究 Future 的源码,认识了 Future 类只是一层 监听-通知 机制的封装。并引出了 Timer 和 scheduleMicrotask 两个可以异步触发回调函数的方式。本文我们将继续对这两者进行探索,认识一下其背后隐藏的 消息处理机制 与 微任务循环 。一、从 Timer 认识消息处理我们已经知道, Future.delayed 和 Future 构造创建的延时 异步任务 ,本质上是 Timer 对象设置定时回调。对于回调来说,有两个非常重要的线索:回调函数去哪里了 ?回调函数如何触发的 ?1.Timer 回调是如何触发对于 Timer 来说,可以通过 Duration 指定时长,触发回调函数。要注意,这里的 Duration 并非严格的时长,也不是由于程序运行中的不稳定性出现的 误差 。如下案例中,在发起 200 ms 的延迟异步任务后,执行 loopAdd 的同步任务,进行 10 亿次 累加,耗时 444 ms 。即使异步在设置的期望延迟执行时间为 200 ms ,但实际运行时异步回调 也必须在同步任务执行完毕才可以触发。如下 Timer 的回调在 449 ms 之后被触发。之前有个朋友做节拍器,通过 Timer 开启循环任务,发现误差很大,因为 Timer 本来就不是用来处理精确时间的,对时间精度要求高的场景,需要使用 Ticker 。main() { int start = DateTime.now().millisecondsSinceEpoch; Timer(const Duration(milliseconds: 200),(){ int end = DateTime.now().millisecondsSinceEpoch; print('Timer Cost: ${(end-start)} ms'); }); loopAdd(1000000000); } int loopAdd(int count) { int startTime = DateTime.now().millisecondsSinceEpoch; int sum = 0; for (int i = 0; i [_RawReceivePortImpl#_handleMessage]---- @pragma("vm:entry-point", "call") static _handleMessage(int id, var message) { final handler = _portMap[id]?['handler']; // tag1 if (handler == null) { return null; } handler(message); // tag2 _runPendingImmediateCallback(); return handler; } handler 函数对象在 tag1 处被赋值,它的值为: 以 id 为 key 在 _portMap 中取值的 handler 属性。也就是说,_portMap 是一个映射对象,键是 int 类型,值是 Map 类型。static final _portMap = {}; 通过调试可以发现,此时的 handler 对象类型如下:是只有一个参数,无返回值的 _handleMessage 静态方法。那这个 _handleMessage 是什么呢?反正肯定不是上面的 _handleMessage 方法。从调试中可以清楚地看到,这个 handler 函数触发时,会进入 _Timer._handleMessage 方法中,所以 handler 自然指的是 _Timer._handleMessage ,函数签名也是可以对得上的。这就有个问题:既然 _RawReceivePortImpl#_handleMessage 方法中,可以通过 _portMap 找到该函数,那么_Timer#_handleMessage 一定在某个时刻被加入到了 _portMap 中。下面,我们通过这条线索,追踪一下 _RawReceivePortImpl 如何设置处理器。3. _RawReceivePortImpl 设置处理器在 _Timer 中搜索一下 _handleMessage 就很容易看到它被设置的场景:如下所示,在 _Timer#_createTimerHandler 方法中, 458 行 创建 _RawReceivePortImpl 类型的 port 对象,并在 459 行 为其设置 handler,值正是 _Timer#_handleMessage 方法。其实从这里就能大概猜到 _RawReceivePortImpl#handler 的 set 方法的处理逻辑,大家可以暂停脑补一下,猜猜看。没错,_RawReceivePortImpl#handler 的 set 方法,就是为 _portMap 映射添加元素的。这就和 _RawReceivePortImpl#_handleMessage 从 _portMap 映射中取出 handler 函数对象交相呼应。到这里,可以知道 _Timer#_createTimerHandler 方法会将回调加入映射中去,就可以大胆的猜测一下,在 Timer 对象创建后,一定会触发 _createTimerHandler。4. Timer 对象的创建与回调函数的 "流浪"首先 Timer 是一个 抽象类 ,本身并不能实例化对象。如下是 Timer 的普通工厂构造,返回值是由 Zone 对象通过 createTimer 方法创建的对象,也就是说该方法一定会返回 Timer 的实现类对象。另外,我们有了一条新线索,可以追寻一下入参中的回调 callback 是如何在源码中 "流浪" 的。---->[Timer]---- factory Timer(Duration duration, void Function() callback) { if (Zone.current == Zone.root) { return Zone.current.createTimer(duration, callback); } return Zone.current .createTimer(duration, Zone.current.bindCallbackGuarded(callback)); } 由于默认情况下, Zone.current 是 Zone.root , 其对应的实现类是 _RootZone 。也就是说 Timer 的构造会触发 _RootZone#createTimer 方法。static const Zone root = _rootZone; const _Zone _rootZone = const _RootZone(); 所以,callback流浪的第一站是 _RootZone 的 createTimer 方法,如下所示。其中会触发 Timer 的 _createTimer 静态方法,这也是 callback 流浪的第二站:---->[_RootZone#createTimer]---- Timer createTimer(Duration duration, void f()) { return Timer._createTimer(duration, f); } Timer#_createTimer 静态方法是一个 external 的方法,在 Flutter 提供的 sdk 中是找不到的。---->[Timer#_createTimer]---- external static Timer _createTimer( Duration duration, void Function() callback); 可以在 【dart-lang/sdk/sdk/_internal/vm/lib】 中找到内部文件,比如这里是 timer_patch.dart。在调试时可以显示相关的代码,但无法进行断点调试。 (PS 不知道有没有人知道如何能调试这种内部文件)接下来, callback 会进入第三站:作为 factory 函数的第二参回调的处理逻辑。现在问题来了,factory 对象是什么东西? 从代码来看,它是 VMLibraryHooks 类的静态成员 timerFactory。由于 factory 可以像函数一样通过 () 执行,毫无疑问它是一个函数对象,函数的签名也很明确: 三个入参,类型依次是 int 、回调函数 、bool ,且返回 Timer 类型成员。如下是内部文件 timer_impl.dart 中的代码。其中 _Timer 是 Timer 的实现类,也是 dart 中 Timer 构造时创建的实际类型。从中可以看到 VMLibraryHooks.timerFactory 被赋值为 _Timer._factory ,所以上面说的 factory 是什么就不言而喻了。class _Timer implements Timer { 从 481 行 代码可以看出,callback 进入的第三站是 _Timer 的构造函数:在 _Timer 的构造函数中,callback 进入的第四站: _Timer#_createTimer 方法。其中 _Timer 通过 _internal 构造,将 callback 作为参数传入,为 _Timer 的 _callback 成员赋值。自此, callback 进入第五站,被 _Timer 持有,结束了流浪生涯,终于安家。---->[_Timer 构造]---- factory _Timer(int milliSeconds, void callback(Timer timer)) { return _createTimer(callback, milliSeconds, false); } ---->[_Timer#_createTimer]---- static _Timer _createTimer( if (milliSeconds < 0) { milliSeconds = 0; } int now = VMLibraryHooks.timerMillisecondClock(); int wakeupTime = (milliSeconds == 0) ? now : (now + 1 + milliSeconds); _Timer timer = new _Timer._internal(callback, wakeupTime, milliSeconds, repeating); timer._enqueue(); // tag1 return timer; } ---->[_Timer#_internal]---- _Timer._internal( this._callback, this._wakeupTime, this._milliSeconds, this._repeating) : _id = _nextId(); 另外说一句,上面的 tag1 处 timer._enqueue() 会触发 _notifyZeroHandler ,从而导致 _createTimerHandler 的触发。也就是上面 第 3 小节 中为 _RawReceivePortImpl 设置处理器的契机。_enqueue --> _notifyZeroHandler --> _createTimerHandler 到这里可以把线索串连一下,想一想:一个 Timer 对象是如何实例化的;其中 callback 对象是如何一步步流浪,最终被 _Timer 对象持有的; _createTimerHandler 是如何为 _RawReceivePortImpl 设置 handler 的。5. Timer 中的堆队列与链表队列在 _Timer#_enqueue 中,当定义的时长非零时,通过堆结构维护 Timer 队列。 数据结构定义在 _TimerHeap 中,就算一个非常简单的 二叉堆,感兴趣的可以自己看看。 _Timer 类持有 _TimerHeap 类型的静态成员 _heap, 这里 enqueue 就是 进入队列 的意思。---->[_Timer#_heap]---- static final _heap = new _TimerHeap(); 另外,_Timer 本身是一个链表结构,其中持有 _indexOrNext 属性用于指向下一节点,并维护 _firstZeroTimer 和 _lastZeroTimer 链表首尾节点。在 _enqueue 方法中,当 Timer 时长为 0 ,会通过链表结构维护 Timer 队列。---->[_Timer]---- Object? _indexOrNext; static _Timer _lastZeroTimer = _sentinelTimer; static _Timer? _firstZeroTimer; ---->[_Timer#_enqueue]---- if (_milliSeconds == 0) { if (_firstZeroTimer == null) { _lastZeroTimer = this; _firstZeroTimer = this; } else { _lastZeroTimer._indexOrNext = this; // _lastZeroTimer = this; } // Every zero timer gets its own event. _notifyZeroHandler(); 在 _Timer#_handleMessage 中,会触发 _runTimers 方法,其中入参的 pendingTimers 就是从堆 和 链表 队列中获取的,已超时或零时 Timer 对象。在 _runTimers 中,会遍历 pendingTimers,取出 Timer中的 _callback 成员, 这个成员就算创建 Timer 时的入参函数,在 398 行触发回调。这就是 Timer 回调函数在 Dart 中的一生。把这些理清楚之后, Timer 对象在 Dart 中的处理流程就算是比较全面了。二、消息的发送端口和接收端口其实 Dart 中的 Timer 代码只是流程中的一部分。对于消息处理机制来说,还涉及 Dart 虚拟机 中 C++ 代码的处理。也就是 Isolate 的通信机制,这里稍微介绍一下,也有利于后面对 Isolate 的认识。1. Timer 的发送端口 SendPort在 Timer 创建并进入队列后,会在 _createTimerHandler 中创建 _RawReceivePortImpl 对象,这个对象从名称上来看,是一个 接收端口 的实现类。顾名思义,接收端是由于接收消息、处理消息的。通过前面的调试我们知道,接收端消息的处理方法是由 handler 进行设置,如下 tag1 处。既然有 接收端口,自然有 发送端口, _Timer 中持有 SendPort 类型的 _sendPort 对象。该成员在 tag2 处由 接收端口 的 sendPort 赋值。---->[_Timer#_sendPort]---- static SendPort? _sendPort; static _RawReceivePortImpl? _receivePort; static void _createTimerHandler() { var receivePort = _receivePort; if (receivePort == null) { assert(_sendPort == null); final port = _RawReceivePortImpl('Timer'); port.handler = _handleMessage; // tag1 _sendPort = port.sendPort; // tag2 _receivePort = port; _scheduledWakeupTime = 0; } else { receivePort._setActive(true); } _receivePortActive = true; } _RawReceivePortImpl 中的 sendPort 的 get 方法,由 _get_sendport 外部方法实现。最终由 C++ 代码实现,在 【dart-lang/sdk/runtime/lib/isolate.cc】 中触发:---->[_RawReceivePortImpl#sendPort]---- SendPort get sendPort { return _get_sendport(); } @pragma("vm:external-name", "RawReceivePortImpl_get_sendport") external SendPort _get_sendport(); _createTimerHandler 方法触发之后, _Timer 中的发送、接收端口已经准备完毕。之后自然是要发送消息。在 _Timer#_enqueue 中如果是时长为 0 的定时器,在 tag2 处会通过 _sendPort 发送 _ZERO_EVENT 的事件。---->[_Timer#_enqueue]---- void _enqueue() { if (_milliSeconds == 0) { // 略... _notifyZeroHandler(); // tag1 // 略... } ---->[_Timer#_notifyZeroHandler]---- static void _notifyZeroHandler() { if (!_receivePortActive) { _createTimerHandler(); } _sendPort!.send(_ZERO_EVENT); // tag2 } 2. 零延迟定时器消息的发送_SendPortImpl 是 SendPort 的唯一实现类。零延迟定时器加入链表队列后,发送 _ZERO_EVENT 消息使用的是如下的 send 方法,最终会通过 SendPortImpl_sendInternal_ 的 C++ 方法进行实现,向 Dart 虚拟机 发送消息。class _SendPortImpl implements SendPort { @pragma("vm:entry-point", "call") void send(var message) { _sendInternal(message); } @pragma("vm:external-name", "SendPortImpl_sendInternal_") external void _sendInternal(var message); 如下是 【dart-lang/sdk/runtime/lib/isolate.cc】 中 SendPortImpl_sendInternal_ 入口的逻辑。会通过 PortMap 的 PostMessage 静态方法发送消息。PortMap 是定义在 port.h 在的 C++ 类,其中 PostMessage 是一个静态方法,从注释来看,该方法会将消息加入消息队列:---->[sdk untimevmport.h]---- class PortMap : public AllStatic { // Enqueues the message in the port with id. Returns false if the port is not // active any longer. // // Claims ownership of 'message'. static bool PostMessage(std::unique_ptr message, bool before_events = false); } 在 port.cc 对 PostMessage 方法实现时,由 MessageHandler#PostMessage 进行处理:---->[sdk untimevmport.cc]---- bool PortMap::PostMessage(std::unique_ptr message, bool before_events) { MutexLocker ml(mutex_); if (ports_ == nullptr) { return false; } auto it = ports_->TryLookup(message->dest_port()); if (it == ports_->end()) { // Ownership of external data remains with the poster. message->DropFinalizers(); return false; } MessageHandler* handler = (*it).handler; ASSERT(handler != nullptr); handler->PostMessage(std::move(message), before_events); return true; } MessageHandler 类中有两个 MessageQueue 消息队列成员, queue_ 和 oob_queue_ 。如下,在 message_handler.cc 中,会根据 message#IsOOB 值将消息加入到消息队列中。 IsOOB 由 Message 对象的优先级 Priority 枚举属性决定。 为 OOB 消息 是优先处理的消息。从 SendPortImpl_sendInternal_ 中加入的消息是 kNormalPriority 优先级的,也就是普通消息。---->[MessageHandler]---- MessageQueue* queue_; MessageQueue* oob_queue_; ---->[sdk untimevmmessage_handler.cc]---- void MessageHandler::PostMessage(std::unique_ptr message, bool before_events) { Message::Priority saved_priority; // 略... saved_priority = message->priority(); if (message->IsOOB()) { oob_queue_->Enqueue(std::move(message), before_events); } else { queue_->Enqueue(std::move(message), before_events); } // 略... } // Invoke any custom message notification. MessageNotify(saved_priority); } 3. Dart 中 _RawReceivePortImpl#_handleMessage 的触发在消息加入队列之后,会触发 MessageNotify 进行处理,这里就不细追了。最后看一下,C++ 的代码是如何导致 Dart 中的_RawReceivePortImpl#_handleMessage 方法触发的。如下所示,【runtime/vm/dart_entry.cc】 中定义了很多入口函数。其中 DartLibraryCalls::HandleMessage 里会触发 object_store 中存储的 handle_message_function :在【runtime/vm/object_store.cc】 的 LazyInitIsolateMembers 中,会将 _RawReceivePortImpl 的 _handleMessage 存储起来。这就是 C++ 中 DartLibraryCalls::HandleMessage 会触发Dart 中 _RawReceivePortImpl#_handleMessage 的原因。到这里,我们再回首一下本文开始时的调试结果。大家可以结合下面的线索自己疏通一下: Timer 对象的创建、 Dart 端设置监听、回调函数的传递、 Timer 队列的维护 、Timer 发送和接收端口的创建、Timer 发送消息、消息加入 C++ 中消息息队列、最后 C++ 处理消息,通知 Dart 端触发 _RawReceivePortImpl#_handleMessage。这就是 Timer 异步任务最简单的消息处理流程。3. 有延迟定时器消息的发送前面介绍的是零延迟的消息发送,下面看一下有延迟时消息发送的处理。如下所示,当进入队列时 _milliSeconds 非零,加入堆队列,通过 _notifyEventHandler 来发送消息:---->[_Timer#_enqueue]---- void _enqueue() { if (_milliSeconds == 0) { // 略... } else { _heap.add(this); if (_heap.isFirst(this)) { _notifyEventHandler(); } } } _notifyEventHandler 在开始会进行一些空链表的校验,触发 _scheduleWakeup 方法,告知 EventHandler 在指定的时间后告知当前的 isolate 。其中发送通知的核心方法是 VMLibraryHooks#eventHandlerSendData :---->[_Timer#_enqueue]---- static void _notifyEventHandler() { // 略 基础判断... if ((_scheduledWakeupTime == 0) || (wakeupTime != _scheduledWakeupTime)) { _scheduleWakeup(wakeupTime); } } ---->[_Timer#_enqueue]---- // Tell the event handler to wake this isolate at a specific time. static void _scheduleWakeup(int wakeupTime) { if (!_receivePortActive) { _createTimerHandler(); } VMLibraryHooks.eventHandlerSendData(null, _sendPort!, wakeupTime); _scheduledWakeupTime = wakeupTime; } 通过 eventHandler 发送的消息,由 C++ 中 【sdk/runtime/bin/eventhandler.cc】 处理,如下所示:交由 EventHandler 类触发 SendData 处理:EventHandler 中持有 EventHandlerImplementation 类型的 delegate_ 成员, SendData 方法最终由实现类完成:class EventHandler { public: EventHandler() {} void SendData(intptr_t id, Dart_Port dart_port, int64_t data) { delegate_.SendData(id, dart_port, data); } // 略... private: friend class EventHandlerImplementation; EventHandlerImplementation delegate_; 不同的平台都有相关的实现类,具体处理逻辑就不细追了。因为定时的延迟任务不会阻塞当前线程,使用肯定要交给 C++ 创建子启线程处理。延迟完成之后,最终会通知 Dart 端触发 _RawReceivePortImpl#_handleMessage,从而完成定时回调的任务。可以看出一个小小的 延迟任务 , 其中涉及的知识也是非常广泛的。通过 Timer 来了解消息处理机制是一个比较好的切入点。三、 main 函数的启动与微任务循环上一篇我们知道 scheduleMicrotask 的回调对于 main 函数体来说也是异步执行的,但那它和 Timer 触发的延迟任务有着本质的区别。接下来我们将对 scheduleMicrotask 进行全面分析,从中就能明白为什么 scheduleMicrotask 中的回调总可以在 Timer 之前触发。 不过在此之前,必须说明一下 main 函数的触发。1. main 函数的触发在我们的认知中,main 函数是程序的入口。但如果打个断点调试一下会发现,其实 main 函数本身是一个回调。和 Timer 回调一样,也是由 _RawReceivePortImpl#_handleMessage 方法触发的:所以 main 函数的触发也是涉及 消息通知机制,如下所示: _startIsolate 触发 _delayEntrypointInvocation 方法,其中创建 RawReceivePort 接收端看对象,并为 handler 赋值。@pragma("vm:entry-point", "call") void _startIsolate( Function entryPoint, List? args, Object? message, bool isSpawnUri) { _delayEntrypointInvocation(entryPoint, args, message, isSpawnUri); } void _delayEntrypointInvocation(Function entryPoint, List? args, Object? message, bool allowZeroOneOrTwoArgs) { final port = RawReceivePort(); port.handler = (_) { // tag1 port.close(); if (allowZeroOneOrTwoArgs) { // 略... } else { entryPoint(); // tag2 } } else { entryPoint(message); } }; port.sendPort.send(null); } 也就是说收到消息,触发 _RawReceivePortImpl#_handleMessage 时,执行的 handler 就是 tag1 所示的函数。 tag2 的 entryPoint() 方法就是 main 方法。2. scheduleMicrotask 回调函数的触发如下所示,在 scheduleMicrotask 的回调中打上断点,可以看出它也是由 _RawReceivePortImpl#_handleMessage 触发的。难道 scheduleMicrotask 也是走的消息处理机制? 这个问题萦绕我很久,现在幡然醒悟:其实不然 !void main() { scheduleMicrotask(() { print("executed1"); //
2022年10月18日
0 阅读
0 评论
0 点赞
2022-10-18
Kotlin内存陷阱 | inline虽好,但别滥用-掘金
持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情引言inline ,翻译过来为 内联 ,在 Kotlin 中,一般建议用于 高阶函数 中,目的是用来弥补其运行时的 额外开销。其原理也比较简单,在调用时将我们的代码移动到调用处使用,从而降低方法调用时的 栈帧 层级。栈帧: 指的是虚拟机在进行方法调用和方法执行时的数据结构,每一个栈帧里都包含了相应的数据,比如 局部参数,操作数栈等等。Jvm在执行方法时,每执行一个方法会产生一个栈帧,随后将其保存到我们当前线程所对应的栈里,方法执行完毕时再将此方法出栈,所以内联后就相当于省了一个栈帧调用。如果上述描述中,你只记住了后半句,降低栈帧 ,那么此时你可能已经陷入了一个使用陷阱?错误示例如下截图中所示,我们随便创建了一个方法,并增加了 inline 关键字:观察截图会发现,此时IDE已经给出了提示,它建议你移除 inline , Why? 为什么呢?🥲不是说内联可以提高性能吗,那么不应该任何方法都应该加 inline 提高性能吗?(就是这么倔强🤌🏼)上面我们提到了,内联是会将代码移动到调用处,降低 一层栈帧,但这个性能提升真的大吗?再仔细想想,移动到调用处,移动到调用处。这是什么概念呢?假设我们某个方法里代码只有两行(我想不会有人会某个方法只有一行吧🥲),这个方法又被好几处调用,内联是提高了调用性能,毕竟节省了一次栈帧,再加上方法行数少。但如果方法里代码有几十行?每次调用都会把代码内联过来,那调用处岂不💥,从而影响包大小。相比起来,此时内联的这点性能可以说完全不值得(因为虚拟机本身也会对方法进行优化)。如下图所示,我们对上述示例做一个论证:Jvm: 我谢谢你。推荐示例我们在文章最开始提到了,Kotlin inline ,一般建议用于 高阶函数(lambda) 中。为什么呢?如下示例:转成字节码后,可以发现,tryKtx() 被创建为了一个匿名内部类 (Simple$test|1) 。每次调用时,相当于需要创建匿名类的实例对象,从而导致二次调用的性能损耗。那如果我们给其增加 inline 呢?🤖,反编译后相应的 java代码 如下:具体对比图如上所示,不难发现,我们的调用处已经被替换为原方法,相应的 lambda 也被消除了,从而显著减少了性能损耗。总结如果查看官方库相应的代码,如下所示,比如 with :public inline fun with(receiver: T, block: T.() -> R): R { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } return receiver.block() } 不难发现,inline 的大多数场景仅且在 高阶函数 并且 方法行数较短 时适用。因为对于普通方法,jvm本身对其就会进行优化,所以 inline 在普通方法上的的意义几乎聊胜于无。总结如下: 因为内联函数会将方法函数移动到调用处,会增加调用处的代码量,所以对于较长的方法应该避免使用; 内联函数应该用于使用了 高阶函数(lambda) 的方法,而不是普通方法。 关于本系列Kotlin 是一个越用越爽的语言,从null安全,支持方法扩展与属性扩展,到内联方法、内联类,使用Kotlin也越来越变得简单舒心。但编程从来不是一件简单的事,那些看似简单的代码,底层往往隐藏着不容忽视的内存开销。本系列正是结合实际开发,分享日常经验,以此尽可能避免这些问题。追求 简、精、深,拒绝长篇废话。更多文章如下: Kotlin内存陷阱 | inline虽好,但别滥用 Kotlin内存陷阱 | 关于密封类,你可能还需要知道的 Kotlin内存陷阱 | 你的伴生对象可能真的不需要 Kotlin内存陷阱 | 注意,你的 lazy 可能没有意义 Kotlin内存陷阱 | 以下日常行为,请尽可能避免 ... 关于我我是 Petterp ,一个三流开发,如果本文对你有所帮助,不妨点赞评论支持一波。 🫡来日方长,愿与君共破浪。🌊以上文章来自[掘金]-[Petterp]本程序使用github开源项目RSSHub提取聚合!
2022年10月18日
1 阅读
0 评论
0 点赞
2022-10-18
Compose 动画艺术探索之灵动岛-掘金
在网上找了写灵动岛的视频,大家想看的可以点击链接去看下,肯定比Gif图清晰。灵动岛视频嗯,这样看着确实挺好看,如果不是见过真机显示效果我真的就信了😂,不过还是上面说的,思路奇特,大方承认缺点值得肯定!Compose 简单实现之前几篇文章大概说了下 Compose 中的动画,思考下这个动画该如何写?我刚看到这个动画的时候也觉得实现起来不容易,但其实转念一想并不难,其实这些动画总结下来就是根据事件不同 Size 的大小也发生了改变,如果在之前原生安卓实现的话会复杂一些,但在 Compose 中就很简单了,还记得之前几篇文章中提到的 animateSizeAsState 么?这是 Compose 中开箱即用的 API,这里其实就可以使用这个来实现,来一起看下代码!@Composable fun DynamicScreen() { var isCharge by remember { mutableStateOf(true) } val animateSizeAsState by animateSizeAsState( targetValue = Size(if (isCharge) 170f else 100f, 30f) ) Column { Box(modifier = Modifier .width(animateSizeAsState.width.dp) .height(animateSizeAsState.height.dp) .shadow(elevation = 3.dp, shape = RoundedCornerShape(15.dp)) .background(color = Color.Black), ) Button( modifier = Modifier.padding(top = 30.dp, bottom = 5.dp), onClick = { isCharge = false }) { Text(text = "默认状态") } Button( modifier = Modifier.padding(vertical = 5.dp), onClick = { isCharge = true }) { Text(text = "充电状态") } } } 其实核心代码只有一行,就是上面所说的 animateSizeAsState ,其他的代码基本都在画布局,这里使用 Box 来画了下灵动岛的黑色圆角,并且将 box 的背景设置为了黑色,然后画了两个按钮,一个表示充电状态,另一个表示默认状态,点击按钮就可以进行切换,来看下效果!大概样式有了,但是不是感觉少了点什么?没错!苹果的动画有回弹效果,但咱们这个没有,那该怎么办呢?还好上一篇文章中咱们讲过动画规格,这里就使用 Spring 就可以满足咱们的需求了,如果想详细了解 Compose 动画规格的话可以移步上一篇文章:Compose 动画艺术探索之动画规格。来稍微改下代码:val animateSizeAsState by animateSizeAsState( targetValue = Size(if (isCharge) 170f else 100f, 30f), animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow ) ) 别的代码都没动,只是修改了下动画规格,再来看下效果!嗯,是不是有点意思了!实现多种切换上面咱们简单实现了充电的一种状态,但是咱们可以看到苹果里面可不止这一种,上面咱们使用的是 Boolean 值来进行切换的,但如果多种状态的话 Boolean 就有点力不从心了,这个时候就得考虑新的方案了!private sealed class BoxState(val height: Dp, val width: Dp) { // 默认状态 object NormalState : BoxState(30.dp, 100.dp) // 充电状态 object ChargeState : BoxState(30.dp, 170.dp) // 支付状态 object PayState : BoxState(100.dp, 100.dp) // 音乐状态 object MusicState : BoxState(170.dp, 340.dp) // 多个状态 object MoreState : BoxState(30.dp, 100.dp) } 可以看到上面代码中写了一个密封类,参数就是灵动岛的宽和高,然后根据苹果灵动岛的样式大概可以分为了几种状态:默认状态就是一小条;充电状态高度较默认状态不变,宽度增加;支付状态高度增加,宽度较默认状态不变;音乐状态高度和宽度都较默认状态增加;多个应用状态宽度不变,但会多出一个小黑圆点。下面还需要修改下状态:var boxState: BoxState by remember { mutableStateOf(BoxState.NormalState) } 将状态值由 Boolean 改为了刚刚编写的 BoxState ,然后修改下 animateSizeAsState 的使用:val animateSizeAsState by animateSizeAsState( targetValue = Size(boxState.width.value, boxState.height.value), animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow ) ) 接下来再修改下按钮的点击事件:Button( modifier = Modifier.padding(top = 30.dp, bottom = 5.dp), onClick = { boxState = BoxState.NormalState }) { Text(text = "默认状态") } Button( modifier = Modifier.padding(vertical = 5.dp), onClick = { boxState = BoxState.ChargeState }) { Text(text = "充电状态") } 可以看到代码较上面基本没什么改动,只是在点击的时候切换了对应的 BoxState 值。下面再添加几个按钮来对应上面编写的几种状态:Button( modifier = Modifier.padding(vertical = 5.dp), onClick = { boxState = BoxState.PayState }) { Text(text = "支付状态") } Button( modifier = Modifier.padding(vertical = 5.dp), onClick = { boxState = BoxState.MusicState }) { Text(text = "音乐状态") } 嗯,代码很简单,就不过多描述,直接运行看效果吧!嗯,效果是不是已经出来了,哈哈哈,是不是很简单,代码实现个简单样式固然不难,但是如果想把系统应用甚至三方应用都适配灵动岛可不是一个简单的事。不过这里咱们值考虑如何实现灵动岛的动画,并不深究系统实现的问题及瓶颈。多应用状态上面基本已经实现了灵动岛的大部分动画,但状态中还有一个多应用,就是多个应用在灵动岛上的显示效果还没弄。多应用状态和别的不太一样,别的状态都是灵动岛宽高的变化,但多应用状态会多分出一个小黑圆点,这个需要单独写下。val animateDpAsState by animateDpAsState( targetValue = if (boxState is BoxState.MoreState) 105.dp else 70.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow ) ) Box { Box( modifier = Modifier .width(animateSizeAsState.width.dp) .height(animateSizeAsState.height.dp) .shadow(elevation = 3.dp, shape = RoundedCornerShape(15.dp)) .background(color = Color.Black), ) Box( modifier = Modifier .padding(start = animateDpAsState) .size(30.dp) .shadow(elevation = 3.dp, shape = RoundedCornerShape(15.dp)) .background(color = Color.Black) ) } 可以看到这块又加了一个动画 animateDpAsState 来处理多应用状态小黑圆点的展示,如果当前状态为多应用状态的话即 padding 值增加,这样小黑圆点就会单独显示出来,反之不是多应用状态的话,小黑圆点就会在灵动岛下面进行隐藏,不进行展示。实现效果就是开头的效果了。此处也就不再进行展示。其他方案实现上面的动画实现主要使用的是 animateSizeAsState ,这个实现当然是没有问题的,但如果不止需要 Size 的话就不太够用了,比如还需要透明度的变化,亦或者还需要旋转缩放等操作的时候就不够用了,这个时候应该怎么办呢?别担心,官方为我们提供了 updateTransition 来处理这种情况,Transition 可管理一个或多个动画作为其子项,并在多个状态之间同时运行这些动画。其实 updateTransition 咱们并不陌生,在 Compose 动画艺术探索之可见性动画 这篇文章中也提到过,AnimatedVisibility 源码中就使用到了。下面来试着将 animateSizeAsState 修改为 updateTransition 。val transition = updateTransition(targetState = boxState, label = "transition") val boxHeight by transition.animateDp(label = "height", transitionSpec = boxSizeSpec()) { boxState.height } val boxWidth by transition.animateDp(label = "width", transitionSpec = boxSizeSpec()) { boxState.width } Box( modifier = Modifier .width(boxWidth) .height(boxHeight) .shadow(elevation = 3.dp, shape = RoundedCornerShape(15.dp)) .background(color = Color.Black), ) 使用方法并不难,可以看到这里使用了 animateDp 方法来处理灵动岛的宽高动画,然后设置了下动画规格,为了方便这里将动画规格抽取了下,其实和上面使用的一致,都是 spring ;transition 还为我们提供了一些常用的动画方法,来看下有哪些吧!上图中的动画方法都可以进行使用,大家可以根据需求来选择使用。下面来运行看下 updateTransition 实现的效果吧:可以看到效果基本一致,如果不需要别的参数直接使用 animateSizeAsState 就足够了,但如果需要别的一些操作的话就可以考虑使用 updateTransition 来实现了。多个应用切换优化多应用状态苹果实现的样式中有类似水滴的动效,这块需要使用二阶贝塞尔曲线,其实并不复杂,来看下代码:Canvas(modifier = Modifier.padding(start = 70.dp)) { val path = Path() val width = (animateFloatAsState + 30) * density val x = animateFloatAsState * density val p2x = density * 15f val p2y = density * 25f val p1x = density * 15f val p1y = density * 5f val p4x = width - 15f * density val p4y = density * 30f val p3x = width - 15f * density val p3y = 0f val c2x = (abs(p4x - p2x)) / 2 val c2y = density * 20f val c1x = (abs(p3x - p1x)) / 2 val c1y = density * 10f path.moveTo(p2x, p2y) path.lineTo(p1x, p1y) // 用二阶贝塞尔曲线画右边的曲线,参数的第一个点是上面的一个控制点 path.quadraticBezierTo(c1x, c1y, p3x, p3y) path.lineTo(p4x, p4y) // 用二阶贝塞尔曲线画左边边的曲线,参数的第一个点是下面的一个控制点 path.quadraticBezierTo(c2x, c2y, p2x, p2y) if (animateFloatAsState == 35f) { path.reset() } else { drawPath( path = path, color = Color.Black, style = Fill ) } path.addOval(Rect(x + 0f, 0f, x + density * 30f, density * 30f)) path.close() drawPath( path = path, color = Color.Black, style = Fill ) } 嗯,看着其实还挺多,其实并不难,确定好四个个点,然后连接上色就行,然后根据小黑圆点的位置动态绘制连接部分即可,关于贝塞尔曲线在这里就不细说了,大伙应该比我懂。最后来看下效果吧!这回是不是就有点像了,哈哈哈!打完收工本文带大家一起写了下当下很火的苹果灵动岛,只是最简单的模仿实现,效果肯定不如苹果调教一年的效果,仅供大家参考。本文所有代码都在 Github 中:https://github.com/zhujiang521/ComposeBookSource本文至此结束,有用的地方大家可以参考,当然如果能帮助到大家,哪怕是一点也足够了。就这样。以上文章来自[掘金]-[Zhujiang]本程序使用github开源项目RSSHub提取聚合!
2022年10月18日
2 阅读
0 评论
0 点赞
2022-10-18
移除Message的各式各样的方法,你了解吗?-掘金
持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情本篇文章主要介绍MessageQueue提供的各种移除Message的方法,大概有八九个,接下来会对其中比较典型的移除方法进行详细分析。历史文章Message使用及分发的几个必备知识点,了解一下Handler创建的几个必备知识点,了解一下退出Looper循环移除Message的两种方式大家都知道,消息机制在Android系统运行中扮演着重要的角色,通过消息发送、添加消息队列、分发等一整个流程驱动Android的运行。主线程是在ActivityThread.main()中调用了Looper.loop(),开启消息循环遍历执行的,这个消息循环可以退出吗,接下来我们仔细研究下;上源码:void quit(boolean safe) { //1.不允许退出就抛出异常 if (!mQuitAllowed) { throw new IllegalStateException("Main thread not allowed to quit."); } synchronized (this) { if (mQuitting) { return; } //2. mQuitting = true; if (safe) { //3.安全退出 removeAllFutureMessagesLocked(); } else { //4.非安全退出 removeAllMessagesLocked(); } nativeWake(mPtr); } } 对于主线程而言,mQuitAllowed的值是false,也就是说主线程的Looper循环不允许手动调用quit()退出,否则就抛出异常; 将退出标识mQuitting置为true,这样当从消息队列中取消息时,会先判断下这个标识mQuitting是否为true,是就会经过调用链一步步退出Looper消息循环; 安全的退出Looper开启的消息循环,深入下removeAllFutureMessagesLocked()看下: private void removeAllFutureMessagesLocked() { final long now = SystemClock.uptimeMillis(); Message p = mMessages; if (p != null) { //1. if (p.when > now) { removeAllMessagesLocked(); } else { Message n; for (;;) { n = p.next; if (n == null) { return; } if (n.when > now) { break; } p = n; } p.next = null; do { p = n; n = p.next; p.recycleUnchecked(); } while (n != null); } } } 首先如果消息队列队头的消息的执行时间戳when大于当前时间,则直接调用 removeAllMessagesLocked()方法移除所有的消息 ,这个方法之后会讲解; 上面条件不满足,就不断的遍历消息队列,直到找出执行时间戳大于当前时间的消息,然后通过do-while()循环,将该消息及之后的消息全部进行回收处理,放入到我们之前讲解的对象池; 非安全的退出是直接调用了removeAllMessagesLocked()方法,我们深入看下: private void removeAllMessagesLocked() { Message p = mMessages; while (p != null) { Message n = p.next; p.recycleUnchecked(); p = n; } mMessages = null; } 可以看到这种移除方法大杀特杀,不会去比较消息执行的时间戳啥的,直接全部干翻回收到消息对象池,简单粗暴。这里对于非安全和安全退出Looper循环做个总结:安全退出Looper循环只会移除回收大于当前时间戳的消息,而不大于当前时间戳的消息都可以保证正常执行;而非安全的退出比较粗暴,直接清空回收整个消息队列。这两种情况大家根据需要选择性的使用。removeXXXMessages()移除指定的消息可以看到移除消息的方法一大堆,比如通过指定Message的what、obj、callback等信息移除指定Message,这里我们就以removeCallbacksAndMessages()举例。removeCallbacksAndMessages()方法大家应该很梳理,是我们在某个界面中使用Handler发送消息时,避免发生内存泄漏的一种方式,接下来我们深入分析下:void removeCallbacksAndMessages(Handler h, Object object) { //... synchronized (this) { Message p = mMessages; //1.从头移除消息 while (p != null && p.target == h && (object == null || p.obj == object)) { Message n = p.next; mMessages = n; p.recycleUnchecked(); p = n; } //2. 从中间移除消息 while (p != null) { Message n = p.next; if (n != null) { if (n.target == h && (object == null || n.obj == object)) { Message nn = n.next; n.recycleUnchecked(); p.next = nn; continue; } } p = n; } } } 如果这个方法传入的object不为null,就会移除指定的Message,如果指定为null,就会移除传入的Handler发送的所有消息。上面的源码中可以看到,移除Message分为两个部分,为什么要这么做呢?假设消息队列中存在下面一系列消息集合:如果Message1满足移除条件,那么直接回收这条消息,并将消息队列的队头指针指向下一个消息即可mMessages = mMessages.next,对应上面源码中前半部分移除消息的逻辑。但假设Message1不满足移除条件,Message2满足移除条件,这样移除就不是直接将消息队列的队头指针指向next即下一个Message就能简单解决的。正确的做法是:先要保存Message1,然后通过Message2.next获取到Message3的引用保存起来,最后将Message1.next指向上面保存的Message3引用。这部分就对应上面源码中后半部分移除消息的逻辑。总结本篇文章主要是对MessageQueue提供的各种移除Message的方法做了一个简单的介绍,方法很多主要分为两种:移除指定标识的Handler发送的Message和移除所有Handler发送的Message。希望对大家有所帮助。以上文章来自[掘金]-[长安皈故里]本程序使用github开源项目RSSHub提取聚合!
2022年10月18日
0 阅读
0 评论
0 点赞
2022-10-18
Android 音视频开发之交叉编译-掘金
本文分别为: 交叉编译 Clang交叉编译 X264 FAAC CameraX 今天来说一下关于今天先说一下音视频交叉编译一些内容🤨关注公众号:初一十五a解锁 《Android十大板块文档》音视频大合集,从初中高到面试应有尽有;让学习更贴近未来实战。已形成PDF版十个模块内容如下:1.2022最新Android11位大厂面试专题,128道附答案2.音视频大合集,从初中高到面试应有尽有3.Android车载应用大合集,从零开始一起学4.性能优化大合集,告别优化烦恼5.Framework大合集,从里到外分析的明明白白6.Flutter大合集,进阶Flutter高级工程师7.compose大合集,拥抱新技术8.Jetpack大合集,全家桶一次吃个够9.架构大合集,轻松应对工作需求10.Android基础篇大合集,根基稳固高楼平地起整理不易,关注一下吧。开始进入正题,ღ( ´・ᴗ・` ) 🤔一丶什么是交叉编译在一种计算机环境中运行的编译程序,能编译出在另外一种环境下运行的代码,我们就称这种编译器支持交叉编译。这个编译过程就叫交叉编译。简单地说,就是在一个平台上生成另一个平台上的可执行代码。这里需要注意的是所谓平台,实际上包含两个概念:1. 体系结构(Architecture)2. 操作系统(OperatingSystem)同一个体系结构可以运行不同的操作系统;同样,同一个操作系统也可以在不同的体系结构上运行。举例来说:我们常说的x86 Linux平台实际上是Intel x86体系结构和Linux for x86操作系统的统称;而x86WinNT平台实际上是Intel x86体系结构和Windows NT for x86操作系统的简称。要进行交叉编译,我们需要在主机平台上安装对应的交叉编译工具链(crosscompilation tool chain),然后用这个交叉编译工具链编译我们的源代码,最终生成可在目标平台上运行的代码。常见的交叉编译例子如下: 在Windows PC上,利用ADS(ARM 开发环境),使用armcc编译器,则可编译出针对ARM CPU的可执行代码。 在Linux PC上,利用arm-linux-gcc编译器,可编译出针对Linux ARM平台的可执行代码。 在Windows PC上,利用cygwin环境,运行arm-elf-gcc编译器,可编译出针对ARM CPU的可执行代码。 1.1、为什么要使用交叉编译有时是因为目的平台上不允许或不能够安装我们所需要的编译器,而我们又需要这个编译器的某些特征;有时是因为目的平台上的资源贫乏,无法运行我们所需要编译器;有时又是因为目的平台还没有建立,连操作系统都没有,根本谈不上运行什么编译器。1.2 、本地编译和交叉编译的比较本地编译:本地编译可以理解为,在当前编译平台下,编译出来的程序只能放到当前平台下运行。平时 我们常见的软件开发,都是属于本地编译。比如,我们在 x86 平台上,编写程序并编译成可执行程序。 这种方式下,我们使用 x86 平台上的工具,开发针对 x86 平台本身的可执行程序,这个编译过程称为本地编译。交叉编译:交叉编译可以理解为,在当前编译平台下,编译出来的程序能运行在体系结构不同的另一种 目标平台(该平台自己不能干,所以让其它平台来干)上,但是编译平台本身却不能运行该程序。比如,我们在 x86 平台上,编写程序并编译成能运行在 ARM 平台的程序,编译得到的程序在 x86 平台上是不能运行的,必须放到 ARM 平台上才能运行。二丶Clang 交叉编译添加NDK编译环境变量2.1.下载NDK滴2.2.安装NDK下载NDK,并解压 这里下载了 android-ndk-r21b,解压到 /home/temp/programs/android-ndk-r21b2.3. 添加系统环境变量vim etc/profileexport PATH=$PATH:/root/ndk/android-ndk-r21d export SYSROOT="$NDK/toolchains/llvm/prebuilt/linux-x86_64/sysroot/" export ANDROID_GCC="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linuxandroid24-clang"2.4.生效环境变量source /etc/profile$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang++ main.cpp -o hello 这里NDK用的是r19及以上的版本。2.5.写main.cpp文件#include int main() { printf("hello world "); return 0; } 2.6.写交叉编译脚本 generate.sh由于命令比较短,也可直接在命令行里写。 新建generate.sh,并给执行权限 : chmod +x generate.shexport NDK=/home/temp/programs/android-ndk-r21b $NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang++ main.cpp -o hello 使用了NDK 默认安装的工具链,按照官网说明,NDK在r21之后,NDK 默认安装的工具链可供使用。可以不需要使用 make_standalone_toolchain.py 脚本生成独立工具链来使用。这样使用自带的工具链就比较方便,不用再配置 sysroot 等编译选项。其中NDK为自己解压的目录。编译器要选择自己手机的架构,这里用的是arm64,所以是aarch64-linux-android。编译器要选择android的api版本,这里用的是anroid 8.0.0,对应api是26。Android NDK从r13起,默认使用Clang进行编译。 交叉编译出可执行程序 hello./generate.sh 2.7.放到手机执行2.8.push到手机adb push hello /data/local/tmp 2.9.给hello执行权限adb shell cd /data/local/tmp chmod +x hello 执行hello./hello 可以看到输出hello world 三丶X264x264是一个开源的H.264/MPEG-4 AVC视频编码函数库,是最好的有损视频编码器之一。 它将作为我们直播数据的视频编码库。FFmpeg中同样实现了H.264的编码,同时FFmpeg也能够集成X264。本次我们将直接使用X264来进行视 频编码而不是FFmpeg下载源码:(前提是已经安装git并存在环境变量)git clone https://code.videolan.org/videolan/x264.git 并不是所有的库都已经存在configure文件,可能只存在configure.ac和makefile.am。这种情况需要借 助autoconf来生成configure。cd x264 ls 发现已经帮助我们生成好了configuration文件 如果存在configure,这时候我们第一反应一定是执行help./configure --help --prefix 设置编译结果输出目录,一般规范都会存在这个参数 --exec-prefix 参数表示我们可以借助这个对编译器(gcc/clang)设置选项 「类似javac设置-classpath」 --disable-cli 这个配置是关闭编译命令行工具,在Android,是自己编译代码,用不到,也可以不管,倒库的是后不用就好 --enable-shared & --enable-static 这两个参数是指我们编译成静态库.a 还是动态库.so 静态库-> xxxx.c 生成xxxx.a 里面函数 编译时,从xxxx.a 中找到这个函数,与xxxx.c 一起生成一个 a.so 最终 可能 .a 库 10M动态库-> xxxx.a 生成xxxx.so 里面函数 编译时,只会找有没有这个函数,有就行不做其他操作 运行时,当a.so 执行到这个函数,在动态去找对应的函数 最终,会有两个so .so 6M a.so 5M. 加起来会稍微大一点多个库情况,a.so 和 b.so 都需要x264 那么这个时候我们应该选择动态库。 静态库会编译两份,动态库是用到时候去找可以公用一份。 --enable-debug & --enable-gprof & --enable-strip 给编译器传递参数 相当 gcc -g / clang -g --enable-pic 如果编译Android使用的动态库,使用PIC 指令。有的--with-pic. 一些脚本没有提供 那么我们可以加上gcc -fPIC 通过 CFLAGS 变量 CFLAGS=“-g -fPIC -xxx" 开启 x264 还给我们提供了跨平台的参数Configuration options: --cross-prefix --cross-prefix=前缀- 那么我们相当于使用“gcc xxx.c” 就会用 “前缀-gcc xxxx.c”编译 --sysroot 查找库,有点类似-L 使用。但是有区别 --extra-cflags 作为传递给编译器的参数,所以就算有些库没有--extra-cflags配置,我们也可以 自己创建变量cFLAGS传参 比如指定了搜索路径/abc/ 会在 指定的路径/abc/usr/lib/libxxx.so(libxxx.a) /abc/usr/include/xxx.hgcc -Lxxx -Ixxx gcc -Labc 会在 /abc/libxxx.so(libxxx.a)根据上面的分析我们形成配置脚本, 对于android 我们需要用ndk编译。但是ndk19 以上移除了gcc,高版本我们需要用clang编译工具。在toolchainsllvm 下面可以找到 /Users/xxx/Android/.../sdk/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/bin/ 类 似路径,我的是Macbook路径我们如何指定clang 编译器。那么需要给编译器变量CC 的编译器变量printf -> 这种实现在哪? 标准类库中 好比(java->jdk/rt.jar -> java 官方提供的库)那么需要我们 c/c++ -> NDK 中的头文件与库 才能给Android中使用Linux脚本$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang++ main.cpp -o hello #!/bin/bash export TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/linux-x86_64 export API=21 function build { ./configure --prefix=$PREFIX --disable-cli --enable-static --enable-pic --host=$HOST --cross-prefix=$CROSS_PREFIX --sysroot=$NDK/toolchains/llvm/prebuilt/linux-x86_64/sysroot make clean make -j8 make install } #armeabi-v7a PREFIX=./armeabi-v7a HOST=armv7a-linux-android export TARGET=armv7a-linux-androideabi export CC=$TOOLCHAIN/bin/$TARGET$API-clang export CXX=$TOOLCHAIN/bin/$TARGET$API-clang++ export CROSS_PREFIX=$TOOLCHAIN/bin/arm-linux- androideabi- build 如需要编译arm64-v8a架构版本,则修改以下变量:#arm64-v8a PREFIX=./android/arm64-v8a HOST=aarch64-linux-android export TARGET=aarch64-linux-android export CC=$TOOLCHAIN/bin/$TARGET$API-clang export CXX=$TOOLCHAIN/bin/$TARGET$API-clang++ CROSS_PREFIX=$TOOLCHAIN/bin/aarch64-linux-android- mac脚本#!/bin/bash # NDK目录 NDK_ROOT=/Users/xxx/Android/android_SDK/sdk/ndk/21.1.6352462 #编译后安装位置 pwd表示当前目录 PREFIX=`pwd`/android/armeabi-v7a #目标平台版本,我们将兼容到android-21 API=21 #编译工具链目录 TOOLCHAIN=$NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64 #小技巧,创建一个AS的NDK工程,执行编译, #然后在 app/.cxx/cmake/debug(release)/自己要编译的平台/ 目录下自己观察 build.ninja与 rules.ninja #虽然x264提供了交叉编译配置:--cross-prefix,如--corss-prefix=/NDK/arm-linuxandroideabi- #那么则会使用 /NDK/arm-linux-androideabi-gcc 来编译 #然而ndk19开始gcc已经被移除,由clang替代。 # 小常识:一般的库都会使用$CC 变量来保存编译器,我们自己设置CC变量的值为clang。 export CC=$TOOLCHAIN/bin/armv7a-linux-androideabi$API-clang export CXX=$TOOLCHAIN/bin/armv7a-linux-androideabi$API-clang++ #--extra-cflags会附加到CFLAGS 变量之后,作为传递给编译器的参数,所以就算有些库没有--extracflags配置,我们也可以自己创建变量cFLAGS传参 FLAGS="--target=armv7-none-linux-androideabi21 --gcc-toolchain=${TOOLCHAIN} -g - DANDROID -fdata-sections -ffunction-sections -funwind-tables -fstack-protectorstrong -no-canonical-prefixes -D_FORTIFY_SOURCE=2 -march=armv7-a -mthumb -Wformat -Werror=format-security -Oz -DNDEBUG -fPIC " # echo ${FLAGS} # prefix: 指定编译结果的保存目录 `pwd`: 当前目录 ./configure --prefix=${PREFIX} --disable-cli --enable-static --enable-pic=no --host=arm-linux --cross-prefix=${TOOLCHAIN}/bin/arm-linux-androideabi- --sysroot=${TOOLCHAIN}/sysroot --extra-cflags="$cleFLAGS" make install 四丶FAACFAAC是一个MPEG-4和MPEG-2的AAC编码器,我们将使用它作为音频编码库。在Linux/Mac中下载源码:wget https://nchc.dl.sourceforge.net/project/faac/faac-src/faac-1.29/faac- 1.29.9.2.tar.gz # 解压 tar xvf faac-1.29.9.2.tar.gz # 进入facc目录 cd faac-1.29.9.2 类似x2264我们编译FAAC 了五丶CameraXCameraX 是 Android Jetpack 的新增功能,通过该功能,向应用添加相机功能变得更加容易。该库提供了很多兼容性修复程序和解决方法,有助于在很多设备上打造一致的开发者体验。关于CameraX的使用在官网有详细文档及 Example 我们需要获取摄像头的数据自行编码,需要使用分析图片功能。CameraX获取数据格式为YUV_420_888,此数据 被包含在Image中,从Image取出YUV数据,关注公众号:初一十五a解锁 《Android十大板块文档》 音视频大合集,从初中高到面试应有尽有;让学习更贴近未来实战。已形成PDF版十个模块内容如下:1.2022最新Android11位大厂面试专题,128道附答案2.音视频大合集,从初中高到面试应有尽有3.Android车载应用大合集,从零开始一起学4.性能优化大合集,告别优化烦恼5.Framework大合集,从里到外分析的明明白白6.Flutter大合集,进阶Flutter高级工程师7.compose大合集,拥抱新技术8.Jetpack大合集,全家桶一次吃个够9.架构大合集,轻松应对工作需求10.Android基础篇大合集,根基稳固高楼平地起整理不易,关注一下吧。ღ( ´・ᴗ・` ) 🤔以上文章来自[掘金]-[初一十五不吃饭]本程序使用github开源项目RSSHub提取聚合!
2022年10月18日
0 阅读
0 评论
0 点赞
2022-10-18
Android Framework | 读懂异常调用栈-掘金
本文分析基于Android 13 (T)异常,是程序未按预设逻辑运行的一种提示。Java中的异常输出通常包含一句提示语和其发生时的调用栈。多数情况下,这些提示是直接且清晰的。但如果我们将异常捕获后封装一下重新抛出,或者让它发生在跨进程通信的过程中,那么此时的调用栈信息将会变得复杂,甚至会干扰我们对最终原因的判断。以下将详解几种不同形式的异常调用栈。1. 异常捕获后重新抛出以下是剥离了时间、pid、tid和tag后的输出。*** FATAL EXCEPTION IN SYSTEM PROCESS: main java.lang.RuntimeException: Error receiving broadcast Intent { act=android.intent.action.NEW_OUTGOING_CALL flg=0x11000010 (has extras) } in com.android.server.location.injector.SystemEmergencyHelper$1@42d2813 at android.app.LoadedApk$ReceiverDispatcher$Args.lambda$getRunnable$0$android-app-LoadedApk$ReceiverDispatcher$Args(LoadedApk.java:1800) at android.app.LoadedApk$ReceiverDispatcher$Args$$ExternalSyntheticLambda0.run(Unknown Source:2) at android.os.Handler.handleCallback(Handler.java:942) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loopOnce(Looper.java:201) at android.os.Looper.loop(Looper.java:288) at com.android.server.SystemServer.run(SystemServer.java:966) at com.android.server.SystemServer.main(SystemServer.java:651) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:920) Caused by: java.lang.IllegalStateException: telephony service is null. at android.telephony.TelephonyManager.isEmergencyNumber(TelephonyManager.java:14136) at com.android.server.location.injector.SystemEmergencyHelper$1.onReceive(SystemEmergencyHelper.java:70) at android.app.LoadedApk$ReceiverDispatcher$Args.lambda$getRunnable$0$android-app-LoadedApk$ReceiverDispatcher$Args(LoadedApk.java:1790) ... 10 more 可以发现其中有两段不同的调用栈,由"Caused by"字段进行分隔。结合以下代码,我们可以分析出此异常的转换过程:广播处理会进入到receiver.onReceive中,其中发生了"telephony service is null"的IllegalStateException。此异常向上抛出,最终被如下代码的1791行捕获。捕获之后的异常会在1800行进行重新封装,原始异常e将会作为第二个参数参与RuntimeException的构造(赋值给cause字段)。因此,这个RuntimeException是导致进程退出的直接原因,而原始异常IllegalStateException则是根本原因。1781 try { 1782 ClassLoader cl = mReceiver.getClass().getClassLoader(); 1783 intent.setExtrasClassLoader(cl); 1784 // TODO: determine at registration time if caller is 1785 // protecting themselves with signature permission 1786 intent.prepareToEnterProcess(ActivityThread.isProtectedBroadcast(intent), 1787 mContext.getAttributionSource()); 1788 setExtrasClassLoader(cl); 1789 receiver.setPendingResult(this); 1790 receiver.onReceive(mContext, intent); 1791 } catch (Exception e) { 1792 if (mRegistered && ordered) { 1793 if (ActivityThread.DEBUG_BROADCAST) Slog.i(ActivityThread.TAG, 1794 "Finishing failed broadcast to " + mReceiver); 1795 sendFinished(mgr); 1796 } 1797 if (mInstrumentation == null || 1798 !mInstrumentation.onException(mReceiver, e)) { 1799 Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER); 1800 throw new RuntimeException( 1801 "Error receiving broadcast " + intent 1802 + " in " + mReceiver, e); 1803 } 1804 } 306 public Throwable(String message, Throwable cause) { //第二个参数赋值给cause字段 307 fillInStackTrace(); 308 detailMessage = message; 309 this.cause = cause; 310 } 从调用栈的打印来看,它会首先将直接导致崩溃的异常调用栈打印出来,之后会递归地将cause的异常调用栈打印出来(因为cause也可能有自己的cause)。另外需要注意的是,IllegalStateException的调用栈最下方有"... 10 more"的字样。它表示的其实就是RuntimeException的调用栈(除去最后一帧)。因为异常在向上抛出的过程中被捕获,因此捕获位置往上的调用栈是不变的。我们把这10帧补齐,IllegalStateException的完整调用栈便如下所示。Caused by: java.lang.IllegalStateException: telephony service is null. at android.telephony.TelephonyManager.isEmergencyNumber(TelephonyManager.java:14136) at com.android.server.location.injector.SystemEmergencyHelper$1.onReceive(SystemEmergencyHelper.java:70) at android.app.LoadedApk$ReceiverDispatcher$Args.lambda$getRunnable$0$android-app- LoadedApk$ReceiverDispatcher$Args(LoadedApk.java:1790) at android.app.LoadedApk$ReceiverDispatcher$Args$$ExternalSyntheticLambda0.run(Unknown Source:2) at android.os.Handler.handleCallback(Handler.java:942) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loopOnce(Looper.java:201) at android.os.Looper.loop(Looper.java:288) at com.android.server.SystemServer.run(SystemServer.java:966) at com.android.server.SystemServer.main(SystemServer.java:651) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:920) 2. Binder同步通信时发生的异常以下是剥离了时间、pid、tid和tag后的输出。FATAL EXCEPTION: main PID: 3264 java.lang.NullPointerException: Attempt to invoke virtual method 'int java.lang.String.hashCode()' on a null object reference at android.os.Parcel.createExceptionOrNull(Parcel.java:3017) at android.os.Parcel.createException(Parcel.java:2995) at android.os.Parcel.readException(Parcel.java:2978) at android.os.Parcel.readException(Parcel.java:2920) at android.app.IActivityManager$Stub$Proxy.attachApplication(IActivityManager.java:5148) at android.app.ActivityThread.attach(ActivityThread.java:7644) at android.app.ActivityThread.main(ActivityThread.java:7943) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:942) Caused by: android.os.RemoteException: Remote stack trace: at com.android.server.am.HostingRecord.getHostingTypeIdStatsd(HostingRecord.java:234) at com.android.server.am.ActivityManagerService.attachApplicationLocked(ActivityManagerService.java:5102) at com.android.server.am.ActivityManagerService.attachApplication(ActivityManagerService.java:5115) at android.app.IActivityManager$Stub.onTransact(IActivityManager.java:2339) at com.android.server.am.ActivityManagerService.onTransact(ActivityManagerService.java:2655) 这种调用栈中有Parcel.readException字样,且"Caused by"后面跟的是"Remote stack trace",它们通常是由Binder同步通信时对端进程中的异常所导致。对端进程发生的异常拆分为3个部分,序列化地发回给本进程: code:表示该异常的类型 msg:异常的具体描述 remoteStackTrace:异常发生时的调用栈 这3部分信息在本进程中组合成了两个Exception对象。一个由code和msg构造,如下2978行所示,它是造成进程退出的直接原因;另一个由remoteStackTrace构造,如下2981行所示,它是造成进程退出的根本原因(2983行将它赋值给e的cause)。2972 public final void readException(int code, String msg) { 2973 String remoteStackTrace = null; 2974 final int remoteStackPayloadSize = readInt(); 2975 if (remoteStackPayloadSize > 0) { 2976 remoteStackTrace = readString(); 2977 } 2978 Exception e = createException(code, msg); 2979 // Attach remote stack trace if availalble 2980 if (remoteStackTrace != null) { 2981 RemoteException cause = new RemoteException( 2982 "Remote stack trace: " + remoteStackTrace, null, false, false); 2983 ExceptionUtils.appendCause(e, cause); 2984 } 2985 SneakyThrow.sneakyThrow(e); 2986 } 回到上面这个例子,它真实的含义是:本App进程希望通过attachApplication接口和system_server进程通信,但是system_server在处理这个请求时,发生了NullPointerException。System_server将这个异常发回给App进程,最终导致了App进程的退出。其实我觉得现有的调用栈输出是有瑕疵的。它将原本属于同一个异常的msg和stackTrace拆分开来,会给开发者带来困扰。按照正确的理解,上面的调用栈显示为如下格式会更加清晰。android.os.RemoteException: Binder transaction failed at android.os.Parcel.createExceptionOrNull(Parcel.java:3017) at android.os.Parcel.createException(Parcel.java:2995) at android.os.Parcel.readException(Parcel.java:2978) at android.os.Parcel.readException(Parcel.java:2920) at android.app.IActivityManager$Stub$Proxy.attachApplication(IActivityManager.java:5148) at android.app.ActivityThread.attach(ActivityThread.java:7644) at android.app.ActivityThread.main(ActivityThread.java:7943) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:942) Caused by: java.lang.NullPointerException in remote process: Attempt to invoke virtual method 'int java.lang.String.hashCode()' on a null object reference at com.android.server.am.HostingRecord.getHostingTypeIdStatsd(HostingRecord.java:234) at com.android.server.am.ActivityManagerService.attachApplicationLocked(ActivityManagerService.java:5102) at com.android.server.am.ActivityManagerService.attachApplication(ActivityManagerService.java:5115) at android.app.IActivityManager$Stub.onTransact(IActivityManager.java:2339) at com.android.server.am.ActivityManagerService.onTransact(ActivityManagerService.java:2655) 不过需要注意,并非对端进程处理binder通信时发生的任何异常都可以传回,只有如下这9类异常可以。 Exception Code Parcelable Exceptions in BootClassLoader EX_PARCELABLE SecurityException EX_SECURITY BadParcelableException EX_BAD_PARCELABLE IllegalArgumentException EX_ILLEGAL_ARGUMENT NullPointerException EX_NULL_POINTER IllegalStateException EX_ILLEGAL_STATE NetworkOnMainThreadException EX_NETWORK_MAIN_THREAD UnsupportedOperationException EX_UNSUPPORTED_OPERATION ServiceSpecificException EX_SERVICE_SPECIFIC 当对端进程将异常传回后,对端进程恢复正常。仔细思考这样设计也是很合理的。作为Server进程,它在什么时候执行,该执行些什么都不由自己掌控,而是由Client进程发起。因此抛出异常本质上与Client进程相关,让一个Client进程的行为导致Server进程退出显然是不合理的。此外,Server进程可能关联着多个Client,不能由于一个Client的错误行为而影响本可以正常获取服务的其他Client。除了上述9种异常以外,其余的异常将由对端进程的JavaBBinder::onTransact来处理,最终会通过LOGE将该异常输出。值得注意的是,异常中的Exception输出完后进程恢复,而Error则会导致进程退出。410 jboolean res = env->CallBooleanMethod(mObject, gBinderOffsets.mExecTransact, 411 code, reinterpret_cast(&data), reinterpret_cast(reply), flags); 412 413 if (env->ExceptionCheck()) { 414 ScopedLocalRef excep(env, env->ExceptionOccurred()); 415 binder_report_exception(env, excep.get(), 416 "*** Uncaught remote exception! " 417 "(Exceptions are not yet supported across processes.)"); 418 res = JNI_FALSE; 419 } *** Uncaught remote exception! (Exceptions are not yet supported across processes.) java.lang.OutOfMemoryError: Failed to allocate a 280361534 byte allocation with 25165820 free bytes and 258MB until OOM, target footprint 291421224, growth limit 536870912 at java.util.Arrays.copyOf(Arrays.java:3136) at java.util.Arrays.copyOf(Arrays.java:3106) at java.util.ArrayList.grow(ArrayList.java:275) at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:249) at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:241) at java.util.ArrayList.add(ArrayList.java:467) at android.os.Parcel.readStringList(Parcel.java:3093) at android.content.IntentFilter.(IntentFilter.java:2377) at android.content.IntentFilter$1.createFromParcel(IntentFilter.java:2269) at android.content.IntentFilter$1.createFromParcel(IntentFilter.java:2267) at android.app.IActivityManager$Stub.onTransact(IActivityManager.java:2241) at com.android.server.am.ActivityManagerService.onTransact(ActivityManagerService.java:2669) at android.os.Binder.execTransactInternal(Binder.java:1221) at android.os.Binder.execTransact(Binder.java:1163) 3. Binder异步通信时发生的异常对于普通的异步通信,Client进程发送完后就不会再管了,所以Server端在收到通信后处理时发生的异常不会回传。最终所有的异常都会交由JavaBBinder::onTransact进行处理,处理的原则和上面一样:Exception输出完后进程恢复,Error则会导致进程退出。不过有一类Binder异步通信的异常非常隐晦,如果不了解内部原理基本无法理解。示例如下。FATAL EXCEPTION: Thread-3 Process: com.android.systemui, PID: 31695 java.lang.RuntimeException: Error receiving broadcast Intent { act=android.bluetooth.device.action.BOND_STATE_CHANGED flg=0x10 (has extras) } in com.android.bluetooth.BluetoothManager$Blueto
[email protected]
at android.app.LoadedApk$ReceiverDispatcher$Args.lambda$getRunnable$0$android-app-LoadedApk$ReceiverDispatcher$Args(LoadedApk.java:1920) at android.app.LoadedApk$ReceiverDispatcher$Args$$ExternalSyntheticLambda0.run(Unknown Source:2) at android.os.Handler.handleCallback(Handler.java:942) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loopOnce(Looper.java:240) at android.os.Looper.loop(Looper.java:351) at android.os.HandlerThread.run(HandlerThread.java:67) Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'android.os.Looper android.os.HandlerThread.getLooper()' on a null object reference at com.android.bluetooth.a2dp.A2dpService.getOrCreateStateMachine(A2dpService.java:2101) at com.android.bluetooth.a2dp.A2dpService.connect(A2dpService.java:515) at com.android.bluetooth.btservice.AdapterService.connectEnabledProfiles(AdapterService.java:1548) at com.android.bluetooth.btservice.AdapterService.connectAllEnabledProfiles(AdapterService.java:4962) at com.android.bluetooth.btservice.AdapterService$AdapterServiceBinder.connectAllEnabledProfiles(AdapterService.java:3076) at com.android.bluetooth.btservice.AdapterService$AdapterServiceBinder.connectAllEnabledProfiles(AdapterService.java:3057) at android.bluetooth.IBluetooth$Stub.onTransact(IBluetooth.java:1750) at android.os.Binder.execTransactInternal(Binder.java:1331) at android.os.Binder.execTransact(Binder.java:1268) 其实同步通信除了通过Binder同步模式实现,还可以通过两个Binder异步通信实现。而这也正是上面调用栈形成的原因。Systemui进程接收到广播后,会执行相应广播的onReceive方法。此次广播处理会尝试连接蓝牙。而异常发生的关键点,就在如下代码中。final SynchronousResultReceiver recv = SynchronousResultReceiver.get(); service.connectAllEnabledProfiles(this, mAttributionSource, recv); return recv.awaitResultNoInterrupt(getSyncTimeout()).getValue(defaultValue); connectAllEnabledProfiles是异步的Binder请求,它的通信对端是com.android.bluetooth进程。Systemui发送完connectAllEnabledProfiles的异步请求后会继续往下执行,但是awaitResultNoInterrupt会将线程挂起,等待对端进程的回复。对端进程的回复同样是一个异步通信,这样程序便通过SynchronousResultReceiver和两次异步通信,模仿了同步通信的过程。com.android.bluetooth进程接收到异步请求后,会执行如下代码。如果程序没有异常,最终receiver.send会将返回值发回给systemui进程。但如果程序发生了RuntimeException,receiver.propagateException会将异常发回给systemui。public void connectAllEnabledProfiles(BluetoothDevice device, AttributionSource source, SynchronousResultReceiver receiver) { try { receiver.send(connectAllEnabledProfiles(device, source)); } catch (RuntimeException e) { receiver.propagateException(e); } } 发回给systemui的异常最终会在哪里抛出呢?答案是上面代码的getValue方法中。public T getValue(T defaultValue) { if (mException != null) { throw mException; } if (mObject == null) { return defaultValue; } return mObject; } 至此我们可以知道,上述调用栈中的caused by部分(截取如下)其实是Binder异步通信后对端进程(com.android.bluetooth)发生的异常。而整个调用栈中没有任何的remote字样,所以非常容易让人误以为是systemui进程中发生的异常。大家以后碰到这种调用栈时,一定要小心。Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'android.os.Looper android.os.HandlerThread.getLooper()' on a null object reference at com.android.bluetooth.a2dp.A2dpService.getOrCreateStateMachine(A2dpService.java:2101) at com.android.bluetooth.a2dp.A2dpService.connect(A2dpService.java:515) at com.android.bluetooth.btservice.AdapterService.connectEnabledProfiles(AdapterService.java:1548) at com.android.bluetooth.btservice.AdapterService.connectAllEnabledProfiles(AdapterService.java:4962) at com.android.bluetooth.btservice.AdapterService$AdapterServiceBinder.connectAllEnabledProfiles(AdapterService.java:3076) at com.android.bluetooth.btservice.AdapterService$AdapterServiceBinder.connectAllEnabledProfiles(AdapterService.java:3057) at android.bluetooth.IBluetooth$Stub.onTransact(IBluetooth.java:1750) at android.os.Binder.execTransactInternal(Binder.java:1331) at android.os.Binder.execTransact(Binder.java:1268) 结语本文属于一个很小的知识点。但再小的知识点,都有值得深挖的必要。只有一次次深入地挖凿,才能构筑起坚实的技术堡垒。以上文章来自[掘金]-[芦半山]本程序使用github开源项目RSSHub提取聚合!
2022年10月18日
0 阅读
0 评论
0 点赞
1
2
...
151