注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

工信部又出新规!爬坑指南

一、背景 工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。 二、整改 2.1 个...
继续阅读 »

一、背景


工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。


二、整改


2.1 个人信息保护


2.1.1 基本模式(无权限、无个人信息获取模式)


这次整改涉及到最大的一个点就是基本模式,基本模式指的是在用户选择隐私协议弹窗时,不能点击“不同意”即退出应用,而是需要给用户提供一个除联网功能外,无任何权限,无任何个人信息获取的模式且用户能正常使用。


这个说法有点抽象,我们来看下友商已经做好的案例。


腾讯视频



从腾讯视频的策略来看,用户第一次使用app,依旧会弹出一个“用户隐私协议”弹窗供用户选择,但是和以往不同的是,“不同意”按钮替换为了“不同意并进入基本功能模式”,用户点击“不同意并进入基本功能模式”则进入到一个简洁版的页面,只提供一些基本功能,当用户点击“进入全功能模式”,则再次弹出隐私协议弹窗。当杀死进程后,再次进入则直接进入基本模式。


网易云音乐



网易云音乐和腾讯视频的产品策略略有不同,在用户在一级授权弹窗点击“不同意”,会跳转至二级授权弹窗,当用户在二级弹窗上点击“不同意,进入基本功能模式”,才会进入基本功能页面,在此页面上点击“进入完整功能模式”后就又回到了二级授权页。当用户杀死进程,重新进入app时,还是会回到一级授权页。


网易云音乐比腾讯视频多了一个弹窗,也只是为了提升用户进入完整模式的概率,并不涉及到新规。


另外,B站、酷狗音乐等都已经接入了基本模式,有兴趣的伙伴可以自行下载体验。


2.1.2 隐私政策内容


如果app存在读取并传送用户个人信息的行为,需要检查其是否具备用户个人信息收集、使用规则,并明确告知读取和传送个人信息的目的、方式和范围。


判断权限是否有读取、修改、传送行为,如果有,需要在隐私协议中明文告知。


举个例子,app有获取手机号码并且保存在服务器,需要在协议中明确声明:读取并传送用户手机号码。


2.2 app权限调用


2.2.1 应用内权限调用



  1. 获取定位信息和生物特征识别信息


在获取定位信息以及生物特征识别信息时需要在调用权限前,单独向用户明示调用权限的目的,不能用系统权限弹窗替代。



如上图,申请位置权限,需要在申请之前,弹出弹窗供用户选择,用户同意调用后才可以申请位置权限。



  1. 其他权限


其他权限如上面隐私政策一样,需要在调用时,声明是读取、修改、还是传送行为,如下图



2.3 应用软件升级


2.3.1 更新


应用软件或插件更新应在用户授权的情况下进行,不能直接更新,另外要明确告知用户此行为包含下载和安装。


简单来说,就是在app进行更新操作时,需要用弹窗告知用户,是否更新应用,更新的确认权交给用户,并且弹窗上需要声明此次更新有下载和安装两个操作。如下图



2.4 应用签名


需要保

作者:付十一
来源:juejin.cn/post/7253610755126476857
证签名的真实有效性。

收起阅读 »

Android常见问题

1.1.Demo为啥手机号验证无法登录? 首先我们将demo跑起来是UI是这个样式的点击4.0.3版本号两下,会出现一个提示 我们点击ok2.切换到这个页面我们点击 服务器配置将在管理后台的appkey填写以后 点击下面的保存这样我们在页面正常按照在环信管理后...
继续阅读 »

1.1.Demo为啥手机号验证无法登录?

首先我们将demo跑起来是UI是这个样式的

点击4.0.3版本号两下,会出现一个提示 我们点击ok
2.
切换到这个页面我们点击 服务器配置

将在管理后台的appkey填写以后 点击下面的保存
这样我们在页面正常按照在环信管理后台申请的 环信id 登录就可以了 (登录方式是账号密码登录)
2.修改会话条目的尺寸宽高 他是属于EaseBaseLayout ,相比EaseChatLayout 他是ChatLayout的父类 关于尺寸大小的设计是存在基本都在父类中


3.集成后环信后,App被其他应用平台下架,厂商反馈是自启动的原因

将此服务去除


4.如何将百度地图切换到高德地图


1.因为百度地图将easeimkit中关于百度地图的集成去掉,改成高德地图;2.在chatfragment中重写位置的点击事件方法startMapLocation或者是直接在EaseChatFragment中直接修改点击事件startMapLocation跳转到高德地图;3.在调用环信api去发送地理位置消息时,传入高德获取到的经纬度。
2..点击位置的点击事件更换 ,demo中的点击事件是在EaseChatFragment下的onExtendMenuItemClick里面官方提供了EaseBaiduMapActivity 这个定位页面。2.修改为高德其实非常简单只需要在ChatFragment操作就可以了2.1修改点击事件在ChatFragment的onExtendMenuItemClick方法中添加2.2 在自己实现高德地图的页面返回定位信息 参数名称不要修改 不然其它地方也要修改2.3接下来在ChatFragment中的onActivityResult中接收定位信息并发送消息走到这里从高德获取的位置消息已经成功发送给好友了 接下来是获取查看好友位置消息2.4 查看位置消息还是在ChatFragment里 通过getCustomChatRow方法LoccationAdapter 继承位置消息展示 重写。
5.播放语音消息语音消息的声音小(不是语音通话)
(1)首先要打开扬声器 如果觉得声音还是比较小

(2)将ui库中调用的原声音量模式修改为媒体音量模式




收起阅读 »

Flutter如何实现IOC与AOP

在Flutter中实现IOC(Inversion of Control,控制反转)与AOP(Aspect-Oriented Programming,面向切面编程)之前,让我们先来了解一下这两个概念。 IOC(控制反转) 是一种设计原则,它将应用程序的控制权从应...
继续阅读 »

在Flutter中实现IOC(Inversion of Control,控制反转)与AOP(Aspect-Oriented Programming,面向切面编程)之前,让我们先来了解一下这两个概念。


IOC(控制反转) 是一种设计原则,它将应用程序的控制权从应用程序本身转移到外部框架或容器。传统上,应用程序会自己创建和管理对象之间的依赖关系。而在IOC中,对象的创建和管理被委托给一个专门的框架或容器。框架负责创建和注入对象,以实现松耦合和可扩展的架构。通过IOC,我们可以将应用程序的控制流程反转,从而实现更灵活、可测试和可维护的代码。


AOP(面向切面编程) 是一种编程范式,用于将横切关注点(如日志记录、事务管理、性能监控等)从应用程序的主要业务逻辑中分离出来。AOP通过在特定的切入点上织入额外的代码(称为切面),从而实现对这些关注点的统一管理。这种分离和集中的方式使得我们可以在不修改核心业务逻辑的情况下添加、移除或修改横切关注点的行为。


对于Java开发者来说,IOC和AOP可能已经很熟悉了,因为在Java开发中有许多成熟的框架,如Spring,提供了强大的IOC和AOP支持。


在Flutter中,尽管没有专门的IOC和AOP框架,但我们可以利用语言本身和一些设计模式来实现类似的功能。


接下来,我们可以探讨在Flutter中如何实现IOC和AOP的一些常见模式和技术。无论是依赖注入还是横切关注点的管理,我们可以使用一些设计模式和第三方库来实现类似的效果,以满足我们的开发需求


1. 控制反转(IOC):


依赖注入(Dependency Injection):依赖注入是一种将依赖关系从组件中解耦的方式,通过将依赖项注入到组件中,实现控制反转的效果。在Flutter中,你可以使用get_it库来实现依赖注入。下面是一个示例:


import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';

class UserService {
String getUser() => 'John Doe';
}

class GreetingService {
final UserService userService;

GreetingService(this.userService);

String greet() {
final user = userService.getUser();
return 'Hello, $user!';
}
}

void main() {
// 注册依赖关系
GetIt.instance.registerSingleton<UserService>(UserService());
GetIt.instance.registerSingleton<GreetingService>(
GreetingService(GetIt.instance<UserService>()),
);

runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final greetingService = GetIt.instance<GreetingService>();

return MaterialApp(
title: 'IOC Demo',
home: Scaffold(
appBar: AppBar(title: Text('IOC Demo')),
body: Center(child: Text(greetingService.greet())),
),
);
}
}


在上述示例中,我们定义了UserServiceGreetingService两个类。GreetingService依赖于UserService,我们通过依赖注入的方式将UserService注入到GreetingService中,并通过get_it库进行管理。


2. 面向切面编程(AOP):


在Flutter中,可以使用Dart语言提供的一些特性,如Mixin和装饰器(Decorator)来实现AOP。


Mixin:Mixin是一种通过将一组方法和属性混入到类中来实现代码复用的方式。下面是一个示例:


import 'package:flutter/material.dart';

mixin LogMixin<T extends StatefulWidget> on State<T> {
void log(String message) {
print('[LOG]: $message');
}
}

class LogButton extends StatefulWidget {
final VoidCallback onPressed;

const LogButton({required this.onPressed});

@override
_LogButtonState createState() => _LogButtonState();
}

class _LogButtonState extends State<LogButton> with LogMixin {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
log('Button clicked');
widget.onPressed();
},
child: Text('Click Me'),
);
}
}

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AOP Demo',
home: Scaffold(
appBar: AppBar(title: Text('AOP Demo')),
body: Center(child: LogButton(onPressed: () => print('Button pressed'))),
),
);
}
}



在上面的示例中,我们定义了一个LogMixin,其中包含了一个log方法,用于记录日志。然后我们在_LogButtonState中使用with LogMixin将日志记录功能混入到_LogButtonState中。每次按钮被点击时,会先打印日志,然后调用传入的回调函数。


装饰器:装饰器是一种将额外行为添加到方法或类上的方式。下面是一个示例:


void logDecorator(Function function) {
print('[LOG]: Method called');
function();
}

@logDecorator
void greet() {
print('Hello, world!');
}

void main() {
greet();
}

在Flutter中,虽然没有专门的IOC(控制反转)和AOP(面向切面编程)框架,但我们可以利用一些设计模式和技术来实现类似的效果。


对于IOC,我们可以使用依赖注入(Dependency Injection)的方式实现。依赖注入通过将依赖项注入到组件中,实现了控制反转的效果。在Flutter中,可以借助第三方库如get_itkiwi来管理依赖关系,将对象的创建和管理交由依赖注入框架处理。


在AOP方面,我们可以使用Dart语言提供的Mixin和装饰器(Decorator)来实现类似的功能。Mixin是一种通过将一组方法和属性混入到类中的方式实现代码复用,而装饰器则可以在不修改被装饰对象的情况下,添加额外的行为或改变对象的行为。


通过使用Mixin和装饰器,我们可以在Flutter中实现横切关注点的管理,例如日志记录、性能监测和权限控制等。通过将装饰器应用于关键的方法或类,我们可以在应用程序中注入额外的功能,而无需直接修改原始代码。


需要注意的是,以上仅为一些示例,具体实现方式可能因项目需求和个人偏好而有所不同。在Flutter中,我们可以灵活运用设计模式、第三方库和语言特性,以实现IOC和AOP的效果,从而提升代码的可维护性、可扩展性和重用性。


总结而言,尽管Flutter没有专门的IOC和AOP框架,但我们可以借助依赖注入和装饰器等技术,结合常见的设计模式,构建灵活、可测试和可维护的应用程序。这些技术和模式为开发者提供了良好的开发体验和代码结构。


希望对您有所帮助谢谢!!

作者:北漂十三载
来源:juejin.cn/post/7251032736692600869

收起阅读 »

Android 内存治理之线程

1、 前言   当我们在应用程序中启动一个线程的时候,也是有可能发生OOM错误的。当我们看到以下log的时候,就说明系统分配线程栈失败了。 java.lang.OutOfMemoryError: pthread_create (1040KB stack) fa...
继续阅读 »

1、 前言


  当我们在应用程序中启动一个线程的时候,也是有可能发生OOM错误的。当我们看到以下log的时候,就说明系统分配线程栈失败了。


java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory


这种情况可能是两种原因导致的。



  • 第一个就是系统的内存不足的时候,我们去启动一个线程。

  • 第二种就是进程内运行的线程总数超过了系统的限制。



  如果是内存不足的情况,需按照堆内存治理的方式来进行解决,检查应用内存泄漏问题并优化,此情况不作为本次讨论的重点。

  本次主要讨论进程内运行的线程总数超过了系统的限制所导致的情况。出现此情况时,我们就需要通过控制并发的线程总数来解决这个问题。


  想要控制并发的线程数。最直接的一种方式就是利用回收的思路,也就是让我们的线程通过串行的方式来执行;一个线程执行完毕之后,再启动下一个线程。这样就能够让并发的线程总数达到一个可控的状态。

  另外一种方式就是通过复用来解决,让同一个线程的实例可以被反复的利用,只创建较少的线程实例,就能完成大量的异步操作。


2、异步任务的方式对比


  对比一下,在安卓平台我们比较常用的开启异步任务的方式中,有哪些是更加有利于我们进行线程总数的控制的。


开启异步任务的方式特点
Thread.start()并行,难以管理
HandlerThread带消息循环的线程,线程内部串行任务(线程复用)
AsyncTask轻量级,串行(3.0以上),可以结合线程池使用
线程池可管理并发数,池化复用线程
Kotlin协程简化异步编程代码,复用线程,提高并发效率
##### 2.1 Thread

  从最简单的直接创建Thread的实例的方式来说起。在Java中这种方式虽然是最简单的去开启一个线程的方式,但是在实际开发中,一旦我们通过这种方式去自己创建 Thread 类的实例,并且调用 start 来开启一个线程的话,所开启的线程会非常的难以调度和管理。这种线程也就是我们平时所说的野线程。所以我们最好不要直接的创建thread类的实例。


2.2 HandlerThread

public class HandlerThread extends Thread { }

  HandlerThread是Thread类的子类,对Thread做了很多便利的封装。它有自己的Loop,它能够进行消息循环,所以就能够做到通过Handler执行异步任务,也能够做到在不同的线程之间,通过Handler进行现成的通讯。我们可以利用Handler的post操作,让我们在一个线程内部串行的执行多个异步任务。从内存的角度来说,也就相当于对线程进行了复用。


2.3 AsyncTask

  AsyncTask是一个相对更加轻量级,专门为了完成执行异步任务,然后返回UI线程更新UI的操作而设计的。对于我们来说,AsyncTask更像是一个任务的概念,而不是一个线程的概念。我们不需要把它当做一个线程去理解。 AsyncTask的本质,其实也是对线程和Handler的封装。



  • Android 1.6前,串行执行,原理:一个子线程进行任务的串行执行;

  • Android 1.6到2.3,并行执行,原理:一个线程数为5的线程池并行执行,但如果前五个任务执行时间过长,会堵塞后续任务执行,故不适合大量任务并发执行;

  • Android 3.0后,串行执行,原理:全局线程池进行串行处理任务;


到了Android 3.0以上版本,默认是串行执行的,但是可以结合线程值来实现有限制的并行。也可以达到一个限制线程总数的目的。


2.4 线程池

  Java语言本身也为我们提供了线程池。线程池的作用就是可以管理并发数,并且能够持续的去复用线程。如果在一个应用内部的全部异步操作,全部都采用线程池的方式来开启的话,那么我们就能够管理我们所有的异步任务了。这样一来,能够大大的降低线程治理的成本。


2.5 Kotlin协程

  在Kotlin中还引入了协程的概念。协程给传统的Java的异步编程带来最大的改变,就是能够让我们更加优雅的去实现异步任务。我们前面所说的这几种异步任务的执行方式,都需要我们额外的去写大量的样本代码。而Kotlin协程就能够做到让我们用写同步代码的方式去写异步代码。


  在语法的层面上,协程的另一个优势就是性能方面。协程能够帮助我们用更少的线程去执行更多的并发任务。同样也降低了我们治理内存的成本。从治理内存的角度来说,用线程池接管线程或者采用协程都是很好的方式。

作者:大神仙
来源:juejin.cn/post/7250357906712854589

收起阅读 »

Vue3 如何开发原生(安卓,ios)

Vue3 有没有一款好用的开发原生的工具 1.uniapp 我个人认为uniapp 适合开发小程序之类的,用这个去开发原生应用会存在一些问题 性能限制:由于 Uniapp 是通过中间层实现跨平台,应用在访问底层功能时可能存在性能损失。与原生开发相比,Uni...
继续阅读 »

Vue3 有没有一款好用的开发原生的工具


1.uniapp 我个人认为uniapp 适合开发小程序之类的,用这个去开发原生应用会存在一些问题




  • 性能限制:由于 Uniapp 是通过中间层实现跨平台,应用在访问底层功能时可能存在性能损失。与原生开发相比,Uniapp 在处理大规模数据、复杂动画和高性能要求的应用场景下可能表现较差。




  • 平台限制:不同平台有着各自的设计规范和特性,Uniapp 在跨平台时可能受到一些平台限制。有些平台特有的功能或界面设计可能无法完全实现,需要使用特定平台的原生开发方式来解决。




  • 生态系统成熟度: 相比于原生开发,Uniapp 的生态系统相对较新,支持和资源相对有限。在遇到问题时,可能难以找到完善的解决方案,开发者可能需要花费更多的时间和精力来解决问题。




  • 用户体验差异: 由于不同平台的设计规范和用户习惯不同,使用 Uniapp 开发的应用在不同平台上的用户体验可能存在差异。开发者需要针对每个平台进行特定的适配和调优,以提供更好的用户体验。




  • 功能支持限制: Uniapp 尽可能提供了跨平台的组件和 API,但某些特定平台的功能和接口可能无法完全支持。在需要使用特定平台功能的情况下,可能需要使用原生开发或自定义插件来解决。




  • uni 文档 uniapp.dcloud.net.cn/




2.react 拥有react native 开发原生应用 Vue无法使用 http://www.reactnative.cn/


3.Cordova cordova.apache.org/ 支持原生html js css 打包成 ios android exe dmg


4.ionic 我发现这个框架支持Vue3 angular react ts 构建Android iOS 桌面程序 这不正合我意 ionicframework.com/docs


前置条件


1.安装 java 环境 和 安卓编辑器sdk



安装完成检查环境变量


image.png


image.png


image.png


检查安卓编辑器的sdk 如果没安装就装一下


image.png


image.png


image.png


ionic


npm install -g @ionic/cli

初始化Vue3项目


安装完成后会有ionic 命令


ionic start [name] [template] [options]
# 名称 模板 类型为vue项目
ionic start app tabs --type vue

image.png


npm install #安装依赖

npm run dev 启动测试

image.png


启动完成后自带一个tabs demo


image.png


运行至android 编辑器 调试


npm run build
ionic capacitor copy android

注意检查


image.png


如果没有这个文件 删除android目录 重新执行下面命令


ionic capacitor copy android

预览


ionic capacitor open android

他会帮你打开安卓编辑器


如果报错说丢失sdk 注意检查sdk目录


image.png.


等待编译


image.png


点击上面绿色箭头运行


image.png


热更新


如果要热更新预览App 需要一个安卓设备


一直点击你的版本号就可以开启开发者模式


bd36c9f72990ae5cf2275e7690c7f354.jpg


开启usb调试 连接电脑


8f1085f12207c5107d39dd8d193dadfb.jpg


ionic capacitor run android -l --external

选择刚才的安卓设备


image.png


成功热更新


image.png


20c29c088e7f4f152fe1af0adbc4035f.jpg


作者:小满zs
来源:juejin.cn/post/7251113487317106745
收起阅读 »

gradle 实用技巧

前言 总结一些日常开发中非常有用的 gradle 脚本、自定义功能实现。 实现 以下实现基于 AGP 8.0.2 版本 ,AGP 的 API 隔三岔五就会迎来一波破坏性的变更,导致脚本和插件无法使用,因此这里需要关注一下版本。 输出打包后 apk 文件路径及 ...
继续阅读 »

前言


总结一些日常开发中非常有用的 gradle 脚本、自定义功能实现。


实现


以下实现基于 AGP 8.0.2 版本 ,AGP 的 API 隔三岔五就会迎来一波破坏性的变更,导致脚本和插件无法使用,因此这里需要关注一下版本。


输出打包后 apk 文件路径及 apk 大小。


Android Studio 最新版本 Run 之后,每次输出的 apk 并没有在这 app/build/outputs 文件夹下(不知道 Android 官方是出于什么考虑要更改这个路径),而是移动到了 build\intermediates\apk\{flavor}\debug\ 目录下。为了方便后续快速找到每次运行完成的 apk ,可以在每次打包后输出 apk 文件路径及大小,从而可以关注一下日常开发过程中自己
的 apk 体积大概是一个什么样的范围。


static def getFileHumanSize(length) {
def oneMB = 1024f * 1024f
def size = String.valueOf((length / oneMB))
def value = new BigDecimal(size)
return value.setScale(2, BigDecimal.ROUND_HALF_UP)
}
/**
* 打包完成后输出 apk 大小*/
android {
applicationVariants.all { variant ->
variant.assembleProvider.configure() {
it.doLast {
variant.outputs.forEach {
logger.error("apk fileName ==> ${it.outputFile.name}")
logger.error("apk filePath ==> ${it.outputFile}")
logger.error("apk fileSize ==> ${it.outputFile.length()} , ${getFileHumanSize(it.outputFile.length())} MB")
}
}
}
}
}

apk fileName ==> app-huawei-global-debug.apk
apk filePath ==> D:\workspace\MinApp\app\build\intermediates\apk\huaweiGlobal\debug\app-huawei-global-debug.apk
apk fileSize ==> 11987818 , 11.43 MB

可以看到 apk 的路径在 build/intermediates 目录下。当然,我们可以通过下面的方法修改这个路径,定义成我们习惯的路径。


gradle 自定义功能的模块化


日常开发中,会有很多关于 build.gradle 的修改和更新。日积月累,build.gradle 的内容越来越多,代码几乎要爆炸了。其实,可以用模块化的思路将每一个小功能单独抽取出来,这样不仅可以减少 build.gradle 的规模,同时小功能可以更加容易的复用。


比如上面定义的输出打包后 apk 文件路径及 apk 大小的功能,我们就可以把他定义在 report_apk_size_after_package.gradle 这样一个文件中,然后在要使用的 build.gradle 中导入即可。


比如我们要在 app module 中使用这个功能,那么就可以直接在其 build.gradle 文件中按照相对路径引入即可。


gradle_dep.png


apply from: file("../custom-gradle/report_apk_size_after_package.gradle") // 打包完成后输出 apk 大小


修改 release 包的输出路径及文件名


输出 apk 后改名的需求,应该已经很普遍了。在最终输出的 apk 文件中,我们可以追加一些和代码相关的信息,方便通过 apk 文件名迅速确定一些内容。


def getCommit() {
def stdout = new ByteArrayOutputStream()
exec {
commandLine "git"
args "rev-parse", "--short", "HEAD"
standardOutput = stdout
}
return stdout.toString().trim()
}

def getBranch() {
def stdout = new ByteArrayOutputStream()
exec {
commandLine "git"
args "rev-parse", "--abbrev-ref", "HEAD"
standardOutput = stdout
}
return stdout.toString().trim()
}

def gitLastCommitAuthorName() {
return "git log -1 --pretty=format:'%an'".execute(null, rootDir).text.trim().replaceAll("\'", "")
}

def gitLastCommitAuthorEmail() {
return "git log -1 --pretty=format:'%ae'".execute(null, rootDir).text.trim().replaceAll("\'", "")
}


android {
def i = 0
applicationVariants.all { variant ->
if (variant.assembleProvider.name.contains("Debug")) {
// 只对 release 包生效
return
}

// 打包完成后复制到的目录
def outputFileDir = "${rootDir.absolutePath}/build/${variant.buildType.name}/${variant.versionName}"
//确定输出文件名
def today = new Date()
def path = ((project.name != "app") ? project.name : rootProject.name.replace(" ", "")) + "_" + variant.flavorName + "_" + variant.buildType.name + "_" + variant.versionName + "_" + today.format('yyyy_MM_dd_HH_mm') + "_" + getBranch() + "_" + getCommit() + "_" + gitLastCommitAuthorName() + ".apk"
println("path is $path")
variant.outputs.forEach {
it.outputFileName = path
}
// 打包完成后做的一些事,复制apk到指定文件夹
variant.assembleProvider.configure() {
it.doLast {
File out = new File(outputFileDir)
copy {
variant.outputs.forEach { file ->
copy {
from file.outputFile
into out
}
}
}
}
}
}
}

打 release 包后的日志


let me do something after assembleHuaweiGlobalRelease
apk fileName ==> MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk
apk filePath ==> D:\workspace\MinApp\app\build\outputs\apk\huaweiGlobal\release\MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk
apk fileSize ==> 4959230 , 4.73 MB

通过上面的日志,可以看到 MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk 包含了 ProjectName、flavor、debug/release、打包时间、分支、commitId 即最后一个 commitor 邮箱这些信息。通过这样的信息,可以更加方便快速的定位问题和解决问题。


妙用 flavor 实现不同的功能


使用 flavor 可以定制代码的不同功能及组合。不用把所有内容一锅乱炖似的放在一起搞。比如 MiniApp 随着演示代码的增多,已经逐渐丧失了 Mini 的定位,Apk 大小已经来到了 22 MB之多。究其原因,就是把所有代码验证和功能都放在一起导致的,音视频、compose、C++ 代码全都混在一起。部分功能不常用,但是每次为了验证一部分小功能,却要连带编译这些所有功能,同时打出的 apk 包体积也变大了,从编译到安装,无形中浪费了很多时间。


因此,可以通过 flavor 将一些不常用的功能,定义到不同的 flavor 中,真正需要的时候,编译相应 flavor 的包即可。


首先我们可以从 type 维度定义两个 flavor


    flavorDimensions "channel", "type"
productFlavors {
xiaomi {
dimension "channel"
}
oppo {
dimension "channel"
}
huawei {
dimension "channel"
}

global {
dimension "type"
}
local {
dimension "type"
}
}

在 type 维度,我们可以认为 global 是功能完整的 flavor,而 local 是部分功能缺失的 flavor 。那么具体缺失哪些功能呢?这就要从实际情况出发了,比如产品定义,代码架构及模块组合之类的。回到 Mini App 中,我们使用不同 flavor 的目标就是通过减少非常用功能模块,获得一个体积相对较小的 apk. 因此,可以做如下配置。


    if (source_code.toBoolean()) {
globalImplementation project(path: ':thirdlib')
} else {
globalImplementation 'com.engineer.third:thirdlib:1.0.0'
}
globalImplementation project(path: ':compose')
globalImplementation 'com.github.CarGuo.GSYVideoPlayer:GSYVideoPlayer:v8.1.8-release-jitpack'

如上我们只在 global 这个 flavor 依赖 thirdlib, compose, GSYVideoPlayer 这些组件。这样 local flavor 就不会引入这些组件,那么就会带来一个问题,local flavor 编译的时候没有这些组件的类,会出现找不到类的情况。


class_missing.png


对于这种情况,我们可以在项目 src 和 main 同级的目录下,创建 local 文件夹,然后在其内部按照具体 Class 文件的路径创建相应的类即可。


package com.engineer.compose.ui

import com.engineer.BasePlaceHolderActivity

/**
* Created on 2022/7/31.
* @author rookie
*/

class MainComposeActivity : BasePlaceHolderActivity()



package com.engineer.third

import com.engineer.BasePlaceHolderActivity

/**
* Created on 2022/7/31.
* @author rookie
*/

class CppActivity : BasePlaceHolderActivity()

package com.engineer

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.engineer.android.mini.ext.toast

/**
* Created on 2022/8/1.
* @author rookie
*/

open class BasePlaceHolderActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
"please use global flavor ".toast()
finish()
}
}

local_flavor.png


这里的思路其实很简单,类似于 leanCanary ,就是在不需要这个功能的 flavor 提供空实现,保证编译可以正常通过即可。缺什么类,建按照类的完整路径创建相应的 Class 文件,这样既保证了编译可以通过,同时在不需要次功能的 flavor 又减少无冗余的代码。


flavor 扩展


其实顺着上面的思路,基于不同 flavor 我们可以做更多的事情。基于 Java 虚拟机的类加载机制限制,相同的类只能有一个,因此我们无法做的事情是,通过 flavor 创建同名的类,去覆盖或重写其他 flavor 的逻辑,这种在编译阶段(其实是在创建同名类的阶段)就会报错。


但是,有些功能是可以被覆盖和定制的。比如包名、App 的名称、icon 之类。这些配置可以通过 AndroidManifest.xml/gradle 进行配置。可以在 local 目录下创建这个 flavor 特有的一些资源文件,这样就可以实现基于 flavor 的产品功能定制了。


比如最简单的修改 applicationId


        global {
dimension "type"
}
local {
dimension "type"
applicationId "com.engineer.android.mini.x"
}

这样,local 和 global 就有了各自不同的 applicationId, 这两种不同 flavor 的包就可以安装在同一台设备了。当然,现在这两个包的 label 和 icon 都是一样的,完全看不出区别。这里就可以利用 flavor 各自的文件夹,来定制各类资源和命名了。


flavor 过滤


不同维度的 flavor 会导致最终的 variant 变多。比如定义 product、channel、type 这些几个 dimension 的之后,后续新增的 flavor 会以乘积的形式增长,但是有些 flavor 又是我们不需要的,这个时候我们就可以过滤掉某些不需要的 flavor 。


比如以上面定义的 channel,type 这两个维度为例,在这两个维度下分别又扩展了 xiaomi/opop/huawei,global/local 这些 flavor 。按照规则会有 2x3x2=12 种 flavor,但实际情况可能不需要这么多,为了减少编译的压力,提升代码的可维护性,我们可以对 flavor 进行过滤。


    variantFilter { variant ->
println "variant is ${variant.flavors*.name}"
def dimens = variant.flavors*.name
def type = dimens[1]
def channel = dimens[0]
switch (type) {
case "global":
if (channel == "xiaomi") {
setIgnore(true)
}
break
case "local":
if (channel == "oppo") {
setIgnore(true)
}
break
}
}

这样我们就成功的过滤掉了 xiaomiGlobal 和 oppoLocal 的 flavor ,一下子就去掉了 4 个 flavor 。


基于现有 task 定制任务


再回顾一下上面的 修改 release 包的输出路径及文件名 的代码实现,我们是在打包完成之后进行了 apk 文件的重命名。


        // 打包完成后做的一些事,复制apk到指定文件夹
variant.assembleProvider.configure() {
it.doLast {
File out = new File(outputFileDir)
copy {
variant.outputs.forEach { file ->
copy {
from file.outputFile
into out
}
}
}
}
}

这里的 doLast 就是说,无论是否需要,每次都会在 assemble 这个 task 完成之后做一件事。这样在某些情况下显得非常的不灵活,尤其是当 doLast 闭包中要做的事情非常繁重的时候。这里的 copy 操作显然是比较轻量的,但是换做是其他操作,比如 apk 安全加固等操作,并不是每次必然需要的操作。这种情况下,就需要我们换一种方式去实现相应的逻辑了。


我们就以加固为例,一般情况下,我们需要对各个版本的 release 包进行加固。因此,我们可以基于现有的 assembleXXXRelease 这个 task 展开。


android {
applicationVariants.all { variant ->
if (variant.assemble.name.contains("Debug")) {
// 只对 release 包生效
return
}

def taskPrefix = "jiagu"
def groupName = "jiagu"
def assembleTask = variant.assembleProvider.name
def taskName = assembleTask.replace("assemble", taskPrefix)
tasks.create(taskName) {
it.group groupName
it.dependsOn assembleTask
variant.assembleProvider.configure() {
it.doLast {
logger.error("let me do something after $assembleTask")
}
}
}
}
}

添加上面的代码后,再执行一下 gradle sync ,我们就可以看到新添加的 jiagu 这个 group 和其中的 task 了。


jiagu.png


这里使用创建 task 的一种方式,使用 createdependsOn ,动态创建 task,并指定其依赖的 task 。


这样当我们执行 ./gradlew jiaguHuaweiLocalRelease 时就可以看到结果了。


> Task :app:assembleHuaweiLocalRelease
let me do something after assembleHuaweiLocalRelease

>
Task :app:jiaguHuaweiLocalRelease

BUILD SUCCESSFUL in 56s
82 actionable tasks: 12 executed, 70 up-to-date

可以看到我们自定义的 task 已经生效了,会在 assembleXXXRelease 这个 task 完成之后执行。


关于 gradle 的使用,可以说是孰能生巧,只要逐渐熟悉了 groovy 的语法和 Java 语法之间的差异,那么就可以逐渐摸索出更多有意思的用法了。


本文源码可以参考 Github MiniApp


小结


可以看到基于 gradle 构建流程,我们仅仅通过编写一些脚本,可以做的事情还是很多的。但是由于 groovy 语法过于灵活,不像 Java 那样有语法提示,因此尝试一些新的语法时难免不知所措。面对这种情况,去看他的源码就好。通过源码,我们就可以知道某个类有哪

作者:IAM四十二
来源:juejin.cn/post/7250071693543145529
些属性,有哪些方法。

收起阅读 »

Android 冷启动优化的3个小案例

背景 为了提高App的冷启动耗时,除了在常规的业务侧进行耗时代码优化之外,为了进一步缩短启动耗时,需要在纯技术测做一些优化探索,本期我们从类预加载、Retrofit 、ARouter方面进行了进一步的优化。从测试数据上来看,这些优化手段的收益有限,可能在中端机...
继续阅读 »

背景


为了提高App的冷启动耗时,除了在常规的业务侧进行耗时代码优化之外,为了进一步缩短启动耗时,需要在纯技术测做一些优化探索,本期我们从类预加载、Retrofit 、ARouter方面进行了进一步的优化。从测试数据上来看,这些优化手段的收益有限,可能在中端机上加起来也不超过50ms的收益,但为了冷启动场景的极致优化,给用户带来更好的体验,任何有收益的优化手段都是值得尝试的。


类预加载


一个类的完整加载流程至少包括 加载、链接、初始化,而类的加载在一个进程中只会触发一次,因此对于冷启动场景,我们可以异步加载原本在启动阶段会在主线程触发类加载过程的类,这样当原流程在主线程访问到该类时就不会触发类加载流程。


Hook ClassLoader 实现


在Android系统中,类的加载都是通过PathClassLoader 实现的,基于类加载的父类委托机制,我们可以通过Hook PathClassLoader 修改其默认的parent 来实现。


首先我们创建一个MonitorClassLoader 继承自PathClassLoader,并在其内部记录类加载耗时


class MonitorClassLoader(
dexPath: String,
parent: ClassLoader, private val onlyMainThread: Boolean = false,
) : PathClassLoader(dexPath, parent) {

val TAG = "MonitorClassLoader"

override fun loadClass(name: String?, resolve: Boolean): Class<*> {
val begin = SystemClock.elapsedRealtimeNanos()
if (onlyMainThread && Looper.getMainLooper().thread!=Thread.currentThread()){
return super.loadClass(name, resolve)
}
val clazz = super.loadClass(name, resolve)
val end = SystemClock.elapsedRealtimeNanos()
val cost = end - begin
if (cost > 1000_000){
Log.e(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
} else {
Log.d(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
}
return clazz;

}
}

之后,我们可以在Application attach阶段 反射替换 application实例的classLoader 对应的parent指向。


核心代码如下:


    companion object {
@JvmStatic
fun hook(application: Application, onlyMainThread: Boolean = false) {
val pathClassLoader = application.classLoader
try {
val monitorClassLoader = MonitorClassLoader("", pathClassLoader.parent, onlyMainThread)
val pathListField = BaseDexClassLoader::class.java.getDeclaredField("pathList")
pathListField.isAccessible = true
val pathList = pathListField.get(pathClassLoader)
pathListField.set(monitorClassLoader, pathList)

val parentField = ClassLoader::class.java.getDeclaredField("parent")
parentField.isAccessible = true
parentField.set(pathClassLoader, monitorClassLoader)
} catch (throwable: Throwable) {
Log.e("hook", throwable.stackTraceToString())
}
}
}

主要逻辑为



  • 反射获取原始 pathClassLoader 的 pathList

  • 创建MonitorClassLoader,并反射设置 正确的 pathList

  • 反射替换 原始pathClassLoader的 parent指向 MonitorClassLoader实例


这样,我们就获取启动阶段的加载类了



基于JVMTI 实现


除了通过 Hook ClassLoader的方案实现,我们也可以通过JVMTI 来实现类加载监控。关于JVMTI 可参考之前的文章 juejin.cn/post/694278…


通过注册ClassPrepare Callback, 可以在每个类Prepare阶段触发回调。




当然这种方案,相比 Hook ClassLoader 还是要繁琐很多,不过基于JVMTI 还可以做很多其他更强大的事。


类预加载实现


目前应用通常都是多模块的,因此我们可以设计一个抽象接口,不同的业务模块可以继承该抽象接口,定义不同业务模块需要进行预加载的类。


/**
* 资源预加载接口
*/

public interface PreloadDemander {
/**
* 配置所有需要预加载的类
* @return
*/

Class[] getPreloadClasses();
}

之后在启动阶段收集所有的 Demander实例,并触发预加载


/**
* 类预加载执行器
*/

object ClassPreloadExecutor {


private val demanders = mutableListOf<PreloadDemander>()

fun addDemander(classPreloadDemander: PreloadDemander) {
demanders.add(classPreloadDemander)
}

/**
* this method shouldn't run on main thread
*/

@WorkerThread fun doPreload() {
for (demander in localDemanders) {
val classes = demander.preloadClasses
classes.forEach {
val classLoader = ClassPreloadExecutor::class.java.classLoader
Class.forName(it.name, true, classLoader)
}
}
}

}

收益


第一个版本配置了大概90个类,在终端机型测试数据显示 这些类的加载需要消耗30ms左右的cpu时间,不同类加载的消耗时间差异主要来自于类的复杂度 比如继承体系、字段属性数量等, 以及类初始化阶段的耗时,比如静态成员变量的立即初始化、静态代码块的执行等。


方案优化思考


我们目前的方案 配置的具体类列表来源于手动配置,这种方案的弊端在于,类的列表需要开发维护,在版本快速迭代变更的情况下 维护成本较大, 并且对于一些大型App,存在着非常多的AB实验条件,这也可能导致不同的用户在类加载上是会有区别的。


在前面的小节中,我们介绍了使用自定义的 ClassLoader可以手动收集 启动阶段主线程的类列表,那么 我们是否可以在端上 每次启动时 自动收集加载的类,如果发现这个类不在现有 的名单中 则加入到名单,在下次启动时进行预加载。 当然 具体的策略还需要做详细设计,比如 控制预加载名单的列表大小, 被加入预加载名单的类最低耗时阈值, 淘汰策略等等。


Retrofit ServiceMethod 预解析注入


背景


Retrofit 是目前最常用的网络库框架,其基于注解配置的网络请求方式及Adapter的设计模式大大简化了网络请求的调用方式。 不过其并没有采用类似APT的方式在编译时生成请求代码,而是采用运行时解析的方式。


当我们调用Retrofit.create(final Class service) 函数时,会生成一个该抽象接口的动态代理实例。



接口的所有函数调用都会被转发到该动态代理对象的invoke函数,最终调用loadServiceMethod(method).invoke 调用。



在loadServiceMethod函数中,需要解析原函数上的各种元信息,包括函数注解、参数注解、参数类型、返回值类型等信息,并最终生成ServiceMethod 实例,对原接口函数的调用其实最终触发的是 这个生成的ServiceMethod invoke函数的调用。


从源码实现上可以看出,对ServiceMethod的实例做了缓存处理,每个Method 对应一个ServiceMethod。


耗时测试


这里我模拟了一个简单的 Service Method, 并调用archiveStat 观察首次调用及其后续调用的耗时,注意这里的调用还未触发网络请求,其返回的是一个Call对象。




从测试结果上看,首次调用需要触发需要消耗1.7ms,而后续的调用 只需要消耗50微妙左右。



优化方案


由于首次调用接口函数需要触发ServiceMethod实例的生成,这个过程比较耗时,因此优化思路也比较简单,收集启动阶段会调用的 函数,提前生成ServiceMethod实例并写入到缓存中。


serviceMethodCache 的类型本身是ConcurrentHashMap,所以它是并发安全的。



但是源码中 进行ServiceMethod缓存判断的时候 还是以 serviceMethodCache为Lock Object 进行了加锁,这导致 多线程触发同时首次触发不同Method的调用时,存在锁等待问题



这里首先需要理解为什么这里需要加锁,其目的也是因为parseAnnotations 是一个好事操作,这里是为了实现类似 putIfAbsent的完全原子性操作。 但实际上这里加锁可以以 对应的Method类型为锁对象,因为本身不同Method 对应的ServiceMethod实例就是不同的。 我们可以修改其源码的实现来避免这种场景的锁竞争问题。




当然针对我们的优化场景,其实不修改源码也是可以实现的,因为 ServiceMethod.parseAnnotations 是无锁的,毕竟它是一个纯函数。 因此我们可以在异步线程调用parseAnnotations 生成ServiceMethod 实例,之后通过反射 写入 Retrofit实例的 serviceMethodCache 中。这样存在的问题是 不同线程可能同时触发了一个Method的解析注入,但 由于serviceMethodCache 本身就是线程安全的,所以 它只是多做了一次解析,对最终结果并无影响。


ServiceMethod.parseAnnotations是包级私有的,我们可以在当前工程创建一个一样的包,这样就可以直接调用该函数了。 核心实现代码如下


package retrofit2

import android.os.Build
import timber.log.Timber
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.Modifier

object RetrofitPreloadUtil {
private var loadServiceMethod: Method? = null
var initSuccess: Boolean = false
// private var serviceMethodCacheField:Map<Method,ServiceMethod<Any>>?=null
private var serviceMethodCacheField: Field? = null

init {
try {
serviceMethodCacheField = Retrofit::class.java.getDeclaredField("serviceMethodCache")
serviceMethodCacheField?.isAccessible = true
if (serviceMethodCacheField == null) {
for (declaredField in Retrofit::class.java.declaredFields) {
if (Map::class.java.isAssignableFrom(declaredField.type)) {
declaredField.isAccessible =true
serviceMethodCacheField = declaredField
break
}
}
}
loadServiceMethod = Retrofit::class.java.getDeclaredMethod("loadServiceMethod", Method::class.java)
loadServiceMethod?.isAccessible = true
} catch (e: Exception) {
initSuccess = false
}
}

/**
* 预加载 目标service 的 相关函数,并注入到对应retrofit实例中
*/

fun preloadClassMethods(retrofit: Retrofit, service: Class<*>, methodNames: Array<String>) {
val field = serviceMethodCacheField ?: return
val map = field.get(retrofit) as MutableMap<Method,ServiceMethod<Any>>

for (declaredMethod in service.declaredMethods) {
if (!isDefaultMethod(declaredMethod) && !Modifier.isStatic(declaredMethod.modifiers)
&& methodNames.contains(declaredMethod.name)) {
try {
val parsedMethod = ServiceMethod.parseAnnotations<Any>(retrofit, declaredMethod) as ServiceMethod<Any>
map[declaredMethod] =parsedMethod
} catch (e: Exception) {
Timber.e(e, "load method $declaredMethod for class $service failed")
}
}
}

}

private fun isDefaultMethod(method: Method): Boolean {
return Build.VERSION.SDK_INT >= 24 && method.isDefault;
}

}

预加载名单收集


有了优化方案后,还需要收集原本在启动阶段会在主线程进行Retrofit ServiceMethod调用的列表, 这里采取的是字节码插桩的方式,使用的LancetX 框架进行修改。



目前名单的配置是预先收集好,在配置中心进行配置,运行时根据配置中写的配置 进行预加载。 这里还可以提供其他的配置方案,比如 提供一个注解用于标注该Retrofit函数需要进行预解析,



之后,在编译期间收集所有需要预加载的Service及函数,生成对应的名单,不过这个方案需要一定开发成本,并且需要去修改业务模块的代码,目前的阶段还处于验证收益阶段,所以暂未实施。


收益


App收集了启动阶段20个左右的Method 进行预加载,预计提升10~20ms。


ARouter


背景


ARouter框架提供了路由注册跳转 及 SPI 能力。为了优化冷启动速度,对于某些服务实例可以在启动阶段进行预加载生成对应的实例对象。


ARouter的注册信息是在预编译阶段(基于APT) 生成的,在编译阶段又通过ASM 生成对应映射关系的注入代码。



而在运行时以获取Service实例为例,当调用navigation函数获取实例最终会调用到 completion函数。



当首次调用时,其对应的RouteMeta 实例尚未生成,会继续调用 addRouteGroupDynamic函数进行注册。



addRouteGroupDynamic 会创建对应预编译阶段生成的服务注册类并调用loadInto函数进行注册。而某些业务模块如何服务注册信息比较多,这里的loadInto就会比较耗时。



整体来看,对于获取Service实例的流程, completion的整个流程 涉及到 loadInto信息注册、Service实例反射生成、及init函数的调用。 而completion函数是synchronized的,因此无法利用多线程进行注册来缩短启动耗时。


优化方案


这里的优化其实和Retroift Service 的注册机制类似,不同的Service注册时,其对应的元信息类(IRouteGroup)其实是不同的,因此只需要对对应的IRouteGroup加锁即可。


在completion的后半部分流程中,针对Provider实例生产的流程也需要进行单独加锁,避免多次调用init函数。



收益


根据线下收集的数据 配置了20+预加载的Service Method, 预期收益 10~20ms (中端机) 。


其他


后续将继续结合自身业务现状以及其他一线大厂分享的样例,在 x2c、class verify、禁用JIT、 disableDex2AOT等方面继续尝试优化。


如果通过本文对你有所收获,可以来个点赞、收藏、关注三连,后续将分享更多性能监控与优化相关的文章。


也可以关注个人公众号:编程物语


image.png


本文相关测试代码已分享至github: github.com/Knight-ZXW/…


APM性能监控与优化专栏


性能优化专栏历史文章:


作者:卓修武K
来源:juejin.cn/post/7249228528573513789
tbody>
文章地址
Android平台下的cpu利用率优化实现juejin.cn/post/724324…
抖音消息调度优化启动速度方案实践juejin.cn/post/721766…
扒一扒抖音是如何做线程优化的juejin.cn/post/721244…
监控Android Looper Message调度的另一种姿势juejin.cn/post/713974…
Android 高版本采集系统CPU使用率的方式juejin.cn/post/713503…
Android 平台下的 Method Trace 实现及应用juejin.cn/post/710713…
Android 如何解决使用SharedPreferences 造成的卡顿、ANR问题juejin.cn/post/705476…
基于JVMTI 实现性能监控juejin.cn/post/694278…
收起阅读 »

Flutter卡片分享功能实现:将你的内容分享给世界

前言 在app中,在实现分享功能的时候,通常会有一种以卡片形式展示和分享内容的分享方式。这种功能可以将信息以整洁、易读的方式呈现给用户,使他们能够快速了解内容的关键信息,并将其分享给其他人。那么在这篇文章中,就一起来探索下,如何使用Flutter来实现这卡片...
继续阅读 »

前言



在app中,在实现分享功能的时候,通常会有一种以卡片形式展示和分享内容的分享方式。这种功能可以将信息以整洁、易读的方式呈现给用户,使他们能够快速了解内容的关键信息,并将其分享给其他人。那么在这篇文章中,就一起来探索下,如何使用Flutter来实现这卡片分享功能吧~


源代码:http://www.aliyundrive.com/s/FH7Xc2vyL…


效果图:



实现方案


为了卡片的样式的灵活性和可定制性,本文采用对组件进行截图的方式来实现卡片保存分享的功能,选择这个方案还有一点好处就是充分利用了flutter跨平台的优势。当然也会有一定的缺点,例如对于性能的考虑,当对复杂的嵌套卡片组件截图时,渲染和图像转换的计算量是需要考虑的,当然也可以选择忽略不计~


创建弹窗&卡片布局


在生成分享卡片的同时还会有其他的操作选项,例如保存图片、复制链接、浏览器打开等等,所以通常分享卡片的形式为弹窗形式,中间为分享卡片主体,剩余空间为操作项。



操作项组件封装:


class ImageDialog extends StatelessWidget {
const ImageDialog({
Key? key,
required this.items,
...
}) : super(key: key);
final List<ItemLittleView> items;
...

@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
...
child: Row(
children: items
.map((e) => itemLittleView(
label: e.label,
icon: e.icon,
onTap: () {
Navigator.pop(context);
e.onTap?.call();
}))
.toList()),
),
],
);
}

Widget itemLittleView({
required String label,
required String icon,
Function()? onTap,
}) =>
InkWell(
onTap: onTap,
child: Container(
margin: EdgeInsets.only(right: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
//图标
),
Container(
//文字
),
],
),
),
);
}
}

class ItemLittleView {
final String label;
final String icon;
final Function()? onTap;

ItemLittleView({required this.label, required this.icon, this.onTap});
}

需要加入新的操作项时,只需要简单的添加一个ItemLittleView即可。


ImageDialog(
items: [
ItemLittleView(
label: "生成图片 ",
icon: "assets/images/icon/ic_down.png",
onTap: () => doSaveImage(),
),
...
],
),

卡片的布局则根据业务的需求自定义即可,本文也只是一个简单的例子。


渲染并截取组件截图


在flutter中可以使用RepaintBoundary将将组件渲染为图像。



  • 第一步:定义全局的GlobalKey,用于获取卡片布局组件的引用


var repaintKey = GlobalKey();

RepaintBoundary(
key: repaintKey,
//分享卡片
child: shareImage(),
),


  • 第二步:使用RenderRepaintBoundary的toImage方法将其转换为图像


Future<Uint8List> getImageData() async {
BuildContext buildContext = repaintKey.currentContext!;
//用于存储截取的图片数据
var imageBytes;
//通过 buildContext 获取到 RenderRepaintBoundary 对象,表示要截取的组件边界
RenderRepaintBoundary boundary =
buildContext.findRenderObject() as RenderRepaintBoundary;

//这行代码获取设备的像素密度,用于设置截取图片的像素密度
double dpr = ui.window.devicePixelRatio;
//将边界对象 boundary 转换为图像,使用指定的像素密度。
ui.Image image = await boundary.toImage(pixelRatio: dpr);
// image.width
//将图像转换为ByteData数据,指定了数据格式为 PNG 格式。
ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
//将ByteData数据转换为Uint8List 类型的图片数据。
imageBytes = byteData!.buffer.asUint8List();
return imageBytes;
}


  • 第三步:获取权限&保存截图


//获取权限
_requestPermission() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.storage,
].request();

final info = statuses[Permission.storage].toString();
}

Future<String> saveImage(Uint8List imageByte) async {
//将回调拿到的Uint8List格式的图片转换为File格式
//获取临时目录
var tempDir = await getTemporaryDirectory();
//生成file文件格式
var file =
await File('${tempDir.path}/image_${DateTime.now().millisecond}.png')
.create();
//转成file文件
file.writeAsBytesSync(imageByte);
print("${file.path}");
String path = file.path;
return path;
}

//最后通过image_gallery_saver来保存图片
/// 执行存储图片到本地相册
void doSaveImage() async {
await _requestPermission();
Uint8List data = await getImageData();
String path = await saveImage(data);
final result = await ImageGallerySaver.saveFile(path);
showDialog(
context: context,
builder: (_) {
return AlertDialog(
title: Text("保存成功!"),
);
});
}

到这里,分享卡片的功能就实现啦~


总结


在本文中,我们探索了使用Flutter实现卡片分享功能的过程。在开发app时,卡片分享功能可以为用户提供更好的交互和共享体验,我猜大家在开发的过程中也会有很大的概率碰上这样的需求。通过设计精美的卡片样式,可以帮助更快速的推广APP。


关于我


Hello,我是Taxze,如果您觉得文章对您有价值,希望您能给我的文章点个❤️,有问题需要联系我的话:我在这里 ,也可以通过掘金的新的私信功能联系到我。如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章~万

作者:编程的平行世界
来源:juejin.cn/post/7249347871564300345
一哪天我进步了呢?😝

收起阅读 »

Kotlin1.8新增特性,进来了解一下

大家好,之前我已经写过了分析kotlin1.5、1.6、1.7、1.9插件版本新增的一些特性,唯独kotlin1.8的特性还没好好讲讲,本篇文章就带大家好好分析下kotlin1.8新增了那些特性,能对我们日常开发带来哪些帮助。 其中Kotlin1.8.0提供的...
继续阅读 »

大家好,之前我已经写过了分析kotlin1.5、1.6、1.7、1.9插件版本新增的一些特性,唯独kotlin1.8的特性还没好好讲讲,本篇文章就带大家好好分析下kotlin1.8新增了那些特性,能对我们日常开发带来哪些帮助。


其中Kotlin1.8.0提供的特性有限,本篇文章主要是分析Kotlin1.8.20提供的一些新特性。下面是支持该插件的IDE对应版本:



一. 提供性能更好的Enum.entries替代Enum.values()



在之前,如果我们想遍历枚举内部元素,我们通常会写出以下代码:


enum class Color(val colorName: String, val rgb: String) {
RED("Red", "#FF0000"),
ORANGE("Orange", "#FF7F00"),
YELLOW("Yellow", "#FFFF00")
}

fun main() {
Color.values().forEach {
println("${it.rgb}--${it.colorName}--${it.name}")
}
}

但是不知道大家是否清楚,Color.values() 其实存在性能问题,换句话说,每调用一次该方法,就会触发重新分配一块内存,如果调用的频率过高,就很可能引发内存抖动


我们可以反编译下枚举类简单看下原因:



Color.values()每次都会调用Object.clone()方法重新创建一个新的数组,这就是上面说的潜在的性能问题,github上也有相关的问题链接,感兴趣的可以看下:HttpStatus.resolve allocates HttpStatus.values() once per invocation


同时Color.values()返回的是一个数组,而在我们大多开发场景中,可能集合使用的频率更高,这就可能涉及到一个数组转集合的操作。


基于以上考虑,Kotlin1.8.20官方提供了一个新的属性:Color.entries这个方法会预分配一块内存并返回一个不可变集合,多次调用也不会产生潜在的性能问题


我们简单看下使用:


fun main() {
Color.entries.forEach {
println("${it.rgb}--${it.colorName}--${it.name}")
}
}

输出:



同时我们也可以从反编译的代码中看出区别:



不会每次调用都重新分配一块内存并返回。


如果想要使用这个特性,可以加上下面配置:


compileKotlin.kotlinOptions {
languageVersion = "1.9"
}

另外多说一下,IntelliJ IDEA 2023.1版本也会检测代码中是否存在Enum.values()的使用,存在就提示使用Enum.entries代替。


二. 允许内联类声明次级构造函数



内联类在Kotlin1.8.20之前是不允许带body的次级构造函数存在的,也就是说下面的代码运行会报错:


@JvmInline
value class Person( val fullName: String) {
constructor(name: String, lastName: String) : this("$name $lastName") {
check(lastName.isNotBlank()) {
"Last name shouldn't be empty"
}
}
}

fun main() {
println(Person("a", "b").fullName)
}

运行看下结果:



如果没有次级构造函数body,下面这样写是没问题的:


    constructor(name: String, lastName: String) : this("$name $lastName") 

如果想要支持带body的次级构造函数,只需要在kotlin1.8.20插件版本上和上一个特性一样增加languageVersion = "1.9"配置即可。


然后上面的代码块运行就没问题了,我们看下输出:


fun main() {
println(Person("a", "").fullName)
}


准确的执行了次级构造函数body内的逻辑。


三. 支持java synthethic属性引用



这个特性用文字不好解释,我们直接通过代码去学习下该特性。


当前存在一个类Person1


public class Person1 {
private String name;
private int age;

public Person1(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

在Kotlin1.8.20之前,以下这种写法是会报错的:



而是必须改成sortedBy(Person1::getAge)才能运行通过。


和上面特性一样,如果想要支持Person1::age这种引用方式,只需要在kotlin1.8.20插件版本上和上一个特性一样增加languageVersion = "2.1"配置即可。



PS:请注意,Kotlin官方网站提示配置languageVersion = "1.9" 就能使用上面的实验特性,但是编译器还是提示报错,然后你找报错提示信息改成了languageVersion = "2.1" 就正常了。




四. 新Kotlin K2编译器的更新



就是说目前Kotlin K2编译器还是一个实验阶段,不过Kotlin官方在其stable的路上又增加了一些更新:



  1. 序列化插件的预览版本;

  2. JS IR编译器的alpha支持;

  3. Kotlin2.0版本特性的引入;


如果大家想要体验下最新版的Kotlin K2编译器,增加配置:languageVersion ="2.0"即可。


五. Kotlin标准库支持AutoCloseable



这个AutoCloseable 接口就是用来支持资源关闭的,搭配提供的use扩展函数,就能帮助我们在资源流使用完毕后自动关闭。


Kotlin之所以在标准库中支持,应该是想要支持多平台吧。


六. Kotlin标准库支持Base64编解码


这里不做太多介绍,看下面的使用例子即可:



七. Kotlin标准库@Volatile支持Kotlin/Native


@Volatile注解在Kotlin/JVM就是保证线程之间可见性以及有序性的,kotlin官方在Kotlin/Native中也支持了该注解使用,有兴趣的可以实战试下效果。


总结


本篇文章主要是介绍了Kotlin1.8版本新增的一些特性,主要挑了一些我能理解的、常用的一些特性拉出来介绍,希望能对你有所帮助。


历史文章


两个Kotlin优化小技巧,你绝对用的上


浅析一下:kotlin委托背后的实现机制


Kotlin1.9.0-Beta,它来了!!


聊聊Kotlin1.7.0版本提供的一些特性


聊聊kotlin1.5和1.6版本提供的一些新特性


kotlin密封sealed class/interface的迭代之旅


优化@BuilderInference注解,Kotlin高版本下了这些“毒手”!


@JvmDefaultWithCompatibility优化小技巧

,了解一下~

收起阅读 »

Flutter 初探原生混合开发

转载请注明出处:juejin.cn/post/724677… 本文出自 容华谢后的博客 0.写在前面 现如今跨平台技术被越来越多的开发者提起和应用,从最早的Java到后来的RN、Weex,到现在的Compose、Flutter,大前端已经成为了趋势,很多公司...
继续阅读 »

转载请注明出处:juejin.cn/post/724677…


本文出自 容华谢后的博客



0.写在前面


现如今跨平台技术被越来越多的开发者提起和应用,从最早的Java到后来的RN、Weex,到现在的Compose、Flutter,大前端已经成为了趋势,很多公司为了节省成本,包括一些大厂已经在Android和iOS平台上使用了Flutter技术,效果还可以,贴近原生但是还会有一些卡顿的问题,好在Flutter目前还在不断的优化更新,希望越来越好吧。


Flutter从2017年发布到现在已经历经了6年,如果你现在创建一个Flutter项目,会发现已经支持了Android、iOS、Linux、MacOS、Web、Windows六大主流的操作系统平台,我以前经常会写一些在Windows上运行的小工具,使用java写的不仅复杂界面也不好看,用Flutter试了试,好像发现了新大陆,在PC上运行十分流畅,还直接支持在其他平台上运行,感觉十分不错,这也让我对未来Flutter的发展抱有期待。


Flutter开发有两种方式,一种是纯Flutter开发,一种是Flutter+原生的开发方式,正如上面所说的,Flutter在PC上运行十分流畅,可能是PC配置比较高的原因,但是在客户端上的运行效果却不如人意,启动有点慢,一些复杂列表有点卡,一些底层功能的API不支持,这就需要原生开发的介入,小部分原生+大部分Flutter开发可能是后续比较主流的一种开发方式。


本文主要讲的是在Android平台上,与Flutter混合开发的一些步骤,一起来看下吧。


1.准备


1.1 先贴下我用的开发环境:




  • 操作系统:Windows 10




  • IDE:Android Studio Flamingo




  • Android SDK:33




  • Gradle:8.0.2




  • JDK:17.0.7




  • Flutter:3.10.4




1.2 下载Flutter SDK


下载地址:docs.flutter.dev/get-started…


是个压缩包,解压到你存放开发环境的目录,然后在AS中打开 File->Settings->Languages&Frameworks,在里面配置一下SDK的路径就可以了。


1.3 配置环境变量


和Jdk一样,为了使用方便,还需要配置下环境变量,设置->关于->高级系统设置->环境变量,找到用户变量,在Path里面新增一个路径 flutter SDK的路径\bin,前面如果有值的话,别忘了在前面加个英文分号进行分割。


1.4 检测flutter状态


为了验证Flutter是否安装成功,打开cmd命令行,输入 flutter doctor 进行检测:


flutter doctor


如果出现上面的提示,是因为Android证书的问题,再输入 flutter doctor --android-licenses 进行修复:


不支持Jdk 1.8版本


可能会出现这样的错误,这个是因为JDK版本有点低,现在大部分还是用的1.8版本,安装配置下JDK 17就可以,再运行下flutter doctor,已经可以了:


flutter doctor通过


1.5 安装Flutter插件


在AS中打开 File->Settings->Plugins,安装下面两个插件:


插件


到这里,所有的准备工作就完成了,接下来去创建项目。


2.创建项目


首先创建一个标准的Android项目,在此基础上,打开 File->New->New Flutter Project 创建一个Flutter Module:


创建Flutter Module


注意Project location要选择你当前的工程目录,Project types选择Module,然后CREATE,看下创建好的目录结构:


目录结构


3.项目Flutter配置


打开项目根目录的settings.gradle配置文件,增加下面的配置:


// Flutter配置
setBinding(new Binding([gradle: this]))
evaluate(new File(
settingsDir,
'flutter_lib/.android/include_flutter.groovy'
))
include ':flutter_lib'


然后再修改下dependencyResolutionManagement,把FAIL_ON_PROJECT_REPOS 改成 PREFER_SETTINGS,增加flutter的maven仓库地址:


dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
google()
mavenCentral()
maven {
allowInsecureProtocol = true
url "http://download.flutter.io"
}
}
}

找到flutter_lib->.android->Flutter->build.gradle,在android属性增加namespace,这个是Gradle 8.0新增的特性:


android {
namespace 'com.example.flutter_lib'
compileSdkVersion flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
...
}

找到主app的build.gradle,在dependencies中引用flutter模块,注意模块名称是flutter,无论你创建的Moudle是什么名字,这里的名字都是flutter:


dependencies {
...
implementation project(':flutter')
}

4.开始使用


在清单文件中,增加下面的activity标签,注意这个Activity是SDK中自带的,不需要自己手动创建:


<application>
...

<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize" />
</application>

在MainActivity写个跳转方法进行测试:


val intent = FlutterActivity
.withNewEngine()
.initialRoute("home")
.build(this)
startActivity(intent)

看下效果:


跳转效果


可以看到在点击跳转按钮后,有一个明显的停顿,这是因为初始化Flutter引擎比较慢导致的,那就提前初始化试试,在Application中初始化引擎:


class App : Application() {

override fun onCreate() {
super.onCreate()
// 创建 Flutter 引擎
val flutterEngine = FlutterEngine(this)
// 指定要跳转的flutter页面
flutterEngine.navigationChannel.setInitialRoute("main")
flutterEngine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())
// 这里做一个缓存,可以在适当的时候执行它,例如app里,在跳转前执行预加载
val flutterEngineCache = FlutterEngineCache.getInstance()
flutterEngineCache.put("default_engine_id", flutterEngine)
}
}

然后使用已经提前创建后的引擎再次跳转:


val intent = FlutterActivity
.withCachedEngine("default_engine_id")
.build(this)
startActivity(intent)

看下效果,已经非常丝滑了:


优化后跳转效果


5.写在最后


GitHub地址:github.com/alidili/Flu…


到这里,Flutter与原生混合开发的基本步骤就介绍完了,如有问题可以给我留言评论或者在GitHub中提交

作者:容华谢后
来源:juejin.cn/post/7246778558248058938
Issues,谢谢!

收起阅读 »

像支付宝那样“致敬”第三方开源代码

前言 通常我们在App中会使用第三方的开源代码,按照许可协议,我们应该在App中公开使用的开源代码并且附上对应的开源协议。当然,实际上只有少部分注重合规性的大厂才会这么干,比如下图是支付宝的关于界面的第三方信息。当然,对于小企业,基本上都不会放使用的第三方开源...
继续阅读 »

前言


通常我们在App中会使用第三方的开源代码,按照许可协议,我们应该在App中公开使用的开源代码并且附上对应的开源协议。当然,实际上只有少部分注重合规性的大厂才会这么干,比如下图是支付宝的关于界面的第三方信息。当然,对于小企业,基本上都不会放使用的第三方开源代码的任何信息。
image.png


不过,作为一个有“追求”的码农,我们还是想对开源软件致敬一下的,毕竟,没有他们我都不知道怎么写代码。然而,我们的 App 里用了那么多第三方开源插件,总不能一个个找出来一一致敬吧?怎么办?其实,Flutter 早就为我们准备好了一个组件,那就是本篇要介绍的 AboutDialog


AboutDialog 简介


AboutDialog 是一个对话框,它可以提供 App 的基本信息,如 Icon、版本、App 名称、版权信息等。
image.png


同时,AboutDialog还提供了一个查看授权信息(View Licenses)的按钮,点击就可以查看 App 里所有用到的第三方开源插件,并且会自动收集他们的 License 信息展示。所以,使用 AboutDialog 可以让我们轻松表达敬意。怎么使用呢?非常简单,我们点击一个按钮的时候,调用 showAboutDialog 就搞定了,比如下面的代码:


IconButton(
onPressed: () {
showAboutDialog(
context: context,
applicationName: '岛上码农',
applicationVersion: '1.0.0',
applicationIcon: Image.asset('images/logo.png'),
applicationLegalese: '2023 岛上码农版权所有'
);
},
icon: const Icon(
Icons.info_outline,
color: Colors.white,
),
),

参数其实一目了然,具体如下:



  • context:当前的 context

  • applicationName:应用名称;

  • applicationVersion:应用版本,如果要自动获取版本号也可以使用 package_info_plus 插件。

  • applicationIcon:应用图标,可以是任意的 Widget,通常会是一个App 图标图片。

  • applicationLegalese:其他信息,通常会放置应用的版权信息。


点击按钮,就可以看到相应的授权信息了,点击一项就可以查看具体的 License。我看了一下使用的开源插件非常多,要是自己处理还真的很麻烦。
image.png


可以说非常简单,当然,如果你直接运行还有两个小问题。


按钮本地化


AboutDialog 默认提供了两个按钮,一个是查看授权信息,一个是关闭,可是两个按钮 的标题默认是英文的(分别是VIEW LICENSES和 CLOSE)。
image.png


如果要改成本地话的,还需要做一个自定义配置。我们扒一下 AboutDialog 的源码,会发现两个按钮在DefaultMaterialLocalizations中定义,分别是viewLicensesButtonLabelcloseButtonLabel。这个时候我们自定义一个类集成DefaultMaterialLocalizations就可以了。


class MyMaterialLocalizationsDelegate
extends LocalizationsDelegate<MaterialLocalizations>
{
const MyMaterialLocalizationsDelegate();

@override
bool isSupported(Locale locale) => true;

@override
Future<MaterialLocalizations> load(Locale locale) async {
final myTranslations = MyMaterialLocalizations(); // 自定义的本地化资源类
return Future.value(myTranslations);
}

@override
bool shouldReload(
covariant LocalizationsDelegate<MaterialLocalizations> old) =>
false;
}

class MyMaterialLocalizations extends DefaultMaterialLocalizations {
@override
String get viewLicensesButtonLabel => '查看版权信息';

@override
String get closeButtonLabel => '关闭';

}

然后在 MaterialApp 里指定本地化localizationsDelegates参数使用自定义的委托类对象就能完成AboutDialog两个按钮文字的替换。


return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const AboutDialogDemo(),
localizationsDelegates: const [MyMaterialLocalizationsDelegate()],
);

添加自定义的授权信息


虽然 Flutter 会自动收集第三方插件,但是如果我们自己使用了其他第三方的插件的话,比如没有在 pub.yaml 里引入,而是直接使用了源码。那么还是需要手动添加一些授权信息的,这个时候我们需要自己手动添加了。添加的方式也不麻烦,Flutter 提供了一个LicenseRegistry的工具类,可以调用其 addLicense 方法来帮我们添加授权信息。具体使用如下:


LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
['关于岛上码农'],
'我是岛上码农,微信公众号同名。\f如有问题可以加本人微信交流,微信号:island-coder。',
);
});

这个方法可以在main方法里调用。其中第一个参数是一个数组,是因为可以允许多个开源代码共用一份授权信息。同时,如果一份开源插件有多个授权信息,可以多次添加,只要名称一致,Flutter就会自动合并,并且会显示该插件的授权信息条数,点击查看时,会将多条授权信息使用分割线分开,代码如下所示:


void main() {
runApp(const MyApp());
LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
['关于岛上码农'],
'我是岛上码农,微信公众号同名。如有问题可以加本人微信交流,微信号:island-coder。',
);
});

LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
['关于岛上码农'],
'使用时请注明来自岛上码农、。',
);
});
}

image.png


总结


本篇介绍了在 Flutter 中快速展示授权信息的方法,通过 AboutDialog 就可以轻松搞定,各位“抄代码”的码农们,赶紧用起来向大牛们致敬吧!



我是岛上码农,微信公众号同名。如有问题可以加本人微信交流,微信号:island-coder


👍🏻:觉得有收获请点个赞鼓励一下!


🌟:收藏文章,方便回看哦!


💬:评论交流,互相进步!


作者:岛上码农
来源:juejin.cn/post/7246328828837871677

收起阅读 »

浅析一下:kotlin委托背后的实现机制

大家好,kotlin的属性委托、类委托、lazy等委托在日常的开发中,给我们提供了很大的帮助,我之前的文章也是有实战过几种委托。不过对比委托实现的背后机制一直都没有分析过,所以本篇文章主要是带领大家分析下委托的实现原理,加深对kotlin的理解。 一. laz...
继续阅读 »

大家好,kotlin的属性委托、类委托、lazy等委托在日常的开发中,给我们提供了很大的帮助,我之前的文章也是有实战过几种委托。不过对比委托实现的背后机制一直都没有分析过,所以本篇文章主要是带领大家分析下委托的实现原理,加深对kotlin的理解。


一. lazy委托


这里我们不说用法,直接说背后的实现原理。


先看一段代码:


val content: String by lazy {
"oiuytrewq"
}

fun main() {
println(content.length)
}

我们看下反编译后的java代码:




  1. 首先会通过DelegateDemoKt静态代码块饿汉式的方式创建一个Lazy类型的变量content$delegate,命名的规则即代码中定义的原始变量值拼接上$delegate,我们原始定义的content变量就会从属性定义上消失,但会生成对应的get方法,即getContent()



  1. 当我们在main方法中调用content.length时,其实就是调用getContent().length(),而getContent()最终是调用了content$delegate.getValue方法;



  1. 这个lazy类型的变量是调用了LazyKt.lazy()方法创建,而真正的核心逻辑——该方法具体参数的传入,在反编译的java代码中并没有体现;


java代码既然看不到,我们退一步看下字节码:



上面是DelegateDemoKt类构造器对应的字节码,其中就是获取了DelegateDemoKt$content$2作为参数传入了LazyKt.lazy()方法。


我们看下DelegateDemoKt$content$2类的实现字节码:



DelegateDemoKt$content$2类实现了Function0接口,所以上面lazy的真正实现逻辑就是DelegateDemoKt$content$2类的invoke方法中,上图的字节码红框圈出的地方就很直观的看出来了。


二. 属性委托


属性委托的委托类就是指实现了ReadWritePropertyReadOnlyProperty接口的类,像官方提供的Delegates.observable()Delegates.vetoable()这两个api也是借助前面两个接口实现的。这里我们就以支持读写的ReadWriteProperty委托接口进行举例分析。


先看一段例子代码:


var age: Int by object : ReadWriteProperty<Any?, Int> {
override fun getValue(thisRef: Any?, property: KProperty<*>): Int {
return 10
}

override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
val v = value * value
println("setValue: $v")
}
}

fun main() {
age = 4
println(age)
}

我们看下反编译的java代码:


public final class DelegateDemoKt {
// $FF: synthetic field
static final KProperty[] $$delegatedProperties = new KProperty[]{Reflection.mutableProperty0(new MutablePropertyReference0Impl(DelegateDemoKt.class, "age", "getAge()I", 1))};
@NotNull
private static final <undefinedtype> age$delegate = new ReadWriteProperty() {
@NotNull
public Integer getValue(@Nullable Object thisRef, @NotNull KProperty property) {
Intrinsics.checkNotNullParameter(property, "property");
return 10;
}

public void setValue(@Nullable Object thisRef, @NotNull KProperty property, int value) {
Intrinsics.checkNotNullParameter(property, "property");
int v = value * value;
String var5 = "setValue: " + v;
System.out.println(var5);
}
};

public static final int getAge() {
return age$delegate.getValue((Object)null, $$delegatedProperties[0]);
}

public static final void setAge(int var0) {
age$delegate.setValue((Object)null, $$delegatedProperties[0], var0);
}

public static final void main() {
setAge(4);
int var0 = getAge();
System.out.println(var0);
}
}


  1. 和lazy有些类似,会生成一个实现了ReadWriteProperty接口的匿名类变量age$delegate,命名规则和lazy相同,通过还帮助我们生成了对应的getAgesetAge方法;



  1. 当我们在代码中执行age = 4就会调用setAge(4)方法,最终会调用age$delegate.setValue()方法;类似的调用age就会调用getAge(),最终调用到age$delegate.getValue()方法;



  1. 编译器还通过反射帮助我们生成了一个KProperty类型的$$delegatedProperties变量,主要是ReadWritePropertysetValuegetValue方法都需要传入这样一个类型的对象,通过$$delegatedProperties变量我们可以访问到具体的变量名等信息;




类似的还有一种属性委托,我们看下代码:


val map = mutableMapOf<String, Int>()

val name: Int by map

上面代码的意思是:当访问name时,就会从map这个散列表中获取key为"name"的value值并返回,不存在就直接抛异常,接下来我们看下反编译后的java代码:


public final class DelegateDemoKt {
// $FF: synthetic field
static final KProperty[] $$delegatedProperties = new KProperty[]{Reflection.property0(new PropertyReference0Impl(DelegateDemoKt.class, "name", "getName()I", 1))};
@NotNull
private static final Map map = (Map)(new LinkedHashMap());
@NotNull
private static final Map name$delegate;

static {
name$delegate = map;
}

public static final int getName() {
Map var0 = name$delegate;
Object var1 = null;
KProperty var2 = $$delegatedProperties[0];
return ((Number)MapsKt.getOrImplicitDefaultNullable(var0, var2.getName())).intValue();
}
}


  1. 生成一个Map类型的name$delegate变量,这个变量其实就是我们定义的map散列表;



  1. 通过反射生成了一个KProperty类型对象变量$$delegatedProperties,通过这个对象的getName()我们就能拿到变量名称,比如这里的"name"变量名;



  1. 最终调用了MapsKt.getOrImplicitDefaultNullable方法,去map散列表去查找"name"这个key对应的value;



PS:记得kotlin1.6还是1.7的插件版本对应委托进行了优化,这个后续的文章会再进行讲解。



三. 类委托


类委托实现就比较简单了,这里我们看下样例代码:


fun interface Fruit {
fun type(): Int
}

class FruitProxy(private val model: Fruit) : Fruit by model

fun main() {
val proxy: FruitProxy = FruitProxy {
-1
}
println(proxy.type())
}

反编译成java代码看下:





首先我们看下FruitProxy这个类,其实现了Fruit接口,借助属性委托特性,编译器会自动帮助我们生成type() 接口方法的实现,并再其中调用构造方法传入的委托类对象modeltype()方法,类委托的核心逻辑就这些。


再main()方法中构造FruitProxy时,我们也无法知晓具体的构造参数对象是啥,和上面的lazy一样,我们看下字节码:



其实FruitProxy方法就传入了一个DelegateDemoKt$main$proxy$1类型的对象,并实现了Fruit接口重写了type方法。


总结


本篇文章主要是讲解了三种委托背后的实现原理,有时候反编译字节码看不出来原理的,可以从字节码中寻找答案,希望本篇文章能对你有所帮助。


历史文章


这里是我整理的过往kotlin特性介绍的历史文章,大家感兴趣可以阅读下:


Kotlin1.9.0-Beta,它来了!!


聊聊Kotlin1.7.0版本提供的一些特性


聊聊kotlin1.5和1.6版本提供的一些新特性


kotlin密封sealed class/interface的迭代之旅


优化@BuilderInference注解,Kotlin高版本下了这些“毒手”!


@JvmDefaultWithCompatibility优化小技巧

,了解一下~

收起阅读 »

父母在家千万注意别打开“共享屏幕”,银行卡里的钱一秒被转走......

打开屏幕共享,差点直接被转账 今天和爸妈聊天端午回家的事情,突然说到最近AI诈骗的事情,千叮咛万嘱咐说要对方说方言才行,让他们充分了解一下现在骗子诈骗的手段,顺便也找了一下骗子还有什么其他的手段,打算一起和他们科普一下,结果就发现下面这一则新闻: 在辽宁大连务...
继续阅读 »

打开屏幕共享,差点直接被转账


今天和爸妈聊天端午回家的事情,突然说到最近AI诈骗的事情,千叮咛万嘱咐说要对方说方言才行,让他们充分了解一下现在骗子诈骗的手段,顺便也找了一下骗子还有什么其他的手段,打算一起和他们科普一下,结果就发现下面这一则新闻:


在辽宁大连务工的耿女士接到一名自称“大连市公安局民警”的电话,称其涉嫌广州一起诈骗案件,让她跟广州警方对接。耿女士在加上所谓的“广州警官”的微信后,这位“警官”便给耿女士发了“通缉令”,并要求耿女士配合调查,否则将给予“强制措施”。随后,对方与耿女士视频,称因办案需要,要求耿女士提供“保证金”,并将所有存款都集中到一张银行卡上,再把钱转到“安全账户”。


图片


期间,通过 “屏幕共享”,对方掌握了耿女士银行卡的账号和密码。耿女士先后跑到多家银行,取出现金,将钱全部存到了一张银行卡上。正当她打算按照对方指示,进行下一步转账时,被民警及时赶到劝阻。在得知耿女士泄露了银行卡号和密码后,银行工作人员立即帮助耿女士修改了密码,幸运的是,银行卡的近6万元钱没有受到损失。


就这手段,我家里的老人根本无法预防,除非把手机从他们手里拿掉,与世隔绝还差不多,所以还是做APP的各大厂商努力一下吧!


希望各大厂商都能看看下面这个防劫持SDK,让出门在外打工的我们安心一点。


防劫持SDK


一、简介


防劫持SDK是具备防劫持兼防截屏功能的SDK,可有效防范恶意程序对应用进行界面劫持与截屏的恶意行为。


二、iOS版本


2.1 环境要求


条目说明
兼容平台iOS 8.0+
开发环境XCode 4.0 +
CPU架构armv7, arm64, i386, x86_64
SDK依赖libz, libresolv, libc++

2.2 SDK接入


2.2.1 DxAntiHijack获取

官网下载SDK获取,下面是SDK的目录结构


1.png


DXhijack_xxx_xxx_xxx_debug.zip 防劫持debug 授权集成库 DXhijack_xxx_xxx_xxx_release.zip 防劫持release 授权集成库




  • 解压DXhijack_xxx_xxx_xxx_xxx.zip 文件,得到以下文件




    • DXhijack 文件夹



      • DXhijack.a 已授权静态库

      • Header/DXhijack.h 头文件

      • dx_auth_license.description 授权描述文件

      • DXhijackiOS.framework 已授权framework 集成库






2.2.2 将SDK接入XCode

2.2.2.1 导入静态库及头文件

将SDK目录(包含静态库及其头文件)直接拖入工程目录中,或者右击总文件夹添加文件。 或者 将DXhijackiOS.framework 拖进framework存放目录


2.2.2.2 添加其他依赖库

在项目中添加 libc++.tbd 库,选择Target -> Build Phases,在Link Binary With Libraries里点击加号,添加libc++.tbd


2.2.2.3 添加Linking配置

在项目中添加Linking配置,选择Target -> Build Settings,在Other Linker Flags里添加-ObjC配置


2.3 DxAntiHijack使用


2.3.1 方法及参数说明

@interface DXhijack : NSObject

+(void)addFuzzy; //后台模糊效果
+(void)removeFuzzy;//后台移除模糊效果
@end

2.3.2 使用示例

在对应的AppDelegate.m 文件中头部插入


#import "DXhijack.h"

//在AppDelegate.m 文件中applicationWillResignActive 方法调用增加
- (void)applicationWillResignActive:(UIApplication *)application {
[DXhijack addFuzzy];
}

//在AppDelegate.m 文件中applicationDidBecomeActive 方法调用移除
- (void)applicationDidBecomeActive:(UIApplication *)application {
[DXhijack removeFuzzy];
}


三、Android版本


3.1 环境要求


条目说明
开发目标Android 4.0+
开发环境Android Studio 3.0.1 或者 Eclipse + ADT
CPU架构ARM 或者 x86
SDK三方依赖

3.2 SDK接入


3.2.1 SDK获取


  1. 访问官网,注册账号

  2. 登录控制台,访问“全流程端防控->安全键盘SDK”模块

  3. 新增App,填写相关信息

  4. 下载对应平台SDK


3.2.2 SDK文件结构



  • SDK目录结构 android-dx-hijack-sdk.png



    • dx-anti-hijack-${version}.jar Android jar包

    • armeabiarmeabi-v7aarm64-v8ax86 4个abi平台的动态库文件




3.2.3 Android Studio 集成

点击下载Demo


3.2.3.1 Android Studio导入jar, so

把dx-anti-hijack-x.x.x.jar, so文件放到相应模块的libs目录下


android-dx-hijack-as.png



  • 在该Module的build.gradle中如下配置:


 android{
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}

repositories{
flatDir{
dirs 'libs'
}
}
}


dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
}



3.2.3.2 权限声明

Android 5.0(不包含5.0)以下需要在项目AndroidManifest.xml文件中添加下列权限配置:


<uses-permission android:name="android.permission.GET_TASKS"/>

3.2.3.3 混淆配置

-dontwarn *.com.dingxiang.mobile.**
-dontwarn *.com.mobile.strenc.**
-keep class com.security.inner.**{*;}
-keep class *.com.dingxiang.mobile.**{*;}
-keep class *.com.mobile.strenc.**{*;}
-keep class com.dingxiang.mobile.antihijack.** {*;}

3.3 DxAntiHijack 类使用


3.3.1 方法及参数说明

3.3.1.1 初始化


建议在Application的onCreate下調用


/**
* 使用API前必須先初始化
* @param context
*/

public static void init(Context context);

3.3.1.2 反截屏功能


/**
* 反截屏功能
* @param activity
*/

public static void DGCAntiHijack.antiScreen(Activity activity);

/**
* 反截屏功能
* @param dialog
*/

public static void DGCAntiHijack.antiScreen(Dialog dialog);

3.3.1.3 反劫持检测


/**
* 调用防劫持检测,通常现在activity的onPause和onStop调用
* @return 是否存在被劫持风险
*/

public static boolean DGCAntiHijack.antiHijacking();

3.3.2 使用示例

//使用反劫持方法
@Override
protected void onPause() {
boolean safe = DXAntiHijack.antiHijacking();
if(!safe){
Toast.makeText(getApplicationContext(), "App has entered the background", Toast.LENGTH_LONG).show();
}
super.onPause();
}

@Override
protected void onStop() {
boolean safe = DXAntiHijack.antiHijacking();
if(!safe){
Toast.makeText(getApplicationContext(), "App has entered the background", Toast.LENGTH_LONG).show();
}
super.onStop();
}



//使用反截屏方法
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
DXAntiHijack.antiScreen(MainActivity.this);
}

以上。


结语


这种事情层出不穷,真的不是吾等普通民众能解决的,最好有从上至下的政策让相应的厂商(尤其是银行和会议类的APP)统一做处理,这样我们在外

作者:昀和
来源:juejin.cn/post/7242145254057312311
打工的人才能安心呀。

收起阅读 »

末日终极坐标安卓辅助工具

前言 本文档只介绍工具的使用方法,有时间再写一篇介绍一下实现细节。 整体的话就是借助这个工具方便记录当前坐标,可以实现游戏资源不浪费。 阅读本文档前提是大家是《末日血战》游戏玩家。 工具下载安装 download.csdn.net/download/u0… 安...
继续阅读 »

前言


本文档只介绍工具的使用方法,有时间再写一篇介绍一下实现细节。
整体的话就是借助这个工具方便记录当前坐标,可以实现游戏资源不浪费。

阅读本文档前提是大家是《末日血战》游戏玩家。


工具下载安装


download.csdn.net/download/u0…


安装工具


工具安装后,桌面会有这个图标。

在打开工具前需进入应用设置页打开这个应用的显示悬浮窗权限图片说明


填入初始坐标


打开工具大致显示是这个样子,坐标初始都是0,那么填入相应的坐标保存就会是这个样子。
14ae0a88098b04a18b0a0c36b400e3c.jpg
可以看到左上角有个小图,这是一个直接坐标系的缩略图,拖拽可以移动位置。
小图中会显示3个红点,一个绿点。绿点表示当前坐标,红点表示终极坐标。当前点的坐标是固定显示在左上角的


此时可以按返回键退出app,但是不要杀掉应用。


建立坐标系


初次使用,建议可以打开小程序截一张生存之路的全屏图,然后我们打开这张图并横屏显示图片开始操作。当已经熟悉工具如何使用后可以在游戏中进行操作了


建立坐标系

打开图片或者游戏,进入到这个界面,点击左上角悬浮窗上的开始按钮,会看到这样一个界面(没有中间两条直线)
47b446b703476c931a27667de16d706.jpg
我们的目标就是为了建立中间两条直线。


按图片指示的顺序操作。尽可能点击在轴线的中心位置
d6bdd46daf0a94960c54bf8918af5f5.png
以上操作只需要执行一次,后续就不需要操作了。
完成以上操作,就得到有两条线的图了。这个时候就完成了建立坐标系


开始寻找终极坐标


注意观察小地图,找一个我们没有到达的离的近的终极坐标为目标。可以看到x和y的差距。

举个例子,我们当前坐标49,52,刚刚已经在48,52这里取得了一个宝箱,那么下一个目的地选237,29。因为是x坐标相差较大,我们x太小,而y坐标我们的大一点。所以主要的方向是加x,少量的减y。
05c5394993b5a8877db3d81c8ce6425.png
所以我们应该按x轴正方向和y轴负方向这里走,因为x相差较大,所以如果可以的话(有障碍物就走不了)就直接沿着x轴正方向走就好了。


我想游戏玩家应该还是知道要往哪走的,但是容易算错坐标或者根本懒得记,凭运气。那么我们指定往哪走之后,接下来怎么使用这个工具。



  • 1、点击一个位置(我们要让小车开到的位置),这个时候小车不会走,因为我们工具盖住了游戏

  • 2、app回退一下(不是杀掉应用),这时可以发现小车在抖动了,其实就是小车可以走了,再点一下刚才那个位置,小车就会走到那个位置。这样我们就完成了一次移动和坐标记录。小地图当前坐标就会变化。绿点也会移动。

  • 3、小车走完之后,我们再点开始,然后重复1,2 步骤。


补充



  • 1、本工具存在误差,一般每次执行在小车x,y <=|2| 基本100%准确。x,y <= |3| 100个汽油可能会有|2|以内坐标的误差(仅本人测试数据)

  • 1、点击位置尽可能点在地图块的中间,这样可以减少误差。遇到坐标点在路径上,可以进入其中对当前坐标进行校准,当然一般是不需要的。

  • 1、如果遇到了事件,我们就处理完事件后再点开始按钮

  • 2、回退怎么用:右下角回退用途是当我们不想走这一步,可以回退一步。重新再点一个点。确认这个点没问题我们就回退app,如果回退还是后悔不想走这一步(这个回退是指回退我们的记录,游戏中的步骤我们肯定是做不到回退的),再打开开始点击回退

  • 3、本次汽油用完后,就可以杀掉辅助工具app了。下次有汽油可以继续直接使用,记住使用过程中的退出都是回退而不是杀掉app


最后


希望大家先熟悉工具流程,可以截一张图去操作,然后再在游戏中操作避免浪费资源。坐标可以随时矫正。

希望大家游戏愉快,也希望本工具对大家有所帮助。

如有建议或问题可在文章评论

作者:流光无影
来源:juejin.cn/post/7243081126826491941
中反馈或者群里找我。

收起阅读 »

10 秒看懂 Android 动画的实现原理

介绍 动画是 Android 应用程序中重要的交互特性。Android 提供了多种动画效果,包括平移、缩放、旋转和透明度等,它们可以通过代码或 XML 来实现。本文将介绍 Android 动画的原理和实现方法,并提供一些示例。 原理 Android 动画的实现...
继续阅读 »

介绍


动画是 Android 应用程序中重要的交互特性。Android 提供了多种动画效果,包括平移、缩放、旋转和透明度等,它们可以通过代码或 XML 来实现。本文将介绍 Android 动画的原理和实现方法,并提供一些示例。


原理


Android 动画的实现原理是通过改变视图的属性来实现的。当我们在代码中设置视图的属性值时,Android 会通过平滑过渡的方式来将视图从一个状态过渡到另一个状态。这种平滑过渡的效果就是动画效果。


属性


Android 中有许多属性可以用来实现动画效果,以下是一些常用的属性:



  • translationX:视图在 X 轴上的平移距离。

  • translationY:视图在 Y 轴上的平移距离。

  • scaleX:视图在 X 轴上的缩放比例。

  • scaleY:视图在 Y 轴上的缩放比例。

  • rotation:视图的旋转角度。

  • alpha:视图的透明度。


类型


Android 中有多种不同类型的动画,每种类型都有其自身的特点和用途:


View 动画


View 动画是一种在应用程序中实现动画效果的简单方法。它可以通过 XML 或代码来实现。View 动画可以应用于任何 View 对象,包括按钮、文本框、图像等等。常见的 View 动画包括平移、缩放、旋转和透明度等效果。以下是一个 View 动画的 XML 示例:


<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="0%"
android:toXDelta="50%"
android:duration="500"
android:repeatCount="infinite"
android:repeatMode="reverse" />

</set>

帧动画


帧动画是一种将一系列图像逐帧播放来实现动画效果的方法。它可以通过 XML 或代码来实现。帧动画常用于播放一系列连续的图像,例如动态图像、电影等等。以下是一个帧动画的 XML 示例:


<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="false">

<item android:drawable="@drawable/animation_frame1" android:duration="50" />
<item android:drawable="@drawable/animation_frame2" android:duration="50" />
<item android:drawable="@drawable/animation_frame3" android:duration="50" />
...
</animation-list>

属性动画


属性动画是一种可以改变视图属性值的动画效果。它可以通过 XML 或代码来实现。属性动画可以应用于任何属性,包括大小、颜色、位置、透明度等等。它可以在运行时动态地更改属性值,从而实现平滑的动画效果。以下是一个属性动画的 Java 代码的示例:


ObjectAnimator animator = ObjectAnimator.ofFloat(view, "translationX", 0f, 300f);
animator.setDuration(1000);
animator.start();

过渡动画


过渡动画是一种在应用程序中实现平滑过渡效果的方法。它可以通过 XML 或代码来实现。过渡动画常用于实现屏幕之间的切换效果,例如滑动、淡入淡出等等。以下是一个过渡动画的 XML 示例:


<transition xmlns:android="http://schemas.android.com/apk/res/android">
<fade android:duration="500" />
</transition>

Lottie 动画


Lottie 是 Airbnb 开源的一种动画库,它可以将 Adobe After Effects 中制作的动画直接导出为 JSON 格式,并在 Android 应用程序中使用。Lottie 动画可以实现非常复杂的动画效果,例如骨骼动画、粒子效果等等。


实现


要实现 Android 动画,我们需要按照以下步骤:



  1. 创建动画资源文件。

  2. 在代码中加载动画资源文件。

  3. 将动画应用到相应的视图中。


我们可以通过 XML 或代码来创建动画资源文件。以下是一个简单的平移动画的 XML 示例:


<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="0%p"
android:toXDelta="50%p"
android:duration="500"
android:repeatCount="infinite"
android:repeatMode="reverse" />

</set>

在代码中加载动画资源文件的方法如下:


Animation animation = AnimationUtils.loadAnimation(this, R.anim.translate);

最后,我们需要将动画应用到相应的视图中:


imageView.startAnimation(animation);

下面是一个实现平移动画效果的 Java 代码示例:


View view = findViewById(R.id.view);
ObjectAnimator animator = ObjectAnimator.ofFloat(view, "translationX", 0f, 300f);
animator.setDuration(1000);
animator.start();

结论


无论是在应用程序设计中还是在用户体验中,动画都是一个非常重要的因素。如果你想要在你的应用程序中实现动画效果,本文提供了 Android 动画的基本原理和实现方法。你可以根据自己的需要使用不同类型的动画来实现不同的效果。


推荐


android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件,优化启动速度。不仅支持Jetpack App Startup的全部功能,还提供额外的同步与异步等待、线程控制与多进程支持等功能。


AwesomeGithub: 基于Github的客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin语言进行开发,项目架构是基于JetPack&DataBinding的MVVM;项目中使用了Arouter、Retrofit、Coroutine、Glide、Dagger与Hilt等流行开源技术。


flutter_github: 基于Flutter的跨平台版本Github客户端,与AwesomeGithub相对应。


android-api-analysis: 结合详细的Demo来全面解析Android相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。


daily_algorithm: 每日一算法,由浅入深

作者:午后一小憩
来源:juejin.cn/post/7242596746180739128
,欢迎加入一起共勉。

收起阅读 »

天马行空使用适配器模式

1. 前言 因为最近没有什么比较好的技术想要分享,所以来水一下文章,啊不对,是分享一下一些思路,和大家交流一下想法,没准能产生一些新的想法。这篇文章不具备权威,不一定正确,简单来说全都是我瞎吹。 开发这么久,多多少少也有了解过适配器模式。我还是一如既往的建议学...
继续阅读 »

1. 前言


因为最近没有什么比较好的技术想要分享,所以来水一下文章,啊不对,是分享一下一些思路,和大家交流一下想法,没准能产生一些新的想法。这篇文章不具备权威,不一定正确,简单来说全都是我瞎吹。


开发这么久,多多少少也有了解过适配器模式。我还是一如既往的建议学习设计模式,不能光靠看别人的文章,得靠自己的积累和理解源码的做法来得更切实际。


2. 浅谈适配器


我们都知道,适配器模式是一种结构型模式,结构型模式我的理解都有一个特点,简单来说就是封装了一些操作,就是隐藏细节嘛,比如说代理就是隐藏了通过代理调用实体的细节。


我还是觉得我们要重头去思考它是怎样的一个思路。如果你去查“适配器模式”,相信你看到很多关于插头的说法,而且你会觉得这东西很简单,但好像看完之后又感觉学了个寂寞。


还是得从源码中去理解。先看看源码中最经典的适配器模式的使用地方,没错,RecyclerView的Adapter。但是它又不仅仅只使用Adapter模式,如果拿它的源码来做分析反而会有些绕。但是我们可以进行抽象。想想Adapter其实主要的流程就是输入数据然后输出View给RecyclerView


然后又可以大胆的去思考一下,如果不定义一个Adapter,没有ViewHolder,要怎么实现这个效果?写一个循环,然后在循环里面做一些逻辑判断,然后创建对应的子View,添加到父View中 ,大概会这样做吧。那其实Adapter就帮我们做了这一步,并且还做了很多比较经典的优化操作,其实大概就是这样。


然后从这样的模型中,我大概是能看出了一些东西,比如使用Adapter是为了输入一些东西,也可以说是为了让它封装一些逻辑,然后达到某些目的,如果用抽象的眼光去看,我不care你封装的什么东西,我只要你达到我的目的(输出我的东西),RecyclerView的adapter就是输出View。这是其一,另外,在这个模型中我有一个使用者去使用这个Adaper,这个Apdater在这里不是一个概念,而是一个对象,我这个使用者通过Adaper这个对象给它一些输入,以此来实现我的某些目标


ok,结合Adapter模式的结构图看看(随便从网上找一张图)


image.png


可以看到这个模型中有个Client,有个Tagrget,有个Adapter。拿RecyclerView的Adapter来说,Client是RecyclerView (当然具体的addView操作是在LayoutManager中,这里是抽象去看),Adapter就是RecyclerView.Adapter,而Tagrget抽象去看就是对view的操作。


3. 天马行空的使用


首先一般使用官方的RecyclerView啊这些,都会提供自己的Adapter,但是会让人会容易觉得Adapter就是在复用View的情况下使用。而我的理解是,RecyclerView的复用是ViewHolder的思想,不是Adapter的思想,比如早期的ListView也有Adapter啊,当时出RecyclerView之前也没有说用到ViewHolder的这种做法(这是很多年前的事),我说这个是想要表达不要把Adapter和RecyclerView绑一起,这样会让思维受到局限。


对我而言,Adapter在RecyclerView中的作用是 “Data To View” ,适配数据而产出对应的View。


那我可以把Apdater的作用理解成“Object To Object” ,对于我来说,Object它可以是Data,可以是View,甚至可以是业务逻辑。


所以当我跳出RecyclerView这种传统的 “Data To View” 的思维模式之后,Adapter适配器模式可以做到的场景就很多,正如上面我理解的,Adapter简单来说就是 “Data To View” ,那我可以用适配器模式去做 “Data To Data” ,可以去做 “View To Data” ,可以去做 “Business To Business” , “Business To View” 等等,实现多种效果。


假设我这里做个Data To Data的场景 (强行举的例子可能不是很好)


我请求后台拿到个人的基本信息数据


data class PeopleInfo(
var name: String?,
var sex: Int?,
......
)

然后通过个人的ID,再请求服务端另外一个接口拿到成绩数据


data class ScoreInfo(
var language : Int,
var math : Int,
......
)

然后我有个数据竞赛的报名表对象。


data class MathCompetition(
var math : Int,
var name : String
)

然后一般我们使用到的时候就会这样赋值,假设这段代码在一个Competition类中进行


val people = getPeopleInfo()
val score = getScoreInfo()
val mathTable = MathCompetition(
score.math?,
people.name?,
......
)

就是深拷贝的一种赋值,我相信很多人的代码里面肯定会有


两个对象 AB
A.1 = B.1
A.2 = B.2
A.3 = B.3
......

这样的代码,然后对象的熟悉多的话这个代码就会写得很长。当然这不会造成什么大问题,但是你看着看着,总觉得缺少一些美感,但是好像这玩意没封装不起来这种感觉。


如果用适配器来做的话,首先明确要做的流程是从Competition这个类中,将所有数据整合成MathCompetition对象,目标就是输出MathCompetition对象。那从适配器模式的模型上去看,client就是Competition,是它的需求,Taget就是getMathCompetition,输出mathTable,然后我们可以写一个Adapter。


class McAdapter {

var mathCompetition : MathCompetition? = null

init {
// 给默认值
mathCompetition = MathCompetition(0, "name", ......)
}

fun setData(people : PeopleInfo? = null, score : ScoreInfo? = null){
people?.let {
mathCompetition?.name = it.name
......
}

score?.let {
mathCompetition?.math = it.math
......
}
}

fun getData() : MathCompetition?{
return mathCompetition
}

}

然后在Competition中就不需要直接引用MathCompetition,而是设置个setAdapter方法,然后需要拿数据时再调用adapter的getData()方法,这样就恰到好处,不会把这些深拷贝方式的赋值代码搞得到处都是。 这个Demo看着好像没什么,但是真碰到了N合1这样的数据场景的时候,使用Adapter显然会更安全。


我再简单举一个Business To View的例子吧。假设你的app中有很几套EmptyView,比如你有嵌入到页面的EmptyView,也有做弹窗类型的EmptyView,我们一般的做法就是对应的页面的xml文件中直接写EmptyView,那这些EmptyView的代码就会很分散是吧。OK,你也想整合起来,所以你会写个EmptyHelper,大概是这个意思,用单例写一个管理EmptyView的类,然后里面统一封装对EmptyView的操作,一般都会这样写。 其实如果你让我来做,我可能就会用适配器模式去实现。 ,当然也有其他办法能很好的管理,这具体的得看心情。


写一个Adapter,我这里写一段伪代码,应该比较容易能看懂


class EmptyAdapter() {
// 这样语法要伴生,懒得写了,看得懂就行
const val STATUS_NORMAL = 0
const val STATUS_LOADING = 1
const val STATUS_ERROR = 2

private var type: Int = 0
var parentView: ViewGroup? = null
private var mEmptyView: BaseEmptyView? = null
private var emptyStatus = 0 // 有个状态,0是不显示,1是显示加载中,2是加载失败......

init {
createEmptyView()
}

private fun createEmptyView() {
// 也可以判断是否有parentView决定使用哪个EmptyView等逻辑
mEmptyView = when (type) {
0 -> AEmptyView
1 -> BEmptyView
2 -> CEmptyView
else -> {
AEmptyView
}
}
}

fun setData(status: Int) {
when (status) {
0 -> parentView?.removeView(mEmptyView)
1 -> mEmptyView?.showLoading()
2 -> mEmptyView?.showError()
}
}

fun updateType(type: Int) {
setData(0)
this.type = type
createEmptyView()
}
}

然后在具体的Activity调用的时候,可以


val emptyAdapter = EmptyAdapter(getContentView())
// 然后在每次要loading的时候去设置adapter的的状态
emptyAdapter.setData(EmptyAdapter.STATUS_LOADING)
emptyAdapter.setData(EmptyAdapter.STATUS_NORMAL)
emptyAdapter.setData(EmptyAdapter.STATUS_ERROR)

可以看出这样做就有几个好处,其一就是不用每个xml都写EmptyView,然后也能做到统一的管理,和一些人写的Helper这种的效果类似,最后调用的方法也很简单,你只需要创建一个Adaper,然后调用它的setData就行,我们的RecyclerView也是这样的,在外层去创建然后调用Adapter就行。


4. 总结


写这篇文章主要是为了水,啊不对,是为了想说明几个问题:

(1)开发时要善于跳出一些限制去思考,比如RecyclerView你可能觉得适配器模式就和它绑定了,就和View绑定了,有View的地方才能使用适配器模式,至少我觉得不是这样。

(2)学习设计模式,只去看一些介绍是很难理解的,当然要知道它的一个大致的思想,然后要灵活运用到开发中,这样学它才有用。

(3)我对适配器模式的理解就是 Object To Object,我可以去写ViewAdapter,可以去写DataAdapter,也可以去写BusinessAadpter,可以用这个模式去适配不同的场景,利用这个思想来使代码更加合理。


当然最后还是要强调一下,我不敢保证我说的就是对的,我肯定不是权威的。但至少我使用这招之后的的新代码效果要比一些旧代码更容

作者:流浪汉kylin
来源:juejin.cn/post/7242623772301459517
易维护,更容易扩展。

收起阅读 »

App高级感营造之 高斯模糊

效果 类似毛玻璃,或者马赛克的效果。我们可以用它来提升app背景的整体质感,或者给关键信息打码。 源代码 import 'dart:ui'; import 'package:flutter/material.dart'; void main() { ...
继续阅读 »

效果


类似毛玻璃,或者马赛克的效果。我们可以用它来提升app背景的整体质感,或者给关键信息打码。


高斯模糊1.gif


高斯模糊2.gif


源代码


import 'dart:ui';

import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});

final String title;

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
// 高斯模糊的第一种写法 ImageFiltered 包裹要模糊的组件

/// 将子组件进行高斯模糊
/// [child] 要模糊的子组件
Widget _imageFilteredWidget1({required Widget child, double sigmaValue = 1}) {
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: sigmaValue, sigmaY: sigmaValue),
child: child,
);
}

/// 使用第一种模糊方式的案例
Widget _demo1() {
return Container(
padding: const EdgeInsets.all(50),
color: Colors.blue.shade100,
width: double.infinity,
child: Column(
children: [
_imageFilteredWidget1(
child: SizedBox(
width: 150,
child: Image.asset(
"assets/images/bz1.jpg",
fit: BoxFit.fitHeight,
),
),
),
const SizedBox(height: 100),
_imageFilteredWidget1(
child: const Text(
"测试高斯模糊",
style: TextStyle(fontSize: 30, color: Colors.blueAccent),
),
sigmaValue: 2)
],
),
);
}

/// 利用 BackdropFilter 做高斯模糊
_backdropFilterWidget2({
required Widget child,
double sigmaValueX = 1,
double sigmaValueY = 1,
}) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigmaValueX, sigmaY: sigmaValueY),
child: child,
),
);
}

///
Widget _demo2() {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(
"assets/images/bz1.jpg",
fit: BoxFit.fill,
),
),
Positioned(
child: _backdropFilterWidget2(
sigmaValueX: _sigmaValueX,
sigmaValueY: _sigmaValueY,
child: Container(
width: MediaQuery.of(context).size.width - 100,
height: MediaQuery.of(context).size.height / 2,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x90ffffff),
),
child: const Text(
"高斯模糊",
style: TextStyle(fontSize: 30, color: Colors.white),
),
)),
top: 20,
),
_slider(
bottomMargin: 200,
themeColors: Colors.yellow,
title: '横向模糊度',
valueAttr: _sigmaValueX,
onChange: (double value) {
setState(() {
_sigmaValueX = value;
});
},
),
_slider(
bottomMargin: 160,
themeColors: Colors.blue,
title: '纵向模糊度',
valueAttr: _sigmaValueY,
onChange: (double value) {
setState(() {
_sigmaValueY = value;
});
},
),
_slider(
bottomMargin: 120,
themeColors: Colors.green,
title: '同时调整:',
valueAttr: _sigmaValue,
onChange: (double value) {
setState(() {
_sigmaValue = value;
_sigmaValueX = value;
_sigmaValueY = value;
});
},
),
],
),
);
}

Widget _slider({
required String title,
required double bottomMargin,
required Color themeColors,
required double valueAttr,
required ValueChanged<double>? onChange,
}) {
return Positioned(
bottom: bottomMargin,
child: Row(
children: [
Text(title, style: TextStyle(color: themeColors, fontSize: 18)),
SliderTheme(
data: SliderThemeData(
trackHeight: 20,
activeTrackColor: themeColors.withOpacity(.7),
thumbColor: themeColors,
inactiveTrackColor: themeColors.withOpacity(.4)
),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: Slider(
value: valueAttr,
min: 0,
max: 10,
onChanged: onChange,
),
),
),
SizedBox(
width: 50,
child: Text('${valueAttr.round()}',
style: TextStyle(color: themeColors, fontSize: 18)),
),
],
),
);
}

double _sigmaValueX = 10;
double _sigmaValueY = 10;

double _sigmaValue = 10;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: _demo2(),
);
}
}

实现原理


实现高斯模糊,在flutter中有两种方式:


ImageFiltered


它可以对其包裹的子组件施加高斯模糊,需要传入 ImageFilter 控制模糊程度,分为X Y两个方向的模糊,实际上就是对图片进行拉伸,数字越大,模糊效果越大。


/// 将子组件进行高斯模糊
/// [child] 要模糊的子组件
Widget _imageFilteredWidget1({required Widget child, double sigmaValue = 1}) {
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: sigmaValue, sigmaY: sigmaValue),
child: child,
);
}

BackDropFilter


同样需要一个 ImageFilter参数控制模糊度,与 ImageFilter的区别是,它会对它覆盖的组件整体模糊。
所以如果我们需要对指定的子组件进行模糊的话,需要再包裹一个ClipRect裁切。


/// 利用  BackdropFilter 做高斯模糊
_backdropFilterWidget2({required Widget child, double sigmaValue = 1}) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaY: sigmaValue, sigmaX: sigmaValue),
child: child,
),
);
}

由于 BackdropFilter 会对其子组件进行图形处理,所以其子组件可能会变得更加消耗性能。因此,需要谨慎使用 BackdropFilter 组件。


作者:拳布离手
来源:juejin.cn/post/7239631010429108280
收起阅读 »

Android 14 新增权限

原文: medium.com/proandroidd… 译者:程序员 DHL 本文已收录于仓库 Technical-Article-Translation 这篇文章,主要分享在 Android 14 以上新增的权限 READ_MEDIA_VISUAL_US...
继续阅读 »



这篇文章,主要分享在 Android 14 以上新增的权限 READ_MEDIA_VISUAL_USER_SELECTED,该权限允许用户仅授予对选定媒体的访问权限(Photos / Videos)),而不是访问整个媒体库。


新的权限弹窗


当你的 App 运行在 Andrid 14 以上的设备时,如果请求访问照片,会出现以下对话框,你将看到新的选项。



受影响的行为


当我们在项目中声明新的权限 READ_MEDIA_VISUAL_USER_SELECTED ,并且用户选择 Select photos and videos(Select photos or Select videos)




  • READ_MEDIA_IMAGESREAD_MEDIA_VIDEO 权限都会被拒绝




  • READ_MEDIA_VISUAL_USER_SELECTED 权限被授予时,将会被允许临时访问用户的照片和视频




  • 如果我们需要访问其他照片和视频,我们需要同时申请 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限




如何在项目中使用新的权限



  • AndroidManifest.xml 文件中添加下面的权限


<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

// new permisison
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />


  • 使用 ActivityResultContract 请求新的权限


val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { mapResults ->
mapResults.forEach {
Log.d(TAG, "Permission: ${it.key} Status: ${it.value}")
}
// check if any of the requested permissions is granted or not
if (mapResults.values.any { it }) {
// query the content resolver
queryContentResolver(context) { listOfImages ->
imageDataModelList = listOfImages
}
}
}

为什么要使用 RequestMultiplePermissions,因为我们需要同时请求 READ_MEDIA_IMAGES , READ_MEDIA_VIDEO 权限



  • 启动权限申请流程


OutlinedButton(onClick = {
permissionLauncher.launch(arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VISUAL_USER_SELECTED))
}) {
Text("Allow to read all or select images")
}

关于 Android 12 、 Android 13 、Android 14 功能和权限的变更,点击下方链接前往查看:



最后我们看一下运行效果





全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎在看、点赞、分享给身边的小伙伴,我会持续分享原创干货!!!




我开了一个云同步编译工具(SyncKit),主要用于本地写代码,同步到远程设备,在远程设备上进行编译,最后将编译的结果同步到本地,代码已经上传到 Github,欢迎前往仓库 hi-dhl/SyncKit 查看。





Hi 大家好,我是 DHL,就职于美团、快手、小米。公众号:ByteCode ,分享有用、有趣的硬核原创内容,Kotlin、Jetpack、性能优化、系统源码、算法及数据结构、动画、大厂面经,真诚推荐你关注我。





最新文章



开源新项目




  • 云同步编译工具(SyncKit),本地写代码,远程编译,欢迎前去查看 SyncKit




  • KtKit 小巧而实用,用 Kotlin 语言编写的工具库,欢迎前去查看 KtKit




  • 最全、最新的 AndroidX Jetpack 相关组件的实战项目以及相关组件原理分析文章,正在逐渐增加 Jetpack 新成员,仓库持续更新,欢迎前去查看 AndroidX-Jetpack-Practice




  • LeetCode / 剑指 offer,包含多种解题思路、时间复杂度、空间复杂度分析,在线阅读




作者:程序员DHL
来源:juejin.cn/post/7238762963908689957
收起阅读 »

你真的了解Systrace吗?

欢迎关 Android茶话会 在技术学习、个人成长的道路上,让我们一起前进! 一、什么是SysTrace? 在日常开发中有时候遇到棘手的性能问题就需要使用这个工具,Systrace 是 「Android 4.1」 中新增的性能数据采样和分析工具。它可帮助开发...
继续阅读 »

欢迎关 Android茶话会 在技术学习、个人成长的道路上,让我们一起前进!



一、什么是SysTrace?


在日常开发中有时候遇到棘手的性能问题就需要使用这个工具,Systrace 是 「Android 4.1」 中新增的性能数据采样和分析工具。它可帮助开发者收集 Android 关键子系统
(如 SurfaceFlinger/SystemServer/Kernel/Input/Display 等 Framework 部分关键模块、服务,View」系统等 的运行信息,从而帮助开发者更直观的「分析系统瓶颈,改进性能」image.png


二、如何使用SysTrace?


2.1 采集trace


首先我们需要了解Trace的采集主要涉及几部分:采集方式、自定义trace阶段、Release包抓取Trace和系统Trace类同异步调用的差异;


命令行采集:



  • 设备要求**「Android 4.3 (API level 18)及以上」**

  • 命令如下:python systrace.py [options][「categories」],示例如下:


python /Users/yangzhiyong/Library/Android/sdk/platform-tools/systrace/systrace.py -t 10 -o trace.html gfx input view sched freq wm am hwui workq res dalvik sync disk load perf hal rs idle mmc -a com.ss.android.lark.debug

# -o : 指示输出文件的路径和名字
# -t : 抓取时间(最新版本可以不用指定, 按 Enter 即可结束),单位为秒
# -b : 指定 buffer 大小 (一般情况下,默认的 Buffer 是够用的,如果你要抓很长的 Trae , 那么建议调大 Buffer )
# -a : 指定 app 包名 (如果要 Debug 自定义的 Trace 点, 记得要加这个)



  • 查看支持的categories



    • adb shell atrace --list_categories

    • python systrace.py -l




// 粗体部分是常用的categories;
         gfx - Graphics
       input - Input
        view - View System
     webview - WebView
          wm - Window Manager
          am - Activity Manager
          sm - Sync Manager
       audio - Audio
       video - Video
      camera - Camera
         hal - Hardware Modules
         res - Resource Loading
      dalvik - Dalvik VM
          rs - RenderScript
      bionic - Bionic C Library
       power - Power Management
          pm - Package Manager
          ss - System Server
    database - Database
     network - Network
         adb - ADB
    vibrator - Vibrator
        aidl - AIDL calls
         pdx - PDX services
       sched - CPU Scheduling
        freq - CPU Frequency
        idle - CPU Idle
        disk - Disk I/O
        sync - Synchronization
  memreclaim - Kernel Memory Reclaim
  binder_driver - Binder Kernel driver
  binder_lock - Binder global lock trace

系统自带工具:




  • 设备要求**「Android 9(API level 28)及其以上」**




  • 开发者模式->系统跟踪




  • trace导出:



    • 通知栏分享;

    • adb pull /data/local/traces/ .

    • systrace --from-file .ctrace .perfetto-trace 转换成html




网页版采集分析工具:💻



  • 设备要求**「Android 10(API level 29)及其以上;」**

  • Perfetto UI




除此之外还有一些常用的技巧





  1. 自定义TAG


image.png 具体使用可参考:developer.android.com/topic/perfo…




  • 「定义」



    • 在事件开始调用Trace.beginSection(event);

    • 在事件结束调用Trace.endSection(),需成对调用;




  • 「使用」 添加了自定义TAG后,需要-a指定包名参数,才可以采集到自定义的trace信息;





  1. Release包抓取trace


反射调用setAppTracingAllowed即可:


try {
    Class threadClazz = Class.forName("android.os.Trace");
    Method setAppTracingAllowed = threadClazz.getDeclaredMethod("setAppTracingAllowed"boolean.class);
    setAppTracingAllowed.invoke(nulltrue);
catch (Exception e) {
    e.printStackTrace();
}

3. 同异步trace的差异


「同步」「异步」「区别」
beginSection(@NonNull String sectionName)beginAsyncSection(@NonNull String methodName, int cookie)异步调用需要传methodName及cookie,开始结束匹配更准确,且不用B-A-A-B类似嵌套,不过该接口为隐藏接口,需反射调用,可使用SystemTracer类;
endSection()endAsyncSection(@NonNull String methodName, int cookie)

2.2 分析


拿到这些trace 我们如何分析也是一个重要的部分


2.2.1 Trace文件打开


首先是打开这个文件



  1. chrome://tracing:推荐:不过近期版本该工具对trace中线程、进程名解析不出来,不利于查看;

  2. ui.perfetto.dev/#!/: 推荐,不过新版分析工具不支持VSync的高亮;

  3. perfetto.bytedance.net/#!/viewer: 不推荐,仅支持单进程查看,对于涉及系统调用的分析不方便;


较早版本sysTrace生成的html文件浏览器即可打开,但最近版本已无法正常打开,需要通过chrome工具手动加载;


2.2.2 面板区域说明


image.png


image.png



  1. 用户屏幕交互

  2. CPU 使用率

  3. CPU各核心的运行情况

  4. 进程信息

  5. 进程变量/进程

  6. 选中区段的详细信息

  7. 鼠标操作选项,可通过1-4快速切换

  8. 进程过滤

  9. VSync高亮配置


2.2.3 常用快捷键


比较常用的快捷键是W S A D M V;



  • W : 放大 Systrace,放大可以更好地看清局部细节

  • S : 缩小 Systrace,缩小以查看整体

  • A : 左移

  • D : 右移

  • M : 高亮选中当前鼠标点击的段(这个比较常用,可以快速标识出这个方法的左右边界和执行时间,方便上下查看)

  • V : 高亮VSync的时机,方便分析掉帧的原因;


image.png


image.png


2.2.4 线程状态


a) 线程状态

「线程状态」「Systrace中的显示」「说明」
绿色 : 运行中(Running)image.png我们经常会查看 Running 状态的线程,查看其运行的时间,与竞品做对比,分析快或者慢的原因
蓝色 : 可运行(Runnable)image.pngRunnable 状态的线程状态持续时间越长,则表示 cpu 的调度越忙,没有及时处理到这个任务
白色 : 休眠中(Sleep)image.png一般是在等事件驱动
橘色 : 不可中断的睡眠态 IO Block(Uninterruptible Sleep WakeKill)image.png一般是标示 IO 操作慢,如果有大量的橘色不可中断的睡眠态出现,那么一般是由于进入了低内存状态
紫色 : 不可中断的睡眠态(Uninterruptible Sleep)image.png一般是陷入了内核态,有些情况下是正常的,有些情况下是不正常的,需要按照具体的情况去分析

b) 线程唤醒

一个任务在进入 Running 状态之前,会先进入 Runnable 状态进行等待,而 Systrace 会把这个状态也标示在 Systrace 上; image.png image.png stateWhenDescheduled定义:chromium.googlesource.com/external/tr…


c) 线程参数说明


  • Wall Duration:函数执行的总耗时

  • CPU Duration:在 CPU 上执行的耗时

  • Self Time:自身执行的总耗时,不包括子方法

  • CPU Self Time:自身在 CPU 上执行的耗时,不包括子方法


d) 函数调用虚实区别

如下图,我们看到标识setUpHWComposer调用的紫色条并不是实心的,实心的部分代表CPU Duration相对于Wall Duration的占比: image.png


2.2.5 CPU信息


image.png


image.png



  1. C-State


为了在CPU空闲的时候降低功耗,CPU可以被命令进入low-power模式。每个CPU都有几种power模式,这些模式被统称为C-states或者C-modes; C-States从C0开始,C0是CPU的正常工作模式,CPU处于100%运行状态。C后的数越高,CPU睡眠得越深,CPU的功耗被降低得越多,同时需要更多的时间回到C0模式。


「C-State」「描述」
C-0RUN MODE,运行模式。
C-1STANDBY,就位模式,随时准备投入运行
C-2DORMANT,休眠状态,被唤醒投入运行时有一定的延迟
C-3SHUTDOWN,关闭状态,需要有较长的延迟才能进入运行状态,减少耗电


  1. Clock Frequency:CPU当前运行频率;

  2. Clock Frequency Limits:CPU最大最小频率,通过该参数可以判断CPU核心差异,如大中小核


2.2.6 常见系统进程、线程




  • 进程




    • system_server



      • AMS

      • WMS

      • SurfaceFlinger






  • 线程



    • UI Thread //主线程

    • Render Thread //渲染线程

    • Binder Thread //跨进程调用线程




2.2.7 常见问题


a) 「锁等待」

image.png 「monitor contention」 with owner 「caton_dump_stack (11056)」 waiters=0 blocking from 「boolean android.os.MessageQueue.enqueueMessage(android.os.Message, long)(MessageQueue.java:544)」 image.png 结合代码看,这段信息的意思是,「caton_dump_stack」线程(线程ID是11056)作为「owner」持有了主线程消息队列对象锁,「waiters」表示等待在该对象锁上的其他线程数(不包括当前线程),所以总的有1个线程等待对象锁释放,当前线程等待的位置是「enqueueMessage调用处」


b) 「SurfaceFlinger绘制」

SurfaceFlinger主要是收集各个UI渲染层的数据合成发送给Hardware Composer;一般应用的渲染层包括状态栏、应用页面、导航栏,每个部分都是单独渲染生成Buffer的,基本步骤如下:



  1. 应用收到VSYNC-app信号后,在**「UI Thread」完成数据计算;准备好后,将数据发送到「RenderThread」**;

  2. 应用在**「RenderThread」完成数据渲染后,将数据填充到「SurfaceFlinger」**的对应页面BufferQueue中;

  3. 在**「SurfaceFlinger」收到VSYNC-sf信号后,「SurfaceFlinger」** 会遍历它的层列表的BufferQueue,以寻找新的缓冲区。如果找到新的缓冲区,它会获取该缓冲区;否则,它会继续使用以前获取的缓冲区。「SurfaceFlinger」 必须始终显示内容,因此它会保留一个缓冲区。如果在某个层上没有提交缓冲区,则该层会被忽略;

  4. **「SurfaceFlinger」**向 「HWC」 提供一个完整的层列表,并询问“您希望如何处理这些层?”

  5. 「HWC」 的响应方式是将每个层标记为叠加层或 GLES 合成;

  6. 「SurfaceFlinger」 会处理所有 GLES 合成,将输出缓冲区传送到 「HWC」,并让 「HWC」 处理其余部分;


image.png


image.png


c) 「掉帧」

上一部分描述了SurfaceFlinger合成每帧数据的过程,在上述过程中,如果**「UI Thread」计算不及时,或者「RenderThread」渲染不及时,或者「BufferQueue」**中可用Buffer不足导致在下一次VSYNC信号来临之前,没有准备好需要显示的帧的数据,就会出现丢帧,Systrace 报告列出了渲染界面帧的每个进程,并指明了沿时间轴渲染的每个帧。在 16.6 毫秒内渲染的必须保持每秒 60 帧稳定帧速率的帧会以绿色圆圈表示。渲染时间超过 16.6 毫秒的帧会以黄色或红色帧圆圈表示:



  1. 绿色:未丢帧;

  2. 棕色:轻微丢帧,丢1帧 ;

  3. 红色:严重丢帧,丢大于1帧;


image.png 通过Systrace右上角的View Options > Highlight VSync,我们可以高亮VSYNC信号到来的时刻,需要注意的是,高亮的VSYNC主要是VSYNC-app信号(可在SurfaceFlinger进程中查看),且灰白相间的位置是VSYNC信号到来的时刻。 如下图,显示了丢1帧的情况,第二个VSYNC到来时,主线程仍未完成显示帧数据的计算,所以出现丢帧的问题。 image.png


三、常见trace工具对比


其实我们分析trace不光只有系统这一种方法,下图做个简单的总结


「工具名」「类型」「原理」「优缺点」「使用场景」「使用说明」
Traceviewinstrument利用 Android Runtime 函数调用的 event 事件,将函数运行的耗时和调用关系写入 trace 文件中。「优点:」 全函数调用分析;「缺点:」 工具本身带来的性能开销过大,有时无法反映真实的情况。比如一个函数本身的耗时是 1 秒,开启 Traceview 后可能会变成 5 秒,而且这些函数的耗时变化并不是成比例放大;- 线下- 整个程序执行流程的耗时已废弃,之前DDMS有相关工具入口
Nanoscopeinstrument直接修改 Android 虚拟机源码,在ArtMethod执行入口和执行结束位置增加埋点代码,将所有的信息先写到内存,等到 trace 结束后才统一生成结果文件。「优点:」- 全函数调用分析;- 性能开销小;- 可以支持分析任意一个应用,可用于做竞品分析;「缺点:」- 需要自己刷 ROM,并且当前只支持 Nexus 6P,或者采用其提供的 x86 架构的模拟器;- 默认只支持主线程采集,其他线程需要代码手动设置;- 线下- 整个程序执行流程的耗时github.com/uber/nanosc…
systracesample实际是其他工具的封装,Systrace使用atrace开启追踪,然后读取ftrace的缓存,并且把它重新转换成HTML格式。「优点:」- 可以看到整个流程系统和应用程序的调用流程。包括系统关键线程的函数调用,例如渲染耗时、线程锁,GC 耗时等;- 性能损耗可以接受;「缺点:」- 不支持应用程序代码的耗时分析;需手动添加或者编译期插装;- 线下- 分析系统调用developer.android.com/topic/perfo…
Simpleperfsample利用 CPU 的性能监控单元(PMU)提供的硬件 perf 事件。「优点:」- 支持Native分析;- 性能开销非常低;「缺点:」- Java分析对Android版本要求比较高;- 线下- 分析 Native 代码的耗时android.googlesource.com/platform/sy… 在 Android M 和以前,Simpleperf 不支持 Java 代码分析。- 在 Android O 和以前,需要手动指定编译 OAT 文件。- 在 Android P 和以后,无需做任何事情,Simpleperf 就可以支持 Java 代码分析;
Profiler(CPU Profiler)混合- Sample Java Methods 的功能类似于 Traceview 的 sample 类型;- Trace Java Methods 的功能类似于 Traceview 的 instrument 类型;- Trace System Calls 的功能类似于 systrace;- Sample Native (API Level 26+) 的功能类似于 Simpleperf;「优点:」- 集成在IDE中,操作简单;「缺点:」- 性能开销大,应用明显卡顿;- 无法用于自动化测试等场景;- 线下developer.android.com/studio/prof…

「instrument」:获取一段时间内所有函数的调用过程,可以通过分析这段时间内的函数调用流程,再进一步分析待优化的点。


「sample」:有选择性或者采用抽样的方式观察某些函数调用过程,可以通过这些有限的信息推测出流程中的可疑点,然后再继续细化分析。


作者:Android茶话会
来源:juejin.cn/post/7238172236185354297
收起阅读 »

初入Android TV/机顶盒应用开发小记1

1.前期 去年公司开展了一个新的项目,是一个运行在机顶盒上的App。项目经理把整个部门的几个重要成员叫到会议室开了场研讨,讨论了关于整个项目的详细情况,但是公司现有做安卓哥们都没有开发过Tv上的项目,所以当时都没有人主动想要负责这个项目的开发。可能在会上我问的...
继续阅读 »

1.前期


去年公司开展了一个新的项目,是一个运行在机顶盒上的App。项目经理把整个部门的几个重要成员叫到会议室开了场研讨,讨论了关于整个项目的详细情况,但是公司现有做安卓哥们都没有开发过Tv上的项目,所以当时都没有人主动想要负责这个项目的开发。可能在会上我问的问题比较多引起了注意。然后就毫无意外的把这个项目客户端开发硬塞了给我负责。我也是醉了。。。


2.准备


在美工(设计师)正画高保真图的这段时间我也开始了研究关于在机顶盒上的一些相关技术储备,也试的写了一些Demo出来。感觉还是阔以拿捏的。但是当前我等到高保真出来的时侯大家一起探讨机顶盒上的一些交互时,我发现我的的相关技术储备还是有点欠缺,没办法,只能先跟着图开始做着。


3.开干


没过这方面开发的哥们可能不知道。开发一个几个按钮外加一个列表的页面如果是手机端的我不用半小时就写完了,但是我在开发TV上的类似的页面时我足足做一了一个多星期。而且产经理看了还是不满意,说这不行,说那焦点有问题。还经常用几个主流的Tv应用在跟我展示说别人也是这么做,也是那么做。


4.遇到问题


就拿一个控件获取焦点时的问题来说吧,别人主流的TV应用里的控件获取焦点显示焦框时,控件里的内容是不是被挤压的。而且有的焦框带有阴影,阴影还会复盖在别的控件之上,也就是说焦点框不占据控件的大小,如果有传统的方式给控件设置src或是background属性来显示焦点框的话是会占据控件原本的大小的。


如图下面三个正方形的控件的宽高都是100dp的,第1个和第2个是可以获取焦点的,第3个是用来作为对比大小的。给第1,2个控件添加了一个selector类型的drawable,作为当控件获取焦点时的的焦点框,很明显可以看到,当获取焦点时第2个控件显示了一个红色的焦点框,但是焦点框却挤压了控件的内容,也就是说焦点框显示在控件100dp之内。像这种方式是有问题的。我们要的是焦点框要显示的控件之外的区域这样就会不占用控件的大小。


7.焦点知识入门-焦点框问题[00_08_02][20230518-190621-4].JPG


5寻找解决方案


顺着这个问题在各大社区寻找解决方案,找到了一种可以把焦点框显示在控件之外的方式。核心就是默认情况下Android的组件可以绘制超出它原本大小之外的区域,但是默认只会显示控件大小之内的区域,如果我把给这个控件的父控件的两个属性设置为false那么就可以进显示控件之外的内容了。而这两个属性就是:


android:clipChildren="false"
android:clipToPadding="false"

接着就要自定义一个控件,这里以ImageView为例,首页要拿到获取焦点框图片的边框大小:


int resourceId = ta.getResourceId(R.styleable.EBoxImageView_ebox_iv_focused_border,
-1);
Rect mFocusedDrawable.getPadding(mFocusDrawableRect);


然后计算出焦点框加点组件之后的整个显示的区域的大小,


private void mergeRect(Rect layoutRect, Rect drawablePaddingRect) {
layoutRect.left -= drawablePaddingRect.left;
layoutRect.right += drawablePaddingRect.right;
layoutRect.top -= drawablePaddingRect.top;
layoutRect.bottom += drawablePaddingRect.bottom;
}

最后再根据这个大小区域绘制焦点框,而绘制内容这一块直接调用super.onDraw(canvas);就可以了。


下面是一个完整的代码:


public class MyImageView extends AppCompatImageView {
private static final String TAG = "EBoxImageView";

private static final Rect mLayoutRect = new Rect();
private static final Rect mFocusDrawableRect = new Rect();

private final static Drawable DEFAULT_FOCUSED_DRAWABLE = ResourceUtils.getDrawable(R.drawable.drawable_focused_border);
private Drawable mFocusedDrawable = DEFAULT_FOCUSED_DRAWABLE;

public MyImageView(Context context) {
this(context, null);
}

public MyImageView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public MyImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(attrs);
}

private void init(AttributeSet attributeSet) {
setScaleType(ScaleType.FIT_XY);

TypedArray ta = getContext().obtainStyledAttributes(attributeSet, R.styleable.MyImageView);
boolean selectorMode = ta.getBoolean(R.styleable.MyImageView_ebox_iv_selected_mode,false);
int resourceId = ta.getResourceId(R.styleable.MyImageView_ebox_iv_focused_border,
-1);
ta.recycle();

if(selectorMode){
setFocusable(false);
}else {
setFocusable(true);
}

if (resourceId != -1) {
mFocusedDrawable = ResourceUtils.getDrawable(resourceId);
}
mFocusedDrawable.getPadding(mFocusDrawableRect);
}


@Override
public void invalidateDrawable(@NonNull Drawable dr) {
super.invalidateDrawable(dr);
invalidate();
}

@CallSuper
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (isFocused()||isSelected()) {
getDrawingRect(mLayoutRect);

mergeRect(mLayoutRect, mFocusDrawableRect);
mFocusedDrawable.setBounds(mLayoutRect);
canvas.save();
mFocusedDrawable.draw(canvas);
canvas.restore();
}

}

/**
* 合并drawable的padding到borderRect里去
*
*
@param layoutRect 当前布局的Rect
*
@param drawablePaddingRect borderDrawable的Rect
*/

private void mergeRect(Rect layoutRect, Rect drawablePaddingRect) {
layoutRect.left -= drawablePaddingRect.left;
layoutRect.right += drawablePaddingRect.right;
layoutRect.top -= drawablePaddingRect.top;
layoutRect.bottom += drawablePaddingRect.bottom;
}

/**
* 指定一个焦点框图片资源,如果不调用此方法默认用,R.drawable.drawable_recommend_focused
*
*
@param focusDrawableRes
*/

public void setFocusDrawable(@DrawableRes int focusDrawableRes) {
mFocusedDrawable = ResourceUtils.getDrawable(focusDrawableRes);
mFocusedDrawable.getPadding(mFocusDrawableRect);
}


总结



以上就是在开发AndroidTV、机顶盒中遇到的焦点框问题的解决方案,后来在CS某N社区中找到一套关于AndroidTV项目开发实战的视频教程看了一下还不错,在其它地方也找不更好的资源。再加上项目实现太赶没有那么多的试错时间成本,然后就报名买了那教程。后来边看边开发,用着这套视频的作者提供的一个UI库,来开发我的项目确定方便快速了很多。现在整套视频教程还没看完,一边学习一边写公司的项目和写博客。


作者:本拉茶
来源:juejin.cn/post/7234447275861475365
收起阅读 »

基于人脸识别算法的考勤系统

​ 作为一个基于人脸识别算法的考勤系统的设计与实现教程,以下内容将提供详细的步骤和代码示例。本教程将使用 Python 语言和 OpenCV 库进行实现。 一、环境配置 安装 Python 请确保您已经安装了 Python 3.x。可以在Python 官网...
继续阅读 »


作为一个基于人脸识别算法的考勤系统的设计与实现教程,以下内容将提供详细的步骤和代码示例。本教程将使用 Python 语言和 OpenCV 库进行实现。


一、环境配置



  1. 安装 Python


请确保您已经安装了 Python 3.x。可以在Python 官网下载并安装。



  1. 安装所需库


在命令提示符或终端中运行以下命令来安装所需的库:


pip install opencv-python
pip install opencv-contrib-python
pip install numpy
pip install face-recognition


二、创建数据集



  1. 创建文件夹结构


在项目目录下创建如下文件夹结构:


attendance-system/
├── dataset/
│ ├── person1/
│ ├── person2/
│ └── ...
└── src/


将每个人的照片放入对应的文件夹中,例如:


attendance-system/
├── dataset/
│ ├── person1/
│ │ ├── 01.jpg
│ │ ├── 02.jpg
│ │ └── ...
│ ├── person2/
│ │ ├── 01.jpg
│ │ ├── 02.jpg
│ │ └── ...
│ └── ...
└── src/


三、实现人脸识别算法


src 文件夹下创建一个名为 face_recognition.py 的文件,并添加以下代码:


import os
import cv2
import face_recognition
import numpy as np

def load_images_from_folder(folder):
images = []
for filename in os.listdir(folder):
img = cv2.imread(os.path.join(folder, filename))
if img is not :
images.append(img)
return images

def create_known_face_encodings(root_folder):
known_face_encodings = []
known_face_names = []
for person_name in os.listdir(root_folder):
person_folder = os.path.join(root_folder, person_name)
images = load_images_from_folder(person_folder)
for image in images:
face_encoding = face_recognition.face_encodings(image)[0]
known_face_encodings.append(face_encoding)
known_face_names.append(person_name)
return known_face_encodings, known_face_names

def recognize_faces_in_video(known_face_encodings, known_face_names):
video_capture = cv2.VideoCapture(0)
face_locations = []
face_encodings = []
face_names = []
process_this_frame = True

while True:
ret, frame = video_capture.read()
small_frame = cv2.resize(frame, (0, 0), fx=0.25, fy=0.25)
rgb_small_frame = small_frame[:, :, ::-1]

if process_this_frame:
face_locations = face_recognition.face_locations(rgb_small_frame)
face_encodings = face_recognition.face_encodings(rgb_small_frame, face_locations)

face_names = []
for face_encoding in face_encodings:
matches = face_recognition.compare_faces(known_face_encodings, face_encoding)
name = "Unknown"

face_distances = face_recognition.face_distance(known_face_encodings, face_encoding)
best_match_index = np.argmin(face_distances)
if matches[best_match_index]:
name = known_face_names[best_match_index]

face_names.append(name)

process_this_frame = not process_this_frame

for (top, right, bottom, left), name in zip(face_locations, face_names):
top *= 4
right *= 4
bottom *= 4
left *= 4

cv2.rectangle(frame, (left, top), (right, bottom), (0, 0, 255), 2)
cv2.rectangle(frame, (left, bottom - 35), (right, bottom), (0, 0, 255), cv2.FILLED)
font = cv2.FONT_HERSHEY_DUPLEX
cv2.putText(frame, name, (left + 6, bottom - 6), font, 0.8, (255, 255, 255), 1)

cv2.imshow('Video', frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break

video_capture.release()
cv2.destroyAllWindows()

if __name__ == "__main__":
dataset_folder = "../dataset/"
known_face_encodings, known_face_names = create_known_face_encodings(dataset_folder)
recognize_faces_in_video(known_face_encodings, known_face_names)


四、实现考勤系统


src 文件夹下创建一个名为 attendance.py 的文件,并添加以下代码:


import os
import datetime
import csv
from face_recognition import create_known_face_encodings, recognize_faces_in_video

def save_attendance(name):
attendance_file = "../attendance/attendance.csv"
now = datetime.datetime.now()
date_string = now.strftime("%Y-%m-%d")
time_string = now.strftime("%H:%M:%S")
if not os.path.exists(attendance_file):
with open(attendance_file, "w", newline="") as csvfile:
csv_writer = csv.writer(csvfile)
csv_writer.writerow(["Name", "Date", "Time"])
with open(attendance_file, "r+", newline="") as csvfile:
csv_reader = csv.reader(csvfile)
rows = [row for row in csv_reader]
for row in rows:
if row[0] == name and row[1] == date_string:
return
csv_writer = csv.writer(csvfile)
csv_writer.writerow([name, date_string, time_string])

def custom_recognize_faces_in_video(known_face_encodings, known_face_names):
video_capture = cv2.VideoCapture(0)
face_locations = []
face_encodings = []
face_names = []
process_this_frame = True
while True:
ret, frame = video_capture.read()
small_frame = cv2.resize(frame, (0, 0), fx=0.25, fy=0.25)
rgb_small_frame = small_frame[:, :, ::-1]

if process_this_frame:
face_locations = face_recognition.face_locations(rgb_small_frame)
face_encodings = face_recognition.face_encodings(rgb_small_frame, face_locations)
face_names = []
for face_encoding in face_encodings:
matches = face_recognition.compare_faces(known_face_encodings, face_encoding)
name = "Unknown"
face_distances = face_recognition.face_distance(known_face_encodings, face_encoding)
best_match_index = np.argmin(face_distances)
if matches[best_match_index]:
name = known_face_names[best_match_index]
save_attendance(name)
face_names.append(name)
process_this_frame = not process_this_frame
for (top, right, bottom, left), name in zip(face_locations, face_names):
top *= 4
right *= 4
bottom *= 4
left *= 4
cv2.rectangle(frame, (left, top), (right, bottom), (0, 0, 255), 2)
cv2.rectangle(frame, (left, bottom - 35), (right, bottom), (0, 0, 255), cv2.FILLED)
font = cv2.FONT_HERSHEY_DUPLEX
cv2.putText(frame, name, (left + 6, bottom - 6), font, 0.8, (255, 255, 255), 1)

cv2.imshow('Video', frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break

video_capture.release()
cv2.destroyAllWindows()

if __name__ == "__main__":
dataset_folder = "../dataset/"
known_face_encodings, known_face_names = create_known_face_encodings(dataset_folder)
custom_recognize_faces_in_video(known_face_encodings, known_face_names)


五、运行考勤系统


运行 attendance.py 文件,系统将开始识别并记录考勤信息。考勤记录将保存在 attendance.csv 文件中。


python src/attendance.py


现在,您的基于人脸识别的考勤系统已经实现。请注意,这是一个基本示例,您可能需要根据实际需求对其进行优化和扩展。例如,您可以考虑添加更多的人脸识别算法、考勤规则等

作者:A等天晴
来源:juejin.cn/post/7235458133505867837


收起阅读 »

Android自定义一个省份简称键盘

hello啊各位老铁,这篇文章我们重新回到Android当中的自定义View,其实最近一直在搞Flutter,初步想法是,把Flutter当中的基础组件先封装一遍,然后接着各个工具类,列表,网络,统统由浅入深的搞一遍,弄完Flutter之后,再逐步的更新And...
继续阅读 »

hello啊各位老铁,这篇文章我们重新回到Android当中的自定义View,其实最近一直在搞Flutter,初步想法是,把Flutter当中的基础组件先封装一遍,然后接着各个工具类,列表,网络,统统由浅入深的搞一遍,弄完Flutter之后,再逐步的更新Android当中的技术点,回头一想,还是穿插着来吧,再系统的规划,难免也有变化,想到啥就写啥吧,能够坚持输出就行。


今天的这个知识点,是一个自定义View,一个省份的简称键盘,主要用到的地方,比如车牌输入等地方,相对来说还是比较的简单,我们先看下最终的实现效果:



实现方式呢有很多种,我相信大家也有自己的一套实现机制,这里,我采用的是组合View,用的是LinearLayout的方式。


今天的内容大致如下:


1、分析UI,如何布局


2、设置属性和方法,制定可扩展效果


3、部分源码剖析


4、开源地址及实用总结


一、分析UI,如何布局


拿到UI效果图后,其实也没什么好分析的,无非就是两块,顶部的完成按钮和底部的省份简称格子,一开始,打算用RecyclerView网格布局来实现,但是最后的删除按钮如何摆放就成了问题,直接悬浮在网格上边,动态计算位置,显然不太合适,也没有这样去搞的,索性直接抛弃这个方案,多布局的想法也实验过,但最终还是选择了最简单的LinearLayout组合View形式。


所谓简单,就是在省份简称数组的遍历中,不断的给LinearLayout进行追加子View,需要注意的是,本身的View,也就是我们自定义View,继承LinearLayout后,默认的是垂直方向的,往本身View追加的是横向属性的LinearLayout,这也是换行的效果,也就是,一行一个横向的LinearLayout,记住,横向属性的LinearLayout,才是最终添加View的直接父类。



换行的条件就是基于UI效果,当模于设置length等于0时,我们就重新创建一个水平的LinearLayout,这就可以了,是不是非常的简单。


至于最后的删除按钮,使其靠右,占据两个格子的权重设置即可。


二、设置属性和方法,制定可扩展效果


当我们绘制完这个身份简称键盘后,肯定是要给他人用的,基于灵活多变的需求,那么相对应的我们也需要动态的进行配置,比如背景颜色,文字的颜色,大小,还有边距,以及点击效果等等,这些都是需要外露,让使用者选择性使用的,目前所有的属性如下,大家在使用的时候,也可以对照设置。


设置属性


属性类型概述
lp_backgroundcolor整体的背景颜色
lp_rect_spacingdimension格子的边距
lp_rect_heightdimension格子的高度
lp_rect_margin_topdimension格子的距离上边
lp_margin_left_rightdimension左右距离
lp_margin_topdimension上边距离
lp_margin_bottomdimension下边距离
lp_rect_backgroundreference格子的背景
lp_rect_select_backgroundreference格子选择后的背景
lp_rect_text_sizedimension格子的文字大小
lp_rect_text_colorcolor格子的文字颜色
lp_rect_select_text_colorcolor格子的文字选中颜色
lp_is_show_completeboolean是否显示完成按钮
lp_complete_text_sizedimension完成按钮文字大小
lp_complete_text_colorcolor完成按钮文字颜色
lp_complete_textstring完成按钮文字内容
lp_complete_margin_topdimension完成按钮距离上边
lp_complete_margin_bottomdimension完成按钮距离下边
lp_complete_margin_rightdimension完成按钮距离右边
lp_text_click_effectboolean是否触发点击效果,true点击后背景消失,false不消失

定义方法


方法参数概述
keyboardContent回调函数获取点击的省份简称简称信息
keyboardDelete函数删除省份简称简称信息
keyboardComplete回调函数键盘点击完成
openProhibit函数打开禁止(使领学港澳),使其可以点击

三、关键源码剖析


这里只贴出部分的关键性代码,整体的代码,大家滑到底部查看源码地址即可。


定义身份简称数组


    //省份简称数据
private val mLicensePlateList = arrayListOf(
"京", "津", "渝", "沪", "冀", "晋", "辽", "吉", "黑", "苏",
"浙", "皖", "闽", "赣", "鲁", "豫", "鄂", "湘", "粤", "琼",
"川", "贵", "云", "陕", "甘", "青", "蒙", "桂", "宁", "新",
"藏", "使", "领", "学", "港", "澳",
)

遍历省份简称


mLength为一行展示多少个,当取模为0时,就需要换行,也就是再次创建一个水平的LinearLayout,添加至外层的垂直LinearLayout中,每个水平的LinearLayout中,则是一个一个的TextView。


  //每行对应的省份简称
var layout: LinearLayout? = null
//遍历车牌号
mLicensePlateList.forEachIndexed { index, s ->
if (index % mLength == 0) {
//重新创建,并添加View
layout = createLinearLayout()
layout?.weightSum = 1f
addView(layout)
val params = layout?.layoutParams as LayoutParams
params.apply {
topMargin = mRectMarginTop.toInt()
height = mRectHeight.toInt()
leftMargin = mMarginLeftRight.toInt()
rightMargin = mMarginLeftRight.toInt() - mSpacing.toInt()
layout?.layoutParams = this
}
}

//创建文字视图
val textView = TextView(context).apply {
text = s
//设置文字的属性
textSize = px2sp(mRectTextSize)
//最后五个是否禁止
if (mNumProhibit && index > (mLicensePlateList.size - 6)) {
setTextColor(mNumProhibitColor)
mTempTextViewList.add(this)
} else {
setTextColor(mRectTextColor)
}

setBackgroundResource(mRectBackGround)
gravity = Gravity.CENTER
setOnClickListener {
if (mNumProhibit && index > (mLicensePlateList.size - 6)) {
return@setOnClickListener
}
//每个格子的点击事件
changeTextViewState(this)
}
}

addRectView(textView, layout, 0.1f)
}

追加最后一个View


由于最后一个视图是一个图片,占据了两个格子的大小,所以需要特殊处理,需要做的就是,单独设置权重weight和单独设置宽度width,如下所示:


  /**
* AUTHOR:AbnerMing
* INTRODUCE:追加最后一个View
*/

private fun addEndView(layout: LinearLayout?) {
val endViewLayout = LinearLayout(context)
endViewLayout.gravity = Gravity.RIGHT
//删除按钮
val endView = RelativeLayout(context)
//添加删除按钮
val deleteImage = ImageView(context)
deleteImage.setImageResource(R.drawable.view_ic_key_delete)
endView.addView(deleteImage)

val imageParams = deleteImage.layoutParams as RelativeLayout.LayoutParams
imageParams.addRule(RelativeLayout.CENTER_IN_PARENT)
deleteImage.layoutParams = imageParams
endView.setOnClickListener {
//删除
mKeyboardDelete?.invoke()
invalidate()
}
endView.setBackgroundResource(mRectBackGround)
endViewLayout.addView(endView)
val params = endView.layoutParams as LayoutParams
params.width = (getScreenWidth() / mLength) * 2 - mMarginLeftRight.toInt()
params.height = LayoutParams.MATCH_PARENT

endView.layoutParams = params

layout?.addView(endViewLayout)
val endParams = endViewLayout.layoutParams as LayoutParams
endParams.apply {
width = (mSpacing * 3).toInt()
height = LayoutParams.MATCH_PARENT
weight = 0.4f
rightMargin = mSpacing.toInt()
endViewLayout.layoutParams = this
}


}

四、开源地址及使用总结


开源地址:github.com/AbnerMing88…


关于使用,其实就是一个类,大家可以下载源码,直接复制即可使用,还可以进行修改里面的代码,非常的方便,如果懒得下载源码,没关系,我也上传到了远程Maven,大家可以按照下面的方式进行使用。


Maven具体调用


1、在你的根项目下的build.gradle文件下,引入maven。


 allprojects {
repositories {
maven { url "https://gitee.com/AbnerAndroid/almighty/raw/master" }
}
}

2、在你需要使用的Module中build.gradle文件下,引入依赖。


 dependencies {
implementation 'com.vip:plate:1.0.0'
}

代码使用


   <com.vip.plate.LicensePlateView
android:id="@+id/lp_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:lp_complete_text_size="14sp"
app:lp_margin_left_right="10dp"
app:lp_rect_spacing="6dp"
app:lp_rect_text_size="19sp"
app:lp_text_click_effect="false" />


总结


大家在使用的时候,一定对照属性表进行选择性使用;关于这个省份简称自定义View,实现方式有很多种,我目前的这种也不是最优的实现方式,只是自己的一个实现方案,给大家一个作为参考的依据,好了,铁子们,本篇文章就先到这里,希望可以帮助到大家。


作者:二流小码农
来源:juejin.cn/post/7235484890019659834
收起阅读 »

Android 逆向从入门到入“狱”

免责声明 本次技术分享仅用于逆向技术的交流与学习,请勿用于其他非法用途;技术是把双刃剑,请善用它。 逆向是什么、可以做什么、怎么做 简单讲,就是将别人打包好的 apk 进行反编译,得到源码并分析代码逻辑,最终达成自己的目的。 可以做的事: 修改 sm...
继续阅读 »

免责声明


本次技术分享仅用于逆向技术的交流与学习,请勿用于其他非法用途;技术是把双刃剑,请善用它。


逆向是什么、可以做什么、怎么做




  • 简单讲,就是将别人打包好的 apk 进行反编译,得到源码并分析代码逻辑,最终达成自己的目的。




  • 可以做的事:



    • 修改 smali 文件,使程序达到自己想要的效果,重新编译签名安装,如去广告、自动化操作、电商薅羊毛、单机游戏修改数值、破解付费内容、汉化、抓包等

    • 阅读源码,借鉴别人写好的技术实践

    • 破解:小组件盒子:http://www.coolapk.com/apk/io.ifte…




  • 怎么做:



    • 这是门庞杂的技术活,需要知识的广度、经验、深度

    • 需要具体问题,具体分析,有针对性的学习与探索

    • 了解打包原理、ARM、Smali汇编语言

    • 加固、脱壳

    • Xposed、Substrate、Fridad等框架

    • 加解密

    • 使用好工具## 今日分享涉及工具




  • apktool:反编译工具



    • 反编译:apktool d <apkPath> o <outputPath>

    • 重新打包:apktool b <fileDirPath> -o <apkPath>

    • 安装:brew install apktool




  • jadx:支持命令行和图形界面,支持apk、dex、jar、aar等格式的文件查看





  • apksigner:签名工具





  • Charles:抓包工具



    • http://www.charlesproxy.com/

    • Android 7 以上抓包 HTTPS ,需要手机 Root 后将证书安装到系统中

    • Android 7 以下 HTTPS 直接抓




正题





  • 正向编译



    • java -> class -> dex -> apk




  • 反向编译



    • apk -> dex -> smali -> java




  • Smali 是 Android 的 Dalvik 虚拟机所使用的一种 dex 格式的中间语言




  • 官方文档source.android.com/devices/tec…




  • code.flyleft.cn/posts/ac692…




  • 正题开始,以反编译某瓣App为例:




    • jadx 查看 Java 源码,找到想修改的代码




    • 反编译得到 smali 源码:apktool d douban.apk -o doubancode --only-main-classes




    • 修改:找到 debug 界面入口并打开




    • 将修改后的 smali 源码正向编译成 apk:apktool b doubancode -o douban_mock1.apk




    • 重签名:jarsigner -verbose -keystore keys.jks test.apk key0




    • 此时的包不能正常访问接口,因为豆瓣 API 做了签名校验,而我们的新 apk 是用了新的签名,看接口抓包




    • 怎么办呢?




    • 继续分析代码,修改网络请求中的 apikey




    • 来看看新的 apk






  • 也可以做爬虫等




启发与防范



  • 混淆

  • 加固

  • 加密

  • 运行环境监测

  • 不写敏感信息或操作到客户端

  • App 运行签名验证

  • Api 接口签名验证


One More Thing



作者:Sinyu101220157
来源:juejin.cn/post/7202573260659163195
收起阅读 »

看了十几篇MVX架构的文章后,我悟了...

当经过学一遍又一遍,改一遍又一遍,觉得学不懂想哭的时候,我选择了放弃(洗澡),然后我悟了。 适当开摆有益身心健康 当你纠结于文件该放在哪个包下,纠结于什么功能的代码应该放在哪个类中,于是在学习过程中不断修改,不断重构...开始了这样一个循环。 这有很大一部分原...
继续阅读 »

当经过学一遍又一遍,改一遍又一遍,觉得学不懂想哭的时候,我选择了放弃(洗澡),然后我悟了。


适当开摆有益身心健康


当你纠结于文件该放在哪个包下,纠结于什么功能的代码应该放在哪个类中,于是在学习过程中不断修改,不断重构...开始了这样一个循环。


这有很大一部分原因是因为不统一,架构是一种设计思想,而且大部分是由国外公司、大牛提出,首先在语言理解上就会有一定的差异和误解,如果我们能正确理解设计原则,就可以事半功倍。


就比如并发和并行,看过很多对于这两个的解释就是:并发是多个任务交替使用CPU,同一时刻只有一个任务在跑;并行是多个任务同时跑。


其实就是一种误解,【并发】和【并行】描述的是两个频道的事情。并发是一种处理方法,通过拆分代码,各部分代码互不影响,这样可以充分利用多核心。所以如果想让一个事情变得容易【并行】,先得让制定一个【并发】的方法。倘若一个事情压根就没有【并发】的方法,那么无论有多少个可以干活的人,也不能【并行】。


回到MVX架构,对于怎么样分包,怎么样拆分代码,我觉得应该从思想原理入手,因为文章的写法是各个作者理解,他们的理解不一定就是正确的,包括我。


就以谷歌推荐的架构原则来说,它有以下4点:分离关注点、通过数据模型驱动界面、单一数据源、单向数据流;推荐的架构图如下:


image.png


按照上面这张图,我们在Activity中写界面和界面的展示的数据,现在回看架构原则第一点”关注分离点”,于是我们把界面和界面的数据拆分开,这个过程是自然而然的,所以我更倾向于发挥自己的想象力去把架构实现好,而不是去进行拙略的模仿,现在回想起来20年时我在写项目的时候会自己思考如何去改进,于是自然而然添加了事件和状态(单向数据流),而在之前我并没有去看关于这方面的文章。


当你学习累了,那就大喊一句“开摆”,什么屁架构文章一边去,不学了。(优秀的文章还是值得我们学习的,这里只是我的情绪宣泄)


也许回过头你就学会了,这并不是什么魔法,而是把你从一个深坑中拉了出来,让你的大脑能换个方向思考问题。


我们需要重点学习的是设计原则,剩下的就是发挥我们的想象力。


相关资料


如何理解:程序、进程、线程、并发、并行、高并发? - 大宽宽 知乎 (zhihu.com)


应用架构指南  |  Android 开发者  |  Android Developers (google.cn)


作者:Fanketly
来源:juejin.cn/post/7234057845620408375
收起阅读 »

Flutter list 数组排序

以使用Dart的 sort() 方法对Flutter中的List进行升序或降序排序。 sort()方法需要传递一个比较函数来指定如何对对象进行比较,并按照您指定的顺序进行排序。 以下是一个示例,假设有一个包含整数的列表,可以按照整数值进行排序: List<...
继续阅读 »

以使用Dart的 sort() 方法对Flutter中的List进行升序或降序排序。
sort()方法需要传递一个比较函数来指定如何对对象进行比较,并按照您指定的顺序进行排序。
以下是一个示例,假设有一个包含整数的列表,可以按照整数值进行排序:


List<int> numbers = [1, 3, 2, 5, 4];
// 升序排序
numbers.sort((a, b) => a.compareTo(b));
print(numbers); // 输出:[1, 2, 3, 4, 5]

// 降序排序
numbers.sort((a, b) => b.compareTo(a));
print(numbers); // 输出:[5, 4, 3, 2, 1]

在上述代码中,我们使用了sort()方法将数字列表按照升序和降序进行了排序。


在比较函数中,我们使用了 compareTo() 方法来比较两个数字对象。


如果想按照其他字段进行排序,只需将比较函数中的a和b替换为您想要排序的字段即可。




以下是示例代码,假设您有一个包含Person对象的列表,可以按照Person的年龄字段进行排序:


class Person {
String name;
int age;

Person({this.name, this.age});
}

List<Person> persons = [
Person(name: "John", age: 30),
Person(name: "Jane", age: 20),
Person(name: "Bob", age: 25),
];

// 按照年龄字段进行排序
persons.sort((a, b) => a.age.compareTo(b.age));

// 输出排序后的列表
print(persons);

在上述代码中,我们使用了sort()函数将Person对象列表按照年龄字段进行排序。
在该示例中,我们使用了compareTo()函数来比较Person对象的年龄字段,并按照升序排序。



如果您有小程序、APP、公众号、网站相关的需求,您可以通过私信来联系我


如果你有兴趣,可以关注一下我的综合公众号:biglead


作者:早起的年轻人
来源:juejin.cn/post/7230420475494137913

收起阅读 »

Android - 统一依赖管理(config.gradle)

介绍 Android 依赖统一管理距目前为止,博主一共知道有三种方法,分别是: 传统apply from的方式(也是本文想讲的一种方式):新建一个 「config.gradle」 文件,然后将项目中所有依赖写在里面,更新只需修改 「config.gradl...
继续阅读 »
646286.webp

介绍


Android 依赖统一管理距目前为止,博主一共知道有三种方法,分别是:




  1. 传统apply from的方式(也是本文想讲的一种方式):新建一个 「config.gradle」 文件,然后将项目中所有依赖写在里面,更新只需修改 「config.gradle」 文件内容,作用于所有module。

  2. buildSrc 方式:当运行 Gradle 时会检查项目中是否存在一个名为 buildSrc 的目录。然后 Gradle 会自动编译并测试这段代码,并将其放入构建脚本的类路径中, 对于多项目构建,只能有一个 buildSrc 目录,该目录必须位于根项目目录中, buildSrc 是 Gradle 项目根目录下的一个目录。

  3. Composing builds 方式:复合构建只是包含其他构建的构建. 在许多方面,复合构建类似于 Gradle 多项目构建,不同之处在于,它包括完整的 builds ,而不是包含单个 projects,总的来说,他有 buildSrc 方式的优点,同时更新不需要重新构建整个项目。



三种方式各有各的好,目前最完美的应该是第三种实现。但是这种方式不利于框架使用,因为它属于的是新建一个module,如果项目远程依赖了框架,默认也包含了这个 module。所以博主选择了第一种方式。以下文章也是围绕第一种方式进行讲解。


实现方式


实现这个统一依赖管理,拢共分三步,分别是:




  • 第一步:创建「config.gradle」 文件

  • 第二步:项目当中引入「config.gradle」

  • 第三步:在所有module的「build.gradle」当中添加依赖





  • 第一步:创建 「config.gradle」 文件


    首先将 Aandroid Studio 目录的Android格式修改为Project,然后再创建一个「config.gradle」的文件


    1681962514751.jpg


    然后我们编辑文章里面的内容,这里直接给出框架的代码出来(篇幅太长,省略部分代码):


    ext {
    /**
    * 基础配置 对应 build.gradle 当中 android 括号里面的值
    */

    android = [
    compileSdk : 32,
    minSdk : 21,
    targetSdk : 32,
    versionCode : 1,
    versionName : "1.0.0",
    testInstrumentationRunner: "androidx.test.runner.AndroidJUnitRunner",
    consumerProguardFiles : "consumer-rules.pro"

    ......
    ]

    /**
    * 版本号 包含每一个依赖的版本号,仅仅作用于下面的 dependencies
    */

    version = [
    coreKtx : "1.7.0",
    appcompat : "1.6.1",
    material : "1.8.0",
    constraintLayout : "2.1.3",
    navigationFragmentKtx: "2.3.5",
    navigationUiKtx : "2.3.5",
    junit : "4.13.2",
    testJunit : "1.1.5",
    espresso : "3.4.0",

    ......
    ]

    /**
    * 项目依赖 可根据项目增加删除,但是可不删除本文件里的,在 build.gradle 不写依赖即可
    * 因为MVP框架默认依赖的也在次文件中,建议只添加,不要删除
    */

    dependencies = [

    coreKtx : "androidx.core:core-ktx:$version.coreKtx",
    appcompat : "androidx.appcompat:appcompat:$version.appcompat",
    material : "com.google.android.material:material:$version.material",
    constraintLayout : "androidx.constraintlayout:constraintlayout:$version.constraintLayout",
    navigationFragmentKtx : "androidx.navigation:navigation-fragment-ktx:$version.navigationFragmentKtx",
    navigationUiKtx : "androidx.navigation:navigation-ui-ktx:$version.navigationUiKtx",
    junit : "junit:junit:$version.junit",
    testJunit : "androidx.test.ext:junit:$version.testJunit",
    espresso : "androidx.test.espresso:espresso-core:$version.espresso",

    ......
    ]
    }

    简单理解就是将所有的依赖,分成版本号以及依赖名两个数组的方式保存,所有都在这个文件统一管管理。用 ext 包裹三个数组:第一个是「build.gradle」Android 里面的,第二个是版本号,第三个是依赖的名字。依赖名字数组里面的依赖版本号通过 $ 关键字指代 version 数组里面的版本号




  • 第二步:项目当中引入 「config.gradle」


    将「config.gradle」文件引入项目当中,在项目的根目录的「build.gradle」文件(也就是刚刚新建的 「config.gradle」同目录下的),添加如下代码:


    apply from:"config.gradle"

    需要注意的的是,如果你是 AndroidStudio 4.0+ 那么你将看到这样的「build.gradle」文件


    // Top-level build file where you can add configuration options common to all sub-projects/modules.
    plugins {
    id 'com.android.application' version '7.2.2' apply false
    id 'com.android.library' version '7.2.2' apply false
    id 'org.jetbrains.kotlin.android' version '1.7.10' apply false
    }

    apply from:"config.gradle"

    相反,如果你是 AndroidStudio 4.0- 那么你将会看到这样的「build.gradle」文件



    apply from: "config.gradle"

    buildscript {
    ext.kotlin_version="1.7.10"
    repositories {
    maven { url "https://jitpack.io" }
    mavenCentral()
    google()
    }
    dependencies {
    classpath "com.android.tools.build:gradle:4.2.1"
    classpath 'com.jakewharton:butterknife-gradle-plugin:10.2.3'
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

    // NOTE: Do not place your application dependencies here; they belong
    // in the individual module build.gradle files
    }
    }

    allprojects {
    repositories {
    maven { url "https://jitpack.io" }
    mavenCentral()
    google()
    }
    }

    task clean(type: Delete) {
    delete rootProject.buildDir
    }


    不过仅仅是两个文件里面的内容不一致,这个文件的位置是一样的,而且我们添加的引入代码也是一样的。可以说,这只是顺带提一嘴,实际上不影响我们实现统一依赖管理这个方式。




  • 第三步:在所有module的「build.gradle」当中添加依赖


    这一步是最重要的,我们完成了上面两步之后,只是做好了准备,现在我们需要将我们每一个module里面「build.gradle」文件里面的依赖指向「config.gradle」文件。也就是下图圈起来的 那两个「build.gradle」文件。


    Snipaste_2023-04-20_14-15-58.png


    因为我们第二步的时候已经在根目录引入了「config.gradle」,所以我们在「build.gradle」就可以指向「config.gradle」例如:



    implementation rootProject.ext.dependencies.coreKtx



    这一行,就指代了我们「config.gradle」文件里面的 dependencies 数组里面的 coreKtx 的内容。完整示例如下:


    plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    }
    android {
    namespace 'leo.dev.mvp.kt'
    // compileSdk 32
    compileSdk rootProject.ext.android.compileSdk

    defaultConfig {
    applicationId "leo.dev.mvp.kt"
    // minSdk 21
    // targetSdk 32
    // versionCode 1
    // versionName "1.0"
    //
    // testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

    minSdk rootProject.ext.android.minSdk
    targetSdk rootProject.ext.android.targetSdk
    versionCode rootProject.ext.android.versionCode
    versionName rootProject.ext.android.versionName

    testInstrumentationRunner rootProject.ext.android.testInstrumentationRunner

    }

    ......
    }

    dependencies {

    implementation fileTree(include: ['*.jar'], dir: 'libs')

    // implementation 'androidx.core:core-ktx:1.7.0'
    // implementation 'androidx.appcompat:appcompat:1.6.1'
    // implementation
    //
    // testImplementation 'junit:junit:4.13.2'
    // androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    // androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

    implementation rootProject.ext.dependencies.coreKtx
    implementation rootProject.ext.dependencies.appcompat
    implementation rootProject.ext.dependencies.material

    testImplementation rootProject.ext.dependencies.junit
    androidTestImplementation rootProject.ext.dependencies.testJunit
    androidTestImplementation rootProject.ext.dependencies.espresso

    }


    需要注意的是,我们在编写代码的时候,是没有代码自动补全的。所以得小心翼翼,必须要和「config.gradle」文件里面的名字向一致。




注意事项



  • 首先就是这种方式在coding的时候,是没有代码补全的(只有输入过的,才会有提示),我们需要确保我们的名字一致

  • 我们在增加依赖的时候,在「config.gradle」里面添加完之后,记得在对应的module里面的「build.gradle」里面添加对应的指向代码。


总结


以上就是本篇文章的全部内容,总结起来其实步骤不多,也就三步。但是需要注意的是细节。需要保持写入的依赖与「config.gradle」文件一致,并且未写过的词,是不会有代码自动补全的。


抬头图片


作者:肥仔仁
来源:juejin.cn/post/7224007334513770551
收起阅读 »

Android 自定义开源库 EasyView

  这是一个简单方便的Android自定义View库,我一直有一个想法弄一个开源库,现在这个想法付诸实现了,如果有什么需要自定义的View可以提出来,不一定都会采纳,合理的会采纳,时间周期不保证,咱要量力而行呀,踏实一点。 配置EasyView 1. 工程b...
继续阅读 »

  这是一个简单方便的Android自定义View库,我一直有一个想法弄一个开源库,现在这个想法付诸实现了,如果有什么需要自定义的View可以提出来,不一定都会采纳,合理的会采纳,时间周期不保证,咱要量力而行呀,踏实一点。


1682474222191_095705.gif.gif


配置EasyView


1. 工程build.gradle 或 settings.gradle配置


   代码已经推送到MavenCentral(),在Android Studio 4.2以后的版本中默认在创建工程的时候使用MavenCentral(),而不是jcenter()


   如果是之前的版本则需要在repositories{}闭包中添加mavenCentral(),不同的是,老版本的Android Studio是在工程的build.gradle中添加,而新版本是工程的settings.gradle中添加,如果已经添加,则不要重复添加。


repositories {
...
mavenCentral()
}

2. 使用模块的build.gradle配置


   例如在app模块中使用,则打开app模块下的build.gradle,在dependencies{}闭包下添加即可,之后记得要Sync Now


dependencies {
implementation 'io.github.lilongweidev:easyview:1.0.3'
}

使用EasyView


   这是一个自定义View的库,会慢慢丰富里面的自定义View,我先画个饼再说。


一、MacAddressEditText


MacAddressEditText是一个蓝牙Mac地址输入控件,点击之后出现一个定制的Hex键盘,用于输入值。


在这里插入图片描述


1. xml中使用


首先是在xml中添加如下代码,具体参考app模块中的activity_mac_address.xml


    <com.easy.view.MacAddressEditText
android:id="@+id/mac_et"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:boxBackgroundColor="@color/white"
app:boxStrokeColor="@color/black"
app:boxStrokeWidth="2dp"
app:boxWidth="48dp"
app:separator=":"
app:textColor="@color/black"
app:textSize="14sp" />


2. 属性介绍


这里使用了MacAddressEditText的所有属性,可以自行进行设置,使用说明参考下表。


属性说明
app:boxBackgroundColor设置输入框的背景颜色
app:boxStrokeColor设置输入框的边框颜色
app:boxStrokeWidth设置输入框的边框大小
app:boxWidth设置输入框大小
app:separatorMac地址的分隔符,例如分号:
app:textColor设置输入框文字颜色
app:textSize设置输入框文字大小

3. 代码中使用


    MacAddressEditText macEt = findViewById(R.id.mac_et);
String macAddress = macEt.getMacAddress();

macAddress可能会是空字符串,使用之前请判断一下,参考app模块中的MacAddressActivity中的使用方式。


二、CircularProgressBar


CircularProgressBar是圆环进度条控件。


在这里插入图片描述


1. xml中使用


首先是在xml中添加如下代码,具体参考app模块中的activity_progress_bar.xml


    <com.easy.view.CircularProgressBar
android:id="@+id/cpb_test"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
app:maxProgress="100"
app:progress="10"
app:progressbarBackgroundColor="@color/purple_500"
app:progressbarColor="@color/purple_200"
app:radius="80dp"
app:strokeWidth="16dp"
app:text="10%"
app:textColor="@color/teal_200"
app:textSize="28sp" />


2. 属性介绍


这里使用了MacAddressEditText的所有属性,可以自行进行设置,使用说明参考下表。


属性说明
app:maxProgress最大进度
app:progress当前进度
app:progressbarBackgroundColor进度条背景颜色
app:progressbarColor进度颜色
app:radius半径,用于设置圆环的大小
app:strokeWidth进度条大小
app:text进度条中心文字
app:textColor进度条中心文字颜色
app:textSize进度条中心文字大小

3. 代码中使用


    CircularProgressBar cpbTest = findViewById(R.id.cpb_test);
int progress = 10;
cpbTest.setText(progress + "%");
cpbTest.setProgress(progress);

参考app模块中的ProgressBarActivity中的使用方式。


三、TimingTextView


TimingTextView是计时文字控件。


在这里插入图片描述


1. xml中使用


首先是在xml中添加如下代码,具体参考app模块中的activity_timing_text.xml


    <com.easy.view.TimingTextView
android:id="@+id/tv_timing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:text="计时文字"
android:textColor="@color/black"
android:textSize="32sp"
app:countdown="false"
app:max="60"
app:unit="s" />


2. 属性介绍


这里使用了TimingTextView的自定义属性不多,只有3个,TextView的属性就不列举说明,使用说明参考下表。


属性说明
app:countdown是否倒计时
app:max最大时间长度
app:unit时间单位:s(秒)、m(分)、h(时)

3. 代码中使用


    TimingTextView tvTiming = findViewById(R.id.tv_timing);
tvTiming.setMax(6);//最大时间
tvTiming.setCountDown(false);//是否倒计时
tvTiming.setUnit(3);//单位 秒
tvTiming.setListener(new TimingListener() {
@Override
public void onEnd() {
//定时结束
}
});
//开始计时
tvTiming.start();
//停止计时
//tvTiming.end();

参考app模块中的TimingActivity中的使用方式。


四、EasyEditText


EasyEditText是一个简易输入控件,可用于密码框、验证码输入框进行使用。


在这里插入图片描述


1. xml中使用


首先是在xml中添加如下代码,具体参考app模块中的activity_easy_edittext.xml


    <com.easy.view.EasyEditText
android:id="@+id/et_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:boxBackgroundColor="@color/white"
app:boxFocusStrokeColor="@color/green"
app:boxNum="6"
app:boxStrokeColor="@color/black"
app:boxStrokeWidth="2dp"
app:boxWidth="48dp"
app:ciphertext="false"
app:textColor="@color/black"
app:textSize="16sp" />


2. 属性介绍


这里使用了EasyEditText的所有属性,可以自行进行设置,使用说明参考下表。


属性说明
app:boxBackgroundColor设置输入框的背景颜色
app:boxFocusStrokeColor设置输入框获取焦点时的颜色
app:boxNum设置输入框的个数,4~6个
app:boxStrokeColor设置输入框的边框颜色
app:boxStrokeWidth设置输入框的边框大小
app:boxWidth设置输入框大小
app:ciphertext是否密文,用于密码框
app:textColor设置输入框文字颜色
app:textSize设置输入框文字大小

3. 代码中使用


        binding.cbFlag.setOnCheckedChangeListener((buttonView, isChecked) -> {
binding.etContent.setCiphertext(isChecked);
binding.cbFlag.setText(isChecked ? "密文" : "明文");
});
//输入框
binding.btnGetContent.setOnClickListener(v -> {
String content = binding.etContent.getText();
if (content.isEmpty()) {
showMsg("请输入内容");
return;
}
if (content.length() < binding.etContent.getBoxNum()) {
showMsg("请输入完整内容");
return;
}
showMsg("输入内容为:" + content);
});

参考app模块中的EasyEditTextActivity中的使用方式。


作者:初学者_Study
来源:juejin.cn/post/7225407341633175613
收起阅读 »

Android 中创建子线程的方式有哪几种

在 Android 中,创建子线程的方式通常有以下几种: 使用 Thread 类进行创建 Thread 是 Java 中的一个类,可以通过继承 Thread 类或者创建 Thread 对象并传入 Runnable 对象来创建子线程。例如: // 继承 Th...
继续阅读 »

在 Android 中,创建子线程的方式通常有以下几种:



  1. 使用 Thread 类进行创建 Thread 是 Java 中的一个类,可以通过继承 Thread 类或者创建 Thread 对象并传入 Runnable 对象来创建子线程。例如:


// 继承 Thread 类
public class MyThread extends Thread {
@Override
public void run() {
// 子线程要执行的代码
}
}

// 创建 Thread 对象并传入 Runnable 对象
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 子线程要执行的代码
}
});
thread.start();


  1. 使用 Runnable 接口进行创建 Runnable 是 Java 中的一个接口,可以通过实现 Runnable 接口并将其传入 Thread 对象来创建子线程。例如:


// 实现 Runnable 接口
public class MyRunnable implements Runnable {
@Override
public void run() {
// 子线程要执行的代码
}
}

// 创建 Thread 对象并传入 Runnable 对象
Thread thread = new Thread(new MyRunnable());
thread.start();


  1. 使用 AsyncTask 类进行创建 AsyncTask 是 Android 中的一个类,可以通过继承 AsyncTask 类并重写其方法来创建子线程。AsyncTask 可以方便地进行 UI 操作,并且不需要手动处理线程间通信问题。例如:


public class MyTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... voids) {
// 子线程要执行的代码
return null;
}

@Override
protected void onPostExecute(Void aVoid) {
// UI 操作
}
}

// 创建 AsyncTask 对象并调用 execute 方法
MyTask task = new MyTask();
task.execute();


  1. 使用线程池进行创建 线程池是一种可以重复利用线程的机制,可以减少创建和销毁线程所带来的开销。Android 中常用的线程池包括 ThreadPoolExecutor 和 ScheduledThreadPoolExecutor。例如:


// 创建 ThreadPoolExecutor 对象
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

// 创建 ScheduledThreadPoolExecutor 对象
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1);

综上所述,Android 中常用的创建子线程的方式有使用 Thread 类、使用 Runnable 接口、使用 AsyncTask 类和使用线程池。每种方式都有其优缺点,需要根据实际

作者:早起的年轻人
来源:juejin.cn/post/7229401405344415781
需求选择合适的方式。

收起阅读 »

Flutter App开发实现循环语句的方式

如果您有小程序、APP、公众号、网站相关的需求,您可以通过私信来联系我 Flutter 中循环语句的使用方式与其他编程语言比较类似,常见的包括 for 循环和 while 循环。 1 for 循环 Flutter 中的 for 循环语法如下: for (va...
继续阅读 »

如果您有小程序、APP、公众号、网站相关的需求,您可以通过私信来联系我



Flutter 中循环语句的使用方式与其他编程语言比较类似,常见的包括 for 循环和 while 循环。


1 for 循环


Flutter 中的 for 循环语法如下:


for (var i = 0; i < count; i++) {
// 循环体
}

其中的 count 为循环次数, i 初始值默认为 0,每次循环自增 1。在循环体内部可以编写需要重复执行的代码。 例如,以下代码循环输出 1 到 10 的数字:


for (var i = 1; i <= 10; i++) {
print(i);
}

下面是一个使用 for 循环实现的案例,用于遍历一个列表并输出其中的元素。假设有一个列表 fruits ,其中包含了一些水果,现在需要遍历列表并输出其中的每个元素:


List<String> fruits = ['apple', 'banana', 'orange', 'grape'];
for (String fruit in fruits) {
print(fruit);
}

上述代码中,使用 for 循环遍历了列表 fruits 中的每个元素,变量 fruit 用于存储当前循环到的元素,并输出了该元素。在每次循环中,变量 fruit 都会被更新为列表中的下一个元素,直到遍历完整个列表为止。


2 for in


在 Flutter 中, for...in 主要是用于遍历集合类型的数据,例如 List、Set 和 Map。


下面是一个使用 for...in 遍历 List 的案例:


List<int> numbers = [1, 2, 3, 4, 5];
for (int number in numbers) {
print(number);
}

上述代码中, numbers 是一个包含整数的 List, for...in 循环遍历该 List 中的每个元素,将每个元素赋值给变量 number ,并输出 number 的值。在每次遍历中, number 都会被更新为 List 中的下一个元素,直到遍历完整个 List 为止。


下面是一个使用 for...in 遍历 Map 的案例:


Map<String, String> fruits = {
'apple': 'red',
'banana': 'yellow',
'orange': 'orange',
'grape': 'purple'
};
for (String key in fruits.keys) {
print('$key is ${fruits[key]}');
}

上述代码中, fruits 是一个包含水果名称和颜色的 Map, for...in 循环遍历该 Map 中的每个键,将每个键赋值给变量 key ,并输出该键及其对应的值。在每次遍历中, key 都会被更新为 Map 中的下一个键,直到遍历完整个 Map 为止。


在遍历集合类型的数据时,使用 for...in 语句可以简化代码,避免了使用下标、索引等方式进行访问和处理,使代码更加易读、优雅。


3 while 循环


Flutter 中的 while 循环语法如下:


while (expression) {
// 循环体
}

其中, expression 是布尔表达式,循环体内部的代码会一直循环执行,直到 expression 不再为真时跳出循环。 例如,以下代码使用 while 循环实现输出 1 到 10 的数字:


var i = 1;
while (i <= 5) {
print(i);
i++;
}

上述代码中,我们定义了一个变量 i ,并使用 while 循环判断 i 是否小于 5,如果为真,则输出变量 i 的值并将 i 的值加 1,然后继续循环;如果为假,则跳出 while 循环。


在每次循环中,变量 i 都会被更新为上一次的值加 1,直到变量 i 的值达到 5 时, while 循环结束。


while 循环还可以和条件表达式一起使用,例如,下面是一个使用 while 循环判断列表是否为空的示例:


List<int> numbers = [1, 2, 3, 4, 5];
while (numbers.isNotEmpty) {
print(numbers.removeLast());
}

上述代码中,我们定义了一个包含整数的列表 numbers ,并使用 while 循环判断 numbers 是否为空,如果不为空,则输出列表中的最后一个元素并将其从列表中删除,然后继续循环;如果为空,则跳出 while 循环。
在每次循环中, numbers 列表都会被更新,直到列表为空时 while 循环结束。 使用 while 循环可以在满足一定条件的情况下,重复执行一组语句,从而实现某些特定的功能需求。


在使用 while 循环时,需要注意控制循环条件,避免出现死循环的情况。


以上就是 Flutter 中实现循环语句的方式。




如果你有兴趣,可以关注一下我的综合公

作者:早起的年轻人
来源:juejin.cn/post/7229388804611932217
众号:biglead

收起阅读 »

前端访问系统文件夹

随着前端技术和浏览器的升级,越来越多的功能可以在前端实现而不用依赖于后端。其中,访问系统文件夹是一个非常有用的功能,例如上传文件时,用户可以直接从自己的电脑中选择文件。 使用方法 在早期的浏览器中,要访问系统中的文件夹需要使用 ActiveX 控件或 Java...
继续阅读 »

随着前端技术和浏览器的升级,越来越多的功能可以在前端实现而不用依赖于后端。其中,访问系统文件夹是一个非常有用的功能,例如上传文件时,用户可以直接从自己的电脑中选择文件。


使用方法


在早期的浏览器中,要访问系统中的文件夹需要使用 ActiveX 控件或 Java Applet,这些方法已经逐渐淘汰。现在,访问系统文件夹需要使用HTML5的 API。


最常使用的 API 是FileAPI,配合 input[type="file"] 通过用户的交互行为来获取文件。但是,这种方法需要用户选择具体的文件而不是像在系统中打开文件夹来进行选择。


HTML5 还提供了更加高级的 API,如 showDirectoryPicker。它支持在浏览器中打开一个目录选择器,从而简化了选择文件夹的流程。这个 API 的使用也很简单,只需要调用 showDirectoryPicker() 方法即可。


async function pickDirectory() {
const directoryHandle = await window.showDirectoryPicker();
console.log(directoryHandle);
}

但是需要注意的是该api的兼容性较低,目前所支持的浏览器如下图所示:


image.png


点击一个按钮后,调用 pickDirectory() 方法即可打开选择文件夹的对话框,选择完文件夹后,该方法会返回一个 FileSystemFileHandle 对象,开发者可以使用这个对象来访问所选择的目录的内容。


应用场景


访问系统文件夹可以用于很多场景,下面列举几个常用的场景。


上传文件


在前端上传文件时,用户需要选择所需要上传的文件。这时,打开一个文件夹选择器,在用户选择了一个文件夹后,就可以读取文件夹中的文件并进行上传操作。


本地文件管理


将文件夹中的文件读取到前端后,可以在前端进行一些操作,如修改文件名、查看文件信息等。这个在 纯前端文件管理器 中就被广泛使用。


编辑器功能


访问系统文件夹可以将前端编辑器与本地的文件夹绑定,使得用户直接在本地进行编写代码,而不是将代码保存到云端,这对于某些敏感数据的处理尤为重要。


结尾的话


通过HTML5的 API,前端可以访问到系统中的文件夹,这项功能可以应用于上传文件、本地文件管理和编辑器功能等场景,为用户带来了极大的便利。


作者:白椰子
来源:juejin.cn/post/7222636308740014135
收起阅读 »

Android 自定义View 之 计时文字

前言   在Android开发中,常常会有计时的一些操作,例如收验证码的时候倒计时,秒表的计时等等,于是我就有了一个写自定义View的想法,本文效果图。 正文   那么现在我们将想法换成现实,这个自定义View比较简单,我们来看怎么写的,首先我们还是在Eas...
继续阅读 »

前言


  在Android开发中,常常会有计时的一些操作,例如收验证码的时候倒计时,秒表的计时等等,于是我就有了一个写自定义View的想法,本文效果图。


在这里插入图片描述


正文


  那么现在我们将想法换成现实,这个自定义View比较简单,我们来看怎么写的,首先我们还是在EasyView中进行添加。


一、XML样式


  根据上面的效果图,我们首先来确定XML中的属性样式,在attrs.xml中增加如下代码:


    <!--计时文字-->
<declare-styleable name="TimingTextView">
<!--倒计时-->
<attr name="countdown" format="boolean" />
<!--时间最大值-->
<attr name="max" format="integer" />
<!--时间单位,时:h,分:m,秒:s-->
<attr name="unit">
<enum name="h" value="1" />
<enum name="m" value="2" />
<enum name="s" value="3" />
</attr>
</declare-styleable>

  这里的计时文字目前有3个属性,第一个boolean用来确定是计时还是倒计时,第二个是最大时间,第三个是时间单位:时分秒。


二、构造方法


  之前我说自定义View有三种方式,一种是继承View,一种是继承现有的View,还有一种是继承ViewGroup,那么今天的这个计时文字,我们就可以继承现有的View,这样做的目的就是可以让我们减少一定的工作量,专注于功能上,下面我们在com.llw.easyview包下新建一个TimingTextView类,里面的代码如下所示:


public class TimingTextView extends MaterialTextView {

/**
* 时间单位
*/

private int mUnit;
/**
* 计时最大值
*/

private int mMax;
/**
* 是否倒计时
*/

private boolean mCountDown;
private int mTotal;
/**
* 是否计时中
*/

private boolean mTiming;

public TimingTextView(Context context) {
this(context, null);
}

public TimingTextView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public TimingTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
@SuppressLint("CustomViewStyleable")
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TimingTextView);
mCountDown = typedArray.getBoolean(R.styleable.TimingTextView_countdown, false);
mMax = typedArray.getInteger(R.styleable.TimingTextView_max, 60);
mUnit = typedArray.getInt(R.styleable.TimingTextView_unit, 3);
typedArray.recycle();
}
}

  因为有计时的缘故,所以我们需要一个计时监听,主要用于结束的时候进行调用,可以在com.llw.easyview下新建一个TimingListener接口,代码如下:


public interface TimingListener {
void onEnd();
}

三、API方法


下面在TimingTextView中新增一些API方法和变量,首先增加变量:


    private TimingListener listener;
private CountDownTimer countDownTimer;

然后增加API方法:


    /**
* 设置时间单位
*
* @param unit 1,2,3
*/

public void setUnit(int unit) {
if (unit <= 0 || unit > 3) {
throw new IllegalArgumentException("unit value can only be between 1 and 3");
}
mUnit = unit;
}

/**
* 设置最大时间值
*
* @param max 最大值
*/

public void setMax(int max) {
mMax = max;
}

/**
* 设置是否为倒计时
*
* @param isCountDown true or false
*/

public void setCountDown(boolean isCountDown) {
mCountDown = isCountDown;
}

public void setListener(TimingListener listener) {
this.listener = listener;
}

public boolean isTiming() {
return mTiming;
}

/**
* 开始
*/

public void start() {
switch (mUnit) {
case 1:
mTotal = mMax * 60 * 60 * 1000;
break;
case 2:
mTotal = mMax * 60 * 1000;
break;
case 3:
mTotal = mMax * 1000;
break;
}
if (countDownTimer == null) {
countDownTimer = new CountDownTimer(mTotal, 1000) {
@Override
public void onTick(long millisUntilFinished) {
int time = 0;
if (mCountDown) {
time = (int) (millisUntilFinished / 1000);
setText(String.valueOf(time));
} else {
time = (int) (mTotal / 1000 - millisUntilFinished / 1000);
}
setText(String.valueOf(time));
}

@Override
public void onFinish() {
//倒计时结束
end();
}
};
mTiming = true;
countDownTimer.start();
}

}

/**
* 计时结束
*/

public void end() {
mTotal = 0;
mTiming = false;
countDownTimer.cancel();
countDownTimer = null;
if (listener != null) {
listener.onEnd();
}
}

代码还是很简单的,你敢信,这个自定义View就写完了,不过可能存在一些问题,我将自定义View的代码都放到了一个library下面里,然后将这个library进行构建成aar,然后上传到mavenCentral()中。


四、使用


  然后我们修改一下activity_main.xml,代码如下所示:


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
android:gravity="center"
android:orientation="vertical"
android:padding="16dp"
tools:context=".MainActivity">


<com.easy.view.MacAddressEditText
android:id="@+id/mac_et"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:boxBackgroundColor="@color/white"
app:boxStrokeColor="@color/black"
app:boxStrokeWidth="2dp"
app:boxWidth="48dp"
app:separator=":"
app:textColor="@color/black"
app:textSize="16sp" />


<Button
android:id="@+id/btn_mac"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:text="获取地址" />


<com.easy.view.CircularProgressBar
android:id="@+id/cpb_test"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
app:maxProgress="100"
app:progress="10"
app:progressbarBackgroundColor="@color/purple_500"
app:progressbarColor="@color/purple_200"
app:radius="80dp"
app:strokeWidth="16dp"
app:text="10%"
app:textColor="@color/teal_200"
app:textSize="28sp" />


<Button
android:id="@+id/btn_set_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:text="随机设置进度" />


<com.easy.view.TimingTextView
android:id="@+id/tv_timing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:text="计时文字"
android:textColor="@color/black"
android:textSize="32sp"
app:countdown="false"
app:max="60"
app:unit="s" />


<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:orientation="vertical">


<CheckBox
android:id="@+id/cb_flag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="计时" />


<Button
android:id="@+id/btn_start"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="开始" />

</LinearLayout>
</LinearLayout>

预览效果如下图所示:


在这里插入图片描述


下面我们回到MainActivity中,在onCreate()方法中添加如下代码:


        //计时文本操作
TimingTextView tvTiming = findViewById(R.id.tv_timing);
CheckBox cbFlag = findViewById(R.id.cb_flag);
Button btnStart = findViewById(R.id.btn_start);
tvTiming.setListener(new TimingListener() {
@Override
public void onEnd() {
tvTiming.setText("计时文字");
btnStart.setText("开始");
}
});
cbFlag.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
cbFlag.setText(isChecked ? "倒计时" : "计时");
}
});
//计时按钮点击
btnStart.setOnClickListener(v -> {
if (tvTiming.isTiming()) {
//停止计时
tvTiming.end();
btnStart.setText("开始");
} else {
tvTiming.setMax(6);
tvTiming.setCountDown(cbFlag.isChecked());
tvTiming.setUnit(3);//单位 秒
//开始计时
tvTiming.start();
btnStart.setText("停止");
}
});

下面运行一下看看:


在这里插入图片描述


五、源码


如果对你有所帮助的话,不妨 Star 或 Fork,山高水长,后会有期~


源码地址:EasyView


作者:初学者_Study
来源:juejin.cn/post/7225045596029075511
收起阅读 »

无聊的分享:点击EditText以外区域隐藏软键盘

1.前言 当我们在使用一个应用的搜索功能或者任何带有输入功能的控件时,如果想取消输入往往会点击外部空间,这个时候系统的软键盘就会自动收起,并且输入框也会清楚焦点,这样看上去很自然,其实使用EditText控件是没有这种效果的,本文章就如何实现上述效果提供一点小...
继续阅读 »

1.前言


当我们在使用一个应用的搜索功能或者任何带有输入功能的控件时,如果想取消输入往往会点击外部空间,这个时候系统的软键盘就会自动收起,并且输入框也会清楚焦点,这样看上去很自然,其实使用EditText控件是没有这种效果的,本文章就如何实现上述效果提供一点小小的思路。


2.如何实现


当我们在Activity单纯的添加一个EditText时,点击吊起软键盘,这个时候再点击EditText外部区域会是这个样子的:



会发现,无论我们怎么点击外部区域软键盘都不会收起。所以要达到点击外部区域收起键盘效果需要我们自己添加方法去隐藏键盘:


重写dispatchTouchEvent


override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
   ev?.let {
       if (it.action == MotionEvent.ACTION_DOWN) {
           //如果现在取得焦点的View为EditText则进入判断
           currentFocus?.let { view ->
               if (view is EditText) {
                   if (!isInSide(view, ev) && isSoftInPutDisplayed()) {
                       hideSoftInPut(view)
                  }
              }
          }
      }
  }
   return super.dispatchTouchEvent(ev)
}

在Activity 中重写dispatchTouchEvent,对ACTION_DOWN事件做处理,使用getCurrentFocus()方法拿到当前获取焦点的View,判断其是否为EditText,若为EditText,则看当前软键盘是否展示(isSoftInPutDisplayed)并且点击坐标是否在EditText的外部区域(isInSide),满足条件则隐藏软键盘(hideSoftInPut)。


判断点击坐标是否在EditText内部


//判断点击坐标是否在EditText内部
private fun isInSide(currentFocus: View, ev: MotionEvent): Boolean {
   val location = intArrayOf(0, 0)
//获取当前EditText坐标
   currentFocus.getLocationInWindow(location)
//上下左右
   val left = location[0]
   val top = location[1]
   val right = left + currentFocus.width
   val bottom = top + currentFocus.height
//点击坐标是否在其内部
   return (ev.x >= left && ev.x <= right && ev.y > top && ev.y < bottom)
}

定义一个数组location存储当前EditText坐标,计算出其边界,再用点击坐标(ev.x,ev.y)和边界做比较最终得出点击坐标是否在其内部。


来判断软键盘是否展示


private fun isSoftInPutDisplayed(): Boolean {
   return ViewCompat.getRootWindowInsets(window.decorView)
       ?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
}

使用
WindowInsetsCompat类来判断当前状态下软键盘是否展示,WindowInsetsCompat是AndroidX库中的一个类,用于处理窗口插入(WindowInsets)的辅助类,可用于帮助开发者处理设备的系统UI变化,如状态栏、导航栏、软键盘等,给ViewCompat.getRootWindowInsets传入decorView拿到其实例,利用isVisible方法判断软键盘(WindowInsetsCompat.Type.ime())是否显示。


隐藏软键盘


private fun hideSoftInPut(currentFocus: View) {
   currentFocus.let {
    //清除焦点
       it.clearFocus()
    //关闭软键盘
       val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
       imm.hideSoftInputFromWindow(it.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
  }
}

首先要清除当前EditText的焦点,防止出现键盘收起但是焦点还在的情况:



最后是获取系统Service隐藏当前的键盘。


来看看最终的效果吧:



3.结尾


以上就是关于点击EditText外部区域隐藏软键盘并且清除焦点的实现方法,当然这只是其中的一种方式,如有不足请在评论区或私信指出,如果你们有更多的实现方法也欢迎在论区或私信留言捏❤️❤️


作者:Otaku_尻男
来源:juejin.cn/post/7226248402798936119
收起阅读 »

Android大图预览

前言 加载高清大图时,往往会有不能缩放和分段加载的需求出现。本文将就BitmapRegionDecoder和subsampling-scale-image-view的使用总结一下Bitmap的分区域解码。 定义 假设现在有一张这样的图片,尺寸为3040 × ...
继续阅读 »

前言


加载高清大图时,往往会有不能缩放分段加载的需求出现。本文将就BitmapRegionDecodersubsampling-scale-image-view的使用总结一下Bitmap的分区域解码


定义


image.png


假设现在有一张这样的图片,尺寸为3040 × 1280。如果按需要完全显示在ImageView上的话就必须进行压缩处理。当需求要求是不能缩放的时候,就需要进行分段查看了。由于像这种尺寸大小的图片在加载到内存后容易造成OOM,所以需要进行区域解码


图中红框的部分就是需要区域解码的部分,即每次只有进行红框区域大小的解码,在需要看其余部分时可以通过如拖动等手势来移动红框区域,达到查看全图的目的。


BitmapRegionDecoder


Android原生提供了BitmapRegionDecoder用于实现Bitmap的区域解码,简单使用的api如下:


// 根据图片文件的InputStream创建BitmapRegionDecoder
val decoder = BitmapRegionDecoder.newInstance(inputStream, false)

val option: BitmapFactory.Options = BitmapFactory.Options()
val rect: Rect = Rect(0, 0, 100, 100)

// rect制定的区域即为需要区域解码的区域
decoder.decodeRegion(rect, option)


  • 通过BitmapRegionDecoder.newInstance可以根据图片文件的InputStream对象创建一个BitmapRegionDecoder对象。

  • decodeRegion方法传入一个Rect和一个BitmapFactory.Options,前者用于规定解码区域,后者用于配置Bitmap,如inSampleSize、inPreferredConfig等。ps:解码区域必须在图片宽高范围内,否则会出现崩溃。


区域解码与全图解码


通过区域解码得到的Bitmap,宽高和占用内存只是指定区域的图像所需要的大小


譬如按1080 × 1037区域大小加载,可以查看Bitmap的allocationByteCount为4479840,即1080 * 1037 * 4


image.png


若直接按全图解码,allocationByteCount为15564800,即3040 * 1280 * 4
image.png


可以看到,区域解码的好处是图像不会完整的被加载到内存中,而是按需加载了。


自定义一个图片查看的View


由于BitmapRegionDecoder只是实现区域解码,如果改变这个区域还是需要开发者通过具体交互实现。这里用触摸事件简单实现了一个自定义View。由于只是简单依赖触摸事件,滑动的灵敏度还是偏高,实际开发可以实现一些自定义的拖拽工具来进行辅助。代码比较简单,可参考注释。


class RegionImageView @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attr, defStyleAttr) {

private var decoder: BitmapRegionDecoder? = null
private val option: BitmapFactory.Options = BitmapFactory.Options()
private val rect: Rect = Rect()

private var lastX: Float = -1f
private var lastY: Float = -1f

fun setImage(fileName: String) {
val inputStream = context.assets.open(fileName)
try {
this.decoder = BitmapRegionDecoder.newInstance(inputStream, false)

// 触发onMeasure,用于更新Rect的初始值
requestLayout()
} catch (e: Exception) {
e.printStackTrace()
} finally {
inputStream.close()
}
}

override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
this.decoder ?: return false
this.lastX = event.x
this.lastY = event.y
return true
}
MotionEvent.ACTION_MOVE -> {
val decoder = this.decoder ?: return false
val dx = event.x - this.lastX
val dy = event.y - this.lastY

// 每次MOVE事件根据前后差值对Rect进行更新,需要注意不能超过图片的实际宽高
if (decoder.width > width) {
this.rect.offset(-dx.toInt(), 0)
if (this.rect.right > decoder.width) {
this.rect.right = decoder.width
this.rect.left = decoder.width - width
} else if (this.rect.left < 0) {
this.rect.right = width
this.rect.left = 0
}
invalidate()
}
if (decoder.height > height) {
this.rect.offset(0, -dy.toInt())
if (this.rect.bottom > decoder.height) {
this.rect.bottom = decoder.height
this.rect.top = decoder.height - height
} else if (this.rect.top < 0) {
this.rect.bottom = height
this.rect.top = 0
}
invalidate()
}
}
MotionEvent.ACTION_UP -> {
this.lastX = -1f
this.lastY = -1f
}
else -> {

}
}

return super.onTouchEvent(event)
}

// 测量后默认第一次加载的区域是从0开始到控件的宽高大小
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)

val w = MeasureSpec.getSize(widthMeasureSpec)
val h = MeasureSpec.getSize(heightMeasureSpec)

this.rect.left = 0
this.rect.top = 0
this.rect.right = w
this.rect.bottom = h
}

// 每次绘制时,通过BitmapRegionDecoder解码出当前区域的Bitmap
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.let {
val bitmap = this.decoder?.decodeRegion(rect, option) ?: return
it.drawBitmap(bitmap, 0f, 0f, null)
}
}
}

SubsamplingScaleImageView


davemorrissey/subsampling-scale-image-view可以用于加载超大尺寸的图片,避免大内存导致的OOM,内部依赖的也是BitmapRegionDecoder。好处是SubsamplingScaleImageView已经帮我们实现了相关的手势如拖动、缩放,内部还实现了二次采样和区块显示的逻辑。


如果需要加载assets目录下的图片,可以这样调用


subsamplingScaleImageView.setImage(ImageSource.asset("sample1.jpeg"))

public final class ImageSource {

static final String FILE_SCHEME = "file:///";
static final String ASSET_SCHEME = "file:///android_asset/";

private final Uri uri;
private final Bitmap bitmap;
private final Integer resource;
private boolean tile;
private int sWidth;
private int sHeight;
private Rect sRegion;
private boolean cached;

ImageSource是对图片资源信息的抽象



  • uri、bitmap、resource分别指代图像来源是文件、解析好的Bitmap对象还是resourceId。

  • tile:是否需要分片加载,一般以uri、resource形式加载的都会为true。

  • sWidth、sHeight、sRegion:加载图片的宽高和区域,一般可以指定加载图片的特定区域,而不是全图加载

  • cached:控制重置时,是否需要recycle掉Bitmap


public final void setImage(@NonNull ImageSource imageSource, ImageSource previewSource, ImageViewState state) {
...

if (imageSource.getBitmap() != null && imageSource.getSRegion() != null) {
...
} else if (imageSource.getBitmap() != null) {
...
} else {
sRegion = imageSource.getSRegion();
uri = imageSource.getUri();
if (uri == null && imageSource.getResource() != null) {
uri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + getContext().getPackageName() + "/" + imageSource.getResource());
}
if (imageSource.getTile() || sRegion != null) {
// Load the bitmap using tile decoding.
TilesInitTask task = new TilesInitTask(this, getContext(), regionDecoderFactory, uri);
execute(task);
} else {
// Load the bitmap as a single image.
BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, false);
execute(task);
}
}
}

由于在我们的调用下,tile为true,setImage方法最后会走到一个TilesInitTask当中。是一个AsyncTask。ps:该库中的多线程异步操作都是通过AsyncTask封装的。


// TilesInitTask
@Override
protected int[] doInBackground(Void... params) {
try {
String sourceUri = source.toString();
Context context = contextRef.get();
DecoderFactory<? extends ImageRegionDecoder> decoderFactory = decoderFactoryRef.get();
SubsamplingScaleImageView view = viewRef.get();
if (context != null && decoderFactory != null && view != null) {
view.debug("TilesInitTask.doInBackground");
decoder = decoderFactory.make();
Point dimensions = decoder.init(context, source);
int sWidth = dimensions.x;
int sHeight = dimensions.y;
int exifOrientation = view.getExifOrientation(context, sourceUri);
if (view.sRegion != null) {
view.sRegion.left = Math.max(0, view.sRegion.left);
view.sRegion.top = Math.max(0, view.sRegion.top);
view.sRegion.right = Math.min(sWidth, view.sRegion.right);
view.sRegion.bottom = Math.min(sHeight, view.sRegion.bottom);
sWidth = view.sRegion.width();
sHeight = view.sRegion.height();
}
return new int[] { sWidth, sHeight, exifOrientation };
}
} catch (Exception e) {
Log.e(TAG, "Failed to initialise bitmap decoder", e);
this.exception = e;
}
return null;
}

@Override
protected void onPostExecute(int[] xyo) {
final SubsamplingScaleImageView view = viewRef.get();
if (view != null) {
if (decoder != null && xyo != null && xyo.length == 3) {
view.onTilesInited(decoder, xyo[0], xyo[1], xyo[2]);
} else if (exception != null && view.onImageEventListener != null) {
view.onImageEventListener.onImageLoadError(exception);
}
}
}

TilesInitTask主要的操作是创建一个SkiaImageRegionDecoder,它主要的作用是封装BitmapRegionDecoder。通过BitmapRegionDecoder获取图片的具体宽高和在Exif中获取图片的方向,便于显示调整。


后续会在onDraw时调用initialiseBaseLayer方法进行图片的加载操作,这里会根据比例计算出采样率来决定是否需要区域解码还是全图解码。值得一提的是,当采样率为1,图片宽高小于Canvas的getMaximumBitmapWidth()getMaximumBitmapHeight()时,也是会直接进行全图解码的。这里调用的TileLoadTask就是使用BitmapRegionDecoder进行解码的操作。


ps:Tile对象为区域的抽象类型,内部会包含指定区域的Bitmap,在onDraw时会根据区域通过Matrix绘制到Canvas上。


private synchronized void initialiseBaseLayer(@NonNull Point maxTileDimensions) {
debug("initialiseBaseLayer maxTileDimensions=%dx%d", maxTileDimensions.x, maxTileDimensions.y);

satTemp = new ScaleAndTranslate(0f, new PointF(0, 0));
fitToBounds(true, satTemp);

// Load double resolution - next level will be split into four tiles and at the center all four are required,
// so don't bother with tiling until the next level 16 tiles are needed.
fullImageSampleSize = calculateInSampleSize(satTemp.scale);
if (fullImageSampleSize > 1) {
fullImageSampleSize /= 2;
}

if (fullImageSampleSize == 1 && sRegion == null && sWidth() < maxTileDimensions.x && sHeight() < maxTileDimensions.y) {
// Whole image is required at native resolution, and is smaller than the canvas max bitmap size.
// Use BitmapDecoder for better image support.
decoder.recycle();
decoder = null;
BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, false);
execute(task);
} else {
initialiseTileMap(maxTileDimensions);

List<Tile> baseGrid = tileMap.get(fullImageSampleSize);
for (Tile baseTile : baseGrid) {
TileLoadTask task = new TileLoadTask(this, decoder, baseTile);
execute(task);
}
refreshRequiredTiles(true);

}

}

加载网络图片


BitmapRegionDecoder只能加载本地图片,而如果需要加载网络图片,可以结合Glide使用,以SubsamplingScaleImageView为例


Glide.with(this)
.asFile()
.load("")
.into(object : CustomTarget<File?>() {
override fun onResourceReady(resource: File, transition: Transition<in File?>?) {
subsamplingScaleImageView.setImage(ImageSource.uri(Uri.fromFile(resource)))
}

override fun onLoadCleared(placeholder: Drawable?) {}
})

可以通过CustomTarget获取到图片的File文件,然后再调用SubsamplingScaleImageView#setImage


最后


本文主要总结Bitmap的分区域解码,利用原生的BitmapRegionDecoder可实现区域解码,通过SubsamplingScaleImageView可以对BitmapRegionDecoder进行进一步的交互扩展和优化。如果需要是TV端开发可以参考这篇文章,里面有结合具体的TV端操作适配:Android实现TV端大图浏览


参考文章:



Android实现TV端大图浏览


tddrv.cn/a/233555


Android 超大图长图浏览库 SubsamplingScaleImageView 源码解析



作者:Cy13er
来源:juejin.cn/post/7224311569778229304
收起阅读 »

Android动态权限申请从未如此简单

前言 注:只想看实现的朋友们可以直接跳到最后面的最终实现 大家是否还在为动态权限申请感到苦恼呢?传统的动态权限申请需要在Activity中重写onRequestPermissionsResult方法来接收用户权限授予的结果。试想一下,你需要在一个子模块中申请权...
继续阅读 »

前言


注:只想看实现的朋友们可以直接跳到最后面的最终实现


大家是否还在为动态权限申请感到苦恼呢?传统的动态权限申请需要在Activity中重写onRequestPermissionsResult方法来接收用户权限授予的结果。试想一下,你需要在一个子模块中申请权限,那得从这个模块所在的ActivityonRequestPermissionsResult中将结果一层层再传回到这个模块中,相当的麻烦,代码也相当冗余和不干净,逼死强迫症。


使用


为了解决这个痛点,我封装出了两个方法,用于随时随地快速的动态申请权限,我们先来看看我们的封装方法是如何调用的:


activity.requestPermission(Manifest.permission.CAMERA, onPermit = {
//申请权限成功 Do something
}, onDeny = { shouldShowCustomRequest ->
//申请权限失败 Do something
if (shouldShowCustomRequest) {
//用户选择了拒绝并且不在询问,此时应该使用自定义弹窗提醒用户授权(可选)
}
})

这样是不是非常的简单便捷?申请和结果回调都在一个方法内处理,并且支持随用随调。


方案


那么,这么方便好用的方法是怎么实现的呢?不知道小伙伴们在平时开发中有没有注意到过,当你调用startActivityForResult时,AS会提示你该方法已被弃用,点进去看会告诉你应该使用registerForActivityResult方法替代。没错,这就是androidx给我们提供的ActivityResult功能,并且这个功能不仅支持ActivityResult回调,还支持打开文档,拍摄照片,选择文件等各种各样的回调,同样也包括我们今天要说的权限申请


其实Android在官方文档 请求运行时权限 中就已经将其作为动态权限申请的推荐方法了,如下示例代码所示:


val requestPermissionLauncher =
registerForActivityResult(RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
// Permission is granted. Continue the action or workflow in your
// app.
} else {
// Explain to the user that the feature is unavailable because the
// feature requires a permission that the user has denied. At the
// same time, respect the user's decision. Don't link to system
// settings in an effort to convince the user to change their
// decision.
}
}

when {
ContextCompat.checkSelfPermission(
CONTEXT,
Manifest.permission.REQUESTED_PERMISSION
) == PackageManager.PERMISSION_GRANTED -> {
// You can use the API that requires the permission.
}
shouldShowRequestPermissionRationale(...) -> {
// In an educational UI, explain to the user why your app requires this
// permission for a specific feature to behave as expected, and what
// features are disabled if it's declined. In this UI, include a
// "cancel" or "no thanks" button that lets the user continue
// using your app without granting the permission.
showInContextUI(...)
}
else -> {
// You can directly ask for the permission.
// The registered ActivityResultCallback gets the result of this request.
requestPermissionLauncher.launch(
Manifest.permission.REQUESTED_PERMISSION)
}
}

说到这里,可能有小伙伴要质疑我了:“官方文档里都写明了的东西,你还特地写一遍,还起了这么个标题,是不是在水文章?!”


莫急,如果你遵照以上方法这么写的话,在实际调用的时候会直接发生崩溃:


java.lang.IllegalStateException: 
LifecycleOwner Activity is attempting to register while current state is RESUMED.
LifecycleOwners must call register before they are STARTED.

这段报错很明显的告诉我们,我们的注册工作必须要在Activity声明周期STARTED之前进行(也就是onCreate时和onStart完成前),但这样我们就必须要事先注册好所有可能会用到的权限,没办法做到随时随地有需要时再申请权限了,有办法解决这个问题吗?答案是肯定的。


绕过生命周期检测


想解决这个问题,我们必须要知道问题的成因,让我们带着问题进到源码中一探究竟:


public final <I, O> ActivityResultLauncher<I> registerForActivityResult(
@NonNull ActivityResultContract<I, O> contract,
@NonNull ActivityResultCallback<O> callback)
{
return registerForActivityResult(contract, mActivityResultRegistry, callback);
}

public final <I, O> ActivityResultLauncher<I> registerForActivityResult(
@NonNull final ActivityResultContract<I, O> contract,
@NonNull final ActivityResultRegistry registry,
@NonNull final ActivityResultCallback<O> callback)
{
return registry.register(
"activity_rq#" + mNextLocalRequestCode.getAndIncrement(), this, contract, callback);
}

public final <I, O> ActivityResultLauncher<I> register(
@NonNull final String key,
@NonNull final LifecycleOwner lifecycleOwner,
@NonNull final ActivityResultContract<I, O> contract,
@NonNull final ActivityResultCallback<O> callback)
{

Lifecycle lifecycle = lifecycleOwner.getLifecycle();

if (lifecycle.getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
throw new IllegalStateException("LifecycleOwner " + lifecycleOwner + " is "
+ "attempting to register while current state is "
+ lifecycle.getCurrentState() + ". LifecycleOwners must call register before "
+ "they are STARTED.");
}

registerKey(key);
LifecycleContainer lifecycleContainer = mKeyToLifecycleContainers.get(key);
if (lifecycleContainer == null) {
lifecycleContainer = new LifecycleContainer(lifecycle);
}
LifecycleEventObserver observer = new LifecycleEventObserver() { ... };
lifecycleContainer.addObserver(observer);
mKeyToLifecycleContainers.put(key, lifecycleContainer);

return new ActivityResultLauncher<I>() { ... };
}

我们可以发现,registerForActivityResult实际上就是调用了ComponentActivity内部成员变量的mActivityResultRegistry.register方法,而在这个方法的一开头就检查了当前Activity的生命周期,如果生命周期位于STARTED后则直接抛出异常,那我们该如何绕过这个限制呢?


其实在register方法的下面就有一个同名重载方法,这个方法并没有做生命周期的检测:


public final <I, O> ActivityResultLauncher<I> register(
@NonNull final String key,
@NonNull final ActivityResultContract<I, O> contract,
@NonNull final ActivityResultCallback<O> callback)
{
registerKey(key);
mKeyToCallback.put(key, new CallbackAndContract<>(callback, contract));

if (mParsedPendingResults.containsKey(key)) {
@SuppressWarnings("unchecked")
final O parsedPendingResult = (O) mParsedPendingResults.get(key);
mParsedPendingResults.remove(key);
callback.onActivityResult(parsedPendingResult);
}
final ActivityResult pendingResult = mPendingResults.getParcelable(key);
if (pendingResult != null) {
mPendingResults.remove(key);
callback.onActivityResult(contract.parseResult(
pendingResult.getResultCode(),
pendingResult.getData()));
}

return new ActivityResultLauncher<I>() { ... };
}

找到这个方法就简单了,我们将registerForActivityResult方法调用替换成activityResultRegistry.register调用就可以了


当然,我们还需要注意一些小细节,检查生命周期的register方法同时也会注册生命周期回调,当Activity被销毁时会将我们注册的ActivityResult回调移除,我们也需要给我们封装的方法加上这个逻辑,最终实现就如下所示。


最终实现


private val nextLocalRequestCode = AtomicInteger()

private val nextKey: String
get() = "activity_rq#${nextLocalRequestCode.getAndIncrement()}"

fun ComponentActivity.requestPermission(
permission: String,
onPermit: () -> Unit,
onDeny: (shouldShowCustomRequest: Boolean) -> Unit
)
{
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
onPermit()
return
}
var launcher by Delegates.notNull<ActivityResultLauncher<String>>()
launcher = activityResultRegistry.register(
nextKey,
ActivityResultContracts.RequestPermission()
) { result ->
if (result) {
onPermit()
} else {
onDeny(!ActivityCompat.shouldShowRequestPermissionRationale(this, permission))
}
launcher.unregister()
}
lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
launcher.unregister()
lifecycle.removeObserver(this)
}
}
})
launcher.launch(permission)
}

fun ComponentActivity.requestPermissions(
permissions: Array<String>,
onPermit: () -> Unit,
onDeny: (shouldShowCustomRequest: Boolean) -> Unit
)
{
var hasPermissions = true
for (permission in permissions) {
if (ContextCompat.checkSelfPermission(
this,
permission
) != PackageManager.PERMISSION_GRANTED
) {
hasPermissions = false
break
}
}
if (hasPermissions) {
onPermit()
return
}
var launcher by Delegates.notNull<ActivityResultLauncher<Array<String>>>()
launcher = activityResultRegistry.register(
nextKey,
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
var allAllow = true
for (allow in result.values) {
if (!allow) {
allAllow = false
break
}
}
if (allAllow) {
onPermit()
} else {
var shouldShowCustomRequest = false
for (permission in permissions) {
if (!ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) {
shouldShowCustomRequest = true
break
}
}
onDeny(shouldShowCustomRequest)
}
launcher.unregister()
}
lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
launcher.unregister()
lifecycle.removeObserver(this)
}
}
})
launcher.launch(permissions)
}

总结


其实很多实用技巧本质上都是很简单的,但没有接触过就很难想到,我将我的开发经验分享给大家

作者:dreamgyf
来源:juejin.cn/post/7225516176171188285
,希望能帮助到大家。

收起阅读 »

Android 将json数据显示在RecyclerView

json数据要通过Get请求获取,这里有个重要的知识点,get请求需要拼接url的 本次拼接url的参数为phone,由于登录的时候已经填了手机号,如果这里再收集手机号就会让客户体验变差,于是我采用了SharedPreferences进行记录并调出 Share...
继续阅读 »

json数据要通过Get请求获取,这里有个重要的知识点,get请求需要拼接url的
本次拼接url的参数为phone,由于登录的时候已经填了手机号,如果这里再收集手机号就会让客户体验变差,于是我采用了SharedPreferences进行记录并调出


SharedPreferences pref=getSharedPreferences("data",MODE_PRIVATE);
String phone=pref.getString("phone","");

得到了phone之后,我采用了okhttp请求返回json,注意:进行网络请求都需要开启线程以及一些必要操作
例如


<uses-permission android:name="android.permission.INTERNET" /> 

url为你申请的网络url


 new Thread(new Runnable() {
@Override
public void run() {
OkHttpClient client=new OkHttpClient().newBuilder()
.connectTimeout(60000, TimeUnit.MILLISECONDS)
.readTimeout(60000,TimeUnit.MILLISECONDS).build();
//url/phone
Request request=new Request.Builder().url("url/phone"+phone).build();
try {
Response sponse=client.newCall(request).execute();
String string = sponse.body().string();
Log.d("list",string);
jsonJXDate(string);
}catch (IOException | JSONException e){
e.printStackTrace();
}
}
}).start();

由上可知,string即为所需的json


展示大概长这样


{
"code": 200,
"message": "成功",
"data": [
{
"id": "string",
"createTime": "2023-04-18T05:50:08.905+00:00",
"updateTime": "2023-04-18T05:50:08.905+00:00",
"isDeleted": 0,
"param": {},
"phone": "15019649343",
"commercialTenant": "string",
"payTime": "2023-04-18T05:50:08.905+00:00",
"type": "string",
"paymentType": "string",
"bills": [
{
"product": "烧烤",
"amount": "4",
"price": "60",
"subtotal": "240"
}
],
"total": "string"
},
{
"id": "643e9efb09ecf071b0fd2df0",
"createTime": "2023-04-18T13:28:35.889+00:00",
"updateTime": "2023-04-18T13:28:35.889+00:00",
"isDeleted": 0,
"param": {},
"phone": "15019649343",
"commercialTenant": "string",
"payTime": "2023-04-18T13:28:35.889+00:00",
"type": "string",
"paymentType": "string",
"bills": [
{
"product": "兰州拉面",
"amount": "5",
"price": "40",
"subtotal": "200"
}
],
"total": "string"
}
],
"ok": true
}

我所需要的是payTime,product,subtotal


有{}用JSONObject,有[]用JSONArray,一步步来靠近你的需要


JSONObject j1 = new JSONObject(data);
try {
JSONArray array = j1.getJSONArray("data");
for (int i=0;i<array.length();i++){
j1=array.getJSONObject(i);
Map<String,Object>map=new HashMap<>();
String payTime = j1.getString("payTime");
JSONObject bills = j1.getJSONArray("bills").getJSONObject(0);
String product = bills.getString("product");
String subtotal = bills.getString("subtotal");
map.put("payTime",payTime);
map.put("product",product);
map.put("subtotal",subtotal);
list.add(map);
}
Message msg=new Message();
msg.what=1;
handler.sendMessage(msg);

}catch (JSONException e){
e.printStackTrace();
}

}
public Handler handler=new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case 1:
//添加分割线
rv.addItemDecoration(new androidx.recyclerview.widget.DividerItemDecoration(
MeActivity.this, androidx.recyclerview.widget.DividerItemDecoration.VERTICAL));
MyAdapter recy = new MyAdapter(MeActivity.this, list);
//设置布局显示格式
rv.setLayoutManager(new LinearLayoutManager(MeActivity.this));
rv.setAdapter(recy);
break;
}
}
};

在adapter处通过常规layout显示后填入数据


 //定义时间展现格式
Map<String, Object> map = list.get(position);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
LocalDateTime dateTime = LocalDateTime.parse(map.get("payTime").toString(), formatter);
String strDate = dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

holder.produce.setText(map.get("product").toString());
holder.payTime.setText(strDate);
holder.price.setText(map.get("subtotal").toString());

就大功告成啦,由于后台那边还没把base64图片传上来,导致少了个图片,大致就是这样的


6a2d483f93267f3cd09f25576c1f29c.jpg


作者:用户2510099123599
来源:juejin.cn/post/7224841852305588280
收起阅读 »

微信黑科技

我一直认为技术是用来服务于用户,提升用户体验,而不是像 拼多多,写了恶意代码,操控用户的手机 ,利用技术做一些不好的事,今天这篇文章主要分享微信如何利用黑科技,减少 512MB 内存,降低 OOM 和 Native Crash 提升用户体验。 在上一篇文章谁动...
继续阅读 »

我一直认为技术是用来服务于用户,提升用户体验,而不是像 拼多多,写了恶意代码,操控用户的手机 ,利用技术做一些不好的事,今天这篇文章主要分享微信如何利用黑科技,减少 512MB 内存,降低 OOM 和 Native Crash 提升用户体验。


在上一篇文章谁动了我的内存,揭秘 OOM 崩溃下降 90% 的秘密 中分享了内存相关的知识点,包含堆、虚拟内存、发生 OOM 的原因,以及 为什么虚拟内存不足主要发生在 32 位的设备上导致虚拟内存不足的原因都有那些,目前都有哪些黑科技帮助我们去降低 OOM,有兴趣的小伙伴可以前往查看,从这篇文章开始细化每个知识点。


随着业务的增长,32 位设备上虚拟内存不足问题会越来越突出,尤其是大型应用会更加明显。除了业务上的优化之后,还需要一些黑科技尽可能降低更多的内存,而今天这篇主要分析微信分享的「堆空间减半」的方案,最高可减少 512MB 内存,从而降低 OOM 和 Native Crash,在开始之前,我们需要介绍一下 相关的知识点。


根据 Android 源码中的解释,Java 堆的大小应该是根据 RAM Size 来设置的,这是一个经验值,厂商是可以更改的,如果手机 Root 之后,自己也可以改,Google 源码的设置如下如下图所示

android.googlesource.com/platform/fr…



RAM (MB)-dalvik-heap. mkheapgrowthlimit (MB)heapsize (MB) 需要设置 android: largeHeap 为 true
512-dalvik-heap. mk48128
1024-dalvik-heap. mk96256
2048-dalvik-heap. mk192512
4096-dalvik-heap. mk192512
6144-dalvik-heap. mk256512
无论 RAM 多大,到目前为止堆的最大上限都是 512MB

正如上面表格所示,在 AndroidManifest.xml 文件 Application 节点中设置 android:largeHeap="true" 和不设置 largeHeap 获取到的最大堆的上限是不一样。


"true">



为什么默认关闭 android:largeHeap


Java 堆用于分配 Java / Kotlin 创建的对象,由 GC 管理和回收,GC 回收时将 From Space 里的对象复制到 To Space,这两片区域分别为 dalvik-main spacedalvik-main space 1, 这两片区域的大小和 Java 堆大小一样,如下图所示。



图中我们只需要关注 size(虚拟内存) 即可,如果 Java 堆的上限是 512 MB,那么 dalvik-main space(512 MB)dalvik-main space 1(512 MB) 共占用 1G 的虚拟内存。


如果堆的上限越大,那么 main space 占用的虚拟内存就会越大,在 32 位设备上,用户空间可用虚拟内存只有 3G,但是如果堆上限是 512MB,那么 main space 总共占用 1G 虚拟内存,剩下只有 2G 可用,因此 Google 在默认情况下会关闭 android:largeHeap 选项,只有在有需要的时候,主动设置 android:largeHeap = true,尝试获取更大的堆内存。


main space 占用虚拟内存的计算方式是不一样的。


Android 5. x ~ Android 7. x



  • 如果设置 android:largeHeap = true 时,main space size = dalvik.vm.heapsize,如果 heapsize 是 512MB,那么两个 main space 共占用 1G 虚拟内存

  • 如果不设置 largeHeap,那么 main space size = dalvik.vm.heapgrowthlimit,如果 heapgrowthlimit 是 256 MB,那么两个 main space 共占用 512 MB 虚拟内存


>= Android 8. x


无论 AndroidManifest 是否设置 android:largeHeapmain space size = dalvik.vm.heapsize * 2,如果 dalvik.vm.heapsize 是 512MB 那么 main space 占用 1G 的虚拟内存内存。


main space 在不同的系统分配方式是不一样的。



  • Android 5.x ~ Android 7.x 中,系统分配两块 main space,它们占用虚拟内存的大小和堆的大小是一样的

  • >= Android 8.x 之后,只分配了一个 main space,但是它占用虚拟内存的大小是堆的 2 倍


不同的系统上,它们的实现方式是不一样的,所以我们要采用不同的方法来释放 main space 占用的内存。


在 Android 5. x ~ Android 7. x


5.0 之后使用的是 ART 虚拟机,在 ART 虚拟机引入了,两种 Compacting GC 分为 Semi-Space(SS)GC (半空间压缩) 和 Generational Semi-Space(GSS)GC (分代半空间压缩)。 GSS GCSS GC 的改进版本,作为 background GC 的默认实现方式。


这两种 GC 的共同点,存在两片大小和堆大小一样的内存空间分别作为 From SpaceTo Space,这两片区域分别为 dalvik-main space1dalvik-main space2



上面的这两块区域对应的源码 地址

cs.android.com/android/_/a…



执行 Compact / Moving GC 的时候才会使用到这两片区域,在 GC 执行期间,将 From Space 分配的还存活的对象会依次拷贝到 To Space 中,在复制对象的过程中 From Space 中的碎片就会被消除,下次 GC 时重复这套逻辑,但是 GSS GC 还多了一个 Promote Space


Promote Space 主要存储老年代的对象,老年代对象的存活性要比新生代的久,因此将它们拷贝到 Promote Space 中去,可以避免每次执行 GSS GC 时,都需要对它们进行无用的处理。


新生代和老年代采用的不同的算法:



  • 新生代:复制算法。在两块 space 来回移动,高效且执行频繁,每次 GC 不需要挂起线程

  • 老年代:标记-压缩算法。会在 Mark 阶段是在挂起除当前线程之外的所有其它运行时线程,然后在 Compact 阶段才移动对象,Compact 方式是 Sliding Compaction,也就是在 Mark 之后就可以按顺序一个个对象 “滑动” 到空间的某一侧,移动的时候都是在一个空间内移动,不需要多一份空间


如何释放掉其中一个 main space 占用的内存


释放方案,可以参考腾讯开源的方案 Matrix,总来的来说分为两步:

github.com/Tencent/mat…



  • 确定 From SpaceTo Space 的内存地址

  • 调用 munmap 函数释放掉其中一个 Space 所占用的内存


如何确定 From Space 和 To Space 的内存地址


我们需要读取 mpas 文件,然后搜索关键字 main spacemain space 1,就可以知道 main spacemain space 1 的内存地址。


当我们知道 space 的内存地址之后,我们还需要确认当前正在使用的是那个 space,才能安全的调用 munmap 函数,释放掉另外一个没有使用的 space


matrix 的方案,创建一个基本类型的数组,然后通过 GetPrimitiveArrayCritical 方法获取它的地址,代码如下:



调用 GetPrimitiveArrayCritical 方法会返回对象的内存地址,如果地址在那块区域,当前的区域就是我们正在使用的区域,然后我们就可以安全的释放掉另外一个 space 了。



释放掉其中一个 Space 会有问题吗?


如果我们直接释放掉其中一个 Space,在执行 Compact / Moving GC 的时候,需要将 From Space 分配的对象依次拷贝到 To Space 中,因为找不到 To Space,会引起 crash, 所以需要阻止 Moving GC


源码中也说明了调用 GetPrimitiveArrayCritical 方法可以阻止 Moving GC。



GetPrimitiveArrayCritical 方法会调用 IncrementDisableMovingGC 方法阻止 Moving GC,对应的源码如下。

https://android. googlesource. com/platform/art/+/master/runtime/gc/heap. cc #956


void Heap::IncrementDisableMovingGC(Thread* self) {
// Need to do this holding the lock to prevent races where the GC is about to run / running when
// we attempt to disable it.
ScopedThreadStateChange tsc(self, kWaitingForGcToComplete);
MutexLock mu(self, *gc_complete_lock_);
++disable_moving_gc_count_;
if (IsMovingGc(collector_type_running_)) {
WaitForGcToCompleteLocked(kGcCauseDisableMovingGc, self);
}
}

所以只需要调用 GetPrimitiveArrayCritical 方法,阻止 Moving GC,也就不需要用到另外一个空间了,因此可以安全的释放掉。


阻止 Compact / Moving GC 会有性能问题吗


按照微信给出的测试数据,在性能上没有明显的变化。



OS Version >= Android 8. x


8.0 引入了 Concurrent Copying GC(并发复制算法),堆空间也变成了 RegionSpace。RegionSpace 的算法并不是靠把已分配对象在两片空间之间来回倒腾来实现的,分析 smaps 文件,发现也只创建了一个 main space,但是它占用的虚拟内存是堆的 2 倍,所以 8.0 之前的方案释放另外一个 space 是无法使用的。


为什么没有创建 main space2


我们从源码看一下创建 main space2 的触发条件。


if (foreground_collector_type_ == kCollectorTypeCC) {
use_homogeneous_space_compaction_for_oom_ = false;
}

bool support_homogeneous_space_compaction =
background_collector_type_ == gc::kCollectorTypeHomogeneousSpaceCompact ||
use_homogeneous_space_compaction_for_oom_;

if (support_homogeneous_space_compaction ||
background_collector_type_ == kCollectorTypeSS ||
foreground_collector_type_ == kCollectorTypeSS) {

ScopedTrace trace2("Create main mem map 2");
main_mem_map_2 = MapAnonymousPreferredAddress(
kMemMapSpaceName[1], main_mem_map_1.End(), capacity_, &error_str);
}

正如如源码所示,后台回收器类型 kCollectorTypeHomogeneousSpaceCompactkCollectorTypeCC 才会创建 main space2



  • kCollectorTypeHomogeneousSpaceCompact(同构空间压缩(HSC),用于后台回收器类型)

  • kCollectorTypeCCCompacting GC) 分为两种类型

    • Semi-Space(SS)GC (半空间压缩)

    • Generational Semi-Space(GSS)GC (分代半空间压缩),GSS GCSS GC 的改进版本




而 Android 8.0 将 Concurrent Copying GC 作为默认方式,对应的回收器的类型是 kCollectorTypeCCBackground



Concurrent Copying GC 分为 Pause, Copying, Reclaim 三个阶段,以 Region 为单位进行 GC,大小为 256 KB。



  • pause: 这个阶段耗时非常少,这里很重要的一块儿工作是确定需要进行 GC 的 region, 被选中的 region 称为 source region

  • Copying:这个阶段是整个 GC 中耗时最长的阶段。通过将 source region 中对象根据 root set 计算并标记为 reachable,然后将标记为 reachable 的对象拷贝到 destination region

  • Reclaim:在经过 Copying 阶段后,整个进程中就不再存在指向 source regions 的引用了,GC 就可以将这些 source region 的内存释放供以后使用了。


Concurrent Copying GC 使用了 read barrier 技术,来确保其它线程不会读到指向 source region 的对象,所以不会将 app 线程挂起,也不会阻止内存分配。


如何减少 main space 占用的内存


Adnroid 8.0 之后使用的阿里巴巴 Patrons 的方案,在虚拟内存占用超过一定阈值时调用 RegionSpace 中的 ClampGrowthLimit 方法来缩减 RegionSpace 的大小。


但是 ClampGrowthLimit 只在 Android 9.0 以后才出现,8.0 是没有的,所以参考了 Android 9.0 的代码实现了一个 ClampGrowthLimit。



ClampGrowthLimit 方法中,通过调用 MemMap::SetSize 方法来调整 RegionSpace 的大小。

https://android. googlesource. com/platform/art/+/5f0b71ab2f60f76b5f73402bd1fdd25bbc179b6c/runtime/gc/space/region_space. cc #416



MemMap::SetSize 方法的实现。

https://android. googlesource. com/platform/art/+/android-9.0.0_r7/runtime/mem_map. cc #883



new_base_size_base_size_ 不相等的情况下会执行 munmap 函数 , munmap 释放的大小为 base_size_new_base_size_ 的差值。




全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎在看、点赞、分享给身边的小伙伴,我会持续分享原创干货!!!




我开了一个云同步编译工具(SyncKit),主要用于本地写代码,同步到远程设备,在远程设备上进行编译,最后将编译的结果同步到本地,代码已经上传到 Github,欢迎前往仓库 hi-dhl/SyncKit 查看。



作者:程序员DHL
来源:juejin.cn/post/7223930180867063864
收起阅读 »

UI集成-EaseUI

如何集成环信的UI,我尝试集成,但以失败告终

如何集成环信的UI,我尝试集成,但以失败告终

明修"栈"道——越过Android启动栈陷阱

作者:vivo 互联网大前端团队- Zhao Kaiping 本文从一例业务中遇到的问题出发,以FLAG_ACTIVITY_NEW_TASK这一flag作为切入点,带大家探究Activity启动前的一项重要的工作——栈校验。 文中列举一系列业务中可能遇到的异...
继续阅读 »

作者:vivo 互联网大前端团队- Zhao Kaiping



本文从一例业务中遇到的问题出发,以FLAG_ACTIVITY_NEW_TASK这一flag作为切入点,带大家探究Activity启动前的一项重要的工作——栈校验。


文中列举一系列业务中可能遇到的异常状况,详细描述了使用FLAG_ACTIVITY_NEW_TASK时可能遇到的“坑”,并从源码中探究其根源。只有合理使用flag、launchMode,才能避免因为栈机制的特殊性,导致一系列与预期不符的启动问题。


一、问题及背景


应用间相互联动、相互跳转,是实现系统整体性、体验一致性的重要手段,也是最简单的一种方法。


当我们用最常用的方法去startActivity时,竟也会遇到失败的情况。在真实业务中,就遇到了这样一例异常:用户点击某个按钮时,想要“简简单单”跳转另一个应用,却没有任何反应。


经验丰富的你,脑海中是否涌现出了各种猜想:是不是目标Activity甚至目标App不存在?是不是目标Activty没有对外开放?是不是有权限的限制或者跳转的action/uri错了……


真实的原因被flag、launchMode、Intent等特性层层藏匿,可能超出你此时的思考。


本文将从源码出发,探究前因后果,展开讲讲在startActivity()真正准备启动一个Activity前,需要经过哪些“磨难”,怎样有据可依地解决由栈问题导致的启动异常。


1.1 业务中遇到的问题


业务中的场景是这样的,存在A、B、C三个应用。


(1)从应用A-Activity1跳转至应用B-Activity2;


(2)应用B-Activity2继续跳转到应用C-Activity3;


(3)C内某个按钮,会再次跳转B-Activity2,但点击后没有任何反应。如果不经过前面A到B的跳转,C直接跳到B是可以的。


图片


1.2 问题代码


3个Activity的Androidmanifest配置如下,均可通过各自的action拉起,launchMode均为标准模式。


<!--应用A--> 
<activity
android:name=".Activity1"
android:exported="true">
<intent-filter>
<action android:name="com.zkp.task.ACTION_TO_A_PAGE1" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<!--应用B-->
<activity
android:name=".Activity2"
android:exported="true">
<intent-filter>
<action android:name="com.zkp.task.ACTION_TO_B_PAGE2" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<!--应用C-->
<activity
android:name=".Activity3"
android:exported="true">
<intent-filter>
<action android:name="com.zkp.task.ACTION_TO_C_PAGE3" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

A-1到B-2的代码,指定flag为FLAG_ACTIVITY_NEW_TASK


private void jumpTo_B_Activity2_ByAction_NewTask() {
Intent intent = new Intent();
intent.setAction("com.zkp.task.ACTION_TO_B_PAGE2");
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}

B-2到C-3的代码,未指定flag


private void jumpTo_C_Activity3_ByAction_NoTask() {
Intent intent = new Intent();
intent.setAction("com.zkp.task.ACTION_TO_C_PAGE3");
startActivity(intent);
}

C-3到B-2的代码,与A-1到B-2的完全一致,指定flag为 FLAG_ACTIVITY_NEW_TASK


private void jumpTo_B_Activity2_ByAction_NewTask() {
Intent intent = new Intent();
intent.setAction("com.zkp.task.ACTION_TO_B_PAGE2");
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}

1.3 代码初步分析


仔细查看问题代码,在实现上非常简单,有两个特征:


(1)如果直接通过C-3跳B-2,没有任何问题,但A-1已经跳过B-2后,C-3就失败了。


(2)在A-1和C-3跳到B-2时,都设置了flag为FLAG_ACTIVITY_NEW_TASK。


依据经验,我们推测与栈有关,尝试将跳转前栈的状态打印出来,如下图。


图片


由于A-1跳到B-2时设置了FLAG_ACTIVITY_NEW_TASK,B-2跳到C-3时未设置,所以1在独立栈中,2、3在另一个栈中。示意如下图。


图片


C-3跳转B-2一般有3种可能的预期,如下图:预想1,新建一个Task,在新Task中启动一个B-2;预想2,复用已经存在的B-2;预想3,在已有Task中新建一个实例B-2。


图片


但实际上3种预期都没有实现,所有Activity的任何声明周期都没有变化,界面始终停留在C-3。


看一下FLAG_ACTIVITY_NEW_TASK的官方注释和代码注释,如下图:



图片


重点关注这一段:



When using this flag, if a task is already running for the activity you are now starting, then a new activity will not be started; instead, the current task will simply be brought to the front of the screen with the state it was last in.


使用此flag时,如果你正在启动的Activity已经在一个Task中运行,那么一个新Activity不会被启动;相反,当前Task将简单地显示在界面的前面,并显示其最后的状态。



——显然,官方文档与代码注释的表述与我们的异常现象是一致的,目标Activity2已经在Task中存在,则不会被启动;Task直接显示在前面,并展示最后的状态。由于目标Activty3就是来源Activity3,所以页面没有任何变化。


看起来官方还是很靠谱的,但实际效果真的能一直与官方描述一致吗?我们通过几个场景来看一下。


二、场景拓展与验证


2.1 场景拓展


在笔者依据官方描述进行调整、复现的过程中,发现了几个比较有意思的场景。


PS:上面业务的案例中,B-2和C-3在不同应用内,又在相同的Task内,但实际上是否是同一个应用,对结果的影响并不大。为了避免不同应用和不同Task造成阅读混乱,同一个栈的跳转,我们都在本应用内进行,故业务中的场景等价于下面的【场景0】



【场景0】把业务中B-2到C-3的应用间跳转改为B-2到B-3的应用内跳转



// B-2跳转B-3
public static void jumpTo_B_3_ByAction_Null(Context context) {
Intent intent = new Intent();
intent.setAction("com.zkp.task.ACTION_TO_B_PAGE3");
context.startActivity(intent);
}

如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,最终设置NEW_TASK想跳转B-2。虽然跳C-3改为了跳B-3,但与之前问题的表现一致,没有反应,停留在B-3。


图片


有的读者会指出这样的问题:如果同一个应用内使用NEW_TASK跳转,而不指定目标的taskAffinity属性,实际是无法在新Task中启动的。请大家忽略该问题,可以认为笔者的操作是已经加了taskAffinity的,这对最终结果并没有影响。



【场景1】如果目标Task和来源Task不是同一个,情况是否会如官方文档所说复用已有的Task并展示最近状态?我们改为B-3启动一个新Task的新Activity C-4,再通过C-4跳回B-2



// B-3跳转C-4
public static void jumpTo_C_4_ByAction_New(Context context) {
Intent intent = new Intent("com.zkp.task.ACTION_TO_C_PAGE4");
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
// C-4跳转B-2
public static void jumpTo_B_2_ByAction_New(Context context) {
Intent intent = new Intent();
intent.setAction("com.zkp.task.ACTION_TO_B_PAGE2");
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}

如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,再设置NEW_TASK跳转C-4,最终设置NEW_TASK想跳转B-2。


图片


预想的结果是:不会跳到B-2,而是跳到它所在Task的顶层B-3。


实际的结果是:与预期一致,确实是跳到了B-3。



【场景2】把场景1稍做修改:C-4到B-2时,我们不通过action来跳,改为通过setClassName跳转



// C-4跳转B-2
public static void jumpTo_B_2_ByPath_New(Context context) {
Intent intent = new Intent();
intent.setClassName("com.zkp.b", "com.zkp.b.Activity2"); // 直接设置classname,不通过action
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}

如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,再设置NEW_TASK跳转C-4,最终设置NEW_TASK想跳转B-2。


图片


预想的结果是:与场景0一致,会跳到B-2所在Task的已有顶层B-3。


实际的结果是:在已有的Task2中,产生了一个新的B-2实例。


仅仅是改变了一下重新跳转B-2的方式,效果就完全不一样了!这与官方文档中提到该flag与"singleTask" launchMode值产生的行为并不一致!



【场景3】把场景1再做修改:这次C-4不跳栈底的B-2,改为跳转B-3,且还是通过action方式。



// C-4跳转B-3
public static void jumpTo_B_3_ByAction_New(Context context) {
Intent intent = new Intent();
intent.setAction("com.zkp.task.ACTION_TO_B_PAGE3");
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}

如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,再设置NEW_TASK跳转C-4,最终设置NEW_TASK想跳转B-3。


图片


预想的结果是:与场景0一致,会跳到B-2所在Task的顶层B-3。


实际的结果是:在已有的Task2中,产生了一个新的B-3实例。


不是说好的,Activity已经存在时,展示其所在Task的最新状态吗?明明Task2中已经有了B-3,并没有直接展示它,而是生成了新的B-3实例。



【场景4】既然Activity没有被复用,那Task一定会被复用吗?把场景3稍做修改,直接给B-3指定一个单独的affinity。



<activity
android:name=".Activity3"
android:exported="true"
android:taskAffinity="b3.task"><!--指定了亲和性标识-->
<intent-filter>
<action android:name="com.zkp.task.ACTION_TO_B_PAGE3" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,再设置NEW_TASK跳转C-4,最终设置NEW_TASK想跳转B-3。



——这次,连Task也不会再被复用了……Activity3在一个新的栈中被实例化了。


再回看官方的注释,就会显得非常不准确,甚至会让开发者对该部分的认知产生严重错误!稍微改变过程中的某个毫无关联的属性(如跳转目标、跳转方式……),就会产生很大差异。


在看flag相关注释时,我们要树立一个意识:Task和Activity跳转的实际效果,是launchMode、taskAffinity、跳转方式、Activity在Task中的层级等属性综合作用的结果,不要相信“一面之词”。


回到问题本身,究竟是哪些原因造就了上面的不同效果呢?只有源码最值得信赖了。


三、场景分析与源码探索


本文以Android 12.0源码为基础,进行探究。上述场景在不同Android版本上的表现是一致的。


3.1 源码调试注意事项


源码的调试方法,许多文章已经有了详细的教学,本文不再赘述。此处只简单总结其中需要注意的事项





  1. 下载模拟器时,不要使用Google Play版本,该版本类似user版本,无法选择system_process进程进行断点。




  2. 即使是Google官方模拟器和源码,在断点时,也会有行数严重不对应的情况(比如:模拟器实际会运行到方法A,但在源码中打断点时,实际不能定位到方法A的对应行数),该问题并没有很好的处理方法,只能尽量规避,如使模拟器版本与源码版本保持一致、多打一些断点增加关键行数被定位到的几率。





3.2 初步断点,明确启动结果


以【场景0】为例,我们初步确认一下,为什么B-3跳转B-2会无反应,系统是否告知了原因。


3.2.1 明确启动结果及其来源


在Android源码的断点调试中,常见的有两类进程:应用进程和system_process进程。


在应用进程中,我们能获取到应用启动结果的状态码result,这个result用来告诉我们启动是否成功。涉及堆栈如下图(标记1)所示:


Activity类::startActivity() → startActivityForResult() 
→ Instrumentation类::execStartActivity(),返回值result则是ATMS
(ActivityTaskManagerService)执行的结果。


图片


如上图(标记2)标注,ATMS类::startActivity()方法,返回了result=3。


在system_process进程中,我们看一下这个result=3是怎样被赋值的。略去详细断点步骤,实际堆栈如下图(标注1)所示:


ATMS类::startActivity() → startActivityAsUser() 
→ ActivityStarter类::execute() 
→ executeRequest() 
→ startActivityUnchecked() 
→ startActivityInner() 
→ recycleTask(),在recycleTask()中返回了结果。


图片


如上图(标注2)所示,result在mMovedToFront=false时被赋值,即result=START_DELIVERED_TO_TOP=3,而START_SUCCESS=0才代表创建成功。


看一下源码中对START_DELIVERED_TO_TOP的说明,如下图:




Result for IActivityManaqer.startActivity: activity wasn't really started, but the given Intent was given to the existing top activity.


(IActivityManaqer.startActivityActivity的结果:Activity并未真正启动,但给定的Intent已提供给现有的顶层Activity。)



“Activity并未真正启动”——是的,因为可以复用


“给定的Intent已提供给现有的顶层Activity”——实际没有,顶层Activity3并没有收到任何回调,onNewIntent()未执行,甚至尝试通过Intent::putExtra()传入新的参数,Activity3也没有收到。官方文档又带给了我们一个疑问点?我们把这个问题记录下来,在后面分析。


满足什么条件,才会造成START_DELIVERED_TO_TOP的结果呢?笔者的思路是,通过与正常启动流程对比,找出差异点。


3.3 过程断点,探索启动流程


一般来说,在定位问题时,我们习惯通过结果反推原因,但反推的过程只能关注到与问题强关联的代码分支,并不能能使我们很好地了解全貌。


所以,本节内容我们通过顺序阅读的方法,正向介绍startActivity过程中与上述【场景01234】强相关的逻辑。再次简述一下:





  1. 【场景0】同一个Task内,从顶部B-3跳转B-2——停留在B-3




  2. 【场景1】从另一个Task内的C-4,跳转B-2——跳转到B-3




  3. 【场景2】把场景1中,C-4跳转B-2的方式改为setClassName()——创建新B-2实例




  4. 【场景3】把场景1中,C-4跳转B-2改为跳转B-3——创建新B-3实例




  5. 【场景4】给场景3中的B-3,指定taskAffinity——创建新Task和新B-3实例





3.3.1 流程源码概览


源码中,整个启动流程很长,涉及的方法和逻辑也很多,为了便于大家理清方法调用顺序,方便后续内容的阅读,笔者将本文涉及到的关键类及方法调用关系整理如下。


后续阅读中如果不清楚调用关系,可以返回这里查看:


// ActivityStarter.java

ActivityStarter::execute() {
executeRequest(intent) {
startActivityUnchecked() {
startActivityInner();
}
}
ActivityStarter::startActivityInner() {
setInitialState();
computeLaunchingTaskFlags();
Task targetTask = getReusableTask(){
findTask();
}
ActivityRecord targetTaskTop = targetTask.getTopNonFinishingActivity();
if (targetTaskTop != null) {
startResult = recycleTask() {
setTargetRootTaskIfNeeded();
complyActivityFlags();
if (mAddingToTask) {
return START_SUCCESS; //【场景2】【场景3】从recycleTask()返回
}
resumeFocusedTasksTopActivities()
return mMovedToFront ? START_TASK_TO_FRONT : START_DELIVERED_TO_TOP;//【场景1】【场景0】从recycleTask()返回
}
} else {
mAddingToTask = true;
}
if (startResult != START_SUCCESS) {
return startResult;//【场景1】【场景0】从startActivityInner()返回
}
deliverToCurrentTopIfNeeded();
resumeFocusedTasksTopActivities();
return startResult;
}

3.3.2 关键流程分析


(1)初始化


startActivityInner()是最主要的方法,如下列几张图所示,该方法会率先调用setInitialState(),初始化各类全局变量,并调用reset(),重置ActivityStarter中各种状态。


在此过程中,我们记下两个关键变量mMovedToFront和mAddingToTask,它们均在此被重置为false。


其中,mMovedToFront代表当Task可复用时,是否需要将目标Task移动到前台;mAddingToTask代表是否要将Activity加入到Task中。


图片


图片


图片


(2)计算确认启动时的flag


该步骤会通过computeLaunchingTaskFlags()方法,根据launchMode、来源Activity的属性等进行初步计算,确认LaunchFlags。


此处重点处理来源Activity为空的各类场景,与我们上文中的几种场景无关,故不再展开讲解。


(3)获取可以复用的Task


该步骤通过调用getReusableTask()实现,用来查找有没有可以复用的Task。


先说结论:场景0123中,都能获取到可以复用的Task,而场景4中,未获取到可复用的Task。


为什么场景4不可以复用?我们看一下getReusableTask()的关键实现。


图片


上图(标注1)中,putIntoExistingTask代表是否能放入已经存在的Task。当flag含有NEW_TASK且不含MULTIPLE_TASK时,或指定了singleInstance或singleTask的launchMode等条件,且没有指定Task或要求返回结果 时,场景01234均满足了条件。


然后,上图(标注2)通过findTask()查找可以复用的Task,并将过程中找到的栈顶Activity赋值给intentActivity。最终,上图(标注3)将intentActivity对应的Task作为结果。


findTask()是怎样查找哪个Task可以复用呢?


图片


主要是确认两种结果mIdealRecord——“理想的ActivityRecord”  和 mCandidateRecord——"候选的ActivityRecord",作为intentActivity,并取intentActivity对应的Task作为复用Task。


什么ActivityRecord才是理想或候选的ActivityRecord呢?在mTmpFindTaskResult.process()中确认。


图片


程序会将当前系统中所有的Task进行遍历,在每个Task中,进行如上图所示的工作——将Task的底部Activity realActivity与目标Activity cls进行对比。


场景012中,我们想跳转Activity2,即cls是Activity2,与Task底部的realActivity2相同,则将该Task顶部的Activity3 r作为“理想的Activity”;


场景3中,我们想跳转Activity3,即cls是Activity3,与Task底部的realActivity2不同,则进一步判断task底部Activity2与目标Activity3的栈亲和行,具有相同亲和性,则将Task的顶部Activity3作为“候选Activity”;


场景4中,所有条件都不满足,最终没能找到可复用的Task。在执行完getReusableTask()后将mAddingToTask赋值为true


由此,我们就能解释【场景4】中,新建了Task的现象。


(4)确定是否需要将目标Task移动到前台


如果存在可复用的Task,场景0123会执行recycleTask(),该方法中会相继进行几个操作:setTargetRootTaskIfNeeded()、complyActivityFlags()。


首先,程序会执行setTargetRootTaskIfNeeded(),用来确定是否需要将目标Task移动到前台,使用mMovedToFront作为标识。


图片


图片


在【场景123】中,来源Task和目标Task是不同的,differentTopTask为true,再经过一系列Task属性对比,能够得出mMovedToFront为true;


而场景0中,来源Task和目标Task相同,differentTopTask为false,mMovedToFront保持初始的false。


由此,我们就能解释【场景0】中,Task不会发生切换的现象。


(5)通过对比flag、Intent、Component等确认是否要将Activity加入到Task中


还是在【场景0123】中,recycleTask()会继续执行complyActivityFlags(),用来确认是否要将Activity加入到Task中,使用mAddingToTask作为标识。


该方法会对FLAG_ACTIVITY_NEW_TASK、FLAG_ACTIVITY_CLEAR_TASK、FLAG_ACTIVITY_CLEAR_TOP等诸多flag、Intent信息进行一系列判断。


图片


上图(标注1)中,会先判断后续是否需要重置Task,resetTask,判断条件则是FLAG_ACTIVITY_RESET_TASK_IF_NEEDED,显然,场景0123的resetTask都为false。继续执行。


接着,会有多种条件判断按顺序执行。


在【场景3】中,目标Component(mActivityComponent)是B-3,目标Task的realActivity则是B-2,两者不相同,进入了resetTask相关的判断(标注2)。


之前resetTask已经是false,故【场景3】的mAddingToTask脱离原始值,被置为true。


在【场景012】中,相对比的两个Activity都是B-2(标注3),可以进入下一级判断——isSameIntentFilter()。


图片


图片


图片


这一步判断的内容就很明显了,目标Activity2的已有Intent 与 新的Intent做对比。很显然,场景2中由于改为了setClassName跳转,Intent自然不一样了。


故【场景2】的mAddingToTask脱离原始值,被置为true。


总结看一下:



【场景123】的mMovedToFront最先被置为true,而【场景0】经历重重考验,保持初始值为false。


——这意味着当有可复用Task时,【场景0】不需要把Task切换到前列;【场景123】需要切换到目标Task。


【场景234】的mAddingToTask分别在不同阶段被置为true,而【场景01】,始终保持初始值false。


——这意味着,【场景234】需要将Activity加入到Task中,而【场景01】不再需要。



(6)实际启动Activity或直接返回结果


被启动的各个Activity会通过resumeFocusedTasksTopActivities()等一系列操作,开始真正的启动与生命周期的调用。


我们关于上述各个场景的探索已经得到答案,后续流程便不再关注。


四、问题修复及遗留问题解答


4.1 问题修复


既然前面总结了这么多必要条件,我们只需要破坏其中的某些条件,就可以修复业务中遇到的问题了,简单列举几个的方案。




  • 方案一:修改flag。B-3跳转B-2时,增加FLAG_ACTIVITY_CLEAR_TASK或FLAG_ACTIVITY_CLEAR_TOP,或者直接不设置flag。经验证可行。




  • 方案二:修改intent属性,即【场景2】。A-1通过action方式隐式跳转B-2,则B-3可以通过setClassName方式,或修改action内属性的方式跳转B-2。经验证可行。




  • 方案三:提前移除B-2。B-2跳转B-3时,finish掉B-2。需要注意的是,finish()要在startActivity()之前执行,以避免遗留的ActivityRecord和Intent信息对后续跳转的影响。尤其是当你把B-2作为自己应用的deeplink分发Activity时,更值得警惕。




4.2 遗留问题


还记得我们在文章开端的某个疑惑吗,为什么没有回调onNewIntent()?


onNewIntent() 会通过deliverNewIntent()触发,而deliverNewIntent()仅通过以下两个方法调用。


图片


complyActivityFlags()就是上文3.3.1.5中我们着重探讨的方法,可以发现complyActivityFlags()中所有可能调用deliverNewIntent()的条件均被完美避开了。


而deliverToCurrentTopIfNeeded()方法则如下图所示。


图片


mLaunchFlags和mLaunchMode,无法满足条件,导致dontStart为false,无缘deliverNewIntent()。


至此,onNewIntent()的问题得到解答。


五、结语


通过一系列场景假设,我们发现了许多出乎意料的现象:





  1. 文档提到FLAG_ACTIVITY_NEW_TASK等价于singleTask,与事实并不完全如此,只有与其他flag搭配才能达到相似的效果。这一flag的注释非常片面,甚至会引发误解,单一因素无法决定整体表现。




  2. 官方文档提到




  3. START_DELIVERED_TO_TOP会将新的Intent传递给顶层Activity,但事实上,并不是每一种START_DELIVERED_TO_TOP都会把新的Intent重新分发。




  4. 同一个栈底Activity,前后两次都通过action或都通过setClassName跳转到时,第二次跳转竟然会失败,而两次用不同方式跳转时,则会成功。




  5. 单纯使用FLAG_ACTIVITY_NEW_TASK时,跳栈底Activity和跳同栈内其他Activity的效果大相径庭。





业务中遇到的问题,归根结底就是对Android栈机制不够了解造成的。


在面对栈相关的编码时,开发者务必要想清楚,承担新开应用栈的Activty在应用全局承担怎样的使命,要对Task历史、flag属性、launchMode属性、Intent内容等全面评估,谨慎参考官方文档,才能避免栈陷阱,达成理想可靠的效果。


作者:vivo互联网技术
来源:juejin.cn/post/7223175468621774907
收起阅读 »

使用 Compose 时长两年半的 Android 开发者,又有什么新总结?

大家好啊,我是使用 Compose 时长两年半的 Android 开发者,今天来点大家想看的东西啊,距离上次文章也已经过去一段时间了,是时候再次总结一下了。 期间一直在实践着之前文章说的使用 Compose 编写业务逻辑,但随着业务逻辑和页面越来越复杂,在使用...
继续阅读 »

大家好啊,我是使用 Compose 时长两年半的 Android 开发者,今天来点大家想看的东西啊,距离上次文章也已经过去一段时间了,是时候再次总结一下了。

期间一直在实践着之前文章说的使用 Compose 编写业务逻辑,但随着业务逻辑和页面越来越复杂,在使用的过程中也遇到了一些问题。


Compose Presenter


上一篇文章中有提到的用 Compose 写业务逻辑是这样写的:


@Composable
fun Presenter(
action: Flow<Action>,
)
: State {
var count by remember { mutableStateOf(0) }

action.collectAction {
when (this) {
Action.Increment -> count++
Action.Decrement -> count--
}
}

return State("Clicked $count times")
}

优点在之前的文章中也提到过了,这里就不再赘述,说一下这段时间实践下来发现的缺点:



  • 业务复杂后会拆分出非常多的 Presenter,导致在最后组合 Presenter 的时候会非常复杂,特别是对于子 Presenter 的 Action 处理

  • 如果 Presenter 有 Action,这样的写法并不能很好的处理 early return。


一个一个说


组合 Action 处理


每调用一个带 Action 的子 Presenter,就至少需要新建一个 Channel 以及对应的 Flow,并且需要增加一个对应的 Action 处理,举个例子


@Composable
fun FooPresenter(
action: Flow<FooAction>
)
: FooState {
// ...
// 创建子 Presenter 需要的 Channel 和 Flow
val channel = remember { Channel<Action>(Channel.UNLIMITED) }
val flow = remember { channel.consumeAsFlow() }
val state = Presenter(flow)
LaunchedEffect(Unit) {
action.collect {
when (it){
// 处理并传递 Action 到子 Presenter中
is FooAction.Bar -> channel.trySend(it.action)
}
}
}

// ...

return FooState(
state = state,
// ...
)
}

如果页面和业务逻辑复杂之后,组合 Presenter 会带来非常多的冗余代码,这些代码只是做桥接,没有任何的业务逻辑。并且在 Compose UI 中发起子 Presenter 的 Action 时也需要桥接调用,最后很容易导致冗余代码过多。


Early return


如果一个 Presenter 中有 Action 处理,那么需要非常小心的处理 early return,例如:


@Composable
fun Presenter(
action: Flow<Action>,
)
: State {
var count by remember { mutableStateOf(0) }

if (count == 10) {
return State("Woohoo")
}

action.collectAction {
when (this) {
Action.Increment -> count++
Action.Decrement -> count--
}
}

return State("Clicked $count times")
}

count == 10 时会直接 return,跳过后面的 Action 事件订阅,造成后续的事件永远无法触发。所以所有的 return 必须在 Action 事件订阅之后。


当业务复杂之后,上面两个缺点就成为了最大的痛点。


解决方案


有一天半夜我看到了 Slack 的 Circuit 是这样写的:


object CounterScreen : Screen {
data class CounterState(
val count: Int,
val eventSink: (CounterEvent) -> Unit,
) : CircuitUiState
sealed interface CounterEvent : CircuitUiEvent {
object Increment : CounterEvent
object Decrement : CounterEvent
}
}

@Composable
fun CounterPresenter(): CounterState {
var count by rememberSaveable { mutableStateOf(0) }

return CounterState(count) { event ->
when (event) {
is CounterEvent.Increment -> count++
is CounterEvent.Decrement -> count--
}
}
}

这 Action 原来还可以在 State 里面以 Callback 的形式处理,瞬间两眼放光,一次性解决了两个痛点:



  • 子 Presenter 不再需要 Action Flow 作为参数,事件处理直接在 State Callback 里面完成,减少了大量的冗余代码

  • 在 return 的时候就附带 Action 处理,early return 不再是问题。


好了,之后的 Presenter 就这么写了。期待再过半年的我能再总结出来一些坑吧。


为什么 Early return 会导致事件订阅失效


可能有人会好奇这一点,Presenter 内不是已经订阅过了吗,怎么还会失效。

我们还是从 Compose 的原理开始说起吧。

先免责声明一下:以下是我对 Compose 实现原理的理解,难免会有错误的地方。

网上讲述 Compose 原理的文章都非常多了,这里就不再赘述,核心思想是:Compose 的状态由一个 SlotTable 维护。

还是结合 Early return 的例子来说,我稍微画了一下 SlotTable 在不同时候的状态:


@Composable                                          
fun Presenter(
action: Flow<Action>, count != 10 | count == 10
)
: State {
var count by remember { mutableStateOf(0) } | State | State |
if (count == 10) { | State | State |
return State("Woohoo") | Empty | State |
} | | |
action.collectAction { | State | Empty |
when (this) { | State | Empty |
Action.Increment -> count++ | State | Empty |
Action.Decrement -> count-- | State | Empty |
} | | |
} | | |
return State("Clicked $count times") | State | Empty |
}

count != 10 的时候,SlotTable 内部保存的状态是包含 Action 事件订阅的,但是当 count == 10 之后,SlotTable 就会清空所有之后语句对应的状态,而之后正好包含了 Action 事件订阅,所以订阅就失效了。

我觉得这是 Compose 和 React Hooks 又一个非常相似的地方,React Hooks 的状态也是由一个列表维护的

再举一个例子:


@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Column {
var boolean by remember {
mutableStateOf(true)
}
Text(
text = "Hello $name!",
modifier = modifier
)
Button(onClick = {
boolean = !boolean
}) {
Text(text = "Hide counter")
}

if (boolean) {
var a by remember {
mutableStateOf(0)
}
Button(onClick = {
a++
}) {
Text(text = "Add")
}
Text(text = "a = $a")
}
}
}

这段代码大家也可以试试。当我做如下操作时:



  • 点击 Add 按钮,此时显示 a = 1

  • 点击 Hide counter 按钮,此时 counter 被隐藏

  • 再次点击 Hide counter 按钮,此时 counter 显示,其中 a = 0


因为当 counter 被隐藏时,包括变量 a 在内所有的状态都从 SlotTable 里面清除了,那么新出现的变量 a 其实是完全一个新初始化的一个变量,和之前的变量没有任何关系。


总结


过了大半年,也算是对 Compose 内部实现原理又有了一个非常深刻的认识,特别是当我用 C# 自己实现一遍声明式 UI 之后,然后再次感叹:SlotTable 真是天才般的解决思路,本质上并不复杂,但大大简化了声明式

作者:Tlaster
来源:juejin.cn/post/7222897518501543991
UI 的状态管理。

收起阅读 »

Android 获取短信验证码并自动填充

Android 获取短信验证码并自动填充(踩坑小米、荣耀、OPPO) 前言 最近弄了个短信自动填充功能,一开始觉得很简单,不就是动态注册个广播接收器去监听短信消息不就可以了吗?结果没这么简单,问题就出在机型的适配上。小米的短信权限、荣耀、OPPO的短信监听都是...
继续阅读 »

Android 获取短信验证码并自动填充(踩坑小米、荣耀、OPPO)


前言


最近弄了个短信自动填充功能,一开始觉得很简单,不就是动态注册个广播接收器去监听短信消息不就可以了吗?结果没这么简单,问题就出在机型的适配上。小米的短信权限、荣耀、OPPO的短信监听都是坑,暂时就用这三个手机测了,其他的遇到了再补充。


下面简单讲讲:


权限


申请权限


短信属于隐私权限,Android 6.0后需要动态申请权限。首先在manifest里面注册权限:


<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />

在需要用的地方,动态申请下:


String[] smsPermission = {Manifest.permission.READ_SMS, Manifest.permission.RECEIVE_SMS};

小米短信权限问题


本来这样权限问题就搞定了,但是在小米手机上就不行。小米手机会把短信归类到通知类权限里:
pic


在 ContextCompat.checkSelfPermission 的时候会直接返回true,并且不会弹出权限对话框,而是在实际使用的时候才会咨询用户,按理说好像和我们逻辑没有冲突,但是在使用receiver进行监听前,不是得确保有权限么?实际效果也是,在没有权限时,不能获取到短信的广播。


小米短信权限解决


在网上找了找办法,好像也没多少博文,但是大致有了思路:不是用的时候才申请么?那我就先用一下,再去用receiver监听。下面是方法:


// 读取一下试试,能读取到就有权限
boolean flag = false;
try {
Uri uri = Uri.parse("content://sms/inbox");
ContentResolver cr = context.getContentResolver();
String[] projection = new String[]{"_id"};
Cursor cur = cr.query(uri, projection, null, null, "date desc");
if (null != cur) {
cur.close();
}
lag = true;
}catch (Exception e) {
e.printStackTrace();
}

这里仅针对小米手机啊,对小米手机的判断我只是用 android.os.Build.MANUFACTURER 简单判断了下,如果有更高要求请查找资料。


使用Receiver进行监听


编写SmsReceiver


这里也是网上随便找了个代码,能用,但是在荣耀手机上却是偶尔能收到一次,后面几次就收不到了,打了log也没进入到onReceive中,这就很离奇了,排查了很久。同样的代码,在小米手机上是没问题的,那就只可能是适配问题了。


import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.telephony.SmsMessage;
import android.util.Log;

public class SmsReceiver extends BroadcastReceiver {

@Override
public void onReceive(Context context, Intent intent) {
//Toast.makeText(context, "收到信息", Toast.LENGTH_LONG).show();
Log.d("SmsReceiver", "onReceive: " + intent.getAction());
if(intent.getAction().equals("android.provider.Telephony.SMS_RECEIVED")){
//intent.getExtras()方法就是从过滤后的意图中获取携带的数据,
// 这里携带的是以“pdus”为key、短信内容为value的键值对
// android设备接收到的SMS是pdu形式的
Bundle bundle = intent.getExtras();
SmsMessage msg;
if (null != bundle){
//生成一个数组,将短信内容赋值进去
Object[] smsObg = (Object[]) bundle.get("pdus");
//遍历pdus数组,将每一次访问得到的数据方法object中
for (Object object:smsObg){
//获取短信
msg = SmsMessage.createFromPdu((byte[])object);
//获取短信内容
String content = msg.getDisplayMessageBody();
Log.d("SmsReceiver", "onReceive: content = " + content);
//获取短信发送方地址
String from = msg.getOriginatingAddress();
Log.d("SmsReceiver", "onReceive: from = " + from);

// TODO ...
}
}
}
}
}

使用方法:


// 使用广播进行监听
IntentFilter smsFilter = new IntentFilter();
smsFilter.addAction("android.provider.Telephony.SMS_RECEIVED");
smsFilter.addAction("android.provider.Telephony.SMS_DELIVER");
if (smsReceiver == null) {
smsReceiver = new SmsReceiver();
}
smsReceiver.setCallbackContext(callbackContext);
context.registerReceiver(smsReceiver, smsFilter);

接触监听,最好在收到短信的时候就取消注册广播:


context.unregisterReceiver(smsReceiver);

解决OPPO手机无法接收短信广播问题


本来小米荣耀都搞定了,给测试一测,结果又不行了。收不到广播,用下面的ContentObserver还总拿不到对的数据。找了下资料,发现OPPO手机需要在短信APP进行设置。


ps. 后面发现华为、荣耀都是这样,会对验证码进行保护。可以使用ContentObserver 监听,能触发onChange,但是拿不到Uri,不过可以使用查询,拿到倒叙的第一条数据,取出其中的date属性,比对监听时的时间,如果短信两分钟有效,那就看看第一条数据是不是在两分钟内,如果不是,那就是没拿到,问题就出在用户开启了短信验证码保护,可以提示用户自行输入验证码(毕竟这个不是我们的锅)。


解决方法:
在短信 -> 短信设置里面 -> 打开禁止后台应用读取验证码


解决荣耀无法连续监听短信的问题


既然上面的方法没用了,只能找新的办法喽,网上很多提供了两种办法,第二种就是通过ContentResolver去监听短信添加的更新动作,其实也和广播类似,代码如下:


import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.provider.Telephony;
import android.util.Log;

import androidx.annotation.RequiresApi;

@RequiresApi(api = Build.VERSION_CODES.KITKAT)
public class ReadSmsObserver extends ContentObserver {

private final Context context;

public ReadSmsObserver(Handler handler, Context context) {
super(handler);
this.context = context;
}

private static final String SMS_INBOX_URI = "content://sms/inbox";//API level>=23,可直接使用Telephony.Sms.Inbox.CONTENT_URI,用于获取cusor
// private static final String SMS_URI = "content://sms";//API level>=23,可直接使用Telephony.Sms.CONTENT_URI,用于注册内容观察者
private static final String[] PROJECTION = new String[]{
Telephony.Sms._ID,
Telephony.Sms.ADDRESS,
Telephony.Sms.BODY,
Telephony.Sms.DATE
};

@Override
public void onChange(boolean selfChange, Uri uri) {
super.onChange(selfChange);
Log.d("ReadSmsObserver", "onChange: ");
// 当收到短信时调用一次,当短信显示到屏幕上时又调用一次,所以需要return掉一次调用
if(uri.toString().equals("content://sms/raw")){
return;
}
// 读取短信收件箱,只读取未读短信,即read=0,并按照默认排序
Cursor cursor = context.getContentResolver().query(Uri.parse(SMS_INBOX_URI), PROJECTION,
Telephony.Sms.READ + "=?", new String[]{"0"}, Telephony.Sms.Inbox.DEFAULT_SORT_ORDER);
if (cursor == null) return;
// 获取倒序的第一条短信
if (cursor.moveToFirst()) {
// 读取短信发送人
String address = cursor.getString(cursor.getColumnIndex(Telephony.Sms.ADDRESS));
Log.d("ReadSmsObserver", "onChange: address = " + address);
// 读取短息内容
String smsBody = cursor.getString(cursor.getColumnIndex(Telephony.Sms.BODY));
Log.d("ReadSmsObserver", "onChange: smsBody = " + smsBody);

// TODO 传递出去,最好切下线程

}
// 关闭cursor的方法
cursor.close();
}
}

用的时候要注册和取消注册:


// 使用ContentResolver进行监听
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
if (smsObserver == null) {
smsObserver = new ReadSmsObserver(new SmsHandler(), context);
}
smsObserver.setCallbackContext(callbackContext);
context.getContentResolver().registerContentObserver(
Uri.parse("content://sms/"), true, smsObserver);
}

取消注册:


context.getContentResolver().unregisterContentObserver(smsObserver);

解决OPPO手机无法拿到最新短信问题


很神奇啊,每次使用ContentObserver去监听短信变化,明明onChange触发了,但是去拿短信就是拿不到最新的,开了上面的设置也不行,弄了好久。


最后想的解决办法是,两种方式同时监听,在onChange触发后等待三秒钟(开始试了1s还不行),看看有没有onReceive,如果有就直接使用onReceive的短信,如果没有再验证onChange内拿到的短信,看看是不是有效时间内的,连倒叙第一个都在有效时间外,那就是没拿到了,直接舍弃了。


PS. 后续更新,感觉这些问题都可能是手机系统开了短信验证码保护。


思路是这样,做起来不麻烦,用个handler就可以解决,读者自行处理吧。


结语


这些机型的兼容性搞起来真头疼,上面两种方法可以兼容起来使用,收到一条短信后直接取消注册就行了。


作者:方大可
来源:juejin.cn/post/7222897518501003319
收起阅读 »

Android 应用架构指南

一 简介 遵循摩尔定律,手机终端随着每年的更新换代,其性能也飞速增长。依附于此的 Android 应用规模也愈发复杂。截止 2023 年 4 月,最新版本 8.0.32 微信 apk 大小为 238MB,而对比 2011 年微信 1.0 版本 apk 包大小仅...
继续阅读 »

一 简介


遵循摩尔定律,手机终端随着每年的更新换代,其性能也飞速增长。依附于此的 Android 应用规模也愈发复杂。截止 2023 年 4 月,最新版本 8.0.32 微信 apk 大小为 238MB,而对比 2011 年微信 1.0 版本 apk 包大小仅为 457KB,短短 12 年增长了 533 倍。


image.png


随着应用规模增大,功能扩展困难、测试规模大及并行开发难等问题愈发突出。为了从根本上解决这些问题,就需要对应用进行重构,此时应用架构设计就显得尤为重要。


Android 应用架构设计三步走:



  • 现象: 程序代码和资源越来越多,代码耦合度高,扩展、维护及测试困难

  • 手段: 分离代码,提炼模式

  • 结果: 确保应用的稳健性、可测试性和可维护性


下文主要介绍三种常见的架构设计模式 MVC、MVP、MVVM


二 MVC


MVC 全称 Model View Controller,是模型(Model)-视图(View)-控制器(Controller)的缩写。


image (1).png



  • View: 负责界面数据的展示,与用户进行交互;对应于 xml布局文件和 java 代码动态 view 部分;

  • Controller: 负责逻辑业务的处理;

  • Model: 负责管理业务数据逻辑,如网络请求、数据库处理和 I/O 的操作等。


MVC 初步解决了 Activity 代码太多的问题,但 Activity 天然不可避免要处理 UI,也要处理用户交互,导致 Activity 里糅合了视图和业务的代码,分离程度不够。


优点:



  • 耦合性较低,生命周期成本低,部署快,适用于快速开发的小型项目


缺点:



  • 不适合中大型项目,View 层和 Controller 层连接过于紧密

  • View 层对 Model 层的访问效率低

  • 一般的高级 UI 页面工具和构造器不支持 MVC 模式


三 MVP


为了将 Activity 中的表现逻辑彻底分离出来,业界提出了 MVP 的设计。


MVP 全称 Model View Controller,是模型(Model)-视图(View)-呈现者(Presenter)的缩写。


image (2).png



  • View: 只负责显示 UI,只与 Presenter 层交互,与 Model 层没有耦合。对应于 ActivityXML

  • Presenter: 负责处理业务逻辑,通过接口回调 View 层;

  • Model: 负责管理业务数据逻辑,如网络请求、数据库处理和 I/O 的操作等。


在 MVP 模式中,Model 与 View 无法直接进行交互,所以 Presenter 层会从 Model 层获得数据,适当处理后交给 View 层进行显示。在 MVP 模式中,Presenter 层将 View 层和 Model 层进行隔离,使 View 和 Model 之间不存在耦合,同时将业务逻辑从 View 层剥离。


优点:



  • 逻辑结构清晰,View 层代码不再臃肿,所有的交互都发生在 Presenter 内部


缺点:



  • View 层和 Presenter 层的交互需要定义接口方法,当交互非常复杂时,需要定义很多接口方法和回调方法,增加维护复杂度

  • Presenter 层 持有 View 层的引用,当用户关闭了 View 层,但 Model 层仍然在进行耗时操作,会有内存泄漏风险


四 MVVM


MVVM 全称 Model View ViewModel,模式改动在于中间的 Presenter 改为 ViewModel,MVVM 同样将代码划分为三个部分:


image (3).png



  • View: 与 MVP 中 View 的概念相同;

  • ViewModel: 连接 View 与 Model 的中间桥梁,ViewModel 与 Model 直接交互,通过 DataBinding 将数据变化反应给 View;

  • Model: 负责管理业务数据逻辑,如网络请求、数据库处理和 I/O 的操作等。


在实现细节上,View 和 Presenter 从双向依赖变成 View 可以向 ViewModel 发指令,但 ViewModel 不会直接向 View 回调,而是让 View 通过观察者的模式去监听数据的变化,有效规避了 MVP 双向依赖的缺点。


优点:



  • 模块间充分解耦,结构清晰,职责划分清晰

  • 在 MVP 的基础上,MVVM 把 View 和 ViewModel 也进行了解耦


缺点:



  • View 与 ViewModel 的交互分散,缺少唯一修改源,不易于追踪

  • 复杂的页面需要定义多个 MutableLiveData,并且都需要暴露为不可变的 LiveData


五 参考文献


Jetpack 系列(5)—— Android UI 架构演进:从 MVC 到 MVP、MVVM、MVI


MVC、MVP、MVVM,我到底该怎么选?


作者:话唠扇贝
来源:juejin.cn/post/7220985690795524156
收起阅读 »

Android Watchdog 狗子到底做了啥

前言 有一定开发经验的或多或少有听过Watchdog,那什么是Watchdog呢?Watchdog又称看门狗,看门狗是育碧开发的一款游戏,目前已出到《看门狗军团》。开个玩笑,Watchdog是什么,为什么会设计出它,听到它也许能快速联想到死锁,它是一个由Sys...
继续阅读 »

前言


有一定开发经验的或多或少有听过Watchdog,那什么是Watchdog呢?Watchdog又称看门狗,看门狗是育碧开发的一款游戏,目前已出到《看门狗军团》。开个玩笑,Watchdog是什么,为什么会设计出它,听到它也许能快速联想到死锁,它是一个由SystemServer启动的服务,本质上是一个线程,这次我们就从源码的角度分析,它到底做了啥。


准备


当然看源码前还需要做一些准备,不然你可能会直接看不懂。首先,Handler机制要了解。锁和死锁的概念都要了解,但我感觉应都是了解了死锁之后才听说Watchdog的。SystemServer至少得知道是做什么的。Monitor的设计思想懂更好,不懂在这里也不会影响看主流程。


这里源码有两个重要的类HandlerChecker和Monitor,简单了解它的流程大概就是用handler发消息给监控的线程,然后计时,如果30秒内有收到消息,什么都不管,如果超过30秒没收到但60秒内有收到,就打印,如果60秒内没收到消息,就炸。


主要流程源码解析


PS:源码是29的


首先在SystemServer中创建并启动这个线程,你也可以说启动这个服务


private void startBootstrapServices() {
......
final Watchdog watchdog = Watchdog.getInstance();
watchdog.start();
......
watchdog.init(mSystemContext, mActivityManagerService);
......
}

单例,我们看看构造方法


private Watchdog() {
super("watchdog");
mMonitorChecker = new HandlerChecker(FgThread.getHandler(),
"foreground thread", DEFAULT_TIMEOUT);
mHandlerCheckers.add(mMonitorChecker);
// Add checker for main thread. We only do a quick check since there
// can be UI running on the thread.
mHandlerCheckers.add(new HandlerChecker(new Handler(Looper.getMainLooper()),
"main thread", DEFAULT_TIMEOUT));
// Add checker for shared UI thread.
mHandlerCheckers.add(new HandlerChecker(UiThread.getHandler(),
"ui thread", DEFAULT_TIMEOUT));
// And also check IO thread.
mHandlerCheckers.add(new HandlerChecker(IoThread.getHandler(),
"i/o thread", DEFAULT_TIMEOUT));
// And the display thread.
mHandlerCheckers.add(new HandlerChecker(DisplayThread.getHandler(),
"display thread", DEFAULT_TIMEOUT));
// And the animation thread.
mHandlerCheckers.add(new HandlerChecker(AnimationThread.getHandler(),
"animation thread", DEFAULT_TIMEOUT));
// And the surface animation thread.
mHandlerCheckers.add(new HandlerChecker(SurfaceAnimationThread.getHandler(),
"surface animation thread", DEFAULT_TIMEOUT));

// 看主流程的话,Binder threads可以先不用管
// Initialize monitor for Binder threads.
addMonitor(new BinderThreadMonitor());

mOpenFdMonitor = OpenFdMonitor.create();

// See the notes on DEFAULT_TIMEOUT.
assert DB ||
DEFAULT_TIMEOUT > ZygoteConnectionConstants.WRAPPED_PID_TIMEOUT_MILLIS;
}

看主流程的话,Binder threads可以先不用管,精讲。可以明显的看到这里就是把一些重要的线程的handler去创建HandlerChecker对象放到数组mHandlerCheckers中。简单理解成创建一个对象去集合这些线程的信息,并且Watchdog有个线程信息对象数组。


public final class HandlerChecker implements Runnable {
private final Handler mHandler;
private final String mName;
private final long mWaitMax;
private final ArrayList<Monitor> mMonitors = new ArrayList<Monitor>();
private final ArrayList<Monitor> mMonitorQueue = new ArrayList<Monitor>();
private boolean mCompleted;
private Monitor mCurrentMonitor;
private long mStartTime;
private int mPauseCount;

HandlerChecker(Handler handler, String name, long waitMaxMillis) {
mHandler = handler;
mName = name;
mWaitMax = waitMaxMillis;
mCompleted = true;
}

......
}

然后我们先看init方法


public void init(Context context, ActivityManagerService activity) {
mActivity = activity;
context.registerReceiver(new RebootRequestReceiver(),
new IntentFilter(Intent.ACTION_REBOOT),
android.Manifest.permission.REBOOT, null);
}

final class RebootRequestReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context c, Intent intent) {
if (intent.getIntExtra("nowait", 0) != 0) {
rebootSystem("Received ACTION_REBOOT broadcast");
return;
}
Slog.w(TAG, "Unsupported ACTION_REBOOT broadcast: " + intent);
}
}

void rebootSystem(String reason) {
Slog.i(TAG, "Rebooting system because: " + reason);
IPowerManager pms = (IPowerManager)ServiceManager.getService(Context.POWER_SERVICE);
try {
pms.reboot(false, reason, false);
} catch (RemoteException ex) {
}
}

明显能看出是重启的操作,注册广播,接收到这个广播之后重启。这个不是主流程,简单看看就行。


来了,重点来了,开始讲主流程。Watchdog是继承Thread,所以上面调start方法会执行到这里的run方法,润起来


@Override
public void run() {
boolean waitedHalf = false;
while (true) {
......
synchronized (this) {
long timeout = CHECK_INTERVAL;
for (int i=0; i<mHandlerCheckers.size(); i++) {
HandlerChecker hc = mHandlerCheckers.get(i);
hc.scheduleCheckLocked();
}

......
long start = SystemClock.uptimeMillis();
while (timeout > 0) {
if (Debug.isDebuggerConnected()) {
debuggerWasConnected = 2;
}
try {
wait(timeout);
} catch (InterruptedException e) {
Log.wtf(TAG, e);
}
......
timeout = CHECK_INTERVAL - (SystemClock.uptimeMillis() - start);
}

......

if (!fdLimitTriggered) {
// 直接先理解成正常情况下会进这里
final int waitState = evaluateCheckerCompletionLocked();
if (waitState == COMPLETED) {
// The monitors have returned; reset
waitedHalf = false;
continue;
} else if (waitState == WAITING) {
// still waiting but within their configured intervals; back off and recheck
continue;
} else if (waitState == WAITED_HALF) {
if (!waitedHalf) {
Slog.i(TAG, "WAITED_HALF");
// We've waited half the deadlock-detection interval. Pull a stack
// trace and wait another half.
ArrayList<Integer> pids = new ArrayList<Integer>();
pids.add(Process.myPid());
ActivityManagerService.dumpStackTraces(pids, null, null,
getInterestingNativePids());
waitedHalf = true;
}
continue;
}

// something is overdue!
blockedCheckers = getBlockedCheckersLocked();
subject = describeCheckersLocked(blockedCheckers);
} else {
......
}
......
}

// 扒日志然后退出
......

waitedHalf = false;
}
}

把一些代码屏蔽了,这样看会比较舒服,主要是怕代码太多劝退人。


首先死循环,然后遍历mHandlerCheckers,就是我们在构造方法那创建的HandlerCheckers数组,遍历数组调用HandlerChecker的scheduleCheckLocked方法


public void scheduleCheckLocked() {
if (mCompleted) {
// Safe to update monitors in queue, Handler is not in the middle of work
mMonitors.addAll(mMonitorQueue);
mMonitorQueue.clear();
}
if ((mMonitors.size() == 0 && mHandler.getLooper().getQueue().isPolling())
|| (mPauseCount > 0)) {
mCompleted = true;
return;
}
if (!mCompleted) {
// we already have a check in flight, so no need
return;
}

mCompleted = false;
mCurrentMonitor = null;
mStartTime = SystemClock.uptimeMillis();
mHandler.postAtFrontOfQueue(this);
}

HandlerChecker内有个Monitor数组,Monitor是一个接口,然后外部一些类实现这个接口实现monitor方法,这个后面会说。


public interface Monitor {
void monitor();
}

这个mCompleted默认是true


if (mCompleted) {
// Safe to update monitors in queue, Handler is not in the middle of work
mMonitors.addAll(mMonitorQueue);
mMonitorQueue.clear();
}

把mMonitorQueue数组中的元素移动到mMonitors中。这个什么意思呢?有点难解释,这样,你想想,Watchdog的run方法中是一个死循环不断调用scheduleCheckLocked方法吧,我这段代码的逻辑操作用到mMonitors,那我不能在我操作的同时你添加元素进来吧,那不就乱套了,所以如果有新加Monitor的话,就只能在每次循环执行这段逻辑开始的时候,添加进了。这段代码是这个意思。


if ((mMonitors.size() == 0 && mHandler.getLooper().getQueue().isPolling())
|| (mPauseCount > 0)) {
mCompleted = true;
return;
}

如果mMonitors数组不为空,并且这个handler的messageQueue正在工作,你理解这个isPolling方法是正在工作就行,把mCompleted状态设true,然后直接结束这个方法,这什么意思呢?你想想,我的目的是要判断这个线程是否卡住了,那我messageQueue正在工作说明没卡住嘛。看不懂这里的话可以再理解理解handler机制。


假如没有,我们往下走


// 先不管,先标记这里是A1点
if (!mCompleted) {
// we already have a check in flight, so no need
return;
}

这段不用管它,从上面可以看出这里mCompleted是true,往下走,我们先标记这里是A1点,后面流程会执行回来。


mCompleted = false;
mCurrentMonitor = null;
mStartTime = SystemClock.uptimeMillis();
mHandler.postAtFrontOfQueue(this);

把mCompleted状态设为false,mStartTime用来记录当前时间作为我们整个判断的起始时间,用handler发消息postAtFrontOfQueue。然后这里传this,就会调用到这个HandlerChecker自身的run方法。


好了,考验功底的地方,这个run方法是执行在哪个线程中?


@Override
public void run() {
final int size = mMonitors.size();
for (int i = 0 ; i < size ; i++) {
synchronized (Watchdog.this) {
mCurrentMonitor = mMonitors.get(i);
}
mCurrentMonitor.monitor();
}

synchronized (Watchdog.this) {
mCompleted = true;
mCurrentMonitor = null;
}
}

这里是拿mMonitors数组循环遍历然后执行monitor方法,其实这个就是判断死锁的逻辑,你先简单理解成如果发生死锁,这个mCurrentMonitor.monitor就会卡住在这里,不会往下执行mCompleted = true;


handler发消息的同时run方法其实已经是切线程了 ,所以Watchdog线程会继续往下执行,我们回到Watchdog的run方法


long start = SystemClock.uptimeMillis();
while (timeout > 0) {
if (Debug.isDebuggerConnected()) {
debuggerWasConnected = 2;
}
try {
wait(timeout);
// Note: mHandlerCheckers and mMonitorChecker may have changed after waiting
} catch (InterruptedException e) {
Log.wtf(TAG, e);
}
if (Debug.isDebuggerConnected()) {
debuggerWasConnected = 2;
}
timeout = CHECK_INTERVAL - (SystemClock.uptimeMillis() - start);
}

wait(timeout);进行线程阻塞,线线程生命周期变成TIME_WAITTING,timeout在这里是CHECK_INTERVAL,就是30秒。


30秒之后进入这个流程


final int waitState = evaluateCheckerCompletionLocked();
if (waitState == COMPLETED) {
// The monitors have returned; reset
waitedHalf = false;
continue;
} else if (waitState == WAITING) {
// still waiting but within their configured intervals; back off and recheck
continue;
} else if (waitState == WAITED_HALF) {
if (!waitedHalf) {
Slog.i(TAG, "WAITED_HALF");
// We've waited half the deadlock-detection interval. Pull a stack
// trace and wait another half.
ArrayList<Integer> pids = new ArrayList<Integer>();
pids.add(Process.myPid());
ActivityManagerService.dumpStackTraces(pids, null, null,
getInterestingNativePids());
waitedHalf = true;
}
continue;
}

private int evaluateCheckerCompletionLocked() {
int state = COMPLETED;
for (int i=0; i<mHandlerCheckers.size(); i++) {
HandlerChecker hc = mHandlerCheckers.get(i);
state = Math.max(state, hc.getCompletionStateLocked());
}
return state;
}

evaluateCheckerCompletionLocked就是轮询调用HandlerChecker的getCompletionStateLocked方法,然后根据全部的状态,返回一个最终的状态, 我后面会解释状态。 ,先看getCompletionStateLocked方法 (可以想想这个方法是在哪个线程中执行的)


public int getCompletionStateLocked() {
if (mCompleted) {
return COMPLETED;
} else {
long latency = SystemClock.uptimeMillis() - mStartTime;
if (latency < mWaitMax/2) {
return WAITING;
} else if (latency < mWaitMax) {
return WAITED_HALF;
}
}
return OVERDUE;
}

其实HandlerChecker的getCompletionStateLocked方法对应scheduleCheckLocked方法。


判断mCompleted为true的话返回COMPLETED状态。COMPLETED状态就是正常,从上面看出正常情况下都会返回true,只有在那条线程还卡住的情况下,返回false。什么叫“那条线程还卡住的情况”,我们在scheduleCheckLocked方法postAtFrontOfQueue之后有两种情况会出现卡住。


(1)这个Handler的MessageQueue的前一个Message一直在处理中,导致postAtFrontOfQueue在这30秒之后都没执行到run方法

(2)run方法中的mCurrentMonitor.monitor()一直卡住,30秒了还是卡住,准确来说是竞争锁处于BLOCKED状态,没能执行到mCompleted = true


这两种情况下mCompleted都为false,然后latency来计算这段时间,如果小于30秒,返回WAITING状态,如果大于30秒小于60秒,返回WAITED_HALF状态,如果大于60秒返回OVERDUE状态。


然后看回evaluateCheckerCompletionLocked方法state = Math.max(state, hc.getCompletionStateLocked());这句代码的意思就是因为我们是检测多条线程的嘛,这么多条线程里面,但凡有一条不正常,最终这个方法都返回最不正常的那个状态。


假如返回COMPLETED状态,说明这轮循环正常,开始下一轮循环判断,假如返回WAITING, 下一轮执行到HandlerChecker的scheduleCheckLocked方法的时候,就会走点A1的判断


if (!mCompleted) {
// we already have a check in flight, so no need
return;
}

这种情况下就不用重复发消息和记录开始时间。当返回WAITED_HALF的情况下调用dumpStackTraces收集信息,当返回OVERDUE的情况下就直接收集信息然后重启了。下面是收集信息重启的源码,不想看可以跳过。



......

// If we got here, that means that the system is most likely hung.
// First collect stack traces from all threads of the system process.
// Then kill this process so that the system will restart.
EventLog.writeEvent(EventLogTags.WATCHDOG, subject);

ArrayList<Integer> pids = new ArrayList<>();
pids.add(Process.myPid());
if (mPhonePid > 0) pids.add(mPhonePid);

final File stack = ActivityManagerService.dumpStackTraces(
pids, null, null, getInterestingNativePids());

// Give some extra time to make sure the stack traces get written.
// The system's been hanging for a minute, another second or two won't hurt much.
SystemClock.sleep(5000);

// Trigger the kernel to dump all blocked threads, and backtraces on all CPUs to the kernel log
doSysRq('w');
doSysRq('l');

// Try to add the error to the dropbox, but assuming that the ActivityManager
// itself may be deadlocked. (which has happened, causing this statement to
// deadlock and the watchdog as a whole to be ineffective)
Thread dropboxThread = new Thread("watchdogWriteToDropbox") {
public void run() {
// If a watched thread hangs before init() is called, we don't have a
// valid mActivity. So we can't log the error to dropbox.
if (mActivity != null) {
mActivity.addErrorToDropBox(
"watchdog", null, "system_server", null, null, null,
subject, null, stack, null);
}
StatsLog.write(StatsLog.SYSTEM_SERVER_WATCHDOG_OCCURRED, subject);
}
};
dropboxThread.start();
try {
dropboxThread.join(2000); // wait up to 2 seconds for it to return.
} catch (InterruptedException ignored) {}

IActivityController controller;
synchronized (this) {
controller = mController;
}
if (controller != null) {
Slog.i(TAG, "Reporting stuck state to activity controller");
try {
Binder.setDumpDisabled("Service dumps disabled due to hung system process.");
// 1 = keep waiting, -1 = kill system
int res = controller.systemNotResponding(subject);
if (res >= 0) {
Slog.i(TAG, "Activity controller requested to coninue to wait");
waitedHalf = false;
continue;
}
} catch (RemoteException e) {
}
}

// Only kill the process if the debugger is not attached.
if (Debug.isDebuggerConnected()) {
debuggerWasConnected = 2;
}
if (debuggerWasConnected >= 2) {
Slog.w(TAG, "Debugger connected: Watchdog is *not* killing the system process");
} else if (debuggerWasConnected > 0) {
Slog.w(TAG, "Debugger was connected: Watchdog is *not* killing the system process");
} else if (!allowRestart) {
Slog.w(TAG, "Restart not allowed: Watchdog is *not* killing the system process");
} else {
Slog.w(TAG, "*** WATCHDOG KILLING SYSTEM PROCESS: " + subject);
WatchdogDiagnostics.diagnoseCheckers(blockedCheckers);
Slog.w(TAG, "*** GOODBYE!");
Process.killProcess(Process.myPid());
System.exit(10);
}

waitedHalf = false;

补充


补充一下4个状态的定义


static final int COMPLETED = 0;
static final int WAITING = 1;
static final int WAITED_HALF = 2;
static final int OVERDUE = 3;

COMPLETED是正常情况,其它都是异常情况,OVERDUE直接重启。


然后关于Monitor,可以随便拿个类来举例子,我看很多人都是用AMS,那我也用AMS吧


public class ActivityManagerService extends IActivityManager.Stub
implements Watchdog.Monitor, BatteryStatsImpl.BatteryCallback {

看到AMS实现Watchdog.Monitor,然后在AMS的构造方法中


Watchdog.getInstance().addMonitor(this);
Watchdog.getInstance().addThread(mHandler);

public void addMonitor(Monitor monitor) {
synchronized (this) {
mMonitorChecker.addMonitorLocked(monitor);
}
}

public void addThread(Handler thread, long timeoutMillis) {
synchronized (this) {
final String name = thread.getLooper().getThread().getName();
mHandlerCheckers.add(new HandlerChecker(thread, name, timeoutMillis));
}
}

先看addThread方法,能看出,Watchdog除了自己构造函数中添加的那些线程之外,还能提供方法给外部进行添加。然后addMonitor就是把Monitor添加到mMonitorQueue里面


void addMonitorLocked(Monitor monitor) {
// We don't want to update mMonitors when the Handler is in the middle of checking
// all monitors. We will update mMonitors on the next schedule if it is safe
mMonitorQueue.add(monitor);
}

之后在scheduleCheckLocked方法再把mMonitorQueue内容移动到mMonitors中,这个上面有讲了。然后来看AMS实现monitor方法。


public void monitor() {
synchronized (this) { }
}

表面看什么都没做,实则这里有个加锁,如果这时候其它线程占有锁了,你这里调monitor就会BLOCKED,最终时间长就导致Watchdog那超时,这个上面也有讲了。


分析


首先看了源码之后我觉得总体来说不够其它功能设计的源码亮眼,比如我上篇写的线程池,感觉设计上比它就差点意思。当然也有好的地方,比如mMonitorQueue和mMonitors的设计这里。


然后从设计的角度去反推,为什么要定30秒,这个我是分析不出的,这里定30秒是有什么含义,随便差不多定一个时机,还是根据什么原理去设定的时间。


然后我觉得有个地方挺迷的,如果有懂的大佬可以解答一下。


就是getCompletionStateLocked,什么情况下会返回WAITING状态。 记录mStartTime -> sleep 30秒 -> getCompletionStateLocked,正常来看,getCompletionStateLocked中获取时间减去mStartTime肯定是会大于30秒,所以要么getCompletionStateLocked直接返回COMPLETED,要么就是WAITED_HALF或者OVERDUE,什么情况下会WAITING。


然后看源码的时候,有个地方挺有意思的,这个也可以分享一下,就是run方法中,收集信息重启那个流程,有一句注释


// Give some extra time to make sure the stack traces get written.
// The system's been hanging for a minute, another second or two won't hurt much.
SystemClock.sleep(5000);

我是没想到官方人员也这么调皮。


最后回顾一下标题,狗子到底做了什么?


现在其实去网上找,有很多人说Watchdog是为了检测死锁,然后相当于把Watchdog和死锁绑一起了。包括在SystemServer调用的时候官方也有一句注释。


// Start the watchdog as early as possible so we can crash the system server
// if we deadlock during early boot
traceBeginAndSlog("StartWatchdog");
final Watchdog watchdog = Watchdog.getInstance();
watchdog.start();
traceEnd();

if we deadlock during early boot,让人觉得就是专门处理死锁的。当然如果出现死锁的话mCurrentMonitor.monitor()会阻塞住所以能检测出来。但是我上面也说了,从源码的角度看,有两种情况会导致卡住。


(1)这个Handler的MessageQueue的前一个Message一直在处理中,导致postAtFrontOfQueue在这30秒之后都没执行到run方法

(2)run方法中的mCurrentMonitor.monitor()一直卡住,30秒了还是卡住,准确来说是竞争锁处于BLOCKED状态,没能执行到mCompleted = true


第一种情况,我如果上一个message是耗时操作,那这个run就不会执行,这种情况下可没走到死锁的判断。当然,这里都是监听的特殊的线程,主线程之类的做耗时操作也不切实际。第二种,mCurrentMonitor.monitor()一直卡住就一定是死锁了吗?我一直持有锁不释放也会导致这个结果。


所以我个人觉得这里Watchdog的作用不仅仅是为了监测死锁,而是监测一些线程,防止它们长时间被持有导致无法响应或者因为耗时操作导致无法及时响应。再看看看门狗的定义,看门狗的功能是定期的查看芯片内部的情况,一旦发生错误就向芯片发出重启信号 ,我觉得,如果单单只是为了监测死锁,那完全可以叫DeadlockWatchdog。


总结


Watchdog的主要流程是:开启一个死循环,不断给指定线程发送一条消息,然后休眠30秒,休眠结束后判断是否收到消息的回调,如果有,则正常进行下次循环,如果没收到,判断从发消息到现在的时机小于30秒不处理,大于30秒小于60秒收集信息,大于60秒收集信息并重启。


当然还有一些细节,比如判断时间是用SystemClock.uptimeMillis(),这些细节我这里就不单独讲了。


从整体来看,这个设计的思路还是挺好的,发消息后延迟然后判断有没有收到消息 ,其实这就是和判断ANR一样,埋炸弹拆炸弹的过程,是这样的一个思路。


个人比较有疑问的就是这个30秒的设计,是有什么讲究。还有上面说的,什么情况下会出现

作者:流浪汉kylin
来源:juejin.cn/post/7215498393429983291
小于30秒的场景。


收起阅读 »

Flutter应用如何打包发版

Flutter应用程序的打包和发布可以通过Flutter命令行工具完成。以下是具体步骤: 确保你已经安装了Flutter SDK,并且在终端中配置了Flutter环境变量。 在终端中进入Flutter项目的根目录,运行flutter build apk命令生...
继续阅读 »

Flutter应用程序的打包和发布可以通过Flutter命令行工具完成。以下是具体步骤:



  1. 确保你已经安装了Flutter SDK,并且在终端中配置了Flutter环境变量。

  2. 在终端中进入Flutter项目的根目录,运行flutter build apk命令生成APK文件(Android)或flutter build ios命令生成ipa文件(iOS)。

  3. 如果需要对APK或ipa进行签名,则需要使用相应的签名工具,例如jarsignerFastlane。签名后的文件可以直接上传至Google Play Store、Apple App Store等进行发布。

  4. 对于Android,可以使用Flutter gradle插件构建和打包APK文件,并自动签名。在build.gradle文件中添加Flutter插件依赖,然后运行flutter build apk命令即可生成签名过的APK文件。

  5. 对于单独渠道号等,有需要的可以单独配置。此文不做具体详解。


以下是Flutter代码示例:


# pubspec.yaml
name: my_flutter_app
version: 1.0.0
dependencies:
flutter:
sdk: flutter

# build.gradle
buildscript {
repositories {
google()
jcenter()
}

dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'

// 添加Flutter插件依赖
classpath 'com.android.tools.build:gradle:4.1.0'
classpath 'com.google.gms:google-services:4.3.8'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.6.1'
classpath 'com.google.firebase:perf-plugin:1.4.0'
}
}

allprojects {
repositories {
google()
jcenter()
}
}

rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}

task clean(type: Delete) {
delete rootProject.buildDir
}

# 运行打包命令
flutter build apk --release
# 如果有空安全,运行打包命令
# 可选debug,release,profile
flutter build apk --no-sound-null-safety --release

需要注意的是,要发布到应用商店(如Google Play Store或Apple App Store),需要对应用进行签名。在Flutter应用程序构建后,可以使用相应的签名工具对APK或ipa文件进行签名。例如,在Android上,可以使用jarsignerzipalign工具对APK文件进行签名和优化。而在iOS上,则需要使用Xcode中的证书签署和打包工具进行签名和打包。


Flutter支持通过构建发布包来支持多种CPU架构(如armeabi-v7a,arm64-v8a和x86_64等),以最大程度地提高应用程序的兼容性和性能。以下是具体步骤:



  1. 在Flutter项目的根目录下运行flutter build apk --target-platform android-arm,android-arm64,android-x64命令以生成支持多架构的APK文件。

  2. 运行上述命令后,Flutter会自动构建三个不同CPU架构的版本,并将它们打包到一个单独的APK文件中。

  3. 如果需要对APK进行签名,则可以使用常规的签名工具,例如jarsignerFastlane。需要对每个CPU架构分别进行签名,然后使用zipalign工具对打包后的文件进行优化。

  4. 最后,可以将签名过的APK文件上传至Google Play Store或其他应用商店进行发布。


注意,如果使用的是Flutter 2.5或更高版本,则无需手动指定目标平台,因为Flutter会自动为你构建多架构版本的应用程序。


以下是Flutter代码示例:


# 运行打包命令
flutter build apk --release --target-platform android-arm,android-arm64,android-x64

需要注意的是,在打包发布之前,应该先通过模拟器或真机设备进行测试,确保应用程序在所有CPU架

作者:IT编程学习栈
来源:juejin.cn/post/7220657346543435831
构上都可以正常运行。

收起阅读 »

关于使用EaseTitleBar编译无报错,打开app崩溃与解决方法。相关日志及描述如下,(使用sdk版本为3.9.5)。

问题描述:使用EaseTitleBar编译无报错,打开app崩溃解决方法: 随便设置一下titleBarLeftImage的属性就可以了,没有使用这个属性的加上这个属性。日志:04/10 12:07:08: Launching 'app' on Nexus 5...
继续阅读 »

问题描述:使用EaseTitleBar编译无报错,打开app崩溃

解决方法: 随便设置一下titleBarLeftImage的属性就可以了,没有使用这个属性的加上这个属性。


日志:

04/10 12:07:08: Launching 'app' on Nexus 5X API 29.

App restart successful without requiring a re-install.
$ adb shell am start -n "com.example.my/com.example.my.MainActivity" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER
Connected to process 13230 on device 'Nexus_5X_API_29 [emulator-5554]'.
Capturing and displaying logcat messages from application. This behavior can be disabled in the "Logcat output" section of the "Debugger" settings page.
I/com.example.my: Late-enabling -Xcheck:jni
E/com.example.my: Unknown bits set in runtime_flags: 0x8000
W/com.example.my: Unexpected CPU variant for X86 using defaults: x86
W/RenderThread: type=1400 audit(0.0:184): avc: denied { write } for name="property_service" dev="tmpfs" ino=7412 scontext=u:r:untrusted_app:s0:c137,c256,c512,c768 tcontext=u:object_r:property_socket:s0 tclass=sock_file permissive=0
D/libEGL: Emulator has host GPU support, qemu.gles is set to 1.
W/libc: Unable to set property "qemu.gles" to "1": connection failed; errno=13 (Permission denied)
D/libEGL: loaded /vendor/lib/egl/libEGL_emulation.so
D/libEGL: loaded /vendor/lib/egl/libGLESv1_CM_emulation.so
D/libEGL: loaded /vendor/lib/egl/libGLESv2_emulation.so
W/com.example.my: Accessing hidden method Landroid/view/View;->computeFitSystemWindows(Landroid/graphics/Rect;Landroid/graphics/Rect;)Z (greylist, reflection, allowed)
W/com.example.my: Accessing hidden method Landroid/view/ViewGroup;->makeOptionalFitsSystemWindows()V (greylist, reflection, allowed)
D/AndroidRuntime: Shutting down VM
E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.example.my, PID: 13230
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.my/com.example.my.MainActivity}: android.view.InflateException: Binary XML file line #16 in com.example.my:layout/activity_main: Binary XML file line #16 in com.example.my:layout/activity_main: Error inflating class com.hyphenate.easeui.widget.EaseTitleBar
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3270)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3409)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:83)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2016)
at android.os.Handler.dispatchMessage(Handler.java:107)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:7356)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
Caused by: android.view.InflateException: Binary XML file line #16 in com.example.my:layout/activity_main: Binary XML file line #16 in com.example.my:layout/activity_main: Error inflating class com.hyphenate.easeui.widget.EaseTitleBar
Caused by: android.view.InflateException: Binary XML file line #16 in com.example.my:layout/activity_main: Error inflating class com.hyphenate.easeui.widget.EaseTitleBar
Caused by: java.lang.reflect.InvocationTargetException
at java.lang.reflect.Constructor.newInstance0(Native Method)
at java.lang.reflect.Constructor.newInstance(Constructor.java:343)
at android.view.LayoutInflater.createView(LayoutInflater.java:854)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:1006)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:961)
at android.view.LayoutInflater.rInflate(LayoutInflater.java:1123)
at android.view.LayoutInflater.rInflateChildren(LayoutInflater.java:1084)
at android.view.LayoutInflater.inflate(LayoutInflater.java:682)
at android.view.LayoutInflater.inflate(LayoutInflater.java:534)
at android.view.LayoutInflater.inflate(LayoutInflater.java:481)
at androidx.appcompat.app.AppCompatDelegateImpl.setContentView(AppCompatDelegateImpl.java:706)
at androidx.appcompat.app.AppCompatActivity.setContentView(AppCompatActivity.java:195)
at com.example.my.MainActivity.onCreate(MainActivity.java:16)
at android.app.Activity.performCreate(Activity.java:7802)
at android.app.Activity.performCreate(Activity.java:7791)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1299)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3245)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3409)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:83)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2016)
at android.os.Handler.dispatchMessage(Handler.java:107)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:7356)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
E/AndroidRuntime: at androidx.appcompat.app.AppCompatDelegateImpl.setSupportActionBar(AppCompatDelegateImpl.java:581)
at androidx.appcompat.app.AppCompatActivity.setSupportActionBar(AppCompatActivity.java:183)
at com.hyphenate.easeui.widget.EaseTitleBar.initToolbar(EaseTitleBar.java:180)
at com.hyphenate.easeui.widget.EaseTitleBar.init(EaseTitleBar.java:92)
at com.hyphenate.easeui.widget.EaseTitleBar.<init>(EaseTitleBar.java:62)
at com.hyphenate.easeui.widget.EaseTitleBar.<init>(EaseTitleBar.java:56)
... 28 more

收起阅读 »

实战经验:打造仿微信聊天键盘,解决常见问题

防苹果微信聊天页面,聊天中的布局不是,主要是键盘部分,键盘部分在做的过程中遇到了几个坑,记录一下,看看大家有没有越到过 分析ios微信聊天页面 UI组成看起来比较简单,但是包含的内容可真不少,首先语音、输入框、表情、更多四个简单元素,元素间存在互斥的一些状态...
继续阅读 »

防苹果微信聊天页面,聊天中的布局不是,主要是键盘部分,键盘部分在做的过程中遇到了几个坑,记录一下,看看大家有没有越到过


output_image.gif


分析ios微信聊天页面


UI组成看起来比较简单,但是包含的内容可真不少,首先语音、输入框、表情、更多四个简单元素,元素间存在互斥的一些状态操作,比如语音时,显示按住说话,键盘关闭,表情面板时面板关闭,面板关闭则联动表情和EditText图标的切换。


各状态分析



  1. 语音状态



语音状态时,语音与edit图标切换,EditText 与按住说话UI切换,此时如果键盘处于编辑状态,则收回键盘,此时键盘处于表情面板或者更多面板需要收回面板,若表情面板时,表情与edit图位置恢复表情icon。




  1. 键盘状态



点击语音与edit图标 位置时,icon 为语音标,键盘弹出,当前再表情面板时,点击表情与edit图标, 键盘弹出,icon 变换




  1. 表情状态



注意语音与edit图标 位置恢复即可




  1. 更多面板



注意语音与edit图标,表情与edit图标位置恢复



对于这四种状态直接使用LiveData, 然后与点击事件做出绑定,事件发生时处理对应状态即可


image.png


键盘UI组成


image.png


所以可以将结构设置为:


<LinearLayout
android:id="@+id/cl_voiceRoom_inputRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="vertical"
tools:visibility="visible">


<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/imEditBgCL"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/im_chat_bottom_bg"
android:minHeight="60dp">


// 键盘顶部,表情输入框等

</androidx.constraintlayout.widget.ConstraintLayout>

// 指定面板占位
<androidx.fragment.app.FragmentContainerView
android:id="@+id/imMiddlewareVP"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="#F0EFEF"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/imEditBgCL"
tools:visibility="visible"
/>




</LinearLayout>

然后对应上述的状态进行UI和键盘的操作


键盘逻辑处理



  1. EditText 自动换行输入并将action设置为send 按钮


这一步很简单,但是有一个坑,按照正常逻辑,再xml中的EditText 设置以下属性,即可完成这个需求


android:imeOptions="actionSend"
android:inputType="textMultiLine"

按照属性的原义,这样将显示正常的发送按钮以及可自动多行输入,但是就是不显示发送,查资料发现imeOptions 需要使inputType 为text 时才显示,但是又实现不了我们的需求,最后处理方式


android:imeOptions="actionSend"
android:inputType="text"

//然后在代码中进行如下设置:
binding.imMiddlewareET.run {
imeOptions = EditorInfo.IME_ACTION_SEND
setHorizontallyScrolling(false)
maxLines = Int.MAX_VALUE
}



  1. 按照上面的状态互斥,我们需要动态监听软键盘的打开和关闭


系统没有提供对应的实现,所以我们才采取的办法是,监听软键盘的高度变化


View rootView = getWindow().getDecorView().getRootView();
rootView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
Rect rect = new Rect();
rootView.getWindowVisibleDisplayFrame(rect);
int heightDiff = rootView.getHeight() - rect.bottom;
boolean isSoftKeyboardOpened = heightDiff > 0;
// 处理软键盘打开或关闭的逻辑
}
});

通过判断高度来推算键盘的打开或者关闭


解决切换键盘问题


切换键盘时,比如表情和Edit 切换




  • 当面板是键盘时,点击图标区域



    • 取消Edit焦点

    • 关闭键盘

    • 打开emoji面板




  • 当面板是emoji时



    • 隐藏面板

    • 设置获取焦点

    • 打开键盘
      其他场景下切换没什么问题,但是当键盘和自定义面板切换时有可能出现这样的问题:




image.png


因为键盘的关闭和View的显示,或者View的隐藏和键盘的显示那个先执行完毕逻辑不能串行,导致会出现这种闪烁的画面


解决方案:


分析上述问题后会发现,导致的出现这种情况的原因就是逻辑不能串行,那我们保证二者的逻辑串行就不会出现这问题了,怎么保证呢?


首先要知道的是肯定不能让View先行,View先行一样会出现这个问题,所以要保证让键盘先行,我们看一下,键盘的打开和关闭:


// 显示键盘
private fun showSoftKeyBoard(view: View) {
val imm = mContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
imm?.showSoftInput(view, InputMethodManager.SHOW_FORCED)
}

// 隐藏键盘
private fun hideSoftKeyBoard(view: View) {
val imm = mContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
if (imm != null && imm.isActive) {
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
}

这个代码对于键盘的显示隐藏是没有任何问题的,但是我们怎么判断它执行这个动作完毕了呢?


方法一:


上面我们有这样的操作,监听了键盘高度的监听,我们可以在执行切换操作时启动一个线程的死循环,然后再循环中判断高度,满足高度时执行上述逻辑。


方法二:


看下InputMethodManager 的源码,发现:


/**
* Synonym for {@link #hideSoftInputFromWindow(IBinder, int, **ResultReceiver**)}
* without a result: request to hide the soft input window from the
* context of the window that is currently accepting input.
*
* @param windowToken The token of the window that is making the request,
* as returned by {@link View#getWindowToken() View.getWindowToken()}.
* @param flags Provides additional operating flags. Currently may be
* 0 or have the {@link #HIDE_IMPLICIT_ONLY} bit set.
*/

public boolean hideSoftInputFromWindow(IBinder windowToken, int flags) {
return hideSoftInputFromWindow(windowToken, flags, null);
}

是不是很神奇,这个隐藏方法有一个ResultReceiver 的回调,卧槽,是不是看这个名字就感觉有戏,具体看一下:


public boolean hideSoftInputFromWindow(IBinder windowToken, int flags,
ResultReceiver resultReceiver)
{
return hideSoftInputFromWindow(windowToken, flags, resultReceiver,
SoftInputShowHideReason.HIDE_SOFT_INPUT);
}


ResultReceiver 是一个用于在异步操作完成时接收结果的类,它可以让你在不同的线程之间进行通信。在 hideSoftInputFromWindow() 方法中,ResultReceiver 作为一个可选参数,用于指定当软键盘隐藏完成时的回调。该回调会在后台线程上执行,因此不会阻塞主线程,从而提高应用程序的响应性能。


ResultReceiver 类有一个 onReceiveResult(int resultCode, Bundle resultData) 方法,当异步操作完成时,该方法会被调用。通过实现该方法,你可以自定义处理异步操作完成后的行为。例如,在软键盘隐藏完成后,你可能需要执行一些操作,例如更新 UI 或者执行其他任务。


在 hideSoftInputFromWindow()方法中,你可以通过传递一个 ResultReceiver 对象来指定异步操作完成后的回调。当软键盘隐藏完成时,系统会调用ResultReceiver对象的send()方法,并将结果代码和数据包装在 Bundle对象中传递给 ResultReceiver对象。然后,ResultReceiver 对象的 onReceiveResult() 方法会在后台线程上执行,以便你可以在该方法中处理结果。



然后看了showSoftInput 也同样有这个参数


public boolean showSoftInput(View view, int flags, ResultReceiver resultReceiver) {
return showSoftInput(view, flags, resultReceiver, SoftInputShowHideReason.SHOW_SOFT_INPUT);
}

那我们可以这样解决:


隐藏为例:
当我执行切换时,首先调用hideSoftInputFromWindow, 并创建ResultReceiver监听,当返回结果后,执行View的操作,保证他们的串行,以此解决切换键盘闪烁问题。


private fun hideSoftKeyBoard(view: View, callback: () -> Unit) {
val imm = mActivity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
if (imm != null && imm.isActive) {
val resultReceiver = object : ResultReceiver(Handler(Looper.getMainLooper())) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
super.onReceiveResult(resultCode, resultData)
// 在这里处理软键盘隐藏完成后的逻辑
callback.invoke()
//...
}
}
imm.hideSoftInputFromWindow(view.windowToken, 0, resultReceiver)
}
}

Emoji 显示


在 Android 中,Emoji 表情可以通过以下方式在字符串中表示:



  1. Unicode 编码:Emoji 表情的 Unicode 编码可以直接嵌入到字符串中,例如 "\u2764\ufe0f" 表示一个红色的心形 Emoji。其中,\u 是 Unicode 转义字符,后面跟着 4 个十六进制数表示该字符的 Unicode 编码。

  2. Unicode 代码点:Unicode 代码点是 Unicode 编码的十进制表示,可以使用 &# 后跟代码点数字和分号 ; 来表示 Emoji,例如 &#128512; 表示一个笑脸 Emoji。在 XML 中,可以使用 &#x 后跟代码点的十六进制表示来表示 Emoji,例如 &#x1f600; 表示一个笑脸 Emoji。

  3. Emoji 表情符号:在 Android 4.4 及以上版本中,可以直接使用 Emoji 表情符号来表示 Emoji,例如 😊 表示一个微笑的 Emoji。在 Android 4.3 及以下版本中,需要使用第一种或第二种方式来表示 Emoji。


我在此demo中使用第一种实现的,具体使用步骤:



  1. UI布局

  2. 数据



flow {

val pattern = Regex("^(\S+)\s+;\s+fully-qualified\s+#\s+((?:\S+\s+)+)(.+)$")
val filterNotNull = readAssetsFile("emoji.txt", IMApplication.context)
.trim()
.lines()
.map { line ->
val matchResult = pattern.find(line)
if (matchResult != null) {
val (emoji, codePointHex, comment) = matchResult.destructured
val codePoint = emoji.drop(2).toInt(16)
EmojiEntry(emoji, codePoint, "E${emoji.take(2)}", comment,codePointHex)
} else {
null
}
}.filterNotNull()
emit(filterNotNull)
}

使用



  • 使用google 提供的emoji库


implementation 'androidx.emoji:emoji:1.1.0'


  • 在Application中初始化


val fontRequest = FontRequest(
"com.google.android.gms.fonts",
"com.google.android.gms",
"Montserrat Subrayada",
R.array.com_google_android_gms_fonts_certs
)
val config = FontRequestEmojiCompatConfig(this, fontRequest)
EmojiCompat.init(config)

对于FontRequest 是使用的Goolge 提供的可下载字体配置进行初始化的,当然可以不用,但是系统的字体对于表情不是高亮的,看起来是灰色的(也可以给TextView 设置字体解决)


通过 Android Studio 和 Google Play 服务使用可下载字体



  1. 在 Layout Editor 中,选择一个 TextView,然后在 Properties 下,选择 fontFamily > More Fonts。


image.png



  1. 在 Source 下拉列表中,选择 Google Fonts。

  2. 在 Fonts 框中,选择一种字体。

  3. 选择 Create downloadable font,然后点击 OK


image.png


然后会在项目的res 下生成文字


<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:app="http://schemas.android.com/apk/res-auto"
app:fontProviderAuthority="com.google.android.gms.fonts"
app:fontProviderPackage="com.google.android.gms"
app:fontProviderQuery="Montserrat Subrayada"
app:fontProviderCerts="@array/com_google_android_gms_fonts_certs">
</font-family>

Emoji 面板中的删除操作


再IOS微信中,点击Emoji面板后输入框是没有焦点的,然后点击删除时Emoji会有一个问题,因为它的大小是2个byte,所以常规删除是不行的,


expressionDeleteFL.setOnClickListener {
val inputConnection =
editText.onCreateInputConnection(
EditorInfo()
)
// 找到要删除的字符的边界
val text = editText.text.toString()
val index = editText.selectionStart
var deleteLength = 1
if (index > 0 && index <= text.length) {
val codePoint = text.codePointBefore(index)
deleteLength = if (Character.isSupplementaryCodePoint(codePoint)) 2 else 1
}
inputConnection.deleteSurroundingText(deleteLength, 0)
}


  1. 首先,通过 editText.onCreateInputConnection(EditorInfo()) 方法获取输入连接器(InputConnection),它可以用于向 EditText 发送文本和控制命令。在这里,我们使用它来删除文本。

  2. 接着,获取 EditText 中当前的文本,并找到要删除的字符的边界。通过 editText.selectionStart方法获取当前文本的光标位置,然后使用 text.codePointBefore(index)方法获取光标位置前面一个字符的 Unicode 编码点。如果该字符是一个 Unicode 表情符号,它可能由多个 Unicode 编码点组成,因此需要使用 Character.isSupplementaryCodePoint(codePoint) 方法来判断该字符是否需要删除多个编码点。

  3. 最后,使用 inputConnection.deleteSurroundingText(deleteLength, 0)方法删除要删除的字符。其中,deleteLength 是要删除的字符数,0 表示没有要插入的新文本。


主要的技术点在于“text.codePointBefore(index)方法获取光标位置前面一个字符的 Unicode 编码点,然后向前探测,找到字符边界” 以此完成删除操作


打开面板时 RV布局的处理


这个就比较简单了



  1. 首先,通过 root.viewTreeObserver.addOnGlobalLayoutListener 方法添加一个全局布局监听器,该监听器可以监听整个布局树的变化,包括软键盘的弹出和隐藏。

  2. 在监听器的回调函数中,通过 root.getWindowVisibleDisplayFrame(r) 方法获取当前窗口的可见区域(不包括软键盘),并通过 root.rootView.height 方法获取整个布局树的高度,从而计算出软键盘的高度 keypadHeight。

  3. 接着,通过计算屏幕高度的 15% 来判断软键盘是否弹出。如果软键盘高度超过了屏幕高度的 15%,则认为软键盘已经弹出。

  4. 如果软键盘已经弹出,则通过 imMiddlewareRV.scrollToPosition(mAdapter.getItemCount() - 1) 方法将 RecyclerView滚动到最后一条消息的位置,以确保用户始终能看到最新的消息


root.viewTreeObserver.addOnGlobalLayoutListener {
val r = Rect()
root.getWindowVisibleDisplayFrame(r)
val screenHeight = root.rootView.height
val keypadHeight = screenHeight - r.bottom
//键盘是否弹出
val diff = screenHeight * 0.15
if (keypadHeight > diff) { // 15% of the screen height
imMiddlewareRV.scrollToPosition(mAdapter.getItemCount() - 1);
}
}

总结


仿照微信聊天键盘的方法,实现了一个包含表情等功能的键盘区域,并解决了一些常见的问题。通过实践和调查,解决了切换键盘的问题,并实现了Emoji的Unicode显示和自定义删除时向前探索字符边界完成表情删除等操作。在过程中,以为很简单的一个东西花了大量的时间调查原因,发现键盘这一块水很深,当我看到ResultReceiver时,看到了AIDL通信,所以再Android这个体系中,Binder的机制需要了然于胸的,刚好我最近在学习Binder得各种知识,不久后会发布对应的博客,关注我,哈哈。


此系列属于我的一个 《Android IM即时通信多进程中间件设计与实现》 系列的一部分,可以看看这个系列


项目地址


作者:麦客奥德彪
来源:juejin.cn/post/7215416975605628987
收起阅读 »

写给 Android 开发者的系统基础知识科普

与我以往的风格不同,本文为科普类文章,因此不会涉及到太过高深难懂的知识。但这些内容可能 Android 应用层开发者甚至部分 framework 层开发者都不了解,因此仍旧高能预警。 另外广东这两天好冷啊,大家注意保暖~ 虚拟机与运行时 对象的概念 假设 ge...
继续阅读 »

与我以往的风格不同,本文为科普类文章,因此不会涉及到太过高深难懂的知识。但这些内容可能 Android 应用层开发者甚至部分 framework 层开发者都不了解,因此仍旧高能预警。


另外广东这两天好冷啊,大家注意保暖~


虚拟机与运行时


对象的概念


假设 getObjectAddress(Object) 是一个获取对象内存地址的方法。


第一题:


考虑如下代码:


public static void main(String[] args) {
Object o = new Object();
long address1 = getObjectAddress(o);
// .......
long address2 = getObjectAddress(o);
}

main 方法中,创建了一个 Object 对象,随后两次调用 getObjectAddress 获取该对象的地址。两次获取到的对象地址是否有可能不同?换句话说,对象的地址是否有可能变更?










答:有可能。JVM 中存在 GC 即“垃圾回收”机制,会回收不再使用的对象以腾出内存空间。GC 可能会移动对象。


第二题:


考虑如下代码:


private static long allocate() {
Object o = new Object();
return getObjectAddress(o);
}

public static void main(String[] args) {
long address1 = allocate();
// ......
long address2 = allocate();
}

allocate() 创建了一个 Object 对象,然后获取它的对象地址。
main 方法中调用两次 allocate(),这两个对象的内存地址是否有可能相同?










答:有可能。在 allocate() 方法中创建的对象在该方法返回后便失去所有引用成为“不再需要的对象”,如果两次方法调用之间,第一次方法调用中产生的临时对象被上文中提到的 GC 机制回收,对应的内存空间就变得“空闲”,可以被其他对象占用。


第三题:


哎呀,既然上面说同一个对象的内存地址可能不相同,两个不同对象也有可能有相同的内存地址,而java 里的 == 又是判断对象的内存地址,那么


Object o = new Object();
if (o != o)

还有


Object o1 = new Object();
Object o2 = new Object();
if (o1 == o2)

这里的两个 if 不是都有可能成立?










答:不可能。== 操作符比较的确实是对象地址没错,但是这里其实还隐含了两个条件:



  1. 这个操作符比较的是 “那一刻” 两个对象的地址。

  2. 比较的两个对象都位于同一个进程内。


上述提到的两种情况都不满足“同一时间”这一条件,因此这两条 if 永远不会成立。


类与方法


第四题:


假设 Framework 是 Android Framework 里的一个类,App 是某个 Android App 的一个类:


public class Framework {
public static int api() {
return 0;
}
}

public class App {
public static void main(String[] args) {
Framework.api();
}
}

编译 App,然后将 Frameworkapi 方法的返回值类型从 int 改为 long,编译 Framework 但不重新编译 App,App 是否可以正常调用 Framework 的 api 方法?










答:不能。Java 类内存储的被调用方法的信息里包含返回值类型,如果返回值类型不对在运行时就找不到对应方法。将方法改为成员变量然后修改该变量的类型也同理。


第五题:


考虑如下代码:


class Parent {
public void call() {
privateMethod();
}
private void privateMethod() {
System.out.println("Parent method called");
}
}

class Child extends Parent {
private void privateMethod() {
System.out.println("Child method called");
}
}

new Child().call();

Child 里的 privateMethod 是否重写了 Parent 里的?call 中调用的 privateMethod() 会调用到 Parent 里的还是 Child 里的?










答:不构成方法重写,还是会调用到 Parent 里的 privateMethod。private 方法是 direct 方法,direct 方法无法被重写。


操作系统基础


多进程与虚拟内存


假设有进程 A 和进程 B。


第六题:


进程 A 里的对象 a 和进程 B 里的对象 b 拥有相同的内存地址,它们是同一个对象吗?










答:当然不是,上面才说过“对象相等”这个概念在同一个进程里才有意义,不认真听课思考是会被打屁屁的~


第七题:


进程 A 内有一个对象 a 并将这个对象的内存地址传递给了 B,B 是否可以直接访问(读取、写入等操作)这个对象?










答:不能,大概率会触发段错误,小概率会修改到自己内存空间里某个冤种对象的数据,无论如何都不会影响到进程 A。作为在用户空间运行的进程,它们拿到的所谓内存地址全部都是虚拟地址,进程访问这些地址的时候会先经过一个转换过程转化为物理地址再操作。如果转换出错(人家根本不认识你给的这个地址,或者对应内存的权限不让你执行对应操作),就会触发段错误。


第八题:


还是我们可爱的进程 A 和 B,但是这次 B 是 A 的子进程,即 A 调用 fork 产生了 B 这个新的进程:


void a() {
int* p = malloc(sizeof(int));
*p = 1;
if (fork() > 0) {
// 进程 A 也即父进程
// 巴拉巴拉巴拉一堆操作
} else {
// 进程 B 也即子进程
*p = 2;
}
}

(fork 是 Posix 内创建进程的 API,调用完成后如果仍然在父进程则返回子进程的 pid 永远大于 0,在子进程则返回 0)


(还是理解不了就把 A 想象为 Zygote 进程,B 想象为任意 App 进程)


这一段代码分配了一段内存,调用 fork 产生了一个子进程,然后在子进程里将预先分配好的那段内存里的值更改为 2。
问:进程 B 做出的更改是否对进程 A 可见?










答:不可见,进程 A 看见的那一段内存的值依然是 1。Linux 内核有一个叫做“写时复制”(Copy On Write)的技术,在进程 B 尝试写入这一段内存的时候会偷偷把真实的内存给复制一份,最后写入的是这份拷贝里的值,而进程 A 看见的还是原来的值。


跨进程大数据传递


已知进程 A 和进程 B,进程 A 暴露出一个 AIDL 接口,现在进程 B 要从 A 获取 10M 的数据(远远超出 binder 数据大小限制),且禁止传递文件路径,只允许调用这个 AIDL 接口一次,请问如何实现?










答:可以传递文件描述符(File Descriptor)。别以为这个玩意只能表示文件!举个例子,作为应用层开发者我们可以使用共享内存的方法,这样编写 AIDL 实现类把数据传递出去:


@Override public SharedMemory getData() throws RemoteException {
int size = 10 * 1024 * 1024;
try {
SharedMemory sharedMemory = SharedMemory.create("shared memory", size);
ByteBuffer buffer = sharedMemory.mapReadWrite();
for (int i = 0;i < 10;i++) {
// 模拟产生一堆数据
buffer.put(i * 1024 * 1024, (byte) 114);
buffer.put(i * 1024 * 1024 + 1, (byte) 51);
buffer.put(i * 1024 * 1024 + 2, (byte) 4);
buffer.put(i * 1024 * 1024 + 3, (byte) 191);
buffer.put(i * 1024 * 1024 + 4, (byte) 98);
buffer.put(i * 1024 * 1024 + 5, (byte) 108);
buffer.put(i * 1024 * 1024 + 6, (byte) 93);
}
SharedMemory.unmap(buffer);
sharedMemory.setProtect(OsConstants.PROT_READ);
return sharedMemory;
} catch (ErrnoException e) {
throw new RemoteException("remote create shared memory failed: " + e.getMessage());
}
}

然后在进程 B 里这样拿:


IRemoteService service = IRemoteService.Stub.asInterface(binder);
try {
SharedMemory sharedMemory = service.getData();
ByteBuffer buffer = sharedMemory.mapReadOnly();

// 模拟处理数据
int[] temp = new int[10];
for (int i = 0;i < 10;i++) {
for (int j = 0;j < 10;j++) {
temp[j] = buffer.get(i * 1024 * 1024 + j);
}
Log.e(TAG, "Large buffer[" + i + "]=" + Arrays.toString(temp));
}
SharedMemory.unmap(buffer);
sharedMemory.close();
} catch (Exception e) {
throw new RuntimeException(e);
}

这里使用的 SharedMemory 从 Android 8.1 开始可用,在 8.1 之前的系统里也有一个叫做 MemoryFile 的 API 可以用。
打开 SharedMemory 里的源码,你会发现其实它内部就是创建了一块 ashmem (匿名共享内存),然后将对应的文件描述符传递给 binder。内核会负责将一个可用的文件描述符传递给目标进程。
你可以将它理解为可以跨进程传递的 File Stream(只要能通过权限检查),合理利用这个

作者:canyie
来源:juejin.cn/post/7215509220750098488
小玩意有奇效哦 :)

收起阅读 »

Compose Desktop实战网易云桌面应用

NCMusicDesktop 去年刚用Jetpack Compose写了个仿网易云app ,最近发现compose-jb正式版已经发布到了v1.3.1, 又玩了一下Compose Desktop,决定搞了个桌面版的NCMusicDesktop,数据源还是来自B...
继续阅读 »

NCMusicDesktop


去年刚用Jetpack Compose写了个仿网易云app ,最近发现compose-jb正式版已经发布到了v1.3.1,
又玩了一下Compose Desktop,决定搞了个桌面版的NCMusicDesktop,数据源还是来自Binaryify大佬的NeteaseCloudMusicApi


由于以前没有开发桌面应用的经验,索性想按照Android jetpack的套路来开发,然而Navigation、Lifecycle 、ViewModel、LiveData等等这些在compose-jb中,暂时通通没有~
不要慌,一番查找在掘金上看到一篇文章《推销 Compose 跨平台 Navigation:PreCompose》
讲了Precompose这个跨平台Navigation框架的使用, 它基本复刻了Jetpack Navigation、Lifecycle、ViewModel这些组件,
使用方式也基本保持一致,美滋滋!当然LiveData已经被废弃了,推荐使用Flow代替~至于网络请求,Retrofit照用不误,又一次美滋滋~


怎么用Android老套路来写Desktop应用

老规矩,先定义一波BaseResult、BaseViewModel、ViewStateComponent(页面状态切换组件)


代码: 略

Model层


class LyricResult(
val transUser: LyricContributorBean?,
val lyricUser: LyricContributorBean?,
val lrc: LrcBean?,
val tlyric: LrcBean?
) : BaseResult()

ViewModel层


class CpnLyricViewModel : BaseViewModel() {
fun getLyric(id: Long) = launchFlow {
NCRetrofitClient.getNCApi().getLyric(id)
}
}

interface NCApi {
@GET("/lyric")
suspend fun getLyric(@Query("id") id: Long): LyricResult
}

View层


@Composable
fun CpnLyric() {
ViewStateComponent(
key = "CpnLyric-${id}",
loadDataBlock = {viewModel.getLyric(id)}
) {
LyricList(it)
}
}

怎么播放音乐


至于在Compose Desktop上怎么播放音乐呢,毕竟没有Android的MediaPlayer,在github上找了找,发现succlz123大佬开源的Compose Multiplatform项目
AcFun-Client-Multiplatform,里面有视频播放的功能,是基于vlcj来实现的,看了下vlcj的api,使用AudioPlayerComponent播放音乐不是问题


关于嵌套滑动


开发过程中,有些交互感觉需要涉及到嵌套滑动,在Jetpack Compose中,使用NestedScrollConnection来处理嵌套滑动到场景,于是乎,写了一堆✨✨代码后,
发现NestedScrollConnection在Compose Desktop中完全不起作用,后面找了下github的issue,发现有哥们也遇到了哈哈哈,然而官方21年的回复是暂时没有计划,
到现在还是没有解决,凉飕飕~


nested_issue1.png


nested_issue2.png


第三方框架

  • PreCompose
  • zxing
  • compose-imageloader-desktop
  • vlcj


运行效果图


登录.gif


首页.gif


歌单列表.gif


歌单详情.gif


音乐播放.gif


主题切换.gif


项目源码地址


github.com/sskEvan/NCM…


参考文章


从 0 到 1 搞一个 Compose Desktop 版本的天气应用(附源码)

推销 Compose 跨平台 Navigation:PreCompose


作者:sskEvan
来源:juejin.cn/post/7215528103467679804
收起阅读 »

“千变万化”——神奇的Android图片规格调整器(构思篇)

前言 灵感与动力 灵感突现 做这个APP的想法,起源于两周前我堂妹突然转发给我了她的照片,因为她手上没有电脑不好调整图片的大小,希望我能帮她把照片的格式调成她需要的大小规格,我当时第一个想法是用win电脑自带的图片功能去给她限制大小,后面发现ps能保留更多图片...
继续阅读 »

前言


灵感与动力


灵感突现


做这个APP的想法,起源于两周前我堂妹突然转发给我了她的照片,因为她手上没有电脑不好调整图片的大小,希望我能帮她把照片的格式调成她需要的大小规格,我当时第一个想法是用win电脑自带的图片功能去给她限制大小,后面发现ps能保留更多图片细节和不同的采样方式,所以又使用ps给她调整了一下,发给了她,结果她也很满意,可是这又引发了我的思考,手机真的不好调整吗?


1d02b2584977cb768b16ecd996e92e6.jpg


f7b34fafcab1c00e8d590a7865855b3.png


00c5535a7e3fc4a7a104680b4cf6f0f.jpg


fa475d410ff25cb646cda4a652a1581.png


在查看众多美图APP如美图秀秀之类的,发现它们都有许多复杂的美颜功能,贴图功能,但唯独找不到指定宽高输出图片这个功能,它们也和手机自带相机一样,只有着为数不多的几个固定高宽比输出。


点燃动力


机缘巧合,由于当时工作上的业务正好用到了bitmap这个类,发现其实手机理论上也能实现指定高宽去输出图片,所以萌发了去做一个可以指定高宽的图片工具APP去练手,也因此引出了一堆我平常公司业务开发所忽略的问题,更是让我想通过这个简单工具去提升自己!


正篇


难得糊涂


怪圈


通过近一周的下班回家的编写,这才领悟到什么是纸上得来终觉浅,还是要应到那句“绝知此事要躬行”上的,我以为就是使用这个bitmap的方法去写一个函数然后用dialog弹窗让用户去选择图片调用它即可:


public Bitmap getNewBitmap(Bitmap bitmap, int newWidth ,int newHeight){
// 获区bitmap图片的宽高.
int width = bitmap.getWidth();
int height = bitmap.getHeight();
// 计算调整后与调整前的缩放比例.
float scaleWidth = ((float) newWidth) / width;
float scaleHeight = ((float) newHeight) / height;
// 取得想要缩放的matrix参数.
Matrix matrix = new Matrix();
//缩放坐标轴
matrix.postScale(scaleWidth, scaleHeight);
// 得到新的图片.
Bitmap newBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true);
return newBitmap;
}


结果发现这个方法倒是没什么问题,但dialog弹窗选择图片把我折磨坏了,然后一直就绕进了怪圈:

我想点击就去选择图片,结果发现自己用图片的绝对地址一直没有权限


恍然大悟


几个晚上过去我才终于想起来我应该求助与网上的大神博客,首先我先去了我们安卓著名大佬鸿洋博客搜了一下,在发现有自定义view的教程后又看了好几天才想起自己是来找打开相册获取图片的,发现正确的打开图库方式应该是URI获取,而不是直接找文件地址去拿图片,这已经涉及到用户安全的问题。


后记


通过构思我大概确定了这个APP的核心功能区域,也解决了一些小问题,但我还是没有来得及去实现,所以下一篇我将从构思过渡到完整逻辑代码的实现,至少先可以拿到手机上使用,然后再去看看UI方面要不要调整,以及是否需要增加新的功能,拭目以待吧!


作者:ObliviateOnline
来源:juejin.cn/post/7149199341616365604
收起阅读 »

动态代理View 实现无感化的用户状态检测框架

user-state-check 基于AOP实现用户状态检测的框架 github地址: user-state-check 功能 通过dexmaker 实现动态代理,通过设置ViewFactory2,动态生成view的子类。配合xml中定义属性。可以无感的拦截...
继续阅读 »

user-state-check


基于AOP实现用户状态检测的框架


github地址:
user-state-check


功能



  • 通过dexmaker 实现动态代理,通过设置ViewFactory2,动态生成view的子类。配合xml中定义属性。可以无感的拦截任意view的点击事件

  • 通过dexMaker 实现AOP,可以生成任意类的子类。便于和viewDataBing联合使用。

  • 可以和RxJava联合使用。

  • 可以自定义多个用户状态(最多32个,用的int保存的,可以自行扩展成long类型)

  • 可以自动跳转相关页面


示例


所有的示例都在demo的MainActivity


1.配合viewDataBiding使用


布局文件如下


    <RelativeLayout
android:layout_width="match_parent"
android:layout_height="100dp"
android:background="#eee">


<Button
android:id="@+id/btn_collection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:onClick="@{activity::doCollection}"
android:text="收藏(需要登陆,绑定手机号,实名认证)"
tools:ignore="HardcodedText" />

</RelativeLayout>

在需要拦截的方法上使用注解


设置需要检测的状态为: 登录,绑定手机号,实名认证。


    @CheckUserState(states = Login|BindPhoneNumber|BindRealName,policy = 	UserStateCheckPolicy.autoGo)
public void doCollection(View view){
GZToast.success().show("收藏成功");
}

动态生成Activity的子类


 private void aopActivity() {
binding.setActivity( UserStateManager.getProxy(compositeDisposable,this,this,e->{
GZToast.error().show(e.getMessage());
}));
}

效果


在执行收藏的之前,会检测用户状态,如果用户状态不满足,会自动跳转到相关页面。最后全面满足以后会自动执行收藏操作。


aop其他类.gif


2.拦截View的点击事件


1.设置ViewFactory2


注意需要在onCreate()方法之前注入。避免AppCompatActivity先注入


   @Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
//注入factory2
LayoutInflaterCompat.setFactory2(LayoutInflater.from(this), UserStateManager.getLayoutInflaterFactory(this,compositeDisposable, e->{
GZToast.error().show(e.getMessage());
}));
super.onCreate(savedInstanceState);
currentActivity=this;
}

设置view要检测的状态


通过 app:checkUserPolicy设置要检测的状态


通过app:checkUserPolicy设置状态不满足情况下的执行策略


包含两种:


策略值执行的动作
autoGo自动跳转到相关界面
justCheck只检查状态,如果不满足会抛出异常

  <LinearLayout
android:id="@+id/layout_collection"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_centerVertical="true"
android:layout_marginStart="10dp"
android:background="#25DA6E"
android:orientation="vertical"
app:checkUserPolicy="autoGo"
app:checkUserState="login">


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:text="拦截LinearLayout 需要登录以后才能执行收藏,没有登录自动登录"
android:textColor="@color/white" />

</LinearLayout>

使用


正常设置点击事件即可,通过框架实现拦截工作对业务代码无感。


 private void aopView() {
binding.layoutCollection.setOnClickListener(v->{
GZToast.success().show("收藏成功");
});
binding.tvCollection.setOnClickListener(v -> {
GZToast.success().show("收藏成功");
});
}

效果


拦截view.gif


3.和RXJava配合使用


重点是下面这句


compose(new UserStateTransform<>(this, Login|BindRealName))


  private void aopApi() {
Retrofit retrofit = new Retrofit.Builder().baseUrl("https://tenapi.cn/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build();
BaiduApi baiduApi = retrofit.create(BaiduApi.class);

BaiduApi finalBaiduApi = baiduApi;
binding.btnGetBaiduHot.setOnClickListener(v->{
Disposable disposable = finalBaiduApi.getHotList()
//检测用户状态
.compose(new UserStateTransform<>(this, Login|BindRealName))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.map(BaiduHotListResult::getData)
.subscribe(list -> {
StringBuffer buffer=new StringBuffer();
for(HotItemBean itemBean:list){
buffer.append(itemBean.getName()).append("\n");
}
binding.tvBaiduContent.setText(buffer);
}, e -> {
e.printStackTrace();
GZToast.error().show(e.getMessage());
});
compositeDisposable.add(disposable);
});
}

效果


rxjava.gif


用户不同意


会抛出UserStateCheckException通过其 getState() 方法可以获取匹配不成功的用户状态


在动态代理相关类的时候都可以传入一个错误的处理器,自定义错误的处理逻辑


 /**
* 获取代理类,该类为 delegate的子类
* 并且自会重写具有{@link com.zhuguohui.demo.userstate.CheckUserState}注解的方法
* 自动插入用户状态检测的逻辑
* @param compositeDisposable 用于取消请求
* @param context 上下文
* @param delegate 被代理的类
* @param errorFunction 出错时的回调
* @param <T> delegate的类型
* @return 返回delegate的子类对象
*/

public static <T> T getProxy(CompositeDisposable compositeDisposable, Context context, T delegate, CallBack<Throwable> errorFunction) {
return UserStateCheckUtil.getProxy(compositeDisposable,context,delegate,errorFunction);
}

效果


只是单纯的弹出提示框


取消.gif


使用


1.添加依赖


该库已经上传到 jitpack


	allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}

添加依赖


dependencies {
implementation 'com.github.zhuguohui:user-state-check:1.0.0'
}

具体代码看demo


1.自定义用户状态


示例代码:


重点是要在构造函数中穿入,状态名称,和对应于xml中的属性名称。这样才能和xml属性联动。


public final class DemoUserState extends IUserState {
private static final DemoUserState login=new DemoUserState("登录",1);
private static final DemoUserState bindPhoneNumber=new DemoUserState("绑定手机号",2);
private static final DemoUserState bindRealName=new DemoUserState("实名认证",4);

public static final int Login=1;
public static final int BindPhoneNumber=2;
public static final int BindRealName=4;


public static final DemoUserState[] values=new DemoUserState[]{login,bindPhoneNumber,bindRealName};
protected DemoUserState(String desc, int attrFlagValue) {
super(desc, attrFlagValue);
}


public static IUserState[] getUserStateByFlags(int flags) {
DemoUserState[] values = DemoUserState.values;
List<DemoUserState> stateList=new ArrayList<>(0);
for(DemoUserState state:values){
boolean match =( flags & state.getAttrFlagValue()) == state.getAttrFlagValue();
if(match){
stateList.add(state);
}
}

return stateList.toArray(new IUserState[0]);
}
}

声明xml属性


<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--用户检查相关的属性,放置在类下方便查找-->
<!--注释使用flag的属性不能定义format-->
<attr name="checkUserState">
<flag name="login" value="1"/>
<flag name="bindPhone" value="2"/>
<flag name="bindRealName" value="4"/>
</attr>

</resources>

实现自己的状态管理器


/**
* <pre>
* Created by zhuguohui
* Date: 2023/3/1
* Time: 13:27
* Desc:用户状态管理的接口
* 定义成接口方便不同的项目实现具体的类
* </pre>
*/

public interface IUserStateManager {
/**
* 是否达到了用户状态
* @param userState
* @return
*/

boolean isMatchUserState(IUserState userState);

/**
* 执行相应的请求
* @param state
* @return
*/

void doMatchUserState(Context context,IUserState state);

/**
* 用于判断 当前的页面的用途,因为每种用户状态的确认可能涉及多个页面
* 比如登录可能涉及到登录和注册两个页面。只有相关的页面都销毁了。
* 才可以判断是否登录成功,回调相关的callback
* @param activity
* @return 如果当前页面和登录相关返回 用户状态数组
* 以此类推,如果都不相关,返回null。
*/

IUserState[] getActivityUserStateType(Activity activity);

/**
* 通过flags获取用户状态
* @param flags
* @return
*/

IUserState[] getUserStateByFlags(int flags);
}

改造BaseActivity


1.要实现View拦截,需要注入ViewFactory2


2.要实现回调。需要在 onPostCreate()onDestroy() 方法中回调框架的方法。


public class BaseActivity extends AppCompatActivity {

private static Activity currentActivity;
protected CompositeDisposable compositeDisposable=new CompositeDisposable();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
//注入factory2
LayoutInflaterCompat.setFactory2(LayoutInflater.from(this), UserStateManager.getLayoutInflaterFactory(this,compositeDisposable, e->{
GZToast.error().show(e.getMessage());
}));
super.onCreate(savedInstanceState);
currentActivity=this;
}

@Override
protected void onResume() {
super.onResume();
currentActivity=this;
}

public static Activity getCurrentActivity() {
return currentActivity;
}


@Override
protected void onPostCreate(@Nullable Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
UserStateManager.getInstance().onActivityPostCreate(this);
}



@Override
protected void onDestroy() {
super.onDestroy();
UserStateManager.getInstance().onActivityDestroy(this);
compositeDisposable.dispose();
}
}

用户状态相关的界面需要实现IUserStatePage接口


package com.zhuguohui.demo.userstate;


import androidx.annotation.Nullable;

/**
* <pre>
* Created by zhuguohui
* Date: 2023/3/1
* Time: 14:07
* Desc:通常由activity实现。
* 表示这个界面和用户的状态的流程有关,比如登录,绑定手机号,实名认证
* 因为一个流程比如登录,可能涉及到多个页面。比如注册,登录,找回密码
* 只有相关的页面全部finish以后,才去检查是否登录成功,回调相关的方法。
* </pre>
*/

public interface IUserStatePage {

/**
* 返回当前页面和那些流程相关
* @return 返回相关用户状态的数组,可以为null
*/

@Nullable
IUserState[] getUserSatePageTypes();
}

将状态管理器设置给框架


/**
* Created by zhuguohui
* Date: 2023/3/26
* Time: 11:18
* Desc:
*/

public class MyApp extends Application {
@Override
public void onCreate() {
super.onCreate();
UserStateManager.getInstance().setUserStateManagerImpl(DemoUserStateManager.getInstance());
}
}

具体代码还得看demo


原理


动态代理


基于开源库


最开始使用的是 下面这个cglib库


CGLib-for-Android


但是这个库有个问题,不支持对没有无参构造函数类的动态代理,我还提了Issues


提问题.png


后来我发现这个库用的是dexmaker于是,直接使用dexmaker


dexmaker


dexmaker可以对只有有参构造函数的类实现动态代理。


但是又遇到下一个问题。动态代理view。有些方法不能被代理


如果一个方法被@UnsupportedAppUsage 注解注释。那么无法通过反射获取。


不支持的方法.png


而我们要代理onClick方法也不需要重写所有方法


所以我在原有的dexmaker的基础上,添加了可以自定义要重写那个方法的功能。



public final class ProxyBuilder<T> {
public interface MethodOverrideFilter{
boolean isOverrideMethod(Method method);
}

MethodOverrideFilter methodOverrideFilter;

public ProxyBuilder<T> setMethodOverrideFilter(MethodOverrideFilter methodOverrideFilter) {
this.methodOverrideFilter = methodOverrideFilter;
return this;
}
}

为了便于使用生成了自己的库。


zhuguohui/Android-Cglib


使用该库实现对setOnClickListener的动态代理。


还有个好处,如果不单独重写这一个方法的话。光是LinearLayout中就要800多个public方法需要重写。每个方法都通过反射调用。性能比较差。


return (View) ProxyBuilder.forClass(viewClass)
.constructorArgTypes(Context.class, AttributeSet.class)
.constructorArgValues(context, attrs)
.dexCache(context.getDir("dx", Context.MODE_PRIVATE))
//只重写setOnClickListener
// 因为android中的View中的一些方式被 @UnsupportedAppUsage注解修饰
//客户端无法通过反射来获取,无法重写
.setMethodOverrideFilter(method -> method.getName().equals("setOnClickListener"))
.handler(new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] objects) throws Throwable {
if(method.getName().equals("setOnClickListener")){
if(objects.length>0&& objects[0] instanceof View.OnClickListener){
View.OnClickListener onClickListener= (View.OnClickListener) objects[0];
objects=new Object[]{new CheckUserStateOnClickListener(context,compositeDisposable,onClickListener,userStateFlags,policy,errorFunction)};

}
}

return ProxyBuilder.callSuper(proxy, method, objects);
}
}).build();

用户状态检测


基于rxjava的ObservableTransformer实现,代码如下。


package com.zhuguohui.demo.userstate.transform;

import android.content.Context;
import android.os.Looper;

import com.zhuguohui.demo.userstate.IUserState;
import com.zhuguohui.demo.userstate.UserStateCallBack;
import com.zhuguohui.demo.userstate.UserStateCheckException;
import com.zhuguohui.demo.userstate.manager.UserStateManager;
import com.zhuguohui.demo.userstate.policy.UserStateCheckPolicy;

import java.util.Arrays;
import java.util.List;

import io.reactivex.Observable;
import io.reactivex.ObservableOnSubscribe;
import io.reactivex.ObservableSource;
import io.reactivex.ObservableTransformer;
import io.reactivex.Scheduler;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;


/**
* <pre>
* Created by zhuguohui
* Date: 2023/2/28
* Time: 15:30
* Desc:
* </pre>
*/

public class UserStateTransform<T> implements ObservableTransformer<T, T> {
private Context context;
private List<IUserState> userStateList;
private UserStateCheckPolicy policy;

public UserStateTransform(Context context,int userStateFlags) {
this(context,false, userStateFlags);
}

public UserStateTransform(Context context,boolean justCheck, int userStateFlags) {
this(context,justCheck ? UserStateCheckPolicy.justCheck : UserStateCheckPolicy.autoGo, userStateFlags);

}

public UserStateTransform(Context context,UserStateCheckPolicy policy,int userStateFlags) {

this.context=context;
IUserState[] states = UserStateManager.getInstance().getUserStateByFlags(userStateFlags);
userStateList=Arrays.asList(states);
this.policy = policy == null ? UserStateCheckPolicy.autoGo : policy;

}

private static Object object = new Object();
Scheduler scheduler;

@Override
public ObservableSource<T> apply(Observable<T> upstream) {
//先验证用户状态
return Observable.just(object)
.doOnNext(obj -> scheduler = getCallSchedulers())
.flatMap(obj->{
Observable<Object> next=Observable.just(obj);
for(IUserState userState:userStateList){
next=next.flatMap(o->matchUserState(o,userStateList,userState));
}
return next;
})
.flatMap(obj -> upstream) //全部验证通过才订阅上游
;

}

/**
* 获取上游的Scheduler,这样在回调的时候切换回原先的Scheduler
*
* @return
*/

private Scheduler getCallSchedulers() {
if (Looper.myLooper() == Looper.getMainLooper()) {
return AndroidSchedulers.mainThread();
} else {
return Schedulers.io();
}
}


private Observable<Object> matchUserState(Object t, List<IUserState> userStateList, IUserState userState) {
boolean needMatch = userStateList.contains(userState);
if (needMatch && !UserStateManager.getInstance().isMatchUserState(userState)) {
if (policy == UserStateCheckPolicy.autoGo) {
return Observable.create((ObservableOnSubscribe<Object>) emitter -> {
UserStateManager.getInstance().doMatchUserState(context,userState, new UserStateCallBack() {
@Override
public void onMath() {
scheduler.scheduleDirect(() -> {
//登录成功
emitter.onNext(t);
emitter.onComplete();
});

}

@Override
public void onCancel() {
scheduler.scheduleDirect(() -> {
emitter.onError(new UserStateCheckException(userState));
});

}
});
});
} else {
return Observable
.error(new UserStateCheckException(userState))
.observeOn(scheduler);
}
} else {
return Observable
.just(t)
.subscribeOn(scheduler);
}
}

}

作者:solo_99
来源:juejin.cn/post/7218244395451727930
收起阅读 »

集成环信推送要注意哪些事项?

环信即时通讯 IM 支持集成第三方厂商的消息推送服务,为 Android 开发者提供低延时、高送达、高并发、不侵犯用户个人数据的离线消息推送服务。当客户端应用进程被关闭等原因导致用户离线,环信即时通讯 IM 服务会通过第三方厂商的消息推送服务向该离线用户的设备...
继续阅读 »

环信即时通讯 IM 支持集成第三方厂商的消息推送服务,为 Android 开发者提供低延时、高送达、高并发、不侵犯用户个人数据的离线消息推送服务。

当客户端应用进程被关闭等原因导致用户离线,环信即时通讯 IM 服务会通过第三方厂商的消息推送服务向该离线用户的设备推送消息通知。当用户再次上线时,会收到离线期间所有消息。

目前支持的手机厂商推送服务包括:Google、华为、小米、OPPO、VIVO 和魅族。本文介绍在客户端应用中实现各厂商的推送服务时需要注意的事项。


1.初始化注意事项

参考文档中提供的案例
这部分操作是在环信初始化的时候来进行的
注意:
1.EMoptions 一定不要重复创建对象 如果重复创建,是不会出现对象重复的报错,因此会导致初始化的时候绑定不上证书 ,所以这块要特别注意
2.FCM推送需要注意事项
(1)FCM推送上传


此图的json文件是需要上传到环信管理后台的并不是给客户端的json
(2)options.enableFCM("") 参数值就是在环信管理后台上传的SenderID

关于客户端绑定的id 在我们的 google-service.json文件中project_number
(3) 如果在接收推送的时候没有出现通知,可以检查下 是否已经在EMFCMMSGService中自己写入了本地通知,环信只为FCM做了一个唤醒需要您自己做本地通知进行展示,如果没有看到通知 但是接收方是有唤醒的,也视为成功,自己评判不准确可以提供接收方的日志提供给环信技术支持查看下
3.华为推送注意事项
(1)华为的通知在app未上架之前的通知级别默认是不重要通知(运营通知) 只有上线以后才可以自己定义界别 其次可能是因 为推送的标题的限制直接到不重要通知中,
(2)支持自定义铃声 华为设备必须安装2.6+以上华为移动服务 还需要开启当前app的启动权限
(3)清单文件中声明appid <meta-data android:name="com.huawei.hms.client.appid" android:value="appid=填写开发者的appid" />
(4)证书上传在管理后台要上传 OAuth2.0客户端 中Client Secret

(5)华为厂商集成 需要 导入agconnect-services.json文件 以及 环信封装好的类导入到项目中(HMSPushHelper) 需要在初始化以后 在Mainactivty的onCreate中 引用 HMSPushHelper.getInstance().getHMSToken(this);

HMSPushHelper在环信demo中有提供直接将此类拖入到您的项目中即可使用
(6)华为推送的通知进入到营销通知 去华为官网按照这个看下自动类权益设置一下


4.OPPO推送注意事项

1.环信管理后台截图
(1)OPPO集成的时候 上传证书环信管理后台上传的是MasterSecret 而客户端(AS初始化)上绑定的是AppSecret

2. oppo官网Master位置截图

(1)客户端绑定证书是Appsecret 调用 enableOppoPush("appkey","appSecret");

(2) SDK 3.7.1-升3.9.0级到 2.1.0 版本初始化添加(HeytapPushManager.init(context,true)), 如果OPPO aar版本为 3.0.0 环信sdk需要升级大于或等于3.9.1 OPPO的通知也会归纳到不重要级别 具体配置需要在OPPO控制台进行自己配置
(样例图中展示SDK是3.8.5 所以使用的oppo2.1.0.aar)

5.VIVO集成注意事项
(1)vivo 集成 3.9.1或以上需要升级推送版本到3.0.0.4_484,vivo默认是推送是运营消息 重新上传证书需要重新配置,jar包需要放在libs幕布下并sync
(2)vivo需要上架app后才能收到离线推送

更多详细攻略请查看推送文档地址:https://docs-im-beta.easemob.com/document/android/push.html
收起阅读 »