注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

项目提交按钮没防抖,差点影响了验收

前言一个运行了多年的ToB的项目,由于数据量越来越大,业务越来越复杂,也一直在迭代,今年的阶段性交付那几天,公司 最大的客户 现场那边人员提出,某某某单据页面速度太慢了,点击会出现没反应的情况,然后就多点了几次,结果后面发现有的数据重复提交...
继续阅读 »

前言

一个运行了多年的ToB的项目,由于数据量越来越大,业务越来越复杂,也一直在迭代,今年的阶段性交付那几天,公司 最大的客户 现场那边人员提出,某某某单据页面速度太慢了,点击会出现没反应的情况,然后就多点了几次,结果后面发现有的数据重复提交了,由于数据错误个别单据流程给弄不正常了,一些报表的数据统计也不对了,客户相关人员很不满意,马上该交付了,出这问题可还了得,项目款不按时给了,这责任谁都担不起🤣

领导紧急组织相关技术人员开会分析原因

初步分析原因

发生这个情况前端选手应该会很清楚这是怎么回事,明显是项目里的按钮没加防抖导致的,按钮点击触发接口,接口响应慢,用户多点了几次,可能查询接口还没什么问题,如果业务复杂的地方,部分按钮的操作涉及到一些数据计算和后端多次交互更新数据的情况,就会出现错误。

看下项目情况

用到的框架和技术

项目使用 angular8 ts devextreme 组合。对!这就是之前文章提到的那个屎山项目(试用期改祖传屎山是一种怎么样的体验

项目规模

业务单据页面大约几百个,项目里面的按钮几千个,项目里面的按钮由于场景复杂,分别用了如下几种写法:

  • dx-button
  • div
  • dx-icon
  • input type=button
  • svg

由于面临交付,领导希望越快越好,最好一两天之内解决问题

还好我们领导没有说这问题当天就要解决 😁

解决方案

1. 添加防抖函数

按钮点击添加防抖函数,设置合理的时间

function debounce(func, wait) {
let timeout;
return function () {
if(timeout) clearTimeout(timeout);
timeout = setTimeout(func, wait)
}
}

优点

封装一个公共函数,往每个按钮的点击事件里加就行了

缺点

这种情况有个问题就是在业务复杂的场景下,时间设置会比较棘手,如果时间设置短了,接口请求慢,用户多次点击还会出现问题,如果时间设置长了,体验变差了

2. 设置按钮禁用

设置按钮的 disabled 相关属性,按钮点击后设置禁用效果,业务代码执行结束后取消禁用

this.disabled = true
this.disabled = false

优点

原生按钮和使用的UI库的按钮设置简单

缺点

diviconsvg 这种自定义的按钮的需要单独处理效果,比较麻烦

3. 请求拦截器中添加loading

在请求拦截器中根据请求类型显示 loading,请求结束后隐藏

优点

直接在一个地方设置就行了,不用去业务代码里一个个加

缺点

由于我们的技术栈使用的 angular8 内置的请求,无法实现类似 axios 拦截器那种效果,还有就是项目中的接口涉及多个部门的接口,不同部门的规范命名不一样,没有统一的标准,在实际的业务场景中,一个按钮的行为可能触发了多个请求,因此这个方案不适合当前的项目

4. 添加 loading 组件(项目中使用此方案)

新增一个 loading 组件,绑定到全局变量中,按钮点击触发显示 loading,业务执行结束后隐藏。

loading 组件核心代码

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
  providedIn: 'root'
})
export class LoadingService {
  private isLoading$ = new BehaviorSubject<boolean>(false);
  private message$ = new BehaviorSubject<string>('正在加载中...');
  constructor() {}
  show(): void {
    this.isLoading$.next(true);
  }
  hide(): void {
    this.isLoading$.next(false);
  }
}

主要是 show() 和 hide() 函数,将 loading 组件绑定到 app.components.ts 中,绑定组件到window 对象上,

window['loading'] = this.loadingService

在按钮点击时触发 show() 函数,业务代码执行结束后触发 hide() 函数

window['loading'].show();
window['loading'].hide();

优点

这种方式很好的解决了问题,由于 loading 有遮罩层还避免了用户点击某提交按钮后,接口响应慢,这时候去点击了别的操作按钮的情况。

缺点

需要在业务单据的按钮提交的地方一个个加

问题来了,一两天解决所有问题了吗?

这么大的项目一两天不管哪种方案,把所有按钮都处理好是不现实的,经过分析讨论,最终选择了折中处理,先把客户提出来的几个业务单据页面,以及相关的业务单据页面添加上提交 loading 处理,然后再一边改 bug 一边完善剩余的地方,优先保证客户正常使用


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

Android View绘制原理 - RenderNode

这一篇文章我们继续分析另外一个重要的类RenderNode, 这个在前面绘制流程里有也有提到,这里我将更加深入的介绍这个类1 简介RenderNode是一个绘制节点,一个大的界面是由很多小的绘制单元组成,这个正如View的层级结构,整个界面由很多控件组成,这样...
继续阅读 »

这一篇文章我们继续分析另外一个重要的类RenderNode, 这个在前面绘制流程里有也有提到,这里我将更加深入的介绍这个类

1 简介

RenderNode是一个绘制节点,一个大的界面是由很多小的绘制单元组成,这个正如View的层级结构,整个界面由很多控件组成,这样带来的好处就是需要整体绘制界面的时候,只有那些变化的单元重新绘制,然后在重新组装界面即可。这让我联想到了活字印刷术,当我们要印刷一页内容的时候,如果将所有的字都刻在一块板上,当要修改的时候,就需要整体重新来刻,效率很低成本很高,但是如果是将每一个字作为一个组件,页面只是这些字拼接出来的,修改或者重用的话就相对容易很多,RenderNode就相当于是一个个的字。

尽管在应用层我们很少使用这个类,但是实际上的每个View都持有 一个RenderNode,我们可以这样去理解,View作为一个组件,会由很多业务,比如事件,布局,测量和绘制等,而绘制业务正是委托给RenderNode去完成,绘制需要Canvas也是由这个RenderNode提供的。RenderNode除了为View提供绘制能力外,还为其他可绘制的API提供绘制能力,最常见的就是Drawable,我们也可以封装自己的绘制组件,基于RenderNode的绘制是利用了硬件加速的绘制。

在应用层,View会形成树型的层级结构,因此RenderNode也会相应的构造一个出绘制节点的树形结构。但是RenderNode的树形结构和View的树形结构可能是不一样的,因为一个View可能会对应着几个RenderNode,比如View的背景也会转换成一个RenderNode,因此一个View节点可能会产生多个RenderNode对象,通常一个View的背景和View的的Children是平级的。

2 属性

2.1 Java层

RenderNode 的功能主要是在C层实现的。在java层,它持有一个mCurrentRecordingCanvas,表示当前正在使用的那个Canvas
frameworks/base/graphics/java/android/graphics/RenderNode.java

private RecordingCanvas mCurrentRecordingCanvas;

这个Canvas的类型是RecordingCanvas, 它由RenderNode的beginRecording方法创建的

public @NonNull RecordingCanvas beginRecording(int width, int height) {
if (mCurrentRecordingCanvas != null) {
throw new IllegalStateException(
"Recording currently in progress - missing #endRecording() call?");
}
mCurrentRecordingCanvas = RecordingCanvas.obtain(this, width, height);
return mCurrentRecordingCanvas;
}

这里可以看到beginRecording方法不能连续调用,需要在调用endRecording之后才能再次调用。这个canvas是通过RecordingCanvas获得的一个canvas,obtain方法往往代表是从缓存池中获取的,这里我们不深入介绍,我们知道这个Canvas 是再从这里获得的,它的类型是RecordingCanvas. 它是Canvas的子类。

RenderNode 也由很多其他的属性,但是在C层定义的,所以我们继续分析一下在C层的RenderNode

2.2 C层

在JNI 和C层这里,主要有这个几个文件
frameworks/base/libs/hwui/jni/android_graphics_RenderNode.cpp

frameworks/base/libs/hwui/RenderNode.h
frameworks/base/libs/hwui/RenderNode.cpp

以及专门用于存储属性的RenderProperties类

frameworks/base/libs/hwui/RenderProperties.h
frameworks/base/libs/hwui/RenderProperties.cpp

2.2.1 mStagingProperties

mStagingProperties记录的是修改过的属性,在没有提交前,所有的修改都临时存在mStagingProperties。

RenderProperties mStagingProperties;

对属性的修改,是通过一个宏定义来实现的,来分析一个简单属性的top的修改流程

frameworks/base/graphics/java/android/graphics/RenderNode.java

public boolean setTop(int top) {
return nSetTop(mNativeRenderNode, top);
}

frameworks/base/libs/hwui/jni/android_graphics_RenderNode.cpp

static jboolean android_view_RenderNode_setTop(CRITICAL_JNI_PARAMS_COMMA jlong renderNodePtr, int top) {
return SET_AND_DIRTY(setTop, top, RenderNode::Y);
}


通过SET_AND_DIRTY这个宏定义来调用mutateStagingProperties上的方法

#define SET_AND_DIRTY(prop, val, dirtyFlag) \
(reinterpret_cast<RenderNode*>(renderNodePtr)->mutateStagingProperties().prop(val) \
? (reinterpret_cast<RenderNode*>(renderNodePtr)->setPropertyFieldsDirty(dirtyFlag), true) \
: false)

扩展开就相当于是

reinterpret_cast<RenderNode*>(renderNodePtr)->mutateStagingProperties().setTop(top) 
? (reinterpret_cast<RenderNode*>(renderNodePtr)->setPropertyFieldsDirty(dirtyFlag), true)
: false

renderNode->mutateStagingProperties() 返回的就是 mStagingProperties
frameworks/base/libs/hwui/RenderNode.h

RenderProperties& mutateStagingProperties() { return mStagingProperties; }

因此,会执行RenderProperties的setTop方法。如果setTop返回true,则会调用setPropertyFieldsDirty,记录发生变化的属性,这里传入的是RenderNode::Y这个枚举值,定义如下:

 enum DirtyPropertyMask {
GENERIC = 1 << 1,
TRANSLATION_X = 1 << 2,
TRANSLATION_Y = 1 << 3,
TRANSLATION_Z = 1 << 4,
SCALE_X = 1 << 5,
SCALE_Y = 1 << 6,
ROTATION = 1 << 7,
ROTATION_X = 1 << 8,
ROTATION_Y = 1 << 9,
X = 1 << 10,
Y = 1 << 11,
Z = 1 << 12,
ALPHA = 1 << 13,
DISPLAY_LIST = 1 << 14,
};

frameworks/base/libs/hwui/RenderProperties.h

bool setTop(int top) {
if (RP_SET(mPrimitiveFields.mTop, top)) {
mPrimitiveFields.mHeight = mPrimitiveFields.mBottom - mPrimitiveFields.mTop;
if (!mPrimitiveFields.mPivotExplicitlySet) {
mPrimitiveFields.mMatrixOrPivotDirty = true;
}
return true;
}
return false;
}

RP_SET是一个宏定义

#define RP_SET(a, b, ...) ((a) != (b) ? ((a) = (b), ##__VA_ARGS__, true) : false)

也就是如果mPrimitiveFields.mTop与top不相同,则将top赋值给mPrimitiveFields.mTop, 并且返回true,否则直接返回false。
如果top变化了,同步修改高度。
这就是一个简单属性的修改流程。 那么RenderNode有那些属性呢?来看一看RenderProperties的定义

 struct PrimitiveFields {
int mLeft = 0, mTop = 0, mRight = 0, mBottom = 0;
int mWidth = 0, mHeight = 0;
int mClippingFlags = CLIP_TO_BOUNDS;
SkColor mSpotShadowColor = SK_ColorBLACK;
SkColor mAmbientShadowColor = SK_ColorBLACK;
float mAlpha = 1;
float mTranslationX = 0, mTranslationY = 0, mTranslationZ = 0;
float mElevation = 0;
float mRotation = 0, mRotationX = 0, mRotationY = 0;
float mScaleX = 1, mScaleY = 1;
float mPivotX = 0, mPivotY = 0;
bool mHasOverlappingRendering = false;
bool mPivotExplicitlySet = false;
bool mMatrixOrPivotDirty = false;
bool mProjectBackwards = false;
bool mProjectionReceiver = false;
bool mAllowForceDark = true;
bool mClipMayBeComplex = false;
Rect mClipBounds;
Outline mOutline;
RevealClip mRevealClip;
} mPrimitiveFields;

我们可以看到这里的属性和我们在JAVA层View的几何属性是非常相似的,基本上View的几何属性都会类似setTop的方式反映到RenderProperties。大部分的简单属性比如top,bottom,translate,rotate,elevation,scale,pivot这些就不介绍了,我们分析一下两个比较特殊的属性mProjectBackwards 和 mProjectionReceiver。这两个属性会更改RenderNode绘制顺序。设置成mProjectionReceiver的RenderNode会成为一个锚点,被标记成mProjectBackwards的RenderNode不会被绘制在它的父节点,而是绘制到它最近的父节点中的标记成mProjectionReceiver的子节点中。例如P节点包含一个子节点C,以及P的背景PB,C包含一个背景CB. 一般的顺序应该是CB绘制到C中,然后C和PB绘制到P中。 但是如果PB被设置成mProjectionReceiver ,且CB被标记成mProjectBackwards,绘制的顺序将变成,C绘制到P中,CB绘制到PB 中,然后PB绘制到P中。也就是说将CB投影到PB中去。这种做法将使得CB的变化不会导致C重新绘制,从而提升效率,比如作为背景动画的RenderNode,它不会导致View自身的RenderNode的重新绘制。

2.2.1 mProperties

mStagingProperties暂存的修改将会与mProperties同步,从而正式成为影响绘制的参数。同步的方法很简单,直接赋值,在绘制帧之前会完成这些参数的同步。

void RenderNode::syncProperties() {
mProperties = mStagingProperties;
}

个人感觉好像第一同步之后,mProperties就和mStagingProperties指向同一个对象,只有似乎以后没有同步的必要了。

3 总结

RenderNode主要保存了一系列的属性,大部分的View属性都会反映到RenderNode,RenderNode使用RenderProperties来保存这些属性,在绘制帧的时候,这些属性会影响最终的绘制。RenderNode也会形成一颗树形的层级结构,但是它与View的层级结构并不是一一对应的,在同一级中的RenderNode不仅包含View的兄弟节点的RenderNode,也包含父View的背景等可绘制内容。除了属性之外,RenderNode的另外一个重要属性是DisplayList,它存放的是这个RenderNode的绘制指令,这个将在下一篇中继续分析。

以上内容是对RenderNode的分析,基于个人的理解,如有疏漏和错误, 👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀


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

前端跨域的几种方式

前端跨域的几种方式一、 什么是跨域跨域(Cross-Origin)是指在浏览器你执行脚本时,通过XMLHttpRequest、Fetch等方式请求不同源(协议、域名、端口)的资源。同源策略是浏览器的一种安全机制,它限制了网页中的脚本只能与同源(相同协议、域名、...
继续阅读 »

前端跨域的几种方式

一、 什么是跨域

跨域(Cross-Origin)是指在浏览器你执行脚本时,通过XMLHttpRequest、Fetch等方式请求不同源(协议、域名、端口)的资源。同源策略是浏览器的一种安全机制,它限制了网页中的脚本只能与同源(相同协议、域名、端口)的资源进行交互,防止恶意网站获取用户的敏感信息或进行攻击。

在同源策略下。浏览器允许脚本访问同源的资源,但不允许访问不同域的资源。跨域请求会触发浏览器的安全机制,导致请求被拒绝。例如,如果网页在域名A下加载了一个脚本,而在这个脚本尝试访问域名B下的资源,浏览器阻止这个跨域请求。

对于前端开发来说,跨域请求是一个常见的问题,因为现代应用通常需要不同的服务器或域名上获取数据。为了实现跨域访问,开发者可以采用常用的一些常见的方式,如 JSONP、CORS、代理服务器或 WebSocket等。这些允许前端页面与其他源的服务器进行安全的通信。

二、 前端跨域的几种方式

1、JSONP

JSONP(JSON with Padding)是一种利用<script>标签跨域获取数据的方法,可以绕过浏览器的同源策略限制。

JSONP的原理如下:

  • 通过请求参数作为回调函数的参数传递给服务器,服务器在响应中返回这个回调函数的调用,前端页面通过动态插入<script>标签来加载数据。
  • 由于<script>标签不受同源策略的限制,因此可以跨域加载并执行返回的脚本。

以下是JSONP的使用示例:

<script>
function callback(data) {
// 处理数据
}
</script>

<script src="http://example.com/api?callback=callback"></script>

上面的示例中,我们定义了一个名为callback的函数,在之后的脚本中使用这个函数来处理返回的数据。通过将callback函数的名称作为请求参数传递给服务器(例如: example.com/api?callbac… ),服务器在返回的响应中将调用该函数并传递数据。前端页面通过动态插入<script>标签来加载这个跨域的脚本,并在脚本执行时用callback函数来处理数据。

JSONP的应用场景是在需要获取跨域数据时,由于同源策略的限制我们无法直接使用XMLHttpRequest 或 Fetch方法时。比如说:我们需要从另一个域名的API获取数据,而该API支持JSONP,我们可以使用JSONP来实现跨域获取数据并在前端页面中进行处理。

JSONP需要服务器直接返回可执行的脚本代码。此外,JSONP只支持GET请求,不支持POST请求等其他类型的脚本。

2、CORS

CORS(跨域资源共享)是一种通过在服务器配置响应头来实现跨域请求的机制。它允许在浏览器中进行安全的跨域通信,突破同源策略的限制。

CORS的原理如下:

  • 前端页面发送跨域请求给服务器。
  • 服务器在响应头中添加Access-Control-Allow-Origin字段,指定允许跨域的源。例如,可以将其设置为Access-Control-Allow-Origin: http://example.com
  • 浏览器收到带有这个响应头的请求后,会判断该请求是否在允许的跨域列表中,如果是则将响应返回给前端页面,否则会被浏览器拦截。
  • 前端页面收到响应后,跨域像处理同源请求一样处理响应数据。

以下是CORS的使用示例:

//服务器端响应头配置
Access-Control-Allow-Origin: http://example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type


//前端页面请求
fetch('http://example.com/api',{
method: 'GET',
mode: 'cors'
})
.then(response => response.json())
.then(data => {
//处理数据
});

在上面的示例中,服务器在响应头中添加了Access-Control-Allow-Origin字段,指定允许跨域请求源为http://example.com。前端页面在发送具有mode: 'cors'的跨域请求时,浏览器会允许请求通过,并将响应返回给前端页面,使得前端跨域像处理同源请求一样处理跨域请求的响应数据。

CORS的运用非常广泛,特别是在现代的Web应用中。通过使用CORS,前端跨域与其他域的服务器进行安全的跨域通信,实现数据的获取与交互。开发者跨域在服务器端配置CORS响应头,来实现不同应用之间的跨域请求,提供更好的用户体验以及功能拓展。

3、前端代理服务器

前端代理服务器作为中间层。通过其中转,跨域绕过浏览器的同源限制,实现跨域请求。这种方法的优点是简单、灵活、适用于各种场景。

前端代理服务器的原理如下:

  • 前端代理服务器位于浏览器和后端服务器之间,充当转发请求和响应的角色。
  • 当浏览器发起跨域请求时,请求会先发送到前端代理服务器。
  • 前端代理服务器收到请求后,根据根据配置的规则判断是否属于跨域请求。
  • 如果属于跨域请求,前端代理服务器将发送新的请求到后端服务器,获取数据。
  • 前端代理服务器收到后端服务器的响应后,将响应内容返回给浏览器。

以下是如何使用Node.js创建一个前端代理服务器:

const http = require('http');
const request = require('request');

const proxy = http.createServer((req,res) => {
//处理跨域请求
res.setHeader('Access-Control-Allow-Origin','*');
res.setHeader('Access-Control-Allow-Methods','GET,POST,PUT,DELETE');

//转发请求到后端服务器
const url = 'http://example.com' + req.url;
req.pipe(request(url)).pipe(res);
});

const port = 8080;
proxy.listen(port, () => {
console.log('Proxy server is running on port ${port}');
});

使用前端代理服务器的好处是可以方便地在开发环境中进行前后端分离,同时避免一些跨域请求带来地麻烦。但在生产环境中,建议采用更成熟地反向代理服务器,如Nginx来处理跨域请求。

4、WebSocket

前端WebSocket实现跨域的原理是基于浏览器的同源策略的限制,通过WebSocket协议进行通信。由于WebSocket是基于TCP协议的全双工通信协议,WebSocket对象不受同源策略的约束,因此跨域实现跨域通信。

WebSocket通信的实现原理:

  • 在服务器端配置允许跨域请求:服务器端需要设置响应头,允许特定的域名访问该服务器资源。可以通过Access-Control-Allow-Origin进行跨域资源共享。
  • 在前端使用WebSocket对象与服务器建立连接:在前端代码中,可以使用WebSocket对象建立与目标服务器的连接。使用WebSocket构造函数提供服务器的URL,例如:let socket = new WebSocket('ws://example.com/socket').
  • 进行WebSocket通信:一旦与服务器建立了WebSocket连接,前端就可以通过WebSocket对象发送和接收数据。可以使用WebSocket对象的send()方法发送数据,使用onmessage时间监听接收到的信息,使用onopen事件监听连接建立成功,使用onclose事件建立连接关闭。

WebSocketHTML5的新技术,不是所有的浏览器都支持。在使用WebSocket实现跨域通信时,需要检查浏览器的兼容性并提供备选方案,确保在不支持WebSocket的情况下仍能正常工作。


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

前端开发如何给自己定位?初级?中级?高级!

引言在快速发展的互联网时代,前端开发一直处于高速增长的趋势中。作为构建用户界面和实现交互功能的关键角色,前端开发人员需要不断提升自己的技能和能力,以适应变化的行业需求。本文将为前端开发人员提供一个能力定位指南,帮助他们了解自己在前端领域的定位,内容参考阿里前端...
继续阅读 »

引言

在快速发展的互联网时代,前端开发一直处于高速增长的趋势中。作为构建用户界面和实现交互功能的关键角色,前端开发人员需要不断提升自己的技能和能力,以适应变化的行业需求。本文将为前端开发人员提供一个能力定位指南,帮助他们了解自己在前端领域的定位,内容参考阿里前端面试指南,P6/P6+/P7的能力标准。

目录

0.掌握图形学,webgl或熟练使用threejs框架,熟练canvas相关渲染及动画操作的优先。
1.熟练掌握JavaScript。
2.熟悉常用工程化工具,掌握模块化思想和技术实现方案。
3.熟练掌握React前端框架,了解技术底层。同时了解vue以及angular等其他框架者优先。
4.熟练掌握react生态常用工具,redux/react-router等。
5.熟悉各种Web前端技术,包括HTML/XML/CSS等,有基于Ajax的前端应用开发经验。
6.有良好的编码习惯,对前端技术有持续的热情,个性乐观开朗,逻辑性强,善于和各种背景的人合作。
7.具有TS/移动设备上前端开发/NodeJS/服务端开发等经验者优先。

0.掌握图形学,webgl或熟练使用threejs框架,熟练canvas相关渲染及动画操作的优先。

初级:

  • 学习过图形学相关知识,知道矩阵等数学原理在动画中的作用,知道三维场景需要的最基础的构成,能用threejs搭3d场景,知道webgl和threejs的关系。
  • 知道canvas是干嘛的,聊到旋转能说出canvas的api。
  • 知道css动画,css动画属性知道关键字和用法(换句话说,电话面试会当场出题要求口喷css动画,至少能说对大概,而不是回答百度一下就会用)。
  • 知道js动画,能说出1~2个社区js动画库,知道js动画和css动画优缺点以及适用场景。
  • 知道raf和其他达到60fps的方法。

中级:

  • 如果没有threejs,你也能基于webgl自己封装一个简单的threejs出来。
  • 聊到原理能说出四元数,聊到鼠标操作能提到节流,聊到性能能提到restore,聊到帧说出raf和timeout的区别,以及各自在优化时候的作用。
  • 知道怎样在移动端处理加载问题,渲染性能问题。
  • 知道如何结合native能力优化性能。
  • 知道如何排查性能问题。对chrome动画、3d、传感器调试十分了解。

高级:

  • 搭建过整套资源加载优化方案,能说明白整体方案的各个细节,包括前端、客户端、服务端分别需要实现哪些功能点、依赖哪些基础能力,以及如何配合。
  • 设计并实现过前端动画引擎,能说明白一个复杂互动项目的技术架构,知道需要哪些核心模块,以及这些模块间如何配合。
  • 有自己实现的动画相关技术方案产出,这套技术方案必须是解决明确的业务或技术难点问题的。为了业务快速落地而封装一个库,不算这里的技术方案。如果有类似社区方案,必须能从原理上说明白和竞品的差异,各自优劣,以及技术选型的原因。

1.熟练掌握JavaScript。

初级:

  • JavaScript各种概念都得了解,《JavaScript语言精粹》这本书的目录都得有概念,并且这些核心点都能脱口而出是什么。这里列举一些做参考:
  • 知道组合寄生继承,知道class继承。
  • 知道怎么创建类function + class。
  • 知道闭包在实际场景中怎么用,常见的坑。
  • 知道模块是什么,怎么用。
  • 知道event loop是什么,能举例说明event loop怎么影响平时的编码。
  • 掌握基础数据结构,比如堆、栈、树,并了解这些数据结构计算机基础中的作用。
  • 知道ES6数组相关方法,比如forEach,map,reduce。

中级:

  • 知道class继承与组合寄生继承的差别,并能举例说明。
  • 知道event loop原理,知道宏微任务,并且能从个人理解层面说出为什么要区分。知道node和浏览器在实现loop时候的差别。
  • 能将继承、作用域、闭包、模块这些概念融汇贯通,并且结合实际例子说明这几个概念怎样结合在一起。
  • 能脱口而出2种以上设计模式的核心思想,并结合js语言特性举例或口喷基础实现。
  • 掌握一些基础算法核心思想或简单算法问题,比如排序,大数相加。

2.熟悉常用工程化工具,掌握模块化思想和技术实现方案。

初级:

  • 知道webpack,rollup以及他们适用的场景。
  • 知道webpack v4和v3的区别。
  • 脱口而出webpack基础配置。
  • 知道webpack打包结果的代码结构和执行流程,知道index.js,runtime.js是干嘛的。
  • 知道amd,cmd,commonjs,es module分别是什么。
  • 知道所有模块化标准定义一个模块怎么写。给出2个文件,能口喷一段代码完成模块打包和执行的核心逻辑。

中级:

  • 知道webpack打包链路,知道plugin生命周期,知道怎么写一个plugin和loader。
  • 知道常见loader做了什么事情,能几句话说明白,比如babel-loader,vue-loader。
  • 能结合性能优化聊webpack配置怎么做,能清楚说明白核心要点有哪些,并说明解决什么问题,需要哪些外部依赖,比如cdn,接入层等。
  • 了解异步模块加载的实现原理,能口喷代码实现核心逻辑。

高级:

  • 能设计出或具体说明白团队研发基础设施。具体包括但不限于:
  • 项目脚手架搭建,及如何以工具形态共享。
  • 团队eslint规范如何设计,及如何统一更新。
  • 工具化打包发布流程,包括本地调试、云构建、线上发布体系、一键部署能力。同时,方案不仅限于前端工程部分,包含相关服务端基础设施,比如cdn服务搭建,接入层缓存方案设计,域名管控等。
  • 客户端缓存及预加载方案。

3.熟练掌握React前端框架,了解技术底层。同时了解vue以及angular等其他框架者优先。

初级:

  • 知道react常见优化方案,脱口而出常用生命周期,知道他们是干什么的。
  • 知道react大致实现思路,能对比react和js控制原生dom的差异,能口喷一个简化版的react。
  • 知道diff算法大致实现思路。
  • 对state和props有自己的使用心得,结合受控组件、hoc等特性描述,需要说明各种方案的适用场景。
  • 以上几点react替换为vue或angular同样适用。

中级:

  • 能说明白为什么要实现fiber,以及可能带来的坑。
  • 能说明白为什么要实现hook。
  • 能说明白为什么要用immutable,以及用或者不用的考虑。
  • 知道react不常用的特性,比如context,portal。
  • 能用自己的理解说明白react like框架的本质,能说明白如何让这些框架共存。

高级:

  • 能设计出框架无关的技术架构。包括但不限于:
  • 说明如何解决可能存在的冲突问题,需要结合实际案例。
  • 能说明架构分层逻辑、各层的核心模块,以及核心模块要解决的问题。能结合实际场景例举一些坑或者优雅的处理方案则更佳。

4.熟练掌握react生态常用工具,redux/react-router等。

初级:

  • 知道react-router,redux,redux-thunk,react-redux,immutable,antd或同级别社区组件库。
  • 知道vue和angular对应全家桶分别有哪些。
  • 知道浏览器react相关插件有什么,怎么用。
  • 知道react-router v3/v4的差异。
  • 知道antd组件化设计思路。
  • 知道thunk干嘛用的,怎么实现的。

中级:

  • 看过全家桶源码,不要求每行都看,但是知道核心实现原理和底层依赖。能口喷几行关键代码把对应类库实现即达标。
  • 能从数据驱动角度透彻的说明白redux,能够口喷原生js和redux结合要怎么做。
  • 能结合redux,vuex,mobx等数据流谈谈自己对vue和react的异同。

高级:

  • 有基于全家桶构建复杂应用的经验,比如最近很火的微前端和这些类库结合的时候要注意什么,会有什么坑,怎么解决

5.熟悉各种Web前端技术,包括HTML/XML/CSS等,有基于Ajax的前端应用开发经验。

初级:

  • HTML方面包括但不限于:语义化标签,history api,storage,ajax2.0等。
  • CSS方面包括但不限于:文档流,重绘重排,flex,BFC,IFC,before/after,动画,keyframe,画三角,优先级矩阵等。
  • 知道axios或同级别网络请求库,知道axios的核心功能。
  • 能口喷xhr用法,知道网络请求相关技术和技术底层,包括但不限于:content-type,不同type的作用;restful设计理念;cors处理方案,以及浏览器和服务端执行流程;口喷文件上传实现;
  • 知道如何完成登陆模块,包括但不限于:登陆表单如何实现;cookie登录态维护方案;token base登录态方案;session概念;

中级:

  • HTML方面能够结合各个浏览器api描述常用类库的实现。
  • css方面能够结合各个概念,说明白网上那些hack方案或优化方案的原理。
  • 能说明白接口请求的前后端整体架构和流程,包括:业务代码,浏览器原理,http协议,服务端接入层,rpc服务调用,负载均衡。
  • 知道websocket用法,包括但不限于:鉴权,房间分配,心跳机制,重连方案等。
  • 知道pc端与移动端登录态维护方案,知道token base登录态实现细节,知道服务端session控制实现,关键字:refresh token。
  • 知道oauth2.0轻量与完整实现原理。
  • 知道移动端api请求与socket如何通过native发送,知道如何与native进行数据交互,知道ios与安卓jsbridge实现原理。

高级:

  • 知道移动端webview和基础能力,包括但不限于:iOS端uiwebview与wkwebview差异;webview资源加载优化方案;webview池管理方案;native路由等。
  • 登陆抽象层,能够给出完整的前后端对用户体系的整体技术架构设计,满足多业务形态用户体系统一。考虑跨域名、多组织架构、跨端、用户态开放等场景。
  • mock方案,能够设计出满足各种场景需要的mock数据方案,同时能说出对前后端分离的理解。考虑mock方案的通用性、场景覆盖度,以及代码或工程侵入程度。
  • 埋点方案,能够说明白前端埋点方案技术底层实现,以及技术选型原理。能够设计出基于埋点的数据采集和分析方案,关键字包括:分桶策略,采样率,时序性,数据仓库,数据清洗等。

6.有良好的编码习惯,对前端技术有持续的热情,个性乐观开朗,逻辑性强,善于和各种背景的人合作。

初级:

  • 知道eslint,以及如何与工程配合使用。
  • 了解近3年前端较重要的更新事件。
  • 面试过程中遇到答不出来的问题,能从逻辑分析上给出大致的思考路径。
  • 知道几个热门的国内外前端技术网站,同时能例举几个面试过程中的核心点是从哪里看到的。

高级:

  • 在团队内推行eslint,并给出工程化解决方案。
  • 面试过程思路清晰,面试官给出关键字,能够快速反应出相关的技术要点,但是也要避免滔滔不绝,说一堆无关紧要的东西。举例来说,当时勾股老师面试我的时候,问了我一个左图右文的布局做法,我的回答是:我自己总结过7种方案,其中比较好用的是基于BFC的,float的以及flex的三种。之后把关键css口喷了一下,然后css就面完了。

7.具有TS/移动设备上前端开发/NodeJS/服务端开发等经验者优先。

  • 根据了解的深度分初/中/高级。
  • 知道TS是什么,为什么要用TS,有TS工程化实践经验。
  • 知道移动端前端常见问题,包括但不限于:rem + 1px方案;预加载;jsbridge原理等。
  • 能说出大概的服务端技术,包括但不限于:docker;k8s;rpc原理;中后台架构分层;缓存处理;分布式;响应式编程等。

5. 结论与进一步学习

本文为前端开发人员提供了一个能力定位指南,帮助他们了解自己在前端领域的定位,并提供了具体的代码示例来巩固学习成果。通过不断学习和实践,前端开发人员可以逐步提升自己的能力,从初级到中级再到高级。但请注意,在实际工作中,不同公司和项目对于各个级别的要求可能会有所不同。

为了进一步提高自己的水平,前端开发人员可以考虑以下学习路径和资源:

  • 阅读官方文档和教程,如MDN、React官方文档等;
  • 参与开源项目,并与其他开发人员进行交流和合作;
  • 关注前端开发的博客和社区,如Medium、Stack Overflow等;
  • 参加在线或线下的前端开发培训课程;
  • 阅读经典的前端开发书籍,如《JavaScript高级程序设计》、《CSS权威指南》等。

通过持续学习和实践,相信每个前端开发人员都可以不断成长,并在前端领域中取得更好的成就。祝愿大家在前端开发的道路上越走越远!


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

这次被 foreach 坑惨了,再也不敢乱用了...

近日,项目中有一个耗时较长的Job存在CPU占用过高的问题,经排查发现,主要时间消耗在往MyBatis中批量插入数据。mapper configuration是用foreach循环做的,差不多是这样。(由于项目保密,以下代码均为自己手写的demo代码)<...
继续阅读 »

近日,项目中有一个耗时较长的Job存在CPU占用过高的问题,经排查发现,主要时间消耗在往MyBatis中批量插入数据。mapper configuration是用foreach循环做的,差不多是这样。(由于项目保密,以下代码均为自己手写的demo代码)

<insert id="batchInsert" parameterType="java.util.List">  
insert into USER (id, name) values
<foreach collection="list" item="model" index="index" separator=",">
(#{model.id}, #{model.name})
</foreach>
</insert>

这个方法提升批量插入速度的原理是,将传统的:

INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");  
INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");
INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");
INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");
INSERT INTO `table1` (`field1`, `field2`) VALUES ("data1", "data2");

转化为:

INSERT INTO `table1` (`field1`, `field2`)   
VALUES ("data1", "data2"),
("data1", "data2"),
("data1", "data2"),
("data1", "data2"),
("data1", "data2");

在MySql Docs中也提到过这个trick,如果要优化插入速度时,可以将许多小型操作组合到一个大型操作中。理想情况下,这样可以在单个连接中一次性发送许多新行的数据,并将所有索引更新和一致性检查延迟到最后才进行。

乍看上去这个foreach没有问题,但是经过项目实践发现,当表的列数较多(20+),以及一次性插入的行数较多(5000+)时,整个插入的耗时十分漫长,达到了14分钟,这是不能忍的。在资料中也提到了一句话:

Of course don't combine ALL of them, if the amount is HUGE. Say you have 1000 rows you need to insert, then don't do it one at a time. You shouldn't equally try to have all 1000 rows in a single query. Instead break it into smaller sizes.

它强调,当插入数量很多时,不能一次性全放在一条语句里。可是为什么不能放在同一条语句里呢?这条语句为什么会耗时这么久呢?我查阅了资料发现:

Insert inside Mybatis foreach is not batch, this is a single (could become giant) SQL statement and that brings drawbacks:

some database such as Oracle here does not support.

in relevant cases: there will be a large number of records to insert and the database configured limit (by default around 2000 parameters per statement) will be hit, and eventually possibly DB stack error if the statement itself become too large.

Iteration over the collection must not be done in the mybatis XML. Just execute a simple Insertstatement in a Java Foreach loop. The most important thing is the session Executor type.

SqlSession session = sessionFactory.openSession(ExecutorType.BATCH);
for (Model model : list) {
session.insert("insertStatement", model);
}
session.flushStatements();

Unlike default ExecutorType.SIMPLE, the statement will be prepared once and executed for each record to insert.

从资料中可知,默认执行器类型为Simple,会为每个语句创建一个新的预处理语句,也就是创建一个PreparedStatement对象。

在我们的项目中,会不停地使用批量插入这个方法,而因为MyBatis对于含有的语句,无法采用缓存,那么在每次调用方法时,都会重新解析sql语句。

Internally, it still generates the same single insert statement with many placeholders as the JDBC code above.

MyBatis has an ability to cache PreparedStatement, but this statement cannot be cached because it containselement and the statement varies depending on the parameters. As a result, MyBatis has to 1) evaluate the foreach part and 2) parse the statement string to build parameter mapping [1] on every execution of this statement. And these steps are relatively costly process when the statement string is big and contains many placeholders.

[1] simply put, it is a mapping between placeholders and the parameters.

从上述资料可知,耗时就耗在,由于我foreach后有5000+个values,所以这个PreparedStatement特别长,包含了很多占位符,对于占位符和参数的映射尤其耗时。并且,查阅相关资料可知,values的增长与所需的解析时间,是呈指数型增长的。

图片

所以,如果非要使用 foreach 的方式来进行批量插入的话,可以考虑减少一条 insert 语句中 values 的个数,最好能达到上面曲线的最底部的值,使速度最快。一般按经验来说,一次性插20~50行数量是比较合适的,时间消耗也能接受。

重点来了。上面讲的是,如果非要用的方式来插入,可以提升性能的方式。而实际上,MyBatis文档中写批量插入的时候,是推荐使用另外一种方法。(可以看
http://www.mybatis.org/mybatis-dyn… Insert Support 标题里的内容)

SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH);  
try {
SimpleTableMapper mapper = session.getMapper(SimpleTableMapper.class);
List<SimpleTableRecord> records = getRecordsToInsert(); // not shown

BatchInsert<SimpleTableRecord> batchInsert = insert(records)
.into(simpleTable)
.map(id).toProperty("id")
.map(firstName).toProperty("firstName")
.map(lastName).toProperty("lastName")
.map(birthDate).toProperty("birthDate")
.map(employed).toProperty("employed")
.map(occupation).toProperty("occupation")
.build()
.render(RenderingStrategy.MYBATIS3);

batchInsert.insertStatements().stream().forEach(mapper::insert);

session.commit();
} finally {
session.close();
}

即基本思想是将 MyBatis session 的 executor type 设为 Batch ,然后多次执行插入语句。就类似于JDBC的下面语句一样。

Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/mydb?useUnicode=true&characterEncoding=UTF-8&useServerPrepStmts=false&rewriteBatchedStatements=true","root","root");  
connection.setAutoCommit(false);
PreparedStatement ps = connection.prepareStatement(
"insert into tb_user (name) values(?)");
for (int i = 0; i < stuNum; i++) {
ps.setString(1,name);
ps.addBatch();
}
ps.executeBatch();
connection.commit();
connection.close();

经过试验,使用了 ExecutorType.BATCH 的插入方式,性能显著提升,不到 2s 便能全部插入完成。

总结一下

如果MyBatis需要进行批量插入,推荐使用 ExecutorType.BATCH 的插入方式,如果非要使用  的插入的话,需要将每次插入的记录控制在 20~50 左右。


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

如何诊断Java 应用线程泄漏

大家经常听到内存泄漏, 那么线程泄漏是指什么呢?线程泄漏是指 JVM 里面的线程越来越多, 而这些新创建的线程在初期被使用之后, 再也不被使用了, 然而也没有被销毁. 通常是由于错误的代码导致的这类问题.一般通过监控 Java 应用的线程数量的相关指标, 都能...
继续阅读 »

大家经常听到内存泄漏, 那么线程泄漏是指什么呢?

线程泄漏是指 JVM 里面的线程越来越多, 而这些新创建的线程在初期被使用之后, 再也不被使用了, 然而也没有被销毁. 通常是由于错误的代码导致的这类问题.

一般通过监控 Java 应用的线程数量的相关指标, 都能发现这种问题. 如果没有很好的对这些指标的监控措施, 或者没有设置报警信息, 可能要到等到线程耗尽操作系统内存导致OOM才能暴露出来.

最常见的例子

在生产环境中, 见过很多次类似下面例子:

public void handleRequest(List<String> requestPayload) {
if (requestPayload.size() > 0) {
ExecutorService executor = Executors.newFixedThreadPool(2);

for (String str : requestPayload) {
final String s = str;
executor.submit(new Runnable() {
@Override
public void run() {
// print 模拟做很多事情
System.out.println(s);
}
});
}
}
// do some other things
}

这段代码在处理一个业务请求, 业务请求中包含很多小的任务, 于是想到使用线程池去处理每个小任务, 于是创建了一个 ExecutorService, 接着去处理小任务去了.

错误及改正

看到这段代码, 大家会觉的不可能啊, 怎么会有人这么使用线程池呢? 线程池不是这么用的啊? 一脸问号. 可是现实情况是: 总有新手写出这样的代码.

有的新手被指出这个问题之后, 就去查文档, 发现 ExecutorService 有 shutdown() 和 shutdownNow() 方法啊, 于是就在 for 循环后边加了 executor.shutdown(). 当然, 这会解决线程泄漏的问题. 但却不是线程池正确的用法, 因为这样虽然避免了线程泄漏, 却还是每次都要创建线程池, 创建新线程, 并没有提升性能.

正确的使用方法是做一个全局的线程池, 而不是一个局部变量的线程池, 然后在应用退出前通过 hook 的方式 shutdown 线程池.

然而, 我们是在知道这段代码位置的前提下, 很快就修好了. 如果你有一个复杂的 Java 应用, 它的线程不断的增加, 我们怎么才能找到导致线程泄漏的代码块呢?

情景再现

通常情况下, 我们会有每个应用的线程数量的指标, 如果某个应用的线程数量启动后, 不管分配的 CPU 个数, 一直保持上升趋势, 那么就危险了. 这个时候, 我们就会去查看线程的 Thread dump, 去查看到底哪些线程在持续的增加, 为什么这些线程会不断创建, 创建新线程的代码在哪?

找到出问题的代码

在 Thread dump 里面, 都有线程创建的顺序, 还有线程的名字. 如果新创建的线程都有一个自己定义的名字, 那么就很容易的找到创建的地方了, 我们可以根据这些名字去查找出问题的代码.

根据线程名去搜代码

比如下面创建的线程的方式, 就给了每个线程统一的名字:

Thread t = new Thread(new Runnable() {
@Override
public void run() {
}
}, "ProcessingTaskThread");
t.setDaemon(true);
t.start();

如果这些线程启动之前不设置名字, 系统都会分配一个统一的名字, 比如thread-npool-m-thread-n, 这个时候通过名字就很难去找到出错的代码.

根据线程处理的业务逻辑去查代码

大多数时候, 这些线程在 Thread dump 里都表现为没有任何事情可做, 但有些时候, 你可以能发现这些新创建的线程还在处理某些业务逻辑, 这时候, 根据这些业务逻辑的代码向上查找创建线程的代码, 也不失为一种策略.

比如下面的线程栈里可以看出这个线程池在处理我们的业务逻辑代码 AsyncPropertyChangeSupport.run, 然后根据这个关键信息, 我们就可以查找出到底那个地方创建了这个线程:

"pool-2-thread-4" #159 prio=5 os_prio=0 cpu=7.99ms elapsed=354359.32s tid=0x00007f559c6c9000 nid=0x6eb in Object.wait()  [0x00007f55a010a000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(java.base@11.0.18/Native Method)
- waiting on <0x00000007c5320a88> (a java.lang.ProcessImpl)
at java.lang.Object.wait(java.base@11.0.18/Object.java:328)
... 省略 ...
at com.tianxiaohui.JvmConfigBean.propertyChange(JvmConfigBean.java:180)
at com.tianxiaohui.AsyncPropertyChangeSupport.run(AsyncPropertyChangeSupport.java:346)
at java.util.concurrent.Executors$RunnableAdapter.call(java.base@11.0.18/Executors.java:515)
at java.util.concurrent.FutureTask.run(java.base@11.0.18/FutureTask.java:264)
at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@11.0.18/ThreadPoolExecutor.java:1128)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@11.0.18/ThreadPoolExecutor.java:628)
at java.lang.Thread.run(java.base@11.0.18/Thread.java:829)

使用 btrace 查找创建线程的代码

在上面2种比较容易的方法已经失效的时候, 还有一种一定能查找到问题代码的方式, 就是使用 btrace 注入拦截代码: 拦截创建新线程的地方, 然后打印当时的线程栈.

我们稍微改下官方的拦截启动新线程的例子, 加入打印当前栈信息:

import org.openjdk.btrace.core.annotations.BTrace;
import org.openjdk.btrace.core.annotations.OnMethod;
import org.openjdk.btrace.core.annotations.Self;

import static org.openjdk.btrace.core.BTraceUtils.*;

@BTrace
public class ThreadStart {
@OnMethod(
clazz = "java.lang.Thread",
method = "start"
)
public static void onnewThread(@Self Thread t) {
D.probe("jthreadstart", Threads.name(t));
println("starting " + Threads.name(t));
println(jstackStr());
}
}

然后执行 btrace 注入, 一旦有新线程被创建, 我们就能找到创建新线程的代码, 当然, 我们可能拦截到不是我们想要的线程创建栈, 所以要区分, 哪些才是我们希望找到的, 有时候, 上面的代码中可以加一个判断, 比如线程名字是不是符合我们要找的模式.

$ ./bin/btrace 1036 ThreadStart.java
Attaching BTrace to PID: 1036
starting HandshakeCompletedNotify-Thread
java.base/java.lang.Thread.start(Thread.java)
java.base/sun.security.ssl.TransportContext.finishHandshake(TransportContext.java:632)
java.base/sun.security.ssl.Finished$T12FinishedConsumer.onConsumeFinished(Finished.java:558)
java.base/sun.security.ssl.Finished$T12FinishedConsumer.consume(Finished.java:525)
java.base/sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:392)

上面的代码, 就抓住了一个新创建的线程的地方, 只不过这个可能不是我们想要的.

除了线程会泄漏之外, 线程组(ThreadGroup) 也有可能泄漏, 导致内存被用光, 感兴趣的可以查看生产环境出现的一个真实的问题: 为啥 java.lang.ThreadGroup 把内存干爆了

总结

针对线程泄漏的问题, 诊断的过程还算简单, 基本过程如下:

  1. 先确定是哪些线程在持续不断的增加;
  2. 然后再找出创建这些线程的错误代码;
    1. 根据线程名字去搜错误代码位置;
    2. 根据线程处理的业务逻辑代码去查找错误代码位置;
    3. 使用 btrace 拦截创建新线程的代码位置

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

Android-Widget重装上阵

如果要在Android系统中找一个一直存在,但一直被人忽略,而且有十分好用的功能,那么Widget,一定算一个。这个从Android 1.x就已经存在的功能,经历了近10年的迭代,在遭到无数无视和白眼之后,又重新回到了大家的视线之内,当然,也有可能是App内部...
继续阅读 »

如果要在Android系统中找一个一直存在,但一直被人忽略,而且有十分好用的功能,那么Widget,一定算一个。这个从Android 1.x就已经存在的功能,经历了近10年的迭代,在遭到无数无视和白眼之后,又重新回到了大家的视线之内,当然,也有可能是App内部已经没东西好卷了,所以大家又把目光放到了App之外,但不管怎样,Widget在Android 12之后,都开始焕发一新,官网镇楼,让我们重新来了解下这个最熟悉的陌生人。

developer.android.com/develop/ui/…

Widget使用的是RemoteView,这与Notification的使用如出一辙,RemoteView是继承自Parcelable的组件,可以跨进程使用。在Widget中,通过AppWidgetProvider来管理Widget的行为,通过RemoteView来对Widget进行布局,通过AppWidgetManager来对Widget进行刷新。基本的使用方式,我们可以通过一套模板代码来实现,在Android Studio中,直接New Widget即可。这样Android Studio就可以自动为你生成一个Widget的模板代码,详细代码我们就不贴了,我们来分析下代码的组成。

首先,每个Widget都包含一个AppWidgetProvider。这是Widget的逻辑管理类,它继承自BroadcastReceiver,然后,我们需要在清单中注册这个Receiver,并在meta-data中指定它的配置文件,它的配置文件是一个xml,这里描述的是添加Widget时展示的一些信息。

从这些地方来看,其实Widget的使用还是比较简单的,所以本文也不准备来讲解这些基础知识,下面我们针对开发中会遇到的一些实际需求来进行分析。

appwidget-provider配置文件

这个xml文件虽然简单,但还是有些有意思的东西的。

尺寸

在这里我们可以为Widget配置尺寸信息,通过maxResizeWidth、maxResizeHeight和minWidth、minHeight,我们可以大致将Widget的尺寸控制在MxN的格子内,这也是Widget在桌面上的展示方式,它并不是通过指定的宽高来展示的,而是桌面所占据的格子数。

官方设计文档中,对格子数和尺寸的转换标准,有一个表格,如下所示。

我们在设计的时候,也应该尽量遵循这个尺寸约束,避免在桌面上展示异常。在Android12之后,描述文件中,还增加了targetCellWidth和targetCellHeight两个参数,他们可以直接指定Widget所占据的格子数,这样更加方便,但由于它仅支持Android12+,所以,通常这些属性会一起设置。

有意思的是这个尺寸标准并不适用于所有的设备,因为ROM的碎片化问题,各个厂商的桌面都不一样,所以。。。只能参考参考。

updatePeriodMillis

这个参数用于指定Widget的被动刷新频率,它由系统控制,所以具有很强的不定性,而且它也不能随意设置,官网上对这个属性的限制如下所示。

updatePeriodMillis只支持设置30分钟以上的间隔,即1800000milliseconds,这也是为了保证后台能耗,即使你设置了小于30分钟的updatePeriodMillis,它也不会生效。

对于Widget来说,updatePeriodMillis控制的是系统被动刷新Widget的频率,如果当前App是活着的,那么随时可以通过广播来修改Widget。

而且这个值很有可能因为不同ROM而不同,所以,这是一个不怎么稳定的刷新机制。

其它

除了上面我们提到的一些属性,还有一些需要留意的。

  • resizeMode:拉伸的方向,可以设置为horizontal|vertical,表示两边都可以拉伸。
  • widgetCategory:对于现在的App来说,只能设置为home_screen了,5.0之前可以设置为锁屏,现在基本已经不用了。
  • widgetFeatures:这是Android12之后新加的属性,设置为reconfigurable之后,就可以直接调整Widget的尺寸,而不用像之前那样先删除旧的Widget再添加新的Widget了。

配置表

这个配置文件的主要作用,就是在添加Widget时,展示一个简要的描述信息,所以,一个App中是可以存在多个描述xml文件的,而且有几个描述文件,添加时,就会展示几个Widget的缩略图,通常我们会创建几个不同尺寸的Widget,例如2x2、4x2、4x1等,并创建多个xml面试文件,从而让用户可以选择添加哪一个Widget。

不过在Android12之后,设置一个Widget,通过拉动来改变尺寸,就可以动态改变Widget的不同展示效果了,但这仅限于Android12+,所以需要权衡使用利弊。

configure

通过configure属性可以配置添加Widget时的Configure Activity,这个在创建默认的Widget项目时就已经可以选择创建了,所以不多讲了,实际上就是一个简单的Activity,你可以配置一些参数,写入SP,然后在Widget中进行读取,从而实现自定义配置。

应用内唤起Widget的添加页面

大部分时候,我们都是通过在桌面上长按的方式来添加Widget,但是在Android API 26之后,系统提供了一直新的方式来在应用内唤起——requestPinAppWidget。

文档如下。

developer.android.com/reference/a…

代码如下所示。

fun requestToPinWidget(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val appWidgetManager: AppWidgetManager? = getSystemService(context, AppWidgetManager::class.java)
appWidgetManager?.let {
val myProvider = ComponentName(context, NewAppWidget::class.java)
if (appWidgetManager.isRequestPinAppWidgetSupported) {
val pinnedWidgetCallbackIntent = Intent(context, MainGroupActivity::class.java)
val successCallback: PendingIntent = PendingIntent.getBroadcast(context, 0,
pinnedWidgetCallbackIntent, PendingIntent.FLAG_UPDATE_CURRENT)
appWidgetManager.requestPinAppWidget(myProvider, null, successCallback)
}
}
}
}

通过这种方式,就可以直接唤起Widget的添加入口,从而避免用户手动在桌面中进行添加。

应用内主动更新Widget

前面我们提到了,当App活着的时候,可以主动来更新Widget,而且有两种方式可以实现,一种是通过广播ACTION_APPWIDGET_UPDATE,触发Widget的update回调,从而进行更新,代码如下所示。

val manager = AppWidgetManager.getInstance(this)
val ids = manager.getAppWidgetIds(ComponentName(this, XXXWidget::class.java))
val updateIntent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
sendBroadcast(updateIntent)

这种方式的本质就是发送更新的广播,除此之外,还可以使用AppWidgetManager来直接对Widget进行更新,代码如下。

val remoteViews = RemoteViews(context.packageName, R.layout.xxx)
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, XXXWidgetProvider::class.java)
appWidgetManager.updateAppWidget(componentName, remoteViews)

这种方式就是通过AppWidgetManager来对指定的Widget进行修改,使用新的RemoteViews来更新当前Widget。

这两种方式一种是主动替换,一种是被动刷新,具体的使用场景可以根据业务的不同来使用不同的方式。

应用外被动更新Widget

产品现在重新开始重视Widget的一个重要原因,实际上就是App内部卷不动了,Widget可以在不打开App的情况下,对App进行引流,所以,应用外的Widget更新,就是一个很重要的组成部分,Widget需要展示用户感兴趣的内容,才能触发用户的点击。

前面我们提到了通过设置updatePeriodMillis来进行Widget的更新,但是这种方式存在一些使用限制,如果你需要完全自主的控制Widget的刷新,那么可以使用AlarmManager或者WorkManager,类似的代码如下所示。

private fun scheduleUpdates(context: Context) {
val activeWidgetIds = getActiveWidgetIds(context)
if (activeWidgetIds.isNotEmpty()) {
val nextUpdate = ZonedDateTime.now() + WIDGET_UPDATE_INTERVAL
val pendingIntent = getUpdatePendingIntent(context)
context.alarmManager.set(
AlarmManager.RTC_WAKEUP,
nextUpdate.toInstant().toEpochMilli(), // alarm time in millis since 1970-01-01 UTC
pendingIntent
)
}
}

当然,这种方式也同样会受到ROM的限制,所以说,不管是WorkManager还是AlarmManager,或者是updatePeriodMillis,都不是稳定可靠的,随它去吧,强扭的瓜不甜。

一般来说,使用updatePeriodMillis就够了,Widget的目的是为了引流,对内容的实时性其实并不是要求的那么严格,updatePeriodMillis在大部分场景下,都是够用的。

多布局动态适配

由于在Android12之后,用户可以在单个Widget上进行修改,从而修改Widget当前的配置,所以,用户在拖动修改Widget的尺寸时,就需要动态去调整Widget的布局,以自动适应不同的尺寸。我们可以通过下面的方式,来进行修改。

internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, widgetData: AppWidgetData) {
val views41 = RemoteViews(context.packageName, R.layout.new_app_widget41).also { updateView(it, context, appWidgetId, widgetData) }
val views42 = RemoteViews(context.packageName, R.layout.new_app_widget42).also { updateView(it, context, appWidgetId, widgetData) }
val views21 = RemoteViews(context.packageName, R.layout.new_app_widget21).also { updateView(it, context, appWidgetId, widgetData) }
val viewMapping: Map<SizeF, RemoteViews> = mapOf(
SizeF(180f, 110f) to views21,
SizeF(270f, 110f) to views41,
SizeF(270f, 280f) to views42
)
appWidgetManager.updateAppWidget(appWidgetId, RemoteViews(viewMapping))
}

private fun updateView(remoteViews: RemoteViews, context: Context, appWidgetId: Int, widgetData: AppWidgetData) {
remoteViews.setTextViewText(R.id.xxx, widgetData.xxx)
}

它的核心就是RemoteViews(viewMapping),通过这个就可以动态适配当前用户选择的尺寸。

那么如果是Android12之前呢?

我们需要重写onAppWidgetOptionsChanged回调来获取当前Widget的宽高,从而修改不同的布局,模板代码如下所示。

override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle) {
   super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
   val options = appWidgetManager.getAppWidgetOptions(appWidgetId)

   val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
   val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)

   val rows: Int = getWidgetCellsM(minHeight)
   val columns: Int = getWidgetCellsN(minWidth)
   updateAppWidget(context, appWidgetManager, appWidgetId, rows, columns)
}

fun getWidgetCellsN(size: Int): Int {
var n = 2
while (73 * n - 16 < size) {
++n
}
return n - 1
}

fun getWidgetCellsM(size: Int): Int {
var m = 2
while (118 * m - 16 < size) {
++m
}
return m - 1
}

其中的计算公式,n x m:(73n-16)x(118m-16)就是文档中提到的算法。

但是这种方案有一个致命的问题,那就是不同的ROM的计算方式完全不一样,有可能在Vivo上一个格子的高度只有80,但是在Pixel中,一个格子就是100,所以,在不同的设备上显示的n x m不一样,也是很正常的事。

也正是因为这样的问题,如果不是只在Android 12+的设备上使用,那么通常都是固定好Widget的大小,避免使用动态布局,这也是没办法的权衡之举。

RemoteViews行为

RemoteViews不像普通的View,所以我们不能像写普通布局的方式一样来操纵View,但RemoteViews提供了一些set方法来帮助我们对RemoteViews中的View进行修改,例如下面的代码。

remoteViews.setTextViewText(R.id.title, widgetData.xxx)

再比如点击后刷新Widget,实际上就是创建一个PendingIntent。

val intentUpdate = Intent(context, XXXAppWidget::class.java).also {
it.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
it.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
}
val pendingUpdate = PendingIntent.getBroadcast(
context, appWidgetId, intentUpdate,
PendingIntent.FLAG_UPDATE_CURRENT)
views.setOnClickPendingIntent(R.id.btn, pendingUpdate)

原理

RemoteViews通常用在通知和Widget中,分别通过NotificationManager和AppWidgetManager来进行管理,它们则是通过Binder来和SystemServer进程中的NotificationManagerService以及AppWidgetService进行通信,所以,RemoteViews实际上是运行在SystemServer中的,我们在修改RemoteViews时,就需要进行跨进程通信了,而RemoteViews封装了一系列跨进程通信的方法,简化了我们的调用,这也是为什么RemoteViews不支持全部的View方法的原因,RemoteViews抽象了一系列的set方法,并将它们抽象为统一的Action接口,这样就可以提供跨进程通信的效率,同时精简核心的功能。

如何进行后台请求

Widget在后台进行更新时,通常会请求网络,然后根据返回数据来修改Widget的数据展示。

AppWidgetProvider本质是广播,所以它拥有和广播一致的生命周期,ROM通常会定制广播的生命周期时间,例如设置为5s、7s,如果超过这个时间,那么就会产生ANR或者其它异常。

所以,我们一般不会把网络请求直接写在AppWidgetProvider中,一个比较好的方式,就是通过Service来进行更新。

首先我们创建一个Service,用来进行后台请求。

class AppWidgetRequestService : Service() {

override fun onBind(intent: Intent): IBinder? {
return null
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val appWidgetManager = AppWidgetManager.getInstance(this)
val allWidgetIds = intent?.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS)
if (allWidgetIds != null) {
for (appWidgetId in allWidgetIds) {
BackgroundRequest.getWidgetData {
NewAppWidget.updateAppWidget(this, appWidgetManager, appWidgetId, AppWidgetData(book1Cover = it))
}
}
}
return super.onStartCommand(intent, flags, startId)
}
}

在onStartCommand中,我们创建一个协程,来进行真正的网络请求。

object BackgroundRequest : CoroutineScope by MainScope() {
fun getWidgetData(onSuccess: (result: String) -> Unit) {
launch(Dispatchers.IO) {
val response = RetrofitClient.getXXXApi().getXXXX()
if (response.isSuccess) {
onSuccess(response.data.toString())
}
}
}
}

所以,在AppWidgetProvider的update里面,就需要进行下修改,将原有逻辑改为对Service的启动。

class NewAppWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
val intent = Intent(context.applicationContext, AppWidgetRequestService::class.java)
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}

动画?

有必要这么卷吗,Widget里面还要加动画。由于RemoteViews里面不能实现正常的View动画,所以,Widget里面的动画基本都是通过类似「帧动画」的方式来实现的,即将动画抽成一帧一帧的图,然后通过Animator来进行切换,从而实现动画效果,群友给出了一篇比较好的实践,大家可以参考参考,我就不卷了。

juejin.cn/post/704862…

Widget的使用场景主要还是以实用功能为主,只有让用户觉得有用,才能锦上添花给App带来更多的活跃,否则只能是鸡肋。


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

请自信的大声告诉面试官forEach跳不出循环

如果面试官,或者有人问你foreach怎么跳出循环,请你大声的告诉ta,跳不出!!!!!!!!!!foreach 跳不出循环为什么呢?先看看foreach大体实现。Array.prototype.customForEach = function (fn) { ...
继续阅读 »

如果面试官,或者有人问你foreach怎么跳出循环,请你大声的告诉ta,跳不出!!!!!!!!!!

foreach 跳不出循环

为什么呢?

先看看foreach大体实现。

Array.prototype.customForEach = function (fn) {
for (let i = 0; i < this.length; i++) {
fn.call(this, this[i], i, this)
}
}

list.customForEach((item, i, list) => {
console.log(item, i, list)
})

let list = [1,2,3,4,5]

list.forEach((item,index,list)=>{
console.log(item,index,list)
})

list.customForEach((item,index,list)=>{
console.log(item,index,list)
})

两个输出的结果是一样的没啥问题,这就是foreach的大体实现,既然都知道了它的实现,那么对它为什么跳不出循环♻️应该都知道了,再不清楚的话,再看一下下面的例子。


function demo(){
return 'demo'
}

function demo2(){
demo()
return 'demo2'
}

demo()

在demo2函数里面调用demo函数,demo函数的return能阻止demo2函数下面的执行吗?很明显不行啊,demo函数里的return跟demo2函数一点关系都没有。现在你再回头看看foreach的实现,就明白它跳不出循环一清二楚了。

有点同学说不是可以通过抛出错误跳出循环吗?是的。看看下面例子。


let list = [1,2,3,4,5]

try {
list.forEach((item, index, list) => {
if (index === 2) {
throw new Error('demo')
}
console.log(item)
})
} catch (e) {
// console.log(e)
}

结果是我们想要,但是你看代码,哪个正常人会这样写代码?是非foreach不用吗?还是其他的循环关键字不配呢。

end

有反驳在评论区,show me your code !!!!!!!!!


作者:啥也不懂的前端
链接:https://juejin.cn/post/7259595485090906149
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

new 一个对象时,js 做了什么?

js
前言在 JavaScript 中, 通过 new 操作符可以创建一个实例对象,而这个实例对象继承了原对象的属性和方法。因此,new 存在的意义在于它实现了 JavaScript 中的继承,而不仅仅是实例化了一个对象。new 的作用我们先通过例子来了解 ...
继续阅读 »

前言

在 JavaScript 中, 通过 new 操作符可以创建一个实例对象,而这个实例对象继承了原对象的属性和方法。因此,new 存在的意义在于它实现了 JavaScript 中的继承,而不仅仅是实例化了一个对象。

new 的作用

我们先通过例子来了解 new 的作用,示例如下:

function Person(name) {
this.name = name
}
Person.prototype.sayName = function () {
console.log(this.name)
}
const t = new Person('小明')
console.log(t.name) // 小明
t.sayName() // 小明

从上面的例子中我们可以得出以下结论:

  • new 通过构造函数 Person 创建出来的实例对象可以访问到构造函数中的属性。

  • new 通过构造函数 Person 创建出来的实例可以访问到构造函数原型链中的属性,也就是说通过 new 操作符,实例与构造函数通过原型链连接了起来。

构造函数 Person 并没有显式 return 任何值(默认返回 undefined),如果我们让它返回值会发生什么事情呢?

function Person(name) {
this.name = name
return 1
}
const t = new Person('小明')
console.log(t.name) // 小明

在上述例子中的构造函数中返回了 1,但是这个返回值并没有任何的用处,得到的结果还是和之前的例子完全一样。我们又可以得出一个结论:

构造函数如果返回原始值,那么这个返回值毫无意义。

我们再来试试返回对象会发生什么:

function Person(name) {
this.name = name
return {age: 23}
}
const t = new Person('小明')
console.log(t) // { age: 23 }
console.log(t.name) // undefined

通过上面这个例子我们可以发现,当返回值为对象时,这个返回值就会被正常的返回出去。我们再次得出了一个结论:

构造函数如果返回值为对象,那么这个返回值会被正常使用。

总结:这两个例子告诉我们,构造函数尽量不要返回值。因为返回原始值不会生效,返回对象会导致 new 操作符没有作用。

实现 new

首先我们要清楚,在使用 new 操作符时,js 做了哪些事情:

  1. js 在内部创建了一个对象
  2. 这个对象可以访问到构造函数原型上的属性,所以需要将对象与构造函数连接起来
  3. 构造函数内部的this被赋值为这个新对象(即this指向新对象)
  4. 返回原始值需要忽略,返回对象需要正常处理

知道了步骤后,我们就可以着手来实现 new 的功能了:

function _new(fn, ...args) {
const newObj = Object.create(fn.prototype);
const value = fn.apply(newObj, args);
return value instanceof Object ? value : newObj;
}

测试示例如下:

function Person(name) {
this.name = name;
}
Person.prototype.sayName = function () {
console.log(this.name);
};

const t = _new(Person, "小明");
console.log(t.name); // 小明
t.sayName(); // 小明

以上就是关于 JavaScript 中 new 操作符的作用,以及如何来实现一个 new 操作符。


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

手写一个Promise

Promise背景JavaScript这种单线程事件循环模型,异步行为是为了优化因计算量大而时间长的操作。在JavaScript中我们可以见到很多异步行为,比如计时器、ui渲染、请求数据等等。Promise的主要功能,是为异步代码提供了清晰的抽象,支持优雅地定...
继续阅读 »

Promise

背景

JavaScript这种单线程事件循环模型,异步行为是为了优化因计算量大而时间长的操作。在JavaScript中我们可以见到很多异步行为,比如计时器、ui渲染、请求数据等等。

Promise的主要功能,是为异步代码提供了清晰的抽象,支持优雅地定义和组织异步逻辑。可以用Promise表示异步执行的代码块,也可以用Promise表示异步计算的值。

Promise现在主流的翻译为“期约”,在英文里,promise还有承诺的意思,既然是承诺,那就是一种约定,这恰好就符合异步情境的需求:异步的代码不在当前的代码块中调用,而是由外部调用。既然如此,为了获取到异步代码执行的状态,或是为了拿到执行结果,就需要制定一定的规范去获取和维护,Promise A+就是对此指定的规范,Promise类型就是对Promise A+规范的实现。

过去在JavaScript中处理异步,通常会使用一层层的回调嵌套,没有一个规范、清晰的处理逻辑,造成的结果就是阅读困难、调试困难,可维护性差。

Promise A+规范设计的一套逻辑,Promise提供统一的API,可以使我们更有条理的去处理异步操作。

首先,将某个异步任务相关的代码包裹在一个代码块里,也就是Promise执行器函数的函数体中;比如下面的代码:

let p1 = new Promise((resolve, reject) => { // 执行器函数
   const add = (a, b) => a + b;
   resolve(add(1, 2));
   console.log(add(3, 4));
});

同时,针对这段异步代码的执行状态和执行结果,Promise实例内部会进行维护;

此外,Promise类型内部维护一个resolve和reject函数,用于维护状态的更新,以及调用处理程序将异步执行结果传递给用户进行后续处理,这些处理程序由用户自己定义。

Promise类型实现了Thenable接口,用户可以通过Promise的实例方法then来新增处理程序

当用Promise指代异步执行的代码块时,他涉及异步代码执行的三种状态:进行中等待结果的pending、成功执行fulfilled(一般也用resolved)、执行失败或出现异常rejected。当一个Promise实例被初始化时,其对应的异步代码块就进入进行中的状态,也就是说pending是初始状态。

当代码块执行完毕或者出现异常,将得到最终的一个确定状态,resolved或者rejected,和执行结果,并且不能被再次更新。

Promise的基本使用

let p = new Promise((resolve, reject) => {
   const add = (a, b) => a + b;
   resolve(add(1, 2));
   console.log(add(3, 4));
});
p.then(res => {
   console.log(res);
});

简易版Promise

针对Promise的基本使用,可以实现一个简易版的Promise

首先是状态常量的维护,以便于开发和后期维护:

const PENDING = 'pending';
const RESOLVED = 'resolved';
const REJECTED = 'rejected';

然后定义我们自己的MyPromise,维护Promise实例对象的属性

function MyPromise(fn) {
const that = this;
   that.state = PENDING;
   that.value = null;
   that.resolvedCallbacks = [];
   that.rejectedCallbacks = [];
}
  • 首先是state,表示异步代码块执行的状态,初始状态为pending
  • value变量用于维护异步代码执行的结果
  • resolvedCallbacks用于维护部分的处理程序,处理成功执行的结果
  • rejectedCallbacks用于维护另一部分的处理程序,处理的是执行失败的结果

内部使用常量that是因为,代码可能会异步执行,这用于获取正确的this。

接下来定义resolve和reject函数,添加在MyPromise函数体内部

function resolve(value) {
   if (that.state === PENDING) {
       that.state = RESOLVED;
       that.value = value;
       that.resolvedCallbacks.forEach(cb => cb(value));
  }
}

function reject(reason) {
   if (that.state === PENDING) {
       that.state = REJECTED;
       that.value = reason;
       that.rejectedCallbacks.forEach(cb => cb(reason));
  }
}
  • 首先这两个函数都得判断当前状态是否为pending,因为状态落定后不允许再次修改
  • 如果判断为pending,就更新为对应状态,并且将异步执行结果维护到Promise实例的value属性上
  • 最后遍历处理程序,并传入异步结果挨个执行

当然传递给Promise的执行器函数fn也得执行

try {
   fn(resolve, reject);
} catch (e) {
   reject(e);
}

执行器函数接收两个函数类型的参数,实际传入的就是前面定义的resolve和reject。另外,执行函数的过程中可能会抛出异常,需要捕获并执行reject函数。

最后实现较为复杂的then函数

MyPromise.prototype.then = function (onResolved, onRejected) {
   const that = this;
   onResolved = typeof onResolved === 'function' ? onResolved: v => v;
   onRejected = typeof onRejected === 'function'
       ? onRejected
      : r => {
           throw r;
      };
   if (that.state === PENDING) {
       that.resolvedCallbacks.push(onResolved);
       that.rejectedCallbacks.push(onRejected);
  }
   if (that.state === RESOLVED) {
       onResolved(that.value);
  }
   if (that.state === REJECTED) {
       onRejected(that.value);
  }
}
  • 首先判断两个参数是否为函数类型,因为这两个参数是可选参数。
  • 当参数不是函数类型时,就创建一个函数赋值给对应的参数,实现透传
  • 然后是状态的判断,当Promise的状态是等待结果pending时,就会将处理程序维护到Promise实例内部的处理程序的数组中,resolvedCallbacks和rejectedCallbacks,如果不是pending,就去执行对应状态的处理程序。

至此就实现了一个简易版本的MyPromise,可以进行测试:

let p = new MyPromise((resolve, reject) => {
   const add = (a, b) => a + b;
   resolve(add(1, 2));
   console.log(add(3, 4));
});
p.then(res => {
   console.log(res);
});

进阶版Promise

根据promise的使用经验,我们知道promise解析异步结果是一个微任务,并且promise的原型方法then会返回一个promise类型的值,这些简易版中都没有实现,为了使我们的MyPromise更符合Promise A+的规范,我们需要对简易版进行改造。

首先是resolvereject函数,这两个函数中的代码会被推入微任务的队列中等待执行

  function resolve(value) {
       if (value instanceof MyPromise) {
           return value.then(resolve, reject);
      }

       // 调用queueMicrotask,将代码插入微任务的队列
       queueMicrotask(() => {
           if (that.state === PENDING) {
               that.state = RESOLVED;
               that.value = value;
               that.resolvedCallbacks.forEach(cb => cb(value));
          }
      })
  }

   function reject(reason) {
       queueMicrotask(() => {
           if (that.state === PENDING) {
               that.state = REJECTED;
               that.value = reason;
               that.rejectedCallbacks.forEach(cb => cb(reason));
          }
      });
  }
  • 对于resolve函数,我们首先需要判断传入的值是否为Promise类型,如果是,则要得到x最终的异步执行结果再继续执行resolve和reject
  • 此处使用queueMicrotask方法将代码推入微任务队列

接下来继续改造then函数中的代码

  • 首先新增一个变量promise2用于返回,因为每个then函数都需要返回一个新的Promise对象,该变量就用于保存新的返回对象

    let promise2; // then方法必须返回一个promise
  • 然后先改造pending状态的逻辑

    if (that.state === PENDING) {
       return promise2 = new MyPromise((resolve, reject) => {
           that.resolvedCallbacks.push(() => {
               try {
                   const x = onResolved(that.value); // 执行原promise的成功处理程序,如果未定义就透传
                   // 如果正常得到一个解决值x,即onResolved的返回值,就解决新的promise2,即调用resolutionProcedure函数,这是对[[Resolve]](promise, x)的实现
                   // 将新创建的promise2,处理程序返回结果x,以及与promise2关联的resolve和reject函数作为参数传递给 这个函数
                   resolutionProcedure(promise2, x, resolve, reject);
              } catch(r) { // 如果onResolved程序执行过程中抛出异常,promise2就被标记为失败,执行reject
                   reject(r);
              }
          });
           that.rejectedCallbacks.push(() => {
               try {
                   const x = onRejected(that.value); // 执行原promise的失败处理程序,如果未定义就抛出异常
                   resolutionProcedure(promise2, x, resolve, reject); // 解决新的promise2
              } catch(r) {
                   reject(r);
              }
          });
      })
    }

    整体来看下:

    • 首先创建新的Promise实例,传入执行器函数
    • 大致逻辑还是和之前一样,往回调数组中push处理程序,只是除了onResolved函数之外,还做了一些额外操作
    • 首先在onResolved和onRejected函数调用的时候包裹了一层try/catch用于处理异常,如果出现异常,promise2就被标记为失败,执行其关联的reject函数
    • 如果onResolved和onRejected正常执行,就调用resolutionProcedure函数去解决promise2
  • 继续改造resolved状态的逻辑

    if (that.state === RESOLVED) {
       return promise2 = new MyPromise((resolve, reject) => {
           queueMicrotask(() => {
               try {
                   const x = onResolved(that.value);
                   resolutionProcedure(promise2, x, resolve, reject);
              } catch (r) {
                   reject(r);
              }
          });
      })
    }
    • 这段代码和pending的逻辑基本一致,不同之处在于,这里直接将处理程序插入微任务队列,而不是push进回调数组
    • rejected状态的逻辑基本也类似

最后就是实现上述代码中所调用的resolutionProcedure函数,用于解决promise2

function resolutionProcedure(promise2, x, resolve, reject) {}
  • 首先规范规定了x不能与promise2相等,否则会发生循环引用的问题

    if (promise2 === x) { // 如果x和promise2相等,以 TypeError 为拒因 拒绝执行 promise2
       return reject(new TypeError('Error'));
    }
  • 接着判断x的类型是否为promise

    if (x instanceof MyPromise) { // 如果x为Promise类型,则使 promise2 接受 x 的状态
       x.then(function (value) {
           // 等到x状态落定后,再去解决promise2,也就是递归调用resolutionProcedure这个函数
           resolutionProcedure(promise2, value, resolve, reject);
      }, reject/*如果x落定为拒绝状态,就用同样的拒因拒绝promise2*/);
    }
  • 处理x的类型不是promise的情况

    首先创建一个变量called用于标识是否调用过函数

    let called = false;
    if (x !== null && (typeof x === 'object' || typeof x === 'function')) { // 如果x为对象或函数类型
       try {
           let then = x.then; // 取出x上的then属性
           if (typeof then === 'function') { // 判断then的类型是否为函数,进行调用
            // 根据规范可知,在then调用时,要将this指向x,所以这里使用call对then函数进行调用
               // then接收两个函数类型的参数,第一个参数叫做resolvePromise,第二个参数叫做rejectPromise
               // 如果resolvePromise被执行,则去解决promise2,如果rejectPromise被调用,则promise2被认为失败,会调用其关联的reject函数
               then.call(
                   x, // 将this指向x
                   y => { // 第一个参数叫做resolvePromise
                       if (called) return;
                       called = true;
                       resolutionProcedure(promise2, y, resolve, reject);
                  },
                   r => { // 第二个参数叫做rejectPromise
                       if (called) return;
                       called = true;
                       reject(r);
                  }
              )
          } else { // 如果then不是函数,就将x传递给resolve,执行promise2的resolve函数
               resolve(x);
          }
      } catch (e) { // 如果上述代码抛出异常,则认为promise2失败,执行其关联的reject函数
           if (called) return;
           called = true;
           reject(e);
      }
    } else { // 如果x不是对象或函数,就将x传递给promise2关联的resolve并执行
       resolve(x);
    }

至此resolutionProcedure函数就完成了,最终会执行promise2关联的resolve或者reject函数。之所以说关联,是因为这两个函数中有对实例的引用。

到这为止,进阶版的promise就基本完成了,可以来试用一下:

let p = new MyPromise((resolve, reject) => {
   const add = (a, b) => a + b;
   resolve(add(1, 2));
   console.log(add(3, 4));
});
p.then(res => {
   console.log(res);
   return res;
}).then(res => {
   console.log(res);
});
p.then(res => {
   return {
       name: 'x',
       then: function (resolvePromise, rejectPromise) {
           resolvePromise(this.name + res);
      }
  }
}).then(res => {
   console.log(res);
})

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

你的代码不堪一击!太烂了!

前言小王,你的页面白屏了,赶快修复一下。小王排查后发现是服务端传回来的数据格式不对导致,无数据时传回来不是 [] 而是 null, 从而导致 forEach 方法报错导致白屏,于是告诉测试,这是服务端的错误导致...
继续阅读 »

前言

小王,你的页面白屏了,赶快修复一下。小王排查后发现是服务端传回来的数据格式不对导致,无数据时传回来不是 [] 而是 null, 从而导致 forEach 方法报错导致白屏,于是告诉测试,这是服务端的错误导致,要让服务端来修改,结果测试来了一句:“服务端返回数据格式错误也不能白屏!!” “好吧,千错万错都是前端的错。” 小王抱怨着把白屏修复了。

刚过不久,老李喊道:“小王,你的组件又渲染不出来了。” 小王不耐烦地过来去看了一下,“你这个属性data 格式不对,要数组,你传个对象干嘛呢。”老李反驳: “ 就算 data 格式传错,也不应该整个组件渲染不出来,至少展示暂无数据吧!” “行,你说什么就是什么吧。” 小王又抱怨着把问题修复了。

类似场景,小王时不时都要经历一次,久而久之,大家都觉得小王的技术太菜了。小王听到后,倍感委屈:“这都是别人的错误,反倒成为我的错了!”

等到小王离职后,我去看了一下他的代码,的确够烂的,不堪一击!太烂了!下面来吐槽一下。

一、变量解构一解就报错

优化前

const App = (props) => {
const { data } = props;
const { name, age } = data
}

如果你觉得以上代码没问题,我只能说你对你变量的解构赋值掌握的不扎实。

解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于 undefined 、null无法转为对象,所以对它们进行解构赋值时都会报错。

所以当 data 为 undefined 、null 时候,上述代码就会报错。

优化后

const App = (props) => {
const { data } = props || {};
const { name, age } = data || {};
}

二、不靠谱的默认值

估计有些同学,看到上小节的代码,感觉还可以再优化一下。

再优化一下

const App = (props = {}) => {
const { data = {} } = props;
const { name, age } = data ;
}

我看了摇摇头,只能说你对ES6默认值的掌握不扎实。

ES6 内部使用严格相等运算符(===)判断一个变量是否有值。所以,如果一个对象的属性值不严格等于 undefined ,默认值是不会生效的。

所以当 props.data 为 null,那么 const { name, age } = null 就会报错!

三、数组的方法只能用真数组调用

优化前:

const App = (props) => {
const { data } = props || {};
const nameList = (data || []).map(item => item.name);
}

那么问题来了,当 data 为 123 , data || [] 的结果是 123123 作为一个 number 是没有 map 方法的,就会报错。

数组的方法只能用真数组调用,哪怕是类数组也不行。如何判断 data 是真数组,Array.isArray 是最靠谱的。

优化后:

const App = (props) => {
const { data } = props || {};
let nameList = [];
if (Array.isArray(data)) {
nameList = data.map(item => item.name);
}
}

四、数组中每项不一定都是对象

优化前:

const App = (props) => {
const { data } = props || {};
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => `我的名字是${item.name},今年${item.age}岁了`);
}
}

一旦 data 数组中某项值是 undefined 或 null,那么 item.name 必定报错,可能又白屏了。

优化后:

const App = (props) => {
const { data } = props || {};
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => `我的名字是${item?.name},今年${item?.age}岁了`);
}
}

? 可选链操作符,虽然好用,但也不能滥用。item?.name 会被编译成 item === null || item === void 0 ? void 0 : item.name,滥用会导致编辑后的代码大小增大。

二次优化后:

const App = (props) => {
const { data } = props || {};
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => {
const { name, age } = item || {};
return `我的名字是${name},今年${age}岁了`;
});
}
}

五、对象的方法谁能调用

优化前:

const App = (props) => {
const { data } = props || {};
const nameList = Object.keys(data || {});
}

只要变量能被转成对象,就可以使用对象的方法,但是 undefined 和 null 无法转换成对象。对其使用对象方法时就会报错。

优化后:

const _toString = Object.prototype.toString;
const isPlainObject = (obj) => {
return _toString.call(obj) === '[object Object]';
}
const App = (props) => {
const { data } = props || {};
const nameList = [];
if (isPlainObject(data)) {
nameList = Object.keys(data);
}
}

六、async/await 错误捕获

优化前:

import React, { useState } from 'react';

const App = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
const res = await queryData();
setLoading(false);
}
}

如果 queryData() 执行报错,那是不是页面一直在转圈圈。

优化后:

import React, { useState } from 'react';

const App = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
try {
const res = await queryData();
setLoading(false);
} catch (error) {
setLoading(false);
}
}
}

如果使用 trycatch 来捕获 await 的错误感觉不太优雅,可以使用 await-to-js 来优雅地捕获。

二次优化后:

import React, { useState } from 'react';
import to from 'await-to-js';

const App = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
const [err, res] = await to(queryData());
setLoading(false);
}
}

七、不是什么都能用来JSON.parse

优化前:

const App = (props) => {
const { data } = props || {};
const dataObj = JSON.parse(data);
}

JSON.parse() 方法将一个有效的 JSON 字符串转换为 JavaScript 对象。这里没必要去判断一个字符串是否为有效的 JSON 字符串。只要利用 trycatch 来捕获错误即可。

优化后:

const App = (props) => {
const { data } = props || {};
let dataObj = {};
try {
dataObj = JSON.parse(data);
} catch (error) {
console.error('data不是一个有效的JSON字符串')
}
}

八、被修改的引用类型数据

优化前:

const App = (props) => {
const { data } = props || {};
if (Array.isArray(data)) {
data.forEach(item => {
if (item) item.age = 12;
})
}
}

如果谁用 App 这个函数后,他会搞不懂为啥 data 中 age 的值为啥一直为 12,在他的代码中找不到任何修改 data中 age 值的地方。只因为 data 是引用类型数据。在公共函数中为了防止处理引用类型数据时不小心修改了数据,建议先使用 lodash.clonedeep 克隆一下。

优化后:

import cloneDeep from 'lodash.clonedeep';

const App = (props) => {
const { data } = props || {};
const dataCopy = cloneDeep(data);
if (Array.isArray(dataCopy)) {
dataCopy.forEach(item => {
if (item) item.age = 12;
})
}
}

九、并发异步执行赋值操作

优化前:

const App = (props) => {
const { data } = props || {};
let urlList = [];
if (Array.isArray(data)) {
data.forEach(item => {
const { id = '' } = item || {};
getUrl(id).then(res => {
if (res) urlList.push(res);
});
});
console.log(urlList);
}
}

上述代码中 console.log(urlList) 是无法打印出 urlList 的最终结果。因为 getUrl 是异步函数,执行完才给 urlList 添加一个值,而 data.forEach 循环是同步执行的,当 data.forEach 执行完成后,getUrl 可能还没执行完成,从而会导致 console.log(urlList) 打印出来的 urlList 不是最终结果。

所以我们要使用队列形式让异步函数并发执行,再用 Promise.all 监听所有异步函数执行完毕后,再打印 urlList 的值。

优化后:

const App = async (props) => {
const { data } = props || {};
let urlList = [];
if (Array.isArray(data)) {
const jobs = data.map(async item => {
const { id = '' } = item || {};
const res = await getUrl(id);
if (res) urlList.push(res);
return res;
});
await Promise.all(jobs);
console.log(urlList);
}
}

十、过度防御

优化前:

const App = (props) => {
const { data } = props || {};
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => {
const { name, age } = item || {};
return `我的名字是${name},今年${age}岁了`;
});
}
const info = infoList?.join(',');
}

infoList 后面为什么要跟 ?,数组的 map 方法返回的一定是个数组。

优化后:

const App = (props) => {
const { data } = props || {};
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => {
const { name, age } = item || {};
return `我的名字是${name},今年${age}岁了`;
});
}
const info = infoList.join(',');
}

后续

以上对小王代码的吐槽,最后我只想说一下,以上的错误都是一些 JS 基础知识,跟任何框架没有任何关系。如果你工作了几年还是犯这些错误,真的可以考虑转行。


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

多个AAR打包成一个AAR

AAR
1. 背景介绍公司日常开发基于自建的Maven服务器,不对外开放,公司内开发的SDK都传到私服,经过这么多年的迭代已经有上百个包,前段时间有其他公司需要依赖内部某个SDK,而这个SDK有依赖了公司好多SDK,但是公司内网权限无法对外开放,所以无法使用Maven...
继续阅读 »

1. 背景介绍

公司日常开发基于自建的Maven服务器,不对外开放,公司内开发的SDK都传到私服,经过这么多年的迭代已经有上百个包,前段时间有其他公司需要依赖内部某个SDK,而这个SDK有依赖了公司好多SDK,但是公司内网权限无法对外开放,所以无法使用Maven方式对外提供依赖,如果基于AAR方式,对外提供十几个AAR不仅不友好,而且内部也不好维护迭代。

2. 解决思路及办法

市面上有一套开源的合并AAR的方案,合并AAR主要的步骤:

  • AndroidManifest合并
  • Classes合并
  • Jar合并
  • Res合并
  • Assets合并
  • Jni合并
  • R.txt合并
  • R.class合并
  • DataBinding合并
  • Proguard合并
  • Kotlin module合并

这些都有对应Gradle task,具体方案可以看对应源码:adwiv/android-fat-aar目前已不再维护,gradle不支持高版本,kezong/fat-aar-android虽然也不在维护,但是已经适配了AGP 3.0 - 7.1.0,Gradle 4.9 - 7.3。

3. 遇到问题

3.1 资源冲突

如果library和module中含有同名的资源(比如 string/app_name),编译将会报duplication resources的相关错误,有两种方法可以解决这个问题:

  • 将library以及module中的资源都加一个前缀来避免资源冲突(不是所有历史版本的SDK都遵循这个规范);
  • gradle.properties中添加android.disableResourceValidation=true可以忽略资源冲突的编译错误,程序会采用第一个找到的同名资源作为实际资源(资源覆盖可能会导致某些错误)

3.2 动态库冲突

在application中动态库冲突可以使用pickFirst指定第一个,但是这个无法适用于library中。

关于packagingOptions常见的设置项有exclude、pickFirst、doNotStrip、merge。

1. exclude,过滤掉某些文件或者目录不添加到APK中,作用于APK,不能过滤aar和jar中的内容。

比如:

packagingOptions {
exclude 'META-INF/**'
exclude 'lib/arm64-v8a/libopus.so'
}

2. pickFirst,匹配到多个相同文件,只提取第一个。只作用于APK,不能过滤aar和jar中的文件。

比如:

 packagingOptions {
pickFirst "lib/armeabi-v7a/libopus.so"
pickFirst "lib/armeabi-v7a/libopus.so"
}

3. doNotStrip,可以设置某些动态库不被优化压缩。

比如:

 packagingOptions{
doNotStrip "*/armeabi/*.so"
doNotStrip "*/armeabi-v7a/*.so"
}

4. merge,将匹配的文件都添加到APK中,和pickFirst有些相反,会合并所有文件。

比如:

packagingOptions {
merge '**/LICENSE.txt'
merge '**/NOTICE.txt'
}

最后针对包含冲突动态库的SDK,单独对外依赖,在application中pickfirst,暂时没有特别好的方法。

3.3 外部依赖库

SDK中有些依赖的是外部公共仓库,比如OKHTTP等,如果都合并到同一的AAR,会导致外部依赖不够灵活,我们的思路是合并的时候不合并外部SDK,只打包公司内部SDK,并打印外部依赖的SDK,提供给外部手动依赖:

  1. 先定义内部SDK规则方法:
static boolean isInnerDep(RenderableDependency dep) {
return (dep.name.contains("com.xxx")
|| dep.name.contains("com.xxxxx")
|| dep.name.contains("com.xxxxxxx")
|| dep.name.contains("com.xxxxxxxx"))
}
  1. 定义三个集合:
//所有的内部库依赖
Map<String, String> allInnerDeps = new HashMap<>()
//所有的非内部依赖:公共平台库
Map<String, String> allCommonDeps = new HashMap<>()
//库的类型,jar 或者 aar,依赖方式不同
Map<String, String> depType = new HashMap<>()
  1. 分析依赖,放到不同集合打印、合并:

void collectDependencies(Map<String, String> commonDependencies, Map<String, String> innerDependencies, RenderableDependency result) {
String depName = result.name.substring(0, result.name.lastIndexOf(":"))
// println "denName = " + depName
String version = result.name.substring(result.name.lastIndexOf(":") + 1, result.name.length())

if (result.getChildren() != null && result.getChildren().size() > 0) {
if (isInnerDep(result) && !isExcludeDep(result)) {
tryToAdd(innerDependencies, depName, version)
result.getChildren().each {
res ->
collectDependencies(commonDependencies, innerDependencies, res)
}
} else {
tryToAdd(commonDependencies, depName, version)
}
} else {
if (isInnerDep(result) && !isExcludeDep(result)) {
tryToAdd(innerDependencies, depName, version)
} else {
tryToAdd(commonDependencies, depName, version)
}
}
}

configurations.findAll { conf ->
return conf.name == "implementation" || conf.name == "api"
}.each {
conf ->
// println "--------------"+conf.name
def copyConf = conf.copy()
copyConf.setCanBeResolved(true)
copyConf.each {
file ->
String s = file.name.substring(0, file.name.lastIndexOf("."))
String key
if (s.contains("-SNAPSHOT")) {
String t = (s.substring(0, s.lastIndexOf("-SNAPSHOT")))
key = t.substring(0, t.lastIndexOf("-"))
} else {
key = s.substring(0, s.lastIndexOf("-"))
}
String value = file.name.substring(file.name.lastIndexOf("."), file.name.length())
depType.put(key, value)
}
ResolutionResult result = copyConf.getIncoming().getResolutionResult()
RenderableDependency depRoot = new RenderableModuleResult(result.getRoot())
depRoot.getChildren().each {
d ->
collectDependencies(allCommonDeps, allInnerDeps, d)
}

}
println("==================内部依赖====================")

allInnerDeps.each {
dep ->
println dep.key + ":" + dep.value

dependencies {
String key = dep.key.substring(dep.key.lastIndexOf(":") + 1, dep.key.length())
String type = depType.get(key)
if (type == ".aar") {
embed(dep.key + ":" + dep.value + "@aar")
} else {
embed(dep.key + ":" + dep.value)
}
}
}

println "=====================正确使用 sdk,需要添加如下依赖========================"
allCommonDeps.each {
dep ->
println "api " + """ + dep.key + ":" + dep.value + """
}

3.4 对外提供多个业务SDK

我们提供一个同一AAR后,另一个业务也要对外提供SDK,这样有公共依赖的就会有冲突问题,如果都合并成一个,某一方改动,势必会引起另一方回归测试,最后抽取公共的sdk合并成一个aar,各自业务合并各自的AAR。

4. 参考资料

使用fat-aar编译打包多个aar库 - 简书

fat-aar实践及原理分享 - 简书

github.com/kezong/fat-…

GitHub - adwiv/android-fat-aar: Gradle script that allows you to merge and embed dependencies in generted aar file

5. 总结

本文介绍了Android对外输出AAR和不依赖maven,通过合并多个AAR的方式减少依赖方成本,并介绍了实际使用过程中遇到的问题和解决方案。


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

那些隐藏在项目中的kotlin小知识,在座各位...

写kotlin越来越久,很多代码虽然能看懂,并且能去改,但是不知道他用了啥,里面的原理是什么,举个例子?大家一起学习一下吧内联函数顾名思义,但是在项目中我遇到得很少,他比较适用于一些包装方法的写法,比如下面这个inline fun measureTimeMil...
继续阅读 »

写kotlin越来越久,很多代码虽然能看懂,并且能去改,但是不知道他用了啥,里面的原理是什么,举个例子?大家一起学习一下吧

内联函数

顾名思义,但是在项目中我遇到得很少,他比较适用于一些包装方法的写法,比如下面这个

inline fun measureTimeMillis(block: () -> Unit): Long {
val startTime = System.currentTimeMillis()
block()
return System.currentTimeMillis() - startTime
}

val time = measureTimeMillis {
// code to be measured here
}
println("Time taken: $time ms")

这样的函数,以后如果想要测一段代码的运行时间,只需要将measureTimeMillis包着他就行

类型别名

一个很神奇的东西,允许为现有类型定义新名称

data class Person(val name: String, val age: Int)
typealias People = List<Person>

val people: People = listOf(
Person("Alice", 25),
Person("Bob", 30),
Person("Charlie", 35)
)

fun findOlderThan(people: People, age: Int): People {
return people.filter { it.age > age }
}

fun main() {
val olderPeople = findOlderThan(people, 30)
println(olderPeople)
}

其中People就是一个别名,如果使用typealias替代直接定义list,项目中就会少很多后缀为list的列表,少了类似于personlist这种变量,在搜索,全局替换,修改时也会更加直观看到person和people的区分场景

typealias可以被大量使用在list, map乃至于函数中,因为这些命名可能会比较长,替换后可以提高可读性

高阶函数

一个一开始很难理解,理解后又真香的函数,我愿称理解的那一刻为程序员进阶闪耀时,当一个老程序员回首往事时,他不会因为虚度年华而悔恨,但是一定会因为不懂高阶函数而羞耻

尤其是在项目中发现这种函数,又看不懂时,是万万不敢问同事的,所以,请现在就了解清楚吧

fun calculate(x: Int, y: Int, operation: (Int, Int) -> Int): Int {
return operation(x, y)
}

fun main() {
val sum = calculate(10, 5) { x, y -> x + y }
println("Sum is $sum")

val difference = calculate(10, 5) { x, y -> x - y }
println("Difference is $difference")
}

可以看到,calculate其实并没有做什么,只是执行了传入进来的operation,这,就是高阶函数,所谓领导也是如此,优秀的下属,往往将方案随着问题传入进来,领导只要批示一下执行operation即可

配合上lambda原则,最后一个参数可以提出到括号外面,也就是讲operation提出到外面的{}中,交给调用方自己执行,就形成了这样的写法

    val sum = calculate(10, 5) { x, y -> x + y }

理解这一点后,一下子就清晰了很多,calculate看起来什么都没做,他却成为了世界上功能最强大,最灵活,bug最少的计算两个数运算结果的函数

深入

了解上面分析,已经足够我们在kotlin项目中进阶了,现在,我们来看下高阶函数反编译后的java代码

public final class TestKt {
public static final int calculate(int x, int y, @NotNull Function2 operation) {
Intrinsics.checkNotNullParameter(operation, "operation");
return ((Number)operation.invoke(x, y)).intValue();
}

public static final void main() {
int sum = calculate(10, 5, (Function2)null.INSTANCE);
String var1 = "Sum is " + sum;
System.out.println(var1);
int difference = calculate(10, 5, (Function2)null.INSTANCE);
String var2 = "Difference is " + difference;
System.out.println(var2);
}

// $FF: synthetic method
public static void main(String[] var0) {
main();
}
}

虽然java的实现太不优雅,但是我们可以看出,高阶函数,本质上传入的函数是一个名为Function2的对象,

public interface Function2<in P1, in P2, out R> : Function<R> {
/** Invokes the function with the specified arguments. */
public operator fun invoke(p1: P1, p2: P2): R
}

他是kotlin包自带的函数,看起来可以用来在反编译中替换匿名lambda表达式,将其逻辑移动到自身的invoke中,然后生成一个Function2对象,这样实现kotlin反编译为java时的lambda替换

这也是高阶函数得以实现的根本原因


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

Kotlin语法和 Gson 碰撞产生的空指针问题

1. 背景Gson 作为 json 解析最有名的库,我们也在多处使用或借鉴其实现。但是 json解析本就存在很多问题,并且这些问题轻则导致数据丢失,重则直接崩溃,我们应该对他引起重视。在项目更新kotlin之后,更由于g...
继续阅读 »

1. 背景

  • Gson 作为 json 解析最有名的库,我们也在多处使用或借鉴其实现。但是 json解析本就存在很多问题,并且这些问题轻则导致数据丢失,重则直接崩溃,我们应该对他引起重视。在项目更新kotlin之后,更由于gson库是基于java设计的,进而引出了我们今天遇到的问题。

2. 问题

  • 当通过 kotlin 调用 Gson.fromJson(“json”, Class<T>) 解析 json,并且对象通过 kotlin 创建时,有可能在非空的字段解析出 null,例如使用下列 json 和 class 进行解析。
data class LowGsonData(
@SerializedName("name") var name: String,
@SerializedName("age") var age: Int,
@SerializedName("address") var address: String
)

data class LowGsonData(
@SerializedName("name") var name: String = "",
@SerializedName("age") var age?: Int = 0,
@SerializedName("address") var address: String = ""
)

class TestGson {
@Test
fun test() {
val json = "{\"name\":\"cong\",\"age\":11}"
// val json2 = "{\"name\":,\"age\":11}"
val testData = Gson().fromJson(json, LowGsonData::class.java)
println("testData: name = ${testData.name} age = ${testData.age} address = ${testData.address}")
}
}
  • 在我们使用上述两个LowGsonData对json进行解析时,我们关注一下 testData.address 会被解析为什么?

test_address_null.png

address 不是非空的吗?为什么这里address是空?这样在业务代码很容易因为kotlin的空安全检测,导致空指针问题!

3. 寻找原因

1.把kotlin data转为java

  • 因为 kotlin 最终都是转化成 java 字节码运行在虚拟机上的,所以我们先把这个类转为 java 代码方便我们看清这个对象的本质
public final class TestGsonData {
@SerializedName("name")
@NotNull
private String name;
@SerializedName("age")
private int age;
@SerializedName("address")
@NotNull
private String address;

public TestGsonData(@NotNull String name, int age, @NotNull String address) {
Intrinsics.checkNotNullParameter(name, "name");
Intrinsics.checkNotNullParameter(address, "address");
super();
this.name = name;
this.age = age;
this.address = address;
}
}
  • 看着好像没啥问题,调用这个构造函数依然能保证数据非空。那我们就需要继续分析gson是怎么构造出对象的?

2.分析Gson是如何构造对象的

  • Gson 的逻辑,一般都是根据读取到的类型,然后找对应的 TypeAdapter 处理,本例为普通自定义对象,所以会最终走到 ReflectiveTypeAdapterFactory.create 返回相应的 TypeAdapter。其中包含构造对象的方法 3 个:

(1)newDefaultConstructor :我们大部分对象都是通过这个地方创建的,获取无参的构造函数,如果能够找到,则通过 newInstance反射的方式构建对象。

private <T> ObjectConstructor<T> newDefaultConstructor(Class<? super T> rawType) {
try {
final Constructor<? super T> constructor = rawType.getDeclaredConstructor();
if (!constructor.isAccessible()) {
constructor.setAccessible(true);
}
return new ObjectConstructor<T>() {
@SuppressWarnings("unchecked") // T is the same raw type as is requested
@Override public T construct() {
Object[] args = null;
return (T) constructor.newInstance(args);

// 省略了一些异常处理
};
} catch (NoSuchMethodException e) {
return null;
}
}

(2)newDefaultImplementationConstructor:都是一些集合类相关对象的逻辑。

(3)newUnsafeAllocator:通过 sun.misc.Unsafe 构造了一个对象,是用来访问 hidden API,以及获取一定的操作内存的能力。

public static UnsafeAllocator create() {
// try JVM
// public class Unsafe {
// public Object allocateInstance(Class<?> type);
// }
try {
Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
Field f = unsafeClass.getDeclaredField("theUnsafe");
f.setAccessible(true);
final Object unsafe = f.get(null);
final Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class);
return new UnsafeAllocator() {
@Override
@SuppressWarnings("unchecked")
public <T> T newInstance(Class<T> c) throws Exception {
assertInstantiable(c);
return (T) allocateInstance.invoke(unsafe, c);
}
};
} catch (Exception ignored) {
}

// try dalvikvm, post-gingerbread use ObjectStreamClass
// try dalvikvm, pre-gingerbread , ObjectInputStream

}
  • 现在我们已经知道了,当这个对象没有无参构造函数时,第一个方法不成立,最终会通过 unSafe 方式构建对象。虽然 gson 自身的设计,通过三种方式来保证对象创建成功很棒,但是这恰好在Unsafe构造中绕过了 kotlin 的空安全检查。

  • 所以 Unsafe 为啥没能符合空安全呢?

    因为 UnSafe 是直接获取内存中的值, String 对象在没有赋值时正好是 null,并且 json 里没有对应值,最后将不会覆盖他。

  • 好的真相大白了,那有什么改进方法吗?有,尽量满足第一个条件。

  • kotlin 的 data calss 只要有一个属性没有给初始值就不会生成无参构造方法。所以要想保证 gson 解析场景的非空性,我们应该给所有非可空属性附初始值。或者一开始就设置可空,并在业务代码中判空。

data class FullGsonData(
@SerializedName("name") var name: String = "",
@SerializedName("age") var age: Int = 0,
@SerializedName("address") var address: String = ""
)
  • 但是全都这么写吗?毕竟有些对象在业务中需要构造方法传入一些必传的值。那我就比较贪心,我既要又要还要。

  • 我的想法: 在聊天 elem 的场景,结合业务,封装一个工厂供业务构造对象。并在 data class 中继续保持非空构造。

  • 有没有其他好的想法?

    • 通过 kotlin 插件规避

4. 如何规避该问题:

经过调研我认为比较好的方式有:

1.引入noarg和allopen自动生成无参构造函数。

2.尝试对现有项目中使用的json解析库进行升级改造

如moshi,同时适配属性缺失、属性异常等在生产中可能会遇到的问题。

5. 参考:


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

编写整洁代码的技巧

背景前菜什么样的代码是整洁的?衡量代码质量的唯一标准,是别人阅读你代码时的感受。所谓整洁代码,即可读性高、易于理解的代码。不整洁的代码,阅读体验是这样的:乱(组织乱、职责乱、名称乱起)逻辑不清晰(if-else太多)绕弯子(简单的事写的很复杂)看不懂(只有写的...
继续阅读 »

背景

前菜

什么样的代码是整洁的?

衡量代码质量的唯一标准,是别人阅读你代码时的感受。所谓整洁代码,即可读性高、易于理解的代码。

不整洁的代码,阅读体验是这样的:

  1. 乱(组织乱、职责乱、名称乱起)
  2. 逻辑不清晰(if-else太多)
  3. 绕弯子(简单的事写的很复杂)
  4. 看不懂(只有写的人能理解)
  5. 难修改(耦合严重,各种写死)

整洁的代码,阅读体验是这样的:

  1. 清晰(是什么,做了什么,一眼看得出来)
  2. 简单(职责少,代码少,逻辑少)
  3. 干净(没有多余的逻辑)
  4. 好拓展(依赖的比较少,修改不会影响很多)

为什么需要编写整洁的代码?

  1. 保持代码整洁是程序员专业性的重要体现。 写软件就像是盖房子,很难想象一个地板不平、门窗关不严实的房子能称为一个大师制作。代码整洁可以体现一个人的专业水平和追求专业性的态度。

  2. 读代码的时间远远大于写代码。 根据《整洁代码之道》作者在书中小数据量统计,读代码与写代码的时间比可能达到10:1,实际项目中虽然达不到这个比例,但是需要阅读其他同学代码的场景并不少见。让代码容易阅读和理解,可以优化阅读代码的时间成本和沟通成本。

  3. 不整洁的代码带来诸多坏处。

    1. 每一笔不整洁的代码都是一笔技术债,迟早需要偿还,且随着时间的推移,偿还成本可能会越来越大。
    2. 烂代码难以理解,不敢改动,容易按住葫芦浮起瓢,修完一个bug又引入了另一个bug。
    3. 阅读不好的代码,会让人心情烦躁,充满负能量,是一种精神折磨。
    4. 容易引起破窗效应。当代码开始有一些bad smell,因为破窗效应,可能会导致代码越来越烂,不断积累形成“屎山”。

让代码变得整洁

命名

名副其实

在新建变量、函数或类的时候,给一个语义化的命名,不要因为害怕花时间取名字就先随手写一个想着以后再改(个人经验以后大概率是不会再改,或者想改的时候忘记要改哪里了)。如果名称需要注释来补充,那就不算是名副其实。

避免误导

起名字时,避免别人将这个名字误读成其他的含义。有以下几条准则可以使用:

  • 避免使用和本意相悖的词。

e.g.表达一组账号的变量:

  • 如果不是一个List类型,不要使用accountList。
  • 建议使用accountGroup。
  • 避免有歧义的命名。

e.g. 表达过滤后剩下的数据

  • 不要使用filteredUsers,filter具有二义性,不清楚到底是被过滤的,还是过滤后剩下的。
  • 建议使用removedUsers、remainedUsers来分别表示被过滤的和过滤后剩下的。
  • 避免使用外形相似度高的名称。

e.g.简单和单选:

  • 不要使用simple和single,外形相似,容易混淆。
  • 建议使用easy和single。
  • 避免使用不常见的缩写。

避免使用没有区分性的命名

  • 避免使用一些很宽泛的词: 比如Product类和ProductInfo或者ProductData类名称虽然不同,但其实意思是一样的。应该使用更有区分性的命名。再比如,getSize可能返回一个数据结构的长度、所占空间等,改成getLength或getMemoryBytes则更合适一些。
  • 避免tmp之类的名字: 除非真的是显而易见且无关紧要的变量,否则不要使用tmpXxx来命名。
  • 修改 IDE 自动生成的 变量名  IDE自动生成变量名字,有些时候是没有语义的,为了易于理解,在生成代码后,顺便修改变量名字。
/// BAD: element表示的语义是啥?需要结合前面的selectedOptions来推断element的语义
List<String> get selectedKeys {
return selectedOptions.map((element) => element.key).toList();
}

/// GOOD: 阅读代码即可知道获取的是已选选项的key
List<String> get selectedKeys {
return selectedOptions.map((option) => option.key).toList();
}

给变量名带上重要的细节

  • 表示度量的 变量名 带上单位: 如果变量是一个度量(长度、字节数),最好在名字中带上它的单位。比如:startMs、delaySecs、sizeMb等。
  • 附带其他属性: 比如未处理的变量前面加上raw。

不使用魔法数字

遇到常量时,避免直接将魔法数字编写到代码中。这种方式有诸多坏处:

  • 没有语义,需要思考这个魔法数字代表什么意思。进而导致这个代码只有写的人敢改。
  • 如果该魔法数出现多次,之后修改时需要覆盖到每个使用之处,一旦有一处没改,就会有风险。
  • 不便于搜索。

建议改为表达意图的常量,或使用枚举。

/// BAD: 需要耗费注意力寻找2的意义
if (status == 2) {
retry();
}

/// GOOD: 改为表达意图的命名变量
const int timeOut = 2;
if (status == timeOut) {
retry();
}

避免拼写错误

AndroidStudio有自带的拼写检查,平时在写代码的时候可以注意一下拼写错误提示。

注意变量名的长度

变量名不能太长,也不能太短。 太长的名字读起来太费劲,太短的名字读不懂是什么意思。那变量名长度到底多少最合适呢?这个问题没有定论,但是在决策变量名长度时,有一些准则可以使用:

  • 在小的作用域里可以使用短的名字: 作用域小的标识符不用带上太多信息。
  • 丢掉没用的词: 有时候名字中的某些单词可以拿掉且不会损失任何信息。例如:convertToString可以替换为toString。
  • 使用常见的缩写降低变量长度: 例如,pre代替previous、eval代替evaluation、doc代替document、tmp代替temporary、str代替string。

e.g. 在方法里,使用tempMap命名,只需要理解它是用于临时存储,最后作为返回值;但是如果tempMap是在一个类中,那么看到这个变量可能就会比较费解了。

static Map<String, dynamic> toMap(List<Pair> valuePairs) {
Map<String, dynamic> tempMap = {};
for (final pair in valuePairs) {
tempMap[pair.first] = pair.second;
}
return tempMap;
}

附:一些常用 命名规范 

变量

删除没有价值的临时变量

当某个临时变量满足以下条件时,可以删除这个临时变量:

  • 没有拆分任何复杂的表达式。
  • 没有做更多的澄清,即表达式本身就已经比较容易理解了。
  • 只用过一次,并没有压缩任何冗余代码。
/// BAD: 使用临时变量now
final now = datetime.datetime.now();
rootMessage.lastVisitTime = now;

/// GOOD: 去除临时变量now
rootMessage.lastVisitTime = datetime.datetime.now();

缩小变量的作用域

  1. 谨慎使用全局变量。 因为很难跟踪这些全局变量在哪里以及如何使用他们,并且过多的全局变量可能会导致与局部变量命名冲突,进而使得代码会意外地改变全局变量的值。所以在定义全局变量时,问自己一个问题,它一定要被定义成全局变量吗?

  2. 让你的变量对尽可能少的代码可见。 因为这样有效地减少了读者需要同时考虑的变量个数,如果能把所有的变量作用于都减半,则意味着同时需要思考的变量个数平均来说是原来的一半。比如:

    1. 当类中的成员变量太多时,可以将大的类拆分成小的类,让某些变量成为小类中的私有变量。
    2. 定义类的成员变量或方法时,如果不希望外界使用,将它定义成私有的。
  3. 把定义下移。 把变量的定义放在紧贴着它使用的地方。不要在函数或语句块的顶端直接放上所有需要使用的变量的定义,这会让读者在还没开始阅读代码的时候强迫考虑这几个变量的意义,并在接下来的阅读中,不断地索引是哪个变量。

函数

  1. 避免长函数

  • 函数要短小!函数要短小!函数要短小!(重要的事情说三遍)
  • 每个函数只做一件事。

如果发现一个函数太长,一般都是一个函数里干了太多事情,可以使用Extract Method(提取函数) 重构技巧,将函数拆分成若干个子功能,放到若干个子函数中,并给每个子函数一个语义化的命名(必要时可以添加注释)。这样既提高了函数的可读性,同时短小、单一功能的函数也方便复用。

避免太重的分支逻辑

if-else语句、switch语句、try-catch语句中,如果某个分支过于复杂,可以将该分支的内容提炼成独立的函数。这样不但能保持函数短小,而且因为块内调用的函数拥有较具说明性的名称,从而增加了文档上的价值。

/// BAD: if-else中语句多且繁杂
if (date.before(summerStart) || date.after(summerEnd)) {
charge = quantity * winterRate + winterServiceCharge;
} else {
charge = quantity * summerRate;
}

/// GOOD: 分别提炼函数
if (notSummer(date)) {
charge = winterCharge(quantity);
} else {
charge = summerCharge(quantity);
}

使用具有语义化的描述性名称给函数命名

  1. 函数名称应具有描述性,别害怕长的名称。长而具有描述性的名称,要比短而令人费解的名称好。
  2. 别害怕花时间取名字。

降低参数个数

  1. 参数个数越少,理解起来越容易。同时也意味着单测需要覆盖的参数组合少,有利于写单测。

  2. 当输入参数中有bool值时,建议使用Dart中的命名参数。

       /// BAD: bool类型取值只有true和false,无法理解在这个场景下取值的意义,必须得点到方法的声明里
    search(true);

    /// GOOD: 通过命名函数可以了解到取值的意义
    search(forceSearch : true);
  3. 当输入参数过多时,建议将其中一些参数封装成类。不然后续每每增加一个参数,就得修改函数的声明。

       /// BAD: 函数参数中放置多个离散的数据项
    void initUser({
    required String key,
    required String name,
    required int age,
    required String sex,
    }) {
    ...
    }

    /// GOOD: 将紧密相连的数据项聚合到一个类中
    class UserInfo {
    String key;
    String name;
    String sex;
    int age;
    }

    void initStore({required UserInfo user}) {
    ...
    }

分隔指令和查询

函数要么做什么事,要么回答什么事,二者不可得兼。函数应该修改某对象的状态,或是返回该对象的有关信息。两样都干常会导致逻辑混乱。

注释

真正好的注释只有一种,那就是通过其他方式不写注释。

  1. 如果你发现自己需要写注释,再想想看能否用更清晰的代码来表达。
  2. 为什么要贬低注释的价值?注释存在的时间越久,就离所描述的代码越远,变得越来越错误,因为程序员不能坚持维护注释。
  3. 这个目标也并非铁律,项目中经常会存在一些千奇百怪背景的代码,指望全部靠代码表达是不可能的。

坏的注释

  1. 臃肿的、不清楚的、令人费解的注释。 如果不确定注释写的是否合适,让你旁边的同学看下能不能看懂。
  2. 简单的代码,复杂的注释。 阅读注释比代码本身更费时。
  3. 有误导的注释。 随着业务迭代,在改代码的时候并没有更改对应的注释,导致代码逻辑与注释不匹配,引起误解,这对其他开发人员是致命的。
  4. 显而易见的东西,没必要写注释。
  5. 注释不需要的代码。 不需要的代码不要通过注释的方式保存,直接删掉就好。不然别人可能也不敢删除这段代码,会觉得放在那里是有原因的。
  6. 注释不应该用于粉饰不好的设计。 比如给函数或变量随便取了个名字,然后花一大段注释来解释。应该想办法取个更易懂的名字。

好的注释

以下场景值得加上注释:

  1. 代码中含有复杂的业务逻辑,或需要一定的上下文才能理解的代码。 如果希望阅读者对代码背景或代码设计有个全局的了解,可以附上相关的文档链接。
  2. 用输入输出举例,来说明特别的情况。 相较于大段注释来说,一个精心挑选的输入、输出例子更有效。
  3. 将一些晦涩的参数和返回值翻译成可读的东西。
assertTrue(a.compareTo(a) == 0);  // a == a
assertTrue(a.compareTo(b) != 0); // a != b
  1. 代码中的警告、强调,避免其他人调用代码时踩坑。 在写注释的时候问问自己:“这段代码有什么出人意料的地方?会不会被误用?”预料到其他人使用你的代码时可能会遇到的问题,再针对问题写注释。
  2. 对代码的想法。 TODO(待办)、FIXME(有问题的代码)、HACK(对一个问题不得不采用的比较粗糙的解决方案)或一些自定义的注释(REFACTOR、WARNING)。
  3. 在文件、类的级别上,使用“全局观”的注释来解释所有的部分是如何工作的。 用注释来总结代码块,使读者不至于迷失在细节中。
  4. 代码注释应该仅回答代码不能回答的问题。 例如,方法注释应当应该写的是“为什么存在这个方法” 和 “方法做了什么”,而不是“方法是如何实现的”。如果方法注释过于关注方法“如何”工作,那么随着代码的不断变化,它很快就会过时。当开发人员依赖过时的注释来理解方法的工作原理时,这可能会导致混淆和错误。

格式

限制单个文件的代码行数

上图统计了Java中一些知名项目的每个文件的代码行数。可以看到都是由很多行数比较小的文件构成,没有超过500行的单独文件,大多数都少于200行。

小文件通常比大文件更加容易理解。 虽然这不是一个硬性规定,但一般一个文件不应该超过200行,且上限为500行。

dart中,可以通过part字段,对长文件进行拆分。

  1. 限制代码的长度

眼睛在阅读高而窄的文本时会更舒服,这正是报纸文章看起来是这样的原因:避免编写太长的代码行是一个很好的做法。另外,短行代码在使用三栏方式解冲突的时候,不需要横向滚动,更容易发现冲突的内容。

/// BAD: 参数放在一行展示
Future navigateToXxxPage({required BuildContext context, required Map<String, dynamic> queryParams, Object? arguments,});

/// GOOD: 每个参数一行展示,更清晰
Future navigateToXxxPage({
required BuildContext context,
required Map<String, dynamic> queryParams,
Object? arguments,
});

合理使用代码中的空行

源代码中的空行可以很好的区分不同的概念。反之,内容相关的代码不应该空行,应该紧贴在一起。

变量、函数声明

  1. 变量的声明应尽可能接近它使用的地方。 类的成员变量的声明应该出现在类的顶部。局部使用的变量应该声明在它使用之处附近。
  2. Dart函数中参数的声明,required标记的参数尽量归在一起。
  3. 如果有一堆变量要声明(类的成员变量、函数的参数),可以从重要的到不重要的进行排序。
  4. 如果一个函数调用另一个函数,它们应该在垂直上靠近,并且如果可能的话,调用者应该在被调用者之上。 在一般情况下,我们希望函数调用依赖关系指向向下的方向。也就是说,一个被调用的函数应该在一个执行调用的函数下面。像在看报纸一样,我们期待最重要的概念最先出现,低层次的细节出现在最后。

简化控制流、表达式

如果代码中没有条件判断、循环或者任何其他的控制流语句,那么它的可读性会很好。而跳转和分支等部分则会很快地让代码变得混乱。

调整条件语句中参数的顺序

比较的左值为变量,右值为常量。这种方式更符合自然语言的顺序。

/// BAD: 
if (10 <= length)

/// GOOD:
if (length >= 10)

调整if-else语句块的顺序

在写if-else语句的时候:

  • 首先处理正逻辑而不是负逻辑的情况。例如,用if(debug)而不是if(!debug)
  • 先处理掉简单的情况。这种方式可能还会使得if和else在屏幕之内都可见。
  • 先处理有趣的或者是可疑的情况。

合并相同返回值

当有一系列的条件测试返回同样的结果时,可以将这些测试合并成一个条件表达式,并将这个条件表达式提炼成一个独立函数。/// BAD: 多个条件分开写,但是返回了同一个值。

int test() {
final bool case1 = ...;
final bool case2 = ...;
final bool case3 = ...;
if (case1) {
return 0;
}
if (case2) {
return 0;
}
if (case3) {
return 0;
}
return 1;
}

/// GOOD:将统一返回值对应的条件合并。
int test() {
if (shouldReturnZero()) {
return 0;
}
return 1;
}

bool shouldReturnZero() {
final bool case1 = ...;
final bool case2 = ...;
final bool case3 = ...;
return case1 || case2 || case3;
}

不要追求降低代码行数而写出难理解的表达式

三目运算符可以写出紧凑的代码,但是不要为了将所有代码都挤到一行里而使用三目运算符。三目运算符应该是从两个简单的值中做选择,如果逻辑复杂,使用if-else更好。

/// BAD: 
return exponent >= 0 ? mantissa * (1 << exponent): mantissa/(1<<-exponent);

/// GOOD:
if (exponent >= 0) {
return mantissa * (1 << exponent);
} else {
return mantissa / (1 << -exponent);
}

避免嵌套过深

条件表达式通常有两种表现形式:

  • 所有分支都属于正常行为(使用if-else形式)。
  • 只有一种是正常行为,其他都是不常见的情况(if-if-if...-正常情况)。

嵌套过多深使代码更难读取和跟踪,可以尽量将代码转为以上两种标准的if形式。

/// BAD: if-else嵌套太深,难以理解逻辑。
void test() {
if (case1) {
return a;
} else {
if (case2) {
return b;
} else {
if (case3) {
return c;
} else {
return d;
}
}
}
}

/// GOOD: 先处理非正常情况,直接退出,再处理正常情况,降低理解成本
void test() {
if (case1) return a;
if (case2) return b;
if (case3) return c;
return d;
}

不要使用if-else代替switch

一般使用switch的场景,都是某个变量有可枚举的取值,比如枚举类型,不要使用if-else来代替枚举值的判断:

enum State {success, failed, loading}

/// BAD: 对于现在的流程是没问题,但是万一新增了一个State,忘记修改这里,就会出现风险;
/// 况且switch本身就适合在这种场景下使用。
void fun() {
if (state == State.success) {
// do something when success
} else if (state == State.failed) {
// do something when failed
} else {
// do something when loading
}
}

/// GOOD: 当State新增了一个枚举值时,这里会报错,必须修改这里才能编译通过
void fun () {
switch (state) {
case State.success:
// do something when success
break;
case State.failed:
// do something when failed
break;
case State.loading:
// do something when loading
break;
}
}

让表达式更易读

日常写代码时,最常见的一个现象就是if语句的条件中,包含了大量的与或非表达式,如果表达式的逻辑简单还好,一旦表达式开始嵌套或多个与或非并列,那么对于理解代码的人来说将是一个灾难。遇到这种情况,可以使用以下的技巧,逐步优化代码:

  1. 提取解释变量。 引入额外的变量,来表示一个小一点的子表达式。

    /// BAD: 阅读代码的人需要理解line.split(":")[0].trim()代表什么,当没有注释时往往纯靠猜测
    if (line.split(":")[0].trim() == "root") {
    // xxx
    }
    /// GOOD: 快速理解line.split(":")[0].trim()的语义,便于理解if条件表达式
    final userName = line.split(":")[0].trim();
    if (userName == "root") {
    // xxx
    }

    /// BAD: 理解这个表达式需要花多久?
    if (line.split(":")[0].trim() == "root" || line.split(":")[1].trim() == "admin") {
    // xxx
    }
    /// GOOD: 还是这个更容易理解?
    final isRootUser = line.split(":")[0].trim() == "root";
    final isAdminUser = line.split(":")[1].trim() == "admin";
    if (isRootUser || isAdminUser) {
    // xxx
    }
  2. 使用总结变量。 当if语句的条件比较复杂时,将整个条件表达式使用一个总结变量代替。

    /// BAD: 阅读代码的人需要理解什么情况下能进入if语句,代表什么语义
    if (newSelect != null && preSelect != null && newSelect != preSelect) {
    // xxx
    }
    /// GOOD: 快速理解if语句的语义,如果关注细节,再看表达式的构成
    final selectionChanged = newSelect != null && preSelect != null && newSelect != preSelect;
    if (selectionChanged) {
    // xxx
    }
  3. 减少非逻辑嵌套。 对于一个bool表达式,有一下两种等价写法,大家可以自行判断哪个更加可读。

    /// BAD: 阅读代码的人需要理解什么情况下能进入if语句,代表什么语义
    if (!(fileExists && !isProtected)) {
    // xxx
    }
    /// GOOD: 快速理解if语句的语义,如果关注细节,再看表达式的构成
    if (!fileExists || isProtected) {
    // xxx
    }

类应该短小

与函数一样,在设计类时,首要规则就是尽可能短小。对于函数,评价的指标是代码行数;对于类,评价指标则为职责,即如果无法为某个类取个精准的名称,那就表明这个类太长了。

那么,如何让类保持短小呢?这里需要先介绍一条原则:

单一职责原则:即类或模块应有且只有一条加以修改的理由,即一个类只负责一项职责。

使用单一职责,将大类拆分为若干内聚性高的小类,即可实现类应该短小的规则。

  • 所谓内聚,即类应该只有少量实体变量,类中的每个方法都应该操作一个或多个这种变量。通常而言,方法操作的变量越多,则内聚性越高。
  • 让代码能运行保持代码整洁,是截然不同的两项工作。大多数人往往把精力花在了前者,这是没问题的。问题在于,当代码能运行后,不是马上转向实现下一个功能,而是回头将臃肿的类切分成只有单一职责的去耦合单元。
  • 许多开发者可能会觉得使用单一职责会导致类的数量变多,但其实这种方式会让复杂系统中的检索和修改变得更加清晰简单。

为修改而组织

编写代码时,需要考虑以后的修改是否方便,降低修改代码的风险。

开放封闭原则 :类应当对扩展开放,对修改关闭。

单元测试

测试驱动开发(Test-Driven Development, TDD),要求在编写某个功能的代码之前先编写测试代码,然后只编写使测试通过的功能代码,通过测试来推动整个开发的进行。 这有助于编写简洁可用和高质量的代码,并加速开发过程。

虽然在日常开发中,我们并不是按照这种方式开发,但是这种思想对于提高代码能力大有裨益。也许你会好奇,这种测试先行的方法与测试后行的方法有什么区别?总结下来就是:

  • 假如你首先编写 测试用例 ,那么你将可以更早发现缺陷,同时也更容易修正它们。 当你聚焦在一个方法时,将会更容易发现这个方法的边界case,而我们代码中大部分的缺陷都是由于一些边界case疏漏导致的。
  • 在编写代码之前先编写 测试用例 ,能更早地把需求上的问题暴露出来。 在写业务代码前,先思考测试用例有哪些,一些边界问题自然就会浮出水面,迫使你去解决。
  • 首先编写 测试用例 ,将迫使你在开始写代码之前至少思考一下需求和设计,而这往往会催生更高质量的代码。 写测试,你就会站在代码用户的角度来思考,而不仅仅是一个单纯的实现者,因为你自己要使用它们,所以能设计一个更有用,更一致的接口。另外为了保证代码的可测性,也迫使你会将不同的逻辑解耦,降低测试需要的上下文。

保持测试整洁

  • 如果没有测试代码,程序员会不敢动他的业务代码。 这点在改表单、计算引擎逻辑时深有体会,经常按下葫芦浮起瓢。

  • 修改业务代码的同时,相应的测试代码也需要修改。 如果单测不能跑,那单测就毫无意义,写单测并不是为了应付,而是保证代码的正确性,所以不要因为懒得修改导致破窗效应。

  • 如何让测试代码整洁? 可读性!可读性!可读性!单元测试中的可读性比生产代码中更加重要。测试代码中,相同的代码应抽象在一起,遵循 Build-Operate-Check 原则,即每一个测试应该清晰的由以下这三个部分组成:

    • Build: 构建测试数据。
    • Operation: 操作测试数据。
    • Check: 检验操作是否得到期望结果。
  • 每个 测试用例 只做一件事。 不要写出超长的测试函数。如果想要测试3个功能,就是拆成3个测试用例。

整洁测试规则(F.I.R.S.T)

  • 快速(Fast) :测试代码需要执行得很快。测试运行慢→不想频繁运行测试代码→不能尽早发现生产代码的问题→代码腐坏。
  • 独立(Independent):测试代码不应该相互依赖,某个测试不应该成为下一个测试的设定条件。测试代码都应该可以独立运行,以及按任何顺序运行。当测试互相依赖时,会导致问题难以定位。
  • 可重复(Repeatable):测试代码应该在任何环境下都可以重复执行。
  • 自足验证(Self-Validating) :测试需要有一个bool类型的输出。不能通过看log判断测试是否通过,而应该通过断言。
  • 及时(Timely):测试代码需要及时更新,在编写业务代码之前先写测试代码。如果程序员先写业务代码,很有可能造成写测试代码不方便的问题。

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

做项目,聊聊敏捷开发

我对敏捷开发是源于10多年前看了一本关于迭代开发的书,从而对迭代开发有了一些兴趣。从那时开始有了迭代开发的概念。随着项目经验的增加迭代的重要性也越发觉得明显。随后进入了提倡敏捷开发的公司,被迫式的接触了许多“敏捷开发”,随着项目经历越来越多,慢慢的就开始有了更...
继续阅读 »

我对敏捷开发是源于10多年前看了一本关于迭代开发的书,从而对迭代开发有了一些兴趣。从那时开始有了迭代开发的概念。随着项目经验的增加迭代的重要性也越发觉得明显。随后进入了提倡敏捷开发的公司,被迫式的接触了许多“敏捷开发”,随着项目经历越来越多,慢慢的就开始有了更新的认识和想法。

但是在接触敏捷开发这个体系之前,自己有机会做一个项目,那个时候我开始将自己认为更有利于项目的管理工作做了一些应用,那个阶段我的主要做法是:

1、项目中开始划分更短的制品交互周期,而不是以前那样等待产品开发完毕后发布各种测试版本。
2、更充分与市场人员交流,在市场人员进行需求交底时,让更多的甚至全体成员参与会议,了解产品的原始业务及需求。并且在过程中有问题也及时的解答及沟通。
3、加强沟通力度,开发测试都在一起每天都会开个小会,通报每日的工作成果,将自己的问题说出来。
4、不同以往的发布频率,测试从项目开始便要切入到产品生产过程,而不是等到最后所有功能都完成后。从而大大减少变动对计划的影响。

在做这些工作的时候我并不知道敏捷开发这个东西,直到在2010年进入一个公司非常提倡敏捷开发,已经有了迭代周期、backlog、站立会议、周例会等等,在这个团队中对开发过程有各种规章要求,完全是制度化的,这在我加入的初期非常的不适应。事实上回头想想,那种方式已经变的不敏捷了,完全是一种教条式的应用。

后来自己有机会回到了老东家,开始自己带团队,很巧老东家被收购后开始推广敏捷开发,只不过因为不是总部,所以这次没有范本,完全由我自己来组织及控制。很高兴这个小团队几个月下来,个人觉得比较成功,当然后面也得到了公司的认可。

下面就敏捷开发分享一些应该着重注意的点,解决这些问题我想对任何开发团队都会有很大的帮助。

需求在开发中的重要性

大量的开发过程告诉我,需求在软件开发过程中是极其重要的。传统的开发强调初期的需求调研及需要分析,这个过程对于一些正规的团队会产生大量的文档,而后交由开发展开产品生产。

然而,事实却不是想象这么简单,无数的例子说明了一点,仅仅在需求调研过程中了解到的需求是无法保证的。数不清的例子告诉我们,需求是会变的,变的原因很多。在极端的情况下,有些客户签字的需求在开发完后,有需要变更也很正常。

所以需求是影响软件开发的第一重要因素,需求来源于业务,我们开发的产品不就是因为这些业务才去做的吗?如何需求都无法把握好,还谈什么开发出好用的产品?

然而如何做好需求呢?我想首先要确立需求的地位,然后只有通过不断的沟通、尝试、反馈向真实需求迈进。

强调人与人的交流

不管怎么样开发过程中主要还是靠人的,而且软件开发是个复杂的团体工程,一个小些的产品也会涉及到各类人:客户、业务分析、管理人员、程序员、测试员等等。这么多人在一起做事情,有一方没有处理好结果肯定就会有问题。

有这样一个例子:客户提出了一个会员管理功能需求,需求人员了解后组织了解决方案,于是交付了开发实现。而经过二个月无尽的黑夜之后交付,需求一看有个模块做的有偏差,但是已经来不及修改了。交给客户看后,发现这不是他们要的会员管理功能相差较大,另外在功能开发的这一段时间,客户又有了新想法,要对原先需求做调整。

这种例子可能大家经常经历吧?

这种问题在敏捷开发方法中提出了解决方法,就是通过不断的交付可用的制品。看起来很抽象,其实很简单。同样是上面的例子:
Ø 客户提出会员管理功能需求
Ø 需求人员在了解需求后与开发负责人商量,确定一个快迭代的开发计划,每二周向客户演示一次,并将这个计划与客户确认
Ø 确认后需求人员向全体成员讲解需求背景故事
Ø 开发负责人组织并确定迭代计划内容,明确每个迭代提交的产品目标、开发任务安排、测试跟踪计划
Ø 每个迭代过程中都由需求及测试进行确认每个任务的实现结果是否跑偏
Ø 后面就是每二周向客户演示一次产品,并获得客户的反馈
Ø 根据客户的反馈调整下个迭代计划,并继续下一个迭代
Ø 直到产品交付

通过上面的步骤,就不至于在开发完成后才知道用户的真实想法,因为很多用户对软件开发是没有概念的,他只知道自己有某种需求,但最开始是没有一个完整的概念的。所以就要通过不断的让用户看到产品的模型,这个过程用户才会逐步的对产品产生概念。同样的在过程中客户的提出需求变更也是在一定的可控制范围之内,这样一来可以大大的减少软件返工的情况,自然就不会拖延计划了。

而这个过程中,需求已经完成了一个真正的过渡,不再是一头重的情况了。他让需求从客户那快速的反馈到开发团队中。同样的,在开发不断的交付制品时,需求也更加及时的了解到产品的进度,把握开发人员开发的功能是否符合需求。
当然这并不是一个标准做法,不同的团队可以有不同的处理方式。这里只是想强调需求需要更多的投入到开发过程中去,及时的与客户沟通交流,了解到客户的真实想法。

强调文档的作用

我觉得很多对敏捷开发的一个误解就是不需要文档,敏捷开发并未抛弃文档。只是更强调更有效的方式使用文档。在很多传统开发方法中,特别是很多很正规的开发团队对文档的要求非常苛刻。然而事实是文档不易管理,最痛苦的是不好维护,文档需要随着变化而变化,比如需求调整、技术架构升级、产品维护等等。如果要保证文档的一致性,太难了。特别是对于一些无法进行有效管理的开发团队就更加明显,经常是软件已经几个版本了,文档却是两年前的。

但敏捷真的不需要文档吗?我想不是的,如何把文档做到好维护我想才是最重要的。文档到底指的指的什么?什么样的算文档?

提出上面两个问题,我们先想想经常说的文档的作用是什么?不就是一个传播工具吗?可以用作记录、给他人看、用于以后查看。有很多方法可就解决了这个问题,比如wiki系统。维护一个wiki系统,可以随时写,随时维护,可以方便的查找。嗯,多方便。

另外一个问题就是什么样的工作需要形成文档呢?

记得在前一家公司,维护一个10多年的老系统修改一个公式计算的BUG,但是怎么也不知道这个复杂的公式是什么意思,问过了公司大部分的人也无人可解。这时想,如果当初有那么一份文档,谢天谢地。

像这种关键的内容有份文档还是很重要的,否则随着时间推移,谁也不能保证能记得住当时为什么会这么干。

记得多年前一次记笔记的经历,我看了一篇文章了解了DELPHI实现单实例模式的方法,这种方法很酷。于是整理成了笔记写在了wiki上,第二天就得到了回复,帮助到了别外产品开发组的同事。

嗯,文档就是这样他具有传播性,你不可能跑去跟所有人说出你的想法,但是文档却更容易达成。他也有传承性,有些文档也许10多年后又起了重要作用。

团队协作

1、减少对开发人员的干扰

曾经接手一个产品的开发,最初遇到一个很头痛的问题,原先写好的迭代计划,而且工作量也较大,大家都在忙着。即便在这样的状态下,客服人员却经常跑来找某个程序员A维护各种系统问题,程序员A在一次维护中竟然导致了系统数据出现大面积错误。程序员A心理上承受着巨大的压力,而每天的这些问题又不得不解决,加之新版本又有很重的开发任务无法完成,最终导致整个开发计划变更。

我无法再忍受,找到了需求及客服的负责人,沟通后发现这些问题很多都是重复性的,主要是因为原先系统的不足。于是回去组织人员做了几个后台临时功能,并交付给了客服人员,之后就没有再来找过这位程序员A。后续我又找到了客服负责人,要求不能直接找开发人员解决这类问题,并与负责人约定了处理过程。

这是个例子,在实际情况中还有很多这种事情,甚至有很多开发人员要直接面对客户。我想对于职能型团队来说,开发团队最好是减少这些方面的干忧。当然对于一个人包干的情况就不讨论了。

大部分的人都不是超人,在一个时间段内处理超出自己负荷的工作是很难做好保质保量的。所以对于开发管理人员一定要考虑到这点,尽量让开发人员有比较好的工作进度环境,通过外界的方式来解决一些开发团队的干扰。

成功的前端工程师很会善用工具,这些年低代码概念开始流行,像国外的Mendix,国内的JNPF,这种新型的开发方式,图形化的拖拉拽配置界面,并兼容了自定义的组件、代码扩展,确实在B端后台管理类网站建设中很大程度上的提升了效率。

开源地址:http://www.yinmaisoft.com/?from=jueji…

任何信息化项目都可以基于 JNPF 开发出 ERP、OA、CRM、EHR 等各类管理系统。

这边强烈建议试试它,你会发现不一样的惊喜!心情舒畅还是很重要的,记得有一次迭代总结时,有个程序员总结说:发现心情舒畅自己的工作效率很高。呵呵。我想你也有同感吧。

2、不要忽略测试人员在开发阶段的作用

曾经多少次在项目发布前加班到深夜2点的情景还历历在目,那种感觉即快乐又痛苦。由于和客户签定的合同的交付日期就要到了,产品却迟迟未集成完成,测试只能干等着上网聊QQ。就在下班前的一刻发布了,测试开始了紧张的测试,在屏幕闪动中,一个个的BUG提交,直到流程都无法都走不下去,测试无奈了。第二天就要发布,实施人员就等着制品第二天出差。只有不断的改,再发布,无尽的循环。直到大家都憔悴的看着老大,终于老大说:还剩下的这几个问题无关紧要,大家回去吧。

几个月的开发过去后在总结会上,只能抱怨测试资源不足,时间太短,需求更改太多,需求更改后测试不知道。无数的问题一次一次的出现在同样的总结会议上。

上面的这个例子很多人应该经历过,真的测试只有最后一刻才能体现价值吗?我想不是的。

在后面的项目中我总结了这个问题的,针对每个开发任务要求进行测试验证。而测试如何验证呢?他需要知道这个开发任务的需求是如何,提前做好测试计划及测试用例,在接到开发制品后测试并提交BUG,这个工作是可以开发过程中就能不断的进行的。保证每一个任务的质量,可以大大减少后期集成的错误量。

另外根据敏捷开发的思想,测试团队在开发过程中也需要加强与开发团队的交流,甚至有必要组成虚拟团队,位置调整到一起,这样可以及时快速的交流,参加开发团队的站立会议同样可以及时了解到开发的实际情况及进度,反过来把握测试计划及测试内容。

特别是测试从另一个角度来审视需求,这样也可以一定程度上发现或者改善需求上的不足。

3、发挥团队人员的潜力

敏捷开发比较提倡开发任务由开发自己评估并认领工作任务,这样可以激发开发的潜在动力。

之前在做一个新产品时,需要使用java,而我们团队是使用C#的,面临转型问题。而有一位同事很感兴趣,于是我就让他负责前期的框架探索与搭建。结果就是这位小伙工作效率很高,我最初给他的目标全部都完成了。最有意思的是后面产品开始研发时,这位小伙已经成为了团队的大牛,大家有问题都找他解决。也正是因为这个过程,这位小伙被全面激活,也在大家面前展示了能力。甚至在小伙离职时也被领导给予大幅涨薪来挽留。只不过谁又能想象到这位小伙进入我团队之前是因为被定为裁员的目标而调剂过来的呢!

所以充分发挥好每个人员的特点,让人能够在自己感兴趣的工作中,效果会很多。减少指派方式的任务的分配,充分发挥个人的主动性,这个团队精神面貌也会好很多。

4、管理者不要离团队太远

作为团队的Leader要参与到团队的工作中去,比如一个开发主管一定要写写代码,参与架构等对项目有关的事情,而不是在那里分分任务。这样团队成员才会觉得这个Leader很亲近感。

特别是有些开发主管在带队后离团队越来越远,有时对于开发进度不如意时就说:“这么个简单功能怎么会搞了这么久?”,其实每天都在加班的同事心里想着:“有本事你来?”,即使这个小组长有这个能力,但对于团队来说也不是一件好事,因为大家都抱有怨恨之心,还谈什么好好工作呢?这个小组长就是失职的。所以这种情况下应该主动去了解进度滞后的原因,并且自己要加入到解决问题的工作中去,而不是在边上抱怨别人。

5、小组织不要搞太多的官

中国几千年的文化,官本位一直影响着我们,大家都想坐在那指挥,自己啥事也不用干,想想都惬意。在我们这个行业是不是发现也很类似?大家都想着干几年当个小组长,然后升个部门经理,当上CTO迎娶白富美。

团队的管理基本是事与人的管理,非常的伤脑和心。如果一个组织内,特别是小组织内“官”太多,协调就会非常的难,大家就会经常性的扯皮。

结束

与敏捷开发结缘也有几年,从开始的抵触到后面的认可经历了许多,这个过程并不是一蹴而就的,需要花时间花精力,特别是要去实践、总结。

还有我觉得是不能太教条,很多事情都要有怀疑的心,然后去实践总结,找到合适自己团队的方式方法。

注:此文章为原创,欢迎转载,请在文章页面明显位置给出此文链接! 若您觉得这篇文章还不错请点击下右下角的推荐,非常感谢!


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

OutOfMemoryError是如何产生的

背景其实这个问题也挺有趣的,OutOfMemoryError,算是我们常见的一个错误了,大大小小的APP,永远也逃离不了这个Error,那么,OutOfMemroyError是不是只有才分配内存的时候才会发生呢?是不是只有新建对象的时候才会发生呢?要弄清楚这个...
继续阅读 »

背景

其实这个问题也挺有趣的,OutOfMemoryError,算是我们常见的一个错误了,大大小小的APP,永远也逃离不了这个Error,那么,OutOfMemroyError是不是只有才分配内存的时候才会发生呢?是不是只有新建对象的时候才会发生呢?要弄清楚这个问题,我们就要了解一下这个Error产生的过程。

OutOfMemoryError

我们常常在堆栈中看到的OOM日志,大多数是在java层,其实,真正被设置OOM的,是在ThrowOutOfMemoryError这个native方法中

void Thread::ThrowOutOfMemoryError(const char* msg) {
LOG(WARNING) << "Throwing OutOfMemoryError "
<< '"' << msg << '"'
<< " (VmSize " << GetProcessStatus("VmSize")
<< (tls32_.throwing_OutOfMemoryError ? ", recursive case)" : ")");
ScopedTrace trace("OutOfMemoryError");
jni调用设置ERROR
if (!tls32_.throwing_OutOfMemoryError) {
tls32_.throwing_OutOfMemoryError = true;
ThrowNewException("Ljava/lang/OutOfMemoryError;", msg);
tls32_.throwing_OutOfMemoryError = false;
} else {
Dump(LOG_STREAM(WARNING)); // The pre-allocated OOME has no stack, so help out and log one.
SetException(Runtime::Current()->GetPreAllocatedOutOfMemoryErrorWhenThrowingOOME());
}
}

下面,我们就来看看,常见的抛出OOM的几个路径

MakeSingleDexFile

在ART中,是支持合成单个Dex的,它在ClassPreDefine阶段,会尝试把符合条件的Class(比如非数据/私有类)进行单Dex生成,这里我们不深入细节流程,我们看下,如果此时把旧数据orig_location移动到新的final_data数组里面失败,就会触发OOM

static std::unique_ptr<const art::DexFile> MakeSingleDexFile(art::Thread* self,
const char* descriptor,
const std::string& orig_location,
jint final_len,
const unsigned char* final_dex_data)
REQUIRES_SHARED(art::Locks::mutator_lock_) {
// Make the mmap
std::string error_msg;
art::ArrayRef<const unsigned char> final_data(final_dex_data, final_len);
art::MemMap map = Redefiner::MoveDataToMemMap(orig_location, final_data, &error_msg);
if (!map.IsValid()) {
LOG(WARNING) << "Unable to allocate mmap for redefined dex file! Error was: " << error_msg;
self->ThrowOutOfMemoryError(StringPrintf(
"Unable to allocate dex file for transformation of %s", descriptor).c_str());
return nullptr;
}

unsafe创建

我们java层也有一个很神奇的类,它也能够操作指针,同时也能直接创建类对象,并操控对象的内存指针数据吗,它就是Unsafe,gson里面就大量用到了unsafe去尝试创建对象的例子,比如需要创建的对象没有空参数构造函数,这里如果malloc分配内存失败,也会产生OOM

static jlong Unsafe_allocateMemory(JNIEnv* env, jobject, jlong bytes) {
ScopedFastNativeObjectAccess soa(env);
if (bytes == 0) {
return 0;
}
// bytes is nonnegative and fits into size_t
if (!ValidJniSizeArgument(bytes)) {
DCHECK(soa.Self()->IsExceptionPending());
return 0;
}
const size_t malloc_bytes = static_cast<size_t>(bytes);
void* mem = malloc(malloc_bytes);
if (mem == nullptr) {
soa.Self()->ThrowOutOfMemoryError("native alloc");
return 0;
}
return reinterpret_cast<uintptr_t>(mem);
}

Thread 创建

其实我们java层的Thread创建的时候,都会走到native的Thread创建,通过该方法CreateNativeThread,其实里面就调用了传统的pthread_create去创建一个native Thread,如果创建失败(比如虚拟内存不足/FD不足),就会走到代码块中,从而产生OOM

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
....

if (pthread_create_result == 0) {
// pthread_create started the new thread. The child is now responsible for managing the
// JNIEnvExt we created.
// Note: we can't check for tmp_jni_env == nullptr, as that would require synchronization
// between the threads.
child_jni_env_ext.release(); // NOLINT pthreads API.
return;
}
}

// Either JNIEnvExt::Create or pthread_create(3) failed, so clean up.
{
MutexLock mu(self, *Locks::runtime_shutdown_lock_);
runtime->EndThreadBirth();
}
// Manually delete the global reference since Thread::Init will not have been run. Make sure
// nothing can observe both opeer and jpeer set at the same time.
child_thread->DeleteJPeer(env);
delete child_thread;
child_thread = nullptr;
如果没有return,证明失败了,爆出OOM
SetNativePeer(env, java_peer, nullptr);
{
std::string msg(child_jni_env_ext.get() == nullptr ?
StringPrintf("Could not allocate JNI Env: %s", error_msg.c_str()) :
StringPrintf("pthread_create (%s stack) failed: %s",
PrettySize(stack_size).c_str(), strerror(pthread_create_result)));
ScopedObjectAccess soa(env);

soa.Self()->ThrowOutOfMemoryError(msg.c_str());
}
}

堆内存分配

我们平时采用new 等方法的时候,其实进入到ART虚拟机中,其实是走到Heap::AllocObjectWithAllocator 这个方法里面,当内存分配不足的时候,就会发起一次强有力的gc后再尝试进行内存分配,这个方法就是AllocateInternalWithGc

mirror::Object* Heap::AllocateInternalWithGc(Thread* self,
AllocatorType allocator,
bool instrumented,
size_t alloc_size,
size_t* bytes_allocated,
size_t* usable_size,
size_t* bytes_tl_bulk_allocated,
ObjPtr<mirror::Class>* klass)

流程如下: 

void Heap::ThrowOutOfMemoryError(Thread* self, size_t byte_count, AllocatorType allocator_type) {
// If we're in a stack overflow, do not create a new exception. It would require running the
// constructor, which will of course still be in a stack overflow.
if (self->IsHandlingStackOverflow()) {
self->SetException(
Runtime::Current()->GetPreAllocatedOutOfMemoryErrorWhenHandlingStackOverflow());
return;
}
这里官方给了一个钩子
Runtime::Current()->OutOfMemoryErrorHook();
输出OOM的原因
std::ostringstream oss;
size_t total_bytes_free = GetFreeMemory();
oss << "Failed to allocate a " << byte_count << " byte allocation with " << total_bytes_free
<< " free bytes and " << PrettySize(GetFreeMemoryUntilOOME()) << " until OOM,"
<< " target footprint " << target_footprint_.load(std::memory_order_relaxed)
<< ", growth limit "
<< growth_limit_;
// If the allocation failed due to fragmentation, print out the largest continuous allocation.
if (total_bytes_free >= byte_count) {
space::AllocSpace* space = nullptr;
if (allocator_type == kAllocatorTypeNonMoving) {
space = non_moving_space_;
} else if (allocator_type == kAllocatorTypeRosAlloc ||
allocator_type == kAllocatorTypeDlMalloc) {
space = main_space_;
} else if (allocator_type == kAllocatorTypeBumpPointer ||
allocator_type == kAllocatorTypeTLAB) {
space = bump_pointer_space_;
} else if (allocator_type == kAllocatorTypeRegion ||
allocator_type == kAllocatorTypeRegionTLAB) {
space = region_space_;
}

// There is no fragmentation info to log for large-object space.
if (allocator_type != kAllocatorTypeLOS) {
CHECK(space != nullptr) << "allocator_type:" << allocator_type
<< " byte_count:" << byte_count
<< " total_bytes_free:" << total_bytes_free;
// LogFragmentationAllocFailure returns true if byte_count is greater than
// the largest free contiguous chunk in the space. Return value false
// means that we are throwing OOME because the amount of free heap after
// GC is less than kMinFreeHeapAfterGcForAlloc in proportion of the heap-size.
// Log an appropriate message in that case.
if (!space->LogFragmentationAllocFailure(oss, byte_count)) {
oss << "; giving up on allocation because <"
<< kMinFreeHeapAfterGcForAlloc * 100
<< "% of heap free after GC.";
}
}
}
self->ThrowOutOfMemoryError(oss.str().c_str());
}

这个就是我们常见的,也是主要OOM产生的流程

JNI层

这里还有很多,比如JNI层通过Env调用NewString等分配内存的时候,会进入条件检测,比如分配的String长度超过最大时产生Error,即使说内存空间依旧可以分配,但是超过了虚拟机能处理的最大限制,也会产生OOM

    if (UNLIKELY(utf16_length > static_cast<uint32_t>(std::numeric_limits<int32_t>::max()))) {
// Converting the utf16_length to int32_t would overflow. Explicitly throw an OOME.
std::string error =
android::base::StringPrintf("NewStringUTF input has 2^31 or more characters: %zu",
utf16_length);
ScopedObjectAccess soa(env);
soa.Self()->ThrowOutOfMemoryError(error.c_str());
return nullptr;
}

OOM 路径总结

通过本文,我们看到了OOM发生时,可能存在的几个主要路径,其他引起OOM的路径,也是在这几个基础路径之上产生的,希望大家以后可以带着源码学习,能够帮助我们了解ART更深层的秘密。


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

什么是优雅的代码设计

今天我来解释一下什么样的代码才是优雅的代码设计。当然我们的代码根据实际的应用场景也分了很多维度,有偏向于底层系统的,有偏向于中间件的,也有偏向上层业务的,还有偏向于前端展示的。今天我主要来跟大家分析一下我对于业务代码的理解以及什么样才是我认为的优雅的业务代码设...
继续阅读 »

今天我来解释一下什么样的代码才是优雅的代码设计。当然我们的代码根据实际的应用场景也分了很多维度,有偏向于底层系统的,有偏向于中间件的,也有偏向上层业务的,还有偏向于前端展示的。今天我主要来跟大家分析一下我对于业务代码的理解以及什么样才是我认为的优雅的业务代码设计。

大家吐槽非常多的是,我们这边的业务代码会存在着大量的不断地持续的变化,导致我们的程序员对于业务代码设计得就比较随意。往往为了快速上线随意堆叠,不加深入思考,或者是怕影响到原来的流程,而不断在原来的代码上增加分支流程。

这种思想进一步使得代码腐化,使得大量的程序员更失去了“好代码”的标准。

那么如果代码优雅,那么要有哪些特征呢?或者说我们做哪些事情才会使得代码变得更加优雅呢?

结构化

结构化定义是指对某一概念或事物进行系统化、规范化的分析和定义,包括定义的范围、对象的属性、关系等方面,旨在准确地描述和定义所要表达的概念或事物。

我觉得首要的是代码,要一个骨架。就跟我们所说的思维结构是一样,我们对一个事物的判断,一般都是综合、立体和全面的,否则就会成为了盲人摸象,只见一斑。因此对于一个事物的判断,要综合、结构和全面。对于一段代码来说也是一样的标准,首先就是结构化。结构化是对一段代码最基本的要求,一个有良好结构的代码才可能称得上是好代码,如果只是想到哪里就写到哪里,一定成不了最优质的代码。

代码的结构化,能够让维护的人一眼就能看出主次结构、看出分层结构,能够快速掌握一段代码或者一段模块要完成的核心事情。

精简

代码跟我们抽象现实的物体一样,也要非常地精简。其实精简我觉得不仅在代码,在所有艺术品里面都是一样的,包括电影。电影虽然可能长达一个小时,两个小时,但你会发现优雅的电影它没有一帧是多余的,每出现的一个画面、一个细节,都是电影里要表达的某个情绪有关联。我们所说的文章也是一样,没有任何一个伏笔是多余的。代码也是一样,严格来说代码没有一个字符、函数、变量是多余的,每个代码都有它应该有的用处。就跟“奥卡姆剃刀”原理一样,每块代码都有它存在的价值包括注释。

但正如我们的创作一样,要完成一个功能,我们把代码写得复杂是简单的,但我们把它写得简单是非常难的。代码是思维结构的一种体现,而往往抽象能力是最为关键的,也是最难的。合适的抽象以及合理的抽象才能够让代码浓缩到最少的代码函数。

大部分情况来说,代码行数越少,则运行效率会越高。当然也不要成为极端的反面例子,不要一味追求极度少量的代码。代码的优雅一定是精要的,该有的有,不该有的一定是没有的。所以在完成一个业务逻辑的时候,一定要多问自己这个代码是不是必须有的,能不能以一种简要的方式来表达。

善用最佳实践

俗话说太阳底下没有新鲜事儿,一般来说,没有一个业务场景所需要用到的编码方式是需要你独创发明的。你所写的代码功能大概率都有人遇到过,因此对于大部分常用的编码模式,也都大家被抽象出来了一些最佳实践。那么最经典的就是23种设计模式,基本上可以涵盖90%以上的业务场景了。

以下是23种设计模式的部分简单介绍:

  1. 单例模式(Singleton Pattern):确保类只有一个实例,并提供全局访问点。
  2. 工厂模式(Factory Pattern):定义一个用于创建对象的接口,并让子类决定实例化哪个对象。
  3. 模板方法模式(Template Method Pattern):提供一种动态的创建对象的方法,通过使用不同的模板来创建对象。
  4. 装饰器模式(Decorator Pattern):将对象包装成另一个对象,从而改变原有对象的行为。
  5. 适配器模式(Adapter Pattern):将一个类的接口转换成客户希望的另一个接口,以使其能够与不同的对象交互。
  6. 外观模式(Facade Pattern):将对象的不同方面组合成一个单一的接口,从而使客户端只需访问该接口即可使用整个对象。

我们所说的设计模式就是一种对常用代码结构的一种抽象或者说套路。并不是说我们一定要用设计模式来实现功能,而是说我们要有一种最高效,最通常的方式去实现。这种方式带来了好处就是高效,而且别人理解起来也相对来说比较容易。

我们也不大推荐对于一些常见功能用一些花里胡哨的方式来实现,这样往往可能导致过度设计,但实际用处可能反而会带来其他问题。我觉得要用一些新型的代码,新型的思维方式应该是在一些比较新的场景里面去使用,去验证,而不应该在我们已有最佳实践的方式上去造额外的轮子。

这个就比如我们如果要设计一辆汽车,我们应该采用当前最新最成熟的发动机方案,而不应该从零开始自己再造一套新的发动机。但是如果这个发动机是在土星使用,要面对极端的环境,可能就需要基于当前的方案研制一套全新的发动机系统,但是大部分人是没有机会碰到土星这种业务环境的。所以通常情况下,还是不要在不需要创新的地方去创新。

除了善用最佳实践模式之快,我们还应该采用更高层的一些最佳实践框架的解决方案。比如我们在面对非常抽象,非常灵活变动的一些规则的管理上,我们可以使用大量的规则引擎工具。比如针对于流程式的业务模型上面,我们可以引入一些工作流的引擎。在需要RPC框架的时候,我们可以根据业务情况去调研使用HTTP还是DUBBO,可以集百家之所长。

持续重构

好代码往往不是一蹴而就的,而是需要我们持续打磨。有很多时候由于业务的变化以及我们思维的局限性,我们没有办法一次性就能够设计出最优的代码质量,往往需要我们后续持续的优化。所以除了初始化的设计以外,我们还应该在业务持续的发展过程中动态地去对代码进行重构。

但是往往程序员由于业务繁忙或者自身的懒惰,在业务代码上线正常运行后,就打死不愿意再动原来的代码。第一个是觉得跑得没有问题了何必去改,第二个就是改动了反而可能引起故障。这就是一种完全错误的思维,一来是给自己写不好的线上代码的一个借口,二来是没有让自己持续进步的机会。

代码重构的原则有很多,这里我就不再细讲。但是始终我觉得对线上第一个要敬畏,第二个也要花时间持续续治理。往往我们在很多时候初始化的架构是比较优雅的,是经过充分设计的,但是也是由于业务发展的迭代的原因,我们持续在存量代码上添加新功能。

有时候有一些不同的同学水平不一样,能力也不一样,所以导致后面写上的代码会非常地随意,导致整个系统就会变得越来越累赘,到了最后就不敢有新同学上去改,或者是稍微一改可能就引起未知的故障。

所以在这种情况下,如果还在追求优质的代码,就需要持续不断地重构。重构需要持续改善,并且最好每次借业务变更时,做小幅度的修改以降低风险。长此以往,整体的代码结构就得以大幅度的修改,真正达到集腋成裘的目的。下面是一些常见的重构原则:

  1. 单一职责原则:每个类或模块应该只负责一个单一的任务。这有助于降低代码的复杂度和维护成本。
  2. 开闭原则:软件实体(类、模块等)应该对扩展开放,对修改关闭。这样可以保证代码的灵活性和可维护性。
  3. 里氏替换原则:任何基类都可以被其子类替换。这可以减少代码的耦合度,提高代码的可扩展性。
  4. 接口隔离原则:不同的接口应该是相互独立的,它们只依赖于自己需要的实现,而不是其他接口。
  5. 依赖倒置原则:高层模块不应该依赖低层模块,而是依赖应用程序的功能。这可以降低代码的复杂度和耦合度。
  6. 高内聚低耦合原则:尽可能使模块内部的耦合度低,而模块之间的耦合度高。这可以提高代码的可维护性和可扩展性。
  7. 抽象工厂原则:使用抽象工厂来创建对象,这样可以减少代码的复杂度和耦合度。
  8. 单一视图原则:每个页面只应该有一个视图,这可以提高代码的可读性和可维护性。
  9. 依赖追踪原则:对代码中的所有依赖关系进行跟踪,并在必要时进行修复或重构。
  10. 测试驱动开发原则:在编写代码之前编写测试用例,并在开发过程中持续编写和运行测试用例,以确保代码的质量和稳定性。

综合

综上所述,代码要有结构化、可扩展、用最佳实践和持续重构。追求卓越的优质代码应该是每一位工程师的基本追求和基本要求,只有这样,才能不断地使得自己成为一名卓越的工程师。


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

面试题:Android 中 Intent 采用了什么设计模式?

答案是采用了原型模式。原型模式的好处在于方便地拷贝某个实例的属性进行使用、又不会对原实例造成影响,其逻辑在于对 Cloneable接口的实现。话不多说看下 Intent 的关键源码: // frameworks/base...
继续阅读 »

答案是采用了原型模式

原型模式的好处在于方便地拷贝某个实例的属性进行使用、又不会对原实例造成影响,其逻辑在于对 Cloneable接口的实现。

话不多说看下 Intent 的关键源码:

 // frameworks/base/core/java/android/content/Intent.java
 public class Intent implements Parcelable, Cloneable {
    ...
     private static final int COPY_MODE_ALL = 0;
     private static final int COPY_MODE_FILTER = 1;
     private static final int COPY_MODE_HISTORY = 2;
 
     @Override
     public Object clone() {
         return new Intent(this);
    }
 
     public Intent(Intent o) {
         this(o, COPY_MODE_ALL);
    }
 
     private Intent(Intent o, @CopyMode int copyMode) {
         this.mAction = o.mAction;
         this.mData = o.mData;
         this.mType = o.mType;
         this.mIdentifier = o.mIdentifier;
         this.mPackage = o.mPackage;
         this.mComponent = o.mComponent;
         this.mOriginalIntent = o.mOriginalIntent;
        ...
 
         if (copyMode != COPY_MODE_FILTER) {
            ...
             if (copyMode != COPY_MODE_HISTORY) {
                ...
            }
        }
    }
    ...
 }

可以看到 Intent 实现的 clone() 逻辑是直接调用了 new 并传入了自身实例,而非调用 super.clone() 进行拷贝。

默认的拷贝策略是 COPY_MODE_ALL,顾名思义,将完整拷贝源实例的所有属性进行构造。其他的拷贝策略是 COPY_MODE_FILTER 指的是只拷贝跟 Intent-filter 相关的属性,即用来判断启动目标组件的 actiondatatypecomponentcategory 等必备信息。无视启动 flagbundle 等数据。

 // frameworks/base/core/java/android/content/Intent.java
 public class Intent implements Parcelable, Cloneable {
    ...
     public @NonNull Intent cloneFilter() {
         return new Intent(this, COPY_MODE_FILTER);
    }
 
     private Intent(Intent o, @CopyMode int copyMode) {
         this.mAction = o.mAction;
        ...
 
         if (copyMode != COPY_MODE_FILTER) {
             this.mFlags = o.mFlags;
             this.mContentUserHint = o.mContentUserHint;
             this.mLaunchToken = o.mLaunchToken;
            ...
        }
    }
 }

还有中拷贝策略是 COPY_MODE_HISTORY,不需要 bundle 等历史数据,保留 action 等基本信息和启动 flag 等数据。

 // frameworks/base/core/java/android/content/Intent.java
 public class Intent implements Parcelable, Cloneable {
    ...
     public Intent maybeStripForHistory() {
         if (!canStripForHistory()) {
             return this;
        }
         return new Intent(this, COPY_MODE_HISTORY);
    }
 
     private Intent(Intent o, @CopyMode int copyMode) {
         this.mAction = o.mAction;
        ...
 
         if (copyMode != COPY_MODE_FILTER) {
            ...
             if (copyMode != COPY_MODE_HISTORY) {
                 if (o.mExtras != null) {
                     this.mExtras = new Bundle(o.mExtras);
                }
                 if (o.mClipData != null) {
                     this.mClipData = new ClipData(o.mClipData);
                }
            } else {
                 if (o.mExtras != null && !o.mExtras.isDefinitelyEmpty()) {
                     this.mExtras = Bundle.STRIPPED;
                }
            }
        }
    }
 }

总结起来:

Copy Modeaction 等数据flags 等数据bundle 等历史
COPY_MODE_ALLYESYESYES
COPY_MODE_FILTERYESNONO
COPY_MODE_HISTORYYESYESNO

除了 Intent,Android 源码中还有很多地方采用了原型模式。

  • Bundle 也实现了 clone(),提供了 new Bundle(this) 的处理:

     public final class Bundle extends BaseBundle implements Cloneable, Parcelable {
        ...
         @Override
         public Object clone() {
             return new Bundle(this);
        }
     }
  • 组件信息类 ComponentName 也在 clone() 中提供了类似的实现:

     public final class ComponentName implements Parcelable, Cloneable, Comparable<ComponentName> {
        ...
         public ComponentName clone() {
             return new ComponentName(mPackage, mClass);
        }
     }
  • 工具类 IntArray 亦是如此:

     public class IntArray implements Cloneable {
        ...
         @Override
         public IntArray clone() {
             return new IntArray(mValues.clone(), mSize);
        }
     }

原型模式也不一定非得实现 Cloneable,提供了类似的实现即可。比如:

  • Bitmap 没有实现该接口但提供了 copy(),内部将传递原始 Bitmap 在 native 中的对象指针并伴随目标配置进行新实例的创建:

     public final class ComponentName implements Parcelable, Cloneable, Comparable<ComponentName> {
        ...
         public Bitmap copy(Config config, boolean isMutable) {
            ...
             noteHardwareBitmapSlowCall();
             Bitmap b = nativeCopy(mNativePtr, config.nativeInt, isMutable);
             if (b != null) {
                 b.setPremultiplied(mRequestPremultiplied);
                 b.mDensity = mDensity;
            }
             return b;
        }
     }

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

这代码,你不包装下?

不管做什么事情,我们都要有一颗上进的心,写代码也是如此。最开始要写得出,然后要写得对,然后要写得又对又好,最后再追求那个传说中的快。当然,现在好用又强大的三方库越来越多了,业务开发渐渐的变成 api boy 了。当然,api boy&nbs...
继续阅读 »

不管做什么事情,我们都要有一颗上进的心,写代码也是如此。最开始要写得出,然后要写得对,然后要写得又对又好,最后再追求那个传说中的快。

当然,现在好用又强大的三方库越来越多了,业务开发渐渐的变成 api boy 了。当然,api boy 亦有差距,就看你能不能把 api 封装得足够好用了。

今天,我来教教你们如何用协程包装下微信分享的接口。

首先看看微信分享接口,它的发送数据接口和接收接口是分开的。

发送请求为:

fun shareToWx(msg: WXMediaMessage, transaction: String){
val req = SendMessageToWX.Req()
// 唯一标示一个请求, 用于微信返回的回调使用
req.transaction = transaction
// 调用 api 接口,发送数据到微信
api.sendReq(req)
}

然后跳转到微信,微信处理完后,会调起 WXEntryActivity ,通过 onResp 去接收消息:

class WXEntryActivity() : AppCompatActivity(), IWXAPIEventHandler{
override fun onResp(baseResp: BaseResp) {
// 通过 baseResp.transaction 去 match 请求
// TODO handle the baseResp
finish()
}
}

整个流程清晰,就是请求和回应分离了,虽然有 transaction 去追踪请求连,但我也见过完全不用这个参数的开发,而是做了个假设:短时间必定只有一次分享,而且回应应该也很快,所以从微信收到的结果必定是当前界面分享的。。。(反正一般跑起来都是正常的。)

更多高明的开发可能就会用上 EventBus 了,把 resp 抛到 EventBus 里,然后业务自己去监听 EventBus 里的消息,然后处理。

这样也不是不可以,但总归一条业务链下来,代码要写在两个地方,维护起来也不是很爽,而且每次还要去关注那个 transaction 参数。

那该怎么包装呢?RxJava 有 RxJava 的包装,协程有协程的包装,但大体思路应该一样。总之我们要善于利用新的工具去让这个世界变得更美好。对于我,我当然是选择更现代的协程了。

代码如下:

private val wxMsgChannelMap = HashMap<String, Channel<BaseResp>>()

// 入口方法
suspend fun shareToWx(msg: WXMediaMessage): BaseResp {
val req = SendMessageToWX.Req()
// 构造唯一标示,仅内部使用
req.transaction = System.currentTimeMillis().toString()
// 构建一个缓存一个结果的 channel
val channel = Channel<BaseResp>(1)
return withContext(Dispatchers.Main) {
// 将 channel 存储起来
wxMsgChannelMap[transaction] = channel
try {
// 调用 api 接口,发送数据到微信
api.sendReq(req)
// 协程等待 channel 的结果
channel.receive()
} finally {
channel.close()
wxMsgChannelMap.remove(transaction)
}
}
}

class WXEntryActivity() : AppCompatActivity(), IWXAPIEventHandler{
override fun onResp(baseResp: BaseResp) {
AppScope.launch(Dispatchers.Main) {
// 从 map 中寻找到对应的 channel
val channel = wxMsgChannelMap[baseResp.transaction] ?: return@launch
if (channel.isClosedForSend || channel.isClosedForReceive) {
return@launch
}
// 向 channel 发送数据
channel.send(baseResp)
}
finish()
}
}

上面的代码,其实很简单,就是借助了 channel 而已,但只要有这一层封装,业务放就可以将逻辑写得简洁明了了:

val msg = WXMediaMessage(...) // 构造消息
val result = shareToWx(msg)
// 处理结果

是不是看上去就清爽多了?那可不可以做得更好呢? 我们必须要在工程中的特定目录下新建 WXEntryActivity, 然后各个 app 里面的代码都是非常雷同,所以,我们能不能搞个库,把它给封装并隐藏起来?就可以造福更多的开发?WXEntryActivity 要求特定目录,那是不是只能动态生成? ksp 是不是就可以上场装逼了?

问:那继续搞下去吗?

答:不搞,现在的又不是不能用。


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

学习Retrofit后,你要知道的知识点

下面我将从以下几个问题来梳理Retrofit的知识体系,方便自己理解Retrofit中create为什么使用动态代理?谈谈Retrofit运用的动态代理及反射?Retrofit注解是怎么进行解析的?Retrofit如何将注解封装成OKHttp的Call?Rre...
继续阅读 »

下面我将从以下几个问题来梳理Retrofit的知识体系,方便自己理解

  • Retrofitcreate为什么使用动态代理?
  • 谈谈Retrofit运用的动态代理及反射?
  • Retrofit注解是怎么进行解析的?
  • Retrofit如何将注解封装成OKHttpCall?
  • Rretrofit是怎么完成线程切换和数据适配的?

Retrofitcreate为什么使用动态代理

我们首先可以看Retrofit代理实例创建过程,通过一个例子来说明

    val retrofit = Retrofit.Builder()
          .baseUrl("https://www.baidu.com")
          .build()
       val myInterface = retrofit.create(MyInterface::class.java)

创建了一个MyInterface接口类对象,create函数内使用了动态代理来创建接口对象,这样的设计可以让所有的访问请求都给被代理,这里我简化了下它的create函数,简单来说它的作用就是创建了一个你传入类型的接口实例

/**
    *
    * @param loader 需要代理执行的接口类
    * @return 动态代理,运行的时候生成一个loader对象类型的类,在调用它的时候走
    */
   @SuppressWarnings("unchecked")
   public <T> T create(final Class<T> loader) {
       return (T) Proxy.newProxyInstance(loader.getClassLoader(),
               new Class<?>[]{loader}, new InvocationHandler() {
                   @Override
                   public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                       System.out.println("具体方法调用前的准备工作");
                       Object result = method.invoke(object, args);
                       System.out.println("具体方法调用后的善后事情");
                       return result;
                  }
              });
  }

那么这个函数为什么要使用动态代理呢,这样有什么好处?

我们进行Retrofit请求的时候构建了许多接口,并且都要调用接口中的对象;接下来我们再调用这个对象中的getSharedList方法

val sharedListCall:Call<ShareListBean> = myService.getSharedList(2,1)

在调用它的时候,在动态代理里面,在运行的时候会存在一个函数getSharedList,这个函数里面会调用invoke,这个invoke函数就是Retrofit里的invoke函数;并且也形成了一个功能拦截,如下图所示:

所以,相当于动态代理可以代理所有的接口,让所有的接口都走invoke函数,这样就可以拦截调用函数的值,相当于获取到所有的注解信息,也就是Request动态变化内容,至此不就可以动态构建带有具体的请求的URL了么,从而就可以将网络接口的参数配置归一化

这样也就解决了之前OKHttp存在的接口配置繁琐问题,既然都是要构建Request,为了自主动态的来完成,所以Retrofit使用了动态代理

谈谈Retrofit运用的动态代理及反射

那么我们在读Retrofit源码的时候,是否都有这样一个问题,为什么我写个接口以及一些接口Api,我们就可以完成相应的http请求呢?Retrofit到底在这其中做了什么工作?简单来说,其核心就是通过反射+动态代理来解决的,那么动态代理和反射的原理是怎么样的?

代理模式梳理

首先我们要明白代理模式到底是怎么样的,这里我简单梳理下

  • 代理类与委托类有着同样的接口
  • 代理类主要为委托类预处理消息,过滤消息,然后把消息发送给委托类,以及事后处理消息等等
  • 一个代理类的对象与一个委托类的对象关联,代理类的对象本身并不真正实现服务,而是通过调用委托类的对象的相关方法,为提供特定的服务

以上是普通代理模式(静态代理)它是有一个具体的代理类来实现的

动态代理+反射

那么Retrofit用到的动态代理呢?答案不言而喻,所谓动态就是没有一个具体的代理类,我们看到Retrofitcreate函数中,它是可以为委托类对象生成代理类, 代理类可以将所有的方法调用分派到委托对象上反射执行,大致如下

  • 接口的classLoader
  • 只包含接口的class数组
  • 自定义的InvocationHandler()对象, 该对象实现了invoke() 函数, 通常在该函数中实现对委托类函数的访问

这就是在create函数中所使用的动态代理及反射

扩展:通过这些文章,了解更多动态代理与反射

反射,动态代理在Retrofit中的运用

Retrofit的代理模式解析

Retrofit注解是怎么进行解析的?

在使用Retrofit的时候,或者定义接口的时候,在接口方法中加入相应的注解(@GET,@POST,@Query@FormUrlEncoded等),然后我们就可以调用这些方法进行网络请求了;那么就有了问题,为什么注解可以完整的覆盖网络请求?我们知道,注解大致分为三类,通过请求方法、请求体编码格式、请求参数,大致有22种注解,它们基本完整的覆盖了HTTP请求方案 ;通过它们我们确定了网络请求request的具体方案;

此时,就抛出了开始的问题,Retrofit注解是怎么被解析的呢?这里就要熟悉Retrofit中的ServiceMethod类了,总的来说,它首先选择Retrofit里提供的工具(数据转换器converter,请求适配器adapter),相当于就是具体请求Request的一个封装,封装完成之后将注解进行解析;

下面通过官方提供例子来说明下ServiceMethod组成的部分

5ce1ff984d5b49de878b45e7a88af7a.png

其中 @GET("users/{user}/repos"是由parseMethodAnnotation负责解析的;@Path参数注解就由对应ParameterHandler进行处理,剩下的Call<List<Repo>毫无疑问就是使用CallAdapter将这个Call 类型适配为用户定义的 service method 的返回类型。

那么ServiceMethod是怎么对注解进行解析的呢,来简单梳理下它的源码

  • 首先,在loadService方法中进行检测,禁止静态方法,这里Retrofit笔者使用的是2.9.0版本,不再是直接调用ServiceMethod.Builder(),而是使用缓存的方式调用ServiceMethod.parseAnnotations(this, method),将它转为RequestFactory对象,其实本地大同小异,原理是差不多的
final class RequestFactory {
 static RequestFactory parseAnnotations(Retrofit retrofit, Method method) {
   return new Builder(retrofit, method).build();
}
...
  • 同样在RequestFactory中也是使用Builder模式,其实就是封装了一层,传入retrofit-method两个参数,在这里面我们调用了Method类获取了它的注解数组methodAnnotations,型参的类型数组parameterTypes,型参的注解数组parameterAnnotationsArray

     Builder(Retrofit retrofit, Method method) {
         this.retrofit = retrofit;
         this.method = method;
         this.methodAnnotations = method.getAnnotations();
         this.parameterTypes = method.getGenericParameterTypes();
         this.parameterAnnotationsArray = method.getParameterAnnotations();
      }
  • 然后在build方法中,它会创建一个ReqeustFactory对象,最终解析它通过HttpServiceMethod又转换成ServiceMethod实例,这个方法里主要就是针对注解的解析过程,由于源码非常长,感兴趣的同学可以去详细阅读下,这里大概概括下几个重要的解析方法

    1. parseMethodAnnotation

    该方法就是确定网络的传输方式,判断加了哪些注解,下面借用一张网络上的图表达会更直观点

aHR0cDovL2ltZy5ibG9nLmNzZG4ubmV0LzIwMTYwOTIzMTMyMzU4NDA2.png

  1. parseHttpMethodAndPath,parseHeaders

    我们通过上图也可以看到,其实就是解析httpMethodheaders,它们都是在parseMethodAnnotation方法中被调用的,从而进行细化。前者确定的是请求方法(get,post,delete等),后者顾名思义确定的是headers头部;前者会检测httpMethod,它不允许有多个方法注解,会使用正则表达式进行判断,url中的参数必须用括号占位,最终提取出了真实的urlurl中携带的参数名称;后者就是解析当前Http请求的头部信息

  • 经过以上方法注解处理以及验证,在build方法中还要对参数注解进行处理

    int parameterCount = parameterAnnotationsArray.length;
         parameterHandlers = new ParameterHandler<?>[parameterCount];
         for (int p = 0, lastParameter = parameterCount - 1; p < parameterCount; p++) {
           parameterHandlers[p] =
               parseParameter(p, parameterTypes[p], parameterAnnotationsArray[p], p == lastParameter);
        }

    它循环进行参数的验证处理,通过parseParameter方法最后一个参数判断是否继续,参考下网络上的图示

aHR0cDovL2ltZy5ibG9nLmNzZG4ubmV0LzIwMTYwOTIzMTMyNDM1MDQ3.png

简单说下ParameterHandler是怎么处理这个参数注解的?它会通过进行两项校验,分别是对不确定的类型合法校验路径名称的校验,然后就是一堆参数注解的处理,分析源码后可以看到ParameterHandler最终都组装成了一个RequestBuilder,那么它是用来干神马的?答案是生成OKHttpRequest,网络请求还是交给OKHttp来完成

以上简单分析了下Retrofit注解的解析过程,需要深入了解的同学请自行探索。

如果同学对注解不太熟悉,想要了解Java注解的相关知识点可以阅读这篇文章--->(Retrofit注解

Retrofit如何将注解封装成OKHttpcall

上个问题已经知道了Retrofit中的ServiceMethod对会注解进行解析封装,这时候各种网络请求适配器,请求头,参数,转换器等等都准备好了,最终它会将ServiceMethod转为Retrofit提供的OkHttpCall,这个就是对okhttp3.Call的封装,答案已经呼之欲出了。

 @Override
 final @Nullable ReturnT invoke(Object[] args) {
   Call<ResponseT> call = new OkHttpCall<>(requestFactory, args, callFactory, responseConverter);
   return adapt(call, args);
}

换种说法,相当于在ServiceMethod中已经将注解转变为url +请求头+参数等等格式,对照OKHttp请求流程,是不是已经完成了构建Request请求了,它最终要变成Okhttp3.call才能进行网络请求,所以OkHttpCall基本上就是做了这么一件事情,下面有张图可以直观看下ServiceMethod大概做了哪些事情

接着我们看下okhttpCall中的enqueue方法,它会去创建一个真实的Call,这个其实就是OKHttp中的call,接下来的网络请求工作就交给OKHttp来完成

private okhttp3.Call createRawCall() throws IOException {
   okhttp3.Call call = callFactory.newCall(requestFactory.create(args));
   if (call == null) {
     throw new NullPointerException("Call.Factory returned null.");
  }
   return call;
}

到这里,说白了Retrofit其实没有任何与网络请求相关的东西,它最终还是通过统一解析注解Request去构建OkhttpCall执行,通过设计模式去封装执行OkHttp

Rretrofit是怎么完成线程切换和数据适配的

Retrofit在网络请求完成后所做的就只有两件事,自动线程切换和数据适配;那么它是如何完成这些操作的呢?

关于数据适配

其实这个在上文注解解析问题中已经回答了一部分了,这里我大概总结下流程,具体的数据解析适配过程细节需要大家私下去深入探讨;在封装的OkhttpCall调用OKHttp进行网络请求后会拿到接口响应结果response,这时候就要进行数据格式的解析适配了,会调用parsePerson方法,里面最终还是会调用RetrofitconverterFactories拿到数据转换器工厂的集合其中的一个,所以当我们创建Retrfoit中进行addConvetFactory的时候,它保存在了Retrofit当中,交给了responseConverter.convert(responsebody),从而完成了数据转换适配的过程

关于线程切换

首先我们要知道线程切换是发生在什么时候?毫无疑问,肯定是在最后一步,当网络请求回来后,且进行数据解析后,那这样我们向上寻根,发现最终数据解析由HttpServiceMethod之后它会调用callAdapter.adapt()进行适配

 protected Object adapt(Call<ResponseT> call, Object[] args) {
     call = callAdapter.adapt(call);
    ....
}

这意味着callAdapter会去包装OkhttpCall,那么这个callAdapter是来自哪里的,追本朔源,它其实在Retrofit中的build会去添加defaultCallAdapterFactories,这个方法里就调用了DefaultCallAdapterFactory,真正的线程切换就在这里

 Executor callbackExecutor = this.callbackExecutor;
     if (callbackExecutor == null) {
       callbackExecutor = platform.defaultCallbackExecutor();
    }    
// Make a defensive copy of the adapters and add the default Call adapter.
     List<CallAdapter.Factory> callAdapterFactories = new ArrayList<>(this.callAdapterFactories);
     callAdapterFactories.addAll(platform.defaultCallAdapterFactories(callbackExecutor));

这个默认的defaultCallAdapterFactories会传入平台的defaultCallbackExecutor(),由于我们平台是Android,所以它里面存放的就是主线程的Executor,它里面就是一个Handler

    static final class MainThreadExecutor implements Executor {
     private final Handler handler = new Handler(Looper.getMainLooper());

     @Override
     public void execute(Runnable r) {
       handler.post(r);
    }
  }

到这来看下DefaultCallAdapterFactory中的enqueue(代理模式+装饰模式), 这里面使用了一个代理类delegate,它其实就是Retrofit中的OkhttpCall,最终请求结果完成后使用callbackExecutor.execute()将线程变为主线程,最终又回到了MainThreadExecutor当中

callbackExecutor.execute(
                () -> {
                   if (delegate.isCanceled()) {
                     // Emulate OkHttp's behavior of throwing/delivering an IOException on
                     // cancellation.
                     callback.onFailure(ExecutorCallbackCall.this, new IOException("Canceled"));
                  } else {
                     callback.onResponse(ExecutorCallbackCall.this, response);
                  }
                });

所以总的来说,这其实是一个层层叠加的过程,Retrofit的线程切换原理本质上就是Handler消息机制;到这关于数据适配和线程切换的回答就告一段落了,有很多细节的东西没有提到,有时间的话,需要自己去补充,用一张草图来展示下Retrofit对它们进行的封装


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

面试官:“你知道什么情况下 HTTPS 不安全么”

在现代互联网的安全保障中,HTTPS 已经成为了一种标配,几乎所有的网站都会使用这种加密方式来保护用户的隐私和数据安全。但是,就算是 HTTPS,也并不是绝对安全的,存在一些情况下 HTTPS 可能会被攻击或者不安全。那么,这些情况具体是什么呢?下面我们就来一...
继续阅读 »

在现代互联网的安全保障中,HTTPS 已经成为了一种标配,几乎所有的网站都会使用这种加密方式来保护用户的隐私和数据安全。但是,就算是 HTTPS,也并不是绝对安全的,存在一些情况下 HTTPS 可能会被攻击或者不安全。那么,这些情况具体是什么呢?下面我们就来一一解析。

  1. 中间人攻击

中间人攻击是指攻击者通过某种手段,让用户和服务器之间的 HTTPS 连接被攻击者所掌握,从而窃取用户的信息或者篡改数据。这种攻击方式的实现原理是攻击者在用户和服务器之间插入一个自己的服务器,然后将用户的请求转发到真正的服务器上,同时将服务器返回的数据再转发给用户。在这个过程中,攻击者可以窃取用户的信息或者篡改数据,而用户和服务器之间的通信则被攻击者所掌握。

中间人攻击的防御方式主要是使用证书验证机制。在 HTTPS 连接建立之前,服务器会将自己的数字证书发送给客户端,客户端会验证证书的合法性。如果证书合法,则可以建立 HTTPS 连接;如果证书不合法,则会提示用户连接不安全。因此,在防范中间人攻击时,特别需要注意数字证书的合法性。

  1. SSL/TLS 协议漏洞

SSL/TLS 协议是 HTTPS 的核心协议,负责加密和解密数据。然而,SSL/TLS 协议本身也存在漏洞,攻击者可以通过这些漏洞来破解加密数据或者进行其他攻击。比如,2014 年发现的 Heartbleed 漏洞就是 SSL/TLS 协议中的一个严重漏洞,可以让攻击者窃取服务器内存中的数据。

为了防范 SSL/TLS 协议漏洞,需要及时更新 SSL/TLS 协议版本,并使用最新的加密算法。同时,也需要定期对 SSL/TLS 协议进行安全评估和漏洞扫描,以确保协议的安全性。

  1. SSL/TLS 证书被盗用

SSL/TLS 证书是 HTTPS 连接中保障安全性的重要组成部分,如果证书被盗用,则攻击者可以冒充合法网站进行欺骗和攻击。SSL/TLS 证书被盗用的方式有很多种,比如私钥泄露、数字证书机构被攻击等。

为了防范 SSL/TLS 证书被盗用,需要使用可靠的数字证书机构颁发证书,并定期更换证书。同时,也需要对私钥进行保护,并定期更换私钥。

  1. 安全策略不当

HTTPS 的安全性除了依赖协议和证书外,还和网站本身的安全策略有关。如果网站本身的安全策略不当,则可能会导致 HTTPS 连接不安全。比如,网站没有启用 HSTS(HTTP Strict Transport Security)机制,则可能被攻击者利用 SSLStrip 攻击进行欺骗。

为了防范安全策略不当导致 HTTPS 连接不安全,需要建立完善的安全策略体系,并对网站进行定期安全评估和漏洞扫描。

总结

虽然 HTTPS 在现代互联网中已经成为了一种标配,并且相对于 HTTP 来说具有更高的安全性,但是 HTTPS 也并不是绝对安全的。在使用 HTTPS 的过程中,需要注意中间人攻击、SSL/TLS 协议漏洞、SSL/TLS 证书被盗用以及安全策略不当等情况。只有建立完善的安全策略体系,并定期进行安全评估和漏洞扫描,才能确保 HTTPS 连接的安全性。


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

Android | View.post 到底是在什么时候执行的?

前言相信绝大部分人都使用过 view.post这个方法,且使用场景基本上都是用来获取 view 的一些属性数据,并且我们也都知道,该方法会使用 handler 发送一个消息,并且该消息被回调执行的时候 ...
继续阅读 »

前言

相信绝大部分人都使用过 view.post这个方法,且使用场景基本上都是用来获取 view 的一些属性数据,并且我们也都知道,该方法会使用 handler 发送一个消息,并且该消息被回调执行的时候 view 是已经绘制完成的,今天我们来聊一聊它内部的一些细节。

View.post

public boolean post(Runnable action) {
final AttachInfo attachInfo = mAttachInfo;
if (attachInfo != null) {
return attachInfo.mHandler.post(action);
}
getRunQueue().post(action);
return true;
}

代码看起来非常清楚明了,主要可以分为两部分

  • 如果 attachInfo 不为 null ,则直接获取它的 handler 将 action 发送出去
  • 否则就调用 getRunQueue.post ,并传入 action,看名字好像是一个可运行的队列

下面我们来分别看一下这两者都干了什么

AttachInfo

/**
* 将视图附加到其父视图时提供的一组信息 窗口
*/
final static class AttachInfo {
//......
@UnsupportedAppUsage
final Handler mHandler;

AttachInfo(IWindowSession session, IWindow window, Display display,
ViewRootImpl viewRootImpl, Handler handler, Callbacks effectPlayer,
Context context) {
mSession = session;
mWindow = window;
mWindowToken = window.asBinder();
mDisplay = display;
mViewRootImpl = viewRootImpl;
mHandler = handler;
mRootCallbacks = effectPlayer;
mTreeObserver = new ViewTreeObserver(context);
}
}

根据该类的注释信息可以看出来这个类是用来保存窗口信息的,并且熟悉 View 添加流程的同学应该清楚,该类是在 WindowManager.addView 中创建 ViewRootImpl 的时候在 ViewRootImpl 的构造方法中创建的:

public ViewRootImpl(@UiContext Context context, Display display, IWindowSession session,
boolean useSfChoreographer) {
//.....
mAttachInfo = new View.AttachInfo(mWindowSession, mWindow, display, this, mHandler, this,
context);

}

ViewRootImpl 是所有 View 的顶层,测量,布局绘制都是从该类中开始的。

WindowManager 创建完 ViewRootImpl 后会调用他的 setView 方法

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,
int userId) {
synchronized (this) {
if (mView == null) {
//......
mAttachInfo.mDisplayState = mDisplay.getState();
mSoftInputMode = attrs.softInputMode;
mWindowAttributesChanged = true;
mAttachInfo.mRootView = view;
mAttachInfo.mScalingRequired = mTranslator != null;
mAttachInfo.mApplicationScale =
mTranslator == null ? 1.0f : mTranslator.applicationScale;
if (panelParentView != null) {
mAttachInfo.mPanelParentWindowToken
= panelParentView.getApplicationWindowToken();
}
mAdded = true;
int res; /* = WindowManagerImpl.ADD_OKAY; */

// 请求布局
requestLayout();

try {
mOrigWindowType = mWindowAttributes.type;
//调用 WMS 添加窗口
res = mWindowSession.addToDisplayAsUser(mWindow, mWindowAttributes,
getHostVisibility(), mDisplay.getDisplayId(), userId,
mInsetsController.getRequestedVisibilities(), inputChannel, mTempInsets,
mTempControls);
} catch (RemoteException e) {
}
}
}

上面代码中先是对 mAttachInfo 进行各种赋值操作,接着 requestLayout ,View 的测量绘制布局都是从该方法中开始的,最后调用系统服务添加窗口,我们需要关心的就是 requestLayout

@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();//检测线程
mLayoutRequested = true;
scheduleTraversals();
}
}
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
//发送同步屏障,立即执行 mTraversalRunnable 任务
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

最终会调用到 doTraversal 方法中:

void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
//移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
//开始执行view的绘制流程
performTraversals();
}
}
private void performTraversals() {
// cache mView since it is used so much below...
final View host = mView;

if (mFirst) {
//调用 view 该方法
host.dispatchAttachedToWindow(mAttachInfo, 0);
}
//.........
}
#View
void dispatchAttachedToWindow(AttachInfo info, int visibility) {
mAttachInfo = info; 进行赋值
//...
}

通过上面可以看出来最终 mAttachInfo 的的赋值是在 performTraversals 方法中调用完成的,该方法中也进行了测量布局绘制等操作,如果仔细看源码就会发现 dispatchAttachedToWindow 是在测量等操作之前执行的,那为什么 View.post 中还能获取到 View 的宽高等属性呢?

其实这个问题也不是特别难,因为 performTraversals 方法也是通过 handler 发送的,在执行 mTraversalRunnable 的时候才对 mAttachInfo 进行的赋值,然后再执行绘制流程,所以通过 mAttachInfo.handler 发送的消息肯定是在 mTraversalRunnable 之后执行的,这个时候绘制流程已经结束了,正因为如此,所以才可以获取到 View 的宽高等属性。

小结一下

在 mAttachInfo 不为空的情况下会直接使用 handler 发送消息,为什么 mAttacheInfo 发送后就可以获取到各种属性数据,主要流程如下所示:

  1. View 在创建出来后需要使用 WindowManager.addView 添加到屏幕上,期间会创建 View 的顶层类 ViewRootImpl
  2. 在 ViewRootImpl 构造方法中回创建 mAttachInfo
  3. 在 ViewRootImpl.setView 中对 mAttacheInfo 添加各种数据,并调用 View 的绘制流程,设置同步屏障,使用 handler 发送绘制任务,使得该消息可以再第一时间执行
  4. 在绘制流程的最开始的时候将 mAttachInfo 传递给 View,这样便是整个流程了
  5. 等到 View.post 执行的时候,使用 mattachInfo.handler 发送的消息肯定会在 View 绘制的任务之后执行

如果你对 View 的添加流程和绘制流程不太熟悉,这里推荐两篇文章对你会有一点帮助

Android | 理解 Window 和 WindowManager :里面有 View 的添加流程等

Android | 理解 ViewRootImpl : View 的绘制流程等

getRunQueue.post

通过 View.post 中的代码可以知道如果 mAttachInfo 为 null 就会执行 getRunQueue().post() 方法,下面我们来看一下这个方法:

private HandlerActionQueue getRunQueue() {
if (mRunQueue == null) {
mRunQueue = new HandlerActionQueue();
}
return mRunQueue;
}
/**
* 当没有附加处理程序时,用于从视图中排队等待处理的类
*
* @hide Exposed for test framework only.
*/
public class HandlerActionQueue {
private HandlerAction[] mActions;
private int mCount;
//......
private static class HandlerAction {
final Runnable action;
final long delay;

public HandlerAction(Runnable action, long delay) {
this.action = action;
this.delay = delay;
}

public boolean matches(Runnable otherAction) {
return otherAction == null && action == null
|| action != null && action.equals(otherAction);
}
}
}

该类也比较简单,主要就是对需要处理的任务进行排队等待,我们直接来看 post 方法

public void post(Runnable action) {
postDelayed(action, 0);
}

public void postDelayed(Runnable action, long delayMillis) {
final HandlerAction handlerAction = new HandlerAction(action, delayMillis);

synchronized (this) {
if (mActions == null) {
mActions = new HandlerAction[4];
}
mActions = GrowingArrayUtils.append(mActions, mCount, handlerAction);
mCount++;
}
}
#GrowingArrayUtils.java
public static <T> T[] append(T[] array, int currentSize, T element) {
if (currentSize + 1 > array.length) {
T[] newArray = (T[]) Array.newInstance(array.getClass().getComponentType(),
growSize(currentSize));
System.arraycopy(array, 0, newArray, 0, currentSize);
array = newArray;
}
array[currentSize] = element;
return array;
}
public static int growSize(int currentSize) {
return currentSize <= 4 ? 8 : currentSize * 2;
}

上面代码中创建了 HandlerAction 对象,并且保存到 mActions 数组中,默认数组大小等于 4,如果已经满了就会通过反射重新创建一个数组,并将数据迁移过去,每次创建数组的大小都是之前的两倍。

到这里添加到数组之后就没有别的操作了,此时我们需要推测一下这个数组中的任务会在何时被取出来然后在执行,通过上面的分析,我们大致就可以推断出来八成是在 dispatchAttachedToWindow() 方法中执行的,我们重新看一下这个方法:

void dispatchAttachedToWindow(AttachInfo info, int visibility) {
mAttachInfo = info;
//....
// Transfer all pending runnables.
if (mRunQueue != null) {
//传入 handler
mRunQueue.executeActions(info.mHandler);
mRunQueue = null;
}
//.....
}

果不其然,就是在该方法中执行的,在该方法中执行肯定就可以保证任务是在绘制流程之后执行的,我们继续跟进一下执行的方法:

//
public void executeActions(Handler handler) {
synchronized (this) {
final HandlerAction[] actions = mActions;
for (int i = 0, count = mCount; i < count; i++) {
final HandlerAction handlerAction = actions[i];
handler.postDelayed(handlerAction.action, handlerAction.delay);
}

mActions = null;
mCount = 0;
}
}

上面遍历数组,将任务取出使用 Handler 发送,最后清理资源就完事了。

总结一下

通过上面的分析,其实这个逻辑本身还是非常简单的,但是需要你提前了解 View 的添加流程以及绘制流程和Handler ,了解这些你再去看这个源码就会非常简单。

如果感觉本文对你有点帮助的话请点赞支持一下,多谢啦!


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

Moshi 真正意义上的完美解决Gson在kotlin中默认值空的问题

MoshiMoshi是一个对Kotlin更友好的Json库,square/moshi: A modern JSON library for Kotlin and Java. (github.com)依赖implementation("com.squareup....
继续阅读 »

Moshi

Moshi是一个对Kotlin更友好的Json库,square/moshi: A modern JSON library for Kotlin and Java. (github.com)

依赖

implementation("com.squareup.moshi:moshi:1.8.0")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.8.0")

使用场景

基于kotlin-reflection反射需要额外添加 com.squareup.moshi:moshi-kotlin:1.13.0 依赖

// generateAdapter = true 表示使用codegen生成这个类的JsonAdapter
@JsonClass(generateAdapter = true)
// @Json 标识json中字段名
data class Person(@Json(name = "_name")val name: String, val age: Int)
fun main() {
   val moshi: Moshi = Moshi.Builder()
       // KotlinJsonAdapterFactory基于kotlin-reflection反射创建自定义类型的JsonAdapter
      .addLast(KotlinJsonAdapterFactory())
      .build()
   val json = """{"_name": "xxx", "age": 20}"""
   val person = moshi.adapter(Person::class.java).fromJson(json)
   println(person)
}
  • KotlinJsonAdapterFactory用于反射生成数据类的JsonAdapter,如果不使用codegen,那么这个配置是必要的;如果有多个factory,一般将KotlinJsonAdapterFactory添加到最后,因为创建Adapter时是顺序遍历factory进行创建的,应该把反射创建作为最后的手段
  • @JsonClass(generateAdapter = true)标识此类,让codegen在编译期生成此类的JsonAdapter,codegen需要数据类和它的properties可见性都是internal/public
  • moshi不允许需要序列化的类不是存粹的Java/Kotlin类,比如说Java继承Kotlin或者Kotlin继承Java

存在的问题

所有的字段都有默认值的情况

@JsonClass(generateAdapter = true)
data class DefaultAll(
  val name: String = "me",
  val age: Int = 17
)

这种情况下,gson 和 moshi都可以正常解析 “{}” json字符

部分字段有默认值

@JsonClass(generateAdapter = true)
data class DefaultPart(
  val name: String = "me",
  val gender: String = "male",
  val age: Int
)

// 针对以下json gson忽略name,gender属性的默认值,而moshi可以正常解析
val json = """{"age": 17}"""

产生的原因

Gson反序列化对象时优先获取无参构造函数,由于DefaultPart age属性没有默认值,在生成字节码文件后,该类没有无参构造函数,所有Gson最后调用了Unsafe.newInstance函数,该函数不会调用构造函数,执行对象初始化代码,导致name,gender对象是null。

Moshi 通过adpter的方式匹配类的构造函数,使用函数签名最相近的构造函数构造对象,可以是的默认值不丢失,但在官方的例程中,某些情况下依然会出现我们不希望出现的问题。

Moshi的特殊Json场景

1、属性缺失

针对以下类

@JsonClass(generateAdapter = true)
data class DefaultPart(
   val name: String,
   val gender: String = "male",
   val age: Int
)

若json = """ {"name":"John","age":18}""" Moshi可以正常解析,但如果Json=""" {"name":"John"}"""Moshi会抛出Required value age missing at $ 的异常,

2、属性=null

若Json = """{"name":"John","age":null} ”“”Moshi会抛出Non-null value age was null at $ 的异常

很多时候后台返回的Json数据并不是完全的统一,会存在以上情况,我们可以通过对age属性如gender属性一般设置默认值的方式处理,但可不可以更偷懒一点,可以不用写默认值,系统也能给一个默认值出来。

完善Moshi

分析官方库KotlinJsonAdapterFactory类,发现,以上两个逻辑的判断代码在这里

internal class KotlinJsonAdapter<T>(
 val constructor: KFunction<T>,
   // 所有属性的bindingAdpter
 val allBindings: List<Binding<T, Any?>?>,
   // 忽略反序列化的属性
 val nonIgnoredBindings: List<Binding<T, Any?>>,
   // 反射类得来的属性列表
 val options: JsonReader.Options
) : JsonAdapter<T>() {

 override fun fromJson(reader: JsonReader): T {
   val constructorSize = constructor.parameters.size

   // Read each value into its slot in the array.
   val values = Array<Any?>(allBindings.size) { ABSENT_VALUE }
   reader.beginObject()
   while (reader.hasNext()) {
       //通过reader获取到Json 属性对应的类属性的索引
     val index = reader.selectName(options)
     if (index == -1) {
       reader.skipName()
       reader.skipValue()
       continue
    }
       //拿到该属性的binding
     val binding = nonIgnoredBindings[index]
// 拿到属性值的索引
     val propertyIndex = binding.propertyIndex
     if (values[propertyIndex] !== ABSENT_VALUE) {
       throw JsonDataException(
         "Multiple values for '${binding.property.name}' at ${reader.path}"
      )
    }
// 递归的方式,初始化属性值
     values[propertyIndex] = binding.adapter.fromJson(reader)

       // 关键的地方1
       // 判断 初始化的属性值是否为null ,如果是null ,代表这json字符串中的体现为 age:null
     if (values[propertyIndex] == null && !binding.property.returnType.isMarkedNullable) {
         // 抛出Non-null value age was null at $ 异常
       throw Util.unexpectedNull(
         binding.property.name,
         binding.jsonName,
         reader
      )
    }
  }
   reader.endObject()

   // 关键的地方2
    // 初始化剩下json中没有的属性
   // Confirm all parameters are present, optional, or nullable.
     // 是否调用全属性构造函数标志
   var isFullInitialized = allBindings.size == constructorSize
   for (i in 0 until constructorSize) {
     if (values[i] === ABSENT_VALUE) {
         // 如果等于ABSENT_VALUE,表示该属性没有初始化
       when {
           // 如果该属性是可缺失的,即该属性有默认值,这不需要处理,全属性构造函数标志为false
         constructor.parameters[i].isOptional -> isFullInitialized = false
           // 如果该属性是可空的,这直接赋值为null
         constructor.parameters[i].type.isMarkedNullable -> values[i] = null // Replace absent with null.
           // 剩下的则是属性没有默认值,也不允许为空,如上例,age属性
           // 抛出Required value age missing at $ 异常
         else -> throw Util.missingProperty(
           constructor.parameters[i].name,
           allBindings[i]?.jsonName,
           reader
        )
      }
    }
  }

   // Call the constructor using a Map so that absent optionals get defaults.
   val result = if (isFullInitialized) {
     constructor.call(*values)
  } else {
     constructor.callBy(IndexedParameterMap(constructor.parameters, values))
  }

   // Set remaining properties.
   for (i in constructorSize until allBindings.size) {
     val binding = allBindings[i]!!
     val value = values[i]
     binding.set(result, value)
  }

   return result
}

 override fun toJson(writer: JsonWriter, value: T?) {
   if (value == null) throw NullPointerException("value == null")

   writer.beginObject()
   for (binding in allBindings) {
     if (binding == null) continue // Skip constructor parameters that aren't properties.

     writer.name(binding.jsonName)
     binding.adapter.toJson(writer, binding.get(value))
  }
   writer.endObject()
}

通过代码的分析,是不是可以在两个关键的逻辑点做以下修改


// 关键的地方1
// 判断 初始化的属性值是否为null ,如果是null ,代表这json字符串中的体现为 age:null
if (values[propertyIndex] == null && !binding.property.returnType.isMarkedNullable) {
   // 抛出Non-null value age was null at $ 异常
   //throw Util.unexpectedNull(
   //   binding.property.name,
   //   binding.jsonName,
   //   reader
   //)
   // age:null 重置为ABSENT_VALUE值,交由最后初始化剩下json中没有的属性的时候去初始化
values[propertyIndex] = ABSENT_VALUE
}

// 关键的地方2
// 初始化剩下json中没有的属性
// Confirm all parameters are present, optional, or nullable.
// 是否调用全属性构造函数标志
var isFullInitialized = allBindings.size == constructorSize
for (i in 0 until constructorSize) {
   if (values[i] === ABSENT_VALUE) {
       // 如果等于ABSENT_VALUE,表示该属性没有初始化
       when {
           // 如果该属性是可缺失的,即该属性有默认值,这不需要处理,全属性构造函数标志为false
           constructor.parameters[i].isOptional -> isFullInitialized = false
           // 如果该属性是可空的,这直接赋值为null
           constructor.parameters[i].type.isMarkedNullable -> values[i] = null // Replace absent with null.
           // 剩下的则是属性没有默认值,也不允许为空,如上例,age属性
           // 抛出Required value age missing at $ 异常
           else ->{
               //throw Util.missingProperty(
                   //constructor.parameters[i].name,
                   //allBindings[i]?.jsonName,
                   //reader
          //)
               // 填充默认
               val index = options.strings().indexOf(constructor.parameters[i].name)
               val binding = nonIgnoredBindings[index]
               val propertyIndex = binding.propertyIndex
// 为该属性初始化默认值
               values[propertyIndex] = fullDefault(binding)

          }
      }
  }
}



private fun fullDefault(binding: Binding<T, Any?>): Any? {
       return when (binding.property.returnType.classifier) {
           Int::class -> 0
           String::class -> ""
           Boolean::class -> false
           Byte::class -> 0.toByte()
           Char::class -> Char.MIN_VALUE
           Double::class -> 0.0
           Float::class -> 0f
           Long::class -> 0L
           Short::class -> 0.toShort()
           // 过滤递归类初始化,这种会导致死循环
           constructor.returnType.classifier -> {
               val message =
                   "Unsolvable as for: ${binding.property.returnType.classifier}(value:${binding.property.returnType.classifier})"
               throw JsonDataException(message)
          }
           is Any -> {
               // 如果是集合就初始化[],否则就是{}对象
               if (Collection::class.java.isAssignableFrom(binding.property.returnType.javaType.rawType)) {
                   binding.adapter.fromJson("[]")
              } else {
                   binding.adapter.fromJson("{}")
              }
          }
           else -> {}
      }
  }

最终效果

"""{"name":"John","age":null} ”“” age会被初始化成0,

"""{"name":"John"} ”“” age依然会是0,即使我们在类中没有定义age的默认值

甚至是对象@JsonClass(generateAdapter = true)

data class DefaultPart(
   val name: String,
   val gender: String = "male",
   val age: Int,
   val action:Action
)
class Action(val ac:String)

最终Action也会产生一个Action(ac:"")的值

data class RestResponse<T>(
val code: Int,
val msg: String="",
val data: T?
) {
fun isSuccess() = code == 1

fun checkData() = data != null

fun successRestData() = isSuccess() && checkData()

fun requsetData() = data!!
}
class TestD(val a:Int,val b:String,val c:Boolean,val d:List<Test> ) {
}

class Test(val a:Int,val b:String,val c:Boolean=true)



val s = """
{
"code":200,
"msg":"ok",
"data":[{"a":0,"c":false,"d":[{"b":null}]}]}
""".trimIndent()

val a :RestResponse<List<TestD>>? = s.fromJson()


最终a为 {"code":200,"msg":"ok","data":[{"a":0,"b":"","c":false,"d":[{"a":0,"b":"","c":true}]}]}


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

Kotlin 常见面试知识点

一.Kotlin 与 Java 对比Kotlin 和 Java 都是针对 JVM 的编程语言。它们有一些相似之处,比如都支持面向对象编程、静态类型和垃圾回收等。但是 Kotlin 和 Java 也有很多不同之处。以下是一些 Kotlin 和 Java 的比较:...
继续阅读 »

一.Kotlin 与 Java 对比

Kotlin 和 Java 都是针对 JVM 的编程语言。它们有一些相似之处,比如都支持面向对象编程、静态类型和垃圾回收等。但是 Kotlin 和 Java 也有很多不同之处。以下是一些 Kotlin 和 Java 的比较:

  1. 代码量:Kotlin 比 Java 代码量少很多。Kotlin 通过使用更简洁的语法和函数式编程的概念来简化 Java 代码,以减少代码的复杂性。

  2. 空指针安全:Kotlin 通过引入空指针安全机制来避免空指针异常,而 Java 中需要手动检查 null 值。

  3. 扩展函数:Kotlin 中有一个强大的功能叫做扩展函数,它允许用户将一个已存在的类进行扩展。

  4. 函数式编程概念:Kotlin 支持更多的函数式编程概念,比如 lambda 表达式、高阶函数和尾递归等。

  5. 数据类:Kotlin 中引入了数据类,它允许程序员快速创建简单的数据类。相比之下,Java 需要编写大量的样板代码。

总的来说,Kotlin 相对于 Java 拥有更简洁的语法,更少的瑕疵,更多的功能和更高的生产效率,但是 Java 相对于 Kotlin 拥有更成熟的生态体系,更广泛的支持和更好的跨平台支持。

Kotlin 常见关键字

Kotlin 作为一种独立的编程语言,有一些 Java 中没有的关键字,以下是 Kotlin 特有的一些关键字:

  1. companion:伴生对象,可以在类内部定义一个对象,用于实现静态方法和属性。

  2. data:数据类,用于快速创建一个用于存储数据的类。

  3. by:委托,可以在一个对象中使用另一个对象的属性或方法。

  4. reified:具体化,用于解决 Java 泛型擦除问题。

  5. inline:内联,用于在编译时将函数代码插入到调用处,提高性能。

  6. non-local return:非局部返回,可以在嵌套函数中使用 return 关键字返回到外部函数。

  7. tailrec:尾递归,用于将递归函数改为尾递归函数,提高性能。

  8. suspend 和 coroutine:协程,Kotlin 支持协程编程,可以使用 suspend 关键字定义挂起函数,使用 coroutine 构建异步和并发程序。

这些关键字提供了 Kotlin 编程中一些独特的语法异构,使得程序员可以更轻松地编写高效、可读性优秀的代码。

Kotlin 常见内置函数

  1. let:作用于某个对象,让其调用一个函数,并返回 Lambda 表达式的结果。let 函数可以避免在调用 Lambda 表达式时产生多余的变量名,提高了代码可读性。

  2. apply:作用于某个对象,将对象本身作为接收器(this)返回,可以连续进行多次调用,非常适合链式调用代码块的场景。

  3. with:非扩展函数,接受一个对象和一个 Lambda 表达式,可以让您在将对象本身作为参数传递的情况下调用 Lambda 表达式。with 函数允许编写更紧凑的代码,特别是当您需要访问一个对象的属性时。

  4. run:类似于 let 函数,但是只能作用于可空对象。如果对象不为空,run 函数会让对象调用 Lambda 表达式并返回其结果;如果对象为空,run 函数返回 null。

  5. also:类似于 let 函数,但是返回的值是指定的接收器对象,而不是 Lambda 表达式的结果。可以用于在对象的生命周期内执行额外的操作。

  6. takeIf:接受一个谓词(Lambda 表达式),并返回任何满足该谓词的对象,否则返回 null。

  7. takeUnless:与 takeIf 函数相反,如果对象不满足指定的谓词,则返回对象本身,否则返回 null。

  8. when:作为表达式或语句,类似于 Java 中的 switch 语句,可以匹配多个条件或者值,并执行与条件/值对应的代码块。

这些内置函数属于 Kotlin 标准库的一部分,使得 Kotlin 代码更加简洁、易读、易于维护,特别适用于链式调用或需要多次对某个对象执行某个操作的场景。

Kotlin 与 RxJava

Kotlin是一种现代的编程语言,它对函数式编程和响应式编程提供了很好的支持。RxJava也是一种非常流行的响应式编程库。虽然Kotlin本身没有RxJava那么强大,但它提供了一些工具和语言功能来简化异步编程和响应式编程。下面是一些使用Kotlin替代RxJava的技术:

  1. 协程:Kotlin提供了一种名为协程的轻量级线程,可以简化异步编程。协程使用类似于JavaScript的async/await语法,允许您轻松地编写异步代码而无需编写回调或使用RxJava。

  2. Flow:Kotlin的流是一种响应式编程的替代方案。它提供了与RxJava的Observable类似的流式API,但它是基于协程的,并且更容易与Kotlin集成。

  3. LiveData:LiveData是一种Kotlin Android架构组件,它提供了类似于RxJava的观察者模式。LiveData可以让您轻松地观察数据变化,同时避免RxJava的一些复杂性和性能问题。

总之,Kotlin提供了许多替代RxJava的工具和功能,从而使异步编程和响应式编程更加简单和直观。

Kotlin 协程

以下是一些与Kotlin协程相关的面试题和答案:

  1. 什么是Kotlin协程?

答:Kotlin协程是一种轻量级的线程,它使用协作式调度来实现并发。与传统的线程不同,协程可以自由地挂起和恢复。它们使并发代码更加轻松和直观,并且可以避免一些常见的并发问题。

  1. Kotlin协程的优点是什么?

答:Kotlin协程的优点包括:

  • 简单易用:协程使异步代码更加轻松和直观,而无需编写复杂的回调或使用RxJava。
  • 轻量级:协程使用协作式调度,因此它们比传统线程更加轻量级。
  • 避免共享状态问题:协程通过将计算任务拆分为许多小的、非共享的组件来避免共享状态问题。
  • 更好的性能:因为协程是轻量级的,它们的创建和销毁所需的开销更小,因此具有更好的性能。
  1. Kotlin协程中的“挂起”意味着什么?

答:在Kotlin协程中,挂起是指暂停协程的执行,直到某些条件满足。在挂起期间,协程不会占用线程,并且可以由另一个协程或线程执行。协程通常在遇到I/O操作或长时间运行的计算时挂起。

  1. 如何在Kotlin中创建协程?

答:在Kotlin中,可以使用launch、async和runBlocking等函数来创建协程。例如:

// 使用launch创建协程
GlobalScope.launch {
// 协程执行的代码
}

// 使用async创建协程
val deferred = GlobalScope.async {
// 协程执行的代码并返回结果
42
}

// 使用runBlocking创建协程
runBlocking {
// 协程执行的代码
}
  1. Kotlin中的“协程作用域”是什么?

答:协程作用域是一种可以帮助协程被正确地取消和清理的机制。它是由Kotlin提供的一个结构,可以创建和管理多个相关联的协程。协程作用域可以确保在其范围内创建的所有协程都被正确地取消,并且可以管理这些协程的执行顺序。

  1. Kotlin协程中的“挂起函数”是什么?

答:挂起函数是指可以在协程中使用的特殊函数,它们可以在执行过程中暂停协程的执行,直到某些条件满足。通常,挂起函数通过使用“挂起标记”(suspend)来定义。例如:

suspend fun getUser(id: Int): User {
// 从远程服务器获取用户数据
return user
}
  1. 如何处理Kotlin协程中的异常?

答:在Kotlin协程中,可以使用try/catch语句来处理异常。如果协程中的异常未被捕获,它将传播到协程的上层。可以使用CoroutineExceptionHandler在协程中设置一个全局异常处理程序。例如:

val handler = CoroutineExceptionHandler { _, exception ->
// 处理异常
}

GlobalScope.launch(handler) {
// 协程执行的代码
}

Kotlin 泛型-逆变/协变

Kotlin中的泛型支持协变和逆变。接下来分别对它们进行介绍:

  1. 协变(Covariant)

协变意味着可以使用子类型作为父类型的替代。在Kotlin中,为了支持协变,我们可以将out修饰符添加到泛型参数上。例如,让我们看一个用于生产者的接口:

interface Producer<out T> {
fun produce(): T
}

这个接口可以使用out修饰符,表示这是一个生产者,它只会产生类型T的值,而不会对其进行任何更改。因此,我们可以将子类型作为父类型的替代:

class AnimalProducer : Producer<Animal> {
override fun produce(): Animal {
return Animal()
}
}

class DogProducer : Producer<Dog> {
override fun produce(): Dog {
return Dog()
}
}

这里DogAnimal的子类型,所以我们可以使用DogProducer作为类型为Producer<Animal>的变量的值。因为我们知道我们总是可以期望DogProducer生产类型为Animal的值。

  1. 逆变(Contravariant)

逆变意味着可以使用父类型作为子类型的替代。在Kotlin中,为了支持逆变,我们可以将in修饰符添加到泛型参数上。例如,让我们看一个用于消费者的接口:

interface Consumer<in T> {
fun consume(item: T)
}

这个接口可以使用in修饰符,表示这是一个消费者,它只接受类型T的值,而不会返回任何值。因此,我们可以将父类型作为子类型的替代:

class AnimalConsumer : Consumer<Animal> {
override fun consume(item: Animal) {
// 消费Animal类型的值
}
}

class DogConsumer : Consumer<Dog> {
override fun consume(item: Dog) {
// 消费Dog类型的值
}
}

这里AnimalDog的父类型,所以我们可以使用AnimalConsumer作为类型为Consumer<Dog>的变量的值。因为我们知道我们总是可以期望AnimalConsumer会接受类型为Dog的值。

总之,Kotlin中的协变和逆变提供了更好的类型安全性和代码灵活性。使用它们可以确保类型转换是正确的,并且可以使程序更加健壮和易于维护。


作者:流光容易把人抛
链接:https://juejin.cn/post/7233717220352802853
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Kotlin:正则Regex原来这么方便

一、前言不着急讲述Regex,我们先看一个需求,统计某个字符或字符串在整个字符串中出现的次数,举例,字符串如下:今天是2023年6月29日,北京,天气晴,与昨天不同的是,今天格外的热,也不知道明天会怎么样,是晴天还是阴天呢,具体得到明天才能知道了。 请统计“天...
继续阅读 »

一、前言

不着急讲述Regex,我们先看一个需求,统计某个字符或字符串在整个字符串中出现的次数,举例,字符串如下:

今天是2023年6月29日,北京,天气晴,与昨天不同的是,今天格外的热,也不知道明天会怎么样,是晴天还是阴天呢,具体得到明天才能知道了。

请统计“天”字出现的次数。

实现上边的功能,我相信大家,有很多种方式,下面也举例几种:

1、循环遍历

这是最常见,也是首先能想到的。

 val content = "今天是2023年6月29日,北京,天气晴,与昨天不同的是,今天格外的热,也不知道明天会怎么样,是晴天还是阴天呢,具体得到明天才能知道了。"
var count=0
//遍历
content.forEach {
if ('天' == it) {
count++//次数累加
}
}
print(count)

打印结果

8

2、Kotlin中count操作符

 val content = "今天是2023年6月29日,北京,天气晴,与昨天不同的是,今天格外的热,也不知道明天会怎么样,是晴天还是阴天呢,具体得到明天才能知道了。"
val count=content.count { ch -> ch == '天' }
print(count)

打印结果

8

3、Kotlin中的filter操作符

 val content = "今天是2023年6月29日,北京,天气晴,与昨天不同的是,今天格外的热,也不知道明天会怎么样,是晴天还是阴天呢,具体得到明天才能知道了。"
val filterContent=content.filter { ch -> ch == '天' }
print(filterContent.length)

打印结果

8

4、使用Java中的正则

val content = "今天是2023年6月29日,北京,天气晴,与昨天不同的是,今天格外的热,也不知道明天会怎么样,是晴天还是阴天呢,具体得到明天才能知道了。"
val pattern = Pattern.compile("天")
val matcher = pattern.matcher(content)
var count = 0
while (matcher.find()) {
count++
}
print(count)

打印结果

8

5、使用Regex对象

val content = "今天是2023年6月29日,北京,天气晴,与昨天不同的是,今天格外的热,也不知道明天会怎么样,是晴天还是阴天呢,具体得到明天才能知道了。"
val matchResult = Regex("天").findAll(content)
print(matchResult.count())

当然了还有很多的实现方式,我们暂且举这五种,看到第五种的Regex对象实现方式,有的铁子就问了,我丝毫没有发现Regex到底有什么可取之处啊,简单吗?与Kotlin的操作符相比,简直就是小巫见大巫,毫无优点可言,别慌啊铁子,如果只是简单的文本寻找,Regex绝对没有优势,毕竟,这只是它的一个引子,冰山一角的功能,我们慢慢拉开序幕~

二、Regex方法列举

通过以上的前言,我们大致知道了,原来Regex也可以实现查找的功能,无形当中,又多了一种选择方式,除此之外,它还有那些功能呢?

构造函数

我们先看一下基本的构造函数

方法参数类型概述
Regex(pattern: String)String要匹配的正则表达式模式。
Regex(pattern: String, option: RegexOption)String,RegexOption根据指定的模式字符串和指定的单个选项创建正则表达式。
Regex(pattern: String, options: Set)String,Set根据指定的模式字符串和指定的选项集创建正则表达式

对于一个参数的构造,没什么好说的,就是一个正则表达式,这也是我们最常用的,至于后面两个,相对使用的较少,不过我们还是简单的介绍一下:

RegexOption是一个枚举类型,具体类型如下:

参数概述
IGNORE_CASE启用不区分大小写的匹配。大小写比较支持Unicode
MULTILINE启用多行模式。在多行模式中,表达式^和$分别在输入序列的行终止符或末尾之后或之前匹配。
LITERAL启用模式的文字分析。输入序列中的元字符或转义序列将不会被赋予特殊含义。
UNIX_LINES启用Unix行模式。在这种模式下,只有'\n'被识别为行终止符。
COMMENTS允许在模式中使用空格和注释。
DOT_MATCHES_ALL启用表达式时的模式。匹配任何字符,包括行终止符。
CANON_EQ通过规范分解实现等价。

大家可以根据不同的情况,选择对应的参数即可,至于Set,无非就是多个RegexOption。

常见方法

了解完基本的构造,我们再来看下常用的方法:

方法参数类型概述
find(input: CharSequence, startIndex: Int = 0)CharSequence,Int寻找字符串中第一个匹配的MatchResult对象,默认从索引0开始。
findAll(input: CharSequence, startIndex: Int = 0)CharSequence,Int字符串中所有匹配的MatchResult序列,默认从索引0开始。
containsMatchIn(input: CharSequence)CharSequence如果包含输入的字符就返回true。
replace(input: CharSequence, replacement: String)CharSequence,String和String的replace类似,第一个是输入的目标字符,第二个是替换字符。
replaceFirst(input: CharSequence, replacement: String)CharSequence,String替换第一个查找到的字符
matches(input: CharSequence)CharSequence输入字符序列是否与正则表达式匹配
matchEntire(input: CharSequence)CharSequence用于匹配模式中的完整输入字符

三、Regex常见方法使用举例

在上篇文章《Android:这个需求搞懵了,产品说要实现富文本回显展示》中,不知道大家是否还有印象,对于富文本的截取,我们就采用了Regex,简简单单的就实现了富文本的内容获取,当然了也简单的介绍了部分的方法。下面,我们针对第二项中的各个方法,简单做个使用案例。

1、find

find,用于寻找第一次出现的结果,比如我们要寻找某个字符串中第一次出现的数字,如下举例:

  val content = "有这样一串数字2345,还有6789,以及012,我们如何只获取数字2345呢"
val regex = Regex("\d+")
val matchResult = regex.find(content)
print(matchResult?.value)

打印结果

2345

2、findAll

findAll,顾名思义,就是寻找所有的结果,还是上面那个案例,我们改成findAll

 val content = "有这样一串数字2345,还有6789,以及012,我们如何只获取数字2345呢"
val regex = Regex("\d+")
val matchResult = regex.findAll(content)
matchResult.forEach {
println(it.value)
}

打印结果

2345
6789
012
2345

还有一个典型的案例,就是富文本标签的截取,这个在上篇文章举例过了,大家可以看上篇文章。

3、containsMatchIn

用于判断是否包含某个字符,和String的使用方式类似:

 val content = "二流小码农"
val regex = Regex("农")
val regex2 = Regex("中")
val isContains = regex.containsMatchIn(content)
val isContains2 = regex2.containsMatchIn(content)
println(isContains)
println(isContains2)

打印结果

true
false

4、replace

用于替换字符串中的相关内容:

 val content = "二流小码农"
val regex = Regex("二")
val replaceContent=regex.replace(content,"一")
println(replaceContent)

打印结果

一流小码农

5、replaceFirst

用于替换字符串中第一次相符合的内容:

val content = "有这样一串数字2345,还有6789,以及012,我们如何只获取数字2345呢"
val regex = Regex("\d+")
//把第一次出现的数字替换为字母abcd
val replaceContent=regex.replaceFirst(content,"abcd")
println(replaceContent)

打印结果

有这样一串数字abcd,还有6789,以及012,我们如何只获取数字2345呢

6、matches

用于输入的字符和目标内容是否匹配,比如用于邮箱的验证,手机号的验证等等情况:

//邮箱验证
val content = "11@qq.com"
val content2 = "11@qq"
val regex = Regex("[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+")
val matches=regex.matches(content)
val matches2=regex.matches(content2)
println(matches)
println(matches2)

打印结果

true
false

7、matchEntire

用于匹配模式中的完整输入字符。

 //匹配数字
val regex = Regex("\d+")
val matchResult=regex.matchEntire("二流小码农")
val matchResult2=regex.matchEntire("二流小码农666")
val matchResult3=regex.matchEntire("123456")
println(matchResult?.value)
println(matchResult2?.value)
println(matchResult3?.value)

打印结果

null
null
123456

四、总结

Regex相对于Java的Api来说,使用起来更加的简单,如果大家在非正则的功能使用时,比如寻找,替换,是否包含等等,完全可以使用字符串自带的功能即可,如果说要实现一些较为复杂的,比如邮箱的验证,手机号的验证等等,那么Regex绝对是你的首选。


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

聊聊javascript中令人头大的this

在JavaScript中,this是一个非常重要的关键字,可以用来访问对象中的属性和方法。它指向当前函数的执行上下文。由于 JavaScript 可以是面向对象的,也可以是基于原型的语言,所以 this 的含义有时会有些复杂,它的行为有时候...
继续阅读 »

在JavaScript中,this是一个非常重要的关键字,可以用来访问对象中的属性和方法。它指向当前函数的执行上下文。由于 JavaScript 可以是面向对象的,也可以是基于原型的语言,所以 this 的含义有时会有些复杂,它的行为有时候会让人感到困惑。

一、让人头痛的this

在JavaScript中,this的值取决于它在哪个上下文中被调用。上下文可以是全局对象、函数、对象等。下面我们来详细聊聊这些情况:

1. 全局上下文中的this

在全局上下文中,this指向全局对象。在浏览器环境中,这个全局对象是window对象。在Node.js环境中,这个全局对象是global对象。

console.log(this === window); // true

需要注意的是,虽然this指向全局对象,但是在严格模式下,thisundefined。此外,在函数内部,this的值取决于函数是如何被调用的。如果函数被作为对象的方法调用,那么this指向该对象。如果函数是作为普通函数调用,那么this指向全局对象。如果函数是作为构造函数调用,那么this指向新创建的对象。

2. 函数上下文中的this

在函数中,this的值取决于函数是如何被调用的。当一个函数被作为普通函数调用时,this指向全局对象。例如:

function myFunction() {
console.log(this);
}

myFunction(); // 输出全局对象(window或global)

但是,当一个函数作为对象的方法调用时,this指向调用该方法的对象。例如:

const myObject = {
myMethod: function() {
console.log(this);
}
};

myObject.myMethod(); // 输出myObject对象

3. 构造函数中的this

当一个函数被用作构造函数时,this指向新创建的对象。例如:

function Person(name) {
this.name = name;
}

const person = new Person('张三');
console.log(person.name); // 输出 '张三'

4. 箭头函数中的this

箭头函数中的this与普通函数不同,它没有自己的执行上下文,而是与其所在的执行上下文共享同一个执行上下文。在箭头函数中,this指向函数定义时所在的上下文。例如:

const myObject = {
myMethod: function() {
const myArrowFunction = () => {
console.log(this);
}
myArrowFunction();
}
};

myObject.myMethod(); // 输出myObject对象

箭头函数在定义时会捕获其所在的执行上下文中的 this值。因此,箭头函数的执行上下文中的 this 值与定义它时所在的执行上下文中的 this 值相同,且无法通过 call()apply()bind()改变箭头函数中的 this 指向。

二、改变this的值

有时候,我们需要显式地改变this的值,这时就可以使用call()apply()bind()以及new操作符方法。

1. call()和apply()方法

call()apply()方法可以用来改变函数中this的值,并立即调用该函数。两者的区别在于传参方式不同,call()方法传参以逗号分隔,apply()方法传参以数组形式。例如:

function myFunction(a, b) {
console.log(this, a, b);
}

const myObject = {
myProperty: 'Hello'
}

myFunction.call(myObject, 1, 2); // 输出myObject对象,1,2
myFunction.apply(myObject, [1, 2]); // 输出myObject对象,1,2

2. bind()方法

bind()方法可以用来改变函数中this的值,并返回一个新的函数,不会立即调用该函数。例如:

function myFunction(a, b) {
console.log(this, a, b);
}

const myObject = {
myProperty: 'Hello'
}

const boundFunction = myFunction.bind(myObject, 1, 2);
boundFunction(); // 输出myObject对象,1,2

3. new绑定

在JavaScript中,new操作符用于创建一个新的对象,并将构造函数中的this指向该新对象。new操作符执行以下操作:

  1. 创建一个新的空对象
  2. 将该空对象的原型指向构造函数的prototype属性
  3. 将构造函数中的this指向该新对象
  4. 执行构造函数中的代码,并给该新对象添加属性和方法
  5. 返回该新对象

例如:

function Person(name) {
this.name = name;
}

const person = new Person('张三');
console.log(person.name); // 输出 '张三'

在这个例子中,new操作创建了一个新的对象,并将构造函数Person中的this指向该新对象。在构造函数中,this.name = name将新对象的name属性设置为'张三'。最后,new操作返回该新对象,将其赋值给变量person。

new绑定是一种特殊的方式,它可以使this指向新创建的对象。在JavaScript中,new操作符是一种常用的创建新对象的方式,使用它能够方便地创建新的对象并初始化它们的属性和方法。

三、写在最后

JavaScript中的this关键字是非常重要的,它可以用来访问对象中的属性和方法。由于this的行为有时候会让人感到困惑,因此需要对它有一个全面而深入的理解。只有理解了this的行为,才能更好地使用它,写出更加高效和可读性强的JavaScript代码。


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

为什么很多程序员都建议使用Linux

在计算机领域,Linux 是一个极具影响力的操作系统。它是由 Linus Torvalds 在 1991 年创建的,现在已经成为了开源世界中最流行的操作系统之一。很多程序员都建议使用 Linux,那么,为什么呢?安全性高Linux 是一个开源操作系统,因此,任...
继续阅读 »

在计算机领域,Linux 是一个极具影响力的操作系统。它是由 Linus Torvalds 在 1991 年创建的,现在已经成为了开源世界中最流行的操作系统之一。很多程序员都建议使用 Linux,那么,为什么呢?

  1. 安全性高

Linux 是一个开源操作系统,因此,任何人都可以查看和修改其源代码。这意味着,如果存在安全漏洞,那么很多人都可以发现并修复它们。相比之下,闭源操作系统只有少数人可以查看和修改其源代码,这意味着安全漏洞可能会被发现但不会被修复。

  1. 免费

Linux 是免费的,这意味着你可以在没有任何费用的情况下使用它。相比之下,商业操作系统需要支付高昂的许可证费用。对于个人用户和小型企业来说,这是非常有吸引力的。

  1. 强大的命令行工具

Linux 提供了非常强大的命令行工具,这些工具可以让程序员更快地完成任务。相比之下,Windows 和 macOS 的命令行工具要弱得多。

  1. 可定制性高

Linux 可以根据你的需要进行定制。你可以选择不同的桌面环境、窗口管理器、文件管理器等等。这意味着你可以创建一个完全适合你自己工作风格的操作系统。

  1. 支持众多编程语言

Linux 支持众多编程语言,包括 C、C++、Python、Java、PHP 等等。这意味着你可以使用自己喜欢的编程语言开发应用程序。

  1. 社区支持强大

Linux 社区非常强大,你可以在社区中找到各种各样的问题解答和支持。相比之下,商业操作系统的支持通常需要支付高昂的费用。

总结

在计算机领域,Linux 是一个极具影响力的操作系统。它具有高安全性、免费、强大的命令行工具、可定制性高、支持众多编程语言和社区支持强大等优点。因此,很多程序员都建议使用 Linux。


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

面试官直问:你晓得什么是有序广播?

当面试官直接问你该问题时,对我们考察的是看我们否了解广播相关的知识这里我们必须了解广播的基本知识和原理,接下来对该问题进行回答解析。问题正解广播是什么?广播是一种广泛运用的在应用程序之间传输信息的机制,Android 主要是将系统运行时的各种“事件”通知给其他...
继续阅读 »

当面试官直接问你该问题时,对我们考察的是看我们否了解广播相关的知识

这里我们必须了解广播的基本知识和原理,接下来对该问题进行回答解析。

问题正解

  1. 广播是什么?

  • 广播是一种广泛运用的在应用程序之间传输信息的机制,Android 主要是将系统运行时的各种“事件”通知给其他应用,因此设计了多种广播。广播机制最大的特征就是发送方并不关注接收方是否接到数据,也不关注接收方是如何处理数据的。
  • Android 中的每个应用程序都可以对自己有利的广播进行注册,这样程序就只会接收到自己所关心的广播内容,这些广播可能是来自于系统的,也可能是来自于其他应用程序的,前者是系统广播,后者是自定义广播。广播在具体的项目中应用场景并不多,但一旦使用会使得程序变得精简很多。
  • 安卓的广播原理,BroadCastReceiver是对分发出来的Broadcast 进行过滤接受并响应的一类组件,属于Android四大组件之一,主要用于接收系统或者app发送的广播事件。在我们的项目中常常使用广播接收者接收系统通知,比如开机启动、低电量、外播电话、锁屏、sd挂载等。 如果我们需要做的是播放器,那么监听到用户锁屏后我们应该将播放状态的暂停。android的四大组件核心就是为了实现移动、或者讲是嵌入式设备上的架构,它们之间有时候是一种相互依赖的关系, 有时候又是一种补充关系,引入广播机制可以极大方便几大组件的信息和数据交流。广播有利于程序间交流消息,例如在自己的应用程序内监听系统来电。
  1. 如何使用广播?

  • 自定义一个BroadcastReceiver,在onReceive()方法中完成广播要处理的事务,比如这里的提示Toast信息。
public class MyReceiver extends BroadcastReceiver{
   @Override
   public void onReceive(Context context, Intent intent) {
       Toast.makeText(context,"你的网络状态发生改变~",Toast.LENGTH_SHORT).show();
  }
}
  • MainActivity.java中动态注册广播:
public class MainActivity extends AppCompatActivity {

   MyBRReceiver myReceiver;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_main);
       //核心部分代码:
       myReceiver = new MyReceiver();
       IntentFilter itFilter = new IntentFilter();
       itFilter.addAction("android.net.conn.CONNECTIVITY_CHANGE");
       registerReceiver(myReceiver, itFilter);
  }

   //别忘了将广播取消掉哦~
   @Override
   protected void onDestroy() {
       super.onDestroy();
       unregisterReceiver(myReceiver);
  }
}
  • 我们也可以在AndroidManifest.xml中静态注册:
<receiver android:name=".MyReceiver">
   <intent-filter>
       <action android:name = "android.net.conn.CONNECTIVITY_CHANGE">
   </intent-filter>
</receiver>
  • 动态注册的广播接收器可以自由的控制注册、取消,有很大的灵活性。但是只能在程序启动之后才能收到广播,此外,广播接收器的注销是在onDestroy()方法中的。所以广播接收器的生命周期是和当前活动的生命周期一致。
  • 静态注册的广播不受程序是否启动的约束,当应用程序关闭之后,还是能接收到广播。
  1. 有序广播、无序广播。

  • 按照广播的属性区分,广播分两种:有序广播和无序广播。
  • 无序广播:又叫普通广播,完全异步,不会被某个广播接收者终止,逻辑上可以被任何广播接收者接收到,如果在广播发出之后,所有的广播接收器几乎都会在同时接收到这条广播消息,因此它们之间没有任何先后顺序可言。优点是效率较高。缺点是一个接收者不能将处理结果传递给下一个接收者,并无法停止广播intent的传播。Context.sendBroadcast() 发送的是普通广播,所有订阅者都有机会获得并进行处理。
  • 有序广播:按照被接收者的优先级顺序,在被接收者中依次传播。比如有三个广播接收者A,B,C,优先级顺序是A > B > C。那这个消息先传递给A,再传给B,最后传给C。因此常常需要在AndroidManifest.xml 中进行注册,优先级别声明在intent-filter 元素的android:priority 属性中,数值越大优先级别越高,取值范围:-1000 到1000,优先级别也可以调用IntentFilter 对象的setPriority()进行设置。
  • 有序广播的接收者可以停止广播的传播,广播的传播一旦停止,后面的接收者就无法接收到广播,有序广播的接收者可以将数据传递给下一个接收者,如:A 得到广播后,可以往它的结果对象中存入数据,当广播传给B 时,B 可以从A 的结果对象中得到A 存入的数据。Context.sendOrderedBroadcast() 发送的是有序广播。Bundlebundle = getResultExtras(true))可以获取上一个接收者存入在结果对象中的数据。
  1. Android中常用的系统广播。

  • 系统广播是指系统作为广播的发送方,发出来的广播,我们可以在应用程序中通过监听这些广播来得到各种系统的状态信息。
//关闭或打开飞行模式时的广播
Intent.ACTION_AIRPLANE_MODE_CHANGED;

//充电状态,或者电池的电量发生变化。电池的充电状态、电荷级别改变,不能通过组建声明接收这个广播,只有通过Context.registerReceiver()注册
Intent.ACTION_BATTERY_CHANGED;

//表示电池电量低
Intent.ACTION_BATTERY_LOW;

//表示电池电量充足,即从电池电量低变化到饱满时会发出广播
Intent.ACTION_BATTERY_OKAY;

//在系统启动完成后,这个动作被广播一次(只有一次)。
Intent.ACTION_BOOT_COMPLETED;

//按下照相时的拍照按键(硬件按键)时发出的广播
Intent.ACTION_CAMERA_BUTTON;

//当屏幕超时进行锁屏时,当用户按下电源按钮,长按或短按(不管有没跳出话框),进行锁屏时,android系统都会广播此Action消息
Intent.ACTION_CLOSE_SYSTEM_DIALOGS;

//设备当前设置被改变时发出的广播(包括的改变:界面语言,设备方向,等,请参考Configuration.java)
Intent.ACTION_CONFIGURATION_CHANGED;

//设备日期发生改变时会发出此广播
Intent.ACTION_DATE_CHANGED;
//用户重新开始一个包,包的所有进程将被杀死,所有与其联系的运行时间状态应该被移除,包括包名(重新开始包程序不能接收到这个广播)
Intent.ACTION_PACKAGE_RESTARTED;

//插上外部电源时发出的广播
Intent.ACTION_POWER_CONNECTED;

//已断开外部电源连接时发出的广播
Intent.ACTION_POWER_DISCONNECTED;

Intent.ACTION_PROVIDER_CHANGED;//

//重启设备时的广播
Intent.ACTION_REBOOT;

//屏幕被关闭之后的广播
Intent.ACTION_SCREEN_OFF;

//屏幕被打开之后的广播
Intent.ACTION_SCREEN_ON;

//关闭系统时发出的广播
Intent.ACTION_SHUTDOWN;

//时区发生改变时发出的广播
Intent.ACTION_TIMEZONE_CHANGED;

//时间被设置时发出的广播
Intent.ACTION_TIME_CHANGED;

//广播:当前时间已经变化(正常的时间流逝), 当前时间改变,每分钟都发送,不能通过组件声明来接收
,只有通过Context.registerReceiver()方法来注册
Intent.ACTION_TIME_TICK;

//一个用户ID已经从系统中移除发出的广播
Intent.ACTION_UID_REMOVED;

  1. 广播的原理。

  • 从实现原理看上,Android中的广播运用了观察者模式,基于消息的发布/订阅事件模型。因此,从实现的角度来看,Android中的广播将广播的发送者和接受者进行了极大程度上解耦,使得系统能够容易集成,更易扩展。具体实现流程要点如下:

    1.广播接收者BroadcastReceiver通过Binder机制向AMS(Activity Manager Service)进行注册;

    2.广播发送者通过binder机制向AMS发送广播;

    3.AMS查找符合相应条件(IntentFilter/Permission等)的BroadcastReceiver,将广播发送到BroadcastReceiver(一般情况下是Activity)相应的消息循环队列中;

    4.消息循环执行得到此广播,回调BroadcastReceiver中的onReceive()方法。 对于不同的广播类型,以及不同的BroadcastReceiver注册方式,具体实现上会有不一样。

  • 广播发送者和广播接收者分别属于观察者模式中的消息发布和订阅两端,AMS是处理中心。广播发送者和广播接收者的执行是异步的,发出去的广播不会关心有无接收者接收,也不确定接收者到底是何时才能接收到。
  • 对Binder和AMS知识不熟悉的话可以翻阅前面讲解Binder和AMS的原理章节,学习其中的内容。

总结

有序广播是广播中比较特殊的类型,广播接受者接收广播的时间是不一样的,它们之间是有先后顺序的。系统通过priority的大小来排列优先级别,数值越大级别越高。广播的传播可以依照优先级别逐个传递下去,也可以在某一接收者处中断,那样后面的接收者就无法收到广播。


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

聊聊Flutter中json序列化和反序列化

Flutter中是否有类似于GSON/Jackson/Moshi的json序列化插件?没有,因为这样的插件使用了反射,反射在flutter中是被禁止使用的,这个是因为在Dart中存在Tree Shaking功能,Tree Shaking可以在release b...
继续阅读 »

Flutter中是否有类似于GSON/Jackson/Moshi的json序列化插件?

没有,因为这样的插件使用了反射,反射在flutter中是被禁止使用的,这个是因为在Dart中存在Tree Shaking功能,Tree Shaking可以在release build去掉无用的代码, 而反射会对Tree Shaking功能造成影响.

在Dart中有两种json序列化

第一种:手动序列化

第二种:使用代码生成的方式自动的序列化

对于一些小工程或者demo应用来说手动序列化是一个比较方便的方式,在大型工程中使用代码生成的方式自动的序列化可以减少工作量,而且避免拼写错误的情况.

第一种:手动序列化

Flutter 中提供一个dart:convert库包含一个json encoder和decoder用来序列化和反序列化. 如下我要序列化下面这段json内容.

{
"name": "John Smith",
"email": "john@example.com"
}

这里有2种序列化使用方式.

内联序列化Json

Map<String, dynamic> user = jsonDecode(jsonString);

print('Howdy, ${user['name']}!');
print('We sent the verification link to ${user['email']}.');

这种序列化有2个问题

1,非类型安全

2,容易出现拼写错误

序列化Json到模型类中

为了解决上面提到的2个问题,我们把json转化为模型类,需要创建一个User模型

class User {
final String name;
final String email;

User(this.name, this.email);

User.fromJson(Map<String, dynamic> json)
: name = json['name'],
email = json['email'];

Map<String, dynamic> toJson() => {
'name': name,
'email': email,
};
}

这里我们多了2个方法:

1,fromJson用于使用一个json map构造一个User对象.

2, toJson把该对象转换成一个json map

decode过程放在模型类里

Map<String, dynamic> userMap = jsonDecode(jsonString);
var user = User.fromJson(userMap);

print('Howdy, ${user.name}!');
print('We sent the verification link to ${user.email}.');

encode直接调用dart:convert库中的jsonEncode既可.

String json = jsonEncode(user);

第二种:使用代码生成的方式自动的序列化

如果要使用自动生成序列化代码需要添加依赖

flutter pub add json_annotation dev:build_runner dev:json_serializable

这里我们还是创建一个模型

import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';

@JsonSerializable()
class User {
User(this.name, this.email);

String name;
String email;

factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

Map<String, dynamic> toJson() => _$UserToJson(this);
}

JsonSerializable 代表我需要序列化该类 JsonKey 用这个注解我可以重新对成员进行命名,因为有些情况是json中的key是蛇形的,例如

@JsonKey(name: 'registration_date_millis')
final int registrationDateMillis;

我们在命令行运行

flutter pub run build_runner build --delete-conflicting-outputs

既可以生成对应的对应的序列化代码.

嵌套类的情况 有时候我们会遇到一个类中嵌套一个类的情况, 例如

import 'package:json_annotation/json_annotation.dart';
part 'address.g.dart';

@JsonSerializable()
class Address {
String street;
String city;

Address(this.street, this.city);

factory Address.fromJson(Map<String, dynamic> json) =>
_$AddressFromJson(json);
Map<String, dynamic> toJson() => _$AddressToJson(this);
}

这个Address嵌套在User中.

import 'package:json_annotation/json_annotation.dart';

import 'address.dart';

part 'user.g.dart';

@JsonSerializable()
class User {
User(this.name, this.address);

String name;
Address address;

factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}

我们运行命令后看到这里输出的序列化代码

Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
'name': instance.name,
'address': instance.address,
};

这里address赋值了一个引用类型,显然不是需要的,我们需要的是嵌套类的json。这里我们要在JsonSerialization中加上explicitToJson: true参数

import 'package:json_annotation/json_annotation.dart';

import 'address.dart';

part 'user.g.dart';

@JsonSerializable(explicitToJson: true)
class User {
User(this.name, this.address);

String name;
Address address;

factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}

总结

Flutter的序列化和反序列方式相对于Android原生平台更为繁琐一些,我们可以根据项目的情况选择手动进行序列化,也可以通过注解进行生成.


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

Activity界面路由的一种简单实现

1. 引言平时Android开发中,启动Activity是非常常见的操作,而打开一个新Activity可以直接使用Intent,也可以每个Activity提供一个静态的启动方法。但是有些时候使用这些方法并不那么方便,比如:一个应用内的网页需要打开一个原生Act...
继续阅读 »

1. 引言

平时Android开发中,启动Activity是非常常见的操作,而打开一个新Activity可以直接使用Intent,也可以每个Activity提供一个静态的启动方法。但是有些时候使用这些方法并不那么方便,比如:一个应用内的网页需要打开一个原生Activity页面时。这种情况下,网页的调用代码可能是app.openPage("/testPage")这样,或者是用app.openPage("local://myapp.com/loginPage")这样的方式,我们需要用一种方式把路径和页面关联起来。Android可以允许我们在Manifest文件中配置<data>标签来达到类似效果,也可以使用ARouter框架来实现这样的功能。本文就用200行左右的代码实现一个类似ARouter的简易界面路由。

2. 示例

2.1 初始化

这个操作建议放在Application的onCreate方法中,在第一次调用Router来打开页面之前。

public class AppContext extends Application {
@Override
public void onCreate() {
super.onCreate();
Router.init(this);
}
}

2.2 启动无参数Activity

这是最简单的情况,只需要提供一个路径,适合“关于我们”、“隐私协议”这种简单无参数页面。

Activity配置:

@Router.Path("/testPage")
public class TestActivity extends Activity {
//......
}

启动代码:

Router.from(mActivity).toPath("/testPage").start();
//或
Router.from(mActivity).to("local://my.app/testPage").start();

2.3 启动带参数Activity

这是比较常见的情况,需要在注解中声明需要的参数名称,这些参数都是必要参数,如果启动的时候没有提供对应参数,则发出异常。

Activity配置:

@Router.Path(value = "/testPage",args = {"id", "type"})
public class TestActivity extends Activity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//加载布局...

String id = getIntent().getStringExtra("id"); //获取参数
int type = getIntent().getIntExtra("type", 0);//获取参数
}
}

启动代码:

Router.from(mActivity).toPath("/testPage").with("id", "t_123").with("type", 1).start();

2.4 启动带有静态启动方法的Activity

有一些Activity需要通过它提供的静态方法启动,就可以使用Path中的method属性和Entry注解来声明入口,可以提供参数。在提供了method属性时,需要用Entryargs来声明参数。

Activity配置:

@Router.Path(value = "/testPage", method = "open")
public class TestActivity extends Activity {

@Router.Entry(args = {"id", "type"})
public static void open(Activity activity, Bundle args) {
Intent intent = new Intent(activity, NestWebActivity.class);
intent.putExtras(args);
activity.startActivity(intent);
}

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//加载布局...

String id = getIntent().getStringExtra("id"); //获取参数
int type = getIntent().getIntExtra("type", 0);//获取参数
}
}

启动代码:

Router.from(mActivity).toPath("/testPage").with("id", "t_123").with("type", 1).start();

3. API介绍

3.1 Path注解

这个注解只能用于Activity的子类,表示这个Activity需要页面路由的功能。这个类有三个属性:

  • value:表示这个Activity的相对路径。
  • args:表示这个Activity需要的参数,都是必要参数,如果打开页面时缺少指定参数,就会发出异常。
  • method:如果这个Activity需要静态方法做为入口,就将这个属性指定为方法名,并给对应方法添加Entry注解。(注意:这个属性值不为空时,忽略这个注解中的args属性内容)

3.1 Entry注解

这个注解只能用于Activity的静态方法,表示这个方法作为打开Activity的入口。仅包含一个属性:

  • args:表示这个方法需要的参数。

3.2 Router.init方法

  • 方法签名:public static void init(Context context)
  • 方法说明:这个方法用于初始化页面路由表,必须在第一次用Router打开页面之前完成初始化。建议在Application的onCreate方法中完成初始化。

3.3 Rouater.from方法

  • 方法签名:public static Router from(Activity activity)
  • 方法说明:这个方法用于创建Router实例,传入的参数通常为当前Activity。例如,要从AActivity打开BActivity,那么传入参数为AActivity的实例。

3.4 Rouater.to和Rouater.toPath方法

  • 方法签名:
  1. public RouterBuilder to(String urlString)
  2. public RouterBuilder toPath(String path)
  • 方法说明:这个方法用于指定目标的路径,to需要执行绝对路径,而toPath需要指定相对路径。返回的RouterBuilder用于接收打开页面需要的参数。

3.4 RouterBuilder.with方法

  • 方法签名:
  1. public RouterBuilder with(String key, String value)
  2. public RouterBuilder with(String key, int value)
  • 方法说明:这个方法用于添加参数,对应Bundle的各个put方法。目前只有常用的Stringint两个类型。如有需要可自行在RouterBuilder中添加对应的方法。

3.4 RouterBuilder.start方法

  • 方法签名:public void start()
  • 方法说明:这个方法用于打开页面。如果存在路径错误、参数错误等异常情况,会发出对应运行时异常。

4. 实现

import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;

import androidx.annotation.Keep;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

@Keep
public class Router {

public static final String SCHEME = "local";
public static final String HOST = "my.app";
public static final String URL_PREFIX = SCHEME + "://" + HOST;

private static final Map<String, ActivityStarter> activityPathMap = new ConcurrentHashMap<>();

public static void init(Context context) {
try {
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageInfo(
context.getPackageName(), PackageManager.GET_ACTIVITIES);

for (ActivityInfo activityInfo : packageInfo.activities) {
Class<?> aClass = Class.forName(activityInfo.name);
Path annotation = aClass.getAnnotation(Path.class);
if (annotation != null && !TextUtils.isEmpty(annotation.value())) {
activityPathMap.put(annotation.value(), (Activity activity, Bundle bundle) -> {
if (TextUtils.isEmpty(annotation.method())) {
for (String arg : annotation.args()) {
if (!bundle.containsKey(arg)) {
throw new IllegalArgumentException(String.format("Bundle does not contains argument[%s]", arg));
}
}
Intent intent = new Intent(activity, aClass);
intent.putExtras(bundle);
activity.startActivity(intent);
} else {
try {
Method method = aClass.getMethod(annotation.method(), Activity.class, Bundle.class);
Entry entry = method.getAnnotation(Entry.class);
if (entry != null) {
for (String arg : entry.args()) {
if (!bundle.containsKey(arg)) {
throw new IllegalArgumentException(String.format("Bundle does not contains argument[%s]", arg));
}
}
method.invoke(null, activity, bundle);
} else {
throw new IllegalStateException("can not find a method with [Entry] annotation!");
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public static Router from(Activity activity) {
return new Router(activity);
}

private final Activity activity;

private Router(Activity activity) {
this.activity = activity;
}

public RouterBuilder to(String urlString) {
if (TextUtils.isEmpty(urlString)) {
return new ErrorRouter(new IllegalArgumentException("argument [urlString] must not be null"));
} else {
return to(Uri.parse(urlString));
}
}

public RouterBuilder toPath(String path) {
return to(Uri.parse(URL_PREFIX + path));
}

public RouterBuilder to(Uri uri) {
try {
if (SCHEME.equals(uri.getScheme())) {
if (HOST.equals(uri.getHost())) {
String path = uri.getPath();//note: 二级路径暂不考虑
ActivityStarter starter = activityPathMap.get(path);
if (starter == null) {
throw new IllegalStateException(String.format("path [%s] is not support", path));
} else {
NormalRouter router = new NormalRouter(activity, starter);
for (String key : uri.getQueryParameterNames()) {
if (!TextUtils.isEmpty(key)) {
router.with(key, uri.getQueryParameter(key));
}
}
return router;
}
} else {
throw new IllegalArgumentException(String.format("invalid host : %s", uri.getHost()));
}
} else {
throw new IllegalArgumentException(String.format("invalid scheme : %s", uri.getScheme()));
}
} catch (RuntimeException e) {
return new ErrorRouter(e);
}
}

public static abstract class RouterBuilder {
public abstract RouterBuilder with(String key, String value);

public abstract RouterBuilder with(String key, int value);

public abstract void start();
}


private static class ErrorRouter extends RouterBuilder {
private final RuntimeException exception;

private ErrorRouter(RuntimeException exception) {
this.exception = exception;
}

@Override
public RouterBuilder with(String key, String value) {
return this;
}

@Override
public RouterBuilder with(String key, int value) {
return this;
}

@Override
public void start() {
throw exception;
}
}

private static class NormalRouter extends RouterBuilder {
final Activity activity;
final Bundle bundle = new Bundle();
final ActivityStarter starter;

private NormalRouter(Activity activity, ActivityStarter starter) {
this.activity = Objects.requireNonNull(activity);
this.starter = Objects.requireNonNull(starter);
}

@Override
public RouterBuilder with(String key, String value) {
bundle.putString(key, value);
return this;
}

@Override
public RouterBuilder with(String key, int value) {
bundle.putInt(key, value);
return this;
}

@Override
public void start() {
starter.start(activity, bundle);
}
}

@FunctionalInterface
private interface ActivityStarter {
void start(Activity activity, Bundle bundle);
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Path {
String value();

String method() default "";

String[] args() default {};
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Entry {
String[] args() default {};
}
}

5. 注意

  1. 这个工具的一些功能与ARouter类似,实际项目中建议使用ARouter。如果有特殊需求,例如,页面参数的检查或定制具体打开行为,可以考虑基于这个工具进行修改。
  2. 使用了Pathmethod属性时注意添加对应的混淆设置,避免因混淆而导致找不到对应方法。

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

Android SpannableStringBuilder 持久化探索

问题业务上需要将一些数据缓存到本地,思路是定义个类,赋值后使用 Gson 转换为 Json 数据存到本地。但是由于需要 SpannableStringBuilder 来保存Text的富文本属性,尝试序列化会 Json 后,再反序列化为 SpannableStr...
继续阅读 »

问题

业务上需要将一些数据缓存到本地,思路是定义个类,赋值后使用 Gson 转换为 Json 数据存到本地。但是由于需要 SpannableStringBuilder 来保存Text的富文本属性,尝试序列化会 Json 后,再反序列化为 SpannableStringBuilder 赋值给 TextView 会有一些意外的错误。

Stack trace:  
java.lang.IndexOutOfBoundsException: setSpan (0 ... -1) has end before start
at android.text.SpannableStringInternal.checkRange(SpannableStringInternal.java:485)
at android.text.SpannableStringInternal.setSpan(SpannableStringInternal.java:199)
at android.text.SpannableStringInternal.copySpansFromSpanned(SpannableStringInternal.java:87)
at android.text.SpannableStringInternal.<init>(SpannableStringInternal.java:48)
at android.text.SpannedString.<init>(SpannedString.java:35)
at android.text.SpannedString.<init>(SpannedString.java:44)
at android.text.TextUtils.stringOrSpannedString(TextUtils.java:532)
at android.widget.TextView.setText(TextView.java:6318)
at android.widget.TextView.setText(TextView.java:6227)
at android.widget.TextView.setText(TextView.java:6179)

探索

SpannableString

起初尝试将 SpannableStringBuilder 转为 SpannableString:

val spannableStringBuilder = SpannableStringBuilder("测试文本")
val spannableString = SpannableString.valueOf(spannableStringBuilder)

虽然恢复数据时不会报错,但 SpannableString 的属性全部消失了。

Html

于是开始检索如何持久化 SpannableStringBuilder, 在 Stackoverflow 上有这么一个方案

android: how to persistently store a Spanned?

其中提到需要可以使用 Android 的 Html 类的 Html.toHtml 方法将 SpannableStringBuilder 数据转换为 html 的标签语言,恢复时再使用 Html.fromHtml

val spannableStringBuilder = SpannableStringBuilder("测试文本")

val htmlString = Html.toHtml(spannableStringBuilder)

val spannableStringBuilder = Html.fromHtml(htmlString)

测试了一个,以上方式确实是一个顺利解决的崩溃问题。需要注意的是,Html 的两个方法都是耗时方法,最好异步调用。

自定义 Gson 序列化和反序列化适配器

项目的 Json 解析框架使用的是 Gson,支持自定义序列化和反序列化。于是,编写一个适配器实现 JsonSerializer和 JsonDeserializer

class SpannableStringBuilderTypeAdapter : JsonSerializer<SpannableStringBuilder>,
JsonDeserializer<SpannableStringBuilder> {
override fun serialize(
src: SpannableStringBuilder?,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return src?.let {
JsonPrimitive(Html.toHtml(src))
} ?: JsonPrimitive("")
}

override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): SpannableStringBuilder {
return json?.let {
val fromHtml = Html.fromHtml(json.asString).trim()
SpannableStringBuilder(fromHtml)
} ?: SpannableStringBuilder("")
}
}

//使用
Gson gson = new GsonBuilder()
.setDateFormat("yyyy-MM-dd hh:mm:ss")
.registerTypeAdapter(SpannableStringBuilder.class,
new SpannableStringBuilderTypeAdapter())
.create();

以上代码可以很好的工作,如果细心的话,可以注意到反序列化时用到 trim(),因为反序列化为 SpannableStringBuilder 后字符串末尾会多处两个换行符,这个 Stackoverflow 有提到HTML.fromHtml adds space at end of text?

总结,这次探索让我对持久化多了一些思路,对于一些无法修改源码的类可以自定义适配器来序列化。


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

你真的理解 JavaScript 中的 “this” 了

前言JavaScript 中的 this 是一个非常重要的概念,也是一个令新手开发者甚至有些不深入理解的多年经验开发者都会感到困惑的概念。如果你希望自己能够使用 this 编写更好的代码或者更好理解他人的代码,...
继续阅读 »

前言

JavaScript 中的 this 是一个非常重要的概念,也是一个令新手开发者甚至有些不深入理解的多年经验开发者都会感到困惑的概念。

如果你希望自己能够使用 this 编写更好的代码或者更好理解他人的代码,那就跟着我一起理解一下this吧。

要理解this的原因

我们先搞清楚为什么要理解 this 的,再去学习它。

  • 学习 this 可以帮助我们更好地理解代码的上下文和执行环境,从而编写更好的代码。

例1

function speakfullName(){
console.log(this.firstname + this.lastname)
}

var firstname = '南'
var lastname = '墨'

const gril = {
firstname: '黎',
lastname: '苏苏',
speakfullName,
}

const boy = {
firstname: '澹台',
lastname: '烬',
speakfullName,
}

gril.speakfullName(); // 输出: 黎苏苏
boy.speakfullName(); // 输出: 澹台烬
speakfullName(); // 输出: 南墨

在这个例子中,如果你没理解 this 的用法,那么阅读这段代码就会觉得奇怪,为什么同一个函数会输出不同的结果。之所以奇怪,是因为你不知道他的上下文到底是什么。

  • 学习 this 可以帮助我们编写更具可重用性和可维护性的代码

在例1中可以在不同的上下文中使用 this,不用针对不同版本写不同的函数。当然不使用 this,也是可以的。

例2

function speakfullName(person){
console.log(person.firstname + person.lastname)
}

const gril = {
firstname: '黎',
lastname: '苏苏',
}

const boy = {
firstname: '澹台',
lastname: '烬',
}

speakfullName(gril); // 黎苏苏
speakfullName(boy); // 澹台烬

虽然目前这段代码没有问题,如果后续使用的模式越来越复杂,那么这样的显示传递会让代码变得难以维护和重用,而this的隐式传递会显得更加优雅一些。因此,学习this可以帮助我们编写更具有可重用性和可维护性的代码。

接下来我们开始正式全面解析 this

解析 this

我相信大家多多少少都理解一些 this 的用法,但可能不够全面,所以接下来我们就全面性的理解 this

很多人可能认为this 写在哪里就是指向所在位置本身,如下代码:

var a = 2
function foo (){
var a = 1
console.log(this.a)
}
foo();

有些人认为会输出1,实际是输出2,这就是不够理解 this 所产生的的误会。

this的机制到底是什么样的呢?

其实this不是写在哪里就被绑定在哪里,而是代码运行的时候才被绑定的。也就是说如果一个函数中存在this,那么this到底被绑定成什么取决于这个函数以什么样的方式被调用。

既然已经提出了这样一个机制,那我们该如何根据这个机制,去理解和判断this被绑定成什么呢?

下面我们继续介绍这个机制的基本原理。

调用位置

上面说过,函数的调用位置会影响this被绑定成什么了,所以我们需要知道函数在哪里被调用了。

我们回头去看一下 例1,来理解什么是调用位置:

// ...
gril.speakfullName(); // 输出: 黎苏苏
boy.speakfullName(); // 输出: 澹台烬
speakfullName(); // 输出: 南墨

同一个函数 speakfullName, 在不同的调用位置,它的输出就不一样。

在 gril 对象中调用时,输出了黎苏苏,在 boy 对象中调用时,输出了澹台烬,在全局调用时输出了南墨

当然例子中的调用位置是非常容易看出来的。所以我们接下来继续讲解在套多层的情况下如何找到调用位置。

我们要找到调用位置就要分析调用栈。

看下简单例子:

function baz() {
// 当前调用栈:baz
console.log('baz')
bar(); // bar 调用的位置
}

function bar() {
// 当前调用栈:baz-bar
console.log('bar')
foo(); // foo 调用的位置
}

function foo() {
// 当前调用栈:baz-bar-foo
console.log('foo')
}
baz() // baz的调用位置

其实调用栈就是调用位置的链条,就像上面代码例子中所分析的一样。不过在一些复杂点的代码中,这样去分析很容易出错。所以我们可以用现代浏览器的开发者工具帮助我们分析。

比如上例中,我们想找到 foo 的调用位置,在 foo 中第一行输入debugger

// ...

function foo() {
debugger
// ...
}
// ...

或者打开浏览器的开发者工具到源代码一栏找到,foo的代码的第一行打一个断点也行,如下图:

1682495831231.png

接着在源代码一栏,找到调用堆栈的foo的下一个就是bar,bar就是foo的调用位置。

绑定规则

接下来看看调用位置如何决定this被绑定成什么,并且进行总结。

默认规则

第一种情况是函数最经常被调用的方式,函数被单独调用。看以下例子:

var name = '澹台烬'
function fn(){
console.log('我是' + this.name)
}
fn() // 我是澹台烬

运行fn后,最终输出了 我是澹台烬。众所周知,上例中的 name 是全局的变量,这样说明了fn中的 this.name 被绑定成了全局变量name。因此,this指向了全局对象。

因为在上例的代码片段中,foo的调用位置是在全局中调用的,没有其他任何修饰, 所以我们称之为默认规则

使用了严格模式的话,上例代码会出现什么样的情况呢?

var name = '澹台烬'
function sayName(){
"use strict"
console.log(this) // (1)
console.log('我是' + this.name) // (2)
}
fn()
// undefined
// TypeError: cannot read properties of undefined (reading 'name') as sayName

可以看出来(1)也就是this,输出了undefiend 所以(2)就直接报错了。

因此我们可以得出默认规则的结论:在非严格模式下,this默认绑定成全局对象,在严格模式下,this 被绑成 undefined

隐式绑定

这条规则需要我们去判断函数的调用是否有上下文对象,也就是说函数调用的时候前面是否跟了一个对象,举个例子看下。

function sayName() {
console.log(`我是` + this.name)
}
var person = {
name: '澹台烬',
sayName,
}
person.sayName(); // 我是澹台烬

在这个例子中, sayName 前面有一个 person,也就是说 sayName 函数有一个上下文对象person, 这样调用 sayName 的时候,函数中 this 被绑定成了person,因此 this.name 和 person.name 是一样的。

在观察隐式绑定的时候,有两种值得我们注意的情况:

  • 如果说一个函数是通过对象的方式调用时,只有最后一个对象起到上下文的作用。 例3:
function sayName() {
console.log(`我是` + this.name)
}

var child = {
name: '澹台烬',
sayName,
}

var father = {
name: '澹台无极',
child,
}

father.child.sayName(); // 我是澹台烬

这个例子中,是通过一个对象链调了sayName,没有输出我是澹台无极,而是我是澹台烬。因此 this 指向了child 对象,说明this 最终绑定为对象链的最后一个对象。

  • 隐式丢失的情况就是被隐式绑定的函数丢失绑定的上下文,转而变成了应用默认绑定。
function sayName() {
console.log(`我是` + this.name)
}
var person = {
name: '澹台烬',
sayName,
}

var personSayName = person.sayName;

var name = '南墨'

pesonSayName() // '我是南墨'

虽然 personSayName 看起来是由 person.sayName 赋值的来,拥有上下文对象person,但实际上 personSayName 被赋予的是 sayName 函数本身,因此此时的 personSayName其实是一个不带修饰的函数, 所以说会被认为是默认绑定。

显示绑定

隐式绑定是通过一个看起来不经意间的上下文的形式去绑定的。

那也当然也有通过一个明显的上下文强制绑定的,这就是显示绑定

在 javaScript 中,要是使用显示绑定,就要通过 call 和 apply方法去强制绑定上下文了

这两个方法的使用方法是什么样的呢? call 和 apply的第一个参数的是一样的,就是传入一个我们想要给函数绑定的上下文。

来看一下下面的例子

function sayName () {
console.log(this.name)
}

var person = {
name: 南墨
}

sayName.call(person) // 南墨

看到没? 我们通过call的方式,将函数的上下文绑定为了 person,因此打印出了 南墨

使用了 call 绑定也会有绑定丢失的情况,所以我们需要一种保证在我意料之内的办法, 可以改造显示绑定,思考如下代码:


function sayName() {
console.log(this.name)
}

var person = {
name: 南墨
}

function sayNanMo() {
sayName.call(person);
}

sayNanMo() // 南墨
setTimeout(sayNanMo, 10000) // 南墨
sayNanMo.call(window) // 南墨

这样一来,不管怎么操作,都会输出南墨,是我想要的结果

我们将 sayName.call(person) 放在的 sayNanMo 中,因此sayName 只能被绑定为我们想要绑定的 person

我们可以将其写成可复用的函数

function bind(fn, obj) {
return function() {
fn.apply(obj, arguments)
}
}

ES5 就提供了内置的方法 Function.prototype.bind,用法如下:

function sayName() {
console.log(this.name)
}

var person = {
name: 南墨
}

sayName.bind(person)

new绑定

new 绑定也可以影响函数调用时的 this 绑定行为,我们称之为new 绑定。

思考如下代码:

function person(name) {
this.name = name;
this.sayName = function() {
console.log(this.name)
}
}

var personOne = new person('南墨')

personOne.sayName() // 南墨

personOne.sayName 能够输出南墨,是因为使用 new 调用 person 时,会创建一个新的对象并将它绑定到 person中的 this 上,所以personOne.sayName中的 this.name 等于外面的this.name

规则之外

值得一提的是,ES6的箭头函数,它的this无法使用以上四个规则,而是而是根据外层(函数或者全局)作用域来决定this。

  function sayName () {
return () => {
console.log(this.name)
}
}

var person1 = {
name: 南墨
}

var person2 = {
name: '澹台烬'
}
sayName.call(person1)
sayName.call(person1).call(person2) // 澹台烬,如果是普通函数会输南墨
}

总结

要想判断一个运行的函数中this的绑定,首先要找到函数调用位置,因为它会影响this的绑定。然后使用四个绑定规则:new绑定、显示绑定、隐式绑定、默认规则 来判断this的绑定。


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

安卓拍照、裁切、选取图片实践

安卓拍照、裁切、选取图片实践前言最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。拍照本来拍照是没什么难度的,不就...
继续阅读 »

安卓拍照、裁切、选取图片实践

前言

最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。

拍照

本来拍照是没什么难度的,不就是调用intent去系统相机拍照么,但是由于文件权限问题,Uri这东西就能把人很头疼。下面是代码(onActivityResult见后文):

    private fun openCamera() {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
// 应用外部私有目录:files-Pictures
val picFile = createFile("Camera")
val photoUri = getUriForFile(picFile)
// 保存路径,不要uri,读取bitmap时麻烦
picturePath = picFile.absolutePath
// 给目标应用一个临时授权
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
//android11以后强制分区存储,外部资源无法访问,所以添加一个输出保存位置,然后取值操作
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
startActivityForResult(intent, REQUEST_CAMERA_CODE)
}

private fun createFile(type: String): File {
// 在相册创建一个临时文件
val picFile = File(requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES),
"${type}_${System.currentTimeMillis()}.jpg")
try {
if (picFile.exists()) {
picFile.delete()
}
picFile.createNewFile()
} catch (e: IOException) {
e.printStackTrace()
}

// 临时文件,后面会加long型随机数
// return File.createTempFile(
// type,
// ".jpg",
// requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
// )

return picFile
}

private fun getUriForFile(file: File): Uri {
// 转换为uri
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
//适配Android 7.0文件权限,通过FileProvider创建一个content类型的Uri
FileProvider.getUriForFile(
requireActivity(),
"com.xxx.xxx.fileProvider", file
)
} else {
Uri.fromFile(file)
}
}

简单说明

这里的file是使用getExternalFilesDir和Environment.DIRECTORY_PICTURES生成的,它的文件保存在应用外部私有目录:files-Pictures里面。这里要注意不能存放在内部的私有目录里面,不然是无法访问的,外部私有目录虽然也是私有的,但是外面是可以访问的,这里拿官网上的说明:

在搭载 Android 9(API 级别 28)或更低版本的设备上,只要您的应用具有适当的存储权限,就可以访问属于其他应用的应用专用文件。为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被授予了对外部存储空间的分区访问权限(即分区存储)。启用分区存储后,应用将无法访问属于其他应用的应用专属目录。

Uri的获取

再一个比较麻烦的就是Uri的获取了,网上有一大堆资料,不过我这也贴一下,网上的可能有问题。

manifest.xml

        <provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.xxx.xxx.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"
/>
</provider>

res -> xml -> file_paths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!--1、对应内部内存卡根目录:Context.getFileDir()-->
<files-path
name="int_root"
path="/" />
<!--2、对应应用默认缓存根目录:Context.getCacheDir()-->
<cache-path
name="app_cache"
path="/" />
<!--3、对应外部内存卡根目录:Environment.getExternalStorageDirectory()-->
<external-path
name="ext_root"
path="/" />
<!--4、对应外部内存卡根目录下的APP公共目录:Context.getExternalFileDir(String)-->
<external-files-path
name="ext_pub"
path="/" />
<!--5、对应外部内存卡根目录下的APP缓存目录:Context.getExternalCacheDir()-->
<external-cache-path
name="ext_cache"
path="/" />
</paths>

ps. 注意authorities这个最好填自己的包名,不然有两个应用用了同样的authorities,后面的应用会安装不上。

打开相册

这里打开相册用的是SAF框架,使用intent去选取(onActivityResult见后文)。

    private fun openAlbum() {
val intent = Intent()
intent.type = "image/*"
intent.action = "android.intent.action.GET_CONTENT"
intent.addCategory("android.intent.category.OPENABLE")
startActivityForResult(intent, REQUEST_ALBUM_CODE)
}

裁切

裁切这里比较麻烦,参数比较多,而且Uri那里有坑,不能使用provider,再一个就是图片传递那因为安卓版本变更,不会传略缩图了,很坑。

    private fun cropImage(path: String) {
cropImage(getUriForFile(File(path)))
}

private fun cropImage(uri: Uri) {
val intent = Intent("com.android.camera.action.CROP")
// Android 7.0需要临时添加读取Url的权限
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
intent.setDataAndType(uri, "image/*")
// 使图片处于可裁剪状态
intent.putExtra("crop", "true")
// 裁剪框的比例(根据需要显示的图片比例进行设置)
// if (Build.MANUFACTURER.contains("HUAWEI")) {
// //硬件厂商为华为的,默认是圆形裁剪框,这里让它无法成圆形
// intent.putExtra("aspectX", 9999)
// intent.putExtra("aspectY", 9998)
// } else {
// //其他手机一般默认为方形
// intent.putExtra("aspectX", 1)
// intent.putExtra("aspectY", 1)
// }

// 设置裁剪区域的形状,默认为矩形,也可设置为圆形,可能无效
// intent.putExtra("circleCrop", true);
// 让裁剪框支持缩放
intent.putExtra("scale", true)
// 属性控制裁剪完毕,保存的图片的大小格式。太大会OOM(return-data)
// intent.putExtra("outputX", 400)
// intent.putExtra("outputY", 400)

// 生成临时文件
val cropFile = createFile("Crop")
// 裁切图片时不能使用provider的uri,否则无法保存
// val cropUri = getUriForFile(cropFile)
val cropUri = Uri.fromFile(cropFile)
intent.putExtra(MediaStore.EXTRA_OUTPUT, cropUri)
// 记录临时位置
cropPicPath = cropFile.absolutePath

// 设置图片的输出格式
intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString())

// return-data=true传递的为缩略图,小米手机默认传递大图, Android 11以上设置为true会闪退
intent.putExtra("return-data", false)

startActivityForResult(intent, REQUEST_CROP_CODE)
}

回调处理

下面是对上面三个操作的回调处理,一开始我觉得uri没什么用,还制造麻烦,后面发现可以通过流打开uri,再去获取bitmap,好像又不是那么麻烦了。

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK) {
when(requestCode) {
REQUEST_CAMERA_CODE -> {
// 通知系统文件更新
// requireContext().sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
// Uri.fromFile(File(picturePath))))
if (!enableCrop) {
val bitmap = getBitmap(picturePath)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}else {
cropImage(picturePath)
}
}
REQUEST_ALBUM_CODE -> {
data?.data?.let { uri ->
if (!enableCrop) {
val bitmap = getBitmap("", uri)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}else {
cropImage(uri)
}
}
}
REQUEST_CROP_CODE -> {
val bitmap = getBitmap(cropPicPath)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}
}
}
}

private fun getBitmap(path: String, uri: Uri? = null): Bitmap? {
var bitmap: Bitmap?
val options = BitmapFactory.Options()
// 先不读取,仅获取信息
options.inJustDecodeBounds = true
if (uri == null) {
BitmapFactory.decodeFile(path, options)
}else {
val input = requireContext().contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(input, null, options)
}

// 预获取信息,大图压缩后加载
val width = options.outWidth
val height = options.outHeight
Log.d("TAG", "before compress: width = " +
options.outWidth + ", height = " + options.outHeight)

// 尺寸压缩
var size = 1
while (width / size >= MAX_WIDTH || height / size >= MAX_HEIGHT) {
size *= 2
}
options.inSampleSize = size
options.inJustDecodeBounds = false
bitmap = if (uri == null) {
BitmapFactory.decodeFile(path, options)
}else {
val input = requireContext().contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(input, null, options)
}
Log.d("TAG", "after compress: width = " +
options.outWidth + ", height = " + options.outHeight)

// 质量压缩
val baos = ByteArrayOutputStream()
bitmap!!.compress(Bitmap.CompressFormat.JPEG, 80, baos)
val bais = ByteArrayInputStream(baos.toByteArray())
options.inSampleSize = 1
bitmap = BitmapFactory.decodeStream(bais, null, options)

return bitmap
}

这里还做了一个图片的质量压缩和采样压缩,需要注意的是采样压缩的采样率只能是2的倍数,如果需要按任意比例采样,需要用到Matrix,不是很难,读者可以研究下。

权限问题

如果你发现你没有申请权限,那你的去申请一下相机权限;如果你发现你还申请了储存权限,那你可以试一下去掉储存权限,实际还是可以使用的,因为这里并没有用到外部储存,都是应用的私有储存内,具体关于储存的适配,可以看我转载的这几篇文章,我觉得写的非常好:

Android 存储基础

Android 10、11 存储完全适配(上)

Android 10、11 存储完全适配(下)

结语

以上代码都经过我这里实践了,确认了可用,可能写法不是最优,可以避免使用绝对路径,只使用Uri。至于请求码、布局什么的,读者自己改一下加一个就行,核心部分已经在这了。如果需要完整代码,可以看下篇文章末尾!

Android 不申请权限储存、删除相册图片


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

巧妙利用枚举来替代if语句

前言亲爱的友友们,我们今天来看一下如何巧妙利用枚举来替代if语句能实现功能的代码千篇一律,但优雅的代码万里挑一业务背景在工作中遇到一个需求,经过简化后就是:需要根据不同的code值,处理逻辑,然后返回对应的对象。我就简答举个栗子哈💬根据 不同的code,返回不...
继续阅读 »

前言

亲爱的友友们,我们今天来看一下如何巧妙利用枚举来替代if语句

能实现功能的代码千篇一律,但优雅的代码万里挑一

业务背景

在工作中遇到一个需求,经过简化后就是:需要根据不同的code值,处理逻辑,然后返回对应的对象。

我就简答举个栗子哈💬

根据 不同的code,返回不同的对象 传1 返回 一个对象,包含属性:name、age ; 传2,返回一个对象,包含属性name ; 传3,返回一个对象,包含属性sex .... 字段值默认为 test

思路

摇头版

public class TestEnum {
public static void main(String[] args) {
Integer code = 1;//这里为了简单,直接这么写的,实际情况一般是根据参数获取
JSONObject jsonObject = new JSONObject();
if(Objects.equals(0,code)){
jsonObject.fluentPut("name", "test").fluentPut("age", "test");
System.out.println("jsonObject = " + jsonObject);
}
if(Objects.equals(1, code)){
jsonObject.fluentPut("name", "test");
System.out.println("jsonObject = " + jsonObject);
}
if(Objects.equals(2,code)){
jsonObject.fluentPut("sex", "test");
System.out.println("jsonObject = " + jsonObject);
}
}
}

上面的代码在功能上是没有问题滴,但是要扩展的话就💘,比如 当code为4时,ba la ba la,我们只有再去写一遍if语句,随着code的增加,if语句也会随之增加,后面的人接手你的代码时 💔

优雅版

我们首先定义一个枚举类,维护对应Code需要返回的字段

@Getter
@AllArgsConstructor
public enum DataEnum {
/**
* 枚举类
*/
CODE1(1,new ArrayList<>(Arrays.asList("name","age"))),
CODE2(2,new ArrayList<>(Arrays.asList("name"))),
CODE3(3,new ArrayList<>(Arrays.asList("sex")))
;
private Integer code;
private List<String> fields;
//传入code 即可获取对应的 fields
public static List<String> getFieldsByCode(Integer code){
DataEnum[] values = DataEnum.values();
for (DataEnum value : values) {
if(Objects.equals(code, value.getCode())) {
return value.getFields();
}
}
return null;
}
}

客户端代码

public class TestEnum {
public static void main(String[] args) {
//优雅版
JSONObject jsonObject = new JSONObject();
//传入code,获取fields
List<String> fieldsByCode = DataEnum.getFieldsByCode(1);
assert fieldsByCode != null;
fieldsByCode.forEach(x->{
jsonObject.put(x,"test");
});
System.out.println(jsonObject);
}
}

实现的功能和上面的一样,但是我们发现TestEnum代码里面一条if语句都没有也,这时,即使code增加了,我们也只需要维护枚举类里面的代码,压根不用在TestEnum里面添加if语句,是不是很优雅😎

小总结

【Tips】我们在写代码时,一定要考虑代码的通用性

上面的案例中,第一个版本仅仅只是能实现功能,但是当发生变化时难以维护,代码里面有大量的if语句,看着也比较臃肿,后面的人来维护时,也只能不断的添加if语句,而第二个版本巧用枚举类的方法,用一个通用的获取fields的方法,我们的TestEnum代码就变得相当优雅了😎

结语

谢谢你的阅读,由于作者水平有限,难免有不足之处,若读者发现问题,还请批评,在留言区留言或者私信告知,我一定会尽快修改的。若各位大佬有什么好的解法,或者有意义的解法都可以在评论区展示额,万分谢谢。 写作不易,望各位老板点点赞,加个关注!😘😘😘


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

@Contended注解有什么用?

@Contended是Java 8中引入的一个注解,用于减少多线程环境下的“伪共享”现象,以提高程序的性能。要理解@Contended的作用,首先要了解一下什么是伪共享(False Sharing)。1. 什么是伪共享?伪共享(False Sharing)是多...
继续阅读 »

@Contended是Java 8中引入的一个注解,用于减少多线程环境下的“伪共享”现象,以提高程序的性能。

要理解@Contended的作用,首先要了解一下什么是伪共享(False Sharing)。

1. 什么是伪共享?

伪共享(False Sharing)是多线程环境中的一种现象,涉及到CPU的缓存机制和缓存行(Cache Line)。

现代CPU中,为了提高访问效率,通常会在CPU内部设计一种快速存储区域,称为缓存(Cache)。CPU在读写主内存中的数据时,会首先查看该数据是否已经在缓存中。如果在,就直接从缓存读取,避免了访问主内存的耗时;如果不在,则从主内存读取数据并放入缓存,以便下次访问。

缓存不是直接对单个字节进行操作的,而是以块(通常称为“缓存行”)为单位操作的。一个缓存行通常包含64字节的数据。

在多线程环境下,如果两个或更多的线程在同一时刻分别修改存储在同一缓存行的不同数据,那么CPU为了保证数据一致性,会使得其他线程必须等待一个线程修改完数据并写回主内存后,才能读取或者修改这个缓存行的数据。尽管这些线程可能实际上操作的是不同的变量,但由于它们位于同一缓存行,因此它们之间就会存在不必要的数据竞争,这就是伪共享。

伪共享会降低并发程序的性能,因为它会增加缓存的同步操作和主内存的访问。解决伪共享的一种方式是尽量让经常被并发访问的变量分布在不同的缓存行中,例如,可以通过增加无关的填充数据,或者利用诸如Java的@Contended注解等工具。

2. @Contended注解是什么?

@Contended 是Java 8引入的一个注解,设计用于减少多线程环境下的伪共享(False Sharing)问题以提高程序性能。

伪共享是现代多核处理器中一个重要的性能瓶颈,它发生在多个处理器修改同一缓存行(Cache Line)中的不同数据时。缓存行是内存的基本单位,一般为64字节。当一个处理器读取主内存中的数据时,它会将整个缓存行(包含需要的数据)加载到本地缓存(L1,L2或L3缓存)中。如果另一个处理器修改了同一缓存行中的其他数据,那么原先加载到缓存中的数据就会变得无效,需要重新从主内存中加载。这会增加内存访问的延迟,降低程序性能。

@Contended注解可以标注在字段或者类上。它能使得被标注的字段在内存布局上尽可能地远离其他字段,使得被标注的字段或者类中的字段分布在不同的缓存行上,从而减少伪共享的发生。

例如,考虑以下代码:

public class Foo {
@Contended
long x;
long y;
}

在这里,x@Contended注解标记,所以xy可能会被分布在不同的缓存行上,这样如果多个线程并发访问xy,就不会引发伪共享。

需要注意的是,@Contended是JDK的内部API,它在Java 8中引入,但在默认情况下是不开放的,要使用需要添加JVM参数-XX:-RestrictContended,并且在编译时需要使用--add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED。此外,过度使用@Contended可能会浪费内存,因为它会导致大量的内存空间被用作填充以保持字段间的距离。所以在使用时需要谨慎权衡内存和性能的考虑。

3. 简单案例

在Java 8及以上版本中,@Contended注解是属于jdk的内部API,因此在正常情况下使用时需要打开开关-XX:-RestrictContended才能正常使用。同时需要注意的是,@Contended在JDK 9以后的版本中可能无法正常工作,因为JDK 9开始禁止使用Sun的内部API。

以下是一个@Contended注解的简单使用案例:

import jdk.internal.vm.annotation.Contended;

public class ContendedExample {

@Contended
volatile long value1 = 0L;

@Contended
volatile long value2 = 0L;

public void increaseValue1() {
value1++;
}

public void increaseValue2() {
value2++;
}

public static void main(String[] args) {
ContendedExample example = new ContendedExample();

Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000000; i++) {
example.increaseValue1();
}
});

Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000000; i++) {
example.increaseValue2();
}
});

thread1.start();
thread2.start();

try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("value1: " + example.value1);
System.out.println("value2: " + example.value2);
}
}

这个例子中定义了两个使用了@Contended注解的volatile长整型字段value1value2。两个线程分别对这两个字段进行增加操作。因为这两个字段使用了@Contended注解,所以他们会被分布在不同的缓存行中,减少了因伪共享带来的性能问题。但由于伪共享的影响在实际运行中并不容易直接观察,所以这个例子主要展示了@Contended注解的使用方式,而不是实际效果。


作者:一只爱撸猫的程序猿
链接:https://juejin.cn/post/7255604228956110904
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

多页应用和单页应用的对比

在前端开发中,多页应用(MPA,Multi-Page Application)和单页应用(SPA,Single-Page Application)是两种不同的应用构建方式,它们的主要区别在于如何处理页面切换。多页应用(MPA)的主要特点是每个页面都是独立的 H...
继续阅读 »

在前端开发中,多页应用(MPA,Multi-Page Application)和单页应用(SPA,Single-Page Application)是两种不同的应用构建方式,它们的主要区别在于如何处理页面切换。

多页应用(MPA)的主要特点是每个页面都是独立的 HTML 文件,页面的切换通过重新加载整个页面来实现。在 MPA 中,每个页面都有自己的控制器和视图,因此需要每个页面单独加载所需的资源,如样式表、脚本和图片等。这种架构通常适用于传统的 Web 应用程序,具有稳定的 URL 和 SEO 优化的能力。

单页应用(SPA)的主要特点是在一个 HTML 文件中加载应用程序的所有资源,如 JavaScript、CSS 和 HTML。在 SPA 中,所有页面切换都是在客户端完成的,不需要重新加载整个页面,而是通过 AJAX(FETCH) 和 JavaScript 动态地更新 DOM。由于只需要加载一次资源,因此可以提高应用程序的性能和响应速度。但是,这种架构可能不太适合需要 SEO 优化的网站,因为搜索引擎爬虫通常不能很好地处理 SPA。

多页应用(MPA):


  1. 区别:多页应用是由多个页面组成的应用程序,每个页面都有自己的 URL。在浏览器中导航时,页面会重新加载。每个页面的内容和数据都是从服务器请求的。

  2. 优点:

  • 对搜索引擎优化(SEO)更友好,因为每个页面都有一个独立的 URL。
  • 适用于传统的网站架构,易于实现。
  • 页面加载速度较快,因为用户只加载当前访问页面的资源。
  1. 缺点:
  • 页面间的跳转和响应速度较慢,因为每次跳转都需要重新加载页面。
  • 前后端耦合度较高,需要处理多个页面的模板和资源。
  • 前端代码复用率较低,因为每个页面可能都需要编写独立的代码。

单页应用(SPA):


  1. 区别:单页应用是只有一个页面的应用程序,其中所有内容和数据都通过 AJAX 请求从服务器获取。页面不会重新加载,而是通过 JavaScript 动态更新内容。

  2. 优点:

  • 用户体验更好,页面间跳转和响应速度快,类似于原生应用。
  • 前后端分离,有利于团队协作和开发效率的提升。
  • 前端代码复用率高,可以使用组件和模块化的方式构建应用。
  1. 缺点:
  • 对搜索引擎优化(SEO)不友好,因为只有一个 URL,部分搜索引擎爬虫可能无法解析 JavaScript 生成的内容。
  • 首次加载时间较长,因为需要加载整个应用的资源和代码。
  • 对于大型应用,维护和管理可能会变得复杂。

多页应用程序示例:电子商务网站

电子商务网站是一个典型的多页应用程序,它通常有多个页面,如首页、产品列表、产品详情、购物车、结账和付款等。

每个页面都有自己的 URL,因此用户可以通过书签或链接直接访问页面。每个页面都是独立的 HTML 文件,包含自己的控制器和视图,因此可以单独加载和缓存。

但是,每次用户访问新页面时,需要重新加载整个页面,这可能会影响页面切换的速度。

单页应用程序示例:后台管理系统

现在多数后台管理系统是用Vue、React等框架开发的单页应用。

它的所有功能都包含在一个 HTML 文件中,包括 JavaScript、CSS 和 HTML。页面切换是通过 AJAX 和 JavaScript 动态地更新 DOM 实现的,不需要重新加载整个页面。

由于只需要加载一次资源,因此页面切换速度非常快。但是,由于所有内容都在一个页面中,因此首次加载可能需要一些时间。此外,由于没有单独的页面,因此不太适合需要 SEO 优化的网站。


根据项目需求和目标,可以选择使用多页应用还是单页应用。一般来说,对于需要优化搜索引擎排名的网站,可以选择多页应用;对于需要提供流畅的用户体验和快速响应的应用,可以选择单页应用。


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

探索Flutter包体优化

前言在产品的运营期间,我们发现Android用户对App容量大小的敏感度明显高于iOS用户。所以,为了提升产品在市场中竞争力,进一步提升产品的下载量,我们需要对基于Flutter开发的Android客户端进行包体优化。经过调研,我们发现Flutter经过多年的...
继续阅读 »

前言

在产品的运营期间,我们发现Android用户对App容量大小的敏感度明显高于iOS用户。

所以,为了提升产品在市场中竞争力,进一步提升产品的下载量,我们需要对基于Flutter开发的Android客户端进行包体优化。

经过调研,我们发现Flutter经过多年的发展后,DevTools已经完善,用较低的低成本即可满足需求。

下面我们会从分析开始,了解不同构成部分的容量分布情况。再对构成部分的特性来决定最终如何进行包体优化。

生成分析报告

首先,在build产物时,我们可以加上 --analyze-size 标识来生成分析报告。apk 和 appbundle 还可以指定产物的平台架构,如:--target-platform android-arm,分析实际机型的情况。

# 变异产物
# Specify one of android-arm, android-arm64, or android-x64 in the --target-platform flag.
flutter build apk --analyze-size --target-platform android-arm64
flutter build appbundle --analyze-size --target-platform android-arm64

apk 分析日志

app-release.apk (total compressed)                                         33 MB
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
assets/
dexopt 1 KB
flutter_assets 5 MB
classes.dex 7 MB
lib/
arm64-v8a 18 MB
Dart AOT symbols accounted decompressed size 8 MB
package:flutter 3 MB
package:image 799 KB
dart:core 325 KB
package:xxx_xxx_flutter 293 KB
dart:ui 279 KB
dart:typed_data 226 KB
dart:io 212 KB
dart:collection 177 KB
package:vector_graphics_compiler 170 KB
dart:async 154 KB
package:flutter_localizations 129 KB
package:flutter_spinkit 99 KB
package:extended_image 93 KB
dart:ffi 85 KB
package:petitparser 73 KB
dart:convert 62 KB
package:archive 62 KB
package:dio 57 KB
package:vector_math 54 KB
package:riverpod 49 KB
AndroidManifest.xml 3 KB
res/
-j.png 4 KB
1k.png 33 KB
1r.png 91 KB
2M.png 3 KB
33.9.png 2 KB
3m.png 1 KB
4C.png 1 KB
4F.png 10 KB
AQ.png 33 KB
Bj.png 29 KB
CG.png 23 KB
Ch.png 98 KB
D2.png 14 KB
E7.png 98 KB
EA.png 98 KB
ER.9.png 2 KB
FM.9.png 1 KB
Fo.png 29 KB
G1.png 2 KB
J6.9.png 2 KB
JO.png 29 KB
Mr.9.png 1 KB
NF.png 6 KB
Ni.png 33 KB
ON.png 91 KB
Pi.9.png 3 KB
Q11.9.png 3 KB
RX.png 16 KB
S9.png 2 KB
SD.png 5 KB
Tu.png 10 KB
Vq.png 1 KB
Vw.png 71 KB
WR.png 30 KB
XU.png 1 KB
Yj.png 4 KB
_C.png 3 KB
_f.png 2 KB
bI.png 3 KB
color-v23 2 KB
color 16 KB
dn.png 2 KB
e1.xml 1 KB
eB.9.png 2 KB
fM.png 5 KB
fR.png 67 KB
gV.9.png 1 KB
jy.png 8 KB
nN.png 4 KB
qt.png 2 KB
tj.9.png 2 KB
u3.png 3 KB
wi.9.png 2 KB
wi1.9.png 1 KB
wn.png 10 KB
x4.png 3 KB
yT.png 1 KB
zT.png 91 KB
resources.arsc 926 KB
client_analytics.proto 1 KB
dc/
a.gz 37 KB
kotlin/
collections 1 KB
kotlin.kotlin_builtins 5 KB
ranges 1 KB
reflect 1 KB
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
A summary of your APK analysis can be found at: /Users/xxx/.flutter-devtools/apk-code-size-analysis_01.json

To analyze your app size in Dart DevTools, run the following command:
dart devtools --appSizeBase=apk-code-size-analysis_01.json

appbundle 分析日志

app-release.aab (total compressed)                                         18 MB
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
BUNDLE-METADATA/
assets.dexopt 1 KB
com.android.tools.build.libraries 8 KB
com.android.tools.build.obfuscation 628 KB
base/
assets 5 MB
dex 3 MB
lib 8 MB
Dart AOT symbols accounted decompressed size 8 MB
package:flutter 3 MB
package:image 799 KB
dart:core 325 KB
package:xxx_xxx_flutter 293 KB
dart:ui 279 KB
dart:typed_data 226 KB
dart:io 212 KB
dart:collection 177 KB
package:vector_graphics_compiler 170 KB
dart:async 154 KB
package:flutter_localizations 129 KB
package:flutter_spinkit 99 KB
package:extended_image 93 KB
dart:ffi 85 KB
package:petitparser 73 KB
dart:convert 62 KB
package:archive 62 KB
package:dio 57 KB
package:vector_math 54 KB
package:riverpod 49 KB
manifest 4 KB
res 1 MB
resources.pb 292 KB
root 51 KB
META-INF/
UPLOAD.SF 46 KB
UPLOAD.RSA 1 KB
MANIFEST.MF 34 KB
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
A summary of your AAB analysis can be found at: /Users/xxx/.flutter-devtools/aab-code-size-analysis_01.json

To analyze your app size in Dart DevTools, run the following command:
dart devtools --appSizeBase=aab-code-size-analysis_01.json
✓ Built build/app/outputs/bundle/release/app-release.aab (18.2MB).

解读分析报告

使用DevTools解读报告

这里以 VS Code DevTools 举例(Install and run DevTools from VS Code

  • shift + cmd + p打开命令面板,再输入Open DevTools,打开面板。

  • 选择App Size Tooling,打开,并选择需要分析的文件,路径在生成报告时会提供(如:/Users/xxx/.flutter-devtools/aab-code-size-analysis_01.json)

202306271030501687833050QkTyxa.jpg

以上图为例,appbundle在未压缩的情况下, 体积为23.1MB,其中base占据了主要的容量。

base中占据主要大小的分别有lib (8MB)、assest (5MB)、dex (3MB)、res (1.2MB)。

分析内容物可以得出:

  • lib,Flutter端的代码实现以及依赖库。
  • assets,Flutter端使用到的资源文件。
  • dex,原生端的代码实现以及依赖库。
  • res,原生端使用到的资源文件。

除去这些主要的文件内容,剩余的多位配置文件,所占容量的比例也不大,可优化空间不大。

可探索的优化方向

第一步,通过优化素材可以直接减少包体内容的大小。

  • 降低视频素材的分辨率和质量

  • 多使用jpg、svg等高压缩率的图片素材

  • 使用IconFont替换icon的图片素材

  • 使用网络素材,替换本地素材,比如使用网络图片/视频,网络素材包等。

  • 使用GoogleFonts替代本地Fonts。

第二步,减少不必要的代码模块。

  • 检查 pubspec.yaml 并删除未使用的库/包。
  • 对第三方库进行精简定制,再引入使用,删除不需要的功能。
  • 使用Flutter提供的精简代码功能,对产物代码进行重定义。

下面介绍下Flutter提供的精简代码功能。

精简代码

在build产物时,添加 --obfuscate 和 --split-debug-info 来实现对代码的混淆,以及对代码的精简。

  • --split-debug-info ,提取调试信息,实现精简代码,可单独使用。
  • --obfuscate,开启代码混淆,提高代码反编译门槛。
# 指定android-arm64平台,在当前路径输出符号表(如`app.android-arm64.symbols`)
flutter build appbundle --target-platform android-arm64 --split-debug-info=.

未精简的日志

flutter build appbundle --target-platform android-arm64                           

Running Gradle task 'bundleRelease'...
Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 3124 bytes (99.8% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app.
Running Gradle task 'bundleRelease'... 26.7s
✓ Built build/app/outputs/bundle/release/app-release.aab (18.2MB).

精简后的日志

flutter build appbundle --target-platform android-arm64 --split-debug-info=. 

Running Gradle task 'bundleRelease'...
Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 3124 bytes (99.8% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app.
Running Gradle task 'bundleRelease'... 56.6s
✓ Built build/app/outputs/bundle/release/app-release.aab (17.6MB).

开启混淆的日志

flutter build appbundle --target-platform android-arm64 --split-debug-info=.  --obfuscate

Running Gradle task 'bundleRelease'...
Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 3124 bytes (99.8% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app.
Running Gradle task 'bundleRelease'... 57.5s
✓ Built build/app/outputs/bundle/release/app-release.aab (17.6MB).

读取混淆的堆栈跟踪

如需要调试被混淆的应用程序创建的堆栈跟踪,请遵循以下步骤将其解析为可读的内容:

  • 找到与应用程序匹配的符号文件。例如,在 Android arm64 设备崩溃时,需要 app.android-arm64.symbols文件。

  • 向 flutter symbolize 命令提供堆栈跟踪(存储在文件中)和符号文件。例如:

flutter symbolize -i <stack trace file> -d out/android/app.android-arm64.symbols

总结

在素材和代码模块的两个方向上进行包体优化,是比较容易操作,并且不会影响App稳定性的方案。

在出包的工作流中,我们会启用精简代码以及代码混淆,优化产物中代码所占的体积。

排查已经引入的依赖库,看是否存在未使用或者已经弃用的多余依赖。同时分析占容量较大的三方依赖,判断是否需要通过精简定制来降低容量。

最终是排查素材,筛选体积,对不达标的素材进行重新制作,或者寻找替代方案,比如改用网络资源。

参考

Obfuscating The Flutter App

Mastering Dart & Flutter DevTools — Part 3: App Size Tool

Reducing Flutter App Size

Using the app size tool


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

客户端日志&埋点&上报的线程安全问题

引子如果设计一个客户端埋点上报库,日志的完整性、高效传输、日志的及时性都是需要考量的点。其中“高效传输”除了采用更高效的序列化方案、压缩日志、还包含减少通信次数。若每产生一条日志就上报一次就浪费流量了。通常的做法是“批量上报”,即先将日志堆积在内存中,数量达到...
继续阅读 »

引子

如果设计一个客户端埋点上报库,日志的完整性、高效传输、日志的及时性都是需要考量的点。

其中“高效传输”除了采用更高效的序列化方案、压缩日志、还包含减少通信次数。若每产生一条日志就上报一次就浪费流量了。通常的做法是“批量上报”,即先将日志堆积在内存中,数量达到阈值时才触发一次上报。

批量上传 V1.0

假设埋点上报的实现如下:

object EasyLog {
var maxSize = 50
// 用于堆积日志的列表
private val logs = mutableListOf<Any>()
fun log(any: Any){
logs.add(any)
if(logs.size() >= maxSize) {
uploadLogs(logs)
logs.clear()
}
}
}

这样实现存在多线程安全问题,当log()被多线程并发访问时,共享变量logs并不是线程安全的。在多线程环境下调用 ArrayList.add() 会发生数据损坏,因为 ArrayList.add() 实现如下:

public boolean add(E e) {
ensureCapacityInternal(size + 1);
elementData[size++] = e;
return true;
}

其中的前两句都不是线程安全的。

第一句是扩容,重新申请内存并将原数组拷贝至新数组。如果两个线程发现容量不足同时进行扩容,因为拷贝数组过程不是原子的,若被打断,则已复制的内容可能会被覆盖。

第二句是索引自增,++ 操作也不是原子的,多线程环境下可能发生现有数据被覆盖。

使用@Synchronized注解可以解决该问题:

object EasyLog {
@Synchronized
fun log(any: Any){}
}

相当于为整段代码套上 synchronized,同一瞬间只有一个线程可以输出日志。

性能更好的做法是使用线程安全的容器,比如ConcurrentLinkedQueue,它使用无锁机制实现线程安全的并发读写,关于它源码级别的分析可以点击面试题 | 徒手写一个非阻塞线程安全队列

批量上传 V2.0 —— 调控日志生产消费速度

埋点上报场景中,日志的生产的速度远大于消费速度(上传是耗时操作)。这样用于堆积日志的容器可能无限增长,内存有爆炸的风险。

得使用一种机制调控日志生产和消费速度。比如,丢弃新/旧日志、暂停生产。

Kotlin 中的 Channel 提供了需要的所有功能,包括线程安全以及调控生产消费速度。

使用 Channel 重构如下:

object EasyLog {
// 容量为50的 Channel
private val channel = Channel<Any>(50)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var maxSize = 50
private val logs = mutableListOf<Any>()
init {
//新起协程作为日志消费者
scope.launch { channel.consumeEach { innerLog(it) } }
}

fun log(any: Any){
// 新起协程作为日志生产者
scope.launch { channel.send(any) }
}

private fun innerLog(any: Any){
logs.add(any)// 堆积日志
if(logs.size() >= maxSize) {// 日志数量超阈值后上传
uploadLogs(logs)
logs.clear()
}
}
}

每一条新日志都会转发到 Channel 上,在 Channel 另一头有一个单独的协程消费日志。

Channel 就像一个队列,生产者队尾插入,消费者队头取出。它是一个线程安全的容器,多线程并发写没有问题,而且现在只有一个消费者,所以消费日志的代码不会有线程安全问题。就好比四面八方涌入的购票者只能在一个窗口前排队。将并行问题串行化是实现线程安全的一种方法。

Channel 的构造方法可传入三个参数:

public fun <E> Channel(
// 缓存大小
capacity: Int = RENDEZVOUS,
// 溢出策略
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
// 如何处理未传递元素
onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E>

缓冲大小表示生产速度大于消费速度时最多缓存的元素数。当缓存满后继续产生的元素会触发溢出策略,默认策略是BufferOverflow.SUSPEND,表示挂起生产者,而不是阻塞,生产者线程可以继续运行。这是 Channel 相较于 Java 阻塞队列的优势。

批量上传 V3.0 —— 延迟去小尾巴

若客户端产生了49条日志,应用被杀,那这49条日志就丢了,因为还未达到50条上传阈值。为了确保日志的完整性,不得不对每一条日志进行持久化。然后在下一次应用启动时从磁盘读取并上传之。

这样依然不能满足日志的及时性,比如该用户一周之后才启动应用。

需要一种机制及时处理日志的小尾巴(未达批量阈值的日志):当每一条日志到达时,开启倒计时,如果倒计时归零前无新日志请求,则将已堆积日志批量上传,否则关闭前一个倒计时,开启新的倒计时。

日志&埋点&上报(一)中使用 Handler 实现了这套机制。

这次换用协程实现:

object EasyLog {
// 容量为50的 Channel
private val channel = Channel<Any>(50)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var maxSize = 50
private val logs = mutableListOf<Any>()
// 冲刷job
private var flushJob: Job? = null
init {
scope.launch { channel.consumeEach { innerLog(it) } }
}

fun log(any: Any){
scope.launch { channel.send(any) }
}

private fun innerLog(any: Any){
logs.add(log)
flushJob?.cancel() // 取消上一次倒计时
// 若日志数量达到阈值,则直接冲刷,否则延迟冲刷
if (logs.size() >= maxSize) {
flush()
} else {
flushJob = delayFlush()
}
}

// 冲刷:上传内存中堆积的批量日志
private fun flush() {
uploadLogs(logs)
logs.clear()
}

// 延迟冲刷
private fun delayFlush() = scope.launch {
delay(5000)// 延迟5秒,如果没有新日志产生,则冲刷
flush()
}
}

批量上报 V4.0 —— 并行问题串行化

上述代码存在多线程安全问题。

因为延迟冲刷时新起一个协程,导致 logs.clear() 和 logs.add() 可能并发执行。

为了复现这个多线程问题,写了一个压测demo:

// 线程安全的自增 Int 值
val count = AtomicInteger(0)
// 用于验证多线程问题的 Int 数组,初始值都是 -1
private val testArray = Array(100) { -1 }
val list = mutableListOf<Int>()
var flushJob: Job? = null

// 模拟并发生产日志
repeat(100) {
scope.launch(Dispatchers.Default) {
delay((50L..10_00L).random())
channel.send(count.getAndIncrement())
}
}

// 模拟串行消费日志
scope.launch {
channel.consumeEach {
delay((50L..200L).random())
log(it)
}
}
//模拟日志库入口方法
private fun log(value: Int) {
list.add(value)
flushJob?.cancel()
if (list.size >= 5) {// 5条为一批
flush()
} else {
flushJob = delayFlush()
}
}

// 延迟冲刷
fun delayFlush() = scope.launch {
delay(100)
flush()
}

// 冲刷
private fun flush() {
// 以批量日志的值赋值测试数组
list.forEach { testArray[it.value] = it.value }
list.clear()
}

检测方法是并发输出100条日志(内容为一个自增序号0-99),同时声明一个大小为100的数组,所有元素初始化为-1,每当日志被批量处理时将序号对应的数组索引值置为序号值。所有日志处理完毕后,检测数组中是否有-1,若有则表示复现多线程问题,检测代码如下:

val isOk = testArray.any { it == -1 }

若数组中所有的值都不是 -1,则表示 0-100 的日志都被正确地处理了。

运行 demo,isOk 是 false。。。。继续运行了 n 次,居然全都是 false。。。。开始怀疑我对多线程的认知,然后就问 ChatGPT:“理论上这段代码会发生线程安全问题,但是我的测试代码没有复现这个问题,如何才能复现?”

这段代码中的线程安全问题可能不容易复现,因为涉及到多个线程和协程同时运行,而且延迟时间是随机的。要复现这个问题,可以尝试以下方法:

  1. 增加重复次数:增加 repeat(100) 中的数字,这将增加线程安全问题发生的可能性。
  2. 减小延迟时间范围:将 delay((50L..10_00L).random()) 和 delay((50L..200L).random()) 中的延迟时间范围减小,以便让线程和协程更密集地运行。

尽管这些方法可以增加线程安全问题发生的可能性,但并不能保证一定能复现问题。线程安全问题的复现通常取决于操作系统、硬件和运行时环境等多种因素。

听了他的建议调整了 delay 的时间间隔,并增加次数到1000次,多线程问题浮现了:

java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.next(ArrayList.java:860)
at test.taylor.com.taylorcode.concurrent.ConcurrentInitActivity.flush(ConcurrentInitActivity.kt:134)
at test.taylor.com.taylorcode.concurrent.ConcurrentInitActivity.log(ConcurrentInitActivity.kt:66)

在遍历列表的同时增删列表元素就会发生这个问题。即使没有发生上述 crash,也可能会打印出 isOk=true,表示array中有元素为-1。

使用线程安全的ConcurrentLinkedQueue作为容器,仍然无法解决问题,因为它的clear()方法是弱一致性的,它需要遍历整个队列,但遍历是基于快照机制的。

最后还是运用 “将并行问题串行化” 来解决这个多线程问题:

// 单线程 Dispatcher
val logDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()

// 在单线程上延迟冲刷
fun delayFlush() = scope.launch(logDispatcher) {
delay(50)
flush()
}

// 在单线程上消费日志
scope.launch(logDispatcher) {
channel.consumeEach {
delay((25L..100L).random())
log(it)
}
}

构建一个单独的线程,使得日志的消费和冲刷都在该线程进行。

单线程会降低性能吗? 不会,因为延迟冲刷是挂起剩余的代码,而不会阻塞线程。在单线程上延迟冲刷就好比使用 Handler.postDelay() 将冲刷逻辑排到主线程消息队列的末尾。


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

flutter 极简的网络请求 - Retrofit 文档记录

前言对于Retrofit插件说实话之前是不太了解的,后来偶然发现了它,感觉还是比较惊艳的。主要工作流程就是注解、生成,通过定义简化通用请求方法的繁杂工作。(ps: json_serializable、freezed 和 最新的Riverpo...
继续阅读 »

前言

对于Retrofit插件说实话之前是不太了解的,后来偶然发现了它,感觉还是比较惊艳的。主要工作流程就是注解、生成,通过定义简化通用请求方法的繁杂工作。(ps: json_serializablefreezed 和 最新的Riverpod也是类似的工作方式,但Riverpod理解要稍微复杂一些。)

优秀插件太多感觉都有点看不完了,但是一聊到能减少重()复()工()作()那高低肯定是要上车了。 (●'◡'●)

插件Git地址:retorfit.dart

一、Retrofit 文档记录

主要目的还是记录一下,方便后续使用的时候查看。

1、添加插件引用

dependencies:
dio: any
retrofit: '>=4.0.0 <5.0.0'
logger: any #for logging purpose
json_annotation: ^4.8.1

dev_dependencies:
retrofit_generator: '>=7.0.0 <8.0.0' // required dart >=2.19
build_runner: '>=2.3.0 <4.0.0'
json_serializable: ^6.6.2

2、定义请求使用

import 'package:dio/dio.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:retrofit/retrofit.dart';

part 'example.g.dart';

@RestApi(
// 请求域名
baseUrl: 'https://5d42a6e2bc64f90014a56ca0.mockapi.io/api/v1/',
// 数据解析方式,默认为json
parser: Parser.JsonSerializable,
)
abstract class RestClient {
// 标准的构建方式
// dio: 传入发起网络请求的对象
// baseUrl: 请求域名,优先级高于注解
factory RestClient(Dio dio, {String baseUrl}) = _RestClient;

// 1、添加请求方式注解,接口地址
// 2、定义返回值类型(可以是任意类型,也可以是定义好的model),请求方法名,请求参数(后面会提到)
@GET('/tasks')
Future<List<Task>> getTasks();
}

example.g.dart 是脚本生成的具体实现文件;

@RestApi(baseUrl:...) 添加注解及部分配置参数;

Future<List<Task>> getTasks(...) 定义请求的返回值、参数值、请求类型与接口地址;

这个文件请求方法配置,按规范书写就可以了。

3、执行编译脚本

# dart
dart pub run build_runner build

# flutter
flutter pub run build_runner build

// 个人更建议使用 watch 命令
// 该命令监听输入,可以实时编译最新的代码,不用每次修改之后重复使用 build 了
flutter pub run build_runner watch

4、基本使用

import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:retrofit_example/example.dart';

final logger = Logger();

void main(List<String> args) {
final dio = Dio(); // Provide a dio instance
dio.options.headers['Demo-Header'] = 'demo header'; // config your dio headers globally
final client = RestClient(dio);

client.getTasks().then((it) => logger.i(it));
}

5、更多的请求方式

  @GET('/tasks/{id}')
Future<Task> getTask(@Path('id') String id);

@GET('/demo')
Future<String> queries(@Queries() Map<String, dynamic> queries);

@GET('https://httpbin.org/get')
Future<String> namedExample(
@Query('apikey') String apiKey,
@Query('scope') String scope,
@Query('type') String type,
@Query('from') int from);

@PATCH('/tasks/{id}')
Future<Task> updateTaskPart(
@Path() String id, @Body() Map<String, dynamic> map);

@PUT('/tasks/{id}')
Future<Task> updateTask(@Path() String id, @Body() Task task);

@DELETE('/tasks/{id}')
Future<void> deleteTask(@Path() String id);

@POST('/tasks')
Future<Task> createTask(@Body() Task task);

@POST('http://httpbin.org/post')
Future<void> createNewTaskFromFile(@Part() File file);

@POST('http://httpbin.org/post')
@FormUrlEncoded()
Future<String> postUrlEncodedFormData(@Field() String hello);

6、在方法中额外添加请求头

  @GET('/tasks')
Future<Task> getTasks(@Header('Content-Type') String contentType);

-- or --

import 'package:dio/dio.dart' hide Headers;

@GET('/tasks')
@Headers(<String, dynamic>{
'Content-Type': 'application/json',
'Custom-Header': 'Your header',
})
Future<Task> getTasks();

官方后续文档就不在这里复述了,下面记录一下我自己的使用方式。

二、Retrofit 个人使用

如官方文档所述,Retrofit的使用本就十分简单,这里更多的是对请求使用的归纳。

1、创建请求体

创建文件api_client.dart,该文件主要是对请求方法的编写。

...
@RestApi()
abstract class ApiClient {
factory ApiClient(Dio dio, {String baseUrl}) = _ApiClient;
// 定义请求方法
...
}

2、创建dio请求拦截

创建文件interceptor.dart,文件主要是对发起请求响应结果的通用处理,简化我们在使用过程中,重复的处理公共模块。


class NetInterceptor extends Interceptor {
NetInterceptor();

@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// 在这里可以配置请求头,设置公共参数
final token = UserService.to.token.token;
if (token.isNotEmpty) {
options.headers['Authorization'] = token;
}
handler.next(options);
}

@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// 预处理请求返回结果,处理通用的错误信息
// 包括不限于数据格式错误、用户登录失效、 需单独处理的额外约定错误码
Map dataMap;
if (response.data is Map) {
dataMap = response.data;
} else if (response.data is String) {
dataMap = jsonDecode(response.data);
} else {
dataMap = {'code': 200, 'data': response.data, 'message': 'success'};
}

if (dataMap['code'] != 200) {
if (dataMap['code'] == 402 || dataMap['code'] == 401) {
// _ref.read(eventBusProvider).fire(AppNeedToLogin());
}
handler.reject(
DioError(
requestOptions: response.requestOptions,
error: dataMap['message'],
),
true,
);
return;
}
response.data = dataMap['result'];
handler.next(response);
}
}

3、独立请求参数类

创建文件params.dart,文件主要目的是存放请求类型参数(毕竟官方推荐使用具体类型作为结构参数,非Map),当然也可以直接使用我们请求结果的数据模型作为请求参数(但是不是所有的方法都合适),简单的参数还是不用写这,个人感觉还是太复杂了,不够简洁。

具体没啥说的,直接使用json_serializable就可以了,当然也可以手写,这里推荐一下 VS Code插件 - Dart Data Class Generator,直接定义好属性之后直接通过提示扩展对应的方法就好了。

import 'package:json_annotation/json_annotation.dart';
part 'params.g.dart';

@JsonSerializable()
class TokenParams {
@JsonKey(name: 'client_id')
final String clientId;

TokenParams(this.clientId);
factory TokenParams.fromJson(Map<String, Object?> json) =>
_$TokenParamsFromJson(json);
Map<String, dynamic> toJson() => _$TokenParamsToJson(this);
}

4、添加请求桥接(独立基础请求配置)

创建实际请求类repository.dart,简化实际使用


class NetRepository {
/// 独立请求体
static ApiClient client = ApiClient(
Dio(BaseOptions())
..interceptors.addAll([
LogInterceptor(
requestBody: true,
responseBody: true,
),
NetInterceptor(),
]),
baseUrl: _devDomain.host,
);

/// 如果域名不一致可以独立创建,方便区分
static ApiClient user...
static ApiClient company...

}

final _devDomain = AppDomain(
host: 'https://api.apiopen.top/api',
pcHost: 'http://www.xxx.com ',
);

// 定义域名配置,用类的形式只是为了更好的管理和使用
// 当然这里也可以直接换成枚举、常量字符串等等,看个人编写习惯
class AppDomain {
/// 接口域名
final String host;

/// 电脑端地址
final String pcHost;

/// final String host1;
/// final String host2;
/// ...

AppDomain({
required this.host,
required this.pcHost,
});
}

5、使用案例

这里是搬用上一篇 一站式刷新和加载 的使用场景,其他地方放使用类似。


@override
FutureOr fetchData(int page) async {
try {
final data = await NetRepository.client.videoList(page, 20);
await Future.delayed(const Duration(seconds: 1));
if (tag) {
endLoad(data.list as List<VideoList>, maxCount: data.total);
} else {
tag = true;
endLoad([], maxCount: data.total);
}
} catch (e) {
formatError(e);
}
}

总结

如果使用这个请求库的话,可以极大的简化我们样板式代码的书写,还是值得推荐的。 一切的一切就是都是为了更简单,也算是为了尽量少写没用的代码而努力。

毕竟不想当将军的士兵不是好士兵,不想写代码的程序猿才是好猿。 ( ̄▽ ̄)"

附Demo地址: boomcx/template_getx


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

搞懂ThreadLocal

前言ThreadLocal可以说是面试的常客了,虽然在日常开发中用到的次数并不多,但因为其在Handler、ActivityThread中都发挥着重要的作用,使得面试官在问其他问题的时候会顺便考查一下ThreadLocal。为了彻底理清其逻辑,这里系统的整理一...
继续阅读 »

前言

ThreadLocal可以说是面试的常客了,虽然在日常开发中用到的次数并不多,但因为其在Handler、ActivityThread中都发挥着重要的作用,使得面试官在问其他问题的时候会顺便考查一下ThreadLocal。为了彻底理清其逻辑,这里系统的整理一遍ThreadLocal结构,帮助自己理解。

一、概述

在分析ThreadLocal之前先不要看源码,我们先来大致建立起关于ThreadLocal整体的认知。

TheadLocal工具涉及到的几个类:Thread、ThreadLocal、ThreadLocalMap,对于它们之间的关系我们可以这样简单理解:每个Thread对象都拥有一个独属于自己的Map容器-ThreadLocalMap,这里我们先把它理解为HashMap,该容器的作用是存储和维护独属于本线程的值,而它的key值就是TheadLocal对象,value值就是我们需要存储的Object。

这就是ThreadLocal工具的结构,所以在ThreadLocal工具中真正重要的是ThreadLocalMap,它才是存储线程独有数据的地方。

图片出处

二、ThreadLocal是什么

看了概述之后你其实已经对ThreadLocal有了一个大致的认知了,但是仅仅这些还不够,还需要更加深入的了解ThreadLocal。

ThreadLocal,即线程的本地变量,设计目的是为了让线程中拥有属于自己的变量,主要用于线程间数据隔离,是用来解决线程安全性问题的一个工具。它相当于为每一个线程都开辟了一块内存空间,用来存储共享变量的副本,每个线程访问共享变量时只能去访问和操作自己共享变量的副本,从而避免多线程竞争同一个共享数据,保证了在多线程环境下各个线程里的变量相对独立于其他线程内的变量。

在这里所谓开辟的内存空间就是 ThreadLocalMap,共享变量就是 ThreadLocal,共享变量的副本就是存储到ThreadLocalMap中的key。

//创建一个ThreadLocal共享变量
static final ThreadLocal<String> sThreadLocal = new ThreadLocal<String>();

创建一个ThreadLocal修饰的共享变量,当线程访问该共享变量时,这个线程就会在自己的成员变量ThreadLocalMap中保存一份数据副本,多个线程操作这个变量的时候,实际是在操作自身线程本地内存里面的变量,从而起到线程隔离的作用,避免了并发场景下的线程安全问题。

三、Thread源码分析

class Thread implements Runnable {
...
ThreadLocal.ThreadLocalMap threadLocals = null;
...
}

每个线程都有一个成员变量-ThreadLocalMap,但是该变量并没有设置引用,也就是说内存并没有为它分配空间,它的引用实际是在ThreadLocal#set方法中设置的,这样的话,虽然每个Thread对象都会有一个ThreadLocalMap变量,但是只有在使用ThreadLocal工具实现线程数据隔离的时候才会实例化,不使用则不会实例化,避免了内存占用。

四、ThreadLocal源码分析

既然每个Thread对象都有一个属于自己的容器ThreadLocalMap,那么对于数据的管理无外乎添加、获取、删除,也就是就是set、get、remove,但是这些操作并不是线程直接对ThreadLocalMap进行,而是通过ThreadLocal来间接实现的,ThreadLocalMap是ThreadLocal的静态内部类

1、ThreadLocal#set()

    public void set(T value) {
//获取当前线程对象
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);//this表示当前ThreadLocal对象
else
createMap(t, value);
}

//获取thread对象的成员变量ThreadLocalMap
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

首先会获取当前线程的变量ThreadLocalMap,如果该变量为null,那么会调用createMap方法初始化ThreadLocalMap,如果不为null,则调用ThreadLocalMap#set方法将数据存储起来。

2、ThreadLocal#get()

    public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

private T setInitialValue() {
T value = initialValue();//initialValue方法会返回null
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

与set方法同理,首先会获取ThreadLocalMap,根据ThreadLocalMap是否为null来进行操作。如果不为null,则根据key值-ThreadLocal对象直接从ThreadLocalMap中取值并返回。如果为null,则调用setInitialValue方法,该方法逻辑几乎和set方法相同,不同的是value值为null,所以最终返回的也是null。

从上面的方法中我们可以看到不管是set方法还是get方法,都会先获取当前的Thread对象,然后获取Thread对象的成员变量ThreadLocalMap,最终对Map进行操作,这样也就保证了所有操作都是作用在Thread对象的同一个ThreadLocalMap上。

五、ThreadLocalMap

static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}

ThreadLocal没有直接使用HashMap而是自己重新开发了一个 map,最主要的作用是让它的key为虚引用类型,这样当ThreadLocal对象销毁时,多个持有其引用的线程不会影响它的回收。 ThreadLocalMap是一个很像HashMap的数据结构,但他并没有实现 Map接口,而且它的 Entry是继承WeakReference的,也没有 next 指针,所以不存在链表。对于hash冲突,采用的是开放地址法来进行解决。 ThreadLocaMap的扩容机制也不同于HashMap,ThreadLocalMap的扩容阈值是长度的2/3,当表中的元素数量达到阈值时,不会立即进行扩容,而是会触发一次rehash操作清除过期数据,如果清除过期数据之后元素数量大于等于总容量的3/4才会进行真正意义上的扩容。

六、ThreadLocal的内存泄漏

我们都知道内存泄漏必然和对象的引用有关,先来看一下ThreadLocal的引用关系图。

image.png

Thread中的成员变量ThreadLocalMap,它里面的key指向ThreadLocal成员变量,并且是一个弱引用。

1、为什么Entry的key使用弱引用?

如果 Entry 的key为强引用,则会导致ThreadLocal对象在被创建它的线程销毁时,由于ThreadLocalMap的持有而导致ThreadLocal对象无法被回收,进而导致严重的内存泄漏问题,因此Eetry的key被声明为弱引用来避免这种问题

2、ThreadLocal弱引用下为什么会导致内存泄漏?

所谓弱引用,是指对象允许在这种引用关系存在的情况下被GC回收。

前面也说过,ThreadLocalMap中的key是一个弱引用,当ThreadLocal变量被设置为null,即此时ThreadLocal对象仅有一个弱引用-key,而没有任何外部强引用关系。发生一次系统GC后,ThreadLocal对象会被GC回收,key的引用就变成一个null,导致这部分内存永远无法被访问,造成内存泄漏的问题。因此这些value就会一直存在一条强引用链: Thread变量 -> Thread对象 -> ThreaLocalMap -> Entry -> value -> Object 无法回收,造成内存泄漏。

所以说,从ThreadLocal本身的设计来看,是一定存在内存泄漏的。有的朋友可能会说不会出现内存泄漏啊,如果线程被回收了,线程里面的成员变量也都会被回收,也就不存在内存泄漏了,这是不对的。首先,在线程执行期间,始终有一块无法访问的内存被占用。其次,我们在实际开发中多数情况下使用线程池,而线程池是重复利用的,线程池不会销毁线程,那么线程中会一直存在这种类型的value,导致内存泄漏。

image.png

3、如何避免内存泄漏

既然已经知道弱引用下内存泄漏的原因,那么解决方案也就很清晰了,将不再被使用的Entry及时从线程的ThreadLocalMap中删除,或者延长ThreadLocal的生命周期。

而删除不再使用的Entry有两种方式。

  • 主动清除:使用完ThreadLocal后,手动调用ThreadLocal#remove()方法,将Entry从ThreadLocalMap中删除。
  • 条件触发清除:当然,为了避免内存泄漏的问题,ThreadLocal也做了一些工作。ThreadLocalMap拥有自动清除机制去清除过期Entry,当调用ThreadLocalMapget()、set()对数据进行读写时,都会触发对Entry里面key为null的数据的清除。

我们也能看到系统自动清除是需要一定的触发条件的,不能完全避免内存泄漏,所以正确的做法是调用ThreadLocal#remove()主动清除。

还可以将ThreadLocal声名为private static,使它的生命周期与线程保持一致,保证一直存在与之关联的强引用。

总的来说,有两个方法可以避免内存泄漏

  1. 每次使用完ThreadLocal之后,主动调用remove()方法移除数据。
  2. 扩大成员变量ThreadLocal的作用域,把ThreadLocal声名为private static,使它无法被GC回收。这种方法虽然避免了key为null的情况,但是如果后续线程不再继续访问这个key,也就会导致这个内存一直占用不被释放,最后造成内存溢出的问题。

所以说来说去,最好的方式还是在使用完之后,调用remove方法去移除掉这个数据

七、总结

  • ThreadLocal为每一个线程创建一个ThreadLocalMap,用于存储独属于线程自己的数据。
  • ThreadLocal的设计并不是为了解决并发问题,而是解决变量在线程内部的共享问题,线程内部可以访问独属于自己的变量。
  • 因为每个线程都只会访问自己ThreadLocalMap 保存的变量,所以不存在线程安全问题。
  • 为了避免ThreadLocal造成的内存泄漏,最好在每次使用完ThreadLocal之后,主动调用remove()方法移除数据。

个人能力经验有限,文章如有错误,还望指正。


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

Git 回退到指定版本

Git
在 Git 中,我们可以使用多种方法回退代码到指定版本,包括使用 reset 命令、使用 revert 命令、使用 checkout 命令等。下面分别介绍这些方法。方法一: 使用 git r...
继续阅读 »

在 Git 中,我们可以使用多种方法回退代码到指定版本,包括使用 reset 命令、使用 revert 命令、使用 checkout 命令等。下面分别介绍这些方法。

方法一: 使用 git reset 命令

命令

git reset

命令可以将当前分支的 HEAD 指针指向指定的提交,从而回退代码到指定版本。

该命令有三种模式:--soft--mixed 和 --hard。它们的区别在于回退代码的程度不同。

  • --mixed (默认):将 HEAD 指针和暂存区都回退到指定提交,但不改变工作区的内容。
  • --soft 仅将 HEAD 指针回退到指定提交,不改变暂存区和工作区的内容。
  • --hard 将 HEAD 指针、暂存区和工作区都回退到指定提交,会丢失最新的代码修改,慎用。

示例

# 查看提交历史
git log

# 回退到指定提交(使用 --soft 模式)
git reset --soft <commit>

# 查看状态
git status

# 提交回退后的代码
git commit -m "回退到 <commit>"

# 推送到远程仓库
git push origin <branch>

我们首先使用 git log 命令查看提交历史,找到要回退到的提交的 SHA-1 值。

然后使用 git reset 命令回退代码到指定提交,

这里使用了 --soft 模式,这样暂存区和工作区的内容不会改变,只是 HEAD 指针指向了指定提交。

接着我们使用 git status 命令查看当前状态,确认回退操作是否正确。

最后,我们使用 git commit 命令提交回退后的代码,并使用 git push 命令将代码推送到远程仓库。

方法二:使用 git revert 命令

命令

git revert 命令可以将指定提交的修改反向应用到当前分支上,相当于撤销指定提交的修改。

这种方式比使用 git reset 命令更加安全,因为它不会改变提交历史,而是创建一个新的提交来撤销之前的修改。

示例

# 查看提交历史
git log

# 撤销指定提交
git revert <commit>

# 提交撤销操作
git commit -m "回退到版本 <commit>"

# 推送到远程仓库
git push origin <branch>

我们首先使用 git log 命令查看提交历史,找到要回退的提交的 SHA-1 值。

然后使用 git revert 命令撤销指定提交的修改,这样会创建一个新的提交来撤销之前的修改。

接着我们使用 git commit 命令提交撤销操作,

并使用 git push 命令将代码推送到远程仓库。

方法三:使用 git checkout 命令

命令

git checkout 命令可以将当前分支的 HEAD 指针指向指定的提交,并将工作区的内容替换成指定提交的内容。这种方式不改变提交历史,但会直接覆盖工作区的内容,慎用。

示例

# 查看提交历史
git log

# 切换到指定提交
git checkout <commit>

# 提交回退后的代码
git commit -m "回退到版本 <commit>"

# 切回到原来的分支
git checkout <branch>

# 推送到远程仓库
git push origin <branch>

我们首先使用 git log 命令查看提交历史,找到要回退的提交的 SHA-1 值。

然后使用 git checkout 命令切换到指定提交,这样工作区的内容就会被直接替换成指定提交的内容。

接着我们使用 git commit 命令提交回退后的代码,并使用 git checkout 命令切回到原来的分支。

最后,我们使用 git push 命令将代码推送到远程仓库。

改之后git push上去远程仓库的命令行 以及 报错的相关解决办法

当我们改完代码后,想要将代码推送到远程仓库时,可以使用以下命令:

# 推送当前分支到远程仓库
git push origin <branch>

其中,<branch> 表示当前分支的名称,例如 master。这个命令会将本地分支的提交推送到远程仓库,并将远程分支更新为与本地分支一致。

如果在推送代码时出现错误,可以根据错误提示进行相应的解决办法。

常见的错误及其解决办法如下:

  • error: failed to push some refs to 'git@github.com:<username>/<repository>.git':这个错误通常是由于本地分支和远程分支的提交历史不一致导致的。解决办法是先执行 git pull 命令将远程分支的代码拉取到本地,然后再执行 git push 命令推送代码。

  • error: src refspec <branch> does not match any:这个错误通常是由于本地分支不存在或者拼写错误导致的。解决办法是先执行 git branch 命令查看本地分支列表,确认分支名是否正确,如果不存在则需要先创建分支。

  • error: failed to push some refs to 'git@github.com:<username>/<repository>.git':这个错误通常是由于权限不足导致的。解决办法是确认当前用户是否有权限推送代码到远程仓库,如果没有则需要联系管理员进行授权。

总结

总之,回退代码和推送代码都是 Git 中非常常见的操作,掌握这些操作可以帮助我们更加高效地进行开发和协作。


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

我在数据中台建设和落地的一些经验总结

软件工程师罗小东,多年平台架构设计和落地经验,这里从智慧型项目、数字化项目进行数据治理建设的一些经验总结。概述针对于中小型团队和当前接触到的大部分项目来说,很少有非常大的数据治理需求,特别是互联网型的PB级数据。在大部分情况下,数据量在TB级或亿级级别较多。相...
继续阅读 »

软件工程师罗小东,多年平台架构设计和落地经验,这里从智慧型项目、数字化项目进行数据治理建设的一些经验总结。

概述

针对于中小型团队和当前接触到的大部分项目来说,很少有非常大的数据治理需求,特别是互联网型的PB级数据。在大部分情况下,数据量在TB级或亿级级别较多。相对于PPT级别的方法论,会更加注重于实际运用,为了应对这些场景,在以下几个方面进行了考虑和优化:

  • 是否真的需要建立一个Hadoop体系的数据仓库

  • 针对于中小型客户数据治理需求怎么建设

  • 怎么样针对当前的项目进行数据资源管理

  • 后期的数据治理和各个数据治理维护怎么做

在真正理解项目需求、精细化管理以及灵活选择数据治理工具和技术的基础上,能够更好地应对不同场景下的数据治理需求。不同项目不同架构,我有我思。

过程建设

许多客户都有数字化建设的需求,但不同的场景需要使用不同的技术方案,在具体的建设过程中,整理的一些思路:

  • 首先要充分了解客户的业务场景和需求,从而选择最适合的技术方案。

  • 在建设过程中,要注重数据质量和服务,以确保数据的准确性和能力体现。

  • 合理规划数据治理流程,包括数据采集、清洗、转换、存储等环节,并通过数据可视化手段展示数据治理效果,提高数据治理成效。

  • 对于不同的项目规模和预算成本,选择不同方案,优化算法和调整计算引擎,减少资源和成本。

针对不同的客户场景,规划合理的数据治理流程。

是否真的需要建立一个全套的数据仓库体系

针对于不同的场景,对于数据治理,需要根据具体场景来选择合__适的方案

前期的搭建方式

目前在搭建数据治理平台时,开始我们使用的是CDH做为数据仓库底座,通常使用Hadoop体系的数据仓库平台,并按照ODS/DWD/ADS等层级进行划分,通过Kettle/Filebeat/Sqoop等方式抽取数据进行离线计算,使用Hive做为数据仓库,我们的工程师在这块上也有多年的治理经验,计算引擎使用的偏向于Spark,数据建模和维护也是按通用的数据标准处理,这个有前期多个项目里面基本上都是,有一些项目会运行在K8S上。

这个过程消耗的资源较多,而且计算引擎和计算过程比较统一,特别是Spark计算的时候,消耗大量的内存资源。而对于一般中小型项目,或者一般的客户来说,这个资源的建设会成本过高,特别是在数据治理这块并不是要求特别高的时候。

客户数据治理成本高

一些客户可能并不理解数据治理的成本和价值,除了政务型项目或不缺费用的项目,很难落地,没有达到预期的数据运营效果。

比如一个智慧社区项目,在这块上的数据仓库主要存储的数据在几个方面,用户行为、IOT数据采集、还有视频流数据的存储(只存储主键祯数据),另外就是一些业务系统的数据采集存储,针对于以上数据的分析,与AI结合,提供出API服务能力,在这些数据中,超过一定生命周期的会做清理,最后评估出来10年左右180T存储,而这个过程中,大部分是冷数据。

最后建设使用的方案是云厂家的一体机来进行管理,但是这个成本是极高的,类似于这样的数据场景,遇到的比较多,最后在考虑一个问题,是否需要这么重的数据仓库。

针对于中小型客户数据治理需求怎么建设的

建立一个轻量级的数据场景,以更好地满足不同项目的需求。建设轻量级数据治理平台

建设轻量级数据治理平台,是优化数据管理和维护成本的方法之一。目前大数据套件较多,学习成本较高,对中小型团队而言,这一成本占比较大。因此,需要采取有效措施降低人员培训成本和管理维护成本。

将多个工具整合为轻量级数据中台,使用minio分布式存储、Clickhouse数据仓库、kettle抽取工具和kafka数据总线等技术统一数据治理,适配各类规模的企业需求。在数据清理和转换后,将数据存储到ODS层,非结构化和半结构化数据存储在分布式存储和ES中,并根据生命周期规划定期清理不必要的数据,只保留有价值的数据和流程相关数据。

此外,针对人员培训,设计系统化的培训课程和多种灵活的培训方式,以提高员工的数据管理和分析能力。对于团队管理和维护,可以建立数据治理的文化氛围,鼓励全员参与,同时引入自动化工具和脚本,减少人工操作和管理成本。

通过以上措施,项目可以建设出高效、灵活的数据治理平台,降低人员培训和管理成本,提高数据治理能力和业务价值体现 ,实现项目的业务需求和决策目标。

怎么样针对当前的项目进行数据资源管理

建设通用的数据治理能力组件和平台组件,以便根据具体项目需求进行选择和组合,实现对数据资源的有效管理。

针对当前的项目进行数据资源管理,可以建设一套通用的数据治理能力组件和平台组件。这些组件可用于多种场景下的数据治理工作,如:

  • 数据上报服务:供政务、个人、单位等通用型用户使用的通用数据采集上报平台,支持非技术型人员和部门进行数据入仓。

  • 数据总线服务:连接数据平台中不同组件和子系统的核心组件,实现数据的快速传输和交换,并统一集成数据主题管理。

  • 主数据管理服务:帮助企业确保数据质量、提高业务流程效率,并为数据分析和决策提供支持,促进企业内部数据的标准化、管理和共享。

  • 数据集成服务:提供在线设置ETL作业、转换任务的定时运行策略,监控任务的执行情况,查看任务执行日志的功能,强有力地支撑后续的数据开发、数据挖掘。

  • 数据开发服务:向数据开发工程师提供拖拉拽控件的方式,设计复杂的工作流有向无环图,挖掘出有商业价值的数据。

  • 数据安全网关:提供数据交换、数据共享、数据开放的平台,包含网关接口安全、接口权限认证、黑名单管理、Oauth2接口认证等功能,向组织内各个部门提供支持。

这些数据治理能力组件和平台组件可根据具体项目需求进行选择和组合,实现对数据资源的有效管理,我们采用灵活的数据治理方案,根据项目大小和需求,选择相应的数据治理工具和技术。

在提供工具的同时,针对于业务的个性化要求和业务开发需求,比如报表、大屏、还有数据服务使用等,当前是让ISV团队进行处理,而这个过程由中台团队提供技术支持和培训,而数据治理套件不对客户。

后期的数据治理和各个数据治理维护怎么做

建立一套完善的数据治理流程和规范,包括数据质量控制、数据安全保护、数据持续更新等方面的要求

实现数据治理和各个数据治理维护的目标,包括数据流程标准化、人员技术培训、数据指标采集等。在实际应用过程中,需要根据企业的具体需求和情况,适当调整和优化数据治理策略,以提高数据质量和效率,为项目的发展提供有力支撑。

  • 数据流程标准化:通过数据总线服务连接数据平台中的不同组件和子系统,以便实现数据的快速传输和交换,并统一集成数据主题管理。建立标准化的数据流程,包括数据采集、清洗、存储、转换等环节,并确保每个环节都符合相关标准和规范。

  • 人员技术培训:利用主数据管理服务对企业内部数据进行标准化、管理和共享,确保数据质量和提高业务流程效率。同时,为各个层次的员工提供有针对性、系统化的培训课程,提高他们的数据管理和分析能力。

  • 数据指标采集:使用数据集成服务在线设置ETL作业和转换任务的定时运行策略,监控任务的执行情况和查看任务执行日志的功能。确保多种数据格式和来源的数据经过清洗、转换后能够及时有效地送达组织的数据仓库,并为后续的数据开发和挖掘提供支持。

  • 数据治理目标达成:使用数据开发服务向数据开发工程师提供拖拉拽式的控件,设计复杂的工作流图,挖掘出有商业价值的数据,帮助企业实现对数据的全面管控和治理。同时,使用数据安全网关进行数据交换、共享和开放的管理,确保数据的安全性和防止潜在的风险。

同时实现对数据的全面管控和治理,确保数据的质量和安全,提高数据开发和分析的效率和准确性,从而更好地支撑企业的业务需求和决策,提供出数据服务和治理。

总结

数据治理是数字化建设中非常重要的一环。在进行数据治理时,我们需要根据不同的业务场景和需求,选择最适合的数据治理方案,包括选择不同的组件组装和数据存储方式等。对于轻量级数据管理平台和重量级数据管理平台,我们可以针对具体情况进行选择,权衡成本与效益,以满足客户实际需求。在整个数据治理过程中,我们还需要注重客户成本的管理,确保项目的落地和实际效果,并且不断优化数据治理流程,需要积极参与业务需求分析和技术选型,确保数据治理方案符合客户需求和行业标准。

过程考虑不同的场景选择不同的数据治理方案和组件组装,根据实际情况选择轻量级或重量级数据中台,注重客户成本管理和实际效果,以满足客户需求并推动数字中台建设。

以上为在大中小型项目中的数据治理经验输出,提供一些参考。


作者:软件工程师_罗小东
链接:https://juejin.cn/post/7238978524030861371
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

一次查找分子级Bug的经历,过程太酸爽了

bug
在软件开发的世界里,偶尔会出现一些非常隐蔽的 Bug,这时候工程师们像探险家一样,需要深入代码的丛林,寻找隐藏在其中的“幽灵宝藏”。前段时间,我和我的团队也踏上了这样一段刺激、有趣的探险之旅。最近繁忙的工作告一段落,我总算轻松下来了,想趁这个机会,跟大家分享我...
继续阅读 »

在软件开发的世界里,偶尔会出现一些非常隐蔽的 Bug,这时候工程师们像探险家一样,需要深入代码的丛林,寻找隐藏在其中的“幽灵宝藏”。前段时间,我和我的团队也踏上了这样一段刺激、有趣的探险之旅。

最近繁忙的工作告一段落,我总算轻松下来了,想趁这个机会,跟大家分享我们的这次“旅途”。

01 引子

我是 ShowMeBug 的 CEO 李亚飞,是一个古老的 Ruby 工程师。由于 2019 年招聘工程师的噩梦经历,我立志打造一个真实模拟工作场景的 IDE,用来终结八股文、算法横行的技术招聘时代。

这个云上的 IDE 引擎,我称之为轻协同 IDE 引擎——因为它不是为了繁杂重度的工作场景准备的,而是适应于大部分人的习惯、能快速上手熟悉、加载速度快、能协同(面试用)、低延迟感,让用户感受非常友好 

多环境启动与切换 

为了达成秒级启动环境的性能要求,我们设计了一套精巧的分布式文件系统架构,其核心是一个可以瞬间复制大量小文件的写时复制 (COW) 技术。IO 吞吐能达到几万人同时在线,性能绝对是它的一大优势。

我们对此信心满满,然而没想到,很快就翻车了。

02 探险启程

2023 年 1 月,北方已经白雪皑皑,而深圳却仍难以感受到冬天的寒意。

我和我的团队在几次打开文件树的某个文件时,会显得有点慢——当时没有人在意,按照常规思路,“网速”背了这个锅。事后我们复盘才发现,这个看似微不足道的小问题,其实正是我们开始这次探险之旅的起点。

1 月底,南方的寒意缓缓侵入。这时候我们的轻协同 IDE 引擎已经开始陆续支持了 Vue2、Vue3、React、Django、Rails 等框架环境,一开始表现都很棒,加载和启动速度都很快。但是,跑了一段时间,我们开始察觉,线上环境就出现个别环境(Rails 环境)启动要 20-30s 才能完成

虽然其他环境仍然保持了极快的加载和启动速度,但敏锐的第六感告诉我,不行,这一定有什么猫腻,如果不立即行动,势必会对用户体验带来很不好的影响。于是,我开始安排团队排查眼前这个不起眼的问题,我们的探险之旅正式开始。

03 初露希望

湿冷的冬季,夜已深,我和我们的团队依旧坐在电脑前苦苦探索,瑟瑟发抖。

探险之旅的第一站,就是老大难的问题:定位Bug。目前只有某一个环境启动很慢,其他的环境都表现不错。大家想了很多办法都没有想明白为什么,甚至怀疑这个环境的模板是不是有问题——但把代码放在本地启动,最多就2秒。

哎,太诡异了。我们在这里卡了至少一周时间,不断追踪代码,分析日志文件,尝试各种方案,都没有弄清楚一个正常的程序启动为什么会慢。我们一度陷入了疲惫和焦虑的情绪中。

Debug 是种信仰,只有坚信自己能找到 Bug,才有可能找到 Bug。

软件开发界一直有一个低级 Bug 定律:所有诡异的问题都来自一个低级原因。在这“山重水复疑无路”之际,我们决定重新审视我们的探险路径:为什么只有 Rails 更慢,其他并不慢?会不会只是一个非常微小的原因而导致?

这时候,恰好有一个架构师朋友来访,向我们建议,可以用 perf 火焰图分析看看 Rails 的启动过程。

perf火焰图实例

当我们用 perf 来分析时,惊讶地发现:原来 Rails 的启动要加载更多的文件! 紧接着,我们又重新用了一个文件读写监控的工具:fatrace,通过它,我们看到 Rails 每次启动需要读写至少 5000 个文件,但其他框架并不需要。

这才让我们突然意识到,会不会是文件系统读写速度不及预期,导致了启动变慢。

04 Bug现身

为了搞清楚是不是文件系统读写速度的问题,我急需一个测试 IO 抖动的脚本。我们初步估算一下,写好这个脚本需要好几个小时的时间。

夜已深,研发同学都陆续下班了。时间紧迫!我想起了火爆全球的 ChatGPT,心想,不如让它写一个试试。

测试 IO 抖动的脚本 

Cool,几乎不需要改动就能用,把代码扔在服务器开跑,一测,果然发现问题:每一次文件读写都需要 10-20ms 才能完成 。实际上,一个优秀的磁盘 IO 读写时延应该在亚毫级,但这里至少慢了 50 倍。 

Bingo,如同“幽灵宝藏”一般的分子级 Bug 逐渐显现,问题的根因已经确认:过慢的磁盘 IO 读写引发了一系列操作变慢,进而导致启动时间变得非常慢 

更庆幸的是,它还让我们发现了偶尔打开文件树变慢的根本原因,这也是整个系统并发能力下降的罪魁祸首 

05 迷雾追因

看到这里,大家可能会问,这套分布式文件系统莫非一直这么慢,你们为什么在之前没有发现?

非也,早在项目开始的时候,这里的时延是比较良好的,大家没有特别注意这个 IOPS 性能指标,直到我们后面才留意到,系统运行超过一个月时,IO 读写时延很容易就进入到卡顿的状态,表现就是文件系统所在主机 CPU 忽高忽低,重启就会临时恢复。

此时,探险之旅还没结束。毕竟,这个“幽灵宝藏”周围依旧笼罩着一层迷雾。

我们继续用 fatrace(监控谁在读写哪个 IO)监控线上各个候选人答题目录的 IO读写情况,好家伙,我们发现了一个意外的情况:几乎每一秒都有一次全量的文件 stats 操作 (这是一个检测文件是否有属性变化的 IO 操作)!

也就是说,比如有 1000 个候选人正在各自的 IDE 中编码,每个候选人平均有 300 个文件,就会出现每秒 30 万的 IO 操作数!

我们赶紧去查资料,根据研究数据显示,一个普通的 SSD 盘的 IOPS 最高也就到 2-3 万 。于是,我们重新测试了自己分布式文件系统的 IOPS 能力,结果发现也是 2-3 万 。

那这肯定远远达不到我们理想中的能力级别。

这时,问题更加明确:某种未知的原因导致了大量的 IOPS 的需求,引发了 IO 读写时延变长,慢了大约几十倍

06 接近尾声

我和我的团队继续深究下去,问题已经变得非常明确了:

原来,早在去年 12 月,我们上线一个监听文件增删的变化来通知各端刷新的功能。

最开始我们采用事件监听 (fswatch event),因为跨了主机,所以存在 1-2s 的延迟。研发同学将其改为轮询实现的方案,进而引发了每秒扫描目录的 stats 行为。

当在百人以下访问时,IOPS 没有破万,还足够应对。但一旦访问量上千,便会引发 IO 变慢,进而导致系统出现各种异常:间歇导致某些关键接口 QPS 变低,进而引发系统抖动

随着“幽灵宝藏”显露真身,这次分子级 Bug 的探险之旅也已经接近尾声。团队大 呼:这过程实在太酸爽了!

07 技术无止境

每一个程序员在成长路上,都需要与 Bug 作充足的对抗,要么你勇于探索,深入代码的丛林,快速定位,挖到越来越丰富的“宝藏”,然后尽情汲取到顶级的知识,最终成为高手;或者被它打趴下, 花费大量时间都找不到问题的根源,成为芸芸众生中的一人。

当然,程序员的世界中,不单单是 Debug。

当我毕业 5 年之后,开始意识到技术的真正价值是解决真正的社会问题。前文中我提到,由于我发现技术招聘真是一个极其痛苦的事:特别花面试官的时间,却又无法有效分析出候选人的技术能力,所以创立 ShowMeBug 来解决这个问题:用模拟实战的编程环境,解决科学评估人才的难度

这个轻协同 IDE 技术从零开发,支持协同文件树、完全自定义的文件编辑器、协同的控制台 (Console) 与终端 (Shell),甚至直接支持 Ctrl+P 的文件树搜索,不仅易于使用,又强大有力。

但是这还不够。要知道,追求技术精进是我们技术人的毕生追求。对于这个轻协同IDE,我们追求三个零:零配置、零启动、零延迟。其中,零启动就是本文所追求的极限:以最快的速度启动环境和切换环境

因此,探险之旅结束后,我们进一步改进了此文件系统,设定 raid 的多磁盘冗余,采用高性能 SSD,同时重新制定了新磁盘架构参数,优化相关代码,最终大幅提升了分布式文件系统的稳定性与并发能力。

截止本文结尾,我们启动环境的平均速度为 1.3 秒,切换环境速度进入到亚秒级,仅需要 780ms。目前在全球范围的技术能力评估赛道 (TSA) 中,具备 1-2 年的领先性

08 后记

正当我打算结束本文时,我们内部的产品吐槽群信息闪烁,点开一看:嚯,我们又发现了新 Bug。

立夏已至,我们的探险之旅又即将开始。


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

写"简单"而不是"容易"的代码

简单 vs 容易简单 == 容易?在大多数人的第一印象中,这两个词好像是等同的,我们在很多场景下会相互替换这两个词而表达出相同的意思,比如下面这些:“高数题很简单/容易” “开车很容易/简单” “C语言写起来很简单/容易”当然 “抗原测试阳性也很容易...
继续阅读 »

简单 vs 容易

简单 == 容易?

在大多数人的第一印象中,这两个词好像是等同的,我们在很多场景下会相互替换这两个词而表达出相同的意思,比如下面这些:

“高数题很简单/容易” “开车很容易/简单” 


image

“C语言写起来很简单/容易

当然 “抗原测试阳性也很容易/简单”...

对于以上这些句子我想大家都是认同的......吧?好吧,或许前三个问题可能有不同的意见,但在第四个问题上我们应该还是能达成一致的(如果还是有疑问,也许去医院溜达一圈可以改变你的想法,即使全程佩戴口罩;))。

正如上面的句子所示,随意更换结尾的 简单 和 *容易 *两个词,好像依然可以得出一样的含义,这并不会带来理解上的偏差,所以我们可以就此推出结论 简单 == 容易 吗?或许我们可以从另一个角度再来看下这个问题

从其他语言体系中收获一些启发:

Simple:

image

在英文单词中 Simple** **的词根是sim 和 plex, 可以理解是一层,一圈,至于plex 中所代表的折叠和扭曲的含义,当只有一层或者一圈时,实际上也就是没有折叠,不扭曲了,这个词的反义词是complex,意思是编织在一起或者折叠在一起

image

因此对应到软件开发过程中,当我们追求简单的事物时,其中最关键的一点就是,我们希望它是专注于某一方面的,我们不想看到它和其他事物交织在一起,当然,这并不意味着我们需要太过追求单一一个,相反 简单* *的关键无关乎数量,更多的是追求是更少的交织,或者是没有交织,这一点才是重中之重,正如我们上面描述的一样,事物是否是交织重叠的,只要进去看下就知道了,它是客观的,是可以深入去研究的,而这种客观也是后面我们区分于 **容易 **的核心所在

Easy:

image

来源于拉丁语动词adjacere(附近放置),其现在分词为adjacens(附近的,手边的,方便的,英语adjacent的词源,adjective的间接词源),进入古法语后有名词aise(英语ease的词源),派生了动词aisier(轻松、随意放置),其过去分词aisie进入盎格鲁-诺曼底语中为aise,进入英语为easy

而 **Easy *就有趣了,其最初是来源于拉丁词 ***adjacens(附近的,手边的,方便的)一词。***靠近是一个很有意思的概念,*我们可以从下面几个方面来理解下它

  1. 物理意义上的靠近

这个东西就在你附近,触手可得,不需要骑车,或是开车去

  1. 靠近我们已知的东西,或者换个词:熟悉

对于我们来说俄语很难吗?当然是的,不过对于俄国人来说他们可能并不会这么觉得,无非是他们相对来说更熟悉罢了

  1. 靠近我们的能力

手里有一把锤子,看什么都像钉子,当我们说这个东西很容易的时候,很大程度上是因为我们已经想到了一些功能相似的东西

所以,simple** == easy** 吗?不,easy 是相对的, 弹钢琴和俄语对我来说真的很难,但是对其他人来说却很容易。不像 *simple,它是客观的,*我们总是可以进去看看,寻找是否存在重叠和交叉,而 easy 总是要问一句,对谁来说容易,对谁来说很难?

为什么需要简单,而不是容易

许多时候我们说的简单,都是从自身出发的,其实更应该用词为 容易;而真正的 **简单 **,是不和别的东西耦合的,独立的东西。多数时候我们在产品开发过程中的冲突在于,产品经理会说自己的设计是简单的(Easy),但是开发同学认为复杂(不符合自己开发的 Easy),但是却忽略了,我们真正需要的是 Simple

这样说你会发现,我们做的许多事情,往往都是从 easy 开始,而不是 simple 。 easy 开始的速度很快,但是随着项目的扩展,复杂度越来越高,速度慢慢就掉下来了 —— 想想每次重构代码的痛苦吧。而 simple 则刚开始并没有太快的速度,因为需要定义许多的东西,抽象归纳许多对象,但后续推进则是越来越快 —— 因为结构清晰构件完备,只需要理解有限的上下文就可以完成模块的修改或扩展。

因为限制

任何事情都是有限制的:

  1. 我们只能让我们理解的东西变可靠

  2. 我们只能在同时思考很少的一些事情

  3. 互相纠缠的事情我们只能把它们放在一起来思考

  4. 复杂性会降低我们的理解

我们怎么可能制造出我们不了解的可靠产品,当我们在某些系统上,想在未来使事情变得更加灵活、可扩展和动态时,我们将在理解它们的行为并确保它们正确的能力上做出权衡。但是对于我们想要确保正确的事情,我们都将受到限制,受限于对它的理解。

而且我们的理解力是很有限的,举个例子,你一次能在空中保持多少个球,或者你一次能记住多少件事?数量有限,而且数量很少,对吧?所以我们只能考虑一些事情,当事情交织在一起时,我们就失去了独立对待它们的能力。

image

image

抛球讲解:http://www.matrix67.com/blog/archiv…

因此,每次我们需要理解软件的一个新部分,并且它与另一件事相关联时,我就不得不将另一件事拉入脑海,因为我们无法在没有另一件事的情况下考虑这件事。这就是他们交织在一起的本质。因此,每一次交织都会增加这种负担,而这种负担是我们可以考虑的事物数量的组合。因此,从根本上说,这种复杂性,这种将事物编织在一起,将极大的限制我们理解系统的能力

简单带来的收益

《针织城堡》 《积木城堡》

  • 容易理解

理解代码想表达什么,而不是写的是什么

  • 容易改变

你能想象在一个针织的城堡上做改动吗?

  • 容易debug

  • 灵活性

容易代码的产生

随着时间的拉长和各种各样因素的干扰,代码慢慢就脱离了我们的掌控,当回过神来,看着日积月累的代码库,我们会发现它已大到经难以撼动了,只能寄希望于它不会哪一天突然炸开,或赶在它炸开前把它扔出去。

坏味道

image

image

读侦探小说时,透过一些神秘的文字猜测故事情节是一种很棒的体验;但如果是在阅读代码,这样的体验就不怎么好了。我们也许会幻想自己是《名侦探柯南》中的柯南,但我们写下的代码应该直观明了,代码中最重要的一环就是好的名字。

然而,很遗憾,命名是编程中最难的事情之一,所以我们需要深思熟虑如何给函数、模块、变量和类命名,使它们能清晰地表明自己的功能和用法,很多情况下我们不愿意给程序元素改名,觉得不值得费这个劲,但好的名字能节省未来用在猜谜上的大把时间。

重复代码

如果你在一个以上的地点看到相同的代码结构,那么可以肯定,设法将它们合而为一,程序会变得更好。一旦有重复代码存在,阅读这些重复的代码时你就必须加倍仔细,留意其间细微的差异。如果要修改重复代码,你必须找出所有的副本来修改。

过长函数

函数越长,就越难理解,从已有的经验中可以看出,活得最长、最好的程序,其中的函数往往都比较短。初次接触到这种代码库的程序员常常会觉得“计算都没有发生”——程序里满是无穷无尽的委托调用。但和这样的程序共处几年之后,你就会明白这些小函数的价值所在。间接性带来的好处,更好的阐释力、更易于复用、更多的选择,都是由小函数来支持的。

过长参数列表

把函数所需的所有东西都以参数的形式传递进去。这可以理解,因为除此之外就只能选择全局数据,而全局数据很快就会变成邪恶的东西。但过长的参数列表本身也经常令人迷惑,使用它的人必须小心再小心。尤其在参数末尾出现了布尔类型的参数时,更容易引起人的误解,传true或false会有什么不同吗?

发散式变化

我们希望软件能够更容易被修改——毕竟软件本来就该是“软”的。一旦需要修改,我们希望能够跳到系统的某一点,只在该处做修改。如果不能做到这一点,这可不是一个好的信号。

重复的switch

在不同的地方反复使用同样的switch 逻辑(可能是以switch/case语句的形式,也可能是以连续的if/else语句的形 式)。重复的switch的问题在于:每当你想增加一个选择分支时,必须找到所有 的switch,并逐一更新。

“所有条件逻辑都应该用多态取代,绝大多数if语句都应该被扫进历史的垃圾桶”,这不免有些矫枉过正了

过大的类

一个类过大往往是因为想利用单个类做太多事情,其内往往就会出现太多字段。一旦如此,重复代码也就接踵而至了

注释

当然,并不是说你不该写注释,之所以要在这里提到注释是因为注释往往被当做除臭剂来使用了,大多数情况下注释的大量出现是是因为代码本身已经很糟糕了

有意义的注释应该更多关注无法通过代码本身表达的内容,除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己“为什么做某某事”,这类信息可以帮助将来的修改者理解代码所没有表达出来的细节

保持简单的代码

 

什么是重构

如何保证代码不随着时间和迭代而慢慢腐坏呢? 恐怕没有比重构更有效的方法了吧,但是重构一词近些年被用的太广泛了,很多人用“重构”这个词来指代任何形式的代码清理,但实际上用“结构调整”来泛指对代码库进行的各种形式的重新组织或清理更准确一点,而重构则是特定的一类“结构调整”,整体而言,经过重构之后的代码所做的事应该与重构之前大致一样:

 

重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本

重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构

也就是说,如果你在重构过程中发现了一个bug,那么重构之后它应该依然存在(当然,你可以在重构后顺手就修改掉它),同样,我们也需要把它跟性能优化也区分下开,他们两个也很像,都是在不改变程序的可观测行为下做的改动,两者的差别在于,重构的目的是为了让代码更容易理解和更容易修改,最终可能使程序更快了,也可能更慢了,而性能优化时,我们只关注让程序运行的更快,最终可能使得代码变得更难以理解和维护了,当然这点也是在我们预期之内的。

为何要重构

 

重构并不是万能药,它只是一种工具,帮助我们达到以下目的方式中的一种

▪  保持软件的设计

如果没有重构,程序的内部设计会逐渐腐败,当我们经常只为了短期目的而修改代码时,往往会忽略掉或者说没有理解整体的设计,于是代码会逐渐失去其结构,我们会越来越难以通过阅读代码来理解原来的设计,而随着代码结构的流失,我们也将越来越难以维护其设计意图,导致更快的代码腐败,所以,经常性的重构有助于我们维护代码应有的形态

▪ 使软件更容易理解

机器并不关心程序的样子,它只是按照我们的指示精确执行罢了,但别忘记了,除了我们自己和机器外,代码还有其他的读者,几个月后可能会有另一个程序员尝试读懂你的代码并做一些修改,他才是重要的那一个,相比于机器是否多消耗一个时钟周期,如果一个程序员需要花费一周时间才能读懂你的代码并修改才更要命呢。

重构可以帮我们让代码更易读。开始进行重构前,代码可以正常运行,但结构不够理想。在重构上花一点点时间,就可以让代码更好地表达自己的意图——更清晰地说出我想要做的。

▪ 提高编程速度

归结到最后其实可以总结为一点:重构帮我们更快速地开发程序

我们很容易得出这些好处:改善设计、提升可读性、减少bug,这些都是在提高质量。但花在重构上的时 间,难道不是在降低开发速度吗?

我们在软件开发中经常能碰到这种场景,一个团队,一开始的迭代和进展都很快,但是如今想要添加一个新的功能需要的时间就长的多了,bug排查和修复也越来越慢,代码库一个补丁摞一个补丁,需要细致的“考古”工作才能搞懂系统是如何工作的,这些负担会不断的拖累我们的开发速度,直到最后恨不得重写系统。

 

有些团队则不同,他们添加新功能的速度越来越快,因为他们可以利用已有的功能快速构建新功能,两者

的主要区别就在于软件的内部质量,良好的内部质量可以让我们轻易找到在哪里以及如何修改,良好的模块划分可以使我们只需要理解代码的一小块就可以做出修改,引入bug的可能性也会变小,即使有了bug,我们也可以很快的找出来并修复掉,最终我们的代码会演变成一个平台,在其上,我们可以很容易的构造其领域相关的新功能

 

 

何时重构

▪ 添加新功能

重构的最佳时机就在添加新功能之前,当要添加一个功能时,我们一般都会先看下代码库中已有的内容,此时经常可以发现,有些函数或者代码只要稍微调整下结构,就能使我们添加新功能变得更容易,可能只是一个函数的参数不太一样或是代码中一些字面量不太一样,如果不先重构,就只能把这段代码复制过来,修改几个值,这样就产生了重复的代码,更麻烦的是,一旦后续需要调整这块逻辑,我就需要同时修改这两处(希望我还能想起来有两处需要修改)

就好像要去东边的上海,你不一定会直接向东开,而是先向北开去上高速,后者会使你更快到达目的地。

▪ 使代码更易懂

在做改动前你需要先先理解代码在做什么,然后才能修改,这段代码可能是自己写的,也可能是别人写的,一旦你需要花费很大精力思考代码在做什么时,这就是一个好的时机了,“这个变量名称代表这个意思”,“这段逻辑是这样工作的”......我们通常无法把太多的细节长时间留存在脑海里,为什么不把他转移到代码本身,使其一目了然呢?如果把对代码的理解植入代码中,这份知识会保存得更久,其他人也能获得同等的收益。

不仅如此,长远来看的话,当代码变得更易理解时,我们通常能够看到之前设计中没有看见的问题,如果没有做前面的重构,也许我们永远也看不到这些设计问题,因为我们不够聪明,无法在脑海中推演所有的变化。

▪ 有计划的重构

上面的重构时机都是夹杂在我们日常的功能开发中的,重构本身就是我们开发新功能或者修复bug的一环,但有时候,因为快速的功能迭代而忽视了代码的设计,问题就会在某些区域逐渐累积长大,最终需要专门花些时间来解决,但这种有计划的重构应该很少,大部分重构应该是不起眼的、见机行事的。

▪ 何时不应该重构

虽然我们提倡重构,但确实有一些不值得重构的情况。

如果看见一块凌乱的代码,但并不需要修改它,那么就不需要重构它。 如果丑陋的代码能被隐藏在一个函数或者API之下,我们可以暂时容忍它继续保持丑陋。只有当我们需要理解其工作原理时,对其进行重构才有价值。

另一种情况是,如果重写比重构还容易,就别重构了,当然在这之前我们总是需要花些时间尝试的。

重构的挑战

 

▪ 延缓新功能开发

从上面的讨论中其实我们已经得到这个问题的答案了,重构的意义不在于炫技或是把代码库打磨的闪闪发光,而是纯粹从经济角度出发的考量。我们之所以重构,因为它能让我们更快,添加功能更快,修复bug更快。如果有人告诉我们“重构会拖慢进度”,我们应该坚信,他说的一定是别的东西,而不是重构,重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值

▪ 代码归属

很多重构可能不仅涉及一个模块的改动,同时也会影响其他一些模块或者系统,代码的所有权边界会妨碍我们的一些重构,例如修改某个模块暴露出去的函数命名,不仅实现方需要修改,调用方也需要修改,尤其是涉及到暴露给其他团队的api,情况可能会更复杂,很有可能根本不知道调用法有哪些

当然这并不会完全阻止我们,只是受到很多限制罢了,比如我们可以同时保留老的函数签名和api,使其内部调用重构后的实现,等到确认所有调用方都修改后,再删除老的声明,当然也可能选择永久保留老的声明。

▪ 分支合并

大多数情况下,我们都是多个人同时维护一个代码仓库的,现代便利的仓库分支管理工具很好的支持了我们的团队协作,使我们可以更快的完成产品的开发,我们通常会从主干上拉取一个功能分支进行开发,直到功能上线时才会合并会主干,以保证主干不被功能分支所影响,这存在一个问题,我们的功能分支存在的时间越久,和主干的差异就会越大,尤其是当多个人同时在进行不同的功能分支开发时情况会更加复杂,当你因为重构修改了一个函数的命名,而其他人在新加代码中又使用了它,当代码集成时,问题就来了,而且随着功能分支存在时间的增加,这种痛苦也会不断的增加。

▪ 测试

重构的一个重要特征就是--不会改变程序可观察的行为,我们不能寄希望于“只要我足够小心,就不会破坏任何东西”,但万一我们犯了个错误怎么办?或许应该把万一两个字去掉,人总是会犯错误的,关键是在于如何快速的发现错误,要做到这一点,我们就需要一套能够快速测试的代码组件,所以大多数情况下如果我们想要重构,我们就需要先有可以自测试的代码,一旦能够自测试,我们就可以使用很小的步子进行前进,一旦测试失败,我们只需要执行退回到上一次可以成功运行的状态即可。

▪ 遗留代码

大多数情况下,有一大笔遗产是件好事,但从程序员的角度来看就不同了。遗留代码往往很复杂,测试又不足,而且最关键的是,是别人写的。重构能很好的帮助我们理解系统,理顺代码的逻辑,但是关键点在于遗留系统多半没测试。如果你面对一个庞大而又缺乏测试的遗留系统,很难安全地重构清理它。

对于这个问题,显而易见的答案就是“没测试就加测试”,说起来轻松,做起来可就不轻松了,一个系统只有在一开始设计时就考虑到了测试,添加测试才会容易,可要是如此的话系统早就该有测试了,还需要现在才开始加吗?

但是,无论如何,就像《整洁代码之道》中所说的那样:“让营地比你来时更干净些”。

如何安全的重构

▪ TDD

▪ 自动化重构


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

再学http-为什么文件上传要转成Base64?

1 前言最近在开发中遇到文件上传采用Base64的方式上传,记得以前刚开始学http上传文件的时候,都是通过content-type为multipart/form-data方式直接上传二进制文件,我们知道都通过网络传输最终只能传输二进制流,所以毫无疑问他们本质...
继续阅读 »

1 前言

最近在开发中遇到文件上传采用Base64的方式上传,记得以前刚开始学http上传文件的时候,都是通过content-type为multipart/form-data方式直接上传二进制文件,我们知道都通过网络传输最终只能传输二进制流,所以毫无疑问他们本质上都是一样的,那么为什么还要先转成Base64呢?这两种方式有什么区别?带着这样的疑问我们一起来分析下。

2 multipart/form-data上传

先来看看multipart/form-data的方式,我在本地通过一个简单的例子来查看http multipart/form-data方式的文件上传,html代码如下

<!DOCTYPE html>
<html>
<head>
<title>上传文件示例</title>
<meta charset="UTF-8">
<body>
<h1>上传文件示例</h1>
<form action="/upload" method="POST" enctype="multipart/form-data">
<label for="file">选择文件:</label>
<input type="file" id="file" name="file"><br>
<label for="tx">说明:</label>
<input type="text" id="tx" name="remark"><br><br>
<input type="submit" value="上传">
</form>
</body>
</html>

页面展示也比较简单

image.png

选择文件点击上传后,通过edge浏览器f12进入调试模式查看到的请求信息。
请求头如下 image.png 在请求头里Content-Type 为 multipart/form-data; boundary=----WebKitFormBoundary4TaNXEII3UbH8VKo,刚开始看肯定有点懵,不过其实也不复杂,可以简单理解为在请求体里要传递的参数被分为多部份,每一部分通过分解符boundary分割,就比如在这个例子,表单里有file和remark两个字段,则在请求体里就被分为两部分,每一部分通过boundary=----WebKitFormBoundary4TaNXEII3UbH8VKo来分隔(实际上还要加上CRLF回车换行符,回车表示将光标移动到当前行的开头,换行表示一行文本的结束,也就是新文本行的开始)。需要注意下当最后一部分结尾时需要加多两个"-"结尾。
我们继续来看请求体

image.png 第一部分是file字段部分,它的Content-Type为image/png,第二部分为remark字段部分,它没有声明Content-Type,则默认为text/plain纯文本类型,也就是在例子中输入的“测试”,到这里大家肯定会有个疑问,上传的图片是放在哪里的,这里怎么没看到呢?别急,我猜测是浏览器做了特殊处理,请求体里不显示二进制流,我们通过Filder抓包工具来验证下。

image.png 可以看到在第一部分有一串乱码显示,这是因为图片是二进制文件,显示成文本格式自然就乱码了,这也证实了二进制文件也是放在请求体里。后端使用框架springboot通过MultipartFile接受文件也是解析请求体的每一部分最终拿到二进制流。

@RestController
public class FileController {
// @RequestParam可接收Content-Type 类型为:multipart/form-data 
// 或 application/x-www-form-urlencoded 请求体的内容
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
return "test";
}
}

到此multipart/form-data方式上传文件就分析完了,关于multipart/form-data官方说明可参考 RFC 7578 - Returning Values from Forms: multipart/form-data (ietf.org)

3 Base64上传

在http的请求方式中,文件上传只能通过multipart/form-data的方式上传,这样一来就会有比较大的限制,那有没其他方式可以突破这一限制,也就是说我可以通过其他的请求方式上传,比如application/json?当然有,把文件当成一个字符串,和其他普通参数没什么两样,我们可以通过其他任意请求方式上传。如果转成了字符串,那上传文件就比较简单了,但问题是我们怎么把二进制流转成字符串,因为这里面可能会有很多“坑”,业界一般的做法是通过Base64编码把二进制流转成字符串,那为什么不直接转成字符串而要先通过Base64来转呢?我们下面来分析下。

3.1 Base64编码原理

在分析原理之前,我们先来回答什么是Base64编码?首先我们要知道Base64只是一种编码方式,并不是加解密算法,因此Base64可以编码,那也可以解码,它只是按照某种编码规则把一些不可显示字符转成可显示字符。这种规则的原理是把要编码字符的二进制数每6位分为一组,每一组二进制数可对应Base64编码的可打印字符,因为一个字符要用一个字节显示,那么每一组6位Base64编码都要在前面补充两个0,因此总长度比编码前多了(2/6) = 1/3,因为6和8最小公倍数是24,所以要编码成Base64对字节数的要求是3的倍数(24/8=3字节),对于不足字节的需要在后面补充字节数,补充多少个字节就用多少个"="表示(一个或两个),这么说有点抽象,我们通过下面的例子来说明。
我们对ASCII码字符串"AB\nC"(\n和LF都代表换行)进行Base64编码,因为一共4字节,为了满足是3的倍数需要扩展到6个字节,后面补充了2个字节。

image.png

表3.1

转成二级制后每6位一组对应不同颜色,每6位前面补充两个0组成一个字节,最终Base64编码字符是QUIKQw==,Base64编码表大家可以自行网上搜索查看。

image.png 我们通过运行程序来验证下

image.png 最终得出的结果与我们上面推理的一样。

3.2 Base64编码的作用

在聊完原理之后,我们继续来探讨文件上传为什么要先通过Base64编码转成字符串而不直接转成字符串?一些系统对特殊的字符可能存在限制或者说会被当做特殊含义来处理,直接转成普通字符串可能会失真,因此上传文件要先转成Base64编码字符,不能把二进制流直接字符串。

另外,相比较multipart/form-data Base64编码文件上传比较灵活,它不受请求类型的限制,可以是任何请求类型,因为最终就是一串字符串,相当于请求的一个参数字段,它不像二进制流只能限定multipart/form-data的请求方式,日常开发中,我们用的比较多的是通过apllication/json的格式把文件字段放到请求体,这种方式提供了比较便利的可操作性。

4 总结

本文最后再来总结对比下这两种文件上传的方式优缺点。
(1)multipart/form-data可以传输二进制流,效率较高,Base64需要编码解码,会耗费一定的性能,效率较低。
(2)Base64不受请求方式的限制,灵活度高,http文件二进制流方式传输只能通过multipart/form-data的方式,灵活度低。
因为随着机器性能的提升,小文件通过二进制流传输和字符串传输,我们对这两种方式时间延迟的感知差异并不那么明显,因此大部分情况下我们更多考虑的是灵活性,所以采用Base64编码的情况也就比较多。


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

懒汉式逆向APK

通过各方神仙文档,以及多天调试,整理了这篇极简反编译apk的文档(没几个字,吧).轻轻松松对一个apk(没壳的)进行逆向分析以及调试.其实主要就是4个命令.准备下载apktool下载Android SDK Build-Tools,其中对齐和签名所需的命令都在此...
继续阅读 »

通过各方神仙文档,以及多天调试,整理了这篇极简反编译apk的文档(没几个字,吧).轻轻松松对一个apk(没壳的)进行逆向分析以及调试.其实主要就是4个命令.

准备

  1. 下载apktool
  2. 下载Android SDK Build-Tools,其中对齐和签名所需的命令都在此目录下对应的版本的目录中,比如我的在D:\sdk\build-tools\30.0.3目录下,可以将此目录加入环境变量中,后续就可以直接使用签名和对齐所需的命令了
  3. 可选,下载jadx-gui,可查看apk文件,并可导出为gralde项目供AS打开

流程

  1. 解压apk: apktool d C:\Users\CSP\Desktop\TEMP\decompile\test.apk -o C:\Users\CSP\Desktop\TEMP\decompile\test,第一个参数是要解压的apk,第二个参数(-o后面)是解压后的目录

  2. 修改: 注意寄存器的使用别错乱,特别注意,如果需要使用更多的寄存器,要在方法开头的.locals x或.registers x中对x+1

    • 插入代码:在idea上使用java2smali插件先生成smali代码,可复制整个.smali文件到包内,或者直接复制smali代码,注意插入后修改包名;
    • 修改代码:需要熟悉smali语法,可自行百度;
    • 修改so代码,需要IDA,修改完重新保存so文件,并替换掉原so文件,注意如有多个架构的so,需要都进行修改并替换;
    • 删除代码:不建议,最好逻辑理清了再删,但千万别删一半;
    • 资源:修改AndroidManifest.xml,可在application标签下加入android:debuggable="true",重新打包后方可对代码进行调试;
  3. 重打包apktool b C:\Users\CSP\Desktop\TEMP\decompile\test -o C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk,第一个参数是要进行打包的目录文件,第二个参数(-o后面)是重新打包后的apk路径.重新打包成功,会出现Press any key to continue ...

  4. 对齐zipalign -v 4 C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk,第一个参数是需要进行对齐的apk路径,第二个参数是对齐后的apk路径.对齐成功,会出现Verification succesful

  5. 签名apksigner sign -verbose --ks C:\Users\CSP\Desktop\软件开发\反编译\mykeys.jks --v1-signing-enabled true --v2-signing-enabled true --ks-key-alias key0 --ks-pass pass:mykeys --key-pass pass:mykeys --out C:\Users\CSP\Desktop\TEMP\decompile\test_b_sign.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk,第一个参数(--ks后面)是密钥路径,后面跟着是否开启V1、V2签名,在后面跟着签名密码,最后两个参数(--out后面)是签名后的apk路径以及需要签名的apk(注意需对齐)路径.签名成功,会出现Signed

  6. 安装adb install C:\Users\CSP\Desktop\TEMP\decompile\test_b_sign.apk

  7. 调试: 用jdax将apk导出为gradle项目,在AS中打开,即可通过attach debugger的方式对刚重新打包的项目进行调试.注意,调试时因为行号对不上,所以只能在方法上打上断点(菱形图标,缺点,运行速度极慢)

  8. 注意事项:

    • 上述命令中,将目录和项目'test'改成自己的目录和项目名即可;
    • apktool,zipalign,apksigner,adb命令需加入环境变量,否则在各自的目录下./xxx 去执行命令;
    • zipalign,apksigner所需的运行文件在X:XX\sdk\build-tools\30.0.3目录下;
    • 使用apksigner签名,对齐操作必须在签名之前(推荐使用此签名方式);
    • 新版本Android Studio生成的签名密钥,1.8版本JDK无法使用,我是安装了20版本的JDK(AS自带的17也行)

假懒

为了将懒进行到底,写了个bat脚本(需要在test文件目录下):

::关闭回显
@echo off
::防止中文乱码
chcp 65001
title 一键打包

start /wait apktool b C:\Users\CSP\Desktop\TEMP\decompile\test -o C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk
start /b /wait zipalign -v 4 C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk
start /b /wait apksigner sign -verbose --ks C:\Users\CSP\Desktop\软件开发\反编译\mykeys.jks --v1-signing-enabled true --v2-signing-enabled true --ks-key-alias key0 --ks-pass pass:mykeys --key-pass pass:mykeys --out C:\Users\CSP\Desktop\TEMP\decompile\test_b_sign.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk

大家将此脚本复制进bat文件,即可一键输出.

不过目前略有瑕疵:1.重新打包需要新开窗口,并且完成后还需手动关闭;2.关闭后还要输入'N'才能进行后续的对齐和签名操作有无bat大神帮忙优化下/(ㄒoㄒ)/~~!

-------更新

真懒

对于'假懒'中的打包脚本,会有2个瑕疵,使得不能将懒进行到底.经过查找方案,便有了以下'真懒'的方案,使得整个打包可以真正一键执行:

::关闭回显
@echo off
::防止中文乱码
chcp 65001
title 一键打包

call apktool b C:\Users\CSP\Desktop\TEMP\decompile\test -o C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk
call zipalign -v 4 C:\Users\CSP\Desktop\TEMP\decompile\test_b.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk
del test_b.apk
call apksigner sign -verbose --ks C:\Users\CSP\Desktop\软件开发\反编译\mykeys.jks --v1-signing-enabled true --v2-signing-enabled true --ks-key-alias key0 --ks-pass pass:mykeys --key-pass pass:mykeys --out C:\Users\CSP\Desktop\TEMP\decompile\test_b_sign.apk C:\Users\CSP\Desktop\TEMP\decompile\test_b_zipalign.apk
del test_b_zipalign.apk

echo 打包结束
echo 输出文件是-----test_b_sign.apk

pause

可以看到,把start换成了call,并且删除了重新打包和对齐后的文件,只留下最后签完名的文件

image.png

到此够了吗?不够,因为运行第一个apktool b命令时,重新打包完,会被pasue,让你按一个按键再继续.

image.png

这当然不行,这可不算一键,那么我们找到apktool的存放路径,打开apktool.bat,找到最后一行

image.png

就是这里对程序暂停了,那么就把这一行删了,当然最好是注释了就行,在最前面rem即可对命令进行注释,处理完之后,再重新运行我们的'一键打包.bat'脚本,这时候在中途重新打包后就不会出现'Press any key to continue...'了,即可一键实现打包-对齐-签名的流程了( •̀ ω •́ )y.

当然,如果想使脚本到处运行,可以给脚本增加一个变量,在运行前通过环境变量的形式把要打包的目录路径加入进来,这个大家可以自行尝试.

最后,感谢大家的阅读.这里面有对smali和so的修改,有机会和时间,我也会继续分享!!!


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

Parcelable为什么速度优于 Serializable ?

在Android开发中,我们有时需要在组件之间传递对象,比如Activity之间、Service之间以及进程之间。传递对象的方式有三种:将对象转换为Json字符串通过Serializable序列化通过Parcelable序列化 1、什么是序列化  序...
继续阅读 »

在Android开发中,我们有时需要在组件之间传递对象,比如Activity之间、Service之间以及进程之间。
传递对象的方式有三种:

  • 将对象转换为Json字符串
  • 通过Serializable序列化
  • 通过Parcelable序列化 

1、什么是序列化

微信截图_20230619105720.png

  序列化:简单来说,就是将实例的状态转化为可以存储或者传输的形式的过程
  反序列化:反过来再把这种形式还原成实例的过程就叫做反序列化

  这种可以传输或者存储的形式,可以是二进制流,也可以是字符串,可以被存储到文件,也可以通过各种协议被传输。

2、Serializable 的实现原理

   Serializable 是 Java 平台中用于对象序列化和反序列化的接口。,它是一个空接口,没有定义任何方法,它仅仅只起到了标记作用。通过实现它,Java 虚拟机可以识别该类是可以进行序列化和反序列化操作的。 

2.1 Serializable 序列化的使用

将一个对象序列化写入文件:

public class User implements Serializable {

private String name;
private String email;

public User(String name, String email) {
this.name = name;
this.email = email;
}

/***** get set方法省略 *****/
}
File file = new File("write.txt");

//序列化写入文件
FileOutputStream fileOutputStream = new FileOutputStream(file);
ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
objectOutputStream.writeObject(new User("李四", "lisi@qq.com"));
objectOutputStream.flush();

//读取文件反序列化
FileInputStream fileInputStream = new FileInputStream(file);
ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
User user = (User) objectInputStream.readObject();

序列化写入文件的结果:
image.png

2.2 Serializable 的关键方法ObjectOutputStream() 和 writeObject()

  那么对于一个只是实现了一个空接口的实例,ObjectOutputStream是如何做到知道这个类中都有哪些属性结构的呢?并且是如何获取它们的值的呢?
  我们来看一下 ObjectOutputStream的源码实现,在它的构造方法中主要做两件事:

  • 创建一个用于写入文件的Stream
  • 写入魔数和版本号
public ObjectOutputStream(OutputStream out) throws IOException {
verifySubclass();
bout = new BlockDataOutputStream(out);//创建Stream
...
writeStreamHeader();//写入魔数和版本号
...
}

  再来看 writeObject() 方法,writeObject的核心是调用 writeObject0()方法,在writeObject0中通过 ObjectStreamClass desc = ObjectStreamClass.lookup(cl, true) 创建出一个原始实例的描述信息的实例,即desc。desc这个描述信息中就包括原始类的属性结构和属性的值。接着再根据实例的类型调用对应的方法将实例的类名和属性信息写入到输出流;字符串、数组、枚举和一般的实例写入的逻辑也是不同的。


image.png

2.3 性能分析

  很明显在序列化的过程中,写输出流的过程肯定不存在输出瓶颈,复杂操作集中在如何去解析原始对象的结构,如何读取它的属性。所以要把重点放在ObjectStreamClass这个类是如何的被创建出来的。
  我们分析lookup方法,发现创建过程会先去读取缓存。如果发现已经解析并且加载过相同的类,那么就直接返回。在没有缓存的情况下,才会根据class去创建新的ObjectStreamClass实例。

    static ObjectStreamClass lookup(Class<?> cl, boolean all) {
...
Reference<?> ref = Caches.localDescs.get(key);//读取缓存
if (ref != null) {
entry = ref.get();
}


if (entry instanceof ObjectStreamClass) {
return (ObjectStreamClass) entry;
}


...

if (entry == null) {
entry = new ObjectStreamClass(cl);//没有缓存
}

if (entry instanceof ObjectStreamClass) {
return (ObjectStreamClass) entry;
}
}

  在创建过程中,类名name是通过class实例调用反射API来获取的。再通过getDeclaredSUID 方法提取到serialVersionUID 字段信息。如果没有配置,getSerialVersionUID 方法会通过 computeDefaultSUID 生成一个默认的序列号。
  接下来就会去获取属性以及计算属性值的偏移量。

    private ObjectStreamClass(final Class<?> cl) {
name = cl.getName();//类名

suid = getDeclaredSUID(cl);//提取 serialVersionUID 字段信息


fields = getSerialFields(cl);//获取属性 即通过反射获取该类所有需要被序列化的Field
computeFieldOffsets();//计算属性值的偏移量
}

  我们再来看一下读取属性信息的代码 getSerialFields(),首先系统会判断我们是否自行实现了字段序列化 serialPersistentFields 属性,否则走默认序列化流程,既忽律 static、transient 字段。

    private static ObjectStreamField[] getSerialFields(Class<?> cl)
throws InvalidClassException
{
ObjectStreamField[] fields;
if (Serializable.class.isAssignableFrom(cl) &&
!Externalizable.class.isAssignableFrom(cl) &&
!Proxy.isProxyClass(cl) &&
!cl.isInterface())
{
if ((fields = getDeclaredSerialFields(cl)) == null) {
fields = getDefaultSerialFields(cl);//默认序列化字段规则
}
Arrays.sort(fields);
} else {
fields = NO_FIELDS;
}
return fields;
}

  然后在getDefaultSerialFields 中使用了大量的反射API,最后把属性信息构建成了ObjectStreamField的实例。

    private static ObjectStreamField[] getDefaultSerialFields(Class<?> cl) {
Field[] clFields = cl.getDeclaredFields();//获取当前类的所有字段
ArrayList<ObjectStreamField> list = new ArrayList<>();
int mask = Modifier.STATIC | Modifier.TRANSIENT;

for (int i = 0; i < clFields.length; i++) {
if ((clFields[i].getModifiers() & mask) == 0) {
//将其封装在ObjectStreamField中
list.add(new ObjectStreamField(clFields[i], false, true));
}
}
int size = list.size();
return (size == 0) ? NO_FIELDS :
list.toArray(new ObjectStreamField[size]);
}

  到这里我们会发现Serializable 整个计算过程非常复杂,而且因为存在大量反射和 GC 的影响,序列化的性能会比较差。另外一方面因为序列化文件需要包含的信息非常多,导致它的大小比 Class 文件本身还要大很多,这样又会导致 I/O 读写上的性能问题。

  总结,实现Serializable接口后 ,Java运行时将使用反射来确定如何编组和解组对象。所以我们可以认定这些反射操作就是影响 Serializable 性能的一个重要的因素,同时会创建大量临时对象并导致相当多的垃圾收集。但是因为这些反射,所以Serializable的使用非常简单。

3、Parcelable的实现原理

  在Android中提供了一套机制,可以将序列化之后的数据写入到一个共享内存。其他进程就可以通过Parcel来读取这块共享内存中的字节流,并且反序列化成实例。Parcelable相对于Serializable的使用相对复杂一些。
微信截图_20230629112841.png

3.1 Parcelable 序列化的使用

public class User implements Parcelable {

private String name;

private String email;

//反序列化
protected User(Parcel in) {
name = in.readString();
email = in.readString();
}


public static final Creator<User> CREATOR = new Creator<User>() {
@Override
public User createFromParcel(Parcel in) {
return new User(in);
}

@Override
public User[] newArray(int size) {
return new User[size];
}
};

@Override
public int describeContents() {
return 0;
}

// 用于序列化
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(name);
dest.writeString(email);
}
}
User user = new User();
Bundle params = new Bundle();
params.putParcelable("user", user);

Bundle arguments = getArguments();
personBean = arguments.getParcelable("user");

  实现 Parcelable 接口,需要实现writeToParcel的方法以提供序列化时,将数据写入Parcel的代码。除此之外还要提供一个Creator以及一个参数是Parcel类型的构造方法,用来反序列化。
  序列化和反序列化每个字段的代码统一由使用者自己来实现。这样一来在序列化和反序列化的过程中,就不必再去关心实例的属性结构和访问权限。这些都由开发者自己来实现。所以能够避免大面积的使用反射的情况,算是牺牲了一定的易用性来提升运行时的效率。当然了这个易用性我们也可以通过parcelize的方式来弥补。此外,Parcelable还有一个优点,就是它可以手动控制序列化和反序列化的过程。这意味着我们可以选择只序列化对象的部分字段,或者在反序列化的时候对字段进行一些额外的处理。这种灵活性使得Parcelable在某些特定的场景下更加有用。
  虽然Parcelable的设计初衷并不是像Serializable那样,基于输入流和输出流的操作,而是基于共享内存的概念。但Parcelable是支持让我们获取到序列化之后的data数组的。这样一来,我们就可以同样把序列化后的信息写入到文件中。

        //序列化写入byte[]
Parcel parcel = Parcel.obtain();
parcel.setDataPosition(0);
new User().writeToParcel(parcel, 0);
byte[] bytes = parcel.marshall();
parcel.recycle();

//从byte数组反序列化
Parcel parcel = Parcel.obtain();
parcel.unmarshall(bytes, 0, bytes.length);
parcel.setDataPosition(0);
new User(parcel);

3.2 Intent、Bundle中传值对比

public class Intent implements Parcelable, Cloneable { }

public final class Bundle extends BaseBundle implements Cloneable, Parcelable { }

  在安卓平台最经常用到序列化的情况,是通过Intent传值。我们可以看到,无论是Intent还是Bundle,其实都是Parcelable的实现类。
  那么当Intent或者Bundle被序列化的时候,它们内部的Serializable是如何被处理的呢?
  通过代码可以看到,在Parcel的writeSerializable方法中,还是会先把Serializable转化成Byte数组。然后再通过writeByteArray去写入到Parcel中。

    public final void writeSerializable(@Nullable Serializable s) {
if (s == null) {
writeString(null);
return;
}
String name = s.getClass().getName();
writeString(name);

ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(s);
oos.close();

writeBytea

  所以在Intent传值的场景下,Parcelable也是存在速度优势的。因为Parcelable就是正常的基于writeToParcel的方法中的逻辑去进行序列化的。而Serializable要先通过ObjectOutputStream把对象转换成ByteArray,再通过writeByteArray写入Parcel。

  • Parcelable

  调用对象内部实现的writeToParcel 方法,通过一些write方法直接写入Parcel。

  • Serializable

  通过ObjectOutputStream把对象转换成ByteArray,再通过writeByteArray写入Parcel。

  但是有些情况下不一定Parcelable更快。
  之前在看Serializable源码的时候,我们发现ObjectStreamClass是存在缓存机制的。所以在一次序列化的过程中,如果涉及到大量相同类型的不同实例序列化,比如一个实例反复嵌套自己的类型,或者是在序列化大数组的情况下。Serializable的性能还是存在优势的。 

4、Parcelable为什么速度优于Serializable?

  • Parcelable
    1. 对象自行实现出入口方法,避免使用反射的情况。
    2. 二进制流存储在连续内存中,占用空间更小。
    3. 牺牲易用性(kotlin的Parcelize 可以弥补),换取性能。
  • Serializable
    1. 用反射获取类的结构和属性信息,过程中会产生中间信息。
    2. 有缓存结构,在解析相同类型的情况下,能复用缓存。
    3. 性能在可接受的范围内,易用性较好。

  不能抛开应用场景谈技术方案,在大多数场景下Parcelable确实存在性能优势,而Serializable的性能缺陷主要来自反射构建ObjectStreamClass类型的描述信息。在构建ObjectStreamClass类型的描述信息的过程中,是有缓存机制的。所以能够大量复用缓存的场景下,Serializable反而会存在性能优势。 Parcelable原本在易用性上是存在短板的,但是kotlin的Parcelize 很好的弥补了这个缺点。


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

从面试官角度分析:介绍一下Android中的Context?

Context是什么Context的结构Context的注意事项问题正解:一、Context是什么Context 是 Android 中用的十分常见的一种概念,常被翻译成上下文,这个概念在其他的技术中也有运用。Android 官方对它的解释,可以理解为应用程序...
继续阅读 »
  1. Context是什么
  2. Context的结构
  3. Context的注意事项

问题正解:

一、Context是什么

Context 是 Android 中用的十分常见的一种概念,常被翻译成上下文,这个概念在其他的技术中也有运用。Android 官方对它的解释,可以理解为应用程序环境中全局信息的接口,它整合了相当多系统级的服务,可以用来得到应用中的类、资源,以及可以进行应用程序级的调起操作,比如启动 Activity、Service等等,而且 Context 这个类是 抽象abstract 的,不含有具体的函数实现。

二、Context结构

Context 是维持 Android 程序中各组件能够正常工作的一个核心功能类。

Context 本身是一个抽象类,其主要实现类为 ContextImpl,另有直系子类两个:

  • ContextWrapper
  • ContextThemeWrapper

这两个子类是 Context 的代理类,它们继承关系如下:

image.png

ContextImpl类介绍

ContextImpl 是 Context API 的十分常见实现,它为 Activity 和其他应用程序组件提供基本上下文对象,说白了就是 ContextImpl 实现了抽象类的方法,我们在使用 Context 的时候的方法就是它实现的。

ContextWrapper类介绍

ContextWrapper 类代理 Context 的实现,将其所有调用简单地代理给另一个 Context 对象(ContextImpl),可以被分类为修饰行为而不更改原始 Context 的类,其实就 Context 类的修饰类。真正的实现类是 ContextImpl,ContextWrapper 里面的方法执行也是执行 ContextImpl 里面的方法。

ContextThemeWrapper

就是一个带有主题的封装类,比 ContextWrapper 多了主题,它的一个直接子类就是 Activity。

通过Context的继承关系图结合我们几个开发中比较常见的类,Activity、Service、Application,所以Context 一共有三种类型,分别是 Application、Activity 和Service,他们分别承担不同的责任,都属于 Context,而他们具有 Context 的功能则是由ContextImpl 类实现的。

三、Context的数量

其实根据上面的 Context 类型我们就已经可以得出答案了。Context 一共有 Application、Activity 和 Service 三种类型对象,因此一个应用程序中Context 数量的计算公式就可以这样写:

Context数量 = Activity数量 + Service数量 + 1

上面的1代表着 Application 的数量,因为一个应用程序中可以有多个 Activity 和多个 Service,但是只能有一个 Application。

四、Context注意事项

Context 如果使用不恰当很容易引起内存泄露问题。

最简单的例子比如说使用了 Context 的错误的单例模式:

public class Singleton {
  private static Singleton instance;
  private Context mContext;

  private Singleton(Context context) {
      this.mContext = context;
  }

  public static Synchronized Singleton getInstance(Context context) {
      if (instance == null) {
          instance = new Singleton(context);
      }
      return instance;
  }
}

上述代码中,我们使得了一个静态对象持有 Context 对象,而静态数据的生命一般是长于普通对象的,因此当 Context 被销毁(例如假设这里持有的是 Activity 的上下文对象,当 Activity 被销毁的时候),因为 instance 仍然持有 Context 的引用,导致 Context 虽然被销毁了但是却无法被GC机制回收,因为造成内存泄露问题。

而一般因为Context所造成的内存泄漏,基本上都是 Context 已经被销毁后,却因为被引用导致GC回收失败。但是 Application 的 Context 对象却会随着当前进程而一直存在,所以使用 Context 是应当注意:

  • 当 Application 的 Context 能完成需要的情况下,并且生命周期长的对象,优先使用 Application 的 Context。
  • 不要让生命周期长于 Activity 的对象持有到 Activity 的引用。
  • 尽量不在 Activity 中使用非静态内部类,因为非静态内部类会隐式持有外部类实例的引用,如果使用静态内部类,将外部实例引用作为弱引用持有。
五、如何正确回复以上面试题
  1. 面试官:Android 中有多少类型的 Context,它们有什么区别?

回答总共有 Activity 、Service、Application 这些 Context 。

共同点:它们都是 ContextWrapper 的派生类,而 ContextWrapper 的成员变量 mBase 可以用来存放系统实现的 ContextImpl,这样我们在执行如 Activity 的 Context 方法时,都是通过静态代理的方式最终执行到 ContextImpl 的方法。我们调用 ContextWrapper 的 getBaseContext 方法就能得到 ContextImpl 的实例。

不同点:它们有各自不同的生命周期;在功能上,只有 Activity 显示界面,正因为如此,Activity 继承的是 ContextThemeWrapper 提供一些关于主题、界面显示的能力,间接继承了 ContextWrapper ;而 Applicaiton 、Service 都是直接继承 ContextWrapper ,所以我们注意一点,但凡跟 UI 有关的,都应该用 Activity 作为 Context 来处理,不然要么会报错,要么 UI 会使用系统默认的主题。

  1. 面试官:一个APP应用里有几个 Context 呢?

Context 一共有 Application 、Activity 和 Service 三种类型,因此一个应用程序中 Context 数量的计算公式就可以这样写:

Context 数量 = Activity 数量 + Service 数量 + 1

上面的1代表着 Application 的数量,因为一个App中可以有多个Activity和多个 Service,但是只能有一个 Application。

  1. 面试官:Android 开发过程中,Context 有什么用?

Context 就等于 Application 的大管家,主要负责:

  • 四大组件的信息交互,包括启动 Activity、Broadcast、Service,获取 ContentResolver 等。
  • 获取系统/应用资源,包括 AssetManager、PackageManager、Resources、System Service 以及 color、string、drawable 等。
  • 文件,包括获取缓存文件夹、删除文件、SharedPreference 相关等。
  • 数据库(SQLite)相关,包括打开数据库、删除数据库、获取数据库路径等。

其它辅助功能,比如配置 ComponentCallbacks,即监听配置信息改变、内存不足等事件的发生

  1. 面试官:ContextImpl 实例是什么时候生成的,在 Activity 的 onCreate 里能拿到这个实例吗?

可以。我们开发的时候,经常会在 onCreate 里拿到 Application,如果用 getApplicationContext 取,最终调用的就是 ContextImpl 的 getApplicationContext 方法,如果调用的是 getApplication 方法,虽然没调用到 ContextImpl ,但是返回 Activity 的成员变量 mApplication 和 ContextImpl 的初始化时机是一样的。 再说下它的原理,Activity 真正开始启动是从 ActivityThread.performLaunchActivity 开始的,这个方法做了这些事:

  • 通过 ClassLoader 去加载目标 Activity 的类,从而创建 对象。
  • 从 packageInfo 里获取 Application 对象。
  • 调用 createBaseContextForActivity 方法去创建 ContextImpl。
  • 调用 activity.attach ( contextImpl , application) 这个方法就把 Activity 和 Application 以及 ContextImpl 关联起来了,就是上面结论里说的时机一样。
  • 最后调用 activity.onCreate 生命周期回调。

通过以上的分析,我们知道了 Activity 是先创建类,再初始化 Context ,最后调用 onCreate , 从而得出问题的答案。不仅 Activity 是这样, Application 、Service 里的 Context 初始化也都是这样的。

  1. 面试官:ContextImpl 、ContextWrapper、ContextThemeWrapper 有什么区别?
  • ContextWrapper、ContextThemeWrapper 都是 Context 的代理类,二者的区别在于 ContextThemeWrapper 有自己的 Theme 以及 Resource,并且 Resource 可以传入自己的配置初始化。
  • ContextImpl 是 Context 的主要实现类,Activity、Service 和 Application 的 Base Context 都是由它建立的,即 ContextWrapper 代理的就是 ContextImpl 对象本身。
  • ContextImpl 和 ContextThemeWrapper 的主要区别是, ContextThemeWrapper 有 Configuration 对象,Resource 可以根据这个对象来初始化。
  • Service 和 Application 使用同一个 Recource,和 Activity 使用的 Resource 不同。
  1. 面试官:Activity Context、Service Context、Application Context、Base Context 有什么区别?
  • Activity、Service 和 Application 的 Base Context 都是由 ContextImpl 创建的,且创建的都是 ContextImpl 对象,即它们都是 ContextImpl 的代理类 。
  • Service 和 Application 使用相同的Recource,和 Activity 使用的 Resource 不同。
  • getApplicationContext 返回的就是 Application 对象本身,一般情况下它对应的是应用本身的 Application 对象,但也可能是系统的某个 Application。
  1. 面试官:为什么不推荐使用 BaseContext?
  • 对于 Service 和 Application 来说,不推荐使用 Base Context,是担心用户修改了 Base Context 而导致出现错误。
  • 对于 Activity 而言,除了担心用户的修改之外,Base Context 和 Activity 本身对于 Reource 以及 Theme 的相关行为是不同的(如果应用了 Configuration 的话),使用 Base Context 可能出现无法预期的现象。
  1. 面试官:ContentProvider 里的 Context 是什么时候初始化的呢?

ContentProvider 不是 Context ,但是它有一个成员属性 mContext ,是通过构造函数传入的。那么这个问题就变成了,ContentProvider 什么时候创建。应用创建 Application 是通过执行 ActivityThread.handleBindApplication 方法,这个方法的相关流程有:

  • 创建 Application
  • 初始化 Application 的 Context
  • 执行 installContentProviders 并传入刚创建好的 Application 来创建 ContentProvider
  • 执行 Application.onCreate

得出结论,ContentProvider 的 Context 是在 Applicaiton 创建之后,但是 onCreate 方法调用之前初始化的。

  1. 面试官:BroadcastReceiver 里的 Context是哪来的?

广播接收器,分动态注册和静态注册。

  • 动态注册很简单,在调用 Context.registerReceiver 动态注册 BroadcastReceiver 时,会生成一个 ReceiverDispatcher 会持有这个 Context ,这样当有广播分发到它时,执行 onReceiver 方法就可以把 Context 传递过去了。当然,这也是为什么不用的时候要 unregisterReceiver 取消注册,不然这个 Context 就泄漏了哦。
  • 静态注册时,在分发的时候最终执行的是 ActivityThread.handleReceiver ,这个方法直接通过 ClassLoader 去创建一个 BroadcastReceiver 的对象,而传递给 onReceiver 方法的 Context 则是通过 context.getReceiverRestrictedContext() 生成的一个以 Application 为 mBase 的 ContextWrapper。注意这边的 Context 不是 Application 。


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

改行后我在做什么?(2022-9-19日晚)

闲言碎语今天回了趟家里,陪父母一起吃了个饭。父母照例是在唠叨,这个年纪了还不结婚,也没个稳定的工作,巴拉巴拉的一大堆。吃完饭我匆匆的就回到了我租住的地方。在现阶段,其实我对于父母所诉说的很多东西,我都是认同的。但在我这个年纪,这个阶段,看似有很多选择,但其实我...
继续阅读 »

闲言碎语

今天回了趟家里,陪父母一起吃了个饭。父母照例是在唠叨,这个年纪了还不结婚,也没个稳定的工作,巴拉巴拉的一大堆。吃完饭我匆匆的就回到了我租住的地方。在现阶段,其实我对于父母所诉说的很多东西,我都是认同的。

但在我这个年纪,这个阶段,看似有很多选择,但其实我没有选择。能做的也只是多挣点钱。

在这个信息爆炸的时代,我们知道更高的地方在哪里。但当你想要再往上走一步的时候,你发现你的上限,其实从出生或从你毕业的那一刻就已经注定了。可能少部分人通过自身的努力,的确能突破壁垒达到理想的高度。但这只是小概率事件罢了。在我看来整个社会的发展,其实早就已经陷入了一种怪圈。

在我,早些年刚刚进入社会的时候。那时的想法特别简单。就想着努力工作,努力提升自身的专业素养。被老板赏识,升职加薪成为一名管理者。如果,被淘汰了那应该是自己不够优秀,不够努力,专业技能不过硬,自己为人处事不够圆滑啥的。

内卷这个词语引爆网络的时候;当35岁被裁员成为常态的时候。再回头看我以前的那些想法那真的是一个笑话。(我觉得我可能是在为自己被淘汰找借口)

当前的状态

游戏工作室的项目,目前基本处于停滞的状态。我不敢加机器也不敢关机。有时候我都在想,是不是全中国那3-4亿的人都在搞这个?一个国外的游戏,金价直接拉成这个逼样。

汽配这边的话,只能说喝口稀饭。(我花了太多精力在游戏工作室上了)

梦想破灭咯

其实按照正常情况来说,游戏工作室最开始的阶段,我应该是能够稍微挣点钱的。我感觉我天时、地利、人和。我都占的。现在来看的话,其实我只占了人和。我自己可以编码,脚本还是从驱动层模拟键鼠,写的一套脚本。这样我都没赚钱,我擦勒。

接下来干嘛

接下来准备进厂打螺丝。(开玩笑的) 还是老老实实跟着我弟学着做生意吧。老老实实做汽配吧!在这个时代,好像有一技之长(尤其是IT)的人,好像并不能活得很好。除非,你这一技之长,特别特别长。(当下的中国不需要太多的这类专业技术人员吧。)

我感受到的大环境

我身边有蛮多的大牛。从他们的口中和我自己看到的。我感觉在IT这个领域,国内的环境太恶劣了。在前端,除开UI库,我用到的很多多的库全是老外的。为什么没有国人开源呢?因为,国人都忙着996了。我们可以在什么都不知道的情况下,通过复制粘贴,全局搜索解决大部分问题。 机械视觉、大数据分析、人工智能 等很多东西。这一切的基石很多年前就有了,为什么没人去研究他?为什么我们这波人,不断的在学习:这样、那样的框架。搭积木虽然很好玩。但创造一个积木,不应该也是一件更有挑战性的事情么?

在招聘网站还有一个特别奇怪的现象。看起来这家公司是在招人,但其实是培训机构。 看起来这家公司正儿八经是在招聘兼职,但其实只想骗你去办什么兼职卡。看起来是在招送快递,送外卖的,招聘司机的,但其实只是想套路你买车。我擦勒。这是怎样的一个恶劣的生存环境。这些个B人就不能干点,正经事?

卖菜的、拉车的、搞电商的、搞短视频、搞贷款的、卖保险的、这些个公司市值几百亿。很难看到一些靠创新,靠创造,靠产品质量,发展起来的公司。


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