OpenHarmony 扫码自动配网

背景

随着移动互联网的发展,WiFi已成为人们生活中不可或缺的网络接入方式。但在连接WiFi时,用户常需要手动输入一个复杂的密钥,这带来了一定的不便。针对这一痛点,利用QR码连接WiFi的方案应运而生。QR码连接WiFi的工作流程是:商家或公共场所提供含有WiFi密钥的QR码,用户只需使用手机扫一扫即可读取密钥信息并连接WiFi,无需手动输入,这种连接方式大大简化了用户的操作。随着智能手机摄像头识别能力的提升,以及用户需求的引领,利用QR码连接WiFi的方式未来还将得到更广泛的应用,为用户提供更稳定便捷的上网体验。它利用了移动互联网时代的技术优势,解决了传统WiFi连接中的痛点,是一种值得推广的网络连接方式。

效果

页面截图

扫码页面

配网连接中

配网连接成功

配网连接失败

在线视频播放的地址

优势

使用QR码连接WiFi具有以下优势:

  • 提高了连接成功率,避免因手动输入密钥错误导致的连接失败问题。
  • 加快了连接速度,扫码相对于手动输入更高效方便。
  • 提升了用户体验,无需记忆和输入复杂密钥,操作更人性化。
  • 方便密钥分享和更改,通过更新QR码即可实现。
  • 在一些需要频繁连接不同WiFi的场景下尤其便利,如酒店、餐厅、机场等。
  • 一些App可以自动识别WiFi二维码,实现零点击连接。

开发与实现

开发环境

开发平台:windows10、DevEco Studio 3.1 Release系统:OpenHarmony 3.2 Release,API9(Full SDK 3.2.11.9)设备:SD100(工业平板设备、平台:RK3568、屏幕像素:1920 * 1200)

项目开发

需求分析

  • 支持相机扫码,并可以解析二维码信息。
  • 获取二维码中的wifi连接信息,自动完成网络连接。
  • 网络连接成功,则提示用户成功。
  • 网络连接失败,则提示用户失败,可以重新连接。
  • UI界面符合OpenHarmony设计原则,应用界面简洁高效、自然流畅。

项目流程图

界面

说明:从需求上分析,可以有两个界面,一是扫码界面、二是wifi连接等待和显示结果界面。

详细开发

一、创建项目

说明:通过DevEco Studio创建一个OpenHarmony的项目。

二、申请权限

说明:在应用中涉及到使用相机和wifi的操作,需要动态申请一些必要的权限,我们可以在 EntryAbility.ts中实现,EntryAbility.ts继承UIAbility,用于管理应用的生面周期,在OnCreate是实例冷启动时触发,在此函数中实现权限申请。具体代码如下:

let permissionList: Array = [
  "ohos.permission.GET_WIFI_INFO",
  "ohos.permission.INTERNET",
  'ohos.permission.CAMERA',
  'ohos.permission.READ_MEDIA',
  'ohos.permission.WRITE_MEDIA',
  'ohos.permission.MEDIA_LOCATION',
  'ohos.permission.LOCATION',
  'ohos.permission.APPROXIMATELY_LOCATION'
]

onCreate(want, launchParam) {
  hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
  this.requestPermissions()
}

private requestPermissions() {
  let AtManager = abilityAccessCtrl.createAtManager()
  AtManager.requestPermissionsFromUser(this.context, permissionList).then(async (data) => {
    Logger.info(`${TAG} data permissions: ${JSON.stringify(data.permissions)}`)
    Logger.info(`${TAG} data authResult: ${JSON.stringify(data.authResults)}`)
    // 判断授权是否完成
    let resultCount: number = 0
    for (let result of data.authResults) {
      if (result === 0) {
        resultCount += 1
      }
    }
    let permissionResult : boolean = false
    if (resultCount === permissionList.length) {
      permissionResult = true
    }
    AppStorage.SetOrCreate(KEY_IS_PERMISSION, true)
    this.sendPermissionResult(permissionResult)
  })
}

sendPermissionResult(result : boolean) {
  let eventData: emitter.EventData = {
    data: {
      "result": result
    }
  };

  let innerEvent: emitter.InnerEvent = {
    eventId: EVENT_PERMISSION_ID,
    priority: emitter.EventPriority.HIGH
  };

  emitter.emit(innerEvent, eventData);
  Logger.info(`${TAG} sendPermissionResult`)
}

onDestroy() {
  Logger.info(`${TAG} onDestroy`)
  emitter.off(EVENT_PERMISSION_ID)
}

代码解析:

  • 在应用中使用到相机和操作wifi需要根据需要动态申请相关权限,具体的权限用途可以查看:应用权限列表。
  • 应用动态授权需要使用到@ohos.abilityAccessCtrl (程序访问控制管理),通过abilityAccessCtrl.createAtManager()获取到访问控制对象 AtManager。
  • 通过AtManager.requestPermissionsFromUser() 拉起请求用户授权弹窗,由用户动态授权。
  • 授权成功后通过Emitter(@ohos.events.emitter)向主界面发送授权结果。
  • 在onDestroy()应用退出函数中取消Emitter事件订阅。

三、首页

说明:首页即为扫码页面,用于识别二维码获取二维码信息,为网络连接准备。所以此页面有有个功能,加载相机和识别二维码。

  • 相机功能在CameraServices中,源码参考CameraServices.ets。
  • 获取相机实例使用到媒体相机接口@ohos.multimedia.camera (相机管理)。
  • 首先使用camera.getCameraManager方法获取相机管理器,然后使用cameraManager.getSupportedCameras方法得到设备列表, 这里默认点亮列表中的首个相机;
  • 打开相机:使用 cameraManager.createCameraInput方法创建CameraInput实例,调用open方法打开相机;
  • 获取相机输出流:使用getSupportedOutputCapability查询相机设备在模式下支持的输出能力,然后使用createPreviewOutput创建相机输出流。
  • 获取拍照输出流,使用@ohos.multimedia.image接口的 createImageReceiver 方法创建ImageReceiver实例,并通过其getReceivingS_urfaceId()获取S_urfaceId,通过CameraManager.createPhotoOutput()函数构建拍照输出流,并将imageReceive 的 S_urfaceId与其建立绑定关系。
  • 获取相片输出:首先使用createCaptureSession方法创建捕获会话的实例,然后使用beginConfig方法配置会话,接下来使用addInput方法添加一个摄像头输入流,使用addOutput添加一个摄像头和相机照片的输出流,使用commitConfig方法提交会话配置后,调用会话的start方法开始捕获相片输出。
  • 这里也可以使用相机预览流获取图像数据,但在界面上需要预览,所以这里需要构建两条预览流,一条预览流用于显示,在XComponent组件中渲染,另外一条预览流用于获取头像数据用于解析,根据实践发现,开启两条预览流后,相机帧率为:7fsp,表现为预览卡顿,所以为提升预览效果,使用定时拍照的方式获取图像数据。
  • 获取图像的在SaveCameraAsset.ets中实现,扫码页面启动后每间隔1.5s调用PhotoOutput.capture()实现拍照,通过imageReceiver.on(‘imageArrival’)接收图片,使用imageReceiver.readNextImage()获取图像对象,通过Image.getComponent()获取图像缓存数据。

具体实现代码:

"dependencies": {
    "jsqr": "^1.4.0",
    "@ohos/zxing": "^2.0.0"
  }

四、配网协议

说明:处于通用性考虑,需要对配网的二维码解析约定一个协议,也就是约定联网二维码数据的格式:##ssid##pwd##securityType

  • ssid : 热点的SSID,编码格式为UTF-8。
  • pwd :热点的密钥。
  • securityType : 加密类型,这可以参看wifiManager.WifiSecurityType。

在项目中也提供了协议解析类AnalyticResult.ts,具体代码如下:

/**
 * 结果解析类
 */
export type ResultType = {
  ssid: string,
  pwd: string,
  securityType : number
}

const SEPARATOR: string = '##'

export class Analytic {
  constructor() {

  }

  getResult(msg: string): ResultType {
    let result: ResultType = null
    if (msg && msg.length > 0 && msg.indexOf(SEPARATOR) >= 0) {
      let resultArr: string[] = msg.split(SEPARATOR)
      if (resultArr.length >= 4) {
        result = {
          ssid: resultArr[1],
          pwd: resultArr[2],
          securityType: parseInt(resultArr[3])
        }
      }
    }
    return result
  }
}

五、配网页面

说明:通过对配网二维码的解析获取到热点的ssid、密钥、加密类型,就可以通过@ohos.wifiManager(WLAN)提供的网络连接接口实现配网。因为网络连接需要调用系统的一些验证流程,需要消耗一些时间,为了优化交互,需要一个网络连接等待界面ConnectPage.ets,界面截图如下:

具体代码如下:

import { WifiConnectStatus } from '../model/Constant'
import router from '@ohos.router';
import { Logger } from '@ohos/common'
import wifi from '@ohos.wifiManager';
import { ResultType } from '../model/AnalyticResult'
import { WifiModel } from '../model/WifiModel'
/**
 * 网络连接页面
 */
const TAG: string = '[ConnectPage]'
const MAX_TIME_OUT: number = 60000 // 最大超时时间
@Entry
@Component
struct ConnectPage {
  @State mConnectSsid: string = ''
  @State mConnectStatus: WifiConnectStatus = WifiConnectStatus.CONNECTING
  @State mConnectingAngle : number = 0
  @State mConnectFailResource : Resource = $r('app.string.connect_wifi_fail')
  private linkedInfo: wifi.WifiLinkedInfo = null
  private mWifiModel: WifiModel = new WifiModel()
  private mTimeOutId: number = -1
  private mAnimationTimeOutId : number = -1
  async aboutToAppear() {
    Logger.info(`${TAG} aboutToAppear`)
    this.showConnecting()
    let wifiResult: ResultType = router.getParams()['wifiResult']
    Logger.info(`${TAG} wifiResult : ${JSON.stringify(wifiResult)}`)
    // 如果wifi是开的,就记录下状态,然后扫描wifi,并获取连接信息
    if (!wifi.isWifiActive()) {
      Logger.info(TAG, 'enableWifi')
      try {
        wifi.enableWifi()
      } catch (error) {
        Logger.error(`${TAG} wifi enable fail, ${JSON.stringify(error)}`)
      }
    }
    await this.getLinkedInfo()
    // 启动监听
    this.addListener()
    if (wifiResult == null) {
      Logger.info(TAG, 'wifiResult is null')
      this.mConnectFailResource = $r('app.string.scan_code_data_error')
      this.mConnectStatus = WifiConnectStatus.FAIL
    } else {
      this.mConnectSsid = wifiResult.ssid
      Logger.info(`${TAG} connect wifi ${this.mConnectSsid}`)
      this.disposeWifiConnect(wifiResult)
    }
  }
  /**
   * 启动超时任务
   */
  startTimeOut(): void {
    Logger.info(TAG, `startTimeOut`)
    this.mTimeOutId = setTimeout(() => {
      // 如果超过1分钟没有连接上网络,则认为网络连接超时
      try {
        this.mConnectFailResource = $r('app.string.connect_wifi_fail')
        this.mConnectStatus = WifiConnectStatus.FAIL
        wifi.disconnect();
      } catch (error) {
        Logger.error(TAG, `failed,code:${JSON.stringify(error.code)},message:${JSON.stringify(error.message)}`)
      }
    }, MAX_TIME_OUT)
  }
  /**
   * 取消超时任务
   */
  cancelTimeOut() {
    Logger.info(TAG, `cancelTimeOut id:${this.mTimeOutId}`)
    if (this.mTimeOutId >= 0) {
      clearTimeout(this.mTimeOutId)
      this.mTimeOutId = -1
    }
  }
  // 监听wifi的变化
  addListener() {
    // 连接状态改变时,修改连接信息
    wifi.on('wifiConnectionChange', async state => {
      Logger.info(TAG, `wifiConnectionChange: ${state}`)
      // 判断网络是否连接 0=断开  1=连接
      if (state === 1) {
        this.mConnectStatus = WifiConnectStatus.SUCCESS
        this.cancelTimeOut()
      }
      await this.getLinkedInfo()
    })
    // wifi状态改变时,先清空wifi列表,然后判断是否是开启状态,如果是就扫描
    wifi.on('wifiStateChange', state => {
      Logger.info(TAG, `wifiStateLisener state: ${state}`)
    })
  }
  // 获取有关Wi-Fi连接的信息,存入linkedInfo
  async getLinkedInfo() {
    try {
      let wifiLinkedInfo = await wifi.getLinkedInfo()
      if (wifiLinkedInfo === null || wifiLinkedInfo.bssid === '') {
        this.linkedInfo = null
        return
      }
      this.linkedInfo = wifiLinkedInfo
    } catch (err) {
      Logger.info(`getLinkedInfo failed err is ${JSON.stringify(err)}`)
    }
  }
  /**
   * 处理wifi连接
   * @param wifiResult
   */
  disposeWifiConnect(wifiResult: ResultType): void {
    this.mConnectStatus = WifiConnectStatus.CONNECTING
    if (this.linkedInfo) {
      // 说明wifi已经连接,需要确认需要连接的wifi和已连接的wifi是否为相同
      let linkedSsid: string = this.linkedInfo.ssid;
      if (linkedSsid === wifiResult.ssid) {
        Logger.info(`${TAG} The same ssid`);
        this.mConnectStatus = WifiConnectStatus.SUCCESS
        return;
      }
      // 如果wifi不同,则先断开网络连接,再重新连接
      try {
        wifi.disconnect();
        this.connectWifi(wifiResult.ssid, wifiResult.pwd, wifiResult.securityType)
      } catch (error) {
        Logger.error(TAG, `failed,code:${JSON.stringify(error.code)},message:${JSON.stringify(error.message)}`)
      }
    } else {
      this.connectWifi(wifiResult.ssid, wifiResult.pwd, wifiResult.securityType)
    }
  }
  private connectWifi(ssid: string, pwd: string, securityType : number) {
    this.startTimeOut()
    this.mWifiModel.connectNetwork(ssid, pwd, securityType)
  }
  async gotoIndex() {
    try {
      let options: router.RouterOptions = {
        url: "pages/Index"
      }
      await router.replaceUrl(options)
    } catch (error) {
      Logger.error(`${TAG} go to index fail, err: ${JSON.stringify(error)}`)
    }
  }
  showConnecting() {
    this.mConnectingAngle = 0
    this.mAnimationTimeOutId = setTimeout(() => {
      this.mConnectingAngle = 360
    }, 500)
  }
  closeConnecting() {
    if (this.mAnimationTimeOutId > -1) {
      clearTimeout(this.mAnimationTimeOutId)
    }
  }
  aboutToDisappear() {
    wifi.off('wifiConnectionChange')
    wifi.off('wifiStateChange')
    this.cancelTimeOut()
    this.closeConnecting()
  }
  build() {
    Column() {
      // back
      Row() {
        Image($r('app.media.icon_back'))
          .width(30)
          .height(30)
          .objectFit(ImageFit.Contain)
          .onClick(() => {
            router.back()
          })
      }
      .width('90%')
      .height('10%')
      .justifyContent(FlexAlign.Start)
      .alignItems(VerticalAlign.Center)
      Stack() {
        // 背景
        Column() {
          Image($r('app.media.bg_connect_wifi'))
            .width('100%')
            .height('100%')
            .objectFit(ImageFit.Contain)
            .rotate({
              x: 0,
              y: 0,
              z: 1,
              centerX: '50%',
              centerY: '49%',
              angle: this.mConnectingAngle
            })
            .animation({
              duration: 2000, // 动画时长
              curve: Curve.Linear, // 动画曲线
              delay: 0, // 动画延迟
              iterations: -1, // 播放次数
              playMode: PlayMode.Normal // 动画模式
            })
        }
        Column({ space: 20 }) {
          if (this.mConnectStatus === WifiConnectStatus.SUCCESS) {
            // 连接成功
            Image($r('app.media.icon_connect_wifi_success'))
              .width(80)
              .height(80)
              .objectFit(ImageFit.Contain)
            Text($r('app.string.connect_wifi_success'))
              .fontSize(32)
              .fontColor($r('app.color.connect_wifi_text'))
            Text(this.mConnectSsid)
              .fontSize(22)
              .fontColor($r('app.color.connect_wifi_text'))
          } else if (this.mConnectStatus === WifiConnectStatus.FAIL) {
            // 连接失败
            Image($r('app.media.icon_connect_wifi_fail'))
              .width(80)
              .height(80)
              .objectFit(ImageFit.Contain)
            Text(this.mConnectFailResource)
              .fontSize(32)
              .fontColor($r('app.color.connect_wifi_text'))
            Button($r('app.string.reconnect_wifi'))
              .width(260)
              .height(55)
              .backgroundColor($r('app.color.connect_fail_but_bg'))
              .onClick(() => {
                this.gotoIndex()
              })
          } else {
            // 连接中
            Image($r('app.media.icon_connect_wifi'))
              .width(100)
              .height(100)
              .objectFit(ImageFit.Contain)
            Text($r('app.string.connect_wifi_hint'))
              .fontSize(16)
              .fontColor($r('app.color.connect_wifi_text'))
            Text($r('app.string.connecting_wifi'))
              .fontSize(32)
              .fontColor($r('app.color.connect_wifi_text'))
            Text(this.mConnectSsid)
              .fontSize(22)
              .fontColor($r('app.color.connect_wifi_text'))
          }
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .height('80%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.connect_bg'))
  }
}

整个界面比较简单,主要显示当前的连接状态:连接中、连接成功、连接超时,特别强调连接超时,计划热点最长连接60s,如果在预定时间未连接成功,则显示超时,超时后可以通过重新配网按钮进行重新扫码连接,根据实际测试,在热点未打开状态下扫码连接耗时平均值12s。

Image($r('app.media.bg_connect_wifi'))
            .width('100%')
            .height('100%')
            .objectFit(ImageFit.Contain)
            .rotate({
              x: 0,
              y: 0,
              z: 1,
              centerX: '50%',
              centerY: '49%',
              angle: this.mConnectingAngle
            })
            .animation({
              duration: 2000, // 动画时长
              curve: Curve.Linear, // 动画曲线
              delay: 0, // 动画延迟
              iterations: -1, // 播放次数
              playMode: PlayMode.Normal // 动画模式
            })

六、网络自动连接

说明:网络自动连接主要是通过@ohos.wifiManager(WLAN)提供的连接接口实现,具体代码如下:

import wifi from '@ohos.wifiManager'
import { Logger } from '@ohos/common'
const TAG: string = '[WiFiModel]'
export type WifiType = {
  ssid: string,
  bssid: string,
  securityType: wifi.WifiSecurityType,
  rssi: number,
  band: number,
  frequency: number,
  timestamp: number
}
export class WifiModel {
  async getScanInfos(): Promise {
    Logger.info(TAG, 'scanWifi begin')
    let wifiList: Array = []
    let result: Array = []
    try {
      result = await wifi.getScanResults()
    } catch (err) {
      Logger.info(TAG, `scan info err: ${JSON.stringify(err)}`)
      return wifiList
    }
    Logger.info(TAG, `scan info call back: ${result.length}`)
    for (var i = 0; i < result.length; ++i) {
      wifiList.push({
        ssid: result[i].ssid,
        bssid: result[i].bssid,
        securityType: result[i].securityType,
        rssi: result[i].rssi,
        band: result[i].band,
        frequency: result[i].frequency,
        timestamp: result[i].timestamp
      })
    }
    return wifiList
  }
  connectNetwork(wifiSsid: string, psw: string, securityType : number): void {
    Logger.debug(TAG, `connectNetwork bssid=${wifiSsid} securityType:${securityType}`)
    // securityType 加密类型默认:Pre-shared key (PSK)加密类型
    let deviceConfig: wifi.WifiDeviceConfig  = {
      ssid: wifiSsid,
      preSharedKey: psw,
      isHiddenSsid: false,
      securityType: securityType
    }
    try {
      wifi.connectToDevice(deviceConfig)
      Logger.info(TAG, `connectToDevice success`)
    } catch (err) {
      Logger.error(TAG, `connectToDevice fail err is ${JSON.stringify(err)}`)
    }
    try {
      wifi.addDeviceConfig(deviceConfig)
    } catch (err) {
      Logger.error(TAG, `addDeviceConfig fail err is ${JSON.stringify(err)}`)
    }
  }
}

网络连接主要是通过wifi.connectToDevice(deviceConfig)实现,其中:deviceConfig: wifi.WifiDeviceConfig为WLAN配置信息,在连接网络时必填三个参数ssid、preSharedKey、securityType。

  • ssid:热点的SSID。
  • preSharedKey:热点密钥。
  • securityType:加密类型。

注意:在调用connectToDevice()函数连接网络时,如果网络已经连接,则需要先调用disconnect()接口断开网络后再执行。

至此,你已经完成了扫码即可连接网络的应用。

想了解更多关于开源的内容,请访问:

51CTO 开源基础软件社区

https://ost.51cto.com