广播最佳实践:实现强制下线功能

161 阅读5分钟

前言

“强制下线”是一个很常见的功能。比如当你的账号在另一台设备上登录时,当前设备就会被强制下线。强制下线会在界面上弹出一个不可取消的对话框,提示你已下线,你无法进行其他操作,点击确定按钮就会跳转到登录界面,让你重新登录。

因为需要强制下线时,你可能处于应用的任何一个界面。但我们并不想要在每一个 Activity 中重复地编写跳转的逻辑,这时,我们可以利用广播来轻松完成这个功能。

实现步骤

准备工作:创建一个名为 BroadcastBestPractice 的 Empty Views Activity 项目。

实现 Activity 管理器

因为强制下线时,需要关闭所有的 Activity 实例。所以我们创建 ActivityCollector 单例对象来统一管理所有的 Activity 实例,以便需要时,可以一次性将它们全部关闭。代码如下:

object ActivityCollector {
    private val activities = ArrayList<Activity>()
    fun addActivity(activity: Activity) {
        activities.add(activity)
    }

    fun removeActivity(activity: Activity) {
        activities.remove(activity)
    }

    fun finishAll() {
        for (activity in activities) {
            if (!activity.isFinishing) {
                activity.finish()
            }
        }
        activities.clear()
    }
}

并且创建 BaseActivity 类来自动添加新创建的 Activity 实例,移除将被销毁的 Activity 实例,并让它作为项目中所有 Activity 的父类。代码如下:

open class BaseActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Activity 创建时添加到管理器
        ActivityCollector.addActivity(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        // Activity 销毁时从管理器移除
        ActivityCollector.removeActivity(this)
    }
}

登录界面和逻辑

首先创建一个 LoginActivity 作为登录界面,其布局文件中的代码:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:gravity="center_vertical">

        <TextView
            android:layout_width="90dp"
            android:layout_height="wrap_content"
            android:text="Account:"
            android:textSize="18sp" />

        <EditText
            android:id="@+id/accountEdit"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:autofillHints="username"
            android:inputType="text" />
    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:gravity="center_vertical"
        android:layout_marginTop="16dp">

        <TextView
            android:layout_width="90dp"
            android:layout_height="wrap_content"
            android:text="Password:"
            android:textSize="18sp" />

        <EditText
            android:id="@+id/passwordEdit"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:autofillHints="password"
            android:inputType="textPassword" />
    </LinearLayout>

    <Button
        android:id="@+id/login"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:layout_marginTop="32dp"
        android:text="Login" />
</LinearLayout>

效果图:

image.png

然后在 LoginActivity 中实现简单的登录逻辑:当用户输入的账号为 admin 并且密码是 123456 时,我们就认为登录成功,跳转至 MainActivity 界面,否则提示用户账号或密码错误。

class LoginActivity : BaseActivity() {
    // ViewBinding 视图绑定
    private lateinit var binding: ActivityLoginBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityLoginBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 给登录按钮注册点击事件
        binding.login.setOnClickListener {
            // 获取用户输入的账号和密码
            val account = binding.accountEdit.text.toString()
            val password = binding.passwordEdit.text.toString()

            // 如果账号是admin且密码是123456,就认为登录成功
            if (account == "admin" && password == "123456") {
                val intent = Intent(this, MainActivity::class.java)
                startActivity(intent)
                finish()
            } else {
                Toast.makeText(
                    this, "account or password is invalid",
                    Toast.LENGTH_SHORT
                ).show()
            }
        }
    }
}

主界面和触发机制

activity_main.xml 布局文件中,添加一个按钮,用于触发强制下线。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:id="@+id/forceOffline"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Send force offline broadcast" />

</LinearLayout>

然后在 MainActivity 中:

class MainActivity : BaseActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 给强制下线按钮注册点击事件
        binding.forceOffline.setOnClickListener {
            val intent = Intent("com.example.broadcastbestpractice.FORCE_OFFLINE")
            intent.setPackage(packageName) // Android 8.0+系统,必须指定包名,将隐式广播变为显式广播
            sendBroadcast(intent)
        }
    }
}

在强制下线按钮的点击回调中,发送了 action 值为 "com.example.broadcastbestpractice.FORCE_OFFLINE" 的自定义广播,这样就可以将强制下线的具体逻辑写在相应的广播接收器中了,无需在 Activity 界面中实现。

创建广播接收器和处理逻辑

创建 ForceOfflineReceiver 类来接收广播,并且在其中实现弹出对话框、销毁所有 Activity并且返回登录页的逻辑。代码如下:

class ForceOfflineReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        // 弹出警告对话框
        AlertDialog.Builder(context).apply {
            setTitle("Warning")
            setMessage("You are forced to be offline. Please try to login again.")
            setCancelable(false) // 设置不可因按下返回键或是点击对话框外部关闭对话框
            setPositiveButton("OK") { _, _ ->
                ActivityCollector.finishAll() // 销毁所有 Activity 实例
                val i = Intent(context, LoginActivity::class.java)
                // 在非 Activity 上下文中启动 Activity,必须添加此 Flag
                i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                context.startActivity(i) // 启动 LoginActivity
            }
            show()
        }
    }
}

这样只需在任何界面,发送这样的广播,用户就会被强制下线。

其中启动 Activity 时,我们添加了 Flag。这样做能确保在非 Activity 的上下文中创建新的任务栈来承载 Activity,避免程序崩溃。

动态注册接收器

那问题来了:广播接收器在哪里注册呢?

因为我们需要在接收到广播时弹出一个对话框,所以不能使用静态注册的方式,静态注册的 BroadcastReceiver 是无法在 onReceive() 方法中弹出像对话框这样的 UI 控件的,所以只能采取动态注册的方式,但你也不应该在每个 Activity 都进行重复注册。

我们可以在所有 Activity 的共同父类 BaseActivity 中,统一进行动态注册 BroadcastReceiver,代码如下:

open class BaseActivity : AppCompatActivity() {

    private lateinit var receiver: ForceOfflineReceiver

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ActivityCollector.addActivity(this)
    }

    @SuppressLint("UnspecifiedRegisterReceiverFlag")
    override fun onResume() {
        super.onResume()

        val intentFilter = IntentFilter()
        intentFilter.addAction("com.example.broadcastbestpractice.FORCE_OFFLINE")
        receiver = ForceOfflineReceiver()

        // 注册广播接收器
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            // 在Android 13 (API 33)以上,必须指定接收器是否对外部应用可见
            registerReceiver(receiver, intentFilter, RECEIVER_NOT_EXPORTED)
        } else {
            registerReceiver(receiver, intentFilter)
        }

    }

    override fun onPause() {
        super.onPause()
        unregisterReceiver(receiver)
    }


    override fun onDestroy() {
        super.onDestroy()
        ActivityCollector.removeActivity(this)
    }
}

为什么不在 onCreate()onDestroy() 方法中注册和注销 BroadcastReceiver 呢?

因为我们只需确保当前对用户来说可见、与用户进行交互的 Activity 才需要接收到下线的广播,准确来说是处于任务栈栈顶的 Activity 才要监听下线广播,一旦 Activity 不可见,进入后台时,便会注销 BroadcastReceiver,避免不必要的资源消耗。

这样其实强制下线功能就完成了。最后在清单文件中,将 LoginActivity 设为启动项:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        ...>
        <activity
            android:name=".LoginActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity
            android:name=".MainActivity"
            android:exported="false" />
    </application>

</manifest>

重新运行程序,会进入登录界面,如图所示:

image.png

输入账号、密码并点击登录按钮后:

image.png

此时点击界面中的强制下线按钮,会弹出警告对话框,如图所示:

image.png

点击确定,会回到登录界面:

image.png