Android 蓝牙BLE开发详解

由于年初接手了个有关蓝牙BLE的项目,开始了对蓝牙ble的学习,经过长时间的慢慢学习(学得太慢,太拖了),终于了解了该怎么写蓝牙BLE,现在就给大家分享一下。

一、了解蓝牙BLE

1、什么是BLE

首先,我们来了解一下,什么是蓝牙BLE。
BLE的全名是 Bluetooth Low Energy 就是低功耗蓝牙的意思,支持 API18(Android 4.3)及以上的设备。
它的特点有低成本、短距离、可互操作。
通过GATT协议来进行BLE设备之间的通信。

2、BLE的优势

相比于传统蓝牙的高耗能,这个BLE可以说是低耗能至极,一颗纽扣电池都够用一年。所以,现在穿戴设备的流行,离不开BLE的发展。
蓝牙BLE适合传输数据小但实时性要求比较高的设备。比如手环。

3、BLE设备有什么东西

然后,我们来看一看,一个蓝牙设备里面,有哪些东西。

一个BLE终端可以包含多个Service(服务)

一个Service可以包含多个Characteristic(特征)

一个Characteristic包含一个value和多个Descriptor(描述符),一个Descriptor包含一个Value。

其中,我们要注意的是,每一个Service、Characteristic都会有一个uuid,这是一个唯一值,我们接下来的传输数据,将用到这个。每一个Characteristic都有一个Value,我们就是通过改变这个值,来对设备进行交互的。

小总结

这里引用其他文章的总结,来给大家一个更清晰的理解。

Generic Attribute Profile (GATT)
通过BLE连接,读写属性类小数据的Profile通用规范。现在所有的BLE应用Profile都是基于GATT的。

Attribute Protocol (ATT)
GATT是基于ATT Protocol的。ATT针对BLE设备做了专门的优化,具体就是在传输过程中使用尽量少的数据。每个属性都有一个唯一的UUID,属性将以characteristics and services的形式传输。

Characteristic
Characteristic可以理解为一个数据类型,它包括一个value和0至多个对次value的描述(Descriptor)。
Descriptor 对Characteristic的描述,例如范围、计量单位等。

Service
Characteristic的集合。例如一个service叫做“Heart Rate Monitor”,它可能包含多个Characteristics,其中可能包含一个叫做“heart rate measurement”的Characteristic。


二、开始写代码

1、开启权限

和传统蓝牙一样,BLE我们也需要开启权限

<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>

在6.0以上的系统,我们要加入获取位置权限,不然,搜索不到ble设备的

<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>

除了蓝牙权限外,如果需要BLE feature则还需要声明uses-feature:

<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>

按时required为true时,则应用只能在支持BLE的Android设备上安装运行;required为false时,Android设备均可正常安装运行,需要在代码运行时判断设备是否支持BLE feature:

// 检查手机是否支持BLE,不支持则退出
if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {Toast.makeText(this, "您的设备不支持蓝牙BLE,将关闭", Toast.LENGTH_SHORT).show();finish();
}

2、获得BluetoothAdapter

初始化 Bluetooth adapter, 通过蓝牙管理器得到一个参考蓝牙适配器

final BluetoothManager bluetoothManager =(BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
BluetoothAdapter mBluetoothAdapter = bluetoothManager.getAdapter();

3、判断是否支持蓝牙并提示用户打开蓝牙

if (mBluetoothAdapter == null || !mBluetoothAdapter.isEnabled()) {Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT);
}

4、提示用户允许获得位置权限

在我们搜索之前,还应当有这么一步,去动态申请位置权限得到位置信息,这样才能搜索到设备。

@RequiresApi(api = Build.VERSION_CODES.M)private void initPermission() {if (ContextCompat.checkSelfPermission(MainActivity.this,Manifest.permission.ACCESS_COARSE_LOCATION)!= PackageManager.PERMISSION_GRANTED) {//判断是否需要向用户解释为何要此权限if (!ActivityCompat.shouldShowRequestPermissionRationale(MainActivity.this,Manifest.permission.READ_CONTACTS)) {showMessageOKCancel("你必须允许这个权限,否则无法搜索到BLE设备", new DialogInterface.OnClickListener() {@Overridepublic void onClick(DialogInterface dialogInterface, int i) {ActivityCompat.requestPermissions(MainActivity.this,new String[]{Manifest.permission.ACCESS_COARSE_LOCATION},MY_PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION);}});return;}//请求权限requestPermissions(new String[]{Manifest.permission.WRITE_CONTACTS},MY_PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION);}}private void showMessageOKCancel(String message, DialogInterface.OnClickListener okListener) {new AlertDialog.Builder(MainActivity.this).setMessage(message).setPositiveButton("OK", okListener).setNegativeButton("Cancel", okListener).create().show();}@Overridepublic void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {if (requestCode == MY_PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION) {if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {//用户允许改权限,0表示允许,-1表示拒绝 PERMISSION_GRANTED = 0, PERMISSION_DENIED = -1//这里进行授权被允许的处理//可以弹个Toast,感谢用户爸爸允许了。Toast.makeText(MainActivity.this, "谢谢爸爸", Toast.LENGTH_SHORT).show();} else {//这里进行权限被拒绝的处理,就跳转到本应用的程序管理器Toast.makeText(MainActivity.this, "请开启位置权限", Toast.LENGTH_SHORT).show();Intent i = new Intent("android.settings.APPLICATION_DETAILS_SETTINGS");String pkg = "com.android.settings";String cls = "com.android.settings.applications.InstalledAppDetails";i.setComponent(new ComponentName(pkg, cls));i.setData(Uri.parse("package:" + getPackageName()));startActivity(i);}} else {super.onRequestPermissionsResult(requestCode, permissions, grantResults);}}

我们最终的目标就是让用户同意获取位置权限,当然,还有很多方法可以实现,甚至更简单。

5、搜索设备

好了,经历了漫长的过程,我们来到了搜索设备的时候了。
调用BluetoothAdapter的startLeScan()方法来实现开始搜索。此方法时需要传入 BluetoothAdapter.LeScanCallback参数。搜素到的蓝牙设备都会通过这个回调返回。
注意设定一个搜索时间,超过这个时间后则停止搜索。

 private BluetoothAdapter mBluetoothAdapter;private boolean mScanning;//是否正在搜索private Handler mHandler;//15秒搜索时间private static final long SCAN_PERIOD = 15000;private void scanLeDevice(final boolean enable) {if (enable) {//true//10秒后停止搜索mHandler.postDelayed(new Runnable() {@Overridepublic void run() {mScanning = false;mBluetoothAdapter.stopLeScan(mLeScanCallback);}}, SCAN_PERIOD); mScanning = true;mBluetoothAdapter.startLeScan(mLeScanCallback); //开始搜索} else {//falsemScanning = false;mBluetoothAdapter.stopLeScan(mLeScanCallback);//停止搜索}}

相应的BluetoothAdapter.LeScanCallback如下

 private BluetoothAdapter.LeScanCallback mLeScanCallback = new BluetoothAdapter.LeScanCallback() {@Overridepublic void onLeScan(final BluetoothDevice device, int rssi, byte[] scanRecord) {runOnUiThread(new Runnable() {@Overridepublic void run() {//在这里可以把搜索到的设备保存起来      //device.getName();获取蓝牙设备名字//device.getAddress();获取蓝牙设备mac地址//这里的rssi即信号强度,即手机与设备之间的信号强度。}});}};

6、注册广播

在连接前,首先要注册广播。因为在连接后,我们通过广播传递连接状态。

public final static String ACTION_GATT_CONNECTED = "com.charon.www.BleCar.ACTION_GATT_CONNECTED";public final static String ACTION_GATT_DISCONNECTED = "com.charon.www.BleCar.ACTION_GATT_DISCONNECTED";public final static String ACTION_GATT_SERVICES_DISCOVERED = "com.charon.www.BleCar.ACTION_GATT_SERVICES_DISCOVERED";public final static String ACTION_DATA_AVAILABLE = "com.charon.www.BleCar.ACTION_DATA_AVAILABLE";public final static String READ_RSSI = "com.charon.www.BleCar.READ_RSSI";private static IntentFilter makeGattUpdateIntentFilter() {final IntentFilter intentFilter = new IntentFilter();intentFilter.addAction(BluetoothLeService.ACTION_GATT_CONNECTED);intentFilter.addAction(BluetoothLeService.ACTION_GATT_DISCONNECTED);intentFilter.addAction(BluetoothLeService.ACTION_GATT_SERVICES_DISCOVERED);intentFilter.addAction(BluetoothLeService.ACTION_DATA_AVAILABLE);intentFilter.addAction(BluetoothLeService.READ_RSSI);return intentFilter;}

在onResume()方法中注册广播,mGattUpdateReceiver为用来处理接收到的广播,就是连接的状态。

registerReceiver(mGattUpdateReceiver, makeGattUpdateIntentFilter());

7、了解BluetoothGatt

在真正的连接前,我们有必要来了解一下BluetoothGatt这个类。
BluetoothGatt这个类是用得最多,也是最重要的一个类了。
该类最主要的有以下几个方法

connect() :连接远程设备。
discoverServices() : 搜索连接设备所支持的service。
disconnect():断开与远程设备的GATT连接。
close():关闭GATT Client端。
readCharacteristic(characteristic) :读取指定的characteristic。
setCharacteristicNotification(characteristic, enabled) :设置当指定characteristic值变化时,发出通知。
getServices() :获取远程设备所支持的services。

8、连接

连接的时候,我们可以通过搜索到的mac地址来进行连接。

public boolean connect(final String address) {//4Log.d(TAG, "连接" + mBluetoothDeviceAddress);if (mBluetoothAdapter == null || address == null) {Log.d(TAG,"BluetoothAdapter不能初始化 or 未知 address.");return false;}// 以前连接过的设备,重新连接if (mBluetoothDeviceAddress != null&& address.equals(mBluetoothDeviceAddress)&& mBluetoothGatt != null) {Log.d(TAG,"尝试使用现在的 mBluetoothGatt连接.");if (mBluetoothGatt.connect()) {mConnectionState = STATE_CONNECTING;return true;} else {return false;}}final BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(address);if (device == null) {Log.d(TAG, "设备没找到,不能连接");return false;}mBluetoothGatt = device.connectGatt(this, false, mGattCallback);//真正的连接//这个方法需要三个参数:一个Context对象,自动连接(boolean值,表示只要BLE设备可用是否自动连接到它),和BluetoothGattCallback调用。Log.d(TAG, "尝试新的连接.");mBluetoothDeviceAddress = address;mConnectionState = STATE_CONNECTING;return true;}

BluetoothGattCallback用于传递一些连接状态及结果,在这处理各种连接状态

  private final BluetoothGattCallback mGattCallback = new BluetoothGattCallback() {@Overridepublic void onConnectionStateChange(BluetoothGatt gatt, int status,int newState) {String intentAction;Log.d(TAG, "status" + status);if (newState == BluetoothProfile.STATE_CONNECTED) {//当连接状态发生改变intentAction = ACTION_GATT_CONNECTED;mConnectionState = STATE_CONNECTED;broadcastUpdate(intentAction);//发送广播Log.d(TAG, "连接GATT server");// 连接成功后尝试发现服务//通过mBluetoothGatt.discoverServices(),我们就可以获取到ble设备的所有Services。        gatt.discoverServices();} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {//当设备无法连接intentAction = ACTION_GATT_DISCONNECTED;mConnectionState = STATE_DISCONNECTED;Log.i(TAG, "断开连接");broadcastUpdate(intentAction);   //发送广播}}@Override// 发现新服务,即调用了mBluetoothGatt.discoverServices()后,返回的数据public void onServicesDiscovered(BluetoothGatt gatt, int status) {if (status == BluetoothGatt.GATT_SUCCESS) {//得到所有ServiceList<BluetoothGattService> supportedGattServices = gatt.getServices();for (BluetoothGattService gattService : supportedGattServices) {//得到每个Service的CharacteristicsList<BluetoothGattCharacteristic> gattCharacteristics = gattService.getCharacteristics();for (BluetoothGattCharacteristic gattCharacteristic : gattCharacteristics) {int charaProp = gattCharacteristic.getProperties();//所有Characteristics按属性分类if ((charaProp | BluetoothGattCharacteristic.PROPERTY_READ) > 0) {Log.d(TAG, "gattCharacteristic的UUID为:" + gattCharacteristic.getUuid());Log.d(TAG, "gattCharacteristic的属性为:  可读");readUuid.add(gattCharacteristic.getUuid());}if ((charaProp | BluetoothGattCharacteristic.PROPERTY_WRITE) > 0) {Log.d(TAG, "gattCharacteristic的UUID为:" + gattCharacteristic.getUuid());Log.d(TAG, "gattCharacteristic的属性为:  可写");writeUuid.add(gattCharacteristic.getUuid());}if ((charaProp | BluetoothGattCharacteristic.PROPERTY_NOTIFY) > 0) {Log.d(TAG, "gattCharacteristic的UUID为:" + gattCharacteristic.getUuid() + gattCharacteristic);Log.d(TAG, "gattCharacteristic的属性为:  具备通知属性");notifyUuid.add(gattCharacteristic.getUuid());}}}broadcastUpdate(ACTION_GATT_SERVICES_DISCOVERED);} else {Log.w(TAG, "onServicesDiscovered received: " + status);}}// 读写特性@Overridepublic void onCharacteristicRead(BluetoothGatt gatt,BluetoothGattCharacteristic characteristic, int status) {}@Overridepublic void onDescriptorWrite(BluetoothGatt gatt,BluetoothGattDescriptor descriptor, int status) {}//如果对一个特性启用通知,当远程蓝牙设备特性发送变化,回调函数onCharacteristicChanged( ))被触发。@Overridepublic void onCharacteristicChanged(BluetoothGatt gatt,BluetoothGattCharacteristic characteristic) {}@Overridepublic void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {//mBluetoothGatt.readRemoteRssi()调用得到,rssi即信号强度,做防丢器时可以不断使用此方法得到最新的信号强度,从而得到距离。broadcastUpdate(READ_RSSI);}public void onCharacteristicWrite(BluetoothGatt gatt,BluetoothGattCharacteristic characteristic, int status) {System.out.println("--------write success----- status:" + status);};};

9、状态处理

我们可以在这里,进行各种更新界面,数据操作 。

private final BroadcastReceiver mGattUpdateReceiver = new BroadcastReceiver() {@Overridepublic void onReceive(Context context, Intent intent) {final String action = intent.getAction();//接收广播Log.d(TAG, "action:" + action);if (BluetoothLeService.ACTION_GATT_CONNECTED.equals(action)) {//做连接后的变化} else if (BluetoothLeService.ACTION_GATT_DISCONNECTED.equals(action)) {//未连接} else if (BluetoothLeService.ACTION_GATT_SERVICES_DISCOVERED.equals(action)) {Toast.makeText(ControlActivity.this, "发现新services", Toast.LENGTH_SHORT).show();} else if (BluetoothLeService.ACTION_DATA_AVAILABLE.equals(action)) {} else if (BluetoothLeService.READ_RSSI.equals(action)) {}}};

10、操控ble设备

操控前,我们先看一下我们获得了哪些东西?
通过遍历,我们可以得到Service和Characteristic,每个Characteristic都有唯一的uuid和相应属性,如可读、可写、可提醒。
手机来操控BLE设备,就是通过可写的Characteristic,去改变Characteristic下的值,发送Characteristic给设备,设备接收到信号后,就可以进行相应的操作。

BluetoothGatt
我们可以把它看成Android手机与BLE终端设备建立通信的一个管道,只有有了这个管道,我们才有了通信的前提。

BluetoothGattService
蓝牙设备的服务,在这里我们把BluetoothGattService比喻成班级。而Bluetoothdevice我们把它比喻成学校,一个学校里面可以有很多班级,也就是说我们每台BLE终端设备拥有多个服务,班级(各个服务)之间通过UUID(唯一标识符)区别。

BluetoothGattCharacteristic
蓝牙设备所拥有的特征,它是手机与BLE终端设备交换数据的关键,我们做的所有事情,目的就是为了得到它。在这里我们把它比喻成学生,一个班级里面有很多个学生,也就是说我们每个服务下拥有多个特征,学生(各个特征)之间通过UUID(唯一标识符)区别。

现在,我们已经得到了一个可写的Characteristic,我们知道了其uuid。
在相应控制界面,我们可以通过这个uuid,去得到Characteristic,调用 mBluetoothGatt.writeCharacteristic(characteristic)来发送数据。

private ArrayList<ArrayList<BluetoothGattCharacteristic>> mGattCharacteristics = new ArrayList<>();//需要先把可写的Characteristic添加进去
for (int i = 0; i < mGattCharacteristics.size(); i++) {for (int j = 0; j < mGattCharacteristics.get(i).size(); j++) {if (mGattCharacteristics.get(i).get(j).getUuid().toString().equals("0000fff6-0000-1000-8000-00805f9b34fb")) {//对应的uuidcharacteristic = mGattCharacteristics.get(i).get(j);write(characteristic,changeDate("123"));//写入的数据mBluetoothLeService.writeCharacteristic(characteristic);Log.d(TAG, "发送数据成功");}}}

write()方法,其实就是设置characteristic的值

private void write(BluetoothGattCharacteristic characteristic, byte byteArray[]) {characteristic.setValue(byteArray);}private void write(BluetoothGattCharacteristic characteristic, String string) {characteristic.setValue(string);}

三、总结

ok,经过漫长的学习,我们终于可以连接BLE设备并且可以给它发信号了,让我们再来总结一下。

首先,我们要判断手机是否支持BLE,并且获得各种权限,才能让我们之后的程序能正常运行。
然后,我们去搜索BLE设备,得到它的MAC地址。
其次,我们通过这个MAC地址去连接,连接成功后,去遍历得到Characteristic的uuid。
在我们需要发送数据的时候,通过这个uuid找到Characteristic,去设置其值,最后通过writeCharacteristic(characteristic)方法发送数据。
如果我们想知道手机与BLE设备的距离,则可以通过readRemoteRssi()去得到rssi值,通过这个信号强度,就可以换算得到距离。
只要我们连接上,我们就可以用BluetoothGatt的各种方法进行数据的读取等操作。

四、GitHub

这是我写的最近一个用到BLE的项目,传输数据已经写好了。
https://github.com/Charon1997/BleCar

当然了,GitHub上面也有很多很好的库了,大家可以看一看
https://github.com/Jasonchenlijian/FastBle
https://github.com/dingjikerbo/BluetoothKit

五、结语

PS.由于这是我第一次写博客,可能写得并不是很好,也有部分可能没理解透彻,希望大家一起来讨论。
还有连接多个设备的情况还没写,以后再说吧。

感谢以下大神写的博客对我的帮助,大家也可以看一看
Android BLE 开发心得 UUID获取。
Android BLE浅析
手把手教你Android手机与BLE终端通信–搜索
Android BLE蓝牙4.0开发详解
Android 6.0 扫描不到 Ble 设备需开启位置权限

Android 蓝牙BLE开发详解相关推荐

  1. 转 主流蓝牙BLE控制芯片详解(5):Dialog DA14580

    [导读] Dialog推出的号称全球功率最低.体积最小的SmartBond DA14580蓝牙智能系统级芯片(SoC),与竞争方案相比,该产品可将搭载应用的智能型手机配件,或计算机周边商品的电池巡航时 ...

  2. 主流蓝牙BLE控制芯片详解(1):TI CC2540

    [导读] CC2540是一款高性价比,低功耗的片上系统(SOC)解决方案,适合蓝牙低功耗应用,诸如2.4G 低功耗蓝牙系统.健康医疗.运动和健身设备和消费电子/移动配件等. 关键词:蓝牙BLETI公司 ...

  3. Android网页浏览器开发详解(一)

    Android网页浏览器开发详解(一) 请支持原创,尊重原创,转载请注明出处:http://blog.csdn.net/kangweijian(来自kangweijian的csdn博客) Androi ...

  4. Android蓝牙BLE开发

    最近正在研究Android的蓝牙BLE开发学习,以下是自己做的个人总结 1.1何为BLE? 首先得说明什么是低功耗蓝牙BLE,BLE的全称为Bluetooth low energy(或称Blooth ...

  5. 响应式编程之二:RxJava概述:在Android平台上开发详解

    RxJava 到底是什么 RxJava 好在哪 API 介绍和原理简析 1. 概念:扩展的观察者模式 观察者模式 RxJava 的观察者模式 2. 基本实现 1) 创建 Observer 2) 创建 ...

  6. Android ble开发详解

    前段时间,项目要接入一个ble硬件,以前也没接触过ble开发,在查阅不少资料和踩了不少坑才完成任务,因此打算写一个简单的ble开发步骤,希望能帮助到初次接触ble开发的同学. BLE相关术语简介 GA ...

  7. Android蓝牙BLE开发(一)-基本原理

    公司有需求要做蓝牙BLE传输,经查阅后发现关于BLE开发的知识还真不多.对于BLE开发的同学来说,我的建议是先快速了解一下BLE的基本原理,磨刀不误砍柴工. 什么是BLE BLE全称Bluetooth ...

  8. 转主流蓝牙BLE控制芯片详解(1):TI CC2540

    [导读] CC2540是一款高性价比,低功耗的片上系统(SOC)解决方案,适合蓝牙低功耗应用,诸如2.4G 低功耗蓝牙系统.健康医疗.运动和健身设备和消费电子/移动配件等. 蓝牙BLE的概念近年来十分 ...

  9. 蓝牙|BLE Mesh详解

    版权声明:本文为博主原创文章,未经博主允许不得转载. https://blog.csdn.net/suxiang198/article/details/80160984 What is BLE mes ...

最新文章

  1. 计算机网络与综合布线系统设计,【方案】某医院计算机网络综合布线系统设计...
  2. 【渝粤题库】陕西师范大学210032学前心理学 作业(专升本)
  3. 2013-11-11 Oracle 课堂测试 练习题 例:BULK COLLECT及return table
  4. mysql(mariadb)的安装与使用,mysql相关命令,mysql数据类型
  5. 含有swap的c语言冒泡排序6,c#中写个Swap方法来实现冒泡排序 看看哪里错了
  6. Swift 4.2进入最后开发阶段,为Swift 5铺平道路
  7. Atitit.HTTP 代理原理及实现 正向代理与反向代理attilax总结
  8. JVM内存模型及分区
  9. TK1装kuboki的USB驱动和TK1的无线网卡驱动
  10. Hibernate完全自学手册
  11. linux上传文件夹工具,[转] psftp(linux简易上传上载工具)的用法及常用命令
  12. ★RFC标准库_目录链接
  13. ps快捷键大全(表格汇总)
  14. Windows错误恢复无限重启;开机后灯亮风扇转下停下
  15. 无法创建目录d oracle,Qt无法创建目录(Qt could not create directory)
  16. 新世纪大学英语(第二版)综合教程第一册 Unit 3 (中英翻译和重点单词)
  17. 转:爬虫入门 手写一个Java爬虫
  18. 线性表的顺序存储结构及基本操作
  19. python免费自学资源(视频+图文)
  20. 内存分配者-动态内存

热门文章

  1. 芋道源码的周八(2018.05.20)
  2. idea怎么导入maven项目
  3. 美发美容门店如何用博卡、美管加、美自的小程序来锁客?
  4. swagger入门使用说明
  5. 360全景图转换为天空盒图
  6. 机器学习 | 使用TensorFlow搭建神经网络实现鸢尾花分类
  7. 写了一个图片横向滚动且首尾相接的JS组件
  8. 学习笔记(02):8小时Python零基础轻松入门-实例和属性
  9. 蚂蚁云原生应用运行时的探索和实践 - ArchSummit 上海
  10. 数据库中的字符char、nchar、nvarchar、nchar