himanago

Azure・C#などのMS系技術やLINE関連技術など、好きな技術について書くブログ

【Xamarin.Forms+Cognitive Services】飯テロ防止Twitterクライアント作ってみた

はじめに

はやいものでもう11月になってしまいました。今年もあと2か月弱。
研修などが土曜に多い関係上、なかなか土曜に休むことができない日々が続き、土曜の面白そうな技術系イベントにあまり出られず、ちょっと最近フラストレーションがたまっています。
その憂さ晴らしついでに、今回はちょっと手を動かしてみました。

今回作ったもの

タイトルにある通り、「飯テロ防止Twitterクライアント」です。
Twitterで食べ物の写真をたくさん上げている人をミュートした」ということを言っている後輩がいて、たしかに食べ物の写真は、深夜だったり気分の悪いときに見るのはきついとは思うですが、ミュートしてしまうことで食べ物写真ツイート以外の他の有益な情報まで目に入らなくなってしまうのはもったいないことです。
だったら、食べ物の写真だけうまいこと表示されないような「飯テロ防止」機能を持ったTwitterクライアントがあればよいのでは?と思ったのがはじまりです。
特に実運用は想定していないので、今回は飯テロ防止に関係がある「タイムライン表示」の部分だけ実装しています。

ソースはGitHubに公開しています。
github.com

名前は「FoodPornPreventer」。
preventerはちょっと微妙かもですが、英語で飯テロに近い言葉は「food porn」と言うそう。

アプリのしくみ

まず、Twitterクライアントなので、スマホなどモバイル端末での利用を前提に、Xamarin.FormsでAndroid/iOS/UWPに対応させています。
そこにTwitterクライアントとしての機能を実装するため、「CoreTweet」というパッケージを導入しています。Twitter連携するアプリを作成するうえでの定番のようです。
CoreTweetはこちら。日本語の説明がありました。 github.com

肝心の「飯テロ防止」は、AzureのCognitive Servicesにある「Computer Vision API」による画像解析を用います。
画像を解析してさまざまなことを教えてくれるサービスです。
azure.microsoft.com

流れは、

  1. CoreTweetでTwitterのタイムラインを取得

  2. 画像を含むツイートの場合、その画像URLをComputer Vision APIに渡して解析

  3. 解析結果に「food」という文字列が含まれていれば“飯テロ”とみなし、警告画像に差し替え、それ以外はそのままタイムラインに表示させる

となります。 では実際に中身を見ていきます。

Twitter連携

CoreTweetを導入するには、NuGetでPCLプロジェクトに「CoreTweet」をインストールします。
CoreTweetを入れると「Newtonsoft.Json」のパッケージも一緒に入るのですが、これが最新の「10.x」だとUWPで動きませんでした。なので、最終的にバージョンはCoreTweetが「0.8.1.394」、Newtonsoft.Jsonが「9.0.1」の組み合わせにしています。
また、ターゲットを変更しないとうまく上記がインストールできなかったので、Profileは「7」に変更しています。

では、CoreTweetによるタイムラインの取得処理です。

以下のように生成したアクセストークンを使って、

// トークンの生成
// 今回は事前にTwitterアカウントで作成しておいたアクセス情報を使用
private Tokens Tokens { get; } = Tokens.Create(Secrets.ConsumerKey, Secrets.ConsumerSecret, Secrets.AccessToken, Secrets.AccessTokenSecret);

タイムライン取得を行います。

var timeline = await Tokens.Statuses.HomeTimelineAsync(count: TweetCount, max_id: maxId);

これはTwitter APIの「statuses/home_timeline」に相当します。CoreTweetはこのように、基本的にTwitter APIをそのままC#で呼べるよう実装されているので、Twitter APIさえわかればそれと同じような呼び出し方で使うことができ、とても楽です。

取得したツイートのデータは適当にクラスに詰めてListViewにバインドさせて表示させています。
引用リツイートとか動画とか、そのあたりは今回の本題から外れるのであまり作りこんでいません。

Computer Vision APIでの画像判定

食べ物かどうかの判定ですが、あらかじめAzureでComputer Vision APIを使うためのキー情報を取得しておきます。
Xamarinから呼び出すのは簡単で、NuGetからMicrosoft.ProjectOxford.Visionを導入すればOK。
これでTagsを調べ、飯テロかどうかを判定します。Tagsの要素内のHintまたはNameはが「food」であれば食べ物画像=飯テロとみなします。

private async Task<bool> IsFoodPornAsync(string url)
{
    try
    {
        // Computer Vision APIで解析してTagsを取得
        var vision = new VisionServiceClient(Secrets.VisionSubscriptionKey, Secrets.VisionApiRoot);
        VisualFeature[] features = { VisualFeature.Tags };
        var result = await vision.AnalyzeImageAsync(url, features.ToList());
        return result.Tags.Any(t => t.Hint == "food" || t.Name == "food");
    }
    catch (Exception ex)
    {
        return false;
    }
}

判定処理はわずか4行。お手軽ですね。

サムネイル画像の差し替え

飯テロ画像だったら警告画像に差し替えます。
今回はConverterを使って、Computer Vision APIによる飯テロ判定のフラグ値で切り分けています。

public class ImageMaskConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        var imageInfo = value as TweetImageInfo;

        if (imageInfo == null) return null;

        // 飯テロ画像なら画像を差し替え
        return imageInfo.IsFoodPorn
            ? ImageSource.FromResource("FoodPornPreventer.Images.mark_chuui.png")
            : ImageSource.FromUri(new Uri(imageInfo.Url));
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

実際の動作

今回の開発にあたり、食べ物画像BOT@ibukurogさんのツイートをテストに使わせていただきました。
たとえば、このツイート。

このツイートを本アプリを通して見ると…

Android

f:id:himanago:20171105011730p:plain:w300

iOS

f:id:himanago:20171105011745j:plain:w300

UWP

f:id:himanago:20171105001349p:plain:w300

上記のように、うっかり飯テロにあうことを防ぐことができます!
ちなみに差し替えたアイコンはいらすとやさんからいただいたものです。
注意のマーク | かわいいフリー素材集 いらすとや

なお、iOSだけ、ツイートの追加読み込みでスクロール位置が戻ったり項目が上下に動いて一瞬重なって見えたりと、ちょっと変な動きをするのでいずれ直したいです。

苦労したところ

ListViewはネストできない

ツイートをListViewで並べて表示しますが、各ツイートのListItemに画像を並べる際、ListViewをネストできたら動的に画像の数が変わってくれると思ったのですが、ListViewはネストすることができなかったので、断念。Twitterでは1ツイートには画像が最大4枚という仕様なので、しかたなく4枠固定で画像の領域を用意してごまかしました。

iPhoneでの実機確認時のエラー①

iPhoneの実機で動かしたときがけっこう苦戦しました。
まず、ビルド時に以下のようなエラーが。
f:id:himanago:20171104234147p:plain

Invalid architecture: ARMv7. 32-bit architectures are not supported when deployment target is 11 or later.

端末はiPhone8(iOS11)。おそらく、iOS11から完全64bit化したため「32bitは使えないよ」というエラーが出たのだと思われます。ここは、iOSプロジェクトのビルド設定でサポートされているアーキテクチャを「ARMv7 + ARM64」から「ARM64」に変更すると解消しました。 f:id:himanago:20171104234206p:plain

あとその下の「すべての32ビット浮動小数点演算を64ビット浮動小数点演算として実行する」のチェックも入れておく必要がありそう。
f:id:himanago:20171105100753p:plain

iPhoneでの実機確認時のエラー②

もうひとつ、実機デバッグ時のみ、Computer Vision APIを呼び出す際に例外が発生しました。シミュレーターを使ったときやAndroid/UWPでは発生しなかったものだったので、最初は「?」でした。

  at System.Linq.Expressions.Expression.Call (System.Linq.Expressions.Expression instance, System.Reflection.MethodInfo method, System.Collections.Generic.IEnumerable`1[T] arguments) [0x00111] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/external/corefx/src/System.Linq.Expressions/src/System/Linq/Expressions/MethodCallExpression.cs:1239 
  at System.Linq.Expressions.Expression.Call (System.Linq.Expressions.Expression instance, System.Reflection.MethodInfo method, System.Linq.Expressions.Expression[] arguments) [0x00000] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/external/corefx/src/System.Linq.Expressions/src/System/Linq/Expressions/MethodCallExpression.cs:1046 
  at System.Linq.Expressions.Expression.Call (System.Reflection.MethodInfo method, System.Linq.Expressions.Expression[] arguments) [0x00000] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/external/corefx/src/System.Linq.Expressions/src/System/Linq/Expressions/MethodCallExpression.cs:1001 
  at System.Dynamic.ExpandoObject+MetaExpando.BindSetMember (System.Dynamic.SetMemberBinder binder, System.Dynamic.DynamicMetaObject value) [0x00033] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/external/corefx/src/System.Linq.Expressions/src/System/Dynamic/ExpandoObject.cs:863 
  at System.Dynamic.SetMemberBinder.Bind (System.Dynamic.DynamicMetaObject target, System.Dynamic.DynamicMetaObject[] args) [0x00035] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/external/corefx/src/System.Linq.Expressions/src/System/Dynamic/SetMemberBinder.cs:57 
  at System.Dynamic.DynamicMetaObjectBinder.Bind (System.Object[] args, System.Collections.ObjectModel.ReadOnlyCollection`1[T] parameters, System.Linq.Expressions.LabelTarget returnLabel) [0x000c6] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/external/corefx/src/System.Linq.Expressions/src/System/Dynamic/DynamicMetaObjectBinder.cs:90 
  at System.Runtime.CompilerServices.CallSiteBinder.BindCore[T] (System.Runtime.CompilerServices.CallSite`1[T] site, System.Object[] args) [0x00019] in <773264786149499a986a13db6a7d46fe>:0 
  at System.Runtime.CompilerServices.CallSiteOps.Bind[T] (System.Runtime.CompilerServices.CallSiteBinder binder, System.Runtime.CompilerServices.CallSite`1[T] site, System.Object[] args) [0x00000] in <773264786149499a986a13db6a7d46fe>:0 
  at (wrapper managed-to-native) System.Reflection.MonoMethod:InternalInvoke (System.Reflection.MonoMethod,object,object[],System.Exception&)
  at System.Reflection.MonoMethod.Invoke (System.Object obj, System.Reflection.BindingFlags invokeAttr, System.Reflection.Binder binder, System.Object[] parameters, System.Globalization.CultureInfo culture) [0x00032] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/corlib/System.Reflection/MonoMethod.cs:305 
--- End of stack trace from previous location where exception was thrown ---
  at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw () [0x0000c] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/exceptionservices/exceptionservicescommon.cs:151 
  at System.Linq.Expressions.Interpreter.ExceptionHelpers.UnwrapAndRethrow (System.Reflection.TargetInvocationException exception) [0x00000] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/external/corefx/src/System.Linq.Expressions/src/System/Linq/Expressions/Interpreter/Utilities.cs:174 
  at System.Linq.Expressions.Interpreter.MethodInfoCallInstruction.Run (System.Linq.Expressions.Interpreter.InterpretedFrame frame) [0x00035] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/external/corefx/src/System.Linq.Expressions/src/System/Linq/Expressions/Interpreter/CallInstruction.cs:333 
  at System.Linq.Expressions.Interpreter.Interpreter.Run (System.Linq.Expressions.Interpreter.InterpretedFrame frame) [0x00015] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/external/corefx/src/System.Linq.Expressions/src/System/Linq/Expressions/Interpreter/Interpreter.cs:63 
  at System.Linq.Expressions.Interpreter.LightLambda.Run3[T0,T1,T2,TRet] (T0 arg0, T1 arg1, T2 arg2) [0x00038] in <773264786149499a986a13db6a7d46fe>:0 
  at Microsoft.ProjectOxford.Vision.VisionServiceClient+<AnalyzeImageAsync>d__19.MoveNext () [0x00053] in <26ced5c9a33f420b960f06b6963aca5a>:0 
--- End of stack trace from previous location where exception was thrown ---
  at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw () [0x0000c] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/exceptionservices/exceptionservicescommon.cs:151 
  at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess (System.Threading.Tasks.Task task) [0x00037] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/compilerservices/TaskAwaiter.cs:187 
  at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification (System.Threading.Tasks.Task task) [0x00028] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/compilerservices/TaskAwaiter.cs:156 
  at System.Runtime.CompilerServices.TaskAwaiter.ValidateEnd (System.Threading.Tasks.Task task) [0x00008] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/compilerservices/TaskAwaiter.cs:128 
  at System.Runtime.CompilerServices.TaskAwaiter`1[TResult].GetResult () [0x00000] in <a89624c267f94034b6cf9aa0c56f8864>:0 
  at FoodPornPreventer.MainPage+<IsFoodPornAsync>d__11.MoveNext () [0x00050] in C:\Users\hoge\Documents\Visual Studio 2017\Projects\FoodPornPreventer\FoodPornPreventer\FoodPornPreventer\MainPage.xaml.cs:116 
--- End of stack trace from previous location where exception was thrown ---
  at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw () [0x0000c] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/exceptionservices/exceptionservicescommon.cs:151 
  at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess (System.Threading.Tasks.Task task) [0x00037] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/compilerservices/TaskAwaiter.cs:187 
  at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification (System.Threading.Tasks.Task task) [0x00028] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/compilerservices/TaskAwaiter.cs:156 
  at System.Runtime.CompilerServices.TaskAwaiter.ValidateEnd (System.Threading.Tasks.Task task) [0x00008] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/compilerservices/TaskAwaiter.cs:128 
  at System.Runtime.CompilerServices.TaskAwaiter`1[TResult].GetResult () [0x00000] in /Library/Frameworks/Xamarin.iOS.framework/Versions/11.2.0.11/src/mono/mcs/class/referencesource/mscorlib/system/runtime/compilerservices/TaskAwaiter.cs:357 
  at FoodPornPreventer.MainPage+<AddTweets>d__10.MoveNext () [0x003a2] in C:\Users\hoge\Documents\Visual Studio 2017\Projects\FoodPornPreventer\FoodPornPreventer\FoodPornPreventer\MainPage.xaml.cs:85 

調べてみると、どうやらリンカーの動作を「Don't Link」にすると解決するらしい、ということがわかりました。 www.syncfusion.com github.com

iOSプロジェクトのプロパティで以下のように変更。 f:id:himanago:20171105095756p:plain

すると、無事実機デバッグできるようになりました。
ただこれだとアプリのサイズが大きくなってしまうと思われるため、以下のようにほかの回避方法もありそうですし、もう少し調べてみたいなと思っています。 spiratesta.hatenablog.com

その他

Xamarinの開発をするときは、普段メインPCをWindowsでやっていますが(iOSやるときはMac Book Airをリモートでつないでる)、実機デバッグが楽なのはUWP>AndroidiOSなので、どうしてもiOSの動作確認が最後になってしまい、個人的にそこで詰まるケースが結構多いです。普段使っているスマホiPhoneなので、ちゃんとiPhoneでも使えるようにしたいところではあるのですが。

あと、今回はなるべくシンプルに作りたかったのでコードはほとんどコードビハインドに書いてみました。ただ、これが失敗だったのか、結局コードビハインドでいろいろ頑張ることになってしまい、シンプルというよりは見通しが悪くなってしまったような印象。
拡張性とかも考えて、ちゃんとMVVMでやったほうがむしろ「シンプル」できれいになるのかも、と思いました。のちほど、Prismを使って再実装してみようかとも思います。