0%


放假前在公司看到有同事在玩树莓派,觉得挺有意思,之前也曾经了解过,但是一直都没有下定决定买一个玩玩,这次也算是被同事拉下水吧,趁着这次放假的机会,在淘宝上买了个最新的树莓派3B+ 来学习一下,从小白开始树莓派的折腾之旅。想到自己有linux的基础应该上手不会太难,没想到还是踩了不少的坑,在购买的时候淘宝店家说会有技术支持,结果放假了完全不回消息😔,不过遇到的问题还是自己慢慢踩坑爬出来了,在这里做个简单的记录。


折腾树莓派的准备工作

贴散热贴

拿到树莓派的主板后,先贴好散热贴,总共3个散热贴,有2个带脚的散热贴和1个不带脚并有树莓标志的散热贴,主要是3个地方,CPU,GPU和内存,其中CPU和GPU在主板的正面,贴两个带脚的散热片,正中有两个近似于正方形的方块,大的是CPU,小的是GPU,而内存在主板的背面,贴不带脚并有树莓标志的散热贴。

连接风扇

树莓派的风扇主要有2根连接线,红色和黑色,红色接4针脚,黑色接6针脚,对应树莓派上的两列针脚区域,第二列的第二和第三个针脚。如果树莓派通电,接入后风扇就可以马上转起来。

安装系统

将SD卡插入读卡器,用系统烧录工具,Mac为balenaEtcher,下载好官网的debian系统的zip文件,然后烧录到SD卡中。然后把SD卡插入树莓派的SD卡槽,将树莓派接上电源,这时红色灯光常量,代表电源接通正常。树莓派读取SD卡,自动启动系统,系统启动好后绿色灯光闪烁,代表系统已经启动完毕。

接入局域网

由于mac无法插入网线直接访问树莓派,可以把树莓派的网线插入到路由器上,我们可以直接在路由器的设备管理界面看到树莓派的IP地址,接下来就可以用命令行工具ssh或者图形化界面vnc远程访问到我们的树莓派。

注意事项

作为小白,在前期的准备工作也遇到了坑😔,由于系统下错了,下成了虚拟机的树莓派镜像文件(这是个ISO文件)无语的是balenaEtcher烧录系统到SD卡的时候也没有任何报错,导致SD卡插入到SD卡槽后无法正常启动系统(然而我当时并不知道),所以接入到局域网中发现路由器的设备管理界面根本没有看到树莓派这个设备。后面看到树莓派指示灯相关的内容后,才明白原来系统都没跑起来。这里推荐使用Raspbian Stretch Lite版的系统,比较稳定,没有多余的功能,纯命令行的系统(之前安装过带图形界面的,发现不太好用,用到的也少,因为大部分功能都是命令行下实现的)。如果你发现插入SD卡只有红色灯亮,不是没有读到SD卡,就是系统启动失败,同样关机时执行关机命令后,等到绿色灯熄灭才是系统正常关闭,再拔掉电源。下面对常用的树莓派指示灯含义做一个总结:

  1. 绿色灯闪烁,读取到了SD卡并且也正常启动了系统
  2. 红色灯常亮,电源接入正常
  3. 橙色灯亮全双工状态,不亮半双工状态

    ssh连接

    我认为最简单的方法如下:
  4. 树莓派默认没有开启ssh,把sd卡用读卡器插到电脑上,创建一个名为ssh的文件
  5. 直接用网线连在家里的路由器上,通过路由器后台查看ip地址连接
    ssh pi@xxx.xxx.xxx.xxx
    默认密码为raspberry

    设置中文显示

    打开系统设置sudo raspi-config
  • Localisation Options -> Change Locale
  • 按空格选择
  • 去掉en_GB.UTF-8 UTF-8
  • 勾上en_US.UTF-8 UTF-8、zh_CN.UTF-8 UTF-8、zh_CN.GBK GBK
  • 下一屏幕默认语言选zh_CN.UTF-8

    shadowsocks配置

  1. 安装python的依赖库
    sudo apt-get install python-pip python-gevent python-m2crypto
  2. 利用python安装shadowsocks,注意别忘记sudo,否则可能造成安装路径有问题而无法使用sslocal的命令
    i
  3. 修改/usr/local/lib/python2.7/dist-packages/shadowsocks/crypto/openssl.py,将此文件中的52行和111行中的cleanup替换为reset,否则可能执行启动命令报错
  4. 创建shadowsocks的配置文件ss_conf.json,需根据shadowsocks服务器的信息替换,内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "server":"server_ip",
    "server_port":server_port,
    "local_address":"127.0.0.1",
    "local_port":1080,
    "password":"password",
    "timeout":600,
    "method":"aes-256-cfb"
    }
  5. 最后执行启动命令
    sudo sslocal -c ./ss_conf.json -d start

    privoxy配置

    在树莓派下命令行梯子是十分有必要的,给http,https设置代理使用privoxy。
  6. 安装privoxy
    sudo apt install privoxy
  7. 修改配置文件
    sudo vi /etc/privoxy/config
    783行,去掉注释
    listen-address 127.0.0.1:8118
    1336行,去掉注释,注意与ss地址端口号保持一致
    forward-socks5t / 127.0.0.1:1080
  8. 配置开机启动
    sudo vi /etc/profile
    export https_proxy=http://127.0.0.1:8118
    export http_proxy=http://127.0.0.1:8118
  9. 重启
    sudo systemctl restart privoxy

    开机启动设置

    这个步骤估计是我爬坑时间最长的地方,写入命令到rc.local配置文件中即可实现开机启动的功能,然而到树莓派上始终无法实现,树莓派已经不支持这种自启方式了。
  • 创建一个文件写入自启命令
    sudo vi /etc/rc.local
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #!/bin/sh -e
    #
    # rc.local
    #
    # This script is executed at the end of each multiuser runlevel.
    # Make sure that the script will "exit 0" on success or any other
    # value on error.
    #
    # In order to enable or disable this script just change the execution
    # bits.
    #
    # By default this script does nothing.

    # Print the IP address
    _IP=$(hostname -I) || true
    if [ "$_IP" ]; then
    printf "My IP address is %s\n" "$_IP"
    fi
    sudo sslocal -c /etc/ss.json -d start
    exit 0
  • 创建一个服务文件
    sudo vi /lib/systemd/system/rc.local.service
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #  This file is part of systemd.
    #
    # systemd is free software; you can redistribute it and/or modify it
    # under the terms of the GNU Lesser General Public License as published by
    # the Free Software Foundation; either version 2.1 of the License, or
    # (at your option) any later version.

    # This unit gets pulled automatically into multi-user.target by
    # systemd-rc-local-generator if /etc/rc.local is executable.
    [Unit]
    Description=/etc/rc.local Compatibility
    ConditionFileIsExecutable=/etc/rc.local
    After=network.target

    [Service]
    Type=forking
    ExecStart=/etc/rc.local start
    TimeoutSec=0
    RemainAfterExit=yes
    GuessMainPID=no

    修改apt源

    树莓派默认的apt源在英国,速度非常慢,即使我配置了香港的http代理,还是很慢,换成了阿里的源速度还不错。
    修改apt源的配置文件
    sudo vi /etc/apt/sources.list
    注释掉原来的源,添加
    1
    2
    deb http://mirrors.aliyun.com/raspbian/raspbian/ jessie main non-free contrib rpi
    deb-src http://mirrors.aliyun.com/raspbian/raspbian/ jessie main non-free contrib rpi
    更新索引清单
    1
    sudo apt-get update
    更新依赖库
    1
    sudo apt-get upgrade -y

    U盘挂载和卸载

    挂载u盘

    sudo mount -o umask=0000 /dev/sda1 ~/Downloads
  • o 配置挂载可选项,umask=0000对所有用户都可读可写可执行
    /dev/sda1 usb分区路径
    fdisk -l可查看分区
    ~/Downloads 要挂载的文件夹路径

    卸载u盘

    sudo umount -l ~/Downloads
  • l 强制卸载,避免busy的错误提示

    dlna服务器搭建

  1. 安装dlna
    apt-get install minidlna
  2. 修改配置文件
    sudo vi /etc/minidlna.conf
    media_dir=/home/pi/Downloads
    修改media_dir的变量指向资源路径,这里我是挂载U盘上的资源,遇到了一个坑,需要刷新资源索引,耗了很长时间,网上很多说dlna刷新命令是sudo service minidlna force-reload我发现并没有作用,以为是配置文件的问题改了很多次,还是不起作用,后来发现资源刷新的正确姿势是这样:
    sudo minidlna -R
    然后重启服务:
    sudo service minidlna restart
    设置开机启动
    sudo update-rc.d minidlna defaults
    访问http://树莓派ip地址:8200查看当前服务器上的资源类型和数量

    samba服务器搭建

  3. 安装smaba
    sudo apt-get install samba samba-common-bin
    第二个命令也遇到过一个坑,就是smaba的部分依赖库已经安装,且版本高于smaba依赖的版本,这个时候命令行是不会安装smaba的,网上说需要把这些已经安装的依赖库卸载掉重新安装,但是我试了这种方式仍旧无效,最后我根据当前的树莓派系统版本找到了国内的镜像源(阿里),切换apt源以后再安装就可以了。
  • samba服务器默认支持隐藏文件的展示,用本地编辑器直接编辑脚本,调试运行是十分方便的,推荐刚入门不太习惯vi,nano的朋友都搭建一个,不过samba服务器的搭建有很多坑,第一次安装没啥问题,卸载重装就有问题了,折腾好久,记一次踩坑过程:
    1
    2
    3
    4
    5
    dpkg: error processing package xxx
    dpkg: dependency problems prevent configuration of xxx
    xxx depends on xxx; however:
    Package xxx is not configured yet.
    Sub-process /usr/bin/dpkg returned an error code (1)
    貌似这个错误不止在重装samba会遇到,有时apt软件升级也会遇到
  • 解决步骤
    执行以下命令:
    1
    2
    3
    4
    5
    6
    7
    8
    sudo mv /var/lib/dpkg/info/ /var/lib/dpkg/info_old/
    sudo mkdir /var/lib/dpkg/info/
    sudo apt-get update
    sudo apt-get -f install
    sudo mv /var/lib/dpkg/info/* /var/lib/dpkg/info_old/
    sudo rm -rf /var/lib/dpkg/info
    sudo mv /var/lib/dpkg/info_old/ /var/lib/dpkg/info/
    sudo apt-get update
    再执行刚才的安装或升级命令即可
  1. 修改配置文件
    sudo vi /etc/samba/smb.conf
    1
    2
    3
    4
    5
    6
    7
    8
    [pi]           #共享文件的名称, 将在网络上以此名称显示
    path = /home/pi #共享文件的路径
    valid users = pi #允许访问的用户
    browseable = yes #允许浏览
    writable = yes #可写
    writelist = pi #可写用户
    create mask = 0777 #可读可写权限
    directory mask = 0777 #可读可写权限
  • 创建共享文件夹
    mkdir ~/Downloads
    如果树莓派的内存卡不够用,我们一般需要挂载外置的U盘或硬盘
  • 创建挂载文件夹(挂载点)
    mkdir ~/Downloads/diskMount
  • 挂载硬盘或U盘
    sudo mount -o umask=0000 /dev/sda5 ~/Downloads/diskMount/
  • 如果是exfat格式的硬盘加上格式参数(兼容windows和mac)
    sudo mount -t exfat -o umask=0000 /dev/sda5 ~/Downloads/diskMount/
    挂载完后发现没有权限访问,需要对该文件夹下的所有文件赋予权限
    sudo chmod -R 777 ~/Downloads
    权限修改后需重启samba服务,文件即可访问
    sudo service smbd restart
    smaba4.9.5在服务启动后会在共享文件路径下生成color IA64 W32ALPHA W32MIPS W32PPC W32X86 WIN40 x64一堆文件夹,是默认配置了printer打印机引起的,这些文件夹放对应的驱动文件,如果我们注释了打印机相关的配置就不会在共享文件路径下生成这一堆文件夹
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #[printers]
    # comment = All Printers
    # browseable = no
    # path = /var/spool/samba
    # printable = yes
    # guest ok = no
    # read only = yes
    # create mask = 0700

    # Windows clients look for this share name as a source of downloadable
    # printer drivers
    #[print$]
    # comment = Printer Drivers
    # path = /var/lib/samba/printers
    # browseable = yes
    # read only = yes
    # guest ok = no
  1. 将默认用户添加到samba
    sudo smbpasswd -a pi
  2. 重启smaba
    sudo service samba restart
  3. 配置开机启动
    systemctl enable smdb

ftp服务器搭建

ftp方便文件传输,而且vsftpd搭建服务器只有400k

  1. 安装sftp
    sudo apt-get install vsftpd
  2. 修改配置文件
    sudo vim /etc/vsftpd.conf
    配置可选项如下:
    禁止匿名用户登录:anonymous_enable=NO
    配置用户可以写权限:write_enable=YES
    配置uMask:local_umask=022(077不支持断点续传,修改为022)
  3. 重启
    sudo service vsftpd restart

    折腾摄像头

    我在购买树莓派主板的时候,也购买了官方的摄像头,它是usb接口,插入树莓派的usb接口后,输入命令:ls /dev如果此时能识别到video0的设备时,代表树莓派已识别。
    此时我们使用lsusb命令同样可以查看这个设备的idVendor和idProduct

    拍摄照片

    拍摄一张照片
    sudo fswebcam image.jpg
    预览
    gpicview image.jpg

    搭建视频流服务器

    安装依赖库和编译库

    sudo apt install libjpeg8-dev
    sudo apt install imagemagic
    sudo apt install libv4l-dev
    sudo apt install cmake

    安装mjpg-streamer

    克隆项目到本地并编译
    sudo git clone https://github.com/jacksonliam/mjpg-streamer.git
    cd mjpg-streamer/mjpg-streamer-experimental
    make all
    sudo make install
    启动流服务器
    ./mjpg_streamer -i "./input_uvc.so" -o "./output_http.so -w ./www"
    此时报错,树莓派官方的摄像头不支持v4l的驱动,需安装UV4L兼容驱动,解决方法是
    添加软件源
    curl https://www.linux-projects.org/listing/uv4l_repo/lrkey.asc | sudo apt-key add -
    修改apt-get的源列表文件/etc/apt/sources.list,在文件末尾添加
    deb https://www.linux-projects.org/listing/uv4l_repo/raspbian/ wheezy main
    再安装兼容驱动
    sudo rpi-update
    sudo apt-get update
    sudo apt-get install uv4l uv4l-raspicam
    sudo service uv4l_raspicam restart
    sudo pkill uv4l
    sudo apt-get update
    sudo apt install uv4l-uvc
    sudo apt install uv4l-xscreen
    sudo apt install uv4l-mjpegstream
    启动流服务器
    ./mjpg_streamer -i "./input_uvc.so" -o "./output_http.so -w ./www"
    发现仍然报错
    1
    2
    Unable to set format: 1196444237 res: 640x480
    Init v4L2 failed !! exit fatal
    mjpg-stream支持JPEG和YUV两种格式,默认采用JPEG,这里是因为树莓派官方的摄像头不支持JPEG
    解决方案1:
    cd mjpg-streamer-experimental/plugins/input_uvc/
    sudo vi input_uvc.c
    format = V4L2_PIX_FMT_MJPEG改为format = V4L2_PIX_FMT_YUYV
    解决方案2:
    用命令参数-y使用YUV编码
    ./mjpg_streamer -i "./input_uvc.so -y" -o "./output_http.so -w ./www"
    此时看到启动信息已经启动,但是有一堆error
    用命令参数-n来消除error
    ./mjpg_streamer -i "./input_uvc.so -y -n" -o "./output_http.so -w ./www"

访问树莓派的ip:8080就可以看到采集的视频了


又是很久没有写博客了,最近在搞漫画爬虫项目时发现某些网站把window.scrollTo方法都屏蔽了,由于大多数漫画网站都是在网页滚动时动态加载,所以如果无法实现网页滚动是没办法爬取到所有漫画的,幸好还好有google爸爸维护的puppeteer爬虫框架,这个强大的框架当然是无视这些方法屏蔽的,它可以实现鼠标按下,滑动,弹起等一系列自动化操作,那么利用这些方法我们就可以实现网页滑动了,在此,针对原生滑动方法都被屏蔽的情况下,利用puppeteer实现网页滑动做一个记录。如果有更好的方法的小伙伴欢迎一起交流讨论。


前几天我维护代码的时候,突然发现爬虫代码报错了😂

打开该网站的控制台调试,输入window.scrollTo(0, 800);

控制台输出结果为:Uncaught TypeError: window.scrollTo is not a function

然后再打印一下window这个实例:console.log(window)

可以看到scrollTo: null

估计是网站为了反爬取而采用的措施,直接把window.scrollTo等原生方法赋值为空。

初始化

获取浏览器页面实例

1
2
let browser = await puppeteer.launch({ headless: true })
let page =await browser.newPage()

模拟PC的尺寸和UA

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 模拟pc设备mac
*/
exports.viewPort = {
width: 1080,
height: 1920
}
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36'
/**
* 设置屏幕尺寸和UA
*/
await page.setViewport(viewPort);
await page.setUserAgent(userAgent);

实现网页滑动

利用鼠标动作组合

1
2
3
4
5
6
7
8
9
10
exports.scrollPage = async function (distance) {
await page.mouse.move(Spider.viewPort.width/2, Spider.viewPort.height * 0.9, {steps: 10})
await page.mouse.down(Spider.viewPort.width/2, Spider.viewPort.height * 0.9)
if (Spider.viewPort.height * 0.9 - distance > Spider.viewPort.height * 0.05){
await page.mouse.move(Spider.viewPort.width/2, Spider.viewPort.height * 0.9 - distance, {steps: 10})
}else{
await page.mouse.move(Spider.viewPort.width/2, Spider.viewPort.height * 0.05, {steps: 10})
}
await page.mouse.up()
}

distance是y轴滑动的垂直距离,根据屏幕尺寸计算出鼠标滑动到的组合操作的起始位置x和y的坐标点,然后模拟鼠标按下操作。因为我们需要爬取的网页,屏幕高度的5%是顶部固定的导航栏,所以结束位置的y轴坐标应该大于屏幕高度的5%。由于我们的网页滑动操作普遍是鼠标由下往上滑动,所以我们需要判断一下动作当前的起始位置的y轴坐标减去滑动的垂直距离后的y轴坐标值是否大于屏幕高度的5%,如果是,结束位置的y轴坐标为鼠标按下操作的y轴坐标减去滑动的垂直距离,否则结束位置的y轴坐标即为屏幕高度的5%。将window.scrollTo方法替换为我们自定义的scrollPage方法,传入滑动的垂直距离即可。


最近公司想搭建自动化构建平台,由于以前自己在空闲时间折腾过jenkins搭建相关的东西,领导就把这个任务交给了我,虽然以前在自己的电脑上搭建过,由于时间过去太久了,当时又没做记录,搭建的时候还是踩了不少坑,不过在google爸爸的帮助下,翻阅各种资料,最后都爬坑解决,所以这里对再此搭建做一个记录,避免以后遗忘。


连接远程服务器

  1. ssh -p 端口号 服务器用户名@ip
  2. 输入密码

    安装jenkins相关依赖包

    安装java

  • java -version
  • 如果没有显示版本号则需要安装,安装命令sudo yum install java

    安装jenkins

  • 添加安装源
    sudo wget -O /etc/yum.repos.d/jenkins.repo http://jenkins-ci.org/redhat/jenkins.repo
    sudo rpm --import http://pkg.jenkins-ci.org/redhat/jenkins-ci.org.key
  • 安装
    yum install jenkins

    jenkins安装目录

    /var/lib/jenkins/

    jenkins配置文件目录

    /etc/sysconfig/jenkins
  • 主要参数:
  • JENKINS_HOME:Jenkins的主目录,Jenkins工作目录,储存文件的地址,Jenkins的插件,生成的文件都在这个目录下
  • JENKINS_USER:Jenkins的用户,拥有$JENKINS_HOME和/var/log/jenkins的权限
  • JENKINS_PORT:Jenkins的端口,默认端口是8080

    启动jenkins服务

    sudo service jenkins start
  • 此时在浏览器中输入:http://<服务器ip>:8080/ 就可以进入Jenkins界面,8080是默认端口号

    停止jenkins服务

    sudo service jenkins stop

    创建项目并配置

  1. 选择New任务,新建一个项目
  2. 输入一个项目名称
  3. 选择构建一个自由风格的软件项目,点击左下角的ok
  4. 输入项目的文字描述
  5. 勾选丢弃旧的构建,设置保留的构建天数和保留的最大构建数
  6. 勾选参数化构建过程,添加不同类型的参数
  • 布尔值参数
  • 文本参数

  • 选项参数
  • git Parameter
  • dynamic Parameter
    打包前,要配置这些参数,配置界面展示效果如下:


  1. Source Code Management配置git远程代码仓库
  • 复制项目地址并填入Repository URL
  • Credentials,点击add,选择jenkins账户(jenkins统一用这个),该账户需加入项目,拥有代码拉取权限
  • 配置成功后不会有红字错误提示
  • 有的项目克隆比较慢,可能会有克隆超时导致构建失败的问题,可以把这个超时时间改大一点,需要添加一个配置
  • Additional Behaviours点击Add
  • 选择Advanced clone behaviours
  • 修改Timeout (in minutes) for clone and fetch operations的值,改大一点60
  1. Build Triggers配置轮询构建和定时构建

    定时构建: 无论git中有无提交,均执行定时化的构建任务

    轮询构建: 查看git中是否有提交,如果有,则执行构建任务
    每隔5分钟构建一次
    H/5 * * * *
    每两小时构建一次
    H H/2 * * *
    每天中午下班前定时构建一次
    0 12 * * *
    每天下午下班前定时构建一次
    0 18 * * *
  2. Build Environment设置构建名称
  • 勾选Set Build Name,这里填写的参数对应上面设置的参数,保持名称一致
    打包后展示的效果如下:
  1. Build构建方式(Android以gradle为例)
  • 点击add build step,选择Invoke Gradle script
    勾选Invoke Gradle,选择Gradle Version为gradle4.6
  • 填写task,配置gradle打包命令clean assemble${BUILD_TYPE} --stacktrace,这里的BUILD_TYPE引用前面配置的
  • 点击Advanced选项,勾选Pass all job parameters as Project properties,将上面的配置参数全部传入gradle.properties配置文件中,实现参数化打包
  1. Post-build Actions
  • 点击add Post-build Actions,选择归档成品,提取打包后的apk文件
  • Files to archive填入过滤规则
  • 在所有文件中过滤apk文件
  • 点击add Post-build Actions,配置打包后的邮箱通知

    一般情况下使用默认配置就可以,直接引用全局配置中已经配置过的参数。

    如果要修改接收人邮箱,编辑Project Recipient List,多个邮箱地址,空格隔开
    如果要修改发送邮件的内容,可编辑以下几项
  • Content Type 文本类型,可配置html、纯文本、纯文本和html
  • Default Subject 邮件标题
  • Default Content 邮件内容
    打包日志压缩并添加附件,Attach Build Log选择Compress and Attach Build Log

    邮件发送触发器配置,点击Advanced选项,编辑Triggers选项,点击Add Trigger,可以选择触发器类型,不管构建成功失败总是触发(默认),成功时触发,失败时触发
  • 此时项目配置完毕

    打包流程

  1. 选择要打包的项目
  2. 点击Build with Parameters,进入打包前的参数配置流程
  3. 配置好打包的参数,点击build开始打包
  4. 点击打包进程可查看相关信息
  5. 点击Console Output可查看打包日志
  6. 如果配置了邮件通知,打包完成后邮箱会收到一封邮件


最近公司非常忙,又是很长一段时间没有写博客了,虽然公司的项目使用不到ReactNative和nodejs相关的知识,但我仍然看好跨平台技术,毕竟以后是大前端的发展趋势。所以依旧不能停下学习的脚步,多做技术储备,提升核心竞争力,继续挖全栈的坑,拓展自己的技术栈。毕竟有的时候站在一个不同的角度,可能有不同的看法。之前做了一个获取网易腾讯免费漫画的app,后台用nodejs+mongodb+koa搭建,在展示漫画弹幕的时候,需要用到长链接实时展示当前最新的弹幕内容。之前对socket.io在react-native上的使用不太了解,借此机会学习一下相关的知识并做一个总结和记录。


效果如下:
效果gif

服务端

添加依赖

1
2
npm install socket.io
npm install mongoose

引入socket.io对象

1
var Socketio = require('socket.io')

引入mongoose对象

1
var mongoose = require('mongoose')

创建和引入mongoose的数据库表对象

  • 创建users表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    'use strict'
    var mongoose = require('mongoose')
    var UsersSchema = new mongoose.Schema({
    name: String,
    createdAt: {
    type: Date,
    default: Date.now()
    },
    updateAt: {
    type: Date,
    default: Date.now()
    }
    })
    UsersSchema.pre('save', function (next) {
    if (this.isNew) { // 如果这是个新的数据
    this.createdAt = this.updateAt = Date.now() // 设置当前时间
    } else {
    this.updateAt = Date.now() // 否则刷新更新时间
    }
    next()
    })

    module.export = mongoose.model('users', UsersSchema)
  • 创建messages表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    'use strict'
    var mongoose = require('mongoose')

    var MessagesSchema = new mongoose.Schema({
    text: String,
    user: Object,
    chatId: String,
    createdAt:{
    type:Date,
    default:Date.now()
    },
    updateAt:{
    type:Date,
    default:Date.now()
    }
    })
    MessagesSchema.pre('save', function(next) {
    if(this.isNew){ // 如果这是个新的数据
    this.createAt = this.updateAt = Date.now() // 设置当前时间
    } else {
    this.updateAt = Date.now() // 否则刷新更新时间
    }
    next()
    })

    var MessageModel = mongoose.model('messages', MessagesSchema)
    module.export = MessageModel
  • 引入表对象

    1
    2
    var Users = mongoose.model('users')
    var Messages = mongoose.model('messages')
  • 初始化

    1
    2
    var users = {}; // 客户端用户id和socket id映射表
    var chatId = 1; // 聊天室id

    使用socket.io通信

  • 监听connection消息,与客户端建立连接

    1
    2
    3
    4
    5
    6
    var websocket = Socketio(server)
    websocket.on('connection',(socket) => {
    console.log('连上了')
    socket.on('userJoined', (userId) => onUserJoined(userId, socket));
    socket.on('message', (message) => onMessageReceived(message, socket));
    });
  • 监听userJoined消息,用户进入聊天室触发,客户端向服务端发送userJoined消息,第一次进入的用户,客户端发送的用户id为空,服务端在回调中获得用户id和客户端的socket对象,如果用户id为空,插入数据库,取得用户id,并向客户端发送用户id,客户端将这个用户id保存下来,下一次发送这个id。服务端刷新用户数组中该客户端对应的用户id,如果用户id不为空直接执行这一步,最后发送数据库中保存的该聊天室发送过的消息。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    async function onUserJoined(userId, socket) {
    console.log('有用户进来了' + userId)
    // 新用户userId为空,向数据库users表插入id
    if (!userId) {
    var userData = new Users({})
    let user = await userData.save()
    socket.emit('userJoined', user._id);
    users[socket.id] = user._id;
    } else { //否则刷新用户id
    users[socket.id] = userId;
    }
    // 发送之前的消息
    await sendExistingMessages(socket);
    }
    // 发送之前的消息
    async function sendExistingMessages(socket) {
    // 查数据
    await Messages.find({
    chatId: chatId
    }).sort({createdAt:1}).exec((err, messages) => {
    // 如果没有任何消息,直接返回
    if (!messages.length){
    return;
    }
    socket.emit('message', messages.reverse());
    })

    }
  • 监听message消息,在接收到客户端发送的消息时触发,服务端在回调中获得消息和发送的socket对象,从用户数组中取得该socket对象对应的客户端的用户id,用户id为空时可能因服务端重启造成用户数组的数据丢失,直接返回不处理,最后保存这条消息到数据库,发送这条消息给除了当前客户端用户的所有用户

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // 用户接收到消息
    async function onMessageReceived(message, socket) {
    console.log(message.text)
    var userId = users[socket.id];
    // 用户id为空返回
    if (!userId) {
    console.log('没有这个用户啦')
    return;
    }
       // 保存消息并发送
    await sendAndSaveMessage(message, socket);
    }
    // 保存消息到数据,发送消息给除了当前用户的所有的用户
    async function sendAndSaveMessage(message, socket) {
    // 创建消息数据
    var messageData = new Messages({
    text: message.text,
    user: message.user,
    createdAt: new Date(message.createdAt),
    chatId: chatId
    });
    var message = await messageData.save()
    // 发送消息给除了当前用户的所有的用户
    socket.broadcast.emit('message', message);
    }

    koa中集成socket.io

    如果服务端已经集成了koa需要再集成socket.io
    传入koa的server对象,外面包一层Socketio对象导出,再注册监听端口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    module.exports = function (server) {
    let websocket = Socketio(server)
    websocket.on('connection', (socket) => {
    clients[socket.id] = socket;//保存客户端对象到列表
    console.log('连上了')
    socket.on('userJoined', (userId) => onUserJoined(userId, socket));
    socket.on('message', (message) => onMessageReceived(message, socket));
    });
    }
    // xxx为socketio的module路径
    server.listen(1234)
    require('xxx')(server)

    客户端

    这里以react-native举例

引入socket.io

1
import SocketIOClient from 'socket.io-client';

初始化socket.io

1
this.socket = SocketIOClient('http://localhost:3000'); // 设置服务端的地址和端口号

使用socket.io通信

第一次进入时发送空的用户id给服务端,监听userJoined消息,保存服务端给的用户id,第二次发送本地保存的用户id给服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AsyncStorage.getItem(USER_ID)
.then((userId) => {
// 本地没有用户id
if (!userId) {
// 发送空的用户id
this.socket.emit('userJoined', null);
// 监听userJoined消息,保存服务端给的用户id
this.socket.on('userJoined', (userId) => {
AsyncStorage.setItem(USER_ID, userId);
this.setState({ userId });
});
} else {
// 发送本地保存的用户id
this.socket.emit('userJoined', userId);
this.setState({ userId });
}
})
.catch((e) => alert(e));

监听服务端发送的message消息并刷新

1
2
3
4
5
6
7
8
this.socket.on('message', this.storeMessages); //监听message消息
storeMessages(messages) {
this.setState((previousState) => {
return {
messages: this.msgs.append(previousState.messages, messages),
};
});
}

点击发送按钮,向服务端发送消息

1
2
3
4
onSend(message) {
this.socket.emit('message', message);
this.storeMessages(messages);
}

客户端的ui用了一个封装的库GiftedChat来简单展示一下。demo已打包上传,最后贴一个传送门


由于公司的项目需求,需要在一台搭载Android系统的显示设备的两块屏幕上显示不同的内容,该设备拥有一块主屏和一块辅屏,这里就需要使用双屏异显的技术,以前没有接触过这一块的知识,经过一段时间的熟悉后顺利实现双屏异显,其实Android实现双屏异显并不难而且Google提供了现成的API,用Presentation这个类去实现,只不过没接触过的时候觉得挺高大上的,在此做一个简单的记录。


Presentation是一个特殊的dialog,可以在辅屏上显示与主屏不一样的内容,设置里面也可以把“模拟第二屏”的功能打开,运行的时候会在左上角显示一个小窗口来显示第二屏的显示内容,Presentation在创建的时候需要和Display对象相关联,关联之前需要先获取显示设备,DisplayManager或MediaRouter都可以来获取显示设备。下面用最简单的两个例子来介绍,在主屏和辅屏上都显示一个不同内容的TextView文本。

设置权限

1
2
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW" />

DisplayManager实现

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyPresentation extends Presentation {

public MyPresentation(Context outerContext, Display display) {
super(outerContext,display);

}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.second_screen);

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class MainActivity extends AppCompatActivity {

private MyPresentation myPresentation;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

DisplayManager manager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE);
Display[] displays = manager.getDisplays();
if (presentationDisplays.length > 1) {
// displays[0] 主屏 displays[1] 辅屏
myPresentation = new MyPresentation(this,displays[1]);
myPresentation.getWindow().setType(
WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
myPresentation.show();
}
}

@Override
public void onBackPressed() {
onDestroy();
//完全退出应用,取消双屏异显
Intent startMain = new Intent(Intent.ACTION_MAIN);
startMain.addCategory(Intent.CATEGORY_HOME);
startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(startMain);
System.exit(0);
}

这里的activity_main和second_screen就是一个xml布局,放了一个不同内容的TextView文本,代码就不贴了,可以看到DisplayManager的getDisplays方法可以返回一个可显示的Display数组,也就是当前可以连接的屏幕列表,第一个元素是主屏的Display对象,第二个元素是辅屏的Display对象,然后把辅屏的Display对象传入到Presentation的构造函数中绑定,设置窗口类型就可以展示了。

MediaRouter实现

这里就只贴辅屏的Display对象获取并传入到Presentation的构造函数中绑定的代码,因为其余部分都是一样的。

1
2
3
4
5
6
7
8
9
10
11
MediaRouter mediaRouter = (MediaRouter) getSystemService(Context.MEDIA_ROUTER_SERVICE);
MediaRouter.RouteInfo route = mediaRouter.getSelectedRoute(MediaRouter.ROUTE_TYPE_LIVE_VIDEO);
if(route != null) {
Display display = route.getPresentationDisplay();
if (display != null) {
myPresentation = new MyPresentation(this, display);
myPresentation.getWindow().setType(
WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
myPresentation.show();
}
}

MediaRouter与DisplayManager不同的是MediaRouter会直接绑定周围最合适的设备。总的来说,这两个API实现的思路大致相同,都是要自定义Presentation,获取到一个辅屏的Display对象传入到Presentation的构造函数中进行绑定,Presentation初始化完成后设置窗口类型然后展示。


又是很久没有写博客了,最近忙着一个开源项目,做一个免费漫画app,抓取网易和腾讯漫画的web数据,用nodejs+koa+mongoose+socket.io搭建这个app的后端服务,客户端用react-native编写,同时适配ios和Android。本人是做Android开发的,想借此机会打开全栈技术的大门,给自己的技术栈增加一些广度。对于一个app开发者来说,app的数据源总是一个很头疼的问题,虽然现在免费的api接口有很多,但是类型就那么几种,想做一些感兴趣的app但是苦于找不到数据源。之前学react-native的时候也做过一个开源项目高仿韩寒的one一个SimpleOne传送门,这个app的数据全是通过抓包工具抓取官方app的接口地址(用charles或fidder即可),分析数据获得,由于数据没有做加密,所以没有花太多的成本。但是毕竟是别人的接口,出现问题不是自己可控的。后来了解到google的一个web自动化测试框架puppeteer,解析html抓取dom节点,这下就可以通过服务端访问web,抓取数据,提供接口将抓取到的数据返回给客户端,做自己感兴趣的应用了。而且自己本来对服务端的技术也感兴趣,索性借此机会学下nodejs相关的服务端技术。下面就对puppeteer这个自动化框架爬虫做一个入门总结,接触不久,如有不对的地方欢迎指正。


puppeteer框架背景

puppeteer是 Google Chrome 团队官方的无界面(Headless)Chrome 工具,它是一个 Node 库, web 应用自动化测试框架,提供了一些高级的 API 来控制Chrome浏览器。你也可以在开发过程中开启浏览器,实时查看运行过程方便调试。那它可以做写什么呢?

  • 生成页面的截图和PDF。
  • 抓取SPA并生成预先呈现的内容(即“SSR”)。
  • 从网站抓取你需要的内容。
  • 自动表单提交,UI测试,键盘输入等
  • 创建一个最新的自动化测试环境。使用最新的JavaScript和浏览器功能,直接在最新版本的Chrome中运行测试。
  • 捕获您的网站的时间线跟踪,以帮助诊断性能问题。

为什么要选择puppeteer?

nodejs的爬虫框架有很多,要根据具体的业务进行选择,比如说cheerio一类的静态网页爬虫框架就只能爬取服务端渲染的网页,不能爬取JavaScript运行后的数据,但很多时候我们需要爬取的是动态网页,而puppeteer可以完整的模拟浏览器获取网页,访问dom,且框架的运行效率也很不错,
支持调用Chrome的API来操纵Web,相比较Selenium或是PhantomJs,它最大的特点就是它的操作Dom可以完全在内存中进行模拟既在V8引擎中处理而不打开浏览器,是Chrome团队在维护,拥有更好的兼容性和前景。

puppeteer如何使用?

安装puppeteer

这里有个安装的坑,别看puppeteer的安装只有npm i puppeteer这么一条简单的命令,但是在国内安装起来也不是这么容易的,因为它默认会去下载Chromium作为抓取数据的客户端,国内基本下载不下来,安装会失败。即使我开着ss,仍然失败了,Orz….比如说像下面这样报错

1
2
ERROR: Failed to download Chromium r515411! Set "PUPPETEER_SKIP_CHROMIUM_DOWNLOA
D" env variable to skip download.

我发现从最新的1.7的版本开始分离出一个轻量版的puppeteer核心库,默认不去下载Chromium。
当然网上也有很多教程说先忽略跳过,再去手动下载Chromium,后面可能还有一些坑要跳,个人感觉比较麻烦,这里有一个方便快捷的办法。无意中发现一个库puppeteer-cn传送门,和puppeteer完全一样,你完全可以用puppeteer-cn代替之。这个包会先去检测本地Chrome版本是否大于59,再决定是否通过一个国内源下载Chromium。这个库下载速度很快,直接就安装好了Chromium。

使用puppeteer

这个无非是一些常规的API调用,可以查阅官方的文档来实现一些自己想要的功能,这里给出一个快速上手的小例子。由于我的app是抓取免费漫画,下面就给出抓取网易免费漫画的例子

  1. 引入puppeteer工具类
    1
    const puppeteer = require('puppeteer-cn') //抓取工具类
  2. 设置模拟的pc设备
    设置浏览器的尺寸和userAgent
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /**
    * 模拟pc设备mac
    */
    const viewPort = {
    width: 1920,
    height: 1080
    }
    const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36'
    await page.setViewport(viewPort);
    await page.setUserAgent(userAgent);
  3. 初始化请求客户端
    1
    2
    3
    4
    // 启动了一个Chrome实例
    let browser = await puppeteer.launch({ headless: true })
    // 浏览器中创建一个新的页面
    await browser.newPage()
    headless 设置为true,则不展示Chromium的访问界面,在开发过程中,为了方便调试,最好设置成false,可以实时查看运行过程。
  4. 跳转到目标网站
    1
    2
    3
    4
    5
    let url = 'https://manhua.163.com/category?sort=2&sf=1'
    // 跳转到目标网站
    await page.goto(url)
    // 等待时长
    await page.waitFor(200)
    这个地址正好是网易漫画的官方地址在漫画列表中过滤出免费漫画
  5. 抓取数据,并返回
  • 首先分析抓取到的页面数据,找出目标dom节点,
    可以在调试的Chromium中或者在自己打开一个chrome浏览器中按下查看网页代码。
    调出审查元素界面
    Mac:command+option+I
    windows/linux:ctrl+shift+I
    图
    从网页代码中,我们可以找出目标数据和相关的dom节点。带有comic-item类选择器的div就是漫画列表的每一个数据项,其中子节点cover类选择器div就是数据项的图片信息部分,里面的子元素img标签的src属性就是漫画封面的图片地址。而comic-info类选择器的div就是数据项的文字信息部分,里面的子元素.title类选择器的div中包含的文字就是标题,span标签中包含的文字就是当前章节,.muted类选择器div中的文字就是点击量,漫画的跳转链接在a标签中的href属性中,得到完整的地址需要做一个拼接。
    puppeteer是通过seletor选择器去获取元素的,了解一部分前端知识的人来说并不陌生,也没什么难度。分析完了以后,我们就可以从目标dom中提取到想要返回的数据。
    所以最后的抓取代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    let targetUrl = 'https://manhua.163.com'
    return await page.evaluate((targetUrl) => {
    let data = []
    let elements = document.querySelectorAll('.comic-item') // 获取所有漫画元素
    for (let element of elements) { // 循环
    let title = element.querySelector('.comic-info .title').innerText // 获取标题
    let chapter = element.querySelector('.comic-info span').innerText // 获取章节
    let clickNum = element.querySelector('.comic-info div.muted').innerText // 获取点击量
    let link = element.querySelector('.comic-info a').getAttribute('href')
    link = targetUrl + link
    let cover = element.querySelector('.cover img').getAttribute('src')
    // let id = link.replace('https://manhua.163.com/source/','')
    let id = link.substring(link.lastIndexOf('/')+1,link.length)
    data.push({ id, title, chapter, clickNum, link, cover }) // 存入数组
    }
    return data
    }, targetUrl)
    通过seletor选择器去获取元素,有两种方法可以获取目标节点,一个是通过page.evaluate这个api获取到html内容后,在回调函数中调用dom节点选择器相关api获取,这个回调函数无法打印log,无法内部断点,也无法直接访问外部的变量,需要通过api最后一个参数进行传参访问,最后返回一个操作结果。另一个方法就是通过puppeteer的选择器相关api直接获取。
    比如说这样:
    1
    2
    3
    4
    5
    6
    7
    8
    // 获取匹配选择器'div.portrait-player .img-box'下的所有节点
    let imgs = await page.$$('div.portrait-player .img-box')
    // 获取匹配选择器'div.portrait-player .img-box'下的第一个节点
    let img = await page.$$('div.portrait-player .img-box')
    // 获取匹配选择器'div.portrait-player .img-box'下的所有节点并返回数量
    let imagesLen = await page.$$eval('div.portrait-player .img-box', imgs => imgs.length)
    // 获取匹配选择器'div.portrait-player .img-box'下的第一个节点并返回节点的style属性中的高度值并去掉'px'单位字符串
    let imgHeight =await page.$eval('div.portrait-player .img-box', img => img.style.height.replace('px',''))
    page.$$ 抓取该选择器匹配的所有节点,对应dom获取节点querySelectorAll这个api,如果没有匹配的返回null
    page.$ 抓取该选择器匹配的第一个节点并返回给回调函数,对应dom获取节点querySelector这个api,如果没有匹配的返回null,
    page.$$eval 比起page.$$多了一个回调函数进行抓取后的操作,抓取该选择器匹配的所有节点并返回给回调函数,在回调函数中进行数据转换操作后再返回函数结果,如果没有匹配的是抛出一个异常
    page.$eval 比起page.$多了一个回调函数进行抓取后的操作,抓取该选择器匹配的第一个节点并返回给回调函数,在回调函数中进行数据转换操作后再返回函数结果,如果没有匹配的是抛出一个异常

当然,puppeteer提供的api操作远不止这些,这只是一个快速上手的小例子,更多有趣的玩法,可以参考官方的文档,这里就不一一列举了。


我的开源项目高仿韩寒的one一个,打算长期维护并借此机会学习react-native相关的技术,感兴趣的可以戳这里,此项目不是标准的react-native项目结构,而是在android studio工程项目下引入react-native的混合式开发结构,其中不少地方调用了原生的模块和原生封装的ui组件,相信在react-native跨平台还不成熟的阶段,调用原生完成开发需求是不可避免的,打算在这里对react-native调用原生的相关技术做一个总结.如果有不正确的地方还望指正,或者您有更好的解决方案也欢迎提出一起讨论


调用原生模块

react-native调用原生模块应该是经常会用到的,我们在android studio项目中引入react-native实质上就是设置ReactRootView为activity的布局视图,初始化mReactInstanceManager进行react-native的相关配置,添加自定义的原生组件包,MainReactPackage主组件包,这个是默认添加的,在activity的生命周期中对react-native做相关配置,同时继承DefaultHardwareBackBtnHandler接口,这个接口只有invokeDefaultOnBackPressed的方法,对于android back按键,是在onBackPressed中,把所有的back事件都发到js端,如果js端没监听,或者监听都返回了false,那么就会回到invokeDefaultOnBackPressed的Activity处理。

调用原生的场景

比如我们需要在react-native 的ui中加入toast提示,调用音乐播放等多媒体相关的系统api支持,调用第三方分享和登录授权……不可避免的要调用原生代码,接下来详细记录react-native js端与原生android端之间的通信方式

react-native调用android原生

以加入toast提示为例:

先定义一个ReactContextBaseJavaModule

自定义一个ToastModule继承ReactContextBaseJavaModule,重写其中的getName方法,返回react-native中调用时的组件名,重写getConstants方法,返回一个常量map,定义常量值的key,将所有可供调用的常量值put进去,通过对象.常量值key使用模块常量,使用`@ReactMethod`注解可供react-native调用的方法,@ReactMethod注解的方法返回类型一定是void,这里的回调可以有两种方式实现,第一种是callback的方式,第二种是promise的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class ToastModule extends ReactContextBaseJavaModule {
private static final String DURATION_SHORT_KEY = "SHORT";
private static final String DURATION_LONG_KEY = "LONG";
boolean flag=true;
int count=100000;
public ToastModule(ReactApplicationContext reactContext) {
super(reactContext);
}

// 复写方法,返回react-native中调用的组件名
@Override
public String getName() {
return "ToastNative";
}

// 复写方法,返回常量
@Override
public Map<String, Object> getConstants() {
final Map<String, Object> constants = new HashMap<>();
constants.put(DURATION_SHORT_KEY, Toast.LENGTH_SHORT);
constants.put(DURATION_LONG_KEY, Toast.LENGTH_LONG);
return constants;
}
// 使用 @ReactMethod注解返回react-native中可调用的方法(使用callback的方式)
@ReactMethod
public void show(String message, int duration ,Callback successCallback, Callback errorCallback) {
Toast.makeText(getReactApplicationContext(), message, duration).show();
// 通过invoke调用,随便你传参
if(flag) {
successCallback.invoke("success", ++count);
} else {
errorCallback.invoke("error", ++count);
}
flag = !flag;
}

  // 使用 @ReactMethod注解返回react-native中可调用的方法(使用promise的方式)
@ReactMethod
public void showMsg(String msg, Promise promise) {
String result = "处理结果:" + msg;
Toast.makeText(getReactApplicationContext(), result, Toast.LENGTH_SHORT).show();
promise.resolve(result);
}

// 使用 @ReactMethod注解返回react-native中可调用的 方法
@ReactMethod
public void showMsg(String message, int duration) {
Toast.makeText(getReactApplicationContext(), message, duration).show();
}

 
}

注册ReactContextBaseJavaModule

在自定义的组件包中MyReactPackage注册这个ReactContextBaseJavaModule,其中createViewManagers方法用于注册原生ui组件,而createNativeModules方法用于注册原生模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MyReactPackage implements ReactPackage {

/**
* 引入原生的模块
* @param reactContext
* @return
*/
@Override
public List<NativeModule> createNativeModules(
ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new UShareModule(reactContext));
modules.add(new ULoginModule(reactContext));
modules.add(new ToastModule(reactContext));
modules.add(new MediaPlayerModule(reactContext));
return modules;
}
}

这个MyReactPackage是我们自定义的组件包,我们需要添加到配置中

在react-native工程结构下添加自定义的ReactPackage

修改android工程中的Application类,重写ReactNativeHost中的getPackages方法,在返回的数组中添加自定义的组件包对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class MyApplication extends Application implements ReactApplication{

public static final MyReactPackage myReactPackage=new MyReactPackage();

private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}

@Override
protected List<ReactPackage> getPackages() {
return Arrays.<ReactPackage>asList(
new MainReactPackage(),
myReactPackage
);
}

};

@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
}

}

在android studio工程结构下添加自定义的ReactPackage

找到ReactActivity或者继承DefaultHardwareBackBtnHandler接口的activity中的mReactInstanceManager,添加一个自定义的组件包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private ReactRootView mReactRootView;
private ReactInstanceManager mReactInstanceManager;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
...
mReactRootView = new ReactRootView(this);
mReactInstanceManager = ReactInstanceManager.builder()
.setApplication(getApplication())
.setBundleAssetName("index.android.bundle")
.setJSMainModuleName("index.android")
.addPackage(new MainReactPackage())
.addPackage(new MyReactPackage())
.addPackage(new ImagePickerPackage())
.setUseDeveloperSupport(BuildConfig.DEBUG)
.setInitialLifecycleState(mLifecycleState)
.build();
mReactRootView.startReactApplication(mReactInstanceManager,
"SimpleOne", null);
mReactInstanceManager.getDevSupportManager().showDevOptionsDialog();
setContentView(mReactRootView);
...
}

所以只要实现了ReactPackage和ReactContextBaseJavaModule,将它注册到ReactNativeHost或者ReactInstanceManager,就可以在React Native中调用原生模块了。

在react-native中使用

1
2
3
4
import { NativeModules } from 'react-native';
let toast = NativeModules.ToastNative;
toast.show('Toast message',toast.SHORT,(message,count)=>{console.log("success",message,count)},(message,count)=>{console.log("error",message,count)})
toast.showMsg('今晚22:30主播在这里等你',toast.SHORT);

这里需要注意的是,callback/promise 在执行invoke/(reject、resolve)之后,都会在js的消息队列中被销毁。callback/promise只能用于一次返回,也就是说不能多次调用callback/promise来与react-native的组件进行通信,在一些场景下这种callback/promise的通信方式是不实用的,比如说我们调用android系统api来播放音乐,我们需要监听音乐的播放状态和进度,这里需要原生模块主动与react-native通信而且是多次通信,我们需要采用另一种通信方式,就是emit,有点类似android的广播,发送一条消息给js端,js端注册监听接收这条消息

Android原生调react-native

以实现播放音乐,监听音乐的播放状态和进度为例,我们需要让android主动向react-native多次发送消息,我们同样需要定义一个ReactContextBaseJavaModule,并且在自定义的组件包MyReactPackage中的createNativeModules方法里注册,跟前面的做法完全一样就不再赘述了,这里我们需要关心的是如何主动发送消息

Android端主动发消息

reactContext在我们注册ReactContextBaseJavaModule的时候由MyReactPackage中的createNativeModules方法传入,通过reactContext得到当前的JSModule调用它的emit方法,可以传两个参数,一个事件名称和一个WritableMap对象,事件名称需要与react-native注册监听的事件名称参数对应起来,而这个WritableMap对象存放的就是所有需要传递的参数

1
2
3
4
5
6
7
8
9
10
public static void sendEvent(ReactContext reactContext, String eventName, WritableMap map)
{
System.out.println("reactContext="+reactContext);
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(eventName,map);
}
WritableMap map=Arguments.createMap();
map.putString("state",LOADING_MEDIA_SUCCESS);
sendEvent(mContext,PLAY_STATE,map);

react-native注册监听接收消息

这里我们需要对发送的事件名称注册监听,与发送消息时事件名称的传参保持一致,在回调中处理接收到消息以后的逻辑

1
2
3
4
5
6
7
8
9
10
11
this.listener = (reminder) => {
console.log('当前状态' + reminder.state);
if(this.props.isVisible){
if (reminder.state === constants.STOP_PLAY_MEDIA || reminder.state === constants.PLAY_EXCEPTION || reminder.state == constants.PLAY_COMPLETE) {
this.setState({
isPlay: false,
});
}
}
}
DeviceEventEmitter.addListener(constants.PLAY_STATE, this.listener)

如果你在这个回调逻辑中刷新了ui,然而这个页面当前并没绘制,react-native会发出警告,对应的还有取消监听的方法

1
2
DeviceEventEmitter.removeAllListeners(constants.PLAY_STATE); //移除该事件所有监听
DeviceEventEmitter.removeListener(constants.PLAY_STATE,listener); //移除该事件的指定监听

这里我们需要注意的是一旦调用了DeviceEventEmitter.removeAllListeners方法,影响的不只有当前页面的监听,所有页面的监听都被移除了,移除当前页面的监听是第二个

调原生ui组件

由于react-native官方提供的滚轮组件可定制性比较低,DatePickerIOS和DatePickerAndroid,调用的都是原生的日期滚轮选择器,也没找到合适的第三方库,无法达到设计的效果,所以采用了自定义原生ui组件的方案,给react-native暴露接口,让react-native调用原生的ui组件,这里我们以自定义日期选择滚轮为例,记录一下react-native调用原生ui组件的过程。

封装组件

绘制单个滚轮

常规方式继承View,在onDraw中用canvas直接绘制,监听手势,根据手指滑动的距离对当前滚轮选项进行刷新。注意一下边界判断,当前选择项下标小于0时,选择最后一项,当前项下标大于最后一项下标,选择第一项。手指抬起时滚动到当前选择项的中间位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
public class WheelView extends View {

public static final String TAG = "WheelView";

/**
* 自动回滚到中间的速度
*/
public static final float SPEED = 2;

/**
* 除选中item外,上下各需要显示的备选项数目
*/
public static final int SHOW_SIZE = 1;

private Context context;

private List<String> itemList;
private int itemCount;

/**
* item高度
*/
private int itemHeight = 50;

/**
* 选中的位置,这个位置是mDataList的中心位置,一直不变
*/
private int currentItem;

private Paint selectPaint;
private Paint mPaint;
// 画背景图中单独的画笔
private Paint centerLinePaint;

private float centerY;
private float centerX;

private float mLastDownY;
/**
* 滑动的距离
*/
private float mMoveLen = 0;
private boolean isInit = false;
private SelectListener mSelectListener;
private Timer timer;
private MyTimerTask mTask;

Handler updateHandler = new Handler() {

@Override
public void handleMessage(Message msg) {
if (Math.abs(mMoveLen) < SPEED) {
// 如果偏移量少于最少偏移量
mMoveLen = 0;
if (null != timer) {
timer.cancel();
timer.purge();
timer = null;
}
if (mTask != null) {
mTask.cancel();
mTask = null;
performSelect();
}
} else {
// 这里mMoveLen / Math.abs(mMoveLen)是为了保有mMoveLen的正负号,以实现上滚或下滚
mMoveLen = mMoveLen - mMoveLen / Math.abs(mMoveLen) * SPEED;
}
invalidate();
}

};

public WheelView(Context context) {
super(context);
init(context);
}

public WheelView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}

public void setOnSelectListener(SelectListener listener) {
mSelectListener = listener;
}

public void setWheelStyle(int style) {
itemList = WheelStyle.getItemList(context, style);
if (itemList != null) {
itemCount = itemList.size();
resetCurrentSelect();
invalidate();
} else {
Log.i(TAG, "item is null");
}
}

public void setWheelItemList(List<String> itemList) {
this.itemList = itemList;
if (itemList != null) {
itemCount = itemList.size();
resetCurrentSelect();
} else {
Log.i(TAG, "item is null");
}
}

private void resetCurrentSelect() {
if (currentItem < 0) {
currentItem = 0;
}
while (currentItem >= itemCount) {
currentItem--;
}
if (currentItem >= 0 && currentItem < itemCount) {
invalidate();
} else {
Log.i(TAG, "current item is invalid");
}
}

public int getItemCount() {
return itemCount;
}

/**
* 选择选中的item的index
*/
public void setCurrentItem(String selected) {
for(int i=0; i<itemList.size();i++){
String item=itemList.get(i);
if(item.equals(selected)){
currentItem = i;
break;
}
}

resetCurrentSelect();
}

public int getCurrentItem() {
return currentItem;
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int mViewHeight = getMeasuredHeight();
int mViewWidth = getMeasuredWidth();
centerX = (float) (mViewWidth / 2.0);
centerY = (float) (mViewHeight / 2.0);

isInit = true;
invalidate();
}


private void init(Context context) {
this.context = context;

timer = new Timer();
itemList = new ArrayList<>();

mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Style.FILL);
mPaint.setTextAlign(Align.CENTER);
mPaint.setColor(getResources().getColor(R.color.wheel_unselect_text));
mPaint.setTextSize(SizeConvertUtil.spTopx(context, 15));

selectPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
selectPaint.setStyle(Style.FILL);
selectPaint.setTextAlign(Align.CENTER);
selectPaint.setColor(getResources().getColor(R.color.wheel_select));
selectPaint.setTextSize(SizeConvertUtil.spTopx(context, 17));

centerLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
centerLinePaint.setStyle(Style.FILL);
centerLinePaint.setTextAlign(Align.CENTER);
centerLinePaint.setColor(getResources().getColor(R.color.wheel_unselect_text));

}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (isInit) {
drawData(canvas);
}
}

private void drawData(Canvas canvas) {
// 先绘制选中的text再往上往下绘制其余的text
if (!itemList.isEmpty()) {
// 绘制中间data
drawCenterText(canvas);
// 绘制上方data
for (int i = 1; i < SHOW_SIZE + 1; i++) {
drawOtherText(canvas, i, -1);
}
// 绘制下方data
for (int i = 1; i < SHOW_SIZE + 1; i++) {
drawOtherText(canvas, i, 1);
}
}
}

private void drawCenterText(Canvas canvas) {
// text居中绘制,注意baseline的计算才能达到居中,y值是text中心坐标
float y = centerY + mMoveLen;
FontMetricsInt fmi = selectPaint.getFontMetricsInt();
float baseline = (float) (y - (fmi.bottom / 2.0 + fmi.top / 2.0));
canvas.drawText(itemList.get(currentItem), centerX, baseline, selectPaint);
}

/**
* @param canvas 画布
* @param position 距离mCurrentSelected的差值
* @param type 1表示向下绘制,-1表示向上绘制
*/
private void drawOtherText(Canvas canvas, int position, int type) {
int index = currentItem + type * position;
if (index >= itemCount) {
index = index - itemCount;
}
if (index < 0) {
index = index + itemCount;
}
String text = itemList.get(index);

int itemHeight = getHeight() / (SHOW_SIZE * 2 + 1);
float d = itemHeight * position + type * mMoveLen;
float y = centerY + type * d;

FontMetricsInt fmi = mPaint.getFontMetricsInt();
float baseline = (float) (y - (fmi.bottom / 2.0 + fmi.top / 2.0));
canvas.drawText(text, centerX, baseline, mPaint);
}

@Override
public void setAlpha(int alpha) {

}

@Override
public void setColorFilter(ColorFilter cf) {

}

@Override
public int getOpacity() {
return 0;
}
};
super.setBackground(background);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
doDown(event);
break;
case MotionEvent.ACTION_MOVE:
doMove(event);
break;
case MotionEvent.ACTION_UP:
doUp();
break;
default:
break;
}
return true;
}

private void doDown(MotionEvent event) {
if (mTask != null) {
mTask.cancel();
mTask = null;
}
mLastDownY = event.getY();
}

private void doMove(MotionEvent event) {

mMoveLen += (event.getY() - mLastDownY);

if (mMoveLen > itemHeight / 2) {
// 往下滑超过离开距离
mMoveLen = mMoveLen - itemHeight;
currentItem--;
if (currentItem < 0) {
currentItem = itemCount - 1;
}
} else if (mMoveLen < -itemHeight / 2) {
// 往上滑超过离开距离
mMoveLen = mMoveLen + itemHeight;
currentItem++;
if (currentItem >= itemCount) {
currentItem = 0;
}
}

mLastDownY = event.getY();
invalidate();
}

private void doUp() {
// 抬起手后mCurrentSelected的位置由当前位置move到中间选中位置
if (Math.abs(mMoveLen) < 0.0001) {
mMoveLen = 0;
return;
}
if (mTask != null) {
mTask.cancel();
mTask = null;
}
if (null == timer) {
timer = new Timer();
}
mTask = new MyTimerTask(updateHandler);
timer.schedule(mTask, 0, 10);
}

class MyTimerTask extends TimerTask {
Handler handler;

public MyTimerTask(Handler handler) {
this.handler = handler;
}

@Override
public void run() {
handler.sendMessage(handler.obtainMessage());
}

}

private void performSelect() {
if (mSelectListener != null) {
mSelectListener.onSelect(currentItem, itemList.get(currentItem));
} else {
Log.i(TAG, "null listener");
}
}

public interface SelectListener {
void onSelect(int index, String text);
}

}

生成日期数据源

封装一个工具类用于生成日期滚轮选择器的数据源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
public class WheelStyle {

public static final int minYear = 1980;
public static final int maxYear = 2020;

/**
* Wheel Style Hour
*/
public static final int STYLE_HOUR = 1;
/**
* Wheel Style Minute
*/
public static final int STYLE_MINUTE = 2;
/**
* Wheel Style Year
*/
public static final int STYLE_YEAR = 3;
/**
* Wheel Style Month
*/
public static final int STYLE_MONTH = 4;
/**
* Wheel Style Day
*/
public static final int STYLE_DAY = 5;
/**
* Wheel Style Light Time
*/
public static final int STYLE_LIGHT_TIME = 7;

private WheelStyle() {
}

public static List<String> getItemList(Context context, int Style) {
if (Style == STYLE_HOUR) {
return createHourString();
} else if (Style == STYLE_MINUTE) {
return createMinuteString();
} else if (Style == STYLE_YEAR) {
return createYearString();
} else if (Style == STYLE_MONTH) {
return createMonthString();
} else if (Style == STYLE_DAY) {
return createDayString();
} else if (Style == STYLE_LIGHT_TIME) {
return createWeekString(context);
} else {
throw new IllegalArgumentException("style is illegal");
}
}

private static List<String> createHourString() {
List<String> wheelString = new ArrayList<>();
for (int i = 0; i < 24; i++) {
wheelString.add(String.format("%02d", i));
}
return wheelString;
}

private static List<String> createMinuteString() {
List<String> wheelString = new ArrayList<>();
for (int i = 0; i < 60; i++) {
wheelString.add(String.format("%02d", i));
}
return wheelString;
}

private static List<String> createYearString() {
List<String> wheelString = new ArrayList<>();
for (int i = minYear; i <= maxYear; i++) {
wheelString.add(Integer.toString(i)+'年');
}
return wheelString;
}

private static List<String> createMonthString() {
List<String> wheelString = new ArrayList<>();
for (int i = 1; i <= 12; i++) {
wheelString.add(String.format("%02d", i)+'月');
}
return wheelString;
}

private static List<String> createDayString() {
List<String> wheelString = new ArrayList<>();
for (int i = 1; i <= 31; i++) {
wheelString.add(String.format("%02d", i));
}
return wheelString;
}

private static List<String> createWeekString(Context context) {
List<String> wheelString = new ArrayList<>();
String[] timeString = context.getResources().getStringArray(R.array.weeks);
for (String week : timeString) {
wheelString.add(week);
}
return wheelString;
}

public static List<String> createDayString(int year, int month) {
List<String> wheelString = new ArrayList<>();
int size;
if (isLeapMonth(month)) {
size = 31;
} else if (month == 2) {
if (isLeapYear(year)) {
size = 29;
} else {
size = 28;
}
} else {
size = 30;
}

for (int i = 1; i <= size; i++) {
wheelString.add(String.format("%02d", i));
}
return wheelString;
}

/**
* 计算闰月
*
* @param month
* @return
*/
private static boolean isLeapMonth(int month) {
return month == 1 || month == 3 || month == 5 || month == 7
|| month == 8 || month == 10 || month == 12;
}

/**
* 计算闰年
*
* @param year
* @return
*/
private static boolean isLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}

}

自定义LinearLayout绘制整个滚轮视图

由2个滚轮组成的滚轮视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public class PickDateView extends LinearLayout {

private static final String TAG = "PickDateView原生Tag";
CallBack callBack;
WheelView yearWheel;
WheelView monthWheel;
public interface CallBack{
void changeYear(int year);
void changeMonth(int month);
void onSure(int year, int month, long time);
}

public void setCallBack(CallBack callBack) {
this.callBack = callBack;
}

public PickDateView(Context context) {
super(context);
addCustomLayout(context);
}


public void setYear(String year){
yearWheel.setCurrentItem(year);
}

public void setMonth(String month){
monthWheel.setCurrentItem(month);
}

private void addCustomLayout(Context context){
LayoutInflater mInflater = LayoutInflater.from(context);
View contentView = mInflater.inflate(R.layout.wheel_select_date, null);
yearWheel = (WheelView) contentView.findViewById(R.id.select_date_wheel_year_wheel);
monthWheel = (WheelView) contentView.findViewById(R.id.select_date_month_wheel);

yearWheel.setWheelStyle(WheelStyle.STYLE_YEAR);
yearWheel.setOnSelectListener(new WheelView.SelectListener() {

@Override
public void onSelect(int index, String text) {
int selectYear = index + WheelStyle.minYear;

if (callBack != null) {
callBack.changeYear(selectYear);
}
}
});

monthWheel.setWheelStyle(WheelStyle.STYLE_MONTH);
monthWheel.setOnSelectListener(new WheelView.SelectListener() {
@Override
public void onSelect(int index, String text) {
int selectMonth = index + 1;
if (callBack != null) {
callBack.changeMonth(selectMonth);
}
}
});


Button sureBt = (Button) contentView.findViewById(R.id.select_date_sure);
sureBt.setOnClickListener(new OnClickListener() {

@Override
public void onClick(View v) {
int year = yearWheel.getCurrentItem() + WheelStyle.minYear;
int month = monthWheel.getCurrentItem();

Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.YEAR, year);
calendar.set(Calendar.MONTH, month);

long setTime = calendar.getTimeInMillis();

if (callBack != null) {
callBack.onSure(year, month, setTime);
}
}
});

addView(contentView);
}
}

给react-native暴露接口

自定义ViewManager,继承SimpleViewManager设置需要暴露的ui组件类PickDateView,重写实例化方法createViewInstance在方法内实例化ui组件类,同时在回调被调用时通知js端,发送事件消息,也是通过WritableMap传递参数列表,用@ReactProp注解标注的方法在react-native中作为属性传入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class PickDateViewManager extends SimpleViewManager<PickDateView> {
private static final String REACT_CLASS = "PickDateView";
private static final String TAG = "PickDateViewManger原生Tag";
private PickDateView pickDateView;
@Override
public String getName() {
return REACT_CLASS;
}

@Override
protected PickDateView createViewInstance(final ThemedReactContext reactContext) {
pickDateView=new PickDateView(reactContext);
pickDateView.setCallBack(new PickDateView.CallBack() {
@Override
public void changeYear(int year) {
WritableMap map = Arguments.createMap();
map.putInt("target", pickDateView.getId());
map.putString("msg", "year: " + year);
reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(
pickDateView.getId(), "topChange", map
);
}

@Override
public void changeMonth(int month) {
WritableMap map = Arguments.createMap();
map.putInt("target", pickDateView.getId());
map.putString("msg", "month: " + month);
reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(
pickDateView.getId(), "topChange", map
);

}

@Override
public void onSure(int year, int month, long time) {
WritableMap map = Arguments.createMap();
map.putInt("target", pickDateView.getId());

WritableMap params=Arguments.createMap();
params.putInt("year",year);
params.putInt("month",month);
params.putString("time",time+"");

// String paramsStr=jsonEnclose(params).toString();
map.putMap("msg", params);
reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(
pickDateView.getId(), "topChange", map
);

}
});
return pickDateView;
}

@ReactProp(name = "setYear")
public void setYear(PickDateView pickDateView,String year) {
pickDateView.setYear(year);
}

@ReactProp(name = "setMonth")
public void setMonth(PickDateView pickDateView,String month) {
pickDateView.setMonth(month);
}

}

注册自定义的ViewManager

同样需要在自定义的组件包MyReactPackage的createViewManagers方法中注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 public class MyReactPackage implements ReactPackage {

/**
* 引入原生的view
* @param reactContext
* @return
*/
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
List<ViewManager> viewManagers=new ArrayList<>();
viewManagers.add(new PickDateViewManager());
viewManagers.add(new ShowPlayViewManager());
return viewManagers;
}
}

在react-native中使用

在react-native中声明原生组件

定义原生中也声明了的组件属性和统一接收回调消息的onChange方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react';
import PropTypes from 'prop-types';
import {requireNativeComponent, View} from 'react-native';


let iface = {
name: 'PickDateView',
propTypes: {
setYear: PropTypes.string,
setMonth: PropTypes.string,
//回调
onChange: PropTypes.func,
...View.propTypes //支持View组件的所有属性
}
}

let RCTPickDateView = requireNativeComponent('PickDateView', iface);


export default RCTPickDateView;

在react-native中使用原生组件

绘制一个弹窗,弹窗内部使用原生的日期滚轮选择器,在弹窗弹出时加入一个展开动画,在onChange方法中接收参数obj.nativeEvent.参数名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
Text,
View,
Modal,
Animated,
TouchableOpacity
} from 'react-native';
import PickDateView from '../view/PickDate';
import constants from '../Constants';
let {width, height} = constants.ScreenWH;
class PullPickDate extends Component{
constructor(props){
super(props);
this.onLayout=this.onLayout.bind(this);
this.state={
expanded: false,
animation : new Animated.Value()
};
}
showAnimation(height){
let maxHeight=height;
let minHeight=0;
let initialValue = this.state.expanded ? maxHeight + minHeight : minHeight,
finalValue= this.state.expanded ? minHeight : maxHeight + minHeight;
this.setState({
expanded: !this.state.expanded //Step 2
});
console.log('最大高度'+maxHeight);
console.log('初始值'+initialValue+'最终值'+finalValue);
this.state.animation.setValue(initialValue); //Step 3

this.timer = setTimeout(
() => {
this.toggle(finalValue);
},
5000
);
}

toggle(finalValue) {
Animated.spring(
this.state.animation,
{
toValue: finalValue
}
).start();
}

render() {
return (
<Modal
animationType={'none'}
transparent={true}
visible={this.props.onShow}
onRequestClose={() => {
this.props.onCancel();
}}>

<View style={{width: width, flex: 1, marginTop: height * 0.08 + 0.12 * width,backgroundColor: 'rgba(0, 0, 0, 0.7)'}}>
<PickDateView
setYear={this.props.year}
setMonth={this.props.month}
onLayout={this.onLayout}
onChange={(obj) => {
console.log('onSure收到事件' + obj.nativeEvent.msg + "目标id" + obj.nativeEvent.msg.year);
//当此回调被onSure调用时
let year = obj.nativeEvent.msg.year + '';
let month = obj.nativeEvent.msg.month + '';
let time= obj.nativeEvent.msg.time + '';
if (year !== 'undefined' && month !== 'undefined') {
this.props.onCancel();
}else{
this.props.onSure(year,month,time);
}

}}
style={{width: '100%', flex: 0.42,}}/>
<TouchableOpacity style={{flex:0.58}} onPress={() => this.props.onCancel()}/>
</View>
</Modal>
);
}

onLayout(event){
this.showAnimation(event.nativeEvent.layout.height);
}
}

export default PullPickDate;


自从在github上开源了高仿韩寒的one一个项目之后,空闲时间仍旧保持项目的更新和维护,目前公司没有react-native的项目,但我一直对react-native很有兴趣,所以打算一直维护下去并借此机会学习相关的技术。one一个是资讯类的app,主界面是由3个分页组成的,底部有导航栏,是一个比较大众化的展示方式,其中one分页是用日期作为顶部标题,手指每向左滑动一次就往前翻页,向右滑动一次就往后翻页,每天的展示内容就是一页列表,标题就会随着手指的翻页而刷新,显示当前展示内容的发布日期,这里的日期刷新就是一个数字滚动的动画效果。


先上效果图:

效果图

绘制思路如下:

  1. 设置表示数字范围的数字数组
  2. 测量数字的绘制高度
  3. 对当前文本做切割,遍历每个字符,判断当前字符是否是数字,如果是数字,根据目标数字在数字数组中的下标,和当前数字在数字数组中的下标,以及数字绘制的高度,计算出垂直方向y上需要平移的距离,执行平移动画。如果不是数字,直接绘制这个字符文本
  4. 文本由父组件通过props传递,注意props值变化时重置动画属性值,刷新文本内容

    定义数字布局样式

    数字水平排列,需要先放置一个隐藏的text测量数字的高度
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const styles = StyleSheet.create({
    // 数字水平浮动排列
    row: {
    flexDirection: 'row',
    overflow: 'hidden',
    },
    // 隐藏
    hide: {
    position: 'absolute',
    left: 0,
    right: 0,
    opacity: 0,
    },
    });

    父组件需要传入的参数

    文本内容,样式,滚动时长
    其中文本内容可以有两种传递方式:
  5. 通过props.text进行传递
  6. 通过子组件设置成文本内容直接传递

    设置数字范围

    1
    2
    3
    4
    // 指定范围创建数组
    const range = length => Array.from({ length }, (x, i) => i);
    // 创建"0","1","2","3","4"..."9"的数组,默认绘制数据
    const numberRange = range(10).map(p => p + "");

    定义数字滚动动画组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    class Ticker extends Component {

    // 定义属性类型
    static propTypes = {
    text: PropTypes.string,
    textStyle: PropTypes.oneOfType([PropTypes.number, PropTypes.object, PropTypes.array]),
    };
    // 定义默认属性值
    static defaultProps = {
    rotateTime: 250, // 默认滚动时间
    };

    state = {
    measured: false, // 是否已测量
    height: 0, // 高度
    fontSize: StyleSheet.flatten(this.props.textStyle).fontSize, // 获取props中的字体大小
    };

    // props变动时回调
    componentWillReceiveProps(nextProps) {
    this.setState({
    fontSize: StyleSheet.flatten(nextProps.textStyle).fontSize,
    });
    }

    handleMeasure = e => {
    this.setState({
    measured: true, // 修改flag为已测量
    height: e.nativeEvent.layout.height, //测量高度
    });
    };

    /**
    * 渲染
    * @returns {*}
    */
    render() {
    // 获取文本内容,子组件,样式,滚动时长
    const { text, children, textStyle, style, rotateTime } = this.props;
    // 获取高度, 是否测量标记
    const { height, measured } = this.state;
    // 如果未测量则透明
    const opacity = measured ? 1 : 0;
    // 文本内容获取,读取text或子组件内容,两种方式配置文本内容
    const childs = text || children;
    // 如果子组件是字符串,字符串渲染,否则子组件渲染
    return (
    <View style={[styles.row, { height, opacity }, style]}>
    {/*渲染逻辑*/}
    {numberRenderer({
    children: childs,
    textStyle,
    height,
    rotateTime,
    rotateItems: numberRange,
    })}
    {/*测量text高度,不显示该组件*/}
    <Text style={[textStyle, styles.hide]} onLayout={this.handleMeasure} pointerEvents="none">
    0
    </Text>
    </View>
    );
    }
    }
    在render方法中,绘制了一个隐藏的text组件,是为了测量在当前样式下,绘制出的数字高度值
    1
    2
    3
    4
    {/*测量text高度,不显示该组件*/}
    <Text style={[textStyle, styles.hide]} onLayout={this.handleMeasure} pointerEvents="none">
    0
    </Text>
    其中view中的numberRenderer是数字滚动动画的渲染逻辑,这个view是在测量高度之后再显示的。
    在numberRenderer这个方法中,我们需要对当前的文本做切割得到包含文本中每个字符的字符数组,遍历切割后的字符数组,取出每一个字符,判断是否是数字,不是数字就直接绘制文本,Piece是封装的直接用text进行文本绘制的组件,如果是数字就绘制数字动画组件,Tick是封装的单个数字动画绘制的组件。
    实现如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    const numberRenderer = ({ children, textStyle, height, rotateTime, rotateItems }) => {
    // 切割子组件文本内容遍历
    return splitText(children).map((piece, i) => {
    if (!isNumber(piece)) { //取单个字符,如果不是数字,直接绘制文本
    return (
    <Piece key={i} style={{ height }} textStyle={textStyle}>
    {piece}
    </Piece>
    );
    }
    // 如果是数字,绘制单个数字
    return (
    <Tick
    duration={rotateTime}
    key={i}
    text={piece}
    textStyle={textStyle}
    height={height}
    rotateItems={rotateItems}
    />
    );
    });
    };
    文本切割和数字判断方法如下:
    1
    2
    3
    4
    // 切割
    const splitText = (text = "") => (text + "").split("");
    // 是十进制数字判断
    const isNumber = (text = "") => !isNaN(parseInt(text, 10));
    直接用text进行文本绘制的组件Piece,代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /**
    *
    * @param children 子组件(文本内容)
    * @param style 样式
    * @param height 高度
    * @param textStyle 文本样式
    * @returns 无动画绘制文本
    * @constructor
    */
    const Piece = ({ children, style, height, textStyle }) => {
    return (
    <View style={style}>
    <Text style={[textStyle, { height }]}>{children}</Text>
    </View>
    );
    };
    单个数字动画绘制的组件:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    class Tick extends Component {
    /**
    * 创建动画初始值
    * @type {{animation: Animated.Value}}
    */
    state = {
    animation: new Animated.Value(
    getPosition({
    text: this.props.text,
    items: this.props.rotateItems,
    height: this.props.height,
    }),
    ),
    };
    componentDidMount() {
    // 如果高度已测量,设置动画初始值
    if (this.props.height !== 0) {
    this.setState({
    animation: new Animated.Value(
    getPosition({
    text: this.props.text,
    items: this.props.rotateItems,
    height: this.props.height,
    }),
    ),
    });
    }
    }

    componentWillReceiveProps(nextProps) {
    // 高度变化,重置动画初始值
    if (nextProps.height !== this.props.height) {
    this.setState({
    animation: new Animated.Value(
    getPosition({
    text: nextProps.text,
    items: nextProps.rotateItems,
    height: nextProps.height,
    }),
    ),
    });
    }
    }

    componentDidUpdate(prevProps) {
    const { height, duration, rotateItems, text } = this.props;
    // 数字变化,用当前动画值和变化后的动画值进行插值,并启动动画
    if (prevProps.text !== text) {
    Animated.timing(this.state.animation, {
    toValue: getPosition({
    text: text,
    items: rotateItems,
    height,
    }),
    duration,
    useNativeDriver: true,
    }).start();
    }
    }

    render() {
    const { animation } = this.state;
    const { textStyle, height, rotateItems } = this.props;

    return (
    <View style={{ height }}>
    <Animated.View style={getAnimationStyle(animation)}>
    {/*遍历数字范围数组绘制数字*/}
    {rotateItems.map(v => (
    <Text key={v} style={[textStyle, { height }]}>
    {v}
    </Text>
    ))}
    </Animated.View>
    </View>
    );
    }
    }
    这里就封装了一个平移动画,绘制的时候是绘制了整个范围的数字,getPosition这个方法是用来计算目标数字的y轴坐标值,根据当前数字在数组中的下标乘以测量出的数字文本绘制高度取负值,得出坐标值。具体代码如下:
    1
    2
    3
    4
    5
    6
    const getPosition = ({ text, items, height }) => {
    // 获得文本在数组的下标
    const index = items.findIndex(p => p === text);
    // 返回文本绘制的y轴坐标
    return index * height * -1;
    };
    这里需要注意数字变化后的处理,一旦数字变化,就触发动画,当父组件传递的props的值变化了,就会调用子组件的componentDidUpdate方法,可以在componentDidUpdate方法中对文本内容做比对,如果与之前props的text值不一致,表示数字变化,根据目标数字计算目标y值,执行平移动画。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    componentDidUpdate(prevProps) {
    const { height, duration, rotateItems, text } = this.props;
    // 数字变化,用当前动画值和变化后的动画值进行插值,并启动动画
    if (prevProps.text !== text) {
    Animated.timing(this.state.animation, {
    toValue: getPosition({
    text: text,
    items: rotateItems,
    height,
    }),
    duration,
    useNativeDriver: true,
    }).start();
    }
    }
    注意规避高度变化带来的问题,一旦高度变化,重置动画的初始值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    componentWillReceiveProps(nextProps) {
    // 高度变化,重置动画初始值
    if (nextProps.height !== this.props.height) {
    this.setState({
    animation: new Animated.Value(
    getPosition({
    text: nextProps.text,
    items: nextProps.rotateItems,
    height: nextProps.height,
    }),
    ),
    });
    }
    }
    导出封装的组件:
    1
    2
    export { Tick, numberRange }; // 单个数字动画组件,数字范围
    export default Ticker; // 整个数字动画组件
    在项目中使用,展示格式为yyyy / MM / dd的日期:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <Ticker text={this.state.showDate === '0' ? '' : this.state.showDate.substring(0, 4)} textStyle={styles.dateText} rotateTime={1000} />

    <Text style={styles.dividerText}>{this.state.showDate === '0' ? '' : ' / '}</Text>

    <Ticker text={this.state.showDate === '0' ? '' : this.state.showDate.substring(5, 7)} textStyle={styles.dateText} rotateTime={1000} />

    <Text style={styles.dividerText}>{this.state.showDate === '0' ? '' : ' / '}</Text>

    <Ticker text={this.state.showDate === '0' ? '' : this.state.showDate.substring(8, 10)} textStyle={styles.dateText} rotateTime={1000} />


最近有点闲暇时间,厌倦了windows开发环境,想尝试一下ubuntu下的开发环境。本人Android开发者,也写一点前端,以前家里是mac开发环境,公司是windows开发环境,现在打算把公司的开发环境改为ubuntu,之前也看过ubuntu下搭建Android开发环境的文章,即使这样,也踩了不少的坑,折腾了几天,总算把环境搭建起来了,基本稳定。总的来说ubuntu下的开发环境不像windows和mac那么好搭建,坑点略多,但是搭建成功后运行速度是非常可观的,比起windows快了不少,也不枉费花了这么多时间。接下来总结一下ubuntu下开发的常用软件,插件,以及可能遇到的坑点。


安装系统遇到的问题

安装系统流程比较普通,分区方案网上有很多,不过要注意两点

安装英文版的系统

即使不习惯英文系统也应该在安装系统时选择英文,安装完了再改回中文,因为如果是安装中文版的系统,文件夹的名称会变成下载,文档,音乐,图片...由于ubuntu很多时候用命令行,导致命令行输入经常切换中文不方便,我第一次安装的时候手快选了中文版,以为后面可以改,然而这个目录名是安装系统的时候就建好了的,第二次果断安装英文版

分区的坑

ubuntu虽然可以挂载ntfs分区,但是ide在读取工程目录的时候是只读取/下面的,所以根目录的分区一定不能小

卸载系统自带的软件

有些软件系统自带,但是并不好用或者不会用到的可以卸载掉
以下是卸载清单
libreoffice-common libreoffice(可以换 WPS)
unity-webapps-common Amazon 链接
totem 自带的播放器
rhythmbox 自带的音乐播放器
empathy 自带的即时聊天应用
brasero 自带的光盘刻录器
simple-scan 扫描仪
gnome-mahjongg 对对碰游戏
aisleriot 纸牌游戏
gnome-mines 扫雷游戏
cheese webcam 应用
gnome-sudoku 数独游戏
transmission-common BT 客户端
gnome-orca 屏幕阅读
webbrowser-app 自带的浏览器(这个太难用了,有chrome 和 Firefox)
landscape-client-ui-install landscape 远程控制软件
deja-dup 备份
onboard 屏幕键盘

下面是可以批量卸载的命令
sudo apt-get remove unity-webapps-common;sudo apt-get remove totem;sudo apt-get remove rhythmbox;sudo apt-get remove empathy;sudo apt-get remove brasero;sudo apt-get remove simple-scan;sudo apt-get remove gnome-mahjongg;sudo apt-get remove aisleriot;sudo apt-get remove gnome-mines;sudo apt-get remove cheese;sudo apt-get remove transmission-common;sudo apt-get remove gnome-orca;sudo apt-get remove webbrowser-app;sudo apt-get remove gnome-sudoku;sudo apt-get remove landscape-client-ui-install;sudo apt-get remove onboard;sudo apt-get remove deja-dup;sudo apt-get remove libreoffice-common

apt-get remove –purge xxx # 移除应用及配置
apt-get autoremove # 移除没用的包

apt替换阿里源

图形界面配置

新手推荐使用图形界面配置: 系统设置 -> 软件和更新 选择下载服务器 -> “mirrors.aliyun.com”

手动更改

用你熟悉的编辑器打开:
/etc/apt/sources.list
替换默认的
http://archive.ubuntu.com/为mirrors.aliyun.com
以Ubuntu 14.04.5 LTS为例,最后的效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
deb https://mirrors.aliyun.com/ubuntu/ trusty main restricted universe multiverse
deb-src https://mirrors.aliyun.com/ubuntu/ trusty main restricted universe multiverse
deb https://mirrors.aliyun.com/ubuntu/ trusty-security main restricted universe multiverse
deb-src https://mirrors.aliyun.com/ubuntu/ trusty-security main restricted universe multiverse

deb https://mirrors.aliyun.com/ubuntu/ trusty-updates main restricted universe multiverse
deb-src https://mirrors.aliyun.com/ubuntu/ trusty-updates main restricted universe multiverse

deb https://mirrors.aliyun.com/ubuntu/ trusty-backports main restricted universe multiverse
deb-src https://mirrors.aliyun.com/ubuntu/ trusty-backports main restricted universe multiverse
## Not recommended
# deb https://mirrors.aliyun.com/ubuntu/ trusty-proposed main restricted universe multiverse
# deb-src https://mirrors.aliyun.com/ubuntu/ trusty-proposed main restricted universe multiverse

ubuntu 16.04 配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
deb http://mirrors.aliyun.com/ubuntu/ xenial main
deb-src http://mirrors.aliyun.com/ubuntu/ xenial main

deb http://mirrors.aliyun.com/ubuntu/ xenial-updates main
deb-src http://mirrors.aliyun.com/ubuntu/ xenial-updates main

deb http://mirrors.aliyun.com/ubuntu/ xenial universe
deb-src http://mirrors.aliyun.com/ubuntu/ xenial universe
deb http://mirrors.aliyun.com/ubuntu/ xenial-updates universe
deb-src http://mirrors.aliyun.com/ubuntu/ xenial-updates universe

deb http://mirrors.aliyun.com/ubuntu/ xenial-security main
deb-src http://mirrors.aliyun.com/ubuntu/ xenial-security main
deb http://mirrors.aliyun.com/ubuntu/ xenial-security universe
deb-src http://mirrors.aliyun.com/ubuntu/ xenial-security universe

ubuntu 18.04(bionic) 配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
deb http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse

设置中文输入法

sudo add-apt-repository ppa:fcitx-team/nightly
sudo apt-get update
安装fcitx
sudo apt-get install fcitx
安装fcitx的配置工具
sudo apt-get install fcitx-config-gtk
安装fcitx的table-all软件包
sudo apt-get install fcitx-table-al l
安装输入法切换工具
sudo apt-get install im-switch
设置语言选项:将键盘输入法系统由默认的iBus设置为fcitx
安装了搜狗输入法后频繁崩溃,索性就用fcitx自带的,并没觉得有什么不同

命令行相关

bash对应配置文件为.bashrc
zsh对应配置文件为.zshrc
oh-my-zsh是基于zsh的功能做了一个扩展,方便的插件管理、主题自定义,以及漂亮的自动完成效果。比默认的bash好用

安装zsh

sudo apt-get install zsh

安装ohmyzsh

git源码

git clone git://github.com/robbyrussell/oh-my-zsh.git ~/.oh-my-zsh
cd oh-my-zsh/tools
./install.sh

curl

sh -c “$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)"

wget

sh -c “$(wget https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh -O -)”

修改默认shell

ubuntu下默认的shell是bash,修改它为zsh&ohmyzsh
chsh -s /bin/zsh

配置主题

修改~/.zshrc中的ZSH_THEME

配置插件

修改~/.zshrc中的plugins=(git bundler osx rake ruby)
常用插件
wd 快速进入
zsh-syntax-highlighting 语法高亮
zsh-autosuggestions 历史记录提示
svn
adb
yarn

图形化svn与git管理工具

rabbitvcs

  1. 添加PPA源
    sudo add-apt-repository ppa:rabbitvcs/ppa
    如果导入密钥失败,则在 /etc/apt/sources.list 文件中加入:
    1
    deb http://ppa.launchpad.net/rabbitvcs/ppa/ubuntu **DISTRIBUTION** main
  2. 更新源
    1
    sudo apt-get update
  3. 安装依赖
    1
    sudo apt-get install python-nautilus python-configobj python-gtk2 python-glade2 python-svn python-dbus python-dulwich subversion meld
  4. 安装RabbitVCS
    1
    sudo apt-get install rabbitvcs-cli  rabbitvcs-core rabbitvcs-gedit rabbitvcs-nautilus
  5. 重启文件浏览器
    1
    2
    nautilus -q 
    nautilus

    shadowsocks相关

    作为开发者ss是必不可少的,特别是习惯了google的,完全无法忍受没有梯子的日子,ubuntu下配置shadowsocks稍微有些麻烦。
    首先我们要下载shadowsocks的无图形界面版本,我用过图形界面版qt5,太不稳定了。
    sudo apt update
    sudo apt install python-gevent python-pip
    pip install shadowsocks
    编辑配置文件
    vim /etc/ss.json
    server 服务器地址
    server_port 端口
    local_port 本地端口
    password 密码
    method 加密方式 跟服务器保持一致
    1
    2
    3
    4
    5
    6
    7
    8
    { 
    "server":"server_ip",
    "server_port":30696,
    "local_port":1080,
    "password":"password",
    "timeout":600,
    "method":"rc4-md5"
    }
    ubuntu自带开机启动项设置工具 startupapplications点击add添加
    name和comment随意填写,command填写为sudo sslocal -c /etc/ss.json -d start
    配置好了以后,我们需要修改本机代理,windows、mac都是不需要的
    这里有一个坑点ubuntu18上启动shadowsocks,openssl.py会报错,将此文件中的52行和111行中的cleanup替换为reset,否则可能执行启动命令报错

    全部请求用代理

    SystemSetting->Network->->NetworkProxy
    Method设置为Manual
    sockshost 127.0.0.1 1080,然后Apply System Wide

    添加规则,部分代理

    安装genpac
    sudo apt-get install python-pip
    sudo pip install genpac
    使用genpac生成autoproxy.pac规则文件
    genpac -p “SOCKS5 127.0.0.1:1080” –output=”autoproxy.pac”
    让系统应用规则
    SystemSetting->Network->NetworkProxy
    Method设置为Automatic
    Configuration Url填”file:///home/xxx/autoproxy.pac”,然后Apply System Wide
    chrome和firefox都要安装Proxy SwitchyOmega插件
    方便我们设置自动切换规则

    http代理

    如果是命令行,很多只支持http协议的软件或者工具,我们再配置一次http代理

    安装privoxy

    sudo apt-get install privoxy

    配置privoxy

    打开/etc/privoxy/config
    取消listen-address localhost:8118的代码注释
    找到5.2节
    加入一句
    forward-socks5 / 127.0.0.1:1080 .
    重启privoxy服务
    sudo /etc/init.d/privoxy restart
    开机自启privoxy服务
    sudo /etc/init.d/privoxy start加入到/etc/rc.local文件中的exit 0这句之前
    在profile中写入http代理
    export http_proxy=”127.0.0.1:8118”
    export https_proxy=”127.0.0.1:8118”

Ubuntu软件集合

chrome 开发者必备浏览器,自带有firefox
Typora markdown编辑器
Deepin Screenshot深度截图(Ubuntu Software中安装)
electronic wechat 微信
快速启动ULauncher
Redshift 自动调节屏幕色温
Android studio android开发者都懂
Webstorm 前端开发利器
vscode 微软出的开发神器,轻量级
mpv 播放器
angrySearch文件搜索
ubuntu-tweak 集清理,主题管理,字体管理,热区自定义于一体的软件,可替代ubuntu-tweak-tool,这个软件会默认安装系统自带的浏览器
(2018-5-31 新版的ubuntu18移除了unity桌面,这个没啥用了,可换成gnome-tweak-tool)

ubuntu18 gnome主题定制

定制资源下载
光标: 下载解压后放到/usr/share/icons/目录
GTK主题: 下载解压后放到/usr/share/themes/目录
图标: 下载解压放到/usr/share/icons/目录
shell主题: 下载解压放到/usr/share/themes/目录 (需要安装gnome shell extension)
(使用gnome tweak tool->apperance前面按钮安装zip,会放到~/.local/share/themes下)
重启gnome tweak tool,才可在gnome tweak tool中找到

wps 金山wps有linux版

安装后进入,报字体缺失
下载字体包
百度云http://pan.baidu.com/s/1nuS5U5b 密码:p4vz

  1. 解压
  2. 把wps_symbol_fonts目录拷贝到 /usr/share/fonts/ 目录下
  3. 权限添加
    1
    2
    3
    4
    #cd /usr/share/fonts/
    #chmod 755 wps_symbol_fonts
    #cd /usr/share/fonts/wps_symbol_fonts
    #chmod 644 *
  4. 生成缓存配置信息
    1
    2
    3
    4
    #cd /usr/share/fonts/wps_symbol_fonts
    #mkfontdir
    #mkfontscale
    #fc-cache

    小程序开发工具

    微信开发者工具(微信小程序)linux完美支持
    https://github.com/cytle/wechat_web_devtools

抓包

wireshark
https://blog.csdn.net/xukai871105/article/details/31008635
charles也有linux版

qq 解决方案

优麒麟qq,表情显示不出来且发送错误,版本很老
Wine-QQ-TIM,使用几天后出现电脑无故卡死,输入法切换不了,无法启动等问题
Wine真的不好用,还不如虚拟机
一番折腾后,还是卸载了qq,珍爱生命,远离qq

微信 解决方案 (2019.4.15更新)

  • 目前发现electronic-wechat就是最好的解决方案没有之一,基于web微信用Electron封装并开源
    传送门

    钉钉 解决方案 (2019.4.15更新)

  • 目前发现dingtalk就是最好的解决方案没有之一,基于web钉钉用Electron封装并开源
    传送门

    virtualbox&genymotion

    虚拟机和Android模拟器
    在virtualbox下安装win7,解决ubuntu下无法解决的问题
    安装virtualbox时注意安装ubuntu最新版本,如果开了intel虚拟技术,还是卡开机界面,可能是当前显卡驱动问题,不要选择opensource版本的显卡驱动
    genymotion尽量选择最新的版本,这里有个坑,如果卡死,内存分配尽量大一点

拾色器

前端开发,app开发必不可少的工具
Deepin picker 深度取色器(Ubuntu Software中安装)
添加快捷键:
Settings -> Devices -> Keyboard commond为:/usr/bin/deepin-picker
同理,添加截图的快捷键
Settings -> Devices -> Keyboard commond为:/usr/bin/deepin-screenshot

系统备份

辛苦折腾的系统最好做个备份 ,remastersys这个系统备份工具是不错,但是备份文件大于4g就会备份失败,官方文档也说了这个问题是iso9660的限制,目前无解。
后来发现一个更好用的工具,Timeshift类似mac上的TimeMachine,按备份时间点创建快照,对快照可进行管理,有备份计划等功能,可以实现增强备份和完整备份。

目前就总结到这里,后面如果发现更好用的软件,插件,坑点会持续更新~

  • 2018.11.11爬坑
    Android studio真机调试会报一个错,发现设备无法被识别
    error insufficient permissions for device user in plugdev group are your udev rules wrong
    解决方案:
  1. lsusb命令查出所有通过usb连接pc的设备,找出那个正在调试的读不出型号的设备
    1
    2
    3
    4
    5
    6
    Bus 001 Device 004: ID 0424:2514 Standard Microsystems Corp. USB 2.0 Hub
    Bus 001 Device 003: ID 1bcf:2984 Sunplus Innovation Technology Inc.
    Bus 001 Device 006: ID 2717:9039
    Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
    Bus 003 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
    Bus 002 Device 002: ID 046d:c077 Logitech, Inc.
    注意ID后XXXX:XXXX的参数,前一个是idVendor,后一个是idProduct
  2. 创建adb_usb.ini文件,写入idVendor到~/.android/adb_usb.ini,内容格式为0xidVendor
    1
    echo 0x2717> ~/.android/adb_usb.ini
    这一步官网上没有提到,导致后面android.rules文件一直报SUBSYSTEM=="usb"有错
  • 以下步骤可以解决Android studio无法识别设备XXXX[null]的问题
  1. 添加Usb设备配置文件,创建文件并写入以下内容
    1
    2
    sudo vim /etc/udev/rules.d/70-android.rules
    SUBSYSTEM=="usb", ATTRS{idVendor}=="2717", ATTRS{idProduct}=="9039",MODE="0666"
  2. 给配置文件添加权限
    1
    sudo chmod a+rx /etc/udev/rules.d/70-android.rules
  3. 重新加载usb配置规则文件,重启adb服务并查看
    1
    2
    3
    sudo udevadm control --reload-rules && sudo service udev restart && sudo udevadm trigger
    adb kill-server && adb start-server
    adb devices
    此时设备已经可以识别了

kotlin集合操作

kotlin集合的不同

kotlin在集合这方面与大多数语言不同,分只读集合和读写集合,这种良好的设计有利于在使用的过程中避免bug的出现

集合的创建

读写集合是只读集合的子类,可以将一个读写集合赋值给只读集合引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// list
val list1 = listOf<Int>() // 创建kotlin只读集合
val list2 = mutableListOf<Int>() // 创建kotlin读写集合
val list3 = arrayListOf<Int>() // 创建java arraylist集合,这是读写集合
val list4 = ArrayList<Int>() // 通过构造函数创建也可以
// 下面同理
// set
val set1 = setOf<Int>()
val set2 = mutableSetOf<Int>()
val set3 = hashSetOf<Int>()
// map
val map1 = mapOf<String,Int>()
val map2 = mutableMapOf<String,Int>()
val map3 = hashMapOf<String,Int>()

集合的操作符

条件判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val list = listOf(1,2,3,4,5,6)
// 任何一个满足,返回true
list.any {
it >= 0
}
// 全部满足,返回true
list.all {
it >= 0
}
// 没有一个满足,返回true
list.none{
it < 0
}
// 满足条件的个数
list.count{
it >= 0
}

集合运算

求和

1
2
3
4
5
6
// 所有元素求和
list.sum()
// 对每个元素的lambda表达式求和
list.sumBy {
it % 2
}

遍历每个元素,求经过lambda表达式运算后的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 参数initValue是初始值
// element当前遍历的元素,从第一个开始
// currentValue当前计算后的值
list.fold(initValue) {currentValue,element ->
currentValue + element / 2}
// 反方向遍历
list.foldRight(initValue) {currentValue,element ->
currentValue + element / 2}
// 不带初始值,currentValue是第一个元素,element从第二个开始
list.reduce{currentValue,element ->
currentValue + element / 2}
// 反方向遍历,currentValue是最后一个元素,element从倒数第二个开始
list.reduceRight{currentValue,element ->
currentValue + element / 2}

遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
// 无索引强循环
list.forEach{
print(it)
}
// 带索引循环
list.forEachIndexed{index, value ->
print("position $index is $value")}
// 包括11,但不包括66
for (i in 11 until 66) { ... }
// 每次递增4
for (i in 23..89 step 4) { ... }
// downTo递减
for (i in 50 downTo 7) { ... }

最大最小值

1
2
3
4
5
6
7
8
// 返回最大值
list.max()
// 返回lambda运算后的最大值
list.maxBy{it}
// 返回最小值
list.min()
// 返回lambda运算后的最小值
list.minBy{it}

过滤操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 删除前n个元素返回
list.drop(n)
// 删除后n个元素返回
list.dropLast(n)
// 删除满足条件的第一个元素返回
list.dropWhile {
it > 3
}
// 删除满足条件的最后一个元素返回
list.dropLastWhile {
it > 3
}

// 过滤所有满足条件的元素返回
list.filter {
it > 3
}
// 过滤所有不满足条件的元素返回
list.filterNot {
it > 3
}
// 过滤所有非空元素返回
list.filterNotNull()
// 过滤指定索引集合对应的元素
list.slice(listof(0,1,2))

映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 遍历每个元素,依次经过lambda运算,最终返回新的list
list.map{
it * 2
}
// 同上,带下标的遍历
list.mapIndexed {index, it -> index * it}
// 同上,过滤非空值
list.mapNotNull { it * 2 }
// 遍历每个元素,将返回的每个list合并,最终返回合并后的list
list.flatMap {
listOf(it, it + 1)
}
// 根据key分组,返回一个map
list.groupBy {
if (it % 2 == 0) "even" else "odd"
}
// 将返回的结果list放入一个指定的list
val resultList = mutableListOf<Int>()
list.mapTo (resultList) {
it * 2
}

集合取值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 取前面的n个元素返回
list.take(n)
// 取后面的n个元素返回
list.takeLast(n)
// 取满足条件的第一个元素返回
list.takeWhile {
it > 3
}
// 取满足条件的最后一个元素返回
list.takeLastWhile {
it > 3
}
// 返回下标为n的元素
list.elementAt(n)
// 返回下标为n的元素,越界返回null
list.elementAtOrNull(n)
// 返回下标为n的元素,越界返回经过lambda运算后的值
list.elementAtOrElse(n) { index -> index * 2}
// 返回第一个元素
list.first()
// 返回满足条件的第一个元素
list.first { it > 1}
// 返回第一个元素,如果集合为空返回null
list.firstOrNull()
// 返回满足条件的第一个元素,没有满足的返回null
list.firstOrNull { it > 1 }
// 返回最后一个元素
list.last()
// 返回满足条件的最后一个元素
list.last { it > 1}
// 返回最后一个元素,如果集合为空返回null
list.lastOrNull()
// 返回最后一个元素,没有满足的返回null
list.lastOrNull { it > 1 }
// 返回第一个出现的元素2的索引
list.indexOf(2)
// 返回最后一个出现的元素2的索引
list.lastIndexOf(2)
// 返回满足条件的第一个元素索引
list.indexOfFirst {
it > 2
}
// 返回满足条件的最后一个元素索引
list.indexOfLast {
it > 2
}
// 返回满足条件的唯一元素,如果有多个,或不存在满足条件的则抛异常
list.single {
it == 5
}
// 返回满足条件的唯一元素,如果有多个,或不存在满足条件的则返回null
list.singleOrNull {
it == 5
}

拼接和切割

1
2
3
4
5
6
7
8
9
10
11
12
val list1 = listOf(1,2,3,4,5)
val list2 = listOf(6,7,8,9,10)
//拼接
list1 + list2 = list1.plus(list2)
//按条件切割成两个list
val(list3, list4) = list1.partition {
it % 2 == 0
}
// 合并,返回一个由pair组成的list,每一个pair是取两个list下标相同的两个元素
val pairList: List<Pair<Int,Int>> = list1.zip(list2)
// 将一个由pair组成的list切割成两个list,再返回
val (list5, list6) = pairList.unzip()

排序和逆序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
val list = listOf(1,2,3,4,5,6)
// 逆序
list.reversed()
// 升序
list.sorted()
// 根据lambda表达式返回值来升序排序
list.sortedBy {
it * 2
}
// 降序
list.sortedDescending()
// 根据lambda表达式返回值来降序排序
list.sortedByDescending {
it * 2
}