前言
“强制下线”是一个很常见的功能。比如当你的账号在另一台设备上登录时,当前设备就会被强制下线。强制下线会在界面上弹出一个不可取消的对话框,提示你已下线,你无法进行其他操作,点击确定按钮就会跳转到登录界面,让你重新登录。
因为需要强制下线时,你可能处于应用的任何一个界面。但我们并不想要在每一个 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>
效果图:
然后在 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>
重新运行程序,会进入登录界面,如图所示:
输入账号、密码并点击登录按钮后:
此时点击界面中的强制下线按钮,会弹出警告对话框,如图所示:
点击确定,会回到登录界面: