Cleaner 应用工程及架构设计

目录

Cleaner 是一个新开发的工具类应用,主要集成了内存、通知、应用、大文件、冗余文件、相册清理等功能。同时提供对外的数据提供者接口和 Deeplink 入口。

功能模块

根据功能最终将工程设计为四个模块开发,功能模块之间互相独立且没有耦合。这四个模块独立生成 aar 库文件被主应用引用,主应用只提供入口而不提供任何清理功能。

模块依赖关系

工程结构如下图

工程结构

工程主要分为四个子模块:storage notification providerutils

模块功能
storage实现文件类清理、内存清理
notification实现通知清理、通知白名单及设置
provider数据依赖 storage 和 notification,实现 deeplink 和对外数据接口
utils基础公共方法和基础常量
applauncher 应用入口,不提供任何功能
exampleprovider 数据客户端示例

代码架构

功能模块采用 MVP 架构。用户交互接口如 Activity Service 作为 View 层抽象,逻辑及数据操作全部放在 Presenter 中处理,数据操作封装在 Model 层的 DataSource 中。每一类的数据源都是一个独立不相关的 DataSource,如数据库,应用列表,存储文件都放在不同的 DataSource 中。

下文以通知清理模块为例介绍代码架构。

通知清理 MVP 架构

通知清理 MVP 架构

通知清理工程目录

通知清理工程目录

MVP 接口

通知主界面 InboxGroupActivityMVP 接口如下

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

通知监听服务 NotificationListenerServiceMVP 接口如下

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 支持,实现放在 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;
}