现在大部分网站都有扫码登录功能,搭配相应的App就能免去输入账号密码实现快速登录。本文简单介绍如何实现扫码登录功能。
实现扫码登录
之前参与过一个电视App的开发,采用扫码登录,需要使用配套的App扫码登录后才能进入到主页。那么扫码登录该怎么实现呢?大致流程如下:
- 被扫端展示一个二维码,二维码包含被扫端的唯一标识(如设备id),并与服务端保持通讯(轮询、长连接、推送)。
- 扫码端扫描二维码之后,使用获取到的被扫端的唯一标识(如设备id)调用服务端扫码登录接口。
- 服务端接收扫码端发起的扫码登录请求,处理(如验证用户信息)后将登录信息发送到被扫端。
PS: 此为大致流程,具体使用需要根据实际需求进行调整。
接下来简单演示一下此流程。
添加依赖库
添加需要的SDK依赖库,在项目app module的build.gradle中的dependencies中添加依赖:
scss
dependencies {
// 实现服务端(http、socket)
implementation("org.nanohttpd:nanohttpd:2.3.1")
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
// 与服务端通信
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// 扫描解析、生成二维码
implementation("com.github.jenly1314:zxing-lite:3.1.0")
}
服务端
使用NanoHttpD实现Socket服务端(与被扫端通信)和Http服务端(与扫码端通信),示例代码如下:
Socket服务
与被扫端保持通讯,在Http服务接收并处理完扫码登录请求后,将获取到的用户id发送给被扫端。
kotlin
class ServerSocketClient : NanoWSD(9090) {
private var serverWebSocket: ServerWebSocket? = null
override fun openWebSocket(handshake: IHTTPSession?): WebSocket {
return ServerWebSocket(handshake).also { serverWebSocket = it }
}
private class ServerWebSocket(handshake: IHTTPSession?) : WebSocket(handshake) {
override fun onOpen() {}
override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) {}
override fun onMessage(message: WebSocketFrame?) {}
override fun onPong(pong: WebSocketFrame?) {}
override fun onException(exception: IOException?) {}
}
override fun stop() {
super.stop()
serverWebSocket = null
}
fun sendMessage(message: String) {
serverWebSocket?.send(message)
}
}
Http服务
接收并处理来自扫码端的扫码登录请求,通过设备id和用户id判断被扫端是否可以登录。
kotlin
const val APP_SCAN_INTERFACE = "loginViaScan"
const val USER_ID = "userId"
const val EXAMPLE_USER_ID = "123456789"
const val DEVICE_ID = "deviceId"
const val EXAMPLE_DEVICE_ID = "example_device_id0001"
class ServerHttpClient(private var scanLoginSucceedListener: ((userId: String) -> Unit)? = null) : NanoHTTPD(8080) {
override fun serve(session: IHTTPSession?): Response {
val uri = session?.uri
return if (uri == "/$APP_SCAN_INTERFACE" &&
session.parameters[USER_ID]?.first() == EXAMPLE_USER_ID &&
session.parameters[DEVICE_ID]?.first() == EXAMPLE_DEVICE_ID
) {
scanLoginSucceedListener?.invoke(session.parameters[USER_ID]?.first() ?: "")
newFixedLengthResponse("Login Succeed")
} else {
super.serve(session)
}
}
}
服务控制类
启动或停止Socket服务和Http服务。
kotlin
object ServerController {
private var serverSocketClient: ServerSocketClient? = null
private var serverHttpClient: ServerHttpClient? = null
fun startServer() {
(serverSocketClient ?: ServerSocketClient().also {
serverSocketClient = it
}).run {
if (!isAlive) {
start(0)
}
}
(serverHttpClient ?: ServerHttpClient {
serverSocketClient?.sendMessage("Login Succeed, user id is $it")
}.also {
serverHttpClient = it
}).run {
if (!isAlive) {
start(NanoHTTPD.SOCKET_READ_TIMEOUT, true)
}
}
}
fun stopServer() {
serverSocketClient?.stop()
serverSocketClient = null
serverHttpClient?.stop()
serverHttpClient = null
}
}
被扫端
Socket辅助类
使用OkHttp与服务端进行Socket通信。
kotlin
class DevicesSocketHelper(private val messageListener: ((message: String) -> Unit)? = null) {
private var webSocket: WebSocket? = null
private val webSocketListener = object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
super.onMessage(webSocket, bytes)
messageListener?.invoke(bytes.utf8())
}
override fun onMessage(webSocket: WebSocket, text: String) {
super.onMessage(webSocket, text)
messageListener?.invoke(text)
}
}
fun openSocketConnection(serverPath: String) {
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(120, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
val request = Request.Builder().url(serverPath).build()
webSocket = okHttpClient.newWebSocket(request, webSocketListener)
}
fun release() {
webSocket?.close(1000, "")
webSocket = null
}
}
被扫端示例页面
先展示二维码,接收到服务端的消息后,显示用户id。
kotlin
class DeviceExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutDeviceExampleActivityBinding
private var socketHelper: DevicesSocketHelper? = DevicesSocketHelper() { message ->
// 接收到服务端发来的消息,改变显示内容
runOnUiThread {
binding.tvUserInfo.text = message
binding.ivQrCode.visibility = View.GONE
binding.tvUserInfo.visibility = View.VISIBLE
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutDeviceExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
it.includeTitle.tvTitle.text = "Device Example"
}
lifecycleScope.launch(Dispatchers.IO) {
// 使用设备id生成二维码
CodeUtils.createQRCode(EXAMPLE_DEVICE_ID, DensityUtil.dp2Px(200)).let { qrCode ->
withContext(Dispatchers.Main) {
binding.ivQrCode.setImageBitmap(qrCode)
}
}
}
socketHelper?.openSocketConnection("ws://localhost:9090/")
}
override fun onDestroy() {
super.onDestroy()
socketHelper?.release()
socketHelper = null
}
}
扫描端
扫码页
继承zxing-lite库的BarcodeCameraScanActivity
类,简单实现扫描与解析二维码。
kotlin
class ScanQRCodeActivity : BarcodeCameraScanActivity() {
override fun initCameraScan(cameraScan: CameraScan<Result>) {
super.initCameraScan(cameraScan)
// 播放扫码音效
cameraScan.setPlayBeep(true)
}
override fun createAnalyzer(): Analyzer<Result> {
return QRCodeAnalyzer(DecodeConfig().apply {
// 设置仅识别二维码
setHints(DecodeFormatManager.QR_CODE_HINTS)
})
}
override fun onScanResultCallback(result: AnalyzeResult<Result>) {
// 已获取结果,停止识别二维码
cameraScan.setAnalyzeImage(false)
// 返回扫码结果
setResult(Activity.RESULT_OK, Intent().apply {
putExtra(CameraScan.SCAN_RESULT, result.result.text)
})
finish()
}
}
扫描端示例页面
提供扫码入口,提供输入框用于输入服务端IP,获取到扫码结果后发送给服务端。
kotlin
class AppScanExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutAppScanExampleActivityBinding
private var serverIp: String = ""
private val scanQRCodeLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.getStringExtra(CameraScan.SCAN_RESULT)?.let { deviceId ->
sendRequestToServer(deviceId)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutAppScanExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
}
OkHttpHelper.init()
binding.btnScan.setOnClickListener {
// 获取输入的服务端ip(两台设备在同一WIFI下,直接通过IP访问服务端)
serverIp = binding.etInputIp.text.toString()
if (serverIp.isEmpty()) {
showSnakeBar("Server ip can not be empty")
return@setOnClickListener
}
hideKeyboard(binding.etInputIp)
scanQRCodeLauncher.launch(Intent(this, ScanQRCodeActivity::class.java))
}
}
private fun sendRequestToServer(deviceId: String) {
OkHttpHelper.sendGetRequest("http://${serverIp}:8080/${APP_SCAN_INTERFACE}", mapOf(Pair(USER_ID, EXAMPLE_USER_ID), Pair(DEVICE_ID, deviceId)), object : RequestCallback {
override fun onResponse(success: Boolean, responseBody: ResponseBody?) {
showSnakeBar("Scan login ${if (success) "succeed" else "failure"}")
}
override fun onFailure(errorMessage: String?) {
showSnakeBar("Scan login failure")
}
})
}
private fun hideKeyboard(view: View) {
view.clearFocus()
WindowInsetsControllerCompat(window, view).hide(WindowInsetsCompat.Type.ime())
}
private fun showSnakeBar(message: String) {
runOnUiThread {
Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
}
}
}
示例入口页
提供被扫端和扫码端入口,打开被扫端时同时启动服务端。
kotlin
class ScanLoginExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutScanLoginExampleActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutScanLoginExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
it.includeTitle.tvTitle.text = "Scan Login Example"
it.btnOpenDeviceExample.setOnClickListener {
// 打开被扫端同时启动服务
ServerController.startServer()
startActivity(Intent(this, DeviceExampleActivity::class.java))
}
it.btnOpenAppExample.setOnClickListener { startActivity(Intent(this, AppScanExampleActivity::class.java)) }
}
}
override fun onDestroy() {
super.onDestroy()
ServerController.stopServer()
}
}
效果演示与示例代码
最终效果如下图:
被扫端 | 扫码端 |
---|---|
演示代码已在示例Demo中添加。