android 自定义加载中,Android:你也可以自己写一个可爱 & 小资风格的加载等待自定义View...

67b69fc8b63b

前言

Android开发中,加载等待的需求 非常常见

本文将手把手教你做 一款 可爱 & 小资风格的加载等待Android自定义View控件,希望你们会喜欢。

67b69fc8b63b

示意图

已在Github开源:Kawaii_LoadingView,欢迎 Star !

目录

67b69fc8b63b

示意图

1. 简介

一款 可爱 、清新 & 小资风格的 Android自定义View控件

已在Github开源:Kawaii_LoadingView,欢迎 Star !

67b69fc8b63b

示意图

2. 应用场景

App 长时间加载等待时,用于提示用户进度 & 缓解用户情绪

3. 特点

对比市面上的加载等待自定义控件,该控件Kawaii_LoadingView 的特点是:

3.1 样式清新

对比市面上 各种酷炫、眼花缭乱的加载等待自定义控件,该款 Kawaii_LoadingView 的 清新 & 小资风格 简直是一股清流

同时,可根据您的App定位 & 主色进行颜色调整,使得控件更加符合App的形象。具体如下:

67b69fc8b63b

示意图

67b69fc8b63b

示意图

67b69fc8b63b

示意图

67b69fc8b63b

示意图

3.2 使用简单

仅需要3步骤 & 配置简单。

3.3 二次开发成本低

详细的源码分析文档:具体请看本文的第6节

所以,在其上做二次开发 & 定制化成本非常低。

4. 具体使用

5. 完整Demo地址

67b69fc8b63b

最终示意图.gif

6. 源码分析

下面,我将手把手教你如何实现这款 可爱 & 小资风格的加载等待Android自定义View控件

6.1 准备说明

方格排列说明

67b69fc8b63b

示意图

方块类型说明

67b69fc8b63b

示意图

6.2 动画原理

隐藏固定的2个方块 & 移动方块继承其中1个的位置

注:只有外部方块运动

通过 属性动画 (平移 + 旋转 = 组合动画)改变移动方块的位置 & 旋转角度

通过调用 invalidate() 重新绘制,从而实现动态的动画效果

具体原理图如下:

67b69fc8b63b

示意图

6.3 实现步骤

67b69fc8b63b

示意图

下面我将详细介绍每个步骤:

步骤1:初始化动画属性

属性说明:

67b69fc8b63b

示意图

具体属性设置

67b69fc8b63b

示意图

添加属性文件

attrs.xml

具体源码分析

private void initAttrs(Context context, AttributeSet attrs) {

// 控件资源名称

TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.Kawaii_LoadingView);

// 一行的数量(最少3行)

lineNumber = typedArray.getInteger(R.styleable.Kawaii_LoadingView_lineNumber, 3);

if (lineNumber < 3) {

lineNumber = 3;

}

// 半个方块的宽度(dp)

half_BlockWidth = typedArray.getDimension(R.styleable.Kawaii_LoadingView_half_BlockWidth, 30);

// 方块间隔宽度(dp)

blockInterval = typedArray.getDimension(R.styleable.Kawaii_LoadingView_blockInterval, 10);

// 移动方块的圆角半径

moveBlock_Angle = typedArray.getFloat(R.styleable.Kawaii_LoadingView_moveBlock_Angle, 10);

// 固定方块的圆角半径

fixBlock_Angle = typedArray.getFloat(R.styleable.Kawaii_LoadingView_fixBlock_Angle, 30);

// 通过设置两个方块的圆角半径使得二者不同可以得到更好的动画效果哦

// 方块颜色(使用十六进制代码,如#333、#8e8e8e)

int defaultColor = context.getResources().getColor(R.color.colorAccent); // 默认颜色

blockColor = typedArray.getColor(R.styleable.Kawaii_LoadingView_blockColor, defaultColor);

// 移动方块的初始位置(即空白位置)

initPosition = typedArray.getInteger(R.styleable.Kawaii_LoadingView_initPosition, 0);

// 由于移动方块只能是外部方块,所以这里需要判断方块是否属于外部方块 -->关注1

if (isInsideTheRect(initPosition, lineNumber)) {

initPosition = 0;

}

// 动画方向是否 = 顺时针旋转

isClock_Wise = typedArray.getBoolean(R.styleable.Kawaii_LoadingView_isClock_Wise, true);

// 移动方块的移动速度

// 注:不建议使用者将速度调得过快

// 因为会导致ValueAnimator动画对象频繁重复的创建,存在内存抖动

moveSpeed = typedArray.getInteger(R.styleable.Kawaii_LoadingView_moveSpeed, 250);

// 设置移动方块动画的插值器

int move_InterpolatorResId = typedArray.getResourceId(R.styleable.Kawaii_LoadingView_move_Interpolator,

android.R.anim.linear_interpolator);

move_Interpolator = AnimationUtils.loadInterpolator(context, move_InterpolatorResId);

// 当方块移动后,需要实时更新的空白方块的位置

mCurrEmptyPosition = initPosition;

// 释放资源

typedArray.recycle();

}

// 此步骤结束

/**

* 关注1:判断方块是否在内部

*/

private boolean isInsideTheRect(int pos, int lineCount) {

// 判断方块是否在第1行

if (pos < lineCount) {

return false;

// 是否在最后1行

} else if (pos > (lineCount * lineCount - 1 - lineCount)) {

return false;

// 是否在最后1行

} else if ((pos + 1) % lineCount == 0) {

return false;

// 是否在第1行

} else if (pos % lineCount == 0) {

return false;

}

// 若不在4边,则在内部

return true;

}

// 回到原处

步骤2:初始化方块对象 & 之间的关系

private void init() {

// 初始化画笔

mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

mPaint.setColor(blockColor);

// 初始化方块对象 & 关系 ->>关注1

initBlocks(initPosition);

}

/**

* 关注1

* 初始化方块对象、之间的关系

* 参数说明:initPosition = 移动方块的初始位置

*/

private void initBlocks(int initPosition) {

// 1. 创建总方块的数量(固定方块) = lineNumber * lineNumber

// lineNumber = 方块的行数

// fixedBlock = 固定方块 类 ->>关注2

mfixedBlocks = new fixedBlock[lineNumber * lineNumber];

// 2. 创建方块

for (int i = 0; i < mfixedBlocks.length; i++) {

// 创建固定方块 & 保存到数组中

mfixedBlocks[i] = new fixedBlock();

// 对固定方块对象里的变量进行赋值

mfixedBlocks[i].index = i;

// 对方块是否显示进行判断

// 若该方块的位置 = 移动方块的初始位置,则隐藏;否则显示

mfixedBlocks[i].isShow = initPosition == i ? false : true;

mfixedBlocks[i].rectF = new RectF();

}

// 3. 创建移动的方块(1个) ->>关注3

mMoveBlock = new MoveBlock();

mMoveBlock.rectF = new RectF();

mMoveBlock.isShow = false;

// 4. 关联外部方块的位置

// 因为外部的方块序号 ≠ 0、1、2…排列,通过 next变量(指定其下一个),一个接一个连接 外部方块 成圈

// ->>关注4

relate_OuterBlock(mfixedBlocks, isClock_Wise);

}

// 此步骤结束

/**

* 关注2:固定方块 类(内部类)

*/

private class fixedBlock {

// 存储方块的坐标位置参数

RectF rectF;

// 方块对应序号

int index;

// 标志位:判断是否需要绘制

boolean isShow;

// 指向下一个需要移动的位置

fixedBlock next;

// 外部的方块序号 ≠ 0、1、2…排列,通过 next变量(指定其下一个),一个接一个连接 外部方块 成圈

}

// 请回到原处

/**

* 关注3

*:移动方块类(内部类)

*/

private class MoveBlock {

// 存储方块的坐标位置参数

RectF rectF;

// 方块对应序号

int index;

// 标志位:判断是否需要绘制

boolean isShow;

// 旋转中心坐标

// 移动时的旋转中心(X,Y)

float cx;

float cy;

}

// 请回到原处

/**

* 关注4:将外部方块的位置关联起来

* 算法思想: 按照第1行、最后1行、第1列 & 最后1列的顺序,分别让每个外部方块的next属性 == 下一个外部方块的位置,最终对整个外部方块的位置进行关联

* 注:需要考虑移动方向变量isClockwise( 顺 Or 逆时针)

*/

private void relate_OuterBlock(fixedBlock[] fixedBlocks, boolean isClockwise) {

int lineCount = (int) Math.sqrt(fixedBlocks.length);

// 情况1:关联第1行

for (int i = 0; i < lineCount; i++) {

// 位于最左边

if (i % lineCount == 0) {

fixedBlocks[i].next = isClockwise ? fixedBlocks[i + lineCount] : fixedBlocks[i + 1];

// 位于最右边

} else if ((i + 1) % lineCount == 0) {

fixedBlocks[i].next = isClockwise ? fixedBlocks[i - 1] : fixedBlocks[i + lineCount];

// 中间

} else {

fixedBlocks[i].next = isClockwise ? fixedBlocks[i - 1] : fixedBlocks[i + 1];

}

}

// 情况2:关联最后1行

for (int i = (lineCount - 1) * lineCount; i < lineCount * lineCount; i++) {

// 位于最左边

if (i % lineCount == 0) {

fixedBlocks[i].next = isClockwise ? fixedBlocks[i + 1] : fixedBlocks[i - lineCount];

// 位于最右边

} else if ((i + 1) % lineCount == 0) {

fixedBlocks[i].next = isClockwise ? fixedBlocks[i - lineCount] : fixedBlocks[i - 1];

// 中间

} else {

fixedBlocks[i].next = isClockwise ? fixedBlocks[i + 1] : fixedBlocks[i - 1];

}

}

// 情况3:关联第1列

for (int i = 1 * lineCount; i <= (lineCount - 1) * lineCount; i += lineCount) {

// 若是第1列最后1个

if (i == (lineCount - 1) * lineCount) {

fixedBlocks[i].next = isClockwise ? fixedBlocks[i + 1] : fixedBlocks[i - lineCount];

continue;

}

fixedBlocks[i].next = isClockwise ? fixedBlocks[i + lineCount] : fixedBlocks[i - lineCount];

}

// 情况4:关联最后1列

for (int i = 2 * lineCount - 1; i <= lineCount * lineCount - 1; i += lineCount) {

// 若是最后1列最后1个

if (i == lineCount * lineCount - 1) {

fixedBlocks[i].next = isClockwise ? fixedBlocks[i - lineCount] : fixedBlocks[i - 1];

continue;

}

fixedBlocks[i].next = isClockwise ? fixedBlocks[i - lineCount] : fixedBlocks[i + lineCount];

}

}

// 请回到原处

步骤3:设置方块初始位置

// 该步骤写在onSizeChanged()

@Override

protected void onSizeChanged(int w, int h, int oldw, int oldh) {

// 调用时刻:onCreate之后onDraw之前调用;view的大小发生改变就会调用该方法

// 使用场景:用于屏幕的大小改变时,需要根据屏幕宽高来决定的其他变量可以在这里进行初始化操作

super.onSizeChanged(w, h, oldw, oldh);

int measuredWidth = getMeasuredWidth();

int measuredHeight = getMeasuredHeight();

// 1. 设置移动方块的旋转中心坐标

int cx = measuredWidth / 2;

int cy = measuredHeight / 2;

// 2. 设置固定方块的位置 ->>关注1

fixedBlockPosition(mfixedBlocks, cx, cy, blockInterval, half_BlockWidth);

// 3. 设置移动方块的位置 ->>关注2

MoveBlockPosition(mfixedBlocks, mMoveBlock, initPosition, isClock_Wise);

}

// 此步骤结束

/**

* 关注1:设置 固定方块位置

*/

private void fixedBlockPosition(fixedBlock[] fixedBlocks, int cx, int cy, float dividerWidth, float halfSquareWidth) {

// 1. 确定第1个方块的位置

// 分为2种情况:行数 = 偶 / 奇数时

// 主要是是数学知识,此处不作过多描述

float squareWidth = halfSquareWidth * 2;

int lineCount = (int) Math.sqrt(fixedBlocks.length);

float firstRectLeft = 0;

float firstRectTop = 0;

// 情况1:当行数 = 偶数时

if (lineCount % 2 == 0) {

int squareCountInAline = lineCount / 2;

int diviCountInAline = squareCountInAline - 1;

float firstRectLeftTopFromCenter = squareCountInAline * squareWidth

+ diviCountInAline * dividerWidth

+ dividerWidth / 2;

firstRectLeft = cx - firstRectLeftTopFromCenter;

firstRectTop = cy - firstRectLeftTopFromCenter;

// 情况2:当行数 = 奇数时

} else {

int squareCountInAline = lineCount / 2;

int diviCountInAline = squareCountInAline;

float firstRectLeftTopFromCenter = squareCountInAline * squareWidth

+ diviCountInAline * dividerWidth

+ halfSquareWidth;

firstRectLeft = cx - firstRectLeftTopFromCenter;

firstRectTop = cy - firstRectLeftTopFromCenter;

firstRectLeft = cx - firstRectLeftTopFromCenter;

firstRectTop = cy - firstRectLeftTopFromCenter;

}

// 2. 确定剩下的方块位置

// 思想:把第一行方块位置往下移动即可

// 通过for循环确定:第一个for循环 = 行,第二个 = 列

for (int i = 0; i < lineCount; i++) {//行

for (int j = 0; j < lineCount; j++) {//列

if (i == 0) {

if (j == 0) {

fixedBlocks[0].rectF.set(firstRectLeft, firstRectTop,

firstRectLeft + squareWidth, firstRectTop + squareWidth);

} else {

int currIndex = i * lineCount + j;

fixedBlocks[currIndex].rectF.set(fixedBlocks[currIndex - 1].rectF);

fixedBlocks[currIndex].rectF.offset(dividerWidth + squareWidth, 0);

}

} else {

int currIndex = i * lineCount + j;

fixedBlocks[currIndex].rectF.set(fixedBlocks[currIndex - lineCount].rectF);

fixedBlocks[currIndex].rectF.offset(0, dividerWidth + squareWidth);

}

}

}

}

// 回到原处

/**

* 关注2:设置移动方块的位置

*/

private void MoveBlockPosition(fixedBlock[] fixedBlocks,

MoveBlock moveBlock, int initPosition, boolean isClockwise) {

// 移动方块位置 = 设置初始的空出位置 的下一个位置(next)

// 下一个位置 通过 连接的外部方块位置确定

fixedBlock fixedBlock = fixedBlocks[initPosition];

moveBlock.rectF.set(fixedBlock.next.rectF);

}

// 回到原处

步骤4:绘制方块

// 此步骤写到onDraw()中

@Override

protected void onDraw(Canvas canvas) {

// 1. 绘制内部方块(固定的)

for (int i = 0; i < mfixedBlocks.length; i++) {

// 根据标志位判断是否需要绘制

if (mfixedBlocks[i].isShow) {

// 传入方块位置参数、圆角 & 画笔属性

canvas.drawRoundRect(mfixedBlocks[i].rectF, fixBlock_Angle, fixBlock_Angle, mPaint);

}

}

// 2. 绘制移动的方块

if (mMoveBlock.isShow) {

canvas.rotate(isClock_Wise ? mRotateDegree : -mRotateDegree, mMoveBlock.cx, mMoveBlock.cy);

canvas.drawRoundRect(mMoveBlock.rectF, moveBlock_Angle, moveBlock_Angle, mPaint);

}

}

步骤5:设置动画

实现该动画的步骤包括:设置平移动画、旋转动画 & 组合动画。

1.设置平移动画

private ValueAnimator createTranslateValueAnimator(fixedBlock currEmptyfixedBlock,

fixedBlock moveBlock) {

float startAnimValue = 0;

float endAnimValue = 0;

PropertyValuesHolder left = null;

PropertyValuesHolder top = null;

// 1. 设置移动速度

ValueAnimator valueAnimator = new ValueAnimator().setDuration(moveSpeed);

// 2. 设置移动方向

// 情况分为:4种,分别是移动方块向左、右移动 和 上、下移动

// 注:需考虑 旋转方向(isClock_Wise),即顺逆时针 ->>关注1

if (isNextRollLeftOrRight(currEmptyfixedBlock, moveBlock)) {

// 情况1:顺时针且在第一行 / 逆时针且在最后一行时,移动方块向右移动

if (isClock_Wise && currEmptyfixedBlock.index > moveBlock.index || !isClock_Wise && currEmptyfixedBlock.index > moveBlock.index) {

startAnimValue = moveBlock.rectF.left;

endAnimValue = moveBlock.rectF.left + blockInterval;

// 情况2:顺时针且在最后一行 / 逆时针且在第一行,移动方块向左移动

} else if (isClock_Wise && currEmptyfixedBlock.index < moveBlock.index

|| !isClock_Wise && currEmptyfixedBlock.index < moveBlock.index) {

startAnimValue = moveBlock.rectF.left;

endAnimValue = moveBlock.rectF.left - blockInterval;

}

// 设置属性值

left = PropertyValuesHolder.ofFloat("left", startAnimValue, endAnimValue);

valueAnimator.setValues(left);

} else {

// 情况3:顺时针且在最左列 / 逆时针且在最右列,移动方块向上移动

if (isClock_Wise && currEmptyfixedBlock.index < moveBlock.index

|| !isClock_Wise && currEmptyfixedBlock.index < moveBlock.index) {

startAnimValue = moveBlock.rectF.top;

endAnimValue = moveBlock.rectF.top - blockInterval;

// 情况4:顺时针且在最右列 / 逆时针且在最左列,移动方块向下移动

} else if (isClock_Wise && currEmptyfixedBlock.index > moveBlock.index

|| !isClock_Wise && currEmptyfixedBlock.index > moveBlock.index) {

startAnimValue = moveBlock.rectF.top;

endAnimValue = moveBlock.rectF.top + blockInterval;

}

// 设置属性值

top = PropertyValuesHolder.ofFloat("top", startAnimValue, endAnimValue);

valueAnimator.setValues(top);

}

// 3. 通过监听器更新属性值

valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

@Override

public void onAnimationUpdate(ValueAnimator animation) {

Object left = animation.getAnimatedValue("left");

Object top = animation.getAnimatedValue("top");

if (left != null) {

mMoveBlock.rectF.offsetTo((Float) left, mMoveBlock.rectF.top);

}

if (top != null) {

mMoveBlock.rectF.offsetTo(mMoveBlock.rectF.left, (Float) top);

}

// 实时更新旋转中心 ->>关注2

setMoveBlockRotateCenter(mMoveBlock, isClock_Wise);

// 更新绘制

invalidate();

}

});

return valueAnimator;

}

// 此步骤分析完毕

/**

* 关注1:判断移动方向

* 即上下 or 左右

*/

private boolean isNextRollLeftOrRight(fixedBlock currEmptyfixedBlock, fixedBlock rollSquare) {

if (currEmptyfixedBlock.rectF.left - rollSquare.rectF.left == 0) {

return false;

} else {

return true;

}

}

// 回到原处

/**

* 关注2:实时更新移动方块的旋转中心

* 因为方块在平移旋转过程中,旋转中心也会跟着改变,因此需要改变MoveBlock的旋转中心(cx,cy)

*/

private void setMoveBlockRotateCenter(MoveBlock moveBlock, boolean isClockwise) {

// 情况1:以移动方块的左上角为旋转中心

if (moveBlock.index == 0) {

moveBlock.cx = moveBlock.rectF.right;

moveBlock.cy = moveBlock.rectF.bottom;

// 情况2:以移动方块的右下角为旋转中心

} else if (moveBlock.index == lineNumber * lineNumber - 1) {

moveBlock.cx = moveBlock.rectF.left;

moveBlock.cy = moveBlock.rectF.top;

// 情况3:以移动方块的左下角为旋转中心

} else if (moveBlock.index == lineNumber * (lineNumber - 1)) {

moveBlock.cx = moveBlock.rectF.right;

moveBlock.cy = moveBlock.rectF.top;

// 情况4:以移动方块的右上角为旋转中心

} else if (moveBlock.index == lineNumber - 1) {

moveBlock.cx = moveBlock.rectF.left;

moveBlock.cy = moveBlock.rectF.bottom;

}

//以下判断与旋转方向有关:即顺 or 逆顺时针

// 情况1:左边

else if (moveBlock.index % lineNumber == 0) {

moveBlock.cx = moveBlock.rectF.right;

moveBlock.cy = isClockwise ? moveBlock.rectF.top : moveBlock.rectF.bottom;

// 情况2:上边

} else if (moveBlock.index < lineNumber) {

moveBlock.cx = isClockwise ? moveBlock.rectF.right : moveBlock.rectF.left;

moveBlock.cy = moveBlock.rectF.bottom;

// 情况3:右边

} else if ((moveBlock.index + 1) % lineNumber == 0) {

moveBlock.cx = moveBlock.rectF.left;

moveBlock.cy = isClockwise ? moveBlock.rectF.bottom : moveBlock.rectF.top;

// 情况4:下边

} else if (moveBlock.index > (lineNumber - 1) * lineNumber) {

moveBlock.cx = isClockwise ? moveBlock.rectF.left : moveBlock.rectF.right;

moveBlock.cy = moveBlock.rectF.top;

}

}

// 回到原处

2. 设置旋转动画

private ValueAnimator createMoveValueAnimator() {

// 通过属性动画进行设置

ValueAnimator moveAnim = ValueAnimator.ofFloat(0, 90).setDuration(moveSpeed);

moveAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

@Override

public void onAnimationUpdate(ValueAnimator animation) {

Object animatedValue = animation.getAnimatedValue();

// 赋值

mRotateDegree = (float) animatedValue;

// 更新视图

invalidate();

}

});

return moveAnim;

}

// 此步骤完毕

3. 设置组合动画

private void setAnimation() {

// 1. 获取固定方块当前的空位置,即移动方块当前位置

fixedBlock currEmptyfixedBlock = mfixedBlocks[mCurrEmptyPosition];

// 2. 获取移动方块的到达位置,即固定方块当前空位置的下1个位置

fixedBlock movedBlock = currEmptyfixedBlock.next;

// 3. 设置动画变化的插值器

mAnimatorSet.setInterpolator(move_Interpolator);

mAnimatorSet.playTogether(translateConrtroller, moveConrtroller);

mAnimatorSet.addListener(new AnimatorListenerAdapter() {

// 4. 动画开始时进行一些设置

@Override

public void onAnimationStart(Animator animation) {

// 每次动画开始前都需要更新移动方块的位置 ->>关注1

updateMoveBlock();

// 让移动方块的初始位置的下个位置也隐藏 = 两个隐藏的方块

mfixedBlocks[mCurrEmptyPosition].next.isShow = false;

// 通过标志位将移动的方块显示出来

mMoveBlock.isShow = true;

}

// 5. 结束时进行一些设置

@Override

public void onAnimationEnd(Animator animation) {

isMoving = false;

mfixedBlocks[mCurrEmptyPosition].isShow = true;

mCurrEmptyPosition = mfixedBlocks[mCurrEmptyPosition].next.index;

// 将移动的方块隐藏

mMoveBlock.isShow = false;

// 通过标志位判断动画是否要循环播放

if (mAllowRoll) {

startMoving();

}

}

});

// 此步骤分析完毕

/**

* 关注1:更新移动方块的位置

*/

private void updateMoveBlock() {

mMoveBlock.rectF.set(mfixedBlocks[mCurrEmptyPosition].next.rectF);

mMoveBlock.index = mfixedBlocks[mCurrEmptyPosition].next.index;

setMoveBlockRotateCenter(mMoveBlock, isClock_Wise);

}

// 回到原处

步骤6:启动动画

public void startMoving() {

// 1. 根据标志位 & 视图是否可见确定是否需要启动动画

// 此处设置是为了方便手动 & 自动停止动画

if (isMoving || getVisibility() != View.VISIBLE ) {

return;

}

// 2. 设置标记位:以便是否停止动画

isMoving = true;

mAllowRoll = true;

// 3. 启动动画

mAnimatorSet.start();

// 停止动画

public void stopMoving() {

// 通过标记位来设置

mAllowRoll = false;

}

7. 贡献代码

希望你们能和我一起完善这款清新 & 小资风格的自定义控件,具体请看:贡献代码说明

关于该开源项目的意见 & 建议可在Issue上提出。欢迎 Star !

8. 总结

相信你一定会喜欢上 这款可爱、清新 & 小资风格的加载等待自定义控件

已在Github上开源:Kawaii_LoadingView,欢迎 Star !

67b69fc8b63b

示意图

此外,我还有一些有趣的自定义View实例讲解,有兴趣可以继续关注Carson_Ho的安卓开发笔记

请点赞!因为你的鼓励是我写作的最大动力!

不定期分享关于安卓开发的干货,追求短、平、快,但却不缺深度。

67b69fc8b63b

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值