乐者为王

Do one thing, and do it well.

TabActivity is deprecated

最近在整理Android Tab导航总结的代码时发现 TabActivity在API 13中被标记为过期了,所以就去寻找它的替换类,能尽量满足最小修改的要求。发现可以使用FragmentActivity来替代,Fragment组件作为标签页添加。

Fragment是Android 3.0引入的一个新概念,主要是为了适应各种不同的屏幕大小,它非常类似于Activity,可以像Activity一样包含布局,但是不能单独存在,只能存在于Activity中。下图是Fragment在不同屏幕上的显示以及Fragment与所在Activity的关系:

android-fragments

重构前的布局:

1
2
3
4
5
6
7
8
MainActivity extends TabActivity
    TabHost - tabhost
        LinearLayout
            TabWidget - tabs
            FrameLayout - tabcontent
                TabSpec (Activity)
                ...
                TabSpec (Activity)

重构后的布局:

1
2
3
4
5
6
7
8
MainActivity extends FragmentActivity
    TabHost - tabhost
        LinearLayout
            TabWidget - tabs
            FrameLayout - tabcontent
                TabSpec (Fragment)
                ...
                TabSpec (Fragment)

把TabLeftActivity和TabRightActivity分别改成LeftFragment和RightFragment,并且要把其中的onCreate()方法改成onCreateView()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class LeftFragment extends Fragment {
    private View rootView;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
            Bundle savedInstanceState) {
        rootView = inflater.inflate(R.layout.tab_left, container, false);

        // do something
        // 不能直接使用findViewById()方法,必须加上rootView前缀
        // 如果要引用当前绑定的Activity实例,使用getActivity()方法

        return rootView;
    }

然后在main.xml中的FrameLayout里添加两个Fragment组件:

1
2
3
4
5
6
7
8
9
<fragment android:name="com.example.fragments.LeftFragment"
    android:id="@+id/fragment_left"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

<fragment android:name="com.example.fragments.RightFragment"
    android:id="@+id/fragment_right"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

把MainActivity改成从FragmentActivity继承。这里不能像TabActivity一样直接用getTabHost(),需要改成如下代码:

1
2
TabHost tabHost = (TabHost)findViewById(android.R.id.tabhost);
tabHost.setup();

到这边就已经完成了,其它tabHost.addTab的使用方式一模一样。

根据Exif时间信息归类照片

先要把Exif中的信息解析出来,得到其中的时间,有个exif的gem很不错。然后再根据时间创建目录,把照片移动到对应的目录中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
require 'rubygems'
require 'exifr'
require 'fileutils'

root = ARGV[0] || Dir.pwd

Dir.foreach(root) do |file|
  next if File.extname(file) != '.jpg'

  obj = EXIFR::JPEG.new(file)
  date_time_original = obj.exif.date_time_original if obj.exif?
  next if date_time_original.nil?

  dir = date_time_original.year.to_s
  Dir.mkdir(dir) unless Dir.exist?(dir)
  FileUtils.move(file, dir)
end

重构Rails代码

在以前写的博文部署应用到Heroku时的问题里有这么一段话:

股票功能需要导入交割单文件,因为导入后的文本文件不再使用,可以把上传路径由public/uploads改为tmp,这样就避免了Heroku不能写文件的问题。

下面是那时候写的导入代码:

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
class StocksController < ApplicationController
  UPLOADS_DIRECTORY = File.join("#{Rails.root}", "public/uploads")

  def import
    filename = upload_file(params[:file])
    lines = File.readlines("#{UPLOADS_DIRECTORY}/#{filename}")
    lines.each do |line|
      # do something
    end
    redirect_to stocks_url
  end

  protected
    def upload_file(file)
      if !file.original_filename.empty?
        @filename = get_file_name(file.original_filename)
        Dir.mkdir("#{UPLOADS_DIRECTORY}") unless Dir.exist?("#{UPLOADS_DIRECTORY}")
        File.open("#{UPLOADS_DIRECTORY}/#{@filename}", "wb") do |f|
          f.write(file.read)
        end
        return @filename
      end
    end

    def get_file_name(filename)
      if !filename.nil?
        # does not support chinese filename?
        Time.now.strftime("%Y%m%d%H%M%S") + '.txt'
      end
    end
end

这里的实现方法是先将上传文件保存到服务器上应用的public/uploads目录中,然后再读取和处理。

其实根本不需要写的这么复杂,因为导入的文件被读取一次后就不再使用了。所以在当时写代码的时候一直有这样的想法,如果能直接获得上传文件的数据,那么就不需要再另外去写保存和读取文件的代码了。

事实也是如此。通过表单提交的file字段数据会首先在服务器上形成临时文件,这时其实可以通过临时文件的路径来读取上传文件的数据。

根据该想法重构后的代码如下:

1
2
3
4
5
6
7
8
class StocksController < ApplicationController
  def import
    lines = File.readlines(params[:file].tempfile.to_path.to_s)
    lines.each do |line|
      # do something
    end
    redirect_to stocks_url
  end

重构后的代码果然清爽多了,不过还是有改进的空间。

作为控制器,它应该只负责接收请求,并返回响应。而具体的业务逻辑,则应该交由模型去完成。下面是依照该理念再次重构后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class StocksController < ApplicationController
  def import
    Stock.import(params[:file])
    redirect_to stocks_url
  end
end

class Stock < ActiveRecord::Base
  def self.import(file)
    lines = File.readlines(file.tempfile.to_path.to_s)
    lines.each do |line|
      # do something
    end
  end
end

打错multipart引发的血案

浪费几个小时,杀死无数脑细胞,最终发现是单词打错了。不过错有错招,这个问题也让我重新温习了一遍关于form数据编码的知识。

在做上传表单时,一直报告如下错误:

1
undefined method 'original_filename' for "example.csv":String

http://guides.rubyonrails.org/form_helpers.html#what-gets-uploaded 有这样一段文字:

The object in the params hash is an instance of a subclass of IO. Depending on the size of the uploaded file it may in fact be a StringIO or an instance of File backed by a temporary file. In both cases the object will have an original_filename attribute containing the name the file had on the user's computer and a content_type attribute containing the MIME type of the uploaded file.

说明example.csv应该是IO类型的,这里怎么显示是String呢?

表单代码为

1
<%= form_tag import_stocks_path, mutlipart: true do %>

1
<%= form_tag import_stocks_path, method: :post, mutlipart: true do %>

生成后的HTML代码都是:

1
<form action="/stocks/import" method="post" mutlipart="true">

正确的HTML代码应该是:

1
<form action="/stocks/import" method="post" enctype="multipart/form-data">

使用HttpFox工具抓取表单提交信息如下:

1
2
3
4
5
6
POST /stocks/import HTTP/1.1
Host localhost:3000
Referer http://localhost:3000/stocks/import
Connection keep-alive
Content-Type application/x-www-form-urlencoded
Content-Length 112

什么是application/x-www-form-urlencoded?含有file类型字段的表单编码不应该是multipart/form-data吗。

form的enctype属性通常有两种:application/x-www-form-urlencoded和multipart/form-data,默认为前者。当method=get时,浏览器用application/x-www-form-urlencoded编码方式把form数据转换成一个字串(name1=value1&name2=value2...),然后把这个字串附加到url后面,用?分割。当method=post的时候,浏览器把form数据封装到post-body中。如果没有type=file的控件,就用默认的application/x-www-form-urlencoded编码。但是如果有type=file的话,就要用到multipart/form-data了。浏览器会把整个表单以控件为单位分割,并为每个部分加上Content-Disposition(form-data或者file),Content-Type(默认为text/plain),name等信息,并加上分割边界(boundary)。

这时才发现原来是把multipart打错成mutlipart了,真是惨痛的教训啊!

如何禁止VirtualBox虚拟机和物理机之间的时间同步

主机是Windows Server 2008,虚拟机Windows XP,VirtualBox的版本为4.3.6。

因为某种原因,需要修改XP系统的时间设置。但在设置后不到10秒钟就又和主机的时间自动同步了。

实时同步时间功能是由Guest Additions提供的,把它卸载就可以修改时间,不过这不是好的解决方法。

翻阅VirtualBox User Manual,找到“Disabling the Guest Additions time synchronization”章节,说明如何能把时间同步给禁止掉。在主机环境执行以下命令:

1
VBoxManage setextradata "YOUR_VM_NAME" "VBoxInternal/Devices/VMMDev/0/Config/GetHostTimeDisabled" 1

YOUR_VM_NAME是你的虚拟机名字,可以通过VBoxManage list vms命令查询到。

还有种方法是修改虚拟机的注册表,把HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\VBoxService项下ImagePath值改为system32\VBoxService.exe --disable-timesync,禁止Guest Additions启动时间同步。修改好后重启虚拟机就可以了。

另外就是虚拟机冷启动时都会和主机时间同步先,这意味着关机再开机的话上次的时间修改就失效了,必须再次手动调整。

Ruby中读二进制文件时大小错误

经常会遇到这类场景,要把文件内容一次性全部读取出来。使用IO.read('example.bin')读取二进制文件时,发现读出来的大小与实际结果不符合。原来默认不加参数时仅限于读文本文件,需要指定mode为b。

1
IO.read('example.bin', { mode: 'rb' })

还有种简洁的读取方式是:

1
File.open('example.bin', 'rb').readlines.join

Octopress 2.0使用技巧

Octopress 2.0带的RDiscount支持表格等Markdown扩展语法了。具体语法看 https://michelf.ca/projects/php-markdown/extra/#table 。不过默认表格是不具有边框的,在显示数据时会很难看。http://programus.github.io/blog/2012/03/07/add-table-data-css-for-octopress/ 修复了这个问题,只是它的修改有些复杂,其实只要把data-table.css的内容粘贴到sass/custom/_styles.scss里就出效果了。

还有就是Octopress中的列表项应该是右移的,实际左移了。可以在sass/custom/_styles.scss添加以下代码解决:

1
2
3
4
5
article {
  ol, ul {
    padding-left: 3em;
  }
}

更简单的办法是把sass/custom/_layout.scss中被注释的这行代码打开:

1
//$indented-lists: true;

列表、表格前要有空行,例如

1
2
3
4
5
6
7
8
9
10
List
* item
* item
* item

Table
column | column
------ | ------
value  | value
value  | value

不会正常显示,必须在List和Table后空一行才行。就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
List

* item
* item
* item

Table

column | column
------ | ------
value  | value
value  | value

如果你已经发表了许多的文章,Octopress站点的生成速度将会非常之慢,解决方式是使用:

1
rake isolate['title']

隔离你所工作的文章(使用此命令前请确保该文章已经存在)。

现在使用以下命令:

1
2
rake generate
rake preview

将会仅工作在被隔离的文章上。

解除文章的隔离可以使用以下命令:

1
rake integrate

如果想在rake new_post/page后使用指定编辑器自动打开生成的文件,可以编辑Rakefile,在Misc Configs段中添加以下代码:

1
2
3
4
5
editor="open"
# open,使用系统默认编辑器
# open -a Mou,使用Mou打开
# open -a Byword,使用Byword打开
# subl,使用Sublime Text2打开

然后在task :new_post和task :new_page的末尾添加如下代码:

1
2
3
if #{editor}
  system "sleep 1; #{editor} #{filename}"
end

在rake preview后自动打开浏览器,也可以编辑Rakefile,在task :preview任务中添加:

1
system "sleep 2; open http://localhost:#{server_port}/"

如果日后Octopress有新版本发布,可以使用以下指令升级:

1
2
3
4
5
git remote add octopress git://github.com/imathis/octopress.git
git pull octopress master  # Get the latest Octopress
bundle install             # Keep gems updated
rake update_source         # update the template's source
rake update_style          # update the template's style

Wordpress迁移到Octopress

GitHub Pages提供免费的静态站点托管服务,有两种类型:User/Organization Pages和Project Pages。前者是用户/组织的主页,一个用户/组织仅有一个;后者是每个项目的主页。

安装Octopress时,rake setup_github_pages会要求输入GitHub Pages项目的URL,根据URL判断是哪种服务。如果是前者,会在新建的_deploy目录上创建master分支,用于存放发布的静态文件,并把原master分支改为source分支;后者则仅在_deploy目录上创建gh-pages分支。

Octopress默认的permalink是/blog/:year/:month/:day/:title/,想把它改成/:title/这种格式。修改后,发现生成的文章都跑到了public目录下,默认应该是在public/blog目录里的。为保持目录的简洁,把它改成了/blog/:title/,这样虽然生成的文章没有按年月日分目录,但是用户在访问系列文章时就可以只修改相应数字,不然必须得记住几乎不可能知道的文章的发布年月日。

至于评论的迁移,不想费过多的手脚,直接使用Octopress自带的Disqus。在Wordpress中安装好Disqus插件,通过插件设置将现有的评论内容导入Disqus。不过Disqus处理导入数据的时间有点长,需要等待一段时间,可以在 http://import.disqus.com 查看导入进度。导入完成后,把Wordpress文章的permalink改成和Octopress相同,登录Disqus后台,找到Migrate Threads栏目,在Domain Migration Wizard里把旧域名改成新域名,然后一路Next就大功告成了。

不过在实践中碰到了问题,打开某篇有评论的文章,发现只有评论框,没有评论。搞不明白是怎么回事。后来受到 http://www.ducea.com/2012/11/12/disqus-comments-not-visible-in-octopress/ 的启发,原来是_config.yml中的URL还是生成项目时的,与CNAME中配置的域名不一致,修改后就没问题了。

在侧边栏显示最新评论也很简单,添加recent_comments.html到source/_includes/custom/asides目录下,在_config.yml的default_asides做好配置,最新评论内容就会出现在侧边栏中了:

1
2
3
4
5
6
<section>
  <h1>Recent Comments</h1>
  <div class="dsq-widget">
    <script type="text/javascript" src="http://codemany.disqus.com/recent_comments_widget.js"></script>
  </div>
</section>

在侧边栏显示Tag Cloud参照了 http://codemacro.com/2012/07/18/add-tag-to-octopress/ 教程,没几分钟就搞定了。

分享使用的是JiaThis,只要把source/_includes/post/sharing.html中的代码替换成以下代码就可以:

1
2
3
4
5
6
7
8
9
10
<div id="jiathis_style_32x32">
  <a class="jiathis_button_qzone"></a>
  <a class="jiathis_button_tsina"></a>
  <a class="jiathis_button_tqq"></a>
  <a class="jiathis_button_weixin"></a>
  <a class="jiathis_button_renren"></a>
  <a href="http://www.jiathis.com/share/" class="jiathis jiathis_txt jtico jtico_jiathis" target="_blank"></a>
  <a class="jiathis_counter_style"></a>
</div>
<script type="text/javascript" src="http://v2.jiathis.com/code/jia.js" charset="utf-8"></script>

相关文章本想用Octopress自带的lsi模块实现,只是当文章较多时生成速度实在是巨慢,按照推荐安装gsl模块,结果在Windows Server 2008系统上死活装不上,采用手动编译方式也总是死在rb-gsl的安装上。失望之余找到 https://github.com/LawrenceWoodman/related_posts-jekyll_plugin 这个插件,它使用起来非常简单,只需将related_posts.rb放到自己的plugins文件夹中,然后在source/includes/post中新建relatedposts.html文件:

1
2
3
4
5
6
7
8
9
10
{ % if site.related_posts % }
  <h3>Related posts</h3>
  <ul class="posts">
  { % for post in site.related_posts limit:3 % }
    <li class="related">
      <a href=""></a>
    </li>
  { % endfor % }
  </ul>
{ % endif % }

修改source/_layouts/post.html,在

1
2
3
{ % include post/author.html % }
{ % include post/date.html % }{ % if updated % }{ % else % }{ % endif % }
{ % include post/categories.html % }

后面添加

1
{ % include post/related_posts.html % }

就可以了。使用这个插件会有个小问题,就是它和Jekyll 2.1不兼容,rake generate时会报错,可以使用jumanji27提供的fork版本

在Wordpress的Tools/Export页面选择导出文章内容,保存为wordpress.xml文件。然后使用工具把它转换成markdown格式。这里使用了YORKXIN的修改版本,将脚本和wordpress.xml放到Octopress根目录下,然后运行:

1
ruby -r ./wordpressdotcom.rb -e Jekyll::WordpressDotCom.process

会把转换好的文章都放到source/_posts目录下,文件后缀名是html,直接改成markdown就是。

最后就是苦力活:修改文中的站内链接、上传的图片路径以及代码高亮语法等。

Nokogiri抓取页面URL含有中文参数的问题

使用Nokogiri抓取某网站的长江现货数据,被抓取页面的URL中含有中文参数,使用以下的代码抓取数据失败:

1
2
url = 'http://example.com/search.asp?type=长江有色&sort=asc'
doc = Nokogiri::HTML(open(url))

http://dingr.iteye.com/blog/647244 讲是因为浏览器给服务器发送参数的时候是经过编码的,按照该文的意思试着也给URL里的中文编了下码:

1
2
3
url = 'http://example.com/search.asp?type=长江有色&sort=asc'
url = URI.escape(url)
doc = Nokogiri::HTML(open(url))

结果还是抓取失败。查看URL的编码信息:

1
puts url.encoding  # 输出utf-8

网站页面采用的是GB2132编码,猜测网站后台处理数据时很有可能也是采用的GB2132。做个实验就清楚了,将URL转成GB2132后再编码:

1
2
3
4
url = 'http://example.com/search.asp?type=长江有色&sort=asc'
url = url.encode('gbk', 'utf-8')
url = URI.escape(url)
doc = Nokogiri::HTML(open(url))

发现果然OK了。

逻辑题-几条病狗

村子中有50个人,每人有一条狗,在这50条狗中有病狗(这种病不会传染),于是人们就要找出病狗,每个人可以观察其它49条狗,以判断它们是否生病,只有自己的狗不能看。观察后得到的结果不得交流,也不能通知病狗的主人。主人一旦推算出自己家的是病狗就要枪毙自己的狗,而且每个人只有权利枪毙自己的狗,没有权利打死其他人的狗。第一天,第二天都没有枪响,到了第三天传来一阵枪响,问有几条病狗?

推论:

A、假设有1条病狗,病狗的主人会看到其它狗都没有病,那么就知道自己的狗有病,所以第一天晚上就会有枪响。因为没有枪响,说明病狗数大于1。

B、假设有2条病狗,病狗的主人会看到有1条病狗,因为第一天没有听到枪响,是病狗数大于1,所以病狗的主人会知道自己的狗是病狗,因而第二天会有枪响。既然第二天也没有枪响,说明病狗数大于2。

由此推理,如果第三天枪响,则有3条病狗,第n天枪响,则有n条病狗。