Lifecycle+Retrofit+Room完美结合 领略架构之美

}

//实现类回调方法
protected void onActive() {

}

//实现类回调方法
protected void onInactive() {
}

class LifecycleBoundObserver implements GenericLifecycleObserver {
@Override
public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {
if (owner.getLifecycle().getCurrentState() == DESTROYED) {
removeObserver(observer);
return;
}
// immediately set active state, so we’d never dispatch anything to inactive
// owner
activeStateChanged(isActiveState(owner.getLifecycle().getCurrentState()));
}

void activeStateChanged(boolean newActive) {
if (newActive == active) {
return;
}
active = newActive;
boolean wasInactive = LiveData.this.mActiveCount == 0;
LiveData.this.mActiveCount += active ? 1 : -1;
if (wasInactive && active) {
onActive();
}
if (LiveData.this.mActiveCount == 0 && !active) {
onInactive();
}
if (active) {//只有生命组件处于前台时,才触发数据的变更通知
dispatchingValue(this);
}
}
}

static boolean isActiveState(State state) {
return state.isAtLeast(STARTED);
}
}

看源码,会发现LiveData有个重要的方法observe(LifecycleOwner owner, Observer observer), 在数据源数据有变更时,遍历分发数据到所有监听者,最后会回调onChanged()方法。

public interface Observer {
/**

  • Called when the data is changed.
  • @param t The new data
    */
    void onChanged(@Nullable T t);
    }

LiveData有两个实现类:MediatorLiveData_和_MediatorLiveData,继承关系如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

_MutableLiveData_类很简单,只是暴露了两个方法:postData()和setData()。 _MediatorLiveData_类有个**addSource()**方法,可以实现监听另一个或多个LiveData数据源变化,这样我们就可以比较便捷且低耦合的实现多个数据源的逻辑,并且关联到一个MediatorLiveData上,实现多数据源的自动整合。

@MainThread
public void addSource(@NonNull LiveData source, @NonNull Observer onChanged) {
Source e = new Source<>(source, onChanged);
Source<?> existing = mSources.putIfAbsent(source, e);
if (existing != null && existing.mObserver != onChanged) {
throw new IllegalArgumentException(
“This source was already added with the different observer”);
}
if (existing != null) {
return;
}
if (hasActiveObservers()) {
e.plug();
}
}

ViewModel

LiveData和LiveCycle将数据与数据,数据与UI生命绑定到了一起,实现了数据的自动管理和更新,那这些数据如何缓存呢?能否在多个页面共享这些数据呢?答案是ViewMode。

A ViewModel is always created in association with a scope (an fragment or an activity) and will be retained as long as the scope is alive. E.g. if it is an Activity, until it is finished.

ViewMode相当于一层数据隔离层,将UI层的数据逻辑全部抽离干净,管理制底层数据的获取方式和逻辑。

ViewModel viewModel = ViewModelProviders.of(this).get(xxxModel.class);
ViewModel viewModel = ViewModelProviders.of(this, factory).get(xxxModel.class);

可以通过以上方式获取ViewModel实例,如果有自定义ViewModel构造器参数,需要借助ViewModelProvider.NewInstanceFactory,自己实现create方法。

那么,ViewMode是怎么被保存的呢? 可以顺着ViewModelProviders源码进去看看。

@NonNull
@MainThread
public T get(@NonNull String key, @NonNull Class modelClass) {
ViewModel viewModel = mViewModelStore.get(key);

if (modelClass.isInstance(viewModel)) {
//noinspection unchecked
return (T) viewModel;
} else {
//noinspection StatementWithEmptyBody
if (viewModel != null) {
// TODO: log a warning.
}
}

viewModel = mFactory.create(modelClass);
mViewModelStore.put(key, viewModel);
//noinspection unchecked
return (T) viewModel;
}

发现get方法会先从缓存中获取,没有的化就会通过_Factory_的create方法构造一个ViewModel,然后放入缓存,下次直接使用。

Room

Room是一种ORM(对象关系映射)模式数据库框架,对安卓SQlite的抽象封装,从此操作数据库提供了超便捷方式。

The Room persistence library provides an abstraction layer over SQLite to allow fluent database access while harnessing the full power of SQLite.

同样基于ORM模式封装的数据库,比较有名还有_GreenDao_。而Room和其他ORM对比,具有编译时验证查询语句正常性,支持LiveData数据返回等优势。 我们选择room,更多是因为对LiveData的完美支持,可以动态的将DB数据变化自动更新到LiveData上,在通过LiveData自动刷新到UI上。

这里引用网络上的一张Room与其他同类性能对比图片:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Room用法:

    1. 继承RoomDatabase的抽象类, 暴露抽象方法getxxxDao()。

@Database(entities = {EssayDayEntity.class, ZhihuItemEntity.class}, version = 1)
@TypeConverters(DateConverter.class)
public abstract class AppDB extends RoomDatabase {
private static AppDB sInstance;
@VisibleForTesting
public static final String DATABASE_NAME = “canking.db”;
public abstract EssayDao essayDao();
}

    1. 获取db实例

ppDatabase db = Room.databaseBuilder(getApplicationContext(),
AppDatabase.class, “database-name”).build();

    1. 实现Dao层逻辑

@Dao
public interface ZhuhuDao {
@Query(“SELECT * FROM zhuhulist order by id desc, id limit 0,1”)
LiveData loadZhuhu();

@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertItem(ZhihuItemEntity products);
}

    1. 添加一张表结构

@Entity
public class User {
@PrimaryKey
private int uid;

@ColumnInfo(name = “first_name”)
private String firstName;

public String date;//默认columnInfo 为 date

}

就这么简单,就可以实现数据库的操作,完全隔离的底层复杂的数据库操作,大大节省项目研发重复劳动力。

从使用说明分析,UserDao和Db一个是接口,一个是抽象类,这些逻辑的实现完全是由annotationProcessor依赖注入帮我们实现的, annotationProcessor其实就是开源的android-apt的官方替代品。 那么编译项目后,可以在build目录下看到生成相应的类xxx_impl.class。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

既然Room支持LiveData数据,那么有可以分析下源码,了解下具体原理,方便以后填坑。

先选Demo中Dao层的insert方法,看看数据如何加载到内存的。我们的query方法如下:

@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertItem(ZhihuItemEntity products);

annotationProcessor帮我吗生成后的实现主要代码如下:

private final RoomDatabase __db;
private final EntityInsertionAdapter __insertionAdapterOfZhihuItemEntity;

public ZhuhuDao_Impl(RoomDatabase __db) {
this.__db = __db;
//EntityInsertionAdapter类的匿名内部类实现方式,
this.__insertionAdapterOfZhihuItemEntity = new EntityInsertionAdapter(__db) {
public String createQuery() {
return “INSERT OR REPLACE INTO zhuhulist(id,date,stories,top_stories) VALUES (nullif(?, 0),?,?,?)”;
}

public void bind(SupportSQLiteStatement stmt, ZhihuItemEntity value) {
//通过SQLiteStatement的bind方法,可以很巧妙的将类对象数据转化为数据库要操作的数据类型。
stmt.bindLong(1, (long)value.getId());//按顺序依次放入SQLiteStatement对象。
if(value.date == null) {
stmt.bindNull(2);
} else {
stmt.bindString(2, value.date);
}

//通过DB类注入的自定义转化器,我们可以将任何对象类型持久化到数据库中,并且很便捷的从数据库反序列化出来
String _tmp = DateConverter.toZhihuStoriesEntity(value.stories);
if(_tmp == null) {
stmt.bindNull(3);
} else {
stmt.bindString(3, _tmp);
}

String _tmp_1 = DateConverter.toZhihuStoriesEntity(value.top_stories);
if(_tmp_1 == null) {
stmt.bindNull(4);
} else {
stmt.bindString(4, _tmp_1);
}

}
};
}

public void insertItem(ZhihuItemEntity products) {
this.__db.beginTransaction();

try {
//借助SQLiteStatement类操作数据库,既优化了数据库操作性能,又巧妙的bind了对象类型数据。
this.__insertionAdapterOfZhihuItemEntity.insert(products);
this.__db.setTransactionSuccessful();
} finally {
//这里很重要,我们平时操作数据库或流必须要做 finally块 关闭资源。
this.__db.endTransaction();
}
}

实现类中可以看出insert是通过EntityInsertionAdapter类完成操作的,而EntityInsertionAdapter内部会持有个SupportSQLiteStatement,其实就是_SQLiteStatement_类的抽象封装。 其实例获取是通过RoomData内部方法compileStatement()得到的。

研究下RoomData抽象类源码:

public abstract class RoomDatabase {
// set by the generated open helper.
protected volatile SupportSQLiteDatabase mDatabase;//SQLiteDatabase类的封装抽象层
private SupportSQLiteOpenHelper mOpenHelper;//SQLiteOpenHelper类的封装抽象层
private final InvalidationTracker mInvalidationTracker;//绑定数据变更监听器,如在数据变化时通知LiveData

protected abstract SupportSQLiteOpenHelper createOpenHelper(DatabaseConfiguration config);
protected abstract InvalidationTracker createInvalidationTracker();

public Cursor query(String query, @Nullable Object[] args) {
return mOpenHelper.getWritableDatabase().query(new SimpleSQLiteQuery(query, args));
}

public Cursor query(SupportSQLiteQuery query) {
assertNotMainThread();//每次数据库操作检查线程
return mOpenHelper.getWritableDatabase().query(query);
}

public SupportSQLiteStatement compileStatement(String sql) {
assertNotMainThread();
return mOpenHelper.getWritableDatabase().compileStatement(sql);
}

public void beginTransaction() {
assertNotMainThread();
mInvalidationTracker.syncTriggers();
mOpenHelper.getWritableDatabase().beginTransaction();
}

public void endTransaction() {
mOpenHelper.getWritableDatabase().endTransaction();
if (!inTransaction()) {
// enqueue refresh only if we are NOT in a transaction. Otherwise, wait for the last
// endTransaction call to do it.
mInvalidationTracker.refreshVersionsAsync();
}
}

public static class Builder {
private MigrationContainer mMigrationContainer;//数据库升级辅助类

@NonNull
public Builder addCallback(@NonNull Callback callback) {
if (mCallbacks == null) {
mCallbacks = new ArrayList<>();
}
mCallbacks.add(callback);
return this;
}

@NonNull
public T build() {
//noinspection ConstantConditions
if (mContext == null) {
throw new IllegalArgumentException(“Cannot provide null context for the database.”);
}
//noinspection ConstantConditions
if (mDatabaseClass == null) {
throw new IllegalArgumentException(“Must provide an abstract class that”

  • " extends RoomDatabase");
    }
    if (mFactory == null) {
    //默认的SupportSQLiteOpenHelper创建工厂
    mFactory = new FrameworkSQLiteOpenHelperFactory();//SupportSQLiteOpenHelper的实现类,通过mDelegate带来类操作真正的SQLiteOpenHelper
    }
    DatabaseConfiguration configuration =
    new DatabaseConfiguration(mContext, mName, mFactory, mMigrationContainer,
    mCallbacks, mAllowMainThreadQueries, mRequireMigration);
    //最终通过反射加载系统帮我们实现的真正RoomData
    T db = Room.getGeneratedImplementation(mDatabaseClass, DB_IMPL_SUFFIX);
    db.init(configuration);
    return db;
    }

public abstract static class Callback {

public void onCreate(@NonNull SupportSQLiteDatabase db) {
}

public void onOpen(@NonNull SupportSQLiteDatabase db) {
}
}
}

DB是通过Build设计模式获取实例的,在build过程中,可以添加CallBack抽象类回调数据的_onCreate_和_onOpen_。 这里发现个问题,抽象层封装那么深,*onUpgrade()*方法怎么回调呢?数据库的升级怎么添加自己的逻辑呢?奥秘在MigrationContainer类。

public static class MigrationContainer {
private SparseArrayCompat<SparseArrayCompat> mMigrations =
new SparseArrayCompat<>();

public void addMigrations(Migration… migrations) {
for (Migration migration : migrations) {
addMigration(migration);
}
}

private void addMigration(Migration migration) {
final int start = migration.startVersion;
final int end = migration.endVersion;
SparseArrayCompat targetMap = mMigrations.get(start);
if (targetMap == null) {
targetMap = new SparseArrayCompat<>();
mMigrations.put(start, targetMap);
}
Migration existing = targetMap.get(end);
if (existing != null) {
Log.w(Room.LOG_TAG, "Overriding migration " + existing + " with " + migration);
}
targetMap.append(end, migration);
}

@SuppressWarnings(“WeakerAccess”)
@Nullable
public List findMigrationPath(int start, int end) {
if (start == end) {
return Collections.emptyList();
}
boolean migrateUp = end > start;
List result = new ArrayList<>();
return findUpMigrationPath(result, migrateUp, start, end);
}
}

public abstract class Migration {
public final int startVersion;
public final int endVersion;

public Migration(int startVersion, int endVersion) {
this.startVersion = startVersion;
this.endVersion = endVersion;
}

public abstract void migrate(@NonNull SupportSQLiteDatabase database);
}
}

在Room.databaseBuilder过程中,可以通过*addMigration()*方法,设置多个或一个Migration。

在RoomOpenHelper的onUpgrade()方法中会依次调用升级范围内的Migration:

@Override
public void onUpgrade(SupportSQLiteDatabase db, int oldVersion, int newVersion) {
boolean migrated = false;
if (mConfiguration != null) {
List migrations = mConfiguration.migrationContainer.findMigrationPath(
oldVersion, newVersion);
if (migrations != null) {
for (Migration migration : migrations) {
migration.migrate(db);
}
}
}
}

分析Room到这里基本原理已了解,并且我们可以封装自己的Callback接口,对业务模块依次分发onCreate、onUpgrade方法,统一管理数据库的创建和升级。

Retrofit

当前业界很流行,且很优秀的开源网络库,基于OkHttp之前开发。

A type-safe HTTP client for Android and Java

个人理解Retrofit是高度抽象,且和业务耦合度很低的网络库,通过各种数据转化器或适配器,使得网络返回数据可以很奇妙的直接转化为我们想要的类型,与本地数据的缓存及持久化高度无缝对接,大大减少了开发投入。并且使得项目研发更易模块化和迭代升级。

基本用法可以移步官网学习研究,这里只分析下如何构造自定义返回类型,默认通用的请求返回如下:

XXXService service = retrofit.create(XXXService.class);
Call<List> repos = service.listRepos(“xxx”);

public T create(final Class service) {
Utils.validateServiceInterface(service);
if (validateEagerly) {
eagerlyValidateMethods(service);
}
return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class<?>[] { service },
new InvocationHandler() {
private final Platform platform = Platform.get();

@Override public Object invoke(Object proxy, Method method, @Nullable Object[] args)
throws Throwable {
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
}
if (platform.isDefaultMethod(method)) {
return platform.invokeDefaultMethod(method, service, proxy, args);
}
ServiceMethod<Object, Object> serviceMethod =
(ServiceMethod<Object, Object>) loadServiceMethod(method);
OkHttpCall okHttpCall = new OkHttpCall<>(serviceMethod, args);
return serviceMethod.callAdapter.adapt(okHttpCall);
}
});
}

retrofit.create方法内部通过java动态代理,链接接口方法,替换转化范型类型及返回类型。 Retrofit.Builder有两个重要方法,影响着*service.listRepos()*方法的返回值类型及反序类型。它们分别是:

/** Add converter factory for serialization and deserialization of objects. */
//影响者Call接口中的范型类型
public Builder addConverterFactory(Converter.Factory factory) {
converterFactories.add(checkNotNull(factory, “factory == null”));
return this;
}

/**

  • Add a call adapter factory for supporting service method return types other than {@link
  • Call}.
  • 影响者Call接口的具体实现类型
    */
    public Builder addCallAdapterFactory(CallAdapter.Factory factory) {
    adapterFactories.add(checkNotNull(factory, “factory == null”));
    return this;
    }

通过addConverterFactory方法,可以将网络返回数据直接转化为本地的具体实体类型,并且retrofit已经为我们提供了常见协议数据类型的封装库,如下:

Converter依赖
Gsoncom.squareup.retrofit2:converter-gson:xxx
Jacksoncom.squareup.retrofit2:converter-jackson:xxx
Moshicom.squareup.retrofit2:converter-moshi:xxx
Protobufcom.squareup.retrofit2:converter-protobuf:xxx
Wirecom.squareup.retrofit2:converter-wire:xxx
Simple XMLcom.squareup.retrofit2:converter-simplexml:xxx
Scalarscom.squareup.retrofit2:converter-scalars:xxx

Builder每添加一个转化器会保存在*List<Converter.Factory>*类型列表中去。通过以下代码转化为目标类型。

for (int i = start, count = converterFactories.size(); i < count; i++) {
Converter.Factory factory = converterFactories.get(i);
Converter<?, RequestBody> converter =
factory.requestBodyConverter(type, parameterAnnotations, methodAnnotations, this);
if (converter != null) {
//noinspection unchecked
return (Converter<T, RequestBody>) converter;
}
}

当然也可以自定义Converter类型:

public interface Converter<F, T> {
T convert(F value) throws IOException;

abstract class Factory {
// 这里创建从ResponseBody其它类型的Converter,如果不能处理返回null
// 主要用于对响应体的处理
public Converter<ResponseBody, ?> responseBodyConverter(Type type, Annotation[] annotations,
Retrofit retrofit) {
return null;
}

// 在这里创建 从自定类型到ResponseBody 的Converter,不能处理就返回null,
public Converter<?, RequestBody> requestBodyConverter(Type type,
Annotation[] parameterAnnotations, Annotation[] methodAnnotations, Retrofit retrofit) {
//在这里实现具体转化逻辑
}

// Retrfofit对于上面的几个注解默认使用的是调用toString方法
public Converter<?, String> stringConverter(Type type, Annotation[] annotations,
Retrofit retrofit) {
//在这里实现具体转化逻辑
}
}
}

Retrofit通过_addCallAdapterFactory_方法可以支持返回类型_Java8_或_rxjava_的处理(也需要添加gradle依赖库)。

new Retrofit.Builder()
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build();

三. 封装、整合各框架到项目中去

主要是用LiveData将各框架的数据获取及页面更新,按照MVVM思想整合起来, 使得项目结构符合官方给出的架构图建议,搭建一层逻辑结构,使得更加方便的使用各个组件库。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从上到下的逻辑顺序,依次构建各个业务层 需要的逻辑控件:

1.编写需要数据初始化或更新UI的接口方法,并在Observer中更新。

viewModel.getEssayData().observe(this, new Observer<Resource>() {
@Override
public void onChanged(@Nullable Resource essayDayEntityResource) {
//数据源内数据变动后自动回调该接口,然后更新到UI上
updateUI(essayDayEntityResource.data);
}
});

2.构建UI层需要的ViewModel

public class EssayViewModel extends AndroidViewModel {
private EssayRepository mRepository;
private MediatorLiveData<Resource> mCache;

public EssayViewModel(Application app) {
super(app);
mRepository = new EssayRepository(app);
}

public LiveData<Resource> getEssayData() {
if (mCache == null) {
//初始化后,从缓存读取
mCache = mRepository.loadEssayData();
}
return mCache;
}

public void updateCache() {
final LiveData<Resource> update = mRepository.update();
mCache.addSource(update, new Observer<Resource>() {
@Override
public void onChanged(@Nullable Resource zhihuItemEntityResource) {
mCache.setValue(zhihuItemEntityResource);
}
});

}

public void addMore(){
//TODO: 加载更多
}
}

3.实现Repository类,管理数据获取渠道。

这里按照官方知道,写了个抽象的数据源类,每次先从本地DB取数据,然后获取网络数据更新到数据库,通过LiveData更新到UI层。

public abstract class AbsDataSource<ResultType, RequestType> {
private final MediatorLiveData<Resource> result = new MediatorLiveData<>();

@WorkerThread
protected abstract void saveCallResult(@NonNull RequestType item);

@MainThread
protected abstract boolean shouldFetch(@Nullable ResultType data);

// Called to get the cached getDate from the database
@NonNull
@MainThread
protected abstract LiveData loadFromDb();

@NonNull
@MainThread
protected abstract LiveData<IRequestApi> createCall();

@MainThread
protected abstract void onFetchFailed();

@MainThread
public AbsDataSource() {
final LiveData dbSource = loadFromDb();
result.setValue(Resource.loading(dbSource.getValue()));

result.addSource(dbSource, new Observer() {
@Override
public void onChanged(@Nullable ResultType resultType) {
result.removeSource(dbSource);
if (shouldFetch(resultType)) {
fetchFromNetwork(dbSource);
} else {
result.addSource(dbSource, new Observer() {
@Override
public void onChanged(@Nullable ResultType resultType) {
result.setValue(Resource.success(resultType));
}
});
}
}
});
}

private void fetchFromNetwork(final LiveData dbSource) {
final LiveData<IRequestApi> apiResponse = createCall();
result.addSource(dbSource, new Observer() {
@Override
public void onChanged(@Nullable ResultType resultType) {
result.setValue(Resource.loading(resultType));
}
});

result.addSource(apiResponse, new Observer<IRequestApi>() {
@Override
public void onChanged(@Nullable final IRequestApi requestTypeRequestApi) {
result.removeSource(apiResponse);
result.removeSource(dbSource);
//noinspection ConstantConditions
if (requestTypeRequestApi.isSuccessful()) {
saveResultAndReInit(requestTypeRequestApi);
} else {
onFetchFailed();

result.addSource(dbSource, new Observer() {
@Override
public void onChanged(@Nullable ResultType resultType) {
result.setValue(
Resource.error(requestTypeRequestApi.getErrorMsg(), resultType));
}
});
}
}
});
}

@MainThread
private void saveResultAndReInit(final IRequestApi response) {
new AsyncTask<Void, Void, Void>() {

@Override
protected Void doInBackground(Void… voids) {
saveCallResult(response.getBody());
return null;
}

@Override
protected void onPostExecute(Void aVoid) {
// we specially request a new live getDate,
// otherwise we will get immediately last cached value,
// which may not be updated with latest results received from network.

result.addSource(loadFromDb(), new Observer() {
@Override
public void onChanged(@Nullable ResultType resultType) {
result.setValue(Resource.success(resultType));
}
});
}
}.execute();
}

public final MediatorLiveData<Resource> getAsLiveData() {
return result;
}
}

4.封装Room数据库使用辅助类

这里二次封装了数据库回调接口,便于多个逻辑模块多数据库的统一管理使用。

public abstract class AbsDbCallback {
public abstract void create(SupportSQLiteDatabase db);

public abstract void open();

public abstract void upgrade(SupportSQLiteDatabase db, int oldVersion, int newVersion);
}

public class DbCallbackHelper {
private static ArrayList mDbCallbacks = new ArrayList<>();

public static void init() {
mDbCallbacks.add(new EssayDbCallback());

总结:

面试是一个不断学习、不断自我提升的过程,有机会还是出去面面,至少能想到查漏补缺效果,而且有些知识点,可能你自以为知道,但让你说,并不一定能说得很好。

有些东西有压力才有动力,而学到的知识点,都是钱(因为技术人员大部分情况是根据你的能力来定级、来发薪水的),技多不压身。

附上我的面试各大专题整理: 面试指南,满满的都是干货,希望对大家有帮助!

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
一管理使用。

public abstract class AbsDbCallback {
public abstract void create(SupportSQLiteDatabase db);

public abstract void open();

public abstract void upgrade(SupportSQLiteDatabase db, int oldVersion, int newVersion);
}

public class DbCallbackHelper {
private static ArrayList mDbCallbacks = new ArrayList<>();

public static void init() {
mDbCallbacks.add(new EssayDbCallback());

总结:

面试是一个不断学习、不断自我提升的过程,有机会还是出去面面,至少能想到查漏补缺效果,而且有些知识点,可能你自以为知道,但让你说,并不一定能说得很好。

有些东西有压力才有动力,而学到的知识点,都是钱(因为技术人员大部分情况是根据你的能力来定级、来发薪水的),技多不压身。

附上我的面试各大专题整理: 面试指南,满满的都是干货,希望对大家有帮助!
[外链图片转存中…(img-KOkSzX43-1715674329089)]

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值