Cleaner 应用工程及架构设计
目录
Cleaner 是一个新开发的工具类应用,主要集成了内存、通知、应用、大文件、冗余文件、相册清理等功能。同时提供对外的数据提供者接口和 Deeplink
入口。
功能模块
根据功能最终将工程设计为四个模块开发,功能模块之间互相独立且没有耦合。这四个模块独立生成 aar
库文件被主应用引用,主应用只提供入口而不提供任何清理功能。
工程结构如下图
工程主要分为四个子模块:storage
notification
provider
和 utils
。
模块 | 功能 |
---|---|
storage | 实现文件类清理、内存清理 |
notification | 实现通知清理、通知白名单及设置 |
provider | 数据依赖 storage 和 notification,实现 deeplink 和对外数据接口 |
utils | 基础公共方法和基础常量 |
app | launcher 应用入口,不提供任何功能 |
example | provider 数据客户端示例 |
代码架构
功能模块采用 MVP
架构。用户交互接口如 Activity
Service
作为 View
层抽象,逻辑及数据操作全部放在 Presenter
中处理,数据操作封装在 Model
层的 DataSource
中。每一类的数据源都是一个独立不相关的 DataSource
,如数据库,应用列表,存储文件都放在不同的 DataSource
中。
下文以通知清理模块为例介绍代码架构。
通知清理 MVP 架构
通知清理工程目录
MVP 接口
通知主界面 InboxGroupActivity
的 MVP
接口如下
public interface NotificationContract {
interface View {
void loadData(List<Notification> notifications);
void notifyWhitelistChanged();
void notifyDataExported();
void notifyDataImported();
void notifyCleaned(int size);
}
interface Presenter {
void takeView(View view);
void loadHistoryNotifications();
void start();
void cleanAll();
void filter(String packageName);
void exportHistory();
void importHistory();
}
}
通知监听服务 NotificationListenerService
的 MVP
接口如下
public interface ListenerContract {
interface View {
void recheckActiveNotifications();
void dispatchIntent(Notification notification);
void removeNotification(String key);
void showMissedNotification(List<Notification> notifications);
}
interface Presenter {
void start();
void takeView(View view);
void addNotification(Notification notification);
void calculateMissedNotifications();
void performNotificationAction(String key);
}
}
Presenter 数据依赖注入
DataSource
作为数据源依赖,需要在实例化时注入到 Presenter
中,工程中没有使用 Dagger
,所以需要在new Presenter
时手动注入依赖。注入依赖的代码片段如下:
// InboxGroupActivity Presenter 的数据依赖注入
EntityDataStore<Persistable> dataStore = DataRepository.get().provideDatabaseSource(
getApplicationContext());
DataRepository.get().init(dataStore);
AppStore.get().init(this);
AppDataSource appDataSource = new AppDataRepository(AppStore.get(), getPackageManager(),
dataStore);
mPresenter = new NotificationPresenter(DataRepository.get(), appDataSource,
AppStore.get());
mPresenter.takeView(this);
// NotificationListenerService Presenter 的数据依赖注入
EntityDataStore<Persistable> dataStore = DataRepository.get().provideDatabaseSource(
getApplicationContext());
DataRepository.get().init(dataStore);
Setting setting = new SettingImpl(DataRepository.get());
AppStore.get().init(this);
AppStore appStore = AppStore.get();
mPresenter = new ListenerPresenter(DataRepository.get(), setting, appStore);
// SettingsActivity Presenter 的数据依赖注入
AppStore.get().init(this)
mAppStore = AppStore.get()
val dataStore = DataRepository.get().provideDatabaseSource(this);
DataRepository.get().init(dataStore)
val mAppDataSource = AppDataRepository(mAppStore, packageManager, dataStore)
val setting = SettingImpl(DataRepository.get())
mPresenter = SettingsPresenter(mAppDataSource, setting)
mPresenter.takeView(this)
单元测试
通知清理模块包含完整的单元测试,同时也集成了 UI
测试。和白名单设置界面一样,为了熟练 kotlin
语言并节省代码,测试使用了 kotlin
编写。
通知监听服务的单元测试代码
class ListenerPresenterTest {
lateinit var mListenerPresenter: ListenerPresenter
val mDataSource = mock<DataSource>()
val mSetting = mock<Setting>()
val mAppStore = mock<AppStore>()
val mView = mock<ListenerContract.View>()
@Before
fun setup() {
doReturn(Single.just(true)).`when`(mSetting).isFilterEnabled
doReturn(Single.just(true)).`when`(mDataSource)
.isNotificationWhitelist("com.example.test")
doReturn(Single.just(true)).`when`(mDataSource)
.saveNotification(any())
doReturn(Single.just(ArrayList<Notification>())).`when`(mDataSource)
.notifications
doReturn(Completable.complete()).`when`(mAppStore)
.cache(any<List<Notification>>())
mListenerPresenter = ListenerPresenter(mDataSource, mSetting, mAppStore)
mListenerPresenter.takeView(mView)
}
@Test
fun start() {
mListenerPresenter.start()
verify(mSetting).isFilterEnabled
verify(mView).recheckActiveNotifications()
}
@Test
fun addNotification() {
val notification = Notification()
notification.packageName = "com.example.test"
mListenerPresenter.addNotification(notification)
verify(mDataSource).saveNotification(eq(notification))
}
@Test
fun calculateMissedNotifications() {
mListenerPresenter.calculateMissedNotifications()
verify(mDataSource).notifications
verify(mView).showMissedNotification(any())
}
@Test
fun performNotificationAction() {
val notification = Notification()
doReturn(Single.just(notification)).`when`(mDataSource)
.getNotification("com.example.test")
mListenerPresenter.performNotificationAction("com.example.test")
verify(mView).dispatchIntent(eq(notification))
}
}
DeepLink 入口
为了便于第三方应用拉起,将主要界面都做了 DeepLink
支持,实现放在 provider
模块。所有的界面注册都统一放在 Router
中,同时支持带来源和任意参数拉起。
例如可以使用 com.qiku.android.cleaner:///storage?refer=com.example.app&customData=3
拉起主界面。
可以通过手机浏览器打开 https://cleaner01.netlify.com 测试各个 DeepLink
拉起。
<activity
android:name=".ui.DeepLinkActivity"
android:exported="true"
android:noHistory="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<data android:scheme="com.qiku.android.cleaner" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</activity>
public class DeepLinkActivity extends Activity {
private static final String TAG = DeepLinkActivity.class.getSimpleName();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_deep_link);
Intent intent = getIntent();
if (intent != null && intent.getData() != null) {
Router router = Router.get();
router.open(this, intent.getData());
}
finish();
}
}
public final class Router {
private static final String TAG = Router.class.getSimpleName();
private static final String SCHEME = Constants.DEEP_LINK_SCHEME;
private static final String HOST = Constants.DEEP_LINK_HOST;
private static final Map<String, Class> PATHS = new HashMap<>();
private Router() {
register("/storage", CleanerActivity.class);
register("/storage/app", AppClearActivity.class);
register("/storage/apk", ApkClearActivity.class);
register("/storage/file", LargeFilesActivity.class);
register("/storage/redundancy", RedundancyActivity.class);
register("/storage/photo", PhotoSimilarEntryActivity.class);
register("/storage/memory", CleanerMemoryActivity.class);
register("/storage/finish", ClearFinishActivity.class);
register("/notification", InboxGroupActivity.class);
register("/notification/history", InboxActivity.class);
register("/notification/prompt", PromptActivity.class);
register("/notification/whitelist", SettingsActivity.class);
}
private void register(String path, Class activity) {
PATHS.put(path, activity);
}
public void open(Context context, Uri uri) {
Log.d(TAG, "open: " + uri);
String scheme = uri.getScheme();
String host = uri.getHost();
String path = uri.getPath();
if (scheme == null || host == null || path == null ||
!scheme.equals(SCHEME) || !host.equals(HOST) || !PATHS.containsKey(path)) {
Log.e(TAG, "illegal uri");
return;
}
Intent intent = new Intent(context, PATHS.get(path));
for (String name : uri.getQueryParameterNames()) {
intent.putExtra(name, uri.getQueryParameter(name));
}
context.startActivity(intent);
Log.d(TAG, "start activity: " + intent.toString());
if (intent.getExtras() != null) {
Log.d(TAG, "extras: " + intent.getExtras());
}
}
public static Router get() {
return SingletonHelper.sINSTANCE;
}
private static final class SingletonHelper {
private static final Router sINSTANCE = new Router();
}
}
CleanerProvider 数据提供者
为了第三方应用快速查询清理状态如存储空间,内存占用等信息,cleaner 应用对当前的数据状态做了封装,作为 provider
提供数据。
CleanEvent 数据结构
private static final class CleanEvent {
static final String URL = "content://com.qiku.android.cleaner/events";
static final Uri CONTENT_URI = Uri.parse(URL);
int id;
@EventType
int type; // 功能模块类型
String data; // 提示语数字大小
float weight;
String name; // 功能模块类型描述
String text; // 按钮文字
String prompt; // 提示语
String appName; // 类型为 应用 时的应用名
String link; // 调用页面
String promptText; // 提示语
String value; // 数值
String unit; // 单位
int version; // 版本
String extra;
int fileCount = 0; // 文件个数
long totalLength = 0; // 所有文件大小
@Retention(RetentionPolicy.SOURCE)
@IntDef({STORAGE, MEMORY, LARGE_FILE, APPLICATION, APK, REDUNDANCY, PHOTO, NOTIFICATION})
public @interface EventType {
}
public static final int ERROR = -1;
public static final int STORAGE = 0;
public static final int MEMORY = 1;
public static final int LARGE_FILE = 2;
public static final int APPLICATION = 3;
public static final int APK = 4;
public static final int REDUNDANCY = 5;
public static final int PHOTO = 6;
public static final int NOTIFICATION = 7;
@Override
public String toString() {
return "CleanEvent{" +
"id=" + id +
", type=" + type +
", data='" + data + '\'' +
", weight=" + weight +
", name='" + name + '\'' +
", text='" + text + '\'' +
", prompt='" + prompt + '\'' +
", appName='" + appName + '\'' +
", link='" + link + '\'' +
", promptText='" + promptText + '\'' +
", value='" + value + '\'' +
", unit='" + unit + '\'' +
", version=" + version +
", extra='" + extra + '\'' +
", fileCount=" + fileCount +
", totalLength=" + totalLength +
'}';
}
}
客户端查询数据代码示例
private List<CleanEvent> getCleanEvents(Context context) {
List<CleanEvent> cleanEventList = new ArrayList<>();
Cursor cursor = context.getContentResolver().query(CleanEvent.CONTENT_URI, null, null, null,
null);
if (cursor != null) {
if (!cursor.moveToFirst()) {
Log.e(TAG, "no data");
} else {
do {
CleanEvent event = new CleanEvent();
event.type = cursor.getInt(cursor.getColumnIndex("type"));
event.data = cursor.getString(cursor.getColumnIndex("data"));
event.weight = cursor.getFloat(cursor.getColumnIndex("weight"));
event.name = cursor.getString(cursor.getColumnIndex("name"));
event.text = cursor.getString(cursor.getColumnIndex("text"));
event.prompt = cursor.getString(cursor.getColumnIndex("prompt"));
event.appName = cursor.getString(cursor.getColumnIndex("appName"));
event.link = cursor.getString(cursor.getColumnIndex("link"));
event.promptText = cursor.getString(cursor.getColumnIndex("promptText"));
event.value = cursor.getString(cursor.getColumnIndex("value"));
event.unit = cursor.getString(cursor.getColumnIndex("unit"));
event.version = cursor.getInt(cursor.getColumnIndex("version"));
cleanEventList.add(event);
} while (cursor.moveToNext());
}
cursor.close();
}
return cleanEventList;
}