注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

BaseUrlManager for Android 的设计初衷主要用于开发时,有多个环境需要打包APK的场景

BaseUrlManagerBaseUrlManager for Android 的设计初衷主要用于开发时,有多个环境需要打包APK的场景,通过BaseUrlManager提供的BaseUrl动态设置入口,只需打一 次包,即可轻松随意的切换不同的开发环境或测试...
继续阅读 »


BaseUrlManager

BaseUrlManager for Android 的设计初衷主要用于开发时,有多个环境需要打包APK的场景,通过BaseUrlManager提供的BaseUrl动态设置入口,只需打一 次包,即可轻松随意的切换不同的开发环境或测试环境。在打生产环境包时,关闭BaseUrl动态设置入口即可。

妈妈再也不用担心因环境不同需要打多个包的问题,从此告别环境不同要写一堆配置的烦恼,真香。

配合 RetrofitHelper 动态改变BaseUrl一起使用更香。

Gif 展示

Image

引入

Maven:

<dependency>
<groupId>com.king.base</groupId>
<artifactId>base-url-manager</artifactId>
<version>1.1.1</version>
<type>pom</type>
</dependency>

Gradle:


//AndroidX 版本
implementation 'com.king.base:base-url-manager:1.1.1'

//-----------------------v1.0.x以前的版本
//AndroidX 版本
implementation 'com.king.base:base-url-manager:1.0.1-androidx'

//Android Support 版本
implementation 'com.king.base:base-url-manager:1.0.1'

Lvy:

<dependency org='com.king.base' name='base-url-manager' rev='1.1.1'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>
如果Gradle出现implementation失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来implementation)
allprojects {
repositories {
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

示例

集成步骤代码示例 (示例出自于app中)

Step.1 在您项目中的AndroidManifest.xml中通过配置meta-data来自定义全局配置

    <!-- 在你项目中添加注册如下配置 -->
<activity android:name="com.king.base.baseurlmanager.BaseUrlManagerActivity"
android:screenOrientation="portrait"
android:theme="@style/BaseUrlManagerTheme"/>

Step.2 在您项目Application的onCreate方法中初始化BaseUrlManager

    //获取BaseUrlManager实例(适用于v1.1.x版本)
mBaseUrlManager = BaseUrlManager.getInstance();

//获取BaseUrlManager实例(适用于v1.0.x旧版本)
mBaseUrlManager = new BaseUrlManager(this);

//获取baseUrl
String baseUrl = mBaseUrlManager.getBaseUrl();

Step.3 提供动态配置BaseUrl的入口(通过Intent跳转到BaseUrlManagerActivity界面)

v.1.1.x 新版本写法

   BaseUrlManager.getInstance().startBaseUrlManager(this,SET_BASE_URL_REQUEST_CODE);

v1.0.x 以前版本写法

    Intent intent = new Intent(this, BaseUrlManagerActivity.class);
//BaseUrlManager界面的标题
//intent.putExtra(BaseUrlManagerActivity.KEY_TITLE,"BaseUrl配置");
//跳转到BaseUrlManagerActivity界面
startActivityForResult(intent,SET_BASE_URL_REQUEST_CODE);

Step.4 当配置改变了baseUrl时,在Activity或Fragment的onActivityResult方法中重新获取baseUrl即可


//方式1:通过BaseUrlManager获取baseUrl
String baseUrl = BaseUrlManager.getInstance().getBaseUrl();
//方式2:通过data直接获取baseUrl
UrlInfo urlInfo = BaseUrlManager.parseActivityResult(data);
String baseUrl = urlInfo.getBaseUrl();

更多使用详情,请查看app中的源码使用示例或直接查看API帮助文档

BaseUrlManager.zip

收起阅读 »

Android沙雕操作之hook Toast

一,背景 这是个沙雕操作,原因是:在小米手机的部分机型上,弹Toast时会在吐司内容前面带上app名称,如下: 此时产品经理发话了:为了统一风格,在小米手机上去掉Toast前的应用名。 网上有以下解决方案,比如:先给toast的message设置为空...
继续阅读 »

一,背景


这是个沙雕操作,原因是:在小米手机的部分机型上,弹Toast时会在吐司内容前面带上app名称,如下:


1.gif


此时产品经理发话了:为了统一风格,在小米手机上去掉Toast前的应用名。


网上有以下解决方案,比如:先给toastmessage设置为空,然后再设置需要提示的message,如下:


Toast toast = Toast.makeText(context, “”, Toast.LENGTH_LONG);
toast.setText(message);
toast.show();

但这些都不能从根本上解决问题,于是Hook Toast的方案诞生了。


二,分析


首先分析一下Toast的创建过程.


Toast的简单使用如下:


Toast.makeText(this,"abc",Toast.LENGTH_LONG).show();

1,构造toast


通过makeText()构造一个Toast,具体代码如下:


public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
@NonNull CharSequence text, @Duration int duration)
{
if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
Toast result = new Toast(context, looper);
result.mText = text;
result.mDuration = duration;
return result;
} else {
Toast result = new Toast(context, looper);
View v = ToastPresenter.getTextToastView(context, text);
result.mNextView = v;
result.mDuration = duration;

return result;
}
}

makeText()中也就是设置了时长以及要显示的文本或自定义布局,对Hook什么帮助。


2,展示toast


接着看下Toast的show():


public void show() {
...

INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
final int displayId = mContext.getDisplayId();

try {
if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
if (mNextView != null) {
// It's a custom toast
service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
} else {
// It's a text toast
ITransientNotificationCallback callback =
new CallbackBinder(mCallbacks, mHandler);
service.enqueueTextToast(pkg, mToken, mText, mDuration, displayId, callback);
}
} else {
// 展示toast
service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
}
} catch (RemoteException e) {
// Empty
}
}

代码很简单,主要是通过serviceenqueueToast()enqueueTextToast()两种方式显示toast。


service是一个INotificationManager类型的对象,INotificationManager是一个接口,这就为动态代理提供了可能。


service是在每次show()时通过getService()获取,那就来看看getService():


@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
private static INotificationManager sService;

@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
static private INotificationManager getService() {
if (sService != null) {
return sService;
}
sService = INotificationManager.Stub.asInterface(
ServiceManager.getService(Context.NOTIFICATION_SERVICE));
return sService;
}

getService()最终返回的是sService,是一个懒汉式单例,因此可以通过反射获取到其实例。


3,小结


sService是一个单例,尅反射获取到其实例。


sService实现了INotificationManager接口,因此可以动态代理。


因此可以通过Hook来干预Toast的展示。


三,撸码


理清了上面的过程,实现就很简单了,直接撸码:


1,获取sService的Field


Class<Toast> toastClass = Toast.class;

Field sServiceField = toastClass.getDeclaredField("sService");
sServiceField.setAccessible(true);

2,动态代理替换


Object proxy = Proxy.newProxyInstance(Thread.class.getClassLoader(), new Class[]{INotificationManager.class}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

return null;
}
});
// 用代理对象给sService赋值
sServiceField.set(null, proxy);

3,获取sService原始对象


因为动态代理不能影响被代理对象的原有流程,因此需要在第二步的InvocationHandler()invoke()中需要执行原有的逻辑,这就需要获取sService的原始对象。


前面已经获取到了sService的Field,它是静态的,那直接通过sServiceField.get(null)获取不就可以了?然而并不能获取到,这是因为整个Hook操作是在应用初始化时,整个应用还没有执行过Toast.show()的操作,因此sService还没有初始化(因为它是一个懒汉单例)。


既然不能直接获取,那就通过反射调用一下:


Method getServiceMethod = toastClass.getDeclaredMethod("getService", null);
getServiceMethod.setAccessible(true);
Object service = getServiceMethod.invoke(null);

接着完善一下第二步代码:


Object proxy = Proxy.newProxyInstance(Thread.class.getClassLoader(), new Class[]{INotificationManager.class}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

return method.invoke(service, args);
}
});

到此,已经实现了对Toast的代理,Toast可以按照原始逻辑正常执行,但还没有加入额外逻辑。


4,添加Hook逻辑


InvocationHandlerinvoke()方法中添加额外逻辑:


Object proxy = Proxy.newProxyInstance(Thread.class.getClassLoader(), new Class[]{INotificationManager.class}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 判断enqueueToast()方法时执行操作
if (method.getName().equals("enqueueToast")) {
Log.e("hook", method.getName());
getContent(args[1]);
}
return method.invoke(service, args);
}
});

args数组的第二个是TN类型的对象,其中有一个LinearLayout类型的mNextView对象,mNextView中有一个TextView类型的childView,这个childView就是展示toast文本的那个TextView,可以直接获取其文本内容,也可以对其赋值,因此代码如下:


private static void getContent(Object arg) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
// 获取TN的class
Class<?> tnClass = Class.forName(Toast.class.getName() + "$TN");
// 获取mNextView的Field
Field mNextViewField = tnClass.getDeclaredField("mNextView");
mNextViewField.setAccessible(true);
// 获取mNextView实例
LinearLayout mNextView = (LinearLayout) mNextViewField.get(arg);
// 获取textview
TextView childView = (TextView) mNextView.getChildAt(0);
// 获取文本内容
CharSequence text = childView.getText();
// 替换文本并赋值
childView.setText(text.toString().replace("HookToast:", ""));
Log.e("hook", "content: " + childView.getText());
}

最后看一下效果:


2.gif


四,总结


这个一个沙雕操作,实际应用中这种需求也比较少见。通过Hook的方式可以统一控制,而且没有侵入性。大佬勿喷!!!



作者:giswangsj
链接:https://juejin.cn/post/6982114329889865741
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

未勾选用户协议、隐私政策实现抖动效果

这是我参与新手入门的第2篇文章 产品看到别家的app,未勾选协议的时候,会给用户一个抖动效果的提示,感觉不错,然后看了看自家的app,不行,没有抖动,不能很明显表示,于是需求出来了,用户未勾选的时候,给个抖动效果。( 呵,都不能有点创新,当然不能说出来...
继续阅读 »

这是我参与新手入门的第2篇文章



产品看到别家的app,未勾选协议的时候,会给用户一个抖动效果的提示,感觉不错,然后看了看自家的app,不行,没有抖动,不能很明显表示,于是需求出来了,用户未勾选的时候,给个抖动效果。( 呵,都不能有点创新,当然不能说出来了,只能内心暗说,哈哈,给自己加了点戏,)正事来了,开始。。。干,就完了。




如果需要实现用户协议、隐私政策的代码,请看这篇文章:juejin.cn/post/698126…



实现功能大概需要三个步骤:



一、 用什么实现;二、实现的步骤;三、运行效果



一、用什么实现



其实实现起来很简单,用补间动画就行了。



二、实现的步骤


这里说下实现补间动画的步骤:总共需要以下几个步骤


1.如果res目录下没有anim文件,就新建一个文件夹; image.png 2.在anim文件夹下创建一个名字叫translate_checkbox_shake.xml的文件,抖动动画


<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="300"
android:fromXDelta="0"
android:interpolator="@anim/cyc"
android:toXDelta="30">
</translate>

再在anim下创建一个插值器,名字叫cyc,这样会有抖动效果


<?xml version="1.0" encoding="utf-8"?>
<cycleInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
android:cycles="2">
</cycleInterpolator>

3.在translate_checkbox_shake.xml里写上需要的动画属性;


android:duration="300"与android:cycles="2"联合表示在300毫秒内将动画执行2次,根据需求来设置就行了;


属性toXDelta和fromXDelta是横向效果,toYDela和fromYDelta是竖向,感兴趣的可以尝试下。、


4.在代码中使用 AnimationUtils.loadAnimation加载新创建的动画文件; image.png


 val animation = AnimationUtils.loadAnimation(this, R.anim.translate_checkbox_shake)

5.在代码中使用View的startAnimation启动动画,完事


 binding.llShake.startAnimation(animation)

三、效果如下:


20210704160630743.gif


作者:JasonYin

链接:https://juejin.cn/post/6981998698330849287
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

电子厂里撂了挑子,我默默自学起了Android|2021年中总结

大四那年我被骗到了电子厂,无法忍受流水线的工作,愤而撩了挑子。前途一片渺茫的时候,我连夜爬起自学起了Android,开启了我的Android开发之路。至今已毕业多年,一直在这条热爱的道路上坚持着,快乐、知足、感恩。 分享我的故事之前,先简单回顾一下我这半年都...
继续阅读 »

大四那年我被骗到了电子厂,无法忍受流水线的工作,愤而撩了挑子。前途一片渺茫的时候,我连夜爬起自学起了Android,开启了我的Android开发之路。至今已毕业多年,一直在这条热爱的道路上坚持着,快乐、知足、感恩。


分享我的故事之前,先简单回顾一下我这半年都干了啥。


这半年


年初看到了一篇文章《我的 2020 年终总结》,深受感染。作者杰哥在2020一整年,始终坚持日更输出,拿到了多个平台的证书和奖杯。同时还学做了多道菜品,期间还坚持健身和旅游放松。一年同样是365天,别人竟过得如此充实、如此精彩!


钦佩之余我不禁陷入了思考,联想到了自己。忽然意识到自高考以后,总是间歇性踌躇满志无疾而终,太久没有为一个目标而坚持了。 我想好好做成一件事情,我要给自己定个目标。我擅长Android开发,那就坚持写作,保证一两个礼拜输出一篇高质量文章。


一则将自己用心打磨的东西分享出来,帮助别的开发者;二来利用持续的输出倒逼自己不断地摄入新知识,迫使我持续学习,养成终生学习、定期总结的好习惯。但分享给大家看的东西不比私人笔记,需要注意很多细节,诸如深入的理解、通俗的讲解、友好的排版等等。


为此我做了很多准备,潜心学习了很多优质文章的行文风格、目录次序、MarkDown语言以及一堆作图工具。接着删除了手机、平板里的游戏和视频软件等一切时间杀手。另外收集了大量Android相关的优质话题。并买了个专业的待办事项App,用来随时记录新的灵感,高效地安排每篇文章的写作计划。万事俱备,一月底的时候就开始了半学习、半摸索的写作之路。


写了一些文章


半年不到的时间内我输出了十四篇技术文章和三篇随笔。技术文章主要聚焦在Android领域比较流行的话题,比如持续火爆的Jetpack框架,重大UI变革的Compose 工具包,即将发布的Android 12系统以及国人热捧的鸿蒙系统。



被多个官方转载


自High的文章没有价值,好在我不是自我感动,写的文章被多个官方平台转载。【深度解读Jetpack框架的基石-AppCompat】是第一篇被Google转载的文章,我很激动、也很意外。因为那是今年输出的第一篇文章,排版和措辞都略显粗糙。很感谢他们提供的平台,这些认同让我坚定了写作方向。


2篇文章被Android官方公众号转载:



3篇文章被CSDN官方公众号转载:



1篇文章被搜狐技术公众号分享:



1篇文章被掘金官方公众号转载:



额外赞扬一波掘金平台,上面的高质量文章很多,技术氛围很好。我在这里读到了很多优质文章,也结识了很多优秀作者。而且相较其他平台,掘金对于新人更加友好,只要你的文章认真、质量过关,掘金不会吝啬曝光量。我入驻掘金的时间不长,但前两个月都闯进了移动端前二十的作者榜单,比心。



特别感谢鸿神


从事Android工作以来,拜读过鸿神的很多文章,但并不认识。写文章这段时间与鸿神有了多次交流,在钦佩他技术厉害的同时,更感受到他为人的Nice。很感激他的个人公众号转载过我多篇文章,给予的帮助。



接受认可以及批评


当然,输出文章的初衷还是希望对大家有所帮助。欣慰的是文章受到了很多积极的评价:有留下“全网最佳”评价的朋友,也有专门加我好友跟我道谢的朋友。你们的认可是我持续输出的最大动力。



有赞扬自然也有批评,有些朋友说我某个知识点没提到、评价Demo难以理解、吐槽技术点过时。。。真的,我诚恳接受每个批评,将努力发掘和改正这些不足。


我沉迷于将一个技术点一次性讲清楚,又常常选取一个大的话题,最终导致文章的篇幅都很大。这又需要准备很长时间,而这些时间都来源于工作、生活之余的零碎片段。思路非常很容易被打断,一不小心就错过某个细节,或者代码写得仓促,请大家多多包涵。


那年高考


回到文章的标题上来,回顾下我与Android结缘的心路历程,这还得从那年高考讲起。


高考已过十年有余,那会儿的江苏高考已经很卷,一年一度的新政策搞得我们无所适从。还好我高二那年一鼓作气,势如破竹拿下小四门全A。可惜高考的时候还是大意了,即便我侥幸冲破了葛军神卷的围堵,还是栽在了语文作文上。不会出问题的化学还是出了问题,痛失了6A。在双重失利的情况下,艰难地挺过了一本线。


与理想的211大学失之交臂后,只能在一众双非大学里碰碰运气了。路过江苏大学招生座位的时候,他们的老师对我兴趣十足,想跟我签订个志愿协议:保证能上他们学校的四个好专业之一,最终录取则要按照我定的顺序来。他提供了车辆工程机械工程电气工程电子信息工程这几个专业,事实上这个顺序已经按照分数线进行了由高到低的排名。


爸爸和我在前一分钟还不知道江苏有个不在南京的江苏大学(散装江苏还真不是说笑的)。我们对于这个大学和这些专业完全不了解,彻底犯了难。不知道怎么选,更不知道怎么排序。在这重要的抉择时刻,爸爸把选择权交给了我,让我按照自己的想法来(内心OS:呐,你自己选哦,选错了别怨我)。


面对这一众陌生又熟悉的名词,稚嫩的高三学生开始了他的内心戏:



  • 车辆工程?机械工程?是要学修车吗,还是做拖拉机,摩托车啥的,还是不要了吧

  • 电气工程?是学做电工吗,上电线杆修变压器的那种?但跟我喜欢的物理貌似有点关系,还不错

  • 电子信息工程?电子?电路?芯片?手机?手机能打电话、发短信、玩游戏,高端、有意思,就它了


所以我在协议上郑重写下了:电子信息工程 > 电气工程 > 机械工程> 车辆工程。是的,我把顺序完美调了个头,哈哈。爸爸看到这个完全颠倒的顺序后,一脸疑惑,隐约不安。但确认了我坚定无比的眼神后,欲言又止,不想耽误我的远大前程。



12-widget

结果可想而知,毫无悬念地被江苏大学电子信息工程专业成功录取。进入学校后我才了解到这几个专业的真实情况后,心里直呼草率了,捂脸。


我的大学



电子信息工程专业确如我猜想的那样,跟芯片有关系。除此之外,还跟通信、操作系统密不可分。要学的知识点超级多:有令人头皮发麻的数电模电、单片机,需要记忆一堆公式的通信原理,C语言、Java语言和数据库。一句话,很多很散,复杂且枯燥。完全不是我想象中手机的有趣样子,自然是提不起一点兴趣。


加上高中老师“认真学,到大学就解放了” 的反复洗脑深深地影响了我,便开始混日子。翘课是常有的事,连高等数学挂科了,都没激起我内心的一点涟漪。现在想来也不赖高中老师,这就是给自己的懒惰找的借口,哈哈。


玩命地打工


考研是不可能考研的,进大学的时候我就笃定了毕业后直接参加工作,去挣钱。工作需要什么?当时的我浅薄地以为,表达能力、处事能力这些社交素质才是最重要的。可这些本事,学校里不教啊。那就到社会中去,去打工,玩命地打工,还能挣到零花钱。


在这样的“指导思想”下,大学的寒暑假,几乎都在打工中度过。前前后后在台湾仁宝代工厂做过工人,在日本妮飘面纸厂做过保安,在苏宁电器卖过步步高手机(那一整个暑假,耳朵都被宋慧乔的广告插曲统治着)。。。



多份打工的体验,让我待人接物变得更加自信、接触新的环境也更加的从容,好像确实提升了所谓的社交素质。但让我感受最深的是,很多工作真的不容易,大学里不愁吃穿、只要顾好学习一件事情的生活真的太珍贵了,可那时候就是没有毅力去珍惜。


肆意的青春


大学里特别迷恋某位明星,就跟着一起痴迷Hipop文化。喜欢的歌以说唱为主,看的书都是日韩、港台潮流杂志,外在就更“嘻哈”了:染一头金色头发、打个“钻石”耳钉、戴个夸张的耳环、穿一套炸街的嘻哈服装。从里到外都很Real,简直就是学院里最靓的仔。那个时候Hipop没现在火,知道和接受的人很少,我在他们眼中特别另类,但我不Care。打工得来的大部分钱也都花在了置办这些行头上,在淘宝还不流行的年代买成了淘宝的五星买家。



12-widget

看似充实的大学生活,难掩空洞和无聊。除了帝国时代文明的陪伴,就通过画画、练字来排遣这无病呻吟的时光。


大四了还去电子厂装电路板?


浑浑噩噩地熬到了大四,终于到检验我社交才能的时候了。信心满满地参加了多个宣讲会,最后竟没有一家企业欣赏我“名企”的兼职经历,连笔试机会都不给啊。接连遭受企业的无情毒打,我才认识到专业成绩和基础仍然是企业最看重的东西。 可这就被动了,书本这一块早就被我放弃了。当年可是村里的高考状元啊,要是连工作都没找到就太丢人了!这种焦虑的状况持续了一个多月。



12-widget

工作还得继续找啊!痛定思痛,开始仔细地分析。恶补成绩和基础已经不可能了,那就去整点硬核的实习经验,在专业经验这块弯道超车。 恰好一个电子公司到学校招实习生,说是画PCB电路板子,还发正规的实习证书。这简直是雪中送炭,不拿工资我也得去啊。


到了之后就傻眼了,压根不是想像中的电子公司,而是一家装配电瓶车充电器的电子厂。算嘞,既来之则安之,给我画电路图就行。可他们让我们一帮学生到流水线上组装电路板,就是左手拿电阻右手拿二极管,在快速转动的传送带上放元件!过分!


才练习了半小时就得全部上流水线,我手忙脚乱地忙到几乎崩溃。联想到之前在代工厂的打工经历,心里直犯嘟哝:这哪是实习,分明就是打零工嘛,干上一年我还是找不到工作啊,简直就是在浪费时间! 我越想越气,越气装得越乱,越乱越被骂。情绪被逼到了极点,我甩开了电路板子,气呼呼地跟领班说:我,不干了!


管不了工人们鄙视的眼神,我像逃兵一样跑了出来,钻上了回学校的公交。一路上都在跟自己较劲:你就这么跑了对吗?这点苦都受不了以后能干好什么?跟爸妈吹嘘的实习证书又该怎么办?


复杂的情绪笼罩了一整天,直到晚上睡觉,还在为这事犯愁。


Android给了我曙光


躺在床上,思绪不禁回到了三年前。那时的我对手机兴趣满满,选择了这个专业。如今专业四年即将划上终点,而当初的梦想却未曾踏出半步。 惆怅之余看了眼身旁的HTC G14手机,突然想起店员曾说过它搭载了时下最火的Android智能系统。又回想起学校里曾经有过Android开发的培训广告,我不禁两眼放光:手机我有了,正好是这个最火的Android系统,那干嘛不开发个软件试试呢?如果能开发个完整的App,简历里、面试时不就有东西可说了嘛!


想罢,立马从床上爬起来搜索关于Android开发的一切。那个年代Android Studio还没发布,开发资料更少得可怜。庆幸我学习能力还不错,顺利地装好了驱动、打开了开发者模式、搭好了EclipseSDK环境,这时候已经到了深夜。当G14成功运行了Hello world的时候,我情不自禁地炸了一句“Yeah”,气得舍友直骂娘。那一刻我兴奋不已,因为我感觉找对了方向。


网上的资料少且零碎,第二天一早就去图书馆找相关书籍。谢天谢地,还真有一本Android相关的书。我抱着手里的“圣经”,虔诚地学习了各种控件的使用,小心翼翼地倒腾了两天,终于搞出了一个播放本地mp3的播放界面。看到这有模有样的成果,成就感爆棚。于是乘胜追击,加了很多小功能:音乐封面、上下首、播放模式、文件列表、主题切换、启动画面等等。


大概又搞了一个礼拜,一个完整的音乐App成型了。我把杰作安装到G14上,随身携带。面试的时候时不时拿出来演示一番,顺带着复述着那些似懂非懂的API。 那个年代懂Android的人很少,我如愿以偿地找到了Android开发工作。我清晰地记得拿到Offer后,爸爸在电话那头的兴奋。在他们不看好的方向上获得成功、受到认可的感觉真得很棒!


打那以后,我对Android的兴趣一发不可收拾。在学校的最后一点时光里,总忍不住开发个小Demo把玩把玩,时不时地刷个新Rom体验体验。G14很快就被折腾不行了,对我而言这是一部意义非凡的手机,多次搬家都不忍丢弃。 如今那个启蒙App早已找不着了,很想找来跑一跑,康康当时写的代码有多烂、界面有多丑,哈哈。


社会人


说不清是音乐App助我找到了工作,还是自学Android的热情打动了公司,给了我机会。


我有幸一直从事品牌手机的ROM开发工作,从开发第三方App、到修改系统App、再到定制Framework;从面向Bug编程、到面向对象编程、再到面向产品编程,一晃已过了七年!


临笔前特地到官网瞅了一眼这些年开发过的Android设备,有20多部。当这么多部造型各异的手机和平板,平铺在电脑面前时,回忆历历在目、感慨不已。


成长为安卓老兵的同时,外在也不可抗拒地发生变化。发际线渐渐失守,眼镜戴上就摘不下来了,身形也渐渐走样。好像也不全是坏事,它们提醒着我在工作、生活、学习的同时,时刻关注身体健康。



半山腰回望


如果大四那年没有在电子厂里撂挑子,我大概率不会自学Android。可能最终也能找着工作,但极有可能不会从事我如今热爱的Android行业。


我很荣幸参与和见证了这个行业的发展,这些年它变化太快,像是一场狂欢。从颠覆移动领域的变革时代,到移动互联的红利时代,再到如今内卷严重的存量时代,各方都在努力地维持或改变:



  • 巨头们在不断调整战略:Google通过GMS想方设法地控制Android系统,厂商们在同质化严重的Android设备里寻求亮点和突破,在传统设备以外持续探索和开发新的赛道。。。

  • 开发者们亦疲于奔命:应对各种快速迭代的新技术,应付各种碎片化ROM的适配,苦于前端、跨平台技术的蚕食。。。


移动互联的落寞必然引发Android市场的紧缩,企业对于Android群体的要求将持续拉高,Android开发的内卷加剧则是不争的事实。 如果热爱Android、对Android仍有信心,时刻保持技术人的好奇心和探索欲吧,对新技术以及新领域:



  • AABJetpackKotlinComposeFlutter。。。

  • 革新的智能座舱、划时代的自动驾驶、万物互联的鸿蒙、一统Android和Chrome OS的Fuchsia。。。



最后一点碎碎念



I always knew what the right path was. Without exception, l knew, but l never took it. You know why ? lt was too damn hard.



这是我最喜欢的电影《闻香识女人》里迈克中校的感人自白:“无一例外,我永远知道哪条路是对的。但我从来不走,因为太XX难了”。知易行难,这无疑是古今中外、亘古不变的难题。它关乎的东西太多:改变自律坚持成长,哪一个都不好对付。


如今的我早已被生活磨平了棱角,渐渐丢掉了当年的那份冲劲和激情。但每每想起当年那个敢于说不、熬夜自学的我,感慨之余多了一份坚持。


也许你也曾踌躇满志、无疾而终,记得想想最初的自己,你会找到那个答案。


正值毕业季,祝福即将踏入社会的新朋友,以及社会中浮沉的老朋友,都有个淋漓尽致的人生!



作者:TechMerger
链接:https://juejin.cn/post/6982002538069360676
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


收起阅读 »

想搞懂Jetpack架构可以不搞懂生命周期知识吗?

1. 前言 Activity生命周期真是一个非常古老的话题,无论是10年前,还是当下。不管是面试还是工作,经常会遇到与Activity生命周期相关的问题。比如“按下返回键和Home键,生命周期方法调用顺序”、“A启动B,它们的生命周期方法调用顺序”。工作中,...
继续阅读 »

1. 前言


Activity生命周期真是一个非常古老的话题,无论是10年前,还是当下。不管是面试还是工作,经常会遇到与Activity生命周期相关的问题。比如“按下返回键和Home键,生命周期方法调用顺序”、“A启动B,它们的生命周期方法调用顺序”。工作中,Jetpack Lifecycle、LiveData、ViewModel等组件都是建立在生命周期之上。


在我研究Jetpack Lifecycle、LiveData、ViewModel源码时,我发现它们与组件的生命周期有很大的关系。它们能够自动感知组件的生命周期变化。LiveData能够在onDestroy方法调用时自动将监听注销掉,ViewModel能够在Configuration发生改变时(比如旋转屏幕)自动保存数据,并且在Activity重建时恢复到Configuration发生改变之前。


本文我将从几个场景详细介绍Activity的生命周期变化。


2. 单Activity按返回按钮


触发步骤:



  • 按返回按钮

  • 或者调用finish方法

  • 重新进入Activity


该场景演示了用户启动,销毁,重新进入Activity的生命周期变化。调用顺序如图:


状态管理:



  • onSaveInstanceState没有被调用,因为Activity被销毁,没有必要保存状态

  • 当Activity被重新进入时,onCreate方法bundle参数为null


3. 单Activity按Home键


触发步骤:



  • 用户按Home键

  • 或者切换至其它APP

  • 重新进入Activity


该场景Activity会调用onStop方法,但是不会立即调用onDestroy方法。调用顺序如图:


状态管理:


当Activity进入Stopped状态,系统使用onSaveInstanceState保存app状态,以防系统将app进程杀死,重启后恢复状态。


4. 单Activity旋转屏幕


触发步骤:



  • Configuration发生改变, 比如旋转屏幕

  • 用户在多窗口模式下调整窗口大小


当用户旋转屏幕,系统会保留旋转之前的状态,能很好的恢复到之前的状态。调用顺序如图:


状态管理:



  • Activity被完全销毁掉,但是状态会被保存,而且会在新的Activity中恢复该状态

  • onCreate和onRestoreInstanceState方法中的bundle是一样的


5. 单Activity弹出Dialog


触发步骤:



  • 在API 24+上开启多窗口模式失去焦点时

  • 其它应用部分遮盖当前APP,比如弹出权限授权dialog

  • 弹出intent选择器时,比如弹出系统的分享dialog



该场景不适用于以下情况:



  • 相同APP中弹dialog,比如弹出AlertDialog或者DialogFragment不会导致Activity onPause发生调用

  • 系统通知。当用户下拉系统通知栏时,不会导致下面的Activity onPause发生调用。


6. 多个Activity跳转


触发步骤:



  • activity1 跳转到activity2

  • 按返回按钮



注意:activity1 跳转到activity2 正确的调用顺序是



->activity1.onPause


->activity2.onCreate


->activity2.onStart


->activity2.onResume


->activity1.onStop


->activity1.onSaveInstanceState



在该场景下,当新的activity启动时,activity1处于STOPPED状态下(但是没有被销毁),这与用户按Home键有点类似。当用户按返回按钮时,activity2被销毁掉。


状态管理:



  • onSaveInstanceState会被调用,但是onRestoreInstanceState不会。当activity2展示在前台时,如果发生了旋转屏幕,当activity1再次获得焦点时,它将会被销毁并且重建,这就是为什么activity1在失去焦点时为什么需要保存状态。

  • 如果系统杀死了app进程,该场景后面会介绍到


7. 多个Activity跳转,并且旋转屏幕



  • activity1 跳转到activity2

  • 在activity2上旋转屏幕

  • 按返回按钮



注意: 当返回activity1时,必须保证屏幕是保持旋转后的状态,否则并不会调用onDestroy方法。而且是在activity1回到前台时才会主动掉onDestroy


状态管理:


保存状态对所有的activity都非常重要,不仅仅是对前台activity。所有在后台栈中的activity在configuration发生改变时重建UI时都需要将保存的状态恢复回来。


8. 多个Activity跳转,被系统kill掉app



  • activity1 跳转到activity2

  • 在activity2上按Home键

  • 系统资源不足kill app



9. 总结


本文主要是从Google大佬Jose Alcérreca的文章翻译过来。他假设的这7个关于activity的生命周期场景,对了解Lifecycle有非常大的帮助。甚至对于面试都是有非常大的帮助。


后续我会写一系列关于Jetpack的文章。文风将会延续我的一贯风格,深入浅出,坚持走高质量创作路线。本文是我讲解Lifecycle的开篇之作。生命周期是Lifecycle、LiveDa、ViewModel等组件的基础。在对生命周期知识掌握不牢靠的情况,去研究那些组件,无异于空中楼阁。



作者:字节小站
链接:https://juejin.cn/post/6981965690014007327
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


收起阅读 »

KingPlayer 一个专注于 Android 视频播放器(IjkPlayer、ExoPlayer、VlcPlayer、SysPlayer)的基础库

KingPlayerKingPlayer 一个专注于 Android 视频播放器(IjkPlayer、ExoPlayer、VlcPlayer、SysPlayer)的基础库,无缝切换内核。功能说明 主要播放相关核心功能 播放器无缝切换&nbs...
继续阅读 »

KingPlayer

KingPlayer 一个专注于 Android 视频播放器(IjkPlayer、ExoPlayer、VlcPlayer、SysPlayer)的基础库,无缝切换内核。

功能说明

  •  主要播放相关核心功能
  •  播放器无缝切换
    •  MediaPlayer封装实现(SysPlayer)
    •  IjkPlayer封装实现
    •  ExoPlayer封装实现
    •  vlc-android封装实现
  •  控制图层相关
    •  待补充...

Gif 展示

Image

录制的gif效果有点不清晰,可以下载App查看详情。

引入

gradle:

使用 SysPlayer (Android自带的MediaPlayer)

//KingPlayer基础库,内置SysPlayer
implementation 'com.king.player:king-player:1.0.0-beta1'

使用 IjkPlayer

//KingPlayer基础库(必须)
implementation 'com.king.player:king-player:1.0.0-beta1'
//IjkPlayer
implementation 'com.king.player:ijk-player:1.0.0-beta1'

// 根据您的需求选择ijk模式的so
implementation 'tv.danmaku.ijk.media:ijkplayer-armv7a:0.8.8'
// Other ABIs: optional
implementation 'tv.danmaku.ijk.media:ijkplayer-armv5:0.8.8'
implementation 'tv.danmaku.ijk.media:ijkplayer-arm64:0.8.8'
implementation 'tv.danmaku.ijk.media:ijkplayer-x86:0.8.8'
implementation 'tv.danmaku.ijk.media:ijkplayer-x86_64:0.8.8'

使用 ExoPlayer

//KingPlayer基础库(必须)
implementation 'com.king.player:king-player:1.0.0-beta1'
//ExoPlayer
implementation 'com.king.player:exo-player:1.0.0-beta1'

使用 VlcPlayer

//KingPlayer基础库(必须)
implementation 'com.king.player:king-player:1.0.0-beta1'
//VlcPlayer
implementation 'com.king.player:vlc-player:1.0.0-beta1'

示例

布局示例

    <com.king.player.kingplayer.view.VideoView
android:id="@+id/videoView"
android:layout_width="match_parent"
android:layout_height="match_parent" />

代码示例

        //初始化一个视频播放器(IjkPlayer、ExoPlayer、VlcPlayer、SysPlayer)
videoView.player = IjkPlayer(context)
//初始化数据源
val dataSource = DataSource(url)
videoView.setDataSource(dataSource)

videoView.setOnSurfaceListener(object : VideoView.OnSurfaceListener {
override fun onSurfaceCreated(surface: Surface, width: Int, height: Int) {
LogUtils.d("onSurfaceCreated: $width * $height")
videoView.start()
}

override fun onSurfaceSizeChanged(surface: Surface, width: Int, height: Int) {
LogUtils.d("onSurfaceSizeChanged: $width * $height")
}

override fun onSurfaceDestroyed(surface: Surface) {
LogUtils.d("onSurfaceDestroyed")
}

})

//缓冲更新监听
videoView.setOnBufferingUpdateListener {
LogUtils.d("buffering: $it")
}
//播放事件监听
videoView.setOnPlayerEventListener { event, bundle ->

}
//错误事件监听
videoView.setOnErrorListener { event, bundle ->

}


        
//------------ 控制相关
//开始
videoView.start()
//暂停
videoView.pause()
//进度调整到指定位置
videoView.seekTo(pos)
//停止
videoView.stop()
//释放
videoView.release()
//重置
videoView.reset()

更多使用详情,请查看app中的源码使用示例或直接查看API帮助文档

其他

需使用JDK8+编译,在你项目中的build.gradle的android{}中添加配置:

compileOptions {
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_1_8
}

代码下载:KingPlayer.zip

收起阅读 »

KingKeyboard for Android 是一个自定义键盘

KingKeyboardKingKeyboard for Android 是一个自定义键盘。内置了满足各种场景的键盘需求:包括但不限于混合、字母、数字、电话、车牌号等可输入场景。还支持自定义。集成简单,键盘可定制化。引入Maven:<dependency...
继续阅读 »


KingKeyboard

KingKeyboard for Android 是一个自定义键盘。内置了满足各种场景的键盘需求:包括但不限于混合、字母、数字、电话、车牌号等可输入场景。还支持自定义。集成简单,键盘可定制化。


引入

Maven:

<dependency>
<groupId>com.king.keyboard</groupId>
<artifactId>kingkeyboard</artifactId>
<version>1.0.0</version>
<type>pom</type>
</dependency>

Gradle:

//AndroidX
implementation 'com.king.keyboard:kingkeyboard:1.0.0'

Lvy:

<dependency org='com.king.keyboard' name='kingkeyboard' rev='1.0.0'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>
如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
allprojects {
repositories {
//...
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

自定义按键值


/*
* 在KingKeyboard的伴生对象中定义了一些核心的按键值,当您需要自定义键盘时,可能需要用到
*/

//------------------------------ 下面是定义的一些公用功能按键值
/**
* Shift键 -> 一般用来切换键盘大小写字母
*/
const val KEYCODE_SHIFT = -1
/**
* 模式改变 -> 切换键盘输入法
*/
const val KEYCODE_MODE_CHANGE = -2
/**
* 取消键 -> 关闭输入法
*/
const val KEYCODE_CANCEL = -3
/**
* 完成键 -> 长出现在右下角蓝色的完成按钮
*/
const val KEYCODE_DONE = -4
/**
* 删除键 -> 删除输入框内容
*/
const val KEYCODE_DELETE = -5
/**
* Alt键 -> 预留,暂时未使用
*/
const val KEYCODE_ALT = -6
/**
* 空格键
*/
const val KEYCODE_SPACE = 32

/**
* 无作用键 -> 一般用来占位或者禁用按键
*/
const val KEYCODE_NONE = 0

//------------------------------

/**
* 键盘按键 -> 返回(返回,适用于切换键盘后界面使用,如:NORMAL_MODE_CHANGE或CUSTOM_MODE_CHANGE键盘)
*/
const val KEYCODE_MODE_BACK = -101

/**
* 键盘按键 ->返回(直接返回到最初,直接返回到NORMAL或CUSTOM键盘)
*/
const val KEYCODE_BACK = -102

/**
* 键盘按键 ->更多
*/
const val KEYCODE_MORE = -103

//------------------------------ 下面是自定义的一些预留按键值,与共用按键功能一致,但会使用默认的背景按键

const val KEYCODE_KING_SHIFT = -201
const val KEYCODE_KING_MODE_CHANGE = -202
const val KEYCODE_KING_CANCEL = -203
const val KEYCODE_KING_DONE = -204
const val KEYCODE_KING_DELETE = -205
const val KEYCODE_KING_ALT = -206

//------------------------------ 下面是自定义的一些功能按键值,与共用按键功能一致,但会使用默认背景颜色

/**
* 键盘按键 -> 返回(返回,适用于切换键盘后界面使用,如:NORMAL_MODE_CHANGE或CUSTOM_MODE_CHANGE键盘)
*/
const val KEYCODE_KING_MODE_BACK = -251

/**
* 键盘按键 ->返回(直接返回到最初,直接返回到NORMAL或CUSTOM键盘)
*/
const val KEYCODE_KING_BACK = -252

/**
* 键盘按键 ->更多
*/
const val KEYCODE_KING_MORE = -253

/*
用户也可自定义按键值,primaryCode范围区间为-999 ~ -300时,表示预留可扩展按键值。
其中-399~-300区间为功能型按键,使用Special背景色,-999~-400自定义按键为默认背景色
*/

示例

代码示例

    //初始化KingKeyboard
kingKeyboard = KingKeyboard(this,keyboardParent)
//然后将EditText注册到KingKeyboard即可
kingKeyboard.register(editText,KingKeyboard.KeyboardType.NUMBER)

/*
* 如果目前所支持的键盘满足不了您的需求,您也可以自定义键盘,KingKeyboard对外提供自定义键盘类型。
* 自定义步骤也非常简单,只需自定义键盘的xml布局,然后将EditText注册到对应的自定义键盘类型即可
*
* 1. 自定义键盘Custom,自定义方法setKeyboardCustom,键盘类型为{@link KeyboardType#CUSTOM}
* 2. 自定义键盘CustomModeChange,自定义方法setKeyboardCustomModeChange,键盘类型为{@link KeyboardType#CUSTOM_MODE_CHANGE}
* 3. 自定义键盘CustomMore,自定义方法setKeyboardCustomMore,键盘类型为{@link KeyboardType#CUSTOM_MORE}
*
* xmlLayoutResId 键盘布局的资源文件,其中包含键盘布局和键值码等相关信息
*/
kingKeyboard.setKeyboardCustom(R.xml.keyboard_custom)
// kingKeyboard.setKeyboardCustomModeChange(xmlLayoutResId)
// kingKeyboard.setKeyboardCustomMore(xmlLayoutResId)
kingKeyboard.register(et12,KingKeyboard.KeyboardType.CUSTOM)
 //获取键盘相关的配置信息
var config = kingKeyboard.getKeyboardViewConfig()

//... 修改一些键盘的配置信息

//重新设置键盘配置信息
kingKeyboard.setKeyboardViewConfig(config)

//按键是否启用震动
kingKeyboard.setVibrationEffectEnabled(isVibrationEffectEnabled)

//... 等等,还有各种监听方法。更多详情,请直接使用。
    //在Activity或Fragment相应的生命周期中调用,如下所示

override fun onResume() {
super.onResume()
kingKeyboard.onResume()
}

override fun onDestroy() {
super.onDestroy()
kingKeyboard.onDestroy()
}

相关说明

  • KingKeyboard主要采用Kotlin编写实现,如果您的项目使用的是Java编写,集成时语法上可能稍微有点不同,除了结尾没有分号以外,对应类伴生对象中的常量,需要通过点伴生对象才能获取。
  //Kotlin 写法
var keyCode = KingKeyboard.KEYCODE_SHIFT
  //Java 写法
int keyCode = KingKeyboard.Companion.KEYCODE_SHIFT;

更多使用详情,请查看app中的源码使用示例

代码下载:KeyboardVisibilityEvent.zip

收起阅读 »

WordPOI是一个将Word接口文档转换成JavaBean的工具库

WordPOIWordPOI是一个将Word接口文档转换成JavaBean的工具库,主要目的是减少部分无脑的开发工作。核心功能:将文档中表格定义的实体转换成Java实体对象WordPOI特性说明支持解析doc格式和docx格式的Word文档支持批量解析Word...
继续阅读 »


WordPOI


WordPOI是一个将Word接口文档转换成JavaBean的工具库,主要目的是减少部分无脑的开发工作。

核心功能:将文档中表格定义的实体转换成Java实体对象

WordPOI特性说明

  1. 支持解析doc格式和docx格式的Word文档
  2. 支持批量解析Word文档并转换成实体
  3. 解析配置支持自定义,详情请查看{@link ParseConfig}相关配置
  4. 虽然解析可配置,但因文档内容的不可控,解析转换也具有一定的局限性

只要在文档上定义实体对象时,尽量满足示例文档的规则,就可以规避解析转换时的局限性。

ParseConfig属性说明

属性值类型默认值说明
startTableint0开始表格
startRowint1开始行
startColumnint0开始列
fieldNameColumnint0字段名称所在列
fieldTypeColumnint1字段类型所在列
fieldDescColumnint2字段注释说明所在列
charsetNameStringUTF-8字符集编码
genGetterAndSetterbooleantrue是否生成get和set方法
genToStringbooleantrue是否生成toString方法
useLombokbooleanfalse是否使用Lombok
parseEntityNamebooleanfalse是否解析实体名称
entityNameRowint0实体名称所在行
entityNameColumnint0实体名称所在列
serializablebooleanfalse是否实现Serializable序列化
showHeaderbooleantrue是否显示头注释
headerStringCreated by WordPOI头注释内容
transformationsMap<String,String>需要转型的集合(自定义转型配置)

引入

Maven:

<dependency>
<groupId>com.king.poi</groupId>
<artifactId>word-poi</artifactId>
<version>1.0.1</version>
<type>pom</type>
</dependency>

Gradle:

compile 'com.king.poi:word-poi:1.0.1'

Lvy:

<dependency org='com.king.poi' name='word-poi' rev='1.0.1'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>
如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
allprojects {
repositories {
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

引入的库:

compile 'org.apache.poi:poi:4.1.0'
compile 'org.apache.poi:poi-ooxml:4.1.0'
compile 'org.apache.poi:poi-scratchpad:4.1.0'

如想直接引入jar包可直接点击左上角的Download下载最新的jar,然后引入到你的工程即可。

示例

代码示例 (直接在main方法中调用即可)

        try {

/**
* 解析文档中的表格实体,表格包含了实体名称,只需配置 {@link ParseConfig#parseEntityName} 为 true 和相关对应行,即可开启自动解析实体名称,自动解析实体名称
* {@link ParseConfig}中包含解析时需要的各种配置,方便灵活的支持文档中更多的表格样式
*/
ParseConfig config = new ParseConfig.Builder().startRow(2).parseEntityName(true).build();
WordPOI.wordToEntity(Test.class.getResourceAsStream("Api3.docx"),false,"C:/bean/","com.king.poi.bean",config);
//解析文档docx格式 需要传生成的对象实体名称
// WordPOI.wordToEntity(Test.class.getResourceAsStream("Api1.docx"),false,"C:/bean/","com.king.poi.bean","Result","PageInfo");
//解析文档docx格式 需要传生成的对象实体名称
// WordPOI.wordToEntity(Test.class.getResourceAsStream("Api2.doc"),true,"C:/bean/","com.king.poi.bean","TestBean");
} catch (Exception e) {
e.printStackTrace();
}
  • 文档实体示例一(默认格式,见文档 Api1.docx)

1.1. Result (响应结果实体)

字段字段类型说明
codeString0-代表成功,其它代表失败
descString操作失败时的说明信息
dataT返回对应的泛型实体对象

1.2. PageInfo (页码信息实体)

字段字段类型说明
curPageInteger当前页码
pageSizeInteger页码大小,每一页的记录条数
totalPageInteger总页数
hasNextBoolean是否有下一页
dataList<T>泛型T为对应的数据记录实体
  • 文档实体示例二(自动解析实体名称格式,见文档 Api3.docx)

1.1. 响应结果实体

Result
字段字段类型说明
codeString0-代表成功,其它代表失败
descString操作失败时的说明信息
dataT返回对应的泛型<T>实体对象

1.2. 页码信息实体

PageInfo
字段字段类型说明
curPageInteger当前页码
curPageInteger当前页码
pageSizeInteger页码大小,每一页的记录条数
totalPageInteger总页数
hasNextBoolean是否有下一页
dataList<T>泛型T为对应的数据记录实体

更多使用详情,请查看Test中的源码使用示例或直接查看API帮助文档

代码下载:WordPOI.zip

收起阅读 »

iOS 中的事件传递和响应机制 - 原理篇

注:根据史上最详细的iOS之事件的传递和响应机制-原理篇重新整理(适当删减及补充)。在 iOS 中,只有继承了 UIReponder(响应者)类的对象才能接收并处理事件。其公共子类包括 UIView 、UIViewController 和 UIApplicat...
继续阅读 »

注:根据史上最详细的iOS之事件的传递和响应机制-原理篇重新整理(适当删减及补充)。

在 iOS 中,只有继承了 UIReponder(响应者)类的对象才能接收并处理事件。其公共子类包括 UIView 、UIViewController 和 UIApplication 。

UIReponder 类中提供了以下 4 个对象方法来处理触摸事件:

/// 触摸开始
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {}
/// 触摸移动
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {}
/// 触摸取消(在触摸结束之前)
/// 某个系统事件(例如电话呼入)会打断触摸过程
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
/// 触摸结束
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}

注意:

如果手指同时触摸屏幕,touches(_:with:) 方法只会调用一次,Set<UITouch> 包含两个对象;

如果手指前后触摸屏幕,touches(_:with:) 会依次调用,且每次调用时 Set<UITouch> 只有一个对象

iOS 中的事件传递

事件传递和响应的整个流程

触发事件后,系统会将该事件加入到一个由 UIApplication 管理的事件队列中;
UIApplication 会从事件队列中取出最前面的事件,将之分发出去以便处理,通常,先发送事件给应用程序的主窗口( keyWindow );
主窗口会在视图层次结构中<u>找到一个最适合的视图</u>来处理触摸事件;
找到适合的视图控件后,就会调用该视图控件的 touches(_:with:) 方法;
touches(_:with:) 的默认实现是将事件顺着响应者链(后面会说)一直传递下去,直到连 UIApplication 对象也不能响应事件,则将其丢弃。

如何寻找最适合的控件来处理事件

当事件触发后,系统会调用控件的 hitTest(_:with:) 方法来遍历视图的层次结构,以确定哪个子视图应该接收触摸事件,过程如下:

调用自己的 hitTest(_:with:) 方法;
判断自己能否触发事件、是否隐藏、alpha <= 0.01;
调用 point(inside:with:) 来判断触摸点是否在自己身上;
倒序遍历 subviews ,并重复前面三个步骤。直到找到包含触摸点的最上层视图,并返回这个视图,那么该视图就是那个最适合的处理事件的 view;
如果没有符合条件的子控件,就认为自己最适合处理事件,也就是自己是最适合的 view;
通俗一点来解释就是,其实系统也无法决定应该让哪个视图处理事件,那么就用遍历的方式,依次找到包含触摸点所在的最上层视图,则认为该视图最适合处理事件。

注意:

触摸事件传递的过程是从父控件传递到子控件的,如果父控件也不能接收事件,那么子控件就不可能接收事件。

寻找最适合的的 view 的底层剖析

hitTest(_:with:) 的调用时机

事件开始产生时会调用;
只要事件传递给一个控件,就会调用这个控件的 hitTest(_:with:) 方法(不管这个控件能否处理事件或触摸点是否自己身上)。
hitTest(_:with:) 的作用

返回一个最适合的 view 来处理触摸事件。

注意:

如果 hitTest(_:with:) 方法中返回 nil ,那么该控件本身和其 subview 都不是最适合的 view,而是该控件的父控件。

在默认的实现中,如果确定最终父控件是最适合的 view,那么仍然会调用其子控件的 hitTest(_:with:) 方法(不然怎么知道有没有更适合的 view?参考 如何寻找最适合的控件来处理事件。)

hitTest(_:with:) 的默认实现

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// 1. 判断自己能否触发事件
if !self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01 {
return nil
}
// 2.判断触摸点是否在自己身上
if !self.point(inside: point, with: event) {
return nil
}
// 3. 倒序遍历 `subviews` ,并重复前面两个步骤;
// 直到找到包含触摸点的最前面的视图,并返回这个视图,那么该视图就是那个最合适的接收事件的 view;
for view in subviews.reversed() {
// 把坐标转换成控件上的坐标
let p = self.convert(point, to: view)
if let hitView = view.hitTest(p, with: event) {
return hitView
}
}

return self
}

iOS 中的事件响应

找到最适合的 view 接收事件后,如果不重写实现该 view 的 touches(_:with:) 方法,那么这些方法的默认实现是将事件顺着响应者链向下传递, 将事件交给下一个响应者去处理。


可以说,响应者链是由多个响应者对象链接起来的链条。UIReponder 的一个对象属性 next 能够很好的解释这一规则。

UIReponder().next

返回响应者链中的下一个响应者,如果没有下一个响应者,则返回 nil 。

例如,UIView 调用此属性会返回管理它的 UIViewController 对象(如果有),没有则返回它的 superview;UIViewController 调用此属性会返回其视图的 superview;UIWindow 返回应用程序对象;共享的 UIApplication 对象则通常返回 nil 。

例如,我们可以通过 UIView 的 next 属性找到它所在的控制器:

extension UIView {
var next = self.next
while next != nil { // 符合条件就一直循环
if let viewController = next as? UIViewController {
return viewController
}
// UIView 的下一个响应控件,直到找到控制器。
next = next?.next
}
return nil
}

转自:https://www.jianshu.com/p/024f0c719715

收起阅读 »

iOS开发笔记(十)— Xcode、UITabbar、特殊机型问题分析

前言本文分享iOS开发中遇到的问题,和相关的一些思考。正文一、Xcode10.1 import头文件无法索引【问题表现】如图,当import头文件的时候,索引无效,无法联想出正确的文件;【问题分析】通过多个文件尝试,发现并非完全不能索引头文件,而是只能索引和当...
继续阅读 »

前言

本文分享iOS开发中遇到的问题,和相关的一些思考。

正文

一、Xcode10.1 import头文件无法索引
【问题表现】如图,当import头文件的时候,索引无效,无法联想出正确的文件;


【问题分析】通过多个文件尝试,发现并非完全不能索引头文件,而是只能索引和当前文件在同级目录的头文件;
有点猜测是Xcode10.1的原因,但是在升级完的半年多时间里,都没有出现过索引。
从已有的知识来分析,很可能是Xcode的头文件搜索路径有问题,于是尝试把工程文件下的路径设置递归搜索,结果又出现以下问题:


【问题解决】在多次尝试无效之后,最终还是靠Google解决该问题。
如下路径,修改设置
Xcode --> File --> Workspace Settings --> Build System --> Legacy Build System


二、NSAssert的断点和symbolic 断点

【问题表现】NSAssert是常见的断言,可以在debug阶段快速暴露问题,但是在触发的时候无法保持上下文;
【问题分析】NSAssert的本质就是抛出一个异常,可以通过Xcode添加一个Exception Breakpoint:


如下,便可以NSAssert触发时捕获现场。


同理,在Exception Breakpoint,还有Smybolic Breakpoint较为常用。
以cookie设置接口为例,以下为一段设置cookies的代码
[[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookies];
但是有时候设置cookies的地方可能较多,此时可以添加一个Smybolic Breakpoint并设置符号为cookies。
如下,可以看到所有设置cookies的接口:


三、.m文件改成.mm文件后编译失败

【问题表现】Pointer is missing a nullability type specifier (_Nonnull, _Nullable, or _Null_unspecified)
出错代码行: typedef void(^SSDataCallback)(NSError *error, id obj);
手动给参数添加 nullable的声明并无法解决。

【问题分析】
首先确定的是,这个编译失败实际上是一个warning,只是因为工程设置了把warning识别为error;
其次.m文件可以正常编译,并且.m文件也是开启了warning as error的设置;而从改成.mm就报错的表现和提示log来看,仍然是因为参数为空的原因导致。

【问题解决】
经过对比正常编译的.mm文件,找到一个解决方案:
1,添加NS_ASSUME_NONNULL_BEGIN在代码最前面,NS_ASSUME_NONNULL_END在代码最后面;
2、手动添加_Nullable到函数的参数;
typedef void(^SSDataCallback)(NSError * _Nullable error, id _Nullable obj);

四、UITabbar疑难杂症

问题1、batItem的染色异常问题

【问题表现】添加UITabBarItem到tabbar上,但是图片会被染成蓝色;
【问题分析】tabbar默认会帮我们染色,所以我们创建的UITabBarItem默认会被tinkColor染色的影响。
解决办法就是添加参数imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal,这样UITabBarItem的图片变不会受到tinkColor影响。

UITabBarItem *item1 = [[UITabBarItem alloc] initWithTitle:@"商城" image:[UIImage imageNamed:@"tabbar_item_store"] selectedImage:[[UIImage imageNamed:@"tabbar_item_store_selected"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]];

问题2、tabbar的背景色问题

【问题表现】设置tabbar的背景色是0xFFFFFF的白色,但是实际的效果确是灰白色,并不是全白色;
【问题分析】tabbar默认是透明的(属性translucent),会对tabbar下面的视图进行高斯模糊,然后再与背景色混合。
【问题解决】
1、自由做法,addSubview:一个view到tabbar上,接下来自己绘制4个按钮;(可操作性强,缺点是tabbar的逻辑需要自己再实现一遍)
2、改变tabbar透明度做法,设置translucent=YES,再修改背景色;(引入一个巨大的坑,导致UITabbarViewController上面的子VC的self.view属性高度会变化!)
3、空白图做法,把背景图都用一张空白的图片替代,如下:(最终采纳的做法)

self.tabBar.backgroundImage = [[UIImage alloc] init];
self.tabBar.backgroundColor = [UIColor whiteColor];

问题3、tabbar顶部的线条问题

【问题表现】UITabbar默认在tabbar的顶部会有一条灰色的线,但是并没有一个属性可以修改其颜色。
【问题分析】从Xcode的工具来看,这条线是一个UIImageView:


再从UITabbar的头文件来看,这条线的图片可能是shadowImage。
【问题解决】将shadowImage用一张空白的图片替代,然后自己再添加想要的线条大小和颜色。

self.tabBar.shadowImage = [[UIImage alloc] init];
UIView *lineView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.tabBar.width, 0.5)];
lineView.backgroundColor = [UIColor colorWithHexString:@"e8e8e8"];
[self.tabBar addSubview:lineView];

五、特殊机型出现的异常现象

1、iOS 11.4 充电时无法正常获取电量

【问题表现】在某个场景需要获取电池,于是通过以下addObserverForName:UIDeviceBatteryLevelDidChangeNotification的方式监听电量的变化,在iOS 12的机型表现正常,但是在iOS 11.4的机型上会出现无法获取电量的原因。

void (^block)(NSNotification *notification) = ^(NSNotification *notification) {
SS_STRONG_SELF(self);
NSLog(@"%@", self);
self.batteryView.width = (self.batteryImageView.width - Padding_battery_width) * [UIDevice currentDevice].batteryLevel;
};
//监视电池剩余电量
[[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceBatteryLevelDidChangeNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:block];

【问题分析】从电量获取的api开始入手分析,在获取电量之前,需要显式调用接口
[UIDevice currentDevice].batteryMonitoringEnabled = YES;
于是点击batteryMonitoringEnabled属性进入UIDevice.h,发现有个batteryState属性,里面有一个状态是充电UIDeviceBatteryStateCharging,但是对问题并无帮助;
点击UIDeviceBatteryLevelDidChangeNotification发现还有一个通知是UIDeviceBatteryStateDidChangeNotification,猜测可能是充电状态下的回调有所不同;
【问题解决】最终通过添加新通知的监听解决。该问题并不太难,但是养成多看.h文件相关属性的习惯,还是会有好处。

[[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceBatteryStateDidChangeNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:block];

2、iOS 10.3的UILabel富文本排版异常

【问题表现】有一段文本的显示需要设置首行缩进,所以用的富文本添加段落属性的方式;但是在iOS 10.3的6p机型上出现异常现象,如下:
测试文本:contentStr=@"一年佛山电脑放山东难道是防空洞念佛"
如下,最后的字符没有显示完全。
实现方式是计算得到富文本,然后赋值给UILabel,再调用-sizeToFit的接口。


以上的问题仅在一行的时候出现异常,两行又恢复正常。


【问题分析】
从表现来看,是sizeToFit的时候宽度结算出错;通过多次尝试,发现是少计算了大概两个空格的距离,也即是首行缩进的距离。
【问题解决】
方法1、去除首行缩进,每行增加两个空格;
方法2、一行的时候,把宽度设置到最大;
如何判断1行的情况,可以用以下的代码简短判断

if (self.contentLabel.height < self.contentLabel.font.lineHeight * 2) { // 一行的情况
self.contentLabel.width = self.width - 40;
}

总结

日常开发遇到的问题,如果解决过程超过10分钟,我都会记录下来。
这些问题有的很简单,仅仅是改个配置(如第一个Xcode索引问题),但是在解决过程中还是走了一些弯路,因为完全没想过可能会去改Workspace setting,都是在Build setting修改进行尝试。
还有些问题纯粹是特定现象,比如说特殊机型问题,只是做一个备忘和提醒


链接:https://www.jianshu.com/p/6c964411fc03

收起阅读 »

iOS 任务调度器:为 CPU 和内存减负

GitHub 地址:YBTaskScheduler支持 cocopods,使用简便,效率不错,一个性能优化的基础组件。前言前些时间有好几个技术朋友问过笔者类似的问题:主线程需要执行大量的任务导致卡顿如何处理?异步任务量级过大导致 CPU 和内存压力过高如何优化...
继续阅读 »

GitHub 地址:YBTaskScheduler
支持 cocopods,使用简便,效率不错,一个性能优化的基础组件。

前言

前些时间有好几个技术朋友问过笔者类似的问题:主线程需要执行大量的任务导致卡顿如何处理?异步任务量级过大导致 CPU 和内存压力过高如何优化?

解决类似的问题可以用几个思路:降频、淘汰、优先级调度。

本来解决这些问题并不需要很复杂的代码,但是涉及到一些 C 代码并且要注意线程安全的问题,所以笔者就做了这样一个轮子,以解决任务调度引发的性能问题。

本文讲述 YBTaskScheduler 的原理,读者朋友需要有一定的 iOS 基础,了解一些性能优化的知识,基本用法可以先看看 GitHub README,DEMO 中也有一个相册列表的应用案例。

一、需求分析

就拿 DEMO 中的案例来说明,一个显示相册图片的列表:


实现图中业务,必然考虑到几个耗时操作:

1、从相册读取图片
2、解压图片
3、圆角处理
4、绘制图片

理所当然的想到处理方案(DEMO中有实现):

1、异步读取图片
2、异步裁剪图片为正方形(这个过程中就解压了)
3、异步裁剪圆角
4、回到主线程绘制图片

一整套流程下来,貌似需求很好的解决了,但是当快速滑动列表时,会发现 CPU 和内存的占用会比较高(这取决于从相册中读取并显示多大的图片)。当然 DEMO 中按照屏幕的物理像素处理,就算不使用任务调度器组件快速滑动列表也基本不会有掉帧的现象。考虑到老旧设备或者技术人员的水平,很多时候这种需求会导致严重的 CPU 和内存负担,甚至导致闪退。

以上处理方案可能存在的性能瓶颈:

从相册读取图片、裁剪图片,处理圆角、主线程绘制等操作会导致 CPU 计算压力过大。
同时解压的图片、同时绘制的图片过多导致内存峰值飙升(更不要说做了图片的缓存)。
任何一种情况都可能导致客户端卡死或者闪退,结合业务来分析问题,会发现优化的思路还是不难找到:

· 滑出屏幕的图片不会存在绘制压力,而当前屏幕中的图片会在一个 RunLoop 循环周期绘制,可能造成掉帧。所以可以减少一个 RunLoop 循环周期所绘制的图片数量。
· 快速滑动列表,大量的异步任务直接交由 CPU 执行,然而滑出屏幕的图片已经没有处理它的意义了。所以可以提前删除掉已经滑出屏幕的异步任务,以此来降低 CPU 和内存压力。

没错, YBTaskScheduler 组件就是替你做了这些事情 ,而且还不止于此。

二、命令模式与 RunLoop

想要管理这些复杂的任务,并且在合适的时机调用它们,自然而然的就想到了命令模式。意味着任务不能直接执行,而是把任务作为一个命令装入容器。

在 Objective-C 中,显然 Block 代码块能解决延迟执行这个问题:

[_scheduler addTask:^{
/*
具体任务代码
解压图片、裁剪图片、访问磁盘等
*/
}];

然后组件将这些代码块“装起来”,组件由此“掌握”了所有的任务,可以自由的决定何时调用这些代码块,何时对某些代码块进行淘汰,还可以实现优先级调度。

既然是命令模式,还差一个 Invoker (调用程序),即何时去触发这些任务。结合 iOS 的技术特点,可以监听 RunLoop 循环周期来实现:

static void addRunLoopObserver() {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
taskSchedulers = [NSHashTable weakObjectsHashTable];
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(), kCFRunLoopBeforeWaiting | kCFRunLoopExit, true, 0xFFFFFF, runLoopObserverCallBack, NULL);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
CFRelease(observer);
});
}

然后在回调函数中进行任务的调度。

三、策略模式

考虑到任务的淘汰策略和优先级调度,必然需要一些高效数据结构来支撑,为了提高处理效率,笔者直接使用了 C++ 的数据结构:deque和priority_queue。

因为要实现任务淘汰,所以使用deque双端队列来模拟栈和队列,而不是直接使用stack和queue。使用priority_queue优先队列来处理自定义的优先级调度,它的缺点是不能删除低优先级节点,为了节约时间成本姑且够用。

具体的策略:

栈:后加入的任务先执行(可以理解为后加入的任务优先级高),优先淘汰先加入的任务。
队列:先加入的任务先执行(可以理解为先加入的任务优先级高),优先淘汰后加入的任务。
优先队列:自定义任务优先级,不支持任务淘汰。
实际上组件是推荐使用栈和队列这两种策略,因为插入和取出的时间复杂度是常数级的,需要定制任务的优先级时才考虑使用优先队列,因为其插入复杂度是 O(logN) 的。

至此,整个组件的业务是比较清晰了,组件需要让这三种处理方式可以自由的变动,所以采用策略模式来处理,下面是 UML 类图:


嗯,这是个挺标准的策略模式。

四、线程安全

由于任务的调度可能在任意线程,所以必须要做好容器(栈、队列、优先队列)访问的线程安全问题,组件是使用pthread_mutex_t和dispatch_once来保证线程安全,同时笔者尽量减少临界区来提高性能。值得注意的是,如果不会存在线程安全的代码就不要去加锁了。

后语

部分技术细节就不多说了,组件代码量比较少,如果感兴趣可以直接看源码。实际上这个组件的应用场景并不是很多,在项目稳定需要做深度的性能优化时可能会比较需要它,并且希望使用它的人也能了解一些原理,做到胸有成竹,才能灵活的运用。

转自:https://www.jianshu.com/p/f2a610c77d26

收起阅读 »

从 LiveData 迁移到 Kotlin 数据流

LiveData 的历史要追溯到 2017 年。彼时,观察者模式有效简化了开发,但诸如 RxJava 一类的库对新手而言有些太过复杂。为此,架构组件团队打造了 LiveData: 一个专用于 Android 的具备自主生命周期感知能力的可观察的数据存储器类。L...
继续阅读 »

LiveData 的历史要追溯到 2017 年。彼时,观察者模式有效简化了开发,但诸如 RxJava 一类的库对新手而言有些太过复杂。为此,架构组件团队打造了 LiveData: 一个专用于 Android 的具备自主生命周期感知能力的可观察的数据存储器类。LiveData 被有意简化设计,这使得开发者很容易上手;而对于较为复杂的交互数据流场景,建议您使用 RxJava,这样两者结合的优势就发挥出来了。


DeadData?


LiveData 对于 Java 开发者、初学者或是一些简单场景而言仍是可行的解决方案。而对于一些其他的场景,更好的选择是使用 Kotlin 数据流 (Kotlin Flow)。虽说数据流 (相较 LiveData) 有更陡峭的学习曲线,但由于它是 JetBrains 力挺的 Kotlin 语言的一部分,且 Jetpack Compose 正式版即将发布,故两者配合更能发挥出 Kotlin 数据流中响应式模型的潜力。


此前一段时间,我们探讨了 如何使用 Kotlin 数据流 来连接您的应用当中除了视图和 View Model 以外的其他部分。而现在我们有了 一种更安全的方式来从 Android 的界面中获得数据流,已经可以创作一份完整的迁移指南了。


在这篇文章中,您将学到如何把数据流暴露给视图、如何收集数据流,以及如何通过调优来适应不同的需求。


数据流: 把简单复杂化,又把复杂变简单


LiveData 就做了一件事并且做得不错: 它在 缓存最新的数据 和感知 Android 中的生命周期的同时将数据暴露了出来。稍后我们会了解到 LiveData 还可以 启动协程创建复杂的数据转换,这可能会需要花点时间。


接下来我们一起比较 LiveData 和 Kotlin 数据流中相对应的写法吧:


#1: 使用可变数据存储器暴露一次性操作的结果


这是一个经典的操作模式,其中您会使用协程的结果来改变状态容器:


△ 将一次性操作的结果暴露给可变的数据容器 (LiveData)


△ 将一次性操作的结果暴露给可变的数据容器 (LiveData)


<!-- Copyright 2020 Google LLC.  
SPDX-License-Identifier: Apache-2.0 -->

class MyViewModel {
private val _myUiState = MutableLiveData<Result<UiState>>(Result.Loading)
val myUiState: LiveData<Result<UiState>> = _myUiState

// 从挂起函数和可变状态中加载数据
init {
viewModelScope.launch {
val result = ...
_myUiState.value = result
}
}
}

如果要在 Kotlin 数据流中执行相同的操作,我们需要使用 (可变的) StateFlow (状态容器式可观察数据流):


△ 使用可变数据存储器 (StateFlow) 暴露一次性操作的结果


△ 使用可变数据存储器 (StateFlow) 暴露一次性操作的结果


class MyViewModel {
private val _myUiState = MutableStateFlow<Result<UiState>>(Result.Loading)
val myUiState: StateFlow<Result<UiState>> = _myUiState

// 从挂起函数和可变状态中加载数据
init {
viewModelScope.launch {
val result = ...
_myUiState.value = result
}
}
}

StateFlowSharedFlow 的一个比较特殊的变种,而 SharedFlow 又是 Kotlin 数据流当中比较特殊的一种类型。StateFlow 与 LiveData 是最接近的,因为:



  • 它始终是有值的。

  • 它的值是唯一的。

  • 它允许被多个观察者共用 (因此是共享的数据流)。

  • 它永远只会把最新的值重现给订阅者,这与活跃观察者的数量是无关的。



当暴露 UI 的状态给视图时,应该使用 StateFlow。这是一种安全和高效的观察者,专门用于容纳 UI 状态。



#2: 把一次性操作的结果暴露出来


这个例子与上面代码片段的效果一致,只是这里暴露协程调用的结果而无需使用可变属性。


如果使用 LiveData,我们需要使用 LiveData 协程构建器:


△ 把一次性操作的结果暴露出来 (LiveData)


△ 把一次性操作的结果暴露出来 (LiveData)


class MyViewModel(...) : ViewModel() {
val result: LiveData<Result<UiState>> = liveData {
emit(Result.Loading)
emit(repository.fetchItem())
}
}

由于状态容器总是有值的,那么我们就可以通过某种 Result 类来把 UI 状态封装起来,比如加载中、成功、错误等状态。


与之对应的数据流方式则需要您多做一点配置:


△ 把一次性操作的结果暴露出来 (StateFlow)


△ 把一次性操作的结果暴露出来 (StateFlow)


class MyViewModel(...) : ViewModel() {
val result: StateFlow<Result<UiState>> = flow {
emit(repository.fetchItem())
}.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000), //由于是一次性操作,也可以使用 Lazily
initialValue = Result.Loading
)
}

stateIn 是专门将数据流转换为 StateFlow 的运算符。由于需要通过更复杂的示例才能更好地解释它,所以这里暂且把这些参数放在一边。


#3: 带参数的一次性数据加载


比方说您想要加载一些依赖用户 ID 的数据,而信息来自一个提供数据流的 AuthManager:


△ 带参数的一次性数据加载 (LiveData)


△ 带参数的一次性数据加载 (LiveData)


使用 LiveData 时,您可以用类似这样的代码:


class MyViewModel(authManager..., repository...) : ViewModel() {
private val userId: LiveData<String?> =
authManager.observeUser().map { user -> user.id }.asLiveData()

val result: LiveData<Result<Item>> = userId.switchMap { newUserId ->
liveData { emit(repository.fetchItem(newUserId)) }
}
}

switchMap 是数据变换中的一种,它订阅了 userId 的变化,并且其代码体会在感知到 userId 变化时执行。


如非必须要将 userId 作为 LiveData 使用,那么更好的方案是将流式数据和 Flow 结合,并将最终的结果 (result) 转化为 LiveData。


class MyViewModel(authManager..., repository...) : ViewModel() {
private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

val result: LiveData<Result<Item>> = userId.mapLatest { newUserId ->
repository.fetchItem(newUserId)
}.asLiveData()
}

如果改用 Kotlin Flow 来编写,代码其实似曾相识:


△ 带参数的一次性数据加载 (StateFlow)


△ 带参数的一次性数据加载 (StateFlow)


class MyViewModel(authManager..., repository...) : ViewModel() {
private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

val result: StateFlow<Result<Item>> = userId.mapLatest { newUserId ->
repository.fetchItem(newUserId)
}.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000),
initialValue = Result.Loading
)
}

假如说您想要更高的灵活性,可以考虑显式调用 transformLatest 和 emit 方法:


val result = userId.transformLatest { newUserId ->
emit(Result.LoadingData)
emit(repository.fetchItem(newUserId))
}.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000),
initialValue = Result.LoadingUser //注意此处不同的加载状态
)

#4: 观察带参数的数据流


接下来我们让刚才的案例变得更具交互性。数据不再被读取,而是被观察,因此我们对数据源的改动会直接被传递到 UI 界面中。


继续刚才的例子: 我们不再对源数据调用 fetchItem 方法,而是通过假定的 observeItem 方法获取一个 Kotlin 数据流。


若使用 LiveData,可以将数据流转换为 LiveData 实例,然后通过 emitSource 传递数据的变化。


△ 观察带参数的数据流 (LiveData)


△ 观察带参数的数据流 (LiveData)


class MyViewModel(authManager..., repository...) : ViewModel() {
private val userId: LiveData<String?> =
authManager.observeUser().map { user -> user.id }.asLiveData()

val result = userId.switchMap { newUserId ->
repository.observeItem(newUserId).asLiveData()
}
}

或者采用更推荐的方式,把两个流通过 flatMapLatest 结合起来,并且仅将最后的输出转换为 LiveData:


class MyViewModel(authManager..., repository...) : ViewModel() {
private val userId: Flow<String?> =
authManager.observeUser().map { user -> user?.id }

val result: LiveData<Result<Item>> = userId.flatMapLatest { newUserId ->
repository.observeItem(newUserId)
}.asLiveData()
}

使用 Kotlin 数据流的实现方式非常相似,但是省下了 LiveData 的转换过程:


△ 观察带参数的数据流 (StateFlow)


△ 观察带参数的数据流 (StateFlow)


class MyViewModel(authManager..., repository...) : ViewModel() {
private val userId: Flow<String?> =
authManager.observeUser().map { user -> user?.id }

val result: StateFlow<Result<Item>> = userId.flatMapLatest { newUserId ->
repository.observeItem(newUserId)
}.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000),
initialValue = Result.LoadingUser
)
}

每当用户实例变化,或者是存储区 (repository) 中用户的数据发生变化时,上面代码中暴露出来的 StateFlow 都会收到相应的更新信息。


#5: 结合多种源: MediatorLiveData -> Flow.combine


MediatorLiveData 允许您观察一个或多个数据源的变化情况,并根据得到的新数据进行相应的操作。通常可以按照下面的方式更新 MediatorLiveData 的值:


val liveData1: LiveData<Int> = ...
val liveData2: LiveData<Int> = ...

val result = MediatorLiveData<Int>()

result.addSource(liveData1) { value ->
result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}
result.addSource(liveData2) { value ->
result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}

同样的功能使用 Kotlin 数据流来操作会更加直接:


val flow1: Flow<Int> = ...
val flow2: Flow<Int> = ...

val result = combine(flow1, flow2) { a, b -> a + b }

此处也可以使用 combineTransform 或者 zip 函数。


通过 stateIn 配置对外暴露的 StateFlow


早前我们使用 stateIn 中间运算符来把普通的流转换成 StateFlow,但转换之后还需要一些配置工作。如果现在不想了解太多细节,只是想知道怎么用,那么可以使用下面的推荐配置:


val result: StateFlow<Result<UiState>> = someFlow
.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000),
initialValue = Result.Loading
)

不过,如果您想知道为什么会使用这个看似随机的 5 秒的 started 参数,请继续往下读。


根据文档,stateIn 有三个参数:?


@param scope 共享开始时所在的协程作用域范围

@param started 控制共享的开始和结束的策略

@param initialValue 状态流的初始值

当使用 [SharingStarted.WhileSubscribed] 并带有 `replayExpirationMillis` 参数重置状态流时,也会用到 initialValue。

started 接受以下的三个值:



  • Lazily: 当首个订阅者出现时开始,在 scope 指定的作用域被结束时终止。

  • Eagerly: 立即开始,而在 scope 指定的作用域被结束时终止。

  • WhileSubscribed: 这种情况有些复杂 (后文详聊)。


对于那些只执行一次的操作,您可以使用 Lazily 或者 Eagerly。然而,如果您需要观察其他的流,就应该使用 WhileSubscribed 来实现细微但又重要的优化工作,参见后文的解答。


WhileSubscribed 策略


WhileSubscribed 策略会在没有收集器的情况下取消上游数据流。通过 stateIn 运算符创建的 StateFlow 会把数据暴露给视图 (View),同时也会观察来自其他层级或者是上游应用的数据流。让这些流持续活跃可能会引起不必要的资源浪费,例如一直通过从数据库连接、硬件传感器中读取数据等等。当您的应用转而在后台运行时,您应当保持克制并中止这些协程


WhileSubscribed 接受两个参数:


public fun WhileSubscribed(
stopTimeoutMillis: Long = 0,
replayExpirationMillis: Long = Long.MAX_VALUE
)


超时停止


根据其文档:



stopTimeoutMillis 控制一个以毫秒为单位的延迟值,指的是最后一个订阅者结束订阅与停止上游流的时间差。默认值是 0 (立即停止)。



这个值非常有用,因为您可能并不想因为视图有几秒钟不再监听就结束上游流。这种情况非常常见——比如当用户旋转设备时,原来的视图会先被销毁,然后数秒钟内重建。


liveData 协程构建器所使用的方法是 添加一个 5 秒钟的延迟,即如果等待 5 秒后仍然没有订阅者存在就终止协程。前文代码中的 WhileSubscribed (5000) 正是实现这样的功能:


class MyViewModel(...) : ViewModel() {
val result = userId.mapLatest { newUserId ->
repository.observeItem(newUserId)
}.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000),
initialValue = Result.Loading
)
}

这种方法会在以下场景得到体现:



  • 用户将您的应用转至后台运行,5 秒钟后所有来自其他层的数据更新会停止,这样可以节省电量。

  • 最新的数据仍然会被缓存,所以当用户切换回应用时,视图立即就可以得到数据进行渲染。

  • 订阅将被重启,新数据会填充进来,当数据可用时更新视图。


数据重现的过期时间


如果用户离开应用太久,此时您不想让用户看到陈旧的数据,并且希望显示数据正在加载中,那么就应该在 WhileSubscribed 策略中使用 replayExpirationMillis 参数。在这种情况下此参数非常适合,由于缓存的数据都恢复成了 stateIn 中定义的初始值,因此可以有效节省内存。虽然用户切回应用时可能没那么快显示有效数据,但至少不会把过期的信息显示出来。



replayExpirationMillis 配置了以毫秒为单位的延迟时间,定义了从停止共享协程到重置缓存 (恢复到 stateIn 运算符中定义的初始值 initialValue) 所需要等待的时间。它的默认值是长整型的最大值 Long.MAX_VALUE (表示永远不将其重置)。如果设置为 0,可以在符合条件时立即重置缓存的数据。



从视图中观察 StateFlow


我们此前已经谈到,ViewModel 中的 StateFlow 需要知道它们已经不再需要监听。然而,当所有的这些内容都与生命周期 (lifecycle) 结合起来,事情就没那么简单了。


要收集一个数据流,就需要用到协程。Activity 和 Fragment 提供了若干协程构建器:



  • Activity.lifecycleScope.launch : 立即启动协程,并且在本 Activity 销毁时结束协程。

  • Fragment.lifecycleScope.launch : 立即启动协程,并且在本 Fragment 销毁时结束协程。

  • Fragment.viewLifecycleOwner.lifecycleScope.launch : 立即启动协程,并且在本 Fragment 中的视图生命周期结束时取消协程。


LaunchWhenStarted 和 LaunchWhenResumed


对于一个状态 X,有专门的 launch 方法称为 launchWhenX。它会在 lifecycleOwner 进入 X 状态之前一直等待,又在离开 X 状态时挂起协程。对此,需要注意对应的协程只有在它们的生命周期所有者被销毁时才会被取消


△ 使用 launch/launchWhenX 来收集数据流是不安全的


△ 使用 launch/launchWhenX 来收集数据流是不安全的


当应用在后台运行时接收数据更新可能会引起应用崩溃,但这种情况可以通过将视图的数据流收集操作挂起来解决。然而,上游数据流会在应用后台运行期间保持活跃,因此可能浪费一定的资源。


这么说来,目前我们对 StateFlow 所进行的配置都是无用功;不过,现在有了一个新的 API。


lifecycle.repeatOnLifecycle 前来救场


这个新的协程构建器 (自 lifecycle-runtime-ktx 2.4.0-alpha01 后可用) 恰好能满足我们的需要: 在某个特定的状态满足时启动协程,并且在生命周期所有者退出该状态时停止协程。


△ 不同数据流收集方法的比较


△ 不同数据流收集方法的比较


比如在某个 Fragment 的代码中:


onCreateView(...) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {
myViewModel.myUiState.collect { ... }
}
}
}

当这个 Fragment 处于 STARTED 状态时会开始收集流,并且在 RESUMED 状态时保持收集,最终在 Fragment 进入 STOPPED 状态时结束收集过程。如需获取更多信息,请参阅: 使用更为安全的方式收集 Android UI 数据流


结合使用 repeatOnLifecycle API 和上面的 StateFlow 示例可以帮助您的应用妥善利用设备资源的同时,发挥最佳性能。


△ 该 StateFlow 通过 WhileSubscribed(5000) 暴露并通过 repeatOnLifecycle(STARTED) 收集


△ 该 StateFlow 通过 WhileSubscribed(5000) 暴露并通过 repeatOnLifecycle(STARTED) 收集



注意: 近期在 Data Binding 中加入的 StateFlow 支持 使用了 launchWhenCreated 来描述收集数据更新,并且它会在进入稳定版后转而使用 repeatOnLifecyle


对于数据绑定,您应该在各处都使用 Kotlin 数据流并简单地加上 asLiveData() 来把数据暴露给视图。数据绑定会在 lifecycle-runtime-ktx 2.4.0 进入稳定版后更新。



总结


通过 ViewModel 暴露数据,并在视图中获取的最佳方式是:



  • ?? 使用带超时参数的 WhileSubscribed 策略暴露 StateFlow。[示例 1]

  • ?? 使用 repeatOnLifecycle 来收集数据更新。[示例 2]


如果采用其他方式,上游数据流会被一直保持活跃,导致资源浪费:



  • ? 通过 WhileSubscribed 暴露 StateFlow,然后在 lifecycleScope.launch/launchWhenX 中收集数据更新。

  • ? 通过 Lazily/Eagerly 策略暴露 StateFlow,并在 repeatOnLifecycle 中收集数据更新。


当然,如果您并不需要使用到 Kotlin 数据流的强大功能,就用 LiveData 好了 :)


ManuelWojtekYigit、Alex Cook、FlorinaChris 致谢!




作者:Android_开发者
链接:https://juejin.cn/post/6979008878029570055
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

okhttp文件上传失败,居然是Android Studio背锅?太难了~

1、前言 本案例是我本人遇到的真实案例,因查找原因的过程一度让我崩溃,我相信不少人也遇到过相同的问题,故将其记录下来,希望对大家有帮助,本案例使用RxHttp 2.6.4 + OkHttp 4.9.1版本,当然,如果你使用Retrofit等其它基于OkHtt...
继续阅读 »

1、前言


本案例是我本人遇到的真实案例,因查找原因的过程一度让我崩溃,我相信不少人也遇到过相同的问题,故将其记录下来,希望对大家有帮助,本案例使用RxHttp 2.6.4 + OkHttp 4.9.1版本,当然,如果你使用Retrofit等其它基于OkHttp封装的框架,且用到监听上传进度功能,那么很大概率你也会遇到这个问题,请耐心看完,如果你想直接看到结果,划到文章末尾即可。


2、问题描述


事情是这样的,有一段文件上传的代码,如下:


fun uploadFiles(fileList: List<File>) {
RxHttp.postForm("/server/...")
.add("key", "value")
.addFiles("files", fileList)
.upload {
//上传进度回调
}
.asString()
.subscribe({
//成功回调
}, {
//失败回调
})
}

这段代码在写完后很长一段时间内都是ok的,突然有一天,执行这段代码居然报错了,日志如下:


image.png 这个异常是100%出现的,很熟悉的异常,具体原因就是,数据流被关闭了,但依然往里面写数据,来看看最后抛异常的地方,如下:


image.png 可以看到,方法里面第一行代码就判断数据流是否已关闭,是的话,抛出异常。


注:如果你是RxHttp使用者,正在尝试这段代码,发现没问题,也不要惊讶,因为这需要在Android Studio特定场景下执行才会出现,而且是相对高频使用的场景,请待我一步步揭晓答案


3、一探究竟


本着出现问题,先定位到自己代码的原则,打开ProgressRequestBody类76行看看,如下:


public class ProgressRequestBody extends RequestBody {

//省略相关代码
private BufferedSink bufferedSink;
@Override
public void writeTo(BufferedSink sink) throws IOException {
if (bufferedSink == null) {
bufferedSink = Okio.buffer(sink(sink));
}
requestBody.writeTo(bufferedSink); //这里是76行
bufferedSink.flush();
}
}

ProgressRequestBody继承了okhttp3.RequestBody类,作用是监听上传进度;显然最后执行到这里时,数据流已经被关闭了,从日志里可以看到,最后一次调用ProgressRequestBody#writeTo(BufferedSink)方法的地方在CallServerInterceptor拦截器的59行,打开看看


class CallServerInterceptor(private val forWebSocket: Boolean) : Interceptor {

//省略相关代码
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
//省略相关代码
if (responseBuilder == null) {
if (requestBody.isDuplex()) {
exchange.flushRequest()
val bufferedRequestBody = exchange.createRequestBody(request, true).buffer()
requestBody.writeTo(bufferedRequestBody)
} else {
val bufferedRequestBody = exchange.createRequestBody(request, false).buffer()
requestBody.writeTo(bufferedRequestBody) //这里是59行
bufferedRequestBody.close() //数据写完,将数据流关闭
}
}
}
}

熟悉OkHttp原理的同学应该知道,CallServerInterceptor拦截器是okhttp拦截器链的最后一个拦截器,将客户端数据写出到服务端,就是在这里实现的,也就是59行,那问题就来了,数据都还没写出去,数据流怎么就关闭了呢?这令我百思不得其解,毫无头绪。


于是乎,我做了很多无用功,如:重新检查代码,看看是否有手动关闭数据流的地方,显然没有找到;接着,实在没有办法,代码回滚,回滚到最初写这段代码的版本,我满怀期待的以为,这下应该没问题了,可尝试过后,依旧报java.lang.IllegalStateException: closed,成年人的崩溃就在这一瞬间,我陷入了绝境,已经消耗5个小时在这个问题上,此时已晚上23:30,看来又是一个不眠夜。


question1.jpeg


习惯告诉我,一个问题很久没查出来,可以先放弃,好吧,拔手机关电脑,洗澡睡觉。


半小时后,我躺在床上,很难受,于是我拿出手机,打开app,再试了试上传功能,惊奇的发现,可以了,上传成功了,这。。。。一脸懵逼,我找谁说理去,虽然没问题了,但问题没找到,作为一名初级程序员,这我无法接受。


精神的力量把我从床上扶了起来,再次打开电脑,连上手机,这次,果然有了新的收获,也一下子刷新了我的世界观;当我再次打开app,尝试上传文件时,一样的错误出现在我眼前,What??? 刚才还好好的,连上电脑就不行了?


question2.jpeg


ok,我彻底没脾气了,拔掉手机,重启app,再试,没问题了,再次连上电脑,再试,问题又出来了。。


此时,我的心态有了些许的好转,毕竟有了新的调查方向,我再次查看错误日志,发现了一个很奇怪的地方,如下: image.png


com.android.tools.profiler.agent.okhttp.OkHttp3Interceptor是从哪冒出来的?在我的认知里,OkHttp3是没有这个拦截器的,为了验证我的认知,再次查看okhttp3源码,如下:


image.png


确定是没有添加这个拦截器的,仔细看日志发现,OkHttp3InterceptorCallServerInterceptor、ConnectInterceptor之间执行的,那就只有一个解释,OkHttp3Interceptor是通过addNetworkInterceptor方法添加,现在就好办了,全局搜索addNetworkInterceptor就知道是谁添加的,哪里添加的,很可惜,未找到调用此方法的源码,似乎又陷入了绝境。


question.jpeg


那就只能开启调试,看看OkHttp3Interceptor是否在OkHttpClient对象的networkInterceptors网络拦截器列表里,一调试,果然有发现,如下:


image.png 调试点击下一步,神奇的事情就发生了,如下:


image.png


这怎么解释?networkInterceptors.size始终是0,interceptors.size是如何加1变为5的?再来看看,加的1是什么,如下:


image.png


很熟悉,就是我们之前提到的OkHttp3Interceptor,这是如何做到的?只有一个解释,OkHttpClient#networkInterceptors()方法被字节码插桩技术插入了新的代码,为了验证我的想法,我做了以下实验:


image.png


image.png


可以看到,我直接new了一个OkHttpClient对象,啥也没配置,调用networkInterceptors()方法,就获取了OkHttp3Interceptor拦截器,但OkHttpClient对象里的networkInterceptors列表中是没有这个拦截器的,这就证实了我的想法。


那现在的问题就是,OkHttp3Interceptor是谁注入的?跟文件上传失败是否有直接的关系?


OkHttp3Interceptor是谁注入的?


先来探索第一个问题,通过OkHttp3Interceptor类的包名class com.android.tools.profiler.agent.okhttp,我有以下3点猜测



  • 包名有com.android.tools,应该跟 Android 官方有关系


  • 包名有agent,又是拦截器,应该跟网络代理,也就是网络监控有关


  • 最后一点,也是最重要的,包名有profiler,这让我联想到了Android Studio(以下简称AS)里Profiler网络分析器



果然,在Google的源码中,真找到了OkHttp3Interceptor类,看看相关代码:


public final class OkHttp3Interceptor implements Interceptor {

//省略相关代码
@Override
public Response intercept(Interceptor.Chain chain) throws IOException {
Request request = chain.request();
HttpConnectionTracker tracker = null;
try {
tracker = trackRequest(request); //1、追踪请求体
} catch (Exception ex) {
StudioLog.e("Could not track an OkHttp3 request", ex);
}
Response response;
try {
response = chain.proceed(request);
} catch (IOException ex) {

}
try {
if (tracker != null) {
response = trackResponse(tracker, response); //2、追踪响应体
}
} catch (Exception ex) {
StudioLog.e("Could not track an OkHttp3 response", ex);
}
return response;
}

可以确定它就是一个网络监控器,但它是不是AS的网络监听器,我却还持怀疑态度,因为我这个项目没开启Profiler分析器,但我最近在开发room数据库相关功能,开启了数据分析器Database Inspector,难道跟这个有关?我尝试关掉Database Inspector,并且重启app,再次尝试文件上传,居然成功了,是真的成功了,你能信?我也不信,于是,再次开启Database Inspector,再次尝试文件上传,失败了,异常跟之前的一模一样;接着,我关闭Database Inspector,并且打开Profiler分析器,再次尝试文件上传,一样失败了。


我想到这里,基本可以认定OkHttp3Interceptor就是Profiler里面的网络监控器,但也好像缺乏直接证据,于是,我尝试改了下ProgressRequestBody类,如下:


public class ProgressRequestBody extends RequestBody {

//省略相关代码
private BufferedSink bufferedSink;

@Override
public void writeTo(BufferedSink sink) throws IOException {
//如果调用方是OkHttp3Interceptor,不写请求体,直接返回
if (sink.toString().contains(
"com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker"))
return;
if (bufferedSink == null) {
bufferedSink = Okio.buffer(sink(sink));
}
requestBody.writeTo(bufferedSink);
bufferedSink.flush();
}
}

以上代码,仅仅加了一句if语句,这条语句可以判断当前调用方是不是OkHttp3Interceptor,是的话,不写请求体,直接返回;如果OkHttp3Interceptor就是Profiler里的网络监控器,那么此时Profiler里应该是看不到请求体的,也就是看不到请求参数,如下:


image.png


可以看到,Profiler里的网络监控器,没有监控到请求参数。


这就证实了OkHttp3Interceptor的确是Profiler里的网络监控器,也就是AS动态注入的。


OkHttp3Interceptor 与文件上传是否有直接的关系?


通过上面的案例分析,显然是有直接关系的,当你未打开Database InspectorProfiler时,文件上传一切正常。


OkHttp3Interceptor是如何影响文件上传的?


回到正题,OkHttp3Interceptor是如何影响文件上传的?这个就需要继续分析OkHttp3Interceptor的源码,来看看追踪请求体的代码:


public final class OkHttp3Interceptor implements Interceptor {

private HttpConnectionTracker trackRequest(Request request) throws IOException {
StackTraceElement[] callstack =
OkHttpUtils.getCallstack(request.getClass().getPackage().getName());
HttpConnectionTracker tracker =
HttpTracker.trackConnection(request.url().toString(), callstack);
tracker.trackRequest(request.method(), toMultimap(request.headers()));
if (request.body() != null) {
OutputStream outputStream =
tracker.trackRequestBody(OkHttpUtils.createNullOutputStream());
BufferedSink bufferedSink = Okio.buffer(Okio.sink(outputStream));
request.body().writeTo(bufferedSink); // 1、将请求体写入到BufferedSink中
bufferedSink.close(); // 2、关闭BufferedSink
}
return tracker;
}

}

想到这里问题就很清楚了,上面备注的第一代码中request.body(),拿到的就是ProgressRequestBody对象,随后调用其writeTo(BufferedSink)方法,传入BufferedSink对象,方法执行完,就将BufferedSink对象关闭了,然而,ProgressRequestBody里却将BufferedSink声明为成员变量,并且为空时才会赋值,这就导致后续CallServerInterceptor调用其writeTo(BufferedSink)方法时,使用的还是上一个已关闭的BufferedSink对象,此时再往里面写数据,自然就java.lang.IllegalStateException: closed异常了。


4、如何解决


知道了具体的原因,就好解决,将ProgressRequestBody里面的BufferedSink对象改为局部变量即可,如下:


public class ProgressRequestBody extends RequestBody {

//省略相关代码
@Override
public void writeTo(BufferedSink sink) throws IOException {
BufferedSink bufferedSink = Okio.buffer(sink(sink));
requestBody.writeTo(bufferedSink);
bufferedSink.colse();
}
}

改完后,开启Profiler里的网络监控器,再次尝试文件上传,ok成功了,但又有一个新的问题,ProgressRequestBody是用于监听上传进度的,OkHttp3InterceptorCallServerInterceptor先后调用了其writeTo(BufferedSink)方法,这就会导致请求体写两次,也就是进度监听会收到两遍,而我们真正需要的是CallServerInterceptor调用的那次,咋整?好办,我们前面就判断过调用方是否OkHttp3Interceptor


于是,做出如下更改:


public class ProgressRequestBody extends RequestBody {

//省略相关代码
@Override
public void writeTo(BufferedSink sink) throws IOException {
//如果调用方是OkHttp3Interceptor,直接写请求体,不再通过包装类来处理请求进度
if (sink.toString().contains(
"com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker")) {
requestBody.writeTo(bufferedSink);
} else {
BufferedSink bufferedSink = Okio.buffer(sink(sink));
requestBody.writeTo(bufferedSink);
bufferedSink.colse();
}
}
}

你以为这样就完了?相信很多人都会用到com.squareup.okhttp3:logging-interceptor日志拦截器,当你添加该日志拦截器后,再次上传文件,会发现,进度回调又执行了两遍,为啥?因为该日志拦截器,也会调用ProgressRequestBody#writeTo(BufferedSink)方法,看看代码:


//省略部分代码
class HttpLoggingInterceptor @JvmOverloads constructor(
private val logger: Logger = Logger.DEFAULT
) : Interceptor {

@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val requestBody = request.body

if (logHeaders) {
if (!logBody || requestBody == null) {
logger.log("--> END ${request.method}")
} else if (bodyHasUnknownEncoding(request.headers)) {
logger.log("--> END ${request.method} (encoded body omitted)")
} else if (requestBody.isDuplex()) {
logger.log("--> END ${request.method} (duplex request body omitted)")
} else if (requestBody.isOneShot()) {
logger.log("--> END ${request.method} (one-shot body omitted)")
} else {
val buffer = Buffer()
//1、这里调用了RequestBody的writeTo方法,并传入了Buffer对象
requestBody.writeTo(buffer)
}
}

val response: Response
try {
response = chain.proceed(request)
} catch (e: Exception) {
throw e
}
return response
}

}

可以看到,HttpLoggingInterceptor内部也会调用RequestBody#writeTo方法,并传入Buffer对象,到这,我们就好办了,在ProgressRequestBody类增加一个Buffer的判断逻辑即可,如下:


public class ProgressRequestBody extends RequestBody {

//省略相关代码
@Override
public void writeTo(BufferedSink sink) throws IOException {
//如果调用方是OkHttp3Interceptor,或者传入的是Buffer对象,直接写请求体,不再通过包装类来处理请求进度
if (sink instanceof Buffer
|| sink.toString().contains(
"com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker")) {
requestBody.writeTo(bufferedSink);
} else {
BufferedSink bufferedSink = Okio.buffer(sink(sink));
requestBody.writeTo(bufferedSink);
bufferedSink.colse();
}
}
}

这样就完了?也不见得,如果后续又遇到什么拦截器调用其writeTo方法,还是会出现进度回调执行两遍的情况,只能在遇到这种情况时,加入对应的判断逻辑


到这,也许有人会问,为啥不直接判断调用方是不是CallServerInterceptor,是的话监听进度回调,否则,直接写入请求体。想法很好,也是可行的,如下:


public class ProgressRequestBody extends RequestBody {

//省略相关代码
@Override
public void writeTo(BufferedSink sink) throws IOException {
//如果调用方是CallServerInterceptor,监听上传进度
if (sink.toString().contains("RequestBodySink(okhttp3.internal")) {
BufferedSink bufferedSink = Okio.buffer(sink(sink));
requestBody.writeTo(bufferedSink);
bufferedSink.colse();
} else {
requestBody.writeTo(bufferedSink);
}
}
}

但是该方案有个致命的缺陷,如果okhttp未来版本更改了目录结构,ProgressRequestBody类就完全失效。


两个方案就由大家自己去选择,这里给出ProgressRequestBody完整源码,需要自取


5、小结


本案例上传失败的直接原因就是在AS开启了Database Inspector数据库分析器或Profiler网络监控器时,AS就会通过字节码插桩技术,对OkHttpClient#networkInterceptors()方法注入新的字节码,使其多返回一个com.android.tools.profiler.agent.okhttp.OkHttp3Interceptor拦截器(用于监听网络),该拦截器会调用ProgressRequestBody#writeTo(BufferedSink)方法,并传入BufferedSink对象,writeTo方法执行完毕后,立即将BufferedSink对象关闭,在随后的CallServerInterceptor拦截又调用ProgressRequestBody#writeTo(BufferedSink)方法往已关闭的BufferedSink对象写数据,最终导致java.lang.IllegalStateException: closed异常。


但有个有疑惑,我却未找到答案,那就是为啥开启Database Inspector也会导致AS去监听网络?有知道的小伙伴可以评论区留言。




作者:不怕天黑
链接:https://juejin.cn/post/6981210499815833631
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

面试必备:Kotlin线程同步的N种方法

面试的时候经常会被问及多线程同步的问题,例如: “ 现有 Task1、Task2 等多个并行任务,如何等待全部执行完成后,执行 Task3。” 在 Kotlin 中我们有多种实现方式,本文将所有这些方式做了整理,建议收藏。 1. Thread.join ...
继续阅读 »

面试的时候经常会被问及多线程同步的问题,例如:


“ 现有 Task1、Task2 等多个并行任务,如何等待全部执行完成后,执行 Task3。”


在 Kotlin 中我们有多种实现方式,本文将所有这些方式做了整理,建议收藏。


1. Thread.join
2. Synchronized
3. ReentrantLock
4. BlockingQueue
5. CountDownLatch
6. CyclicBarrier
7. CAS
8. Future
9. CompletableFuture
10. Rxjava
11. Coroutine
12. Flow


我们先定义三个Task,模拟上述场景, Task3 基于 Task1、Task2 返回的结果拼接字符串,每个 Task 通过 sleep 模拟耗时: image.png


val task1: () -> String = {
sleep(2000)
"Hello".also { println("task1 finished: $it") }
}

val task2: () -> String = {
sleep(2000)
"World".also { println("task2 finished: $it") }
}

val task3: (String, String) -> String = { p1, p2 ->
sleep(2000)
"$p1 $p2".also { println("task3 finished: $it") }
}



1. Thread.join()


Kotlin 兼容 Java,Java 的所有线程工具默认都可以使用。其中最简单的线程同步方式就是使用 Threadjoin()


@Test
fun test_join() {
lateinit var s1: String
lateinit var s2: String

val t1 = Thread { s1 = task1() }
val t2 = Thread { s2 = task2() }
t1.start()
t2.start()

t1.join()
t2.join()

task3(s1, s2)

}



2. Synchronized


使用 synchronized 锁进行同步


	@Test
fun test_synchrnoized() {
lateinit var s1: String
lateinit var s2: String

Thread {
synchronized(Unit) {
s1 = task1()
}
}.start()
s2 = task2()

synchronized(Unit) {
task3(s1, s2)
}

}

但是如果超过三个任务,使用 synchrnoized 这种写法就比较别扭了,为了同步多个并行任务的结果需要声明n个锁,并嵌套n个 synchronized




3. ReentrantLock


ReentrantLock 是 JUC 提供的线程锁,可以替换 synchronized 的使用


	@Test
fun test_ReentrantLock() {

lateinit var s1: String
lateinit var s2: String

val lock = ReentrantLock()
Thread {
lock.lock()
s1 = task1()
lock.unlock()
}.start()
s2 = task2()

lock.lock()
task3(s1, s2)
lock.unlock()

}

ReentrantLock 的好处是,当有多个并行任务时是不会出现嵌套 synchrnoized 的问题,但仍然需要创建多个 lock 管理不同的任务,


4. BlockingQueue


阻塞队列内部也是通过 Lock 实现的,所以也可以达到同步锁的效果


	@Test
fun test_blockingQueue() {

lateinit var s1: String
lateinit var s2: String

val queue = SynchronousQueue<Unit>()

Thread {
s1 = task1()
queue.put(Unit)
}.start()

s2 = task2()

queue.take()
task3(s1, s2)
}

当然,阻塞队列更多是使用在生产/消费场景中的同步。




5. CountDownLatch


JUC 中的锁大都基于 AQS 实现的,可以分为独享锁和共享锁。ReentrantLock 就是一种独享锁。相比之下,共享锁更适合本场景。 例如 CountDownLatch,它可以让一个线程一直处于阻塞状态,直到其他线程的执行全部完成:


	@Test
fun test_countdownlatch() {

lateinit var s1: String
lateinit var s2: String
val cd = CountDownLatch(2)
Thread() {
s1 = task1()
cd.countDown()
}.start()

Thread() {
s2 = task2()
cd.countDown()
}.start()

cd.await()
task3(s1, s2)
}

共享锁的好处是不必为了每个任务都创建单独的锁,即使再多并行任务写起来也很轻松




6. CyclicBarrier


CyclicBarrier 是 JUC 提供的另一种共享锁机制,它可以让一组线程到达一个同步点后再一起继续运行,其中任意一个线程未达到同步点,其他已到达的线程均会被阻塞。


CountDownLatch 的区别在于 CountDownLatch 是一次性的,而 CyclicBarrier 可以被重置后重复使用,这也正是 Cyclic 的命名由来,可以循环使用


	@Test
fun test_CyclicBarrier() {

lateinit var s1: String
lateinit var s2: String
val cb = CyclicBarrier(3)

Thread {
s1 = task1()
cb.await()
}.start()

Thread() {
s2 = task1()
cb.await()
}.start()

cb.await()
task3(s1, s2)

}



7. CAS


AQS 内部通过自旋锁实现同步,自旋锁的本质是利用 CompareAndSwap 避免线程阻塞的开销。 因此,我们可以使用基于 CAS 的原子类计数,达到实现无锁操作的目的。


 	@Test
fun test_cas() {

lateinit var s1: String
lateinit var s2: String

val cas = AtomicInteger(2)

Thread {
s1 = task1()
cas.getAndDecrement()
}.start()

Thread {
s2 = task2()
cas.getAndDecrement()
}.start()

while (cas.get() != 0) {}

task3(s1, s2)

}

while 循环空转看起来有些浪费资源,但是自旋锁的本质就是这样,所以 CAS 仅仅适用于一些cpu密集型的短任务同步。




volatile


看到 CAS 的无锁实现,也许很多人会想到 volatile, 是否也能实现无锁的线程安全?


 	@Test
fun test_Volatile() {
lateinit var s1: String
lateinit var s2: String

Thread {
s1 = task1()
cnt--
}.start()

Thread {
s2 = task2()
cnt--
}.start()

while (cnt != 0) {
}

task3(s1, s2)

}

注意,这种写法是错误的 volatile 能保证可见性,但是不能保证原子性,cnt-- 并非线程安全,需要加锁操作




8. Future


上面无论有锁操作还是无锁操作,都需要定义两个变量s1s2记录结果非常不方便。 Java 1.5 开始,提供了 CallableFuture ,可以在任务执行结束时返回结果。


@Test
fun test_future() {

val future1 = FutureTask(Callable(task1))
val future2 = FutureTask(Callable(task2))

Executors.newCachedThreadPool().execute(future1)
Executors.newCachedThreadPool().execute(future2)

task3(future1.get(), future2.get())

}

通过 future.get(),可以同步等待结果返回,写起来非常方便




9. CompletableFuture


future.get() 虽然方便,但是会阻塞线程。 Java 8 中引入了 CompletableFuture ,他实现了 Future 接口的同时实现了 CompletionStage 接口。 CompletableFuture 可以针对多个 CompletionStage 进行逻辑组合、实现复杂的异步编程。 这些逻辑组合的方法以回调的形式避免了线程阻塞:


@Test
fun test_CompletableFuture() {
CompletableFuture.supplyAsync(task1)
.thenCombine(CompletableFuture.supplyAsync(task2)) { p1, p2 ->
task3(p1, p2)
}.join()
}



10. RxJava


RxJava 提供的各种操作符以及线程切换能力同样可以帮助我们实现需求: zip 操作符可以组合两个 Observable 的结果;subscribeOn 用来启动异步任务


@Test
fun test_Rxjava() {

Observable.zip(
Observable.fromCallable(Callable(task1))
.subscribeOn(Schedulers.newThread()),
Observable.fromCallable(Callable(task2))
.subscribeOn(Schedulers.newThread()),
BiFunction(task3)
).test().awaitTerminalEvent()

}



11. Coroutine


前面讲了那么多,其实都是 Java 的工具。 Coroutine 终于算得上是 Kotlin 特有的工具了:


@Test
fun test_coroutine() {

runBlocking {
val c1 = async(Dispatchers.IO) {
task1()
}

val c2 = async(Dispatchers.IO) {
task2()
}

task3(c1.await(), c2.await())
}
}

写起来特别舒服,可以说是集前面各类工具的优点于一身。




12. Flow


Flow 就是 Coroutine 版的 RxJava,具备很多 RxJava 的操作符,例如 zip:



@Test
fun test_flow() {

val flow1 = flow<String> { emit(task1()) }
val flow2 = flow<String> { emit(task2()) }

runBlocking {
flow1.zip(flow2) { t1, t2 ->
task3(t1, t2)
}.flowOn(Dispatchers.IO)
.collect()

}

}

flowOn 使得 Task 在异步计算并发射结果。




总结


上面这么多方式,就像茴香豆的“茴”字的四种写法,没必要都掌握。作为结论,在 Kotlin 上最好用的线程同步方案首推协程!



作者:fundroid
链接:https://juejin.cn/post/6981952428786597902
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

被React Native插件狂虐2天之后,写下c++_share.so冲突处理心路历程

为了应对活体检测客户 react-native 端的支持,需要开发 react-native 插件供客户使用。关于react-native 插件开发具体可以参考react官网: reactnative.cn/docs/native… reactnative....
继续阅读 »

为了应对活体检测客户 react-native 端的支持,需要开发 react-native 插件供客户使用。关于react-native 插件开发具体可以参考react官网:



具体包含两部分



  1. ViewManager:包装原生的 view 供 react-native 的 js 部分使用

  2. NativeModule:提供原生的 api 能力供 react-native 的 js 部分调用


心路历程


参考着官方事例,插件代码很快就完成。开开心心把插件发布到 github 之后试用了一下就遇到了第一个问题


image.png


看错误很容易发现是 so 冲突了,也就是说 react-native 脚手架创建的项目原本就存在libc++_share.so,正好我们的活体检测 sdk 也存在 libc++_shared.so。冲突的解决方法也很简单,在 android 域中添加如下配置:


packagingOptions {
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
}

这边顺便解释下packagingOptions中几个关键字的意思和作用

关键字含义实例
doNotStrip可以设置某些动态库不被优化压缩doNotStrip '*/arm64-v8a/libc++_shared.so'
pickFirst匹配到多个相同文件,只提取第一个pickFirst 'lib/arm64-v8a/libc++_shared.so'
exclude过滤掉某些文件或者目录不添加到APK中exclude 'lib/arm64-v8a/libc++_shared.so'
merge将匹配的文件合并添加到APK中merge 'lib/arm64-v8a/libc++_shared.so'

上述例子中处理的方式是遇到冲突取第一个libc++_shared.so。冲突解决之后继续运行,打开摄像头过一会儿就崩溃了,报错如下:


com.awesomeproject A/libc: Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 30755 (work), pid 30611 (.awesomeproject)

从报错信息来看只知道错误的地方在jni部分,具体在什么位置?哪行代码?一概不知。从现象来看大致能猜到错误的入口,于是逐行代码屏蔽去试,最后定位到报错的代码竟然是:


std::cout << "src: (" << h << ", " << w << ")" << std::endl;

仅仅是简单的c++输出流,对功能本来没有影响。很好奇为什么会崩溃,查了好久一无所获。既然不影响功能就先删掉了这行代码,果然就不报错了,功能都能正常使用了,开开心心的交给测试回归。一切都是好好的,直到跑在arm64-v8a的设备上,出现了如下报错:


1e14141e-51c8-42e7-a5c7-440905742247.png


这次有明显的报错信息,意思是当运行opencv_java3.so的时候缺少_sfp_handler_exception函数,这个函数实际上是在c++_shared.so库中的。奇怪的是原生代码运行在arm64-v8a的设备上是好的,那怎么跑在react-native环境就会缺少_sfp_handler_exception函数了呢?
直到我在原生用ndk20a编译代码报了同样的错误,才意识到一切问题的源头是pickFirst引起的。


4a1a64b0-296d-4753-abc1-92da09d60cde.png


a4d4f827-ccea-4817-9175-e47458f1c917.png


可以明显的看到react-native和原生环境跑出来的apk包中c++_shared.so的大小是不同的。
也就是说pickFirst是存在安全隐患的,就拿这个例子来说,假如两个c++_shared.so是用不同版本的ndk打出来的,其实内部的库函数是不一样的,pickFirst贸然选择第一个必然导致另外的库不兼容。那么是不是可以用merge合并两个c++_shared.so,试了一下针对so merge失效了,只能是另辟蹊径。
如果我们的sdk只有一个库动态依赖于c++_shared.so,大可把c++_shared.so以静态库的方式打入,这样就不会有so冲突问题,同时也解决了上述问题。配置如下:


externalNativeBuild {
ndk {
abiFilters "armeabi-v7a", "arm64-v8a"
}
cmake {
cppFlags "-std=c++11 -frtti -fexceptions"
arguments "-DANDROID_STL=c++_shared" //shared改为static
}
}

可惜的是例子中的sdk不止一个库动态依赖于c++_shared.so,所以这条路也行不通。那么只能从react-native侧出发寻找方案。


方案一(推荐)


找出react-native这边的c++_shared.so是基于什么ndk版本打出来的,想办法把两端的ndk版本保持统一,问题也就迎刃而解了。


b2d4115d-0316-47c5-a14f-3dd5daf167f9.png


从react-native对应的android工程的蛛丝马迹中发现大概是基于ndk r20b打出来的。接下来就是改造sdk中c++_shared.so基于的ndk版本了。



  1. 基于ndk r20b版本重新编译opencv库

  2. 把opencv库连接到项目,基于ndk r20b版本重新编译alive_detected.so库


把编译好的sdk重新导入插件升级,运行之后果然所有的问题得以解决。


方案二


去除react-native中的c++_shared.so库,react-native并不是一开始就引入了c++_shared.so。从React Native版本升级中去查看c++_shared.so是哪个版本被引入的,可以发现0.59之前的版本是没有c++_shared.so库的,详见对比:


bd504920-1855-445d-8f8f-cf4b6e4feabd.png


4a77bb53-f0ad-45b4-862c-2e264b88db9d.png


那么我们把react-native版本降级为0.59以下也能解决问题,降级步骤如下:



  1. 进入工程


cd Temple


  1. 指定版本


npm install --save react-native@0.58.6


  1. 更新


react-native upgrade


  1. 一路替换文件


fdf99f54-b121-4321-8956-6e3bce7efb99.png


总结


Android开发会面临各种环境问题,遇到问题还是要从原理出发,理清问题发生的根源,这样问题就很好解决。



链接:https://juejin.cn/post/6976473129262514207

收起阅读 »

React Native 团队怎么看待 Flutter 的?终于有官方回复了

昨天 React Native 官方团队在 reddit 上发起了一次 AUA(ask us anything)活动,地址在文末。看到这个活动的时候,我脑海里想到的第一个问题就是,他们怎么看待 Flutter 的?结果打开活动后,发现已经有人问了,而且还得到了...
继续阅读 »

昨天 React Native 官方团队在 reddit 上发起了一次 AUA(ask us anything)活动,地址在文末。看到这个活动的时候,我脑海里想到的第一个问题就是,他们怎么看待 Flutter 的?结果打开活动后,发现已经有人问了,而且还得到了官方的回复。



提问者:



你们是怎么看待 Flutter 的,和 Flutter 比起来 React Native 有什么优劣?



官方回复:



我认为 React Native 和 Flutter 的目标是完全不同的,因此在实现上也采取了完全不同的方法,所以如何看待二者,就取决于你要达到什么样的目的了。举例来说,React Native 更倾向于将每个平台各自的特性和组件样式进行保留,而 Flutter 是通过自己的渲染引擎去渲染组件样式,以代替平台原生的效果。这取决于你想做什么以及想做成什么样,这个应该就是你最需要考虑的事情了。



话里有话:



看完了也没说哪里好,哪里不好,很标准的官方回复。看来是早就想好了答案,算准了肯定会有人问这个。而且看完这个回复,我感觉像是在说:“小孩才做选择,大人就都要!”





除了这个绕不开的问题以外,还有一个我认为比较关键的问题,就是关于 React Native 未来的发展。当然,这个问题也有人问了,就排在热门第一个。



提问者:



React Native 已经发布了有 4 年之久了,想问下你们对它未来 4 年的发展有什么想法呢?



官方回复:



我认为未来 React Native 的发展将有两个阶段。




在第一个阶段发展结束的时候,我认为 React Native 将成为一个把 React 语法带到任何一个原生平台上的框架。现在我们已经可以看到,通过 Fabric 以及 TurboModules 会让 React Naitve 变得更易用更通用。我希望 React Native 可以支持任何移动、桌面、AR/VR 平台。目前我们应该也可以看到,公司希望 React Native 能运行在除了 Android 和 iOS 以外的设备上。




在我开始讲述第二阶段前,首先需要明白我们要通过 React Native 达到什么目的是非常重要的,我们在尝试把 React 带到原生界面开发中。我们认为 React 在表现力、直观性以及灵活性之间,做到了一个非常好的平衡,以提供良好的性能和灵活的界面。




在第二阶段发展结束的时候,我认为 React Native 将会重新回归 "React",这意味着很多事情,并且他的定位也会更加模糊。但是,这意味着在 React Native 和 React for web 之间更加聚合与抽象。这可能意味着会将抽象的级别提高到目前开发人员熟悉的 Web 水平上来。然而有趣的是,Twitter 整个网站已经使用 React Native(react-native-web)编写了。虽然这看起来像“代码共享”的 holy grail。但其实没有必要,我相信它可以在任何平台上都能带来高质量的体验。



话里有话:



这段话的大概意思就是,未来,第一阶段,React Native 计划先把 React 搬到所有原生平台上,然后第二阶段,就是逐渐抹平 React Native 和 React for web 之间的区别,代码会朝着 Web 开发者熟悉的方向进行抽象和聚合




从这段话中,给我的感觉像是在说,React Native 是 React 的扩充而已,不要老拿我们和 Flutter 比,我们不一样,OK?至于未来怎么发展,那肯定是不会脱离我们庞大的 React 用户群体的。这本来就不是开发出来给你们原生开发者用的,而是给 Web 开发者扩充技能栈的。这么说,可能也是想避开和 Flutter 的正面交锋吧?毕竟在原生开发领域,Google 的技术积累比 Facebook 还是要深厚。





现在这个活动已经有超过 200 多条回复了,其中有很多大家比较关心的问题,我觉得所有在用 React Native 的开发者都可以去看一下。由于内容实在是太多了,我也就不逐一翻译了。


还有一点需要特别提一下,React Native 为什么要在这个时候搞这次 AUA 活动呢?正如他们在活动详情里提到的,因为 RN0.59 正式版马上就要发布了,官方宣称这次更新带来了“非常值得期待”的更新,所以可能是想出来好好宣传一下吧。


如果你也有关注 React Native 开发,可以关注我的公众号,会不定时分享一些国内外的动态,当然不只有 React Native,也会分享一些关于移动开发的其他原创内容。




围观地址:(要梯子)


https://www.reddit.com/r/reactnative/comments/azuy4v/were_the_react_native_team_aua/


收起阅读 »

RN几种脚手架工具的使用和对比(react-native-cli、create-react-native-app、exp)

1、react-native-cli 无法使用exp服务 react-native init program-name #初始化项目 npm start(react-native start) #在项目目录下启动 js service react-nat...
继续阅读 »

1、react-native-cli



无法使用exp服务



react-native init program-name  #初始化项目
npm start(react-native start) #在项目目录下启动 js service
react-native run-android #已连接真机或开启模拟器前提下,启动项目
react-native run-ios #已连接真机或开启模拟器前提下(仅支持mac系统),启动项目

2、create-react-native-app



create-react-native-app是React 社区孵化出来的一种无需构建配置就可以创建>RN App的一个开源项目,一个创建react native应用的脚手架工具(最好用,无需翻墙



初始化后项目可使用exp服务




安装使用


npm install -g create-react-native-app #全局安装

使用create-react-native-app来创建APP


create-react-native-app program-name #初始化项目
cd program-name #进入项目目录
npm start #启动项目服务

create-react-native-app常用命令


npm start  #启动本地开发服务器,这样一来你就可以通过Expo扫码将APP运行起来了
npm run ios #将APP运行在iOS设备上,仅仅Mac系统支持,且需要安装Xcode
npm run android #将APP运行在Android设备上,需要Android构建工具
npm test # 运行测试用例


如果本地安装了yarn管理工具,会提示使用yarn命令来启动管理服务




运行项目



Expo App扫码启动项目服务屏幕上自动生成的二维码,program-name就可以运
行在Expo App上



expo下载配置参考下一条


3、Expo



Expo是一组工具、库和服务,可以通过编写JavaScript来构建本地的ios和Android应用程序
需翻墙使用,下载资源速度慢



安装使用



PC上通过命令行安装expo服务



1、npm install exp --global #全局安装 简写: npm i -g exp


手机上安装Expo Client App(app store上叫Expo Client)
安装包下载地址:expo官网
手机安装好后注册expo账号(必须,后续用于PC expo 服务直接通过账号将项目应用于expo app




提示:为了确保Expo App能够正常访问到你的PC,你需要确保你的手机和PC处于同一网段内或者他们能够联通



初始化一个项目(Create your first project)


2、exp init my-new-project  #初始化项目,会要求你选择模板


The Blank project template includes the minimum dependencies to run and an empty root component 空白项目模板包含运行的最小依赖项和空白根组件


The Tab Navigation project template includes several example screens Tab Navigation项目模板包含几个示例屏幕




报错:



Set EXPO_DEBUG=true in your env to view the stack trace. 报错如下图
解决方法:下载Expo XDE(PC客户端使用) --初始化项目需翻墙





注:使用命令行初始化项目可能会卡在下载react-native资源,可转换成XDE初始化项目,再使用命令行启动项目并推送



3、cd my-new-project #进入项目目录
4、exp start #启动项目,推送至手机端

启动项目后会要求你输入你在App上注册的Expo账号和密码




初始化后项目结构



主要windows下android目录结构


|- program-name             | 项目工作空间
|- android | android 端代码
|- app | app 模块
|- build.gradle | app 模块 Gradle 配置文件
|- progurad-rules.pro | 混淆配置文件
|- src/main | 源代码
|- AndroidManifest.xml | APK 配置信息
|- java | 源代码
|- 包名 | java 源代码
|- MainActivity.java | 界面文件, (加载ReactNative源文件入口)
|- MainApplication.java | 应用级上下文, (ReactNative 插件配置)
|- res | APK 资源文件
|- gradle | Gradle 版本配置信息
|- keystores | APK 打包签名文件(如果正式开发需要自己定义修改签名文件)
|- gradlew | Gradle运行脚本, 与 react-native run-android 有关
|- gradlew.bat | Gradle运行脚本, 与 react-native run-android 有关
|- gradle.properties | Gradle 的配置文件, 正常是 AndroidHome, NDK, JDK, 环境变量的配置
|- build.gradle | Gradle的全局配置文件, 主要是是配置编译 Android 的 Gradle 插件,及配置 Gradle仓库
|- settings.gradle | Gradle模块配置
|- ios | iOS 端代码
|- node_modules | 项目依赖库
|- package.json | node配置文件, 主是要配置项目的依赖库,
|- index.android.js | Android 项目启动入口
|- index.ios.js | iOS 项目启动入口


package.json文件说明



dependencies




  • 项目的依赖配置

    • 依赖配置,配置信息配置方式

      • “version” 强制使用特定版本

      • “^version” 兼容版本

      • “git…” 从 git版本控制地址获取依赖版本库

      • “path/path/path” 指定本地位置下的依赖库

      • “latest” 使用最新版本

      • “>version” 会在 npm 库中找最新的版本, 并且大于此版本

      • “>=version” 会在 npm 库中找最新的版本, 并且大于等于此版本“







devDependencies



  • 开发版本的依赖库




version




  • js 版本标志



description




  • 项目描述, 主要使用于做第三方支持库时,对库的描述信息



main




  • 项目的缺省入口



engines




  • 配置引擎版本信息, 如 node, npm 的版本依赖



**index.*.js
新版RN统一入口:index.js




  • 正常只作为项目入口,不做其他业务代码处理


注:
1、虚拟机上很消耗电脑内存, 建议使用真机进行安装测试



链接:https://juejin.cn/post/6844903599793766413

收起阅读 »

一份传男也传女的 React Native 学习笔记

这段时间了解了一些前端方面的知识,并且用 React Native 写了一个简易新闻客户端 Demo。 React Native 和原生开发各有所长,具体就不细说。混合使用能充分发挥各自长处,唯一的缺憾就是 React Native 和原生通信过程相对不那么友...
继续阅读 »

这段时间了解了一些前端方面的知识,并且用 React Native 写了一个简易新闻客户端 Demo。


React Native 和原生开发各有所长,具体就不细说。混合使用能充分发挥各自长处,唯一的缺憾就是 React Native 和原生通信过程相对不那么友好。


在这里分享一下学习过程中个人认为比较重要的知识点和学习资料,本文尽量写得轻一些,希望对读者能够有所帮助。


预备知识


有些前端经验的小伙伴学起 React Native 就像老马识途,东西都差不多,变来变去也玩不出什么花样。


HTML5:H5 元素对比 React Native 组件,使用方式如出一辙。


CSS:React Native 的 FlexBox 用来为组件布局的,和 CSS 亲兄弟关系。


JavaScript:用 JavaScript 写,能不了解一下吗? JavaScript 之于 React Native 就如同砖瓦之于摩天大楼。


React JSX:React 使用 JSX 来替代常规的 JavaScript。JSX 是一个看起来很像 XML 的 JavaScript 语法扩展。


一、开始学习 React Native


图片来自网络


React Native 社区相对比较成熟,中文站的内容也比较全面,从入门到进阶,环境安装到使用指南,学习 React Native 推荐从官网 reactnative.cn 开始。FlexBox 布局、组件、API 建议在该官网查看,注意网页顶部可以切换 React Native 的历史版本。


1.1 安装开发环境



  1. React Native 官网推荐搭建开发环境指南传送门。(记得设置 App Transport Security Settings ,允许 http 请求)

  2. 已建立原生项目,将 React Native 集成到现有原生项目传送门

  3. 基于第2点,React Native 与原生混编的情况下,React Native 与原生如何通信传送门

  4. 在 IDE 选择这一点上,不要过多纠结,个人使用 WebStorm ,比较省心。


1.2 生命周期

class Clock extends React.Component {
// 构造函数 通常进行一些初始化操作 如定义 state 初始值
constructor(props) {
super(props);
}

// 组件已挂载
componentDidMount() {}

// 组件即将被卸载
componentWillUnmount() {}

// 渲染函数
render() {
return (
<View></View>
);
}
}


1.3 Props 与 State


一个组件所有的数据来自于 Props 与 State ,分布是外部传入的属性和内部状态。


Props 是父组件给子组件传递数据用的,Props 由外部传入后无法改变,可以同时传递多个属性。

// 父组件 传递一个属性 name 给子组件
<Greeting name='xietao3' />

// 子组件使用父组件传递下来的属性 name
<Text>Hello {this.props.name}!</Text>


State :用来控制组件内部状态,每次修改都会重新渲染组件。

// 初始化 state
constructor(props) {
super(props);
this.state = { showText: 'hello xietao3' };
}

// 使用 state
render() {
// 根据当前showText的值决定显示内容
return (
<Text>{this.state.showText}</Text>
);
}

// 修改state,触发 render 函数,重新渲染页面
this.setState({showText: 'hello world'});


举个栗子(如果理解了就跳过吧):


我们使用两种数据来控制一个组件:props 和 state。 props 是在父组件中指定,而且一经指定,在被指定的组件的生命周期中则不再改变。 对于需要改变的数据,我们需要使用 state 。


一般来说,你需要在 constructor 中初始化 state ,然后在需要修改时调用setState方法。


假如我们需要制作一段不停闪烁的文字。文字内容本身在组件创建时就已经指定好了,所以文字内容应该是一个 prop 。而文字的显示或隐藏的状态(快速的显隐切换就产生了闪烁的效果)则是随着时间变化的,因此这一状态应该写到 state 中。


1.4 组件与 API


说到组件就不得不说 React Native 的组件化思想,尼古拉斯 · 赵四 曾经说过,组合由于继承。简单来说就是多级封装嵌套、组合使用,提高基础组件复用率。


组件怎么用?


授人以鱼不如授人以渔,点击这里打开官方文档 ,在左边导航栏中找到你想使用的组件并且点击,里面就有组件的使用方式和属性的详细介绍。


关于 API


建议写第一个 Demo 之前把所有 API 浏览一遍,磨刀不误砍柴工,不一定要会用,但一定要知道这些提供了哪些功能,后面开发中可能会用得上。API 列表同样可以在官网左边导航栏中找到。


二、助力 React Native 起飞


以下内容不建议在第一个 Demo 中使用:


2.1 Redux


Redux(中文教程英文教程) 是 JavaScript 状态容器,提供可预测化的状态管理。


部分推荐教程:



2.2 CodePush


React Native 热更新的发动机,接入的时候绕了很多圈圈,后面发现接入还挺方便的。CodePush 除了可以使用微软提供的服务进行热更新之外,还可以自建服务器进行热更新。


推荐教程:



三、 与原生端通信


3.1 在 React Native 中使用原生UI组件


填坑:



  • 原生端的 Manager 文件如果有 RCT 前缀,在 RN 中引用的时候不要加 RCT。

  • 原生 UI 组件的 RCTBubblingEventBlock 类型属性命名一定要以 on 开头,例如 onChange。


3.2 在 React Native 中发消息通知给原生端(由于RN调用原生端是异步的,最好在回调中通过通知把消息传递到具体的类)


3.3 在原生端发消息通知给 React Native (建议在Manager中写一个类方法,这样外部也可以灵活发送通知)


这里其实是有 Demo 的,但是还没整理好🤦️。


四、React Native 进阶资源


有时候一下子看到好多感兴趣的东西,容易分散注意力,在未到达一定水平之前建议不要想太多,入门看官网就足够了。当你掌握了那些知识之后,你就可以拓展一下你的知识库了。



  • awesome-react-native 19000+⭐️(包含热门文章、信息、第三方库、工具、学习书籍视频等)

  • react-native-guide 11900+⭐️ (中文 react-native 学习资源、开 源App 和组件)

  • js.coach (第三方库搜索平台)

  • 个人收集的一些开源项目(读万卷书不如行万里路,行万里路不如阅码无数!经常看看别人的代码,总会有新收获的)


五、React Native 第一个小 Demo


5.1 MonkeyNews 简介


MonkeyNews,纯 React Native 新闻客户端,部分参考知乎日报,并且使用了其部分接口
由于是练手项目,仅供参考,这里附上 GitHub 地址,感兴趣的可以了解(star)一下。


首页


频道


个人中心


5.2 用到的第三方库:



  • react-native-code-push:React Native 热更新

  • react-native-swiper:用于轮播图

  • react-navigation:TabBar + NavigationBar


5.3 项目结构



Common



MKSwiper.js

MKNewsListItem.js
MKImage.js

MKPlaceholderView.js

MKThemeListItem.js

MKLoadingView.js

...





Config



MKConstants.js





Pages



Home



MKHomePage.js

MKNewsDetailPage.js





Category



MKCategoryPage.js

MKThemeDetailPage.js





UserCenter



MKUserCenterPage.js






Services



MKServices.js

APIConstants.js





Styles



commonStyles.js




六、总结


在对 React Native 有了一些了解之后,个人感觉目前 React Native 的状况很难替代原生开发,至少现阶段还不行。


个人认为的缺点:React Native 的双端运行的优点并不明显,很多原生 API 使用起来都比较麻烦,很大程度上抵消了双端运行带来的开发效率提升,这种情况下我甚至更愿意用原生 iOS 和 Android 各写一套。


优点:React Native 和原生组合使用,通过动态路由动态在原生页面和 React Native 页面之间切换,可以在原生页面出现 bug 的时候切换至 React Native 页面,或者比较简单的页面直接使用 React Native 开发都是非常不错的。


总之, React Native 也是可以大有作为的。



链接:https://juejin.cn/post/6844903605137342477

收起阅读 »

iOS-汇编-指针、OC

局部变量&全局变量int global = 10; int main(int argc, char * argv[]) { int a = 20; int b = global + 1; return UIApplicatio...
继续阅读 »

编译器优化

局部变量&全局变量

int global = 10;

int main(int argc, char * argv[]) {
int a = 20;
int b = global + 1;
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}

在不进行优化的情况下:


改成Fastest、Smallest模式,ab都被优化掉了。




局部变量和全局变量会被优化掉。

函数

int func(int a,int b) {
return a + b;
}

int main(int argc, char * argv[]) {
int value = func(10, 20);
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}


func函数也会被优化掉,因为对程序的执行结果没有影响。
修改下:


int func(int a,int b) {
return a + b;
}

int main(int argc, char * argv[]) {
int value = func(10, 20);
NSLog(@"%d",value);
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}





可以看到直接将0x1e结果入栈。一样也会被优化。

编译配置(Optimization Level)


编译器(从c汇编的编译过程)的优化配置(指定被编译代码的执行速度和二进制文件大小的优化程度)。优化后的代码效率比较高,但是可读性比较差,且编译时间更长。
优化等级配置在Build Settings -> Apple Clang - Code Generation -> Optimization Level 中:




  • None [-O0]:不优化。
    编译器的目标是降低编译消耗,保证调试时输出期望的结果。程序的语句之间是独立的:如果在程序的停在某一行的断点出,我们可以给任何变量赋新值抑或是将程序计数器指向方法中的任何一个语句,并且能得到一个和源码完全一致的运行结果。
  • Fast [-O, O1]: 大函数所需的编译时间和内存消耗都会稍微增加。
    在这种设置下,编译器会尝试减小代码文件的大小,减少执行时间,但并不执行需要大量编译时间的优化。在苹果的编译器中,在优化过程中,严格别名,块重排和块间的调度都会被默认禁止掉。此优化级别提供了良好的调试体验,堆栈使用率也提高,并且代码质量优于None[-O0]。
  • Faster [-O2]:编译器执行所有不涉及时间空间交换的所有的支持的优化选项。
    更高的性能优化Fast[-O1]。在这种设置下,编译器不会进行循环展开、函数内联或寄存器重命名。和‘Fast[-O1]’项相比,此设置会增加编译时间和生成代码的性能。
  • Fastest [-O3]:在开启Fast[-O1]项支持的所有优化项的同时,开启函数内联和寄存器重命名选项
    是更高的性能优化Faster[-O2],指示编译器优化所生成代码的性能,而忽略所生成代码的大小,有可能会导致二进制文件变大。还会降低调试体验。
  • Fastest, Smallest [-Os]:在不显着增加代码大小的情况下尽量提供高性能
    这个设置开启了Fast[-O1]项中的所有不增加代码大小的优化选项,并会进一步的执行可以减小代码大小的优化。增加的代码大小小于Fastest[-O3]。与Fast[-O1]相比,它还会降低调试体验。
  • Fastest, Aggressive Optimizations [-Ofast]:与Fastest, Smallest[-Os]相比该级别还执行其他更激进的优化
    这个设置开启了Fastest[-O3]中的所有优化选项,同时也开启了可能会打破严格编译标准的积极优化,但并不会影响运行良好的代码。该级别会降低调试体验,并可能导致代码大小增加。
  • Smallest, Aggressive Size Optimizations [-Oz]:不使用LTO的情况下减小代码大小
    与-Os相似,指示编译器仅针对代码大小进行优化,而忽略性能优化,这可能会导致代码变慢。



  • XcodeDebug模式默认为None[-O0]Release默认为Fastest, Smallest[-Os]

    指针

    指针在汇编中只是地址, 在底层来说就是数据。

    指针基本常识

    指针的宽度为8字节。

    void func() {
    //指针的宽度8字节
    int *a;
    printf("%lu",sizeof(a));
    }

    int main(int argc, char * argv[]) {
    func();
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }


    • sizeof:是个符号,操作符。这里验证了是常量8

    指针的运算

    指针++

    int型指针++:

    int *a;
    a = (int *)100;
    a++;

    运算结果:104int4字节。

    char型指针++:

    char *a;
    a = (char *)100;
    a++;

    运算结果:101char1字节。

    指向指针的指针++:

    int **a;
    a = (int **)100;
    a++;

    运算结果:108,指针占8字节

    指针+

    int **a;
    a = (int **)100;
    a = a + 1;

    运算结果:108。与a++++、--与编译器有关)等价。

    指针-

    int *a;
    a = (int *)100;
    int *b;
    b = (int *)200;
    int x = a - b; // a/4 - b/4 = -25


    运算结果:-25。(a/4 - b/4 = -25

    • 指针的运算与指向的数据类型宽度(步长)有关。
    • 指针的运算单位是执行的数据类型的宽度。
    • 结构体和基本类型不能强制转换,普通类型可以通过&

    指针的反汇编

    void func() {
    int* a;
    int b = 10;
    a = &b;
    }

    对应的汇编:

    TestDemo`func:
    0x10098a1c4 <+0>: sub sp, sp, #0x10 ; =0x10
    //sp+0x4 x8
    0x10098a1c8 <+4>: add x8, sp, #0x4 ; =0x4
    //1O给w9
    0x10098a1cc <+8>: mov w9, #0xa
    //w9 入栈
    -> 0x10098a1d0 <+12>: str w9, [sp, #0x4]
    //x8 指向 sp+0x8。相当于x8指向sp,也就是指向10的地址
    0x10098a1d4 <+16>: str x8, [sp, #0x8]

    0x10098a1d8 <+20>: add sp, sp, #0x10 ; =0x10
    0x10098a1dc <+24>: ret

    [sp, #0x8]是个指针变量。从0x8~0x10保存的就是指针。

    数组和指针

    void func() {
    int arr[5] = {1,2,3,4,5};
    //int *a == &arr[0] == arr
    int *a = arr;
    for (int i = 0; i < 5; i++) {
    printf("%d\n",arr[i]);
    printf("%d\n",*(arr + i));
    // printf("%d\n",*(arr++));
    printf("%d\n",*(a++));
    }
    }

    *(arr++) 会报错。int *a = arr; 之后 a++就没问题了、

    • 数组名和指针变量是一样的,唯一的区别是一个是常量,一个是变量。
      int *a == &arr[0] == arr

    指针的基本用法

    void func() {
    char *p1;
    char c = *p1;
    printf("%c",c);
    }



    p1由于是个指针,没有初始化编译不会报错,运行会报错。在iOS中默认是0,运行会直接野指针。

    指向char的指针+0

    void func() {
    char *p1;
    char c = *p1;
    char d = *(p1 + 0);
    }


    对应汇编

    TestDemo`func:
    0x104b661bc <+0>: sub sp, sp, #0x10 ; =0x10
    //p1 -> 0x0 x8指向p1
    0x104b661c0 <+4>: ldr x8, [sp, #0x8]
    //c = [x8] 给到 w9
    -> 0x104b661c4 <+8>: ldrb w9, [x8]
    0x104b661c8 <+12>: strb w9, [sp, #0x7]
    0x104b661cc <+16>: ldr x8, [sp, #0x8]
    //d = [x8] 给到 w9
    0x104b661d0 <+20>: ldrb w9, [x8]
    0x104b661d4 <+24>: strb w9, [sp, #0x6]

    0x104b661d8 <+28>: add sp, sp, #0x10 ; =0x10
    0x104b661dc <+32>: ret

    指向char的指针+1

    void func() {
    char *p1;//指针 -> x8 0x0
    char c = *p1;// [x8]
    char d = *(p1 + 1);//[x8, #0x1]
    }

    对应汇编:

    TestDemo`func:
    0x1041f21bc <+0>: sub sp, sp, #0x10 ; =0x10
    //p1
    0x1041f21c0 <+4>: ldr x8, [sp, #0x8]
    //c
    -> 0x1041f21c4 <+8>: ldrb w9, [x8]
    0x1041f21c8 <+12>: strb w9, [sp, #0x7]
    0x1041f21cc <+16>: ldr x8, [sp, #0x8]
    //d
    0x1041f21d0 <+20>: ldrb w9, [x8, #0x1]
    0x1041f21d4 <+24>: strb w9, [sp, #0x6]
    0x1041f21d8 <+28>: add sp, sp, #0x10 ; =0x10
    0x1041f21dc <+32>: ret

    指向int的指针+1

    void func() {
    int *p1;//指针 -> x8 0x0
    int c = *p1;// [x8]
    int d = *(p1 + 1);//[x8, #0x4]
    }
    TestDemo`func:
    0x1040e61bc <+0>: sub sp, sp, #0x10 ; =0x10
    //p1 [x8]
    0x1040e61c0 <+4>: ldr x8, [sp, #0x8]
    //c
    -> 0x1040e61c4 <+8>: ldr w9, [x8]
    0x1040e61c8 <+12>: str w9, [sp, #0x4]
    0x1040e61cc <+16>: ldr x8, [sp, #0x8]
    //d
    0x1040e61d0 <+20>: ldr w9, [x8, #0x4]
    0x1040e61d4 <+24>: str w9, [sp]
    0x1040e61d8 <+28>: add sp, sp, #0x10 ; =0x10
    0x1040e61dc <+32>: ret

    指向int的指针的指针+1

    void func() {
    int **p1;//指针 -> x8 0x0
    int *c = *p1;// [x8]
    int *d = *(p1 + 1);//[x8, #0x8]
    }
    TestDemo`func:
    0x1041821b8 <+0>: sub sp, sp, #0x20 ; =0x20
    //p1 [x8]
    0x1041821bc <+4>: ldr x8, [sp, #0x18]
    //c
    -> 0x1041821c0 <+8>: ldr x8, [x8]
    0x1041821c4 <+12>: str x8, [sp, #0x10]
    0x1041821c8 <+16>: ldr x8, [sp, #0x18]
    //d
    0x1041821cc <+20>: ldr x8, [x8, #0x8]
    0x1041821d0 <+24>: str x8, [sp, #0x8]
    0x1041821d4 <+28>: add sp, sp, #0x20 ; =0x20
    0x1041821d8 <+32>: ret

    这里拉伸了#0x2016字节对齐。

    指向指针的指针

    void func() {
    char **p1;
    char c = **p1;
    }

    取地址的地址在汇编中:

    TestDemo`func:
    0x102cf61c4 <+0>: sub sp, sp, #0x10 ; =0x10
    //初始值
    0x102cf61c8 <+4>: ldr x8, [sp, #0x8]
    //两次ldr,二级指针在寻址
    -> 0x102cf61cc <+8>: ldr x8, [x8]
    0x102cf61d0 <+12>: ldrb w9, [x8]

    0x102cf61d4 <+16>: strb w9, [sp, #0x7]
    0x102cf61d8 <+20>: add sp, sp, #0x10 ; =0x10
    0x102cf61dc <+24>: ret

    两次ldr,二级指针在寻址。


    指针的指针&指针混合偏移

    void func() {
    char **p1;
    char c = *(*(p1 + 2) + 2); // [0x10 + 0x2]
    }

    p1偏移 (2 * 指针) +(2 * char)


    void func() {
    char **p1;
    char c = *(*(p1 + 2) + 2); // [0x10 + 0x2]
    char c2 = p1[1][2]; // [0x8 + 0x2]
    }

    p1[1][2]等价于*(*(p1 + 1) + 2)


    OC反汇编

    创建一个简单的Hotpot类:

    //Hotpot.h
    @interface Hotpot : NSObject

    @property (nonatomic, copy) NSString *name;
    @property (nonatomic, assign) int age;

    + (instancetype)hotpot;

    @end

    //Hotpot.m
    #import "Hotpot.h"

    @implementation Hotpot

    + (instancetype)hotpot {
    return [[self alloc] init];
    }

    @end

    main.m中调用:

    #import "Hotpot.h"

    int main(int argc, char * argv[]) {
    Hotpot *hp = [Hotpot hotpot];
    return 0;
    }

    对应的汇编代码:




    我们都知道OC方法objc_msgSend默认有两个参数self cmd,分别是idSEL类型。
    验证下:

    (lldb) x 0x1027c95b0
    0x1027c95b0: f8 95 7c 02 01 00 00 00 20 96 7c 02 01 00 00 00 ..|..... .|.....
    0x1027c95c0: 08 00 00 00 10 00 00 00 08 00 00 00 00 00 00 00 ................
    (lldb) po 0x01027c95f8
    Hotpot

    (lldb) x 0x1027c95a0
    0x1027c95a0: bd 65 7c 02 01 00 00 00 a8 af b3 df 01 00 00 00 .e|.............
    0x1027c95b0: f8 95 7c 02 01 00 00 00 20 96 7c 02 01 00 00 00 ..|..... .|.....
    (lldb) po (SEL)0x01027c65bd
    "hotpot"

    (lldb) register read x0
    x0 = 0x00000001027c95f8 (void *)0x00000001027c95d0: Hotpot
    (lldb) register read x1
    x1 = 0x00000001027c65bd "hotpot"
    (lldb)

    接着进入hotpot方法中,对应汇编如下:



    发现没有走objc_msgSend方法,直接走了objc_alloc_init方法。

    ⚠️:这块和支持的最低版本有关。
    iOS9中为objc_msgSend 和 objc_msgSend对应allocinit
    iOS11中为objc_alloc 和 objc_msgSend,这里优化了alloc直接调用了objc_alloc,没有调用objc_msgSend
    iOS13中为objc_alloc_init,这里同时优化了allocinit

    hotpot方法执行完毕后会返回实例对象:




    在下面调用了一个objc_storeStrong函数(OC中用strong修饰的函数都会调用这个函数,例子中hp局部变量默认就是__strong)。objc_storeStrong调用后如果被外部引用引用计数+1,否则就销毁。
    objc4-818.2源码中objc_storeStrong源码(在NSObject.mm中):

    void
    objc_storeStrong(id *location, id obj)
    {
    id prev = *location;
    if (obj == prev) {
    return;
    }
    objc_retain(obj);
    *location = obj;
    objc_release(prev);
    }

    这个函数有两个参数 id* 和 id,函数的目的为对strong修饰的对象retain + 1,对旧对象release

        //x8指向 sp + 0x8 地址
    0x1022421a0 <+60>: add x8, sp, #0x8 ; =0x8
    //x8 就是指向x0的地址
    0x1022421a4 <+64>: str x0, [sp, #0x8]
    0x1022421a8 <+68>: stur wzr, [x29, #-0x4]
    0x1022421ac <+72>: mov x0, x8
    0x1022421b0 <+76>: mov x8, #0x0
    0x1022421b4 <+80>: mov x1, x8
    //objc_storeStrong 第一个参数 &hp,第二个参数 0x0
    -> 0x1022421b8 <+84>: bl 0x102242520 ; symbol stub for: objc_storeStrong
    0x1022421bc <+88>: ldur w0, [x29, #-0x4]
    0x1022421c0 <+92>: ldp x29, x30, [sp, #0x20]
    0x1022421c4 <+96>: add sp, sp, #0x30 ; =0x30
    0x1022421c8 <+100>: ret

    调用objc_storeStrong的过程就相当于:

    //分别传入 &hp  和 0x0
    void
    objc_storeStrong(id *location, id obj)
    {
    id prev = *location;//id prev = *hp
    if (obj == prev) {
    return;
    }
    objc_retain(obj);// nil
    *location = obj;// hp 指向第二个对象 hp = nil
    objc_release(prev);//释放老对象 release hp 释放堆空间
    }
    所以这里objc_storeStrong调用为了释放对象。
    objc_storeStrong断点前后验证:

    (lldb) p hp
    (Hotpot *) $3 = 0x000000028014bf60
    (lldb) ni
    (lldb) p hp
    (Hotpot *) $4 = nil
    (lldb)

    单步执行后hp变成了nil

    工具反汇编

    由于大部分情况下OC代码都比较复杂,自己分析起来比较麻烦。我们一般都借助工具来协助反汇编,一般会用到MachoViewHopper,IDA
    将刚才的代码稍作修改:

    #import "Hotpot.h"

    int main(int argc, char * argv[]) {
    Hotpot *hp = [Hotpot hotpot];
    hp.name = @"cat";
    hp.age = 1;
    return 0;
    }

    通过hopper打开macho文件


    可以看到已经自动解析出了方法名和参数,那么编译器是怎么做到呢?

    双击objc_cls_ref_Hotpot会跳转到对应的地址:




    可以看到所有方法都在这块。
    所以在分析汇编代码的时候编译器就能根据工具找到这些字符串。这也就是能还原的原因。

    Block反汇编

    在平时开发中经常会用到block,那么block汇编是什么样子呢?

    int main(int argc, char * argv[]) {
    void(^block)(void) = ^() {
    NSLog(@"block test");
    };
    block();
    return 0;
    }

    一般在反汇编的时候我们希望定位到block的实现(invoke
    对应汇编如下:


    invoke0x102c4e160

    block源码定义如下(Block_private.h):

    struct Block_layout {
    void *isa; //8字节
    volatile int32_t flags; // contains ref count //4字节
    int32_t reserved;//4字节
    BlockInvokeFunction invoke;
    struct Block_descriptor_1 *descriptor;
    // imported variables
    };

    也就是isa往下16字节就是invoke


    hopper中:



    StackBlock

    int main(int argc, char * argv[]) {
    int a = 10;
    void(^block)(void) = ^() {
    NSLog(@"block test:%d",a);
    };
    block();
    return 0;
    }




    验证isainvoke

    (lldb) po 0x100a8c000
    <__NSStackBlock__: 0x100a8c000>
    signature: "<unknown signature>"

    (lldb) x 0x100a8c000
    0x100a8c000: 30 88 ae df 01 00 00 00 94 3f c5 89 01 00 00 00 0........?......
    0x100a8c010: 00 00 00 00 00 00 00 00 24 00 00 00 00 00 00 00 ........$.......
    (lldb) po 0x01dfae8830
    __NSStackBlock__

    (lldb) dis -s 0x100a8a140
    TestOC&BlockASM`__main_block_invoke:
    0x100a8a140 <+0>: sub sp, sp, #0x30 ; =0x30
    0x100a8a144 <+4>: stp x29, x30, [sp, #0x20]
    0x100a8a148 <+8>: add x29, sp, #0x20 ; =0x20
    0x100a8a14c <+12>: stur x0, [x29, #-0x8]
    0x100a8a150 <+16>: str x0, [sp, #0x10]
    0x100a8a154 <+20>: ldr w8, [x0, #0x20]
    0x100a8a158 <+24>: mov x0, x8
    0x100a8a15c <+28>: adrp x9, 2

    invokeimp实现通过dis -s查看汇编实现。

    hopper中:




    global blockblockdescriptor是在一起的,stack block并不在一起。




    作者:HotPotCat
    链接:https://www.jianshu.com/p/e3351311efa8


    收起阅读 »

    iOS 自定义命令行工具

    我们再越狱手机上能用很多工具,尤其是在终端上的一些操作。那么怎么实现一个在iOS终端的命令行工具呢?比如我们将常用的命令封装成自己的一个命令行工具方便自己调用。在这里我以ps -A和debugserver的开启为例。一、工程创建首先用Xcode创建一个iOS ...
    继续阅读 »

    我们再越狱手机上能用很多工具,尤其是在终端上的一些操作。那么怎么实现一个在iOS终端的命令行工具呢?

    比如我们将常用的命令封装成自己的一个命令行工具方便自己调用。在这里我以ps -Adebugserver的开启为例。


    一、工程创建

    首先用Xcode创建一个iOS App,这么做是因为要生成iOS终端可执行的命令行,默认main函数如下:

    #import <UIKit/UIKit.h>
    #import "AppDelegate.h"

    int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
    // Setup code that might create autoreleased objects goes here.
    appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
    }
    这样工程就创建好了,接下来就是功能的实现了。当然可以根据自己的需要配置自己支持的架构等相关内容。

    二、main函数

    2.1 main函数精简

    由于是制作命令行工具,所以界面相关的内容都删除,只保留main函数。精简后如下:


    #import <Foundation/Foundation.h>
    /**
    @param argc 入参个数
    @param argv 入参数组 argv[0] 为可执行文件
    */

    int main(int argc, char * argv[]) {
    @autoreleasepool {
    //根据自己的需要做逻辑处理
    }
    return 0;
    }

    2.2 main框架

    首先实现基本的框架,我们需要的功能一个是列出所有进程,一个是启动手机端debugserver。后续可能还会扩展更多功能并且为了方便使用需要加入一个help和容错处理。那么就有了:

    • help函数提供说明帮助。
    • runPS实现ps -A列出所有进程。
    • runDebugServer实现开启手机端runDebugServer功能。
    实现代码如下:

    /**
    @param argc 入参个数
    @param argv 入参数组 argv[0] 为可执行文件
    */

    int main(int argc, char * argv[]) {
    @autoreleasepool {
    if (argc == 1 || strcmp(argv[1], "-h") == 0 || strcmp(argv[1], "--help") == 0) {
    //help();
    } else {
    if (strcmp(argv[1], "-p") == 0 || strcmp(argv[1], "--process") == 0) {
    runPS();
    } else if ((strcmp(argv[1], "-d") == 0 || strcmp(argv[1], "--debugserver") == 0) && argc > 2 && argv[2] != NULL) {
    runDebugServer(argv[2]);
    } else {
    printf("illegal option:%s\n",argv[1]);
    printf("Try 'HPCMD --help' for more information. \n");
    }
    }
    }
    return 0;
    }

    main函数有两个参数:

    • argc:参数个数。
    • argv:入参数组,这个入参数组第一个参数argv[0]就是可执行文件本身。

    三、如何代码调用shell命令。


    查询资料得知有3种方式:

    • 1.system函数,目前已经被废弃。不过应该可以找到函数地址去尝试直接调用。
      1. NSTask,不过这个只能用在macOS中,如果写macOS终端命令行工具可以用这个。
      1. posix_spawn目前也只有这个能用了。在#include <spawn.h>中。

    posix_spawn函数定义如下:


    int     posix_spawn(pid_t * __restrict, const char * __restrict,
    const posix_spawn_file_actions_t *,
    const posix_spawnattr_t * __restrict,
    char *const __argv[__restrict],
    char *const __envp[__restrict]) __API_AVAILABLE(macos(10.5), ios(2.0)) __API_UNAVAILABLE(watchos, tvos);

    posix_spawn函数一共6个参数

    • pid_t:子进程pidpid 参数指向一个缓冲区,该缓冲区用于返回新的子进程的进程ID
    • const char * :可执行文件的路径path(其实就是可以调用某些系统命令,只不过要指定其完整路径)
    • posix_spawn_file_actions_tfile_actions 参数指向生成文件操作对象,该对象指定要在子对象之间执行的与文件相关的操作
    • posix_spawnattr_tattrp 指向一个属性对象,该对象指定创建的子进程的各种属性。
    • argv:指定在子进程中执行的程序的参数列表
    • envp:指定在子进程中执行的程序的环境

    这里简单封装runCMD函数如下:


    /*
    posix_spawn 函数一共6个参数
    pid_t:子进程 pid(pid 参数指向一个缓冲区,该缓冲区用于返回新的子进程的进程ID)
    const char * :可执行文件的路径 path(其实就是可以调用某些系统命令,只不过要指定其完整路径)
    posix_spawn_file_actions_t:file_actions 参数指向生成文件操作对象,该对象指定要在子对象之间执行的与文件相关的操作
    posix_spawnattr_t:attrp 指向一个属性对象,该对象指定创建的子进程的各种属性。
    argv:指定在子进程中执行的程序的参数列表
    envp:指定在子进程中执行的程序的环境
    */

    #include <spawn.h>

    int runCMD(char *cmd, char *argv[]) {
    pid_t pid;
    //这里注意 cmd 也要包含在 argv[0]中传入。
    posix_spawn(&pid, cmd, NULL, NULL, argv, NULL);
    int stat;
    waitpid(pid,&stat,0);
    printf("run cmd:%s stat:%d\n",cmd,stat);
    return stat;
    }

    四、功能实现

    4.1 help实现


    //打印help信息
    void help() {
    printf("-p:--process 显示进程 (等效ps -A) \n");
    printf("-d:<--debugserver 应用名称/进程id>开启debugserver (等效 debugserver localhost:12346 -a 进程名/进程id) \n");
    printf("-h:--help \n");
    }

    4.2 runPS实现

    void runPS() {
    char *CMD_argv[] = {
    "/usr/bin/ps",
    "-A",
    NULL
    };
    //ps -A
    runCMD(CMD_argv[0],CMD_argv);
    }

    4.3 runDebugServer 实现

    //debugserver localhost:12346 -a 进程名
    void runDebugServer(char *process) {
    printf("process:%s\n",process);
    char *CMD_argv[5] = {
    "/usr/bin/debugserver",
    "localhost:12346",
    "-a",
    NULL,
    NULL
    };
    CMD_argv[3] = process;
    runCMD(CMD_argv[0],CMD_argv);
    }

    这里需要注意的是最后一个参数要为NULL

    这样整个功能就全部完成。


    五、运行


    1.由于创建的是App工程,编译生成App后将其中的MachO文件拷贝出来。
    2.将可执行文件拷贝到手机根目录

    scp -P 12345 ./HPCMD root@localhost:~/
    3.手机端执行HPCMD
    -h:

    zaizai:~ root# ./HPCMD -h
    -p:--process 显示进程 (等效ps -A)
    -d:<--debugserver 应用名称/进程id>开启debugserver (等效 debugserver localhost:12346 -a 进程名/进程id)
    -h:--help
    -p:
    zaizai:~ root# ./HPCMD -p
    PID TTY TIME CMD
    1 ?? 17:09.03 /sbin/launchd
    295 ?? 5:41.90 /usr/libexec/substituted
    296 ?? 0:00.00 (amfid)
    1585 ?? 0:00.00 /usr/libexec/amfid
    1600 ?? 412:41.57 /usr/sbin/mediaserverd

    -d:
    zaizai:~ root# ./HPCMD -d WeChat
    process:WeChat
    debugserver-@(#)PROGRAM:LLDB PROJECT:lldb-1200.2.12
    for arm64.
    Attaching to process WeChat...
    Listening to port 12346 for a connection from localhost...
    -s:

    zaizai:~ root# ./HPCMD -s
    illegal option:-s
    Try 'HPCMD --help' for more information.

    这样就验证完整个cmd的功能了。

    可以根据自己的需求实现自己的自定义命令行工具,当然对于一些其它操作需要更多权限可以直接导出系统的SpringBoard可执行文件从而导出它的权限文件用ldid重签自己的命令行工具



    作者:HotPotCat
    链接:https://www.jianshu.com/p/d7f0eca98198

    收起阅读 »

    什么是spring,它能够做什么?

    1.什么是Spring Spring是一个开源框架,它由Rod Johnson创建。它是为了解决企业应用开发的复杂性而创建的。    Spring使用基本的JavaBean来完成以前只可能由EJB完成的事情。   然而,Spr...
    继续阅读 »

    1.什么是Spring


    Spring是一个开源框架,它由Rod Johnson创建。它是为了解决企业应用开发的复杂性而创建的。


       Spring使用基本的JavaBean来完成以前只可能由EJB完成的事情。
      然而,Spring的用途不仅限于服务器端的开发。从简单性、可测试性和松耦合的角度而言,任何Java应用都可以从Spring中受益。
       目的:解决企业应用开发的复杂性
       功能:使用基本的JavaBean代替EJB,并提供了更多的企业应用功能
       范围:任何Java应用


       它是一个容器框架,用来装javabean(java对象),中间层框架(万能胶)可以起一个连接作用,比如说把Struts和hibernate粘合在一起运用。简单来说,Spring是一个轻量级的控制反转(IoC)和面向切面(AOP)的容器框架。


    2. 什么是控制反转(或依赖注入) 


       控制反转(IoC=Inversion of Control)IoC,用白话来讲,就是由容器控制程序之间的(依赖)关系,而非传统实现中,由程序代码直接操控。这也就是所谓“控制反转”的概念所在:(依赖)控制权由应用代码中转到了外部容器,控制权的转移,是所谓反转。
       IoC还有一个另外的名字:“依赖注入 (DI=Dependency Injection)”  ,即由容器动态的将某种依赖关系注入到组件之中 ,案例:实现Spring的IoC

    第一步:需要添加springIDE插件,配置相关依赖(插件如何安装点击打开链接


    pom.xml  (1.spring-context   2.spring-orm  3.spring-web  4.spring-aspects)


    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>zking</groupId>
    <artifactId>s1</artifactId>
    <packaging>war</packaging>
    <version>0.0.1-SNAPSHOT</version>
    <name>s1 Maven Webapp</name>
    <url>http://maven.apache.org</url>
    <dependencies>
    <dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>3.8.1</version>
    <scope>test</scope>
    </dependency>

    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.0.1.RELEASE</version>
    </dependency>

    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-web</artifactId>
    <version>5.0.1.RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-orm</artifactId>
    <version>5.0.1.RELEASE</version>
    </dependency>

    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    <version>5.0.1.RELEASE</version>
    </dependency>
    </dependencies>
    <build>
    <finalName>s1</finalName>
    </build>
    </project>

    第二步:插件Spring的xml文件(右键-->new-->other-->spring-->Spring Bean Configuration File)


    注:创建spring的XML文件时,需要添加beans/aop/tx/context标签支持(勾上即可)


    ApplicationContext.xml


     


    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.3.xsd
    http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd
    http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-4.3.xsd">


    </beans>

    第三步:创建一个helloworld类


    package p1;

    import org.springframework.context.ApplicationContext;
    import org.springframework.context.support.ClassPathXmlApplicationContext;

    public class HelloWorld {
    private String name;

    public HelloWorld() {
    super();
    System.out.println("new HelloWorld()");
    }

    public HelloWorld(String name) {
    super();
    this.name = name;
    }

    public void init() {
    System.out.println("init.......");
    }
    public String getName() {
    return name;
    }

    public void setName(String name) {
    this.name = name;
    }
    }

    3. 如何在spring当中定义和配置一个JavaBean


    使用无参构造方法+set方法创建一个JavaBean


       1 id:在容器中查找Bean(对象)的id(唯一、且不能以/开头)
       2 class:bean(对象)的完整类名
       3 name:在容器中查找Bean(对象)的名字(唯一、允许以/开头、允许多个值,多个值之间用逗号或空格隔开)
       4 scope:(singleton|prototype)默认是singleton
         4.1 singleton(单例模式):在每个Spring IoC容器中一个bean定义对应一个对象实例
         4.2 prototype(原型模式/多例模式):一个bean(对象)定义对应多个对象实例
       4 abstract:将一个bean定义成抽象bean(抽象bean是不能实例化的),抽象类一定要定义成抽象bean,非抽象类也可以定义成抽象bean
       5 parent:指定一个父bean(必须要有继承关系才行)
       6 init-method:指定bean对象()的初始化方法


       7 使用有参数构造方法创建javaBean(java对象):constructor-arg


    第四步:在xml中创建bean(看不懂属性的,在第三点中有介绍)


    <bean id="helloworld" class="p1.HelloWorld" scope="prototype" name="a b c" init-method="init">
    <property name="name">
    <value>zs</value>
    </property>
    </bean>

    <bean id="helloworld2" class="p1.HelloWorld">
    <constructor-arg index="0">
    <value>zzz</value>
    </constructor-arg>
    </bean>

    第五步:写一个测试的类即可


    public static void main(String[] args) {
    //以前的写法
    HelloWorld helloWorld=new HelloWorld();
    helloWorld.setName("张三");
    System.out.println("hello"+helloWorld.getName());
    //-------------------------------------------------------------
    //Spring
    ApplicationContext applicationContext=new ClassPathXmlApplicationContext("ApplicationContext.xml");
    HelloWorld a = (HelloWorld)applicationContext.getBean("a");
    System.out.println("你好: "+a.getName());

    HelloWorld b = (HelloWorld)applicationContext.getBean("b");
    System.out.println("你好: "+b.getName());

    HelloWorld c = (HelloWorld)applicationContext.getBean("c");
    System.out.println("你好: "+c.getName());

    HelloWorld d = (HelloWorld)applicationContext.getBean("helloworld2");
    System.out.println("--------------------------------");
    System.out.println("你好: "+d.getName());
    }

    4. 简单属性的配置:


       8+1+3:8大基本数据类型+String+3个sql
                           java.util.Date      java.sql.Date    java.sql.Time    java.sql.Timestamp
       通过<value>标签赋值即可


    5. 复杂属性的配置


      5.1 JavaBean    ref bean=""
      5.2 List或数组
      5.3 Map
      5.4 Properties


    创建一个学生类(Student),定义这几个属性


    private HelloWold helloworld;

    private String []arr;
    private List list;
    private Map map;
    private Properties properties;

    在xml配置进行配置


    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.3.xsd
    http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd
    http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-4.3.xsd">
    <bean id="helloworlds" class="p1.HelloWold">
    <property name="name">
    <value>张三</value>
    </property>

    </bean>
    <bean id="ss" class="p1.Student">
    <property name="helloworld">
    <ref bean="helloworlds"><!-- ref引用另一个对象 -->
    </property>

    <property name="arr">
    <list>
    <value>aa</value>
    <value>bb</value>
    <value>cc</value>
    <value>dd</value>
    </list>
    </property>
    <property name="list">
    <list>
    <value>11</value>
    <value>22</value>
    <value>33</value>
    </list>
    </property>
    <property name="map">
    <map>
    <entry>
    <key>
    <value>zs</value>
    </key>
    <value>张三</value>
    </entry>
    <entry>
    <key>
    <value>ls</value>
    </key>
    <value>李四</value>
    </entry>
    <entry>
    <key>
    <value>ww</value>
    </key>
    <value>王五</value>
    </entry>
    </map>
    </property>
    <property name="properties">
    <props>
    <prop key="a2">222</prop>
    </props>
    </property>

    </bean>

    6. 针对项目,配置文件路径的2种写法


    ApplicationContext 


    String path = "applicationContext.xml";(独自开发)


    String path = "classpath:applicationContext-*.xml";//src(分模块开发  多人开发)


     



    ————————————————
    版权声明:本文为CSDN博主「湮顾千古」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/sujin_/article/details/78700158

    收起阅读 »

    TCP和UDP详解(非常详细)

    TCP和UDP详解 计算机网络知识扫盲:https://blog.csdn.net/hansionz/article/details/85224786 网络编程套接字:https://blog.csdn.net/hansionz/article/detail...
    继续阅读 »


    TCP和UDP详解


    计算机网络知识扫盲:https://blog.csdn.net/hansionz/article/details/85224786
    网络编程套接字:https://blog.csdn.net/hansionz/article/details/85226345
    HTTP协议详解:https://blog.csdn.net/hansionz/article/details/86137260


    前言:本篇博客介绍TCP协议和UDP协议的各个知识点,这两个协议都是位于传输层的协议,我们首先从传输层谈起。


    传输层: 传输层是TCP/IP协议五层模型中的第四层。它提供了应用程序间的通信,它负责数据能够从发送端传输到接收端。其功能包括:一、格式化信息流;二、提供可靠传输。为实现后者,传输层协议规定接收端必须发回确认,并且假如分组丢失,必须重新发送。


    再谈端口号: 在网络知识扫盲博客中谈到端口号标识了一个主机上进行通信的不同应用程序。在TCP/IP协议中, 用"源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过 netstat -n查看,协议号指的是那个使用协议)。
    一个进程可以绑定多个端口号,但是一个端口号不能被多个进程绑定。


    端口号范围划分:



    • 0 - 1023: 知名端口号,HTTP、FTP、 SSH等这些广为使用的应用层协议他们的端口号都是固定的,自己写的程序中,不能随意绑定知名端口号。

    • 1024 - 65535:操作系统动态分配的端口号。 客户端程序的端口号,就是由操作系统从这个范围分配的。


    常见的知名端口号:



    • ssh服务器:22端口

    • ftp服务器:21端口

    • http服务器:80端口

    • telnet服务器:23端口

    • https服务器:443端口

    • MYSQL服务器:3306端口


    在Linux操作系统中使用命令cat /etc/services可以看到所有的知名端口。


    netstat工具: 用来查看网络状态。



    • n 拒绝显示别名,能显示数字的全部转化成数字

    • l 仅列出有在Listen (监听)的服务状态

    • p 显示正在使用Socket的程序识别码和程序名称

    • t (tcp)仅显示tcp相关选项

    • u u (udp)仅显示udp相关选项

    • a (all)显示所有选项,默认不显示LISTEN相关


    pidof [进程名]: 可以根据进程名直接查看服务器的进程id。例如:pidof sshd


    UDP协议


    UDP协议报文格式:
    在这里插入图片描述



    • 16位UDP长度表示整个数据报(UDP首部+UDP数据)的长度

    • 如果校验和出错,就会直接丢弃(UDP校验首部和数据部分)


    UDP协议的特点:



    • 无连接:只知道对端的IP和端口号就可以发送,不需要实现建立连接。

    • 不可靠:没有确认机制, 没有重传机制。如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息。

    • 面向数据报: 应用层交给UDP多长的报文, UDP原样发送既不会拆分,也不会合并。如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个 字节,而不能循环调用10次recvfrom, 每次接收10个字节。所以UDP不能够灵活的控制读写数据的次数和数量。


    UDP的缓冲区:UDP存在接收缓冲区,但不存在发送缓冲区。



    • UDP没有发送缓冲区,在调用sendto时会直接将数据交给内核,由内核将数据传给网络层协议进行后续的传输动作。为什么UDP不需要发送缓冲区? 因为UDP不保证可靠性,它没有重传机制,当报文丢失时,UDP不需要重新发送,而TCP不同,他必须具备发送缓冲区,当报文丢失时,TCP必须保证重新发送,用户不会管,所以必须要具备发送缓冲区。


    • UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的UDP报文的顺序和发送UDP报的顺序一致,如果缓冲区满了再到达的UDP数据报就会被丢弃。



    UDP接收缓冲区和丢包问题:https://blog.csdn.net/ljh0302/article/details/49738191


    UDP是一种全双工通信协议。 UDP协议首部中有一个16位的大长度. 也就是说一个UDP能传输的报文长度是64K(包含UDP首部)。如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装。


    常见的基于UDP的应用层协议:



    • NFS:网络文件系统

    • TFTP:简单文件传输协议

    • DHCP:动态主机配置协议

    • BOOTP:启动协议(用于无盘设备启动)

    • DNS:域名解析协议

    • 程序员在写UDP程序时自己定义的协议


    TCP协议


    TCP全称传输控制协议,必须对数据的传输进行控制。


    TCP协议报文格式:
    在这里插入图片描述



    • 源端口号/目的端口号:表示数据从哪个进程来,要到那个进程去


    • 32位序号:序号是可靠传输的关键因素。TCP将要传输的每个字节都进行了编号,序号是本报文段发送的数据组的第一个字节的编号,序号可以保证传输信息的有效性。比如:一个报文段的序号为300,此报文段数据部分共有100字节,则下一个报文段的序号为401。


    • 32位确认序号:每一个ACK对应这一个确认号,它指明下一个期待收到的字节序号,表明该序号之前的所有数据已经正确无误的收到。确认号只有当ACK标志为1时才有效。比如建立连接时,SYN报文的ACK标志位为0。


    • 4位首部长度(数据偏移): 表示该TCP头部有多少个32位bit(有多少个4字节),所以TCP头部大长度是15 * 4 = 60。根据该部分可以将TCP报头和有效载荷分离。TCP报文默认大小为20个字节。


    • 6位标志位:

      URG:它为了标志紧急指针是否有效。
      ACK:标识确认号是否有效。
      PSH:提示接收端应用程序立即将接收缓冲区的数据拿走。
      RST:它是为了处理异常连接的, 告诉连接不一致的一方,我们的连接还没有建立好, 要求对方重新建立连接。我们把携带RST标识的称为复位报文段。
      SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段。
      FIN:通知对方, 本端要关闭连接了, 我们称携带FIN标识的为结束报文段。






    • 16位的紧急指针:按序到达是TCP协议保证可靠性的一种机制,但是也存在一些报文想优先被处理,这时就可以设置紧急指针,指向该报文即可,同时将紧急指针有效位置位1

    • 16位窗口大小:如果发送方发送大量数据,接收方接收不过来,会导致大量数据丢失。然后接收方可以发送给发送发消息让发送方发慢一点,这是流量控制。接收方将自己接收缓冲器剩余空间的大小告诉发送方叫做16位窗口大小。发送发可以根据窗口大小来适配发送的速度和大小,窗口大小最大是2的16次方,及64KB,但也可以根据选项中的某些位置扩展,最大扩展1G。

    • 16位校验和:发送端填充,CRC校验。如果接收端校验不通过, 则认为数据有问题(此处的检验和不光包含TCP首部也包含TCP数据部分)。


    确认应答机制:
    在这里插入图片描述


    接收端收到一条报文后,向发送端发送一条确认ACK,此ACK的作用就是告诉发送端:接收端已经成功的收到了消息,并且希望收到下一条报文的序列号是什么。这个确认号就是期望的下一个报文的序号。


    每一个ACK都带有对应的确认序列号,意思是告诉发送者,我们已经收到了哪些数据,下一个发送数据应该从哪里开始。 如上图,主机A给主机B发送了1-1000的数据,ACK应答,携带了1001序列号。告诉主机A,我已经接受到了1-1000数据,下一次你从1001开始发送数据。


    超时重传:
    在这里插入图片描述


    TCP在传输数据过程中,还加入了超时重传机制。假设主机A发送数据给主机B,主机B没有收到数据包,主机B自然就不会应答,如果主机A在一个特定时间间隔内没有收到主机B发来的确认应答,就会进行重发,这就是超时重传机制
    当然还存在另一种可能就是主机A未收到B发来的确认应答,也可能是因为ACK丢失了。
    在这里插入图片描述


    因此主机B会收到很多重复数据,那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的包丢弃掉,这时候我们可以利用前面提到的16位序列号, 就可以很容易做到去重的效果。


    超时重发的时间应该如何确定?
    在理想的情况下,可以找到一个小的时间来保证 "确认应答"一定能在这个时间内返回。但是这个时间的长短,随着网络环境的不同是有差异的。如果超时时间设的太长,会影响整体的重传效率。如果超时时间设的太短,有可能会频繁发送重复的包。TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。


    Linux中超时时间以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后,仍然得不到应答,等待2*500ms后再进行重传。如果仍然得不到应答,等待4*500ms进行重传。依次类推,以指数形式递增,当累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。


    连接管理机制


    在正常情况下, TCP要经过三次握手建立连接,四次挥手断开连接。


    三次握手及四次挥手:https://mp.csdn.net/mdeditor/86495932


    TIME_WAIT状态: 当我们实现一个TCP服务器时,我们把这个服务器运行起来然后将服务器关闭掉,再次重新启动服务器会发现一个问题:就是不能马上再次绑定这个端口号和ip,需要等一会才可以重新绑定,其实等的这一会就是TIME_WAIT状态。



    • TCP协议规定主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL的时间后才能回到CLOSED状态。

    • 当我们使用Ctrl-C终止了server,server是主动关闭连接的一方在TIME_WAIT期间仍然不能再次监听同样的server端口。

    • MSLRFC1122中规定为两分钟(120s),但是各操作系统的实现不同,在Centos7上默认配置的值是60s可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout查看MSL的值。


    为什么TIME_WAIT时间一定是2MSL:


    首先,TIME_WAIT是为了防止最后一个ACK丢失,如果没有TIME_WAIT,那么主动断开连接的一方就已经关闭连接,但是另一方还没有断开连接,它收不到确认ACK会认为自己上次发送的FIN报文丢失会重发该报文,但是另一方已经断开连接了,这就会造成连接不一致的问题,所以TIME_WAIT是必须的。


    MSLTCP报文在发送缓冲区的最大生存时间,如果TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失。(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)。同时也是在理论上保证最后一个报文可靠到达。(假设最后一个ACK丢失, 那么服务器会再重发一个FIN,这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK,这就会导致问题)


    解决TIME_WAIT状态引起的bind失败的方法:


    serverTCP连接没有完全断开之前不允许重新绑定,也就是TIME_WAIT时间没有过,但是这样不允许立即绑定在某些情况下是不合理的:



    • 服务器需要处理非常大量的客户端的连接 (每个连接的生存时间可能很短,但是每秒都有很大数量的客户 端来请求)

    • 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),这样服务器端就会产生大量TIME_WAIT状态

    • 如果客户端的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占用一个通信五元组(源ip, 源端口, 目的ip, 目的端口, 协议)。其中服务器的ip和端口和协议是固定的,如果新来的客户端连接的ip和端口号TIME_WAIT占用的连接重复就造成等待。


    解决方法:使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。
    关于setsockopthttps://www.cnblogs.com/clschao/articles/9588313.html


    服务器端CLOSE_WAIT状态: 如果客户端是主动断开连接的一方,在服务器端假设没有关闭新连接,这时服务器端就会产生一个CLOSE_WAIT状态,因为服务器没有去关闭连接,所以这个CLOSE_WAIT状态很容易测试出来,这时四次挥手没有结束,只完成了两次。


    #include "tcp_socket.hpp"

    typedef void (*Handler)(string& req, string* res);

    class TcpServer
    {
    public:
    TcpServer(string ip, uint16_t port)
    :_ip(ip)
    ,_port(port)
    {}

    void Start(Handler handler)
    {
    //1.创建socket
    listen_sock.Socket();
    //2.绑定ip和端口号
    listen_sock.Bind(_ip, _port);
    //3.监听
    listen_sock.Listen(5);

    while(1)
    {
    TcpSocket new_sock;
    string ip;
    uint16_t port;
    //4.接收连接
    listen_sock.Accept(&new_sock, &ip, &port);
    cout <<"client:" << ip.c_str() << " connect" << endl;
    while(1)
    {
    //5.连接成功读取客户端请求
    string req;
    bool ret = new_sock.Recv(&req);
    cout << ret << endl;
    if(!ret)
    {
    //此处服务器端不关闭新连接,导致CLOSE_WAIT状态
    //new_sock.Close();
    break;
    }
    //6.处理请求
    string res;
    handler(req, &res);

    //写回处理结果
    new_sock.Send(res);
    cout << "客户:" << ip.c_str() << " REQ:" << req << ". RES:" << res << endl;
    }
    }
    }
    private:
    TcpSocket listen_sock;
    string _ip;
    uint16_t _port;
    };

    运行结果:
    在这里插入图片描述


    如果服务器上出现大量的CLOSE_WAIT状态,原因就是服务器没有正确的关闭 socket,导致四次挥手没有正确完成。这是可能是一个BUG,只需要加上对应的 close即可解决问题。


    滑动窗口:


    确认应答策略对每一个发送的数据段都要给一个ACK确认应答,接收方收到ACK后再发送下一个数据段,但是这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候。


    既然一发一收的方式性能较低,那么我们考虑一次发送多条数据,就可以大大的提高性能,它是将多个段的等待时间重叠在一起。
    在这里插入图片描述
    窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。发送前四个段的时候,不需要等待任何ACK直接发送即可。当收到第一个ACK后滑动窗口向后移动,继续发送第五个段的数据,然后依次类推。操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答。只有确认应答过的数据,才能从缓冲区删掉,窗口越大,则网络的吞吐率就越高。滑动窗口左边代表已经发送过并且确认,可以从发送缓冲区中删除了,滑动窗口里边代表发送出去但是没有确认,滑动窗口右边代表还没有发送的数据。
    在这里插入图片描述


    如果在这种情况中出现了丢包现象,应该如何重发呢?



    • 数据到达接收方,但是应答报文丢失:可以更具后边的ACK确认。假设发送方发送1-1000的数据,接收方收到返回确认ACK,但是返回的ACK丢失了,另一边发送1001-2000收到的确认ACK 2001,就可以认为1-1000数据接收成功


    • 数据包之间丢失: 当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 "我想要的是 1001" 一样,如果发送端主机连续三次收到了同样一个"1001" 这样的应答,就会将对应的数据 1001 - 2000 重新发送,这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了。因为2001 - 7000接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。这种机制被称为 “高速重发控制”(也叫 "快重传")。



    在这里插入图片描述


    快重传要求接收方在收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方)而不要等到自己发送数据时捎带确认。快重传算法规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器时间到期。由于不需要等待设置的重传计时器到期,能尽早重传未被确认的报文段,能提高整个网络的吞吐量
    流量控制


    接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被装满,这个时候如果发送端继续发送,就会造成丢包,然后引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)



    • 接收端将自己可以接收的缓冲区大小放入TCP首部中的"窗口大小"字段,通过ACK确认报文通知发送端

    • 窗口大小字段越大,说明网络的吞吐量越高,接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端

    • 发送端接受到这个窗口之后,就会减慢自己的发送速度,如果接收端缓冲区满了, 就会将窗口置为0。这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。


    接收端如何把窗口大小告诉发送端呢? 在的TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息,16位数字大表示65535,那么TCP窗口大就是65535字节吗? 实际上TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是 窗口字段的值左移M位。接收端窗口如果更新,会向发送端发送一个更新通知,如果这个更新通知在中途丢失了,会导致无法继续通信,所以发送端要定时发送窗口探测包。


    拥塞控制:


    虽然TCP有了滑动窗口这个大杀器能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题,因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据是很有可能引起雪上加霜的,造成网络更加堵塞


    TCP引入慢启动机制,先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
    在这里插入图片描述


    图中的cwnd为拥塞窗口,在发送开始的时候定义拥塞窗口大小为1,每次收到一个ACK应答拥塞窗口加1。每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口。


    像上面这样的拥塞窗口增长速度,是指数级别的。"慢启动"只是指初使时慢,但是增长速度非常快。为了不增长的那么快,因此不能使拥塞窗口单纯的加倍,此处引入一个叫做慢启动的阈值当拥塞窗口超过这个阈值的时候,不再按照指数方式增长, 而是按照线性方式增长。


    在这里插入图片描述



    • TCP开始启动的时候,慢启动阈值等于窗口最大值

    • 在每次超时重发的时候,慢启动阈值会变成原来的一半同时拥塞窗口置回1


    少量的丢包,我们仅仅是触发超时重传。大量的丢包,我们就认为网络拥塞。当TCP通信开始后,网络吞吐量会逐渐上升。随着网络发生拥堵,吞吐量会立刻下降。拥塞控制归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。


    在这里插入图片描述
    拥塞控制与流量控制的区别:


    拥塞控制是防止过多的数据注入到网络中,可以使网络中的路由器或链路不致过载,是一个全局性的过程。 流量控制是点对点通信量的控制,是一个端到端的问题,主要就是权衡发送端发送数据的速率,以便接收端来得及接收。


    拥塞控制的标志:



    • 重传计时器超时

    • 接收到三个重复确认


    拥塞避免:(按照线性规律增长)



    • 拥塞避免并非完全能够避免拥塞,在拥塞避免阶段将拥塞窗口控制为按线性规律增长,使网络比较不容易出现拥塞。

    • 拥塞避免的思路是让拥塞窗口cwnd缓慢地增大,即每经过一个往返时间RTT就把发送方的拥塞控制窗口加一。


    无论是在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其根据就是没有收到确认,虽然没有收到确认可能是其他原因的分组丢失,但是因为无法判定,所以都当做拥塞来处理),这时就把慢开始门限设置为出现拥塞时的门限的一半。然后把拥塞窗口设置为1,执行慢开始算法。
    在这里插入图片描述



    • 加法增大:执行拥塞避免算法后,拥塞窗口线性缓慢增大,防止网络过早出现拥塞

    • 乘法减小:无论是慢开始阶段还是拥塞避免,只要出现了网络拥塞(超时),那就把慢开始门限值ssthresh减半


    快恢复(与快重传配合使用)



    • 采用快恢复算法时,慢开始只在TCP连接建立时和网络出现超时时才使用。

    • 当发送方连续收到三个重复确认时,就执行“乘法减小”算法,把ssthresh门限减半。但是接下去并不执行慢开始算法。

    • 考虑到如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方现在认为网络可能没有出现拥塞。所以此时不执行慢开始算法,而是将cwnd设置为ssthresh的大小,然后执行拥塞避免算法。


    延迟应答


    如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。假设接收端缓冲区为1M 一次收到了500K的数据。如果立刻应答,返回的窗口就是500K。 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了,在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些也能处理过来。如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M


    窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。



    • 数量限制: 每隔N个包就应答一次

    • 时间限制: 超过大延迟时间就应答一次


    注:具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms


    捎带应答:


    延迟应答的基础上,存在很多情况下,客户端服务器在应用层也是"一发一收" 的。 意味着客户端给服务器说了"How are you", 服务器也会给客户端回一个"Fine, thank you"。那么这个时候ACK就可以搭顺风车,和服务器回应的 "Fine, thank you" 一起回给客户端


    面向字节流:


    当我们创建一个TCPsocket,同时在内核中创建一个发送缓冲区和一个接收缓冲区



    • 调用write时,内核将数据会先写入发送缓冲区中,如果发送的字节数太长,会被拆分成多个TCP的数据包发出,如果发送的字节数太短,就会先在缓冲区里等待, 等到缓冲区长度达到设置长度,然后等到其他合适的时机发送出去。

    • 调用read接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区。然后应用程序可以调用read从接收缓冲区拿数据。TCP的一个连接,既有发送缓冲区, 也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。所以是全双工的。


    由于缓冲区的存在,TCP程序的读和写不需要一一匹配。例如: 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节; 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次 read一个字节, 重复100次


    粘包问题:


    粘包问题中的 "包"是指的应用层的数据包。在TCP的协议头中,没有如同UDP一样的 "报文长度"这样的字段,但是有一个序号这样的字段。站在传输层的角度, TCP是一个一个报文过来的,按照序号排好序放在缓冲区中,但是站在应用层的角度,它看到的只是一串连续的字节数据。应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分结束是一个完整的应用层数据包,这就是粘包问题


    如何避免粘包问题呢?明确两个包之间的边界



    • 对于定长的包,保证每次都按固定大小读取即可。例如一个Request结构, 是固定大小的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可

    • 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置

    • 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议是程序员自己来定义的, 只要保证分隔符不和正文冲突即可)


    对于UDP协议,如果还没有上层交付数据UDP的报文长度仍然在。 同时UDP一个一个把数据交付给应用层,这样就有存在明确的数据边界,站在应用层的角度, 使用UDP的时候要么收到完整的UDP报文要么不收,不会出现"半个"的情况。


    TCP连接异常情况:



    • 进程终止:进程终止会释放文件描述符,仍然可以发送FIN,和正常关闭没有什么区别。机器重启和进程终止一样。

    • 机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。应用层的某些协议, 也有一些这样的检测机制.例如HTTP长连接中, 也会定期检测对方的状态.Q在QQ 断线之后, 也会定期尝试重新连接



    ————————————————
    版权声明:本文为CSDN博主「Hansionz」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/hansionz/article/details/86435127

    收起阅读 »

    Android即时通讯系列文章(4)MapStruct:分层式架构下不同数据模型之间相互转换的利器

    文章开篇,让我们先来解答一下上篇文章中留下的疑问,即:为什么要设计多个Entity?以「分离关注点」为原则的分层式架构,是我们在进行应用架构设计时经常采用的方案,例如为人熟知的MVC/MVP/MVVM等架构设计模式下,划分出的表示层、业务逻辑层、数据访问层、持...
    继续阅读 »

    文章开篇,让我们先来解答一下上篇文章中留下的疑问,即:

    为什么要设计多个Entity?

    以「分离关注点」为原则的分层式架构,是我们在进行应用架构设计时经常采用的方案,例如为人熟知的MVC/MVP/MVVM等架构设计模式下,划分出的表示层、业务逻辑层、数据访问层、持久层等。为了保持应用架构分层之后的独立性,通常需要在各个层次之间定义不同的数据模型,于是不可避免地要面临数据模型之间的相互转换问题。

    常见的不同层次的数据模型包括:

    VO(View Object):视图对象,用于展示层,关联某一指定页面的展示数据。

    DTO(Data Transfer Object):数据传输对象,用于传输层,泛指与服务端进行传输交互的数据。

    DO(Domain Object):领域对象,用于业务层,执行具体业务逻辑所需的数据。

    PO(Persistent Object):持久化对象,用于持久层,持久化到本地存储的数据。

    还是以即时通讯中消息收发为例:

    聊天时序图.png

    • 客户端在会话页面编辑消息并发送后,消息相关的数据在展示层被构造为MessageVO,展示在会话页面的聊天记录中;
    • 展示层将MessageVO转换为持久层对应的MessagePO后,调用持久层的持久化方法,将消息保存到本地数据库或其他地方
    • 展示层将MessageVO转换为传输层所要求的为MessageDTO后,传输层将数据传输到服务端
    • 至于对应的逆向操作,相信你也可以对于推理出来,这里就不再赘述了。

    在上篇文章中,我们以get/set操作的方式手动编写了映射代码,这种方式不但繁琐且容易出错,考虑到后期扩展其他消息类型时又要重复做同样的事情,出于提高开发效率的考虑,经过一番调研之后,我们决定采用MapStruct库以自动化的形式帮我们完成这件事情。

    MapStruct是什么?

    MapStruct是一个代码生成器,用于生成类型安全、高性能、无依赖的映射代码。

    我们所要做的,就是定义一个Mapper(映射器)接口,并声明需要实现的映射方法,即可在编译期利用MapStruct注解处理器,生成该接口的实现类,该实现类以自动化的方式帮我们完成get/set操作,以实现源对象与目标对象之间的映射关系。

    MapStruct的使用

    以Gradle的形式添加MapStruct依赖项:

    在模块级别的build.gradle文件中添加:

    dependencies {
    ...
    implementation "org.mapstruct:mapstruct:1.4.2.Final"
    annotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
    }

    如果项目中使用的是Kotlin语言则需要:

    dependencies {
    ...
    implementation "org.mapstruct:mapstruct:1.4.2.Final"
    kapt("org.mapstruct:mapstruct-processor:1.4.2.Final")
    }

    接下来,我们会以上次定义好的MessageVO与MessageDTO为操作对象,实践如何使用MapStruct自动化完成两者之间的字段映射:

    创建映射器接口

    1. 创建一个Java接口(也可以以抽象类的形式),并添加@Mapper注解表明是个映射器:
    2. 声明一个映射方法,指定入参类型和出参类型:
    @Mapper
    public interface MessageEntityMapper {

    MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);

    MessageVO dto2Vo(MessageDTO.Message messageDto);

    }

    这里使用MessageDTO.Message.Builder而非MessageDTO.Message的原因是,ProtoBuf生成的Message使用了Builder模式,并为了防止外部直接实例化而把构造参数设为private,这将导致MapStruct在编译的时候报错,至于原因,等你看完后面的内容就明白了。

    默认场景下的隐式映射

    当入参类型的字段名与出参类型字段名一致时,MapStruct会帮我们隐式映射,即不需要我们主动处理。

    目前支持以下类型的自动转换:

    • 基本数据类型及其包装类型
    • 数值类型之间,但从较大的数据类型转换为较小的数据类型(例如从long到int)可能会导致精度损失
    • 基本数据类型与字符串之间
    • 枚举类型和字符串之间
    • ...

    这其实是一种约定优于配置的思想:

    约定优于配置(convention over configuration),也称作按约定编程,是一种软件设计范式,旨在减少软件开发人员需做决定的数量,获得简单的好处,而又不失灵活性。

    本质是说,开发人员仅需规定应用中不符约定的部分。如果您所用工具的约定与你的期待相符,便可省去配置;反之,你可以配置来达到你所期待的方式。

    体现在MapStruct库之中即是,我们仅需针对那些MapStruct库没法帮我们完成隐式映射的字段,配置好对应的处理方式即可。

    比如我们例子中的MessageVO与MessageDTO,两者的messageId, senderId, targetId, timestamp几个字段的名称和数据类型都是一致的,因而不需要我们额外处理。

    特殊场景下的字段映射处理

    字段名称不一致:

    这种情况下,只需在映射方法之上添加@Mapping注解,标注源字段的名称以及目标字段的名称即可。

    比如我们例子中在message_dto.proto文件中定义的messageType是一个枚举类型,ProtoBuf为我们生成MessageDTO.Message时,额外为我们生成了一个messageTypeValue来表示该枚举类型的值,我们用上述方法即可完成从messageType到messageTypeValue的映射:

        @Mapping(source = "messageType", target = "messageTypeValue")
    MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);
    字段类型不一致:

    这种情况下,只需为两种不同的数据类型额外声明一个映射方法,即以源字段的类型为入参类型,以目标字段的类型为出参类型的映射方法。

    MapStruct会检查是否存在该映射方法,如果有,则会在映射器接口的实现类中调用该方法完成映射。

    比如我们例子中,content字段被定义为bytes类型,对于生成的MessageDTO.Message类中则是用ByteString类型表示,而MessageVO中的content字段则是String类型,因此需要在映射器接口中额外声明一个byte2String映射方法与一个string2Byte映射方法:

        default String byte2String(ByteString byteString) {
    return new String(byteString.toByteArray());
    }

    default ByteString string2Byte(String string) {
    return ByteString.copyFrom(string.getBytes());
    }

    又比如,我们不想处理上面messageType到messageTypeValue的映射,而是想直接完成messageType到枚举类型的映射,那我们就可以声明以下两个映射方法:

        default int enum2Int(MessageDTO.Message.MessageType type) {
    return type.getNumber();
    }

    default String byte2String(ByteString byteString) {
    return new String(byteString.toByteArray());
    }
    忽略某些字段:

    出于特殊的需要,某些层次的数据模型可能会新增部分字段,用于处理特定的业务,这些字段对于其他层次是没有任何意义的,所以没必要在其他层次保留这些字段,同时为了避免MapStruct隐式映射时找不到相应字段导致出错,我们可以在注解中添加ignore = true忽略这些字段:

    比如我们例子中,ProtoBuf生成的MessageDTO.Message类中还额外为我们新增了三个字段mergeFrom、senderIdBytes、targetIdBytes,这三个字段对于MessageVO是没有必要的,因此需要让MapStruct帮我们忽略掉:

        @Mapping(target = "mergeFrom", ignore = true)
    @Mapping(target = "senderIdBytes", ignore = true)
    @Mapping(target = "targetIdBytes", ignore = true)
    MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);

    其他场景的额外处理

    前面我们说过,由于MessageDTO.Message的构造函数被设为private导致编译时报错,实际上MessageDTO.Message.Builder的构造函数也是private的,该Builder的实例化是通过MessageDTO.Message.newBuilder()方法进行的。

    而MapStruct默认情况下是需要调用目标类的默认构造函数来完成映射任务的,那我们就没有办法了么?

    实际上,MapStruct允许你自定义对象工厂,这些工厂将提供了工厂方法,用以调用来获取目标类型的实例。

    我们要做的,只是声明该工厂方法的返回类型为我们的目标类型,然后在工厂方法中以想要的方式返回该目标类型的实例,随后在映射器接口的@Mapper注解中添加use参数,传入我们的工厂类。MapStruct就会优先自动找到该工厂方法,完成目标类型的实例化。

    public class MessageDTOFactory {

    public MessageDTO.Message.Builder createMessageDto() {
    return MessageDTO.Message.newBuilder();
    }
    }

    @Mapper(uses = MessageDTOFactory.class)
    public interface MessageEntityMapper {

    最后,我们定义一个名为INSTANCE 的成员,该成员通过调用Mappers.getMapper()方法,并传入该映射器接口类型,实现返回该映射器接口类型的单例。

    public interface MessageEntityMapper {

    MessageEntityMapper INSTANCE = Mappers.getMapper(MessageEntityMapper.class);

    完整的映射器接口代码如下:

    @Mapper(uses = MessageDTOFactory.class)
    public interface MessageEntityMapper {

    MessageEntityMapper INSTANCE = Mappers.getMapper(MessageEntityMapper.class);

    @Mapping(source = "messageType", target = "messageTypeValue")
    @Mapping(target = "mergeFrom", ignore = true)
    @Mapping(target = "senderIdBytes", ignore = true)
    @Mapping(target = "targetIdBytes", ignore = true)
    MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);

    MessageVO dto2Vo(MessageDTO.Message messageDto);

    @Mapping(source = "messageTypeValue", target = "messageType")
    default MessageDTO.Message.MessageType int2Enum(int value) {
    return MessageDTO.Message.MessageType.forNumber(value);
    }

    default int enum2Int(MessageDTO.Message.MessageType type) {
    return type.getNumber();
    }

    default String byte2String(ByteString byteString) {
    return new String(byteString.toByteArray());
    }

    default ByteString string2Byte(String string) {
    return ByteString.copyFrom(string.getBytes());
    }
    }

    自动生成映射器接口的实现类

    映射器接口定义好之后,当我们重新构建项目时MapStruct就会帮我们生成该接口的实现类,我们可以在{module}/build/generated/source/kapt/debug/{包名}路径找到该类,来对其细节一探究竟:

    public class MessageEntityMapperImpl implements MessageEntityMapper {

    private final MessageDTOFactory messageDTOFactory = new MessageDTOFactory();

    @Override
    public Builder vo2Dto(MessageVO messageVo) {
    if ( messageVo == null ) {
    return null;
    }

    Builder builder = messageDTOFactory.createMessageDto();

    if ( messageVo.getMessageType() != null ) {
    builder.setMessageTypeValue( messageVo.getMessageType() );
    }
    if ( messageVo.getMessageId() != null ) {
    builder.setMessageId( messageVo.getMessageId() );
    }
    if ( messageVo.getMessageType() != null ) {
    builder.setMessageType( int2Enum( messageVo.getMessageType().intValue() ) );
    }
    builder.setSenderId( messageVo.getSenderId() );
    builder.setTargetId( messageVo.getTargetId() );
    if ( messageVo.getTimestamp() != null ) {
    builder.setTimestamp( messageVo.getTimestamp() );
    }
    builder.setContent( string2Byte( messageVo.getContent() ) );

    return builder;
    }

    @Override
    public MessageVO dto2Vo(Message messageDto) {
    if ( messageDto == null ) {
    return null;
    }

    MessageVO messageVO = new MessageVO();

    messageVO.setMessageId( messageDto.getMessageId() );
    messageVO.setMessageType( enum2Int( messageDto.getMessageType() ) );
    messageVO.setSenderId( messageDto.getSenderId() );
    messageVO.setTargetId( messageDto.getTargetId() );
    messageVO.setTimestamp( messageDto.getTimestamp() );
    messageVO.setContent( byte2String( messageDto.getContent() ) );

    return messageVO;
    }
    }

    可以看到,如上文所讲,由于该实现类实际仍以普通的get/set方法调用来完成字段映射,整个过程并没有用到反射,且由于是在编译期生成该类,减少了运行期的性能损耗,故符合其“高性能”的定义。

    另一方面,当属性映射出错时,能在编译期及时获知,避免了运行时的报错崩溃,且对于某些特定类型增加了非空判断等措施,故符合其“类型安全”的定义。

    接下来,我们即可用该映射器实例的映射方法替换之前手动编写的映射代码:

    class EnvelopeHelper {
    companion object {
    /**
    * 填充操作(VO->DTO)
    * @param envelope 信封类,包含消息视图对象
    */
    fun stuff(envelope: Envelope): MessageDTO.Message? {
    return envelope.messageVO?.run {
    MessageEntityMapper.INSTANCE.vo2Dto(this).build()
    } ?: null
    }

    /**
    * 提取操作(DTO->VO)
    * @param messageDTO 消息数据传输对象
    */
    fun extract(messageDTO: MessageDTO.Message): Envelope? {
    with(Envelope()) {
    messageVO = MessageEntityMapper.INSTANCE.dto2Vo(messageDTO)
    return this
    }
    }
    }
    }

    总结

    如你所见,最终结果就是我们减少了大量的样板代码,使代码整体结构的更易于理解,后期扩展其他类型的对象也只需要增加对应的映射方法即可,即同时提高了代码的可读性/可维护性/可扩展性。

    MapStruct遵循约定优于配置的原则,以尽可能自动化的方式,帮我们解决了应用分层式架构下、不同数据模型之间、繁琐且易出错的相互转换工作,实在是极大提高开发人员开发效率的利器!

    收起阅读 »

    Android即时通讯系列文章(3)数据传输格式选型:资源受限的移动设备上数据传输的困境

    前言跟PC时代的传统互联网相比,移动互联网得益于移动设备的便携性,仅短短数年便快速地渗透到了人们生活、工作的各个方面。虽然通信技术和硬件设备在不断地更新升级换代,但就目前而言,电量、流量等对于移动设备来讲仍属于稀缺资源。参与过Android系统版本升级适配工作...
    继续阅读 »

    前言

    跟PC时代的传统互联网相比,移动互联网得益于移动设备的便携性,仅短短数年便快速地渗透到了人们生活、工作的各个方面。虽然通信技术和硬件设备在不断地更新升级换代,但就目前而言,电量、流量等对于移动设备来讲仍属于稀缺资源。

    参与过Android系统版本升级适配工作的开发人员,也许可以很明显地感受到,近年来Android系统每一个更新的版本都是往更省电、更省流量、更省内存的方向靠拢的,比如:

    • Android 6.0 引入了 低电耗模式 和 应用待机模式
    • Android 7.0 引入了 随时随地低电耗模式
    • Android 8.0 引入了 后台执行限制
    • Android 9.0 引入了 应用待机存储分区

    ...

    移动应用向网络发出的请求时主要的耗电来源之一,除了发送和接收数据包本身需要消耗电量外,开启无线装置并保持唤醒也会消耗额外的电量。特别是对于即时通讯这种网络交互频繁的应用场景来讲,数据传输大小是必须要考虑优化的一个方面,要尽量做到减少冗余数据,提高传输效率,从而减少对电量、流量的损耗。

    二进制数据相对于可读性更好的文本数据而言,数据冗余量小,数据排列更为紧凑,因而体积更小,传输速度更快。但是要使用自定义二进制协议的话,就意味着需要自己定义数据结构,自己做序列化反序列化工作,版本兼容也是个问题。基于时间成本与技术成本的考虑,我们决定采用Protobuf帮我们完成这部分工作。

    什么是Protobuf?

    Protobuf,全称Protocol Buffer(协议缓冲区),是Google开源的跨语言、跨平台、可扩展的结构化数据序列化机制。与XML、JSON及其他数据传输格式相比,Protocol更为轻巧、快速、简单。我们只需在.proto文件中定义好数据结构,即可利用Protobuf编译器编译生成针对各种平台、语言的数据访问类代码,轻松地在各种数据流中写入和读取结构化数据,尤其适用于数据存储及网络通信等场景。

    总结起来即是:

    优点:

    1. 数据大小:以独特的Varint、Zigzag编码方式及T-L-V数据存储方式实现数据压缩
    2. 解析效率:以高效的二进制格式实现数据的自动编码和解析
    3. 通用性:跨语言、跨平台
    4. 易用性:可用Protobuf编译器自动生成数据访问类
    5. 可扩展性:可随着版本迭代扩展格式
    6. 兼容性:可向后兼容旧格式编码的数据
    7. 可维护性:多个平台只需共同维护一个.proto文件

    缺点:

    可读性差:缺少.proto文件情况下难以去理解数据结构

    既然是数据传输格式选型,那么免不了与其他数据传输格式进行比较,我们常见的与服务端交互的数据传输格式莫过于XML与JSON。

    • XML

      可扩展标记语言(Extensible Markup Language),是一种文本类型的数据格式,以“<”开头,“>”结束的标签作为主要的语法规则。XML的设计侧重于作为文档描述,但也被广泛用于表示任意的数据结构。

    优点:

    1. 可读性好
    2. 可扩展性好

    缺点:

    1. 解析代价高,对它进行编码/解码会给应用程序带来巨大的性能损失
    2. 空间占用大,有效数据传输率低(大量的标签)

    从事Android开发的你肯定对Android的轻量级持久化方案SharedPreference不陌生,SharedPreference即是以xml为主要实现,不过目前Android官方已建议使用DataStore作为SharedPreference的替代方案,DataStore则是以ProtoBuf为主要实现。

    • JSON

    JavaScript对象表示法(JavaScript Object Notation),是一种开放标准文件格式以及数据交换格式,以文本形式来存储和传输由属性值对及数组组成的数据对象,常见于与服务器的通信。

    优点:

    除了拥有与XML相同的优点外,由于不需要像XML那样严格的闭合标签,因此有效数据量传输率更高,可节约所占用的带宽。

    ProtoBuf实现

    以Gradle形式添加ProtoBuf依赖项

    1. 项目级别的build.gradle文件:
    dependencies {
    ...
    // Protobuf
    classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8'
    }
    1. 模块级别的build.gradle文件:
    apply plugin: 'com.google.protobuf'

    android {
    sourceSets {
    main {
    // 定义proto文件目录
    proto {
    srcDir 'src/main/proto'
    }
    }
    }
    }

    dependencies {
    def PROTOBUF_VERSION = "3.0.0"

    api "com.google.protobuf:protobuf-java:${PROTOBUF_VERSION}"
    api "com.google.protobuf:protoc:${PROTOBUF_VERSION}"
    }

    protobuf {
    protoc { artifact = 'com.google.protobuf:protoc:3.2.0' }
    plugins {
    javalite {
    artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
    }
    }
    generateProtoTasks {
    all().each {
    task -> task.plugins { javalite {} }
    }
    }
    }

    在proto文件中定义要存储的消息的数据结构

    首先,我们需要在{module}/src/main/proto目录下新建message_dto.proto文件,以定义我们要存储的对象的数据结构,如下:

    1.png

    在定义数据结构之前,我们先来思考一下,一条最基础的即时通讯消息应该要包含哪些字段?这里以生活中常见的收发信件为例子:

    信件内容自然我们最关心的——content

    谁给我寄的信,是给我还是给其他人的呢?——sender_id、target_id

    为了快速检索信件,我们还需要一个唯一值——message_id

    是什么类型的信件呢?是信用卡账单还是情书呢?——type

    如果有多封信件,为了阅读的通顺我们还需要理清信件的时间线——timestamp

    以下就是最终定义出的message_dto.proto文件,接下来让我们逐步去解读这个文件:

    syntax = "proto3";

    option java_package = "com.madchan.imsdk.lib.objects.bean.dto";
    option java_outer_classname = "MessageDTO";

    message Message {
    enum MessageType {
    MESSAGE_TYPE_UNSPECIFIED = 0; // 未指定
    MESSAGE_TYPE_TEXT = 1; // 文本消息
    }
    //消息唯一值
    uint64 message_id = 1;
    //消息类型
    MessageType message_type = 2;
    //消息发送用户
    string sender_id = 3;
    //消息目标用户
    string target_id = 4;
    //消息时间戳
    uint64 timestamp = 5;
    //消息内容
    bytes content = 6;
    }

    声明使用语法
    syntax = "proto3";

    文件首行表明我们使用的是proto3语法,默认不声明的话,ProtoBuf编译器会认为我们使用的是proto2,该声明必须位于首行,且非空、非注释。

    指定文件选项
    option java_package = "com.madchan.imsdk.lib.objects.bean.dto";

    java_package用于指定我们要生成的Java类的包目录路径。

    option java_outer_classname = "MessageDTO";

    java_outer_classname指定我们要生成的Java包装类的类名。默认不声明的话,会将.proto 文件名转换为驼峰式来命名。

    此外还有一个java_multiple_files选项,当为true时,会将.proto文件中声明的多个数据结构转成多个单独的.java文件。默认为false时,则会以内部类的形式只生成一个.java文件。

    指定字段类型
        //消息唯一值
    uint64 message_id = 1;

    也许你注意到了,针对消息唯一值message_id和消息时间戳timestamp我们采用的是uint64,这其实是unsigned int的缩写,意味无符号64位整数,即Long类型的正数,关于无符号整数的解释如下:

    计算机里的数是用二进制表示的,最左边的这一位一般用来表示这个数是正数还是负数,这样的话这个数就是有符号整数。如果最左边这一位不用来表示正负,而是和后面的连在一起表示整数,那么就不能区分这个数是正还是负,就只能是正数,这就是无符号整数。

        enum MessageType {
    MESSAGE_TYPE_UNSPECIFIED = 0; // 未指定
    MESSAGE_TYPE_TEXT = 1; // 文本消息
    }
    //消息类型
    MessageType message_type = 2;

    而描述消息类型时,由于消息类型的值通常只在一个预定义的范围之内,符合枚举特性,因此我们采用枚举来实现。这里我们先简单定义了一个未知类型和文本消息类型。

    需要注意的是,每个枚举定义都必须包含一个映射到零的常量作为其第一个元素,以作为默认值。

    其他的数据类型请参考此表,该表显示了.proto 文件中所支持的数据类型,以及自动生成的对应语言的类中的相应数据类型。

    developers.google.com/protocol-bu…

    分配字段编号

    你可能会觉得奇怪,每个字段后带的那个数字是什么意思。这些其实是每个字段的唯一编号,用于在消息二进制格式中唯一标识我们的字段,一旦该编号被使用,就不应该再更改。

    如果我们在版本迭代中想要删除某个字段,需要确保不会重复使用该字段编号,否则可能会产生诸如数据损坏等严重问题。为了确保不会发生这种状况,我们需要使用reserved标识保留已删除字段的字段编号或名称,如果后续尝试使用这些字段,ProtoBuf编译器将会报错,如下:

    message Message {
    reserved 3, 4 to 6;
    reserved "sender_id ", "target_id ";
    }

    另外一件我们需要了解的事情是,ProtoBuf中1到15范围内的字段编号只占用一个字节进行编码(包括字段编号和字段类型),而16到2047范围内的字段编号则占用两个字节。基于这个特性,我们需要为频繁出现(也即必要字段)的字段保留1到15范围内的字段进行编号,而对于可选字段而采用16到2047范围内的字段进行编号。

    添加注释

    我们还可以向proto文件添加注释,支持// 和 /* ... */ 语法,注释会同样保留到自动生成的对应语言的类中。

    使用ProtoBuf编译器自动生成一个Java类

    一切准备就绪后,我们就可以直接重新构建项目,ProtoBuf编译器会自动根据.proto文件中定义的message,在{module}/build/generated/source/proto/debug/javalite目录下生成对应包名路径的Java类文件,之后只需将该类文件拷贝到src/main/java目录下即可,我们完全可以用Gradle Task帮我们完成这项工作:

    // 是否允许Proto生成DTO类
    def enableGenerateProto = true
    // def enableGenerateProto = false

    project.tasks.whenTaskAdded { Task task ->
    if (task.name == 'generateDebugProto') {
    task.enabled = enableGenerateProto
    if(task.enabled) {
    task.doLast {
    // 复制Build目录下的DTO类到Src目录
    copy {
    from 'build/generated/source/proto/debug/javalite'
    into 'src/main/java'
    }
    // 删除Build目录下的DTO类
    FileTree tree = fileTree("build/generated/source/proto/debug/javalite")
    tree.each{
    file -> delete file
    }
    }
    }
    }
    }

    通过阅读自动生成的MessageDTO.java文件可以看到,Protobuf编译器为每个定义好的数据结构生成了一个Java类,并为访问类中的每个字段提供了sette()r和getter()方法,且提供了Builder类用于创建类的实例。

    用基于Java语言的ProtoBuf API写入和读取消息

    到这里我们先把前面定义好的消息数据结构同步到MessageVO.kt,保持两个实体类的字段一致,至于为什么这样做,而不直接共用一个MessageDTO.java,下一篇文章会解释。

    data class MessageVo(
    var messageId: Long,
    var messageType: Int,
    var sendId: String,
    var targetId: String,
    var timestamp: Long,
    var content: String
    ) : Parcelable {
    constructor(parcel: Parcel) : this(
    parcel.readLong(),
    parcel.readInt(),
    parcel.readString() ?: "",
    parcel.readString() ?: "",
    parcel.readLong(),
    parcel.readString() ?: ""
    ) {
    }

    override fun writeToParcel(parcel: Parcel, flags: Int) {
    parcel.writeLong(messageId)
    parcel.writeInt(messageType)
    parcel.writeString(sendId)
    parcel.writeString(targetId)
    parcel.writeLong(timestamp)
    parcel.writeString(content)
    }

    override fun describeContents(): Int {
    return 0
    }

    companion object CREATOR : Parcelable.Creator<MessageVo> {
    override fun createFromParcel(parcel: Parcel): MessageVo {
    return MessageVo(parcel)
    }

    override fun newArray(size: Int): Array<MessageVo?> {
    return arrayOfNulls(size)
    }
    }

    现在,我们要做的就是以下两件事:

    1. 将来自视图层的MessageVO对象转换为数据传输层MessageDTO对象,并序列化为二进制数据格式进行消息发送。
    2. 接收二进制数据格式的消息,反序列化为MessageDTO对象,并将来自数据传输层的MessageDTO对象转换为视图层的MessageVO对象。

    我们把这部分工作封装到EnvelopHelper类:

    class EnvelopeHelper {
    companion object {
    /**
    * 填充操作(VO->DTO)
    * @param envelope 信封类,包含消息视图对象
    */
    fun stuff(envelope: Envelope): MessageDTO.Message? {
    envelope?.messageVo?.apply {
    return MessageDTO.Message.newBuilder()
    .setMessageId(messageId)
    .setMessageType(MessageDTO.Message.MessageType.forNumber(messageType))
    .setSenderId(sendId)
    .setTargetId(targetId)
    .setTimestamp(timestamp)
    .setContent(ByteString.copyFromUtf8(content))
    .build()
    }
    return null
    }

    /**
    * 提取操作(DTO->VO)
    * @param messageDTO 消息数据传输对象
    */
    fun extract(messageDTO: MessageDTO.Message): Envelope? {
    messageDTO?.apply {
    val envelope = Envelope()
    val messageVo = MessageVo(
    messageId = messageId,
    messageType = messageType.number,
    sendId = senderId,
    targetId = targetId,
    timestamp = timestamp,
    content = String(content.toByteArray())
    )
    envelope.messageVo = messageVo
    return envelope
    }
    return null
    }
    }
    }

    分别在以下两处消息收发的关键节点调用,便可完成对消息传输的序列化反序列化工作:

    MessageAccessService.kt:

    /** 根据MessageCarrier.aidl文件自动生成的Binder对象,需要返回给客户端 */
    private val messageCarrier: IBinder = object : MessageCarrier.Stub() {
    override fun sendMessage(envelope: Envelope) {
    Log.d(TAG, "Send a message: " + envelope.messageVo?.content)
    val messageDTO = EnvelopeHelper.stuff(envelope)
    messageDTO?.let { WebSocketConnection.send(ByteString.of(*it.toByteArray())) }
    ...
    }
    ...
    }

    WebSocketConnection.kt:

    /**
    * 在收到二进制格式消息时调用
    * @param webSocket
    * @param bytes
    */
    override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
    super.onMessage(webSocket, bytes)
    ...
    val messageDTO = MessageDTO.Message.parseFrom(bytes.toByteArray())
    val envelope = EnvelopeHelper.extract(messageDTO)
    Log.d(MessageAccessService.TAG, "Received a message : " + envelope?.messageVo?.content)
    ...
    }

    下一章节预告

    在上面的文章中我们留下了一个疑问,即为何要拆分成MessageVO与MessageDTO两个实体对象?这其实涉及到了DDD(Domain-Driven Design,领域驱动设计)的问题,是为了实现结构分层之后的解耦而设计的,需要在不同的层次使用不同的数据模型。

    不过,像文章中那种使用get/set方式逐一进行字段映射的操作毕竟太过繁琐,且容易出错,因此,下篇文章我们将介绍MapStruct库,以自动化的方式帮我们简化这部分工作,敬请期待。

    收起阅读 »

    Android即时通讯系列文章(2)网络通信协议选型:应以什么样的标准去选择适合你应用的网络通信协议?

    前言在前一篇文章《多进程:为什么要把消息服务拆分到一个独立的进程?》中我们出于保证连接的稳定性的目的,将应用拆分成了「主进程」和「通讯进程」,并为二者定义了相互通信的接口。即便如此,我们也只是实现了客户端一侧的进程间通信,而要实现与完整聊天系统中另一端的角色—...
    继续阅读 »

    前言

    在前一篇文章《多进程:为什么要把消息服务拆分到一个独立的进程?》中我们出于保证连接的稳定性的目的,将应用拆分成了「主进程」和「通讯进程」,并为二者定义了相互通信的接口。即便如此,我们也只是实现了客户端一侧的进程间通信,而要实现与完整聊天系统中另一端的角色——服务端的通信,则需依靠「网络通信协议」来协助完成,在此我们选用的是WebSocket协议。

    什么是WebSocket?

    WebSocket一词,从词面上可以拆解为 Web & Socket 两个单词,Socket我们并不陌生,其是对处于网络中不同主机上的应用进程之间进行双向通信的端点的抽象,是应用程序通过网络协议进行通信的接口,一个Socket对应着通信的一端,由IP地址和端口组合而成。需要注意的是,Socket并不是具体的一种协议,而是一个逻辑上的概念。

    那么WebSocket和Socket之间存在着什么联系呢,是否可以理解为是Socket概念在Web环境的移植呢?为了解答这个疑惑,我们先来回顾一下,在Java平台上进行Socket编程的流程:

    1. 服务端创建ServerSocket实例并绑定本地端口进行监听
    2. 客户端创建Socket实例并指定要连接的服务端的IP地址和端口
    3. 客户端发起连接请求,服务端成功接受之后,双方就建立了一个端对端的TCP连接,在该连接上可以双向通信。而后服务端继续处于监听状态,接受其他客户端的连接请求。

    上述流程还可以简化为:

    1. 服务端监听
    2. 客户端请求
    3. 连接确认

    与之类似,WebSocket服务端与客户端之间的通信过程可以描述为:

    • 服务端创建包含有效主机与端口的WebSocket实例,随后启动并等待客户端连接
    • 客户端创建WebSocket实例,并为该实例提供一个URL,该URL代表希望连接的服务器端点
    • 客户端通过HTTP请求握手建立连接之后,后面就使用刚才发起HTTP请求的TCP连接进行双向通信。

    1.png

    WebSocket协议最初是HTML5规范的一部分,但后来移至单独的标准文档中以使规范集中化,其借鉴了Socket的思想,通过单个TCP连接,为Web浏览器端与服务端之间提供了一种全双工通信机制。WebSocket协议旨在与现有的Web基础体系结构良好配合,基于此设计原则,协议规范定义了WebSocket协议握手流程需借助HTTP协议进行,并被设计工作在与HTTP(80)和HTTPS(443)相同的端口,也支持HTTP代理和中间件,以保证能完全向后兼容。

    由于WebSocket本身只是一个应用层协议,原则上只要遵循这个协议的客户端均可使用,因此我们才得以将其运用到我们的Android客户端。

    什么是全双工通信?

    简单来讲,就是通信双方(客户端和服务端)可同时向对方发送消息。为什么这一点很重要呢?因为传统的基于HTTP协议的通信是单向的,只能由客户端发起,服务端无法主动向客户端推送信息。一旦面临即时通讯这种对数据实时性要求很高的场景,当服务端有数据更新而客户端要获知,就只能通过客户端轮询的方式,具体又可分为以下两种轮询策略:

    • 短轮询

    即客户端定时向服务端发送请求,服务端收到请求后马上返回响应并关闭连接。 优点:实现简单 缺点: 1.并发请求对服务端造成较大压力 2.数据可能没有更新,造成无效请求 3.频繁的网络请求导致客户端设备电量、流量快速消耗 4.定时操作存在时间差,可能造成数据同步不及时 5.每次请求都需要携带完整的请求头

    2.png

    • 长轮询

    即服务端在收到请求之后,如果数据无更新,会阻塞请求,直至数据更新或连接超时才返回。 优点:相较于短轮询减少了HTTP请求的次数,节省了部分资源。 缺点: 1.连接挂起同样会消耗资源 2.冗余请求头问题依旧存在 3.png

    与上述两个方案相比,WebSocket的优势在于,当连接建立之后,后续的数据都是以帧的形式发送。除非某一端主动断开连接,否则无需重新建立连接。因此可以做到:

    1.减轻服务器的负担 2.极大地减少不必要的流量、电量消耗 3.提高实时性,保证客户端和服务端数据的同步 4.减少冗余请求头造成的开销

    4.png

    5.png

    除了WebSocket,实现移动端即时通讯的还有哪些技术?

    • XMPP

    全称(Extensible Messaging and Presence Protocol,可扩展通讯和表示协议),是一种基于XML的协议,它继承了在XML环境中灵活的发展性。 XMPP中定义了三个角色,客户端,服务器,网关。通信能够在这三者的任意两个之间双向发生。服务器同时承担了客户端信息记录,连接管理和信息的路由功能。网关承担着与异构即时通信系统的互联互通,异构系统可以包括SMS(短信),MSN,ICQ等。基本的网络形式是单客户端通过TCP/IP连接到单服务器,然后在之上传输XML。 优点 1.超强的可扩展性。经过扩展以后的XMPP可以通过发送扩展的信息来处理用户的需求。 2.易于解析和阅读。方便了开发和查错。 3.开源。在客户端、服务器、组件、源码库等方面,都已经各自有多种实现。 缺点 1.数据负载太重。过多的冗余标签、低效的解析效率使得XMPP在移动设备上表现不佳。

    应用场景举例:点对点单聊约球

    我刚毕业时入职的公司曾接手开发一个线上足球约战的社交平台APP项目,当时为了提高约球时的沟通效率,考虑为应用引入聊天模块,并优先实现点对点单聊功能。那时市面上的即时通讯SDK方案还尚未成熟,综合当时团队成员的技术栈,决定采用XMPP+Openfire+Smack作为自研技术搭建聊天框架。 Openfire基于XMPP协议,采用Java开发,可用于构建高效的即时通信服务器端,单台服务器可支持上万并发用户。Openfire安装和使用都非常简单,并利用Web进行管理。由于是采用开放的XMPP协议,因此可以使用各种支持XMPP协议的IM客户端软件登录服务。 Smack是一个开源的、易于使用的XMPP客户端Java类库,提供了一套可扩展的API。

    • MQTT

    全称(Message Queuing Telemetry Transport,消息队列遥测传输协议),是一种基于发布/订阅模式的“轻量级”通讯协议,其构建于TCP/IP协议之上。MQTT最大优点在于,可以以极少的代码和有限的带宽,为连接远程设备提供实时可靠的消息服务。作为一种低开销、低带宽占用的即时通讯协议,使其在物联网、小型设备、移动应用等方面有较广泛的应用。 特点 1.基于发布/订阅模型。提供一对多的消息发布,解除应用程序耦合。 2.低开销。MQTT客户端很轻巧,只需要最少的资源,同时MQTT消息头也很小,可以优化网络带宽。 3.可靠的消息传递。MQTT定义了3种消息发布服务质量,以支持消息可靠性:至多一次,至少一次,只有一次。 4.对不可靠网络的支持。专为受限设备和低带宽、高延迟或不可靠的网络而设计。

    应用场景举例:赔率更新、赛事直播聊天室

    我第二家入职的公司的主打产品是一款提供模拟竞猜、赛事直播的体育类APP,其中核心的功能模块就是提供各种赛事的最新比分赔率数据,最初采用的即是上文所说的低效的HTTP轮询方案,效果可想而知。后面技术重构后改用了MQTT,极大地减少了对网络环境的依赖,提高了数据的实时性和可靠性。再往后搭建直播模块时,考虑到聊天室这种一对多的消息发布场景同样适合用MQTT解决,于是沿用了原先的技术方案扩展了新的聊天室模块。

    • WebSocket

    而相较之下,WebSocket的特点包括: 1.**较少的控制开销。**在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。 2.**更好的二进制支持。**Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。 3.**可以支持扩展。**Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议,如以上所说的XMPP协议、MQTT协议等。

    WebSocket协议在Android客户端的实现

    实现WebSocket协议很简单,广为Android开发者使用的网络请求框架——OkHttp对WebSocket通信流程进行了封装,提供了简明的接口用于WebSocket的连接建立、数据收发、连接保活、连接关闭等,使我们可以专注于业务实现而无须关注通信细节,简单到我们只需要实现以下两步:

    • 创建WebSocket实例并提供一个URL以指定要连接的服务器地址
    • 提供一个WebSocket连接事件监听器,用于监听事件回调以处理连接生命周期的每个阶段

    WebSocket URL的构成与Http URL很相似,都是由协议、主机、端口、路径等构成,区别就是WebSocket URL的协议名采用的是ws://和wss://,wss://表明是安全的WebSocket连接。

    6.png

    首先我们在项目中引入OkHttp库的依赖:

    implementation("com.squareup.okhttp3:okhttp:4.9.0")

    其次,我们须指定要连接的服务器地址,此处可以使用WebSocket的官方服务器地址:

    /** WebSocket服务器地址 */
    private var serverUrl: String = "ws://echo.websocket.org"

    @Synchronized
    fun connect() {
    val request = Request.Builder().url(serverUrl).build()
    val okHttpClient = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build()
    ...
    }

    接着,我们调用OkHttpClient实例的newWebSocket(request: Request, listener: WebSocketListener)方法,该方法需传入两个参数,第一个是上文构建的Request对象,第二个是WebSocket连接事件的监听器,WebSocket协议包含四个主要的事件:

    • Open:客户端和服务器之间建立了连接后触发
    • Message:服务端向客户端发送数据时触发。发送的数据可以是纯文本或二进制数据
    • Close:服务端与客户端之间的通信结束时触发。
    • Error:通信过程中发生错误时触发。

    每个事件都通过分别实现对应的回调来进行处理。OkHttp提供的监听器包含以下回调:

    abstract class WebSocketListener {
    open fun onOpen(webSocket: WebSocket, response: Response) {}
    open fun onMessage(webSocket: WebSocket, text: String) {}
    open fun onMessage(webSocket: WebSocket, bytes: ByteString) {}
    open fun onClosing(webSocket: WebSocket, code: Int, reason: String) {}
    open fun onClosed(webSocket: WebSocket, code: Int, reason: String) {}
    open fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {}
    }
    object WebSocketConnection : WebSocketListener()
    @Synchronized
    fun connect() {
    ...
    webSocketClient = okHttpClient.newWebSocket(request, this)
    }
    ...
    }

    以上的事件通常在连接状态发生变化时被动触发,另一方面,如果用户想主动执行某些操作,WebSocket也提供了相应的接口以给用户显式调用。WebSocket协议包含两个主要的操作:

    • send( ) :向服务端发送消息,包括文本或二进制数据
    • close( ):主动请求关闭连接。

    可以看到,OkHttp提供的WebSocket接口也提供了这两个方法:

    interface WebSocket {
    ...
    fun send(text: String): Boolean
    fun send(bytes: ByteString): Boolean
    fun close(code: Int, reason: String?): Boolean
    ...
    }

    当onOpen方法回调时,即是连接建立成功,可以传输数据了。此时我们便可以调用WebSocket实例的send()方法发送文本消息或二进制消息,WebSocket官方服务器会将数据通过onMessage(webSocket: WebSocket, bytes: ByteString)或onMessage(webSocket: WebSocket, text: String)回调原样返回给我们。

    WebSocket是如何建立连接的?

    我们可以通过阅读OkHttp源码获知,newWebSocket(request: Request, listener: WebSocketListener)方法内部是创建了一个RealWebSocket实例,该类是WebSocket接口的实现类,创建实例成功后便调用connect(client: OkHttpClient)方法开始异步建立连接。

    override fun newWebSocket(request: Request, listener: WebSocketListener): WebSocket {
    val webSocket = RealWebSocket(
    taskRunner = TaskRunner.INSTANCE,
    originalRequest = request,
    listener = listener,
    random = Random(),
    pingIntervalMillis = pingIntervalMillis.toLong(),
    extensions = null, // Always null for clients.
    minimumDeflateSize = minWebSocketMessageToCompress
    )
    webSocket.connect(this)
    return webSocket
    }

    连接建立的过程主要是向服务器发送了一个HTTP请求,该请求包含了额外的一些请求头信息:

    val request = originalRequest.newBuilder()
    .header("Upgrade", "websocket")
    .header("Connection", "Upgrade")
    .header("Sec-WebSocket-Key", key)
    .header("Sec-WebSocket-Version", "13")
    .header("Sec-WebSocket-Extensions", "permessage-deflate")
    .build()

    这些请求头的意义如下:

    Connection: Upgrade:表示要升级协议

    Upgrade: websocket:表示要升级到websocket协议。

    Sec-WebSocket-Version:13:表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。

    Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。

    当返回的状态码为101时,表示服务端同意客户端协议转换请求,并将其转换为Websocket协议,该过程称之为Websocket协议握手(websocket Protocol handshake),协议升级完成后,后续的数据交换则遵照WebSocket的协议。

    前面我们一直说「握手」,握手究竟指的是什么呢?在计算机领域的语境中,握手通常是指确保服务器与其客户端同步的过程。握手是WebSocket协议的基本概念。

    为了直观展示,以上实例中传输的消息均以文本为例,WebSocket还支持二进制数据的传输,而这就要依靠「数据传输协议」来完成了,这是下一篇文章的内容,敬请期待。

    总结

    为了完成与服务端的双向通信,我们选取了WebSocket协议作为网络通信协议,并通过对比传统HTTP协议和其他相关的即时通讯技术,总结出,在为移动设备下应用选择的合适的网络通信协议时,可以有以下的参考标准:

    • 支持全双工通信
    • 支持二进制数据传输
    • 支持扩展
    • 跨语言、跨平台实现

    同时,也对WebSocket协议在Android端的实现提供了示例,并对WebSocket协议握手流程进行了初步窥探,当然,这只是第一步,往后的心跳保活、断线重连、消息队列等每一个都可以单独作为一个课题,会在后面陆续推出的。

    收起阅读 »

    Android即时通讯系列文章番外篇(1)使用Netty框架快速搭设WebSocket服务器

    前言随着本系列所讨论技术点的逐步深入,仅靠之前提到的官方测试服务器已经不能满足我们演示的需要了,于是我们有必要尝试在本地搭建自己的WebSocket服务器,今天这篇文章就是介绍这方面的内容。由于不属于原先的写作计划之内,同时也为了保持系列文章的连贯性,因此特意...
    继续阅读 »

    前言

    随着本系列所讨论技术点的逐步深入,仅靠之前提到的官方测试服务器已经不能满足我们演示的需要了,于是我们有必要尝试在本地搭建自己的WebSocket服务器,今天这篇文章就是介绍这方面的内容。

    由于不属于原先的写作计划之内,同时也为了保持系列文章的连贯性,因此特意将本篇文章命名为「番外篇」。

    Netty简单介绍

    还记得前面的文章「 Android即时通讯系列文章(2)网络通信协议选型:应以什么样的标准去选择适合你应用的网络通信协议?」里我们所提到的吗?WebSocket本身只是一个应用层协议,原则上只要遵循这个协议的客户端/服务端均可使用。对于客户端,前面我们已明确采用OkHttp框架来实现了,而对于服务端,我们则计划采用Netty框架来实现。

    Netty是什么?Netty是一款异步的、基于事件驱动的网络应用程序框架,支持快速开发可维护的、高性能的、面向协议的服务端和客户端。

    Netty封装了Java NIO API的能力,把原本在高负载下繁琐且容易出错的I/O操作,隐藏在一个简单易用的API之下。这无疑对于缺少服务端编程经验的客户端开发人员是非常友好的,只要把Netty的几个核心组件弄明白了,快速搭设一个满足本项目演示需要的WebSocket服务器基本上没什么问题。

    Netty核心组件

    Channel

    Channel是Netty传输API的核心,被用于所有的I/O操作,Channel 接口所提供的API大大降低了Java中直接使用Socket类的复杂性。

    回调

    Netty在内部使用了回调来处理事件,当一个回调被触发时,相关的事件可以交由一个ChannelHandler的实现处理。

    Future

    Future提供了一种在操作完成时通知应用程序的方式,可以看作是一个异步操作的结果的占位符,它将在未来的某个时刻完成,并提供对其结果的访问。

    Netty提供了自己的实现——ChannelFuture,由ChannelFutureListener提供的通知机制消除了手动检查对应操作是否完成的步骤。

    事件和ChannelHandler

    Netty使用不同的事件来通知我们状态的改变,这使得我们能够基于已经发生的事件来触发适当的动作。

    每个事件都可以被分发给ChannelHandler类,ChannelHandler类中提供了自定义的业务逻辑,架构上有助于保持业务逻辑与网络处理代码的分离。

    用IntelliJ IDEA运行Netty的WebSocket演示代码

    众所周知,Android Studio是基于IntelliJ IDEA开发的,因此对于习惯了用Android Studio进行开发的Android开发人员,用起IntelliJ IDEA来也几乎没有任何障碍。本篇的目的是快速搭设WebSocket服务器,因此选择直接将Netty的WebSocket演示代码拉取下来运行。在确保项目能成功运行起来的基础上,再逐步去分析演示代码。

    该演示代码展示的交互效果很简单,跟前面的官方测试服务器一样,当客户端向服务端发送一个消息,服务器都会将消息原原本本地回传给客户端(没错,又是Echo Test。。。)。虽然看起来好像用处不大,但它充分地体现了客户端/服务器系统中典型的请求-响应交互模式。

    接下来我们分别进行两端的工作:

    服务端的工作:

    • IntelliJ IDEA左上角New-Project-Maven创建新工程
    • 拉取Netty的WebSocket演示代码到src目录下
    • 按Alt+Enter快捷键自动导入Netty依赖
    • 运行WebSocketServer类的main()函数

    当控制台输出输出语句,即表示WebSocket服务器成功运行在本机上了:

    Open your web browser and navigate to http://127.0.0.1:8080/

    客户端的工作:

    • 保证手机网络与服务端在同一局域网下
    • 将要连接的WebSocket服务器地址更改为:ws://{服务端IP地址}:8080/websocket
    • 正常发送消息

    从控制台可以看到,客户端成功地与WebSocket服务器建立了连接,并在发送消息后成功收到了服务器的回传消息:

    11.png

    WebSocket演示代码分析

    总的来说,Netty的WebSocket演示代码中包含了两部分核心工作,其分别的意义以及对应的类如下表所示:

    核心工作意义对应的类
    提供ChannelHandler接口实现服务器对从客户端接收的数据的业务逻辑处理WebSocketServerHandler
    ServerBootstrap实例创建配置服务器的启动,将服务器绑定到它要监听连接请求的端口上WebSocketServer

    我们先来看看WebSocketServerHandler类核心工作的主要代码:

    public class WebSocketServerHandler extends SimpleChannelInboundHandler {

    private WebSocketServerHandshaker handshaker;

    // ...省去其他代码

    /**
    * 当有新的消息传入时都会回调
    *
    * @param ctx
    * @param msg
    */
    @Override
    public void channelRead0(ChannelHandlerContext ctx, Object msg) {
    if (msg instanceof FullHttpRequest) {
    handleHttpRequest(ctx, (FullHttpRequest) msg);
    } else if (msg instanceof WebSocketFrame) {
    handleWebSocketFrame(ctx, (WebSocketFrame) msg);
    }
    }

    private void handleHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) {
    // ...省去其他代码

    // 握手
    WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(
    getWebSocketLocation(req), null, true, 5 * 1024 * 1024);
    handshaker = wsFactory.newHandshaker(req);
    if (handshaker == null) {
    WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
    } else {
    handshaker.handshake(ctx.channel(), req);
    }
    }

    private void handleWebSocketFrame(ChannelHandlerContext ctx, WebSocketFrame frame) {
    // ...省去其他代码

    // 对于文本帧和二进制数据帧,将数据简单地回送给了远程节点。
    if (frame instanceof TextWebSocketFrame) {
    // Echo the frame
    ctx.write(frame.retain());
    return;
    }
    if (frame instanceof BinaryWebSocketFrame) {
    // Echo the frame
    ctx.write(frame.retain());
    }
    }

    // ...省去其他代码

    }

    如你所见,为了处理所有接收到的数据,我们重写了WebSocketServerHandler类的channelRead()方法,重写的方法中主要处理了Http请求和WebSocket帧两种类型的数据。

    Http请求类型的数据主要是为了处理客户端的握手建立连接过程,详情可参考前面的文章「 Android即时通讯系列文章(2)网络通信协议选型:应以什么样的标准去选择适合你应用的网络通信协议?」,这里就不再展开讲了。

    而WebSocket帧类型的数据主要是为了处理来自客户端主动发送的消息,我们知道,当WebSocket连接建立之后,后续的数据都是以帧的形式发送。主要包含以下几种类型的帧:

    • 文本帧
    • 二进制帧
    • Ping帧
    • Pong帧
    • 关闭帧

    其中,文本帧与二进制帧同属于消息帧,Ping帧和Ping帧主要用于连接保活,关闭帧则用于关闭连接,我们这里主要关心对消息帧的处理,可以看到,我们只是将数据简单回传回了远端节点,从而实现Echo Test。

    然后,我们再回过头来看WebSocketServer类的核心工作的主要代码:

    ublic final class WebSocketServer {

    // ...省去其他代码
    static final int PORT = Integer.parseInt(System.getProperty("port", SSL? "8443" : "8080"));

    public static void main(String[] args) throws Exception {
    // ...省去其他代码

    EventLoopGroup bossGroup = new NioEventLoopGroup(1);
    EventLoopGroup workerGroup = new NioEventLoopGroup();
    try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class) // 指定所使用的NIO传输Channel
    .childHandler(new WebSocketServerInitializer(sslCtx));

    // 使用指定的端口,异步地绑定服务器;调用sync()方法阻塞等待直到绑定完成
    Channel ch = b.bind(PORT).sync().channel();

    System.out.println("Open your web browser and navigate to " +
    (SSL? "https" : "http") + "://127.0.0.1:" + PORT + '/');

    // 获取Channel的CloseFuture,并且阻塞当前线程直到它完成
    ch.closeFuture().sync();
    } finally {
    // 关闭EventLoopGroup,释放所有的资源
    bossGroup.shutdownGracefully();
    workerGroup.shutdownGracefully();
    }
    }
    }

    我们使用ServerBootstrap引导类来完成Websocket服务器的网络层配置,随后调用bind(int inetPort)方法将进程绑定到某个指定的端口,此过程称之为引导服务器。

    我们是如何将前面定义的WebSocketServerHandler与ServerBootstrap关联起来的呢?关键就在于childHandler(ChannelHandler childHandler)方法。

    每个Channel都拥有一个与之相关联的ChannelPipeline,其持有一个ChannelHandler的实例链。我们需要提供一个ChannelInitializer的实现,并在其initChannel()回调方法中,将包括WebSocketServerHandler在内的一组自定义的ChannelHandler安装到ChannelPipeline中:

    public class WebSocketServerInitializer extends ChannelInitializer {

    // ...省去其他代码

    public WebSocketServerInitializer(SslContext sslCtx) {
    // ...省去其他代码
    }

    @Override
    public void initChannel(SocketChannel ch) throws Exception {
    ChannelPipeline pipeline = ch.pipeline();
    if (sslCtx != null) {
    pipeline.addLast(sslCtx.newHandler(ch.alloc()));
    }
    pipeline.addLast(new HttpServerCodec());
    pipeline.addLast(new HttpObjectAggregator(65536));
    pipeline.addLast(new WebSocketServerHandler());
    }
    }

    将Echo形式改为Broadcast形式

    我们之前讲过,现今主流的IM应用几乎都是采用服务器中转的方式来进行消息传输的,为了更好地实践这种设计,我们进一步来对WebSocket服务器进行改造,把Echo形式改为Broadcast形式,即:

    当接收到某一客户端的一条消息之后,将该消息转发给服务端维护的、除发送方之外的其他客户端连接。

    要实现这一功能我们需要用到ChannelGroup类,ChannelGroup负责跟踪所有活跃中的WebSocket连接,当有新的客户端通过握手成功建立连接后,我们就要把这个新的Channel添加到ChannelGroup中去。

    当接收到了WebSocket消息帧数据后,就调用ChannelGroup的writeAndFlush()方法将消息传输给所有已经连接的WebSocket Channel。

    ChannelGroup还允许传递过滤参数,我们可以以此过滤掉发送方的Channel。

    public class WebSocketServerHandler extends SimpleChannelInboundHandler {

    // ...省去其他代码
    private final ChannelGroup group;

    public WebSocketServerHandler(ChannelGroup group) {
    this.group = group;
    }

    private void handleWebSocketFrame(ChannelHandlerContext ctx, WebSocketFrame frame) {
    // ...省去其他代码
    if (frame instanceof TextWebSocketFrame) {
    // ctx.write(frame.retain());
    group.writeAndFlush(frame.retain(), ChannelMatchers.isNot(ctx.channel()));
    return;
    }
    if (frame instanceof BinaryWebSocketFrame) {
    // ctx.write(frame.retain());
    group.writeAndFlush(frame.retain(), ChannelMatchers.isNot(ctx.channel()));
    }
    }

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
    if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
    // 将新的WebSocket Channel添加到ChannelGroup 中,以便它可以接收到所有的消息
    group.add(ctx.channel());
    } else {
    super.userEventTriggered(ctx, evt);
    }
    }
    }


    运行起来之后,让多个客户端连接到此服务器,当客户端中的一个发送了一条消息后,其他连接的客户端会收到由服务器广播的这一条消息:

    12.png 13.png

    相关源码已上传到Github

    总结

    为了满足更多场景的演示需要,我们使用了Netty框架来快速搭建本机的WebSocket服务器。

    我们基于Netty的WebSocket演示代码进行改造,核心工作包括以下两部分:

    • 配置服务器的启动,将服务器绑定到它要监听连接请求的端口上
    • 服务器对从客户端接收的数据的业务逻辑处理

    我们先是以简单的Echo形式实现了客户端/服务器系统中典型的请求/响应交互模式,并进一步改用广播形式,实现了多个用户之间的相互通信。

    收起阅读 »

    为什么说在 Android 中请求权限从来都不是一件简单的事情?

    周末时间参加了东莞和深圳的两场 GDG,因为都是线上参与,所以时间上并不赶,我只需要坐在家里等活动开始就行了。等待的时间一时兴起,突然想写一篇原创,聊一聊我自己在写 Android 权限请求代码时的一些技术心得。正如这篇文章标题所描述的一样,在 Android...
    继续阅读 »

    周末时间参加了东莞和深圳的两场 GDG,因为都是线上参与,所以时间上并不赶,我只需要坐在家里等活动开始就行了。

    等待的时间一时兴起,突然想写一篇原创,聊一聊我自己在写 Android 权限请求代码时的一些技术心得。

    正如这篇文章标题所描述的一样,在 Android 中请求权限从来都不是一件简单的事情。为什么?我认为 Google 在设计运行时权限这块功能时,充分考虑了用户的使用体验,但是却没能充分考虑开发者的编码体验。

    之前在公众号的留言区和大家讨论时,有朋友说:我觉得 Android 提供的运行时权限 API 很好用呀,并没有觉得哪里使用起来麻烦。

    真的是这样吗?我们来看一个具体的例子。

    假设我正在开发一个拍照功能,拍照功能通常都需要用到相机权限和定位权限,也就是说,这两个权限是我实现拍照功能的先决条件,一定要用户同意了这两个权限我才能继续进行拍照。

    那么怎样去申请这两个权限呢?Android 提供的运行时权限 API 相信每个人都很熟悉了,我们自然而然可以写出如下代码:

    class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    ActivityCompat.requestPermissions(this,
    arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION), 1)
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    when (requestCode) {
    1 -> {
    var allGranted = true
    for (result in grantResults) {
    if (result != PackageManager.PERMISSION_GRANTED) {
    allGranted = false
    }
    }
    if (allGranted) {
    takePicture()
    } else {
    Toast.makeText(this, "您拒绝了某项权限,无法进行拍照", Toast.LENGTH_SHORT).show()
    }
    }
    }
    }

    fun takePicture() {
    Toast.makeText(this, "开始拍照", Toast.LENGTH_SHORT).show()
    }

    }

    可以看到,这里先是通过调用 requestPermissions() 方法请求相机权限和定位权限,然后在 onRequestPermissionsResult() 方法里监听授权的结果。如果用户同意了这两个权限,那么我们就可以去进行拍照了,如果用户拒绝了任意一个权限,那么弹出一个 Toast 提示,告诉用户某项权限被拒绝了,从而无法进行拍照。

    这种写法麻烦吗?这个就仁者见仁智者见智了,有些朋友可能觉得这也没多少行代码呀,有什么麻烦的。但我个人认为还是比较麻烦的,每次需要请求运行时权限时,我都会觉得很心累,不想写这么啰嗦的代码。

    不过我们暂时不从简易性的角度考虑,从正确性的角度上来讲,这种写法对吗?我认为是有问题的,因为我们在权限被拒绝时只是弹了一个 Toast 来提醒用户,并没有提供后续的操作方案,用户如果真的拒绝了某个权限,应用程序就无法继续使用了。

    因此,我们还需要提供一种机制,当权限被用户拒绝时,可以再次重新请求权限。

    现在我对代码进行如下修改:

    class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    requestPermissions()
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    when (requestCode) {
    1 -> {
    var allGranted = true
    for (result in grantResults) {
    if (result != PackageManager.PERMISSION_GRANTED) {
    allGranted = false
    }
    }
    if (allGranted) {
    takePicture()
    } else {
    AlertDialog.Builder(this).apply {
    setMessage("拍照功能需要您同意相机和定位权限")
    setCancelable(false)
    setPositiveButton("确定") { _, _ ->
    requestPermissions()
    }
    }.show()
    }
    }
    }
    }

    fun requestPermissions() {
    ActivityCompat.requestPermissions(this,
    arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION), 1)
    }

    fun takePicture() {
    Toast.makeText(this, "开始拍照", Toast.LENGTH_SHORT).show()
    }

    }

    这里我将请求权限的代码提取到了一个 requestPermissions() 方法当中,然后在 onRequestPermissionsResult() 里判断,如果用户拒绝了某项权限,那么就弹出一个对话框,告诉用户相机和定位权限是必须的,然后在 setPositiveButton 的点击事件中调用 requestPermissions() 方法重新请求权限。

    我们来看一下现在的运行效果:

    可以看到,现在我们对权限被拒绝的场景进行了更加充分的考虑。

    那么现在这种写法,是不是就将请求运行时权限的各种场景都考虑周全了呢?其实还没有,因为 Android 权限系统还提供了一种非常 “恶心” 的机制,叫拒绝并不再询问。

    当某个权限被用户拒绝了一次,下次我们如果再申请这个权限的话,界面上会多出一个拒绝并不再询问的选项。只要用户选择了这一项,那么完了,我们之后都不能再去请求这个权限了,因为系统会直接返回我们权限被拒绝。

    这种机制对于用户来说非常友好,因为它可以防止一些恶意软件流氓式地无限重复申请权限,从而严重骚扰用户。但是对于开发者来说,却让我们苦不堪言,如果我的某项功能就是必须依赖于这个权限才能运行,现在用户把它拒绝并不再询问了,我该怎么办?

    当然,绝大多数的用户都不是傻 X,当然知道拍照功能需要用到相机权限了,相信 99% 的用户都会点击同意授权。但是我们可以不考虑那剩下 1% 的用户吗?不可以,因为你们公司的测试就是那 1% 的用户,他们会进行这种傻 X 式的操作。

    也就是说,即使只为了那 1% 的用户,为了这种不太可能会出现的操作方式,我们在程序中还是得要将这种场景充分考虑进去。

    那么,权限被拒绝且不再询问了,我们该如何处理呢?比较通用的处理方式就是提醒用户手动去设置当中打开权限,如果想做得再好一点,可以提供一个自动跳转到当前应用程序设置界面的功能。

    下面我们就来针对这种场景进行完善,如下所示:

    class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    requestPermissions()
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    when (requestCode) {
    1 -> {
    val denied = ArrayList()
    val deniedAndNeverAskAgain = ArrayList()
    grantResults.forEachIndexed { index, result ->
    if (result != PackageManager.PERMISSION_GRANTED) {
    if (ActivityCompat.shouldShowRequestPermissionRationale(this, permissions[index])) {
    denied.add(permissions[index])
    } else {
    deniedAndNeverAskAgain.add(permissions[index])
    }
    }
    }
    if (denied.isEmpty() && deniedAndNeverAskAgain.isEmpty()) {
    takePicture()
    } else {
    if (denied.isNotEmpty()) {
    AlertDialog.Builder(this).apply {
    setMessage("拍照功能需要您同意相册和定位权限")
    setCancelable(false)
    setPositiveButton("确定") { _, _ ->
    requestPermissions()
    }
    }.show()
    } else {
    AlertDialog.Builder(this).apply {
    setMessage("您需要去设置当中同意相册和定位权限")
    setCancelable(false)
    setPositiveButton("确定") { _, _ ->
    val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
    val uri = Uri.fromParts("package", packageName, null)
    intent.data = uri
    startActivityForResult(intent, 1)
    }
    }.show()
    }
    }
    }
    }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
    1 -> {
    requestPermissions()
    }
    }
    }

    fun requestPermissions() {
    ActivityCompat.requestPermissions(this,
    arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION), 1)
    }

    fun takePicture() {
    Toast.makeText(this, "开始拍照", Toast.LENGTH_SHORT).show()
    }

    }

    现在代码已经变得比较长了,我还是带着大家来梳理一下。

    这里我在 onRequestPermissionsResult() 方法中增加了 denied 和 deniedAndNeverAskAgain 两个集合,分别用于记录拒绝和拒绝并不再询问的权限。如果这两个集合都为空,那么说明所有权限都被授权了,这时就可以直接进行拍照了。

    而如果 denied 集合不为空,则说明有权限被用户拒绝了,这时候我们还是弹出一个对话框来提醒用户,并重新申请权限。而如果 deniedAndNeverAskAgain 不为空,说明有权限被用户拒绝且不再询问,这时就只能提示用户去设置当中手动打开权限,我们编写了一个 Intent 来执行跳转逻辑,并在 onActivityResult() 方法,也就是用户从设置回来的时候重新申请权限。

    那么现在运行一下程序,效果如下图所示:

    可以看到,当我们第一次拒绝权限的时候,会提醒用户,相机和定位权限是必须的。而如果用户继续置之不理,选择拒绝并不再询问,那么我们将提醒用户,他必须手动开户这些权限才能继续运行程序。

    到现在为止,我们才算是把一个 “简单” 的权限请求流程用比较完善的方式处理完毕。然而代码写到这里真的还算是简单吗?每次申请运行时权限,都要写这么长长的一段代码,你真的受得了吗?

    这也就是我编写 PermissionX 这个开源库的原因,在 Android 中请求权限从来都不是一件简单的事情,但它不应该如此复杂

    PermissionX 将请求运行时权限时那些应该考虑的复杂逻辑都封装到了内部,只暴露最简单的接口给开发者,从而让大家不需要考虑上面我所讨论的那么多场景。

    而我们使用 PermissionX 来实现和上述一模一样的功能,只需要这样写就可以了:

    class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    PermissionX.init(this)
    .permissions(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION)
    .onExplainRequestReason { scope, deniedList ->
    val message = "拍照功能需要您同意相册和定位权限"
    val ok = "确定"
    scope.showRequestReasonDialog(deniedList, message, ok)
    }
    .onForwardToSettings { scope, deniedList ->
    val message = "您需要去设置当中同意相册和定位权限"
    val ok = "确定"
    scope.showForwardToSettingsDialog(deniedList, message, ok)
    }
    .request { _, _, _ ->
    takePicture()
    }
    }

    fun takePicture() {
    Toast.makeText(this, "开始拍照", Toast.LENGTH_SHORT).show()
    }

    }

    可以看到,请求权限的代码一下子变得极其精简。

    我们只需要在 permissions() 方法中传入要请求的权限名,在 onExplainRequestReason() 和 onForwardToSettings() 回调中填写对话框上的提示信息,然后在 request() 回调中即可保证已经得到了所有请求权限的授权,调用 takePicture() 方法开始拍照即可。

    通过这样的直观对比大家应该能感受到 PermissionX 所带来的便利了吧?上面那段长长的请求权限的代码我真的是为了给大家演示才写的,而我再也不想写第二遍了。

    收起阅读 »

    Compose Text简单使用

    Text控件的相关API说明 Compose中的Text就等价于Android原生中的TextView,API也比较简单: fun Text( text: String, // 文字内容,可以直接传递字符串,也可以使用stringResource(...
    继续阅读 »

    Text控件的相关API说明


    Compose中的Text就等价于Android原生中的TextView,API也比较简单:


    fun Text(
    text: String, // 文字内容,可以直接传递字符串,也可以使用stringResource(id = R.string.hello)来指定
    modifier: Modifier = Modifier, // 修饰符,可以指定宽高,背景,点击事件等。
    color: Color = Color.Unspecified, // 文字颜色
    fontSize: TextUnit = TextUnit.Unspecified, // 文字大小
    fontStyle: FontStyle? = null, // 文字样式,比如斜体
    fontWeight: FontWeight? = null, // 字体宽度,比如粗体
    fontFamily: FontFamily? = null, // 字体样式,比如SansSerif,Serif等
    letterSpacing: TextUnit = TextUnit.Unspecified, // 字符间距
    textDecoration: TextDecoration? = null, // 装饰物,比如添加下划线
    textAlign: TextAlign? = null, // 文字对齐方式,比如居中对齐
    lineHeight: TextUnit = TextUnit.Unspecified, // 行高
    overflow: TextOverflow = TextOverflow.Clip, // 文字溢出的展示方式,比如裁剪,或末尾显示...等
    softWrap: Boolean = true, // 文字过长是否换行
    maxLines: Int = Int.MAX_VALUE, // 最大行数
    onTextLayout: (TextLayoutResult) -> Unit = {}, // 布局变化的回调
    style: TextStyle = LocalTextStyle.current // 设置Style,类似TextView的style
    )

    TextStyle的API,内容跟Text里面的大部分相同,具体可以查看相关API


    基础示例


    我们来个小Demo


    @Composable
    fun TextDemo() {
    val text = "this is compose text demo, which likes TextView in android native xml layout"
    Text(
    text = text, // 文字
    color = Color.Green, // 字体颜色
    fontSize = 16.sp, // 字体大小
    fontStyle = FontStyle.Italic, // 斜体
    fontWeight = FontWeight.Bold, // 粗体
    textAlign = TextAlign.Center, // 对齐方式: 居中对齐
    modifier = Modifier.width(300.dp), // 指定宽度为300dp
    maxLines = 2, // 最大行数
    overflow = TextOverflow.Ellipsis, // 文字溢出后就裁剪
    softWrap = true, // 文字过长时是否换行
    textDecoration = TextDecoration.Underline, // 文字装饰,这里添加下划线
    )
    }

    效果如下:


    示例


    然后我们加上字体样式:


    fontFamily = FontFamily.Cursive, // 字体样式

    效果如下:


    示例


    我们再加上行高和字符间距:


    lineHeight = 40.sp, // 行高40sp
    letterSpacing = 5.sp // 字符间距5sp

    效果如下:


    示例


    富文本


    使用原生的TextView如果想要实现富文本,需要使用Spanable,而且需要计算文字的下标,非常麻烦,Compose的就相当好用了。


    1 使用SpanStyle来实现富文本

    API如下:


    class SpanStyle(
    val color: Color = Color.Unspecified, // 文字颜色
    val fontSize: TextUnit = TextUnit.Unspecified, // 文字大小
    val fontWeight: FontWeight? = null, // 字体粗细,比如粗体
    val fontStyle: FontStyle? = null, // 文字样式,比如斜体
    val fontSynthesis: FontSynthesis? = null, // 指定的字体找不到时,所采用的策略
    val fontFamily: FontFamily? = null, // 字体样式,比如Serif
    val fontFeatureSettings: String? = null, // 字体的排印设置,可以取CSS中font-feature-settings的值
    val letterSpacing: TextUnit = TextUnit.Unspecified, // 字符间距
    val baselineShift: BaselineShift? = null, // 文字举例baseline的像上偏移量
    val textGeometricTransform: TextGeometricTransform? = null, // 用于几何变换,比如缩放、倾斜等
    val localeList: LocaleList? = null, // 国际化相关符号列表
    val background: Color = Color.Unspecified, // 背景色
    val textDecoration: TextDecoration? = null, // 装饰,比如下划线
    val shadow: Shadow? = null // 阴影
    )

    直接看Demo:


    @Composable
    fun TextDemo2() {
    Text(buildAnnotatedString {
    // 使用白色背景,红色字体,18sp,Monospace字体来绘制"Hello " (注意后面有个空格)
    withStyle(style = SpanStyle(color = Color.Red, background = Color.White, fontSize = 18.sp, fontFamily = FontFamily.Monospace)) {
    append("Hello ")
    }
    // 正常绘制"World"
    append("World ")
    // 使用黄色背景,绿色字体,18sp,Serif字体,W900粗体来绘制"Click"
    withStyle(style = SpanStyle(color = Color.Green, background = Color.Yellow, fontSize = 30.sp, fontFamily = FontFamily.Serif, fontWeight = FontWeight.W900)) {
    append("Click")
    }
    // 正常绘制" Me" (注意前面有个空格)
    append(" Me")

    // 添加阴影及几何处理
    withStyle(
    style = SpanStyle(
    color = Color.Yellow,
    background = Color.White,
    baselineShift = BaselineShift(1.0f), // 向BaseLine上偏移10
    textGeometricTransform = TextGeometricTransform(scaleX = 2.0F, skewX = 0.5F), // 水平缩放2.0,并且倾斜0.5
    shadow = Shadow(color = Color.Blue, offset = Offset(x = 1.0f, y = 1.0f), blurRadius = 10.0f) // 添加音阴影和模糊处理
    )
    ) {
    append(" Effect")
    }
    })
    }

    其中buildAnnotatedString()可以理解为构建了一个作用域,在该作用域内可以使用withStyle(style)来指定文字格式,效果如下:


    示例


    2 使用ParagraphStyle来实现段落

    API如下:


    class ParagraphStyle constructor(
    val textAlign: TextAlign? = null, // 对齐方式
    val textDirection: TextDirection? = null, // 文字方向
    val lineHeight: TextUnit = TextUnit.Unspecified, //行高
    val textIndent: TextIndent? = null // 缩进方式
    )

    直接看Demo:


    @Composable
    fun TextDemo3() {
    Text(buildAnnotatedString {
    // 指定对齐方式为Start,通过textIndent指定第一行每段第一行缩进32sp,其余行缩进8sp
    withStyle(style = ParagraphStyle(textAlign = TextAlign.Start, textIndent = TextIndent(firstLine = 32.sp, restLine = 8.sp))) {

    // 第一段,因为只有一行,所以直接缩进32sp
    withStyle(style = SpanStyle(color = Color.Red)) {
    append("Hello, this is first paragraph\n")
    }
    // 第二段(第一行会缩进32sp,后续每行会缩进8sp)
    withStyle(style = SpanStyle(color = Color.Green, fontWeight = FontWeight.Bold)) {
    append("Hello, this is second paragraph,very long very long very long very long very long very long very long very long very long very long\n")
    }
    // 第三段,因为只有一行,所以直接缩进32sp
    append("Hello, this is third paragraph\n")
    }
    })
    }

    效果如下:


    示例


    交互


    传统的Android的TextView可以实现选中/不可选中,但是却很难实现部分可选中,部分不可选中;传统的TextView可以设置点击事件,但是很难实现获取点击文字的位置,这些在Compose中都不是事。


    1 可选中和不可选中

    我们可以直接使用SelectionContainer来包括可以选中的文本,使用DisableSelection来包括不可选中的文本,eg:


    @Composable
    fun TextDemo4() {
    // 设置可选区域
    SelectionContainer {
    // Column等价于竖直的LinearLayout
    Column {
    Text(text = "可以选中我,可以选中我,可以选中我")

    // 设置不可选区域
    DisableSelection {
    Text(text = "选不中我,选不中我,选不中")
    }

    // 位于可选区域内,可选
    Text(text = "可以选中我,可以选中我,可以选中我")
    }
    }
    }

    效果如下:


    示例


    2 单个文字响应点击事件

    我们可以直接使用ClickableText来实现点个文字的点击效果,API如下:


    fun ClickableText(
    text: AnnotatedString, // 传入的文字,这里必须传入AnnotatedString
    modifier: Modifier = Modifier, // 修饰符
    style: TextStyle = TextStyle.Default, // 文本Style
    softWrap: Boolean = true, // 文本长度过长时,是否换行
    overflow: TextOverflow = TextOverflow.Clip, // 文字超出显示范围的处理方式,默认Clip,就是不显示
    maxLines: Int = Int.MAX_VALUE, // 最大行数
    onTextLayout: (TextLayoutResult) -> Unit = {}, // 布局发生变化的回调
    onClick: (Int) -> Unit // 点击事件,参数为点击文字的下标
    )

    Demo如下:


    @Composable
    fun TextDemo5(context: Context) {
    ClickableText(text = AnnotatedString("请点击我"), onClick = { index ->
    Toast.makeText(context, "点击位置:$index", Toast.LENGTH_SHORT).show()
    })
    }

    效果如下:


    示例


    如果要给整个Text()设置点击事件,直接使用Modifier.clickable{}即可。


    3 给指定文字添加注解(超链接)

    我们可以使用pushStringAnnotation()和pop()函数对来给指定文字添加注解,如下:


    @Composable
    fun TextDemo6(context: Context) {

    // 构建注解文本
    val url_tag = "article_url";
    val articleText = buildAnnotatedString {
    append("点击")

    // pushStringAnnotation()表示开始添加注解,可以理解为构造了一个<tag,annotation>的映射
    pushStringAnnotation(tag = url_tag, annotation = "https://devloper.android.com")
    // 要添加注解的文本为"打开本文"
    withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) {
    append("展示Android官网")
    }
    // pop()表示注解结束
    pop()
    }

    // 构造可点击文本
    ClickableText(text = articleText, onClick = { index ->
    // 根据tag取出annotation并打印
    articleText.getStringAnnotations(tag = url_tag, start = index, end = index).firstOrNull()?.let { annotation ->
    Toast.makeText(context, "点击了:${annotation.item}", Toast.LENGTH_SHORT).show()
    }
    })
    }

    效果如下:


    示例


    Demo可在这里下载: gitee.com/lloydfinch/…


    当然,Text的用法远不止此,更多的用法可以查看官方API即可。



    作者:奔波儿灞取经
    链接:https://juejin.cn/post/6981396073952575519
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    WindowInspector(窗口检查器)出来两年了,还不了解?!!

    前言这个知识点,出来两年了,现在在网上搜索,没有看到相关分享。一个非常好用的Api,Android 10 才增加的,解决悬浮窗口的一个痛点,下面把我的经验跟大家分享一下,希望大能够受用。悬浮窗口的痛点(View is attach)为什么说 “View is ...
    继续阅读 »

    前言

    这个知识点,出来两年了,现在在网上搜索,没有看到相关分享。一个非常好用的Api,Android 10 才增加的,解决悬浮窗口的一个痛点,下面把我的经验跟大家分享一下,希望大能够受用。

    悬浮窗口的痛点(View is attach)

    为什么说 “View is attach的判断是悬浮窗口的痛点”?

    在Android 10 之前

    WinodwManager 提供了 addView、removeView 操作,没有查询接口,如何判断view是否被add,所能想到的方式就是view is attach。加上 “ View not attached to window manager” 的 源码log,使开发者确信,通过View.isAttachedToWindow判断,就可以判断 view是否在window上,没有调查使用中可能出现的风险。

    可悲的是,没有其他好的Api之前,使用View.isAttachedToWindow 也几乎变成了一个唯一的选择。

    Android 10 推了新的API,让开发者多了一个更好的选择。

    通过一个案例,来说明这个API,并解决这个痛点。

    案例回顾

    悬浮窗口的使用

    windowManager.addView(view, layoutParams);//新增窗口
    windowManager.removeView(view);移除窗口

    但新增或者移除容易导致Crash,log如下

    如:同一个view 被add 两次

    05-21 03:19:13.285 3463 3463 W System.err: java.lang.IllegalStateException: View XXX{7afdd92 V.E...... ......I. 0,0-56,290} has already been added to the window manager.

    05-21 03:19:13.285 3463 3463 W System.err: at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:359)

    05-21 03:19:13.285 3463 3463 W System.err: at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:96)

    移除一个,没有Attach 的窗口

    W System.err: java.lang.IllegalArgumentException: View=XXXX{25626d6 V.E...... ........ 0,0-56,229} not attached to window manager

    08-19 07:08:08.832 25836 25836 W System.err: at android.view.WindowManagerGlobal.findViewLocked(WindowManagerGlobal.java:517)

    08-19 07:08:08.832 25836 25836 W System.err: at android.view.WindowManagerGlobal.removeView(WindowManagerGlobal.java:426)

    08-19 07:08:08.832 25836 25836 W System.err: at android.view.WindowManagerImpl.removeView(WindowManagerImpl.java:123)

    通常的解决方式:log提示 not attached -> 刚好可以使用View.isAttachedToWindow 去判断代码如下:

    if(view.isAttachedToWindow()){
    try {
    windowManager.removeView(textView);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    if(!view.isAttachedToWindow()){
    try {
    windowManager.addView(view,layoutParams);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    遇到的问题

    使用以上代码,但在项目中还是有极低概率还是会出现crash。

    也就是 isAttachedToWindow ≠ view is add window

    解决问题

    • 方案一:

      应用中给View标志位,表示view 是否被add。但是总觉得本地标识,没有系统api准确,该方案不算太好。偶现的问题,必然有必现的过程,后面进行了深入的分析。

      项目中遇到的问题是:同一个view,windowManager.addView 被连续执行了两次,也不是异步造成的。

      最后发现,两次调用时间间隔极短,isAttachedToWindow 还没有来得及改变。

      经详细调查有了方案二。

    • 方案二:

      使用 WindowInspector,此类在Android 10 framework 才增加,且只有getGlobalWindowViews 这一个静态方法

      view.isAttachedToWindow() 改为 WindowInspector.getGlobalWindowViews().contains(view)

    源码分析

    getGlobalWindowViews

    看下主要类的调用关系,以及主要的方法

    RmzUoV.png

    /**
    * addView 与 removeView 都会调用此方法
    * addView required:false 如果 index !=0 同一个View重复add,抛异常
    * removeView required:true index < 0 ,没有找到View,抛异常
    */
    private int findViewLocked(View view, boolean required) {
    final int index = mViews.indexOf(view);
    if (required && index < 0) {
    throw new IllegalArgumentException("View=" + view + " not attached to window manager");
    }
    return index;
    }

    通过以上分析:WindowManagerGlobal 中 mView 是问题的关键,管理着所属应用的所有View。

    Android 10,提供了 WindowInspector.getGlobalWindowViews()。可以获取mViews。修改代码如下

    if(WindowInspector.getGlobalWindowViews().contains(view)){
    windowManager.removeView(view);
    }
    if(!WindowInspector.getGlobalWindowViews().contains(view)){
    windowManager.addView(view,layoutParams);
    }

    分析到这里,问题就解决了。那isAttachedToWindow 怎么就不行呢?

    isAttachedToWindow

    以下时序图看出:

    当刷新线程走完后,才认为是attach。

    在这里插入图片描述

    两者区别是:attach 当view 被add ,且被刷新了。

    总结

    经过上文分析,从view add -> attach , 区别是view 是否被刷新。

    分析了下:为什么要重新用WindowInspector,而不是用WIndowManager WindowManager:是对Window 的管理,查询觉得不合适吧,所以用了WindowInspector类

    下面说明下可能出现的一些误区

    1、getGlobalWindowViews 获取的View列表 ,其实是WindowManagerGlobal.mViews 的浅拷贝,所以增删改,都不会应该WindowManagerGlobal 中mViews的结构。

    2、强调下,不要被Global迷惑,Global的范围是应用级别的,获取不到其他应用的窗口。

    收起阅读 »

    为什么我推荐你用ViewBinding 替换findViewById?

    为什么推荐你使用ViewBinding 替换findViewById 和 ButterKnife ? 因为太爽了,太上头了。 用过一次就爱上了,再也不想回去。真心的。不信你看下文! 定义 ViewBinding 是google推出Jetpack库的一个...
    继续阅读 »

    为什么推荐你使用ViewBinding 替换findViewById 和 ButterKnife ? 因为太爽了,太上头了。 用过一次就爱上了,再也不想回去。真心的。不信你看下文!



    定义


    ViewBinding 是google推出Jetpack库的一个组件,主要用于视图绑定,替代 findViewById操作.Viewbinding会根据xml文件生成一个对应的绑定类, 比如我们xml文件是: activity_login_layout.xml 生成的绑定类就是ActivityLoginLayoutBinding 这么一个类.在使用的时候直接通过生成的绑定类调用我们xml中的视图组件, 不用findViewById,也不用声明组件. 接下来看下我们集成和项目就能很快的理解.


    集成


    首先在我们工程build.gradld中引入viewBind


        //引入ViewBinding
    viewBinding {
    viewBinding = true
    }

    然后我们就可以在代码中使用ViewBinding了


    代码


    创建了一个登录页面, activity_login_layout.xml



    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    tools:viewBindingIgnore="false"
    android:padding="16dp">


    <androidx.appcompat.widget.AppCompatTextView
    android:id="@+id/tv_login_title"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center"
    android:text="登录"
    android:textColor="@color/design_default_color_primary_variant"
    android:textSize="19sp" />


    <androidx.appcompat.widget.AppCompatEditText
    android:id="@+id/et_login_account"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:layout_marginTop="10dp"
    android:hint="用户名"
    android:paddingStart="10dp"
    android:paddingEnd="10dp" />


    <androidx.appcompat.widget.AppCompatEditText
    android:id="@+id/et_login_pwd"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:layout_marginTop="10dp"
    android:hint="密码"
    android:paddingStart="10dp"
    android:paddingEnd="10dp" />


    <Button
    android:id="@+id/btn_login"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:layout_marginTop="20dp"
    android:background="@color/design_default_color_secondary"
    android:text="登录"
    android:textSize="20dp" />


    <TextView
    android:id="@+id/tv_find_pwd"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="end"
    android:layout_marginTop="10dp"
    android:layout_marginEnd="10dp"
    android:text="找回密码"
    android:textColor="@android:color/holo_red_dark"
    android:textSize="16sp" />

    </LinearLayout>

    大忽悠登录账号.png 接下来我们看下用findViewById方式获取id并且设置点击事件等




    class LoginActivity2 : AppCompatActivity(R.layout.activity_login_layout) {
    //先声明控件
    lateinit var etAccount: EditText
    lateinit var etPwd: EditText
    lateinit var btnLogin: Button

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    initView()
    }

    private fun initView() {
    //挨个findViewById 初始化控件
    etAccount = findViewById(R.id.et_login_account)
    etPwd = findViewById(R.id.et_login_pwd)
    btnLogin = findViewById(R.id.btn_login)
    btnLogin.setOnClickListener {
    var accountInfo = etAccount.text
    var accountPwd = etPwd.text
    if (!TextUtils.isEmpty(accountInfo)) {
    if (!TextUtils.isEmpty(accountPwd)) {
    Log.e("ping", "执行登录操作:用户名:$accountInfo 密码: $accountPwd")
    } else {
    Log.e("ping", "请输入密码")
    }
    } else {
    Log.e("ping", "请输入用户名")
    }
    }
    }
    }

    在看下用ViewBinding的代码



    class LoginActivity : AppCompatActivity() {
    private lateinit var binding: ActivityLoginLayoutBinding
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = ActivityLoginLayoutBinding.inflate(layoutInflater)
    val view = binding.root;
    setContentView(view)
    initView()
    }

    private fun initView() {
    binding.btnLogin.setOnClickListener {
    var accountInfo = binding.etLoginAccount.text
    var accountPwd = binding.etLoginPwd.text
    if (!TextUtils.isEmpty(accountInfo)) {
    if (!TextUtils.isEmpty(accountPwd)) {
    Log.e("ping", "执行登录操作:用户名:$accountInfo 密码: $accountPwd")
    } else {
    Log.e("ping", "请输入密码")
    }
    } else {
    Log.e("ping", "请输入用户名")
    }
    }
    }
    }

    是不是很直观, 不需要声明控件,也不需要findViewById 直接用 ActivityLoginLayoutBinding绑定类 . 操作就可以, 是不是很Nice. 这个时候你又说了,这样我不知道那个是那个控件怎么办? 我们来看下ActivityLoginLayoutBinding类的代码:



    public final class ActivityLoginLayoutBinding implements ViewBinding {
    @NonNull
    private final LinearLayout rootView;
    @NonNull //android:id="@+id/btn_login"
    public final Button btnLogin; //根据我们在xml中定义的id 驼峰命名发声明控件
    @NonNull //android:id="@+id/et_login_account"
    public final AppCompatEditText etLoginAccount;
    @NonNull //android:id="@+id/et_login_pwd"
    public final AppCompatEditText etLoginPwd;
    @NonNull // android:id="@+id/tv_find_pwd"
    public final TextView tvFindPwd;
    @NonNull
    public final AppCompatTextView tvLoginTitle;

    private ActivityLoginLayoutBinding(@NonNull LinearLayout rootView, @NonNull Button btnLogin, @NonNull AppCompatEditText etLoginAccount, @NonNull AppCompatEditText etLoginPwd, @NonNull TextView tvFindPwd, @NonNull AppCompatTextView tvLoginTitle) {
    this.rootView = rootView;
    this.btnLogin = btnLogin;
    this.etLoginAccount = etLoginAccount;
    this.etLoginPwd = etLoginPwd;
    this.tvFindPwd = tvFindPwd;
    this.tvLoginTitle = tvLoginTitle;
    }

    @NonNull
    public LinearLayout getRoot() {
    return this.rootView;
    }

    @NonNull
    public static ActivityLoginLayoutBinding inflate(@NonNull LayoutInflater inflater) {
    return inflate(inflater, (ViewGroup)null, false);
    }

    @NonNull
    public static ActivityLoginLayoutBinding inflate(@NonNull LayoutInflater inflater, @Nullable ViewGroup parent, boolean attachToParent) {
    View root = inflater.inflate(2131427356, parent, false);
    if (attachToParent) {
    parent.addView(root);
    }

    return bind(root);
    }

    @NonNull
    public static ActivityLoginLayoutBinding bind(@NonNull View rootView) {
    int id = 2131230807;
    Button btnLogin = (Button)ViewBindings.findChildViewById(rootView, id);
    if (btnLogin != null) {
    id = 2131230876;
    AppCompatEditText etLoginAccount = (AppCompatEditText)ViewBindings.findChildViewById(rootView, id);
    if (etLoginAccount != null) {
    id = 2131230877;
    AppCompatEditText etLoginPwd = (AppCompatEditText)ViewBindings.findChildViewById(rootView, id);
    if (etLoginPwd != null) {
    id = 2131231121;
    TextView tvFindPwd = (TextView)ViewBindings.findChildViewById(rootView, id);
    if (tvFindPwd != null) {
    id = 2131231122;
    AppCompatTextView tvLoginTitle = (AppCompatTextView)ViewBindings.findChildViewById(rootView, id);
    if (tvLoginTitle != null) {
    return new ActivityLoginLayoutBinding((LinearLayout)rootView, btnLogin, etLoginAccount, etLoginPwd, tvFindPwd, tvLoginTitle);
    }
    }
    }
    }
    }

    String missingId = rootView.getResources().getResourceName(id);
    throw new NullPointerException("Missing required view with ID: ".concat(missingId));
    }
    }

    其实ViewBinding自动为我们声明控件,并且执行fingViewById,我们只需要在用的时候直接用 binding.btnLogin 等等


    在Activity中如何视图绑定


    Activity中使用视图绑定的话需要在 的 onCreate() 方法中执行以下步骤:



    • 调用生成的绑定类中包含的静态 inflate() 方法。此操作会创建该绑定类的实例以供 Activity 使用。

    • 通过调用 getRoot() 方法或使用 Kotlin 属性语法获取对根视图的引用。

    • 将根视图传递到 setContentView(),使其成为屏幕上的活动视图。


     
    private lateinit var binding: ActivityLoginLayoutBinding

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = ActivityLoginLayoutBinding.inflate(layoutInflater)
    val view = binding.root;
    setContentView(view)
    }

    然后就可以使用视图上任何定义的View了,例如:



    binding.btnLogin.setOnClickListener {
    var accountInfo = binding.etLoginAccount.text
    var accountPwd = binding.etLoginPwd.text
    if (!TextUtils.isEmpty(accountInfo)) {
    if (!TextUtils.isEmpty(accountPwd)) {
    Log.e("ping", "执行登录操作:用户名:$accountInfo 密码: $accountPwd")
    } else {
    Log.e("ping", "请输入密码")
    }
    } else {
    Log.e("ping", "请输入用户名")
    }
    }
    }

    在 Fragment 中使用视图绑定


    在Fragment使用视图绑定,首先需要在 onCreateView() 方法中执行以下步骤:



    • 调用生成的绑定类中包含的静态 inflate() 方法。此操作会创建该绑定类的实例以供 Fragment 使用。

    • 通过调用 getRoot() 方法或使用 Kotlin 属性语法获取对根视图的引用。

    • 从 onCreateView() 方法返回根视图,使其成为屏幕上的活动视图。



    class LoginFragment : Fragment() {
    private lateinit var binding: ActivityLoginLayoutBinding
    override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
    )
    : View? {
    binding = ActivityLoginLayoutBinding.inflate(inflater, container, false)
    return binding.root
    }

    override fun onDestroyView() {
    super.onDestroyView()
    null.also { it -> binding = it }
    }
    }

    然后就能愉快的使用了我们视图上定义的view啦


     binding.btnLogin.setOnClickListener {
    //执行登录操作
    }

    总结


    与findViewById相比,很明显的优点是,代码量减少,使用更加简单,减少了很多无用的操作. 还有就是



    • Null 安全:由于视图绑定会创建对视图的直接引用,因此不存在因视图 ID 无效而引发 Null 指针异常的风险。此外,如果视图仅出现在布局的某些配置中,则绑定类中包含其引用的字段会使用 @Nullable 标记。

    • 类型安全:每个绑定类中的字段均具有与它们在 XML 文件中引用的视图相匹配的类型。这意味着不存在发生类转换异常的风险。


    优点



    • 更快的编译速度:视图绑定不需要处理注释,因此编译时间更短

    • 易于使用:视图绑定不需要特别标记的 XML 布局文件,因此在应用中采用速度更快。在模块中启用视图绑定后,它会自动应用于该模块的所有布局。


    缺点



    • 视图绑定不支持布局变量或布局表达式,因此不能用于直接在 XML 布局文件中声明动态界面内容。

    • 视图绑定不支持双向数据绑定。


    以上就是ViewBinding的全部内容啦, 如果觉得不错,不妨点个赞.谢谢.



    作者:丁大忽悠
    链接:https://juejin.cn/post/6981471420769370126
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


    收起阅读 »

    iOS逆向安防从入门到秃头--OC反汇编

    前面和兄弟们写了好多汇编的知识,今天我们开始步入正题了:OC的汇编1. 方法的调用我们开始就简单写个OC对象,看下他的汇编吧@interface XGPerson : NSObject+(XGPerson *)person;@end@implementatio...
    继续阅读 »

    前面和兄弟们写了好多汇编的知识,今天我们开始步入正题了:OC的汇编

    1. 方法的调用
    我们开始就简单写个OC对象,看下他的汇编吧
    @interface XGPerson : NSObject
    +(XGPerson *)person;
    @end

    @implementation XGPerson
    + (XGPerson *)person{
    return [self alloc];
    }
    @end

    int main(int argc, char * argv[]) {
    XGPerson *p = [XGPerson person];
    }

      1. 大家应该知道方法的本质就是消息的发送:objc_msgSend

    1.1. 动态分析

      1. 我们先看下汇编代码

    1.png

    我们知道objc_msgSend会有2个默认的参数(id self, SEL _cmd)

    这个根据前面的知识我们就更能理解,为什么参数最好控制在6个以内了

      1. 我们可以动态看下x0,x1的值
    • 2.png

      1.2. 静态分析

      动态调试是舒服,但是我们逆向开发的时候好多时候都会静态分析~

        1. 我们还是要老规矩分析一波~

      3-1.png

        1. 然后我们验证我们分析的正确性(iOS是小端模式,所以取出地址,从右向左读)

      4.png

      看来我们的静态分析没有错

      1.2.1. 工具分析

      说实在的:静态分析一个方法,我人都快傻掉了。要是真正的工程(成千上万的方法),我不瓦特了?

      估计那些·啥啥家·也是真想的~

      • 咱们先用Hopper看下二进制文件,会不会效果好点

      5.png

      那些imp指向的方法的实现,其实都是objc源码里面的方法---很早之前写了一篇博客objc源码调试 (目前最新的是objc4-818.2,其实差不多)

      2. block反汇编

      关于block,我也写了一篇博客block底层分析 --- 实不相瞒,太早了,有点忘记了,不过应该还可以参考

        1. 不过曾经没有看过汇编。现在看汇编又可以明白好多东西
        1. 先写一些代码
      int main(int argc, char * argv[]) {
      void(^block)(NSInteger index) = ^(NSInteger index){
      NSLog(@"block -- %ld",index);
      };

      block(1);
      }
      复制代码
        1. 我稍稍的画了个小小的图(小谷艺术细菌比较少,兄弟们多担待~)

      6.png

      我的理解:block其实也是个对象 --- 就是有点特殊

      3. 总结

      • hopper是专门做OC的反汇编之类的。但是我们项目中好多都会有C++和C代码,而且这个伪代码不太友好 --- 以后可能会用一个其他的工具

      • 写了好多汇编的博客,其实就那么些指令。我需要的时候就是一边查着看--接下来就要搞搞传说中的逆向了~

      • 还有谢谢兄弟们的点赞和浏览,坚持学习到了现在,非常真诚的给兄弟们鞠个躬Thanks♪(・ω・)ノ

      • 好了!兄弟们,等待我的下一篇产出 ~

      • 更多文章观看:https://github.com/uzi-yyds-code/IOS-reverse-security


    收起阅读 »

    安卓进阶二: 这次我把ARouter源码搞清楚啦!

    四. ARouter 注解处理器:arouter-compilerARouter 生成路由信息代码利用了注解处理器的特性。 arouter-compiler 就是注解处理代码模块,先看看该模块的依赖库//定义的注解类,以及相关数据实体类 i...
    继续阅读 »

    四. ARouter 注解处理器:arouter-compiler

    ARouter 生成路由信息代码利用了注解处理器的特性。 arouter-compiler 就是注解处理代码模块,先看看该模块的依赖库

    //定义的注解类,以及相关数据实体类
    implementation 'com.alibaba:arouter-annotation:1.0.6'

    annotationProcessor 'com.google.auto.service:auto-service:1.0-rc7'
    compileOnly 'com.google.auto.service:auto-service-annotations:1.0-rc7'

    implementation 'com.squareup:javapoet:1.8.0'

    implementation 'org.apache.commons:commons-lang3:3.5'
    implementation 'org.apache.commons:commons-collections4:4.1'

    implementation 'com.alibaba:fastjson:1.2.69'

    依赖库中注解处理相关依赖库说明:

    • Auto-service官方文档 针对被@AutoService注解的类,生成对应元数据,在javac 编译的时候,会自动加载,并放在注释处理环境中。
    • javapoet :square推出的开源java代码生成框架,我们可以很方便的使用它根据注解、数据库模式、协议格式等来对应生成代码。
    • arouter-annotation :arouter 的注解类们和路由信息实体类们
    • 其他,工具类库

    RouteProcessor注解处理器处理流程说明

    我们先看看路由处理器 RouteProcessor

    @AutoService(Processor.class)
    @SupportedAnnotationTypes({ANNOTATION_TYPE_ROUTE, ANNOTATION_TYPE_AUTOWIRED})
    public class RouteProcessor extends BaseProcessor {
    @Override
    //在该方法中可以获取到processingEnvironment对象,
    //借由该对象可以获取到生成代码的文件对象, debug输出对象,以及一些相关工具类
    public synchronized void init(ProcessingEnvironment processingEnv) {
    //...
    super.init(processingEnv);
    }
    @Override
    //返回所支持的java版本,一般返回当前所支持的最新java版本即可
    public SourceVersion getSupportedSourceVersion() {
    //...
    return super.getSupportedSourceVersion();
    }

    @Override
    //必须实现 扫描所有被注解的元素,并作处理,最后生成文件。该方法的返回值为boolean类型,若返回true,
    //则代表本次处理的注解已经都被处理,不希望下一个注解处理器继续处理,
    //否则下一个注解处理器会继续处理。
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
    //...
    return false;
    }

    }

    可以看到处理注解主要是在process方法。 RouteProcessor 继承 BaseProcessor 间接继承了AbstractProcessor,在BaseProcessor#init 方法中,获取到processingEnv 中的各种实用工具,以供处理注解使用。 值得一提的是,init 中获取了moduleName 和 generateDoc 参数代码如下:

    if (MapUtils.isNotEmpty(options)) {
    ///AROUTER_MODULE_NAME
    moduleName = options.get(KEY_MODULE_NAME);
    ///AROUTER_GENERATE_DOC
    generateDoc = VALUE_ENABLE.equals(options.get(KEY_GENERATE_DOC_NAME));
    }

    这一块就是我们常常需要在gradle中配置的arguments 的由来:

    android {
    defaultConfig {
    javaCompileOptions {
    annotationProcessorOptions {
    arguments = [AROUTER_MODULE_NAME: project.getName(), AROUTER_GENERATE_DOC: "enable"]
    }
    }
    }
    }
    //或者kotlin
    kapt {
    arguments {
    arg("AROUTER_MODULE_NAME", project.getName())
    }
    }

    接下来看RouteProcessor#process方法的具体实现:

    Set<? extends Element> routeElements = roundEnv.getElementsAnnotatedWith(Route.class);
    this.parseRoutes(routeElements);

    代码中拿到了所有标注@Route注解的相关类元素。 然后在parseRoutes方法中进行处理: 省略了一大把代码后,代码还是很长,可以直接到下面看结论:

    private void parseRoutes(Set<? extends Element> routeElements) throws IOException {
    if (CollectionUtils.isNotEmpty(routeElements)) {
    // prepare the type an so on.
    logger.info(">>> Found routes, size is " + routeElements.size() + " <<<");

    rootMap.clear();
    ///省略类型获取代码

    /*...省略构建'loadInto'方法描述,通过定义变量名,
    定义类型最后得出 MethodSpec.Builder
    void loadInto(Map<String, Class<? extends IRouteGroup>> atlas);
    */

    MethodSpec.Builder loadIntoMethodOfRootBuilder;

    // Follow a sequence, find out metas of group first, generate java file, then statistics them as root.
    for (Element element : routeElements) {
    //..省略相关代码,根据element类型,创建出对应的RouteMate实例,得到路由信息,
    //并且通过injectParamCollector 方法将Activity和Fragmentr内部的所有@AutoWired
    //注解 的信息放到MetaData的 paramsType 和injectConfig 中
    v
    //对 routeMate进行分类,在groupMap中填充对应数据
    categories(routeMeta);
    }

    /*...省略构建'loadInto'方法描述,通过定义变量名,
    定义类型最后得出 MethodSpec.Builder,主要用来构建providers索引。
    void loadInto(Map<String, RouteMeta> providers);
    */

    MethodSpec.Builder loadIntoMethodOfProviderBuilder;

    Map<String, List<RouteDoc>> docSource = new HashMap<>();

    //...
    if (MapUtils.isNotEmpty(rootMap)) {
    // Generate root meta by group name, it must be generated before root, then I can find out the class of group.
    for (Map.Entry<String, String> entry : rootMap.entrySet()) {
    loadIntoMethodOfRootBuilder.addStatement("routes.put($S, $T.class)", entry.getKey(), ClassName.get(PACKAGE_OF_GENERATE_FILE, entry.getValue()));
    }
    }

    // 2.Output route doc 写入json到doc文档中
    if (generateDoc) {
    docWriter.append(JSON.toJSONString(docSource, SerializerFeature.PrettyFormat));
    docWriter.flush();
    docWriter.close();
    }

    // Write provider into disk
    //3.生成对应的IProviderGroup 类代码文件 ARouter$$Providers$$[moduleName]
    String providerMapFileName = NAME_OF_PROVIDER + SEPARATOR + moduleName;
    JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
    TypeSpec.classBuilder(providerMapFileName)
    .addJavadoc(WARNING_TIPS)
    .addSuperinterface(ClassName.get(type_IProviderGroup))
    .addModifiers(PUBLIC)
    .addMethod(loadIntoMethodOfProviderBuilder.build())
    .build()
    ).build().writeTo(mFiler);

    logger.info(">>> Generated provider map, name is " + providerMapFileName + " <<<");

    // Write root meta into disk.
    //4. 生成对应的IRouteRoot 类代码文件 ARouter$$Root$$[moduleName]
    String rootFileName = NAME_OF_ROOT + SEPARATOR + moduleName;
    JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
    TypeSpec.classBuilder(rootFileName)
    .addJavadoc(WARNING_TIPS)
    .addSuperinterface(ClassName.get(elementUtils.getTypeElement(ITROUTE_ROOT)))
    .addModifiers(PUBLIC)
    .addMethod(loadIntoMethodOfRootBuilder.build())
    .build()
    ).build().writeTo(mFiler);

    logger.info(">>> Generated root, name is " + rootFileName + " <<<");
    }
    }

    代码很长,关键结果就是三点,

    1. com.alibaba.android.arouter.routes 包名下生成ARouter$$Group$$[GroupName] 类,包含路由组的所有路由信息。
    2. 在该包名下生成ARouter$$Root$$[moduleName]类,包含所有组的信息。
    3. 在该包名下生成ARouter$$Providers$$[moduleName] 类,包含所有Providers索引
    4. 在docs下,生成文件名为 "arouter-map-" + moduleName + ".json" 的文档。

    其他注解处理器说明

    剩下还有两个注解处理器 InterceptorProcessor 和 AutowiredProcessor。 生成代码逻辑大同小异,只是逻辑复杂度的区别,

    • AutowiredProcessor :处理@Autowired注解的参数,以参数所在对应的类分类,生成[classSimpleName]$$ARouter$$Autowired 代码文件,以在Activity或者Fragment跳转的时候自动从intent中获取数据,并对activity 和 fragment 对象赋值。
    • InterceptorProcessor: 处理@Interceptor注解。生成对应的ARouter$$Interceptors$$[modulename]代码文件,提供拦截器功能。

    值得一提的是,对于自定义类型的@AutoWiredARouter提供了 SerializationService进行自定义,用户只需要实现该解析类就行。

    小结

    这个模块完成了之前ARouter初始化所需要的所有代码的生成。 ARouter 源码和源码的分析到这里,已经成功走到了闭环,主要功能都已经清楚了。 之前没有写过AnotationProcessor相关的代码生成库。这次算是学习到了整个注解处理代码生成框架的使用方式。也了解了ARouter 代码生成的原理和方式。 业余时间自己也尝试写一个简单的代码生成功能试试看吧。下面一小节,再看看ARouter初始化注册的可选方案,arouter-register的源码。

    五. ARouter 自动注册插件:arouter-register

    代码在arouter-gradle-plugin 文件夹下面,

    刚开始查看这个模块的源码,部分代码老是飘红,找不到部分类,于是我修改了该模块build.gradle 中的gradle依赖版本号。从2.1.3 改成了 4.1.3。代码果然就正常了。 gradle插件调试可以更好地理解代码,参考网上的博客启动插件调试。

    注册转换器

    ARouter-register 插件通过 registerTransform api。添加了一个自定义Transform,对dex进行自定义处理。 直接看 该源码中的入口代码 PluginLaunch#apply

    def isApp = project.plugins.hasPlugin(AppPlugin)
    //only application module needs this plugin to generate register code
    if (isApp) {
    def android = project.extensions.getByType(AppExtension)
    def transformImpl = new RegisterTransform(project)
    android.registerTransform(transformImpl)
    }

    代码中调用了AppExtension.registerTransform方法注册了 RegisterTransform。查阅api文档可知,该方法的功能是:允许第三方方插件在将编译的类文件转换为 dex 文件之前对其进行操作。 那就知道了,该方法就是类文件转换中间的一道工序。

    扫描class文件和jar文件,保存路由类信息

    那工序做了什么呢?看看代码RegisterTransform#transform

    @Override
    void transform(Context context, Collection<TransformInput> inputs
    , Collection<TransformInput> referencedInputs
    , TransformOutputProvider outputProvider
    , boolean isIncremental) throws IOException, TransformException, InterruptedException {

    Logger.i('Start scan register info in jar file.')

    long startTime = System.currentTimeMillis()
    boolean leftSlash = File.separator == '/'

    inputs.each { TransformInput input ->

    //通过AMS 的 ClassVisistor 扫描所有的jar 文件,将所有扫描到的IRouteRoot IInterceptorGroup IInterceptorGroup类
    //都加到ScanSetting 的 classList中
    //详情可以看看 ScanClassVisitor
    //如果jar包是 LogisticsCenter.class,标记该类文件到 fileContainsInitClass

    input.jarInputs.each { JarInput jarInput ->
    //排除对于support库,以及m2repository 内第三方库的扫描。scan jar file to find classes
    if (ScanUtil.shouldProcessPreDexJar(src.absolutePath)) {
    //扫描
    ScanUtil.scanJar(src, dest)
    }
    //..省略重命名扫描过的jar包相关代码
    }
    // scan class files
    //..省略扫描class文件相关代码,方式类似扫描jar包
    }

    Logger.i('Scan finish, current cost time ' + (System.currentTimeMillis() - startTime) + "ms")
    //如果存在 LogisticsCenter.class 类文件
    //插入注册代码到 LogisticsCenter.class 中
    if (fileContainsInitClass) {
    registerList.each { ext ->
    //...省略一些判空和日志代码
    ///插入初始化代码
    RegisterCodeGenerator.insertInitCodeTo(ext)
    }
    }
    Logger.i("Generate code finish, current cost time: " + (System.currentTimeMillis() - startTime) + "ms")
    }

    从代码中可知,这一块代码有四个关键点。

    1. 通过ASM扫描了对应的jar 文件和class文件,并将扫描到的对应routes包下的类加入到ScanSetting 的classList属性 中
    2. 如果扫描到包含LogisticsCenter.class 类文件,将该文件记录到fileContainsInitClass 字段中。
    3. 扫描完成的文件重命名。
    4. 最后通过RegisterCodeGenerator.``*insertInitCodeTo*``(ext) 方法插入初始化代码到LogisticsCenter.class中。

    明白了扫描流程,我们再看看代码是怎么插入的.

    遍历包含入口class的jar文件,准备插入代码

    RegisterCodeGenerator.``*insertInitCodeTo*``(ext)代码中,先判断ScanSetting#classList是否为空,再判断文件是否是jar文件。如果判断都过了,最后走到 RegisterCodeGenerator#insertInitCodeIntoJarFile代码:

    private File insertInitCodeIntoJarFile(File jarFile) {
    //将包含 LogisticsCenter.class 的 jar文件,插入初始化代码
    //操作在 ***.jar.opt 临时文件做
    if (jarFile) {
    def optJar = new File(jarFile.getParent(), jarFile.name + ".opt")
    if (optJar.exists())
    optJar.delete()
    ///通过JarFile 和JarEntry
    def file = new JarFile(jarFile)
    Enumeration enumeration = file.entries()
    JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar))
    //遍历jar中的所有class,查询修改
    while (enumeration.hasMoreElements()) {
    JarEntry jarEntry = (JarEntry) enumeration.nextElement()
    String entryName = jarEntry.getName()
    ZipEntry zipEntry = new ZipEntry(entryName)
    InputStream inputStream = file.getInputStream(jarEntry)
    jarOutputStream.putNextEntry(zipEntry)
    ///如果是LogisticsCenter.class文件,调用referHackWhenInit 插入代码
    ///如果不是,不改变数据直接写入
    if (ScanSetting.GENERATE_TO_CLASS_FILE_NAME == entryName) {
    Logger.i('Insert init code to class >> ' + entryName)
    //!!!!重点代码,插入初始化代码
    def bytes = referHackWhenInit(inputStream)
    jarOutputStream.write(bytes)
    } else {
    jarOutputStream.write(IOUtils.toByteArray(inputStream))
    }
    inputStream.close()
    jarOutputStream.closeEntry()
    }
    jarOutputStream.close()
    file.close()

    if (jarFile.exists()) {
    jarFile.delete()
    }
    optJar.renameTo(jarFile)
    }
    return jarFile
    }

    从代码中可知,按步骤梳理:

    1. 创建临时文件,***.jar.opt
    2. 通过输入输出流,遍历jar文件下面的所有class,判断是否LogisticCenter.class
    3. LogisticCenter.class 调用 referHackWhenInit 方法插入初始化代码,写入到opt临时文件
    4. 对其他class 原封不动写入opt临时文件
    5. 删除原来的jar文件,将临时文件改名为原来的jar文件名

    这一步完成了对于jar文件的修改。插入了ARouter的自动注册初始化代码。

    插入初始化代码

    插入操作主要是找到 LogisticCenter 关键的插入代码在于RegisterCodeGenerator#referHackWhenInit

    private byte[] referHackWhenInit(InputStream inputStream) {
    ClassReader cr = new ClassReader(inputStream)
    ClassWriter cw = new ClassWriter(cr, 0)
    ClassVisitor cv = new MyClassVisitor(Opcodes.ASM5, cw)
    cr.accept(cv, ClassReader.EXPAND_FRAMES)
    return cw.toByteArray()
    }

    可以看到代码中利用了ams 框架的 ClassVisitor 来访问入口类。 再看MyClassVisistor 的visitMethod 实现:

    @Override
    MethodVisitor visitMethod(int access, String name, String desc,
    String signature, String[] exceptions) {
    MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions)
    //generate code into this method
    //针对loadRouterMap 方法进行处理
    if (name == ScanSetting.GENERATE_TO_METHOD_NAME) {
    mv = new RouteMethodVisitor(Opcodes.ASM5, mv)
    }
    return mv
    }

    可以看到,当asm访问的方法名为loadRouterMap时候,就通过RouteMethodVisitor 对齐进行操作,具体代码如下:

    class RouteMethodVisitor extends MethodVisitor {
    RouteMethodVisitor(int api, MethodVisitor mv) {
    super(api, mv)
    }
    @Override
    void visitInsn(int opcode) {
    //generate code before return
    if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)) {
    extension.classList.each { name ->
    name = name.replaceAll("/", ".")
    mv.visitLdcInsn(name)//类名
    // generate invoke register method into LogisticsCenter.loadRouterMap()
    mv.visitMethodInsn(Opcodes.INVOKESTATIC
    , ScanSetting.GENERATE_TO_CLASS_NAME
    , ScanSetting.REGISTER_METHOD_NAME
    , "(Ljava/lang/String;)V"
    , false)
    }
    }
    super.visitInsn(opcode)
    }
    @Override
    void visitMaxs(int maxStack, int maxLocals) {
    super.visitMaxs(maxStack + 4, maxLocals)
    }
    }

    代码中涉及到asm MethodVisistor的不少api,我查询,简单了解下,博客链接在此 解释一下用的的几个方法

    • visitLdcInsn:访问ldc指令,向栈中压入参数
    • visitMethodInsn:调用方法的指令,上面代码中,用来调用LogisticsCenter.register(String className)方法
    • visitMaxs: 用以确定类方法在执行时候的堆栈大小。

    小结

    对这里我们就十分清晰了插入初始化代码的路径。

    1. 首先是扫描所有的jarclass,找到对应的routes包名的类文件和 包含 LogisticsCenter.class 类的jar文件。类文件名依据类别存放在ScanSetting中。
    2. 找到LogisticsCenter.class ,对他进行字节码操作,插入初始化代码。

    整个register插件的流程就完成了

    六. ARouter idea 插件:arouter helper

    该插件源码在 arouter-idea-plugin 文件夹下面

    刚开始的时候编译老不成功,于是我修改了源码模块中 id "org.jetbrains.intellij" 插件的版本号,从0.3.12 改成了 0.7.3,果然就可以成功运行编译了。命令./gradlew :arouter-idea-plugin:buildPlugin 可以编译插件。

    插件效果

    先看看这个用法效果。 安装很简单,只需要在插件市场搜索ARouter Helper 安装就行。 在安装了该插件之后,相关ARouter.build()路由的java代码那一行,行号右侧会出现一个定位图标,如下图所示。 img 点击定位图标,就能自动跳转到路由的定义类。 img

    看完效果,我们直接看源码。 插件模块代码就一个类 com.alibaba.android.arouter.idea.extensions.NavigationLineMarker

    判断是否是ARouter.build

    NavigationLineMarker` 继承了`LineMarkerProviderDescriptor`,实现了`GutterIconNavigationHandler<PsiElement>
    • LineMarkerProviderDescriptor:将小图标(16x16 或更小)显示为行标记。也就是该插件识别到navigation方法之后,显示在行号右侧的标准图标。
    • GutterIconNavigationHandler 图标点击处理器,处理图标点击事件。

    看看行图标的获取代码:

    override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? {
    return if (isNavigationCall(element)) {
    LineMarkerInfo<PsiElement>(element, element.textRange, navigationOnIcon,
    Pass.UPDATE_ALL, null, this,
    GutterIconRenderer.Alignment.LEFT)
    } else {
    null
    }
    }
    1. 先是过isNavigationCall判断是否是ARouter.build() 方法。
    2. 然后配置LineMarkerInfo ,将this 配置为点击处理者

    所以我们先看isNavigationCall

    private fun isNavigationCall(psiElement: PsiElement): Boolean {
    if (psiElement is PsiCallExpression) {
    ///resolveMethod:解析对被调用方法的引用并返回该方法。如果解析失败,则为 null。
    val method = psiElement.resolveMethod() ?: return false
    val parent = method.parent

    if (method.name == "build" && parent is PsiClass) {
    if (isClassOfARouter(parent)) {
    return true
    }
    }
    }
    return false
    }

    该方法判断是否调用的是ARouter.build方法,如果是就返回true。展示行标记图标。

    点击定位图标跳转源码

    接下来再看点击图标的相关跳转 navigate方法:

    override fun navigate(e: MouseEvent?, psiElement: PsiElement?) {
    if (psiElement is PsiMethodCallExpression) {
    ///build方法参数列表
    val psiExpressionList = (psiElement as PsiMethodCallExpressionImpl).argumentList
    if (psiExpressionList.expressions.size == 1) {
    // Support `build(path)` only now.
    ///搜索所有带 @Route 注解的类,匹配注解的path路径有没有包含路径参数,包含的话就跳转
    val targetPath = psiExpressionList.expressions[0].text.replace("\"", "")
    val fullScope = GlobalSearchScope.allScope(psiElement.project)
    val routeAnnotationWrapper = AnnotatedMembersSearch.search(getAnnotationWrapper(psiElement, fullScope)
    ?: return, fullScope).findAll()
    val target = routeAnnotationWrapper.find {
    it.modifierList?.annotations?.map { it.findAttributeValue("path")?.text?.replace("\"", "") }?.contains(targetPath)
    ?: false
    }

    if (null != target) {
    // Redirect to target.
    NavigationItem::class.java.cast(target).navigate(true)
    return
    }
    }
    }

    notifyNotFound()
    }
    1. 获取build方法的参数,作为目标路径
    2. 搜索所有带 @Route 注解的类,匹配注解的path路径有没有包含目标路径参数
    3. 找到的目标文件直接跳转 NavigationItem::class.``*java*``.cast(target).navigate(true)
    收起阅读 »

    安卓进阶二: 这次我把ARouter源码搞清楚啦!

    随着面试和工作中多次遇到ARouter的使用问题,我决定把ARouter的源码从头到尾理一遍。 让我瞧瞧你到底有几斤几两,为啥大家在项目组件化中都用你做路由框架。前言在开发一个项目的时候,我们总是希望架构出的代码能够自由复用,**自由组装。**实现业务模块的范...
    继续阅读 »

    随着面试和工作中多次遇到ARouter的使用问题,我决定把ARouter的源码从头到尾理一遍。 让我瞧瞧你到底有几斤几两,为啥大家在项目组件化中都用你做路由框架。

    前言

    在开发一个项目的时候,我们总是希望架构出的代码能够自由复用,**自由组装。**实现业务模块的范围的单一职责。并且抽离出各种各样可重复使用的组件。 而在组件化过程中,路由是个绕不过去的坎。 当模块可以自由拼装拆除的时候,类的强引用方式变得不可取。因为有些类很可能在编译期间就找不到了。所以就需要有种方式能通直接过序列化的字符串来拉起对应的功能或者页面。也就是通常的路由功能。 ARouter也是接受度比较高的一个开源路由方案。 于是我写了这篇文章,对ARouter的源码原理进行一个全面的分析梳理。 看完文章过后,将能学习到Arouter 的使用原理,注解处理器的开发方式,gradle插件如何对于和class文件转dex进行中间处理。

    名词介绍

    apt:APT(Annotation Processing Tool)即注解处理器,是一种处理注解的工具,确切的说它是javac的一个工具,它用来在编译时扫描和处理注解。注解处理器以Java代码(或者编译过的字节码)作为输入,生成**.java文件**作为输出。ARouter中通过处理注解生成相关路由信息类 asm:ASM 库是一款基于 Java 字节码层面的代码分析和修改工具。ASM 可以直接生产二进制的 class 文件,也可以在类被加载入 JVM 之前动态修改类行为。在ARouter中用于arouter_register插件插入初始化代码。官方链接

    目录

    1. 项目模块结构
    2. ARouter路由使用分析
    3. ARouter初始化分析
    4. ARouter注解处理代码生成:arouter-compiler
    5. ARouter自动注册插件:arouter-register
    6. ARouter idea 插件:arouter helper
    7. 自动代码生成
    8. gradle插件

    一. 项目模块结构

    官方仓库 我们克隆github的ARouter源码,打开项目就是如下的项目结构图。

    模块说明
    app示例app模块
    module-javajava示例组件模块
    module-java-exportjava实例模块的服务数据模块,定义了一个示例的IProvider 服务接口和一些实体类
    module-kotlinkotlin示例组件模块
    arouter-annotation注解类以及相关数据实体类
    arouter-api主要api模块,提供ARouter类等路由方法
    arouter-compiler处理注解,生成相关代码
    arouter-gradle-plugingradle插件,jar包中添加自动注册代码,减少扫描dex文件开销
    arouter-idea-pluginidea插件,
    重点类简介
    • ARouter :api入口类
    • LogisticsCenter :路由逻辑中心维护所有路由图谱
    • Warehouse :保存所有路由映射,通过他找到所以字符串对应的路由信息。这些信息都是从解析注解标记自动生成。//todo 可能不正确
    • RouteType :路由类型,现在有: *ACTIVITY,SERVICE,PROVIDER,CONTENT_PROVIDER,BOARDCAST,METHOD,FRAGMENT,UNKNOWN*
    • RouteMeta:路由信息类,包含路由类型,路由目标类class,路由组group名等。

    二. ARouter 路由使用分析

    ARouter的接入和使用参考官方说明就可以了。

    接下来从常用Activity 跳转入手来了解路由导航处理

    从最常用的api入手,我们就能知道ARouter 最主要的运转原理,了解他是怎么支撑实现跳转这个我们最常用的功能的。 跳转Activity代码如下:

    ARouter.getInstance().build("/test/activity").navigation();

    这一句代码就完成了activity跳转

    要点步骤如下:

    1. 通过PathReplaceService 预处理路径,并从path:"/test/activity" 中抽取出 group: "test"
    2. path 和group 作为参数创建 Postcard 实例
    3. 调用 postcard#navigation ,最终导航到_ARouter#navigation
    4. 通过 group 和 path 从Warehouse.``*routes*获取具体路径信息RouteMeta,完善postcard。

    详细说明

    前面提到的一些用户自定义的 Service

    第一步抽取 group

    而不管是跳转Activity,获取Fragment还是获取Provider。 ARouter.getInstance().build("/test/activity")是 ARouter最核心的路由api。而这个build出来的,是Postcard类。 我们先看build代码的执行路径:

    protected Postcard build(String path) {
    if (TextUtils.isEmpty(path)) {
    throw new HandlerException(Consts.TAG + "Parameter is invalid!");
    } else {
    /// 用户自定义路径处理类。默认为空。 ARouter.getInstance().navigation 直接获取Provider后文分析
    PathReplaceService pService = ARouter.getInstance().navigation(PathReplaceService.class);
    if (null != pService) {
    path = pService.forString(path);
    }
    ///获取path中包含的 group 作为参数二
    return build(path, extractGroup(path), true);
    }
    }

    代码中可以看到,其中通过ARouter.``*getInstance*``().navigation(PathReplaceService.class)获取路径替换类,对路径进行了预处理操作(默认没有自定义实现类)。通过extractGroup方法从 path中获取了 group信息。

    private String extractGroup(String path) {
    if (TextUtils.isEmpty(path) || !path.startsWith("/")) {
    throw new HandlerException(Consts.TAG + "Extract the default group failed, the path must be start with '/' and contain more than 2 '/'!");
    }

    try {
    String defaultGroup = path.substring(1, path.indexOf("/", 1));
    if (TextUtils.isEmpty(defaultGroup)) {
    throw new HandlerException(Consts.TAG + "Extract the default group failed! There's nothing between 2 '/'!");
    } else {
    return defaultGroup;
    }
    } catch (Exception e) {
    logger.warning(Consts.TAG, "Failed to extract default group! " + e.getMessage());
    return null;
    }
    }

    extractGroup 源码可知,抽取group的时候对路由路径 "/test/activity" 做了校验:

    1. 一定要"/" 开头
    2. 至少要有两个"/"
    3. 第一个反斜杠后面的就是group

    所以path路径一定要是类似 的格式,或者多来几个"/"。

    第二步:创建Postcard实例

    很简单,直接new出来了

    return new Postcard(path, group);

    第三步:调用_ARouter#navigation

    这块代码是路由的安卓核心跳转代码 很长一大串:

    protected Object navigation(final Context context, final Postcard postcard, final int requestCode, final NavigationCallback callback) {
    ///1.自定义预处理代码
    PretreatmentService pretreatmentService = ARouter.getInstance().navigation(PretreatmentService.class);
    if (null != pretreatmentService && !pretreatmentService.onPretreatment(context, postcard)) {
    // 预处理拦截了 返回
    return null;
    }

    // 设置context
    postcard.setContext(null == context ? mContext : context);

    try {
    ///2.通过路由信息,找到对应的路由信息 RouteMeta ,根据路由类型 RouteType
    ///完善posrcard
    LogisticsCenter.completion(postcard);
    } catch (NoRouteFoundException ex) {
    ///... 省略异常日志和弹窗展示。以及相关回调方法
    ///值得一提的是走了 DegradeService 的自定义丢失回调
    }

    if (null != callback) {
    callback.onFound(postcard);
    }

    ///3.如果不是绿色通道,需要走拦截器:InterceptorServiceImpl
    if (!postcard.isGreenChannel()) { // It must be run in async thread, maybe interceptor cost too mush time made ANR.
    interceptorService.doInterceptions(postcard, new InterceptorCallback() {
    @Override
    public void onContinue(Postcard postcard) {
    ///4.继续导航方法
    _navigation(postcard, requestCode, callback);
    }
    @Override
    public void onInterrupt(Throwable exception) {
    ///省略拦截后的一些代码
    }
    });
    } else {
    ///4.继续导航方法
    return _navigation(postcard, requestCode, callback);
    }

    return null;
    }

    简单总结一下主要代码步骤:

    1. 如有自定义预处理导航逻辑,执行并检查拦截
    2. 通过path路径找到对应的routemeta路由信息,用该信息完善postcard对象(LogisticsCenter.completion方法中完成,细节后文分析)
    3. 如果不是绿色通道,需要走拦截器:InterceptorServiceImpl 。该拦截器服务类中完成拦截器一一执行。(2的源码细节可知,PROVIDERFRAGMENT类型是绿色通道)
    4. 继续导航方法,调用_navigation。

    看代码:

    private Object _navigation(final Postcard postcard, final int requestCode, final NavigationCallback callback) {
    final Context currentContext = postcard.getContext();

    switch (postcard.getType()) {
    case ACTIVITY:
    // Build intent
    final Intent intent = new Intent(currentContext, postcard.getDestination());
    //...省略完善intent代码
    // Navigation in main looper.
    runInMainThread(new Runnable() {
    @Override
    public void run() {
    startActivity(requestCode, currentContext, intent, postcard, callback);
    }
    });

    break;
    case PROVIDER:
    return postcard.getProvider();
    case BOARDCAST:
    case CONTENT_PROVIDER:
    case FRAGMENT:
    Class<?> fragmentMeta = postcard.getDestination();
    try {
    Object instance = fragmentMeta.getConstructor().newInstance();
    if (instance instanceof Fragment) {
    ((Fragment) instance).setArguments(postcard.getExtras());
    } else if (instance instanceof android.support.v4.app.Fragment) {
    ((android.support.v4.app.Fragment) instance).setArguments(postcard.getExtras());
    }

    return instance;
    } catch (Exception ex) {
    logger.error(Consts.TAG, "Fetch fragment instance error, " + TextUtils.formatStackTrace(ex.getStackTrace()));
    }
    case METHOD:
    case SERVICE:
    default:
    return null;
    }

    return null;
    }

    很明显,代码中注意对各种类型的路由做了处理。

    • *ACTIVITY:*新建Intent ,通过postcard信息,完善intentcontext.startActivity 或者 context.startActivityForResult
    • PROVIDER:postcard.getProvider() 获取provider实例(实例化代码在LogisticsCenter.completion
    • FRAGMENT,BOARDCAST,CONTENT_PROVIDER:routeMeta.getConstructor().newInstance() 通过路由信息实例化出实例,如果是Fragment的话,则另外再设置extras信息。
    • METHOD,SERVICE:返回空,啥也不做。说明该类型路由调用*navigation*没啥意义。

    看到这里,对于Activity 的路由跳转就很直观了,就是调用了startActivity 或者 startActivityForResult 方法,其他provider fragment等实例的获取也十分得清晰明了了,接下来讲讲上面提到的补全postcard关键代码。

    关键代码:LogisticsCenter.completion 分析

    完善postcard信息代码是通过LogisticsCenter.completion 方法完成的。现在来梳理一下这一块代码:

    /**
    * 通过RouteMate 完善 postcard
    * @param postcard Incomplete postcard, should complete by this method.
    */

    public synchronized static void completion(Postcard postcard) {
    //省略空判断
    RouteMeta routeMeta = Warehouse.routes.get(postcard.getPath());
    if (null == routeMeta) {
    // 如果路由的组group没有找到,直接抛异常
    if (!Warehouse.groupsIndex.containsKey(postcard.getGroup())) {
    throw new NoRouteFoundException(TAG + "There is no route match the path [" + postcard.getPath() + "], in group [" + postcard.getGroup() + "]");
    } else {
    //...省略一些日志代码
    // 1.动态添加组元素(从groupsIndex 中找到对应 IRouteGroup的生成类,再对组元素进行加载)
    addRouteGroupDynamic(postcard.getGroup(), null);
    completion(postcard); // Reload
    }
    } else {
    postcard.setDestination(routeMeta.getDestination());
    postcard.setType(routeMeta.getType());
    postcard.setPriority(routeMeta.getPriority());
    postcard.setExtra(routeMeta.getExtra());

    Uri rawUri = postcard.getUri();
    ///2.如果有uri 信息,解析uri相关参数。解析出AutoWired的参数的值
    if (null != rawUri) { // Try to set params into bundle.
    Map<String, String> resultMap = TextUtils.splitQueryParameters(rawUri);
    Map<String, Integer> paramsType = routeMeta.getParamsType();

    if (MapUtils.isNotEmpty(paramsType)) {
    // Set value by its type, just for params which annotation by @Param
    for (Map.Entry<String, Integer> params : paramsType.entrySet()) {
    setValue(postcard,
    params.getValue(),
    params.getKey(),
    resultMap.get(params.getKey()));
    }
    // Save params name which need auto inject.
    postcard.getExtras().putStringArray(ARouter.AUTO_INJECT, paramsType.keySet().toArray(new String[]{}));
    }
    // Save raw uri
    postcard.withString(ARouter.RAW_URI, rawUri.toString());
    }
    ///3.获取provider实例,如果初始获取,初始化该provider, 最后赋值给postcard
    switch (routeMeta.getType()) {
    case PROVIDER: // if the route is provider, should find its instance
    // Its provider, so it must implement IProvider
    Class<? extends IProvider> providerMeta = (Class<? extends IProvider>) routeMeta.getDestination();
    IProvider instance = Warehouse.providers.get(providerMeta);
    if (null == instance) { // There's no instance of this provider
    IProvider provider;
    try {
    provider = providerMeta.getConstructor().newInstance();
    provider.init(mContext);
    Warehouse.providers.put(providerMeta, provider);
    instance = provider;
    } catch (Exception e) {
    logger.error(TAG, "Init provider failed!", e);
    throw new HandlerException("Init provider failed!");
    }
    }
    postcard.setProvider(instance);
    postcard.greenChannel(); // Provider should skip all of interceptors
    break;
    case FRAGMENT:
    postcard.greenChannel(); // Fragment needn't interceptors
    default:
    break;
    }
    }
    }

    梳理一下这一块的代码,这一部分代码完善了postcard信息,总共分成了三个要点

    1. **获取路由信息:**如果路由信息找不到,通过组信息,重新动态添加组group内所有路由 ,调用*addRouteGroupDynamic* 。
    2. **获取uri内的参数:**如果postcard创建的时候有传递uri。解析uri里面所有需要AutoInject的参数。放置到postcard中。
    3. **获取Provider实例,配置是否不走拦截器的绿色通道:**不存在的Provider通过路由信息的 getDestination 反射创建实例并初始化,存在的直接获取。

    分析到这里。各种RouteType的跳转,实例获取都已经明了了。 现在剩下的问题是,WareHouse里面的路由信息数据是哪里来的?前面提到了动态添加组内路由的方法*addRouteGroupDynamic*。我们来看看:

    public synchronized static void addRouteGroupDynamic(String groupName, IRouteGroup group) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
    if (Warehouse.groupsIndex.containsKey(groupName)){
    // If this group is included, but it has not been loaded
    // load this group first, because dynamic route has high priority.
    Warehouse.groupsIndex.get(groupName).getConstructor().newInstance().loadInto(Warehouse.routes);
    Warehouse.groupsIndex.remove(groupName);
    }

    // cover old group.
    if (null != group) {
    group.loadInto(Warehouse.routes);
    }
    }

    可以看到Warehouse.routes 里面的所有路由信息,都是从 IRouteGroup.loadInto 加载出来的。而IRouteGroup 都存在Warehouse.``*groupsIndex* 中。 这时候新的问题出现了,Warehouse.``*groupsIndex*的数据是哪里来的呢? 下一节 ARouter 初始化分析就有答案。

    tips:提到的对外可自定义配置:

    简单罗列下源码中提到的可自定义配置的IProvider。便于使用的时候自定义。

    • PathReplaceService ///路由自定义处理替换
    • DegradeService //没有找到路由的通用回调
    • PretreatmentService ///navigation 预处理拦截

    通过Class 获取IProvider实例

    前面提到的PathReplaceService 等用户自定义类,都是通过ARouter.getInstance().navigation(clazz) 方式获取的。 这一块代码又是怎么从路由信息中获取到实例的呢?看看具体的navigation代码:

    protected <T> T navigation(Class<? extends T> service) {
    try {
    //1.通过类名从Provider路由信息索引中,获取路由信息,组建postcart
    Postcard postcard = LogisticsCenter.buildProvider(service.getName());

    // Compatible 1.0.5 compiler sdk.
    // Earlier versions did not use the fully qualified name to get the service
    if (null == postcard) {
    // No service, or this service in old version.
    //1.通过类名从Provider路由信息索引中,获取路由信息,组建postcart
    postcard = LogisticsCenter.buildProvider(service.getSimpleName());
    }

    if (null == postcard) {
    return null;
    }

    // Set application to postcard.
    postcard.setContext(mContext);
    //2.完善postcard ,该方法里面创建provider
    LogisticsCenter.completion(postcard);
    return (T) postcard.getProvider();
    } catch (NoRouteFoundException ex) {
    logger.warning(Consts.TAG, ex.getMessage());
    return null;
    }
    }

    很明显,主要代码就是 LogisticsCenter.``*buildProvider*``(service.getName()) ,获取到了postcard。后面完善postcard 和 获取provider实例的代码都已经在上文讲过。 所以我们就看*buildProvider* 方法:

    public static Postcard buildProvider(String serviceName) {
    RouteMeta meta = Warehouse.providersIndex.get(serviceName);

    if (null == meta) {
    return null;
    } else {
    return new Postcard(meta.getPath(), meta.getGroup());
    }
    }

    和路由组信息获取类似,Provider的路由信息从 Warehouse.``*providersIndex* 维护的映射表中获取。 所以*providersIndex*是专门用来给没有@Route 路由信息的Provider创建实例用的。这就是维护*providersIndex*的用途。 接下来的问题就转为了 *providersIndex* 里面的数据是哪里来的。

    小结

    路由跳转以及获取Provider等实例的原理可以简单总结下:

    1. 先是获取postcard,可能是直接通过路由路径和uri构建, 如"/test/activity1",也可能是通过Provider 类名从索引获取,如PathReplaceService.class.getName()
    2. 然后通过RouteMate完善 postcard。获取诸如类名信息,路由类型,provider实例等信息。
    3. 最后导航,根据路由类型作出跳转或者返回对应实例。

    关键点在于WareHouse 维护的路由图谱。

    三. ARouter初始化分析

    我们看下对用户提供的ARouter#init方法:

    public static void init(Application application) {
    if (!hasInit) {
    logger = _ARouter.logger;
    _ARouter.logger.info(Consts.TAG, "ARouter init start.");
    ///调用初始化代码
    hasInit = _ARouter.init(application);
    ///初始化完成后,加载拦截器服务,并初始化所有拦截器
    if (hasInit) {
    _ARouter.afterInit();
    }
    _ARouter.logger.info(Consts.TAG, "ARouter init over.");
    }
    }

    代码关键就两步,

    1. 初始化ARouter
    2. 获取拦截器服务实例初始化所有拦截器

    初始化ARouter

    init代码最终调用到了LogisticsCenter#init

    通过代码我们了解到了这么几个过程:

    1. 方式一 : ARouter-auto-register 插件加载路由表(如果有该插件),该方式详细分析见第五节。
    2. 方式二 :
    3. 在需要的时候扫描所有 dex文件,找到所有包名为com.alibaba.android.ARouter.routes的类,类名放到routerMap 集里面。
    4. 实例化上面找到的所有类,并通过这些集类加载对应的集映射索引到WareHouse中。

    很显然,com.alibaba.android.ARouter.routes 包名下面的类都是自动生成的路由表类。 通过搜索我们能找到样例代码中生成的该包名对象们: img module_java 生成的IRouteRoot 代码如下所示

    public class ARouter$$Root$$modulejava implements IRouteRoot {
    @Override
    public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
    routes.put("m2", ARouter$$Group$$m2.class);
    routes.put("module", ARouter$$Group$$module.class);
    routes.put("test", ARouter$$Group$$test.class);
    routes.put("yourservicegroupname", ARouter$$Group$$yourservicegroupname.class);
    }
    }

    这样我们就完全清楚了 WareHouse里面维护的所有路由信息是哪里来的了。追本溯源。接下来我们只需要知道 ARouter$$Root$$modulejava 等类,是啥时候怎么生成的。我们在下面一小节进行分析。初始化ARouter的过程,其实就是填充Warehouse *providersIndex**groupsIndex**interceptorsIndex*

    初始化后续:初始化所有拦截器

    初始化完成,看看初始化完成后的操作afterInit

    static void afterInit() {
    // Trigger interceptor init, use byName.
    interceptorService = (InterceptorService) ARouter.getInstance().build("/ARouter/service/interceptor").navigation();
    }

    这一块代码就是navigation 获取到了InterceptorService。上面也讲过,在执行navigation的时候,会调用IProviderinit方法。所以我们需要找到InterceptorService 的实现类,并看看他的init做了什么。项目中其实现类是InterceptorServiceImpl,找到init代码如下:

    @Override
    public void init(final Context context) {
    LogisticsCenter.executor.execute(new Runnable() {
    @Override
    public void run() {
    if (MapUtils.isNotEmpty(Warehouse.interceptorsIndex)) {
    for (Map.Entry<Integer, Class<? extends IInterceptor>> entry : Warehouse.interceptorsIndex.entrySet()) {
    Class<? extends IInterceptor> interceptorClass = entry.getValue();
    try {
    IInterceptor iInterceptor = interceptorClass.getConstructor().newInstance();
    iInterceptor.init(context);
    Warehouse.interceptors.add(iInterceptor);
    } catch (Exception ex) {
    throw new HandlerException(TAG + "ARouter init interceptor error! name = [" + interceptorClass.getName() + "], reason = [" + ex.getMessage() + "]");
    }
    }

    interceptorHasInit = true;
    logger.info(TAG, "ARouter interceptors init over.");
    synchronized (interceptorInitLock) {
    interceptorInitLock.notifyAll();
    }
    }
    }
    });
    }

    代码很明白的告诉我们,该初始化代码从拦截器路由信息索引里面加载并实例化了所有拦截器。然后通知等待的拦截器开始拦截。

    小结

    看完初始化代码之后,明白了WareHouse的数据来源,现在问题变成了com.alibaba.android.ARouter.routes 包名的代码何时生成。我们且看下回分解。

    收起阅读 »

    【带着问题学】协程到底是怎么切换线程的?

    前言之前对协程做了一个简单的介绍,回答了协程到底是什么的问题,感兴趣的同学可以了解下:【带着问题学】协程到底是什么?通过上文,我们了解了以下内容1.kotlin协程本质上对线程池的封装2.kotlin协程可以用同步方式写异步代码,自动实现对线程切换的管理这就引...
    继续阅读 »

    前言

    之前对协程做了一个简单的介绍,回答了协程到底是什么的问题,感兴趣的同学可以了解下:【带着问题学】协程到底是什么?
    通过上文,我们了解了以下内容
    1.kotlin协程本质上对线程池的封装
    2.kotlin协程可以用同步方式写异步代码,自动实现对线程切换的管理

    这就引出了本文的主要内容,kotlin协程到底是怎么切换线程的?
    具体内容如下:

    1. 前置知识

    1.1 CoroutineScope到底是什么?

    CoroutineScope即协程运行的作用域,它的源码很简单

    public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
    }

    可以看出CoroutineScope的代码很简单,主要作用是提供CoroutineContext,协程运行的上下文
    我们常见的实现有GlobalScope,LifecycleScope,ViewModelScope

    1.2 GlobalScopeViewModelScope有什么区别?

    public object GlobalScope : CoroutineScope {
    /**
    * 返回 [EmptyCoroutineContext].
    */

    override val coroutineContext: CoroutineContext
    get() = EmptyCoroutineContext
    }

    public val ViewModel.viewModelScope: CoroutineScope
    get() {
    val scope: CoroutineScope? = this.getTag(JOB_KEY)
    if (scope != null) {
    return scope
    }
    return setTagIfAbsent(
    JOB_KEY,
    CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    )
    }

    两者的代码都挺简单,从上面可以看出
    1.GlobalScope返回的为CoroutineContext的空实现
    2.ViewModelScope则往CoroutineContext中添加了JobDispatcher

    我们先来看一段简单的代码

    	fun testOne(){
    GlobalScope.launch {
    print("1:" + Thread.currentThread().name)
    delay(1000)
    print("2:" + Thread.currentThread().name)
    }
    }
    //打印结果为:DefaultDispatcher-worker-1
    fun testTwo(){
    viewModelScope.launch {
    print("1:" + Thread.currentThread().name)
    delay(1000)
    print("2:" + Thread.currentThread().name)
    }
    }
    //打印结果为: main

    上面两种Scope启动协程后,打印当前线程名是不同的,一个是线程池中的一个线程,一个则是主线程
    这是因为ViewModelScopeCoroutineContext中添加了Dispatchers.Main.immediate的原因

    我们可以得出结论:协程就是通过Dispatchers调度器来控制线程切换的

    1.3 什么是调度器?

    从使用上来讲,调度器就是我们使用的Dispatchers.Main,Dispatchers.DefaultDispatcher.IO
    从作用上来讲,调度器的作用是控制协程运行的线程
    从结构上来讲,Dispatchers的父类是ContinuationInterceptor,然后再继承于CoroutineContext
    它们的类结构关系如下:

    这也是为什么Dispatchers能加入到CoroutineContext中的原因,并且支持+操作符来完成增加

    1.4 什么是拦截器

    从命名上很容易看出,ContinuationInterceptor即协程拦截器,先看一下接口

    interface ContinuationInterceptor : CoroutineContext.Element {
    // ContinuationInterceptor 在 CoroutineContext 中的 Key
    companion object Key : CoroutineContext.Key<ContinuationInterceptor>
    /**
    * 拦截 continuation
    */

    fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T>

    //...
    }

    从上面可以提炼出两个信息
    1.拦截器的Key是单例的,因此当你添加多个拦截器时,生效的只会有一个
    2.我们都知道,Continuation在调用其Continuation#resumeWith()方法,会执行其suspend修饰的函数的代码块,如果我们提前拦截到,是不是可以做点其他事情?这就是调度器切换线程的原理

    上面我们已经介绍了是通过Dispatchers指定协程运行的线程,通过interceptContinuation在协程恢复前进行拦截,从而切换线程
    带着这些前置知识,我们一起来看下协程启动的具体流程,明确下协程切换线程源码具体实现

    2. 协程线程切换源码分析

    2.1 launch方法解析

    我们首先看一下协程是怎样启动的,传入了什么参数

    public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
    )
    : Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
    LazyStandaloneCoroutine(newContext, block) else
    StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
    }

    总共有3个参数:
    1.传入的协程上下文
    2.CoroutinStart启动器,是个枚举类,定义了不同的启动方法,默认是CoroutineStart.DEFAULT
    3.block就是我们传入的协程体,真正要执行的代码

    这段代码主要做了两件事:
    1.组合新的CoroutineContext
    2.再创建一个 Continuation

    2.1.1 组合新的CoroutineContext

    public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
    val combined = coroutineContext + context
    val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
    return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
    debug + Dispatchers.Default else debug
    }

    从上面可以提炼出以下信息:
    1.会将launch方法传入的contextCoroutineScope中的context组合起来
    2.如果combined中没有拦截器,会传入一个默认的拦截器,即Dispatchers.Default,这也解释了为什么我们没有传入拦截器时会有一个默认切换线程的效果

    2.1.2 创建一个Continuation

    val coroutine = if (start.isLazy)
    LazyStandaloneCoroutine(newContext, block) else
    StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)

    默认情况下,我们会创建一个StandloneCoroutine
    值得注意的是,这个coroutine其实是我们协程体的complete,即成功后的回调,而不是协程体本身
    然后调用coroutine.start,这表明协程开始启动了

    2.2 协程的启动

    public fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
    initParentJob()
    start(block, receiver, this)
    }

    接着调用CoroutineStartstart来启动协程,默认情况下调用的是CoroutineStart.Default

    经过层层调用,最后到达了:

    internal fun <R, T> (suspend (R) -> T).startCoroutineCancellable(receiver: R, completion: Continuation<T>) =
    runSafely(completion) {
    // 外面再包一层 Coroutine
    createCoroutineUnintercepted(receiver, completion)
    // 如果需要,做拦截处理
    .intercepted()
    // 调用 resumeWith 方法
    .resumeCancellableWith(Result.success(Unit))
    }

    这里就是协程启动的核心代码,虽然比较短,却包括3个步骤:
    1.创建协程体Continuation
    2.创建拦截 Continuation,即DispatchedContinuation
    3.执行DispatchedContinuation.resumeWith方法

    2.3 创建协程体Continuation

    调用createCoroutineUnintercepted,会把我们的协程体即suspend block转换成Continuation,它是SuspendLambda,继承自ContinuationImpl
    createCoroutineUnintercepted方法在源码中找不到具体实现,不过如果你把协程体代码反编译后就可以看到真正的实现
    详情可见:字节码反编译

    2.4 创建DispatchedContinuation

    public actual fun <T> Continuation<T>.intercepted(): Continuation<T> =
    (this as? ContinuationImpl)?.intercepted() ?: this

    //ContinuationImpl
    public fun intercepted(): Continuation<Any?> =
    intercepted
    ?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
    .also { intercepted = it }

    //CoroutineDispatcher
    public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
    DispatchedContinuation(this, continuation)

    从上可以提炼出以下信息
    1.interepted是个扩展方法,最后会调用到ContinuationImpl.intercepted方法
    2.在intercepted会利用CoroutineContext,获取当前的拦截器
    3.因为当前的拦截器是CoroutineDispatcher,因此最终会返回一个DispatchedContinuation,我们其实也是利用它实现线程切换的
    4.我们将协程体的Continuation传入DispatchedContinuation,这里其实用到了装饰器模式,实现功能的增强

    这里其实很明显了,通过DispatchedContinuation装饰原有协程,在DispatchedContinuation里通过调度器处理线程切换,不影响原有逻辑,实现功能的增强

    2.5 拦截处理

        //DispatchedContinuation
    inline fun resumeCancellableWith(
    result: Result<T>,
    noinline onCancellation: ((cause: Throwable) -> Unit)?
    ) {
    val state = result.toState(onCancellation)
    if (dispatcher.isDispatchNeeded(context)) {
    _state = state
    resumeMode = MODE_CANCELLABLE
    dispatcher.dispatch(context, this)
    } else {
    executeUnconfined(state, MODE_CANCELLABLE) {
    if (!resumeCancelled(state)) {
    resumeUndispatchedWith(result)
    }
    }
    }
    }

    上面说到了启动时会调用DispatchedContinuationresumeCancellableWith方法
    这里面做的事也很简单:
    1.如果需要切换线程,调用dispatcher.dispatcher方法,这里的dispatcher是通过CoroutineConext取出来的
    2.如果不需要切换线程,直接运行原有线程即可

    2.5.2 调度器的具体实现

    我们首先明确下,CoroutineDispatcher是通过CoroutineContext取出来的,这也是协程上下文作用的体现
    CoroutineDispater官方提供了四种实现:Dispatchers.Main,Dispatchers.IO,Dispatchers.Default,Dispatchers.Unconfined
    我们一起简单看下Dispatchers.Main的实现

    internal class HandlerContext private constructor(
    private val handler: Handler,
    private val name: String?,
    private val invokeImmediately: Boolean
    ) : HandlerDispatcher(), Delay {
    public constructor(
    handler: Handler,
    name: String? = null
    ) : this(handler, name, false)

    //...

    override fun dispatch(context: CoroutineContext, block: Runnable) {
    // 利用主线程的 Handler 执行任务
    handler.post(block)
    }
    }

    可以看到,其实就是用handler切换到了主线程
    如果用Dispatcers.IO也是一样的,只不过换成线程池切换了

    如上所示,其实就是一个装饰模式
    1.调用CoroutinDispatcher.dispatch方法切换线程
    2.切换完成后调用DispatchedTask.run方法,执行真正的协程体

    delay是怎样切换线程的?

    上面我们介绍了协程线程调度的基本原理与实现,下面我们来回答几个小问题
    我们知道delay函数会挂起,然后等待一段时间再恢复。
    可以想象,这里面应该也涉及到线程的切换,具体是怎么实现的呢?

    public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
    // if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
    if (timeMillis < Long.MAX_VALUE) {
    cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
    }
    }
    }

    internal val CoroutineContext.delay: Delay get() = get(ContinuationInterceptor) as? Delay ?: DefaultDelay

    Dealy的代码也很简单,从上面可以提炼出以下信息
    delay的切换也是通过拦截器来实现的,内置的拦截器同时也实现了Delay接口
    我们来看一个具体实现

    internal class HandlerContext private constructor(
    private val handler: Handler,
    private val name: String?,
    private val invokeImmediately: Boolean
    ) : HandlerDispatcher(), Delay {
    override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
    // 利用主线程的 Handler 延迟执行任务,将完成的 continuation 放在任务中执行
    val block = Runnable {
    with(continuation) { resumeUndispatched(Unit) }
    }
    handler.postDelayed(block, timeMillis.coerceAtMost(MAX_DELAY))
    continuation.invokeOnCancellation { handler.removeCallbacks(block) }
    }

    //..
    }

    1.可以看出,其实也是通过handler.postDelayed实现延时效果的
    2.时间到了之后,再通过resumeUndispatched方法恢复协程
    3.如果我们用的是Dispatcher.IO,效果也是一样的,不同的就是延时效果是通过切换线程实现的

    4. withContext是怎样切换线程的?

    我们在协程体内,可能通过withContext方法简单便捷的切换线程,用同步的方式写异步代码,这也是kotin协程的主要优势之一

        fun test(){
    viewModelScope.launch(Dispatchers.Main) {
    print("1:" + Thread.currentThread().name)
    withContext(Dispatchers.IO){
    delay(1000)
    print("2:" + Thread.currentThread().name)
    }
    print("3:" + Thread.currentThread().name)
    }
    }
    //1,2,3处分别输出main,DefaultDispatcher-worker-1,main

    可以看出这段代码做了一个切换线程然后再切换回来的操作,我们可以提出两个问题
    1.withContext是怎样切换线程的?
    2.withContext内的协程体结束后,线程怎样切换回到Dispatchers.Main?

    public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
    )
    : T {
    return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
    // 创建新的context
    val oldContext = uCont.context
    val newContext = oldContext + context
    ....
    //使用新的Dispatcher,覆盖外层
    val coroutine = DispatchedCoroutine(newContext, uCont)
    coroutine.initParentJob()
    //DispatchedCoroutine作为了complete传入
    block.startCoroutineCancellable(coroutine, coroutine)
    coroutine.getResult()
    }
    }

    private class DispatchedCoroutine<in T>(
    context: CoroutineContext,
    uCont: Continuation<T>
    ) : ScopeCoroutine<T>(context, uCont) {
    //在complete时会会回调
    override fun afterCompletion(state: Any?) {
    afterResume(state)
    }

    override fun afterResume(state: Any?) {
    //uCont就是父协程,context仍是老版context,因此可以切换回原来的线程上
    uCont.intercepted().resumeCancellableWith(recoverResult(state, uCont))
    }
    }

    这段代码其实也很简单,可以提炼出以下信息
    1.withContext其实就是一层Api封装,最后调用到了startCoroutineCancellable,这就跟launch后面的流程一样了,我们就不继续跟了
    2.传入的context会覆盖外层的拦截器并生成一个newContext,因此可以实现线程的切换
    3.DispatchedCoroutine作为complete传入协程体的创建函数中,因此协程体执行完成后会回调到afterCompletion
    4.DispatchedCoroutine中传入的uCont是父协程,它的拦截器仍是外层的拦截器,因此会切换回原来的线程中

    总结

    本文主要回答了kotlin协程到底是怎么切换线程的这个问题,并对源码进行了分析
    简单来讲主要包括以下步骤:
    1.向CoroutineContext添加Dispatcher,指定运行的协程
    2.在启动时将suspend block创建成Continuation,并调用intercepted生成DispatchedContinuation
    3.DispatchedContinuation就是对原有协程的装饰,在这里调用Dispatcher完成线程切换任务后,resume被装饰的协程,就会执行协程体内的代码了

    其实kotlin协程就是用装饰器模式实现线程切换的
    看起来似乎有不少代码,但是真正的思路其实还是挺简单的,这大概就是设计模式的作用吧
    如果本文对你有所帮助,欢迎点赞收藏~

    收起阅读 »

    你真的懂android通知消息吗?

    概览通知是 android 系统存在至今为止被变更最为频繁的 api 之一,android 4.1、4.4、5.0、7.0、8.0 都对通知做过比较大的改动。到了 8.0 通知功能趋于稳定,至今没有做过更大的改动。对一个 api 进行如此大的照顾那么这必然是个...
    继续阅读 »

    概览

    通知是 android 系统存在至今为止被变更最为频繁的 api 之一,android 4.1、4.4、5.0、7.0、8.0 都对通知做过比较大的改动。到了 8.0 通知功能趋于稳定,至今没有做过更大的改动。

    对一个 api 进行如此大的照顾那么这必然是个非常重要的 api 了。那么就跟随我一起揭开通知一点都不神秘的面纱吧。

    注:本文主要讲应用

    通知使用

    创建简单通知

    我们使用 NotificationCompat 来创建通知,使用 NotificationCompat 可以兼容所有的系统版本,不需要我们去手动兼容版本。

    创建通知分为两个步骤:

    • 创建渠道
    • 创建通知

    关于渠道

    创建渠道
    notificationManager.createNotificationChannel(channel)

    安卓 8.0 系统要求必须创建渠道才能展示通知,所以我们在 8.0 的系统版本中,必须添加创建渠道的方法。

    创建渠道不一定非要在展示通知的时候做,同一个渠道只需要被创建一次即可(多次亦可)。我们可以在我们即将展示通知的时候创建,可以再应用启动的时候创建,也可以在 activity 中创建。总之渠道创建非常灵活

    如果渠道已经存在我们仍然调用了创建渠道方法,那么什么也不会做,很安全

    下面代码是我们创建渠道的完整代码:

    private val channelName = "安安安安卓"
    private val channelId = "channelId"
    fun createNotificationChannel(context: Context): NotificationChannel? {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    val descriptionText = "渠道描述"
    val importance = NotificationManager.IMPORTANCE_DEFAULT
    val channel = NotificationChannel(channelId, channelName, importance).apply {
    description = descriptionText
    }
    val notificationManager: NotificationManager =
    context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    notificationManager.createNotificationChannel(channel)
    return channel
    }
    return null
    }
    渠道重要性设置

    需要注意,渠道的优先级和通知的优先级是不同的,注意区分

    val importance = NotificationManager.IMPORTANCE_DEFAULT
    val channel = NotificationChannel(channelId, channelName, importance)

    上面的代码创建了通知的重要程度,我们需要说明一下 NotificationChannel 的第三个参数,也就是渠道的重要程度,这个设置不同的值,用户收到通知后手机的展示包括声音、震动、是否弹出都会不同,下面看一下参数的四种设置(四个参数在不同手机的渠道展示不同):

    • IMPORTANCE_HIGH 收到通知发出提示语,并且会浮动提示用户(小米手机表示紧急)
    • IMPORTANCE_DEFAULT 收到通知发出提示语,不会浮动提示(小米手机表示高)
    • IMPORTANCE_LOW 收到通知不会发出声音,状态栏有小图标展示(小米手机表示中)
    • IMPORTANCE_MIN 根本看不到通知(所以你压根就别用就 ok 了),不过似乎可以用于禁用通知的场景(小米手机表示低)
    禁用某个渠道的通知方法

    我们使用创建渠道的方式实现禁用通知,如下:

    比如我们第一次创建渠道的时候代码如下:

    val importance = NotificationManager.IMPORTANCE_HIGH
    val channel = NotificationChannel(channelId, channelName, importance)

    这行代码会创建一个有声音提示、横幅展示(google 文档管这个叫偷窥模式 😄)的渠道。

    如果此时用户通过我们 app 内部的设置想不在收到我们这个渠道的通知,我们需要如下代码这样做:

    val importance = NotificationManager.IMPORTANCE_MIN
    val channel = NotificationChannel(channelId, channelName, importance)

    与上一处的代码的区别是把 IMPORTANCE_HIGH 改成了 IMPORTANCE_MIN,因此我们的渠道就变成了 低级别通知渠道,收到通知也无法展示,因此用户根本看不到通知,从而实现了通知禁用。

    还有一点需要注意,我们可以通过代码将一个高优先级的渠道设置为低优先级渠道,但是无法将低优先级渠道设置为高优先级渠道。

    关于通知

    创建通知

    通知大家都太熟悉,直接上代码,记得看注释

    private val channelName = "安安安安卓"
    private val channelId = "channelId"
    fun showNotification(context: Context) {
    val notification = NotificationCompat.Builder(context, channelName)//这里的渠道名就是你自己想展示通知对应的渠道分组
    .setSmallIcon(R.drawable.apple)//设置状态栏展示的通知样式
    .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.apple))//设置通知中的图标样式
    .setContentTitle("公众号")//设置通知标题
    .setContentText("安安安安卓")//设置通知正文
    .setChannelId(channelId)//设置通知渠道,这个渠道id必须是和我们创建渠道时候的id对应
    .setPriority(NotificationCompat.PRIORITY_DEFAULT).build()//设置通知优先级
    NotificationManagerCompat.from(context).notify(13, notification)
    }

    强调一下:展示通知之前一定要先创建渠道

    通知中的优先级

    设置方法:NotificationCompat.Builder.setPriority 通知优先级极容易跟渠道优先级混淆,一定要注意区分 通知优先级有以下几种:

    • PRIORITY_DEFAULT = 0;默认优先级
    • PRIORITY_LOW = -1; 低优先级
    • PRIORITY_MIN = -2;最低优先级
    • PRIORITY_HIGH = 1;高优先级
    • PRIORITY_MAX = 2;最高优先级

    这个参数主要是给我们的通知进行排序,重要的通知放在前面展示。这可以帮助我们第一时间找到最重要的通知进行处理,这很实用不是

    创建代码

    我们可以再创建 NotificationCompat.Builder 的时候加上如下调用就可以展示展开式通知:

     .setStyle(
    NotificationCompat.BigTextStyle()
    .bigText("本文由 公众号 \"安安安安卓\"作者原创,禁抄袭\n 北国风光," +
    "千里冰封,万里雪飘,望长城内外,惟余莽莽,大河上下,顿失滔滔,山舞银蛇,原驰蜡象,欲与天公试比高。" +
    "须晴日,看银装素裹,分外妖娆")
    )

    通知默认是展开式的,长按通知可以在短文本和长文本之间来回切换

    设置通知的点击事件

    如下代码实现一个可点击的通知栏

      fun showNotification(context: Context) {
    val intent = Intent(context,OnlyShowActivity::class.java).apply {
    flags=Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
    }
    val pendingIntent=PendingIntent.getActivity(context,0,intent,0)
    val notification = NotificationCompat.Builder(context, channelId)
    .setContentText("点击通知跳转的一个页面中")
    .setContentTitle("可点击通知")
    .setSmallIcon(R.drawable.apple)
    .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.apple))
    .setAutoCancel(true)//设置点击了通知,则通知自动消失
    .setContentIntent(pendingIntent)
    .build()
    NotificationManagerCompat.from(context).notify(++count, notification)
    }

    给通知栏设置按钮

    我们可以通过 addAction 给通知设置 action,同时可以指定一个 PendingIntent。

    fun showBtnNotification(context: Context) {
    val intent = Intent(context, OnlyShowActivity::class.java)
    intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
    val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
    val notification = NotificationCompat.Builder(context, channelId)
    .setSmallIcon(R.drawable.apple)
    .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.apple))
    .setContentText("安安安安卓,北国风光,千里冰封,万里雪飘")
    .setContentTitle("按钮通知")
    .addAction(R.drawable.person, "李白", pendingIntent)
    .addAction(R.drawable.apple, "杜甫", pendingIntent)
    .addAction(R.drawable.apple, "王维", pendingIntent)
    .setAutoCancel(true)
    .build()
    NotificationManagerCompat.from(context).notify(++count, notification)
    }

    设置进度条

     private val countdown = object : CountDownTimer(15 * 1000, 1000) {
    private val perdegree = 100 / 15
    var count = 0
    override fun onTick(millisUntilFinished: Long) {
    count++
    showNotification(count * perdegree)//更新进度
    }

    override fun onFinish() {
    showNotification(100)
    count = 0
    }
    }

    /**
    * 启动一个可动的进度条
    */

    fun start() {
    countdown.start()
    }

    private fun showNotification(progress: Int) {
    val builder = NotificationCompat.Builder(context, channelId)
    .setSmallIcon(R.drawable.apple)
    .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.person))
    .setColor(Color.GREEN)
    .setContentTitle("这是个进度标题")

    NotificationManagerCompat.from(context).apply {
    builder.setProgress(100, progress, false)
    builder.setContentText("下载进度 $progress%")
    notify(count, builder.build())
    }
    }

    设置自定义通知

    我们可以通过 RemoteViews 指定一个布局,通过 setCustomContentView 设置我们的自定义布局 代码:

     fun showNotification(context: Context){
    val remoteViews = RemoteViews(context.packageName, R.layout.item_notification)
    val notification = NotificationCompat.Builder(context, channelId)
    .setContentTitle("这个通知的布局是自定义的")
    .setContentText("安安安安卓")
    .setSmallIcon(R.drawable.apple)
    .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.person))
    .setCustomContentView(remoteViews)
    .build()
    NotificationManagerCompat.from(context).notify(count,notification)
    }

    我们的 xml 代码预览图:

    最终效果图:

    其它的知识点

    1. 从 android8.1 开始,应用一秒钟最多只能发出一次通知提示音,如果出现多条通知只有一条通知可以出发提示音
    2. 创建通知的几种样式:NotificationCompat.BigPictureStyle、NotificationCompat.BigTextStyle、NotificationCompat.DecoratedCustomViewStyle
    3. NotificationCompat.Builder.setGroup 方法可以创建一组通知
    4. NotificationManager.getNotificationChannel()或 NotificationManager.getNotificationChannels()两个方法可以获取通知的渠道,通过获取到的渠道可以获取此渠道是否开启声音、渠道通知的重要级别。我们可以据此提示用户打开相应的设置,下面代码展示了打开通知渠道的方法:
      Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS);
    intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
    intent.putExtra(Settings.EXTRA_CHANNEL_ID, myNotificationChannel.getId());
    startActivity(intent);

    1. 删除渠道的方法 deleteNotificationChannel()
    2. 我们可以调用渠道的 NotificationChannel.setShowBadge(false)方法关闭桌面图标圆点。这个其实很有用,比如当你用通知展示下载进度条的时候这条通知明显是不需要展示圆点的,还有大部分的本地提醒类通知都不会希望显示圆点的,用这个方法正好
    3. NotificationCompat.Builder.setNumber 方法可以设置桌面图标的红点数量
    4. 通过 NotificationCompat.DecoratedCustomViewStyle 样式可以给内容区域创建自定义布局。样式就是通知展示图标在左,我们自定义的布局在右,不过这个感觉就没啥用了。
    5. 自定义布局的通知也可以给内部的 view 添加点击跳转事件,实现方法如下代码:
     val remoteViews = RemoteViews(context.packageName, R.layout.item_notification)
    val intent = Intent(context,OnlyShowActivity::class.java)
    val pendingIntent = PendingIntent.getActivity(context,0,intent,0)
    remoteViews.setOnClickPendingIntent(R.id.iv_pendingintent_click,pendingIntent)
    收起阅读 »

    如何规范的进行 Android 组件化开发?

    正文进行组件化开发有一段时间了,不久后就要开始一个新项目了,为此整理了目前项目中使用的组件化开发规范,方便在下一个项目上使用。本文的重点是介绍规范和项目架构,仅提供示例代码举例,目前不打算提供示例Demo。如果你还不了解什么是组件化以及如何进行组件化开发的话,...
    继续阅读 »
    正文

    进行组件化开发有一段时间了,不久后就要开始一个新项目了,为此整理了目前项目中使用的组件化开发规范,方便在下一个项目上使用。本文的重点是介绍规范和项目架构,仅提供示例代码举例,目前不打算提供示例Demo。如果你还不了解什么是组件化以及如何进行组件化开发的话,建议请先看下面这个文章。

    定义

    组件是 Android 项目中一个相对独立的功能模块,是一个抽象的概念,module 是 Android 项目中一个相对独立的代码模块。

    在组件化开发的早期,一个组件就只有一个 module,导致很多代码和资源都会下沉到 common 中,导致 common 会变得很臃肿。有的文章说,专门建立一个 module 来存放通用资源,我感觉这样是治标不治本,直到后面看到微信Android模块化架构重构实践这篇文章,里面的"模块的一般组织方式"一节提到一个模块应该有多个工程,然后开始在项目对 module 进行拆分。

    一般情况下,一个组件有两个 module,一个轻量级的 module 提供外部组件需要和本组件进行交互的接口方法及一些外部组件需要的资源,另一个重量级的 module 完成组件实际的功能和实现轻量级 module 定义的接口方法。

    module 的命名规范请参考module名,在下文中使用 module-api 代表轻量级的 module,使用 module-impl 代表重量级的 module

    common组件

    common 是一个特殊的组件,不区分轻量级和重量级,它是项目中最底层的组件,基本上所有的其他组件都会依赖 common 组件,common 中放项目中所有弱业务逻辑的代码和解决循环依赖的代码和资源。

    一个完整的项目的架构如下:

    弱业务逻辑代码

    何为弱业务逻辑代码?简单来说,就是有一定的业务逻辑,但是这个业务逻辑对于项目中其他组件来说通用的。

    比如在 common 组件集成网络请求库,创建一个 HttpTool 工具类,负责初始化网络请求框架,定义网络请求方法,实现组装通用请求参数以及处理全局通用错误等,对于其他组件直接通过这个工具类进行网络请求就可以了。

    比如定义界面基类,处理一些通用业务逻辑,比如接入统计分析框架。

    解决循环依赖的代码和资源

    何为解决循环依赖的代码和资源?比如说 module-a-api 有一个类 Cmodule-b-api 中有一个类 D,在 module-a-api 中需要使用 D,在 module-b-api 中需要使用 C,这样就会造成 module-a-api 需要依赖 module-b-api,而 module-b-api 也会依赖 module-a-api,这就造成了循环依赖,在 Android Studio 中会编译失败。

    解决循环依赖的方案就是将 C 和 D 其中的一个,或者两个都下沉到 common 组件中,因为 module-a-api 和 module-b-api 都依赖了 common 组件,至于具体下沉几个,这个根据具体的情况而定,但是原则是下沉到 common 组件的东西越少越好。

    上面的举的例子是代码,资源文件同样也可能会有这个问题。

    module代码结构

    一个组件通常含有一个或多个功能点,比如对于用户组件,它有关于界面、意见反馈、修改账户密码等功能点,在 module 中为每一个功能点创建一个路径,里面放实现该功能的代码,比如 ActivityDialog 、Adapter 等。除此之外,为了集中管理组件内部资源和统一编码习惯,特地将一部分的通用功能路径固定下来。这些路径包括 apiprovidertool 等。

    一般情况下 module 的代码架构如下图:

    api

    该路径下放 module 内部使用到的所有网络请求路径和方法,一般使用一个类就够了,比如:UserApi

    object UserApi {

    /**
    * 获取个人中心数据
    */
    fun getPersonCenterData(): GetRequest {
    return HttpTool.get(ApiVersion.v1_0_0 + "authUser/myCenter")
    }
    }

    复制代码

    ApiVersion 全局管理目前项目中使用的所有 api 版本,应当定义在 common 组件的 api 路径下:

    object ApiVersion {
    const val v1_0_0 = "v1/"
    const val v1_1_0 = "v1_1/"
    const val v1_2_2 = "v1_2_2/"
    }

    复制代码

    entity

    该路径下放 module 内部使用到的所有实体类(网络请求返回的数据类)。

    对于所有从服务器获取的字段,全部定义在构造函数中,且实体类应当实现 Parcelable ,并使用 @Parcelize 注解。对于客户端使用而自己定义的字段,基本上定义为普通成员字段,并使用 @IgnoredOnParcel 注解,如果需要在界面间传递客户端定义的字段,可以将该字段定义在构造函数中,但是必须注明是客户端定义的字段。

    示例如下:

    @Parcelize
    class ProductEntity(
    // 产品名称
    var name: String = "",

    // 产品图标
    var icon: String = "",

    // 产品数量(客户端定义字段)
    var count: Int = 0
    ) : Parcelable {
    // 用户是否选择本产品
    @IgnoredOnParcel
    var isSelected = false
    }

    复制代码

    其中 name 和 icon 是从服务器获取的字段,而 count 和 isSelected 是客户端自己定义的字段。

    event

    该路径下放 module 内部使用的事件相关类。对于使用了 EventBus 及类似框架的项目,放事件类,对于使用了 LiveEventBus 的项目,里面只需要放一个类就好,比如:UserEvent

    object UserEvent {

    /**
    * 更新用户信息成功事件
    */
    val updateUserInfoSuccessEvent: LiveEventBus.Event<Unit>
    get() = LiveEventBus.get("user_update_user_info_success")
    }

    复制代码

    注意:对于使用 LiveEventBus 的项目,事件的命名必须用组件名作为前缀,防止事件名重复。

    route

    该路径下放 module 内部所使用到的界面路径和跳转方法,一般使用一个类就够了,比如:UserRoute

    object UserRoute {
    // 关于界面
    const val ABOUT = "/user/about"
    // 常见问题(H5)
    private const val FAQ = "FAQ/"

    /**
    * 跳转至关于界面
    */
    fun toAbout(): RouteNavigation {
    return RouteNavigation(ABOUT)
    }

    /**
    * 跳转至常见问题(H5)
    */
    fun toFAQ(): RouteNavigation? {
    return RouteUtil.getServiceProvider(IH5Service::class.java)
    ?.toH5Activity(FAQ)
    }
    }

    复制代码

    注意:对于组件内部会跳转的H5界面链接也应当写在路由类中。

    provider

    该路径下放对外部 module 提供的服务,一般使用一个类就够了。在 module-api 中是一个接口类,在 module-impl 中是该接口类的实现类。

    目前采用 ARouter 作为组件化的框架,为了解耦,对其进行了封装,封装示例代码如下:

    typealias Route = com.alibaba.android.arouter.facade.annotation.Route

    object RouteUtil {

    fun <T> getServiceProvider(service: Class<out T>): T? {
    return ARouter.getInstance().navigation(service)
    }
    }

    class RouteNavigation(path: String) {

    private val postcard = ARouter.getInstance().build(path)

    fun param(key: String, value: Int): RouteNavigation {
    postcard.withInt(key, value)
    return this
    }
    ...
    }

    复制代码

    示例

    这里介绍如何在外部 module 和 user-impl 跳转至用户组件中的关于界面。

    准备工作

    在 user-impl 中创建路由类,编写关于界面的路由和服务路由及跳转至关于界面方法:

    object UserRoute {
    // 关于界面
    const val ABOUT = "/user/about"
    // 用户组件服务
    const val USER_SERVICE = "/user/service"

    /**
    * 跳转至关于界面
    */
    fun toAbout(): RouteNavigation {
    return RouteNavigation(ABOUT)
    }
    }

    复制代码

    在关于界面使用路由:

    @Route(path = UserRoute.ABOUT)
    class AboutActivity : MyBaseActivity() {
    ...
    }

    复制代码

    在 user-api 中定义跳转界面方法:

    interface IUserService : IServiceProvider {

    /**
    * 跳转至关于界面
    */
    fun toAbout(): RouteNavigation
    }

    复制代码

    在 user-impl 中实现跳转界面方法:

    @Route(path = UserRoute.USER_SERVICE)
    class UserServiceImpl : IUserService {

    override fun toAbout(): RouteNavigation {
    return UserRoute.toAbout()
    }
    }

    复制代码
    界面跳转

    在 user-impl 中可以直接跳转到关于界面:

    UserRoute.toAbout().navigation(this)

    复制代码

    假设 module-a 需要跳转到关于界面,那么先在 module-a 中配置依赖:

    dependencies {
    ...
    implementation project(':user-api')
    }

    复制代码

    在 module-a 中使用 provider 跳转到关于界面:

    RouteUtil.getServiceProvider(IUserService::class.java)
    ?.toAbout()
    ?.navigation(this)

    复制代码
    module依赖关系

    此时各个 module 的依赖关系如下:

    common:基础库、第三方库
    user-api:common
    user-impl:common、user-api
    module-a:common、user-api
    App壳:common、user-api、user-impl、module-a、...

    复制代码

    tool

    该路径下放 module 内部使用的工具方法,一般一个类就够了,比如:UserTool

    object UserTool {

    /**
    * 该用户是否是会员
    * @param gradeId 会员等级id
    */
    fun isMembership(gradeId: Int): Boolean {
    return gradeId > 0
    }
    }

    复制代码

    cache

    该路径下放 module 使用的缓存方法,一般一个类就够了,比如:UserCache

    object UserCache {

    // 搜索历史记录列表
    var searchHistoryList: ArrayList<String>
    get() {
    val cacheStr = CacheTool.userCache.getString(SEARCH_HISTORY_LIST)
    return if (cacheStr == null) {
    ArrayList()
    } else {
    JsonUtil.parseArray(cacheStr, String::class.java) ?: ArrayList()
    }
    }
    set(value) {
    CacheTool.userCache.put(SEARCH_HISTORY_LIST, JsonUtil.toJson(value))
    }

    // 搜索历史记录列表
    private const val SEARCH_HISTORY_LIST = "user_search_history_list"
    }

    复制代码

    注意:

    1. 缓存Key的命名必须用组件名作为前缀,防止缓存Key重复。
    2. CacheTool.userCache 并不是指用户组件的缓存,而是用户的缓存,即当前登录账号的缓存,每个账号会单独存一份数据,相互之间没有干扰。与之对应的是 CacheTool.globalCache,全局缓存,所有的账号会共用一份数据。

    两种module的区别

    module-api 中放的都是外部组件需要的,或者说外部组件和 module-impl 都需要的,其他的都应当放在 module-impl 中,对于外部组件需要的但是能通过 provider 方式提供的,都应当把具体的实现放在 module-impl 中,module-api 中只是放一个接口方法。

    下表列举项目开发中哪些东西能否放 module-api 中:

    类型能否放 module-api备注
    功能界面(Activity、Fragment、Dialog)不能通过 provider 方式提供使用
    基类界面部分能外部 module 需要使用的可以,其他的放 module-impl 中
    adapter部分能外部 module 需要使用的可以,其他的放 module-impl 中
    provider部分能只能放接口类,实现类放 module-impl 中
    tool部分能外部 module 需要使用的可以,其他的放 module-impl 中
    api、route、cache不能通过 provider 方式提供使用
    entity部分能外部 module 需要使用的可以,其他的放 module-impl 中
    event部分能对使用 EventBus 及类似框架的项目,外部组件需要的可以,其他还是放 module-impl 中
    对于使用了 LiveEventBus 的项目不能,通过 provider 方式提供使用
    资源文件和资源变量部分能需要在 xml 文件中使用的可以, 其他的通过 provider 方式提供使用

    注意:如果仅在 module-impl 中存在工具类,则该工具类命名为 xxTool。如果 module-api 和 module-impl 都存在工具类,则 module-api 中的命名为 xxToolmodule-impl 中的命名为 xxTool2

    组件单独调试

    在开发过程中,为了查看运行效果,需要运行整个App,比较麻烦,而且可能依赖的其他组件也在开发中,App可能运行不到当前开发的组件。为此可以采用组件单独调试的模式进行开发,减少其他组件的干扰,等开发完成后再切换回 library 的模式。

    在组件单独调试模式下,可以增加一些额外的代码来方便开发和调试,比如新增一个入口 Actvity,作为组件单独运行时的第一个界面。

    示例

    这里介绍在 user-impl 中进行组件单独调试。

    在项目根目录下的 gradle.properties 文件中新增变量 isDebugModule,通过该变量控制是否进行组件单独调试:

    # 组件单独调试开关,为ture时进行组件单独调试
    isDebugModule = false

    复制代码

    在 user-impl 的 build.gradle 的顶部增加以下代码来控制 user-impl 在 Applicaton 和 Library 之间进行切换:

    if (isDebugModule.toBoolean()) {
    apply plugin: 'com.android.application'
    } else {
    apply plugin: 'com.android.library'
    }

    复制代码

    在 user-impl 的 src/main 的目录下创建两个文件夹 release 和 debugrelease 中放 library 模式下的 AndroidManifest.xmldebug 放 application 模式下的 AndroidManifest.xml、代码和资源,如下图所示:

    在 user-impl 的 build.gradle 中配置上面的创建的代码和资源路径:

    android {
    ...
    sourceSets {
    if (isDebugModule.toBoolean()) {
    main.manifest.srcFile 'src/main/debug/AndroidManifest.xml'
    main.java.srcDirs += 'src/main/debug'
    main.res.srcDirs += 'src/main/debug'
    } else {
    main.manifest.srcFile 'src/main/release/AndroidManifest.xml'
    }
    }
    }

    复制代码

    注意:完成上述配置后,在 library 模式下,debug 中的代码和资源不会合并到项目中。

    最后在 user-impl 的 build.gradle 中配置 applicationId

    android {
    defaultConfig {
    if (isDebugModule.toBoolean()) {
    applicationId "cc.tarylorzhang.demo"
    }
    ...
    }
    }

    复制代码

    注意:如果碰到65536的问题,在 user-impl 的 build.gradle 中新增以下配置:

    android {
    defaultConfig {
    ...
    if (isDebugModule.toBoolean()) {
    multiDexEnabled true
    }
    }
    }

    复制代码

    以上工作都完成后,将 isDebugModule 的值改为 true,则可以开始单独调试用户组件。

    命名规范

    module名

    组件名如果是单个单词的,直接使用该单词 + api 或 impl 的后缀作为 module 名,如果是多个单词的,多个单词小写使用 - 字符作为连接符,然后在其基础上加 api 或 impl 的后缀作为 module 名。

    示例

    用户组件(User),它的 module 名为 user-api 和 user-impl;会员卡组件(MembershipCard),它的 module 名为 membership-card-api 和 membership-card-impl

    包名

    在应用的 applicationId 的基础上增加组件名后缀作为组件基础包名。

    在代码中的包名 module-api 和 module-impl 都直接使用基础包名即可,但是在 Android 中项目 AndroidManifest.xml 文件中的 package 不能重复,否则编译不通过。所以 module-impl 中的 package 使用基础包名,而 module-impl 中的 package 使用基础包名 + api 后缀。

    package 重复的时候,会报 Type package.BuildConfig is defined multiple times 的错误。

    示例

    应用的 applicationId 为 cc.taylorzhang.demo,对于用户组件(user),组件基础包名为 cc.taylorzhang.demo.user,则实际包名如下表:

    代码中的包名AndroidManifest.xml中的包名
    user-apicc.taylorzhang.demo.usercc.taylorzhang.demo.userapi
    user-implcc.taylorzhang.demo.usercc.taylorzhang.demo.user

    对于多单词的会员卡组件(MembershipCard),其组件基础包名为 cc.taylorzhang.demo.membershipcard

    资源文件和资源变量

    所有的资源文件:布局文件、图片等全部要增加组件名作为前缀,所有的资源变量:字符串、颜色等也全部要增加组件名作为前缀,防止资源名重复。

    示例

    • 用户组件(User),关于界面布局文件命名为:user_activity_about.xml
    • 用户组件(User),关于界面标题字符串命名为:user_about_title
    • 会员卡组件(MembershipCard),会员卡详情界面布局文件,文件名为:membership_card_activity_detail
    • 会员卡组件(MembershipCard),会员卡详情界面标题字符串,文件名为:membership_card_detail_title

    类名

    对于类名没必要增加前缀,比如 UserAboutActivity,因为对资源文件和资源变量增加前缀主要是为了避免重复定义资源导致资源被覆盖的问题,而上面的包名命名规范已经避免了类重复的问题,直接命名 AboutActivity 即可。

    全局管理App环境

    App 环境一般分为开发、测试和生产环境,不同环境下使用的网络请求地址大概率是不一样的,甚至一些UI都不一样,在打包的时候手动修改很容易有遗漏,产生不必要的 BUG。应当使用 buildConfigField 在打包的时候将当前环境写入 App 中,在代码中根据读取环境变量,根据不同的环境执行不同的操作。

    示例

    准备工作

    在 App 壳 的 build.gradle 中给每个buildType 都配置 APP_ENV

    android {
    ...
    buildTypes {
    debug {
    buildConfigField "String", "APP_ENV", '\"dev\"'
    ...
    }
    release {
    buildConfigField "String", "APP_ENV", '\"release\"'
    ...
    }
    ctest {
    initWith release

    buildConfigField "String", "APP_ENV", '\"test\"'
    matchingFallbacks = ['release']
    }
    }
    }

    复制代码

    注意:测试环境的 buildType 不能使用 test 作为名字,Android Studio 会报 ERROR: BuildType names cannot start with 'test',这里在 test 前增加了一个 c

    在 common 的 tool 路径下创建一个App环境工具类:

    object AppEnvTool {

    /** 开发环境 */
    const val APP_ENV_DEV = "dev"
    /** 测试环境 */
    const val APP_ENV_TEST = "test"
    /** 生产环境 */
    const val APP_ENV_RELEASE = "release"

    /** 当前App环境,默认为开发环境 */
    private var curAppEnv = APP_ENV_DEV

    fun init(env: String) {
    curAppEnv = env
    }

    /** 当前是否处于开发环境 */
    val isDev: Boolean
    get() = curAppEnv == APP_ENV_DEV

    /** 当前是否处于测试环境 */
    val isTest: Boolean
    get() = curAppEnv == APP_ENV_TEST

    /** 当前是否处于生产环境 */
    val isRelease: Boolean
    get() = curAppEnv == APP_ENV_RELEASE

    }

    复制代码

    在 Application 中初始化App环境工具类:

    class DemoApplication : Application() {

    override fun onCreate() {
    super.onCreate()

    // 初始化App环境工具类
    AppEnvTool.init(BuildConfig.APP_ENV)
    ...
    }
    }

    复制代码

    使用App环境工具类

    这里介绍根据App环境使用不同的网络请求地址:

    object CommonApi {

    // api开发环境地址
    private const val API_DEV_URL = "https://demodev.taylorzhang.cc/api/"
    // api测试环境地址
    private const val API_TEST_URL = "https://demotest.taylorzhang.cc/api/"
    // api生产环境地址
    private const val API_RELEASE_URL = "https://demo.taylorzhang.cc/api/"
    // api地址
    val API_URL = getUrlByEnv(API_DEV_URL, API_TEST_URL, API_RELEASE_URL)

    // H5开发环境地址
    private const val H5_DEV_URL = "https://demodev.taylorzhang.cc/m/"
    // H5测试环境地址
    private const val H5_TEST_URL = "https://demotest.taylorzhang.cc/m/"
    // H5生产环境地址
    private const val H5_RELEASE_URL = "https://demo.taylorzhang.cc/m/"
    // H5地址
    val H5_URL = getUrlByEnv(H5_DEV_URL, H5_TEST_URL, H5_RELEASE_URL)

    private fun getUrlByEnv(devUrl: String, testUrl: String, releaseUrl: String): String {
    return when {
    AppEnvTool.isDev -> devUrl
    AppEnvTool.isTest -> testUrl
    else -> releaseUrl
    }
    }
    }

    复制代码

    打包

    通过不同的命令打包,打出对应的App环境包:

    # 打开发环境包
    ./gradlew clean assembleDebug

    # 打测试环境包
    ./gradlew clean assembleCtest

    # 打生产环境包
    ./gradlew clean assembleRelease

    复制代码

    全局管理版本信息

    项目中的 module 变多之后,如果要修改第三方库和App使用的SDK版本是一件很蛋疼的事情。应当建立一个配置文件进行管理,其他地方使用配置文件中设置的版本。

    示例

    在项目根目录下创建一个配置文件 config.gradle,里面放版本信息:

    ext {
    compile_sdk_version = 28
    min_sdk_version = 17
    target_sdk_version = 28

    arouter_compiler_version = '1.2.2'
    }

    复制代码

    在项目根目录下的 build.gradle 文件中的最上方使用以下代码引入配置文件:

    apply from: "config.gradle"

    复制代码

    创建 module 后,修改该 module 中的 build.gradle 文件,将 SDK 版本默认值换成配置文件中的变量,按需添加第三方依赖,并使用 $ + 配置文件中的变量作为第三方库的版本:

    android {
    ...
    compileSdkVersion compile_sdk_version

    defaultConfig {
    ...
    minSdkVersion min_sdk_version
    targetSdkVersion target_sdk_version
    }
    }

    dependencies {
    ...
    kapt "com.alibaba:arouter-compiler:$arouter_compiler_version"
    }

    复制代码

    混淆

    混淆文件不应该在 App 壳中集中定义,应当在每个 module 中各自定义自己的混淆。

    示例

    这里介绍配置 user-impl 的混淆,先在 user-impl 的 build.gradle 中配置消费者混淆文件:

    android {
    defaultConfig {
    ...
    consumerProguardFiles 'proguard-rules.pro'
    }
    }

    复制代码

    在 proguard-rules.pro 文件中写入该 module 的混淆:

    # 实体类
    -keepclassmembers class cc.taylorzhang.demo.user.entity.** { *; }

    复制代码

    总结

    组件化开发应当遵守"高内聚,低耦合"的原则,尽量少的对外暴露细节。如果用一句话来总结的话,就是代码和资源能放 module-impl 里面的就都放在 module-impl,因为代码隔离问题实在不能放 module-impl 里面的才放 module-api,最后因为涉及到循环依赖问题的才往 common 中放。

    收起阅读 »

    okhttp文件上传失败,居然是Android Studio背锅?太难了~

    1、前言本案例是我本人遇到的真实案例,因查找原因的过程一度让我崩溃,我相信不少人也遇到过相同的问题,故将其记录下来,希望对大家有帮助,本案例使用RxHttp 2.6.4 + OkHttp 4.9.1版本,当然,如果你使用Retrofit等其它基于OkHttp封...
    继续阅读 »

    1、前言

    本案例是我本人遇到的真实案例,因查找原因的过程一度让我崩溃,我相信不少人也遇到过相同的问题,故将其记录下来,希望对大家有帮助,本案例使用RxHttp 2.6.4 + OkHttp 4.9.1版本,当然,如果你使用Retrofit等其它基于OkHttp封装的框架,且用到监听上传进度功能,那么很大概率你也会遇到这个问题,请耐心看完,如果你想直接看到结果,划到文章末尾即可。

    2、问题描述

    事情是这样的,有一段文件上传的代码,如下:

    fun uploadFiles(fileList: List<File>) {
    RxHttp.postForm("/server/...")
    .add("key", "value")
    .addFiles("files", fileList)
    .upload {
    //上传进度回调
    }
    .asString()
    .subscribe({
    //成功回调
    }, {
    //失败回调
    })
    }

    这段代码在写完后很长一段时间内都是ok的,突然有一天,执行这段代码居然报错了,日志如下:

    image.png 这个异常是100%出现的,很熟悉的异常,具体原因就是,数据流被关闭了,但依然往里面写数据,来看看最后抛异常的地方,如下:

    image.png 可以看到,方法里面第一行代码就判断数据流是否已关闭,是的话,抛出异常。

    注:如果你是RxHttp使用者,正在尝试这段代码,发现没问题,也不要惊讶,因为这需要在Android Studio特定场景下执行才会出现,而且是相对高频使用的场景,请待我一步步揭晓答案

    3、一探究竟

    本着出现问题,先定位到自己代码的原则,打开ProgressRequestBody类76行看看,如下:

    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    private BufferedSink bufferedSink;
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    if (bufferedSink == null) {
    bufferedSink = Okio.buffer(sink(sink));
    }
    requestBody.writeTo(bufferedSink); //这里是76行
    bufferedSink.flush();
    }
    }

    ProgressRequestBody继承了okhttp3.RequestBody类,作用是监听上传进度;显然最后执行到这里时,数据流已经被关闭了,从日志里可以看到,最后一次调用ProgressRequestBody#writeTo(BufferedSink)方法的地方在CallServerInterceptor拦截器的59行,打开看看

    class CallServerInterceptor(private val forWebSocket: Boolean) : Interceptor {

    //省略相关代码
    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
    //省略相关代码
    if (responseBuilder == null) {
    if (requestBody.isDuplex()) {
    exchange.flushRequest()
    val bufferedRequestBody = exchange.createRequestBody(request, true).buffer()
    requestBody.writeTo(bufferedRequestBody)
    } else {
    val bufferedRequestBody = exchange.createRequestBody(request, false).buffer()
    requestBody.writeTo(bufferedRequestBody) //这里是59行
    bufferedRequestBody.close() //数据写完,将数据流关闭
    }
    }
    }
    }

    熟悉OkHttp原理的同学应该知道,CallServerInterceptor拦截器是okhttp拦截器链的最后一个拦截器,将客户端数据写出到服务端,就是在这里实现的,也就是59行,那问题就来了,数据都还没写出去,数据流怎么就关闭了呢?这令我百思不得其解,毫无头绪。

    于是乎,我做了很多无用功,如:重新检查代码,看看是否有手动关闭数据流的地方,显然没有找到;接着,实在没有办法,代码回滚,回滚到最初写这段代码的版本,我满怀期待的以为,这下应该没问题了,可尝试过后,依旧报java.lang.IllegalStateException: closed,成年人的崩溃就在这一瞬间,我陷入了绝境,已经消耗5个小时在这个问题上,此时已晚上23:30,看来又是一个不眠夜。

    question1.jpeg

    习惯告诉我,一个问题很久没查出来,可以先放弃,好吧,拔手机关电脑,洗澡睡觉。

    半小时后,我躺在床上,很难受,于是我拿出手机,打开app,再试了试上传功能,惊奇的发现,可以了,上传成功了,这。。。。一脸懵逼,我找谁说理去,虽然没问题了,但问题没找到,作为一名初级程序员,这我无法接受。

    精神的力量把我从床上扶了起来,再次打开电脑,连上手机,这次,果然有了新的收获,也一下子刷新了我的世界观;当我再次打开app,尝试上传文件时,一样的错误出现在我眼前,What??? 刚才还好好的,连上电脑就不行了?

    question2.jpeg

    ok,我彻底没脾气了,拔掉手机,重启app,再试,没问题了,再次连上电脑,再试,问题又出来了。。

    此时,我的心态有了些许的好转,毕竟有了新的调查方向,我再次查看错误日志,发现了一个很奇怪的地方,如下:

    image.png

    com.android.tools.profiler.agent.okhttp.OkHttp3Interceptor是从哪冒出来的?在我的认知里,OkHttp3是没有这个拦截器的,为了验证我的认知,再次查看okhttp3源码,如下:

    image.png

    确定是没有添加这个拦截器的,仔细看日志发现,OkHttp3InterceptorCallServerInterceptor、ConnectInterceptor之间执行的,那就只有一个解释,OkHttp3Interceptor是通过addNetworkInterceptor方法添加,现在就好办了,全局搜索addNetworkInterceptor就知道是谁添加的,哪里添加的,很可惜,未找到调用此方法的源码,似乎又陷入了绝境。

    question.jpeg

    那就只能开启调试,看看OkHttp3Interceptor是否在OkHttpClient对象的networkInterceptors网络拦截器列表里,一调试,果然有发现,如下:

    image.png 调试点击下一步,神奇的事情就发生了,如下:

    image.png

    这怎么解释?networkInterceptors.size始终是0,interceptors.size是如何加1变为5的?再来看看,加的1是什么,如下:

    image.png

    很熟悉,就是我们之前提到的OkHttp3Interceptor,这是如何做到的?只有一个解释,OkHttpClient#networkInterceptors()方法被字节码插桩技术插入了新的代码,为了验证我的想法,我做了以下实验:

    image.png

    image.png

    可以看到,我直接new了一个OkHttpClient对象,啥也没配置,调用networkInterceptors()方法,就获取了OkHttp3Interceptor拦截器,但OkHttpClient对象里的networkInterceptors列表中是没有这个拦截器的,这就证实了我的想法。

    那现在的问题就是,OkHttp3Interceptor是谁注入的?跟文件上传失败是否有直接的关系?

    OkHttp3Interceptor是谁注入的?

    先来探索第一个问题,通过OkHttp3Interceptor类的包名class com.android.tools.profiler.agent.okhttp,我有以下3点猜测

    • 包名有com.android.tools,应该跟 Android 官方有关系

    • 包名有agent,又是拦截器,应该跟网络代理,也就是网络监控有关

    • 最后一点,也是最重要的,包名有profiler,这让我联想到了Android Studio(以下简称AS)里Profiler网络分析器

    果然,在Google的源码中,真找到了OkHttp3Interceptor类,看看相关代码:

    public final class OkHttp3Interceptor implements Interceptor {

    //省略相关代码
    @Override
    public Response intercept(Interceptor.Chain chain) throws IOException {
    Request request = chain.request();
    HttpConnectionTracker tracker = null;
    try {
    tracker = trackRequest(request); //1、追踪请求体
    } catch (Exception ex) {
    StudioLog.e("Could not track an OkHttp3 request", ex);
    }
    Response response;
    try {
    response = chain.proceed(request);
    } catch (IOException ex) {

    }
    try {
    if (tracker != null) {
    response = trackResponse(tracker, response); //2、追踪响应体
    }
    } catch (Exception ex) {
    StudioLog.e("Could not track an OkHttp3 response", ex);
    }
    return response;
    }

    可以确定它就是一个网络监控器,但它是不是AS的网络监听器,我却还持怀疑态度,因为我这个项目没开启Profiler分析器,但我最近在开发room数据库相关功能,开启了数据分析器Database Inspector,难道跟这个有关?我尝试关掉Database Inspector,并且重启app,再次尝试文件上传,居然成功了,是真的成功了,你能信?我也不信,于是,再次开启Database Inspector,再次尝试文件上传,失败了,异常跟之前的一模一样;接着,我关闭Database Inspector,并且打开Profiler分析器,再次尝试文件上传,一样失败了。

    我想到这里,基本可以认定OkHttp3Interceptor就是Profiler里面的网络监控器,但也好像缺乏直接证据,于是,我尝试改了下ProgressRequestBody类,如下:

    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    private BufferedSink bufferedSink;

    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    //如果调用方是OkHttp3Interceptor,不写请求体,直接返回
    if (sink.toString().contains(
    "com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker"))
    return;
    if (bufferedSink == null) {
    bufferedSink = Okio.buffer(sink(sink));
    }
    requestBody.writeTo(bufferedSink);
    bufferedSink.flush();
    }
    }

    以上代码,仅仅加了一句if语句,这条语句可以判断当前调用方是不是OkHttp3Interceptor,是的话,不写请求体,直接返回;如果OkHttp3Interceptor就是Profiler里的网络监控器,那么此时Profiler里应该是看不到请求体的,也就是看不到请求参数,如下:

    image.png

    可以看到,Profiler里的网络监控器,没有监控到请求参数。

    这就证实了OkHttp3Interceptor的确是Profiler里的网络监控器,也就是AS动态注入的。

    OkHttp3Interceptor 与文件上传是否有直接的关系?

    通过上面的案例分析,显然是有直接关系的,当你未打开Database InspectorProfiler时,文件上传一切正常。

    OkHttp3Interceptor是如何影响文件上传的?

    回到正题,OkHttp3Interceptor是如何影响文件上传的?这个就需要继续分析OkHttp3Interceptor的源码,来看看追踪请求体的代码:

    public final class OkHttp3Interceptor implements Interceptor {

    private HttpConnectionTracker trackRequest(Request request) throws IOException {
    StackTraceElement[] callstack =
    OkHttpUtils.getCallstack(request.getClass().getPackage().getName());
    HttpConnectionTracker tracker =
    HttpTracker.trackConnection(request.url().toString(), callstack);
    tracker.trackRequest(request.method(), toMultimap(request.headers()));
    if (request.body() != null) {
    OutputStream outputStream =
    tracker.trackRequestBody(OkHttpUtils.createNullOutputStream());
    BufferedSink bufferedSink = Okio.buffer(Okio.sink(outputStream));
    request.body().writeTo(bufferedSink); // 1、将请求体写入到BufferedSink中
    bufferedSink.close(); // 2、关闭BufferedSink
    }
    return tracker;
    }

    }

    想到这里问题就很清楚了,上面备注的第一代码中request.body(),拿到的就是ProgressRequestBody对象,随后调用其writeTo(BufferedSink)方法,传入BufferedSink对象,方法执行完,就将BufferedSink对象关闭了,然而,ProgressRequestBody里却将BufferedSink声明为成员变量,并且为空时才会赋值,这就导致后续CallServerInterceptor调用其writeTo(BufferedSink)方法时,使用的还是上一个已关闭的BufferedSink对象,此时再往里面写数据,自然就java.lang.IllegalStateException: closed异常了。

    4、如何解决

    知道了具体的原因,就好解决,将ProgressRequestBody里面的BufferedSink对象改为局部变量即可,如下:

    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    BufferedSink bufferedSink = Okio.buffer(sink(sink));
    requestBody.writeTo(bufferedSink);
    bufferedSink.colse();
    }
    }

    改完后,开启Profiler里的网络监控器,再次尝试文件上传,ok成功了,但又有一个新的问题,ProgressRequestBody是用于监听上传进度的,OkHttp3InterceptorCallServerInterceptor先后调用了其writeTo(BufferedSink)方法,这就会导致请求体写两次,也就是进度监听会收到两遍,而我们真正需要的是CallServerInterceptor调用的那次,咋整?好办,我们前面就判断过调用方是否OkHttp3Interceptor

    于是,做出如下更改:

    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    //如果调用方是OkHttp3Interceptor,直接写请求体,不再通过包装类来处理请求进度
    if (sink.toString().contains(
    "com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker")) {
    requestBody.writeTo(bufferedSink);
    } else {
    BufferedSink bufferedSink = Okio.buffer(sink(sink));
    requestBody.writeTo(bufferedSink);
    bufferedSink.colse();
    }
    }
    }

    你以为这样就完了?相信很多人都会用到com.squareup.okhttp3:logging-interceptor日志拦截器,当你添加该日志拦截器后,再次上传文件,会发现,进度回调又执行了两遍,为啥?因为该日志拦截器,也会调用ProgressRequestBody#writeTo(BufferedSink)方法,看看代码:

    //省略部分代码
    class HttpLoggingInterceptor @JvmOverloads constructor(
    private val logger: Logger = Logger.DEFAULT
    ) : Interceptor {

    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
    val request = chain.request()
    val requestBody = request.body

    if (logHeaders) {
    if (!logBody || requestBody == null) {
    logger.log("--> END ${request.method}")
    } else if (bodyHasUnknownEncoding(request.headers)) {
    logger.log("--> END ${request.method} (encoded body omitted)")
    } else if (requestBody.isDuplex()) {
    logger.log("--> END ${request.method} (duplex request body omitted)")
    } else if (requestBody.isOneShot()) {
    logger.log("--> END ${request.method} (one-shot body omitted)")
    } else {
    val buffer = Buffer()
    //1、这里调用了RequestBody的writeTo方法,并传入了Buffer对象
    requestBody.writeTo(buffer)
    }
    }

    val response: Response
    try {
    response = chain.proceed(request)
    } catch (e: Exception) {
    throw e
    }
    return response
    }

    }

    可以看到,HttpLoggingInterceptor内部也会调用RequestBody#writeTo方法,并传入Buffer对象,到这,我们就好办了,在ProgressRequestBody类增加一个Buffer的判断逻辑即可,如下:

    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    //如果调用方是OkHttp3Interceptor,或者传入的是Buffer对象,直接写请求体,不再通过包装类来处理请求进度
    if (sink instanceof Buffer
    || sink.toString().contains(
    "com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker")) {
    requestBody.writeTo(bufferedSink);
    } else {
    BufferedSink bufferedSink = Okio.buffer(sink(sink));
    requestBody.writeTo(bufferedSink);
    bufferedSink.colse();
    }
    }
    }

    这样就完了?也不见得,如果后续又遇到什么拦截器调用其writeTo方法,还是会出现进度回调执行两遍的情况,只能在遇到这种情况时,加入对应的判断逻辑

    到这,也许有人会问,为啥不直接判断调用方是不是CallServerInterceptor,是的话监听进度回调,否则,直接写入请求体。想法很好,也是可行的,如下:

    public class ProgressRequestBody extends RequestBody {

    //省略相关代码
    @Override
    public void writeTo(BufferedSink sink) throws IOException {
    //如果调用方是CallServerInterceptor,监听上传进度
    if (sink.toString().contains("RequestBodySink(okhttp3.internal")) {
    BufferedSink bufferedSink = Okio.buffer(sink(sink));
    requestBody.writeTo(bufferedSink);
    bufferedSink.colse();
    } else {
    requestBody.writeTo(bufferedSink);
    }
    }
    }

    但是该方案有个致命的缺陷,如果okhttp未来版本更改了目录结构,ProgressRequestBody类就完全失效。

    两个方案就由大家自己去选择,这里给出ProgressRequestBody完整源码,需要自取

    5、小结

    本案例上传失败的直接原因就是在AS开启了Database Inspector数据库分析器或Profiler网络监控器时,AS就会通过字节码插桩技术,对OkHttpClient#networkInterceptors()方法注入新的字节码,使其多返回一个com.android.tools.profiler.agent.okhttp.OkHttp3Interceptor拦截器(用于监听网络),该拦截器会调用ProgressRequestBody#writeTo(BufferedSink)方法,并传入BufferedSink对象,writeTo方法执行完毕后,立即将BufferedSink对象关闭,在随后的CallServerInterceptor拦截又调用ProgressRequestBody#writeTo(BufferedSink)方法往已关闭的BufferedSink对象写数据,最终导致java.lang.IllegalStateException: closed异常。

    收起阅读 »

    iOS逆向必须了解的logos语法

    一、概述Logos语法其实是CydiaSubstruct框架提供的一组宏定义。便于开发者使用宏进行HOOK操作。语法简单,功能强大且稳定,它是跨平台的。[logos] http://iphonedevwiki.net/index.php/Logos二...
    继续阅读 »

    一、概述

    Logos语法其实是CydiaSubstruct框架提供的一组宏定义。便于开发者使用宏进行HOOK操作。语法简单,功能强大且稳定,它是跨平台的。[logos] 

    http://iphonedevwiki.net/index.php/Logos

    二、logos语法

    logos语法分为3类。

    2.1、Block level

    这一类型的指令会开辟一个代码块,以%end结束。

    %group

    用来将代码分组。开发中hook代码会很多,这样方便管理Logos代码。所有的group都必须初始化,否则编译报错。


    #import <UIKit/UIKit.h>

    %group group1

    %hook RichTextView

    - (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
    //hook后要处理的方式1
    return %orig;
    }

    %end

    %end


    %group group2

    %hook RichTextView

    - (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
    //hook后要处理的方式2
    return %orig;
    }

    %end

    %end

    %group group3

    %hook RichTextView

    - (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
    //hook后要处理的方式3
    return %orig;
    }

    %end

    %end

    //使用group要配合ctor
    %ctor {
    //[[UIDevice currentDevice] systemVersion].doubleValue 可以用来判断版本或其它逻辑。
    if ([[UIDevice currentDevice] systemVersion].doubleValue >= 11.0) {
    //这里group3会覆盖group1,不会执行group1逻辑。
    %init(group1)%init(group3);
    } else {
    %init(group2);
    }
    }

  • group初始化在%ctor中,需要%init初始化。
  • 所有group必须初始化,否则编译报错。
  • 在一个逻辑中同时初始化多个group,后面的会覆盖前面的。
  • 在不添加group的情况下,默认有个_ungrouped组,会自动初始化。

  • Begin a hook group with the name Groupname. Groups cannot be inside another [%group](https://iphonedev.wiki/index.php/Logos#.25group "Logos") block. All ungrouped hooks are in the implicit "_ungrouped" group. The _ungrouped group is initialized for you if there are no other groups. You can use the %initdirective to initialize it manually. Other groups must be initialized with the %init(Groupname) directive

    %hook

    HOOK某个类里面的某个方法。

    %hook RichTextView

    - (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
    //hook后要处理的方式1
    return %orig;
    }

    %end

    %hook后面需要跟需要hook的类名。

    %new
    为某个类添加新方法,在%hook 和 %end 中使用。

    %hook RichTextView

    %new
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

    }

    %end

    %subclass

    %subclass Classname: Superclass <Protocol list>

    运行时创建子类,只能包含方法或者关联属性,不能包含属性。可以通过%c创建类实例。

    #import <UIKit/UIKit.h>

    @interface MyObject

    - (void)setSomeValue:(id)value;

    @end

    %subclass MyObject : NSObject

    - (id)init {
    self = %orig;
    [self setSomeValue:@"value"];
    return self;
    }

    %new
    - (id)someValue {
    return objc_getAssociatedObject(self, @selector(someValue));
    }

    %new
    - (void)setSomeValue:(id)value {
    objc_setAssociatedObject(self, @selector(someValue), value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }

    %end

    %property

    %property (nonatomic|assign|retain|copy|weak|strong|getter|setter) Type name;

    subclass或者hook的类添加属性。必须在 %subclass 或%hook中。

    %property(nonatomic,assign) NSInteger age;

    %end

    与其它命令配对出现。

    2.2、Top level

    TopLevel指令不放在BlockLevel中。

    %config

    %config(Key=Value);

    logos设置标记。

    Configuration Flags

    keyvaluesnotes
    generatorMobileSubstrate生成的代码使用MobileSubstrate hook
    generatorinternal生成的代码只使用OC runtime方法hook
    warningsnone忽略所有警告
    warningsdefault没有致命的警告
    warningserror使所有警告报错
    dumpyamlYAML格式转储内部解析树

    %config(generator=internal);
    %config(warnings=error);
    %config(dump=yaml);

    %hookf

    hook函数,类似fishhook
    语法

    %hookf(rtype, symbolName, args...) { … }
    • rtype:返回值。
    • symbolName:原函数地址。
    • args...:参数。
      示例
    FILE *fopen(const char *path, const char *mode);
    %hookf(FILE *, fopen, const char *path, const char *mode) {
    NSLog(@"Hey, we're hooking fopen to deny relative paths!");
    if (path[0] != '/') {
    return NULL;
    }
    return %orig;
    }

    %ctor

    构造函数,用于确定加载那个组。和%init结合用。

    %dtor

    析构,做一些收尾工作。比如应用挂起的时候。

    2.3、Function level

    这一块的指令就放在方法中

    %init

    用来初始化某个组。

    %class

    %class Class;

    %class已经废弃了,不建议使用。

    %c

    类似getClass函数,获得一个类对象。一般用于调用类方法。

    //只是为了声明编译通过
    @interface MainViewController

    + (void)HP_classMethod;

    @end


    %hook MainViewController

    %new
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    //方式一
    // [self.class HP_classMethod];
    //方式二
    // [NSClassFromString(@"MainViewController") HP_classMethod];
    //方式三
    [%c(MainViewController) HP_classMethod];
    }

    %new
    + (void)HP_classMethod {
    NSLog(@"HP_classMethod");
    }

    %end
    • %c 中没有引号。

    %orig

    保持原有的方法实现,如果原来的方法有返回值和参数,那么可以传递参数和接收返回值。

    %hook RichTextView

    - (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
    //传递参数&接收返回值。
    BOOL result1 = %orig(arg1,arg2,arg3,arg4);
    BOOL result2 = %orig;
    return %orig;
    }

    %end


    • %orig可以接收返回值。
    • 可以传递参数,不传就是传递该方法的默认参数。

    %log

    能够输出日志,输出方法调用的详细信息 。

    %hook RichTextView

    - (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
    %log;
    return %orig;
    }

    %end
    输出:

     WeChat[11309:6708938] -[<RichTextView: 0x15c4c9720> setPrefixContent:(null) TargetContent:钱已经借给你了。 TargetParserString:<contentMD5>0399062cd62208dad884224feae2aa30</contentMD5><fontsize>20.287109</fontsize><fwidth>240.000000</fwidth><parser><type>1</type><range>{0, 8}</range><info><![CDATA[<style><range>{0, 8}</range><rect>{{0, 0}, {135, 21}}</rect></style>]]></info></parser> SuffixContent:(null)]

    能够输出详细的日志信息,包含类、方法、参数、以及控件信息等详细信息。


    总结

    • logos语法其实是CydiaSubstruct框架提供的一组宏定义。
    • 语法
      • %hook%end勾住某个类,在一个代码块中直接写需要勾住的方法。
      • %group%end用于分组。
        • 每一组都需要%ctor()函数构造。
        • 通过%init(组名称)进行初始化。
      • %log输出方法的详细信息(调用者、方法名、方法参数)
      • %orig调用原始方法。可以传递参数,接收返回值。
      • %c类似getClass函数,获取一个类对象。
      • %new添加某个方法。
    • .xm文件代表该文件支持OCC/C++语法。
    • 编译该文件时需要导入头文件以便编译通过。.xm文件不参与代码的执行,编译后生成的.mm文件参与代码的执行。


    作者:HotPotCat
    链接:https://www.jianshu.com/p/70151c602886






    收起阅读 »

    iOS逆向需要了解的OpenSSH

    这两个源比较有名,推荐添加。然后在搜索中搜索apt.bingner.com。当然直接添加这个源也可以。电脑(客户端)请求连接手机(ip:22)。手机(服务端)将公钥发送给mac电脑通过收到的公钥加密登录密码。手机利用私钥解密登录密码,返回是否登录成功。上面的登...
    继续阅读 »

    一、OpenSSH概述


    1.1 SSH

    SSH是一种网络协议,用于计算机之间的加密登录。
    1995年,芬兰学者Tatu Ylonen设计了SSH协议,将登录信息全部加密,成为互联网安全的一个基本解决方案,迅速在全世界获得推广,目前已经成为Linux系统的标准配置。

    1.2 OpenSSH

    OpenSSH 是 SSH (Secure SHell) 协议的免费开源实现。它是一款软件,应用非常广泛。SSH协议可以用来进行远程控制, 或在计算机之间传送文件。

    1.2.1 OpenSSH插件安装

    通过OpenSSH插件可以连接手机,进行远程控制, 或者传送文件。以cydia为例,需要在软件源中添加源:


    //蜜蜂源
    apt.cydiami.com
    //雷锋源
    apt.abcydia.com

    这两个源比较有名,推荐添加。





    • 软件源可以理解为服务器,存放了插件安装包。

    然后在搜索中搜索OpenSSH,认准来自apt.bingner.com。当然直接添加这个源也可以。




    1.3 SSH登录过程



    1. 电脑(客户端)请求连接手机(ip:22)。
    2. 手机(服务端)将公钥发送给mac电脑。


    1. mac电脑通过收到的公钥加密登录密码。
    2. 手机利用私钥解密登录密码,返回是否登录成功。

    1.4 中间人攻击(Man-in-the-middle attack)

    上面的登录方式存在一种隐患。如果有人 冒充服务器 将生成的 虚假公钥 发给客户端,那么它将获得客户端连接服务器的 密码


    1. 中间人模拟电脑给手机发送登录请求获取手机端公钥(I)
    2. 然后自己生成公私钥(M)将自己生成的公钥(M)发送给电脑
    3. 电脑端密码使用公钥(M)加密后发送给中间人,中间人使用私钥(M)解密拿到密码。
    4. 中间人将密码通过公钥(I)加密从而实现登录。

    那么怎么解决呢?
    这个也就是通过登录的时候返回的hash值来验证公钥的。一般服务器都会在自己的官网上公布自己公钥的hash值。这样就有效避免中间人攻击了。


    二、连接手机

    通过OpenSSH插件使用Wifi连接手机:ssh 用户名@手机IP地址

    • 在这里手机是服务端,电脑是客户端。OpenSSH是让手机开启SSH登录服务。
    • 登录:ssh 用户名@手机IP地址
    • 默认密码:alpine

    首次连接会出现保存提示,需要输入yes继续

      ~ ssh root@172.20.10.11
    The authenticity of host '172.20.10.11 (172.20.10.11)' can't be established.
    RSA key fingerprint is SHA256:pIPlaWYd9wT2MfpRqvP/WOe1wVXfVVKiCKttyPHK3f0.
    Are you sure you want to continue connecting (yes/no/[fingerprint])? yes

    这里其实是提示公钥key 的hash值让验证有没有被篡改的。

    确认后需要输入密码alpine(默认),输入密码后就登录成功了。

    Warning: Permanently added '172.20.10.11' (RSA) to the list of known hosts.
    root@172.20.10.11's password:
    zaizai:~ root#

    2.1 查看文件目录


    在 root用户目录下:

    zaizai:~ root# ls
    Application\ Support/ Library/ Media/

    cd /进入根目录下:

    zaizai:~ root# cd /
    zaizai:/ root# ls
    Applications/ Library/ User@ boot/ dev/ lib/ private/ tmp@ var@
    Developer/ System/ bin/ cores/ etc@ mnt/ sbin/ usr/

    查看安装应用列表:

    zaizai:/ root# cd Applications/
    zaizai:/Applications root# ls
    AXUIViewService.app/
    AccountAuthenticationDialog.app/
    ActivityMessagesApp.app/
    AnimojiStickers.app/
    AppSSOUIService.app/
    AppStore.app/
    Apple\ TV\ Remote.app/
    AskPermissionUI.app/
    AuthKitUIService.app/
    BarcodeScanner.app/
    BusinessChatViewService.app/
    BusinessExtensionsWrapper.app/
    CTCarrierSpaceAuth.app/
    CTKUIService.app/
    CTNotifyUIService.app/
    Camera.app/
    CarPlaySettings.app/
    CarPlaySplashScreen.app/

    ps -A查看当前进程:

    zaizai:/Applications root# ps -A
    PID TTY TIME CMD
    1 ?? 13:45.28 /sbin/launchd
    295 ?? 3:17.53 /usr/libexec/substituted
    296 ?? 0:00.00 (amfid)
    1585 ?? 0:00.00 /usr/libexec/amfid
    12460 ?? 0:00.13 /System/Library/Frameworks/WebKit.framework/XPCService
    12461 ?? 0:00.10 /System/Library/Frameworks/WebKit.framework/XPCService
    12489 ?? 0:00.06 /usr/libexec/tzd
    12522 ?? 0:00.05 /System/Library/PrivateFrameworks/FontServices.framewo
    12524 ?? 0:01.04 /System/Library/PrivateFrameworks/CoreSuggestions.fram
    12528 ?? 0:00.44 /System/Library/PrivateFrameworks/DeviceCheckInternal.
    12538 ?? 0:00.03 /usr/libexec/OTATaskingAgent server-init
    12539 ?? 0:00.05 /usr/libexec/tailspind
    12542 ?? 0:00.58 /usr/libexec/ptpd -t usb
    12545 ?? 0:00.50 /usr/libexec/adprivacyd
    12908 ?? 0:01.20 /System/Library/PrivateFrameworks/AppleMediaServicesUI
    13275 ?? 0:01.73 /usr/libexec/remindd
    13280 ?? 0:00.04 /usr/libexec/microstackshot
    13283 ?? 0:00.24 /System/Library/PrivateFrameworks/DifferentialPrivacy.
    13286 ?? 0:00.14 /System/Library/Frameworks/FileProvider.framework/Plug
    13289 ?? 0:19.42 /System/Library/PrivateFrameworks/AssistantServices.fr
    13294 ?? 0:00.07 /usr/libexec/proactiveeventtrackerd
    13298 ?? 0:00.32 /usr/libexec/gamecontrollerd
    13357 ?? 0:00.17 sshd: root@ttys i
    13359 ttys000 0:00.08 -sh
    13365 ttys000 0:00.04 ps -A

    查看微信进程ps -A | grep WeChat

    zaizai:/Applications root# ps -A | grep WeChat
    12459 ?? 0:18.22 /var/containers/Bundle/Application/295AC27A-5F06-4099-85AC-32EBA9FC9373/MonkeyDemo.app/WeChat
    13373 ttys000 0:00.02 grep WeChat

    这个时候MachO文件路径就找到了。


    exit可以退出登录:


    zaizai:~/Media root# exit
    logout
    Connection to 172.20.10.11 closed.

    2.2 用户

    iOS系统下有两个用户:rootmobile




    • root:最高权限用户,可以访问任意文件。
    • mobile:普通用户,只能访问改用户目录下文件/var/Mobile

    mobile用户在自己的目录下可以创建文件,在根目录下没有权限:


    2.3 修改用户密码

    • root用户可以修改所有用户的密码。
    • passwd命令修改密码:
      • passwd 用户名
      • 输入两次新密码,确认修改。因为是登录状态所以不用输入原始密码。

    root用户修改mobile用户密码:


      ~ ssh root@172.20.10.11
    zaizai:~ root# passwd mobile
    Changing password for mobile.
    New password:
    Retype new password:
    zaizai:~ root#
    一般不推荐修改密码,直接配置免密登录就好了。如果修改密码后忘记了那么重新安装就好了。


    2.4 密钥保存验证

    通过1.3 SSH登录过程我们知道在首次登录的时候会提示验证公钥hash值,并且保存公钥~/.ssh目录下的known_hosts中,那么公私钥手机中也应该是有的。
    进入手机cd /etc/ssh目录:


    可以看到ssh_host_rsa_key的公私钥。这也就验证了上面的登录过程。




    如果下次ip地址变了再登录就访问不了了,出提示中间人攻击。


    2.5 免密登录(公钥登录)

    2.5.1 免密登录原理

    免密码登录也称公钥登录,原理就是用户将自己的公钥储存在远程主机上。登录的时候,远程主机会向用户发送一段随机字符串,用户用自己的私钥加密后再发回来。远程主机用事先储存的公钥进行解密,如果成功,就证明用户是可信的直接允许登录不再要求密码。



    1. mac将自己的公钥(mac)存储在手机上。
    2. 登录的时候手机发送一个随机字符串给mac
    3. mac通过私钥加密字符串发送回给手机。
    4. 手机利用保存的mac公钥进行解密验证。

    这样就完成了免密登录。

    2.5.2 免密登录配置

    1.客户端在~/.ssh/目录下生成公私钥ssh-keygen


      .ssh ssh-keygen
    Generating public/private rsa key pair.
    Enter file in which to save the key (/Users/zaizai/.ssh/id_rsa):
    Enter passphrase (empty for no passphrase):
    Enter same passphrase again:
    Your identification has been saved in /Users/zaizai/.ssh/id_rsa.
    Your public key has been saved in /Users/zaizai/.ssh/id_rsa.pub.
    The key fingerprint is:
    SHA256:dJFdigu6cijJlQf9AaNVBGZPTcLO9itHE/RDT/QiCQk cozhang@zaizai
    The key's randomart image is:
    +---[RSA 3072]----+
    | B=E+++ oo |
    | * =..=+oo.. |
    | o .o=.oo+o. .|
    | o +++..o... |
    | o o.S... . |
    | . o o . + |
    | + o o . o |
    | . o . o |
    | o |
    +----[SHA256]-----+

    一路回车不设置密码(如果设置密码虽然免密登录了,但是每次都要输rsa的密码)。

    2.拷贝公钥SSH服务器ssh-copy-id 用户名@服务器IP

    ➜  .ssh ssh-copy-id root@172.20.10.11
    /usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/Users/zaizai/.ssh/id_rsa.pub"
    /usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
    /usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
    root@172.20.10.11's password:

    Number of key(s) added: 1

    Now try logging into the machine, with: "ssh '
    root@172.20.10.11'"
    and check to
    make sure that only the key(s) you wanted were added.

    拷贝的时候需要输入root账户的密码。这个时候再登录就不需要输入密码了:

    ➜  ~ ssh root@172.20.10.11
    zaizai:~ root#
    ssh-copy-id可以通过-i指定文件。某些系统通过指定-i会无效。(虽然拷贝成功,但是验证的是ssh-copy-id自己生成的key)。

    3.拷贝的公钥在服务器~/.ssh/authorized_keys中:



    在某些版本中ssh-copy-id不需要我们生成公钥,该命令会自己生成公私钥进行拷贝。如果遇见自己生成的公钥key和和拷贝到authorized_keys中的对不上那么很可能是这个问题。

    2.6 配置快捷登录


    加入我们有多台手机,或者并不想输入ip那么麻烦的去登录。在~/.ssh下创建一个config文件,对ssh登录配置别名:

    Host iPhone7
    Hostname 172.20.10.11
    User root
    Port 22

    使用:

    ➜  ~ ssh iPhone7
    zaizai:~ root#

    这样就配置好了别名,可以登录了。


    2.7 SSH其它操作

    • 删除保存的服务器地址的key:ssh-keygen –R 服务器IP地址(当SSH登录手机,手机就是服务器)`

    • know_hosts文件:用于保存SSH登录服务器所接受的key,在系统~/.ssh 目录

    • ssh_host_rsa_key.pub文件:作为SSH服务器发送给连接者的key,在系统/etc/ssh 目录中

    • config文件:在~/.ssh 目录下创建一个config文件。内部可以配置ssh登录的别名。


    Host 别名
    Hostname IP地址
    User 用户名
    Port 端口号

    三、USB登录(推荐)

    上面我们都是通过wifi连接的,由于通过wifi链接存在不稳定性,有时候会断开链接,并且有速度限制。所以推荐使用usb链接。苹果有一个服务,叫usbmuxd,这个服务主要用于在USB协议上实现多路TCP连接。
    usbmuxd目录:

    /System/Library/PrivateFrameworks/MobileDevice.framework/Resources



    3.1 USB 连接

    3.1.1 python脚本映射端口

    ssh root@172.20.10.11其实也就是ssh -p 22 root@172.20.10.11,默认22端口省略了,我们可以通过ssh -p 12345 root@localhost连接,只要将本地的12345端口映射到usb端口,只要usb端口连接哪个设备就相当于给哪个设备发送请求。
    有个python-client工具可以映射端口:



    python tcprelay.py -t 要映射端口:本地端口

      python-client python tcprelay.py -t 22:12345
    Forwarding local port 12345 to remote port 22

    将本地的12345端口映射到设备的TCP端口22。这样就可以通过本地的12345端口建立连接了。


    3.1.2 通过USB进行SSH连接

    映射成功后想要登录直接:

    //也可以 ssh -p 12345 root@127.0.0.1
    ssh -p 12345 root@localhost
    这里有个注意点是映射端口成功后不能关闭窗口,否则映射就没有了。
    ssh连接本地的12345,由于做了端口映射所以会通过usb连接对面设备的22端口。

      ~ ssh -p 12345 root@localhost
    The authenticity of host '[localhost]:12345 ([127.0.0.1]:12345)' can't be established.
    RSA key fingerprint is SHA256:pIPlaWYd9wT2MfpRqvP/WOe1wVXfVVKiCKttyPHK3f0.
    Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
    Warning: Permanently added '[localhost]:12345' (RSA) to the list of known hosts.
    zaizai:~ root#

    这里会重新进行rsa本地记录(ip变了),免密登录仍然有效。



    ip变了,相当于登录一个新的服务器。所以保存rsa


    3.1.3 验证中间人攻击


    这个时候换一台设备进行ssh -p 12345 root@localhost登录就会提示中间人攻击了,由于本地localhost对应的rsa和新手机返回的hash值对应不上。如果只有一台手机可以通过修改know_hosts对应的localhostrsa公钥模拟:


      ~ ssh -p 12345 root@localhost
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
    Someone could be eavesdropping on you right now (man-in-the-middle attack)!
    It is also possible that a host key has just been changed.
    The fingerprint for the RSA key sent by the remote host is
    SHA256:pIPlaWYd9wT2MfpRqvP/WOe1wVXfVVKiCKttyPHK3f0.
    Please contact your system administrator.
    Add correct host key in /Users/zaizai/.ssh/known_hosts to get rid of this message.
    Offending RSA key in /Users/zaizai/.ssh/known_hosts:4
    RSA host key for [localhost]:12345 has changed and you have requested strict checking.
    Host key verification failed.
    所以如果有两台手机可以分别通过ssh -p 12345 root@localhostssh -p 12345 root@127.0.0.1登录,就能区分开了。

    3.2 配置USB快捷登录


    这个时候在 config中取别名就不行了,因为有端口的映射,并且地址也不是真实的地址。
    在自己的脚本目录创建一个iPhone7.sh文件(最好给这个目录配置环境变量),脚本内容如下:

    ssh -p 12345 root@localhost

    那么这个时候还需要端口映射的脚本usbConnect.sh,内容如下:

    python /Users/zaizai/HPShell/python-client/tcprelay.py -t 22:12345

    端口映射脚本和连接脚本分开是为了方便多个设备切换,由于映射只需要一次。


    使用:

    //映射端口
    ~ usbConnect.sh
    //链接
    ~ iPhone7.sh

    这样就连接上手机了。




    需要两个窗口执行,映射完窗口一直存在的。
    脚本目录文件:




    3.3 Iproxy端口映射

    Iproxy也是一个映射工具。

    3.3.1 libimobiledevice 安装

    brew install libimobiledevice

    3.3.2 映射端口

    iproxy 本地端口 要映射端口

    iproxy 12345 22 

    这个映射和python脚本是反过来的。左边是本地端口,右边是要映射端口。其它的使用方式相同。

    映射终端:

      ~ iproxy 12345 22
    Creating listening port 12345 for device port 22
    waiting for connection
    New connection for 12345->22, fd = 5
    waiting for connection
    Requesting connecion to USB device handle 3 (serial: 5d38c0a07ffa912050c2cbc05da5436e10a2d5d7), port 22

    连接终端:

    ➜  ~ iPhone7.sh
    zaizai:~ root#


    总结

    • SSH是一种网络协议。OpenSSH是一款软件。
    • SSH登录过程:
      • 远程主机(服务器)收到用户登录请求,将自己的公钥发送给用户端
      • 用户端使用公钥将自己登录的密码加密发送
      • 远程主机(服务端)使用私钥解密登录密码。密码正确则通过登录。
    • 中间人攻击:冒充服务端将虚拟公钥发送给客户端 。截获用户连接服务器的密码。
    • 服务器防护
      • 服务器在第一次登录时会让客户端保存IP-公钥这个KEY
      • KEY存放在~/.ssh/know_hosts文件中
      • 一般SSH服务器会将自己KEYHASH值公布在网站上
    • 免密登录(公钥登录)
      • 生成公私钥$ssh-keygen
      • ssh-copy-id将公钥拷贝到SSH服务器
      • 原理:
        • 用户将自己的公钥存储在远程服务器上
        • 登录的时候,远程服务器会向用户发送一串随机字符串
        • 用户用自己的私钥加密后再发送给服务器
        • 服务器用事先存储的公钥进行解密。如果成功就证明是真实用户登录,直接允许登录。
    • 取别名
      • ~/.ssh目录中有一个config用来配置SSH
      • 通过Host(别名)Hostname(IP)User(用户名)Port(端口)配置登录的别名
    • 端口映射(USB连接)
      • iproxy
      • python脚本



    作者:HotPotCat
    链接:https://www.jianshu.com/p/51f989c373da





    收起阅读 »

    iOS砸壳

    一、砸壳软件脱壳,顾名思义,就是对软件加壳的逆操作,把软件上存在的壳去掉(解密)。1.1 砸壳原理1.1.1 应用加壳(加密)提交给Appstore发布的App,都经过官方保护而加密,这样可以保证机器上跑的应用是苹果审核过的,也可以管理软件授权(企业包默认情况...
    继续阅读 »

    一、砸壳

    软件脱壳,顾名思义,就是对软件加壳的逆操作,把软件上存在的壳去掉(解密)。

    1.1 砸壳原理

    1.1.1 应用加壳(加密)

    提交给Appstore发布的App,都经过官方保护而加密,这样可以保证机器上跑的应用是苹果审核过的,也可以管理软件授权(企业包默认情况下也是没有加密的,TF是加壳的。)。经过App Store加密的应用,我们无法通过Hopper等反编译静态分析,也无法Class-Dump,在逆向分析过程中需要对加密的二进制文件进行解密才可以进行静态分析,这一过程就是大家熟知的砸壳(脱壳)。

    App Store是通过对称加密(AES)加壳的,为了速度和效率。

    1.1.2 应用砸壳(解密)

    静态砸壳
    静态砸壳就是在已经掌握和了解到了壳应用的加密算法和逻辑后在不运行壳应用程序的前提下将壳应用程序进行解密处理。静态脱壳的方法难度大,而且加密方发现应用被破解后就可能会改用更加高级和复杂的加密技术。

    动态砸壳  
    动态砸壳就是从运行在进程内存空间中的可执行程序映像(image)入手,来将内存中的内容进行转储(dump)处理来实现脱壳处理。这种方法实现起来相对简单,且不必关心使用的是何种加密技术。在iOS中都是用的动态砸壳。

    1.2 iOS应用运行原理


  • 加了壳的程序CPU是读不懂的,只有解密后才能载入内存。
  • iOS系统内核会对MachO进行脱壳。
  • 所以我们只需要将解密后的MachO拷贝出来。
  • 非越狱手机做不到跨进程访问,越狱后拿到root权限就可以访问了。这就是砸壳的原理。(按页加解密-代码段)


  • 二、Clutch

    Clutch是由KJCracks开发的一款开源砸壳工具。工具支持iPhoneiPod TouchiPad。该工具需要使用iOS8.0以上的越狱手机应用。

    2.1安装

    Clutch官网找到发布的版本下载:




    查看这个文件可以看到支持arm_v7arm_v7sarm64设备:

    file Clutch-2.0.4
    Clutch-2.0.4: Mach-O universal binary with 3 architectures: [arm_v7:Mach-O executable arm_v7] [arm_v7s:Mach-O executable arm_v7s] [arm64:Mach-O 64-bit executable arm64]
    Clutch-2.0.4 (for architecture armv7): Mach-O executable arm_v7
    Clutch-2.0.4 (for architecture armv7s): Mach-O executable arm_v7s
    Clutch-2.0.4 (for architecture arm64): Mach-O 64-bit executable arm64

    2.2 使用


    映射端口,python或者iproxy都可以

    usbConnect.sh
    1. 拷贝Clutch到手机(注意加可执行权限)
      scp -P 端口 文件 用户@地址:目录/别名
    ➜ scp -P 12345 ./Clutch-2.0.4  root@localhost:/var/root/Clutch
    Clutch-2.0.4 100% 1204KB 32.1MB/s 00:00

    手机端查看:

    zaizai:~ root# ls
    Application\ Support/ Clutch Library/ Media/

    zaizai:~ root# ls -l
    total 1204
    drwxr-xr-x 3 root wheel 96 Mar 17 2018 Application\ Support/
    -rw-r--r-- 1 root wheel 1232832 May 25 16:59 Clutch
    drwxr-xr-x 11 root wheel 352 Oct 23 2019 Library/
    drwxr-xr-x 2 root wheel 64 Feb 27 2008 Media/
    加可执行权限:

    zaizai:~ root# chmod +x Clutch
    zaizai:~ root# ls -l
    total 1204
    drwxr-xr-x 3 root wheel 96 Mar 17 2018 Application\ Support/
    -rwxr-xr-x 1 root wheel 1232832 May 25 16:59 Clutch*
    drwxr-xr-x 11 root wheel 352 Oct 23 2019 Library/
    drwxr-xr-x 2 root wheel 64 Feb 27 2008 Media/
    1. 列出可以砸壳的应用列表 Clutch -i
    root# ./Clutch -i
    1. 砸壳 Clutch –d 应用ID
    root# Clutch –d  4
    砸壳成功后的应用在Device->private->var->mobileDocuments->Dumped目录下。

    自己拷贝的应用是加壳的。

    1. 在手机端通过ps -A找到进程:
    14837 ??         0:03.93 /var/containers/Bundle/Application/8F382114-BBA7-4D81-AA3E-3CD02E03E23E/WeChat.app/WeChat
    16560 ttys000 0:00.02 grep WeChat
    1. 然后拷贝:
    scp -P 12345 root@localhost://var/containers/Bundle/Application/8F382114-BBA7-4D81-AA3E-3CD02E03E23E/WeChat.app/WeChat
     otool -l WeChat  | grep crypt
    cryptoff 28672
    cryptsize 4096
    cryptid 1

    三、插入动态库

    是通过DYLD_INSERT_LIBRARIES来实现的。

    1. 创建一个HPHook动态库,创建一个类实现一个+load方法:
    + (void)load {
    NSLog(@"\n\n\nInject SUCCESS 🍉🍉🍉\n\n\n");
    }

    编译拷贝出HPHook.framework

    1. 拷贝HPHook.framework到越狱手机
     scp -r -P 12345 HPHook.framework root@localhost:/var/root
    CodeResources 100% 2258 301.0KB/s 00:00
    HPHook 100% 85KB 9.0MB/s 00:00
    HPHook.h 100% 422 91.2KB/s 00:00
    module.modulemap 100% 93 25.4KB/s 00:00
    Info.plist 100% 744 187.8KB/s 00:00

    • -r:代表循环拷贝文件夹。
    1. 查看手机App进程(任意一个)
    zaizai:/var/root mobile$ ps -A | grep InsertDemo
    16708 ?? 0:00.13 /var/containers/Bundle/Application/5AC46FE0-EB40-4FE2-BEA5-1AED9C95E7E9/InsertDemo.app/InsertDemo
    16710 ttys000 0:00.01 grep InsertDemo

    1. HPHook.framework插入步骤3中的App

    zaizai:/var/root mobile$ DYLD_INSERT_LIBRARIES=HPHook.framework/HPHook  /var/containers/Bundle/Application/5AC46FE0-EB40-4FE2-BEA5-1AED9C95E7E9/InsertDemo.app/InsertDemo
    2021-05-25 18:32:07.606 InsertDemo[16797:7420505]


    Inject SUCCESS 🍉🍉🍉

    这个时候就插入成功了。


    iOS9.1以后root用户不能用DYLD_INSERT_LIBRARIES(会报错kill 9),需要切换到mobile用户(su mobile)。
    主流App会有防护,可以自己创建一个App插入。
    高版本的iOS系统可能会遇到错误。

    四、dumpdecrypted

    dumpdecryptedGithub开源工具。这个工具就是通过建立一个名为dumpdecrypted.dylib的动态库,插入目标应用实现脱壳。


    4.1 安装

    1. dumpdecrypted官网直接git clone

    2. 通过make编译生成动态库:

    ➜  dumpdecrypted-master make
    `xcrun --sdk iphoneos --find gcc` -Os -Wimplicit -isysroot `xcrun --sdk iphoneos --show-sdk-path` -F`xcrun --sdk iphoneos --show-sdk-path`/System/Library/Frameworks -F`xcrun --sdk iphoneos --show-sdk-path`/System/Library/PrivateFrameworks -arch armv7 -arch armv7s -arch arm64 -dynamiclib -o dumpdecrypted.dylib dumpdecrypted.o
    ld: warning: directory not found for option '-F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.4.sdk/System/Library/PrivateFrameworks'
    ld: warning: directory not found for option '-F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.4.sdk/System/Library/PrivateFrameworks'
    ld: warning: directory not found for option '-F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.4.sdk/System/Library/PrivateFrameworks'
    直接在clone的目录make,最后会生成dumpdecrypted.dylib

    1. 拷贝到手机


    ➜  dumpdecrypted-master scp  -P 12345  dumpdecrypted.dylib  mobile@localhost:/var/mobile/
    mobile@localhost's password:
    dumpdecrypted.dylib
    100% 209KB 24.6MB/s 00:00
    1. 通过DYLD_INSERT_LIBRARIES 环境变量插入动态库执行
    DYLD_INSERT_LIBRARIES=dumpdecrypted.dylib /var/containers/Bundle/Application/36B02DC8-B625-4633-A2C7-45079855BFAC/Aweme.app/Aweme
    需要将dumpdecrypted.dylib拷贝到mobile路径中,为了导出有写的权限。dumpdecrypted.dylib会导出和自己同一目录。

    五、frida-iOS-dump

    该工具基于frida提供的强大功能通过注入js实现内存dump然后通过python自动拷贝到电脑生成ipa文件。

    5.1 安装

    5.1.1 Mac安装

    1. 查看python版本(Mac自带)


      ~ python --version
    Python 2.7.16

    如果是python3这里需要改成python3。根据自己的版本进行配置。

    2.查看pip版本

      ~ pip --version
    pip 19.0.1 from /Library/Python/2.7/site-packages/pip-19.0.1-py2.7.egg/pip (python 2.7)
    如果没有安装,执行:

    sudo easy_install pip
    卸载pip:python -m pip uninstall pip,如果是python3就安装pip3

    3.安装frida

    sudo pip install frida-tools



    出现这个提示表明目录不归当前用户所有。请检查该目录的权限和所有者。需要sudo-H标志。

    sudo -H pip install frida-tools

    • sudo -H:  set-home 将 HOME 变量设为目标用户的主目录

    5.1.2 iOS安装

    1. 添加源(需要科学上网)
    https://build.frida.re
    安装Frida




    5.2 Mac配置ios-dump

    1. 下载脚本
    sudo git clone https://github.com/AloneMonkey/frida-ios-dump
    或者直接去github下载。然后拷贝到/opt目录。

    当然如果电脑上安装了Monkey那直接在monkey目录中安装frida就好了。不然的话导出环境变量可能会有冲突。(monkey中已经导出dump.py了)。直接在Monkey/bin目录进行安装,将安装需要的内容直接从下载的frida-ios-dump中拷贝到Monkey/bin目录(其实只需要requirements.txtdump.jsdump.py。然后在该目录下安装依赖。

    1. 安装依赖
    //sudo pip install -r /opt/frida-ios-dump/requirements.txt –upgrade
    sudo pip install -r requirements.txt --ignore-installed six
    在这个过程中有可能报错:

    *frida-tools 1.2.2 has requirement prompt-toolkit<2.0.0,>=0.57, but you'll have >prompt-toolkit 2.0.7 which is incompatible.
    需要降低 prompt-toolkit 版本:

    //卸载
    $sudo pip uninstall prompt-toolkit
    //安装指定版本
    $sudo pip install prompt-toolkit==1.0.6
    1. 修改dump.py
    User = 'root'
    Password = 'alpine'
    Host = 'localhost'
    Port = 12345
    一般只需要修改Port就好了,和自己映射的本地端口一致。

    5.3 frida 命令

    • frida-ps:列出电脑上的进程
      ~ frida-ps
    PID Name
    ----- --------------------------------------------------------------------------------
    514 AirPlayUIAgent
    573 AppSSOAgent
    65527 Backup and Sync from Google
    533 Backup and Sync from Google
    636 CoreLocationAgent
    73560 CoreServicesUIAgent

    • frida-ps -U:列出手机进程
      ~ frida-ps -U
    PID Name
    ----- -----------------------------------------------
    15758 AlipayWallet
    16643 BTFuwa
    18079 CAReportingService
    11127 CMFSyncAgent
    1644 CommCenter
    17367 ContainerMetadataExtractor
    6691 EscrowSecurityAlert
    16196 HeuristicInterpreter
    11204 IDSRemoteURLConnectionAgent
    16218 MQQSecure
    11119 MobileGestaltHelper
    17611 PhotosReliveWidget
    10051 PinCleaner

    • frida -U 微信:进入微信进程,调试微信
    ➜  ~ frida -U AlipayWallet
    ____
    / _ | Frida 14.2.18 - A world-class dynamic instrumentation toolkit
    | (_| |
    > _ | Commands:
    /_/ |_| help -> Displays the help system
    . . . . object? -> Display information about 'object'
    . . . . exit/quit -> Exit
    . . . .
    . . . . More info at https://frida.re/docs/home/

    5.4 砸壳

    5.4.1 查看安装的应用

    dump.py -l可以查看已经安装的应用

    ~ dump.py -l
    /Library/Python/2.7/site-packages/paramiko/transport.py:33: CryptographyDeprecationWarning: Python 2 is no longer supported by the Python core team. Support for it is now deprecated in cryptography, and will be removed in the next release.
    from cryptography.hazmat.backends import default_backend
    PID Name Identifier
    ----- ------------ -------------------------------
    13582 微信 com.tencent.xin
    10769 支付宝 com.alipay.iphoneclient
    9912 相机 com.apple.camera
    11265 腾讯手机管家 com.tencent.mqqsecure
    - Acrobat com.adobe.Adobe-Reader
    - App Store com.apple.AppStore
    - Cydia com.saurik.Cydia
    - Enframe me.sspai.Enframe
    - Excel com.microsoft.Office.Excel

    这个时候是不需要映射的。

    5.4.2 导出ipa

    dump.py bundleId/displayName:

    //dump.py 微信
    dump.py com.tencent.xin
    • 可以通过bundleId或者displayName导出应用,推荐使用bundleIddisplayName可能会有同名。如果有同名哪个排在前面导出哪个。
    • 导出ipa包时需要app在运行状态(正常情况下会自动打开App),最好在前台不锁屏。
    • 导出的ipa包一般在你执行导出命令的目录。(如果配置了环境变量,任何目录都可以执行)目录没有权限会报错。
    • 导出包的时候是需要打开端口映射的。

    验证(需要解压拿到.app):


    ➜ otool -l WeChat.app/WeChat | grep crypt
    cryptoff 16384
    cryptsize 101646336
    cryptid 0
    • cryptid0表示没有加密,否则是加密的包。

    错误信息

    1. ImportError: No module named typing
      pip安装后报错:
    ~ sudo pip install frida-tools
    Traceback (most recent call last):
    File "/usr/local/bin/pip", line 11, in <module>
    load_entry_point('pip==21.1.1', 'console_scripts', 'pip')()
    File "/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/pkg_resources/__init__.py", line 489, in load_entry_point
    return get_distribution(dist).load_entry_point(group, name)
    File "/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/pkg_resources/__init__.py", line 2843, in load_entry_point
    return ep.load()
    File "/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/pkg_resources/__init__.py", line 2434, in load
    return self.resolve()
    File "/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/pkg_resources/__init__.py", line 2440, in resolve
    module = __import__(self.module_name, fromlist=['__name__'], level=0)
    File "/Library/Python/2.7/site-packages/pip-21.1.1-py2.7.egg/pip/__init__.py", line 1, in <module>
    from typing import List, Optional
    ImportError: No module named typing
    解决方案:

    sudo easy_install pip==19.0.1

    直接指定版本安装。

    2.Invalid requirement: '–upgrade'
    直接替换掉–upgrade命令:

    //sudo pip install -r /opt/frida-ios-dump/requirements.txt –upgrade
    sudo pip install -r requirements.txt --ignore-installed six

    3.如果遇见命令输入没有反映,电脑端frida没有问题。那么需要重新安装手机端frida

    4.Start the target app QQMusic unable to launch iOS app: The operation couldn’t be completed. Application info provider (FBSApplicationLibrary) returned nil for ""
    如果App启动后还是无法dump,直接通过BundleId dump就好了。



    作者:HotPotCat

    总结

    • 应用砸壳:一般应用为了防止反编译分析会对应用进行加密(加壳)。砸壳就是解密的过程。
      • 静态砸壳:已经知道了解密方式,不需要运行应用的情况下直接解密。
      • 动态砸壳:在应用启动之后从内存中找到应用的位置,dump(内存中)数据。
    • Clutch( 命令行工具)
      • Clutch -i:列出可以砸壳的应用列表。
      • Clutch –d 应用ID:砸壳
    • dumpdecrypted(动态库)
      • 通过DYLD_INSERT_LIBRARIES环境变量插入动态库载入某个进程。
      • 配置DYLD_INSERT_LIBRARIES=dumpdecrypted路径 Macho路径
    • frida-iOS-dump(利用frida加载脚本砸壳)
      • 安装frida(MaciPhone都需要)
      • 下载frida-iOS-dump脚本工具
      • 执行dump.py displayName /BundleId


    作者:HotPotCat
    链接:https://www.jianshu.com/p/0d89bbff8140




    收起阅读 »

    Flutter实战详解--高仿好奇心日报

    前言最近Flutter一直比较火,我也它也是非常感兴趣,看了下官网的基础教程后我决定直接上手做一个App,一是这样学的比较快印象更加深刻,二是可以记录其中遇到的一些坑,帮助大家少走一些弯路.本篇文章我会尽可能详细的讲到每一个点上.项目地址Github,如果觉得...
    继续阅读 »

    前言

    最近Flutter一直比较火,我也它也是非常感兴趣,看了下官网的基础教程后我决定直接上手做一个App,一是这样学的比较快印象更加深刻,二是可以记录其中遇到的一些坑,帮助大家少走一些弯路.本篇文章我会尽可能详细的讲到每一个点上.

    项目地址

    Github,如果觉得不错,欢迎Star

    注意事项

    1.下载项目后报错是因为没有添加依赖,在pubspec.yaml文件中点击Packages get下载依赖,有时候会在这里出现卡死的情况,可以配置一下环境变量.在终端执行vi ~/.bash_profile,再添加export PUB_HOSTED_URL=https://pub.flutter-io.cn和
    export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn.详情请看修改Flutter环境变量.
    2.需要将File Encodings里的Project Encoding设置为UTF-8,否则有时候安卓会报错
    3.如果cocoapods不是最新可能会出现Error Running Pod Install,请更新cocoapods.
    4.由于flutter_webview_plugin这个插件只支持加载url,于是就需要做一些修改.

    iOS 在FlutterWebviewPlugin.m文件中的- (void)navigate:(FlutterMethodCall*)call方法中的最后一排,将[self.webview loadRequest:request]方法改为[self.webview loadHTMLString:url baseURL:nil]
    Android 在WebViewManager.java文件中webView.loadUrl(url)方法改为webView.loadData(url, "text/html", "UTF-8"),以及下面那排的void reloadUrl(String url) { webView.loadUrl(url); }改为void reloadUrl(String url) { webView.loadData(url, "text/html", "UTF-8"); }
    先看看效果图吧.

    iOS效果图


    Android效果图


    正题

    怎么搭建Flutter环境我就不多说了,官网上讲的很详细,还没有搭建开发环境的可以看看这个Flutter中文网.

    1导航栏Tabbar


    这里我用到了DefaultTabController这个控件,使用DefaultTabController包裹需要用到Tab的页面即可,它的child为Scaffold,Scaffold有个appBar属性,在AppBar中设置具体的样式,大家看代码会更加清楚.相关注释也都写上了.

    home: new DefaultTabController(
    length: titleList.length,
    child: new Scaffold(
    appBar: new AppBar(
    elevation: 0.0,//导航栏下面那根线
    title: new TabBar(
    isScrollable: false,//是否可滑动
    unselectedLabelColor: Colors.black26,//未选中按钮颜色
    labelColor: Colors.black,//选中按钮颜色
    labelStyle: TextStyle(fontSize: 18),//文字样式
    indicatorSize: TabBarIndicatorSize.label,//滑动的宽度是根据内容来适应,还是与整块那么大(label表示根据内容来适应)
    indicatorWeight: 4.0,//滑块高度
    indicatorColor: Colors.yellow,//滑动颜色
    indicatorPadding: EdgeInsets.only(bottom: 1),//与底部距离为1
    tabs: titleList.map((String text) {//tabs表示具体的内容,是一个数组
    return new Tab(
    text: text,
    );
    }).toList(),
    ),
    ),
    //body表示具体展示的内容
    body:TabBarView(children: [News(url: 'http://app3.qdaily.com/app3/homes/index_v2/'),News(url: 'http://app3.qdaily.com/app3/papers/index/')]) ,
    ),
    ),

    大家也可以看看官网的示例Flutter官网示例

    2. 不同样式的item

    样式一


    这种布局的大概结构如下


    注意这里图片是紧贴着右边屏幕的,所以这里需要用到Expanded控件,用于自动填充子控件.

    样式二


    这个样式的控件布局就很简单了,结构如下


    样式三


    这个和样式二差不多,只不过最上面多了一块.

    这里需要注意的是,那个你猜这个图片是堆叠在整个大图上面的,所以需要用到Stack这个控件,其中Stack中有个属性const FractionalOffset(double dx, double dy)用于表示子控件相对于父控件的位置

    样式四


    这种样式稍微复杂一点,结构如下


    3、数据抓取

    用青花瓷抓取了好奇心数据.青花瓷使用教程


    简单分析一下,has_more表示是否可以加载更多,last_key用于上拉加载的时候请求用的,feeds就是每一条数据,banners就是轮播图的信息,columns就是横向滚动的ListView的相关数据,这个后面讲.接下来就做json序列化相关的了.

    4.Json序列化

    首先在pubspec.yaml中导入

    dependencies:
    json_annotation: ^2.0.0
    dev_dependencies:
    build_runner: ^1.0.0
    json_serializable: ^2.0.0

    创建一个model.dart文件
    引入文件

    import 'package:json_annotation/json_annotation.dart';
    part 'model.g.dart';

    其中这个model.g.dart等会儿会自动生成.这里需要掌握两个知识点

    1.@JsonSerializable() 这是表示告诉编译器这个类是需要生成Model类的
    2,@JsonKey 由于服务器返回的部分数据名称在Dart语言中是不被允许的,比如has_more,Dart中命名不能出现下划线,所以就需要用到@JsonKey来告诉编译器这个参数对于json中的哪个字段

    @JsonSerializable()
    class Feed {
    String image;
    int type;
    @JsonKey(name: 'index_type')
    int indexType;
    Post post;
    @JsonKey(name: 'news_list')
    List<News> newsList;
    Feed(this.image,this.type,this.post,this.indexType,this.newsList);
    factory Feed.fromJson(Map<String,dynamic> json) => _$FeedFromJson(json);
    Map<String, dynamic> toJson() => _$FeedToJson(this);
    }

    好了,写完后会报错,因为FeedFromJson和FeedToJson没有找到,这个时候在控制到输入flutter packages pub run build_runner build指令后会自动生成一个moded.g.dart文件,于是在网络请求下来数据后就可以用Feed feed = Feed.fromJson(data)这个方法来将Json中数据转换保存在Feed这个实例中了.在model类中还有些复杂的Json嵌套,但是也都很简单,大家看一眼应该就会了,哈哈.JSON和序列化具体教程

    5.轮播图

    Flutter中的轮播图我用到了Fluuter_Swiper这个组件,这里设置小圆点属性的时候稍微麻烦了点,网上好像也没有讲到,我这里讲一下.
    首先要创建DotSwiperPaginationBuilder

    DotSwiperPaginationBuilder builder = DotSwiperPaginationBuilder(
    color: Colors.white,//未选中圆点颜色
    activeColor: Colors.yellow,//选中圆点颜色
    size:7,//未选中大小
    activeSize: 7,//选中圆点大小
    space: 5//圆点间距
    );

    然后在Swiper中的pagination属性中设置它

    pagination: new SwiperPagination(
    builder: builder,
    ),

    6.网络请求

    首先,展示页面要继承自StatefulWidget,因为需要动态更新数据和列表.
    网络请求插件我用的Dio,非常好用.
    在initState方法中请求数据表示刚加载页面的时候进行网络请求,请求数据方法如下

    void getData()async{
    if (lastKey == '0'){
    dataList = [];//下拉刷新的时候将DataList制空
    }
    Dio dio = new Dio();
    Response response = await dio.get("$url$lastKey.json");
    Reslut reslut = Reslut.fromJson(response.data);
    if(!reslut.response.hasMore){
    return;//如果没有数据就不继续了
    }
    if(reslut.response.columns != null) {
    columnList = reslut.response.columns;
    }
    lastKey = reslut.response.lastKey;//更新lastkey
    setState(() {
    if (reslut.response.banners != null){
    banners = reslut.response.banners;//给轮播图赋值
    }
    dataList.addAll(reslut.response.feeds);//给数据源赋值
    });
    }

    因为用到了setState()方法,所以在该方法中改变了的数据会对其相应的地方进行刷新,比如设置了ListView的itemCount个数为dataList.length,如果在SetState方法中dataList.length改变了,那么ListView的itemCount树也会自动改变并刷新ListView.

    7. 上拉刷新与加载

    Flutter中有RefreshIndicator用于下拉刷新,它有个onRefresh闭包方法,表示下拉的时候执行的方法,一般用于网络请求.onRefresh方法如下

    Future<void> _handleRefresh() {
    final Completer<void> completer = Completer<void>();
    Timer(const Duration(seconds: 1), () {
    completer.complete();
    });
    return completer.future.then<void>((_) {
    lastKey = '0';
    getData();
    });
    }

    下拉加载的话需要初始化一个ScrollController,将它设为ListView的controller,并对其进行监听,当滑动到最底部的时候进行网络请求.

    @override
    void initState() {
    url = widget.url;
    getData();
    _scrollController.addListener(() {
    ///判断当前滑动位置是不是到达底部,触发加载更多回调
    if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
    getData();
    }
    });
    }
    final ScrollController _scrollController = new ScrollController();

    上拉加载loading框用到了flutter_spinkit插件,提供了大量的加载样式.


    代码如下

    ///上拉加载更多
    Widget _buildProgressIndicator() {
    ///是否需要显示上拉加载更多的loading
    Widget bottomWidget = new Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
    ///loading框
    new SpinKitThreeBounce(color: Color(0xFF24292E)),
    new Container(
    width: 5.0,
    ),
    ]);
    return new Padding(
    padding: const EdgeInsets.all(20.0),
    child: new Center(
    child: bottomWidget,
    ),
    );
    }

    8. ListView赋值

    由于最上面有一个轮播图,最下面有加载框,所以ListView的itemCount个数为dataList.length+2,又因为每个item之间都有一个浅灰色的风格线,所以需要用到ListView.separated,具体代码如下:

    Widget build(BuildContext context) {
    return RefreshIndicator(
    onRefresh:(()=> _handleRefresh()),
    color: Colors.yellow,//刷新控件的颜色
    child: ListView.separated(
    physics: const AlwaysScrollableScrollPhysics(),
    itemCount: _getListCount(),//item个数
    controller: _scrollController,//用于监听是否滑到最底部
    itemBuilder: (context,index){
    if(index == 0){
    return SwiperWidget(context, banners);//如果是第一个,则展示banner
    }else if(index < dataList.length + 1){
    return WidgetUtils.GetListWidget(context, dataList[index - 1]);//展示数据
    }else {
    return _buildProgressIndicator();//展示加载loading框
    }
    },
    separatorBuilder: (context,idx){//分割线
    return Container(
    height: 5,
    color: Color.fromARGB(50,183, 187, 197),
    );
    },
    ),
    );
    }

    9. ListView嵌套横向滑动ListView

    这种的话也稍微复杂一点,有两种样式.并且到滑到最右边的时候可以继续请求并加载数据.


    首先来分析一下数据


    这个colunmns就是横向滑动列表的重要数据.


    里面的id是请求参数,show_type表示列表的样式,location表示插入的位置.而且通过抓取接口发现,当横向列表快要展示出来的时候,才会去请求横向列表的具体接口.
    那么思路就很清晰了,在请求获得数据后遍历colunmns,根据每个colunmn的location插入一个Map,如下

    data.insert(colunm.location,  {'id':colunm.id,'showType':colunm.showType});

    再创建一个ColumnsListWidget类,继承自StatefulWidget,是一个新item,在滑动到该列表的位置的时候,会将该Map数据传给ColumnsListWidget,这个时候ColumnsListWidget就会加载数据并展示出来了,滑到最右边的时候加载和滑到最底部加载的方法一样,就不多说了.具体可以查看源码,关键代码如下:

    static Widget GetListWidget(BuildContext context, dynamic data) {
    Widget widget;
    if(data.runtimeType == Feed) {
    if (data.indexType != null) {
    widget = NewsListWidget(context, data);
    } else if (data.type == 2) {
    widget = ListImageTop(context, data);
    } else if (data.type == 0) {
    widget = ActivityWidget(context, data);
    } else if (data.type == 1) {
    widget = ListImageRight(context, data);
    }
    }else{
    widget = ColumnsListWidget(id: data['id'],showType: data['showType'],);
    }

    1.横向ListView外需要用Flexible包裹,Flexible组件可以使Row、Column、Flex等子组件在主轴方向有填充可用空间的能力(例如,Row在水平方向,Column在垂直方向),但是它与Expanded组件不同,它不强制子组件填充可用空间。
    2.ListView初始位置用到padding: new EdgeInsets.symmetric(horizontal: 12.0),用padding: EdgeInsets.only(left: 12)的话会让ListView和最左边一直有条线

    10.webview加载复杂的Html字段


    获取到网页详情的数据发现是Html字段,并且其中的css是url地址,试了很多Flutter加载Html的插件发现样式都不正确,最后决定使用原生和Flutter混编,这时候发现flutter_webview_plugin这个插件是使用原生网页的,不过它只支持加载url,于是就需要做一些修改.
    iOS
    在FlutterWebviewPlugin.m文件中的- (void)navigate:(FlutterMethodCall*)call方法中的最后一排,将[self.webview loadRequest:request]方法改为[self.webview loadHTMLString:url baseURL:nil]
    Android
    在WebViewManager.java文件中webView.loadUrl(url)方法改为webView.loadData(url, "text/html", "UTF-8"),以及下面那排的void reloadUrl(String url) { webView.loadUrl(url); }改为void reloadUrl(String url) { webView.loadData(url, "text/html", "UTF-8"); }
    由于服务器端返回的Html中的css和js文件地址是/assets/app3开头的,所以需要替换成绝对路径,所以要用到这个方法htmlBody.replaceAll( '/assets/app3','http://app3.qdaily.com/assets/app3')
    好了,这下就可以呈现出漂亮的网页了.

    11.ListView嵌套GridView

    在点击横向滑动列表的总标题的时候,会进入到相关栏目的详情页,如图


    这个ListView包含上下两部分.上面这部分为:


    结构如下


    下面就是一个GridView,不过有时候下面会是ListView,根据shouwType字段来判断,GridView的代码如下:

    Widget ColumnsDetailTypeTwo(BuildContext context,List<Feed> feesList){
    return GridView.count(
    physics: NeverScrollableScrollPhysics(),
    crossAxisCount: 2,
    shrinkWrap: true,
    mainAxisSpacing: 10.0,
    crossAxisSpacing: 15.0,
    childAspectRatio: 0.612,
    padding: new EdgeInsets.symmetric(horizontal: 20.0),
    children: feesList.map((Feed feed) {
    return ColumnsTypeTwoTile(context, feed);
    }).toList()
    );
    }

    其中 childAspectRatio表示宽高比.

    圆角头像需要用到
    CircleAvatar(backgroundImage:NetworkImage(url),),这个控件

    12、在切换Tab的时候防止执行initState

    在切换顶部tab的时候会发现下面的界面会自动滑动到顶(位置重置)并执行initState,同时每次滑到横向ListView的时候,它也会执行initState并且位置也会重置,要让它只执行一次initState方法的话需要这么做.

    class _XXXState extends State<XXX> with AutomaticKeepAliveClientMixin{
    @override
    bool get wantKeepAlive => true;

    这样它就会只执行一次initState方法了.

    总结
    做了这个项目最大的感受就是界面布局是真的很方便很简单,因为做了一遍对很多知识点也理解的更深了.如果觉得有帮助到你的话,希望可以给个 Star

    项目地址
    Github

    链接:https://www.jianshu.com/p/4a0185b5a8f5

    收起阅读 »

    OLLVM代码混淆移植与使用

    简介OLLVM(Obfuscator-LLVM)是瑞士西北应用科技大学安全实验室于2010年6月份发起的一个项目,该项目旨在提供一套开源的针对LLVM的代码混淆工具,以增加对逆向工程的难度。github上地址是https://github.com/obfusc...
    继续阅读 »

    简介

    OLLVM(Obfuscator-LLVM)是瑞士西北应用科技大学安全实验室于2010年6月份发起的一个项目,该项目旨在提供一套开源的针对LLVM的代码混淆工具,以增加对逆向工程的难度。github上地址是https://github.com/obfuscator-llvm/obfuscator,只不过仅更新到llvm的4.0,2017年开始就没在更新。

    移植

    OLLVM如果自己想拿最新版的LLVM和Clang进行移植功能其实也并不是很难,整理一下其实改动很小,接下来将会讲一下移植的方法。

    个人整理

    先放一下个人移植好的版本地址https://github.com/heroims/obfuscator.git,个人fork原版后又加入了llvm5.0,6.0,7.0以及swift-llvm5.0的版本,应该能满足大部分需求了,如果有新版本下面的讲解,各位也可以自己动手去下载自己需要的llvm和clang进行移植。git上的提交每次都很独立如下图,方便各位cherry-pick。


    下载LLVM

    llvm地址:https://github.com/llvm-mirror
    swift-llvm地址:https://github.com/apple
    大家可以从上面的地址下载最新的自己需要的llvm和clang

    #下载llvm源码
    wget https://codeload.github.com/llvm-mirror/llvm/zip/release_70
    unzip llvm-release_70.zip
    mv llvm-release_70 llvm


    #下载clang源码
    wget https://codeload.github.com/llvm-mirror/clang/zip/release_70
    unzip clang-release_70.zip
    mv clang-release_70 llvm/tools/clang

    添加混淆代码

    如果用git的话只需要执行git cherry-pick xxxx把xxxx换成对应的我的版本上的提交哈希填上即可。极度推荐用git搞定。

    如果手动一点点加的话,第一步就是把我改过的OLLVM文件夹里/include/llvm/Transforms/Obfuscation和/lib/llvm/Transforms/Obfuscation移动到刚才下载好的llvm源码文件夹相同的位置。

    git clone https://github.com/heroims/obfuscator.git
    cd obfuscator
    git checkout llvm-7.0
    cp include/llvm/Transforms/Obfuscation llvm/include/llvm/Transforms/Obfuscation
    cp lib/llvm/Transforms/Obfuscation llvm/lib/llvm/Transforms/Obfuscation

    然后手动修改8个文件如下:









    编译

    mkdir build
    cd build
    #如果不想跑测试用例加上-DLLVM_INCLUDE_TESTS=OFF
    cmake -DCMAKE_BUILD_TYPE=Release -DLLVM_CREATE_XCODE_TOOLCHAIN=ON ../obfuscator/
    make -j7

    使用

    这里原版提供了3种混淆方式分别是控制流扁平化,指令替换,虚假控制流程,用起来都是加cflags的方式。下面简单说下这几种模式。

    控制流扁平化

    这个模式主要是把一些if-else语句,嵌套成do-while语句

    -mllvm -fla:激活控制流扁平化
    -mllvm -split:激活基本块分割。在一起使用时改善展平。
    -mllvm -split_num=3:如果激活了传递,则在每个基本块上应用3次。默认值:1

    指令替换

    这个模式主要用功能上等效但更复杂的指令序列替换标准二元运算符(+ , – , & , | 和 ^)

    -mllvm -sub:激活指令替换
    -mllvm -sub_loop=3:如果激活了传递,则在函数上应用3次。默认值:1

    虚假控制流程

    这个模式主要嵌套几层判断逻辑,一个简单的运算都会在外面包几层if-else,所以这个模式加上编译速度会慢很多因为要做几层假的逻辑包裹真正有用的代码。

    另外说一下这个模式编译的时候要浪费相当长时间包哪几层不是闹得!

    -mllvm -bcf:激活虚假控制流程
    -mllvm -bcf_loop=3:如果激活了传递,则在函数上应用3次。默认值:1
    -mllvm -bcf_prob=40:如果激活了传递,基本块将以40%的概率进行模糊处理。默认值:30

    上面说完模式下面讲一下几种使用方式

    直接用二进制文件

    直接使用编译的二进制文件build/bin/clang test.c -o test -mllvm -sub -mllvm -fla -mllvm -bcf

    NDK集成

    这里分为工具链的制作和项目里的配置。

    制作Toolchains

    这里以修改最新的ndk r18为例,老的ndk版本比这更容易都在ndk-bundle/toolchains里放着需要修改的文件。

    #复制ndk的toolschain里的llvm
    cp -r ndk-bundle/toolchains/llvm ndk-bundle/toolchains/ollvm
    #删除prebuilt文件夹下的文件夹的bin和lib64,prebuilt文件夹下根据系统不同命名也不同
    rm -rf ndk-bundle/toolchains/ollvm/prebuilt/darwin-x86_64/bin
    rm -rf ndk-bundle/toolchains/ollvm/prebuilt/darwin-x86_64/lib64
    #把我们之前编译好的ollvm下的bin和lib移到我们刚才删除bin和lib64的目录下
    mv build/bin ndk-bundle/toolchains/ollvm/prebuilt/darwin-x86_64/
    mv build/lib ndk-bundle/toolchains/ollvm/prebuilt/darwin-x86_64/
    #复制ndk-bundle⁩/⁨build⁩/⁨core⁩/⁨toolchains的文件夹,这里根据自己对CPU架构的需求自己复制然后修改
    cp -r ndk-bundle⁩/⁨build⁩/⁨core⁩/⁨toolchains/arm-linux-androideabi-clang⁩ ndk-bundle⁩/⁨build⁩/⁨core⁩/⁨toolchains/arm-linux-androideabi-clang-ollvm

    最后把arm-linux-androideabi-clang-ollvm里的setup.mk文件进行修改

    TOOLCHAIN_NAME := ollvm
    TOOLCHAIN_ROOT := $(call get-toolchain-root,$(TOOLCHAIN_NAME))
    TOOLCHAIN_PREFIX := $(TOOLCHAIN_ROOT)/bin

    config.mk里是CPU架构,刚才是复制出来的所以不用修改,但如果要添加其他的自定义架构需要严格按照格式规范命名最初的文件夹,如mips的需要添加文件夹mipsel-linux-android-clang-ollvm,setup.mk和刚才的修改一样即可。

    项目中配置

    到了项目里还需要修改两个文件:
    在Android.mk 中添加混淆编译参数

    LOCAL_CFLAGS += -mllvm -sub -mllvm -bcf -mllvm -fla

    Application.mk中配置NDK_TOOLCHAIN_VERSION

    #根据需要添加
    APP_ABI := x86 armeabi-v7a x86_64 arm64-v8a mips armeabi mips64
    #使用刚才我们做好的编译链
    NDK_TOOLCHAIN_VERSION := ollvm

    Visual Studio集成

    编译ollvm的时候,使用cmake-gui选择Visual Studio2015或者命令行选择cmake -G "Visual Studio 14 2015" -DCMAKE_BUILD_TYPE=Release ../obfuscator/
    然后cmake会产生一个visual studio工程,用vs编译即可!
    至于将Visual Studio的默认编译器换成clang编译,参考https://www.ishani.org/projects/ClangVSX/

    Visual Studio2015起官方开始支持Clang,具体做法:
    文件->新建->项目->已安装->Visual C++->跨平台->安装Clang with Microsoft CodeGen
    Clang是一个完全不同的命令行工具链,这时候可以在工程配置中,平台工具集选项里找到Clang,然后使用ollvm的clang替换该clang即可。

    XCode集成

    XCode里集成需要看版本,XCode10之前和之后是一个分水岭,XCode9之前和之后有一个小配置不同。

    XCode10以前

    $ cd /Applications/Xcode.app/Contents/PlugIns/Xcode3Core.ideplugin/Contents/SharedSupport/Developer/Library/Xcode/Plug-ins/
    $ sudo cp -r Clang\ LLVM\ 1.0.xcplugin/ Obfuscator.xcplugin
    $ cd Obfuscator.xcplugin/Contents/
    $ sudo plutil -convert xml1 Info.plist
    $ sudo vim Info.plist

    修改:

    <string>com.apple.compilers.clang</string> -> <string>com.apple.compilers.obfuscator</string>
    <string>Clang LLVM 1.0 Compiler Xcode Plug-in</string> -> <string>Obfuscator Xcode Plug-in</string>

    执行:

    $ sudo plutil -convert binary1 Info.plist
    $ cd Resources/
    $ sudo mv Clang\ LLVM\ 1.0.xcspec Obfuscator.xcspec
    $ sudo vim Obfuscator.xcspec

    修改:

    <key>Description</key>
    <string>Apple LLVM 8.0 compiler</string> -> <string>Obfuscator 4.0 compiler</string>
    <key>ExecPath</key>
    <string>clang</string> -> <string>/path/to/obfuscator_bin/clang</string>
    <key>Identifier</key>
    <string>com.apple.compilers.llvm.clang.1_0</string> -> <string>com.apple.compilers.llvm.obfuscator.4_0</string>
    <key>Name</key>
    <string>Apple LLVM 8.0</string> -> <string>Obfuscator 4.0</string>
    <key>Vendor</key>
    <string>Apple</string> -> <string>HEIG-VD</string>
    <key>Version</key>
    <string>7.0</string> -> <string>4.0</string>

    执行:

    $ cd English.lproj/
    $ sudo mv Apple\ LLVM\ 5.1.strings "Obfuscator 3.4.strings"
    $ sudo plutil -convert xml1 Obfuscator\ 3.4.strings
    $ sudo vim Obfuscator\ 3.4.strings

    修改:

    <key>Description</key>
    <string>Apple LLVM 8.0 compiler</string> -> <string>Obfuscator 4.0 compiler</string>
    <key>Name</key>
    <string>Apple LLVM 8.0</string> -> <string>Obfuscator 4.0</string>
    <key>Vendor</key>
    <string>Apple</string> -> <string>HEIG-VD</string>
    <key>Version</key>
    <string>7.0</string> -> <string>4.0</string>

    执行:

    $ sudo plutil -convert binary1 Obfuscator\ 3.4.strings

    XCode9之后要设置Enable Index-While-Building成NO



    XCode10之后

    xcode10之后无法使用添加ideplugin的方法,但添加编译链跑的依然可行,另外网上一些人说不能开bitcode,不能提交AppStore,用原版llvm改的ollvm的确有可能出现上述情况,所以我用苹果的swift-llvm改了一版暂时没去试着提交,或许可以,有兴趣的也可以自己下载使用试试obfuscator这版,特别备注由于修改没有针对swift部分所以用swift写的代码没混淆,回头有空的话再弄。

    创建XCode的toolchain然后把生成的文件夹放到/Library/Developer/下

    cd build
    sudo make install-xcode-toolchain
    mv /usr/local/Toolchains /Library/Developer/

    Toolchains下的.xctoolchain文件就是一个文件夹,进去修改info.plist

    <key>CFBundleIdentifier</key>
    <string>org.llvm.7.0.0svn</string> -> <string>org.ollvm-swift.5.0</string>

    修改完在XCode的Toolchains下就会显示相应的名称

    然后如图打开XCode选择Toolchaiins




    按这些配置好后就算是可以用了。

    最后

    简单展示一下混淆后的成果

    源码


    反编译未混淆代码


    反编译混淆后代码


    扩展:字符串混淆

    原版是没有这功能的本来,Armariris 提供了这个功能,我这也移植过来了,毕竟不难。
    首先把StringObfuscation的.h,.cpp文件放到对应的Obfuscation文件夹下,然后分别修改下面的文件。


    用法

    -mllvm -sobf:编译时候添加选项开启字符串加密
    -mllvm -seed=0xdeadbeaf:指定随机数生成器种子

    效果

    看个添加了-mllvm -sub -mllvm -sobf -mllvm -fla -mllvm -bcf这么一串的效果。

    源码


    反编译未混淆代码


    反编译混淆后代码


    转自:https://www.jianshu.com/p/e0637f3169a3

    收起阅读 »

    iOS多设备适配简史以及相应的API支撑实现

    远古的iPhone3和iPhone4时代,设备尺寸都是固定3.5inch,没有所谓的适配的问题,只需要用视图的frame属性进行硬编码即可。随着时间的推移,苹果的设备种类越来越多,尺寸也越来越大,单纯的frame已经不能简单解决问题了,于是推出了AutoLay...
    继续阅读 »

    远古的iPhone3和iPhone4时代,设备尺寸都是固定3.5inch,没有所谓的适配的问题,只需要用视图的frame属性进行硬编码即可。随着时间的推移,苹果的设备种类越来越多,尺寸也越来越大,单纯的frame已经不能简单解决问题了,于是推出了AutoLayout技术和SizeClasses技术来解决多种设备的适配问题。一直在做iOS开发的程序员相信在下面的两个版本交界处需要处理适配的坎一定让你焦头烂额过:

    1、iOS7出来后视图控制器的根视图默认的尺寸是占据整个屏幕的,如果有半透明导航条的话也默认是延伸到导航栏和状态栏的下面。这段时间相信你对要同时满足iOS7和以下的版本进行大面积的改版和特殊适配处理,尤其是状态栏的高度问题尤为棘手。

    2、iOS11出来后尤其是iPhoneX设备推出,iPhoneX设备的特殊性表现为顶部的状态栏高度由20变为了44,底部还出现了一个34的安全区,当横屏时还需要考虑左右两边的44的缩进处理。你需要对所有的布局代码进行重新适配和梳理以便兼容iPhoneX和其他设备,这里面还是状态栏的高度以及底部安全区的的高度尤为棘手。

    个人认为这两个版本的发布是iOS开发人员遇到的需要大量布局改版的版本。为了达到完美适配我们可能需要写大量的if,else以及写很多宏以及版本兼容来进行特殊处理。当然苹果也为上面两次大改版提供了诸多的解决方案:

    1、iOS7中对视图控制器提供了如下属性来解决版本兼容性的问题:

    @property(nonatomic,assign) UIRectEdge edgesForExtendedLayout NS_AVAILABLE_IOS(7_0); // Defaults to UIRectEdgeAll
    @property(nonatomic,assign) BOOL extendedLayoutIncludesOpaqueBars NS_AVAILABLE_IOS(7_0); // Defaults to NO, but bars are translucent by default on 7_0.
    @property(nonatomic,assign) BOOL automaticallyAdjustsScrollViewInsets API_DEPRECATED_WITH_REPLACEMENT("Use UIScrollView's contentInsetAdjustmentBehavior instead", ios(7.0,11.0),tvos(7.0,11.0)); // Defaults to YES

    @property(nonatomic,readonly,strong) id<UILayoutSupport> topLayoutGuide API_DEPRECATED_WITH_REPLACEMENT("-[UIView safeAreaLayoutGuide]", ios(7.0,11.0), tvos(7.0,11.0));
    @property(nonatomic,readonly,strong) id<UILayoutSupport> bottomLayoutGuide API_DEPRECATED_WITH_REPLACEMENT("-[UIView safeAreaLayoutGuide]", ios(7.0,11.0), tvos(7.0,11.0));

    2、iOS11中提出了一个安全区的概念,要求我们的可操作视图都放置在安全区内,并对视图和滚动视图提供了如下扩展属性:

    @property (nonatomic,readonly) UIEdgeInsets safeAreaInsets API_AVAILABLE(ios(11.0),tvos(11.0));
    - (void)safeAreaInsetsDidChange API_AVAILABLE(ios(11.0),tvos(11.0));

    /* The top of the safeAreaLayoutGuide indicates the unobscured top edge of the view (e.g, not behind
    the status bar or navigation bar, if present). Similarly for the other edges.
    */
    @property(nonatomic,readonly,strong) UILayoutGuide *safeAreaLayoutGuide API_AVAILABLE(ios(11.0),tvos(11.0));
    /* When contentInsetAdjustmentBehavior allows, UIScrollView may incorporate
    its safeAreaInsets into the adjustedContentInset.
    */
    @property(nonatomic, readonly) UIEdgeInsets adjustedContentInset API_AVAILABLE(ios(11.0),tvos(11.0));

    /* Also see -scrollViewDidChangeAdjustedContentInset: in the UIScrollViewDelegate protocol.
    */
    - (void)adjustedContentInsetDidChange API_AVAILABLE(ios(11.0),tvos(11.0)) NS_REQUIRES_SUPER;

    /* Configure the behavior of adjustedContentInset.
    Default is UIScrollViewContentInsetAdjustmentAutomatic.
    */
    @property(nonatomic) UIScrollViewContentInsetAdjustmentBehavior contentInsetAdjustmentBehavior API_AVAILABLE(ios(11.0),tvos(11.0));

    /* contentLayoutGuide anchors (e.g., contentLayoutGuide.centerXAnchor, etc.) refer to
    the untranslated content area of the scroll view.
    */
    @property(nonatomic,readonly,strong) UILayoutGuide *contentLayoutGuide API_AVAILABLE(ios(11.0),tvos(11.0));

    /* frameLayoutGuide anchors (e.g., frameLayoutGuide.centerXAnchor) refer to
    the untransformed frame of the scroll view.
    */
    @property(nonatomic,readonly,strong) UILayoutGuide *frameLayoutGuide API_AVAILABLE(ios(11.0),tvos(11.0));

    这些属性的具体意义这里就不多说了,网络上以及苹果的官方都有很多资料在介绍这些属性的意思。从上面的这些属性中可以看出苹果提出的这些解决方案其主要是围绕解决视图和导航条、滚动视图、状态栏、屏幕边缘之间的关系而进行的。因为iOS7和iOS11两个版本中控制器中的视图和上面所列出的一些内容之间的关系变化最大。

    NSLayoutConstraint约束以及iOS9上的封装改进
    在iOS6时代苹果推出了AutoLayout的技术解决方案,这是一套采用以相对约束来替代硬编码的解决方法,然而糟糕的方法名和使用方式导致使用成本和代码量的急剧增加。比如下面的一段代码:

    UIButton *button = [self createDemoButton:NSLocalizedString(@"Pop layoutview at center", "") action:@selector(handleDemo1:)];
    button.translatesAutoresizingMaskIntoConstraints = NO; //button使用AutoLayout
    [scrollView addSubview:button];

    //下面的代码是iOS6以来自带的约束布局写法,可以看出代码量较大。
    [scrollView addConstraint:[NSLayoutConstraint constraintWithItem:button attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:scrollView attribute:NSLayoutAttributeCenterX multiplier:1 constant:0]];

    [scrollView addConstraint:[NSLayoutConstraint constraintWithItem:button attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:scrollView attribute:NSLayoutAttributeTop multiplier:1 constant:10]];

    [scrollView addConstraint:[NSLayoutConstraint constraintWithItem:button attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:40]];

    [scrollView addConstraint:[NSLayoutConstraint constraintWithItem:button attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:scrollView attribute:NSLayoutAttributeWidth multiplier:1 constant:-20]];


    一个简单的将按钮放到一个UIScrollView中去的代码,当用AutoLayout来实现时出现了代码量风暴问题。对于约束的设置到了iOS9以后有了很大的改进,苹果对约束的设置进行了封装,提供了三个类:NSLayoutXAxisAnchor, NSLayoutYAxisAnchor, NSLayoutDimension来简化约束的设置,还是同样的功能用新的类来写约束就简洁清晰很多了:

    UIButton *button = [self createDemoButton:NSLocalizedString(@"Pop layoutview at center", "") action:@selector(handleDemo1:)];
    button.translatesAutoresizingMaskIntoConstraints = NO; //button使用AutoLayout
    [scrollView addSubview:button];
    [button.centerXAnchor constraintEqualToAnchor:scrollView.centerXAnchor].active = YES;
    [button.topAnchor constraintEqualToAnchor:scrollView.topAnchor constant:10].active = YES;
    [button.heightAnchor constraintEqualToConstant:40].active = YES;
    [button.widthAnchor constraintEqualToAnchor:scrollView.widthAnchor multiplier:1 constant:-20].active = YES;

    UIStackView
    在iOS9中还提供了一个UIStackView的类来简化那些视图需要从上往下或者从左往右依次添加排列的场景,通过UIStackView容器视图的使用就不再需要为每个子视图添加冗余的依赖约束关系了。在大量的实践中很多应用的各板块其实都是按顺序从上到下排列或者从左到右排列的。所以如果您的应用最低支持到iOS9的话就可以大量的应用这个类来构建你的程序了。

    占位视图类UILayoutGuide
    在iOS9以前两个视图之间的间距和间隔是无法支持浮动和可伸缩设置的,以及我们可以需要在两个视图之间保留一个浮动尺寸的空白区域,解决的方法是在它们中间加入一个透明颜色的UIView来进行处理,不管如何只要是View都需要进行渲染和绘制从而有可能一定程度上影响程序的性能,而在iOS9以后提供了一个占位视图类UILayoutGuide,这个类就像是一个普通的视图一样可以为它设置约束,也可以将它添加进入视图中去,也可以将这个占位视图作为其他视图的约束依赖项,唯一的不同就是占位视图不会进行任何的渲染和绘制,它只会参与布局处理。因此这个类的引入可以很大程度上解决那些浮动间距的问题。

    SizeClasses多屏幕适配
    当我们的程序可能需要同时在横屏和竖屏下运行并且横屏和竖屏下的布局还不一致时,而且希望我们的应用在小屏幕上和大屏幕上(比如iPhone8 Plus 以及iPhoneX S Max)的布局有差异时,我们可能需要用到苹果的SizeClasses技术。这是苹果在iOS8中推出来的一个概念。 但是在实际的实践中我们很少有看到使用SizeClasses的例子和场景以及在我们开发中很少有使用到这方面的技术,所以我认为这应该是苹果的一个多屏幕适配的失败解决的方案。从字面理解SizeClasses就是尺寸的种类,苹果将设备的宽和高分为了压缩和常规两种尺寸类型,因此我们可以得到如下几种类型的设备:


    很欣慰的是如果您的应用是一个带有系统导航条的应用时很多适配的问题都能够得到很好的解决,因为系统已经为你做了很多事情,你不需要做任何特殊的处理。而如果你的应用的某个界面是present出来的,或者是你自己实现的自定义导航条的话,那么你可能就需要自己来处理各种版本的适配问题了。并且如果你的应用可能还有横竖屏的话那这个问题就更加复杂了。

    最后除了可以用系统提供的API来解决所有的适配问题外,还向大家推荐我的开源布局库:MyLayout。它同时支持Objective-C以及Swift版本。而且用这个库后上面的所有适配问题都不是问题。

    转自:https://www.jianshu.com/p/b43b22fa40e3

    收起阅读 »

    一套包含了社区匹配聊天以及语音互动直播相关的社交系统模板项目

    一套包含了社区匹配聊天语音以及直播相关的社交系统模板项目,包括服务器端以及 Android 客户端背景及选型在实现社交相关项目时,少不了 IM 及时聊天功能,这里选择了自己比较熟悉的环信三方 SDK,环信 IM...
    继续阅读 »

    社交模板项目

    一套包含了社区匹配聊天语音以及直播相关的社交系统模板项目,包括服务器端以及 Android 客户端

    项目资源均来自于互联网,如果有侵权请联系我

    背景及选型

    一直以来都是标榜自己是一个喜欢开源的程序猿,一直想做一款能够被大家认同的开源项目,也是想提供给广大的新手程序猿一个比较完整系统的社交系统以供参考,因此有了这一套社交系统模板项目, 当前模板项目主要功能可以看下边的 功能与TODO

    在实现社交相关项目时,少不了 IM 及时聊天功能,这里选择了自己比较熟悉的环信三方 SDK,环信 IMSDK 能够比较方便的实现自定义扩展功能,比如会话扩展,消息扩展等,消息效果可以看下方 项目截图

    通话方面这里选择了声网提供的服务,看了下他们提供的功能还是比较多的,这里主要用到了语音通话,以及变声效果处理,感觉集成还是比较方便的,之前没用过的情况下,其实两天就搞定了 1V1 通话和多人互动通话的功能,他们还提供了更多场景使用,比如教育,直播等,更多功能大家可以搜索他们官网查看,通话效果可以看下方 项目截图

    开发环境

    项目基本属于在Android开发环境下开发,全局使用Kotlin语言,项目已经适配Android6.x以上的动态权限适配,以及7.x的文件选择,和8.x的通知提醒,10.x的文件选择等;

    • 开发系统:Mac OS 11.1
    • 开发工具:Android Studio 4.2
    • 打包工具:Gradle 4.2.0
    • 开发语言:Kotlin 1.4.32

    项目模块儿

    • app是项目主模块儿,这部分主要包含了项目的业务逻辑,比如匹配、内容发布,信息展示等
    • vmcommon是项目基础模块儿,这部分主要包含了项目的基类封装,依赖管理,包括网络请求,图片加载等工具类
    • vmim聊天与通话模块儿,这是为了方便大家引用到自己的项目中做的一步封装,不用再去复杂的复制代码和资源等,只需要将vmimmodule导入到自己的项目中就行了,具体使用方式参见项目app模块儿;

    功能与TODO

    IM部分功能

    •  链接监听
    •  登录注册
    •  会话功能
      •  置顶
      •  标为未读
      •  删除与清空
      •  草稿功能
    •  聊天功能
      • [x]消息类型
        •  文本消息
        •  图片消息
          •  查看大图
          •  保存图片
      •  消息处理
        •  删除
        •  撤回
        •  复制(仅文本可复制)
      •  昵称头像处理(通过回调实现)
      •  头像点击(回调到 App 层)
      •  语音实时通话功能
        •  1V1音频通话
        •  静音、扬声器播放
        •  音效变声
    •  解忧茶室
      •  创建房间
      •  发送消息
        •  文本消息
        •  鼓励消息
      •  上下麦处理
      •  音效变声(彩蛋功能)

    App部分功能

    •  登录注册(包括业务逻辑和 IM 逻辑)
    •  首页
      •  自己的状态
      •  拉取其他人的状态信息
      •  心情匹配
      •  解忧聊天室
    •  聊天(这里直接加载 IM 模块儿)
    •  发现
      •  发布内容
      •  喜欢操作
      •  详情展示
        •  喜欢操作
        •  评论获取
        •  添加评论
    •  我的
      •  个人信息展示
      •  上传头像、封面
      •  设置昵称、签名、职业、地址、生日、性别等
      •  邮箱绑定
      •  个人发布与喜欢内容展示
    •  设置
      •  个人信息设置
      •  深色模式适配
      •  通知设置
      •  资源加载设置
      •  关于
        •  检查更新
        •  问题反馈
      •  环境切换
      •  退出

    发布功能

    •  多渠道打包
    •  签名配置
    •  开发与线上环境配置
    •  敏感信息保护

    配置运行

    1. 首先复制config.default.gradleconfig.gradle
    2. 配置下config.gradle内相关字段
    3. 正式打包需要自己生成签名文件,然后修改下config.gradlesignings签名信息
    4. 需配合服务器端一起使用,修改上边config.gradle配置文件的baseDebugUrlbaseReleaseUrl

    参与贡献

    如果你有什么好的想法,或者好的实现,可以通过下边的步骤参与进来,让我们一起把这个项目做得更好,欢迎参与 😁

    1. Fork本仓库
    2. 新建feature_xxx分支 (单独创建一个实现你自己想法的分支)
    3. 提交代码
    4. 新建Pull Request
    5. 等待Review & Merge

    其他

    下载体验 

    这就是一个使用当前模板运营的一个项目

    项目截图

    这里简单截取了几个界面,更多功能自己去发现吧

    matchHome matchExplore matchMsg matchMine matchChat matchChatFast matchCall matchAbout matchInfo matchAbout

    交流

    QQ群: 901211985 个人QQ: 1565176197

    QQ 交流群 个人 QQ
    收起阅读 »

    android(6大布局)

    LinearLayout(线性布局) RelativeLayout(相对布局) TableLayout(表格布局) FrameLayout(帧布局) FrameLayout的属性很少就两个,但是在说之前我们先介绍一个东西: 前景图像:永远处于帧布局最上面...
    继续阅读 »


    LinearLayout(线性布局)
    在这里插入图片描述
    RelativeLayout(相对布局)
    在这里插入图片描述
    TableLayout(表格布局)
    在这里插入图片描述
    FrameLayout(帧布局)
    FrameLayout的属性很少就两个,但是在说之前我们先介绍一个东西:
    前景图像:永远处于帧布局最上面,直接面对用户的图像,就是不会被覆盖的图片。
    两个属性:
    android:foreground:*设置改帧布局容器的前景图像
    android:foregroundGravity:设置前景图像显示的位置


    GridLayout(网格布局)
    在这里插入图片描述


    AbsoluteLayout(绝对布局)
    1.四大控制属性(单位都是dp):
    ①控制大小: android:layout_width:组件宽度 android:layout_height:组件高度 ②控制位置: android:layout_x:设置组件的X坐标 android:layout_y:设置组件的Y坐标


    收起阅读 »

    Android四大组件的启动分析与整理(二):Service的启动过程

    前言 换工作后,一直忙,没时间整理,逼自己一把吧,目标一周整理出来,理顺思路,这里先起个头。 service的启动过程分两种,一种是直接start,另一种是bind;我们先来分析第一种,直接start过程要简单的多。一样,先分析源码,然后一幅图总结: st...
    继续阅读 »


    前言


    换工作后,一直忙,没时间整理,逼自己一把吧,目标一周整理出来,理顺思路,这里先起个头。
    service的启动过程分两种,一种是直接start,另一种是bind;我们先来分析第一种,直接start过程要简单的多。一样,先分析源码,然后一幅图总结:


    startService()


    	startService(new Intent());
    public ComponentName startService(Intent service) {
    return mBase.startService(service);
    }

    startService();其实是调用了ContextWrapper中的startService方法,ContextWrapper我的理解是一个外观模式,他基本没有什么自己的东西,而是都去间接调用mBase中的方法,mBase,其实就是Context的实现类ContextImpl类;在 Activity的启动过程 的最后已经介绍了,这个ContextImpl是怎么来的了,这里不多将,继续。


        public ComponentName startService(Intent service) {
    warnIfCallingFromSystemProcess();
    return startServiceCommon(service, false, mUser);
    }
    private ComponentName startServiceCommon(Intent service, boolean requireForeground,
    UserHandle user) {
    try {
    validateServiceIntent(service);
    service.prepareToLeaveProcess(this);
    ComponentName cn = ActivityManager.getService().startService(
    mMainThread.getApplicationThread(), service, service.resolveTypeIfNeeded(
    getContentResolver()), requireForeground,
    getOpPackageName(), user.getIdentifier());
    ..................
    return cn;
    } catch (RemoteException e) {
    throw e.rethrowFromSystemServer();
    }
    }

    这个地方非常熟悉了,调用了AMS的startService方法;


    public ComponentName startService(IApplicationThread caller, Intent service,
    String resolvedType, boolean requireForeground, String callingPackage, int userId)
    throws TransactionTooLargeException {
    enforceNotIsolatedCaller("startService");
    ...............
    synchronized(this) {
    final int callingPid = Binder.getCallingPid();
    final int callingUid = Binder.getCallingUid();
    final long origId = Binder.clearCallingIdentity();
    ComponentName res;
    try {
    res = mServices.startServiceLocked(caller, service,
    resolvedType, callingPid, callingUid,
    requireForeground, callingPackage, userId);
    } finally {
    Binder.restoreCallingIdentity(origId);
    }
    return res;
    }
    }

    这里将启动工作委托给了ActiveService,就像Activity启动的时候将委托工作交给ActivityStarter一样;


    ComponentName startServiceLocked(IApplicationThread caller, Intent service, String resolvedType,
    int callingPid, int callingUid, boolean fgRequired, String callingPackage, final int userId)
    throws TransactionTooLargeException {
    final boolean callerFg;
    if (caller != null) {
    final ProcessRecord callerApp = mAm.getRecordForAppLocked(caller);(1)
    ..................
    } else {
    callerFg = true;
    }
    ServiceLookupResult res =
    retrieveServiceLocked(service, resolvedType, callingPackage,
    callingPid, callingUid, userId, true, callerFg, false);(2)
    ..................
    ServiceRecord r = res.record;(3)
    if (!mAm.mUserController.exists(r.userId)) {
    return null;
    }
    ..................
    ComponentName cmp = startServiceInnerLocked(smap, service, r, callerFg, addToStarting);(4)
    return cmp;
    }

    这个方法很长,主要是为了获取ProcessRecorder和ServiceRecorder,就跟Activity启动需要ProcessRecorder和ActivityRecorder一样。
    (2)处先从缓存中查找,没有的话直接new一个对象
    (4)处继续调用startServiceInnerLocked方法,这个方法调用了bringUpServiceLocked()方法。


        private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
    boolean whileRestarting, boolean permissionsReviewRequired)
    throws TransactionTooLargeException {
    if (r.app != null && r.app.thread != null) {
    sendServiceArgsLocked(r, execInFg, false);1
    return null;
    }
    final boolean isolated = (r.serviceInfo.flags&ServiceInfo.FLAG_ISOLATED_PROCESS) != 0;
    final String procName = r.processName;
    String hostingType = "service";
    ProcessRecord app;
    if (!isolated) {
    app = mAm.getProcessRecordLocked(procName, r.appInfo.uid, false);
    if (app != null && app.thread != null) {
    try {
    app.addPackage(r.appInfo.packageName, r.appInfo.versionCode, mAm.mProcessStats);
    realStartServiceLocked(r, app, execInFg);(2)
    return null;
    }
    }
    } else {
    .......................
    }
    if (app == null && !permissionsReviewRequired) {(1)
    if ((app=mAm.startProcessLocked(procName, r.appInfo, true, intentFlags,
    hostingType, r.name, false, isolated, false)) == null) {
    bringDownServiceLocked(r);
    return msg;
    }
    if (isolated) {
    r.isolatedProc = app;
    }
    }
    .......................
    return null;
    }

    (1)处是发送service的入参,就是走的onStartCommand()方法,这里第一次进来,app为null,因为ServiceRecorder是新new出来的
    (2)从AMS中获取ProcessRecorder,获取到成功之后,调用realStartServiceLocked()方法去启动service
    (3)如果上一步没有获取到ProcessRecorder,那么就创建一个,这个过程跟Activity创建进程是一样,都是通过Zygote去执行Process.start方法创建新的进程


        private final void realStartServiceLocked(ServiceRecord r,
    ProcessRecord app, boolean execInFg) throws RemoteException {
    .................
    try {
    .................
    app.thread.scheduleCreateService(r, r.serviceInfo,mAm.compatibilityInfoForPackageLocked(r.serviceInfo.applicationInfo),
    app.repProcState);1
    r.postNotification();
    created = true;
    }
    .................
    sendServiceArgsLocked(r, execInFg, true);2
    .................
    }

    (1)通知ApplicationThread去执行scheduleCreateService方法,
    (2)创建完了之后,发送入参,也就是调用哦那onStartCommand()方法。


            public final void scheduleCreateService(IBinder token,
    ServiceInfo info, CompatibilityInfo compatInfo, int processState) {
    updateProcessState(processState, false);
    CreateServiceData s = new CreateServiceData();
    s.token = token;
    s.info = info;
    s.compatInfo = compatInfo;
    sendMessage(H.CREATE_SERVICE, s);
    }

    然后就是非常熟悉的地方了,发送handler:CREATE_SERVICE消息


                    case CREATE_SERVICE:
    Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, ("serviceCreate: " + String.valueOf(msg.obj)));
    handleCreateService((CreateServiceData)msg.obj);
    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
    break;

    private void handleCreateService(CreateServiceData data) {
    unscheduleGcIdler();
    LoadedApk packageInfo = getPackageInfoNoCheck(
    data.info.applicationInfo, data.compatInfo);
    Service service = null;
    try {
    java.lang.ClassLoader cl = packageInfo.getClassLoader();
    service = (Service) cl.loadClass(data.info.name).newInstance();1
    }
    .................
    try {
    ContextImpl context = ContextImpl.createAppContext(this, packageInfo);2
    context.setOuterContext(service);
    Application app = packageInfo.makeApplication(false, mInstrumentation);3
    service.attach(context, this, data.info.name, data.token, app,
    ActivityManager.getService());4
    service.onCreate();5
    mServices.put(data.token, service);
    .................
    }
    .................
    }

    跟启动Activity一样,需要两个必备因素,Context和Application
    (1)处跟Activity一样通过反射创建Service
    (2)处new一个上下文,跟Activity的区别就是不需要传入AMS和classloader
    (3)处跟Activity一样通过反射创建Application
    (4)处attach上去,将context、app、AMS、binder等都封装进去
    (5)处执行onCreate()方法,区别是Activity通过Instrumentation去创建,这里直接调用


    bindService()


    这里追加一下bindService的过程:
    从调用bindService(new Intent(), mConnection, Context.BIND_AUTO_CREATE);开始,跟startService一样,走的context中的方法,然后调用了bindServiceCommon()


    private boolean bindServiceCommon(Intent service, ServiceConnection conn, int flags, Handler
    handler, UserHandle user) {
    IServiceConnection sd;
    if (conn == null) {
    throw new IllegalArgumentException("connection is null");
    }
    if (mPackageInfo != null) {
    sd = mPackageInfo.getServiceDispatcher(conn, getOuterContext(), handler, flags);(1)
    } else {
    throw new RuntimeException("Not supported in system context");
    }
    validateServiceIntent(service);
    try {
    .............
    int res = ActivityManager.getService().bindService(
    mMainThread.getApplicationThread(), getActivityToken(), service,
    service.resolveTypeIfNeeded(getContentResolver()),
    sd, flags, getOpPackageName(), user.getIdentifier());(2)
    .............
    } catch (RemoteException e) {
    throw e.rethrowFromSystemServer();
    }
    }

    (1)跟startService不一样的是,需要先获取IServiceConnection,从名字可以看出实现了binder,那么service就可以跨进程绑定了,IServiceConnection内部new了一个ServiceDispatcher对象,ServiceDispatcher的内部类InnerConnection就是继承了IServiceConnection.stub,实现binder的。
    (2)走AMS的bindService方法,AMS委托给了ActiveService去执行bindServiceLocked()


        int bindServiceLocked(IApplicationThread caller, IBinder token, Intent service,
    String resolvedType, final IServiceConnection connection, int flags,
    String callingPackage, final int userId) throws TransactionTooLargeException {
    final ProcessRecord callerApp = mAm.getRecordForAppLocked(caller);1
    ................
    ServiceLookupResult res =2
    retrieveServiceLocked(service, resolvedType, callingPackage, Binder.getCallingPid(),
    Binder.getCallingUid(), userId, true, callerFg, isBindExternal);
    ................
    ServiceRecord s = res.record;
    try {
    ................
    AppBindRecord b = s.retrieveAppBindingLocked(service, callerApp);3
    ConnectionRecord c = new ConnectionRecord(b, activity,
    connection, flags, clientLabel, clientIntent);4
    ................
    if ((flags&Context.BIND_AUTO_CREATE) != 0) {
    s.lastActivity = SystemClock.uptimeMillis();
    if (bringUpServiceLocked(s, service.getFlags(), callerFg, false,
    permissionsReviewRequired) != null) {5
    return 0;
    }
    }
    if (s.app != null && b.intent.received) {
    try {
    c.conn.connected(s.name, b.intent.binder, false);6
    } catch (Exception e) {
    }
    if (b.intent.apps.size() == 1 && b.intent.doRebind) {
    requestServiceBindingLocked(s, b.intent, callerFg, true);7
    }
    } else if (!b.intent.requested) {
    requestServiceBindingLocked(s, b.intent, callerFg, false);8
    }
    getServiceMapLocked(s.userId).ensureNotStartingBackgroundLocked(s);
    } finally {
    Binder.restoreCallingIdentity(origId);
    }
    return 1;
    }

    (1)处拿到请求者的进程
    (2)处创建必备的条件:ServiceRecord
    (3、4)处bind要比start多两个对象,AppBindRecord和ConnectionRecord,AppBindRecord对象是
    (5)处因为flag是BIND_AUTO_CREATE,因此走bringUpServiceLocked方法去创建Service
    (6)创建成功后,如果b.intent.received表示已经接受到了绑定的bind就会执行c.conn.connected,这个c.conn就是IServiceConnection,前面bindServiceCommon就讲了,ServiceConnection被封到了LoaderApk中的内部类ServiceDispatcher中,ServiceDispatcher的内部类innerConnection继承了IServiceConnection.stub类,并调用ServiceDispatcher的connect方法,并向mActivityThread 的handler发送一个runnable方法执行mConnection.onServiceConnected回调,到此绑定成功。
    (7)如果第一次bind且还没有rebind过,requestServiceBindingLocked第三个参数为true表重新绑定
    (8)如果创建成功还没有绑定,就执行requestServiceBindingLocked第三个参数为false
    这里第一次bind应该是创建了但还没有发送请求,走的8;


    private final boolean requestServiceBindingLocked(ServiceRecord r, IntentBindRecord i,
    boolean execInFg, boolean rebind) throws TransactionTooLargeException {
    if ((!i.requested || rebind) && i.apps.size() > 0) {(1)
    try {
    bumpServiceExecutingLocked(r, execInFg, "bind");
    r.app.forceProcessStateUpTo(ActivityManager.PROCESS_STATE_SERVICE);
    r.app.thread.scheduleBindService(r, i.intent.getIntent(), rebind,
    r.app.repProcState);2
    if (!rebind) {
    i.requested = true;3
    }
    i.hasBound = true;
    i.doRebind = false;4
    } catch (TransactionTooLargeException e) {
    .............
    } catch (RemoteException e) {
    .............
    }
    }
    return true;
    }

    (1)第一次进来,i.requested没有发送过请求,因此为false,不是重新rebind,在创建AppBinderRecord的时候,i.apps.size() > 0;
    (2)熟悉的一幕,发送scheduleBindService方法,然后发送BIND_SERVICE,然后执行handleBindService方法
    (3、4)设置标志位,请求过了,非重绑


    private void handleBindService(BindServiceData data) {
    Service s = mServices.get(data.token);
    if (s != null) {
    try {
    data.intent.setExtrasClassLoader(s.getClassLoader());
    data.intent.prepareToEnterProcess();
    try {
    if (!data.rebind) {
    IBinder binder = s.onBind(data.intent);
    ActivityManager.getService().publishService(
    data.token, data.intent, binder);
    } else {
    s.onRebind(data.intent);
    ActivityManager.getService().serviceDoneExecuting(
    data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
    }
    ensureJitEnabled();
    } catch (RemoteException ex) {
    }
    } catch (Exception e) {
    }
    }
    }


    没有rebind过的话,通知AMS去执行publishService方法,如果是rebind操作,那么就直接s.onRebind方法,然后通知AMS绑定结束。这里第一次进来,通知AMS去publishService,然后委托ActiveService去执行publishServiceLocked方法;


    void publishServiceLocked(ServiceRecord r, Intent intent, IBinder service) {
    final long origId = Binder.clearCallingIdentity();
    try {
    if (r != null) {
    Intent.FilterComparison filter
    = new Intent.FilterComparison(intent);
    IntentBindRecord b = r.bindings.get(filter);
    if (b != null && !b.received) {
    b.binder = service;
    b.requested = true;(1)
    b.received = true;(2)
    for (int conni=r.connections.size()-1; conni>=0; conni--) {
    ArrayList<ConnectionRecord> clist = r.connections.valueAt(conni);
    for (int i=0; i<clist.size(); i++) {
    ConnectionRecord c = clist.get(i);(3)
    .....................
    try {
    c.conn.connected(r.name, service, false);3
    } catch (Exception e) {
    .....................
    }
    }
    }
    }
    serviceDoneExecutingLocked(r, mDestroyingServices.contains(r), false);
    }
    } finally {
    Binder.restoreCallingIdentity(origId);
    }
    }

    (1)处设置已请求,(2)处设置已绑定;(3)处就是调用IServiceConnection的connect方法。


    		private static class InnerConnection extends IServiceConnection.Stub {
    final WeakReference<LoadedApk.ServiceDispatcher> mDispatcher;
    InnerConnection(LoadedApk.ServiceDispatcher sd) {
    mDispatcher = new WeakReference<LoadedApk.ServiceDispatcher>(sd);
    }
    public void connected(ComponentName name, IBinder service, boolean dead)
    throws RemoteException {
    LoadedApk.ServiceDispatcher sd = mDispatcher.get();
    if (sd != null) {
    sd.connected(name, service, dead);1
    }
    }
    }
    public void connected(ComponentName name, IBinder service, boolean dead) {
    if (mActivityThread != null) {2
    mActivityThread.post(new RunConnection(name, service, 0, dead));
    } else {
    doConnected(name, service, dead);
    }
    }
    private final class RunConnection implements Runnable {
    .........
    public void run() {
    if (mCommand == 0) {
    doConnected(mName, mService, mDead);
    } else if (mCommand == 1) {
    doDeath(mName, mService);
    }
    }
    .........
    }
    ----------------doConnected-----------------
    mConnection.onServiceConnected(name, service);

    在bind的第一步,其实就将ServiceConnection封装到了ServiceDispatcher中了,其内部类InnerConnection 继承了IServiceConnection.Stub,那么就可以通过binder进行跨进程的通信了,很方便。
    上一步骤的(3)其实就是调用了innerConnection的connect方法(1)处
    (2)处mActivityThread其实就是ActivityThread的handler方法执行run方法,简介调用了doConnected,然后调用mConnection的onServiceConnected()方法,这个mConnection其实就是我们自定义的ServiceConnection类,就此结束;


    startService图解:


    在这里插入图片描述


    bindService图解:


    在这里插入图片描述


    ————————————————
    版权声明:本文为CSDN博主「蒋八九」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/massonJ/article/details/117914349

    收起阅读 »

    Android四大组件的启动分析与整理(一):Activity的启动过程

    前言 换工作后,一直忙,没时间整理,逼自己一把吧,目标一周整理出来,理顺思路,这里先起个头。 首先Activity的启动分两种,一种是根Activity的启动,另一种是普通Activity的启动,根Activity的启动,从LauncherActivity...
    继续阅读 »


    前言


    换工作后,一直忙,没时间整理,逼自己一把吧,目标一周整理出来,理顺思路,这里先起个头。


    首先Activity的启动分两种,一种是根Activity的启动,另一种是普通Activity的启动,根Activity的启动,从LauncherActivity开始,启动方式跟我们平时的startActivity是基本一样的。


    public boolean startActivitySafely(View v, Intent intent, ItemInfo item) {
    。。。。。。。。。。。
    //设置flag为new task
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    。。。。。。。。。。。
    if (Utilities.ATLEAST_MARSHMALLOW
    && (item instanceof ShortcutInfo)
    && (item.itemType == Favorites.ITEM_TYPE_SHORTCUT
    || item.itemType == Favorites.ITEM_TYPE_DEEP_SHORTCUT)
    && !((ShortcutInfo) item).isPromise()) {
    // Shortcuts need some special checks due to legacy reasons.
    startShortcutIntentSafely(intent, optsBundle, item);
    } else if (user == null || user.equals(Process.myUserHandle())) {
    // Could be launching some bookkeeping activity
    //通过startActivity开启
    startActivity(intent, optsBundle);
    } else {
    LauncherAppsCompat.getInstance(this).startActivityForProfile(
    intent.getComponent(), user, intent.getSourceBounds(), optsBundle);
    }
    return true;
    } catch (ActivityNotFoundException|SecurityException e) {
    Toast.makeText(this, R.string.activity_not_found, Toast.LENGTH_SHORT).show();
    Log.e(TAG, "Unable to launch. tag=" + item + " intent=" + intent, e);
    }
    return false;
    }

    当点击桌面的图标的时候,调用startActivitySafely(),然后先设置它的flag是new task,然后调用Activity的startActivity()方法,然后调用继续调用startActivityForResult(intent, -1, options);


    	public void startActivityForResult(@RequiresPermission Intent intent, int requestCode,
    @Nullable Bundle options) {
    if (mParent == null) {
    options = transferSpringboardActivityOptions(options);
    Instrumentation.ActivityResult ar =
    mInstrumentation.execStartActivity(
    this, mMainThread.getApplicationThread(), mToken, this,
    intent, requestCode, options);
    。。。。。。。。。。。。
    } else {
    。。。。。。。。。。。。
    }
    }

    因为根Activity,mParent肯定为null,Activity最终都会通过Instrumentation工具类去执行,这里就调用了execStartActivity()方法。


    	public ActivityResult execStartActivity(
    Context who, IBinder contextThread, IBinder token, String target,
    Intent intent, int requestCode, Bundle options) {
    IApplicationThread whoThread = (IApplicationThread) contextThread;
    。。。。。。。。。。
    try {
    intent.migrateExtraStreamToClipData();
    intent.prepareToLeaveProcess(who);
    int result = ActivityManager.getService()
    .startActivity(whoThread, who.getBasePackageName(), intent,
    intent.resolveTypeIfNeeded(who.getContentResolver()),
    token, target, requestCode, 0, null, options);
    checkStartActivityResult(result, intent);
    } catch (RemoteException e) {
    throw new RuntimeException("Failure from system", e);
    }
    return null;
    }

    instrumentation中通过调用ActivityManager.getService()方法,得到AMS,然后调用AMS中的startActivity方法继续执行。


        public static IActivityManager getService() {
    return IActivityManagerSingleton.get();
    }

    private static final Singleton<IActivityManager> IActivityManagerSingleton =
    new Singleton<IActivityManager>() {
    @Override
    protected IActivityManager create() {
    final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
    final IActivityManager am = IActivityManager.Stub.asInterface(b);
    return am;
    }
    };

    这里需要注意一下,在Android8.0以前,都是通过ActivityManagerNative.getDefalt()方法,然后通过IActiv tyManager am= asinterface (b) ; 去获取AMS的代理类ActivityManagerProxy对象的。在asinterface 中直接new ActivityManagerProxy(b)并返回,8.0之后通过通过IActivityManager.Stub.asInterface(b)去获得,典型的AIDL写法AMS中也继承了IActivityManager.Stub。


    @Override
    public final int startActivity(.....) {
    return startActivityAsUser(.....,UserHandle.getCallingUserId());
    }
    public final int startActivityAsUser(.....) {
    enforceNotIsolatedCaller("startActivity");1
    userId = mUserController.handleIncomingUser(Binder.getCallingPid(), Binder.getCallingUid(),2
    userId, false, ALLOW_FULL_ONLY, "startActivity", null);
    return mActivityStarter.startActivityMayWait(caller, -1, callingPackage, intent,
    resolvedType, null, null, resultTo, resultWho, requestCode, startFlags,
    profilerInfo, null, null, bOptions, false, userId, null, null,
    "startActivityAsUser");
    }

    (1)处判断调用者的进程是否被隔离,如果是就抛出SecurityException异常。
    (2)处检查调用者是否有权限,如果没有也会抛出SecurityException异常。
    然后继续调用ActivityStarter中的startActivityMayWait()方法,ActivityStarter是Activity的一个控制类,主要将flag和intent转为Activity,然后将Activity和task以及stack关联起来。


    int startActivityLocked(...., String reason) {
    if (TextUtils.isEmpty(reason)) {1
    throw new IllegalArgumentException("Need to specify a reason.");
    }
    mLastStartReason = reason;
    mLastStartActivityTimeMs = System.currentTimeMillis();
    mLastStartActivityRecord[0] = null;
    mLastStartActivityResult = startActivity(....);
    if (outActivity != null) {
    outActivity[0] = mLastStartActivityRecord[0];
    }
    return mLastStartActivityResult;
    }

    startActivityMayWait()方法很长,其中调用了startActivityLocked方法,(1)处就是之前传入的“startActivityAsUser”参数,用来说明调用原因,如果没有原因,抛出IllegalArgument异常,然后继续调用startActivity方法。


    private int startActivity(IApplicationThread caller,.....ActivityRecord[] outActivity,.....) {
    int err = ActivityManager.START_SUCCESS;
    final Bundle verificationBundle
    = options != null ? options.popAppVerificationBundle() : null;
    ProcessRecord callerApp = null;
    if (caller != null) {1
    //获取Launcher进程
    callerApp = mService.getRecordForAppLocked(caller);//2
    if (callerApp != null) {
    //获取Launcher进程的pid和uid并赋值
    callingPid = callerApp.pid;
    callingUid = callerApp.info.uid;
    } else {
    Slog.w(TAG,.....);
    err = ActivityManager.START_PERMISSION_DENIED;
    }
    }
    ...
    //创建即将要启动的Activity的描述类ActivityRecord
    ActivityRecord r = new ActivityRecord(mService, callerApp, callingPid, callingUid,
    callingPackage, intent, resolvedType, aInfo, mService.getGlobalConfiguration(),
    resultRecord, resultWho, requestCode, componentSpecified, voiceSession != null,
    mSupervisor, container, options, sourceRecord);2
    if (outActivity != null) {
    outActivity[0] = r;3
    }
    ...
    doPendingActivityLaunchesLocked(false);
    return startActivity(r, sourceRecord, voiceSession, voiceInteractor, startFlags, true,
    options, inTask, outActivity);//4
    }

    这里首先在(1)处通过AMS的getRecordForAppLocked方法获取请求进程对象callerApp ,因为是从launcher启动,所以这里是launcher所在的进程,他是一个ProcessRecord对象,然后拿到pid和uid。
    (2)处创建Activity信息,这样ProcessRecord和ActivityRecord就齐全了。继续startActivity;


        private int startActivityUnchecked(....) {
    ........
    int result = START_SUCCESS;
    if (mStartActivity.resultTo == null && mInTask == null && !mAddingToTask
    && (mLaunchFlags & FLAG_ACTIVITY_NEW_TASK) != 0) {
    newTask = true;
    result = setTaskFromReuseOrCreateNewTask(
    taskToAffiliate, preferredLaunchStackId, topStack);—————(1
    } else if (mSourceRecord != null) {
    result = setTaskFromSourceRecord();
    } else if (mInTask != null) {
    result = setTaskFromInTask();
    } else {
    .........
    setTaskToCurrentTopOrCreateNewTask();
    }
    ........
    if (mDoResume) {
    final ActivityRecord topTaskActivity =
    mStartActivity.getTask().topRunningActivityLocked();
    if (!mTargetStack.isFocusable()
    || (topTaskActivity != null && topTaskActivity.mTaskOverlay
    && mStartActivity != topTaskActivity)) {
    mTargetStack.ensureActivitiesVisibleLocked(null, 0, !PRESERVE_WINDOWS);
    mWindowManager.executeAppTransition();
    } else {
    if (mTargetStack.isFocusable() && !mSupervisor.isFocusedStack(mTargetStack)) {
    mTargetStack.moveToFront("startActivityUnchecked");
    }
    mSupervisor.resumeFocusedStackTopActivityLocked(mTargetStack, mStartActivity,
    mOptions);————(2
    }
    } else {
    mTargetStack.addRecentActivityLocked(mStartActivity);
    }
    .............
    return START_SUCCESS;
    }

    (1)处通过setTaskFromReuseOrCreateNewTask()方法,创建TaskRecorder,这样一来,ProcessRecorder、ActivityRecorder以及TaskRecorder都齐全了,
    (2)处调用resumeFocusedStackTopActivityLocked方法


        boolean resumeFocusedStackTopActivityLocked(
    ActivityStack targetStack, ActivityRecord target, ActivityOptions targetOptions) {
    if (targetStack != null && isFocusedStack(targetStack)) {
    return targetStack.resumeTopActivityUncheckedLocked(target, targetOptions);
    }
    final ActivityRecord r = mFocusedStack.topRunningActivityLocked();
    if (r == null || r.state != RESUMED) {
    mFocusedStack.resumeTopActivityUncheckedLocked(null, null);
    } else if (r.state == RESUMED) {
    mFocusedStack.executeAppTransition(targetOptions);
    }
    return false;
    }
    boolean resumeTopActivityUncheckedLocked(ActivityRecord prev, ActivityOptions options) {
    if (mStackSupervisor.inResumeTopActivity) {
    return false;
    }
    boolean result = false;
    try {
    mStackSupervisor.inResumeTopActivity = true;
    result = resumeTopActivityInnerLocked(prev, options);
    } finally {
    mStackSupervisor.inResumeTopActivity = false;
    }
    mStackSupervisor.checkReadyForSleepLocked();
    return result;
    }

    这里因为我们启动的是根Activity,那么topActivity肯定是为没有在running状态的,走的resumeTopActivityUncheckedLocked方法,然后执行resumeTopActivityInnerLocked方法。


        private boolean resumeTopActivityInnerLocked(ActivityRecord prev, ActivityOptions options) {
    ........
    ActivityStack lastStack = mStackSupervisor.getLastStack();
    if (next.app != null && next.app.thread != null) {
    final boolean lastActivityTranslucent = lastStack != null
    && (!lastStack.mFullscreen
    || (lastStack.mLastPausedActivity != null
    && !lastStack.mLastPausedActivity.fullscreen));
    ..........
    } else {
    ..........
    mStackSupervisor.startSpecificActivityLocked(next, true, true);
    }
    if (DEBUG_STACK) mStackSupervisor.validateTopActivitiesLocked();
    return true;
    }

    这里代码很多,最终执行的是ActivityStackSupervisor类中的startSpecificActivityLocked方法。


        void startSpecificActivityLocked(ActivityRecord r,
    boolean andResume, boolean checkConfig) {
    ProcessRecord app = mService.getProcessRecordLocked(r.processName,
    r.info.applicationInfo.uid, true);
    r.getStack().setLaunchTime(r);
    if (app != null && app.thread != null) {
    try {
    if ((r.info.flags&ActivityInfo.FLAG_MULTIPROCESS) == 0
    || !"android".equals(r.info.packageName)) {
    app.addPackage(r.info.packageName, r.info.applicationInfo.versionCode,
    mService.mProcessStats);
    }
    realStartActivityLocked(r, app, andResume, checkConfig);(1)
    return;
    } catch (RemoteException e) {
    }
    }
    mService.startProcessLocked(r.processName, r.info.applicationInfo, true, 0,
    "activity", r.intent.getComponent(), false, false, true);(2)
    }

    这里先获取启动进程ProcessRecord 然后调用realStartActivityLocked()方法;
    如果ProcessRecorder进程为null那么就通过AMS的startProcessLocked去执行Process.start创建。


        final boolean realStartActivityLocked(ActivityRecord r, ProcessRecord app,
    boolean andResume, boolean checkConfig) throws RemoteException {
    ..........
    app.thread.scheduleLaunchActivity(new Intent(r.intent), r.appToken,
    System.identityHashCode(r), r.info,
    mergedConfiguration.getGlobalConfiguration(),
    mergedConfiguration.getOverrideConfiguration(), r.compat,
    r.launchedFromPackage, task.voiceInteractor, app.repProcState, r.icicle,
    r.persistentState, results, newIntents, !andResume,
    mService.isNextTransitionForward(), profilerInfo);
    ..........
    return true;
    }

    其中app.thread其实就是ProcessRecorder的IApplicationManager,也就是ActivityThread的内部类ApplicationThread。
    Activity启动过程其实就目标应用程序进程启动Activity的过程,这里的app就代表目标应用程序进程,那ApplicationThread继承了IApplicationThread.Stub就是目标应用程序与AMS进行binder通信的桥梁。
    最后通知ApplicationThread调用scheduleLaunchActivity去启动Activity;


    public final void scheduleLaunchActivity(Intent intent, IBinder token, int ident,
    ActivityInfo info, Configuration curConfig, Configuration overrideConfig,
    CompatibilityInfo compatInfo, String referrer, IVoiceInteractor voiceInteractor,
    int procState, Bundle state, PersistableBundle persistentState,
    List<ResultInfo> pendingResults, List<ReferrerIntent> pendingNewIntents,
    boolean notResumed, boolean isForward, ProfilerInfo profilerInfo) {
    updateProcessState(procState, false);
    ActivityClientRecord r = new ActivityClientRecord();
    r.token = token;
    r.ident = ident;
    r.intent = intent;
    r.referrer = referrer;
    r.voiceInteractor = voiceInteractor;
    r.activityInfo = info;
    r.compatInfo = compatInfo;
    r.state = state;
    r.persistentState = persistentState;
    r.pendingResults = pendingResults;
    r.pendingIntents = pendingNewIntents;
    r.startsNotResumed = notResumed;
    r.isForward = isForward;
    r.profilerInfo = profilerInfo;
    r.overrideConfig = overrideConfig;
    updatePendingConfiguration(curConfig);
    sendMessage(H.LAUNCH_ACTIVITY, r);
    }

    这里就是设置一堆属性,然后通过Activity的内部类H,其实就是handler类,发送LAUNCH_ACTIVITY消息,去执行


    case LAUNCH_ACTIVITY: {
    Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
    final ActivityClientRecord r = (ActivityClientRecord) msg.obj;
    r.packageInfo = getPackageInfoNoCheck(
    r.activityInfo.applicationInfo, r.compatInfo);
    handleLaunchActivity(r, null, "LAUNCH_ACTIVITY");
    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
    } break;
    ----------------handleLaunchActivity------------------
    Activity a = performLaunchActivity(r, customIntent);

    然后调用handleLaunchActivity,然后继续调用performLaunchActivity,要执行一个Activity首先需要两个必备因素,一个是Context上下文,一个是Application。


    private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
    .......................
    ContextImpl appContext = createBaseContextForActivity(r);1
    Activity activity = null;
    try {
    java.lang.ClassLoader cl = appContext.getClassLoader();
    activity = mInstrumentation.newActivity(
    cl, component.getClassName(), r.intent);2
    .......................
    } catch (Exception e) {
    .......................
    }
    try {
    Application app = r.packageInfo.makeApplication(false, mInstrumentation);3
    if (activity != null) {
    .......................
    appContext.setOuterContext(activity);
    activity.attach(appContext, this, getInstrumentation(), r.token,
    r.ident, app, r.intent, r.activityInfo, title, r.parent,
    r.embeddedID, r.lastNonConfigurationInstances, config,
    r.referrer, r.voiceInteractor, window, r.configCallback);4
    .......................
    if (r.isPersistable()) {
    mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);5
    } else {
    mInstrumentation.callActivityOnCreate(activity, r.state);
    }
    .......................
    return activity;
    }

    (1)处就是通过new ContextImpl的方式,创建Actviity的上下文。
    (2)处就是通过反射的方式,创建Actviity类。
    (3)处通过反射的方式,创建Application类,并把ContextImpl(这个上下文跟Activity的不一样,上面那个多了token和classloader)上下文attach到父类ContextWrapper中去,也就是mBase。
    (4)有了ContextImpl和Application,然后将Actviity做attach操作,就是将ContextImpl给父类ContextWrapper中的mBase,同时创建PhoneWindow。
    (5)处就是通过Instrumentation去调用oncreate方法
    罗里吧嗦讲那么多,先去上个厕所,回来用一张图总结一下:


    在这里插入图片描述



    ————————————————
    版权声明:本文为CSDN博主「蒋八九」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/massonJ/article/details/117914286


    收起阅读 »

    如何实现跨设备的双向连接? Labo涂鸦鸿蒙亲子版分布式开发技术分享

    近期,首届HarmonyOS开发者创新大赛正式落下帷幕。大赛共历时5个月,超过3000支队伍的10000多名选手参赛,25000多位开发者参与了大赛学习,最终23支参赛队伍斩获奖项,产出了多款有创新、有创意、有价值的优秀作品。其中由“Labo Lado儿童艺术...
    继续阅读 »

    近期,首届HarmonyOS开发者创新大赛正式落下帷幕。大赛共历时5个月,超过3000支队伍的10000多名选手参赛,25000多位开发者参与了大赛学习,最终23支参赛队伍斩获奖项,产出了多款有创新、有创意、有价值的优秀作品。其中由“Labo Lado儿童艺术创想”团队打造的《Labo涂鸦鸿蒙亲子版》就是其中之一,其创造性地通过HarmonyOS分布式技术,实现了多设备下的亲子互动涂鸦功能,最终摘得大赛一等奖。

    在很早以前,“Labo Lado儿童艺术创想”团队就做过一款涂鸦游戏的应用,该应用可以让孩子和父母在一个平板或者手机上进行绘画比赛,比赛的方式就是屏幕一分为二,两人各在设备的一边进行涂鸦。这种方式虽然有趣,但是对于绘画而言,屏幕尺寸限制了用户的发挥和操作。因此团队希望这类玩法能通过多个设备完成,于是他们研究了ZeroConf、iOS的Multipeer Connectivity、Google Nearby等近距离互联的技术, 结果发现这些技术在设备发现和应用拉起方面实现的都不理想,尤其是当目标用户是儿童的情况下,操作起来不够简便也不易上手。

    HarmonyOS的出现给团队带来了希望。他们发现HarmonyOS的分布式技术有着很大的应用潜力,这项技术让设备的发现和应用拉起变的非常的简单自然,互联的过程也很流畅,很好地解决了单机操作的限制,让跨设备联机功能能够非常容易地实现。此外,HarmonyOS的开发也给团队留下了很深刻的印象,以往繁琐的开发步骤,在 HarmonyOS 中仅需几个配置、几行代码即可完成,无需花费太多精力。在《Labo涂鸦鸿蒙亲子版》里面的5个分布式玩法的开发只用了团队一名开发者不到两个月的时间,其中还包括了学习上手、解决文档不全和各种疑难问题的过程。

    以下是“Labo Lado儿童艺术创想”团队基于HarmonyOS的分布式开发关键技术的简单分享:

    一、分布式技术实践

    HarmonyOS的分布式能力是在系统层面实现的,在设备双方同属一个局域网的情况下,设备都可以快速的发现和进行流畅的通讯。下面将从HarmonyOS设备的发现、应用的拉起、应用通讯和双向通讯几个部分来进行分享。

    1、设备的发现

    假设设备A想要邀请另外一个设备B加入,AB任何一方都无需启动特别的广播服务,只要发起方设备A在应用内调用设备发现代码,就可以列出附近符合条件可用的的设备。

    以下是获取设备列表的示例代码:

    public static List<DeviceInfo> getRemoteDevice() {

    List<DeviceInfo> deviceInfoList = DeviceManager.getDeviceList(DeviceInfo.FLAG_GET_ONLINE_DEVICE);

    return deviceInfoList;

    }

    列出设备之后,用户就可以通过设备名选择想要邀请的设备了。

    (左侧设备A发现右侧名为“ye”的设备B的界面展示)

    2、应用的拉起

    设备A邀请了设备B之后,如果设备B上应用没启动,设备A可直接通过调用startAbility方法来拉起设备B上的应用。双方应用都启动了之后,就可以进行RPC通讯了。如果需要事先检查设备B上的应用是否已经启动或者是否在后台,可通过在应用中增加一个PA来实现。在拉起之前,设备A先连接设备B的应用中的PA可以实现更复杂精准的远程应用启动控制。

    3、应用通讯

    在应用中启动一个PA,专门用作通讯的服务器端。当设备B的应用被拉起之后,设备A就会通过connectAbility与设备B的PA进行连接,通讯采用RPC方式实现,并使用IDL定义通讯接口。

    4、双向通讯

    RPC的通讯方式使用简单,但是只能支持单向通讯。为了实现双向通讯,可在设备A与设备B发起建立连接成功之后,再让设备B与设备A发起建立一个连接,用两个连接实现了双向通讯。下面是这两个连接建立过程的示意时序图:

    在设备A与设备B建立连接的时候,设备A必须将自己的DeviceId发送给设备B,然后设备B才可以主动发起一个与设备A的连接,获取当前设备的DeviceId方法如下:

    KvManagerFactory.getInstance().createKvManager(new KvManagerConfig(this)).getLocalDeviceInfo().getId()

    应用中,FA主要实现了界面层逻辑,PA部分用做数据通讯的服务端。为了防止拉起应用导致用户当前面的操作被中断,可通过PA来查询当前FA的状态,如果FA已经启动了,就跳过拉起,直接进行下一步操作即可。

    二、数据接口与数据结构定义

    使用了IDL定义了两个通用的接口,分别用来进行异步和同步调用:

    int sendSyncCommand([in] int command, [in] String params);

    void sendAsyncCommand([in] int command, [in] String params, [in] byte[] content);

    大部分情况下,远程调用大部分都通过同步的方式进行,用户之间的绘画数据通过异步接口传输,数据在用户绘制的时候采集,每50ms左右发送一次,这个频率可以大概保证用户视觉上没有卡顿,而又不至于因为接口过度调用导致卡顿或者耗电量过大。采集的绘画数据的数据结构大致如下:

    enum action //动作,表示落笔、移动、提笔等动作

    int tagId //多点触摸识别标记

    int x //x坐标

    int y //y坐标

    enum brushType //笔刷类型

    int brushSize //笔刷大小

    enum brushColor //笔刷颜色

    int layer //图层

    这款应用是支持多点触摸的,所以每个触摸点在落笔的的时候,都使用了tagId进行标记。这些数据除了通讯外,还会完整地保存在文件中,这样用户就可以通过应用内的播放功能播放该数据,回看绘画的整个过程。

    三、教程录制与曲线平滑

    1、教程制作

    这款产品的特色之一是教程是动态的,用户可以自己拼装或者通过游戏生成教程角色。目前应用内置六种教程。这些教程预先由设计师在photoshop中画好并标记各个部位,然后再通过专门的photoshop脚本导出到教程录制应用中,再由设计师按部位逐个进行临摹绘制,绘制完成,应用会将设计师的绘制过程数据保存为json文件,通过将这些json的文件里的部位互换,我们就实现了用户自己拼装教程的功能了。

    2、曲线平滑

    绘制过程,为了让用户绘制的曲线更加平滑,采用二次贝塞尔曲线算法进行差值(Quadratic Bezier Curve),该算法简单效率也非常不错:

    public Point quadraticBezier(Point p0, Point p1, Point p2, float t) {

    Point pFinal = new Point();

    pFinal.x = (float) (Math.pow(1 - t, 2) * p0.x + (1 - t) * 2 * t * p1.x + t * t * p2.x);

    pFinal.y = (float) (Math.pow(1 - t, 2) * p0.y + (1 - t) * 2 * t * p1.y + t * t * p2.y);

    return pFinal;

    }

    基于HarmonyOS的分布式特性,《Labo涂鸦鸿蒙亲子版》完成了一次已有应用的自我尝试和突破,大大的增加了用户在使用过程中的乐趣,为用户带来了全新的跨设备亲子交互体验,“Labo Lado儿童艺术创想”团队在未来将与更多的HarmonyOS开发者一起,为用户创作出更多更有趣的儿童创造类应用。

    近一段时间以来,HarmonyOS 2的发布吸引了广大开发者的关注。作为一款面向万物互联时代的智能终端操作系统,HarmonyOS 2带来了诸多新特性、新功能和新玩法,等待开发者去探索、去学习、去实践。也欢迎广大开发者继续发挥创造力和想象力,基于HarmonyOS开发出更多有创新、有创意、有价值的作品,打造出专属于万物互联时代的创新产品。

    收起阅读 »

    【面试官爸爸】来给我讲讲View绘制?

    前言 迎面走来的一位中年男子,他一手拿着保温杯,一手抱着笔记本电脑,顶着惺忪的睡眼,不紧不慢地走着,不多的几根头发在他头顶自由飞翔。过了一会,他面对着我坐下,放下电脑和保温杯,边揉眉头边对我说 “来面试的?” “对对对” 我赶紧答应 ...
    继续阅读 »

    前言


    迎面走来的一位中年男子,他一手拿着保温杯,一手抱着笔记本电脑,顶着惺忪的睡眼,不紧不慢地走着,不多的几根头发在他头顶自由飞翔。过了一会,他面对着我坐下,放下电脑和保温杯,边揉眉头边对我说



    “来面试的?”




    “对对对” 我赶紧答应




    “行吧,那你讲讲 View 的绘制流程吧”




    起一个好头


    View 的绘制流程应该是每个初高级 Android 攻城狮必知必会的东西,也是面试必考的内容,每个人都有不同的回答方式。


    简单点譬如 measure,layout,draw 分别对应测量,布局,绘制三个过程,高明一点的会引申出 Handler,同步屏障,View 的事件传递,甚至 activity 的启动过程。掌握哪些东西,如何回答,能够给面试官一种清晰,了然于胸的感觉,同时又不会被追问三连一问三不知。各位老爷听我慢慢道来。



    “噢噢,View 的绘制啊。这个可以分为顶级 View 的绘制,Viewgroup 的绘制和 View 的绘制三个方面。顶级 View 就是 ViewrootImpl”



    将回答的内容分类是体现自己思考能力和知识结构的重要表现。


    什么是 ViewRootImpl


    相比 Viewgroup 和 View,ViewRootImpl 可能更为陌生,实际开发中我们基本用不到它。那么



    什么是 ViewRootImpl 呢?



    从结构上来看,ViewRootImpl 和 ViewGroup 其实是一种东西


    图 9


    它们都继承了 ViewParent。ViewParent 是一个接口,定义了一些父 View 的基本行为,比如 requestlayout,getparent 等。不同的是,ViewRootImpl 并不会像 ViewGroup 一样被真正绘制在屏幕上。在 activity 中,它是专门用来绘制 DecorView 的,核心方法是 setView


    回答的好好的偏要问我其他问题


    提到 DecorView,就不得不说一下 window 了。面试中常常我们提到一个点,或者一个词,面试官会马上引申出这个知识点相关的问题。如果我们只是死记硬背,自顾自背一堆绘制相关的东西而回答不上来,会大大减分。所以储备与必问内容相关的东西对面试和自己的知识体系很有帮助。不少老爷被面试的时候都会被问到一个问题



    “activity,window,View 三者之间的关系是什么?”



    我们可以通过一张图来说明。


    图 1


    如图所示,window 是 activity 里的一个实例变量,本质是一个接口,唯一的实现类是 PhoneWindow。


    activity 的 setContentView 方法实际上是就是交给 phonewindow 去做的。window 和 View 的关系可以类比为显示器显示的内容


    每个 activity 都有一个“显示器” window,“显示的内容”就是 DecorView。这个“显示器”定义了一些方法来决定如何显示内容。比如 setTitleColor setTitle 是设置导航栏的颜色和 title , setAllowReturnTransitionOverlap 设置进/出场动画等等。


    所以 window 是 activity 的一个成员变量,window 和 View 是“显示器”和“显示内容”的关系。


    这就是他们的关系


    View 是怎么绘制的



    “呦呵,不错嘛,这个比喻不错,看来平时还挺爱思考的。行,你继续说说 View 是怎么绘制的”



    在整个 activity 的生命周期中,setContentView 是在 onCreate 中调用的,它实现了对资源文件的解析,完成了 xml 文件到 View 的转化。那么 View 真正开始绘制是在哪个生命周期呢?



    答案是 onResume 结束后



    他们的关系在源码中一目了然。


    图 4


    从源码中可以看到,onResume 之后,ActivityThread 通过调用 activity 中 windowmanager 的 addView 方法,将 decorView 传入到 ViewRootImpl 的 setView 方法中,通过 setView 来完成 View 的绘制。


    问题又来了,setView 到底有什么魔法,为什么他就能完成 View 的绘制工作呢?


    ViewRootImpl 是如何绘制 View 的


    我们再来看一下 setView 方法


    图 5


    简单来说 setView 做了三件事


    ① 检查绘制的线程是不是创建 View 的线程。这里可以引申出一个问题,View 的绘制必须在主线程吗?


    ② 通过内存屏障保证绘制 View 的任务是最优先的


    ③ 调用 performTraversals 完成 measure,layout,draw 的绘制


    看到这里,ViewRootImpl 的绘制基本就完成了。其实这也是面试官希望听到的内容。考察的是面试者对 View 绘制体系的理解。


    后续 ViewGroup 和 View 的绘制其实是 performTraversals 对整个 ViewTree 的绘制。他们的关系可以用下面这张图表示


    图 2


    考考你对知识的运用



    “不错不错,看来你对 Viewrootimpl 的绘制过程掌握的不错嘛,你刚才提到 View 的绘制是在 onResume 之后才开始的,那为什么我在 onCreate 中调用 View.post 方法可以得到 View 的宽高呢”



    这个问题乍看挺唬人的。其实看一眼源码大概就明白了


    图 6


    View.post 会判断当前 View 是否已经被添加到 window 上。如果添加了则立即执行 runnable,如果没有被添加则先放到一个队列中存储起来,等添加到 window 上时再执行。


    而 View 被测量完成后才会 attachToWindow。所以当 post 的 runnable 执行时,View 已经绘制完成了。


    MeasureSpec 的理解



    “可以可以。看来这个小细节你注意到了。再问你个简单的问题,你刚才说到 measure 方法吧,那你说说什么是 MeasureSpec?为什么测量宽高要用它作为参数呢?”



    这个问题看似很简单死板,其实是想考察对 View 测量的理解。


    View 的大小不仅仅取决于自身的宽高,还取决于父 View 的大小和测量模式。一个 200200 的父 View 是不可能容纳一个 300300 的子 View 的,父 View 的 wrap_content 和 match_content 也会影响子 View 的大小。


    所以 View 的 measure 函数其实应该有 4 个参数:父 View 的宽父 View 的高宽的测量模式高的测量模式


    Android 这里用了一个巧妙的设计,用一个 Int 值来表示宽/高的测量模式和大小。一个 int 有 32 位,前 2 位表示测量 MODE,后 30 位表示 SIZE。


    为什么要用 2 位表示 MODE 呢?因为 MODE 只有 3 种呀,UNSPECIFIED,EXACTLY,AT_MOST,小傻瓜。




    “不错啊小伙子,那我自定义一个 View 的时候,如果不对 MeasureSpec 做处理。使用这个 View 时宽高传入 wrap_content,结果会怎么样?”



    这个考察的就是 View 绘制的实际运用了。当我们自定义一个 View 时,如果继承的是 View,measure 方法走的就是 View 默认的逻辑


    图 7


    所以当我们自定义 View 时,如果没有对 MODE 做处理,设置 wrap_content 和 match_content 结果其实是一样的,View 的宽高都是取父 View 的宽高。


    再来点细节



    “呦呵,那你说说 invaliate 和 requestlayout 方法的区别”



    前面我们说到,ViewRootImpl 作为顶级 View 负责 View 的绘制。所以简单来说,requestlayout 和 invaliate 最终都会向上回溯调用到 ViewRootImpl 的 postTranversals 方法来绘制 View。


    不同的是 requestlayout 会绘制 View 的 measure,layout 和 draw 过程。invaliate 因为只添加了绘制 draw 的标志位,只会绘制 draw 过程。


    这也能考算法



    “可以可以,看来 View 绘制这块你理解的不错嘛。来考你个小算法,实现一下 findViewbyid 的过程”



    一般对开发而言,算法的考察都不会太深,主要是常见算法的简单使用。目的是对业务中遇到的一些问题有更好的解决思路。像这个问题其实是想考察一下递归算法的简单使用。


    图 8



    “小伙子准备的不错嘛,好了,View 绘制这块我没有什么问题了,我们来聊聊 View 事件处理吧....”



    View 绘制相关的问题到这里就结束啦。如果大家觉得还不错的话,欢迎各位点赞,收藏,关注三连~


    后续我还会继续更新【面试官爸爸】这个系列,包括事件处理HandlerActivity 启动流程编译打包优化Context 等面试最常问的问题。如果不想错过,欢迎点赞,收藏,关注我!


    也可以关注我的公众号 @方木Rudy 里面不仅有技术,还有故事和感悟。你的支持,是我不断创作的动力!


    哦对了,是不是看完一遍觉得不够爽?杂七杂八说一大堆复习的时候一点也不轻松! 嘿嘿,我把上面提到的所有问题整理成了思维导图,方便各位观众老爷复习 ~


    图 1


    作者:方木Rudy
    链接:https://juejin.cn/post/6979395482946633758
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    Android平台debug完全解析

    一:Java程序调试原理:java这种上层语言编译结果是字节码,字节码需要jvm解释执行,那么调试java具体就是和jvm通信的问题,一般IDE中对Java程序的调试功能都是对jdb的包装,关于jvm调试体系网上有很多文章,比如:juejin.cn/post/...
    继续阅读 »

    一:Java程序调试原理:

    java这种上层语言编译结果是字节码,字节码需要jvm解释执行,那么调试java具体就是和jvm通信的问题,一般IDE中对Java程序的调试功能都是对jdb的包装,关于jvm调试体系网上有很多文章,比如:juejin.cn/post/688739… ,不赘述了

    二:Native 程序调试原理:

    native代码包含的是对应平台的cpu指令,是直接cpu跑的,对native代码调试需要cpu的支持(比如int3软中断指令),以及操作系统的协助(比如Linux的ptrace系统调用),lldb,gdb,IDA的android_server等调试器都是基于上面的功能实现的,具体网上有资料,比如:zhuanlan.zhihu.com/p/336922639 ,不赘述了

    三:Class,Dex,Elf三种文件指令和源码对应关系描述结构:

    1:class字节码和源码行号对应关系描述结构:

    image.png

    2:dex字节码和源码行号对应关系描述结构:

    image.png

    3:elf指令和源码行号对应关系描述结构:

    image.png

    4:小结

    当class没有了行号,那只能反编译调试class指令

    当dex没有了行号,那只能反编译调试smali指令

    当elf没有了行号,那只能反汇编调试汇编指令

    四:调试Android Studio

    AS本质上是个Java程序,调试AS就是调试个Java程序

    1:配置AS以Debug模式启动

    dmg安装包安装后,可执行程序路径: /Applications/Android Studio.app/Contents/MacOS/studio 由于这是个mac下的可执行文件,此程序内部又启动java程序,并传入andorid studio相关的jar路径和参数,直接通过这个没法传递java参数,不过AS提供了个VM配置文件,启动时候会读取此文件的内容加入到java参数中 VM配置文件路径: Applications/Android Studio.app/Contents/bin/studio.vmoptions 加上:-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=6006 这里为方便观察,直接双击/Applications/Android Studio.app/Contents/MacOS/studio 程序启动AS,可观察到终端中输出: image.png

    说明jvm已经准备好被调试器附加了

    2:调试配置:

    实现了JDWP协议的程序都可以作为调试器来用,当然没道理自己搞一个,用jdb则命令行操作太繁琐,手动管理源码也很费劲,不如使用包装完善的IDE,这里使用Idea,新建一个Remote JVM Debug 类型的configuration。配置如下图: image.png

    3:导入代码

    源码从哪来,你可以去下载下来,甚至可以自己编译个AS,参考:tools.android.com/build/studi… 但我们大多数时候只是想调试下,不想这么麻烦,可以直接导入AS的jar包到Idea中,利用Idea的反编译和Debug功能完成我们的目标,(AS的程序包的各个目录中有很多jar,需要哪个导入哪个,如下图):

    image.png

    导入Idea方式:Idea中新建个java项目,随便建个文件夹,把需要的jar复制过去,在jar上右键->Add as Library

    image.png image.png

    4:开始调试

    首先要找到要调试的功能所涉及的类或者方法,寻找的方式可以通过在jar中搜索字符串,或者尝试在感觉相关名称的Class中断点,在 IDEA Plugin 框架体系中,大多数插件的功能入口都依赖 Action,那就可以在Action的一些方法中断点 image.png

    五:调试Gradle

    Gradle本质上是个Java程序,调试Gradle就是调试个Java程序

    1:配置Gradle以Debug模式启动

    gradle.properties中添加 org.gradle.jvmargs=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005

    启动gradle,比如执行下assemble task,执行后下图所示,由于上面设置的suspend=y,启动后会等待调试器链接后再继续运行

    image.png

    2:调试配置

    这里都使用Idea调试,配置和AS调试一样:

    image.png

    3:导入代码

    源码从哪来,你可以去下载下来,甚至可以自己编译个Gradle,参考:github.com/gradle/grad… 但我们大多数时候只是想调试下,不想这么麻烦,可以直接导入Gradle的jar包到Idea中,利用Idea的反编译和Debug功能完成我们的目标,Gradle以及Gradle plugin的jar位置,如下图):

    gradle程序位置

    image.png

    图中lib目录是编译后的jar,如果现在的是gradle-{version}-all类型的,则src目录中会对应的源码,可以导入源码调试

    gradle plugin位置

    在下图所示的文件夹中搜索目标插件:

    image.png

    比如我要搜索android gradle plugin:

    image.png

    导入Idea方式:Idea中新建个java项目,随便建个文件夹,把需要的jar复制过去,在jar上右键->Add as Library,和AS一样,不赘述了

    4:开始调试

    首先要找到要调试的功能所涉及的类或者方法,寻找的方式不多说了,自己摸索着来吧

    image.png

    六:调试任意App Java层

    自己编个aosp刷机,类型选择userDebug或者Eng,这样的系统有root权限且全局可以调试,如何编译网上很多资料,中间若遇到问题也可以参考我写的aosp编译的坑点,不赘述

    如果是第三方App,需要反编译dex为java源码导入AS调试,如果行号对不上老是调飞,说明行号信息被混淆了或去掉了,这时候可以考虑反编译成smali,使用AS+smalidea插件调试smali代码,网上有很多资料。比如:blog.csdn.net/YJJYXM/arti… 如果遇到AS无法对smali类型的文件下断点,就参考 blog.csdn.net/qq_43278826…

    七:调试任意App Native层

    自己编个aosp刷机,类型选择userDebug或者Eng,这样的系统有root权限且全局可以调试,如何编译网上很多资料,中间若遇到问题也可以参考我写的aosp编译的坑点,不赘述

    由于第三方app中的so都是去除debug信息的,以及我们并没有对应源码,所以只能反汇编调试,我一般都是习惯使用IDA,网上有很多资料,比如: blog.csdn.net/Breeze_CAT/… IDA调试时候注意下这个坑: bbs.pediy.com/thread-2654…

    八:调试Android系统Java层

    自己编个aosp刷机,类型选择userDebug或者Eng,这样的系统有root权限且全局可以调试,如何编译网上很多资料,中间若遇到问题也可以参考我写的aosp编译的坑点,不赘述

    导入编译的代码到AS中(可以参考http://www.jianshu.com/p/2ba5d6bd4… ),或者也可以按需要把android sdk中的源码替换为编译系统用的源码(我就是这样),注意targetSdk版本要和编译的系统版本一致

    九:调试Android系统Native层

    自己编个aosp刷机,类型选择userDebug或者Eng,这样的系统有root权限且全局可以调试,如何编译网上很多资料,中间若遇到问题也可以参考我写的aosp编译的坑点,不赘述

    收起阅读 »