From de1ce76fdff4bf509175ff5b709699b250c7cd28 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Mon, 27 Jul 2026 17:39:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor(contact):=20=E9=87=8D=E6=9E=84=20ViewM?= =?UTF-8?q?odel=20=E4=B8=BA=E6=A0=87=E5=87=86=20MVVM=20=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将网络优先/本地降级策略下沉至 ContactDataRepository,ViewModel 仅负责状态分发;引入 Resource 包装类与 SWR 多级缓存机制,简化 RxJava 链式调用并移除冗余的 Observer/RxLifecycle 代码。 --- .../contact/add/ContactAddViewModel.java | 113 ++---- .../contact/edit/ContactEditViewModel.java | 55 +-- .../contact/list/ContactListActivity.java | 34 +- .../contact/list/ContactListViewModel.java | 204 +++------- .../contact/test/ContactTestViewModel.java | 150 ++------ .../ttstd/dialer/base/mvvm/BaseViewModel.java | 21 + .../com/ttstd/dialer/data/ApiException.java | 22 ++ .../dialer/data/NetworkBoundResource.java | 161 ++++++++ .../java/com/ttstd/dialer/data/Resource.java | 97 +++++ .../ttstd/dialer/data/cache/CacheEntry.java | 77 ++++ .../ttstd/dialer/data/cache/CacheManager.java | 108 ++++++ .../ttstd/dialer/data/cache/DiskCache.java | 165 ++++++++ .../ttstd/dialer/data/cache/MemoryCache.java | 55 +++ .../dialer/data/remote/ApiResponseMapper.java | 51 +++ .../data/remote/ContactRemoteDataSource.java | 51 +++ .../data/remote/SnRemoteDataSource.java | 26 ++ .../repository/ContactDataRepository.java | 358 ++++++++++++++++++ .../dialer/data/repository/SnRepository.java | 89 +++++ .../fragment/contact/ContactViewModel.java | 92 ++--- .../ttstd/dialer/network/OkHttpManager.java | 4 +- .../ttstd/dialer/network/api/ContactApi.java | 2 +- .../com/ttstd/dialer/push/PushExecutor.java | 42 +- 22 files changed, 1497 insertions(+), 480 deletions(-) create mode 100644 app/src/main/java/com/ttstd/dialer/data/ApiException.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/NetworkBoundResource.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/Resource.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/cache/CacheEntry.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/cache/MemoryCache.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/remote/ApiResponseMapper.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/remote/ContactRemoteDataSource.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/remote/SnRemoteDataSource.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/repository/ContactDataRepository.java create mode 100644 app/src/main/java/com/ttstd/dialer/data/repository/SnRepository.java diff --git a/app/src/main/java/com/ttstd/dialer/activity/contact/add/ContactAddViewModel.java b/app/src/main/java/com/ttstd/dialer/activity/contact/add/ContactAddViewModel.java index 8dfbfd3..4b9b155 100644 --- a/app/src/main/java/com/ttstd/dialer/activity/contact/add/ContactAddViewModel.java +++ b/app/src/main/java/com/ttstd/dialer/activity/contact/add/ContactAddViewModel.java @@ -4,119 +4,58 @@ import android.content.Context; import androidx.lifecycle.MutableLiveData; -import com.trello.rxlifecycle4.RxLifecycle; import com.trello.rxlifecycle4.android.ActivityEvent; import com.ttstd.dialer.base.mvvm.BaseViewModel; +import com.ttstd.dialer.data.repository.ContactDataRepository; import com.ttstd.dialer.databinding.ActivityContactAddBinding; 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 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 { private static final String TAG = "ContactAddViewModel"; - private ContactRepository mRepository; + private ContactDataRepository mRepository; + + /** + * 服务端保存成功返回的 id + */ public MutableLiveData mOnlineIdData = new MutableLiveData<>(); + /** 网络失败降级到本地 Room 保存返回的 id */ public MutableLiveData mDbIdData = new MutableLiveData<>(); @Override public void setContext(Context context) { super.setContext(context); - mRepository = new ContactRepository(context); + mRepository = ContactDataRepository.getInstance(context); } + /** + * 新增联系人:远程成功 → 自动失效列表缓存;失败 → 自动降级本地保存。 + */ public void addContact(ContactInfo contactInfo) { - OkHttpManager.getInstance().getContactInsertObservable(contactInfo, getLifecycle()) - .subscribeOn(Schedulers.io()) + addDisposable(mRepository.addContact(contactInfo) .observeOn(AndroidSchedulers.mainThread()) - .flatMap(baseResponse -> { - Logger.e(TAG, "addContact", "网络请求响应: " + baseResponse); - if (baseResponse.isSuccess()) { - Long id = baseResponse.getData(); - mOnlineIdData.setValue(id); - return Observable.just(id); + .subscribe(result -> { + Logger.d(TAG, "addContact online=" + result.online + ", id=" + result.id); + if (result.online) { + mOnlineIdData.setValue(result.id); } else { - Logger.w(TAG, "业务失败,降级到本地保存: " + baseResponse.getMsg()); - return saveContactDbObservable(contactInfo); + mDbIdData.setValue(result.id); } - }) - .onErrorResumeNext(throwable -> { - Logger.e(TAG, "网络异常,降级到本地保存: " + throwable.getMessage()); - return saveContactDbObservable(contactInfo); - }) - .compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY)) - .subscribe(new Observer() { - @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 saveContactDbObservable(ContactInfo contactInfo) { - return Observable.create((ObservableOnSubscribe) 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()); + }, throwable -> Logger.e(TAG, "addContact error: " + throwable.getMessage()))); } + /** 仅保存到本地 Room */ public void saveContactDb(ContactInfo contactInfo) { - saveContactDbObservable(contactInfo) - .compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY)) + addDisposable(mRepository.addContactLocalOnly(contactInfo) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - 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: "); - } - }); + .subscribe(mDbIdData::setValue, + throwable -> Logger.e(TAG, "saveContactDb error: " + throwable.getMessage()))); } } diff --git a/app/src/main/java/com/ttstd/dialer/activity/contact/edit/ContactEditViewModel.java b/app/src/main/java/com/ttstd/dialer/activity/contact/edit/ContactEditViewModel.java index 58d4f9b..22bc0c4 100644 --- a/app/src/main/java/com/ttstd/dialer/activity/contact/edit/ContactEditViewModel.java +++ b/app/src/main/java/com/ttstd/dialer/activity/contact/edit/ContactEditViewModel.java @@ -1,42 +1,53 @@ package com.ttstd.dialer.activity.contact.edit; -import android.util.Log; +import android.content.Context; import androidx.lifecycle.MutableLiveData; import com.kongzue.dialogx.dialogs.PopTip; import com.trello.rxlifecycle4.android.ActivityEvent; 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.db.contact.ContactInfo; -import com.ttstd.dialer.network.BaseObserver; -import com.ttstd.dialer.network.OkHttpManager; +import com.ttstd.dialer.utils.Logger; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; + +/** + * 编辑联系人 ViewModel(Google 标准 MVVM):只依赖 Repository。 + */ public class ContactEditViewModel extends BaseViewModel { + private static final String TAG = "ContactEditViewModel"; + + private ContactDataRepository mRepository; public MutableLiveData mBooleanMutableLiveData = new MutableLiveData<>(); - public void updateContact(long id, ContactInfo contactInfo) { - OkHttpManager.getInstance().getContactUpdateObservable(id, contactInfo, getLifecycle()) - .safeSubscribe(new BaseObserver>() { - @Override - public void onSuccess(BaseResponse baseResponse) { - Log.e("updateContact", "onSuccess: " + baseResponse); - if (baseResponse.isSuccess()) { - mBooleanMutableLiveData.postValue(true); - } else { - mBooleanMutableLiveData.postValue(false); - PopTip.show(baseResponse.getMsg()).iconError(); - } - } + @Override + public void setContext(Context context) { + super.setContext(context); + mRepository = ContactDataRepository.getInstance(context); + } - @Override - public void onFailure(Throwable e) { - Log.e("updateContact", "onFailure: " + e.getMessage()); - mBooleanMutableLiveData.postValue(false); + /** + * 更新联系人:Repository 成功后自动失效列表/表单缓存并同步 Room + */ + 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(); } - }); + })); } } diff --git a/app/src/main/java/com/ttstd/dialer/activity/contact/list/ContactListActivity.java b/app/src/main/java/com/ttstd/dialer/activity/contact/list/ContactListActivity.java index 57951f1..44b2576 100644 --- a/app/src/main/java/com/ttstd/dialer/activity/contact/list/ContactListActivity.java +++ b/app/src/main/java/com/ttstd/dialer/activity/contact/list/ContactListActivity.java @@ -19,6 +19,7 @@ import com.ttstd.dialer.activity.contact.edit.ContactEditActivity; import com.ttstd.dialer.activity.contact.test.ContactTestActivity; import com.ttstd.dialer.adapter.ContactInfoAdapter; import com.ttstd.dialer.base.mvvm.BaseMvvmActivity; +import com.ttstd.dialer.data.Resource; import com.ttstd.dialer.databinding.ActivityContactListBinding; import com.ttstd.dialer.db.contact.ContactInfo; import com.ttstd.dialer.fragment.dialog.contact.call.CallFragment; @@ -67,7 +68,8 @@ public class ContactListActivity extends BaseMvvmActivity>() { + // 观察 Resource 状态:LOADING / SUCCESS(含缓存 stale 数据) / ERROR(含兜底数据) + mViewModel.getContactListData().observe(this, new Observer>>() { @Override - public void onChanged(List contactInfos) { - mViewDataBinding.swipeRefreshLayout.setRefreshing(false); - if (contactInfos == null) { + public void onChanged(Resource> resource) { + if (resource == null) { + return; + } + // stale=true 表示当前展示的是过期缓存,后台正在 revalidate,保持刷新动画 + boolean refreshing = resource.isLoading() || resource.stale; + mViewDataBinding.swipeRefreshLayout.setRefreshing(refreshing); - } else { - Logger.e(TAG, "mContactListData: " + contactInfos); - mContactInfos = contactInfos; + if (resource.data != null) { + Logger.d(TAG, "contactList: source=" + resource.source + + ", stale=" + resource.stale + ", size=" + resource.data.size()); + mContactInfos = resource.data; mContactInfoAdapter.setContactInfos(mContactInfos); } + if (resource.isError() && resource.data == null) { + TipDialog.show("加载失败:" + resource.message, WaitDialog.TYPE.ERROR); + } } }); - mViewModel.mDeleteLiveData.observe(this, new Observer() { + mViewModel.getDeleteResult().observe(this, new Observer() { @Override public void onChanged(Boolean aBoolean) { if (aBoolean) { TipDialog.show("删除成功", WaitDialog.TYPE.SUCCESS); - mViewModel.getAllContacts(); + mViewModel.loadContacts(true); } else { TipDialog.show("删除失败", WaitDialog.TYPE.ERROR); } @@ -211,7 +222,8 @@ public class ContactListActivity extends BaseMvvmActivity { private static final String TAG = "ContactListViewModel"; - private ContactRepository mRepository; - private ContactManager mContactManager; + + private ContactDataRepository mRepository; + + private final MutableLiveData>> mContactList = new MutableLiveData<>(); + private final MutableLiveData mDeleteResult = new MutableLiveData<>(); @Override public void setContext(Context context) { super.setContext(context); - mRepository = new ContactRepository(context); - mContactManager = ContactManager.getInstance(context); + mRepository = ContactDataRepository.getInstance(context); } - public MutableLiveData> mContactListData = new MutableLiveData<>(); - - public void getAllContacts() { - getAllContactsFromDB(); + public LiveData>> getContactListData() { + return mContactList; } - private void getAllContactsFromDB() { - Observable.create(new ObservableOnSubscribe>() { - @Override - public void subscribe(@NonNull ObservableEmitter> emitter) throws Throwable { - List contactInfos = mRepository.getAllContacts(); - emitter.onNext(contactInfos); - emitter.onComplete(); - } - }).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY)) - .subscribeOn(Schedulers.io()) + public LiveData getDeleteResult() { + return mDeleteResult; + } + + /** + * 加载联系人列表。 + * 多级缓存 + SWR:命中新鲜缓存秒出;过期缓存先出旧数据再后台刷新; + * 未命中走 Loading → 网络;网络失败自动降级缓存/Room。 + * + * @param forceRefresh 下拉刷新时传 true,强制 revalidate + */ + public void loadContacts(boolean forceRefresh) { + addDisposable(mRepository.getContactList(forceRefresh) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer>() { - @Override - public void onSubscribe(@NonNull Disposable d) { - Logger.e("getAllContacts", "onSubscribe: "); - } - - @Override - public void onNext(@NonNull List 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(); - } - }); + .subscribe(resource -> { + Logger.d(TAG, "loadContacts: " + resource.status + + ", source=" + resource.source + ", stale=" + resource.stale); + mContactList.setValue(resource); + }, throwable -> { + Logger.e(TAG, "loadContacts error: " + throwable.getMessage()); + mContactList.setValue(Resource.error(throwable.getMessage(), null)); + })); } - private void requestNetworkContacts() { - mContactManager.getContacts(getLifecycle(), new ContactManager.ContactCallback() { - @Override - public void onSuccess(List contacts) { - Logger.e(TAG, "获取网络联系人成功: " + contacts.size()); - mContactListData.setValue(contacts); - } - - @Override - public void onFailure(Throwable e) { - Logger.e(TAG, "获取网络联系人失败: " + e.getMessage()); - } - }); + /** + * 拖动排序后仅更新本地顺序 + */ + public void updateContacts(List contactInfos) { + addDisposable(mRepository.updateLocalContacts(contactInfos) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(success -> Logger.d(TAG, "updateContacts success: " + success), + throwable -> Logger.e(TAG, "updateContacts error: " + throwable.getMessage()))); } public void updateItemPosition(ContactInfo contactInfo) { - Logger.e(TAG, "updateItemPosition: " + contactInfo); - Observable.create(new ObservableOnSubscribe() { - @Override - public void subscribe(@NonNull ObservableEmitter emitter) throws Throwable { - int id = mRepository.update(contactInfo); - emitter.onNext(id); - emitter.onComplete(); - } - }).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY)) - .subscribeOn(Schedulers.io()) + addDisposable(mRepository.updateLocalContact(contactInfo) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - 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: "); - } - }); + .subscribe(count -> Logger.d(TAG, "updateItemPosition count: " + count), + throwable -> Logger.e(TAG, "updateItemPosition error: " + throwable.getMessage()))); } - public void updateContacts(List contactInfos) { - Logger.e(TAG, "updateContacts: " + contactInfos.size()); - Observable.create(new ObservableOnSubscribe() { - @Override - public void subscribe(@NonNull ObservableEmitter 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() { - @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 mDeleteLiveData = new MutableLiveData<>(); - + /** + * 删除联系人:Repository 内部成功后会自动失效缓存 + */ public void deleteContact(long id) { - OkHttpManager.getInstance().getContactDeleteObservable(id, getLifecycle()) - .safeSubscribe(new BaseObserver() { - @Override - public void onSuccess(BaseResponse baseResponse) { - Logger.e("deleteContact", "onSuccess: " + baseResponse); - mDeleteLiveData.setValue(baseResponse.isSuccess()); - } - - @Override - public void onFailure(Throwable e) { - Logger.e("deleteContact", "onFailure: " + e.getMessage()); - mDeleteLiveData.setValue(false); - } - }); + addDisposable(mRepository.deleteContact(id) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(mDeleteResult::setValue, + throwable -> { + Logger.e(TAG, "deleteContact error: " + throwable.getMessage()); + mDeleteResult.setValue(false); + })); } } diff --git a/app/src/main/java/com/ttstd/dialer/activity/contact/test/ContactTestViewModel.java b/app/src/main/java/com/ttstd/dialer/activity/contact/test/ContactTestViewModel.java index 62e43f0..0a102ed 100644 --- a/app/src/main/java/com/ttstd/dialer/activity/contact/test/ContactTestViewModel.java +++ b/app/src/main/java/com/ttstd/dialer/activity/contact/test/ContactTestViewModel.java @@ -1,151 +1,69 @@ package com.ttstd.dialer.activity.contact.test; import android.content.Context; -import android.util.Log; import androidx.lifecycle.MutableLiveData; -import com.trello.rxlifecycle4.RxLifecycle; import com.trello.rxlifecycle4.android.ActivityEvent; 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.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 java.util.Comparator; import java.util.List; -import java.util.stream.Collectors; 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 { - private static final String TAG = "ContactListViewModel"; - private ContactRepository mRepository; + private static final String TAG = "ContactTestViewModel"; + + private ContactDataRepository mRepository; @Override public void setContext(Context context) { super.setContext(context); - mRepository = new ContactRepository(context); + mRepository = ContactDataRepository.getInstance(context); } public MutableLiveData> mOnlineContactListData = new MutableLiveData<>(); - - public void getAllContacts() { - OkHttpManager.getInstance().getContactListObservable(getLifecycle()) - .compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY)) - .subscribe(new BaseObserver>>() { - @Override - public void onSuccess(BaseResponse> listBaseResponse) { - Log.e("getAllContacts", "onSuccess: " + listBaseResponse); - if (listBaseResponse.isSuccess()) { - List contactInfos = listBaseResponse.getData(); - List sorted = contactInfos.stream().sorted(new Comparator() { - @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> mLocalContactListData = new MutableLiveData<>(); - public void getAllContactsFromDB() { - Observable.create(new ObservableOnSubscribe>() { - @Override - public void subscribe(@NonNull ObservableEmitter> emitter) throws Throwable { - List contactInfos = mRepository.getAllContacts(); - emitter.onNext(contactInfos); - emitter.onComplete(); - } - }).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY)) - .subscribeOn(Schedulers.io()) + /** + * 网络联系人(走多级缓存 + SWR,数据已按 position 排序) + */ + public void getAllContacts() { + addDisposable(mRepository.getContactList(false) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer>() { - @Override - public void onSubscribe(@NonNull Disposable d) { - Logger.e("getAllContacts", "onSubscribe: "); + .subscribe(resource -> { + Logger.d(TAG, "getAllContacts: " + resource.status + + ", source=" + resource.source + ", stale=" + resource.stale); + 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 - public void onNext(@NonNull List contactInfos) { - Logger.e("getAllContacts", "onNext: "); -// mLocalContactListData.setValue(contactInfos); - - List sorted = contactInfos.stream().sorted(new Comparator() { - @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: "); - } - }); + /** 本地(Room)联系人 */ + public void getAllContactsFromDB() { + addDisposable(mRepository.getLocalContacts() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(mLocalContactListData::setValue, + throwable -> Logger.e(TAG, "getAllContactsFromDB error: " + throwable.getMessage()))); } public void updateItemPosition(ContactInfo contactInfo) { - Logger.e(TAG, "updateItemPosition: " + contactInfo); - Observable.create(new ObservableOnSubscribe() { - @Override - public void subscribe(@NonNull ObservableEmitter emitter) throws Throwable { - int id = mRepository.update(contactInfo); - emitter.onNext(id); - emitter.onComplete(); - } - }).compose(RxLifecycle.bindUntilEvent(getLifecycle(), ActivityEvent.DESTROY)) - .subscribeOn(Schedulers.io()) + addDisposable(mRepository.updateLocalContact(contactInfo) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - 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: "); - } - }); + .subscribe(count -> Logger.d(TAG, "updateItemPosition count: " + count), + throwable -> Logger.e(TAG, "updateItemPosition error: " + throwable.getMessage()))); } - } diff --git a/app/src/main/java/com/ttstd/dialer/base/mvvm/BaseViewModel.java b/app/src/main/java/com/ttstd/dialer/base/mvvm/BaseViewModel.java index 75102ae..0be84a2 100644 --- a/app/src/main/java/com/ttstd/dialer/base/mvvm/BaseViewModel.java +++ b/app/src/main/java/com/ttstd/dialer/base/mvvm/BaseViewModel.java @@ -7,10 +7,31 @@ import androidx.lifecycle.ViewModel; import java.lang.ref.WeakReference; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.subjects.BehaviorSubject; public abstract class BaseViewModel 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 */ diff --git a/app/src/main/java/com/ttstd/dialer/data/ApiException.java b/app/src/main/java/com/ttstd/dialer/data/ApiException.java new file mode 100644 index 0000000..cdc5834 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/ApiException.java @@ -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; + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/NetworkBoundResource.java b/app/src/main/java/com/ttstd/dialer/data/NetworkBoundResource.java new file mode 100644 index 0000000..edd4546 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/NetworkBoundResource.java @@ -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)策略。 + * + *
+ * 数据流(订阅后在 IO 线程执行):
+ *
+ *  查缓存(内存 → 磁盘)
+ *    ├─ 未命中/硬过期  → 发射 Loading           → 请求网络 → 写缓存 → 发射 Success(NETWORK)
+ *    ├─ FRESH(软TTL内) → 发射 Success(缓存)      → 结束(不请求网络)
+ *    └─ STALE(软过期)  → 发射 Success(缓存,stale) → 后台 revalidate → 写缓存 → 发射 Success(NETWORK)
+ *                                                    └─ 网络失败 → 发射 Error(携带缓存兜底数据)
+ * 
+ * + * @param 业务数据类型 + */ +public abstract class NetworkBoundResource { + + 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 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> asObservable() { + return Observable + .defer(() -> { + CacheManager.Hit hit = mCacheManager.get(cacheKey(), cacheType()); + if (hit == null) { + // 缓存未命中:Loading → 网络 + Logger.d(TAG, "cache MISS, key=" + cacheKey()); + return Observable.concat( + Observable.just(Resource.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> 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); + }); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/Resource.java b/app/src/main/java/com/ttstd/dialer/data/Resource.java new file mode 100644 index 0000000..6e354a5 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/Resource.java @@ -0,0 +1,97 @@ +package com.ttstd.dialer.data; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * UI 状态封装(Google 官方架构指南推荐写法) + *

+ * 携带 状态(LOADING/SUCCESS/ERROR) + 数据 + 错误信息 + 数据来源(内存/磁盘/网络) + 是否过期(stale)。 + * ViewModel 通过 LiveData> 将状态透传给 UI 层。 + * + * @param 业务数据类型 + */ +public final class Resource { + + /** + * 请求状态 + */ + 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 Resource loading(@Nullable T data) { + return new Resource<>(Status.LOADING, data, null, null, false); + } + + public static Resource success(@Nullable T data, @NonNull Source source) { + return new Resource<>(Status.SUCCESS, data, null, source, false); + } + + /** + * SWR 场景:发射过期缓存时 stale = true + */ + public static Resource success(@Nullable T data, @NonNull Source source, boolean stale) { + return new Resource<>(Status.SUCCESS, data, null, source, stale); + } + + /** + * 失败时可携带兜底缓存数据,UI 可继续展示旧数据 + */ + public static Resource 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 + '}'; + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/cache/CacheEntry.java b/app/src/main/java/com/ttstd/dialer/data/cache/CacheEntry.java new file mode 100644 index 0000000..80df63f --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/cache/CacheEntry.java @@ -0,0 +1,77 @@ +package com.ttstd.dialer.data.cache; + +import androidx.annotation.NonNull; + +/** + * 缓存条目:值 + 写入时间 + 双 TTL。 + *

+ * Stale-While-Revalidate 语义: + *

    + *
  • age <= softTtl → FRESH:直接使用,无需请求网络
  • + *
  • softTtl < age <= hardTtl → STALE:先返回缓存,同时后台重新验证(revalidate)
  • + *
  • age > hardTtl → EXPIRED:视为未命中,必须等网络
  • + *
+ * + * @param 缓存值类型 + */ +public final class CacheEntry { + + 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; + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java b/app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java new file mode 100644 index 0000000..367500e --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java @@ -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)。 + *

+ * 读取顺序:内存 → 磁盘(命中磁盘时回填内存); + * 写入顺序:同时写内存与磁盘; + * 供 {@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 { + @NonNull + public final CacheEntry entry; + /** + * true = 命中内存,false = 命中磁盘 + */ + public final boolean fromMemory; + + Hit(@NonNull CacheEntry entry, boolean fromMemory) { + this.entry = entry; + this.fromMemory = fromMemory; + } + } + + /** + * 依次查内存、磁盘。磁盘命中会自动回填内存(下次直接内存命中)。 + * 硬过期条目在各级缓存内部已被剔除,此处返回的一定是 FRESH 或 STALE。 + */ + @Nullable + @WorkerThread + public Hit get(@NonNull String key, @NonNull Type type) { + CacheEntry memory = mMemoryCache.get(key); + if (memory != null) { + return new Hit<>(memory, true); + } + CacheEntry disk = mDiskCache.get(key, type); + if (disk != null) { + mMemoryCache.put(key, disk); // 回填 L1 + return new Hit<>(disk, false); + } + return null; + } + + /** + * 双写:内存 + 磁盘 + */ + @WorkerThread + public void put(@NonNull String key, @NonNull T value, long softTtlMillis, long hardTtlMillis) { + CacheEntry 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(); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java b/app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java new file mode 100644 index 0000000..9ede797 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java @@ -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 冷启动后仍可秒出数据。 + *

+ * 存储格式(JSON): + *

+ * { "saveTime": 1710000000000, "softTtl": 60000, "hardTtl": 86400000, "value": {...} }
+ * 
+ * 写入使用「临时文件 + 原子重命名」防止写一半损坏。 + * 所有方法必须在工作线程调用。 + */ +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 CacheEntry 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 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 void put(@NonNull String key, @NonNull CacheEntry 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()); + } + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/cache/MemoryCache.java b/app/src/main/java/com/ttstd/dialer/data/cache/MemoryCache.java new file mode 100644 index 0000000..ae3d265 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/cache/MemoryCache.java @@ -0,0 +1,55 @@ +package com.ttstd.dialer.data.cache; + +import android.util.LruCache; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * L1 内存缓存:基于 LruCache,进程内共享,读写为纳秒级。 + *

线程安全(LruCache 内部已同步)。

+ */ +public final class MemoryCache { + + /** + * 默认最多缓存 64 个接口结果条目 + */ + private static final int DEFAULT_MAX_ENTRIES = 64; + + private final LruCache> mLruCache; + + public MemoryCache() { + this(DEFAULT_MAX_ENTRIES); + } + + public MemoryCache(int maxEntries) { + mLruCache = new LruCache<>(maxEntries); + } + + @Nullable + @SuppressWarnings("unchecked") + public CacheEntry get(@NonNull String key) { + CacheEntry entry = mLruCache.get(key); + if (entry == null) { + return null; + } + // 硬过期直接剔除 + if (entry.isExpired()) { + mLruCache.remove(key); + return null; + } + return (CacheEntry) entry; + } + + public void put(@NonNull String key, @NonNull CacheEntry entry) { + mLruCache.put(key, entry); + } + + public void remove(@NonNull String key) { + mLruCache.remove(key); + } + + public void clear() { + mLruCache.evictAll(); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/remote/ApiResponseMapper.java b/app/src/main/java/com/ttstd/dialer/data/remote/ApiResponseMapper.java new file mode 100644 index 0000000..b35a73b --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/remote/ApiResponseMapper.java @@ -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 统一解包工具: + *
    + *
  • 业务成功 → 发射 data
  • + *
  • 业务失败 → 抛 {@link ApiException} 走 onError 通道,Repository 统一处理
  • + *
+ */ +public final class ApiResponseMapper { + + private ApiResponseMapper() { + } + + /** + * 剥离 BaseResponse,返回业务数据(data 不允许为 null) + */ + @NonNull + public static Observable unwrap(@NonNull Observable> 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 Observable unwrapAsSuccess(@NonNull Observable> upstream) { + return upstream.map(response -> { + if (!response.isSuccess()) { + throw new ApiException(response.getCode(), response.getMsg()); + } + return true; + }); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/remote/ContactRemoteDataSource.java b/app/src/main/java/com/ttstd/dialer/data/remote/ContactRemoteDataSource.java new file mode 100644 index 0000000..2e74205 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/remote/ContactRemoteDataSource.java @@ -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> getContactList() { + return ApiResponseMapper.unwrap(mApi.getContactList()); + } + + @NonNull + public Observable getContactForm(long id) { + return ApiResponseMapper.unwrap(mApi.getContactForm(id)); + } + + @NonNull + public Observable insertContact(@NonNull ContactInfo contactInfo) { + return ApiResponseMapper.unwrap( + mApi.insertContact(OkHttpManager.convertToRequestBodyjson(contactInfo))); + } + + @NonNull + public Observable updateContact(long id, @NonNull ContactInfo contactInfo) { + return ApiResponseMapper.unwrapAsSuccess( + mApi.updateContact(id, OkHttpManager.convertToRequestBodyjson(contactInfo))); + } + + @NonNull + public Observable deleteContact(long id) { + return ApiResponseMapper.unwrapAsSuccess(mApi.deleteContact(id)); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/remote/SnRemoteDataSource.java b/app/src/main/java/com/ttstd/dialer/data/remote/SnRemoteDataSource.java new file mode 100644 index 0000000..a6d4b49 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/remote/SnRemoteDataSource.java @@ -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 getDeveloperOptions() { + return ApiResponseMapper.unwrap(mApi.getDeveloperOptions()); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/repository/ContactDataRepository.java b/app/src/main/java/com/ttstd/dialer/data/repository/ContactDataRepository.java new file mode 100644 index 0000000..f0badcd --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/repository/ContactDataRepository.java @@ -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)。 + *

+ * 职责(Google 架构指南): + *

    + *
  • 对 UI 层(ViewModel)暴露 Observable<Resource<T>>,屏蔽数据来源细节
  • + *
  • 读:L1 内存缓存 → L2 磁盘缓存 → 网络,Stale-While-Revalidate
  • + *
  • 写:远程成功后失效缓存并同步 Room;网络不可用时降级本地保存
  • + *
+ * 线程约定:所有返回的 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>() { + }.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 ==================== + + /** + * 获取联系人列表。 + *

命中新鲜缓存 → 秒出且不请求网络;命中过期缓存 → 先出缓存再后台刷新(SWR); + * 未命中 → Loading + 网络;网络失败 → 降级 Room 本地数据。

+ * + * @param forceRefresh true 强制 revalidate(下拉刷新场景) + */ + @NonNull + public Observable>> getContactList(boolean forceRefresh) { + return new NetworkBoundResource>(mCacheManager, forceRefresh) { + @NonNull + @Override + protected String cacheKey() { + return KEY_CONTACT_LIST; + } + + @NonNull + @Override + protected Type cacheType() { + return CONTACT_LIST_TYPE; + } + + @NonNull + @Override + protected Observable> createNetworkObservable() { + return mRemoteDataSource.getContactList(); + } + + @Override + protected void saveNetworkResult(@NonNull List data) { + // 网络数据同步进 Room(单一事实来源的持久化备份) + syncContactsToLocal(data); + } + + @NonNull + @Override + protected List processResult(@NonNull List 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 local = sortByPosition(mLocalDataSource.getAllContacts()); + if (!local.isEmpty()) { + return Resource.error(resource.message, local); + } + } + return resource; + }); + } + + /** + * 获取单个联系人表单(按 id 独立缓存) + */ + @NonNull + public Observable> getContactForm(long id, boolean forceRefresh) { + return new NetworkBoundResource(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 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 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 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 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 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 updateLocalContacts(@NonNull List contactInfos) { + return Observable.fromCallable(() -> { + mLocalDataSource.update(contactInfos); + invalidateListCache(); + return true; + }) + .subscribeOn(Schedulers.io()); + } + + /** + * 更新单个本地联系人 + */ + @NonNull + public Observable updateLocalContact(@NonNull ContactInfo contactInfo) { + return Observable.fromCallable(() -> { + int count = mLocalDataSource.update(contactInfo); + invalidateListCache(); + return count; + }) + .subscribeOn(Schedulers.io()); + } + + /** + * 读取本地全部联系人(Room) + */ + @NonNull + public Observable> getLocalContacts() { + return Observable.fromCallable(() -> sortByPosition(mLocalDataSource.getAllContacts())) + .subscribeOn(Schedulers.io()); + } + + // ==================== 内部工具 ==================== + + private void invalidateListCache() { + mCacheManager.invalidate(KEY_CONTACT_LIST); + } + + @NonNull + private static List sortByPosition(@NonNull List list) { + return list.stream() + .sorted(Comparator.comparingInt(ContactInfo::getPosition)) + .collect(Collectors.toList()); + } + + /** + * 网络数据与 Room 做差量同步(新增/更新/删除)。 + */ + @WorkerThread + private void syncContactsToLocal(@NonNull List networkContacts) { + try { + List localContacts = mLocalDataSource.getAllContacts(); + + Map networkMap = new HashMap<>(); + for (ContactInfo c : networkContacts) { + networkMap.put(c.getId(), c); + } + Map 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()); + } + } +} diff --git a/app/src/main/java/com/ttstd/dialer/data/repository/SnRepository.java b/app/src/main/java/com/ttstd/dialer/data/repository/SnRepository.java new file mode 100644 index 0000000..0b69d23 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/data/repository/SnRepository.java @@ -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> getDeveloperOptions(boolean forceRefresh) { + return new NetworkBoundResource(mCacheManager, forceRefresh) { + @NonNull + @Override + protected String cacheKey() { + return KEY_DEVELOPER_OPTIONS; + } + + @NonNull + @Override + protected Type cacheType() { + return DeveloperOptions.class; + } + + @NonNull + @Override + protected Observable createNetworkObservable() { + return mRemoteDataSource.getDeveloperOptions(); + } + + @Override + protected long softTtlMillis() { + return SOFT_TTL; + } + + @Override + protected long hardTtlMillis() { + return HARD_TTL; + } + }.asObservable(); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/fragment/contact/ContactViewModel.java b/app/src/main/java/com/ttstd/dialer/fragment/contact/ContactViewModel.java index 7537909..25dcc36 100644 --- a/app/src/main/java/com/ttstd/dialer/fragment/contact/ContactViewModel.java +++ b/app/src/main/java/com/ttstd/dialer/fragment/contact/ContactViewModel.java @@ -4,94 +4,54 @@ import android.content.Context; import androidx.lifecycle.MutableLiveData; -import com.trello.rxlifecycle4.RxLifecycle; import com.trello.rxlifecycle4.android.FragmentEvent; import com.ttstd.dialer.base.mvvm.BaseViewModel; +import com.ttstd.dialer.data.repository.ContactDataRepository; import com.ttstd.dialer.databinding.FragmentContactBinding; import com.ttstd.dialer.db.contact.ContactInfo; -import com.ttstd.dialer.db.contact.ContactRepository; import com.ttstd.dialer.utils.Logger; import java.util.List; -import java.util.concurrent.Callable; 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 { - private ContactRepository mRepository; + private static final String TAG = "ContactViewModel"; + + private ContactDataRepository mRepository; private boolean isDataLoaded = false; - public void preloadData() { - if (isDataLoaded) return; - - getAllContactsObservable() - .subscribe(new Consumer>() { - @Override - public void accept(List contactInfos) throws Throwable { - mContactListData.postValue(contactInfos); - isDataLoaded = true; - } - }); - } + public MutableLiveData> mContactListData = new MutableLiveData<>(); @Override public void setContext(Context context) { super.setContext(context); - mRepository = new ContactRepository(context); + mRepository = ContactDataRepository.getInstance(context); } - public MutableLiveData> mContactListData = new MutableLiveData<>(); - - public Observable> getAllContactsObservable() { - return Observable.fromCallable(new Callable>() { - @Override - public List call() throws Exception { - List contactInfos = mRepository.getAllContacts(); - return contactInfos; - } - }).compose(RxLifecycle.bindUntilEvent(getLifecycle(), FragmentEvent.STOP)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()); + public void preloadData() { + if (isDataLoaded) return; + getAllContacts(); } + /** + * 获取联系人:命中新鲜缓存直接秒出;缓存过期先出旧数据再后台刷新; + * 网络失败自动降级缓存/Room 本地数据。 + */ public void getAllContacts() { - getAllContactsObservable() - .subscribe(new Observer>() { - @Override - public void onSubscribe(@NonNull Disposable d) { - Logger.e("getAllContacts", "onSubscribe: "); + addDisposable(mRepository.getContactList(false) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(resource -> { + Logger.d(TAG, "getAllContacts: " + resource.status + + ", source=" + resource.source + ", stale=" + resource.stale); + if (resource.data != null) { + mContactListData.setValue(resource.data); + isDataLoaded = true; } - - @Override - public void onNext(@NonNull List contactInfos) { - Logger.e("getAllContacts", "onNext: "); - mContactListData.setValue(contactInfos); - -// List sorted = contacts.stream().sorted(new Comparator() { -// @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: "); - } - }); + }, throwable -> Logger.e(TAG, "getAllContacts error: " + throwable.getMessage()))); } - } diff --git a/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java b/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java index 6da1e8f..4510f67 100644 --- a/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java +++ b/app/src/main/java/com/ttstd/dialer/network/OkHttpManager.java @@ -18,8 +18,8 @@ import com.ttstd.dialer.mdm.DeviceManagerService; import com.ttstd.dialer.network.api.ContactApi; import com.ttstd.dialer.network.api.SnApi; import com.ttstd.dialer.network.interceptor.AuthInterceptor; -import com.ttstd.iconloader.utils.FileUtils; import com.ttstd.dialer.utils.Logger; +import com.ttstd.iconloader.utils.FileUtils; import org.jetbrains.annotations.NotNull; @@ -364,7 +364,7 @@ public class OkHttpManager { return schedule(getApiService(ContactApi.class).updateContact(id, convertToRequestBodyjson(contactInfo)), provider); } - public Observable getContactDeleteObservable(long id, BehaviorSubject provider) { + public Observable> getContactDeleteObservable(long id, BehaviorSubject provider) { return schedule(getApiService(ContactApi.class).deleteContact(id), provider); } diff --git a/app/src/main/java/com/ttstd/dialer/network/api/ContactApi.java b/app/src/main/java/com/ttstd/dialer/network/api/ContactApi.java index 2a20d3d..cf61a45 100644 --- a/app/src/main/java/com/ttstd/dialer/network/api/ContactApi.java +++ b/app/src/main/java/com/ttstd/dialer/network/api/ContactApi.java @@ -37,7 +37,7 @@ public interface ContactApi { ); @DELETE(UrlConstants.CONTACT_DELETE) - Observable deleteContact( + Observable> deleteContact( @Query("id") Long id ); diff --git a/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java b/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java index 3af55b3..6163bde 100644 --- a/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java +++ b/app/src/main/java/com/ttstd/dialer/push/PushExecutor.java @@ -14,15 +14,16 @@ import com.ttstd.dialer.bean.DeveloperOptions; import com.ttstd.dialer.bean.PushMessage; import com.ttstd.dialer.bean.req.SnHardwareInfoReq; import com.ttstd.dialer.config.CommonConfig; +import com.ttstd.dialer.data.repository.SnRepository; import com.ttstd.dialer.manager.AppManager; import com.ttstd.dialer.manager.MapManager; import com.ttstd.dialer.mdm.DeviceManagerService; import com.ttstd.dialer.network.BaseObserver; import com.ttstd.dialer.network.OkHttpManager; import com.ttstd.dialer.utils.CmdUtil; -import com.ttstd.iconloader.utils.FileUtils; import com.ttstd.dialer.utils.Logger; import com.ttstd.dialer.utils.RebootUtils; +import com.ttstd.iconloader.utils.FileUtils; import java.io.File; import java.util.List; @@ -120,35 +121,20 @@ public class PushExecutor { } private void getDeviceSettings() { - OkHttpManager.getInstance().getDeveloperOptionsObservable() - .subscribe(new Observer>() { - @Override - public void onSubscribe(@NonNull Disposable d) { - Logger.e("getDeveloperOptions", "onSubscribe: "); - } - - @Override - public void onNext(@NonNull BaseResponse baseResponse) { - Logger.e("getDeveloperOptions", "onNext: " + baseResponse); - if (baseResponse.isSuccess()) { - DeveloperOptions developerOptions = baseResponse.getData(); - if (!BuildConfig.DEBUG) { - DeviceManagerService.getInstance().setDevelopmentOption(developerOptions.getDeveloperOptions() == 1); - DeviceManagerService.getInstance().setUsbDebugMode(developerOptions.getDeveloperOptions() == 1); - } + // 走 Repository(多级缓存 + SWR);推送触发说明服务端配置已变更,强制 revalidate + SnRepository.getInstance(mContext).getDeveloperOptions(true) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(resource -> { + Logger.e("getDeveloperOptions", "resource: " + resource); + // 仅在拿到网络最新数据(非 stale 缓存)时应用配置 + if (resource.isSuccess() && !resource.stale && resource.data != null) { + DeveloperOptions developerOptions = resource.data; + if (!BuildConfig.DEBUG) { + DeviceManagerService.getInstance().setDevelopmentOption(developerOptions.getDeveloperOptions() == 1); + DeviceManagerService.getInstance().setUsbDebugMode(developerOptions.getDeveloperOptions() == 1); } } - - @Override - public void onError(@NonNull Throwable e) { - Logger.e("getDeveloperOptions", "onError: " + e.getMessage()); - } - - @Override - public void onComplete() { - Logger.e("getDeveloperOptions", "onComplete: "); - } - }); + }, throwable -> Logger.e("getDeveloperOptions", "onError: " + throwable.getMessage())); } private void uploadDeviceInfo() {