[Android]_[中级]_[如何创建MVVM架构原型]

场景

  1. 最近需要开发一个Android数据传输的App,所以去重新找了下官方的开发者文档学习。目前谷歌的安卓开发者文档网站已经去掉了Java语言的示例,只有KotlinJetpack Compose; 同时相关的Java版本的View,DataBinding包已经进入了维护阶段,即不添加新特性,只进行安全更新;

  2. 我的原则是目前所学语言能解决问题的话就不去学新的语言,而Java实际上还是可以开发界面的,只是用不了即时预览的声明式UI而已。 那么如果尽量不用过时的库,写一个最小的MVVM架构来开发App如何做呢?

说明

  1. 实际上,目前Jetpack compose的声明式UI框架用的也是MVVM架构,所以Java版本的MVVM架构并不过时; 如果能良好的组织好代码,依赖解耦,模块化开发,那么任一种架构都可以。

  2. 简单说下MVVM架构:

    • M: Model - 业务实体,和后端通讯获取数据的业务逻辑部分; 可以是任意的普通类;

    • V: View - 页面视图,负责页面展现; 一般是View和它的子类,Fragment;

    • VM: ViewModel - 视图模型,负责模型和视图之间的通讯,模型和视图的数据同步。比如从模型里获取到的列表,更新视图模型里的变量,这个变量变化会触发视图的更新;它需要继承ViewModel类;

  3. 官方的ViewModel是在androidx.lifecycle.*包里,这个包在Jetpack里也会用到; 除了负责管理Model之外,ViewModel也用来在配置变更(如屏幕旋转)时存活,从而保存和恢复UI相关的数据状态。

  4. ViewModel和数据类型LiveData也不是必须的,可以自定义实现替换官方的类; 开发界面必须的就是android.view.View和它的子类。

  5. MVVM架构总的来说是这样的:

    • 通讯方式双向: View <-> ViewModel <-> Model

    • 依赖方式单向: View -> ViewModel -> Model

例子

  1. 这里写了一个登录例子,界面View传递登录账号和密码给ViewModel,ViewModel调用Model模拟网络请求登录并返回登录结果。例子没有使用xml来创建界面,只使用了纯Java代码创建。

  2. 当输入错误的账号和密码时,登录会显示用户名密码错误并在ViewModel里修改为正确的用户名密码,ViewModel的数据修改会反应到View上,当再次点击登录按钮会显示登录成功

  3. 界面使用单页面模式,只有一个Activity, 通过替换局部的区域为Fragment来显示不同的内容。

LoginInputFragment

  1. 这个类负责显示登录输入框和按钮,并包含了一个SharedLoginViewModel类型的成员变量。这个成员变量创建的方式是通过以下单例模式创建:
java 复制代码
viewModel = new ViewModelProvider(requireActivity()).get(SharedLoginViewModel.class);
  1. 实现了一个MutableLiveData的子类FlexibleLiveData来绑定ViewModel避免双向绑定失控的问题: ViewMoel的修改通知了View,而View的修改又通知了ViewModel 从而陷入死循环; 目的是在View修改时先移除对ViewModel的修改监听; 而对View的修改也保存了TextWatcher的监听对象,在修改ViewModel时先移除View的监听。
java 复制代码
// 观察用户名变化,同步到输入框
viewModel.getUsername().listen(getViewLifecycleOwner(), new Observer<String>() {
   @Override
   public void onChanged(String username) {
       if (etUsername != null) {
           String currentText = etUsername.getText().toString();
           if (!username.equals(currentText)) {
               etUsername.removeTextChangedListener(userWatcher);
               etUsername.setText(username);
               etUsername.addTextChangedListener(userWatcher);
           }
       }
   }
});
java 复制代码
// 用户名输入监听
userWatcher = new TextWatcher() {
   @Override
   public void beforeTextChanged(CharSequence s, int start, int count, int after) {
   }

   @Override
   public void onTextChanged(CharSequence s, int start, int before, int count) {
   }

   @Override
   public void afterTextChanged(Editable s) {
       viewModel.getUsername().pauseListener();
       viewModel.setUsername(s.toString());
       viewModel.getUsername().resumeListener(getViewLifecycleOwner());
   }
};

etUsername.addTextChangedListener(userWatcher);
  1. 这里是完整的LoginInputFragment代码;
java 复制代码
package com.test;

// LoginInputFragment.java

import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;

public class LoginInputFragment extends Fragment {

    private SharedLoginViewModel viewModel;
    private EditText etUsername, etPassword;

    private TextWatcher userWatcher,passwordWatcher;
    private Button btnLogin;

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 注意:使用 requireActivity() 来获取共享的 ViewModel
        viewModel = new ViewModelProvider(requireActivity()).get(SharedLoginViewModel.class);
    }

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        // 纯代码创建布局
        LinearLayout root = new LinearLayout(getContext());
        root.setOrientation(LinearLayout.VERTICAL);
        root.setPadding(50, 30, 50, 30);

        // 标题
        TextView tvTitle = new TextView(getContext());
        tvTitle.setText("登录信息");
        tvTitle.setTextSize(20);

        // 用户名输入
        TextView tvUserLabel = new TextView(getContext());
        tvUserLabel.setText("用户名:");
        etUsername = new EditText(getContext());
        etUsername.setHint("请输入用户名");

        // 密码输入
        TextView tvPassLabel = new TextView(getContext());
        tvPassLabel.setText("密码:");
        etPassword = new EditText(getContext());
        etPassword.setHint("请输入密码");
        etPassword.setInputType(android.text.InputType.TYPE_CLASS_TEXT |
                android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD);

        // 登录按钮
        btnLogin = new Button(getContext());
        btnLogin.setText("登录");

        // 当前状态提示
        TextView tvStatusHint = new TextView(getContext());
        tvStatusHint.setText("状态: 输入用户名和密码后点击登录");
        tvStatusHint.setTextSize(12);

        // 添加到布局
        root.addView(tvTitle);
        root.addView(tvUserLabel);
        root.addView(etUsername);
        root.addView(tvPassLabel);
        root.addView(etPassword);
        root.addView(btnLogin);
        root.addView(tvStatusHint);

        return root;
    }

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        setupViewModelObservers();
        setupViewListeners();
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();

        etPassword.removeTextChangedListener(passwordWatcher);
        etUsername.removeTextChangedListener(userWatcher);
    }

    private void setupViewModelObservers() {
        // 观察用户名变化,同步到输入框
        viewModel.getUsername().listen(getViewLifecycleOwner(), new Observer<String>() {
            @Override
            public void onChanged(String username) {
                if (etUsername != null) {
                    String currentText = etUsername.getText().toString();
                    if (!username.equals(currentText)) {
                        etUsername.removeTextChangedListener(userWatcher);
                        etUsername.setText(username);
                        etUsername.addTextChangedListener(userWatcher);
                    }
                }
            }
        });

        // 观察密码变化,同步到输入框
        viewModel.getPassword().listen(getViewLifecycleOwner(), new Observer<String>() {
            @Override
            public void onChanged(String password) {
                if (etPassword != null) {
                    String currentText = etPassword.getText().toString();
                    if (!password.equals(currentText)) {
                        etPassword.removeTextChangedListener(userWatcher);
                        etPassword.setText(password);
                        etPassword.addTextChangedListener(userWatcher);
                    }
                }
            }
        });

        // 观察登录触发
        viewModel.getLoginTrigger().listen(getViewLifecycleOwner(), new Observer<Boolean>() {
            @Override
            public void onChanged(Boolean shouldLogin) {
                if (shouldLogin != null && shouldLogin) {
                    // 重置触发标志
                    viewModel.getLoginTrigger().setValue(false);
                    // 开始登录流程
                    viewModel.startLoginProcess();
                }
            }
        });

        // 观察加载状态,禁用/启用按钮
        viewModel.getIsLoading().listen(getViewLifecycleOwner(), new Observer<Boolean>() {
            @Override
            public void onChanged(Boolean loading) {
                if (btnLogin != null) {
                    btnLogin.setEnabled(!loading);
                    btnLogin.setText(loading ? "登录中..." : "登录");
                }
            }
        });
    }

    private void setupViewListeners() {
        // 用户名输入监听
        userWatcher = new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
            }

            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {
            }

            @Override
            public void afterTextChanged(Editable s) {
                viewModel.getUsername().pauseListener();
                viewModel.setUsername(s.toString());
                viewModel.getUsername().resumeListener(getViewLifecycleOwner());
            }
        };

        etUsername.addTextChangedListener(userWatcher);

        // 密码输入监听
        passwordWatcher = new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {}

            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {}

            @Override
            public void afterTextChanged(Editable s) {
                viewModel.getPassword().pauseListener();
                viewModel.setPassword(s.toString());
                viewModel.getPassword().resumeListener(getViewLifecycleOwner());
            }
        };

        etPassword.addTextChangedListener(passwordWatcher);

        // 登录按钮点击
        if (btnLogin != null) {
            btnLogin.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    viewModel.performLogin();
                }
            });
        }
    }
}

SharedLoginViewModel

  1. 这里的ViewModel负责创建Model和维护数据状态变量MutableLiveData<T>,它作为桥来连接ModelView的通讯。
java 复制代码
package com.test;

// SharedLoginViewModel.java
import android.annotation.TargetApi;

import androidx.core.util.Pair;
import androidx.lifecycle.ViewModel;

import java.util.function.BiConsumer;

public class SharedLoginViewModel extends ViewModel {
    // 输入数据
    private final FlexibleLiveData<String> username = new FlexibleLiveData<>("");
    private final FlexibleLiveData<String> password = new FlexibleLiveData<>("");

    // 状态数据
    private final FlexibleLiveData<Boolean> isLoading = new FlexibleLiveData<>(false);
    private final FlexibleLiveData<String> loginResult = new FlexibleLiveData<>("");
    private final FlexibleLiveData<Boolean> loginTrigger = new FlexibleLiveData<>(false);

    // 只读的 LiveData (供外部观察)
    public FlexibleLiveData<String> getUsername() { return username; }
    public FlexibleLiveData<String> getPassword() { return password; }
    public FlexibleLiveData<Boolean> getIsLoading() { return isLoading; }
    public FlexibleLiveData<String> getLoginResult() { return loginResult; }

    // 用于触发登录的 LiveData
    public FlexibleLiveData<Boolean> getLoginTrigger() { return loginTrigger; }

    LoginModel model = new LoginModel();

    // 设置输入数据的方法
    public void setUsername(String user) {
        if (user != null && !user.equals(username.getValue())) {
            username.setValue(user);
        }
    }

    public void setPassword(String pass) {
        if (pass != null && !pass.equals(password.getValue())) {
            password.setValue(pass);
        }
    }

    // 登录方法
    public void performLogin() {
        loginTrigger.setValue(true);
    }

    // 实际的登录逻辑
    @TargetApi(android.os.Build.VERSION_CODES.N)
    public void startLoginProcess() {
        isLoading.setValue(true);
        loginResult.setValue(""); // 清空之前的结果

        model.taskDoLoginWork(username.getValue(), password.getValue(), new BiConsumer<String, Pair<Boolean, Boolean>>() {
            @Override
            public void accept(String result, Pair<Boolean, Boolean> pp) {
                if(pp.first){
                    loginResult.postValue(result);
                }else{
                    if(!result.isEmpty())
                        loginResult.postValue(result);

                    username.postValue("admin");
                    password.postValue("123456");
                }

                if(pp.second)
                    isLoading.postValue(false);
            }
        });
    }
}

LoginModel

  1. 它开了个独立线程来模拟异步登录,它的方法需要接收一个BiConsumer<String, Pair<Boolean,Boolean>>函数对象来回调结果。
java 复制代码
package com.test;

import android.annotation.TargetApi;

import androidx.core.util.Pair;

import java.util.function.BiConsumer;

public class LoginModel {

    /**
     * func: 是否成功登陆/是否结束
     * @param currentUser
     * @param currentPass
     * @param func
     */
    @TargetApi(android.os.Build.VERSION_CODES.N)
    public void taskDoLoginWork(String currentUser, String currentPass,
                                BiConsumer<String, Pair<Boolean,Boolean>> func){
        new Thread(() -> {
            try {
                // 模拟网络请求延迟
                Thread.sleep(2000);

                // 模拟验证逻辑
                boolean success = "admin".equals(currentUser) && "123456".equals(currentPass);
                String result = success ? "登录成功!欢迎 " + currentUser : "登录失败:用户名或密码错误";

                // 注意:后台线程更新 LiveData 要用 postValue
                if(func != null)
                    func.accept(result,new Pair<>(success,false));

            } catch (InterruptedException e) {
                if(func != null)
                    func.accept("登录过程中发生错误",new Pair<>(false,false));

            } finally {

                if(func != null)
                    func.accept("",new Pair<>(false,true));
            }
        }).start();
    }
}

MainActivity

java 复制代码
package com.example.test.test;

// MainActivity.java

import android.os.Bundle;
import android.widget.LinearLayout;

import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;

public class MainActivity extends AppCompatActivity {

    // 自定义 ID 常量
    private static final int CONTAINER_TOP_ID = 1001;
    private static final int CONTAINER_BOTTOM_ID = 1002;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // 创建主布局
        LinearLayout mainLayout = new LinearLayout(this);
        mainLayout.setOrientation(LinearLayout.VERTICAL);
        mainLayout.setLayoutParams(new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.MATCH_PARENT
        ));

        // 创建顶部容器(用于输入 Fragment)
        LinearLayout topContainer = new LinearLayout(this);
        topContainer.setId(CONTAINER_TOP_ID);
        topContainer.setLayoutParams(new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                0,
                1.0f
        ));
        topContainer.setOrientation(LinearLayout.VERTICAL);

        // 添加分隔线
        LinearLayout divider = new LinearLayout(this);
        divider.setLayoutParams(new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                2
        ));
        divider.setBackgroundColor(android.graphics.Color.GRAY);

        // 创建底部容器(用于结果 Fragment)
        LinearLayout bottomContainer = new LinearLayout(this);
        bottomContainer.setId(CONTAINER_BOTTOM_ID);
        bottomContainer.setLayoutParams(new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                0,
                1.0f
        ));
        bottomContainer.setOrientation(LinearLayout.VERTICAL);

        // 将容器和分隔线添加到主布局
        mainLayout.addView(topContainer);
        mainLayout.addView(divider);
        mainLayout.addView(bottomContainer);

        setContentView(mainLayout);

        // 添加 Fragment
        FragmentManager fragmentManager = getSupportFragmentManager();
        FragmentTransaction transaction = fragmentManager.beginTransaction();

        // 顶部:输入 Fragment
        transaction.replace(CONTAINER_TOP_ID, new LoginInputFragment());
        // 底部:结果 Fragment
        transaction.replace(CONTAINER_BOTTOM_ID, new LoginResultFragment());

        transaction.commit();
    }
}

LoginResultFragment

java 复制代码
package com.example.test.test;

// LoginResultFragment.java

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;

public class LoginResultFragment extends Fragment {

    private SharedLoginViewModel viewModel;
    private TextView tvResult, tvStatus, tvCurrentUser;
    private ProgressBar progressBar;

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 同样使用 requireActivity() 获取共享的 ViewModel
        viewModel = new ViewModelProvider(requireActivity()).get(SharedLoginViewModel.class);
    }

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        // 纯代码创建布局
        LinearLayout root = new LinearLayout(getContext());
        root.setOrientation(LinearLayout.VERTICAL);
        root.setPadding(50, 30, 50, 30);

        // 标题
        TextView tvTitle = new TextView(getContext());
        tvTitle.setText("登录结果");
        tvTitle.setTextSize(20);

        // 当前用户信息
        tvCurrentUser = new TextView(getContext());
        tvCurrentUser.setText("当前用户: 未输入");

        // 结果展示
        tvResult = new TextView(getContext());
        tvResult.setText("等待登录...");
        tvResult.setTextSize(18);

        // 进度条
        progressBar = new ProgressBar(getContext(), null, android.R.attr.progressBarStyleHorizontal);
        progressBar.setVisibility(View.GONE);

        // 状态提示
        tvStatus = new TextView(getContext());
        tvStatus.setText("状态: 空闲");
        tvStatus.setTextSize(12);

        // 添加到布局
        root.addView(tvTitle);
        root.addView(tvCurrentUser);
        root.addView(tvResult);
        root.addView(progressBar);
        root.addView(tvStatus);

        return root;
    }

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        setupViewModelObservers();
    }

    private void setupViewModelObservers() {
        // 观察用户名变化
        viewModel.getUsername().observe(getViewLifecycleOwner(), new Observer<String>() {
            @Override
            public void onChanged(String username) {
                if (tvCurrentUser != null) {
                    String displayText = username.isEmpty() ? "当前用户: 未输入" : "当前用户: " + username;
                    tvCurrentUser.setText(displayText);
                }
            }
        });

        // 观察登录结果
        viewModel.getLoginResult().observe(getViewLifecycleOwner(), new Observer<String>() {
            @Override
            public void onChanged(String result) {
                if (tvResult != null) {
                    tvResult.setText(result);
                    // 根据结果设置颜色
                    if (result.contains("成功")) {
                        tvResult.setTextColor(android.graphics.Color.GREEN);
                    } else if (result.contains("失败") || result.contains("错误")) {
                        tvResult.setTextColor(android.graphics.Color.RED);
                    } else {
                        tvResult.setTextColor(android.graphics.Color.BLACK);
                    }
                }
            }
        });

        // 观察加载状态
        viewModel.getIsLoading().observe(getViewLifecycleOwner(), new Observer<Boolean>() {
            @Override
            public void onChanged(Boolean loading) {
                if (progressBar != null) {
                    progressBar.setVisibility(loading ? View.VISIBLE : View.GONE);
                }

                if (tvStatus != null) {
                    tvStatus.setText(loading ? "状态: 正在登录..." : "状态: 空闲");
                }
            }
        });

        // 观察密码变化(可选,用于演示数据同步)
        viewModel.getPassword().observe(getViewLifecycleOwner(), new Observer<String>() {
            @Override
            public void onChanged(String password) {
                // 这里可以显示密码长度等信息
                if (tvStatus != null && !viewModel.getIsLoading().getValue()) {
                    int length = password.length();
                    String strength = length == 0 ? "未输入密码" :
                            length < 3 ? "密码强度: 弱" :
                                    length < 6 ? "密码强度: 中" : "密码强度: 强";
                    tvStatus.setText("状态: " + strength);
                }
            }
        });
    }
}

FlexibleLiveData

java 复制代码
package com.example.test.test;

import androidx.annotation.NonNull;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;

public class FlexibleLiveData<T> extends MutableLiveData<T> {

    public FlexibleLiveData(T value) {
        super(value);
    }

    public FlexibleLiveData() {
        super();
    }

    private Observer<? super T> observer;

    /**
     * 提交唯一观察者
     * @param owner
     * @param observer
     */
    public void listen(@NonNull LifecycleOwner owner, @NonNull Observer<? super T> observer) {
        if(this.observer != null)
            removeObserver(this.observer);

        observe(owner, observer);
        this.observer = observer;
    }

    /**
     * 移除唯一观察者
     */
    public void removeListener() {
        removeObserver(observer);
    }

    /**
     * 暂停监听
     */
    public void pauseListener(){
        removeListener();
    }

    /**
     * 继续监听
     */
    public void resumeListener(@NonNull LifecycleOwner owner){
        observe(owner, this.observer);
    }
}

AndroidManifest.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.test">

    <uses-sdk
        android:minSdkVersion="23"
        android:targetSdkVersion="34" />

    <!-- 如果不需要网络,可以不加这个权限 -->
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="纯JavaApp"
        android:supportsRtl="true"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">

        <!-- MainActivity -->
        <activity
            android:name="com.example.test.test.MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>

</manifest>

build.gradle

groovy 复制代码
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'


def getVersion() {
    def gitVersion
    try {
        def cmd = 'git rev-list HEAD --count'
        gitVersion = cmd.execute().text.trim().toInteger()
    } catch (Exception e) {
        gitVersion = 1
    }
    return gitVersion
}

def gitVersion = getVersion()
def javaVersion = JavaVersion.VERSION_1_8

android {
    signingConfigs {
        Properties properties = new Properties()
        File propFile = project.file('release.properties')
        if (propFile.exists()) {
            properties.load(propFile.newDataInputStream())
        }
        release {
            keyAlias properties.getProperty("RELEASE_KEY_ALIAS")
            keyPassword properties.getProperty("RELEASE_KEY_PASSWORD")
            storeFile file('qmuidemo.keystore')
            storePassword properties.getProperty("RELEASE_STORE_PASSWORD")
            v2SigningEnabled false
        }
    }
    compileSdkVersion parent.ext.compileSdkVersion

    // for butterknife, see https://github.com/JakeWharton/butterknife/blob/master/CHANGELOG.md#version-900-rc2-2018-11-19
    compileOptions {
        sourceCompatibility javaVersion
        targetCompatibility javaVersion
    }

    defaultConfig {
        applicationId "com.example.test"
        minSdk 24
        targetSdkVersion parent.ext.targetSdkVersion
        versionCode gitVersion
        versionName "1.0.0"
    }
    buildTypes {
        debug {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
    }
    // 避免 lint 检测出错时停止构建
    lintOptions {
        abortOnError false
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "androidx.appcompat:appcompat:$appcompatVersion"
    implementation "androidx.annotation:annotation:$annotationVersion"
    implementation "com.google.android.material:material:$materialVersion"
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    testImplementation 'junit:junit:4.13.2'
}

输出

项目下载

https://download.csdn.net/download/infoworld/92989957

参考

  1. MVVM - Model,View,ViewModel

  2. ViewModel 概览

  3. https://developer.android.com/develop/ui/views/components/menus?hl=zh-cn

  4. https://developer.android.com/training/data-storage?hl=zh-cn

相关推荐
地瓜伯伯1 小时前
从MESI缓存一致性协议讲透synchronized的底层
java·spring boot·spring·spring cloud·微服务·springcloud
kingbal2 小时前
Flutter:Flutter SDK版本管理工具FVM
android·flutter·ios·android-studio·window
Devin~Y2 小时前
大厂 Java 面试实录:从音视频内容社区到 AI RAG 的全链路技术设计
java·spring boot·redis·spring cloud·微服务·kafka·音视频
CoderYanger2 小时前
A.每日一题:3612. 用特殊操作处理字符串 I
java·程序人生·leetcode·面试·职场和发展·学习方法·改行学it
天天开发2 小时前
Flutter状态管理新宠:RiverPod全面解析与实战指南
android·flutter
承渊政道2 小时前
飞算JavaAI 智能引导背后的多 Agent 协作机制解析:从老旧 Java 后台升级到可运行工程
java·开发语言·spring boot·安全·intellij-idea·软件工程·ai编程
唐青枫3 小时前
Java Flyway 实战指南:用 SQL 脚本管理数据库版本
java
云飞云共享云桌面10 小时前
传统工作站 vs 云飞云共享云桌面:制造业设计云桌面选型深度对比
运维·服务器·前端·网络·3d·架构·制造
huangdong_10 小时前
电商平台图片URL原图转换技术深度解析:从缩略图到高清原图的完整方案
java·后端·spring