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;
}