2024年Android-开发必看---Flutter之全埋点思考与实现(1),2024年最新android一年经验面试

最后

在这里我和身边一些朋友特意整理了一份快速进阶为Android高级工程师的系统且全面的学习资料。涵盖了Android初级——Android高级架构师进阶必备的一些学习技能。

附上:我们之前因为秋招收集的二十套一二线互联网公司Android面试真题(含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

while (ancestor != null && visitor(ancestor))
ancestor = ancestor._parent;
}
复制代码

而根据Element,是可以通过element.widget获取与之对应的Widget的,根据Widget也就得到了具体的路径。

而如果选择从RenderObejct入手,它内部定义了获取父亲节点与子节点的方法:

abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
///获取树上的父节点
AbstractNode? get parent => _parent;


//遍历搜索子节点
void visitChildren(RenderObjectVisitor visitor) { }

}
复制代码

RenderObject在源码中看似没有定义接口去直接获取对应的Element的,更加无法直接去获取对应的Widget,但是注意到它有一个debugCreator属性:

/// The object responsible for creating this render object.
/// Used in debug messages.
Object? debugCreator;///表示这个render obejct表示负责创建此render object的对象,也就这个render object被谁持有
复制代码

虽然是个Object类型的,但是源码中对应的就是DebugCreator类:

/// A wrapper class for the [Element] that is the creator of a [RenderObject].
///
/// Attaching a [DebugCreator] attach the [RenderObject] will lead to better error
/// message.
class DebugCreator {
/// Create a [DebugCreator] instance with input [Element].
DebugCreator(this.element);

/// The creator of the [RenderObject].
final Element element;

@override
String toString() => element.debugGetCreatorChain(12);
}
复制代码

Element的子类RenderObjectElementmountupdate方法中对这个属性进行了创建:

@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
//省略部分代码…
_renderObject = widget.createRenderObject(this);
//省略部分代码…
assert(() {
//复制debugCreator属性方法(assert部分会在Release的时候删除)
_debugUpdateRenderObjectOwner();
return true;
}());
//省略部分代码…
}

@override
void update(covariant RenderObjectWidget newWidget) {
super.update(newWidget);

assert(() {
//复制debugCreator属性方法(assert部分会在Release的时候删除)
_debugUpdateRenderObjectOwner();
return true;
}());

}

void _debugUpdateRenderObjectOwner() {
assert(() {
//将当前Element传入到DebugCreator中保存。RenderObjectElement继承Element
_renderObject.debugCreator = DebugCreator(this);
return true;
}());
}

复制代码

可以看到通过这种方式,如果是可以通过在RenderObject中的debugCreator属性被赋值,那么是可以通过这个属性获取到对应的Element的,也就可以获取到Widget。但是通过代码也看到这个属性赋值定义在assert中,Release下不会走这部分,所以这一块要做修改。

所以,如果能在点击的时候能直接或间接获取到Element,根据上面路径的规则生成,对于上图中的GestureDetector,它的路径为:

Contain[0]/Column[0]/Contain[1]/GestureDetector[0]

同时,为了防止不同页面中可能存在的路径相同情况,给这个路径加上当前页面的标识,所以path最后的规则为:

[ 页面ID:组件路径 ]。

4. Flutter中事件与手势分析

为了更好的理解Flutter中的手势事件,下面简要的做一个分析:

Flutter中指针事件表示用户交互的原始触摸数据,例如PointerDownEventPointerUpEventPointerCancelEvent等等,当手指触摸屏幕的时候,发生触摸事件,Flutter会确定触发的位置上有哪些组件,并将触摸事件交给最内层的组件去响应,事件会从最内层的组件开始,沿着组件树向根节点向上一级级冒泡分发。

通过对一个简单的GestureDetector组件的点击回调的debug观测,得到如下图的一个调用结构:

[图片上传中…(image-c56bb-1605191361080-9)]

上图中,_rootRunUnary以下为引擎自己实现的调用,会将收集到的事件传递到GestureBinding._handlePointerDataPacket中:

mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
@override
void initInstances() {
super.initInstances();
_instance = this;
///binding初始化的时候设置了回调方法,接受引擎传来的事件数据
window.onPointerDataPacket = _handlePointerDataPacket;///onPointerDataPacket就是一个function
}

}
复制代码

GestureBinding._flushPointerEventQueue方法就是对队列中的事件依次取出并进行处理:

final Queue _pendingPointerEvents = Queue();
void _flushPointerEventQueue() {
assert(!locked);

if (resamplingEnabled) {
_resampler.addOrDispatchAll(_pendingPointerEvents);
_resampler.sample(samplingOffset);
return;
}

// Stop resampler if resampling is not enabled. This is a no-op if
// resampling was never enabled.
_resampler.stop();

while (_pendingPointerEvents.isNotEmpty)
_handlePointerEvent(_pendingPointerEvents.removeFirst());
}
复制代码

所以,真正开始处理PointerEvent应该是从GestureBinding_handlePointerEvent方法开始:

void _handlePointerEvent(PointerEvent event) {
assert(!locked);
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent) {
assert(!_hitTests.containsKey(event.pointer));
hitTestResult = HitTestResult();///1.创建一个HitTestResult对象
hitTest(hitTestResult, event.position);///2.命中测试,实际先调用到RendererBinding的hitTest方法
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;///如果是PointerDownEvent,创建事件标识id与hitTestResult的映射
}

} else if (event is PointerUpEvent || event is PointerCancelEvent) {
hitTestResult = _hitTests.remove(event.pointer);///事件序列结束后移除
} else if (event.down) {
///其他是事件重用Down事件避免每次都要去命中测试(比如:PointerMoveEvents)
hitTestResult = _hitTests[event.pointer];
}

if (hitTestResult != null ||
event is PointerHoverEvent ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
dispatchEvent(event, hitTestResult);///分发事件
}
}
复制代码

对代码中的几点注释说明:

  1. 如果是PointerDownEvent或者是PointerSignalEvent,直接创建一个HitTestResult对象,该对象内部有一个_path字段(集合);

  2. 调用hitTest方法进行命中测试,而该方法就是将自身作为参数创建HitTestEntry,然后将HitTestEntry对象添加到HitTestResult_path中。HitTestEntry中只有一个HitTestTarget字段。实际也就是将这个创建的HitTestEntry添加到HitTestResult_path字段中,当做事件分发冒泡排序中的一个路径节点。

///先RendererBinding的hitTest方法,方法定义如下:
void hitTest(HitTestResult result, Offset position) {
assert(renderView != null);
assert(result != null);
assert(position != null);
renderView.hitTest(result, position: position);
super.hitTest(result, position);
}
复制代码

内部调用主要就是两步:

  • 调用RenderViewhitTest方法(从根节点RenderView开始命中测试):

bool hitTest(HitTestResult result, { required Offset position }) {
if (child != null)
///内部会先对child进行命中测试
child!.hitTest(BoxHitTestResult.wrap(result), position: position);
result.add(HitTestEntry(this));///将自己添加到_path字段,作为一个事件分发的路径节点
return true;
}
///child是RenderBox类型对象,hitTest方法在RenderBox中实现:
bool hitTest(HitTestResult result, { @required Offset position }) {
///…去掉assert部分
///这里就是判断点击的区域置是否在size范围,是否在当前这个RenderObject节点上
if (_size.contains(position)) {
///在当前节点,如果child与自己的hitTest命中测试有一个是返回true,就加入到HitTestResult中
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
复制代码

  • 调用父类的hitTest方法,也就是GestureBindinghitTest方法:

@override // from HitTestable
void hitTest(HitTestResult result, Offset position) {
result.add(HitTestEntry(this));
}
复制代码

经过一系列的hitTest后,通过一下判断:

if (hitTestResult != null ||
event is PointerHoverEvent ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
dispatchEvent(event, hitTestResult);
}
复制代码

调用到GestureBindingdispatchEvent方法:

void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {

for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} catch (exception, stack) {

));
}
}
}
复制代码

该方法就是遍历_path中的每个HitTestEntry,取出target进行事件的分发,而HitTestTarget除了几个Binding,其具体都是由RenderObject实现的,所以也就是对每个RenderObject节点进行事件分发,也就是我们说的“事件冒泡”,冒泡的第一个节点是最小child节点(最内部的组件),最后一个是GestureBinding

值得注意的是,Flutter中并没有机制去取消或者去停止事件进一步的分发,我们只能在hitTestBehavior中去调整组件在命中测试期内应该如何表现,而且只有通过命中测试的组件才能触发事件。

所以,_handlePointerEvent方法主要就是不断通过hitTest方法计算出所需的HitTestResult,然后再通过dispatchEvent对事件进行分发。

以上是简单的对Flutter的事件分发进行一个分析,具体到我们组件层面的使用,Flutter内部还做了较多的处理,在Flutter中,具备手势点击事件的组件的实现,可直接使用的组件层面主要分为以下(也可以其它纬度分类):

  1. 直接使用Listener组件监听事件
  2. 其他基于对手势识别器GestureRecoginzer的实现:
  • 使用GestureDetector组件
  • 使用FloatButtonInkWell…等结构为:xx–xx->GestureDecector->Listener这种依托于GestureDecector->Listener的组件
  • 类似Switch,内部也是基于GestureRecoginzer实现的组件

针对第二点,在遇到多个手势冲突的时候,为了确定最终响应的手势,还得经过一个"手势竞技场"的过程,也就是在上图中recognizer手势识别器以上部分的调用结构,在"手势竞技场"中胜利的才能最终将事件响应组件层面。

以上为手势事件的一个大概的流程分析,了解了其原理与基本流程,能更好的帮助我们去完成自动埋点功能的实现。如果对Flutter手势事件原理还有不清楚的可以去查阅其它资料或者留言交流。

5.AOP

通过上面的描述,首先我们肯定是可以在响应的单击、双击、长按回调函数通过直接调用SDK埋点代码来获得我们的数据,那么如何才能实现这一步的自动化呢?

AOP:在指定的切点插入指定的代码,将所有的代码插桩逻辑几种在一个SDK内处理,可以最大程度的不侵入我们的业务。

目前阿里闲鱼开源的一款面向Flutter设计的AOP框架:Aspectd,具体的使用不多做介绍,看github地址即可。

通过上述手势事件的分析,选择以下两个切入点(当然也有其它的切入方式):

  • HitTestTargethandleEvent(PointerEvent event,HitTestEntry entry)方法;
  • GestureRecognizerinvokeCallback<T>(String name,RecognizerCallback<T> callback,{String debugReport})方法;

其代码大致如下所示:

@Call(“package:flutter/src/gestures/hit_test.dart”, “HitTestTarget”,
“-handleEvent”)
@pragma(“vm:entry-point”)
dynamic hookHitTestTargetHandleEvent(PointCut pointCut) {
dynamic target = pointCut.target;
PointerEvent pointerEvent = pointCut.positionalParams[0];
HitTestEntry entry = pointCut.positionalParams[1];
curPointerCode = pointerEvent.pointer;
if (target is RenderObject) {
if (curPointerCode > prePointerCode) {
clearClickRenderMapData();
}
if (!clickRenderMap.containsKey(curPointerCode)) {
clickRenderMap[curPointerCode] = target;
}
}
prePointerCode = curPointerCode;
target.handleEvent(pointerEvent, entry);
}

@Call(“package:flutter/src/gestures/recognizer.dart”, “GestureRecognizer”,
“-invokeCallback”)
@pragma(“vm:entry-point”)
dynamic hookinvokeCallback(PointCut pointcut) {
var result = pointcut.proceed();
if (curPointerCode > preHitPointer) {
String argumentName = pointcut.positionalParams[0];

if (argumentName == ‘onTap’ ||
argumentName == ‘onTapDown’ ||
argumentName == ‘onDoubleTap’) {
RenderObject clickRender = clickRenderMap[curPointerCode];
if (clickRender != null) {
DebugCreator creator = clickRender.debugCreator;
Element element = creator.element;
//通过element获取路径
String elementPath = getElementPath(element);
///丰富采集时间
richJsonInfo(element, argumentName, elementPath);
}
preHitPointer = curPointerCode;
}
}

return result;
}
复制代码

大体的实现思路如下:

  1. 通过Map记录事件唯一的pointer标识符与响应的RenderObject的映射关系,只记录_path中的第一个,也就是命中测试的最小child,且记录下当前事件序列的pointer(pointer在一个事件序列中是唯一的值,每发生一次手势事件,它会自增1);
  2. GestureRecognizerinvokeCallback<T>(String name,RecognizerCallback<T> callback,{String debugReport})方法中,通过上面记录的的pointer,在Map中取出RenderObject,取debugCreator属性得到Element,再得到对应的widget;

在上述第2步中,其实存在一个问题,就是RenderObjectdebugCreator字段,这个字段表示负责创建此render object的对象,源码中创建过程写在aessert中,所以其实只能在debug模式下获取到,它在源码中实际创建位置在RenderObjectElementmount,在update执行更新的时候同样也会更新:

@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
//省略部分代码…
_renderObject = widget.createRenderObject(this);
//省略部分代码…
assert(() {
//assert部分会在Release的时候删除
_debugUpdateRenderObjectOwner();
return true;
}());
//省略部分代码…
}

void _debugUpdateRenderObjectOwner() {
assert(() {
//将当前Element传入到DebugCreator中保存。RenderObjectElement继承Element
_renderObject.debugCreator = DebugCreator(this);
return true;
}());
}
复制代码

为了让我们在AOP的时候,在Release模式下也能获取到这个数据,所以我们要特殊处理。既然在源码中它只能在debug下创建,我们就创造条件让它在Release下也创建。

@Execute(‘package:flutter/src/widgets/framework.dart’,‘Element’,‘-mount’)
@pragma(‘vm:entry-point’)
static dynamic hookElementMount(PointCut pointCut){
dynamic obj = pointCut.proceed;
Element element = pointCut.target;
if(kReleaseMode||kProfileMode){
//release和profile模式创建这个属性
element.renderObject.debugCreator = DebugCreator(element);
}
}

@Execute(‘package:flutter/src/widgets/framework.dart’,‘Element’,‘-update’)
@pragma(‘vm:entry-point’)
static dynamic hookElementUpdate(PointCut pointCut){
dynamic obj = pointCut.proceed;
Element element = pointCut.target;
if(kReleaseMode||kProfileMode){
//release和profile模式创建这个属性
element.renderObject.debugCreator = DebugCreator(element);
}
}
复制代码

debugCreator字段处理完成后,我们就可以根据RenderObject获取对应的Element,获取到Element也就可以去计算组件的path id了。

通过以上操作,在实际中,我们对一个GestureDetector进行点击测试后,得到如下结果:

GestureDetector[0]/Column[0]/Contain[0]/BodyBuilder[0]/MediaQuery[0]/LayoutId[0]/CustomMultiChildLayout[0]/AnimatedBuilder[0]/DefaultTextStyle[0]/AnimatedDefaultTextStyle[0]/_InkFeatures[0]/NotificationListener[0]/PhysicalModel[0]/AnimatedPhysicalModel[0]/Material[0]/PrimaryScrollController[0]/_ScaffoldScope[0]/Scaffold[0]/MyHomePage[0]…/MyApp[0]
复制代码

经过对比发现,这似乎确实是我们代码中创建的组件的路径没错,但是好像中间多了很多奇怪的组件路径,这似乎不是我们自己创建的,这里还是存在一些问题要优化。

6.关于组件ID的优化
  1. 组件路径ID过长:

组件的路径ID很长。因为Flutter布局嵌套包装的特点,如果一直向上搜索父亲节点,会一直搜索到MyApp这里,中间还会包含很多系统内部创建的组件。

  1. 不同平台特性:(去掉这点,无需优化,因为平台特性只会出现在系统内部节点,自己编写的除非有特别的判断,否则不会出现差异性

在不同的平台,为了保持某些平台的特性风格,可能会出现路径中某个节点不一致的情况(比如在IOS平台的路径可能会出现一个侧滑的节点,其他平台没有)。例如以"Cupertino"、"Material"开头的这种组件,要选择屏蔽掉差异。

  1. 动态插入Widget不稳定

根据上面定义的规则,在页面元素不发生变动的情况下,基本上是能保证"稳定性"与"唯一性",但是如果页面元素发生动态变化,或者在不同的版本之间UI进行了改版,此时我们定义的规则就会变的不够稳定,也可能不再唯一,比如下图所示:

[图片上传中…(image-85fa0d-1605191361072-8)]

在插入一个Widget后,我们的GestureDetector的路径变成了Contain[0]/Column[0]/Contain[2]/GestureDetector[0],与之前相比发生了变化,这点优化比较简单:将同级兄弟节点的位置,变成相同类型的组件的位置。优化后的组件路径为:Contain[0]/Column[0]/Contain[1]/GestureDetector[0]。这样在插入一个非同类型的Widget后,其路径依旧不变,但如果插入的是同类型的还是会发生改变,所以这个是属于相对的稳定。

那么剩下的问题如何优化呢?

7.Dart元编程解决遗留问题

问题1:我们实际获取到的路径并不是我们在代码中创建的组件路径,比如:

//我们自己代码创建一个Contain
@override
Widget build(BuildContext context){
return Contain(
child:Text(‘text’),
);
}
//实际上Contain的内部build函数,会做层层的包装,其他组件也是类似情况
@override
Widget build(BuildContext context) {
Widget current = child;
if (child == null && (constraints == null || !constraints.isTight)) {
current = LimitedBox(
maxWidth: 0.0,
maxHeight: 0.0,
child: ConstrainedBox(constraints: const BoxConstraints.expand()),
);
}
…省略部分代码
if (alignment != null)
current = Align(alignment: alignment, child: current);
…省略部分代码
return current;
}
复制代码

因为这个情况,会导致出现三个情况:

  • 我们在用上述方式获取组件路径的时候,中间会夹杂很多我们并不那么关心的组件路径,即使这些确实是在路径上的的组件,我们实际上只想要关注我们创建的那部分,关键是如何去除"多余组件路径"。
  • 系统组件有时内部为了在一些情况下支持各个平台特性,还会出现使用各自不同的组件,这种差异需要屏蔽。
  • 因为Flutter独特的嵌套方式,每个组件在搜索父节点时最终会搜索到main中,实际其实我们只需要以当前页面为划分即可。

如何解决呢?注意到当我们使用Flutter自带的工具Flutter Inspector观测我们创建的页面时,出现的是我们想要的组件展示情况:

[图片上传中…(image-54a276-1605191361071-7)]

[图片上传中…(image-4ea4a5-1605191361071-6)]

通过图中可以看到,widgets的展示形式完整的表示了我们自己页面代码中创建widget的结构,那么这个是如何实现的呢?

实际上,这个是通过一个WidgetInspectorService的服务来实现的,一个被GUI工具用来与WidgetInspector交互的服务。在Foundation/Binding.dart中通过initServiceExtensions注册,而且只有在debug环境下才会注册这个拓展服务。

通过对官方开源的dev-tools源码的分析,其应用层面的关键方法如下:

// Returns if an object is user created.
//返回该对象是否自己创建的(这里我们针对的是widget)
bool _isLocalCreationLocation(Object object) {
final _Location location = _getCreationLocation(object);
if (location == null)
return false;
return WidgetInspectorService.instance._isLocalCreationLocation(location);
}

/// Creation locations are only available for debug mode builds when
/// the --track-widget-creation flag is passed to flutter_tool. Dart 2.0 is
/// required as injecting creation locations requires a
/// Dart Kernel Transformer.
///
/// Currently creation locations are only available for [Widget] and [Element].
_Location _getCreationLocation(Object object) {
final Object candidate = object is Element ? object.widget : object;
return candidate is _HasCreationLocation ? candidate._location : null;
}

bool _isLocalCreationLocation(_Location location) {
if (location == null || location.file == null) {
return false;
}
final String file = Uri.parse(location.file).path;

// By default check whether the creation location was within package:flutter.
if (_pubRootDirectories == null) {
// TODO(chunhtai): Make it more robust once
// https://github.com/flutter/flutter/issues/32660 is fixed.
return !file.contains(‘packages/flutter/’);
}
for (final String directory in _pubRootDirectories) {
if (file.startsWith(directory)) {
return true;
}
}
return false;
}
复制代码

方法中出现的两个关键类_Location_HasCreationLocation,是在编译期通过Dart Kernel Transformer实现的,与Android中的ASM实现Transform类似,Dart在编译期间也是有一个个的Transform来实现一些特定的操作的,这部分可以在Dart的源码中找到。

widget_inspctor的这个功能,就是在debug模式的编译期间,通过一个特定的Transform,让所有的Widget 实现了抽象类_HasCreationLocation,同时改造了Widget的构造器函数,添加一个命名参数(_Location类型),通过AST,给_Location属性赋值,实现transform的转换。

但是,这个功能是只能在debug模式下开启的,我们要达到这个效果,只能自己实现一个Transform,支持在非debug模式下也能使用。而且,我们可以直接利用aspectd的已有功能,稍微改造一下,添加一个自己的Transform,而且不需要添加widget创建的行列等复杂的信息,只需要能够区分widget是开发者自己项目创建的即可,也就是只需要一个标识即可。

同样的在实现的过程中也有几点要注意:

  1. 对于创建widget的时候,如果加了const修饰,比如下面示例,是需要单独作为一个Transform来处理的。

Text widget = const Text(‘文字’);
Contain(
child:const Text(‘文字’),
);
复制代码

  1. 在debug下可以用TreeNodeLocation字段做区分,但是在release下这个字段是null,不能按照这个区分出自己项目创建的widget。

  2. 如果使用Aspectd的话,自己添加的改造Transform要添加在Aspectd内部实现的几个Transform之前。因为Aspectd提供的比如call api,在用在构造函数的时候,会将方法调用处替换掉,我们如果在这个后面注入会无效。所以转换的顺序应该是修改普通构造在最前面,其次是处理常量声明表达式,最后是Aspectd自己的转换。

参考源码的track_widget_constructor_locations.dart的实现,Transform实现的关键代码如下:

  • 自己定义的一个类,让widget实现这个类,注意该类定义的时候需要我们在main方法中直接或者间接的使用到,对应的_resolveFlutterClasse方法也要修改。

void _resloveFlutterClasses(Iterable libraries){
for(Library library in libraries){
final Uri importUri = library.importUri;
if(importUri != null && importUri.scheme == ‘package’){
//自己定义类的完整路径,比如是:example/local_widget_track_class.dart
if(importUri.path = ‘example/local_widget_track_class.dart’){
for(Class cls in library.classes){
//定义的类名,比如是:LocalWidgetLocation
if(cls.name = ‘LocalWidgetLocation’){
_localWidgetLocation = cls;
}
}
}else if(importUri.path == ‘flutter/src/widgets/framework.dart’|| …){

}
}
}
}
复制代码

  • 继承Transformer主要需要实现visitStaticInvocationvisitConstructorInvocation方法:

@override
StaticInvocation visitStaticInvocation(StaticInvocation node) {
node.transformChildren(this);
final Procedure target = node.target;
if (!target.isFactory) {
return node;
}
final Class constructedClass = target.enclosingClass;
if (!_isSubclassOfWidget(constructedClass)) {
return node;

最后

其实Android开发的知识点就那么多,面试问来问去还是那么点东西。所以面试没有其他的诀窍,只看你对这些知识点准备的充分程度。so,出去面试时先看看自己复习到了哪个阶段就好。

上面分享的百度、腾讯、网易、字节跳动、阿里等公司2021年的高频面试题,博主还把这些技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,上面只是以图片的形式给大家展示一部分。

【Android思维脑图(技能树)】

知识不体系?这里还有整理出来的Android进阶学习的思维脑图,给大家参考一个方向。

【Android高级架构视频学习资源】

**Android部分精讲视频领取学习后更加是如虎添翼!**进军BATJ大厂等(备战)!现在都说互联网寒冬,其实无非就是你上错了车,且穿的少(技能),要是你上对车,自身技术能力够强,公司换掉的代价大,怎么可能会被裁掉,都是淘汰末端的业务Curd而已!现如今市场上初级程序员泛滥,这套教程针对Android开发工程师1-6年的人员、正处于瓶颈期,想要年后突破自己涨薪的,进阶Android中高级、架构师对你更是如鱼得水,赶快领取吧!

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

节**,由于篇幅有限,上面只是以图片的形式给大家展示一部分。

【Android思维脑图(技能树)】

知识不体系?这里还有整理出来的Android进阶学习的思维脑图,给大家参考一个方向。

[外链图片转存中…(img-rNXVjXb2-1715588169745)]

【Android高级架构视频学习资源】

**Android部分精讲视频领取学习后更加是如虎添翼!**进军BATJ大厂等(备战)!现在都说互联网寒冬,其实无非就是你上错了车,且穿的少(技能),要是你上对车,自身技术能力够强,公司换掉的代价大,怎么可能会被裁掉,都是淘汰末端的业务Curd而已!现如今市场上初级程序员泛滥,这套教程针对Android开发工程师1-6年的人员、正处于瓶颈期,想要年后突破自己涨薪的,进阶Android中高级、架构师对你更是如鱼得水,赶快领取吧!

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 28
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值