refactor(contact): 重构 ViewModel 为标准 MVVM 架构
将网络优先/本地降级策略下沉至 ContactDataRepository,ViewModel 仅负责状态分发;引入 Resource 包装类与 SWR 多级缓存机制,简化 RxJava 链式调用并移除冗余的 Observer/RxLifecycle 代码。
This commit is contained in:
@@ -4,119 +4,58 @@ import android.content.Context;
|
|||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
|
||||||
import com.trello.rxlifecycle4.RxLifecycle;
|
|
||||||
import com.trello.rxlifecycle4.android.ActivityEvent;
|
import com.trello.rxlifecycle4.android.ActivityEvent;
|
||||||
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
||||||
|
import com.ttstd.dialer.data.repository.ContactDataRepository;
|
||||||
import com.ttstd.dialer.databinding.ActivityContactAddBinding;
|
import com.ttstd.dialer.databinding.ActivityContactAddBinding;
|
||||||
import com.ttstd.dialer.db.contact.ContactInfo;
|
import com.ttstd.dialer.db.contact.ContactInfo;
|
||||||
import com.ttstd.dialer.db.contact.ContactRepository;
|
|
||||||
import com.ttstd.dialer.network.OkHttpManager;
|
|
||||||
import com.ttstd.dialer.utils.Logger;
|
import com.ttstd.dialer.utils.Logger;
|
||||||
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
import io.reactivex.rxjava3.annotations.NonNull;
|
|
||||||
import io.reactivex.rxjava3.core.Observable;
|
|
||||||
import io.reactivex.rxjava3.core.ObservableOnSubscribe;
|
|
||||||
import io.reactivex.rxjava3.core.Observer;
|
|
||||||
import io.reactivex.rxjava3.disposables.Disposable;
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增联系人 ViewModel(Google 标准 MVVM):
|
||||||
|
* 网络优先 / 本地降级的策略已下沉到 Repository,ViewModel 只负责状态分发。
|
||||||
|
*/
|
||||||
public class ContactAddViewModel extends BaseViewModel<ActivityContactAddBinding, ActivityEvent> {
|
public class ContactAddViewModel extends BaseViewModel<ActivityContactAddBinding, ActivityEvent> {
|
||||||
private static final String TAG = "ContactAddViewModel";
|
private static final String TAG = "ContactAddViewModel";
|
||||||
private ContactRepository mRepository;
|
|
||||||
|
|
||||||
|
private ContactDataRepository mRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端保存成功返回的 id
|
||||||
|
*/
|
||||||
public MutableLiveData<Long> mOnlineIdData = new MutableLiveData<>();
|
public MutableLiveData<Long> mOnlineIdData = new MutableLiveData<>();
|
||||||
|
/** 网络失败降级到本地 Room 保存返回的 id */
|
||||||
public MutableLiveData<Long> mDbIdData = new MutableLiveData<>();
|
public MutableLiveData<Long> mDbIdData = new MutableLiveData<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setContext(Context context) {
|
public void setContext(Context context) {
|
||||||
super.setContext(context);
|
super.setContext(context);
|
||||||
mRepository = new ContactRepository(context);
|
mRepository = ContactDataRepository.getInstance(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增联系人:远程成功 → 自动失效列表缓存;失败 → 自动降级本地保存。
|
||||||
|
*/
|
||||||
public void addContact(ContactInfo contactInfo) {
|
public void addContact(ContactInfo contactInfo) {
|
||||||
OkHttpManager.getInstance().getContactInsertObservable(contactInfo, getLifecycle())
|
addDisposable(mRepository.addContact(contactInfo)
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.flatMap(baseResponse -> {
|
.subscribe(result -> {
|
||||||
Logger.e(TAG, "addContact", "网络请求响应: " + baseResponse);
|
Logger.d(TAG, "addContact online=" + result.online + ", id=" + result.id);
|
||||||
if (baseResponse.isSuccess()) {
|
if (result.online) {
|
||||||
Long id = baseResponse.getData();
|
mOnlineIdData.setValue(result.id);
|
||||||
mOnlineIdData.setValue(id);
|
|
||||||
return Observable.just(id);
|
|
||||||
} else {
|
} else {
|
||||||
Logger.w(TAG, "业务失败,降级到本地保存: " + baseResponse.getMsg());
|
mDbIdData.setValue(result.id);
|
||||||
return saveContactDbObservable(contactInfo);
|
|
||||||
}
|
}
|
||||||
})
|
}, throwable -> Logger.e(TAG, "addContact error: " + throwable.getMessage())));
|
||||||
.onErrorResumeNext(throwable -> {
|
|
||||||
Logger.e(TAG, "网络异常,降级到本地保存: " + throwable.getMessage());
|
|
||||||
return saveContactDbObservable(contactInfo);
|
|
||||||
})
|
|
||||||
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
|
|
||||||
.subscribe(new Observer<Long>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
|
||||||
Logger.d(TAG, "开始添加联系人");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(@NonNull Long id) {
|
|
||||||
Logger.d(TAG, "联系人保存成功,ID: " + id);
|
|
||||||
if (mOnlineIdData.getValue() == null) {
|
|
||||||
mDbIdData.setValue(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Logger.e(TAG, "添加联系人最终失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
Logger.d(TAG, "添加联系人流程完成");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private Observable<Long> saveContactDbObservable(ContactInfo contactInfo) {
|
|
||||||
return Observable.create((ObservableOnSubscribe<Long>) emitter -> {
|
|
||||||
Logger.d(TAG, "执行本地保存: " + contactInfo);
|
|
||||||
contactInfo.setPosition(mRepository.getTotalCount() + 1);
|
|
||||||
contactInfo.setLocalId(System.currentTimeMillis());
|
|
||||||
long id = mRepository.insert(contactInfo);
|
|
||||||
Logger.d(TAG, "执行本地保存: id = " + id);
|
|
||||||
emitter.onNext(id);
|
|
||||||
emitter.onComplete();
|
|
||||||
}).subscribeOn(Schedulers.io());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅保存到本地 Room */
|
||||||
public void saveContactDb(ContactInfo contactInfo) {
|
public void saveContactDb(ContactInfo contactInfo) {
|
||||||
saveContactDbObservable(contactInfo)
|
addDisposable(mRepository.addContactLocalOnly(contactInfo)
|
||||||
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(new Observer<Long>() {
|
.subscribe(mDbIdData::setValue,
|
||||||
@Override
|
throwable -> Logger.e(TAG, "saveContactDb error: " + throwable.getMessage())));
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
|
||||||
Logger.e("saveContact", "onSubscribe: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(@NonNull Long aLong) {
|
|
||||||
Logger.e("saveContact", "onNext: " + aLong);
|
|
||||||
mDbIdData.setValue(aLong);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Logger.e("saveContact", "onError: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
Logger.e("saveContact", "onComplete: ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,53 @@
|
|||||||
package com.ttstd.dialer.activity.contact.edit;
|
package com.ttstd.dialer.activity.contact.edit;
|
||||||
|
|
||||||
import android.util.Log;
|
import android.content.Context;
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
|
||||||
import com.kongzue.dialogx.dialogs.PopTip;
|
import com.kongzue.dialogx.dialogs.PopTip;
|
||||||
import com.trello.rxlifecycle4.android.ActivityEvent;
|
import com.trello.rxlifecycle4.android.ActivityEvent;
|
||||||
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
||||||
import com.ttstd.dialer.bean.BaseResponse;
|
import com.ttstd.dialer.data.ApiException;
|
||||||
|
import com.ttstd.dialer.data.repository.ContactDataRepository;
|
||||||
import com.ttstd.dialer.databinding.ActivityContactEditBinding;
|
import com.ttstd.dialer.databinding.ActivityContactEditBinding;
|
||||||
import com.ttstd.dialer.db.contact.ContactInfo;
|
import com.ttstd.dialer.db.contact.ContactInfo;
|
||||||
import com.ttstd.dialer.network.BaseObserver;
|
import com.ttstd.dialer.utils.Logger;
|
||||||
import com.ttstd.dialer.network.OkHttpManager;
|
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑联系人 ViewModel(Google 标准 MVVM):只依赖 Repository。
|
||||||
|
*/
|
||||||
public class ContactEditViewModel extends BaseViewModel<ActivityContactEditBinding, ActivityEvent> {
|
public class ContactEditViewModel extends BaseViewModel<ActivityContactEditBinding, ActivityEvent> {
|
||||||
|
private static final String TAG = "ContactEditViewModel";
|
||||||
|
|
||||||
|
private ContactDataRepository mRepository;
|
||||||
|
|
||||||
public MutableLiveData<Boolean> mBooleanMutableLiveData = new MutableLiveData<>();
|
public MutableLiveData<Boolean> mBooleanMutableLiveData = new MutableLiveData<>();
|
||||||
|
|
||||||
public void updateContact(long id, ContactInfo contactInfo) {
|
@Override
|
||||||
OkHttpManager.getInstance().getContactUpdateObservable(id, contactInfo, getLifecycle())
|
public void setContext(Context context) {
|
||||||
.safeSubscribe(new BaseObserver<BaseResponse<Void>>() {
|
super.setContext(context);
|
||||||
@Override
|
mRepository = ContactDataRepository.getInstance(context);
|
||||||
public void onSuccess(BaseResponse<Void> baseResponse) {
|
}
|
||||||
Log.e("updateContact", "onSuccess: " + baseResponse);
|
|
||||||
if (baseResponse.isSuccess()) {
|
|
||||||
mBooleanMutableLiveData.postValue(true);
|
|
||||||
} else {
|
|
||||||
mBooleanMutableLiveData.postValue(false);
|
|
||||||
PopTip.show(baseResponse.getMsg()).iconError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public void onFailure(Throwable e) {
|
* 更新联系人:Repository 成功后自动失效列表/表单缓存并同步 Room
|
||||||
Log.e("updateContact", "onFailure: " + e.getMessage());
|
*/
|
||||||
mBooleanMutableLiveData.postValue(false);
|
public void updateContact(long id, ContactInfo contactInfo) {
|
||||||
|
addDisposable(mRepository.updateContact(id, contactInfo)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(success -> {
|
||||||
|
Logger.d(TAG, "updateContact success: " + success);
|
||||||
|
mBooleanMutableLiveData.setValue(success);
|
||||||
|
}, throwable -> {
|
||||||
|
Logger.e(TAG, "updateContact error: " + throwable.getMessage());
|
||||||
|
mBooleanMutableLiveData.setValue(false);
|
||||||
|
if (throwable instanceof ApiException) {
|
||||||
|
PopTip.show(throwable.getMessage()).iconError();
|
||||||
|
} else {
|
||||||
PopTip.show("网络错误,请稍后再试").iconError();
|
PopTip.show("网络错误,请稍后再试").iconError();
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.ttstd.dialer.activity.contact.edit.ContactEditActivity;
|
|||||||
import com.ttstd.dialer.activity.contact.test.ContactTestActivity;
|
import com.ttstd.dialer.activity.contact.test.ContactTestActivity;
|
||||||
import com.ttstd.dialer.adapter.ContactInfoAdapter;
|
import com.ttstd.dialer.adapter.ContactInfoAdapter;
|
||||||
import com.ttstd.dialer.base.mvvm.BaseMvvmActivity;
|
import com.ttstd.dialer.base.mvvm.BaseMvvmActivity;
|
||||||
|
import com.ttstd.dialer.data.Resource;
|
||||||
import com.ttstd.dialer.databinding.ActivityContactListBinding;
|
import com.ttstd.dialer.databinding.ActivityContactListBinding;
|
||||||
import com.ttstd.dialer.db.contact.ContactInfo;
|
import com.ttstd.dialer.db.contact.ContactInfo;
|
||||||
import com.ttstd.dialer.fragment.dialog.contact.call.CallFragment;
|
import com.ttstd.dialer.fragment.dialog.contact.call.CallFragment;
|
||||||
@@ -67,7 +68,8 @@ public class ContactListActivity extends BaseMvvmActivity<ContactListViewModel,
|
|||||||
@Override
|
@Override
|
||||||
public void onRefresh() {
|
public void onRefresh() {
|
||||||
mViewDataBinding.swipeRefreshLayout.setRefreshing(true);
|
mViewDataBinding.swipeRefreshLayout.setRefreshing(true);
|
||||||
mViewModel.getAllContacts();
|
// 下拉刷新:强制 revalidate(仍会先秒出缓存)
|
||||||
|
mViewModel.loadContacts(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
mContactInfoAdapter = new ContactInfoAdapter();
|
mContactInfoAdapter = new ContactInfoAdapter();
|
||||||
@@ -158,25 +160,34 @@ public class ContactListActivity extends BaseMvvmActivity<ContactListViewModel,
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void initData() {
|
protected void initData() {
|
||||||
mViewModel.mContactListData.observe(this, new Observer<List<ContactInfo>>() {
|
// 观察 Resource 状态:LOADING / SUCCESS(含缓存 stale 数据) / ERROR(含兜底数据)
|
||||||
|
mViewModel.getContactListData().observe(this, new Observer<Resource<List<ContactInfo>>>() {
|
||||||
@Override
|
@Override
|
||||||
public void onChanged(List<ContactInfo> contactInfos) {
|
public void onChanged(Resource<List<ContactInfo>> resource) {
|
||||||
mViewDataBinding.swipeRefreshLayout.setRefreshing(false);
|
if (resource == null) {
|
||||||
if (contactInfos == null) {
|
return;
|
||||||
|
}
|
||||||
|
// stale=true 表示当前展示的是过期缓存,后台正在 revalidate,保持刷新动画
|
||||||
|
boolean refreshing = resource.isLoading() || resource.stale;
|
||||||
|
mViewDataBinding.swipeRefreshLayout.setRefreshing(refreshing);
|
||||||
|
|
||||||
} else {
|
if (resource.data != null) {
|
||||||
Logger.e(TAG, "mContactListData: " + contactInfos);
|
Logger.d(TAG, "contactList: source=" + resource.source
|
||||||
mContactInfos = contactInfos;
|
+ ", stale=" + resource.stale + ", size=" + resource.data.size());
|
||||||
|
mContactInfos = resource.data;
|
||||||
mContactInfoAdapter.setContactInfos(mContactInfos);
|
mContactInfoAdapter.setContactInfos(mContactInfos);
|
||||||
}
|
}
|
||||||
|
if (resource.isError() && resource.data == null) {
|
||||||
|
TipDialog.show("加载失败:" + resource.message, WaitDialog.TYPE.ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
mViewModel.mDeleteLiveData.observe(this, new Observer<Boolean>() {
|
mViewModel.getDeleteResult().observe(this, new Observer<Boolean>() {
|
||||||
@Override
|
@Override
|
||||||
public void onChanged(Boolean aBoolean) {
|
public void onChanged(Boolean aBoolean) {
|
||||||
if (aBoolean) {
|
if (aBoolean) {
|
||||||
TipDialog.show("删除成功", WaitDialog.TYPE.SUCCESS);
|
TipDialog.show("删除成功", WaitDialog.TYPE.SUCCESS);
|
||||||
mViewModel.getAllContacts();
|
mViewModel.loadContacts(true);
|
||||||
} else {
|
} else {
|
||||||
TipDialog.show("删除失败", WaitDialog.TYPE.ERROR);
|
TipDialog.show("删除失败", WaitDialog.TYPE.ERROR);
|
||||||
}
|
}
|
||||||
@@ -211,7 +222,8 @@ public class ContactListActivity extends BaseMvvmActivity<ContactListViewModel,
|
|||||||
@Override
|
@Override
|
||||||
protected void onResume() {
|
protected void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
mViewModel.getAllContacts();
|
// 常规进入页面:走 SWR(新鲜缓存秒出,过期缓存先出再后台刷新)
|
||||||
|
mViewModel.loadContacts(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BtnClick {
|
public class BtnClick {
|
||||||
|
|||||||
@@ -2,184 +2,94 @@ package com.ttstd.dialer.activity.contact.list;
|
|||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData;
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
|
||||||
import com.trello.rxlifecycle4.RxLifecycle;
|
|
||||||
import com.trello.rxlifecycle4.android.ActivityEvent;
|
import com.trello.rxlifecycle4.android.ActivityEvent;
|
||||||
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
||||||
import com.ttstd.dialer.bean.BaseResponse;
|
import com.ttstd.dialer.data.Resource;
|
||||||
|
import com.ttstd.dialer.data.repository.ContactDataRepository;
|
||||||
import com.ttstd.dialer.databinding.ActivityContactListBinding;
|
import com.ttstd.dialer.databinding.ActivityContactListBinding;
|
||||||
import com.ttstd.dialer.db.contact.ContactInfo;
|
import com.ttstd.dialer.db.contact.ContactInfo;
|
||||||
import com.ttstd.dialer.db.contact.ContactRepository;
|
|
||||||
import com.ttstd.dialer.manager.ContactManager;
|
|
||||||
import com.ttstd.dialer.network.BaseObserver;
|
|
||||||
import com.ttstd.dialer.network.OkHttpManager;
|
|
||||||
import com.ttstd.dialer.utils.Logger;
|
import com.ttstd.dialer.utils.Logger;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
import io.reactivex.rxjava3.annotations.NonNull;
|
|
||||||
import io.reactivex.rxjava3.core.Observable;
|
|
||||||
import io.reactivex.rxjava3.core.ObservableEmitter;
|
|
||||||
import io.reactivex.rxjava3.core.ObservableOnSubscribe;
|
|
||||||
import io.reactivex.rxjava3.core.Observer;
|
|
||||||
import io.reactivex.rxjava3.disposables.Disposable;
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 联系人列表 ViewModel(Google 标准 MVVM):
|
||||||
|
* 只依赖 Repository,向 UI 暴露 LiveData<Resource<T>>,不感知网络/缓存/数据库细节。
|
||||||
|
*/
|
||||||
public class ContactListViewModel extends BaseViewModel<ActivityContactListBinding, ActivityEvent> {
|
public class ContactListViewModel extends BaseViewModel<ActivityContactListBinding, ActivityEvent> {
|
||||||
private static final String TAG = "ContactListViewModel";
|
private static final String TAG = "ContactListViewModel";
|
||||||
private ContactRepository mRepository;
|
|
||||||
private ContactManager mContactManager;
|
private ContactDataRepository mRepository;
|
||||||
|
|
||||||
|
private final MutableLiveData<Resource<List<ContactInfo>>> mContactList = new MutableLiveData<>();
|
||||||
|
private final MutableLiveData<Boolean> mDeleteResult = new MutableLiveData<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setContext(Context context) {
|
public void setContext(Context context) {
|
||||||
super.setContext(context);
|
super.setContext(context);
|
||||||
mRepository = new ContactRepository(context);
|
mRepository = ContactDataRepository.getInstance(context);
|
||||||
mContactManager = ContactManager.getInstance(context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public MutableLiveData<List<ContactInfo>> mContactListData = new MutableLiveData<>();
|
public LiveData<Resource<List<ContactInfo>>> getContactListData() {
|
||||||
|
return mContactList;
|
||||||
public void getAllContacts() {
|
|
||||||
getAllContactsFromDB();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void getAllContactsFromDB() {
|
public LiveData<Boolean> getDeleteResult() {
|
||||||
Observable.create(new ObservableOnSubscribe<List<ContactInfo>>() {
|
return mDeleteResult;
|
||||||
@Override
|
}
|
||||||
public void subscribe(@NonNull ObservableEmitter<List<ContactInfo>> emitter) throws Throwable {
|
|
||||||
List<ContactInfo> contactInfos = mRepository.getAllContacts();
|
/**
|
||||||
emitter.onNext(contactInfos);
|
* 加载联系人列表。
|
||||||
emitter.onComplete();
|
* 多级缓存 + SWR:命中新鲜缓存秒出;过期缓存先出旧数据再后台刷新;
|
||||||
}
|
* 未命中走 Loading → 网络;网络失败自动降级缓存/Room。
|
||||||
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
|
*
|
||||||
.subscribeOn(Schedulers.io())
|
* @param forceRefresh 下拉刷新时传 true,强制 revalidate
|
||||||
|
*/
|
||||||
|
public void loadContacts(boolean forceRefresh) {
|
||||||
|
addDisposable(mRepository.getContactList(forceRefresh)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(new Observer<List<ContactInfo>>() {
|
.subscribe(resource -> {
|
||||||
@Override
|
Logger.d(TAG, "loadContacts: " + resource.status
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
+ ", source=" + resource.source + ", stale=" + resource.stale);
|
||||||
Logger.e("getAllContacts", "onSubscribe: ");
|
mContactList.setValue(resource);
|
||||||
}
|
}, throwable -> {
|
||||||
|
Logger.e(TAG, "loadContacts error: " + throwable.getMessage());
|
||||||
@Override
|
mContactList.setValue(Resource.error(throwable.getMessage(), null));
|
||||||
public void onNext(@NonNull List<ContactInfo> contactInfos) {
|
}));
|
||||||
Logger.e("getAllContacts", "onNext: ");
|
|
||||||
mContactListData.setValue(contactInfos);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Logger.e("getAllContacts", "onError: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
Logger.e("getAllContacts", "onComplete: ");
|
|
||||||
requestNetworkContacts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requestNetworkContacts() {
|
/**
|
||||||
mContactManager.getContacts(getLifecycle(), new ContactManager.ContactCallback() {
|
* 拖动排序后仅更新本地顺序
|
||||||
@Override
|
*/
|
||||||
public void onSuccess(List<ContactInfo> contacts) {
|
public void updateContacts(List<ContactInfo> contactInfos) {
|
||||||
Logger.e(TAG, "获取网络联系人成功: " + contacts.size());
|
addDisposable(mRepository.updateLocalContacts(contactInfos)
|
||||||
mContactListData.setValue(contacts);
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
}
|
.subscribe(success -> Logger.d(TAG, "updateContacts success: " + success),
|
||||||
|
throwable -> Logger.e(TAG, "updateContacts error: " + throwable.getMessage())));
|
||||||
@Override
|
|
||||||
public void onFailure(Throwable e) {
|
|
||||||
Logger.e(TAG, "获取网络联系人失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateItemPosition(ContactInfo contactInfo) {
|
public void updateItemPosition(ContactInfo contactInfo) {
|
||||||
Logger.e(TAG, "updateItemPosition: " + contactInfo);
|
addDisposable(mRepository.updateLocalContact(contactInfo)
|
||||||
Observable.create(new ObservableOnSubscribe<Integer>() {
|
|
||||||
@Override
|
|
||||||
public void subscribe(@NonNull ObservableEmitter<Integer> emitter) throws Throwable {
|
|
||||||
int id = mRepository.update(contactInfo);
|
|
||||||
emitter.onNext(id);
|
|
||||||
emitter.onComplete();
|
|
||||||
}
|
|
||||||
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(new Observer<Integer>() {
|
.subscribe(count -> Logger.d(TAG, "updateItemPosition count: " + count),
|
||||||
@Override
|
throwable -> Logger.e(TAG, "updateItemPosition error: " + throwable.getMessage())));
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
|
||||||
Logger.e("updateItemPosition", "onComplete: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(@NonNull Integer integer) {
|
|
||||||
Logger.e("updateItemPosition", "onNext: " + integer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Logger.e("updateItemPosition", "onError: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
Logger.e("updateItemPosition", "onComplete: ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateContacts(List<ContactInfo> contactInfos) {
|
/**
|
||||||
Logger.e(TAG, "updateContacts: " + contactInfos.size());
|
* 删除联系人:Repository 内部成功后会自动失效缓存
|
||||||
Observable.create(new ObservableOnSubscribe<Boolean>() {
|
*/
|
||||||
@Override
|
|
||||||
public void subscribe(@NonNull ObservableEmitter<Boolean> emitter) throws Throwable {
|
|
||||||
mRepository.update(contactInfos);
|
|
||||||
emitter.onNext(true);
|
|
||||||
emitter.onComplete();
|
|
||||||
}
|
|
||||||
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(new Observer<Boolean>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(@NonNull Boolean aBoolean) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Logger.e("updateContacts", "onError: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
Logger.e("updateContacts", "onComplete: ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public MutableLiveData<Boolean> mDeleteLiveData = new MutableLiveData<>();
|
|
||||||
|
|
||||||
public void deleteContact(long id) {
|
public void deleteContact(long id) {
|
||||||
OkHttpManager.getInstance().getContactDeleteObservable(id, getLifecycle())
|
addDisposable(mRepository.deleteContact(id)
|
||||||
.safeSubscribe(new BaseObserver<BaseResponse>() {
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
@Override
|
.subscribe(mDeleteResult::setValue,
|
||||||
public void onSuccess(BaseResponse baseResponse) {
|
throwable -> {
|
||||||
Logger.e("deleteContact", "onSuccess: " + baseResponse);
|
Logger.e(TAG, "deleteContact error: " + throwable.getMessage());
|
||||||
mDeleteLiveData.setValue(baseResponse.isSuccess());
|
mDeleteResult.setValue(false);
|
||||||
}
|
}));
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onFailure(Throwable e) {
|
|
||||||
Logger.e("deleteContact", "onFailure: " + e.getMessage());
|
|
||||||
mDeleteLiveData.setValue(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,151 +1,69 @@
|
|||||||
package com.ttstd.dialer.activity.contact.test;
|
package com.ttstd.dialer.activity.contact.test;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
|
||||||
import com.trello.rxlifecycle4.RxLifecycle;
|
|
||||||
import com.trello.rxlifecycle4.android.ActivityEvent;
|
import com.trello.rxlifecycle4.android.ActivityEvent;
|
||||||
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
||||||
import com.ttstd.dialer.bean.BaseResponse;
|
import com.ttstd.dialer.data.repository.ContactDataRepository;
|
||||||
import com.ttstd.dialer.databinding.ActivityContactTestBinding;
|
import com.ttstd.dialer.databinding.ActivityContactTestBinding;
|
||||||
import com.ttstd.dialer.db.contact.ContactInfo;
|
import com.ttstd.dialer.db.contact.ContactInfo;
|
||||||
import com.ttstd.dialer.db.contact.ContactRepository;
|
|
||||||
import com.ttstd.dialer.network.BaseObserver;
|
|
||||||
import com.ttstd.dialer.network.OkHttpManager;
|
|
||||||
import com.ttstd.dialer.utils.Logger;
|
import com.ttstd.dialer.utils.Logger;
|
||||||
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
import io.reactivex.rxjava3.annotations.NonNull;
|
|
||||||
import io.reactivex.rxjava3.core.Observable;
|
|
||||||
import io.reactivex.rxjava3.core.ObservableEmitter;
|
|
||||||
import io.reactivex.rxjava3.core.ObservableOnSubscribe;
|
|
||||||
import io.reactivex.rxjava3.core.Observer;
|
|
||||||
import io.reactivex.rxjava3.disposables.Disposable;
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 联系人测试页 ViewModel(Google 标准 MVVM):只依赖 Repository。
|
||||||
|
*/
|
||||||
public class ContactTestViewModel extends BaseViewModel<ActivityContactTestBinding, ActivityEvent> {
|
public class ContactTestViewModel extends BaseViewModel<ActivityContactTestBinding, ActivityEvent> {
|
||||||
private static final String TAG = "ContactListViewModel";
|
private static final String TAG = "ContactTestViewModel";
|
||||||
private ContactRepository mRepository;
|
|
||||||
|
private ContactDataRepository mRepository;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setContext(Context context) {
|
public void setContext(Context context) {
|
||||||
super.setContext(context);
|
super.setContext(context);
|
||||||
mRepository = new ContactRepository(context);
|
mRepository = ContactDataRepository.getInstance(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public MutableLiveData<List<ContactInfo>> mOnlineContactListData = new MutableLiveData<>();
|
public MutableLiveData<List<ContactInfo>> mOnlineContactListData = new MutableLiveData<>();
|
||||||
|
|
||||||
public void getAllContacts() {
|
|
||||||
OkHttpManager.getInstance().getContactListObservable(getLifecycle())
|
|
||||||
.compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
|
|
||||||
.subscribe(new BaseObserver<BaseResponse<List<ContactInfo>>>() {
|
|
||||||
@Override
|
|
||||||
public void onSuccess(BaseResponse<List<ContactInfo>> listBaseResponse) {
|
|
||||||
Log.e("getAllContacts", "onSuccess: " + listBaseResponse);
|
|
||||||
if (listBaseResponse.isSuccess()) {
|
|
||||||
List<ContactInfo> contactInfos = listBaseResponse.getData();
|
|
||||||
List<ContactInfo> sorted = contactInfos.stream().sorted(new Comparator<ContactInfo>() {
|
|
||||||
@Override
|
|
||||||
public int compare(ContactInfo t0, ContactInfo t1) {
|
|
||||||
return Integer.compare(t0.getPosition(), t1.getPosition());
|
|
||||||
}
|
|
||||||
}).collect(Collectors.toList());
|
|
||||||
mOnlineContactListData.setValue(sorted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onFailure(Throwable e) {
|
|
||||||
Log.e("getAllContacts", "onFailure: " + e.getMessage());
|
|
||||||
mOnlineContactListData.setValue(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public MutableLiveData<List<ContactInfo>> mLocalContactListData = new MutableLiveData<>();
|
public MutableLiveData<List<ContactInfo>> mLocalContactListData = new MutableLiveData<>();
|
||||||
|
|
||||||
public void getAllContactsFromDB() {
|
/**
|
||||||
Observable.create(new ObservableOnSubscribe<List<ContactInfo>>() {
|
* 网络联系人(走多级缓存 + SWR,数据已按 position 排序)
|
||||||
@Override
|
*/
|
||||||
public void subscribe(@NonNull ObservableEmitter<List<ContactInfo>> emitter) throws Throwable {
|
public void getAllContacts() {
|
||||||
List<ContactInfo> contactInfos = mRepository.getAllContacts();
|
addDisposable(mRepository.getContactList(false)
|
||||||
emitter.onNext(contactInfos);
|
|
||||||
emitter.onComplete();
|
|
||||||
}
|
|
||||||
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(new Observer<List<ContactInfo>>() {
|
.subscribe(resource -> {
|
||||||
@Override
|
Logger.d(TAG, "getAllContacts: " + resource.status
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
+ ", source=" + resource.source + ", stale=" + resource.stale);
|
||||||
Logger.e("getAllContacts", "onSubscribe: ");
|
if (resource.data != null) {
|
||||||
|
mOnlineContactListData.setValue(resource.data);
|
||||||
|
} else if (resource.isError()) {
|
||||||
|
mOnlineContactListData.setValue(null);
|
||||||
}
|
}
|
||||||
|
}, throwable -> {
|
||||||
|
Logger.e(TAG, "getAllContacts error: " + throwable.getMessage());
|
||||||
|
mOnlineContactListData.setValue(null);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
/** 本地(Room)联系人 */
|
||||||
public void onNext(@NonNull List<ContactInfo> contactInfos) {
|
public void getAllContactsFromDB() {
|
||||||
Logger.e("getAllContacts", "onNext: ");
|
addDisposable(mRepository.getLocalContacts()
|
||||||
// mLocalContactListData.setValue(contactInfos);
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(mLocalContactListData::setValue,
|
||||||
List<ContactInfo> sorted = contactInfos.stream().sorted(new Comparator<ContactInfo>() {
|
throwable -> Logger.e(TAG, "getAllContactsFromDB error: " + throwable.getMessage())));
|
||||||
@Override
|
|
||||||
public int compare(ContactInfo o1, ContactInfo o2) {
|
|
||||||
return Integer.compare(o1.getPosition(), o2.getPosition());
|
|
||||||
}
|
|
||||||
}).collect(Collectors.toList());
|
|
||||||
mLocalContactListData.setValue(sorted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Logger.e("getAllContacts", "onError: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
Logger.e("getAllContacts", "onComplete: ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateItemPosition(ContactInfo contactInfo) {
|
public void updateItemPosition(ContactInfo contactInfo) {
|
||||||
Logger.e(TAG, "updateItemPosition: " + contactInfo);
|
addDisposable(mRepository.updateLocalContact(contactInfo)
|
||||||
Observable.create(new ObservableOnSubscribe<Integer>() {
|
|
||||||
@Override
|
|
||||||
public void subscribe(@NonNull ObservableEmitter<Integer> emitter) throws Throwable {
|
|
||||||
int id = mRepository.update(contactInfo);
|
|
||||||
emitter.onNext(id);
|
|
||||||
emitter.onComplete();
|
|
||||||
}
|
|
||||||
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(new Observer<Integer>() {
|
.subscribe(count -> Logger.d(TAG, "updateItemPosition count: " + count),
|
||||||
@Override
|
throwable -> Logger.e(TAG, "updateItemPosition error: " + throwable.getMessage())));
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
|
||||||
Logger.e("updateItemPosition", "onComplete: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(@NonNull Integer integer) {
|
|
||||||
Logger.e("updateItemPosition", "onNext: " + integer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Logger.e("updateItemPosition", "onError: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
Logger.e("updateItemPosition", "onComplete: ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,31 @@ import androidx.lifecycle.ViewModel;
|
|||||||
|
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable;
|
||||||
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
||||||
|
|
||||||
public abstract class BaseViewModel<VDB extends ViewDataBinding, T> extends ViewModel {
|
public abstract class BaseViewModel<VDB extends ViewDataBinding, T> extends ViewModel {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google 标准写法:ViewModel 内统一管理 Rx 订阅,
|
||||||
|
* onCleared 时自动取消,防止内存泄漏。
|
||||||
|
*/
|
||||||
|
protected final CompositeDisposable mDisposables = new CompositeDisposable();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将订阅托管给 ViewModel 生命周期
|
||||||
|
*/
|
||||||
|
protected void addDisposable(Disposable disposable) {
|
||||||
|
mDisposables.add(disposable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCleared() {
|
||||||
|
mDisposables.clear();
|
||||||
|
super.onCleared();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前viewmodel对应的页面binding
|
* 当前viewmodel对应的页面binding
|
||||||
*/
|
*/
|
||||||
|
|||||||
22
app/src/main/java/com/ttstd/dialer/data/ApiException.java
Normal file
22
app/src/main/java/com/ttstd/dialer/data/ApiException.java
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ttstd.dialer.data;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务异常:服务端返回非成功 code 时抛出,便于 Repository 统一走 onError 通道。
|
||||||
|
*/
|
||||||
|
public class ApiException extends RuntimeException {
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
public ApiException(@Nullable String code, @Nullable String message) {
|
||||||
|
super(message == null ? "服务端业务异常" : message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.ttstd.dialer.data;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.WorkerThread;
|
||||||
|
|
||||||
|
import com.ttstd.dialer.data.cache.CacheEntry;
|
||||||
|
import com.ttstd.dialer.data.cache.CacheManager;
|
||||||
|
import com.ttstd.dialer.utils.Logger;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Observable;
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google 官方架构组件示例中 NetworkBoundResource 模式的 RxJava3 实现,
|
||||||
|
* 内置「L1 内存 + L2 磁盘」多级缓存与 Stale-While-Revalidate(SWR)策略。
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 数据流(订阅后在 IO 线程执行):
|
||||||
|
*
|
||||||
|
* 查缓存(内存 → 磁盘)
|
||||||
|
* ├─ 未命中/硬过期 → 发射 Loading → 请求网络 → 写缓存 → 发射 Success(NETWORK)
|
||||||
|
* ├─ FRESH(软TTL内) → 发射 Success(缓存) → 结束(不请求网络)
|
||||||
|
* └─ STALE(软过期) → 发射 Success(缓存,stale) → 后台 revalidate → 写缓存 → 发射 Success(NETWORK)
|
||||||
|
* └─ 网络失败 → 发射 Error(携带缓存兜底数据)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @param <T> 业务数据类型
|
||||||
|
*/
|
||||||
|
public abstract class NetworkBoundResource<T> {
|
||||||
|
|
||||||
|
private static final String TAG = "NetworkBoundResource";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认软 TTL:1 分钟内视为新鲜数据,直接使用缓存
|
||||||
|
*/
|
||||||
|
private static final long DEFAULT_SOFT_TTL_MILLIS = 60 * 1000L;
|
||||||
|
/**
|
||||||
|
* 默认硬 TTL:24 小时后缓存彻底失效
|
||||||
|
*/
|
||||||
|
private static final long DEFAULT_HARD_TTL_MILLIS = 24 * 60 * 60 * 1000L;
|
||||||
|
|
||||||
|
private final CacheManager mCacheManager;
|
||||||
|
private final boolean mForceRefresh;
|
||||||
|
|
||||||
|
protected NetworkBoundResource(@NonNull CacheManager cacheManager) {
|
||||||
|
this(cacheManager, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param forceRefresh true 时即使缓存新鲜也会 revalidate(如下拉刷新),
|
||||||
|
* 但仍会先发射缓存数据保证 UI 秒出。
|
||||||
|
*/
|
||||||
|
protected NetworkBoundResource(@NonNull CacheManager cacheManager, boolean forceRefresh) {
|
||||||
|
mCacheManager = cacheManager;
|
||||||
|
mForceRefresh = forceRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 子类需实现的契约 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存 key(需包含区分参数,如 "contact_form_" + id)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
protected abstract String cacheKey();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存值类型(泛型用 TypeToken,如 new TypeToken<List<ContactInfo>>(){}.getType())
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
protected abstract Type cacheType();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建网络请求(返回已剥离 BaseResponse 的业务数据)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
protected abstract Observable<T> createNetworkObservable();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网络数据落库等额外持久化钩子(IO 线程回调,可选实现,例如写入 Room)
|
||||||
|
*/
|
||||||
|
@WorkerThread
|
||||||
|
protected void saveNetworkResult(@NonNull T data) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对读取到的数据统一加工(如排序),缓存数据与网络数据都会经过此方法
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
protected T processResult(@NonNull T data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected long softTtlMillis() {
|
||||||
|
return DEFAULT_SOFT_TTL_MILLIS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected long hardTtlMillis() {
|
||||||
|
return DEFAULT_HARD_TTL_MILLIS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- SWR 主流程 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Observable<Resource<T>>,已 subscribeOn(io),调用方只需 observeOn(mainThread)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public final Observable<Resource<T>> asObservable() {
|
||||||
|
return Observable
|
||||||
|
.defer(() -> {
|
||||||
|
CacheManager.Hit<T> hit = mCacheManager.get(cacheKey(), cacheType());
|
||||||
|
if (hit == null) {
|
||||||
|
// 缓存未命中:Loading → 网络
|
||||||
|
Logger.d(TAG, "cache MISS, key=" + cacheKey());
|
||||||
|
return Observable.concat(
|
||||||
|
Observable.just(Resource.<T>loading(null)),
|
||||||
|
fetchFromNetwork(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
T cachedValue = processResult(hit.entry.getValue());
|
||||||
|
Resource.Source source = hit.fromMemory ? Resource.Source.MEMORY : Resource.Source.DISK;
|
||||||
|
CacheEntry.Freshness freshness = hit.entry.getFreshness();
|
||||||
|
|
||||||
|
if (freshness == CacheEntry.Freshness.FRESH && !mForceRefresh) {
|
||||||
|
// 新鲜缓存:直接返回,不打扰网络
|
||||||
|
Logger.d(TAG, "cache FRESH(" + source + "), key=" + cacheKey());
|
||||||
|
return Observable.just(Resource.success(cachedValue, source, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale-While-Revalidate:先发过期缓存,再后台重新验证
|
||||||
|
Logger.d(TAG, "cache STALE(" + source + "), revalidating... key=" + cacheKey());
|
||||||
|
return Observable.concat(
|
||||||
|
Observable.just(Resource.success(cachedValue, source, true)),
|
||||||
|
fetchFromNetwork(cachedValue));
|
||||||
|
})
|
||||||
|
.subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求网络并双写缓存。
|
||||||
|
*
|
||||||
|
* @param fallback 网络失败时的兜底缓存数据(可为 null)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private Observable<Resource<T>> fetchFromNetwork(@Nullable T fallback) {
|
||||||
|
return createNetworkObservable()
|
||||||
|
.doOnNext(data -> {
|
||||||
|
// 双写多级缓存(内存 + 磁盘)
|
||||||
|
mCacheManager.put(cacheKey(), data, softTtlMillis(), hardTtlMillis());
|
||||||
|
// 额外持久化钩子(如同步 Room)
|
||||||
|
saveNetworkResult(data);
|
||||||
|
})
|
||||||
|
.map(data -> Resource.success(processResult(data), Resource.Source.NETWORK, false))
|
||||||
|
.onErrorReturn(throwable -> {
|
||||||
|
Logger.e(TAG, "revalidate failed, key=" + cacheKey() + ", " + throwable.getMessage());
|
||||||
|
return Resource.error(throwable.getMessage(), fallback);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/src/main/java/com/ttstd/dialer/data/Resource.java
Normal file
97
app/src/main/java/com/ttstd/dialer/data/Resource.java
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package com.ttstd.dialer.data;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI 状态封装(Google 官方架构指南推荐写法)
|
||||||
|
* <p>
|
||||||
|
* 携带 状态(LOADING/SUCCESS/ERROR) + 数据 + 错误信息 + 数据来源(内存/磁盘/网络) + 是否过期(stale)。
|
||||||
|
* ViewModel 通过 LiveData<Resource<T>> 将状态透传给 UI 层。
|
||||||
|
*
|
||||||
|
* @param <T> 业务数据类型
|
||||||
|
*/
|
||||||
|
public final class Resource<T> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求状态
|
||||||
|
*/
|
||||||
|
public enum Status {
|
||||||
|
LOADING,
|
||||||
|
SUCCESS,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据来源
|
||||||
|
*/
|
||||||
|
public enum Source {
|
||||||
|
MEMORY,
|
||||||
|
DISK,
|
||||||
|
NETWORK
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public final Status status;
|
||||||
|
@Nullable
|
||||||
|
public final T data;
|
||||||
|
@Nullable
|
||||||
|
public final String message;
|
||||||
|
@Nullable
|
||||||
|
public final Source source;
|
||||||
|
/**
|
||||||
|
* SWR 标记:true 表示当前数据是"过期但可用"的缓存数据,
|
||||||
|
* 后台正在重新验证(revalidate),随后会再发射一次最新数据。
|
||||||
|
*/
|
||||||
|
public final boolean stale;
|
||||||
|
|
||||||
|
private Resource(@NonNull Status status, @Nullable T data, @Nullable String message,
|
||||||
|
@Nullable Source source, boolean stale) {
|
||||||
|
this.status = status;
|
||||||
|
this.data = data;
|
||||||
|
this.message = message;
|
||||||
|
this.source = source;
|
||||||
|
this.stale = stale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Resource<T> loading(@Nullable T data) {
|
||||||
|
return new Resource<>(Status.LOADING, data, null, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Resource<T> success(@Nullable T data, @NonNull Source source) {
|
||||||
|
return new Resource<>(Status.SUCCESS, data, null, source, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SWR 场景:发射过期缓存时 stale = true
|
||||||
|
*/
|
||||||
|
public static <T> Resource<T> success(@Nullable T data, @NonNull Source source, boolean stale) {
|
||||||
|
return new Resource<>(Status.SUCCESS, data, null, source, stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败时可携带兜底缓存数据,UI 可继续展示旧数据
|
||||||
|
*/
|
||||||
|
public static <T> Resource<T> error(@Nullable String message, @Nullable T data) {
|
||||||
|
return new Resource<>(Status.ERROR, data, message, null, data != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return status == Status.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLoading() {
|
||||||
|
return status == Status.LOADING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isError() {
|
||||||
|
return status == Status.ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Resource{status=" + status + ", source=" + source + ", stale=" + stale
|
||||||
|
+ ", message=" + message + ", data=" + data + '}';
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/src/main/java/com/ttstd/dialer/data/cache/CacheEntry.java
vendored
Normal file
77
app/src/main/java/com/ttstd/dialer/data/cache/CacheEntry.java
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package com.ttstd.dialer.data.cache;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存条目:值 + 写入时间 + 双 TTL。
|
||||||
|
* <p>
|
||||||
|
* Stale-While-Revalidate 语义:
|
||||||
|
* <ul>
|
||||||
|
* <li>age <= softTtl → FRESH:直接使用,无需请求网络</li>
|
||||||
|
* <li>softTtl < age <= hardTtl → STALE:先返回缓存,同时后台重新验证(revalidate)</li>
|
||||||
|
* <li>age > hardTtl → EXPIRED:视为未命中,必须等网络</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param <T> 缓存值类型
|
||||||
|
*/
|
||||||
|
public final class CacheEntry<T> {
|
||||||
|
|
||||||
|
public enum Freshness {
|
||||||
|
FRESH,
|
||||||
|
STALE,
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
private final T value;
|
||||||
|
/**
|
||||||
|
* 写入时间戳(毫秒)
|
||||||
|
*/
|
||||||
|
private final long saveTimeMillis;
|
||||||
|
/**
|
||||||
|
* 软过期时长(毫秒):超过则数据视为 stale,需要后台刷新
|
||||||
|
*/
|
||||||
|
private final long softTtlMillis;
|
||||||
|
/**
|
||||||
|
* 硬过期时长(毫秒):超过则数据完全失效,不可再展示
|
||||||
|
*/
|
||||||
|
private final long hardTtlMillis;
|
||||||
|
|
||||||
|
public CacheEntry(T value, long saveTimeMillis, long softTtlMillis, long hardTtlMillis) {
|
||||||
|
this.value = value;
|
||||||
|
this.saveTimeMillis = saveTimeMillis;
|
||||||
|
this.softTtlMillis = softTtlMillis;
|
||||||
|
this.hardTtlMillis = hardTtlMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getSaveTimeMillis() {
|
||||||
|
return saveTimeMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getSoftTtlMillis() {
|
||||||
|
return softTtlMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getHardTtlMillis() {
|
||||||
|
return hardTtlMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public Freshness getFreshness() {
|
||||||
|
long age = System.currentTimeMillis() - saveTimeMillis;
|
||||||
|
if (age <= softTtlMillis) {
|
||||||
|
return Freshness.FRESH;
|
||||||
|
}
|
||||||
|
if (age <= hardTtlMillis) {
|
||||||
|
return Freshness.STALE;
|
||||||
|
}
|
||||||
|
return Freshness.EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isExpired() {
|
||||||
|
return getFreshness() == Freshness.EXPIRED;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java
vendored
Normal file
108
app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java
vendored
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package com.ttstd.dialer.data.cache;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.WorkerThread;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多级缓存统一入口:L1 内存(LruCache) → L2 磁盘(文件+Gson)。
|
||||||
|
* <p>
|
||||||
|
* 读取顺序:内存 → 磁盘(命中磁盘时回填内存);
|
||||||
|
* 写入顺序:同时写内存与磁盘;
|
||||||
|
* 供 {@link com.ttstd.dialer.data.NetworkBoundResource} 做 Stale-While-Revalidate 判定。
|
||||||
|
*/
|
||||||
|
public final class CacheManager {
|
||||||
|
|
||||||
|
private static volatile CacheManager sInstance;
|
||||||
|
|
||||||
|
private final MemoryCache mMemoryCache;
|
||||||
|
private final DiskCache mDiskCache;
|
||||||
|
|
||||||
|
private CacheManager(@NonNull Context appContext) {
|
||||||
|
mMemoryCache = new MemoryCache();
|
||||||
|
mDiskCache = new DiskCache(appContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CacheManager getInstance(@NonNull Context context) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
synchronized (CacheManager.class) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
sInstance = new CacheManager(context.getApplicationContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存命中结果(带来源,便于 UI 展示与埋点)
|
||||||
|
*/
|
||||||
|
public static final class Hit<T> {
|
||||||
|
@NonNull
|
||||||
|
public final CacheEntry<T> entry;
|
||||||
|
/**
|
||||||
|
* true = 命中内存,false = 命中磁盘
|
||||||
|
*/
|
||||||
|
public final boolean fromMemory;
|
||||||
|
|
||||||
|
Hit(@NonNull CacheEntry<T> entry, boolean fromMemory) {
|
||||||
|
this.entry = entry;
|
||||||
|
this.fromMemory = fromMemory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依次查内存、磁盘。磁盘命中会自动回填内存(下次直接内存命中)。
|
||||||
|
* 硬过期条目在各级缓存内部已被剔除,此处返回的一定是 FRESH 或 STALE。
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
@WorkerThread
|
||||||
|
public <T> Hit<T> get(@NonNull String key, @NonNull Type type) {
|
||||||
|
CacheEntry<T> memory = mMemoryCache.get(key);
|
||||||
|
if (memory != null) {
|
||||||
|
return new Hit<>(memory, true);
|
||||||
|
}
|
||||||
|
CacheEntry<T> disk = mDiskCache.get(key, type);
|
||||||
|
if (disk != null) {
|
||||||
|
mMemoryCache.put(key, disk); // 回填 L1
|
||||||
|
return new Hit<>(disk, false);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双写:内存 + 磁盘
|
||||||
|
*/
|
||||||
|
@WorkerThread
|
||||||
|
public <T> void put(@NonNull String key, @NonNull T value, long softTtlMillis, long hardTtlMillis) {
|
||||||
|
CacheEntry<T> entry = new CacheEntry<>(value, System.currentTimeMillis(), softTtlMillis, hardTtlMillis);
|
||||||
|
mMemoryCache.put(key, entry);
|
||||||
|
mDiskCache.put(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失效指定 key(写操作成功后调用,保证下次读取拿到最新数据)
|
||||||
|
*/
|
||||||
|
@WorkerThread
|
||||||
|
public void invalidate(@NonNull String key) {
|
||||||
|
mMemoryCache.remove(key);
|
||||||
|
mDiskCache.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅标记内存失效(轻量,可在主线程调用)
|
||||||
|
*/
|
||||||
|
public void invalidateMemory(@NonNull String key) {
|
||||||
|
mMemoryCache.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
public void clearAll() {
|
||||||
|
mMemoryCache.clear();
|
||||||
|
mDiskCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
165
app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java
vendored
Normal file
165
app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java
vendored
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package com.ttstd.dialer.data.cache;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.WorkerThread;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.BufferedWriter;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L2 磁盘缓存:文件 + Gson 序列化,App 冷启动后仍可秒出数据。
|
||||||
|
* <p>
|
||||||
|
* 存储格式(JSON):
|
||||||
|
* <pre>
|
||||||
|
* { "saveTime": 1710000000000, "softTtl": 60000, "hardTtl": 86400000, "value": {...} }
|
||||||
|
* </pre>
|
||||||
|
* 写入使用「临时文件 + 原子重命名」防止写一半损坏。
|
||||||
|
* 所有方法必须在工作线程调用。
|
||||||
|
*/
|
||||||
|
public final class DiskCache {
|
||||||
|
|
||||||
|
private static final String TAG = "DiskCache";
|
||||||
|
private static final String CACHE_DIR_NAME = "api_disk_cache";
|
||||||
|
private static final String TMP_SUFFIX = ".tmp";
|
||||||
|
|
||||||
|
private static final String KEY_SAVE_TIME = "saveTime";
|
||||||
|
private static final String KEY_SOFT_TTL = "softTtl";
|
||||||
|
private static final String KEY_HARD_TTL = "hardTtl";
|
||||||
|
private static final String KEY_VALUE = "value";
|
||||||
|
|
||||||
|
private final File mCacheDir;
|
||||||
|
private final Gson mGson = new Gson();
|
||||||
|
|
||||||
|
public DiskCache(@NonNull Context context) {
|
||||||
|
mCacheDir = new File(context.getCacheDir(), CACHE_DIR_NAME);
|
||||||
|
if (!mCacheDir.exists() && !mCacheDir.mkdirs()) {
|
||||||
|
Log.w(TAG, "Failed to create disk cache dir: " + mCacheDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取缓存条目。
|
||||||
|
*
|
||||||
|
* @param key 缓存 key
|
||||||
|
* @param type 值的具体类型(支持泛型,如 new TypeToken<List<ContactInfo>>(){}.getType())
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
@WorkerThread
|
||||||
|
public <T> CacheEntry<T> get(@NonNull String key, @NonNull Type type) {
|
||||||
|
File file = fileFor(key);
|
||||||
|
if (!file.exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try (Reader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||||
|
JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
|
||||||
|
long saveTime = root.get(KEY_SAVE_TIME).getAsLong();
|
||||||
|
long softTtl = root.get(KEY_SOFT_TTL).getAsLong();
|
||||||
|
long hardTtl = root.get(KEY_HARD_TTL).getAsLong();
|
||||||
|
T value = mGson.fromJson(root.get(KEY_VALUE), type);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
CacheEntry<T> entry = new CacheEntry<>(value, saveTime, softTtl, hardTtl);
|
||||||
|
// 硬过期:删除文件并按未命中处理
|
||||||
|
if (entry.isExpired()) {
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
file.delete();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Read disk cache failed, key=" + key, e);
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
file.delete();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入缓存条目(原子写)
|
||||||
|
*/
|
||||||
|
@WorkerThread
|
||||||
|
public <T> void put(@NonNull String key, @NonNull CacheEntry<T> entry) {
|
||||||
|
File file = fileFor(key);
|
||||||
|
File tmp = new File(file.getAbsolutePath() + TMP_SUFFIX);
|
||||||
|
try {
|
||||||
|
JsonObject root = new JsonObject();
|
||||||
|
root.addProperty(KEY_SAVE_TIME, entry.getSaveTimeMillis());
|
||||||
|
root.addProperty(KEY_SOFT_TTL, entry.getSoftTtlMillis());
|
||||||
|
root.addProperty(KEY_HARD_TTL, entry.getHardTtlMillis());
|
||||||
|
root.add(KEY_VALUE, mGson.toJsonTree(entry.getValue()));
|
||||||
|
try (Writer writer = new BufferedWriter(
|
||||||
|
new OutputStreamWriter(new FileOutputStream(tmp), StandardCharsets.UTF_8))) {
|
||||||
|
mGson.toJson(root, writer);
|
||||||
|
}
|
||||||
|
if (!tmp.renameTo(file)) {
|
||||||
|
Log.w(TAG, "Rename tmp cache file failed, key=" + key);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(TAG, "Write disk cache failed, key=" + key, e);
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
tmp.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
public void remove(@NonNull String key) {
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
fileFor(key).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
public void clear() {
|
||||||
|
File[] files = mCacheDir.listFiles();
|
||||||
|
if (files == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (File f : files) {
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
f.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private File fileFor(@NonNull String key) {
|
||||||
|
return new File(mCacheDir, md5(key) + ".json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* key 做 MD5,避免非法文件名字符
|
||||||
|
*/
|
||||||
|
private static String md5(@NonNull String s) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||||
|
byte[] bytes = digest.digest(s.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) {
|
||||||
|
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||||
|
sb.append(Character.forDigit(b & 0xF, 16));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 理论上不会发生
|
||||||
|
return String.valueOf(s.hashCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/src/main/java/com/ttstd/dialer/data/cache/MemoryCache.java
vendored
Normal file
55
app/src/main/java/com/ttstd/dialer/data/cache/MemoryCache.java
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package com.ttstd.dialer.data.cache;
|
||||||
|
|
||||||
|
import android.util.LruCache;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L1 内存缓存:基于 LruCache,进程内共享,读写为纳秒级。
|
||||||
|
* <p>线程安全(LruCache 内部已同步)。</p>
|
||||||
|
*/
|
||||||
|
public final class MemoryCache {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认最多缓存 64 个接口结果条目
|
||||||
|
*/
|
||||||
|
private static final int DEFAULT_MAX_ENTRIES = 64;
|
||||||
|
|
||||||
|
private final LruCache<String, CacheEntry<?>> mLruCache;
|
||||||
|
|
||||||
|
public MemoryCache() {
|
||||||
|
this(DEFAULT_MAX_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MemoryCache(int maxEntries) {
|
||||||
|
mLruCache = new LruCache<>(maxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T> CacheEntry<T> get(@NonNull String key) {
|
||||||
|
CacheEntry<?> entry = mLruCache.get(key);
|
||||||
|
if (entry == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 硬过期直接剔除
|
||||||
|
if (entry.isExpired()) {
|
||||||
|
mLruCache.remove(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (CacheEntry<T>) entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> void put(@NonNull String key, @NonNull CacheEntry<T> entry) {
|
||||||
|
mLruCache.put(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void remove(@NonNull String key) {
|
||||||
|
mLruCache.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear() {
|
||||||
|
mLruCache.evictAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.ttstd.dialer.data.remote;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import com.ttstd.dialer.bean.BaseResponse;
|
||||||
|
import com.ttstd.dialer.data.ApiException;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Observable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BaseResponse 统一解包工具:
|
||||||
|
* <ul>
|
||||||
|
* <li>业务成功 → 发射 data</li>
|
||||||
|
* <li>业务失败 → 抛 {@link ApiException} 走 onError 通道,Repository 统一处理</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class ApiResponseMapper {
|
||||||
|
|
||||||
|
private ApiResponseMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 剥离 BaseResponse,返回业务数据(data 不允许为 null)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public static <T> Observable<T> unwrap(@NonNull Observable<BaseResponse<T>> upstream) {
|
||||||
|
return upstream.map(response -> {
|
||||||
|
if (!response.isSuccess()) {
|
||||||
|
throw new ApiException(response.getCode(), response.getMsg());
|
||||||
|
}
|
||||||
|
T data = response.getData();
|
||||||
|
if (data == null) {
|
||||||
|
throw new ApiException(response.getCode(), "服务端返回数据为空");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于 data 为 Void/无意义的写操作接口:成功映射为 true
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public static <T> Observable<Boolean> unwrapAsSuccess(@NonNull Observable<BaseResponse<T>> upstream) {
|
||||||
|
return upstream.map(response -> {
|
||||||
|
if (!response.isSuccess()) {
|
||||||
|
throw new ApiException(response.getCode(), response.getMsg());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.ttstd.dialer.data.remote;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import com.ttstd.dialer.db.contact.ContactInfo;
|
||||||
|
import com.ttstd.dialer.network.OkHttpManager;
|
||||||
|
import com.ttstd.dialer.network.api.ContactApi;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Observable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 联系人远程数据源(Data Layer - Remote DataSource)。
|
||||||
|
* 只负责发起网络请求并剥离 BaseResponse,不做线程调度、不碰缓存。
|
||||||
|
*/
|
||||||
|
public class ContactRemoteDataSource {
|
||||||
|
|
||||||
|
private final ContactApi mApi;
|
||||||
|
|
||||||
|
public ContactRemoteDataSource() {
|
||||||
|
mApi = OkHttpManager.getInstance().getApiService(ContactApi.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public Observable<List<ContactInfo>> getContactList() {
|
||||||
|
return ApiResponseMapper.unwrap(mApi.getContactList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public Observable<ContactInfo> getContactForm(long id) {
|
||||||
|
return ApiResponseMapper.unwrap(mApi.getContactForm(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public Observable<Long> insertContact(@NonNull ContactInfo contactInfo) {
|
||||||
|
return ApiResponseMapper.unwrap(
|
||||||
|
mApi.insertContact(OkHttpManager.convertToRequestBodyjson(contactInfo)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public Observable<Boolean> updateContact(long id, @NonNull ContactInfo contactInfo) {
|
||||||
|
return ApiResponseMapper.unwrapAsSuccess(
|
||||||
|
mApi.updateContact(id, OkHttpManager.convertToRequestBodyjson(contactInfo)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public Observable<Boolean> deleteContact(long id) {
|
||||||
|
return ApiResponseMapper.unwrapAsSuccess(mApi.deleteContact(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ttstd.dialer.data.remote;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import com.ttstd.dialer.bean.DeveloperOptions;
|
||||||
|
import com.ttstd.dialer.network.OkHttpManager;
|
||||||
|
import com.ttstd.dialer.network.api.SnApi;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Observable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备(SN)远程数据源(Data Layer - Remote DataSource)。
|
||||||
|
*/
|
||||||
|
public class SnRemoteDataSource {
|
||||||
|
|
||||||
|
private final SnApi mApi;
|
||||||
|
|
||||||
|
public SnRemoteDataSource() {
|
||||||
|
mApi = OkHttpManager.getInstance().getApiService(SnApi.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public Observable<DeveloperOptions> getDeveloperOptions() {
|
||||||
|
return ApiResponseMapper.unwrap(mApi.getDeveloperOptions());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
package com.ttstd.dialer.data.repository;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.WorkerThread;
|
||||||
|
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
import com.ttstd.dialer.data.NetworkBoundResource;
|
||||||
|
import com.ttstd.dialer.data.Resource;
|
||||||
|
import com.ttstd.dialer.data.cache.CacheManager;
|
||||||
|
import com.ttstd.dialer.data.remote.ContactRemoteDataSource;
|
||||||
|
import com.ttstd.dialer.db.contact.ContactInfo;
|
||||||
|
import com.ttstd.dialer.db.contact.ContactRepository;
|
||||||
|
import com.ttstd.dialer.utils.Logger;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Observable;
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 联系人仓库(Data Layer - Repository,单一数据源 SSOT)。
|
||||||
|
* <p>
|
||||||
|
* 职责(Google 架构指南):
|
||||||
|
* <ul>
|
||||||
|
* <li>对 UI 层(ViewModel)暴露 Observable<Resource<T>>,屏蔽数据来源细节</li>
|
||||||
|
* <li>读:L1 内存缓存 → L2 磁盘缓存 → 网络,Stale-While-Revalidate</li>
|
||||||
|
* <li>写:远程成功后失效缓存并同步 Room;网络不可用时降级本地保存</li>
|
||||||
|
* </ul>
|
||||||
|
* 线程约定:所有返回的 Observable 均已 subscribeOn(io),调用方按需 observeOn(mainThread)。
|
||||||
|
*/
|
||||||
|
public class ContactDataRepository {
|
||||||
|
|
||||||
|
private static final String TAG = "ContactDataRepository";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存 key
|
||||||
|
*/
|
||||||
|
private static final String KEY_CONTACT_LIST = "contact_list";
|
||||||
|
private static final String KEY_CONTACT_FORM_PREFIX = "contact_form_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表软 TTL:1 分钟内不重复请求网络
|
||||||
|
*/
|
||||||
|
private static final long LIST_SOFT_TTL = 60 * 1000L;
|
||||||
|
/**
|
||||||
|
* 列表硬 TTL:7 天后彻底失效
|
||||||
|
*/
|
||||||
|
private static final long LIST_HARD_TTL = 7 * 24 * 60 * 60 * 1000L;
|
||||||
|
|
||||||
|
private static final Type CONTACT_LIST_TYPE = new TypeToken<List<ContactInfo>>() {
|
||||||
|
}.getType();
|
||||||
|
|
||||||
|
// 同步状态常量(与 SyncStatus 语义一致)
|
||||||
|
private static final int SYNC_STATUS_UNSYNCED = 0;
|
||||||
|
private static final int SYNC_STATUS_SYNCED = 1;
|
||||||
|
|
||||||
|
private static volatile ContactDataRepository sInstance;
|
||||||
|
|
||||||
|
private final ContactRemoteDataSource mRemoteDataSource;
|
||||||
|
private final ContactRepository mLocalDataSource; // Room 本地数据源
|
||||||
|
private final CacheManager mCacheManager;
|
||||||
|
|
||||||
|
private ContactDataRepository(@NonNull Context appContext) {
|
||||||
|
mRemoteDataSource = new ContactRemoteDataSource();
|
||||||
|
mLocalDataSource = new ContactRepository(appContext);
|
||||||
|
mCacheManager = CacheManager.getInstance(appContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ContactDataRepository getInstance(@NonNull Context context) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
synchronized (ContactDataRepository.class) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
sInstance = new ContactDataRepository(context.getApplicationContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 读操作:多级缓存 + SWR ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取联系人列表。
|
||||||
|
* <p>命中新鲜缓存 → 秒出且不请求网络;命中过期缓存 → 先出缓存再后台刷新(SWR);
|
||||||
|
* 未命中 → Loading + 网络;网络失败 → 降级 Room 本地数据。</p>
|
||||||
|
*
|
||||||
|
* @param forceRefresh true 强制 revalidate(下拉刷新场景)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<Resource<List<ContactInfo>>> getContactList(boolean forceRefresh) {
|
||||||
|
return new NetworkBoundResource<List<ContactInfo>>(mCacheManager, forceRefresh) {
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected String cacheKey() {
|
||||||
|
return KEY_CONTACT_LIST;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected Type cacheType() {
|
||||||
|
return CONTACT_LIST_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected Observable<List<ContactInfo>> createNetworkObservable() {
|
||||||
|
return mRemoteDataSource.getContactList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void saveNetworkResult(@NonNull List<ContactInfo> data) {
|
||||||
|
// 网络数据同步进 Room(单一事实来源的持久化备份)
|
||||||
|
syncContactsToLocal(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected List<ContactInfo> processResult(@NonNull List<ContactInfo> data) {
|
||||||
|
return sortByPosition(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected long softTtlMillis() {
|
||||||
|
return LIST_SOFT_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected long hardTtlMillis() {
|
||||||
|
return LIST_HARD_TTL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.asObservable()
|
||||||
|
// 网络失败且无缓存兜底时,再降级 Room 本地数据
|
||||||
|
.map(resource -> {
|
||||||
|
if (resource.isError() && resource.data == null) {
|
||||||
|
List<ContactInfo> local = sortByPosition(mLocalDataSource.getAllContacts());
|
||||||
|
if (!local.isEmpty()) {
|
||||||
|
return Resource.error(resource.message, local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resource;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个联系人表单(按 id 独立缓存)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<Resource<ContactInfo>> getContactForm(long id, boolean forceRefresh) {
|
||||||
|
return new NetworkBoundResource<ContactInfo>(mCacheManager, forceRefresh) {
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected String cacheKey() {
|
||||||
|
return KEY_CONTACT_FORM_PREFIX + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected Type cacheType() {
|
||||||
|
return ContactInfo.class;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected Observable<ContactInfo> createNetworkObservable() {
|
||||||
|
return mRemoteDataSource.getContactForm(id);
|
||||||
|
}
|
||||||
|
}.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 写操作:远程优先 + 缓存失效 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增联系人结果
|
||||||
|
*/
|
||||||
|
public static final class AddResult {
|
||||||
|
public final long id;
|
||||||
|
/**
|
||||||
|
* true = 服务端成功;false = 网络异常降级到本地 Room 保存
|
||||||
|
*/
|
||||||
|
public final boolean online;
|
||||||
|
|
||||||
|
AddResult(long id, boolean online) {
|
||||||
|
this.id = id;
|
||||||
|
this.online = online;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增联系人:远程成功 → 失效列表缓存;远程失败 → 降级保存到本地 Room(待后续同步)。
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<AddResult> addContact(@NonNull ContactInfo contactInfo) {
|
||||||
|
return mRemoteDataSource.insertContact(contactInfo)
|
||||||
|
.map(id -> {
|
||||||
|
invalidateListCache();
|
||||||
|
return new AddResult(id, true);
|
||||||
|
})
|
||||||
|
.onErrorResumeNext(throwable -> {
|
||||||
|
Logger.w(TAG, "addContact 远程失败,降级本地保存: " + throwable.getMessage());
|
||||||
|
return Observable.fromCallable(() -> {
|
||||||
|
contactInfo.setPosition(mLocalDataSource.getTotalCount() + 1);
|
||||||
|
contactInfo.setLocalId(System.currentTimeMillis());
|
||||||
|
contactInfo.setSyncStatus(SYNC_STATUS_UNSYNCED);
|
||||||
|
long localId = mLocalDataSource.insert(contactInfo);
|
||||||
|
return new AddResult(localId, false);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅保存到本地 Room(不请求网络,标记未同步待后续上报)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<Long> addContactLocalOnly(@NonNull ContactInfo contactInfo) {
|
||||||
|
return Observable.fromCallable(() -> {
|
||||||
|
contactInfo.setPosition(mLocalDataSource.getTotalCount() + 1);
|
||||||
|
contactInfo.setLocalId(System.currentTimeMillis());
|
||||||
|
contactInfo.setSyncStatus(SYNC_STATUS_UNSYNCED);
|
||||||
|
long localId = mLocalDataSource.insert(contactInfo);
|
||||||
|
invalidateListCache();
|
||||||
|
return localId;
|
||||||
|
})
|
||||||
|
.subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新联系人:成功后失效列表与表单缓存
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<Boolean> updateContact(long id, @NonNull ContactInfo contactInfo) {
|
||||||
|
return mRemoteDataSource.updateContact(id, contactInfo)
|
||||||
|
.doOnNext(success -> {
|
||||||
|
if (success) {
|
||||||
|
invalidateListCache();
|
||||||
|
mCacheManager.invalidate(KEY_CONTACT_FORM_PREFIX + id);
|
||||||
|
mLocalDataSource.update(contactInfo);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除联系人:成功后失效缓存并删除本地记录
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<Boolean> deleteContact(long id) {
|
||||||
|
return mRemoteDataSource.deleteContact(id)
|
||||||
|
.doOnNext(success -> {
|
||||||
|
if (success) {
|
||||||
|
invalidateListCache();
|
||||||
|
mCacheManager.invalidate(KEY_CONTACT_FORM_PREFIX + id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 本地(Room)操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅更新本地排序等(不请求网络)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<Boolean> updateLocalContacts(@NonNull List<ContactInfo> contactInfos) {
|
||||||
|
return Observable.fromCallable(() -> {
|
||||||
|
mLocalDataSource.update(contactInfos);
|
||||||
|
invalidateListCache();
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新单个本地联系人
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<Integer> updateLocalContact(@NonNull ContactInfo contactInfo) {
|
||||||
|
return Observable.fromCallable(() -> {
|
||||||
|
int count = mLocalDataSource.update(contactInfo);
|
||||||
|
invalidateListCache();
|
||||||
|
return count;
|
||||||
|
})
|
||||||
|
.subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取本地全部联系人(Room)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<List<ContactInfo>> getLocalContacts() {
|
||||||
|
return Observable.fromCallable(() -> sortByPosition(mLocalDataSource.getAllContacts()))
|
||||||
|
.subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 内部工具 ====================
|
||||||
|
|
||||||
|
private void invalidateListCache() {
|
||||||
|
mCacheManager.invalidate(KEY_CONTACT_LIST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static List<ContactInfo> sortByPosition(@NonNull List<ContactInfo> list) {
|
||||||
|
return list.stream()
|
||||||
|
.sorted(Comparator.comparingInt(ContactInfo::getPosition))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网络数据与 Room 做差量同步(新增/更新/删除)。
|
||||||
|
*/
|
||||||
|
@WorkerThread
|
||||||
|
private void syncContactsToLocal(@NonNull List<ContactInfo> networkContacts) {
|
||||||
|
try {
|
||||||
|
List<ContactInfo> localContacts = mLocalDataSource.getAllContacts();
|
||||||
|
|
||||||
|
Map<Long, ContactInfo> networkMap = new HashMap<>();
|
||||||
|
for (ContactInfo c : networkContacts) {
|
||||||
|
networkMap.put(c.getId(), c);
|
||||||
|
}
|
||||||
|
Map<Long, ContactInfo> localMap = new HashMap<>();
|
||||||
|
for (ContactInfo c : localContacts) {
|
||||||
|
localMap.put(c.getId(), c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增 / 更新
|
||||||
|
for (ContactInfo networkContact : networkContacts) {
|
||||||
|
ContactInfo localContact = localMap.get(networkContact.getId());
|
||||||
|
if (localContact == null) {
|
||||||
|
networkContact.setSyncStatus(SYNC_STATUS_SYNCED);
|
||||||
|
networkContact.setLocalId(null);
|
||||||
|
mLocalDataSource.insert(networkContact);
|
||||||
|
} else if (networkContact.getUpdateTime() > localContact.getUpdateTime()) {
|
||||||
|
networkContact.setSyncStatus(SYNC_STATUS_SYNCED);
|
||||||
|
networkContact.setLocalId(localContact.getLocalId());
|
||||||
|
mLocalDataSource.update(networkContact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除:网络已不存在且本地已同步过的记录
|
||||||
|
for (ContactInfo localContact : localContacts) {
|
||||||
|
if (!networkMap.containsKey(localContact.getId())
|
||||||
|
&& localContact.getSyncStatus() == SYNC_STATUS_SYNCED) {
|
||||||
|
mLocalDataSource.delete(localContact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.e(TAG, "syncContactsToLocal failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.ttstd.dialer.data.repository;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import com.ttstd.dialer.bean.DeveloperOptions;
|
||||||
|
import com.ttstd.dialer.data.NetworkBoundResource;
|
||||||
|
import com.ttstd.dialer.data.Resource;
|
||||||
|
import com.ttstd.dialer.data.cache.CacheManager;
|
||||||
|
import com.ttstd.dialer.data.remote.SnRemoteDataSource;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Observable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备(SN)配置仓库(Data Layer - Repository)。
|
||||||
|
* 开发者选项等设备配置走「内存 + 磁盘缓存 + SWR」。
|
||||||
|
*/
|
||||||
|
public class SnRepository {
|
||||||
|
|
||||||
|
private static final String KEY_DEVELOPER_OPTIONS = "developer_options";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 软 TTL:5 分钟;硬 TTL:24 小时
|
||||||
|
*/
|
||||||
|
private static final long SOFT_TTL = 5 * 60 * 1000L;
|
||||||
|
private static final long HARD_TTL = 24 * 60 * 60 * 1000L;
|
||||||
|
|
||||||
|
private static volatile SnRepository sInstance;
|
||||||
|
|
||||||
|
private final SnRemoteDataSource mRemoteDataSource;
|
||||||
|
private final CacheManager mCacheManager;
|
||||||
|
|
||||||
|
private SnRepository(@NonNull Context appContext) {
|
||||||
|
mRemoteDataSource = new SnRemoteDataSource();
|
||||||
|
mCacheManager = CacheManager.getInstance(appContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SnRepository getInstance(@NonNull Context context) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
synchronized (SnRepository.class) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
sInstance = new SnRepository(context.getApplicationContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取开发者选项配置(SWR)。
|
||||||
|
*
|
||||||
|
* @param forceRefresh 推送触发的配置变更等场景传 true,跳过新鲜度判定强制 revalidate
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public Observable<Resource<DeveloperOptions>> getDeveloperOptions(boolean forceRefresh) {
|
||||||
|
return new NetworkBoundResource<DeveloperOptions>(mCacheManager, forceRefresh) {
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected String cacheKey() {
|
||||||
|
return KEY_DEVELOPER_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected Type cacheType() {
|
||||||
|
return DeveloperOptions.class;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
protected Observable<DeveloperOptions> createNetworkObservable() {
|
||||||
|
return mRemoteDataSource.getDeveloperOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected long softTtlMillis() {
|
||||||
|
return SOFT_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected long hardTtlMillis() {
|
||||||
|
return HARD_TTL;
|
||||||
|
}
|
||||||
|
}.asObservable();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,94 +4,54 @@ import android.content.Context;
|
|||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
|
||||||
import com.trello.rxlifecycle4.RxLifecycle;
|
|
||||||
import com.trello.rxlifecycle4.android.FragmentEvent;
|
import com.trello.rxlifecycle4.android.FragmentEvent;
|
||||||
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
import com.ttstd.dialer.base.mvvm.BaseViewModel;
|
||||||
|
import com.ttstd.dialer.data.repository.ContactDataRepository;
|
||||||
import com.ttstd.dialer.databinding.FragmentContactBinding;
|
import com.ttstd.dialer.databinding.FragmentContactBinding;
|
||||||
import com.ttstd.dialer.db.contact.ContactInfo;
|
import com.ttstd.dialer.db.contact.ContactInfo;
|
||||||
import com.ttstd.dialer.db.contact.ContactRepository;
|
|
||||||
import com.ttstd.dialer.utils.Logger;
|
import com.ttstd.dialer.utils.Logger;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.Callable;
|
|
||||||
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
import io.reactivex.rxjava3.annotations.NonNull;
|
|
||||||
import io.reactivex.rxjava3.core.Observable;
|
|
||||||
import io.reactivex.rxjava3.core.Observer;
|
|
||||||
import io.reactivex.rxjava3.disposables.Disposable;
|
|
||||||
import io.reactivex.rxjava3.functions.Consumer;
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主页联系人 ViewModel(Google 标准 MVVM):
|
||||||
|
* 统一走 Repository 多级缓存 + SWR,内存/磁盘缓存秒出,后台自动 revalidate。
|
||||||
|
*/
|
||||||
public class ContactViewModel extends BaseViewModel<FragmentContactBinding, FragmentEvent> {
|
public class ContactViewModel extends BaseViewModel<FragmentContactBinding, FragmentEvent> {
|
||||||
private ContactRepository mRepository;
|
private static final String TAG = "ContactViewModel";
|
||||||
|
|
||||||
|
private ContactDataRepository mRepository;
|
||||||
private boolean isDataLoaded = false;
|
private boolean isDataLoaded = false;
|
||||||
|
|
||||||
public void preloadData() {
|
public MutableLiveData<List<ContactInfo>> mContactListData = new MutableLiveData<>();
|
||||||
if (isDataLoaded) return;
|
|
||||||
|
|
||||||
getAllContactsObservable()
|
|
||||||
.subscribe(new Consumer<List<ContactInfo>>() {
|
|
||||||
@Override
|
|
||||||
public void accept(List<ContactInfo> contactInfos) throws Throwable {
|
|
||||||
mContactListData.postValue(contactInfos);
|
|
||||||
isDataLoaded = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setContext(Context context) {
|
public void setContext(Context context) {
|
||||||
super.setContext(context);
|
super.setContext(context);
|
||||||
mRepository = new ContactRepository(context);
|
mRepository = ContactDataRepository.getInstance(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public MutableLiveData<List<ContactInfo>> mContactListData = new MutableLiveData<>();
|
public void preloadData() {
|
||||||
|
if (isDataLoaded) return;
|
||||||
public Observable<List<ContactInfo>> getAllContactsObservable() {
|
getAllContacts();
|
||||||
return Observable.fromCallable(new Callable<List<ContactInfo>>() {
|
|
||||||
@Override
|
|
||||||
public List<ContactInfo> call() throws Exception {
|
|
||||||
List<ContactInfo> contactInfos = mRepository.getAllContacts();
|
|
||||||
return contactInfos;
|
|
||||||
}
|
|
||||||
}).compose(RxLifecycle.bindUntilEvent(getLifecycle(), FragmentEvent.STOP))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取联系人:命中新鲜缓存直接秒出;缓存过期先出旧数据再后台刷新;
|
||||||
|
* 网络失败自动降级缓存/Room 本地数据。
|
||||||
|
*/
|
||||||
public void getAllContacts() {
|
public void getAllContacts() {
|
||||||
getAllContactsObservable()
|
addDisposable(mRepository.getContactList(false)
|
||||||
.subscribe(new Observer<List<ContactInfo>>() {
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
@Override
|
.subscribe(resource -> {
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
Logger.d(TAG, "getAllContacts: " + resource.status
|
||||||
Logger.e("getAllContacts", "onSubscribe: ");
|
+ ", source=" + resource.source + ", stale=" + resource.stale);
|
||||||
|
if (resource.data != null) {
|
||||||
|
mContactListData.setValue(resource.data);
|
||||||
|
isDataLoaded = true;
|
||||||
}
|
}
|
||||||
|
}, throwable -> Logger.e(TAG, "getAllContacts error: " + throwable.getMessage())));
|
||||||
@Override
|
|
||||||
public void onNext(@NonNull List<ContactInfo> contactInfos) {
|
|
||||||
Logger.e("getAllContacts", "onNext: ");
|
|
||||||
mContactListData.setValue(contactInfos);
|
|
||||||
|
|
||||||
// List<Contact> sorted = contacts.stream().sorted(new Comparator<Contact>() {
|
|
||||||
// @Override
|
|
||||||
// public int compare(Contact o1, Contact o2) {
|
|
||||||
// return Integer.compare(o1.getSort(), o2.getSort());
|
|
||||||
// }
|
|
||||||
// }).collect(Collectors.toList());
|
|
||||||
// mContactListData.setValue(sorted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Logger.e("getAllContacts", "onError: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
Logger.e("getAllContacts", "onComplete: ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import com.ttstd.dialer.mdm.DeviceManagerService;
|
|||||||
import com.ttstd.dialer.network.api.ContactApi;
|
import com.ttstd.dialer.network.api.ContactApi;
|
||||||
import com.ttstd.dialer.network.api.SnApi;
|
import com.ttstd.dialer.network.api.SnApi;
|
||||||
import com.ttstd.dialer.network.interceptor.AuthInterceptor;
|
import com.ttstd.dialer.network.interceptor.AuthInterceptor;
|
||||||
import com.ttstd.iconloader.utils.FileUtils;
|
|
||||||
import com.ttstd.dialer.utils.Logger;
|
import com.ttstd.dialer.utils.Logger;
|
||||||
|
import com.ttstd.iconloader.utils.FileUtils;
|
||||||
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
@@ -364,7 +364,7 @@ public class OkHttpManager {
|
|||||||
return schedule(getApiService(ContactApi.class).updateContact(id, convertToRequestBodyjson(contactInfo)), provider);
|
return schedule(getApiService(ContactApi.class).updateContact(id, convertToRequestBodyjson(contactInfo)), provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Observable<BaseResponse> getContactDeleteObservable(long id, BehaviorSubject<ActivityEvent> provider) {
|
public Observable<BaseResponse<Void>> getContactDeleteObservable(long id, BehaviorSubject<ActivityEvent> provider) {
|
||||||
return schedule(getApiService(ContactApi.class).deleteContact(id), provider);
|
return schedule(getApiService(ContactApi.class).deleteContact(id), provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public interface ContactApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@DELETE(UrlConstants.CONTACT_DELETE)
|
@DELETE(UrlConstants.CONTACT_DELETE)
|
||||||
Observable<BaseResponse> deleteContact(
|
Observable<BaseResponse<Void>> deleteContact(
|
||||||
@Query("id") Long id
|
@Query("id") Long id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,16 @@ import com.ttstd.dialer.bean.DeveloperOptions;
|
|||||||
import com.ttstd.dialer.bean.PushMessage;
|
import com.ttstd.dialer.bean.PushMessage;
|
||||||
import com.ttstd.dialer.bean.req.SnHardwareInfoReq;
|
import com.ttstd.dialer.bean.req.SnHardwareInfoReq;
|
||||||
import com.ttstd.dialer.config.CommonConfig;
|
import com.ttstd.dialer.config.CommonConfig;
|
||||||
|
import com.ttstd.dialer.data.repository.SnRepository;
|
||||||
import com.ttstd.dialer.manager.AppManager;
|
import com.ttstd.dialer.manager.AppManager;
|
||||||
import com.ttstd.dialer.manager.MapManager;
|
import com.ttstd.dialer.manager.MapManager;
|
||||||
import com.ttstd.dialer.mdm.DeviceManagerService;
|
import com.ttstd.dialer.mdm.DeviceManagerService;
|
||||||
import com.ttstd.dialer.network.BaseObserver;
|
import com.ttstd.dialer.network.BaseObserver;
|
||||||
import com.ttstd.dialer.network.OkHttpManager;
|
import com.ttstd.dialer.network.OkHttpManager;
|
||||||
import com.ttstd.dialer.utils.CmdUtil;
|
import com.ttstd.dialer.utils.CmdUtil;
|
||||||
import com.ttstd.iconloader.utils.FileUtils;
|
|
||||||
import com.ttstd.dialer.utils.Logger;
|
import com.ttstd.dialer.utils.Logger;
|
||||||
import com.ttstd.dialer.utils.RebootUtils;
|
import com.ttstd.dialer.utils.RebootUtils;
|
||||||
|
import com.ttstd.iconloader.utils.FileUtils;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -120,35 +121,20 @@ public class PushExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void getDeviceSettings() {
|
private void getDeviceSettings() {
|
||||||
OkHttpManager.getInstance().getDeveloperOptionsObservable()
|
// 走 Repository(多级缓存 + SWR);推送触发说明服务端配置已变更,强制 revalidate
|
||||||
.subscribe(new Observer<BaseResponse<DeveloperOptions>>() {
|
SnRepository.getInstance(mContext).getDeveloperOptions(true)
|
||||||
@Override
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
public void onSubscribe(@NonNull Disposable d) {
|
.subscribe(resource -> {
|
||||||
Logger.e("getDeveloperOptions", "onSubscribe: ");
|
Logger.e("getDeveloperOptions", "resource: " + resource);
|
||||||
}
|
// 仅在拿到网络最新数据(非 stale 缓存)时应用配置
|
||||||
|
if (resource.isSuccess() && !resource.stale && resource.data != null) {
|
||||||
@Override
|
DeveloperOptions developerOptions = resource.data;
|
||||||
public void onNext(@NonNull BaseResponse<DeveloperOptions> baseResponse) {
|
if (!BuildConfig.DEBUG) {
|
||||||
Logger.e("getDeveloperOptions", "onNext: " + baseResponse);
|
DeviceManagerService.getInstance().setDevelopmentOption(developerOptions.getDeveloperOptions() == 1);
|
||||||
if (baseResponse.isSuccess()) {
|
DeviceManagerService.getInstance().setUsbDebugMode(developerOptions.getDeveloperOptions() == 1);
|
||||||
DeveloperOptions developerOptions = baseResponse.getData();
|
|
||||||
if (!BuildConfig.DEBUG) {
|
|
||||||
DeviceManagerService.getInstance().setDevelopmentOption(developerOptions.getDeveloperOptions() == 1);
|
|
||||||
DeviceManagerService.getInstance().setUsbDebugMode(developerOptions.getDeveloperOptions() == 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, throwable -> Logger.e("getDeveloperOptions", "onError: " + throwable.getMessage()));
|
||||||
@Override
|
|
||||||
public void onError(@NonNull Throwable e) {
|
|
||||||
Logger.e("getDeveloperOptions", "onError: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
Logger.e("getDeveloperOptions", "onComplete: ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void uploadDeviceInfo() {
|
private void uploadDeviceInfo() {
|
||||||
|
|||||||
Reference in New Issue
Block a user