HarmonyOS鸿蒙最新深入分析 Android 系统返回手势的实现原理_android手势导航分析,京东HarmonyOS鸿蒙面试题

img
img

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

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

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


updateIsEnabled();
}

private void updateIsEnabled() {
boolean isEnabled = mIsAttached && mIsGesturalModeEnabled;
if (isEnabled == mIsEnabled) {
return;
}
mIsEnabled = isEnabled;
// 如果无效的话结束监听 Input
disposeInputChannel();

// 无效的话
if (!mIsEnabled) {
// 注销监听返回手势参数的设置变化
mGestureNavigationSettingsObserver.unregister();

// 注销 WMS 里保存的除外区域监听
try {
mWindowManagerService.unregisterSystemGestureExclusionListener(
mGestureExclusionListener, mDisplayId);

}
} else {
// 监听返回手势参数的设置变化
mGestureNavigationSettingsObserver.register();

// 监听 WMS 里保存的除外区域
try {
mWindowManagerService.registerSystemGestureExclusionListener(
mGestureExclusionListener, mDisplayId);

}

// 注册名为 edge-swipe 的InputMonitor
mInputMonitor = InputManager.getInstance().monitorGestureInput(
“edge-swipe”, mDisplayId);

// 设置 Input 事件回调为 onInputEvent()
mInputEventReceiver = new InputChannelCompat.InputEventReceiver(
mInputMonitor.getInputChannel(), Looper.getMainLooper(),
Choreographer.getInstance(), this::onInputEvent);

// 添加 NavigationBarEdgePanel 为 Edge Back 事件的处理实现
setEdgeBackPlugin(new NavigationBarEdgePanel(mContext));

}

}

2. 监听返回手势停用区域

EdgeBackGestureHandler 通过 WMS 注册了返回手势停用区域的监听者,他们的 Binder 接口最终被存放在 DisplayContent 中。

// WindowManagerService.java
public void registerSystemGestureExclusionListener(…) {
synchronized (mGlobalLock) {
final DisplayContent displayContent = mRoot.getDisplayContent(displayId);
displayContent.registerSystemGestureExclusionListener(listener);
}
}

// DisplayContent.java
void registerSystemGestureExclusionListener(ISystemGestureExclusionListener listener) {
// 监听实例缓存
mSystemGestureExclusionListeners.register(listener);
final boolean changed;
// 立即检查一次是否恰好发生了变化
if (mSystemGestureExclusionListeners.getRegisteredCallbackCount() == 1) {
changed = updateSystemGestureExclusion();
} else {
changed = false;
}

// 立马回调一次
if (!changed) {
final Region unrestrictedOrNull = mSystemGestureExclusionWasRestricted
? mSystemGestureExclusionUnrestricted : null;
try {
listener.onSystemGestureExclusionChanged(…);

}
}
}

区域变化时 WMS 将通过 Binder 将区域回调过来,EdgeBackGestureHandler 遂更新存放当前 Display 停用手势区域的 mExcludeRegion 变量。

// EdgeBackGestureHandler.java
private ISystemGestureExclusionListener mGestureExclusionListener =
new ISystemGestureExclusionListener.Stub() {
@Override
public void onSystemGestureExclusionChanged(int displayId,
Region systemGestureExclusion, Region unrestrictedOrNull) {
if (displayId == mDisplayId) {
mMainExecutor.execute(() -> {
mExcludeRegion.set(systemGestureExclusion);

});
}
}
};

DisplayContent 里的停用区域 Region 来自于 App 的设置,而 App 一般会在需要停用返回手势的 View 视图里覆写这两个方法,并设置停用区域的 Rect List。

// XXXView.kt
var exclusionRects = listOf(rect1, rect2, rect3)

fun onLayout( … ) {
setSystemGestureExclusionRects(exclusionRects)
}

fun onDraw(canvas: Canvas) {
setSystemGestureExclusionRects(exclusionRects)
}

父类 View 负责将区域通过 Handler 交给根 View 管理者 ViewRootImpl。

// View.java
public void setSystemGestureExclusionRects(@NonNull List rects) {
// List 为空并且 ListenerInfo 也不存在的话
// 不处理
if (rects.isEmpty() && mListenerInfo == null) return;

final ListenerInfo info = getListenerInfo();
// 如果已存在,先清除再添加;反之,创建一个
if (info.mSystemGestureExclusionRects != null) {
info.mSystemGestureExclusionRects.clear();
info.mSystemGestureExclusionRects.addAll(rects);
} else {
info.mSystemGestureExclusionRects = new ArrayList<>(rects);
}
if (rects.isEmpty()) {
// rects 是空的话移除更新的监听
if (info.mPositionUpdateListener != null) {
mRenderNode.removePositionUpdateListener(info.mPositionUpdateListener);
}
} else {
// rects 合法但更新的监听尚未建立的话
if (info.mPositionUpdateListener == null) {
info.mPositionUpdateListener = new RenderNode.PositionUpdateListener() {

};
// 创建一个并放入 RenderNode 中
mRenderNode.addPositionUpdateListener(info.mPositionUpdateListener);
}
}
// 向 ViewRootImpl 中 Handler
// 发送插队 Message
// 任务是向 ViewRootImpl 发出进一步请求
postUpdateSystemGestureExclusionRects();
}

void postUpdateSystemGestureExclusionRects() {
// Potentially racey from a background thread. It’s ok if it’s not perfect.
final Handler h = getHandler();
if (h != null) {
h.postAtFrontOfQueue(this::updateSystemGestureExclusionRects);
}
}

void updateSystemGestureExclusionRects() {
final AttachInfo ai = mAttachInfo;
if (ai != null) {
ai.mViewRootImpl.updateSystemGestureExclusionRectsForView(this);
}
}

ViewRootImpl 是 View 树和 WMS 产生联系的桥梁,其继续将 Rect 通过 WindowSession 进一步交给系统。

// ViewRootImpl.java
void updateSystemGestureExclusionRectsForView(View view) {
mGestureExclusionTracker.updateRectsForView(view);
mHandler.sendEmptyMessage(MSG_SYSTEM_GESTURE_EXCLUSION_CHANGED);
}

// 发送的 msg 为如下函数处理
void systemGestureExclusionChanged() {
final List rectsForWindowManager = mGestureExclusionTracker.computeChangedRects();
if (rectsForWindowManager != null && mView != null) {
try {
mWindowSession.reportSystemGestureExclusionChanged(mWindow, rectsForWindowManager);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
// 回调监听停用区域变化的 Observer
mAttachInfo.mTreeObserver
.dispatchOnSystemGestureExclusionRectsChanged(rectsForWindowManager);
}
}

Binder 调用之后 Session 抵达,之后交给 WMS 并将区域存放在对应的 WindowState 中,管理起来。

// Session.java
public void reportSystemGestureExclusionChanged(IWindow window, List exclusionRects) {
final long ident = Binder.clearCallingIdentity();
try {
mService.reportSystemGestureExclusionChanged(this, window, exclusionRects);
} finally {
Binder.restoreCallingIdentity(ident);
}
}

// WindowManagerService.java
void reportSystemGestureExclusionChanged(Session session, IWindow window,
List exclusionRects) {
synchronized (mGlobalLock) {
final WindowState win = windowForClientLocked(session, window, true);
// 区域保存在在 WindowState 中
// 并告知 DisplayContent 刷新和回调监听者
if (win.setSystemGestureExclusion(exclusionRects)) {
win.getDisplayContent().updateSystemGestureExclusion();
}
}
}

// WindowState.java
boolean setSystemGestureExclusion(List exclusionRects) {
// 检查区域是否发生变化
if (mExclusionRects.equals(exclusionRects)) {
return false;
}
// 清空 & 放入全新的 List
mExclusionRects.clear();
mExclusionRects.addAll(exclusionRects);
return true;
}

同时要求 DisplayContent 立即检查区域是否发生更新,这里面将需要从 WindowState 中取出管理着的 Rect List,封装和转换成 Region。

// DisplayContent.java
boolean updateSystemGestureExclusion() {

final Region systemGestureExclusion = Region.obtain();
// 取得当前的停用区域
mSystemGestureExclusionWasRestricted = calculateSystemGestureExclusion(
systemGestureExclusion, mSystemGestureExclusionUnrestricted);
try {
// 没有发生变化不用通知
if (mSystemGestureExclusion.equals(systemGestureExclusion)) {
return false;
}

// 遍历监听者和回调
for (int i = mSystemGestureExclusionListeners.beginBroadcast() - 1; i >= 0; --i) {
try {
mSystemGestureExclusionListeners.getBroadcastItem(i)
.onSystemGestureExclusionChanged(mDisplayId, systemGestureExclusion,
unrestrictedOrNull);
}
}

}
}

boolean calculateSystemGestureExclusion(Region outExclusion, @Nullable
Region outExclusionUnrestricted) {
// 遍历 WindowState 获取停用区域
forAllWindows(w -> {

if (w.isImplicitlyExcludingAllSystemGestures()) {
local.set(touchableRegion);
} else {
rectListToRegion(w.getSystemGestureExclusion(), local);

local.op(touchableRegion, Op.INTERSECT);
}

return remainingLeftRight[0] < mSystemGestureExclusionLimit
|| remainingLeftRight[1] < mSystemGestureExclusionLimit;
}

3. Monitor 监视 Input 事件

InputManager 经过 Binder 将 monitorGestureInput() 的调用传递到 InputManagerService。
// InputManagerService.java
public InputMonitor monitorGestureInput(String inputChannelName, int displayId) {

try {
InputChannel inputChannel = nativeCreateInputMonitor(
mPtr, displayId, true /isGestureMonitor/, inputChannelName, pid);
InputMonitorHost host = new InputMonitorHost(inputChannel.getToken());
return new InputMonitor(inputChannel, host);
} finally {
Binder.restoreCallingIdentity(ident);
}
}

IMS 的 JNI 将负责向 InputDispatcher 发出调用,并将其创建的 Client 端 InputChannel 实例转为 Java 实例返回。
虽然命名为 InputMonitor 事实上还是 InputChannel,只不过要和普通的 Window 所创建的 InputChannel 区分开来。
可以说留给某些特权 App 监视输入事件的后门吧,比如这次的 SystemUI。

// com_android_server_input_InputManagerService.cpp
static jobject nativeCreateInputMonitor(JNIEnv* env, jclass /* clazz /, jlong ptr, jint displayId,
jboolean isGestureMonitor, jstring nameObj, jint pid) {
NativeInputManager
im = reinterpret_cast<NativeInputManager*>(ptr);

// 调用 NativeInputManager
base::Result<std::unique_ptr> inputChannel =
im->createInputMonitor(env, displayId, isGestureMonitor, name, pid);

// 将 Native 端返回的实例转为 Java 对象
jobject inputChannelObj =
android_view_InputChannel_createJavaObject(env, std::move(*inputChannel));
if (!inputChannelObj) {
return nullptr;
}
return inputChannelObj;
}

// 从持有的 InputManager 实例中
// 取出 InputDispatcher 实例
// 发出创建 Monitor 请求
base::Result<std::unique_ptr> NativeInputManager::createInputMonitor(…) {
ATRACE_CALL();
return mInputManager->getDispatcher()->createInputMonitor(…);
}

InputDispatcher 创建 InputMonitor 的流程和普通 InputChannel 差不多,区别体现在 Server 端 InputChannel 需要额外存放在 mGestureMonitorsByDisplay Map 中。

// InputDispatcher.cpp
Result<std::unique_ptr> InputDispatcher::createInputMonitor(…) {
std::shared_ptr serverChannel;
std::unique_ptr clientChannel;
status_t result = openInputChannelPair(name, serverChannel, clientChannel);

{ // acquire lock
std::scoped_lock _l(mLock);

sp connection = new Connection(serverChannel, true /monitor/, mIdGenerator);
const sp& token = serverChannel->getConnectionToken();
const int fd = serverChannel->getFd();

mConnectionsByToken.emplace(token, connection);
std::function<int(int events)> callback = std::bind(&InputDispatcher::handleReceiveCallback,
this, std::placeholders::_1, token);

auto& monitorsByDisplay =
isGestureMonitor ? mGestureMonitorsByDisplay : mGlobalMonitorsByDisplay;
monitorsByDisplay[displayId].emplace_back(serverChannel, pid);

mLooper->addFd(fd, 0, ALOOPER_EVENT_INPUT, new LooperEventCallback(callback), nullptr);
}

// Wake the looper because some connections have changed.
mLooper->wake();
return clientChannel;
}

4. 创建返回手势视图

InputMonitor 创建完毕之后,EdgeBackGestureHandler 将立即创建手势视图即 NavigationBarEdgePanel 实例。并通过 setEdgeBackPlugin() 将其缓存,同时准备好承载该视图的 Window 参数一并传递过去。

// EdgeBackGestureHandler.java
private void setEdgeBackPlugin(NavigationEdgeBackPlugin edgeBackPlugin) {
if (mEdgeBackPlugin != null) {
mEdgeBackPlugin.onDestroy();
}
// 缓存 NavigationEdgeBackPlugin 实现
mEdgeBackPlugin = edgeBackPlugin;
// 向 NavigationEdgeBackPlugin 注册 Back 手势的触发回调
mEdgeBackPlugin.setBackCallback(mBackCallback);
// 准备好手势视图的 Window 参数
mEdgeBackPlugin.setLayoutParams(createLayoutParams());
updateDisplaySize();
}

// 配置返回手势 Window 的参数
// 包括 flag、type、title 等属性
private WindowManager.LayoutParams createLayoutParams() {
Resources resources = mContext.getResources();
WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
resources.getDimensionPixelSize(R.dimen.navigation_edge_panel_width),
resources.getDimensionPixelSize(R.dimen.navigation_edge_panel_height),
WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT);

layoutParams.setTitle(TAG + mContext.getDisplayId());
layoutParams.setFitInsetsTypes(0 /* types */);
layoutParams.setTrustedOverlay();
return layoutParams;
}

NavigationBarEdgePanel 构造函数将准备视图相关的描画、动画等相关初始化工作。
比如:

持有 WindowManager 为后续添加试图到 Window 上做准备
持有发出振动用的 mVibratorHelper,以进行后续的 click 振动
配置描画用的 Paint 属性
初始化返回箭头的颜色、淡入、角度动画
设置读取手势阈值 mSwipeThreshold

// NavigationBarEdgePanel.java
public NavigationBarEdgePanel(Context context) {
super(context);
mWindowManager = context.getSystemService(WindowManager.class);
mVibratorHelper = Dependency.get(VibratorHelper.class);

mPaint.setStrokeWidth(mArrowThickness);
mPaint.setStrokeCap(Paint.Cap.ROUND);

mArrowColorAnimator = ValueAnimator.ofFloat(0.0f, 1.0f);
mArrowColorAnimator.setDuration(COLOR_ANIMATION_DURATION_MS);
mArrowColorAnimator.addUpdateListener(animation -> {
int newColor = ColorUtils.blendARGB(
mArrowStartColor, mArrowColor, animation.getAnimatedFraction());
setCurrentArrowColor(newColor);
});

mArrowDisappearAnimation = ValueAnimator.ofFloat(0.0f, 1.0f);
mArrowDisappearAnimation.setDuration(DISAPPEAR_ARROW_ANIMATION_DURATION_MS);
mArrowDisappearAnimation.setInterpolator(Interpolators.FAST_OUT_SLOW_IN);
mArrowDisappearAnimation.addUpdateListener(animation -> {
mDisappearAmount = (float) animation.getAnimatedValue();
invalidate();
});

mAngleAnimation =
new SpringAnimation(this, CURRENT_ANGLE);
mAngleAppearForce = new SpringForce()
.setStiffness(500)
.setDampingRatio(0.5f);

mSwipeThreshold = context.getResources()
.getDimension(R.dimen.navigation_edge_action_drag_threshold);
setVisibility(GONE);

}

其后 NavigationBarEdgePanel 复写的 setLayoutParams() 会被 EdgeBackGestureHandler 调用。拿到 Handler 为其准备的 Window 参数后将本视图添加到一个专用 Window。
注意:此时 View 还是不可见的,后续事件产生的时候会进行展示和刷新。

// NavigationBarEdgePanel.java
public void setLayoutParams(WindowManager.LayoutParams layoutParams) {
mLayoutParams = layoutParams;
mWindowManager.addView(this, mLayoutParams);
}

5. 预处理 Touch 事件

当 InputDispatcher 收到 InputReader 传递过来的事件,在分发前会从 mGestureMonitorsByDisplay Map 中收集对应 Display 的 Monitor 实例,并将其中的 Server 端 InputChannel 一并放入到 Input Target 中。

// InputDispatcher.cpp
InputEventInjectionResult InputDispatcher::findTouchedWindowTargetsLocked( … ) {

if (newGesture || (isSplit && maskedAction == AMOTION_EVENT_ACTION_POINTER_DOWN)) {

// 取出 InputMonitor
std::vector newGestureMonitors = isDown
? findTouchedGestureMonitorsLocked(displayId, tempTouchState.portalWindows)
: std::vector{};

newGestureMonitors = selectResponsiveMonitorsLocked(newGestureMonitors);

if (newTouchedWindowHandle != nullptr) {

tempTouchState.addOrUpdateWindow(newTouchedWindowHandle, targetFlags, pointerIds);
}

// 添加 Monitors 到 TouchedState
tempTouchState.addGestureMonitors(newGestureMonitors);
}

// 将 TouchedState 中 Touched Window 添加到 InputTargets 中
for (const TouchedWindow& touchedWindow : tempTouchState.windows) {
addWindowTargetLocked(touchedWindow.windowHandle, touchedWindow.targetFlags,
touchedWindow.pointerIds, inputTargets);
}

// 将 TouchedState 中 Monitors 添加到 InputTargets 中
for (const TouchedMonitor& touchedMonitor : tempTouchState.gestureMonitors) {
addMonitoringTargetLocked(touchedMonitor.monitor, touchedMonitor.xOffset,
touchedMonitor.yOffset, inputTargets);
}

return injectionResult;
}

// 从 mGestureMonitorsByDisplay map 中
// 按照 Display Id 取出 Vector 返回出去
std::vector InputDispatcher::findTouchedGestureMonitorsLocked( … ) const {
std::vector touchedMonitors;

std::vector monitors = getValueByKey(mGestureMonitorsByDisplay, displayId);
addGestureMonitors(monitors, touchedMonitors);
for (const sp& portalWindow : portalWindows) {
const InputWindowInfo* windowInfo = portalWindow->getInfo();

}
return touchedMonitors;
}

// 提取 Monitor 中的 Server InputChannerl
// 放入到 InputTarget Vector
void InputDispatcher::addMonitoringTargetLocked( … ) {
InputTarget target;
target.inputChannel = monitor.inputChannel;
target.flags = InputTarget::FLAG_DISPATCH_AS_IS;
ui::Transform t;
t.set(xOffset, yOffset);
target.setDefaultPointerTransform(t);
inputTargets.push_back(target);
}

之后 dispatchEventLocked 将遍历 InputTarget Vector 实例,逐一使用其 InputChannel 实例通过 Socket 向 App 进程和 SystemUI 进程发送事件。

// InputDispatcher.cpp
void InputDispatcher::dispatchEventLocked( … ) {

for (const InputTarget& inputTarget : inputTargets) {
sp connection =
getConnectionLocked(inputTarget.inputChannel->getConnectionToken());
if (connection != nullptr) {
prepareDispatchCycleLocked(currentTime, connection, eventEntry, inputTarget);
}
}
}

监听 Socket FD 写入的消费端 Looper 将触发 LooperCallback,进而从 Client 端 Socket 读取事件,最后通过 InputEventReceiver 回调。

// android_view_InputEventReceiver.cpp
int NativeInputEventReceiver::handleEvent(int receiveFd, int events, void* data) {

// 通过 Client Socket 读取事件
if (events & ALOOPER_EVENT_INPUT) {
JNIEnv* env = AndroidRuntime::getJNIEnv();
status_t status = consumeEvents(env, false /consumeBatches/, -1, nullptr);
mMessageQueue->raiseAndClearException(env, “handleReceiveCallback”);
return status == OK || status == NO_MEMORY ? KEEP_CALLBACK : REMOVE_CALLBACK;
}

return KEEP_CALLBACK;
}

status_t NativeInputEventReceiver::consumeEvents(JNIEnv* env,
bool consumeBatches, nsecs_t frameTime, bool* outConsumedBatch) {

for (;😉 {
// 通过 Client InputChannel 发出读取事件请求
status_t status = mInputConsumer.consume(&mInputEventFactory,
consumeBatches, frameTime, &seq, &inputEvent);

if (!skipCallbacks) {

switch (inputEvent->getType()) {

case AINPUT_EVENT_TYPE_MOTION: {
MotionEvent* motionEvent = static_cast<MotionEvent*>(inputEvent);
if ((motionEvent->getAction() & AMOTION_EVENT_ACTION_MOVE) && outConsumedBatch) {
*outConsumedBatch = true;
}
inputEventObj = android_view_MotionEvent_obtainAsCopy(env, motionEvent);
break;
}

// 调用 InputEventReceiver Java 端
// dispatchInputEvent()
if (inputEventObj) {
env->CallVoidMethod(receiverObj.get(),
gInputEventReceiverClassInfo.dispatchInputEvent, seq, inputEventObj);

}…
}
}
}

InputEventReceiver 的 dispatchInputEvent() 会回调 onInputEvent()。

// InputEventReceiver.java
private void dispatchInputEvent(int seq, InputEvent event) {
mSeqMap.put(event.getSequenceNumber(), seq);
onInputEvent(event);
}

onInputEvent() 作为 SystemUI 监视到系统 Input 事件回调的入口,将展开整个返回手势的判断、视图和动画的刷新以及返回事件的触发。
首先将检查一下是否是 Touch 的 MotionEvent 类型,之后交给onMotionEvent() 预处理。

// EdgeBackGestureHandler.java
private void onInputEvent(InputEvent ev) {
if (!(ev instanceof MotionEvent)) return;
MotionEvent event = (MotionEvent) ev;

onMotionEvent(event);
}

onMotionEvent() 将先进行共通的事件拦截和停用区域检查,通过后交给返回手势视图即 EdgeBackPlugin 进一步处理。

// EdgeBackGestureHandler.java
private void onMotionEvent(MotionEvent ev) {
int action = ev.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
mInputEventReceiver.setBatchingEnabled(false);
mIsOnLeftEdge = ev.getX() <= mEdgeWidthLeft + mLeftInset;
mMLResults = 0;
mLogGesture = false;
mInRejectedExclusion = false;
boolean isWithinInsets = isWithinInsets((int) ev.getX(), (int) ev.getY());
// 根据返回手势是否有效、
// 点击区域是否是停用区域等条件
// 得到当前是否允许视图处理该手势
mAllowGesture = !mDisabledForQuickstep && mIsBackGestureAllowed && isWithinInsets

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!


img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上鸿蒙开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

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

ogGesture = false;
mInRejectedExclusion = false;
boolean isWithinInsets = isWithinInsets((int) ev.getX(), (int) ev.getY());
// 根据返回手势是否有效、
// 点击区域是否是停用区域等条件
// 得到当前是否允许视图处理该手势
mAllowGesture = !mDisabledForQuickstep && mIsBackGestureAllowed && isWithinInsets

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!


[外链图片转存中…(img-WiHZJLZF-1715307524564)]
[外链图片转存中…(img-XhMKc4kn-1715307524564)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上鸿蒙开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

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

  • 17
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值